From 5ba96f2b4c7e770c4c3493a32d494770840236a0 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:28:43 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(api):=20revalidate=20log=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/common/revalidate.service.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/api/src/common/revalidate.service.ts b/apps/api/src/common/revalidate.service.ts index 40be9b0..d34e3e8 100644 --- a/apps/api/src/common/revalidate.service.ts +++ b/apps/api/src/common/revalidate.service.ts @@ -8,6 +8,8 @@ export class RevalidateService { 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', @@ -15,8 +17,12 @@ export class RevalidateService { 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); } } } From c94d95e952026709bfd2849b66be15151fd85a4f Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:35:37 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(api):=20env=20key=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/common/revalidate.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/common/revalidate.service.ts b/apps/api/src/common/revalidate.service.ts index d34e3e8..6af6161 100644 --- a/apps/api/src/common/revalidate.service.ts +++ b/apps/api/src/common/revalidate.service.ts @@ -4,7 +4,7 @@ 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[]) { @@ -12,7 +12,7 @@ export class RevalidateService { 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; } From 3ed5a15753ff68f8db67d480bb57ee1d594915a5 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:51:44 +0900 Subject: [PATCH 03/11] chore(root): turbo upgrade --- package.json | 2 +- pnpm-lock.yaml | 58 +++++++++++++++++++++++++------------------------- 2 files changed, 30 insertions(+), 30 deletions(-) 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/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: {} From 28df9e10665d04cc8f8955510e233867a7f74aea Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:52:21 +0900 Subject: [PATCH 04/11] docs(root): update readme --- README.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..afd703b --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Blog Platform + +Monorepo-based Web / BO / API Architecture with Contract-First Design +A contract-driven, boundary-oriented architecture designed for scalability, consistency, and runtime 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) +- PostgreSQL From 85a0db75283706f352d66f8b300dc43392b54b2c Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:55:56 +0900 Subject: [PATCH 05/11] docs(root): update readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 링크 추가 --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index afd703b..19fec02 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # Blog Platform -Monorepo-based Web / BO / API Architecture with Contract-First Design -A contract-driven, boundary-oriented architecture designed for scalability, consistency, and runtime separation. +**🌐 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 From 6803cac78467487b31b1fbbe7937c071aed01430 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:21:41 +0900 Subject: [PATCH 06/11] docs(web): create readme --- README.md | 8 +++ apps/web/README.md | 148 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 apps/web/README.md diff --git a/README.md b/README.md index 19fec02..0133dcd 100644 --- a/README.md +++ b/README.md @@ -149,3 +149,11 @@ Zod 기반 API Contract **Single Source of Truth** - NestJS - Prisma (ORM) - PostgreSQL + +## 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..80acf0d --- /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) From 9f2d7f4769b6a16db4139cb493aeb8dd161e6c58 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:33:26 +0900 Subject: [PATCH 07/11] docs(bo): create readme --- README.md | 10 ++-- apps/bo/README.md | 114 +++++++++++++++++++++++++++++++++++++++++++++ apps/web/README.md | 10 ++-- 3 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 apps/bo/README.md diff --git a/README.md b/README.md index 0133dcd..20e68e5 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ Zod 기반 API Contract **Single Source of Truth** ## 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) +- 🗂️ **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/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 index 80acf0d..99d7215 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -141,8 +141,8 @@ export async function getPostDetail(params: GetPostDetailParams) { ## 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) +- 🗂️ **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) From 85a78b0f64e0ae76c44c0a5ab3dc9a4da7b5e130 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:37:39 +0900 Subject: [PATCH 08/11] docs(root): update readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit teck stack 변경 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20e68e5..3ef781f 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Zod 기반 API Contract **Single Source of Truth** - NestJS - Prisma (ORM) -- PostgreSQL +- MariaDB (MySQL-compatible) ## Repository Navigation From c2dd188b9fb505e65cedf05039b32d841cce83bd Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:01:56 +0900 Subject: [PATCH 09/11] docs(api): update readme --- apps/api/README.md | 139 +++++++++++++++++++++------------------------ 1 file changed, 65 insertions(+), 74 deletions(-) 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 @@ -

- Nest Logo -

- -[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.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## 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) From 9bb7261ca2588e9c58f97f3bb4e59c5f3930783f Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:09:51 +0900 Subject: [PATCH 10/11] docs(package): create readme --- packages/contracts/README.md | 174 +++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/contracts/README.md 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) From f604380712ad3d436b76f94660b12a70328a29b1 Mon Sep 17 00:00:00 2001 From: Ian <105128049+minijae011030@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:39:16 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat(bo):=20view=20=EC=A6=9D=EA=B0=80=20a?= =?UTF-8?q?pi=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/post/post.controller.ts | 54 +++++++++++++++++++ apps/api/src/post/post.service.ts | 51 ++++++++++++++++++ apps/web/app/(main)/post/[postSeq]/page.tsx | 4 ++ .../section/post/post-detail/view-tracker.tsx | 26 +++++++++ apps/web/shared/api/post.api.ts | 18 +++++++ packages/contracts/src/post/post.api.ts | 20 +++++++ 6 files changed, 173 insertions(+) create mode 100644 apps/web/section/post/post-detail/view-tracker.tsx 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/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/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