ORM 사용
이전 절들에서는 Node.js 환경에서 Express나 NestJS 프레임워크를 사용하여 API 서버를 구축하는 방법을 살펴보았습니다. 백엔드 애플리케이션의 핵심 역할 중 하나는 데이터베이스와 상호작용하여 데이터를 저장, 조회, 수정, 삭제하는 것입니다. 전통적으로 데이터베이스 작업은 SQL(Structured Query Language) 쿼리를 직접 작성하여 수행했지만, 이는 몇 가지 단점을 가집니다.
- 타입 불일치: SQL 쿼리는 문자열 기반이며, 애플리케이션의 프로그래밍 언어(예: 타입스크립트)의 타입 시스템과는 독립적입니다. 이는 런타임에 타입 관련 오류를 유발할 수 있습니다.
- 생산성 저하: 복잡한 SQL 쿼리를 직접 작성하고 관리하는 것은 시간이 많이 소요되고 오류 발생 가능성이 높습니다.
- 데이터베이스 의존성: 데이터베이스 종류를 변경하면 모든 SQL 쿼리를 다시 작성해야 할 수 있습니다.
이러한 문제들을 해결하고 개발 생산성을 높이기 위해 ORM (Object-Relational Mapping) 이 등장했습니다. ORM은 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스의 테이블을 자동으로 매핑해주는 기술입니다. 이를 통해 개발자는 SQL 쿼리를 직접 작성하는 대신, 객체 지향적인 방식으로 데이터베이스를 조작할 수 있게 됩니다.
이 절에서는 Node.js 환경에서 널리 사용되는 ORM 중 하나인 TypeORM을 중심으로 ORM의 개념과 타입스크립트 환경에서의 사용법을 자세히 다룹니다.
ORM 소개 및 장점
ORM은 애플리케이션의 도메인 모델(객체)과 데이터베이스 스키마(테이블) 사이의 번역기 역할을 합니다. 즉, 클래스나 객체를 사용하여 데이터베이스 테이블의 행(row)을 표현하고, 객체의 메서드를 사용하여 데이터베이스 작업을 수행합니다.
ORM의 주요 장점
- 개발 생산성 향상: SQL 쿼리를 직접 작성하는 시간을 줄여주고, 객체 지향적인 방식으로 데이터베이스 작업을 수행할 수 있어 개발 속도를 높입니다.
- 유지보수성 증대: 코드가 더 추상화되고 읽기 쉬워지므로 유지보수가 용이해집니다.
- 타입 안전성 (타입스크립트 사용 시): ORM이 데이터베이스 스키마를 기반으로 타입을 생성하거나, 개발자가 타입스크립트 클래스로 스키마를 정의할 수 있게 함으로써 런타임 이전에 타입 오류를 감지할 수 있습니다.
- 데이터베이스 독립성: 대부분의 ORM은 여러 데이터베이스(PostgreSQL, MySQL, SQLite 등)를 지원합니다. 데이터베이스를 변경하더라도 코드의 큰 수정 없이 마이그레이션이 가능합니다.
- 객체 관계 매핑: 외래 키(Foreign Key) 관계를 객체 간의 관계로 매핑하여 JOIN 등의 복잡한 쿼리 없이도 관련 데이터를 쉽게 탐색할 수 있습니다.
ORM의 단점
- 성능 오버헤드: 복잡한 쿼리의 경우 ORM이 생성하는 SQL이 최적화되지 않아 성능 문제가 발생할 수 있습니다. 이 경우 직접 SQL 쿼리를 작성하는 것이 더 효율적일 수 있습니다.
- 학습 곡선: 새로운 ORM을 배우는 데 시간이 필요하며, ORM의 동작 방식을 완전히 이해하지 못하면 비효율적인 쿼리를 작성할 수 있습니다.
- 완벽한 추상화의 어려움: 모든 데이터베이스 시나리오를 ORM으로 완벽하게 추상화하기는 어렵습니다.
Node.js 환경의 주요 ORM
Node.js 생태계에는 여러 ORM이 존재하며, 각각의 특징과 장단점이 있습니다.
- Sequelize: 안정적이고 기능이 풍부한 ORM으로, MySQL, PostgreSQL, SQLite, MSSQL 등 다양한 관계형 데이터베이스를 지원합니다. 콜백, Promise, Async/Await 등 다양한 비동기 처리 방식을 지원합니다.
- TypeORM: 타입스크립트에 최적화된 ORM으로, 데코레이터 기반의 문법을 사용하여 엔티티(Entity)를 정의합니다. 관계형 데이터베이스(MySQL, PostgreSQL, SQLite 등)와 NoSQL 데이터베이스(MongoDB)를 동시에 지원하는 독특한 특징을 가집니다.
- Prisma: 차세대 ORM으로 불리며, 강력한 타입 안전성과 개발자 경험에 중점을 둡니다. Prisma Schema Language (PSL)로 스키마를 정의하고, Prisma Client를 통해 타입 안전한 데이터베이스 쿼리를 제공합니다. 마이그레이션 기능이 강력합니다.
이 절에서는 타입스크립트와의 시너지가 가장 좋은 TypeORM을 사용하여 데이터베이스 연동 방법을 설명합니다.
TypeORM 사용하기
NestJS는 TypeORM과의 통합을 위한 전용 모듈을 제공하여 ORM 사용을 더욱 간편하게 만듭니다. 여기서는 NestJS 프로젝트를 기반으로 TypeORM을 설정하고 사용하는 방법을 보여줍니다.
프로젝트 초기화 및 TypeORM 패키지 설치
NestJS 프로젝트가 없으면 먼저 생성합니다.
nest new my-nestjs-typeorm-app
cd my-nestjs-typeorm-app
필요한 패키지를 설치합니다. 여기서는 PostgreSQL을 예시로 사용합니다.
npm install @nestjs/typeorm typeorm pg
npm install --save-dev @types/pg
@nestjs/typeorm
: NestJS와 TypeORM 통합 모듈typeorm
: TypeORM 코어 라이브러리pg
: PostgreSQL 데이터베이스 드라이버 (mysql2
,sqlite3
등 다른 드라이버 사용 가능)@types/pg
:pg
드라이버에 대한 타입 정의
데이터베이스 설정
AppModule
에 TypeOrmModule
을 임포트하고 데이터베이스 연결 설정을 합니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { User } from './user/entities/user.entity'; // User 엔티티 임포트 예정
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres', // 사용하는 데이터베이스 타입 (mysql, sqlite, mongodb 등)
host: 'localhost',
port: 5432,
username: 'your_username', // 실제 DB 사용자 이름
password: 'your_password', // 실제 DB 비밀번호
database: 'your_database_name', // 사용할 데이터베이스 이름
entities: [User], // 애플리케이션에서 사용할 엔티티 클래스 배열
synchronize: true, // 개발 환경에서만 true: 애플리케이션 시작 시 DB 스키마 자동 동기화
logging: true, // SQL 쿼리 로깅
}),
// TypeOrmModule.forFeature([User]), // 각 모듈에서 사용할 엔티티를 등록 (아래 user 모듈에서 등록)
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
synchronize: true
는 개발 단계에서 엔티티 변경 시 데이터베이스 스키마를 자동으로 업데이트해주는 편리한 기능이지만, 운영 환경에서는 절대 사용하지 않아야 합니다. 운영에서는 마이그레이션 도구를 사용하여 스키마 변경을 관리해야 합니다.
엔티티 (Entity) 정의
엔티티는 데이터베이스 테이블과 매핑되는 타입스크립트 클래스입니다. @Entity()
, @PrimaryGeneratedColumn()
, @Column()
등의 데코레이터를 사용하여 스키마를 정의합니다.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity() // 이 클래스가 데이터베이스 테이블과 매핑되는 엔티티임을 선언
export class User {
@PrimaryGeneratedColumn() // 기본 키(Primary Key)로 자동 생성되는 숫자 ID
id!: number; // !는 definite assignment assertion (타입스크립트가 초기화될 것임을 확신)
@Column({ type: 'varchar', length: 100, unique: true }) // 문자열 컬럼, 최대 길이 100, 유니크 제약조건
email!: string;
@Column({ type: 'varchar', length: 50 })
name!: string;
@Column({ type: 'int', nullable: true }) // 선택적(nullable) 숫자 컬럼
age?: number;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) // 생성 시간 자동 기록
createdAt!: Date;
}
엔티티 클래스는 데이터베이스 스키마를 정의하는 동시에, 타입스크립트의 타입으로도 작동하여 쿼리 결과나 데이터 조작 시 강력한 타입 안전성을 제공합니다.
서비스 (Service)에서 DB 작업 수행
서비스 계층에서 Repository
를 주입받아 데이터베이스 작업을 수행합니다.
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 UserService {
constructor(
// User 엔티티에 대한 Repository를 주입받습니다.
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const newUser = this.usersRepository.create(createUserDto); // 엔티티 인스턴스 생성
return this.usersRepository.save(newUser); // 데이터베이스에 저장 (INSERT)
}
async findAll(): Promise<User[]> {
return this.usersRepository.find(); // 모든 사용자 조회 (SELECT *)
}
async findOne(id: number): Promise<User | null> {
// findOneBy 또는 findOne({ where: { id }})
return this.usersRepository.findOneBy({ id }); // 특정 ID의 사용자 조회
}
async update(id: number, updateUserDto: Partial<User>): Promise<User | null> {
const user = await this.usersRepository.findOneBy({ id });
if (!user) {
return null;
}
// Object.assign(user, updateUserDto); // 객체 병합
this.usersRepository.merge(user, updateUserDto); // TypeORM의 merge 사용
return this.usersRepository.save(user); // 업데이트 (UPDATE)
}
async remove(id: number): Promise<void> {
await this.usersRepository.delete(id); // 삭제 (DELETE)
}
}
@InjectRepository(User)
데코레이터를 사용하여 특정 엔티티에 대한 Repository
인스턴스를 주입받습니다. Repository
는 save
, find
, findOneBy
, delete
등 기본적인 CRUD(Create, Read, Update, Delete) 작업을 위한 메서드를 제공합니다.
DTO (Data Transfer Object) 정의
요청 본문(Payload)의 유효성 검사 및 타입 정의를 위한 DTO를 생성합니다.
import { IsEmail, IsString, IsInt, Min, Max, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsOptional() // 선택적 필드
@IsInt()
@Min(0)
@Max(120)
age?: number;
}
사용자 모듈 및 컨트롤러 설정
UserModule
을 생성하여 관련된 엔티티, 서비스, 컨트롤러를 묶습니다.
nest g module user
nest g service user
nest g controller user
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // 이 모듈에서 User 엔티티를 사용할 수 있도록 등록
controllers: [UserController],
providers: [UserService],
exports: [UserService], // 다른 모듈에서 UserService를 사용하려면 exports에 추가
})
export class UserModule {}
// src/user/user.controller.ts
import { Controller, Get, Post, Body, Param, Put, Delete, HttpCode, HttpStatus } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@HttpCode(HttpStatus.CREATED) // 201 Created 응답
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
return this.userService.create(createUserDto);
}
@Get()
async findAll(): Promise<User[]> {
return this.userService.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<User | null> {
return this.userService.findOne(+id); // '+' 연산자로 string을 number로 변환
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateUserDto: Partial<User>): Promise<User | null> {
return this.userService.update(+id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204 No Content 응답
async remove(@Param('id') id: string): Promise<void> {
await this.userService.remove(+id);
}
}
AppModule
에 UserModule
을 임포트합니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { User } from './user/entities/user.entity';
import { UserModule } from './user/user.module'; // UserModule 임포트
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'your_username',
password: 'your_password',
database: 'your_database_name',
entities: [User], // 엔티티 배열에 User 엔티티 포함
synchronize: true,
logging: true,
}),
UserModule, // UserModule 등록
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
데이터베이스 마이그레이션
synchronize: true
는 개발용이며, 프로덕션에서는 마이그레이션(Migrations) 을 사용하여 데이터베이스 스키마 변경 이력을 관리하고 배포합니다. TypeORM은 마이그레이션 기능을 내장하고 있습니다.
package.json
에 스크립트 추가package.json "scripts": { // ... "typeorm": "typeorm-ts-node-commonjs", "migration:generate": "npm run typeorm migration:generate src/migration/init", "migration:run": "npm run typeorm migration:run", "migration:revert": "npm run typeorm migration:revert" }
typeorm-data-source.ts
파일 생성 (TypeORM CLI가 사용할 설정)typeorm-data-source.ts import { DataSource } from 'typeorm'; import { User } from './src/user/entities/user.entity'; // 엔티티 경로 확인 export const AppDataSource = new DataSource({ type: 'postgres', host: 'localhost', port: 5432, username: 'your_username', password: 'your_password', database: 'your_database_name', entities: [User], // 모든 엔티티 등록 migrations: ['src/migration/*.ts'], // 마이그레이션 파일 경로 synchronize: false, // 마이그레이션 사용 시 false logging: true, });
- 데이터소스 등록
AppDataSource.initialize() .then(() => { console.log("Data Source has been initialized!") }) .catch((err) => { console.error("Error during Data Source initialization", err) })
- 마이그레이션 파일 생성:
npm run migration:generate src/migration/create-user-table
- 마이그레이션 실행:
npm run migration:run
TypeORM과 타입스크립트의 시너지
TypeORM은 타입스크립트에 최적화된 ORM으로서 다음과 같은 강력한 시너지를 제공합니다.
- 타입 기반 엔티티 정의: 데이터베이스 스키마와 애플리케이션의 모델이 하나의 타입스크립트 클래스로 관리됩니다. 이는 코드 중복을 줄이고, 스키마 변경 시 관련 코드를 즉시 파악할 수 있게 합니다.
- 컴파일 시점 타입 검사:
Repository
메서드의 인자나 반환 값, 엔티티 속성 접근 등 모든 데이터베이스 관련 로직에서 타입 검사가 이루어집니다. 예를 들어, 존재하지 않는 컬럼 이름으로 쿼리를 시도하면 컴파일 에러가 발생합니다. - 강력한 자동 완성: IDE는 엔티티 클래스와
Repository
메서드를 기반으로 풍부한 자동 완성 기능을 제공하여 개발 생산성을 크게 높입니다. - 명확한 관계 정의:
@OneToMany()
,@ManyToOne()
,@ManyToMany()
등의 데코레이터를 사용하여 복잡한 테이블 간의 관계를 객체 지향적으로 명확하게 정의할 수 있습니다. - 테스트 용이성: 엔티티와 리포지토리를 분리하여 의존성 주입을 통해 쉽게 모킹(Mocking)하고 테스트할 수 있습니다.
결론
ORM은 백엔드 개발에서 데이터베이스와 상호작용하는 방식을 혁신하여 개발 생산성과 코드의 유지보수성을 크게 향상시킵니다. 특히 TypeORM과 같은 타입스크립트에 최적화된 ORM은 강력한 타입 안전성, 향상된 개발자 경험, 그리고 관계형 데이터 모델을 객체 지향적으로 다룰 수 있는 유연성을 제공합니다.
대부분의 현대 백엔드 애플리케이션에서는 ORM을 사용하여 데이터베이스 작업을 추상화하고, 필요에 따라 직접 SQL을 작성하는 하이브리드 접근 방식을 채택합니다. TypeORM은 타입스크립트 기반의 Node.js 백엔드를 구축할 때 데이터베이스 연동을 위한 매우 강력하고 권장되는 도구입니다.