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-practice-lab/        (공통 설정 레벨)
 ├─ package. 
 ├─ node_modules/
 ├─ eslint.config.js
 ├─ .prettierrc
 ├─ tsconfig. 
 └─ fastify-api-rest/         (실제 API 서버)
     ├─ package. 
     ├─ tsconfig. 
     ├─ vite.config.ts
     ├─ prisma.config.ts
     ├─ prisma/
     ├─ src/
     └─ tests/

 

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

# 루트 프로젝트 생성
cd /nodejs-practice-lab
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-practice-lab> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-practice-lab
├── @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-practice-lab/fastify-api-rest
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-practice-lab\fastify-api-rest> npm list
nodejs-practice-lab@1.0.0 D:\NodejsDevelope\workspace\nodejs-practice-lab
└─┬ fastify-api@1.0.0 -> .\fastify-api-rest
  ├── @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[]
  postFiles PostFile[]

  // 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  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)
// Post 삭제는 물리적 삭제이다.
model Post {
  id          Int       @id @default(autoincrement())
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at") // 자동 타임스탬프 관리 데코레이터
  publishedAt DateTime? @map("published_at") // 게시글 공개 시점 (published=true인 경우 null 불가)

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

  // 인덱스 설계
  // LIKE '%keyword%' 쿼리는 인덱스들을 못 타고 Full Table Scan을 유발-> 개선 필요
  // 1. 내 글 목록: 작성자별 최신순 (ID가 최신성을 보장하므로 createdAt 제외)
  @@index([authorId, id(sort: Desc)])
  // 2. 전체 목록: 공개 여부별 최신순
  // 비공개 글이 적더라도 [published, id] 인덱스가 있으면,
  // DB는 정렬된 데이터를 얻기 위해 인덱스를 활용할 수 있음
  @@index([published, id(sort: Desc)])
  // 3. 수치 기반 인기순: (공개글 중) 인기순 + 최신성 보장(id)
  //    복합 정렬 (range + order에서는 desc 명시가 유리
  @@index([published, viewCount(sort: Desc), id(sort: Desc)])
  @@index([published, likeCount(sort: Desc), id(sort: Desc)])
  @@index([published, replyCount(sort: Desc), id(sort: Desc)])
  // 4. 날짜 범위 검색용 (날짜 필터가 빈번할 경우만 유지, 정렬용 아님)
  // 정렬은 여전히 id(sort: Desc)를 활용할 수 있음
  @@index([createdAt])
  @@map("posts")
}

// =========================
// Reply (Post ↔ Reply)
// =========================
// 게시글 댓글 테이블
model Reply {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_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)

  // 핵심: 안정적 정렬 (cursor pagination)
  @@index([postId, id(sort: Desc)])
  @@index([authorId, id(sort: Desc)])
  @@map("replies")
}

// =========================
// PostLike (Explicit M:N)
// =========================
// 좋아요 M:N 관계를 명시적 테이블로 관리
model PostLike {
  userId Int @map("user_id")
  postId Int @map("post_id")

  createdAt DateTime @default(now()) @map("created_at")

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

  @@id([userId, postId]) // 중복 좋아요 금지
  // 최신순 조회(DESC)가 압도적으로 많다면 아래 구성이 유리합니다.
  // 1. 유저가 누른 좋아요 목록 (최신순)
  @@index([userId, createdAt(sort: Desc), postId(sort: Desc)])
  // 2. 게시글에 달린 좋아요 목록 (최신순)
  @@index([postId, createdAt(sort: Desc), userId(sort: Desc)])
  @@map("post_likes")
}

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

  fileKey     String @unique @map("file_key") // 데이터 정합성 확보 위해 고유 제약조건
  fileName    String @map("file_name") // 원본 파일명
  contentType String @map("content_type") // MIME 타입 (예: image/png)
  fileSize    BigInt @map("file_size") // 파일 크기 (바이트)

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

  // 파일 업로드 후 나중에 글 연결하기 위해 nullable로 지정
  postId Int?  @map("post_id")
  post   Post? @relation(fields: [postId], references: [id], onDelete: Cascade)

  // User relation (required 추천)
  userId Int  @map("user_id")
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@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
│  │
│  └─ 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 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형