사실 백엔드를 공부하고자 하는 의지는 예전부터 있었다. 그래서 백엔드 강의도 구매해서 70% 넘게 들었었는데, 다른 일과 겹쳐서 완강을 못했던 기억이 난다. 그리고 다가온 추석 연휴, 교보문고의 개발자 코너에서 무슨 책을 읽을까 고민하다가 ‘NestJS로 배우는 백엔드 프로그래밍’ 책이 눈에 들어왔다. 이번 연휴 때 이 책 하나만 다 읽고 정리해도 보람찬 연휴를 보낸 거라 자부할 수 있지 않을까. 그런 생각이 들어 책을 구매하고 본가에 내려갔다. 그리고 며칠에 걸쳐 틈틈이 책을 읽었고 머지않아 책의 끝 페이지까지 다다를 수 있었다. 이제 읽은 내용을 정리해보며 배운 내용을 복기해보려한다.
1. Node.js 특징
Nest는 Node.js를 기반으로 하는데, Node.js는 단일 스레드에서 구동되는 논블로킹 I/O 이벤트 기반 비동기 방식이다. 이 방식은 서버의 자원에 크게 부하를 가하지 않고, 하나의 스레드로 동작하는 것처럼 (비동기 I/O 라이브러리 libuv가 스레드 풀을 관리) 코드를 작성할 수 있다는 장점이 있다. 하지만 컴파일러 언어의 처리 속도에 비해 성능이 떨어지고, 하나의 스레드에 문제가 생기면 애플리케이션 전체가 오류를 일으킬 위험이 있다는 단점도 존재한다.
이벤트 루프
JS가 단일 스레드 기반임에도 논블로킹 I/O 작업을 수행할 수 있도록 해주는 기능
이벤트 루프는 시스템 커널에서 가능한 작업이 있다면 그 작업을 커널에 이관한다.
타이머 단계 → 대기 콜백 단계 → 유휴 준비 단계 → 폴 단계 → 체크 단계 → 종료 콜백 단계
2. 데코레이터
자바의 애너테이션과 유사한 기능
각 요소의 선언부 앞에 @로 시작하는 데코레이터를 선언하면 데코레이터로 구현된 코드를 함께 실행한다.
데코레이터에는 여러 종류가 있는데,
클래스 데코레이터 → 클래스의 생성자에 적용되어 클래스를 읽거나 수정
@reportableClassDecorator
class ButReport {
type = "report";
title: string;
(...)
메서드 데코레이터 → 메서드의 속성 설명자에 적용되고 메서드의 정의를 읽거나 수정
class Greeter {
@HandleError()
hello() {
throw new Error('error occurred');
}
(...)
접근자 데코레이터 → 접근자의 속성 설명자 적용되고 접근자의 정의를 읽거나 수정
class Person {
constructor(private name: string) {}
@Enumerable(true)
get getName() {
return this.name;
}
(...)
속성 데코레이터 → 속성의 정의를 읽음
function format(formatString: string) {
return function (target: any, propertyKey: string): any {
let value = target[propertyKey];
function getter() {
return `${formatString} ${value}`;
(...)
}
(...)
class Greeter {
@format('Hello')
greeting: string;
}
매개변수 데코레이터 → 매개변수의 정의를 읽음
function MinLength(min: numebr) {
return function (target: any, propertyKey: string, parameterIndex: number) {
target.validators = {
minLength: function (args: string[]) {
return args[parameterIndex].length >= min;
}
}
}
}
(...)
class User {
private name: string;
@Validate
setName(@MinLength(3) name: string) {
this.name = name;
}
}
3. Controller
들어오는 요청을 받고 처리된 결과를 응답으로 돌려주는 인터페이스 역할
‘nest g controller [name]’ 명령어로 컨트롤러를 생성할 수 있다.
(CRUD 보일러플레이트 코드를 한 번에 생성하려면 ‘nest g resource [name]’ 입력)
라우팅
라우팅 경로는 @Get 데커레이터의 인수로 관리 가능한데, @Controller 데코레이터에도 인수를 전달해서 라우팅 경로의 접두어(prefix)를 지정할 수 있다.
@Controller('app')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/hello')
getHello(): string {
return this.appService.getHello();
}
}
위와 같은 코드가 나오면, http://localhost:3000/app/hello 경로로 접근해야한다.
(라우팅 패스는 와일드 카드를 이용하여 작성 가능
→ @Get(’he*llo’)라면 helo, hello, he__lo 경로로 모두 접근이 가능하다)
@Req
Nest는 요청과 함께 전달되는 데이터를 핸들러가 다룰 수 있는 객체로 변환하는데, 이렇게 변환된 객체는 @Req 데코레이터를 이용하여 다룰 수 있다.
@Get()
getHello(@Req() req: Request): string {
console.log(req);
return this.appService.getHello();
}
@Header
응답에 커스텀 헤더를 추가하고 싶으면 @Header 데코레이터를 사용하면 된다.
import { Header } from '@nestjs/common';
@Header('Custom', 'Test Header')
@Get(':id')
findOneWithHeader(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Redirect
서버가 요청을 처리한 후 요청을 보낸 클라이언트를 다른 페이지로 이동시키고 싶을 때 @Redirect 데코레이터를 사용해서 구현할 수 있다. (두 번째 인수는 상태코드)
import { Redirect } form '@nestjs/common';
@Redirect('<https://nestjs.com>', 301)
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
라우트 매개변수
라우트 매개변수를 전달받으려면, 객체로 한번에 받거나 라우팅 매개변수를 따로 받으면 된다.
// 객체로 한 번에 받는 방법
@Delete(':/userId/memo/:memoId')
deleteUserMemo(@Param() params: { [key: string]: string}) {
return 'userId: ${params.userId}, memoId: ${params.memoId}';
}
// 라우팅 매개변수를 따로 받는 방법
@Delete(':userId/memo/:memoId')
deleteUserMemo(
@Param('userId') userId: string,
@Param('memoId') memoId: string,
) {
return 'userId: $userId}, memoID: ${memoId}';
}
하위 도메인 라우팅 기법
현재 회사가 사용하는 도메인은 example.com, API 요청은 api.example.com으로 받는다면
먼저 app.controller.ts에서 ApiController가 먼저 처리되도록 순서를 수정
@Module({
controllers: [ApiController, AppController],
(...)
})
export class AppModule { }
@Controller 데코레이터는 ControllerOptions 객체를 인수로 받는데, host 속성에 하위 도메인을 작성
@Controller({ host: 'api.example.com' }) // 하위 도메인 요청 처리 설정
export class ApiController {
@Get() // 같은 루트 경로
index(): string {
return 'Hello, API'; // 다른 응답
}
}
@HostParam 데코레이터를 이용하면 서브 도메인을 변수로 받을 수 있는데, 이 방법으로 API를 버전별로 분리할 수 있다.
@Controller({ host: ':version.api.localhost' })
export class ApiController {
@Get()
index(@HostParam('version') version: string): string {
return 'Hello, API ${version}';
}
}
DTO
POST, PUT, PATCH 요청은 처리에 필요한 데이터를 함께 실어 보내는데, 이 페이로드를 본문(body)라고 함
→ NestJS는 DTO(Data Transfer Object, 데이터 전송 객체)가 구현되어 있어 본문을 다루기 쉬움
export class CreateUserDTO {
name: string;
email: string;
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
const { name, email } = createUserDto;
return '${name}, ${email}';
}
4. Provider
앱이 제공하고자 하는 핵심 기능(비즈니스 로직)을 수행하는 역할
→ Service, Repository, Factory, Helper와 같은 형태로 구현 가능
의존성 주입(dependency injection, DI)
@Controller('users')
export class UsersController {
constructor(private readonly userService: UsersService) {}
...
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
컨트롤러에 비즈니스 로직을 직접 수행하지 않고, 컨트롤러에 연결된 UsersService 클래스에서 수행
→ UsersService는 UsersController의 생성자에서 주입받아, UsersService라는 객체 멤버 변수에 할당되어 사용
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
(...)
→ UsersService 클래스에 @Injectable 데코레이터를 선언함으로써 다른 어떤 Nest 컴포넌트에서도 주입할 수 있는 Provider가 된다.
@Module({
...
providers: [UsersService]
})
export class UsersModule {}
→ Provider 인스턴스 역시 모듈에서 사용할 수 있도록 등록을 해줘야한다.
만약 상속 관계에 있는 자식 클래스를 주입받아 사용하고 싶다면?
→ 부모 클래스에서 필요한 provider를 super()를 통해 전달해줘야 한다.
→ 아니면 속성 기반 provider를 사용하면 된다
export class BaseService {
@Inject(ServiceA) private readonly serviceA: ServiceA;
(...)
doSomeFuncFromA(): string {
return this.serviceA.getHello();
}
}
스코프
Node.js에서 싱글턴 인스턴스를 사용하는 것은 안전한 방식
→ 요청으로 들어오는 모든 정보를 공유할 수 있다
GraphQL 애플리케이션의 요청별 캐싱을 한다던가, 요청을 추적하거나, 멀티테넌시를 지원하려면 요청 기반으로 생명주기를 제한 해야함
→ controller와 provider에 스코프 옵션을 주어 생명주기를 지정하는 방법이 있음
DEFAULT: 싱글턴 인스턴스가 전체 애플리케이션에서 공유.
→ 인스턴스 수명 = 애플리케이션 생명주기
→ 애플리케이션이 부트스트랩 과정을 마치면 모든 싱글턴 provider의 인스턴스 생성
→ 따로 선언하지 않으면 DEFAULT 적용
REQUEST: 들어오는 요청마다 별도의 인스턴스 생성
→ 요청 처리 후 인스턴스는 garbage collected 됨
TRANSIENT: 이 스코프를 지정한 인스턴스는 공유되지 않음
→ 이 provider를 주입하는 각 컴포넌트는 새로 생성된 전용 인스턴스를 주입 받게 됨
가능하면 DEFAULT 스코프를 사용하는 것 권장
// provider에 스코프 적용
@Injectable({ scope: Scope.REQUEST })
// controller에 스코프 적용
export declare function Controller(options: ControllerOptions): ClassDecorator;
export interface ControllerOptions extends ScopeOptions, VersionOptions {
path?: string | string[];
host?: string | RegExp | Array<string | RegExp>;
}
export interface ScopeOptions {
scope?: Scope;
}
커스텀 프로바이더
- Nest 프레임워크가 만들어주는 인스턴스나 캐시된 인스턴스 대신 인스턴스를 직접 생성하고 싶은 경우
- 여러 클래스가 의존관계에 있을 때 이미 존재하는 클래스를 재사용하고자 할 때
- 테스트를 위해 모의 버전으로 프로바이더를 재정의하려는 경우
→ 커스텀 프로바이더를 사용하면 좋다.
밸류 프로바이더: provide와 useValue 속성 사용
클래스 프로바이더: useClass 속성 사용
팩터리 프로바이더: useFactory 속성 사용
5. Module
여러 컴포넌트를 조합하여 좀 더 큰 작업을 수행할 수 있게 하는 단위
NestJS는 하나의 루트 모듈이 존재하고 이 루트 모듈은 다른 모듈들로 구성됨
@Module 데코레이터를 사용하고, 인수로 ModuleMetaData를 받음
export declare function Module(metadata: ModuleMetadata): ClassDecorator;
export interface ModuleMetaData {
imports?: Array<Type<any> | DynamicModule |
Promise<DynamicModule> | ForwardReference>;
controllers?: Type<any>[];
providers?: Provider[];
exports?: Array<DynamicModule | Promise<DynamicModule> | stirng
| symbol | Provider | ForwardReference | Abstract<any> | Function>;
}
만약 A 모듈에서 B 모듈을 가져오고, C 모듈이 A 모듈을 가져왔다고 가정
→ C 모듈이 B 모듈을 사용하도록 하고 싶다면 가져온 모듈을 내보내야 함
만약 전역 모듈을 만들고 싶으면, @Global 데코레이터만 선언하면 된다.
@Global()
@Module({
providers: [CommonService],
exports: [CommonService],
})
export class CommonModule { }
6. 동적 모듈
모듈이 생성될 때 동적으로 정해지는 변수
→ 호스트 모듈(provider, controller와 같은 컴포넌트를 제공하는 모듈)을 가져다 쓰는 소비 모듈에서 호스트 모듈을 생성할 때 값을 설정하는 방식
ConfigModule: 실행 환경에 따라 서버에 설정되는 환경 변수를 관리하는 모듈
dotenv: 각 환경 변수를 .env 확장자를 가진 파일에 저장해두고 서버가 구동될 때 해당 파일을 읽어 환경 변수로 설정해주는 라이브러리
(AWS Secret Manager를 활용하면 프로비저닝 과정에서 환경 변수를 넣어줄 수 있다)
@nestjs/config 패키지를 활용해서 ConfigModule을 동적으로 생성 할 수 있다
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot()],
...
})
export classs AppModule { }
forRoot 메서드: DynamicModule을 리턴하는 정적 메서드
커스텀 Config 파일 작성
DatabaseConfig, EmailConfig와 같이 의미 있는 단위로 묶어서 처리하고 싶다면 @nestjs/config 패키지에서 제공하는 ConfigModule을 이용하여 구현하면 된다.
import { registerAs } from "@nestjs/config";
export default registerAs('email', () => ({
service: processs.env.EMAIL_SERVICE,
auth: {
user: process.env.EMAIL_AUTH_USER,
pass: process.env.EMAIL_AUTH_PASSWORD,
},
baseUrl: process.env.EMAIL_BASE_URL,
}));
동적 ConfigModule 등록
.env 파일을 루트 경로가 아니라 src/config/env 디렉토리에 모아서 관리하면 되는데, Nest 기본 빌드 옵션은 .ts 파일 외의 에셋은 제외하도록 되어 있다.
→ .env 파일을 out 디렉터리(dist 디렉터리)에 복사할 수 있도록 nest-cli.json에서 옵션을 바꿔줘야 한다.
{
..
"compiloerOptions": {
"assets": [
{
"include": "./config/env/*.env",
"outDir": "./dist"
}
]
}
}
AppModule에 ConfigModule을 동적 모듈로 등록하기
@Module({
imports: [
UsersModule,
ConfigModule.forRoot({
envFilePath: ['${__dirname}/config/env/.${process.env.NODE_ENV}.env'],
load: [emailConfig],
isGlobal: true,
validationSchema,
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
참고로 validationSchema는 유효성 검사 라이브러리 ‘joi’를 활용하면 된다
import * as Joi from 'joi';
export const validationSchema = Joi.object({
EMAIL_SERVICE: Joi.string()
.required(),
(...)
'Study > NestJS' 카테고리의 다른 글
| [‘NestJS로 배우는 백엔드 프로그래밍’ 정리] Pipe, Middleware, Guard, Interceptor (0) | 2025.10.09 |
|---|