Skip to content
Merged

web #20

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
139 changes: 65 additions & 74 deletions apps/api/README.md
Original file line number Diff line number Diff line change
@@ -1,98 +1,89 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>

[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest

<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->

## 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)
24 changes: 17 additions & 7 deletions apps/api/src/common/revalidate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
}
}
}
Loading