나는 풀스택 개발자 하고싶다. 현실적으로 풀스택 개발이 힘든 건 알지만.. 그래도 둘 다 할 수 있으면 좋으니깐... 다음주부터 코드캠프에서 react 관련 새로운 프로젝트를 진행한다. 이 때, 나는 ts기반으로 프론트 next + 백엔드 nest 둘 다 사용해서 프로젝트를 진행해보려고 한다. next는 어느정도 만질 줄 아는데 nest는 한 번도 해보지 못했다. 이번 추석 기간에 짧은 시간 내로 nest를 한 번 찍먹해보려고 한다. graphql까지...
NestJS는 Node.js 환경에서 효율적이고 확장 가능한 서버 측 애플리케이션을 구축하기 위한 프레임워크다. TypeScript로 작성되었으며, 객체 지향 프로그래밍(OOP), 함수형 프로그래밍(FP), 함수형 반응형 프로그래밍(FRP) 등 최신 자바스크립트 기능을 적극적으로 사용하여 모듈화, 테스트 가능성, 유지보수성이 뛰어난 애플리케이션을 개발할 수 있게 해준다.
NestJS 특징
- Modular Architecture : 모듈화된 아키텍처로 소프트웨어의 확장과 관리를 유연하게 해준다.
- Typescript Support : TS를 기본으로 지원해서 강력한 타이핑을 제공해주며 유지보수의 편리함을 제공한다.
- Dependency Injection : 프레임워크 자체적으로 강력한 의존성 주입 시스템을 제공하여 효율적인 의존성 관리가 가능하다.
- REST API & GraphQL : 네이티브하고 REST API와 GraphQL을 모두 지원한다.
- MSA support : 마이크로서비스 아키텍처를 고려해서 설계되었기 때문에 분산화된 시스템 설계에 매우 강력하다.
CLI는 다음과 같다
$ npm i -g @nestjs/cli $ nest new project-name
CLI를 통해 nest를 만들었으면, src 폴더를 하나씩 확인해보겠다.
src내에는 controller / module / service / main으로 구성되어있다. nestjs가 이렇게 구성되는 이유는 유지보수성, 확장성, 테스트 용이성 때문이다.
컨트롤러는 클라이언트로부터 들어오는 HTTP 요청을 처리하고 요청에 따라 적절한 서비스 계층의 메서드를 호출하여 응답을 반환하는 역할을 한다. URL 경로(라우트)와 HTTP 메서드(GET POST PUT DELETE)를 매핑하여 어떤 요청이 들어왔을 때, 어떤 메서드가 실행될 지를 결정한다.
controller는 HTTP 요청을 처리하고, 데코레이터(@Controller / @Get / @Post)를 사용하여 특정 경로와 메서드들을 정의해 Nest로 들어오는 요청을 올바른 핸들러 메서드로 라우팅할 수 있다. 또한, 컨트롤러에서는 일반적으로 비즈니스 로직을 구현하지 않는다. 주로 service.ts에서 비즈니스 로직을 처리하는데, 서비스를 주입받아 사용하며, 요청 데이터를 서비스로 전달하고 서비스로부터 결과를 받아 응답으로 반환하는 역할을 한다.
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
위는 실제 app.controller.ts
부분인데, @controller() 인자로 url 라우터를 받아 관리하고, 내부에서 메서드를 정의하여 해당 요청이 들어오면 요청 데이터를 서비스로 위임하고 서비스로부터 결과를 받아 응답하는 역활을 한다.
예로 들면 현재 요청 URL은 "/"이고, http 요청 메서드는 get이라고 가정했을 때, this.appService.getHello()를 호출하여 비즈니스 로직을 처리한다.
데코레이터 | 설명 |
---|---|
@Controller() | 컨트롤러 클래스를 정의하며 기본 경로를 설정. |
@Redirect() | 요청을 다른 URL로 리다이렉션. |
@Param() | 요청 경로의 매개변수를 가져옴. (/users/:id 같은 URL의 id 추출) |
데코레이터 | 설명 |
---|---|
@Body() | 요청 본문(body) 데이터를 가져옴. |
@Query() | URL 쿼리 파라미터를 가져옴. (?key=value 같은 쿼리) |
@Param() | 경로 매개변수(Route Parameter)를 가져옴. |
@Headers() | 요청 헤더(headers) 데이터를 가져옴. |
@Ip() | 요청 클라이언트의 IP 주소를 가져옴. |
@Session() | 세션 객체를 가져옴. (세션이 활성화된 경우에만 사용 가능) |
@Req() | 원본 요청 객체(Express 또는 Fastify)를 가져옴. |
@Res() | 원본 응답 객체(Express 또는 Fastify)를 가져옴. |
데코레이터 | 설명 |
---|---|
@UseGuards() | 특정 경로에 가드(Guard)를 적용. (예: 인증/권한 검사) |
@UseInterceptors() | 인터셉터를 적용하여 요청/응답 흐름을 제어. |
@SetMetadata() | 커스텀 메타데이터를 설정하여 Guard나 Interceptor에서 활용 가능. |
데코레이터 | 설명 |
---|---|
@ValidationPipe() | DTO에서 유효성 검사를 수행하기 위해 사용. (자동으로 사용 가능) |
@Transform() | 클래스 변환기(Class-transformer)에서 데이터 변환. |
데코레이터 | 설명 |
---|---|
@HttpCode() | 응답 상태 코드를 설정. (기본값 대신 특정 코드 사용 가능) |
@Header() | 응답 헤더 설정. |
@Render() | 템플릿 렌더링 시 사용. |
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
appService: AppService
constructor(appService: AppService) {
this.appService = appService
}
Typescript에서는 private 접근 제한자를 사용할 수 있는데, 접근 제한자를 사용하면 parameter를 class 내 property로 암묵적으로 변환되어 사용할 수 있다.
NestJS가 컨트롤러의 유닛 테스트를 작성하는 파일이다. 주로 JEST를 이용하여 컨트롤러가 올바르게 수행하는 지 확인한다. 프론트엔드와 다르게 백엔드는 실질적으로 보여지는 게 존재하지 않기 때문에, unit 하나당 테스트를 진행해야한다. 그 때 주로 service.spec.ts
를 이용하여 테스트를 진행한다. 주요 문법들은 추후 jest 편으로 따로 뺴겠다. 이번에는 그냥 이 ts가 하는 역활에 대해서만 알고 넘어가겠다.
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
controller는 http 요청과 url을 가지고 네트워크 응답을 처리한다면 실제 비즈니스 로직은 service에서 처리한다. service에는 중요한 개념이 존재하는데 바로 의존성 주입(Dependency Injection)이다. 클래스에서 다른 클래스의 객체가 필요할 때, 직접 해당 객체를 생성또는 접근하는 것 옳지 않는 방법이므로 의존성 주입이라는 개념을 통해 다른 클래스에서 참조할 수 있게 해준다. 의존성 주입을 사용하면 클래스가 다른 클래스의 구체적인 인스턴스에 의존하지 않고, 외부에서 주입받은 인스턴스를 사용하게 된다.
다른 클래스에서 해당 클래스를 의존하게 하려면 @Injectable()라는 데코레이터를 사용한다.
import { Injectable } from '@nestjs/common';
@Injectable() // 의존성 주입을 하기 위해 Injectable 데코레이터를 선언한다
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
당연히 export와 import를 사용해서 접근할 수 있다. 하지만 의존성 주입(Dependency Injection)을 사용하면 의존도 측면이나, 테스트 측면에서 유용하다. export를 이용하여 클래스를 불러오면 인스턴스를 생성하게 되므로 클래스가 변경되면 그에 따라 생성된 인스턴스도 변경되므로 새롭게 다시 코드를 짜야한다. 하지만 의존성 주입을 사용하게 되면 클래스가 변경되어도 영향을 미치지 않는다.
또한 외부에서 클래스를 참조하려면 provider에서 의존성 클래스를 등록한 후, 의존성 주입 받고 싶은 곳의 constructor에 ts 타입 선언하는 것처럼 사용한다.
// controllers/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from '../services/app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {} // 생성자 주입을 통해 AppService를 주입받음
@Get()
getHello(): string {
return this.appService.getHello(); // 주입받은 AppService의 메서드 호출
}
}
Nest.js는 모듈화된 아키텍처를 사용하고 있으며 모듈에는 controller, service, provider, middleware, interceptor, guard, etc 등 여러개로 분리된 구성요소들을 한 개의 모듈로 묶어주는 역활을 한다. 모듈화된 아키텍처를 통해 애플리케이션의 확장성과 유지보수성을 높이며 가각의 모듈이 독립적으로 관리될 수 있도록 돕는다.
@module
데코레이터를 이용하여 현재 파일이 module 파일임을 명시해주고, 모듈 내에는 import
를 이용하여 다른 모듈의 접근할 수 있도록 돕고, controller
는 http 요청을 처리하는 역활을 하며, 각 요청을 적절한 서비스로 라우팅하게 해준다. provider
는 현재 모듈에서 사용할 서비스나 프로바이더를 정의한다. 비즈니스 로직을 수행하고, 데이터베이스와의 상호작용을 관리한다. 세개 뿐만 아니라 전역 모듈 / 미들웨어 / 가드 / 인터셉터 등을 설정할 수 있다.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
기존에는 class를 생성하고 class내에 있는 method를 사용하려면, 인스턴스를 직접 생성하여한다.
class UserService {
findAll() {
return ['User1', 'User2', 'User3'];
}
}
// 인스턴스 생성 후 사용
const userService = new UserService();
console.log(userService.findAll());
하지만 이는 중복 코드를 줄일 수 있다는 장점이 존재하지만, 동일한 인스턴스를 여러 곳에서 사용하려면 매번 생성해야하고(메모리 누수), 클래스가 변경되면 해당 클래스를 참조하는 모든 코드에서 인스턴스를 다시 초기화해야하는 문제가 발생한다.
이를 해결하기 위해 NestJS에서는 DI(Dependency Injection)을 도입했는데 이는 Instance를 직접 생성하지 않고 외부에서 필요한 Instance를 주입받는 방식이다. NestJS는 내부적으로 IoC(Inversion of Control) 컨테이너를 활용하여 DI를 구현한다.
기존방식은 개발자가 직접 클래스를 생성하고 인스턴스를 관리했다. 각 클래스가 자신이 필요한 의존성을 직접 제어한다는 것이다. 하지만 NextJS에서는 클래스가 의존성을 직접 생성하거나 관리하지 않고 IoC 컨테이너가 클래스 생성 및 의존성 관리를 담당한다.
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Nest Framework",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"start:debug",
"--",
"--inspect-brk"
],
"autoAttachChildProcesses": true,
"restart": true,
"sourceMaps": true,
"stopOnEntry": false,
"console": "integratedTerminal",
}
]
}