본문 바로가기
나의 개발/데이터베이스

DB 스키마와 ERD를 설계·확장·운영하려면 어떻게 해야 할까? (NestJS + TypeORM)

by Parkej 2025. 12. 20.

이 글은 “설계 철학”을 말로만 끝내지 않고, NestJS + TypeORM에서 실제로 어떻게 구현되는지까지 예시로 정리한다.

  • N:M을 연결 엔티티로 푸는 방법
  • soft delete + UNIQUE 처리
  • 인덱스 기준과 실제 @Index 사용
  • dual-write 트랜잭션
  • 대량 백필 배치(청크 처리) + 정합성 검증 쿼리

DB는 저장소가 아니라 “데이터 규칙을 강제하는 마지막 방어선”이다.
그래서 설계/마이그레이션/검증은 코드로 재현 가능해야 한다.


1) N:M 관계를 연결 엔티티로 설계하려면 어떻게 해야 할까?

예시: User ↔ Post 좋아요(Like)

1) 엔티티 구조

  • User 1:N PostLike
  • Post 1:N PostLike
 
// user.entity.ts
import { Entity, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { PostLike } from './post-like.entity';

@Entity('user')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @OneToMany(() => PostLike, (like) => like.user)
  likes: PostLike[];
}

 

// post.entity.ts
import { Entity, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { PostLike } from './post-like.entity';

@Entity('post')
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @OneToMany(() => PostLike, (like) => like.post)
  likes: PostLike[];
}

 

// post-like.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  ManyToOne,
  CreateDateColumn,
  DeleteDateColumn,
  Index,
  Unique,
  JoinColumn,
  Column,
} from 'typeorm';
import { User } from './user.entity';
import { Post } from './post.entity';

@Entity('post_like')
@Index('idx_post_like_post_created', ['postId', 'createdAt'])
@Index('idx_post_like_user_created', ['userId', 'createdAt'])
// 중복 좋아요 방지 (soft delete 전략에 따라 아래는 달라질 수 있음)
@Unique('uq_post_like_user_post', ['userId', 'postId'])
export class PostLike {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  userId: number;

  @Column()
  postId: number;

  @ManyToOne(() => User, (u) => u.likes, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'userId' })
  user: User;

  @ManyToOne(() => Post, (p) => p.likes, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'postId' })
  post: Post;

  @CreateDateColumn()
  createdAt: Date;

  @DeleteDateColumn({ nullable: true })
  deletedAt: Date | null;
}

연결 엔티티를 쓰면 createdAt, deletedAt, status, role, sortOrder 같은 관계 속성을 자연스럽게 추가할 수 있다.


2) 연결 엔티티 인덱스는 어떻게 잡아야 할까?

기준은 딱 3개다.

  1. 중복 방지(무결성): UNIQUE
  2. 조회 패턴: WHERE + ORDER BY + LIMIT
  3. JOIN 키: FK 컬럼 인덱스

좋아요 예시 인덱스 전략

  • 유저가 누른 좋아요 목록: WHERE user_id = ? ORDER BY created_at DESC LIMIT 20
    → (user_id, created_at)
  • 게시글의 좋아요 목록: WHERE post_id = ? ORDER BY created_at DESC LIMIT 20
    → (post_id, created_at)
  • 중복 방지: UNIQUE(user_id, post_id)

TypeORM에서는 위처럼 @Index, @Unique로 표현하거나, 운영에서는 migration에서 명시적으로 잡는 경우도 많다.


3) TypeORM에서 이 구조를 어떻게 조회해야 할까?

A. “유저가 좋아요한 게시글 목록”

PostLike를 기준으로 join해서 필요한 것만 가져온다.

 
// post-like.service.ts
async getLikedPosts(userId: number, take = 20) {
  return this.postLikeRepo
    .createQueryBuilder('pl')
    .innerJoinAndSelect('pl.post', 'p')
    .where('pl.userId = :userId', { userId })
    .andWhere('pl.deletedAt IS NULL')
    .orderBy('pl.createdAt', 'DESC')
    .take(take)
    .getMany();
}

B. “게시글 좋아요 수”를 목록에 붙이기 (N+1 피하기)

게시글마다 count를 따로 조회하지 말고, GROUP BY로 한 번에 가져온다.

 
async getLikeCounts(postIds: number[]) {
  const rows = await this.postLikeRepo
    .createQueryBuilder('pl')
    .select('pl.postId', 'postId')
    .addSelect('COUNT(*)', 'cnt')
    .where('pl.postId IN (:...postIds)', { postIds })
    .andWhere('pl.deletedAt IS NULL')
    .groupBy('pl.postId')
    .getRawMany<{ postId: number; cnt: string }>();

  const map = new Map<number, number>();
  for (const r of rows) map.set(Number(r.postId), Number(r.cnt));
  return map;
}

4) soft delete를 쓰면 UNIQUE(user_id, post_id)은 어떻게 해야 할까?

문제

soft delete는 row가 남는다. 그래서 단순 UNIQUE면 “삭제 후 재생성”이 막힐 수 있다.

선택지 1) DB가 지원하면 “활성 데이터만 유니크”로 강제 (추천)

PostgreSQL이면 partial unique index가 가장 깔끔하다.

 
-- PostgreSQL 예시
CREATE UNIQUE INDEX uq_post_like_active
ON post_like(user_id, post_id)
WHERE deleted_at IS NULL;

선택지 2) MySQL 등에서 타협하기

  • 정책에 따라:
    • “soft delete는 사실상 취소”라면 → 기존 row를 복구(update)하는 방식으로 처리
    • “삭제 후 재생성”을 허용하려면 → 별도 정책/제약 설계 필요

실무에서 자주 쓰는 타협: “재좋아요 = 복구”

 
async likePost(userId: number, postId: number) {
  const existing = await this.postLikeRepo.findOne({
    where: { userId, postId },
    withDeleted: true,
  });

  if (!existing) {
    await this.postLikeRepo.insert({ userId, postId });
    return;
  }

  if (existing.deletedAt) {
    // 복구: soft delete 취소
    await this.postLikeRepo.restore(existing.id);
  }
}

면접/실무에서 “DB가 허용하는 한 DB 제약으로 무결성을 보장한다”는 말은
“가능하면 partial unique index 같은 방식으로 활성 데이터 규칙을 DB가 강제하게 한다”는 의미다.


5) 유저가 이미지를 여러 장 가질 때로 바뀌면 마이그레이션은 어떻게 옮길까?

예시 전제:

  • 기존: user.profileImageId (1:1 또는 0..1)
  • 변경: 유저가 여러 장 + 역할 필요 → user_image 연결 엔티티 도입

1) 신규 테이블 추가 (Expand)

 
// s3-image.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';

@Entity('s3_image')
@Index('uq_s3_key', ['s3Key'], { unique: true })
export class S3Image {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 512 })
  s3Key: string;

  @Column({ length: 1024 })
  url: string;

  @CreateDateColumn()
  createdAt: Date;
}
 
// user-image.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
  Index,
  Unique,
} from 'typeorm';
import { User } from './user.entity';
import { S3Image } from './s3-image.entity';

export enum UserImageRole {
  PROFILE = 'PROFILE',
  GALLERY = 'GALLERY',
}

@Entity('user_image')
@Unique('uq_user_image_role', ['userId', 'role']) // "프로필 이미지는 1개" 정책 예시
@Index('idx_user_image_user_created', ['userId', 'createdAt'])
export class UserImage {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  userId: number;

  @Column()
  imageId: number;

  @Column({ type: 'varchar', length: 32 })
  role: UserImageRole;

  @Column({ type: 'int', default: 0 })
  sortOrder: number;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'userId' })
  user: User;

  @ManyToOne(() => S3Image, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'imageId' })
  image: S3Image;

  @CreateDateColumn()
  createdAt: Date;
}

6) dual-write를 TypeORM에서 어떻게 구현할까? (트랜잭션 예시)

전환 기간에는:

  • user.profileImageId도 갱신
  • user_image도 갱신

NestJS Service 예시 (transaction 사용)

 
import { DataSource } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { UserImageRole, UserImage } from './user-image.entity';
import { User } from './user.entity';

@Injectable()
export class ProfileImageService {
  constructor(private readonly dataSource: DataSource) {}

  async changeProfileImage(userId: number, imageId: number) {
    await this.dataSource.transaction(async (manager) => {
      // 1) 기존 컬럼 업데이트 (old path)
      await manager.update(User, { id: userId }, { profileImageId: imageId });

      // 2) 신규 연결 테이블 upsert (new path)
      // TypeORM의 upsert는 DB별 지원이 다를 수 있어, 운영환경에 맞게 선택
      await manager.upsert(
        UserImage,
        {
          userId,
          imageId,
          role: UserImageRole.PROFILE,
          sortOrder: 0,
        },
        {
          conflictPaths: ['userId', 'role'], // uq_user_image_role과 맞춰야 함
        },
      );
    });
  }
}

핵심은 “둘 중 하나만 저장되는 상태”를 줄이기 위해
같은 트랜잭션으로 묶어서 원자적으로 처리하는 것.


7) 대규모 백필은 배치로 어떻게 처리할까? (청크 + 멱등성)

왜 “트랜잭션을 짧게 유지”해야 하나?

  • 한 번에 100만 row를 한 트랜잭션으로 처리하면 락/롤백 비용이 너무 커진다.
  • 보통은 N건씩 처리하고 자주 커밋한다.

백필 배치 예시 (cursor 기반, 청크 처리)

 
import { DataSource } from 'typeorm';
import { User } from './user.entity';
import { UserImage, UserImageRole } from './user-image.entity';

export class BackfillUserProfileImageJob {
  constructor(private readonly dataSource: DataSource) {}

  async run(batchSize = 1000) {
    let lastId = 0;

    while (true) {
      const users = await this.dataSource.getRepository(User)
        .createQueryBuilder('u')
        .select(['u.id', 'u.profileImageId'])
        .where('u.id > :lastId', { lastId })
        .andWhere('u.profileImageId IS NOT NULL')
        .orderBy('u.id', 'ASC')
        .take(batchSize)
        .getMany();

      if (users.length === 0) break;

      await this.dataSource.transaction(async (manager) => {
        // 멱등성: 중복 insert 방지 위해 unique + upsert
        const rows = users.map((u) => ({
          userId: u.id,
          imageId: u.profileImageId!,
          role: UserImageRole.PROFILE,
          sortOrder: 0,
        }));

        await manager.upsert(UserImage, rows, {
          conflictPaths: ['userId', 'role'],
        });
      });

      lastId = users[users.length - 1].id;
    }
  }
}

배치에서의 “짧게 유지” = 청크 단위 커밋 + 멱등성(upsert)


8) 이관 후 정합성 검증은 어떻게 할까? (쿼리 예시)

1) “옮겨야 하는 유저 수” vs “옮겨진 row 수”

-- 옮겨야 하는 유저 수
SELECT COUNT(*)
FROM user
WHERE profile_image_id IS NOT NULL;

-- user_image에 PROFILE로 옮겨진 수
SELECT COUNT(*)
FROM user_image
WHERE role = 'PROFILE';

2) 불일치 탐지

-- 옮겨야 하는 유저 수
SELECT COUNT(*)
FROM user
WHERE profile_image_id IS NOT NULL;

-- user_image에 PROFILE로 옮겨진 수
SELECT COUNT(*)
FROM user_image
WHERE role = 'PROFILE';

3) “fallback read”가 0이 되는지 모니터링(개념)

  • 신규 테이블 조회 결과가 없는 케이스를 카운트해서 점점 0으로 수렴하는지 확인
  • 운영에서는 로그/메트릭으로 잡는 편

9) 인덱스는 언제 추가하고, 언제 오히려 느려질까?

인덱스 추가 기준 (실제 쿼리 패턴)

  • WHERE 조건
  • JOIN 조건
  • ORDER BY + LIMIT (특히 목록 조회)
  • FK 컬럼은 join/필터에 자주 쓰이면 인덱스 고려

인덱스가 느려질 수 있는 경우

  • 조회에 거의 쓰이지 않는 컬럼에 인덱스를 걸면 → 쓰기 비용만 증가
  • insert/update/delete가 매우 많은 테이블(로그 등)에서 인덱스 남발 → 쓰기 성능 급락
  • 카디널리티가 낮은 컬럼 단독 인덱스 → 옵티마이저가 안 쓰거나 효과 낮음

마무리: “정답 스키마”가 아니라 “변경을 버티는 방법”

  • 처음부터 모든 걸 확장형으로 만들기보다
  • 요구사항에 맞게 단순하게 시작하고
  • 확장 요구가 생기면
    • 연결 엔티티 도입
    • 단계적 마이그레이션(Expand → Migrate → Contract)
    • dual-write + 백필 + 검증
      으로 안전하게 옮기는 게 실무적으로 가장 많이 쓰이는 흐름이다.
반응형

댓글