이 글은 “설계 철학”을 말로만 끝내지 않고, 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개다.
- 중복 방지(무결성): UNIQUE
- 조회 패턴: WHERE + ORDER BY + LIMIT
- 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 + 백필 + 검증
으로 안전하게 옮기는 게 실무적으로 가장 많이 쓰이는 흐름이다.
반응형
'나의 개발 > 데이터베이스' 카테고리의 다른 글
| 트랜잭션 읽기와 격리 수준 (Isolation Level) 정리 (3) | 2025.12.29 |
|---|---|
| 관계형 데이터베이스(RDB)란 무엇인가? (0) | 2025.12.29 |
| 인덱스 클러스터링 팩터란? (0) | 2025.12.26 |
| 클러스터드 인덱스란? (3) | 2025.12.26 |
| DB 스키마와 ERD를 설계·확장·운영하려면 어떻게 해야 할까? (요약) (0) | 2025.12.20 |
댓글