diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3ef781f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,159 @@
+# Blog Platform
+
+**๐ Live Service** [Visit the Blog](https://blog.minjae-dev.com)
+
+Monorepo-based Web / BO / API Architecture
+with Contract-First Design and Runtime Boundary Separation
+
+## 1. Project Overview
+
+์ด ํ๋ก์ ํธ๋ ๋จ์ํ ๋ธ๋ก๊ทธ ์ ํ๋ฆฌ์ผ์ด์
์ด ์๋๋ผ,
+**Web / Back Office / API๋ฅผ ๋ถ๋ฆฌํ Monorepo ๊ธฐ๋ฐ ์ํคํ
์ฒ ์ค๊ณ ํ๋ก์ ํธ**์
๋๋ค.
+
+์ฃผ์ ์ค๊ณ ๋ชฉํ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค:
+
+- SEO ์ค์ฌ Web ์ ํ๋ฆฌ์ผ์ด์
๊ณผ ๊ด๋ฆฌ์ ์์คํ
์ ๋ฌผ๋ฆฌ์ ๋ถ๋ฆฌ
+- API ์์ฒญ/์๋ต ๊ณ์ฝ์ ๋จ์ผ ๊ธฐ์ค(Single Source of Truth) ํ๋ฆฝ
+- ๋ฐํ์ ๊ฒฝ๊ณ๋ฅผ ์ ์งํ ์ ํ์ ์บ์ ๋ฌดํจํ ์ ๋ต ์ค๊ณ
+- URL ๊ธฐ๋ฐ ์ํ ๊ด๋ฆฌ ๊ตฌ์กฐ ์ฌ์ค๊ณ
+
+## 2. Architecture Overview
+
+### ๐ฆ Monorepo Structure
+
+```bash
+apps/
+ web/ # SEO ์ค์ฌ ์ฌ์ฉ์ ๋ธ๋ก๊ทธ
+ bo/ # ๊ด๋ฆฌ์ Back Office
+ api/ # NestJS ๊ธฐ๋ฐ API ์๋ฒ
+packages/
+ contracts/ # Zod ๊ธฐ๋ฐ API Contract (SSOT)
+ ui/ # shadcn-ui ๊ธฐ๋ฐ ui ๊ณตํต ์ปดํฌ๋ํธ
+```
+
+### ๐ apps/web
+
+SEO ์ค์ฌ ์ฌ์ฉ์ ๋ธ๋ก๊ทธ ์ ํ๋ฆฌ์ผ์ด์
(Next.js App Router ๊ธฐ๋ฐ)
+โ [๐ apps/web README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+
+- SSR + Tag ๊ธฐ๋ฐ ์บ์ ์ ๋ต
+- ์ ํ์ ์บ์ ๋ฌดํจํ ๊ตฌ์กฐ ์ ์ฉ
+- SEO ์ฑ๋ฅ์ ์ ์งํ๋ ๋ฐ์ดํฐ ํจ์นญ ์ ๋ต ์ค๊ณ
+
+### ๐ apps/bo
+
+๊ด๋ฆฌ์ ์ ์ฉ Back Office ์ ํ๋ฆฌ์ผ์ด์
+โ [๐ apps/bo README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+
+- ๊ฒ์๊ธ ์์ฑ/์์ ๊ด๋ฆฌ
+- Write ์์ฒญ ํ Web ์บ์ ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ ์ญํ ์ํ
+- ์ฌ์ฉ์ ํธ๋ํฝ๊ณผ ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ถ๋ฆฌ๋ ๋ฐฐํฌ ๋จ์
+
+### ๐ apps/api
+
+NestJS ๊ธฐ๋ฐ API ์๋ฒ
+โ [๐ apps/api README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+
+- REST API ์ ๊ณต
+- contracts ํจํค์ง ๊ธฐ๋ฐ ๋ฐํ์ validation ์ํ
+- ๋ฐ์ดํฐ ๋ณ๊ฒฝ ํ Web ๋ฌดํจํ ํ์ดํ๋ผ์ธ ํธ๋ฆฌ๊ฑฐ
+
+### ๐ packages/contracts
+
+Zod ๊ธฐ๋ฐ API Contract **Single Source of Truth**
+โ [๐ packages/contracts README](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
+
+- ์์ฒญ/์๋ต Schema ์ค์ ๊ด๋ฆฌ
+- z.infer ๊ธฐ๋ฐ ์ปดํ์ผ ํ์ ํ์
๊ณต์
+- schema.parse() ๊ธฐ๋ฐ ๋ฐํ์ ๊ฒ์ฆ
+- CJS / ESM dual build ๊ตฌ์กฐ
+
+## 3. Why This Architecture?
+
+### โ Web / BO / API ๋ถ๋ฆฌ ์ด์
+
+- SEO ํธ๋ํฝ๊ณผ ๊ด๋ฆฌ์ ํธ๋ํฝ์ ์ฑ
์ ๋ถ๋ฆฌ
+- ๋ฐฐํฌ ๋จ์ ๋ฐ ๋ฐํ์ ๊ฒฝ๊ณ ๋ถ๋ฆฌ
+- ๊ด๋ฆฌ์ ๊ธฐ๋ฅ ์ธ๋ถ ๋
ธ์ถ ์ต์ํ
+
+### โก Monorepo ์ ์ง ์ด์
+
+- ์ ํ๋ฆฌ์ผ์ด์
์ ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ถ๋ฆฌํ๋,
+- ๋ฐ์ดํฐ ๊ณ์ฝ์ ๋จ์ผ ๊ธฐ์ค์ผ๋ก ์ ์ง ํ์
+
+๐ ๋ฌผ๋ฆฌ์ ๋ถ๋ฆฌ์ ๋
ผ๋ฆฌ์ ์ผ๊ด์ฑ์ ๋์์ ํ๋ณด
+
+## 4. Core Design Decisions
+
+### โ Contract Centralization (Schema Layer)
+
+- Zod ๊ธฐ๋ฐ Schema Layer๋ฅผ ๊ณ์ฝ ๊ณ์ธต์ผ๋ก ์ ์
+- Web / BO / API๊ฐ ๋์ผํ Contract๋ฅผ ๊ณต์
+- ํ์
์ ์ ์ง์ 3 โ 1๋ก ์๋ ด
+
+### โก Cache Invalidation Pipeline
+
+- Web ๋ฐํ์์ด ์บ์ ์์ ๊ถ์ ๋ช
ํํ ๊ฐ์ง
+- Tag ๊ธฐ๋ฐ ์ ํ์ ๋ฌดํจํ ์ ๋ต ์ค๊ณ
+- API ๋ณ๊ฒฝ โ Web /api/revalidate ํธ์ถ ํ์ดํ๋ผ์ธ ๊ตฌ์ฑ
+- no-store ์์ด ์ต์ ์ฑ๊ณผ ์ฑ๋ฅ ๋์ ํ๋ณด
+
+### โข Routing Boundary Redesign
+
+- Search ์ํ๋ฅผ URL ๊ธฐ๋ฐ Single Source๋ก ์ฌ์ ์
+- TanStack Router + validateSearch ๊ธฐ๋ฐ ๊ฒ์ฆ ํ์ดํ๋ผ์ธ
+- ๊ณตํต ํ
์ถ์ํ๋ฅผ ํตํ ์ค๋ณต ์ฝ๋ ์ ๊ฑฐ
+
+## 5. Architecture Trouble Shooting
+
+**๐จ BO ์์ ํ Web์ ์ฆ์ ๋ฐ์๋์ง ์๋ ๋ฌธ์ **
+
+#### ๋ฌธ์
+
+- BO์์ ๊ฒ์๊ธ ์์ ํ DB์๋ ๋ฐ์๋์ง๋ง Web์๋ ์ฆ์ ๋ฐ์๋์ง ์์
+- Next.js App Router ๊ธฐ๋ณธ force-cache ์ ๋ต์ผ๋ก ์บ์ ์ ์ง
+
+#### ์ ์ฝ
+
+- revalidateTag / revalidatePath๋ Web ์๋ฒ ๋ด๋ถ์์๋ง ์ ํจ
+- ์ ์ฒด no-store ์ ๋ต์ ๋น์ฉ ๋ฐ ์ฑ๋ฅ ๋ฌธ์ ๋ฐ์
+
+#### ํด๊ฒฐ
+
+- ์บ์ ์์ ๊ถ์ Web์ผ๋ก ๋ช
ํํ ์ ์
+- Tag ๊ธฐ๋ฐ ์ ํ์ ๋ฌดํจํ ํ์ดํ๋ผ์ธ ์ค๊ณ
+- API โ Web revalidate ์๋ํฌ์ธํธ ํธ์ถ ๊ตฌ์กฐ ๊ตฌ์ฑ
+
+## 6. Tech Stack
+
+### ๐งฉ Core Architecture
+
+- TypeScript
+- pnpm Monorepo
+- Turborepo (Build Orchestration)
+- Zod (Contract / Schema Layer)
+
+### ๐ apps/web
+
+- Next.js (App Router)
+- SSR + Tag-based Cache Strategy
+
+### ๐ apps/bo
+
+- React
+- TanStack Router
+- TanStack Query
+
+### ๐ apps/api
+
+- NestJS
+- Prisma (ORM)
+- MariaDB (MySQL-compatible)
+
+## Repository Navigation
+
+- ๐๏ธ **Root**: [Blog Platform โ](https://github.com/Blog-Archive-Ian/blog-platform)
+- ๐๏ธ **Web**: [apps/web โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+- ๐๏ธ **Back Office**: [apps/bo โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+- ๐๏ธ **API Server**: [apps/api โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+- ๐๏ธ **Contracts**: [packages/contracts โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
diff --git a/apps/api/README.md b/apps/api/README.md
index d30c946..0f81f02 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -1,98 +1,89 @@
-
-
-
-
-[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
-[circleci-url]: https://circleci.com/gh/nestjs/nest
-
- A progressive Node.js framework for building efficient and scalable server-side applications.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## Description
-
-[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
-
-## Project setup
-
-```bash
-$ pnpm install
-```
+# Blog API
-## Compile and run the project
+NestJS ๊ธฐ๋ฐ REST API ์๋ฒ์
๋๋ค.
+DB ์ ๊ทผ, Contract ๊ธฐ๋ฐ ์๋ต ๋ณํ, Web ์บ์ ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ ์ญํ ์ ๋ด๋นํฉ๋๋ค.
-```bash
-# development
-$ pnpm run start
+## 1. Role in Architecture
-# watch mode
-$ pnpm run start:dev
+API๋ ๋ค์ ์ฑ
์์ ๊ฐ์ง๋๋ค:
-# production mode
-$ pnpm run start:prod
-```
+- ๋ฐ์ดํฐ ์์์ฑ ์ฒ๋ฆฌ (Prisma + MariaDB)
+- Contract ๊ธฐ๋ฐ ์๋ต ๊ตฌ์กฐ ๋ณด์ฅ
+- Write ์์ฒญ ํ Web ์บ์ ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ
-## Run tests
+Web๊ณผ BO ์ฌ์ด์์ ๋ฐ์ดํฐ ๊ฒฝ๊ณ๋ฅผ ๋ช
ํํ ๋ถ๋ฆฌํ๋ ์ญํ ์ ์ํํฉ๋๋ค.
-```bash
-# unit tests
-$ pnpm run test
+## 2. Database Layer
-# e2e tests
-$ pnpm run test:e2e
+- Prisma ORM ์ฌ์ฉ
+- MariaDB ์ฐ๊ฒฐ (PrismaMariaDb adapter)
+- NestJS lifecycle ๊ธฐ๋ฐ ์ฐ๊ฒฐ ๊ด๋ฆฌ
-# test coverage
-$ pnpm run test:cov
+```ts
+export class PrismaService
+ extends PrismaClient
+ implements OnModuleInit, OnModuleDestroy
```
-## Deployment
+- onModuleInit โ $connect()
+- onModuleDestroy โ $disconnect()
+
+๐ DB ์ฐ๊ฒฐ ์๋ช
์ฃผ๊ธฐ๋ฅผ NestJS ๋ชจ๋๊ณผ ์ ๋ ฌํ์ต๋๋ค.
+
+## 3. Contract-Based Response Mapping
-When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
+DB ๋ชจ๋ธ์ ์ง์ ๋ฐํํ์ง ์๊ณ ,
+`@blog/contracts`์ ์ ์๋ ํ์
๊ณผ ์คํค๋ง๋ฅผ ๊ธฐ์ค์ผ๋ก ์๋ต์ ๊ตฌ์ฑํฉ๋๋ค.
-If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
+```ts
+import type { PostType } from '@blog/contracts';
+```
-```bash
-$ pnpm install -g @nestjs/mau
-$ mau deploy
+```ts
+toContract(row: any, tags: string[]): PostType
```
-With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
+### ์ค๊ณ ์๋
+
+- DB ๊ตฌ์กฐ์ API ์๋ต ๊ตฌ์กฐ ๋ถ๋ฆฌ
+- snake_case โ camelCase ์ ๊ทํ
+- null / boolean / Date ์ ๊ทํ ์ฒ๋ฆฌ
+- ์ธ๋ถ ๊ณ์ฝ(Contract)๊ณผ ๋ด๋ถ ๋ชจ๋ธ ๋ถ๋ฆฌ
+- zod schema ๊ธฐ๋ฐ ์์ฒญ validation ์ํ (contracts ๊ณต์ )
-## Resources
+๐ Contract ๊ณ์ธต์ด ์ปดํ์ผ ํ์ ํ์
๊ณต์ + ๋ฐํ์ ๊ฒ์ฆ์ ๋์์ ๋ด๋นํ๋๋ก ์ค๊ณํ์ต๋๋ค.
-Check out a few resources that may come in handy when working with NestJS:
+## 4. Cache Invalidation Trigger
-- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
-- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
-- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
-- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
-- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
-- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
-- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
-- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
+๊ฒ์๊ธ create / update / delete ์
+Web์ /api/revalidate ์๋ํฌ์ธํธ๋ฅผ ํธ์ถํฉ๋๋ค.
+
+```ts
+await fetch(`${FRONT_URL}/api/revalidate`, {
+ method: 'POST',
+ headers: {
+ 'x-revalidate-secret': REVALIDATE_SECRET,
+ },
+ body: JSON.stringify({ tags }),
+});
+```
-## Support
+### ์ค๊ณ ํฌ์ธํธ
-Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
+- ์บ์ ์์ ๊ถ์ Web์ ์์
+- API๋ ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ ์ญํ ๋ง ์ํ
+- revalidate ์คํจํด๋ Write๋ ์ฑ๊ณตํด์ผ ํจ
+- ๋ฐํ์ ๊ฒฝ๊ณ๋ฅผ ์นจ๋ฒํ์ง ์๋ ๊ตฌ์กฐ
-## Stay in touch
+## 5. Transaction (Planned)
-- Author - [Kamil Myลliwiec](https://twitter.com/kammysliwiec)
-- Website - [https://nestjs.com](https://nestjs.com/)
-- Twitter - [@nestframework](https://twitter.com/nestframework)
+- ๊ฒ์๊ธ ์์ฑ/์์ ์ ๋ค์ค ํ
์ด๋ธ ๋ณ๊ฒฝ
+- ํฅํ Prisma $transaction ์ ์ฉ ์์
-## License
+## Repository Navigation
-Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+- ๐๏ธ **Root**: [Blog Platform โ](https://github.com/Blog-Archive-Ian/blog-platform)
+- ๐๏ธ **Web**: [apps/web โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+- ๐๏ธ **Back Office**: [apps/bo โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+- ๐๏ธ **API Server**: [apps/api โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+- ๐๏ธ **Contracts**: [packages/contracts โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
diff --git a/apps/api/src/common/revalidate.service.ts b/apps/api/src/common/revalidate.service.ts
index 40be9b0..6af6161 100644
--- a/apps/api/src/common/revalidate.service.ts
+++ b/apps/api/src/common/revalidate.service.ts
@@ -4,19 +4,25 @@ import { Injectable, Logger } from '@nestjs/common';
export class RevalidateService {
private readonly logger = new Logger(RevalidateService.name);
- private readonly webUrl = process.env.WEB_URL;
+ private readonly webUrl = process.env.FRONT_URL;
private readonly secret = process.env.REVALIDATE_SECRET;
async revalidateTags(tags: string[]) {
+ if (!tags.length) return;
+
if (!this.webUrl || !this.secret) {
this.logger.warn(
- 'Revalidate skipped: WEB_URL or REVALIDATE_SECRET missing',
+ 'Revalidate skipped: FRONT_URL or REVALIDATE_SECRET missing',
);
return;
}
+ const url = `${this.webUrl}/api/revalidate`;
+
try {
- const res = await fetch(`${this.webUrl}/api/revalidate`, {
+ this.logger.log(`[revalidate] POST ${url} tags=${JSON.stringify(tags)}`);
+
+ const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
@@ -25,15 +31,19 @@ export class RevalidateService {
body: JSON.stringify({ tags }),
});
+ const body = await res.text().catch(() => '');
+
if (!res.ok) {
- const text = await res.text().catch(() => '');
- this.logger.warn(`Revalidate failed: ${res.status} ${text}`);
+ this.logger.warn(
+ `[revalidate] FAILED status=${res.status} body=${body}`,
+ );
+ return;
}
- this.logger.log(`Revalidated tags: ${tags.join(', ')}`);
+ this.logger.log(`[revalidate] OK status=${res.status} body=${body}`);
} catch (err) {
// revalidate ์คํจํด๋ ๊ธ ์์ ์ ์ฑ๊ณตํด์ผ ํจ
- this.logger.error('Revalidate failed', err);
+ this.logger.error('[revalidate] ERROR', err as any);
}
}
}
diff --git a/apps/api/src/post/post.controller.ts b/apps/api/src/post/post.controller.ts
index b8c330c..07cc37f 100644
--- a/apps/api/src/post/post.controller.ts
+++ b/apps/api/src/post/post.controller.ts
@@ -6,6 +6,7 @@ import type {
GetMonthPostListResponse,
GetPopularPostListResponse,
GetPostDetailResponse,
+ IncreasePostViewResponse,
PinPostResponse,
UnArchivePostResponse,
UnPinPostResponse,
@@ -19,6 +20,7 @@ import {
GetMonthPostList,
GetPopularPostList,
GetPostDetail,
+ IncreasePostView,
PinPost,
UnArchivePost,
UnPinPost,
@@ -34,8 +36,11 @@ import {
Post,
Put,
Query,
+ Req,
+ Res,
UseGuards,
} from '@nestjs/common';
+import type { Request, Response } from 'express';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { User } from 'src/auth/user.decorator';
@@ -134,6 +139,55 @@ export class PostController {
};
}
+ // ์กฐํ์ ์ฆ๊ฐ (์ฟ ํค ๊ธฐ๋ฐ 1์๊ฐ ์ค๋ณต ๋ฐฉ์ง)
+ @Post(IncreasePostView.path(':postSeq'))
+ async increasePostView(
+ @Param() rawParams: Record,
+ @Req() req: Request,
+ @Res({ passthrough: true }) res: Response,
+ ): Promise {
+ const parsed = IncreasePostView.Params.safeParse({
+ postSeq: Number(rawParams.postSeq),
+ });
+
+ if (!parsed.success) {
+ throw new BadRequestException({
+ message: '์กฐํ์ ์ฆ๊ฐ์ ์คํจํ์ต๋๋ค.',
+ status: 500,
+ data: null,
+ });
+ }
+
+ const postSeq = parsed.data.postSeq;
+ const cookieKey = `post_view_${postSeq}`;
+ const hasViewCookie = Boolean(
+ (req as Request & { cookies?: Record }).cookies?.[
+ cookieKey
+ ],
+ );
+
+ // ์ด๋ฏธ 1์๊ฐ ์ด๋ด์ ์กฐํํ ๊ฒฝ์ฐ: DB ์กฐํ์๋ ์ฆ๊ฐ์ํค์ง ์๊ณ ํ์ฌ ๊ฐ๋ง ๋ฐํ
+ const views = hasViewCookie
+ ? await this.postService.getPostViews(parsed.data)
+ : await this.postService.increasePostView(parsed.data);
+
+ if (!hasViewCookie) {
+ res.cookie(cookieKey, '1', {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
+ path: '/',
+ maxAge: 1000 * 60 * 60, // 1์๊ฐ
+ });
+ }
+
+ return {
+ status: 200,
+ message: '์กฐํ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ฒ๋ฆฌ๋์์ต๋๋ค.',
+ data: { views },
+ };
+ }
+
// ๊ธ ๋ณด๊ด
@UseGuards(JwtAuthGuard)
@Post(ArchivePost.path(':postSeq'))
diff --git a/apps/api/src/post/post.service.ts b/apps/api/src/post/post.service.ts
index a032e62..9d697b9 100644
--- a/apps/api/src/post/post.service.ts
+++ b/apps/api/src/post/post.service.ts
@@ -12,6 +12,8 @@ import {
type GetPopularPostListData,
type GetPostDetailData,
type GetPostDetailParams,
+ IncreasePostView,
+ type IncreasePostViewParams,
PinPost,
type PinPostParams,
UnArchivePost,
@@ -363,4 +365,53 @@ export class PostService {
return Array.from(daySet).sort((a, b) => a - b);
}
+
+ // ์กฐํ์ ์ฆ๊ฐ
+ async increasePostView(params: IncreasePostViewParams): Promise {
+ const postSeq = BigInt(params.postSeq);
+
+ const updated = await this.prisma.post.updateMany({
+ where: { post_seq: postSeq },
+ data: {
+ views: {
+ increment: 1,
+ },
+ },
+ });
+
+ if (updated.count === 0) {
+ throw new NotFoundException('๊ฒ์๊ธ์ ์ฐพ์ ์ ์์ต๋๋ค.');
+ }
+
+ const row = await this.prisma.post.findUnique({
+ where: { post_seq: postSeq },
+ select: { views: true },
+ });
+
+ if (!row) {
+ throw new NotFoundException('๊ฒ์๊ธ์ ์ฐพ์ ์ ์์ต๋๋ค.');
+ }
+
+ await this.revalidate.revalidateTags(
+ IncreasePostView.revalidate(params.postSeq),
+ );
+
+ return row.views;
+ }
+
+ // ํ์ฌ ์กฐํ์ ์กฐํ
+ async getPostViews(params: IncreasePostViewParams): Promise {
+ const postSeq = BigInt(params.postSeq);
+
+ const row = await this.prisma.post.findUnique({
+ where: { post_seq: postSeq },
+ select: { views: true },
+ });
+
+ if (!row) {
+ throw new NotFoundException('๊ฒ์๊ธ์ ์ฐพ์ ์ ์์ต๋๋ค.');
+ }
+
+ return row.views;
+ }
}
diff --git a/apps/bo/README.md b/apps/bo/README.md
new file mode 100644
index 0000000..de245ca
--- /dev/null
+++ b/apps/bo/README.md
@@ -0,0 +1,114 @@
+# Blog Back Office
+
+๊ด๋ฆฌ์ ์ ์ฉ ์ฝํ
์ธ ๊ด๋ฆฌ ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค.
+React + TanStack Router ๊ธฐ๋ฐ์ผ๋ก ์ค๊ณ๋์์ผ๋ฉฐ,
+URL ๊ธฐ๋ฐ ์ํ ๊ด๋ฆฌ์ Runtime Guard ๊ตฌ์กฐ๋ฅผ ์ค์ฌ์ผ๋ก ๊ตฌํ๋์์ต๋๋ค.
+
+## 1. Project Overview
+
+Back Office๋ ๋จ์ํ CRUD ๊ด๋ฆฌ์ ํ๋ฉด์ด ์๋๋ผ,
+์ฝํ
์ธ ๊ด๋ฆฌ ๊ณผ์ ์์ ๋ฐ์ํ๋ ์ํ ๊ด๋ฆฌ, URL ๋๊ธฐํ, ์ดํ ๋ฐฉ์ง, ์บ์ ํธ๋ฆฌ๊ฑฐ๊น์ง ๊ณ ๋ คํ ์ด์์ฉ ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค.
+
+Web(๊ณต๊ฐ ์๋น์ค)๊ณผ๋ ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ถ๋ฆฌ๋ ๋ฐฐํฌ ๋จ์๋ก ๊ตฌ์ฑ๋์ด ์์ผ๋ฉฐ,
+Write ์ค์ฌ ๋ฐํ์์ ๋ด๋นํฉ๋๋ค.
+
+### ๐ฏ ์ค๊ณ ๋ชฉํ
+
+- URL ๊ธฐ๋ฐ ๊ฒ์ ์ํ ๊ด๋ฆฌ (Single Source of Truth)
+- TanStack Router ๊ธฐ๋ฐ ํ์
์์ ๋ผ์ฐํ
+- ๊ณตํต Guard๋ฅผ ํตํ ์ดํ ๋ฐฉ์ง ์ ์ฑ
์ค์ํ
+- Contract ๊ธฐ๋ฐ ์์ฒญ/์๋ต ํ์
๊ณต์
+- Web ์บ์ ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ ์ญํ ์ํ
+
+## 2. Core Features
+
+- ๊ฒ์๊ธ ์์ฑ / ์์ / ์ญ์
+- ๊ฒ์๊ธ ๊ณ ์ (Pin) / ํด์
+- ๊ฒ์๊ธ ๋ณด๊ด(Archive) / ๋ณต์
+- ์นดํ
๊ณ ๋ฆฌ / ํ๊ทธ ๊ธฐ๋ฐ ํํฐ ๊ฒ์
+- URL ๊ธฐ๋ฐ ํ์ด์ง๋ค์ด์
+- ๊ด๋ฆฌ์ ํ๋กํ ์กฐํ / ์์
+- ๋์๋ณด๋ (์ถํ ๊ธฐ๋ฅ)
+
+## 3. URL-Driven State Management
+
+BO๋ ๊ฒ์ ์ํ๋ฅผ `useState` ๋ด๋ถ์๋ง ๋์ง ์๊ณ ,
+URL์ ์ํ์ Single Source of Truth๋ก ์ฌ์ ์ํ์ต๋๋ค.
+
+์ด๋ฅผ ์ํด `useSearchParams` ์ปค์คํ
ํ
์ ์ค๊ณํ์ต๋๋ค.
+
+### ๐ ์ค๊ณ ํฌ์ธํธ
+
+- search ๊ฐ์ URL ์ง๋ ฌํ ๊ฐ๋ฅํ ํ์
๋ง ํ์ฉ
+- ๊ฐ์ฒด/Date/Map ๋ฑ์ ํ์
๋จ๊ณ์์ ์ฐจ๋จ
+- undefined๋ ๋ฌด์, null/''๋ ์ ๊ฑฐ ์๋๋ก ์ฒ๋ฆฌ
+- ๋ถ๋ถ ์
๋ฐ์ดํธ(`applySearch`)์ ์ ์ฒด ๋ฆฌ์
(`resetSearch`) ๋ถ๋ฆฌ
+
+```ts
+const { search, applySearch, resetSearch } = useSearchParams({
+ defaultSearch,
+ Route,
+})
+```
+
+### ์ URL ๊ธฐ๋ฐ์ผ๋ก ์ค๊ณํ๋๊ฐ?
+
+- ์๋ก๊ณ ์นจ ์ ์ํ ์ ์ง
+- ๋ค๋ก๊ฐ๊ธฐ / ๋งํฌ ๊ณต์ ๊ฐ๋ฅ
+- ํํฐ ์ํ์ ์์ธก ๊ฐ๋ฅ์ฑ ํ๋ณด
+- ํ์ด์ง๋ณ search ๋ก์ง ์ค๋ณต ์ ๊ฑฐ
+
+๐ ๊ฒฐ๊ณผ์ ์ผ๋ก ๊ฒ์ ์ํ ๊ด๋ฆฌ ๋ก์ง์ ์ค์ํํ๊ณ ,
+ํ์ด์ง ๋จ ์ค๋ณต ์ฝ๋๋ฅผ ๊ตฌ์กฐ์ ์ผ๋ก ์ ๊ฑฐํ์ต๋๋ค.
+
+## 4. Type-Safe Search Constraint
+
+SearchLike ์ ๋ค๋ฆญ์ ํตํด
+URL์ ๋ค์ด๊ฐ ๊ฐ์ ๋ฐ๋์ ์ง๋ ฌํ ๊ฐ๋ฅํ Primitive ํ์
๋ง ํ์ฉํ๋๋ก ์ ํํ์ต๋๋ค.
+
+### ์ ์ฝ
+
+- string | number | boolean | null | undefined
+- ๋๋ ํด๋น ํ์
์ ๋ฐฐ์ด
+- ๊ฐ์ฒด/ํจ์/Date๋ ํ์
๋จ๊ณ์์ ์ฐจ๋จ
+
+```ts
+export type SearchLike = {
+ [K in keyof T]: PrimitiveOrArray
+}
+```
+
+๐ URL ์ง๋ ฌํ ์์ ์ฑ์ ํ์
๋ ๋ฒจ์์ ๊ฐ์ ํ์ต๋๋ค.
+
+## 5. Contract-Driven API Integration
+
+BO๋ @blog/contracts ํจํค์ง๋ฅผ ํตํด
+API ์์ฒญ/์๋ต ํ์
๊ณผ path ์ ์๋ฅผ ๊ณต์ ํฉ๋๋ค.
+
+- API path ๊ณต์
+- Request / Response ํ์
๊ณต์
+- ๋ฐํ์ validation์ API์์ ์ํ
+- ์ปดํ์ผ ํ์ ์์ ์ฑ์ BO์์ ํ๋ณด
+
+๐ ํ์
์ค๋ณต ์ ์ธ ์์ด Web / BO / API ๊ฐ ๊ณ์ฝ ์ผ๊ด์ฑ ์ ์ง
+
+## 6. Runtime Boundary Role
+
+BO๋ Write ์์ฒญ ๋ฐ์ ์
+API ์๋ฒ๋ฅผ ํตํด Web ์บ์ ๋ฌดํจํ ํ์ดํ๋ผ์ธ์ ํธ๋ฆฌ๊ฑฐํฉ๋๋ค.
+
+### ์ญํ ์ ๋ฆฌ
+
+- Web: ์บ์ ์์
+- API: ๋ฐ์ดํฐ ๋ณ๊ฒฝ + ๋ฌดํจํ ํธ๋ฆฌ๊ฑฐ
+- BO: Write ์์ฒญ ๋ฐ์ ์ง์
+
+๐ ๋ฐํ์ ์ฑ
์์ด ๋ช
ํํ ๋ถ๋ฆฌ๋ ๊ตฌ์กฐ
+
+## Repository Navigation
+
+- ๐๏ธ **Root**: [Blog Platform โ](https://github.com/Blog-Archive-Ian/blog-platform)
+- ๐๏ธ **Web**: [apps/web โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+- ๐๏ธ **Back Office**: [apps/bo โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+- ๐๏ธ **API Server**: [apps/api โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+- ๐๏ธ **Contracts**: [packages/contracts โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000..99d7215
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,148 @@
+# Blog Web
+
+**๐ Live Service** [Visit the Blog](https://blog.minjae-dev.com)
+
+Next.js App Router ๊ธฐ๋ฐ SEO ์ค์ฌ ๋ธ๋ก๊ทธ ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค.
+SSR + Tag ๊ธฐ๋ฐ ์บ์ ์ ๋ต + Contract ๊ณต์ ๊ตฌ์กฐ๋ฅผ ์ค์ฌ์ผ๋ก ์ค๊ณ๋์์ต๋๋ค.
+
+## 1. Project Overview
+
+์ด ์ ํ๋ฆฌ์ผ์ด์
์ ๋จ์ํ ๋ธ๋ก๊ทธ UI ๊ตฌํ์ด ์๋๋ผ,
+SEO ์ค์ฌ์ SSR ๊ธฐ๋ฐ ์น ์ํคํ
์ฒ๋ฅผ ์ค๊ณํ๊ณ ์ด์ ์ ๋ต๊น์ง ๊ณ ๋ คํ ํ๋ก์ ํธ์
๋๋ค.
+
+Web์ ์ฌ์ฉ์ ํธ๋ํฝ์ ์ฒ๋ฆฌํ๋ ๊ณต๊ฐ ์๋น์ค์ด๋ฉฐ,
+Back Office์ API์๋ ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ถ๋ฆฌ๋ ๋ฐฐํฌ ๋จ์๋ก ์ด์๋ฉ๋๋ค.
+
+### ๐ฏ ์ค๊ณ ๋ชฉํ
+
+- SEO ์ค์ฌ SSR ์ ๋ต ์ ์ฉ (๋ชจ๋ ํ์ด์ง Server Rendering)
+- ๊ด๋ฆฌ์ ์์คํ
๊ณผ ์ฌ์ฉ์ ์๋น์ค์ ๋ฐํ์ ๋ถ๋ฆฌ
+- Tag ๊ธฐ๋ฐ ์ ํ์ ์บ์ ๋ฌดํจํ ์ค๊ณ
+- Contract ๊ธฐ๋ฐ ํ์
์ผ๊ด์ฑ ์ ์ง
+- ์๋ฒ ๋น์ฉ์ ๊ณ ๋ คํ Next.js Cache ์ ๋ต ์ค๊ณ
+
+## 2. Core Features
+
+- ์ต์ ๊ธ ๋ชฉ๋ก
+- ์ธ๊ธฐ ๊ธ ๋ชฉ๋ก
+- ๊ณ ์ (Pinned) ๊ธ
+- ์นดํ
๊ณ ๋ฆฌ / ํ๊ทธ ๊ธฐ๋ฐ ํํฐ ์กฐํ
+- ์๋ณ ๊ฒ์๊ธ ์์นด์ด๋ธ ์กฐํ
+- ๊ฒ์๊ธ ์์ธ ์กฐํ
+- ๋๊ธ ์์คํ
(Utterances ๊ธฐ๋ฐ GitHub ์ฐ๋)
+
+๋ชจ๋ ํ์ด์ง๋ **Server Component ๊ธฐ๋ฐ SSR ๊ตฌ์กฐ**๋ก ๊ตฌํ๋์์ต๋๋ค.
+
+## 3. Performance & SEO
+
+์ด๊ธฐ ์ง์
ํ์ด์ง Lighthouse ๊ธฐ์ค
+
+- **Performance: 100**
+- **Accessibility: 96**
+- **Best Practices: 100**
+- **SEO: 100**
+
+### ๐ SEO ์ ๋ต
+
+- App Router ๊ธฐ๋ฐ SSR ์ ์ฉ
+- ์ ์ ๋ฉํ๋ฐ์ดํฐ ์์ฑ
+- ๊ฒ์์์ง ์นํ์ URL ๊ตฌ์กฐ
+- no-store ๋์ Tag ๊ธฐ๋ฐ Cache ์ ๋ต ์ฌ์ฉ
+
+## 4. Rendering & Cache Strategy
+
+Next.js App Router์ `fetch cache` ๊ธฐ๋ฅ์ ํ์ฉํ์ฌ
+
+- `revalidate` + `tags` ๊ธฐ๋ฐ ์บ์ฑ
+- ๋๋ฉ์ธ ๋จ์ + ID ๋จ์ Tag ์ค๊ณ
+- ์ ์ฒด no-store ์ ๋ต ๋์ ์ ํ์ ๋ฌดํจํ ์ ์ฉ
+
+```ts
+next: {
+ revalidate: 5 * 60,
+ tags: [CacheTags.Post.detail, CacheTags.Post.byId(params.postSeq)],
+}
+```
+
+### ๐ก ์ no-store๋ฅผ ์ฐ์ง ์์๋๊ฐ?
+
+- ๋ชจ๋ ์์ฒญ์ ์๋ฒ์์ ์ฒ๋ฆฌํ๋ฉด ๋น์ฉ ์ฆ๊ฐ
+- ์บ์๋ฅผ ์ ์งํ๋ฉด์ ์ต์ ์ฑ๋ง ๋ถ๋ถ ๊ฐฑ์ ํ๋ ์ ๋ต์ด ํ์
+- SEO ์ฑ๋ฅ๊ณผ ์๋ฒ ๋น์ฉ์ ๋์์ ๊ณ ๋ ค
+
+๐ ๊ฒฐ๊ณผ์ ์ผ๋ก ์๋ฒ ๋ถํ๋ฅผ ์ค์ด๋ฉด์๋ ์ฝํ
์ธ ์์ ์ ์ฆ์ ๋ฐ์๋๋ ๊ตฌ์กฐ๋ฅผ ์ค๊ณํ์ต๋๋ค.
+
+## 4.1 Cache Invalidation Pipeline
+
+BO์์ ๋ฐ์ดํฐ ์์ ์ด ๋ฐ์ํ๋ฉด
+API ์๋ฒ๊ฐ Web์ `/api/revalidate` ์๋ํฌ์ธํธ๋ฅผ ํธ์ถํฉ๋๋ค.
+
+Web ๋ฐํ์ ๋ด๋ถ์์๋ง `revalidateTag` / `revalidatePath`๊ฐ ์ ํจํ๋ฏ๋ก,
+์บ์ ์์ ๊ถ์ Web์ด ๋ช
ํํ ๋ณด์ ํฉ๋๋ค.
+
+```ts
+for (const tag of tags) revalidateTag(tag, 'max')
+for (const path of paths) revalidatePath(path)
+```
+
+### ์ค๊ณ ํฌ์ธํธ
+
+- ์บ์ ์์ ๊ถ์ Web ๋ฐํ์์ผ๋ก ํ์
+- ์ ์ฒด no-store ์ ๋ต ๋์ ์ ํ์ ๊ฐฑ์
+- Tag ๊ท์น์ contracts ๊ณ์ธต์์ ๊ณต์
+
+๐ ๋ฐํ์ ๊ฒฝ๊ณ๋ฅผ ์ ์งํ๋ฉด์ ์ต์ ์ฑ๊ณผ ์ฑ๋ฅ์ ๋์์ ํ๋ณดํ์ต๋๋ค.
+
+## 5. Contract-Driven Data Layer
+
+Web์ `@blog/contracts` ํจํค์ง๋ฅผ ํตํด
+API ์์ฒญ/์๋ต ํ์
๊ณผ ๊ฒฝ๋ก ์ ์๋ฅผ ๊ณต์ ํฉ๋๋ค.
+
+์ด๋ ๋จ์ํ ํ์
์ฌ์ฌ์ฉ์ด ์๋๋ผ,
+Web / BO / API ๊ฐ **Single Source of Truth ๊ธฐ๋ฐ ๊ณ์ฝ ๊ตฌ์กฐ**๋ฅผ ์๋ฏธํฉ๋๋ค.
+
+### ๐ฆ Contract ๊ณต์ ๊ตฌ์กฐ
+
+- API path ์ ์ ๊ณต์
+- Request / Response ํ์
๊ณต์
+- Cache Tag ๊ท์น ๊ณต์
+- z.infer ๊ธฐ๋ฐ ์ปดํ์ผ ํ์ ํ์
์์ ์ฑ ํ๋ณด
+
+์์:
+
+```ts
+import {
+ GetPostDetail,
+ type GetPostDetailParams,
+ type GetPostDetailResponse,
+} from '@blog/contracts'
+
+export async function getPostDetail(params: GetPostDetailParams) {
+ const res = await API.get(GetPostDetail.path(params.postSeq), {
+ next: {
+ revalidate: 5 * 60,
+ tags: [CacheTags.Post.detail, CacheTags.Post.byId(params.postSeq)],
+ },
+ })
+
+ if (res.status !== 200) throw new Error(res.message)
+ return res.data
+}
+```
+
+### ๐ฏ ์ ์ค์ํ๊ฐ?
+
+- Web์์ ํ์
์ ๋ณ๋๋ก ์ ์ธํ์ง ์์
+- API ์คํค๋ง ๋ณ๊ฒฝ ์ ์ปดํ์ผ ๋จ๊ณ์์ ์ฆ์ ๊ฐ์ง
+- ๋ฐํ์๊ณผ ์ปดํ์ผ ํ์ ์์ ์ฑ์ ๋์์ ํ๋ณด
+- Cache Tag ๊ท์น ๋ํ ๊ณ์ฝ ๊ณ์ธต์์ ๊ณต์
+
+๐ ๊ฒฐ๊ณผ์ ์ผ๋ก ๋ฐ์ดํฐ ๊ณ์ธต์ ์ผ๊ด์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ ๊ตฌ์กฐ์ ์ผ๋ก ํ๋ณดํ์ต๋๋ค.
+
+## Repository Navigation
+
+- ๐๏ธ **Root**: [Blog Platform โ](https://github.com/Blog-Archive-Ian/blog-platform)
+- ๐๏ธ **Web**: [apps/web โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+- ๐๏ธ **Back Office**: [apps/bo โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+- ๐๏ธ **API Server**: [apps/api โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+- ๐๏ธ **Contracts**: [packages/contracts โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
diff --git a/apps/web/app/(main)/post/[postSeq]/page.tsx b/apps/web/app/(main)/post/[postSeq]/page.tsx
index b6f66ca..b675594 100644
--- a/apps/web/app/(main)/post/[postSeq]/page.tsx
+++ b/apps/web/app/(main)/post/[postSeq]/page.tsx
@@ -1,6 +1,7 @@
import { Comments } from '@/section/post/post-detail/comments'
import { PostContent } from '@/section/post/post-detail/post-content'
import { ScrollToBottomButton } from '@/section/post/post-detail/scroll-to-bottom-button'
+import { PostViewTracker } from '@/section/post/post-detail/view-tracker'
import { TableOfContents } from '@/section/post/post-detail/toc'
import { getPostDetail } from '@/shared/api/post.api'
import { formatKoreanDate, stripMarkdown } from '@/shared/lib/format'
@@ -114,6 +115,9 @@ export default async function PostPage({ params }: { params: Promise
+ {/* ์กฐํ์ ์ฆ๊ฐ ํธ๋์ปค (ํด๋ผ์ด์ธํธ์์ ์ฟ ํค ๊ธฐ๋ฐ 1์๊ฐ ์ค๋ณต ๋ฐฉ์ง) */}
+
+
{/* ์ค์ ์ฝํ
์ธ */}
diff --git a/apps/web/section/post/post-detail/view-tracker.tsx b/apps/web/section/post/post-detail/view-tracker.tsx
new file mode 100644
index 0000000..fcaa7e2
--- /dev/null
+++ b/apps/web/section/post/post-detail/view-tracker.tsx
@@ -0,0 +1,26 @@
+'use client'
+
+import { useEffect } from 'react'
+
+import { increasePostView } from '@/shared/api/post.api'
+
+type PostViewTrackerProps = {
+ // ์กฐํ์๋ฅผ ์ฆ๊ฐ์ํฌ ๊ฒ์๊ธ ๋ฒํธ
+ postSeq: number
+}
+
+export function PostViewTracker({ postSeq }: PostViewTrackerProps) {
+ // ํด๋ผ์ด์ธํธ์์๋ง ํ ๋ฒ ํธ์ถํด์ ์ฟ ํค ๊ธฐ๋ฐ ์กฐํ์ ์ฆ๊ฐ ์ฒ๋ฆฌ
+ useEffect(() => {
+ ;(async () => {
+ try {
+ await increasePostView({ postSeq })
+ } catch {
+ // ์กฐํ์ ์ฆ๊ฐ ์คํจ๋ ์ฌ์ฉ์ ๊ฒฝํ์ ํฐ ์ํฅ์ ์ฃผ์ง ์์ผ๋ฏ๋ก ์กฐ์ฉํ ๋ฌด์
+ }
+ })()
+ }, [postSeq])
+
+ return null
+}
+
diff --git a/apps/web/shared/api/post.api.ts b/apps/web/shared/api/post.api.ts
index 9919890..b38d3ce 100644
--- a/apps/web/shared/api/post.api.ts
+++ b/apps/web/shared/api/post.api.ts
@@ -20,6 +20,9 @@ import {
type GetPostDetailData,
type GetPostDetailParams,
type GetPostDetailResponse,
+ IncreasePostView,
+ type IncreasePostViewParams,
+ type IncreasePostViewResponse,
} from '@blog/contracts'
// ๊ธ ๋ชฉ๋ก ์กฐํ
@@ -83,3 +86,18 @@ export async function getMonthPostList(
if (res.status !== 200) throw new Error(res.message)
return res.data
}
+
+// ์กฐํ์ ์ฆ๊ฐ (์ฟ ํค ๊ธฐ๋ฐ 1์๊ฐ ์ค๋ณต ๋ฐฉ์ง)
+export async function increasePostView(
+ params: IncreasePostViewParams,
+): Promise {
+ const res = await API.post(
+ IncreasePostView.path(params.postSeq),
+ )
+
+ if (res.status !== 200) {
+ throw new Error(res.message)
+ }
+
+ return res.data.views
+}
diff --git a/package.json b/package.json
index cc57097..9ff035f 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.0",
- "turbo": "^2.8.3",
+ "turbo": "^2.8.10",
"typescript": "^5"
},
"lint-staged": {
diff --git a/packages/contracts/README.md b/packages/contracts/README.md
new file mode 100644
index 0000000..75bbb82
--- /dev/null
+++ b/packages/contracts/README.md
@@ -0,0 +1,174 @@
+# @blog/contracts
+
+**Zod ๊ธฐ๋ฐ API Contract Single Source of Truth ํจํค์ง**์
๋๋ค.
+Web / BO / API๊ฐ ๊ณต์ ํ๋ Schema Layer์ด์ API Spec Registry ์ญํ ์ ํฉ๋๋ค.
+
+์ด ํจํค์ง๋ ๋จ์ ํ์
์ ์๊ฐ ์๋๋ผ,
+
+- API ์์ฒญ/์๋ต ์คํค๋ง ์ ์
+- ๋ฐํ์ validation ๊ธฐ์ค ์ ๊ณต
+- ์ปดํ์ผ ํ์ ํ์
์ถ๋ก
+- Cache Tag ๊ท์น ์ ์
+- Revalidate ์ ์ฑ
์ ์
+
+๋ฅผ ํ๋์ ๊ณ์ธต์ผ๋ก ํตํฉ ๊ด๋ฆฌํฉ๋๋ค.
+
+## 1. Why Contracts Layer?
+
+Web / BO / API๋ ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ถ๋ฆฌ๋์ด ์์ง๋ง,
+๋ฐ์ดํฐ ๊ณ์ฝ์ ๋จ์ผ ๊ธฐ์ค์ผ๋ก ์ ์ง๋์ด์ผ ํฉ๋๋ค.
+
+์ด๋ฅผ ์ํด
+
+- ํ์
์ ์ ์ค๋ณต ์ ๊ฑฐ
+- ๋ฐํ์ ๊ฒ์ฆ ๊ธฐ์ค ํต์ผ
+- ์บ์ ๋ฌดํจํ ๊ท์น ์ค์ํ
+
+๋ฅผ ๋ชฉํ๋ก ์ค๊ณ๋์์ต๋๋ค.
+
+๐ ๋ฌผ๋ฆฌ์ ๋ถ๋ฆฌ์ ๋
ผ๋ฆฌ์ ์ผ๊ด์ฑ์ ๋์์ ํ๋ณดํฉ๋๋ค.
+
+## 2. What This Package Contains
+
+### โ API Spec Definition
+
+๊ฐ API๋ ๋ค์ ์ ๋ณด๋ฅผ ๋ชจ๋ ํฌํจํฉ๋๋ค:
+
+```ts
+export const GetPostDetail = {
+ method: 'GET',
+ path: (postSeq: number | string) => `/post/${postSeq}`,
+ Params: z.object({...}),
+ Response: ApiResponseStrict(PostSchema),
+}
+```
+
+ํฌํจ ์์:
+
+- HTTP method
+- path ์ ์
+- Params / Query / Body schema
+- Response schema
+- (Write API์ ๊ฒฝ์ฐ) revalidate ์ ์ฑ
+
+๐ API ๋ฌธ์, ํ์
, ๊ฒ์ฆ ๊ธฐ์ค์ด ํ๋์ ๊ฐ์ฒด์ ํตํฉ๋ฉ๋๋ค.
+
+### โก Zod ๊ธฐ๋ฐ Runtime Validation
+
+```ts
+export const CreatePostSchema = z.object({
+ title: z.string().min(1).max(100),
+ content: z.string().min(1),
+ tags: z.array(z.string()).max(10),
+ category: z.string().min(1),
+})
+```
+
+- API ์๋ฒ์์ runtime validation ์ํ
+- Web / BO์์๋ ํ์
์ถ๋ก ๊ธฐ๋ฐ ์ปดํ์ผ ํ์ ์์ ์ฑ ํ๋ณด
+
+๐ ํ๋์ ์คํค๋ง๊ฐ ๋ฐํ์๊ณผ ์ปดํ์ผ ํ์์ ๋์์ ๊ดํตํฉ๋๋ค.
+
+### โข Type Inference (Single Source of Truth)
+
+```ts
+export type CreatePostBody = z.infer
+export type GetPostDetailResponse = z.infer
+```
+
+- API์ Web์์ ๋์ผ ํ์
์ฌ์ฉ
+- ์คํค๋ง ๋ณ๊ฒฝ ์ ์ปดํ์ผ ๋จ๊ณ์์ ์ฆ์ ๊ฐ์ง
+
+๐ ํ์
์ ์ ์ง์ 3 โ 1๋ก ์๋ ด
+
+### โฃ Cache Tag Registry
+
+```ts
+export const CacheTags = {
+ Post: {
+ list: 'post:list',
+ byId: (postSeq) => `post:${postSeq}`,
+ calendar: (year, month) => `post:calendar:${year}-${month}`,
+ },
+}
+```
+
+- Tag ๋ฌธ์์ด ํ๋์ฝ๋ฉ ์ ๊ฑฐ
+- Web / API์์ ๋์ผ ๊ท์น ๊ณต์
+- Tag ์ ์ฑ
๋ณ๊ฒฝ ์ ์ํฅ ๋ฒ์ ์ถ์ ๊ฐ๋ฅ
+
+### โค Revalidation Policy Registry
+
+Write API๋ revalidate ๊ท์น์ ํจ๊ป ์ ์ํฉ๋๋ค.
+
+```ts
+export const UpdatePost = {
+ ...
+ revalidate: (postSeq) => [
+ CacheTags.Post.byId(postSeq),
+ CacheTags.Post.list,
+ CacheTags.Post.popular,
+ ],
+}
+```
+
+๐ ๋ฌดํจํ ์ ์ฑ
์ API ๊ตฌํ์ด ์๋๋ผ ๊ณ์ฝ ๊ณ์ธต์์ ๊ด๋ฆฌํฉ๋๋ค.
+
+- ์บ์ ์ ์ฑ
์ด ์ฝ๋์ ๋ถ์ฐ๋์ง ์์
+- ๋ณ๊ฒฝ ์ํฅ ๋ฒ์๊ฐ ๋ช
ํํด์ง
+
+## 3. Response Wrapper Abstraction
+
+๊ณตํต ์๋ต ๊ตฌ์กฐ๋ ์ค์ ์ ์ํฉ๋๋ค.
+
+```ts
+export const ApiResponseStrict = (schema: T) =>
+ z.object({
+ status: z.number(),
+ message: z.string(),
+ data: schema,
+ })
+```
+
+- ์๋ต ํฌ๋งท ์ผ๊ด์ฑ ํ๋ณด
+- API ๊ตฌ์กฐ ๋ณ๊ฒฝ ์ ์ค์ ์์ ๊ฐ๋ฅ
+
+## 4. ESM / CJS Dual Build
+
+์ด ํจํค์ง๋ ESM, CJS๋ฅผ ๋ชจ๋ ์ง์ํฉ๋๋ค.
+
+- ESM (Next.js / modern bundler)
+- CJS (NestJS runtime)
+
+```json
+"exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.js",
+ "require": "./dist/cjs/index.js"
+ }
+}
+```
+
+๐ ๋ฐํ์ ํ๊ฒฝ ์ฐจ์ด๋ฅผ ํก์ํ๋ ๊ณต์ ํจํค์ง ๊ตฌ์กฐ
+
+## 5. Architectural Role
+
+์ด ํจํค์ง๋ ์๋์ ๊ฐ์ ์ญํ ์ ์ํํฉ๋๋ค.
+
+- ๐ API ๋ช
์ธ ๋ ์ง์คํธ๋ฆฌ
+- ๐ง Schema Layer
+- ๐ Validation ๊ธฐ์ค
+- ๐ Cache Policy Registry
+- ๐ Web / BO / API ๊ฒฝ๊ณ ํตํฉ ๊ณ์ธต
+
+๐ ์ ํ๋ฆฌ์ผ์ด์
์ ๋ถ๋ฆฌ๋์ด ์์ง๋ง,
+๋ฐ์ดํฐ ๊ณ์ฝ์ ์ด ๊ณ์ธต์์ ์ผ๊ด๋๊ฒ ์ ์ง๋ฉ๋๋ค.
+
+## Repository Navigation
+
+- ๐๏ธ **Root**: [Blog Platform โ](https://github.com/Blog-Archive-Ian/blog-platform)
+- ๐๏ธ **Web**: [apps/web โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/web)
+- ๐๏ธ **Back Office**: [apps/bo โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/bo)
+- ๐๏ธ **API Server**: [apps/api โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/apps/api)
+- ๐๏ธ **Contracts**: [packages/contracts โ](https://github.com/Blog-Archive-Ian/blog-platform/tree/dev/packages/contracts)
diff --git a/packages/contracts/src/post/post.api.ts b/packages/contracts/src/post/post.api.ts
index e54af83..478e602 100644
--- a/packages/contracts/src/post/post.api.ts
+++ b/packages/contracts/src/post/post.api.ts
@@ -212,3 +212,23 @@ export const UnArchivePost = {
} as const
export type UnArchivePostResponse = z.infer
export type UnArchivePostParams = z.infer
+
+// ์กฐํ์ ์ฆ๊ฐ (์ฟ ํค ๊ธฐ๋ฐ ์ค๋ณต ๋ฐฉ์ง ์ ์ฉ)
+export const IncreasePostView = {
+ method: 'POST',
+ path: (postSeq: number | string) => `/post/${postSeq}/view`,
+ Params: z.object({
+ postSeq: z.number(),
+ }),
+ Response: ApiResponseStrict(
+ z.object({
+ views: z.number(),
+ }),
+ ),
+ revalidate: (postSeq: number | string) => [
+ CacheTags.Post.byId(postSeq),
+ CacheTags.Post.popular,
+ ],
+} as const
+export type IncreasePostViewResponse = z.infer
+export type IncreasePostViewParams = z.infer
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6a0e8dc..26138cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -54,8 +54,8 @@ importers:
specifier: ^3.8.0
version: 3.8.0
turbo:
- specifier: ^2.8.3
- version: 2.8.3
+ specifier: ^2.8.10
+ version: 2.8.10
typescript:
specifier: ^5
version: 5.9.3
@@ -7450,38 +7450,38 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
- turbo-darwin-64@2.8.3:
- resolution: {integrity: sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw==}
+ turbo-darwin-64@2.8.10:
+ resolution: {integrity: sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g==}
cpu: [x64]
os: [darwin]
- turbo-darwin-arm64@2.8.3:
- resolution: {integrity: sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ==}
+ turbo-darwin-arm64@2.8.10:
+ resolution: {integrity: sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA==}
cpu: [arm64]
os: [darwin]
- turbo-linux-64@2.8.3:
- resolution: {integrity: sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA==}
+ turbo-linux-64@2.8.10:
+ resolution: {integrity: sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA==}
cpu: [x64]
os: [linux]
- turbo-linux-arm64@2.8.3:
- resolution: {integrity: sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q==}
+ turbo-linux-arm64@2.8.10:
+ resolution: {integrity: sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ==}
cpu: [arm64]
os: [linux]
- turbo-windows-64@2.8.3:
- resolution: {integrity: sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw==}
+ turbo-windows-64@2.8.10:
+ resolution: {integrity: sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw==}
cpu: [x64]
os: [win32]
- turbo-windows-arm64@2.8.3:
- resolution: {integrity: sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw==}
+ turbo-windows-arm64@2.8.10:
+ resolution: {integrity: sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ==}
cpu: [arm64]
os: [win32]
- turbo@2.8.3:
- resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==}
+ turbo@2.8.10:
+ resolution: {integrity: sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ==}
hasBin: true
tw-animate-css@1.4.0:
@@ -16165,32 +16165,32 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
- turbo-darwin-64@2.8.3:
+ turbo-darwin-64@2.8.10:
optional: true
- turbo-darwin-arm64@2.8.3:
+ turbo-darwin-arm64@2.8.10:
optional: true
- turbo-linux-64@2.8.3:
+ turbo-linux-64@2.8.10:
optional: true
- turbo-linux-arm64@2.8.3:
+ turbo-linux-arm64@2.8.10:
optional: true
- turbo-windows-64@2.8.3:
+ turbo-windows-64@2.8.10:
optional: true
- turbo-windows-arm64@2.8.3:
+ turbo-windows-arm64@2.8.10:
optional: true
- turbo@2.8.3:
+ turbo@2.8.10:
optionalDependencies:
- turbo-darwin-64: 2.8.3
- turbo-darwin-arm64: 2.8.3
- turbo-linux-64: 2.8.3
- turbo-linux-arm64: 2.8.3
- turbo-windows-64: 2.8.3
- turbo-windows-arm64: 2.8.3
+ turbo-darwin-64: 2.8.10
+ turbo-darwin-arm64: 2.8.10
+ turbo-linux-64: 2.8.10
+ turbo-linux-arm64: 2.8.10
+ turbo-windows-64: 2.8.10
+ turbo-windows-arm64: 2.8.10
tw-animate-css@1.4.0: {}