홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

NestJS에서 Drizzle ORM을 선택한 이유: TypeORM, Prisma와의 비교

정기창·2026년 1월 14일

문제 상황: 새로운 SaaS 모듈에 적합한 ORM 찾기

기존 블로그 시스템은 MongoDB + Mongoose를 사용하고 있었습니다. 새로운 SaaS 서비스를 추가하면서 MySQL을 도입해야 했고, NestJS 생태계에서 어떤 ORM을 사용할지 결정해야 했습니다.

후보는 세 가지였습니다. NestJS 공식 문서에서 가장 먼저 언급되는 TypeORM, 최근 몇 년간 급성장한 Prisma, 그리고 비교적 최근에 등장한 Drizzle ORM. 각각의 특성을 살펴보면서 왜 Drizzle을 선택했는지 정리해보려 합니다.

TypeORM: NestJS의 오랜 친구, 그러나

TypeORM은 NestJS와 가장 오랜 역사를 함께한 ORM입니다. @nestjs/typeorm 패키지가 공식적으로 제공되고, 많은 튜토리얼과 예제 코드가 TypeORM을 기반으로 작성되어 있습니다.

TypeORM의 장점

데코레이터 기반의 엔티티 정의가 직관적입니다. NestJS의 데코레이터 패턴과 일관된 경험을 제공합니다.

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

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Document, (doc) => doc.user)
  documents: Document[];
}

Active Record와 Data Mapper 패턴 모두 지원하고, 마이그레이션 시스템이 내장되어 있습니다.

TypeORM의 문제점

하지만 조사를 진행하면서 몇 가지 우려되는 점들을 발견했습니다.

첫째, 성능 오버헤드입니다. TypeORM은 런타임에 리플렉션을 사용해서 메타데이터를 처리합니다. reflect-metadata 패키지에 의존하고, 데코레이터가 실행될 때마다 메타데이터를 읽고 쓰는 작업이 발생합니다. 단순 CRUD에서는 체감하기 어렵지만, 복잡한 관계를 가진 엔티티나 대량의 쿼리가 실행될 때 이 오버헤드가 누적됩니다.

둘째, N+1 쿼리 문제가 쉽게 발생합니다. eager: true나 관계 로딩을 제대로 설정하지 않으면 N+1 쿼리가 발생하기 쉽습니다. 이를 방지하려면 QueryBuilder를 직접 다뤄야 하는데, 그러면 TypeORM의 추상화 이점이 많이 사라집니다.

셋째, 타입 안전성의 한계입니다. TypeORM의 타입 지원은 완벽하지 않습니다. find 옵션에서 관계 필드를 다룰 때 타입 추론이 제대로 되지 않는 경우가 많습니다.

// 이런 코드에서 relations의 타입이 느슨합니다
const users = await userRepository.find({
  relations: ['documents', 'profile'], // 오타가 있어도 컴파일 에러 없음
});

Prisma: 현대적인 접근, 다른 트레이드오프

Prisma는 완전히 다른 철학을 가지고 있습니다. 스키마 파일(schema.prisma)에서 모델을 정의하고, 이를 기반으로 타입이 완벽하게 추론되는 클라이언트를 생성합니다.

Prisma의 장점

타입 안전성은 Prisma의 가장 큰 강점입니다.

// Prisma의 타입 추론은 정말 뛰어납니다
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { documents: true },
});
// user.documents의 타입이 완벽하게 추론됩니다

Prisma Studio라는 GUI 도구도 제공하고, 마이그레이션 관리도 깔끔합니다.

Prisma의 고민되는 점

첫째, Cold Start 문제입니다. Prisma Client는 Query Engine이라는 바이너리를 사용합니다. 서버리스 환경에서 Cold Start 시 이 엔진을 로드하는 시간이 추가됩니다. NestJS를 Coolify로 배포하는 환경에서는 크게 문제되지 않을 수 있지만, 향후 서버리스로 전환할 가능성을 고려하면 걸리는 부분이었습니다.

둘째, 번들 크기입니다. Prisma Client와 Query Engine을 합치면 상당한 크기가 됩니다. node_modules에서 약 10-20MB를 차지하고, 이는 도커 이미지 크기와 배포 시간에 영향을 줍니다.

셋째, Raw SQL의 불편함입니다. Prisma의 추상화는 강력하지만, 복잡한 쿼리나 DB 특화 기능을 사용해야 할 때 $queryRaw로 벗어나야 합니다. 이때 타입 안전성이 사라집니다.

// 복잡한 쿼리에서 타입 안전성을 잃습니다
const result = await prisma.$queryRaw`
  SELECT * FROM users 
  WHERE MATCH(bio) AGAINST(${keyword} IN NATURAL LANGUAGE MODE)
`;
// result는 unknown 타입

Drizzle ORM: SQL에 가까운 타입 안전성

Drizzle은 "SQL을 타입스크립트로 작성한다"는 철학을 가지고 있습니다. ORM이라기보다 타입 안전한 쿼리 빌더에 가깝습니다.

SQL과의 1:1 대응

Drizzle의 쿼리는 SQL 구조를 거의 그대로 반영합니다.

// SQL: SELECT * FROM users WHERE email = 'test@example.com'
const user = await db.query.users.findFirst({
  where: eq(users.email, 'test@example.com'),
});

// SQL: SELECT * FROM documents WHERE user_id = 1 ORDER BY created_at DESC
const docs = await db
  .select()
  .from(documents)
  .where(eq(documents.userId, 1))
  .orderBy(desc(documents.createdAt));

SQL을 알고 있다면 Drizzle의 API를 익히는 데 시간이 거의 들지 않습니다. TypeORM이나 Prisma처럼 새로운 추상화 계층을 학습할 필요가 없습니다.

제로 런타임 오버헤드

Drizzle은 런타임에 추가적인 작업을 하지 않습니다. 리플렉션이나 메타데이터 처리가 없습니다. 모든 타입 처리는 컴파일 타임에 이루어집니다.

// 스키마 정의
export const users = mysqlTable('users', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 100 }),
  createdAt: timestamp('created_at').defaultNow(),
});

// 타입이 자동으로 추론됩니다
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;

벤치마크에 따르면 Drizzle은 TypeORM 대비 약 10배 빠른 성능을 보여줍니다. 물론 실제 애플리케이션에서는 네트워크 지연이나 DB 자체의 성능이 더 큰 영향을 미치지만, ORM 레이어의 오버헤드가 최소화된다는 것은 분명한 장점입니다.

가벼운 패키지 크기

Drizzle의 핵심 패키지(drizzle-orm)는 약 50KB 정도입니다. Prisma Client의 수십 MB와 비교하면 매우 가볍습니다. 도커 이미지 크기가 중요한 환경에서 이점이 됩니다.

완전한 타입 추론

Drizzle의 타입 추론은 Prisma만큼이나 강력합니다.

// 관계 쿼리에서도 타입이 완벽하게 추론됩니다
const userWithDocs = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: {
    documents: true,
  },
});

// userWithDocs.documents의 타입이 Document[]로 추론됩니다

NestJS와 Drizzle 통합하기

Drizzle은 NestJS 공식 통합 패키지가 없습니다. 커뮤니티 패키지(@knaadh/nestjs-drizzle-mysql2)가 있지만, 직접 모듈을 만드는 것이 더 제어하기 쉽다고 판단했습니다.

// drizzle.module.ts
@Global()
@Module({
  providers: [
    {
      provide: 'MYSQL_POOL',
      useFactory: async (configService: ConfigService) => {
        const host = configService.get<string>('MYSQL_HOST');
        if (!host) {
          console.log('MySQL not configured. Module disabled.');
          return null;
        }
        return mysql.createPool({
          host,
          user: configService.get('MYSQL_USER'),
          password: configService.get('MYSQL_PASSWORD'),
          database: configService.get('MYSQL_DATABASE'),
        });
      },
      inject: [ConfigService],
    },
    {
      provide: 'DRIZZLE_DB',
      useFactory: (pool: Pool | null) => {
        if (!pool) return null;
        return drizzle(pool, { schema, mode: 'default' });
      },
      inject: ['MYSQL_POOL'],
    },
  ],
  exports: ['DRIZZLE_DB'],
})
export class DrizzleModule {}

MySQL 설정이 없으면 null을 반환하도록 해서, 기존 MongoDB 기반 블로그 기능에 영향을 주지 않으면서 SaaS 모듈을 선택적으로 활성화할 수 있게 했습니다.

실제 서비스 코드 예시

다음은 실제로 구현한 SaaS 인증 서비스의 일부입니다.

@Injectable()
export class SaasAuthService {
  constructor(
    @Inject('DRIZZLE_DB') private readonly db: MySql2Database<typeof schema>,
  ) {}

  async signup(dto: SaasSignupDto) {
    // 중복 확인
    const existing = await this.db.query.saasUsers.findFirst({
      where: eq(saasUsers.email, dto.email),
    });

    if (existing) {
      throw new ConflictException('이미 사용 중인 이메일입니다.');
    }

    // 사용자 생성
    const passwordHash = await this.passwordService.hash(dto.password);
    const [result] = await this.db.insert(saasUsers).values({
      email: dto.email,
      passwordHash,
      name: dto.name,
    });

    // 생성된 사용자 조회
    const user = await this.db.query.saasUsers.findFirst({
      where: eq(saasUsers.id, Number(result.insertId)),
    });

    return { user, tokens: this.generateTokens(user) };
  }
}

SQL을 아는 사람이라면 코드를 읽는 데 어려움이 없을 것입니다. eq, insert, values 같은 함수명이 SQL 키워드와 거의 동일합니다.

결론: 상황에 맞는 선택

세 ORM 모두 훌륭한 도구입니다. 선택은 프로젝트의 요구사항에 따라 달라집니다.

  • TypeORM: 복잡한 관계가 많고, Active Record 패턴을 선호하며, NestJS 생태계 내에서 가장 많은 레퍼런스가 필요할 때
  • Prisma: 타입 안전성이 최우선이고, GUI 도구와 우수한 DX를 원하며, 서버리스가 아닌 환경에서
  • Drizzle: SQL에 익숙하고, 런타임 성능과 번들 크기가 중요하며, 세밀한 쿼리 제어가 필요할 때

저의 경우 기존 MongoDB와 공존해야 하는 상황, 가벼운 패키지 크기 선호, SQL에 대한 친숙함 등을 고려해서 Drizzle을 선택했습니다. 아직 프로덕션에 배포하지 않아서 실제 운영 경험은 없지만, 개발 과정에서의 DX는 매우 만족스럽습니다.

다만 Drizzle은 상대적으로 생태계가 작고, NestJS 공식 통합이 없다는 점은 인지하고 있어야 합니다. 팀 프로젝트라면 팀원들의 SQL 숙련도도 고려해야 할 것입니다.

NestJSDrizzle ORMTypeORMPrismaMySQLTypeScript

관련 글

개인 블로그에 AI 검색 달기 (1) - 왜 하이브리드 검색인가

블로그 검색 기능을 개선하면서 키워드 검색의 한계를 느꼈습니다. 벡터 검색과 키워드 검색을 결합한 하이브리드 검색을 선택한 이유와 아키텍처 설계 과정을 공유합니다.

관련도 86%

개인 블로그에 AI 검색 달기 (2) - MongoDB Atlas Vector Search 구현

MongoDB Atlas Vector Search 인덱스 설정부터 NestJS에서 하이브리드 검색을 구현하는 과정. $vectorSearch의 null 필터 제한사항과 RRF 알고리즘, 유사도 임계값 튜닝까지.

관련도 86%

Prettier + husky + lint-staged로 팀 코드 스타일 자동화하기

코드 스타일 논쟁을 없애고 git commit 시 자동으로 포매팅되는 환경을 구축한 경험. Prettier 설정부터 husky + lint-staged 연동, git-blame-ignore-revs까지 실제 적용 과정을 정리했습니다.

관련도 84%