junyeokk
Blog
Database·2024. 11. 07

TypeORM

백엔드에서 데이터베이스를 다룰 때 SQL을 직접 작성하는 건 가장 직관적인 방법이다. 하지만 프로젝트가 커지면서 문제가 생긴다. SQL 문자열이 코드 곳곳에 흩어지고, 테이블 구조가 바뀌면 관련된 쿼리를 전부 찾아서 수정해야 한다. 타입 안전성도 없어서 컬럼명 오타 같은 실수를 런타임에서야 발견하게 된다.

ORM(Object-Relational Mapping)은 데이터베이스 테이블을 클래스로, 행(row)을 객체로 매핑해서 SQL 대신 코드로 데이터를 다루게 해준다. TypeORM은 TypeScript에 특화된 ORM으로, 데코레이터 기반 엔티티 정의와 타입 추론을 지원한다. NestJS와의 통합이 잘 되어 있어서 NestJS 프로젝트에서 가장 많이 사용되는 ORM 중 하나다.


핵심 개념

TypeORM은 크게 Entity, Repository, DataSource 세 가지 개념을 중심으로 동작한다.

  • Entity: 데이터베이스 테이블과 1:1로 매핑되는 TypeScript 클래스
  • Repository: 특정 엔티티에 대한 CRUD 연산을 담당하는 객체
  • DataSource: 데이터베이스 연결 설정과 연결 풀을 관리하는 진입점

이 세 가지만 이해하면 TypeORM의 대부분의 기능을 사용할 수 있다.


Entity 정의

엔티티는 @Entity() 데코레이터를 클래스에 붙여서 정의한다. 각 프로퍼티에 @Column() 데코레이터를 붙이면 테이블 컬럼이 된다.

typescript
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";

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

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

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

  @Column({ type: "int", default: 0 })
  loginCount: number;

  @CreateDateColumn()
  createdAt: Date;
}

@PrimaryGeneratedColumn()은 자동 증가하는 기본 키를 만든다. UUID가 필요하면 @PrimaryGeneratedColumn("uuid")로 바꾸면 된다.

컬럼 옵션

@Column() 데코레이터에는 다양한 옵션을 줄 수 있다.

옵션설명예시
typeDB 컬럼 타입"varchar", "int", "text", "boolean"
length문자열 길이255
nullableNULL 허용true / false (기본: false)
default기본값0, "active"
unique유니크 제약true
selectSELECT 시 포함 여부false면 기본 조회에서 제외
enumenum 타입 지정enum: UserRole

select: false는 비밀번호 같은 민감한 컬럼에 유용하다. 기본 조회에서 제외되고, 필요할 때 addSelect()로 명시적으로 가져올 수 있다.

typescript
@Column({ select: false })
password: string;

특수 컬럼 데코레이터

직접 관리하지 않아도 자동으로 값이 채워지는 특수 컬럼들이 있다.

typescript
@CreateDateColumn()  // INSERT 시 자동으로 현재 시간
createdAt: Date;

@UpdateDateColumn()  // UPDATE 시 자동으로 현재 시간
updatedAt: Date;

@DeleteDateColumn()  // soft delete 시 삭제 시간 기록
deletedAt: Date;

@VersionColumn()     // 낙관적 잠금용 버전 번호
version: number;

@DeleteDateColumn()이 있는 엔티티는 remove() 대신 softRemove()를 쓰면 실제로 삭제하지 않고 deletedAt에 시간만 기록한다. 이후 조회할 때 이 행은 자동으로 제외된다.


관계 (Relations)

테이블 간의 관계를 데코레이터로 표현할 수 있다. 가장 많이 쓰는 세 가지를 살펴보자.

@ManyToOne / @OneToMany

가장 흔한 관계다. 예를 들어 하나의 게시글(Post)이 하나의 사용자(User)에 속하고, 사용자는 여러 게시글을 가질 수 있다.

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

  @Column()
  title: string;

  @ManyToOne(() => User, (user) => user.posts)
  author: User;

  @Column()
  authorId: number; // FK 컬럼을 명시적으로 선언하면 조인 없이 ID만 쓸 수 있다
}

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

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}

@ManyToOne 쪽에 외래 키가 생긴다. authorId를 명시적으로 선언해두면 관계를 조인하지 않고도 post.authorId로 FK 값에 바로 접근할 수 있어서 편리하다.

@ManyToMany

다대다 관계는 중간 테이블이 자동으로 생성된다.

typescript
@Entity()
export class Article {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToMany(() => Tag, (tag) => tag.articles)
  @JoinTable() // 한쪽에만 붙인다 — 중간 테이블의 소유자
  tags: Tag[];
}

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

  @Column()
  name: string;

  @ManyToMany(() => Article, (article) => article.tags)
  articles: Article[];
}

@JoinTable()은 반드시 한쪽에만 붙인다. 이 데코레이터가 붙은 쪽이 관계의 소유자(owner)로, 중간 테이블의 이름이나 컬럼명을 커스터마이징할 수 있다.

관계 로딩: eager vs lazy

기본적으로 관계 데이터는 자동으로 로딩되지 않는다. 세 가지 방법으로 가져올 수 있다.

typescript
// 1. find 옵션에서 relations 지정
const post = await postRepository.findOne({
  where: { id: 1 },
  relations: ["author", "tags"],
});

// 2. QueryBuilder에서 조인
const post = await postRepository
  .createQueryBuilder("post")
  .leftJoinAndSelect("post.author", "author")
  .where("post.id = :id", { id: 1 })
  .getOne();

// 3. Entity에서 eager: true 설정 (항상 로딩)
@ManyToOne(() => User, { eager: true })
author: User;

eager: true는 편하지만 위험하다. 모든 조회에서 관계가 조인되기 때문에 불필요한 쿼리가 발생할 수 있다. 대부분의 경우 relations 옵션이나 QueryBuilder로 필요할 때만 로딩하는 게 낫다.


Repository 패턴

TypeORM의 Repository는 특정 엔티티에 대한 데이터베이스 연산을 캡슐화한다. NestJS에서는 @InjectRepository()로 주입받아 사용한다.

typescript
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";

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

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

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

  create(name: string, email: string): Promise<User> {
    const user = this.userRepository.create({ name, email });
    return this.userRepository.save(user);
  }

  async remove(id: number): Promise<void> {
    await this.userRepository.delete(id);
  }
}

create() vs save()

이 두 메서드의 차이를 이해하는 게 중요하다.

  • create(): 엔티티 인스턴스를 메모리에 생성만 한다. DB에 저장하지 않는다.
  • save(): 엔티티를 실제 DB에 저장한다. 새 엔티티면 INSERT, 기존 엔티티면 UPDATE를 실행한다.
typescript
// 이렇게 하면 일반 객체가 되어서 엔티티 메서드를 못 쓴다
const user = { name: "Alice", email: "alice@example.com" };

// create()로 만들면 엔티티 인스턴스가 되어 메서드 사용 가능
const user = this.userRepository.create({ name: "Alice", email: "alice@example.com" });

// DB에 저장
await this.userRepository.save(user);

save()에 배열을 넘기면 배치 저장도 가능하다. 내부적으로 트랜잭션으로 묶어서 처리한다.

find 옵션

find() 메서드에는 다양한 조건을 줄 수 있다.

typescript
// 기본 조건
const users = await this.userRepository.find({
  where: { name: "Alice" },
  order: { createdAt: "DESC" },
  skip: 0,
  take: 10,
});

// 복합 조건 (OR)
import { In, MoreThan, Like } from "typeorm";

const users = await this.userRepository.find({
  where: [
    { name: Like("%alice%") },
    { loginCount: MoreThan(10) },
  ],
});

// 특정 컬럼만 선택
const users = await this.userRepository.find({
  select: ["id", "name"],
  where: { loginCount: MoreThan(0) },
});

where에 배열을 넘기면 OR 조건이 된다. AND 조건은 하나의 객체 안에 여러 필드를 넣으면 된다. TypeORM은 In, Between, MoreThan, LessThan, Like, IsNull 같은 다양한 비교 연산자를 제공한다.


QueryBuilder

find 옵션으로 표현하기 어려운 복잡한 쿼리는 QueryBuilder로 작성한다. SQL과 비슷한 체이닝 API를 제공하면서도 타입 안전성을 유지한다.

typescript
const posts = await this.postRepository
  .createQueryBuilder("post")
  .leftJoinAndSelect("post.author", "author")
  .leftJoinAndSelect("post.tags", "tag")
  .where("author.id = :authorId", { authorId: 1 })
  .andWhere("tag.name IN (:...tagNames)", { tagNames: ["typescript", "nestjs"] })
  .orderBy("post.createdAt", "DESC")
  .skip(0)
  .take(20)
  .getMany();

파라미터 바인딩(:authorId, :...tagNames)을 사용하면 SQL 인젝션을 방지할 수 있다. :... 구문은 배열을 IN 절로 확장해준다.

서브쿼리

typescript
const posts = await this.postRepository
  .createQueryBuilder("post")
  .where((qb) => {
    const subQuery = qb
      .subQuery()
      .select("tag_post.postId")
      .from("tag_post", "tag_post")
      .where("tag_post.tagId = :tagId")
      .getQuery();
    return `post.id IN ${subQuery}`;
  })
  .setParameter("tagId", 5)
  .getMany();

Raw SQL

정말 복잡한 쿼리는 raw SQL로 실행할 수도 있다. 하지만 타입 안전성을 잃게 되므로 최후의 수단으로만 사용하자.

typescript
const result = await this.dataSource.query(
  `SELECT u.id, u.name, COUNT(p.id) as postCount
   FROM user u
   LEFT JOIN post p ON p.authorId = u.id
   GROUP BY u.id
   HAVING postCount > $1`,
  [5],
);

NestJS 통합

NestJS에서 TypeORM을 사용하려면 @nestjs/typeorm 패키지를 설치하고, TypeOrmModule을 등록한다.

루트 모듈 설정

typescript
import { TypeOrmModule } from "@nestjs/typeorm";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "mysql",
      host: "localhost",
      port: 3306,
      username: "root",
      password: "password",
      database: "my_database",
      entities: [User, Post, Tag],
      synchronize: false, // 프로덕션에서는 반드시 false
    }),
  ],
})
export class AppModule {}

synchronize: true로 설정하면 엔티티 변경 사항이 자동으로 DB 스키마에 반영된다. 개발 중에는 편리하지만, 프로덕션에서는 데이터가 날아갈 수 있으므로 절대 사용하면 안 된다. 프로덕션에서는 마이그레이션을 사용해야 한다.

피처 모듈에서 엔티티 등록

각 피처 모듈에서 사용할 엔티티를 forFeature()로 등록한다.

typescript
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService],
  controllers: [UserController],
})
export class UserModule {}

이렇게 등록하면 해당 모듈 내에서 @InjectRepository(User)로 Repository를 주입받을 수 있다.

비동기 설정

환경 변수나 ConfigService에서 설정값을 가져와야 하는 경우 forRootAsync()를 사용한다.

typescript
TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: "mysql",
    host: config.get("DB_HOST"),
    port: config.get<number>("DB_PORT"),
    username: config.get("DB_USERNAME"),
    password: config.get("DB_PASSWORD"),
    database: config.get("DB_NAME"),
    entities: [__dirname + "/**/*.entity{.ts,.js}"],
    synchronize: false,
  }),
}),

entities 배열에 글로브 패턴을 사용하면 엔티티를 하나하나 임포트하지 않아도 자동으로 로드된다. 단, 이 방식은 entities 배열에 직접 클래스를 넣는 것보다 디버깅이 어려울 수 있다.


트랜잭션

여러 DB 연산을 하나의 트랜잭션으로 묶어야 하는 경우가 자주 있다. 사용자 생성과 동시에 기본 프로필을 만드는 경우, 둘 중 하나가 실패하면 전부 롤백해야 한다.

DataSource 트랜잭션

typescript
@Injectable()
export class UserService {
  constructor(private dataSource: DataSource) {}

  async createWithProfile(data: CreateUserDto): Promise<User> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const user = queryRunner.manager.create(User, {
        name: data.name,
        email: data.email,
      });
      await queryRunner.manager.save(user);

      const profile = queryRunner.manager.create(Profile, {
        userId: user.id,
        bio: data.bio,
      });
      await queryRunner.manager.save(profile);

      await queryRunner.commitTransaction();
      return user;
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

queryRunner를 사용하는 방식이 가장 명시적이고 제어가 쉽다. connect()startTransaction() → 작업 → commitTransaction() or rollbackTransaction()release() 순서를 따른다. finally에서 반드시 release()를 호출해야 연결이 풀로 반환된다.

transaction() 메서드

더 간결한 방법도 있다.

typescript
await this.dataSource.transaction(async (manager) => {
  const user = manager.create(User, { name: "Alice", email: "alice@example.com" });
  await manager.save(user);

  const profile = manager.create(Profile, { userId: user.id, bio: "Hello" });
  await manager.save(profile);
});
// 콜백이 정상 종료되면 자동 커밋, 에러가 발생하면 자동 롤백

콜백 함수가 정상적으로 끝나면 자동으로 커밋되고, 에러가 throw되면 자동으로 롤백된다. QueryRunner보다 간결하지만, 트랜잭션 격리 레벨 설정 같은 세밀한 제어가 필요하면 QueryRunner를 써야 한다.


Entity Subscriber

엔티티에 특정 이벤트가 발생할 때 자동으로 실행되는 로직을 정의할 수 있다. 예를 들어 사용자가 생성되기 전에 비밀번호를 해싱하거나, 삭제 후 관련 캐시를 무효화하는 등의 작업에 활용된다.

typescript
import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from "typeorm";

@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
  listenTo() {
    return User;
  }

  async beforeInsert(event: InsertEvent<User>): Promise<void> {
    if (event.entity.password) {
      event.entity.password = await hashPassword(event.entity.password);
    }
  }

  afterInsert(event: InsertEvent<User>): void {
    console.log(`New user created: ${event.entity.id}`);
  }
}

사용 가능한 이벤트: beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeRemove, afterRemove, afterLoad 등이 있다. NestJS에서 사용하려면 TypeOrmModule.forRoot()subscribers 옵션에 등록하거나, autoLoadEntities처럼 자동 로딩하도록 설정할 수 있다.


Active Record vs Data Mapper

TypeORM은 두 가지 패턴을 모두 지원한다.

Active Record 패턴

엔티티 자체에 DB 연산 메서드가 있다. BaseEntity를 상속받아 사용한다.

typescript
@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  static findByName(name: string) {
    return this.findOneBy({ name });
  }
}

// 사용
const user = await User.findByName("Alice");
await User.save(user);

Data Mapper 패턴

엔티티는 순수한 데이터 객체이고, 모든 DB 연산은 Repository를 통해 한다. NestJS에서 권장하는 패턴이다.

typescript
// 엔티티는 데이터 정의만
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
}

// Repository에서 DB 연산
const user = await userRepository.findOneBy({ name: "Alice" });
await userRepository.save(user);

NestJS 같은 DI 프레임워크에서는 Data Mapper 패턴이 더 자연스럽다. Repository를 주입받아 사용하기 때문에 테스트에서 모킹하기도 쉽고, 엔티티와 비즈니스 로직을 분리할 수 있다. Active Record 패턴은 엔티티가 DB에 직접 의존하기 때문에 단위 테스트가 까다롭다.


주의사항

N+1 문제

관계를 로딩할 때 가장 흔하게 겪는 성능 문제다. 게시글 목록을 가져온 뒤 각 게시글의 작성자를 조회하면, 게시글 수만큼 추가 쿼리가 발생한다.

typescript
// ❌ N+1 발생
const posts = await postRepository.find();
for (const post of posts) {
  console.log(post.author.name); // 각 post마다 별도 쿼리
}

// ✅ 한 번에 조인
const posts = await postRepository.find({
  relations: ["author"],
});

relations 옵션이나 QueryBuilder의 leftJoinAndSelect()로 필요한 관계를 미리 로딩하면 쿼리 한 번으로 해결된다.

select와 addSelect

find()select 옵션이나 QueryBuilder의 select()를 사용하면 필요한 컬럼만 가져올 수 있다. 특히 큰 텍스트 컬럼이나 BLOB이 있는 테이블에서 목록 조회할 때 유용하다.

typescript
// 필요한 필드만 조회
const users = await userRepository
  .createQueryBuilder("user")
  .select(["user.id", "user.name"])
  .getMany();

save()의 동작 방식

save()는 엔티티에 primary key가 있으면 UPDATE, 없으면 INSERT를 실행한다. 이 동작을 이해하지 못하면 의도치 않게 새 행이 생기거나 기존 데이터가 덮어써질 수 있다.

typescript
// id가 없으므로 INSERT
const user = userRepository.create({ name: "Alice" });
await userRepository.save(user);

// id가 있으므로 UPDATE
user.name = "Bob";
await userRepository.save(user);

// 주의: 부분 객체를 save하면 나머지 필드가 null로 덮어써질 수 있다
await userRepository.save({ id: 1, name: "Charlie" });
// email, loginCount 등이 null/default로 리셋될 수 있음

부분 업데이트가 필요하면 save() 대신 update()를 사용하는 게 안전하다.

typescript
await userRepository.update({ id: 1 }, { name: "Charlie" });
// name만 변경되고 나머지 필드는 그대로

정리

  • Entity/Repository/DataSource 세 축으로 동작하며, 데코레이터 기반 엔티티 정의와 NestJS DI 통합이 핵심이다
  • relations나 QueryBuilder로 필요한 관계만 명시적으로 로딩해서 N+1을 방지하고, 부분 업데이트는 save() 대신 update()를 쓴다
  • Data Mapper 패턴으로 엔티티와 DB 연산을 분리하면 테스트와 유지보수가 쉬워지고, 프로덕션에서는 synchronize를 끄고 마이그레이션을 사용한다

관련 문서