NestJS란?
서버사이드 애플리케이션
우리가 어떤 웹 사이트에 접속하면 서버로부터 HTML, CSS, JavaScript 같은 파일을 받아 브라우저에 화면을 표시하게 된다.
하지만 모든 사용자에게 항상 같은 화면만 보여주는 것은 아니다. 로그인한 사용자와 로그인하지 않은 사용자가 보는 화면은 다를 수 있고, 사용자의 권한이나 요청한 데이터에 따라서 응답 결과도 달라질 수 있다.
예를 들어 게시판 서비스를 생각해보자.
- 로그인하지 않은 사용자는 글 목록만 볼 수 있다.
- 로그인한 사용자는 글을 작성할 수 있다.
- 관리자는 다른 사용자의 글을 삭제할 수 있다.
이처럼 사용자의 상태, 권한, 요청 내용에 따라 다른 데이터를 만들어 응답하는 과정을 서버에서 처리한다. 이러한 역할을 담당하는 애플리케이션을 보통 서버사이드 애플리케이션, 백엔드 애플리케이션, 또는 간단히 서버라고 부른다.
Node.js 런타임에서도 아주 간단한 서버사이드 애플리케이션을 만들 수 있다.
// server.js
const http = require('http');
const server = http.createServer((req, res) => {
// 요청 정보 확인
console.log(`${req.method} ${req.url}`);
// 응답 헤더 설정
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
});
// 응답 본문 전송
res.end('Hello Node.js Server!');
});
server.listen(3000, () => {
console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
위 코드를 실행한 뒤 브라우저에서 localhost:3000에 접속하면 다음과 같은 응답을 확인할 수 있다.
Hello Node.js Server!
이 코드는 별도의 프레임워크 없이 Node.js의 기본 http 모듈만 사용해서 동작한다.
요청이 들어오면 서버는 요청 정보를 확인하고, 응답 헤더를 설정한 뒤, 클라이언트에게 응답 본문을 전송한다.
즉, 아주 단순하게 보면 서버사이드 애플리케이션은 다음과 같은 흐름으로 동작한다.
클라이언트 요청
↓
서버에서 요청 처리
↓
필요한 데이터 생성 또는 조회
↓
클라이언트에게 응답 반환
프레임워크
앞에서 본 것처럼 Node.js만으로도 서버를 만들 수 있다.
그렇다면 사람들은 왜 Express나 NestJS 같은 프레임워크를 사용할까?
간단한 서버라면 Node.js의 기본 기능만으로도 충분하다. 하지만 실제 서비스는 단순히 문자열 하나를 응답하는 수준에서 끝나지 않는다.
보통 서버에서는 다음과 같은 일들을 처리해야 한다.
- 요청 URL에 따라 다른 로직 실행
- HTTP 메서드별 처리
- 요청 데이터 검증
- 로그인 여부 확인
- 사용자 권한 확인
- 에러 처리
- 공통 응답 형식 관리
- 데이터베이스 연결
- 비즈니스 로직 분리
- 테스트하기 쉬운 구조 설계
프레임워크 없이 이런 기능을 모두 직접 구현하면 코드가 점점 복잡해진다.
예를 들어 /users, /posts, /comments 같은 API가 늘어나면 요청 URL을 직접 비교하면서 분기해야 한다.
if (req.url === '/users' && req.method === 'GET') {
// 사용자 목록 조회
} else if (req.url === '/posts' && req.method === 'GET') {
// 게시글 목록 조회
} else if (req.url === '/comments' && req.method === 'POST') {
// 댓글 작성
}
처음에는 간단해 보이지만 기능이 많아질수록 코드가 길어지고, 어디에 어떤 로직을 둬야 할지 애매해진다.
이 문제를 해결하기 위해 프레임워크를 사용한다.
프레임워크는 서버 애플리케이션을 만들 때 자주 필요한 구조와 기능을 미리 제공해준다. 개발자는 모든 것을 처음부터 직접 만들지 않고, 프레임워크가 정해둔 방식에 맞춰 필요한 기능을 구현하면 된다.
즉, 프레임워크는 다음과 같은 역할을 한다.
- 애플리케이션의 기본 구조를 제공한다.
- 반복적으로 필요한 기능을 제공한다.
- 코드 작성 방식에 일관성을 준다.
- 유지보수하기 쉬운 구조를 만들 수 있게 도와준다.
NestJS란?
NestJS는 효율적이고 확장 가능한 Node.js 서버사이드 애플리케이션을 만들기 위한 프레임워크다.
NestJS는 내부적으로 Express를 기본 HTTP 서버 프레임워크로 사용한다. 필요하다면 Fastify로 변경해서 사용할 수도 있다.
즉, NestJS는 Express 위에서 동작하면서 더 구조적인 애플리케이션 설계 방식을 제공하는 프레임워크라고 볼 수 있다.
Express가 자유도가 높은 프레임워크라면, NestJS는 정해진 구조를 제공하는 프레임워크에 가깝다.
NestJS에서는 애플리케이션을 보통 다음과 같은 단위로 나누어 작성한다.
- Module
- Controller
- Provider
- Service
이 구조 덕분에 요청을 받는 부분, 비즈니스 로직을 처리하는 부분, 데이터에 접근하는 부분을 명확히 분리할 수 있다.
NestJS의 특징
1. TypeScript 기반
NestJS는 TypeScript를 기본으로 지원한다.
TypeScript를 사용하면 변수, 함수, 객체의 타입을 명확하게 표현할 수 있다. 덕분에 코드 작성 단계에서 실수를 줄일 수 있고, 프로젝트 규모가 커져도 코드의 의도를 파악하기 쉬워진다.
@Get()
findAll(): string {
return 'Hello NestJS';
}
위 코드에서 findAll 메서드는 문자열을 반환한다는 것을 타입으로 명확히 표현하고 있다.
백엔드 애플리케이션에서는 요청 데이터, 응답 데이터, 데이터베이스 모델 등 다양한 데이터 구조를 다루기 때문에 TypeScript의 장점이 크게 드러난다.
2. Module 기반 구조
NestJS 애플리케이션은 Module을 중심으로 구성된다.
Module은 관련된 기능을 하나의 단위로 묶는 역할을 한다. 예를 들어 사용자 기능은 UsersModule, 게시글 기능은 PostsModule처럼 나눌 수 있다.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
위 코드에서 UsersModule은 사용자 기능과 관련된 UsersController와 UsersService를 하나로 묶고 있다.
이렇게 기능 단위로 모듈을 나누면 프로젝트가 커져도 각 기능의 책임을 분리하기 쉽다.
3. Controller
Controller는 클라이언트의 요청을 받는 역할을 한다.
예를 들어 사용자가 /users 경로로 요청을 보내면, 해당 요청을 어떤 메서드가 처리할지 Controller에서 정의한다.
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}
위 코드에서 @Controller('users')는 /users 경로의 요청을 처리한다는 의미다.
@Get()은 HTTP GET 요청을 처리한다는 의미다.
즉, 클라이언트가 GET /users 요청을 보내면 findAll() 메서드가 실행된다.
하지만 Controller에서 모든 로직을 처리하지는 않는다. Controller는 요청을 받고, 실제 비즈니스 로직은 Service에게 위임하는 것이 일반적이다.
4. Provider와 Service
NestJS에서 Provider는 의존성 주입이 가능한 객체를 의미한다.
대표적인 Provider가 바로 Service다.
Service는 주로 비즈니스 로직을 처리하는 역할을 한다.
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
findAll() {
return ['user1', 'user2'];
}
}
@Injectable() 데코레이터는 이 클래스가 NestJS의 의존성 주입 시스템에서 관리될 수 있음을 의미한다.
Controller는 Service를 직접 생성하지 않고 생성자를 통해 주입받는다.
constructor(private readonly usersService: UsersService) {}
이렇게 하면 Controller는 요청 처리에 집중하고, Service는 비즈니스 로직에 집중할 수 있다.
역할이 분리되기 때문에 코드가 더 명확해지고 테스트하기도 쉬워진다.
NestJS 요청 처리 흐름
NestJS의 요청은 보통 다음과 같은 흐름으로 처리된다.
Client
↓
Middleware
↓
Guard
↓
Interceptor
↓
Pipe
↓
Controller
↓
Service
↓
Repository / ORM
↓
Database
각 단계의 역할은 다음과 같다.
| 단계 | 역할 |
|---|---|
| Middleware | 요청 로깅, 공통 전처리 |
| Guard | 인증, 인가 확인 |
| Interceptor | 요청과 응답을 가로채서 공통 처리 |
| Pipe | 요청 데이터 검증 및 변환 |
| Controller | 요청을 받아 적절한 로직 호출 |
| Service | 비즈니스 로직 처리 |
| Repository / ORM | 데이터베이스 접근 |
| Database | 데이터 저장 및 조회 |
예를 들어 사용자가 게시글 목록을 조회하는 요청을 보냈다고 생각해보자.
GET /posts
그러면 NestJS에서는 대략 다음과 같은 일이 일어난다.
1. 클라이언트가 GET /posts 요청을 보낸다.
2. Middleware에서 요청 로그를 남긴다.
3. Guard에서 사용자의 인증 여부를 확인한다.
4. Pipe에서 요청 데이터가 올바른지 검증한다.
5. Controller에서 요청을 받는다.
6. Controller는 Service에게 게시글 목록 조회를 요청한다.
7. Service는 Repository 또는 ORM을 통해 DB에서 데이터를 조회한다.
8. 조회한 데이터를 Controller로 반환한다.
9. Interceptor가 응답 형식을 가공할 수 있다.
10. 최종 응답이 클라이언트에게 전달된다.
이처럼 NestJS는 요청이 들어왔을 때 어떤 순서로 처리되는지에 대한 구조를 제공한다.
덕분에 인증은 Guard에서, 데이터 검증은 Pipe에서, 비즈니스 로직은 Service에서 처리하는 식으로 책임을 분리할 수 있다.
NestJS 앱 만들어보기
NestJS 프로젝트는 Nest CLI를 사용하면 쉽게 생성할 수 있다.
npm i -g @nestjs/cli
Nest CLI를 설치한 뒤 다음 명령어로 프로젝트를 생성한다.
nest new hello-nest
프로젝트를 생성한 뒤 실행한다.
cd hello-nest
npm run start:dev
기본적으로 NestJS 서버는 localhost:3000에서 실행된다.
브라우저나 API 테스트 도구에서 다음 주소로 요청을 보내면 기본 응답을 확인할 수 있다.
http://localhost:3000
NestJS의 기본 애플리케이션 구조는 대략 다음과 같다.
src
├── app.controller.ts
├── app.service.ts
├── app.module.ts
└── main.ts
각 파일의 역할은 다음과 같다.
| 파일 | 역할 |
|---|---|
| main.ts | 애플리케이션을 실행하는 진입점 |
| app.module.ts | 루트 모듈 |
| app.controller.ts | 요청을 받는 컨트롤러 |
| app.service.ts | 비즈니스 로직을 처리하는 서비스 |
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
main.ts는 NestJS 애플리케이션을 실행하는 시작점이다.
NestFactory.create(AppModule)을 통해 루트 모듈인 AppModule을 기반으로 애플리케이션을 생성한다.
app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
AppModule은 애플리케이션의 루트 모듈이다.
여기서 사용할 Controller와 Provider를 등록한다.
app.controller.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();
}
}
AppController는 클라이언트의 요청을 받는다.
@Get() 데코레이터가 붙은 getHello() 메서드는 GET 요청을 처리한다.
app.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello NestJS!';
}
}
AppService는 실제 응답으로 보낼 문자열을 반환한다.
지금 예제는 매우 단순하지만, 실제 애플리케이션에서는 이 Service에 비즈니스 로직이 들어간다.
정리
Node.js만으로도 서버사이드 애플리케이션을 만들 수 있다. 하지만 실제 서비스에서는 라우팅, 인증, 검증, 에러 처리, 데이터베이스 접근, 비즈니스 로직 분리 등 다양한 기능이 필요하다.
프레임워크는 이런 기능을 더 쉽게 구현할 수 있도록 기본 구조와 도구를 제공한다.
NestJS는 Node.js 서버사이드 애플리케이션을 구조적으로 만들 수 있게 도와주는 프레임워크다. Module, Controller, Provider, Service 같은 개념을 통해 역할을 분리하고, TypeScript 기반으로 안정적인 백엔드 애플리케이션을 만들 수 있다.
특히 프로젝트 규모가 커질수록 NestJS의 장점은 더 분명해진다.
요청을 받는 Controller, 비즈니스 로직을 처리하는 Service, 기능을 묶는 Module 구조 덕분에 코드의 책임이 명확해지고 유지보수하기 쉬운 서버 애플리케이션을 만들 수 있다.
참고
- NestJS 공식 문서
- NestJS 한글 문서: https://nestjs.burt.pe.kr/