본문 바로가기

ORM 사용

ORM이 객체와 테이블을 잇는 방식과 그 비용을 정리하고, NestJS 12에서 TypeORM 1로 엔티티와 저장소, DTO를 정의해 사용자 API를 데이터베이스에 연결한 뒤, PATCH의 생략과 null을 구별하고 마이그레이션으로 스키마를 바꾸는 순서를 다룹니다.

앞의 글들에서는 사용자를 메모리의 배열이나 Map에 저장했으므로, 서버를 다시 시작하면 데이터가 모두 사라졌습니다. 실제 서비스는 데이터를 데이터베이스에 저장하며, 관계형 데이터베이스와는 SQL로 이야기합니다. 그런데 SQL은 문자열이어서 타입스크립트의 타입 검사를 받지 못하고, 테이블의 행을 객체로 바꾸는 코드도 반복해서 써야 합니다.

ORM(Object-Relational Mapping)은 클래스와 테이블, 객체와 행을 이어 주어 SQL을 직접 쓰지 않고도 객체를 저장하고 조회하게 해 주는 도구입니다. 이 글은 NestJS 12에서 TypeORM 1로 사용자 API를 PostgreSQL에 연결하고, 수정 요청의 의미를 정한 뒤, 운영 중인 데이터베이스의 스키마를 안전하게 바꾸는 마이그레이션의 순서를 봅니다.

ORM이 하는 일과 그 비용

ORM은 클래스 하나를 테이블 하나에, 그 클래스의 속성을 컬럼에 대응시킵니다. 그러면 repository.findOneBy({ id: 1 }) 같은 메서드 호출이 SELECT 문이 되고, 결과의 행은 클래스의 인스턴스가 되어 돌아옵니다. 조건에 쓰는 속성 이름과 값의 타입이 검사되고, 외래 키로 이어진 테이블도 객체의 관계로 따라갈 수 있으며, PostgreSQL이나 MySQL처럼 데이터베이스가 달라도 기본적인 저장과 조회는 같은 API로 할 수 있습니다.

대신 ORM이 만드는 SQL은 코드에 보이지 않습니다. 관계를 따라가는 코드가 행마다 쿼리를 하나씩 더 보내거나, 필요 없는 컬럼까지 읽는 일이 생기므로, 개발 중에는 실행되는 SQL을 로그로 확인해야 합니다. 또 데이터베이스마다 컬럼 타입과 ID를 만드는 방식, 잠금, 인덱스가 다르므로, ORM을 쓴다고 데이터베이스를 아무 때나 바꿀 수 있는 것은 아닙니다.

Node.js에서는 여러 ORM이 쓰입니다. TypeORM은 데코레이터를 붙인 클래스로 엔티티(entity)를 정의하고, Prisma는 별도의 스키마 파일에서 타입이 붙은 클라이언트를 만들어 주며, Drizzle은 타입스크립트 코드로 스키마를 적고 SQL에 가까운 문법으로 쿼리합니다. 이 글은 NestJS가 전용 모듈을 제공하고 14장 2절에서 본 데코레이터와 같은 방식을 쓰는 TypeORM을 씁니다.

설치와 연결

NestJS 프레임워크처럼 nest new로 만들고 모듈 방식으로 CommonJS를 고른 프로젝트에 TypeORM과 PostgreSQL 드라이버, 검증 라이브러리를 설치합니다. @nestjs/typeorm은 NestJS와 TypeORM을 잇는 모듈이고, typeorm이 ORM 본체, pg가 PostgreSQL에 실제로 접속하는 드라이버입니다. 나머지 두 패키지는 14장 2절처럼 요청 본문을 DTO 클래스로 검사하는 데 씁니다.

npm install @nestjs/typeorm typeorm pg class-validator class-transformer

2026년 10월 현재 TypeORM은 1.1이고, TypeORM 1은 Node.js 20 이상을 요구합니다. 학습용 PostgreSQL은 컴퓨터에 직접 설치하거나, Docker가 있다면 docker run --name typeorm-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:18처럼 컨테이너로 띄웁니다. 그다음 AppModule에서 데이터베이스에 연결합니다.

src/app.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user/entities/user.entity";
import { UserModule } from "./user/user.module";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      host: process.env.DB_HOST ?? "localhost",
      port: Number(process.env.DB_PORT ?? 5432),
      username: process.env.DB_USER ?? "postgres",
      password: process.env.DB_PASSWORD ?? "postgres",
      database: process.env.DB_NAME ?? "postgres",
      entities: [User],
      synchronize: true,
      logging: true,
    }),
    UserModule,
  ],
})
export class AppModule {}

entities는 이 연결에서 쓸 엔티티 클래스의 목록이고, logging: true는 실행되는 SQL을 서버의 콘솔에 찍습니다. synchronize: true는 서버가 시작할 때 엔티티에 맞춰 테이블을 자동으로 만들고 바꾸는 설정으로, 처음 배울 때는 편하지만 컬럼을 지우거나 이름을 바꾸면 그 컬럼의 데이터가 사라질 수 있습니다. 그래서 운영 환경에서는 쓰지 않고, 이 글의 뒤에서 마이그레이션으로 바꿉니다.

연결 정보의 기본값은 학습용입니다. 실제 사용자 이름과 비밀번호는 배포 환경의 비밀 저장소나 환경 변수로 넣고, 소스 코드나 저장소에 올리는 파일에 남기지 않습니다.

엔티티 정의

엔티티는 테이블에 대응하는 클래스입니다. 클래스에 @Entity()를, 속성에 컬럼 데코레이터를 붙여 테이블의 모양을 적습니다.

src/user/entities/user.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";

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

  @Column({ type: "varchar", length: 100, unique: true })
  email!: string;

  @Column({ type: "varchar", length: 50 })
  name!: string;

  @Column({ type: "int", nullable: true })
  age!: number | null;

  @CreateDateColumn({ type: "timestamptz" })
  createdAt!: Date;
}

@PrimaryGeneratedColumn()은 데이터베이스가 1부터 차례로 만들어 주는 기본 키이고, @CreateDateColumn()은 행을 넣을 때의 시각을 자동으로 기록합니다. 속성 뒤의 !는 이 값을 생성자에서 넣지 않아도 ORM이 채운다고 컴파일러에게 알리는 확정 할당 단언입니다. email의 unique: true는 데이터베이스에 유일성 제약을 만들어, 같은 이메일을 두 번 넣으려 하면 데이터베이스가 거절하게 합니다.

컬럼마다 type을 직접 적은 데는 이유가 있습니다. TypeORM은 emitDecoratorMetadata가 남긴 속성의 타입으로 컬럼 타입을 짐작하는데, number | null 같은 유니온 타입은 메타데이터에 Object로만 남아 짐작할 수 없기 때문입니다. nullable: true인 age의 타입에 null을 넣은 것도, 데이터베이스에서 읽은 값이 실제로 null일 수 있다는 사실을 타입에 드러내기 위해서입니다.

요청 DTO와 PATCH의 의미

요청 본문은 엔티티가 아니라 별도의 DTO 클래스로 받습니다. 엔티티를 그대로 받으면 클라이언트가 id나 createdAt까지 보낼 수 있기 때문입니다.

src/user/dto/create-user.dto.ts
import { IsEmail, IsInt, IsOptional, IsString, Max, Min } from "class-validator";

export class CreateUserDto {
  @IsString()
  name!: string;

  @IsEmail()
  email!: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(120)
  age?: number | null;
}

일부 필드만 바꾸는 PATCH 요청은 필드를 보내지 않은 것과 null을 보낸 것을 구별해야 합니다. 기존 사용자가 name="Ada", age=20일 때 이 API는 다음처럼 동작하도록 정합니다.

PATCH 본문의미결과
{}아무것도 바꾸지 않음이름과 나이를 그대로 둡니다.
{"age": null}나이를 지움age에 null을 저장합니다.
{"name": null}잘못된 요청이름은 비울 수 없으므로 400으로 거절합니다.
{"role": "admin"}허용하지 않은 필드whitelist가 지우므로 아무것도 바뀌지 않습니다.

이 표를 DTO로 옮길 때 주의할 점이 있습니다. @IsOptional()은 값이 undefined일 때뿐 아니라 null일 때도 그 필드의 다른 검사를 건너뛰므로, name에 @IsOptional()을 붙이면 {"name": null}이 통과합니다. 그래서 name과 email은 값이 undefined가 아닐 때만 검사를 실행하는 @ValidateIf를 쓰고, null을 허용하는 age에만 @IsOptional()을 씁니다.

src/user/dto/update-user.dto.ts
import { IsEmail, IsInt, IsOptional, IsString, Max, Min, ValidateIf } from "class-validator";

export class UpdateUserDto {
  @ValidateIf((_object, value) => value !== undefined)
  @IsString()
  name?: string;

  @ValidateIf((_object, value) => value !== undefined)
  @IsEmail()
  email?: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(120)
  age?: number | null;
}

name이 null이면 ValidateIf의 조건이 참이 되어 @IsString()이 실행되고, null은 문자열이 아니므로 거절됩니다. 필드를 보내지 않으면 조건이 거짓이어서 검사를 건너뜁니다. 이 검사는 main.ts에서 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))로 ValidationPipe를 등록해야 실행되며, transform: true는 검증한 본문을 DTO 클래스의 인스턴스로 바꾸어 처리 메서드에 넘깁니다.

저장소와 서비스

서비스는 엔티티의 저장소(repository)를 주입받아 데이터베이스 작업을 합니다. 저장소는 그 엔티티의 테이블에 대한 저장, 조회, 삭제 메서드를 가진 객체입니다.

src/user/user.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { User } from "./entities/user.entity";

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly usersRepository: Repository<User>) {}

  create(dto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(dto);
    return this.usersRepository.save(user);
  }

  findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  findOne(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }

  async update(id: number, dto: UpdateUserDto): Promise<User | null> {
    const user = await this.usersRepository.findOneBy({ id });
    if (!user) {
      return null;
    }
    this.usersRepository.merge(user, dto);
    return this.usersRepository.save(user);
  }

  async remove(id: number): Promise<boolean> {
    const result = await this.usersRepository.delete(id);
    return (result.affected ?? 0) > 0;
  }
}

@InjectRepository(User)는 NestJS에게 User 엔티티의 저장소를 넣어 달라고 알리고, Repository<User>의 타입 인수 덕분에 findOneBy의 조건에는 User의 속성만 쓸 수 있습니다. create는 객체로 엔티티 인스턴스를 만들 뿐 저장하지 않으며, save가 행이 없으면 INSERT를, 있으면 UPDATE를 실행합니다. merge는 DTO의 값을 엔티티에 옮기고, save는 값이 undefined인 속성을 건너뛰므로 PATCH에서 보내지 않은 필드는 바뀌지 않습니다.

TypeORM 1부터는 조회 조건에 null이나 undefined가 들어오면 기본으로 오류를 던집니다. 예전에는 findOneBy({ id: undefined })가 그 조건을 조용히 무시해 첫 번째 행을 돌려주는 일이 있었기 때문이며, null인 행을 찾으려면 IsNull() 연산자를 씁니다. 이 서비스는 컨트롤러의 파이프가 검증한 숫자 id만 받으므로 그런 일이 생기지 않습니다.

모듈과 컨트롤러

기능 모듈에서 TypeOrmModule.forFeature([User])로 이 모듈이 쓸 엔티티를 등록하면, 그 모듈의 프로바이더가 User의 저장소를 주입받을 수 있습니다. 다음 두 파일 가운데 user.module.ts는 엔티티와 컨트롤러, 서비스를 한 모듈로 묶고, user.controller.ts는 HTTP 요청을 받아 서비스에 넘깁니다. 컨트롤러의 ParseIntPipe는 경로의 id 문자열을 숫자로 바꾸며, 숫자로 바꿀 수 없으면 처리 함수에 닿기 전에 400 응답을 돌려줍니다.

src/user/user.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./entities/user.entity";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
src/user/user.controller.ts
import { Body, Controller, Delete, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { User } from "./entities/user.entity";
import { UserService } from "./user.service";

@Controller("users")
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  create(@Body() dto: CreateUserDto): Promise<User> {
    return this.userService.create(dto);
  }

  @Get()
  findAll(): Promise<User[]> {
    return this.userService.findAll();
  }

  @Get(":id")
  async findOne(@Param("id", ParseIntPipe) id: number): Promise<User> {
    const user = await this.userService.findOne(id);
    if (!user) throw new NotFoundException("User not found");
    return user;
  }

  @Patch(":id")
  async update(@Param("id", ParseIntPipe) id: number, @Body() dto: UpdateUserDto): Promise<User> {
    const user = await this.userService.update(id, dto);
    if (!user) throw new NotFoundException("User not found");
    return user;
  }

  @Delete(":id")
  @HttpCode(204)
  async remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
    const removed = await this.userService.remove(id);
    if (!removed) throw new NotFoundException("User not found");
  }
}

POST /users에 {"name":"Ada","email":"[email protected]","age":20}을 보내면 데이터베이스가 만든 id와 createdAt이 채워진 사용자가 201과 함께 돌아오고, 서버의 콘솔에는 트랜잭션 안에서 실행된 INSERT INTO "user" 문이 찍힙니다. 없는 ID를 조회, 수정, 삭제하면 404가 되고, 삭제에 성공하면 본문 없이 204가 돌아옵니다. 같은 이메일로 다시 가입하면 데이터베이스의 유일성 제약이 거절하는데, 이 오류는 따로 처리하지 않았으므로 500이 되며, 실제 서비스에서는 그 오류를 409 같은 응답으로 바꾸는 처리를 더합니다.

PERSISTENCE BOUNDARY

도메인 계약과 ORM 엔터티는 같은 타입이 아니다

확장 설계: 공개 DTO·domain port·ORM adapter를 분리하면 저장 세부와 외부 계약을 각각 바꿀 수 있습니다. 현재 본문은 Repository<User>에 직접 의존하고 User 엔터티를 반환하므로 아래 전체 분리가 구현된 것은 아닙니다. 화살표는 작업 호출 흐름입니다.

도메인 계약과 ORM 엔터티는 같은 타입이 아니다 공개 DTO에서 도메인 서비스와 저장소 포트, ORM 어댑터, 데이터베이스로 이어지는 영속성 경계. PUBLIC DOMAIN PORT ADAPTER DATA 공개 DTO allowlisted shape 도메인 서비스 policy owner Repository port domain contract ORM adapter entity · query Database constraint · tx
architecture 핵심 계약
  1. 공개 DTO

    allowlisted shape

  2. 도메인 서비스

    policy owner

  3. Repository port

    domain contract

  4. ORM adapter

    entity · query

  5. Database

    constraint · tx

  • 핵심 경계
  • 검증된 흐름

이 글의 서비스는 TypeORM의 Repository<User>에 직접 의존하고, 컨트롤러는 User 엔티티를 그대로 응답합니다. 그림은 이를 넓혀, 공개 응답 DTO와 도메인 서비스, 도메인이 정한 저장소 인터페이스, 그 인터페이스를 TypeORM으로 구현한 어댑터를 나눈 설계입니다. 이렇게 나누면 저장 방식을 바꾸어도 도메인 코드와 응답의 모양이 그대로이고, 엔티티에 내부용 컬럼을 더해도 응답에 저절로 나가지 않습니다. 앞 글의 toMemberResponse 같은 변환 함수가 그 첫걸음입니다.

마이그레이션으로 스키마 바꾸기

마이그레이션(migration)은 스키마의 변경을 순서가 있는 파일로 남기고, 그 파일을 데이터베이스에 차례로 적용하는 방법입니다. 데이터베이스에는 어느 마이그레이션까지 적용했는지가 기록되므로, 개발, 테스트, 운영 환경의 스키마를 같은 순서로 맞출 수 있습니다. 마이그레이션을 쓰기 시작하면 AppModule의 synchronize를 false로 바꿉니다. 두 방식을 함께 쓰면 실제 스키마와 마이그레이션의 기록이 어긋나기 때문입니다.

TypeORM의 CLI는 연결 설정을 담은 데이터 소스 파일을 읽어 동작합니다. 타입스크립트 파일을 바로 읽도록 ts-node를 설치하고, package.json의 scripts에 CLI를 더합니다.

npm install --save-dev ts-node
package.json
{
  "scripts": {
    "typeorm": "typeorm-ts-node-commonjs"
  }
}
src/data-source.ts
import { DataSource } from "typeorm";
import { User } from "./user/entities/user.entity";

export const AppDataSource = new DataSource({
  type: "postgres",
  host: process.env.DB_HOST ?? "localhost",
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USER ?? "postgres",
  password: process.env.DB_PASSWORD ?? "postgres",
  database: process.env.DB_NAME ?? "postgres",
  entities: [User],
  migrations: ["src/migrations/*.ts"],
  synchronize: false,
});
npm run typeorm migration:generate src/migrations/CreateUser -- -d src/data-source.ts
npm run typeorm migration:run -- -d src/data-source.ts
npm run typeorm migration:revert -- -d src/data-source.ts

migration:generate는 엔티티와 연결한 데이터베이스의 실제 스키마를 비교해, 그 차이를 메우는 SQL을 담은 파일을 이름 앞에 시각 숫자를 붙여 만듭니다. migration:run은 아직 적용하지 않은 마이그레이션을 차례로 실행하고, migration:revert는 마지막 하나의 down을 실행해 되돌립니다. 앞에서 synchronize: true로 이미 테이블을 만든 데이터베이스라면 차이가 없어 만들 파일이 없다고 나오므로, 새 데이터베이스에 연결하거나 엔티티를 바꾼 뒤에 실행합니다.

만들어진 파일은 그대로 적용하지 않고 SQL을 읽어 봅니다. 예를 들어 속성의 이름을 바꾸면 ORM은 그것을 컬럼을 지우고 새로 만드는 변경으로 볼 수 있는데, 그대로 적용하면 그 컬럼의 데이터가 사라집니다.

운영 중인 스키마 바꾸기

서비스가 계속 돌아가는 중에 스키마를 바꿀 때는, 새 버전의 코드가 배포되는 동안 옛 버전의 코드도 같은 데이터베이스를 쓴다는 점을 생각해야 합니다. 그래서 한 번에 바꾸지 않고, 두 버전이 함께 동작할 수 있는 구간을 일부러 만듭니다.

안전한 스키마 변경은 호환 구간을 의도적으로 만든다 — 상태 추적

nullable 확장·구버전/신버전 공존·backfill·cutover·후속 contract에서 schema/row/버전 호환 상태가 바뀐다. 실제 수행된 migration이 아니라 운영 모델임을 표시한다.

EXPAND · MIGRATE · CONTRACT

안전한 스키마 변경은 호환 구간을 의도적으로 만든다

운영 설계: TypeORM CLI가 아래 단계를 자동 수행하지 않습니다. 호환 읽기·쓰기 구간을 만들고 제한된 backfill을 검증한 뒤 전환합니다. 오류·진행률·이전 코드 사용을 관찰하고, 이전 경로가 더 이상 필요 없을 때만 후속 릴리스에서 contract합니다.

안전한 스키마 변경은 호환 구간을 의도적으로 만든다 확장 마이그레이션부터 호환 배포, 백필, 전환, 관찰, 계약 정리까지의 안전한 스키마 변경 순서. PROCESS · 6 STAGES Expand nullable additive 호환 배포 old + new readers Backfill bounded batches Cutover new writes 관찰 errors · lag Contract later release
기존/신규 버전 공존 경계를 먼저 확장하고 backfill을 검증한 뒤 전환하며 구 경로 불필요가 확인된 후에만 축소한다.expand→coexist: 추가 변경이 선행。coexist→backfill: 공존 중 데이터 이동。backfill→fix: 데이터 확인 실패 시 전환 금지。fix→backfill: bounded rerunnable backfill。backfill→cutover: 검증 완료 뒤 전환。cutover→observe: 운영 상태를 관찰。observe→keep: 구 경로 사용 중。observe→contract: 구 사용이 없어야 제거실패통과필요불필요Expand: 추가 변경Expand: 추가 변경nullable 등 호환 유지구·신 코드 공존구 · 신 코드 공존읽기 · 쓰기 호환작은 backfill 묶음작은 backfill 묶음재실행 가능하게 검증불일치·실패불일치 ·실패수정 후재검증검증 통과: 전환검증통과:전환새 읽기경로사용오류·lag·구 경로 관찰오류 ·lag · 구경로관찰구 경로아직필요?필요: 호환 유지필요:호환유지즉시삭제하지않음불필요: 이후 Contract불필요:이후Contract다음배포 때축소
단계별 조건과 보충 설명
process 핵심 계약
  1. Expand

    기존 코드가 수용하는 추가 컬럼·테이블

  2. 호환 배포

    구·신버전 읽기와 쓰기가 함께 동작하도록 구성

  3. Backfill

    제한된 batch·재실행 가능한 보정·결과 확인

  4. Cutover

    검증 뒤 새 경로로 읽기·쓰기 전환

  5. 관찰

    오류·진행률과 이전 경로 사용을 관찰

  6. Contract

    이전 경로 제거를 별도 릴리스로 분리; 삭제된 데이터의 복구 계획도 별도 준비

  • 핵심 경계
  • 검증된 흐름

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석Expand: 기존 코드가 수용하는 추가 컬럼·테이블
원도표 조건·주석호환 배포: 구·신버전 읽기와 쓰기가 함께 동작하도록 구성
원도표 조건·주석Backfill: 제한된 batch·재실행 가능한 보정·결과 확인
원도표 조건·주석Cutover: 검증 뒤 새 경로로 읽기·쓰기 전환
원도표 조건·주석관찰: 오류·진행률과 이전 경로 사용을 관찰
원도표 조건·주석Contract: 이전 경로 제거를 별도 릴리스로 분리; 삭제된 데이터의 복구 계획도 별도 준비
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
expandschema: 기존+새 nullable / 앱: 구버전 동작 가능호환되는 nullable 구조를 먼저 추가한다운영 모델이며 실제 migration 수행은 아니다
공존과 backfill앱: 구/신버전 공존 / rows: 기존→새 값 점진 채움구/신버전과 배치 backfill의 호환 조건을 유지한다기존 데이터와 현재 코드가 모두 새 구조라고 가정하지 않는다
cutover와 contractschema: 나중 contract / 관찰: 롤백/호환 조건새 읽기로 전환하고 관찰 뒤 구 계약을 제거한다단순 이미지 롤백이 데이터 복구를 보장하지 않는다

안전한 스키마 변경은 호환 구간을 의도적으로 만든다 — 상태 추적

nullable 확장·구버전/신버전 공존·backfill·cutover·후속 contract에서 schema/row/버전 호환 상태가 바뀐다. 실제 수행된 migration이 아니라 운영 모델임을 표시한다.

EXPAND · MIGRATE · CONTRACT

안전한 스키마 변경은 호환 구간을 의도적으로 만든다

운영 설계: TypeORM CLI가 아래 단계를 자동 수행하지 않습니다. 호환 읽기·쓰기 구간을 만들고 제한된 backfill을 검증한 뒤 전환합니다. 오류·진행률·이전 코드 사용을 관찰하고, 이전 경로가 더 이상 필요 없을 때만 후속 릴리스에서 contract합니다.

안전한 스키마 변경은 호환 구간을 의도적으로 만든다 확장 마이그레이션부터 호환 배포, 백필, 전환, 관찰, 계약 정리까지의 안전한 스키마 변경 순서. PROCESS · 6 STAGES Expand nullable additive 호환 배포 old + new readers Backfill bounded batches Cutover new writes 관찰 errors · lag Contract later release
기존/신규 버전 공존 경계를 먼저 확장하고 backfill을 검증한 뒤 전환하며 구 경로 불필요가 확인된 후에만 축소한다.expand→coexist: 추가 변경이 선행。coexist→backfill: 공존 중 데이터 이동。backfill→fix: 데이터 확인 실패 시 전환 금지。fix→backfill: bounded rerunnable backfill。backfill→cutover: 검증 완료 뒤 전환。cutover→observe: 운영 상태를 관찰。observe→keep: 구 경로 사용 중。observe→contract: 구 사용이 없어야 제거실패통과필요불필요Expand: 추가 변경Expand: 추가 변경nullable 등 호환 유지구·신 코드 공존구 · 신 코드 공존읽기 · 쓰기 호환작은 backfill 묶음작은 backfill 묶음재실행 가능하게 검증불일치·실패불일치 ·실패수정 후재검증검증 통과: 전환검증통과:전환새 읽기경로사용오류·lag·구 경로 관찰오류 ·lag · 구경로관찰구 경로아직필요?필요: 호환 유지필요:호환유지즉시삭제하지않음불필요: 이후 Contract불필요:이후Contract다음배포 때축소
단계별 조건과 보충 설명
process 핵심 계약
  1. Expand

    기존 코드가 수용하는 추가 컬럼·테이블

  2. 호환 배포

    구·신버전 읽기와 쓰기가 함께 동작하도록 구성

  3. Backfill

    제한된 batch·재실행 가능한 보정·결과 확인

  4. Cutover

    검증 뒤 새 경로로 읽기·쓰기 전환

  5. 관찰

    오류·진행률과 이전 경로 사용을 관찰

  6. Contract

    이전 경로 제거를 별도 릴리스로 분리; 삭제된 데이터의 복구 계획도 별도 준비

  • 핵심 경계
  • 검증된 흐름

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석Expand: 기존 코드가 수용하는 추가 컬럼·테이블
원도표 조건·주석호환 배포: 구·신버전 읽기와 쓰기가 함께 동작하도록 구성
원도표 조건·주석Backfill: 제한된 batch·재실행 가능한 보정·결과 확인
원도표 조건·주석Cutover: 검증 뒤 새 경로로 읽기·쓰기 전환
원도표 조건·주석관찰: 오류·진행률과 이전 경로 사용을 관찰
원도표 조건·주석Contract: 이전 경로 제거를 별도 릴리스로 분리; 삭제된 데이터의 복구 계획도 별도 준비
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
expandschema: 기존+새 nullable / 앱: 구버전 동작 가능호환되는 nullable 구조를 먼저 추가한다운영 모델이며 실제 migration 수행은 아니다
공존과 backfill앱: 구/신버전 공존 / rows: 기존→새 값 점진 채움구/신버전과 배치 backfill의 호환 조건을 유지한다기존 데이터와 현재 코드가 모두 새 구조라고 가정하지 않는다
cutover와 contractschema: 나중 contract / 관찰: 롤백/호환 조건새 읽기로 전환하고 관찰 뒤 구 계약을 제거한다단순 이미지 롤백이 데이터 복구를 보장하지 않는다

name 컬럼을 displayName으로 바꾸는 경우를 예로 들면, 먼저 displayName을 비어 있어도 되는 컬럼으로 더하는 확장(expand) 마이그레이션을 적용합니다. 그다음 두 컬럼에 함께 쓰고, 읽을 때는 displayName이 비어 있으면 name을 쓰는 코드를 배포해 옛 코드와 새 코드가 함께 동작하게 합니다. 이어서 기존 행의 displayName을 작은 묶음으로 나누어 채우는 백필(backfill)을 하고, 결과를 확인한 뒤에야 읽기를 새 컬럼으로 전환합니다.

전환한 뒤에는 오류와 지연, 옛 컬럼을 읽는 코드가 남아 있는지를 지켜봅니다. 옛 컬럼을 쓰는 곳이 더는 없다고 확인되면, 다음 배포에서 name을 지우는 축소(contract) 마이그레이션을 따로 적용합니다. 그림처럼 이 과정은 TypeORM의 CLI가 대신해 주는 것이 아니라 팀이 계획하는 절차이며, 지운 컬럼의 데이터는 down이 컬럼을 다시 만들어도 돌아오지 않으므로 삭제 전에 복구 방법을 따로 준비합니다.

정리

  • ORM은 클래스와 테이블, 객체와 행을 이어 타입이 검사되는 API로 데이터베이스를 다루게 하지만, 실행되는 SQL은 로그로 확인해야 합니다.
  • TypeORM의 엔티티는 데코레이터로 테이블의 모양을 적으며, 유니온 타입의 속성은 메타데이터로 짐작할 수 없으므로 컬럼 타입을 직접 적습니다.
  • 요청 본문은 엔티티가 아닌 DTO로 받고, PATCH에서 보내지 않은 필드와 null을 보낸 필드를 구별해 검증합니다.
  • @IsOptional()은 null일 때도 검사를 건너뛰므로, 비울 수 없는 필드에는 undefined일 때만 건너뛰는 @ValidateIf를 씁니다.
  • TypeORM 1은 조회 조건의 null과 undefined를 기본으로 오류로 처리하며, save는 값이 undefined인 속성을 건너뜁니다.
  • 운영에서는 synchronize를 끄고 마이그레이션을 쓰며, 스키마는 확장, 공존, 백필, 전환, 관찰, 축소의 순서로 나누어 바꿉니다.

연습 문제

1. PATCH의 세 가지 본문

기존 사용자가 name="Ada", age=20입니다. {}, {"age": null}, {"name": null}을 각각 PATCH로 보내면 어떻게 되나요? UpdateUserDto의 name에 @IsOptional()을 썼다면 세 번째 요청은 어떻게 되나요?

해설 보기

{}는 바꿀 값이 없어 이름과 나이가 그대로이고, {"age": null}은 age를 null로 저장합니다. {"name": null}은 ValidateIf의 조건이 참이 되어 @IsString()에 걸리므로 400으로 거절됩니다. @IsOptional()을 썼다면 null에서 검사를 건너뛰어 요청이 통과하고, save가 name에 null을 넣으려 하다가 비어 있으면 안 되는 컬럼의 제약에 걸려 데이터베이스 오류가 납니다.

2. 없는 사용자의 수정과 삭제

ID 99인 사용자가 없을 때 PATCH /users/99를 보내면 서비스는 merge와 save를 몇 번 부르나요? DELETE /users/99는 어떤 응답을 받나요?

해설 보기

findOneBy가 null을 돌려주므로 서비스는 바로 null을 돌려주고, merge와 save는 한 번도 불리지 않습니다. 컨트롤러가 NotFoundException을 던져 404가 됩니다. 삭제는 지운 행이 없어 affected가 0이므로 remove가 false를 돌려주고, 역시 404가 됩니다. 지운 행이 있으면 본문 없이 204가 돌아옵니다.

3. 만들어지지 않는 마이그레이션

synchronize: true로 서버를 여러 번 실행한 데이터베이스에 연결해 migration:generate를 실행했더니 만들 변경이 없다고 나왔습니다. 왜 그런가요? 운영에서 컬럼을 지운 마이그레이션을 migration:revert로 되돌리면 데이터도 돌아오나요?

해설 보기

migration:generate는 엔티티와 연결한 데이터베이스의 현재 스키마를 비교하는데, synchronize가 이미 엔티티에 맞춰 테이블을 만들어 두어 차이가 없기 때문입니다. 새 데이터베이스에 연결해 처음 마이그레이션을 만들고, 그 뒤로는 synchronize를 끈 채 마이그레이션으로만 바꿉니다. 지운 컬럼은 down이 다시 만들어도 빈 컬럼일 뿐 데이터는 돌아오지 않으므로, 축소 단계 전에 백업과 복구 방법을 준비해야 합니다.

이것으로 14장의 백엔드 개발을 마칩니다. 다음 장의 Jest를 사용한 단위 테스트에서는 서버와 라이브러리의 코드를 Jest로 테스트하고, 바깥 의존성을 모의 함수로 바꾸어 검사하는 방법을 다룹니다.