4.Node.js/실무익히기

[REST API] 2편. API 서버 아키텍처 설계하기 : 프로젝트 구조, ER 설계, Prisma 모델링

쿼드큐브 2026. 3. 11. 15:11
반응형
반응형

 

2편. API 서버 아키텍처 설계하기 : 프로젝트 구조, ER 설계, Prisma 모델링

 

📚 목차
1. 공통 개발 환경과 서비스 분리형 모노레포 구조 설계
2. 게시판 DB 설계 : ER 모델링
3. Prisma 스키마 및 인덱스 설계
4. Fastify + Prisma 기반 계층형 모듈 아키텍처 구성
5. 초기화 및 운영 명령어 정리

 

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest

1. 공통 개발 환경과 서비스 분리형 모노레포 구조 설계

프로젝트를 단일 Node 프로젝트가 아닌, 계층형 구조의 이중 Node 프로젝트로 구성합니다.

nodejs-tutorials/        (공통 설정 레벨)
 ├─ package. 
 ├─ node_modules/
 ├─ eslint.config.js
 ├─ .prettierrc
 ├─ tsconfig. 
 └─ fastify-api/         (실제 API 서버)
     ├─ package. 
     ├─ tsconfig. 
     ├─ vite.config.ts
     ├─ prisma.config.ts
     ├─ prisma/
     ├─ src/
     └─ tests/

 

1. 루트 프로젝트 생성 (공통 개발 환경)

# 루트 프로젝트 생성
cd /nodejs-tutorials
npm init -y

#2. 패키지 설치
npm install -D \
  eslint@9.39.2 \
  @eslint/js@9.39.2 \
  @eslint/ @0.14.0 \
  eslint-config-prettier@10.1.8 \
  @types/node@25.0.3

#3 설치 목록
PS D:\NodejsDevelope\workspace\nodejs-tutorials> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-tutorials
├── @eslint/js@9.39.2
├── @eslint/ @0.14.0
├── @types/node@25.0.3
├── eslint-config-prettier@10.1.8
├── eslint@9.39.2

 

2. API 서버 프로젝트 생성 (fastify-api)

루트 하위에 실제 서버 프로젝트를 생성한다.

#1. API 서버 프로젝트 생성
cd /nodejs-tutorials/fastify-api
npm init -y

#2. 패키지 설치
npm install fastify@5.7.1 fastify-plugin@5.1.0
npm install -D prisma@7.2.0
npm install @prisma/client@7.2.0 @prisma/adapter-pg@7.2.0

npm install pg@8.17.1
npm install -D @types/pg@8.16.0

npm install -D vitest@4.0.17 @vitest/ui@4.0.17 @vitest/coverage-v8@4.0.17

#3. 패키지 설치 결과
PS D:\NodejsDevelope\workspace\nodejs-tutorials\fastify-api> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-tutorials
└─┬ fastify-api@1.0.0 -> .\fastify-api
  ├── @prisma/adapter-pg@7.2.0
  ├── @prisma/client@7.2.0
  ├── @types/pg@8.16.0
  ├── @vitest/coverage-v8@4.0.17
  ├── @vitest/ui@4.0.17
  ├── fastify-plugin@5.1.0
  ├── fastify@5.7.1
  ├── pg@8.17.1
  ├── prisma@7.2.0
  └── vitest@4.0.17

 

2. 게시판 DB 설계 : ER 모델링

API 서버 ER모델
API 서버 ER모델

 

1. User & Profile: 책임 분리와 확장성 중심 설계

단순히 정보를 저장하는 것을 넘어, 데이터의 성격과 변경 주기에 따라 테이블을 분리했습니다.

 

1) 1:1 관계 분리 (Separation of Concerns)
▸ User 테이블: 인증(Auth) 및 식별(Identity)의 주체. 보안 및 핵심 로직에 집중.
▸ Profile 테이블: 표현 계층(소개, 아바타 등)의 부가 정보.
▸ 분리 이유(조회 최적화): 인증 과정에서 무거운 프로필 필드(자기소개 등)를 제외하여 메모리 효율 증대.

▸ 유연한 확장: 향후 '다국어 프로필'이나 '프로필 히스토리' 도입 시 기존 User 스키마 변경 없이 대응 가능.

 

2) 아바타(Avatar) 메타데이터 전략

▸ 이미지 파일을 DB에 직접 저장하지 않고 메타데이터(Key, MimeType, Size, Dimension)만 관리합니다.
▸ 외부 스토리지(S3 등) 교체 시 DB 스키마 변경이 불필요하며, 이미지 리사이징 및 CDN 전략을 독립적으로 운영할 수 있습니다.

 

2. 운영의 핵심: Soft Delete 전략

▸ 모든 주요 엔티티에 deletedAt 컬럼을 추가하고 Soft Delete를 기본으로 채택했습니다.
▸ 데이터 복구: 운영 중 발생하는 실수나 고객 문의(CS)에 즉각적인 복구 대응 가능.
▸ 참조 무결성: 물리적 삭제로 인해 관계된 게시글이나 댓글 데이터가 깨지는 현상 방지.
▸ 인덱스 최적화: @@index([deletedAt])를 설정하여 삭제되지 않은 유효 데이터 조회 성능을 확보했습니다.

 

3. Post / Reply / Like: 성능과 정합성의 조화

많은 트래픽이 몰리는 게시판 도메인에서는 읽기 성능 향상을 위한 전략적 설계를 적용했습니다.

 

▸ 명시적 M:N 관계 (PostLike)
User와 Post 사이의 좋아요 관계를 별도 테이블로 선언하여 생성 시간(createdAt) 관리 및 **복합 PK(userId, postId)**를 통한 중복 방지를 달성했습니다.

▸ 카운터 캐시(Counter Cache) 컬럼
viewCount, likeCount, replyCount를 Post 테이블에 직접 유지합니다.
의도적 중복: 목록 조회 시 매번 COUNT 쿼리를 날리는 비용을 절감하기 위해 쓰기 시점에 비용을 지불하고 읽기 성능을 극대화했습니다.

▸ 쿼리 기반 인덱스 설계
단순 PK 조회를 넘어, 실제 서비스 UI/UX에서 자주 사용될 패턴을 인덱스에 반영했습니다.
최신순/공개여부: published + createdAt
인기순: published + viewCount + createdAt

 

4. 통계 설계: PostViewStat 버킷 패턴

조회수 통계는 단순 누적이 아닌 시계열 분석이 가능하도록 설계되었습니다.

 

▸ 버킷(Bucket) 기반 저장
시간(Hourly), 일(Daily), 월(Monthly) 단위로 데이터를 쪼개어 저장합니다.
장점: 특정 시간대 트래픽 분석, 일간 인기글 추출 등 복잡한 통계 요구사항을 쿼리 한 번으로 해결 가능합니다.

 

▸ 이원화 전략 (Real-time vs. History)

     
구분 Post.viewCount PostViewStat
목적 실시간 노출용 (Fast Read) 분석 및 통계용 (Deep Analysis)
저장 방식 하나의 컬럼에 누적 시간 버킷 단위 레코드
데이터 성격 현재 시점의 최종 합계 시간대별 변화 이력
조회 비용 매우 낮음 상대적으로 높음
정합성 근사치 허용 분석 기준의 정확성 중시
대표 예시 “조회수 1,234” “어제 대비 +12%, 시간대별 트래픽”

 

반응형

 

3. Prisma 스키마 및 인덱스 설계

// =========================
// User
// =========================
// 서비스 사용자 기본 계정 테이블
// Soft delete 패턴 사용 (deleted_at)
// 로그인/식별 최소 필드만 포함, 확장 정보는 Profile로 분리
model User {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  // 로그인 및 식별용 유니크 필드
  email       String  @unique
  phoneNumber String  @unique @map("phone_number") // E.164 포맷 권장 (+821012345678)
  displayName String? @map("display_name")

  // 인증용-초기 암호 없음
  passwordHash String? @map("password_hash")
  
  // Relations
  profile Profile?
  posts   Post[]
  replies Reply[]
  likes   PostLike[]

  // Soft delete 조회 최적화
  @@index([deletedAt])
  @@map("users")
}

// =========================
// Profile (User 확장 정보 + Avatar 메타데이터)
// =========================
// User와 1:1 관계
// 파일 자체는 Object Storage(S3 등)에 있고 DB에는 메타데이터만 저장
model Profile {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  bio String?

  // Avatar 파일 메타데이터
  avatarKey      String? @map("avatar_key") // Object Storage key
  avatarFileName String? @map("avatar_file_name") // 원본 파일명
  avatarMimeType String? @map("avatar_mime_type")
  avatarFileSize Int?    @map("avatar_file_size")
  avatarWidth    Int?
  avatarHeight   Int?

  // User 1:1 관계 (User 먼저 삭제 시 Profile 자동 삭제)
  userId Int  @unique @map("user_id")
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([deletedAt])
  @@map("profiles")
}

// =========================
// Post
// =========================
// 게시글 본문 테이블
// 트래픽이 높은 컬럼은 cache column 형태로 유지 (view/like/reply count)
model Post {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  title     String
  content   String?
  published Boolean @default(false)

  // 집계 캐시 컬럼 (실시간 리스트/정렬 성능용)
  viewCount  Int @default(0) @map("view_count")
  likeCount  Int @default(0) @map("like_count")
  replyCount Int @default(0) @map("reply_count")

  // 작성자
  authorId Int  @map("author_id")
  author   User @relation(fields: [authorId], references: [id], onDelete: Restrict)

  // Relations
  replies   Reply[]
  likes     PostLike[]
  files     PostFile[]
  viewStats PostViewStat[]

  // createdAt + id 복합 unique → cursor pagination 안정성 확보
  @@unique([createdAt, id])
  // 주요 조회 패턴 대응 인덱스
  @@index([authorId, createdAt]) // 사용자별 게시글
  @@index([published, createdAt]) // 공개 게시글 최신순
  @@index([published, viewCount, createdAt]) // 인기글 정렬
  @@index([deletedAt])
  @@map("posts")
}

// =========================
// Reply (Post ↔ Reply)
// =========================
// 게시글 댓글 테이블
// Soft delete 지원, 작성자 제한 삭제 정책
model Reply {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  content String

  // 게시글 FK
  postId Int  @map("post_id")
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  // 작성자 FK
  authorId Int  @map("author_id")
  author   User @relation(fields: [authorId], references: [id], onDelete: Restrict)

  // 게시글별 최신 댓글 조회 최적화
  @@index([postId, createdAt])
  // 삭제 포함/제외 조회 패턴 대응
  @@index([postId, deletedAt, createdAt])
  @@index([deletedAt])
  @@map("replies")
}

// =========================
// PostLike (Explicit M:N)
// =========================
// 좋아요 M:N 관계를 명시적 테이블로 관리
// soft delete 가능 → 좋아요 취소 이력 추적 가능
model PostLike {
  userId Int @map("user_id")
  postId Int @map("post_id")

  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  // 한 사용자는 한 게시글에 한 번만 좋아요
  @@id([userId, postId])
  @@index([postId, createdAt]) // 게시글 좋아요 목록/카운트
  @@index([deletedAt])
  @@map("post_likes")
}

// =========================
// PostFile (첨부파일 메타데이터)
// =========================
// 실제 파일은 외부 스토리지, DB에는 메타데이터만 저장
// sortOrder로 사용자 업로드 순서 보장
model PostFile {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")

  fileKey     String @map("file_key") // Object Storage key
  fileName    String @map("file_name")
  contentType String @map("content_type")
  fileSize    Int    @map("file_size")

  downloadCount Int @default(0) @map("download_count")

  // 게시글 내 표시 순서
  sortOrder Int @default(0) @map("sort_order")

  postId Int  @map("post_id")
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  // 동일 게시글 내 순서 중복 방지
  @@unique([postId, sortOrder])
  // 정렬 조회 최적화
  @@index([postId, sortOrder])
  @@index([deletedAt])
  @@map("post_files")
}

// =========================
// PostViewStat (Bucket-based View History)
// =========================
// 시간 단위 집계 통계 테이블 (hour/day/month bucket)
// 대용량 누적 대비용 → Post.viewCount와 별도 관리
model PostViewStat {
  id Int @id @default(autoincrement())

  postId Int @map("post_id")

  // DB ENUM 미사용 → TEXT + 서비스 레벨 enum 매핑 권장
  // 확장 시 migration 부담 최소화
  bucketType String @map("bucket_type") // 'HOURLY' | 'DAILY' | 'MONTHLY'

  // bucket 기준 시각 (UTC 정규화 필수)
  bucketAt DateTime @map("bucket_at")

  // 해당 bucket 내 조회수 누적값
  viewCount Int @map("view_count")

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  // bucket 단위 중복 방지 + UPSERT 키
  @@unique([postId, bucketType, bucketAt])
  // 기간별 통계 조회 최적화
  @@index([bucketType, bucketAt])
  @@map("post_view_stats")
}

 

4. Fastify + Prisma 기반 계층형 모듈 아키텍처 구성 (예)

.
├─ prisma/
│  ├─ schema.prisma              # Prisma 스키마 정의
│  └─ migrations/                # 마이그레이션 히스토리
│
├─ src/
│  ├─ app.ts                     # Fastify 인스턴스 구성
│  ├─ server.ts                  # 서버 실행 엔트리포인트
│  │
│  ├─ config/                    # 환경 및 앱 설정
│  │  └─ env.ts
│  │
│  ├─ plugins/                   # Fastify 플러그인
│  │
│  ├─ common/                    # 전역 공통 모듈
│  │  ├─ errors/
│  │  ├─ utils/
│  │  └─ constants/
│  │
│  ├─ modules/                   # 도메인별 모듈
│  │  ├─ user/
│  │  │  ├─ user.route.ts
│  │  │  ├─ user.controller.ts
│  │  │  ├─ user.service.ts
│  │  │  ├─ user.repository.ts
│  │  │  └─ user.schema.ts
│  │  │
│  │  ├─ profile/
│  │  │  ├─ profile.route.ts
│  │  │  ├─ profile.service.ts
│  │  │  └─ profile.repository.ts
│  │  │
│  │  ├─ post/
│  │  │  ├─ post.route.ts
│  │  │  ├─ post.controller.ts
│  │  │  ├─ post.service.ts
│  │  │  ├─ post.repository.ts
│  │  │  ├─ post.schema.ts
│  │  │  ├─ post.types.ts
│  │  │  └─ post.view-stat.service.ts
│  │  │
│  │  ├─ reply/
│  │  │  ├─ reply.route.ts
│  │  │  ├─ reply.service.ts
│  │  │  └─ reply.repository.ts
│  │  │
│  │  ├─ like/
│  │  │  ├─ post-like.service.ts
│  │  │  └─ post-like.repository.ts
│  │  │
│  │  ├─ file/
│  │  │  ├─ post-file.service.ts
│  │  │  └─ post-file.repository.ts
│  │  │
│  │  └─ health/
│  │     └─ health.route.ts
│  │
│  └─ routes.ts                  # 모든 모듈 라우트 등록
│
├─ tests/
│  ├─ unit/                      # 단위 테스트
│  │  ├─ services/
│  │  │  ├─ post.service.spec.ts
│  │  │  └─ user.service.spec.ts
│  │  │
│  │  └─ utils/
│  │     └─ cursor.util.spec.ts
│  │
│  ├─ integration/               # 통합 테스트
│  ├─ e2e/                       # E2E 테스트
│  ├─ fixtures/                  # 테스트 픽스처
│  ├─ helpers/                   # 테스트 헬퍼
│  └─ setup.ts                   # 테스트 전역 설정
│
├─ .env                          # 로컬 환경 변수
├─ .env.test                     # 테스트 환경 변수
├─ package. 
├─ tsconfig. 
└─ vitest.config.ts

 

5. 초기화 및 운영 명령어 정리

# Node 프로젝트 초기화
npm init -y

# TypeScript + tsx 실행 환경 초기화
# tsconfig 생성
npx tsc --init

# Prisma 초기화
npx prisma init

# 최초 마이그레이션
npx prisma migrate dev --name init

# Prisma Client 생성
npx prisma generate

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형