[Type ORM] TypeORM 들어가기

[Type ORM] TypeORM 들어가기

레거시 SQL을 떠나 TypeORM으로 입문

그동안 Node.js와 MariaDB로 백엔드를 만들면서, 나는 ORM 없이 순수 SQL 쿼리만 고집해왔다. 내가 작성한 SQL이 가장 정확하고, 세밀한 제어가 가능하다는 생각, 그리고 개발자가 SQL문은 기본적으로 알아야지 라는 믿음 때문이었다. 하지만 프로젝트가 복잡해지면서 이 믿음은 종종 발목을 잡았다.

  • 반복적인 CRUD 쿼리 작성은 지루한 작업이었다.
  • 테이블 스키마가 변경되면 관련된 모든 SQL 문자열을 찾아 수정해야 하는 건 끔찍했다.
  • 자바스크립트 코드와 SQL이 뒤섞여 가독성이 떨어졌다.

그래서 이번에 NestJS로 새 프로젝트를 시작하면서, 큰맘 먹고 ORM(Object-Relational Mapping), 그중에서도 NestJS와 궁합이 좋다는 TypeORM을 도입해보기로 했다. 이 글은 그 학습 과정을 기록한 것이다.


ORM, 익숙한 SQL을 버리고 왜 써야 할까?

먼저 ORM이 뭔지, 왜 필요한지에 대한 생각부터 정리했다. ORM은 객체-관계 매핑의 약자로, 이름 그대로 프로그래밍 언어의 **객체(Object)**와 관계형 데이터베이스의 **테이블(Table)**을 자동으로 연결해주는 번역기 같은 존재다.

순수 SQL을 사용하던 내 입장에서 ORM의 장점은 명확하게 다가왔다.

  1. 생산성 향상: INSERT INTO users (name, email) VALUES (?, ?) 같은 긴 SQL 문자열 대신 userRepository.save({ name: '...', email: '...' }) 처럼 객체를 다루듯 코드를 짤 수 있다. 반복 작업이 줄고 코드도 훨씬 깔끔해진다.
  2. 유지보수 용이성: User 테이블에 age 컬럼을 추가해야 한다고 상상해 보자. 순수 SQL 방식에서는 관련된 모든 INSERT, UPDATE, SELECT 쿼리를 개발자가 직접 찾아 수정해야 한다. 하지만 ORM을 쓰면 User 클래스에 age 프로퍼티 하나만 추가하면 된다. 나머지는 ORM이 알아서 처리해준다. 이건 정말 혁신적이다.
  3. 데이터베이스 독립성: 나는 MariaDB에 익숙하지만, 만약 프로젝트가 PostgreSQL로 바뀌어야 한다면? 순수 SQL은 DB 벤더마다 미묘하게 문법이 달라 코드를 수정해야 할 수 있다. 하지만 ORM을 사용하면, 내 비즈니스 로직(서비스 코드)은 전혀 건드릴 필요 없이 설정 파일에서 드라이버 정보만 바꿔주면 된다.

물론 복잡한 쿼리나 성능 최적화가 필요할 땐 여전히 순수 SQL이 필요할 수 있다. 하지만 대부분의 CRUD 작업에서 ORM이 주는 이점은 이런 단점을 상쇄하고도 남는다고 판단했다.


NestJS 생태계의 선택, TypeORM

수많은 ORM 중에서 NestJS가 TypeORM을 유독 사랑하는 이유는 명확했다. 바로 TypeScript와 데코레이터 때문이다.

NestJS에서 @Module, @Controller, @Injectable 같은 데코레이터로 코드의 구조와 역할을 정의했던 것처럼, TypeORM은 @Entity, @Column, @PrimaryGeneratedColumn 같은 데코레이터로 객체(클래스)가 어떤 테이블과 컬럼에 매핑되는지를 정의한다.

// NestJS의 모듈/서비스 정의
@Injectable()
export class UsersService {}

// TypeORM의 엔티티 정의
@Entity()
export class User {}

이처럼 개발 경험이 자연스럽게 이어지기 때문에, NestJS 개발자라면 TypeORM을 배우는 것이 무척 수월하다. 또한, 책임 분리를 강조하는 NestJS의 DI(의존성 주입) 시스템과 TypeORM의 데이터 매퍼(Data Mapper) 패턴은 환상의 궁합을 자랑한다.


TypeORM 설치 및 NestJS에 연동하기

이제 실제로 프로젝트에 TypeORM을 적용해 볼 차례다.

1. 패키지 설치

NestJS에서 TypeORM을 사용하려면 세 가지 종류의 패키지가 필요하다.

# 1. NestJS용 TypeORM 모듈
npm install --save @nestjs/typeorm

# 2. TypeORM 코어 패키지
npm install --save typeorm

# 3. 데이터베이스 드라이버 (나는 MariaDB를 쓰므로 mysql2 사용)
npm install --save mysql2

2. AppModule에 연결 설정 추가

설치한 모듈들을 애플리케이션에 등록해야 한다. 최상위 모듈인 app.module.tsTypeOrmModuleimport하고 설정을 추가한다.

src/app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql', // 데이터베이스 타입
      host: 'localhost',
      port: 3306,
      username: 'root', // DB 유저 이름
      password: 'password', // DB 유저 비밀번호
      database: 'my_database', // DB 이름
      entities: [__dirname + '/**/*.entity{.ts,.js}'], // 엔티티 파일 경로
      synchronize: true, // true로 설정하면 앱 실행 시 엔티티를 바탕으로 DB 스키마를 자동 생성. 개발용!
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

여기서 synchronize: true 옵션은 정말 강력하다. 코드에서 정의한 엔티티(@Entity)를 보고 DB에 테이블이 없으면 자동으로 생성해준다. 개발 환경에서는 매우 편리하지만, 프로덕션 환경에서는 데이터 손실의 위험이 있으므로 절대 true로 두면 안 된다. 프로덕션에서는 migrations를 사용하는 것이 정석이다.


기본 CRUD 구현하기

이제 User에 대한 간단한 CRUD API를 만들어보자.

1. 엔티티(Entity) 생성

먼저 데이터베이스 테이블과 매핑될 User 클래스를 만든다.

src/users/entities/user.entity.ts

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity() // 이 클래스는 DB의 'user' 테이블과 매핑된다.
export class User {
  @PrimaryGeneratedColumn() // PK, Auto Increment
  id: number;

  @Column()
  name: string;

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

2. 모듈(Module) 설정

UsersModule에서 User 엔티티를 사용할 수 있도록 TypeOrmModule.forFeature()를 사용해 등록한다. 이렇게 하면 UserRepository를 이 모듈 내에서 주입받을 수 있게 된다.

src/users/users.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])], // 사용할 엔티티 등록
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

3. 서비스(Service)와 리포지토리(Repository) 작성

서비스 로직에서 실제 DB 작업을 수행한다. @InjectRepository(User) 데코레이터를 사용해 User 엔티티에 대한 리포지토리를 주입받는다.

src/users/users.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto'; // DTO는 생략

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) // User 리포지토리를 주입
    private usersRepository: Repository<User>,
  ) {}

  // CREATE
  create(createUserDto: CreateUserDto): Promise<User> {
    const newUser = this.usersRepository.create(createUserDto);
    return this.usersRepository.save(newUser);
  }

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

  // READ One
  findOne(id: number): Promise<User> {
    return this.usersRepository.findOneBy({ id });
  }

  // UPDATE
  async update(id: number, updateUserDto: any): Promise<void> {
    await this.usersRepository.update(id, updateUserDto);
  }

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

save, find, findOneBy, update, delete 등 직관적인 메서드로 모든 CRUD 작업이 끝났다. SQL 쿼리는 단 한 줄도 보이지 않는다.

4. 컨트롤러(Controller) 연결

마지막으로 컨트롤러에서 서비스의 메서드를 호출해주면 API가 완성된다.

src/users/users.controller.ts

import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() createUserDto: any) {
    return this.usersService.create(createUserDto);
  }

  @Get()
  findAll() {
    return this.usersService.findAll();
  }
  // ... findOne, update, remove ...
}

정리하며

레거시 SQL에서 ORM으로 넘어갔다. 처음에는 추상화된 계층 뒤에서 무슨 일이 일어나는지 몰라 답답할 것이라 생각했지만, TypeORM은 오히려 코드의 역할을 명확히 분리해주고 개발 생산성을 극적으로 높여주었다.