[NestJS] NestJS CLI로 REST API를 사용한 CRUD 기능 만들기(5분버전 vs. 심화버전) with TypeORM & MySQL
2024.01.24- -
데이터베이스와 데이터베이스 연결을 지원하는 ORM 모듈을 사용하는지 여부에 따라 CRUD 생성 프로젝트 수준이 달라지게 됩니다. NestJS CLI가 제공하는 framing은 굉장히 강력하기 때문에 데이터베이스를 사용하지 않는 수준에서 CRUD 프로젝트를 만든다면 5분이 채 걸리지 않을 수 있습니다.
이번 포스팅에서는 데이터베이스를 사용하는 방식과 그렇지 않은 방식으로 CRUD API를 손쉽게 만들어보는 방법에 대해서 알아보도록 하겠습니다.
1. 데이터베이스를 사용하지 않는 5분 버전 CRUD
1-1. 프로젝트 생성
우리는 NestJS CLI를 통해서 특정 기능의 CRUD 코드 구조를 곧바로 생성해낼 수 있습니다. 다음 명령어를 입력하여 우선 프로젝트를 생성해줍시다.
nest new crud-project
1-2. nest g resource users 명령어
REST API는 일반적으로 계층 구조를 갖고 있기 때문에 코드에 대한 유지보수가 쉽도록 구조를 갖춥니다. 이전 포스팅들에서 계속 봤듯이 이러한 계층에는 HTTP 요청을 받는 컨트롤러(controller)와 비지니스 로직을 처리하는 서비스(service)가 존재하며, 하나의 데이터를 나타내기 위해 사용되는 엔티티(Entity) 클래스, 외부로부터 받아오는 데이터를 구성하는 DTO 클래스 등으로 다양한 구성요소들이 존재합니다.
다른 NestJS 프로젝트를 봐도 이러한 구조에서 크게 벗어나지 않는 것을 확인할 수 있는데요. 그러나 이러한 요소들을 전부 직접 파일을 생성하여 만들어준다면 굉장히 피곤한 일이 될 수도 있습니다. 다행히도 NestJS는 이러한 클래스 요소들을 일괄적으로 자동 생성해주는 명령어를 제공하고 있습니다.
우리는 유저 정보를 관리하는 REST API를 만들어보도록 할 것인데요. 가장 먼저 그 구조를 갖춘 파일들을 생성하기 위해 다음 명령어를 입력해줍니다.
nest generate resource users
// or
// nest g res users
명령어가 실행되면 우리의 프로젝트 디렉터리 내에 src/users/ 폴더가 생긴 것을 확인하실 수 있을 겁니다. 해당 폴더에는 총 8개의 파일이 생성된 것을 볼 수 있습니다.
1-3. Entities
생성된 폴더 중 entities 폴더는 주고 받는 데이터에 대한 모델이 들어있는 곳입니다. 대부분의 REST API는 가장 하위 계층에 데이터베이스를 두고 요청받은 HTTP 메서드에 따라 CRUD 작업을 처리합니다.
NestJS로 서버를 구성할 때는 엔티티(Entity)로 불리는 클래스를 만들어 REST API에서 사용하는 그러한 데이터들을 모델링하게 됩니다.
데이터베이스에 따라 작동 방식이 약간 다른데, RDB(관계형 데이터베이스)의 경우 엔티티 클래스는 일반적으로 테이블이라 불리는 요소로 작동할 것이고, NoSQL의 경우 이 엔티티 클래스가 하나의 컬렉션(collection)으로 작동하게 될 것입니다.
- 이러한 엔티티 클래스를 인스턴스화하여 사용할 때, RDB는 그러한 테이블의 레코드(record)가 될 것이고, NoSQL의 경우 컬렉션의 아이템(item) 또는 문서(document)가 될 것입니다.
앞서 생성한 users resource에 대해 만들어진 파일 중 user.entity.ts 라는 파일을 보게되면 신기하게도 해당 파일과 DTO 파일들은 복수형태가 아닌 단수형태로 파일명이 되어 있는 것을 볼 수 있습니다. 이는 앞서 설명한 특성들로 인해 여러 유저가 아닌 User라는 Entity에 대해 정의를 해주는 것이기 때문에 그런 것으로 볼 수 있을 것 같습니다.
여기서는 데이터베이스와 직접 연결을 하는 작업을 진행하지 않기 때문에 아래와 같이 Enity 클래스를 임의로 정의해볼 수 있습니다.
// user.entity.ts
export class User {
id: number;
name: string;
email: string;
phone?: string;
createdAt: Date;
updatedAt?: Date;
}
- 만약 MySQL이나 MongoDB와 같은 데이터베이스와 연결을 하는 경우에는 typeorm과 같은 라이브러리를 통해 실제 데이터베이스와 연결되는 클래스이므로 실제 서비스를 설계할 때, 이 Entity 클래스를 굉장히 잘 작성해야 합니다.
1-4. DTO
DTO라는 개념은 앞선 포스팅에서 봤듯이 외부로부터 유입되거나 외부로 유출시키는 데이터를 모델링 한 것입니다.
일반적인 REST API에서, POST 메소드는 엔드포인트로 생성할 데이터가 들어오게 되고 PATCH 메소드는 엔드포인트로 수정할 데이터가 들어오게 됩니다.
조금만 생각해보면 유저 정보를 새로 생성할 때 넘어오는 데이터와 유저가 정보를 수정할 때 넘어오는 데이터는 대부분의 경우 다를 것임을 알 수 있습니다. (유저는 자신의 정보 중 일부만을 수정하기 때문이죠.)
그렇기 때문에 이러한 각 요청에 맞게 넘어오는 데이터의 형태를 우리는 객체 형태로 미리 지정해 놓을 수가 있는데 이를 NestJS에서는 DTO 클래스를 사용합니다.
생성한 프로젝트 폴더를 보면 src/**/dto라는 폴더가 바로 그러한 DTO 클래스들을 모아놓은 곳이라고 보면 될 것 같습니다. 앞서 Nest 명령어를 통해 생성된 기본 dto로는 create-user.dto.ts와 update-user.dto.ts가 있는데요. 이처럼 dto 파일명은 해당하는 데이터의 목적을 잘 드러내도록 파일 네이밍을 하는 것이 권장됩니다.
아직 해당 파일들은 모두 비어있을 것이기 때문에 create-user.dto.ts 파일을 다음과 같이 작성해봅시다.
// create-user.dto.ts
export class CreateUserDto {
name: string;
email: string;
phone?: string;
}
위 코드를 보시면, 해당 클래스의 변수로 name, email, phone 속성을 작성하였습니다. 이는 User 엔티티의 멤버 변수 중 일부를 가져온 것으로 여기에는 id, createdAt, updatedAt과 같은 속성은 빠져있는데요. 이는 애플리케이션 내부적으로만 필요한 데이터이므로 굳이 DTO를 통해서 외부와 주고 받을 필요가 없는 정보이기 때문입니다.
반면, update-user의 DTO는이러한 create-user 중에서 일부 혹은 전체를 수정할 수 있기 때문에 Partial 타입의 상속을 통해 만들 수 있습니다.
// update-user.dto.ts
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";
export class UpdateUserDto extends PartialType(CreateUserDto) {}
PartialType은 타입스크립트의 Partial 유틸리티 타입을 활용한 NestJS에서 제공하는 유틸리티 타입으로 CreateUserDto 타입 중 일부 속성을 가지는 타입이 됩니다.
1-5. Controller
그런 다음엔 어느 엔드포인트로 들어왔을 때 앞서 정의한 유저 정보에 대해 어떠한 데이터 접근을 하도록 할 것인지 연결해주는 컨트롤러를 구현합니다.
프로젝트 폴더의 src/users/ 내에 있는 users.controller.ts 파일을 보면 이미 정의된 5가지의 라우팅이 정의되어 있습니다.
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
@Controller('users') 데코레이터를 통해 UsersController는 http://localhost:3000/users 경로로 들어오는 요청에 각각 요청 핸들러와 연결시켜주는 기능을 하게 됩니다.
해당 클래스 내부에는 HTTP 메소드에 맞게 특정 비지니스 로직이 구현된 서비스 호출의 결과를 반환하는 5가지의 라우팅 메소드들이 정의되어 있는 것을 볼 수 있습니다. 각 메소드들은 어떤 HTTP 메소드에 대해 처리할지를 데코레이터를 통해 명시하고 필요에 의해 사용자로부터 받는 데이터가 있다면 메소드의 인자로 그러한 데이터를 어떤 식으로 받을지를 별도로 추가합니다.
- 위 코드에서 서비스 메소드를 호출할 때 넘겨주는 인자 중에 id가 있는데 해당 변수 앞에 붙은 '+' 기호는 해당 id는 우리가 만든 User 엔티티의 id로 어떠한 validation도 처리가 되어 있지 않아 문자열로 처리가 될 것이기 때문에 이를 숫자로 바꿔주는 기능을 합니다.
1-6. Service
이제 마지막으로 컨트롤러의 구현되지 않은 비지니스 로직을 서비스(service)의 메소드 구현을 통해 작성하면 모든 작업은 마무리됩니다.
프로젝트 폴더에서 src/users/ 폴더 내에 users.service.ts 파일을 열어보면 역시 미리 정의된 5가지의 메서드로 구성되어 있는 UsersService 클래스가 존재합니다. 각 메서드들은 임의의 문자열을 반환하도록 되어있기 때문에 우리는 앞서 정의한 Entity와 DTO 클래스를 통해 유저 정보를 반환하는 메서드로 바꿔주는 작업을 하면 됩니다.
// users.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { User } from "./entities/user.entity";
@Injectable()
export class UsersService {
private users: Array<User> = [];
private id = 0;
create(createUserDto: CreateUserDto) {
this.users.push({ id: ++this.id, ...createUserDto, createdAt: new Date() });
}
findAll() {
return [...this.users];
}
findOne(id: number) {
const found = this.users.find((u) => u.id === id);
if (!found) throw new NotFoundException();
return found;
}
update(id: number, updateUserDto: UpdateUserDto) {
const found = this.findOne(id);
this.remove(id);
this.users.push({ ...found, ...updateUserDto, updatedAt: new Date() });
}
remove(id: number) {
this.findOne(id);
this.users = this.users.filter((u) => u.id !== id);
}
}
위 코드에서 눈여겨 볼만한 부분은 id를 인자로 받는 update() 메소드와 remove() 메소드에서 내부적으로 findeOne() 메소드를 호출하여 findOne() 메소드에서 진행되는 유저 정보 유무를 판별하는 로직이 해당 메소드들에서도 실행되기 때문에 이들 모두 동일한 피드백을 받을 수 있게 된다는 점입니다.
2. 데이터베이스와 ORM을 사용한 CRUD
앞서 만든 가장 기본형태의 CRUD API를 데이터베이스와 ORM을 사용한 프로젝트로 바꿔보는 작업을 해보도록 하겠습니다.
2-1. MySQL 설치 및 데이터베이스 생성
데이터베이스는 MySQL을 사용할 것입니다. 그러기 위해선 MySQL을 설치하여 새로운 데이터베이스를 만들어주셔야 합니다. 설치 과정은 아래 링크를 참조해주세요.
모든 설치가 완료되면 위 이미지와 같이 MySQL 콘솔을 들어갑니다.
이후 다음 명령어들을 입력하여 데이터베이스 하나를 만들어주도록 하겠습니다.
create database testdb;
testdb 대신 원하시는 데이터베이스 이름을 넣어주시면 됩니다.
show databases;
위 명령을 통해 데이터베이스가 잘 만들어졌는지 확인해줍니다.
mysql> CREATE USER 'testuser'@'localhost' IDENTIFIED BY '1234';
mysql> GRANT ALL PRIVILEGES ON testdb.* TO 'testuser'@'localhost';
mysql> FLUSH PRIVILEGES;
위 명령어들을 차례로 입력하여 해당 데이터베이스를 사용하는 계정을 생성하고 그 계정에 데이터베이스 접근 권한을 부여합니다.
2-2. 데이터베이스 스키마 생성(Code-First vs. Schema-First)
데이터베이스를 만들고 그 안에 들어갈 테이블을 만들게 되는데 그러한 구조를 나타낸 것을 스키마(Schema)라고 합니다.
MySQL 스키마를 구축할 때 방식이 크게 두 가지가 존재합니다.
Schema-First
이 방식은 먼저 스키마를 정의하고, 그 스키마의 정의들을 따라 코드를 작성하는 방식입니다.
주로 SDL(Schema Definition Language)을 사용해 스키마를 정의하게 되는데, SQL은 DDL(Data Definition Language)을 통해서 스키마를 정의 할 수가 있습니다.
CREATE TABLE `User` (
`userID` INT NOT NULL AUTO_INCREMENT,
`nickname` VARCHAR(20) NOT NULL COMMENT '이용자 닉네임',
...
PRIMARY KEY (`userID`)
);
위와 같은 쿼리문을 통해 테이블을 생성하고 나면 다시 프로젝트로 돌아와 typeorm-model-generator라는 모듈을 설치하여 우리 프로젝트에 entity 클래스를 자동으로 만들어줄 수 있습니다.
이 방식은 기존의 데이터베이스 스키마가 이미 존재하거나, 데이터베이스 설계를 먼저 완료한 후 애플리케이션 개발을 시작할 때 유리합니다. 또한 복잡한 스키마를 빠르게 엔티티로 변환할 수 있다는 장점이 있습니다.
하지만 경험상 Schema-First 방식으로 엔티티를 생성해도 결국 해당 클래스를 입맛에 맞게 수정하게 되기 때문에 Code-First 방식을 조금 더 선호하여 Code-First 방식으로 진행하는 것이 더 좋은 것 같습니다. 또한 자바스크립트로 SQL 테이블을 만들 수 있는 것이기 때문에 좀 더 쉽게 도메인 설계를 할 수 있는 것 같습니다. (물론 ERD를 DDL로 변환해주는 플랫폼들이 많긴 합니다.)
위 과정에 대해 궁금하신 분들은 아래 링크를 참조해주세요.
https://velog.io/@fcfargo/Nest.js-typeORM-%EC%84%A4%EC%A0%95
Code-First
이 방식은 NestJS 프로젝트에서 Entity 클래스를 정의가 먼저 이루어지게 됩니다.
우선, 프로젝트 폴더에서 npm을 사용하여 다음 명령어를 통해 필요한 패키지를 설치합니다.
npm install --save @nestjs/typeorm typeorm mysql2
이 명령어를 통해 TypeORM을 사용하여 MySQL 데이터베이스에 연결하는 필수 패키지를 설치할 수 있습니다.
그런 다음 아래와 같이 원하는 Entity를 typeorm 모듈이 제공하는 데코레이터에 따라 클래스로 정의합니다.
// entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
} from 'typeorm';
@Entity('User')
export class User {
@PrimaryGeneratedColumn()
userID: number;
@Column({ length: 20, comment: '이용자 닉네임' })
nickname: string;
}
이렇게 만들어준 엔티티 클래스를 이제 데이터베이스와 연결하여 해당 데이터베이스에 테이블이 만들어지도록 해주어야 합니다. TypeORM에서는 자동 동기화 기능을 통해 orm 연결 설정만 해준다면 서버를 시작했을 때 자동으로 데이터베이스에 반영이 되도록 할 수 있습니다.
이러한 자동 동기화 방식이 아닌 마이그레이션 방식은 SQL 쿼리들로 이루어진 파일로, typeorm 명령어를 통해 직접 수동으로 데이터베이스 동기화를 시켜주는 방식입니다.
- 개발 환경에서는 자동 동기화 방식이 굉장히 편리하지만 실제 운영 단계에서는 안전하지 않기 때문에 마이그레이션을 사용합니다.
- 마이그레이션 방식이 궁금하신 분들은 아래 링크를 참고해주세요.
- https://velog.io/@moongyu1/Nest.js-TypeORM-production-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-migration%ED%95%98%EA%B8%B0
프로젝트 폴더에서 새로운 TypeScript 파일을 생성하고 다음 내용을 작성합니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forRoot()],
})
export class AppModule {}
이제 orm 모듈의 설정을 해주어야 하는데요. 일반적으로는 아래 코드와 .env를 같이 사용하여 AppModule에 그러한 설정을 담는 경우가 종종 있습니다.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT) || 3306,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false
})
})
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
이렇게 직접 설정값을 넣는 것이 아니라 보기 편하게 설정 파일을 따로 분리하여 import 해서 사용하는 경우도 있으나 다소 설정하기에 복잡한 상황이 있어 많은 경우에 위와 같이 app.module.ts에 넣어주는 경우도 많아 위와 같이 진행하도록 하겠습니다.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'name',
password: 'password',
database: 'testdb',
entities: [__dirname + '/**/*.entity.{ts,js}'],
synchronize: true,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
위는 예시 코드로 entities에 들어갈 경로를 잘 지정해주셔야 에러가 없이 연결이 됩니다.
이제 다음 명령어를 통해 서버를 실행시킵니다.
npm run start
이제 MySQL 터미널로 들어가 다음 명령어들을 입력하여 성공적으로 데이터베이스와 연결이 되어 스키마가 만들어졌는지 확인합니다.
mysql> use testdb;
mysql> desc user;
이제 연결이 잘 되었으니 UsersService 모듈을 리팩토링합니다.
우선 @Injectable() 데코레이터를 사용하여 NestJS 의존성 주입 시스템에 이 서비스를 등록해야 합니다. 그 다음 'Repository' 패턴을 사용하여 데이터베이스 작업을 수행하는 로직으로 변경해 줍니다. 여기서 'Repository' 객체는 TypeORM에서 제공하는 메서드를 사용하여 데이터베이스에 접근하고 CRUD 작업을 수행합니다.
UsersService 클래스를 TypeORM을 사용하여 구현한 예시 코드는 다음과 같습니다.
import { Injectable, NotFoundException } 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 UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const newUser = this.userRepository.create(createUserDto);
await this.userRepository.save(newUser);
return newUser;
}
async findAll(): Promise<User[]> {
return await this.userRepository.find();
}
async findOne(id: number): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User with ID ${id} not found`);
return user;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
const updatedUser = { ...user, ...updateUserDto };
await this.userRepository.save(updatedUser);
return updatedUser;
}
async remove(id: number): Promise<void> {
const user = await this.findOne(id);
await this.userRepository.remove(user);
}
}
변경 사항 요약:
- InjectRepository() 데코레이터를 사용하여 UserService에 User 엔티티에 대한 Repository를 주입합니다.
- 각 메서드에서 userRepository의 TypeORM 메서드를 사용하여 데이터베이스 작업을 수행합니다.
- 모든 메서드를 비동기적(asynchronous)으로 처리하고, 결과를 Promise로 반환합니다.
- findOne 메서드에서 사용자를 찾지 못할 경우 NotFoundException을 발생시킵니다.
그 다음에는 User 모듈이 제대로 TypeORM을 사용하도록 하기 위해 TypeOrmModule을 import 하고 User 엔티티를 사용할 수 있도록 하는 부분을 추가해주어야 합니다. 이는 TypeOrmModule.forFeature 메서드를 사용하여 수행할 수 있습니다.
아래는 UserModule을 수정한 코드 예시입니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService]
})
export class UserModule {}
변경 사항 요약:
- TypeOrmModule.forFeature([User]): 이 코드는 User 엔티티를 현재 모듈에서 사용할 수 있도록 설정합니다. forFeature 메서드는 특정 엔티티에 대한 저장소(repository)를 현재 범위의 모듈에 주입하는 역할을 합니다.
- 이 모듈은 UsersController와 UsersService를 포함합니다. UsersService는 이전 단계에서 TypeORM을 사용하도록 리팩토링된 상태입니다.
이렇게 데이터베이스를 활용한 CRUD 작업까지 진행해보았습니다. TypeORM에서 제공하는 기능은 굉장히 다양하고 강력하기 때문에 필요에 따라 공식문서를 잘 활용하여 원하는 대로 로직을 만들어 나갈 수 있다는 장점이 있습니다.
읽어주셔서 감사합니다.
'Back-end > NestJS' 카테고리의 다른 글
[NestJS] NestJS에서 로깅(Logging)하기 - 1 (전문적으로 로깅하기) (0) | 2024.01.26 |
---|---|
[NestJS] Validation(검증) 심화버전 (1) | 2024.01.25 |
[NestJS] NestJS에서 Swagger 사용법 (feat. API Documentation) (2) | 2024.01.23 |
[NestJS] NestJS 시작 (설치 & 구성요소 맛보기) (3) | 2024.01.22 |
[NestJS] NestJS를 위한 선수지식 Node.js & Express.js 이해 (feat. Logging, 폴더 구조) (0) | 2024.01.21 |
소중한 공감 감사합니다