마이크로서비스 개념과 NestJS 적용
6장에서는 GraphQL 서버를 구축하여 클라이언트와 서버 간의 효율적인 데이터 통신 방법을 살펴보았습니다. 이제 7장에서는 현대 소프트웨어 개발에서 점점 더 중요해지고 있는 아키텍처 스타일인 마이크로서비스(Microservices) 에 대해 다루고, 강력한 프레임워크인 NestJS를 어떻게 마이크로서비스 환경에 효과적으로 적용할 수 있는지 알아보겠습니다.
단일 거대 애플리케이션(Monolithic Application)은 개발 초기에는 빠르고 간단하지만, 규모가 커지고 복잡해질수록 유지보수, 확장, 배포에 어려움을 겪게 됩니다. 마이크로서비스는 이러한 문제를 해결하기 위해 애플리케이션을 작고 독립적인 서비스들로 분해하는 아키텍처 접근 방식입니다. 각 서비스는 특정 비즈니스 기능에 집중하고 독립적으로 개발, 배포, 확장될 수 있습니다.
마이크로서비스 아키텍처란?
마이크로서비스 아키텍처는 단일 애플리케이션을 작고 독립적인 서비스의 모음으로 개발하는 접근 방식입니다. 각 서비스는 자체 프로세스에서 실행되며, 잘 정의된 경량 통신 메커니즘(예: HTTP REST API, gRPC, 메시지 큐)을 사용하여 통신합니다.
마이크로서비스의 주요 특징
- 독립적인 배포: 각 서비스는 다른 서비스와 독립적으로 배포될 수 있습니다.
- 독립적인 개발: 각 서비스는 별도의 팀이 독립적으로 개발할 수 있습니다.
- 독립적인 확장: 특정 서비스의 부하가 높을 때 해당 서비스만 확장할 수 있습니다.
- 기술 스택 다양성: 각 서비스는 특정 요구사항에 가장 적합한 기술 스택(언어, 프레임워크, 데이터베이스)을 선택할 수 있습니다 (Polyglot Persistence/Programming).
- 강력한 응집도, 약한 결합도: 각 서비스는 특정 비즈니스 도메인에 대한 강력한 응집도를 가지며, 다른 서비스와의 결합도는 최소화됩니다.
- 장애 격리: 한 서비스의 장애가 전체 시스템에 미치는 영향을 제한합니다.
마이크로서비스의 장점
- 확장성: 특정 서비스만 유연하게 확장 가능
- 유지보수성: 서비스 단위가 작아 이해하고 수정하기 용이
- 배포 용이성: 독립적인 배포로 인한 빠른 릴리스 주기
- 기술 유연성: 서비스별 최적의 기술 스택 선택 가능
- 팀 자율성: 소규모 팀이 독립적으로 작업 가능
마이크로서비스의 단점
- 복잡성 증가: 분산 시스템 관리의 복잡성 (네트워크 지연, 장애 처리, 데이터 일관성)
- 운영 오버헤드: 더 많은 서비스, 배포 파이프라인, 모니터링 시스템 필요
- 데이터 일관성: 분산 트랜잭션 관리의 어려움
- 서비스 간 통신: 통신 메커니즘 선택 및 관리
- 디버깅 어려움: 여러 서비스에 걸친 문제 추적의 복잡성
NestJS가 마이크로서비스에 적합한 이유
NestJS는 마이크로서비스 아키텍처를 구축하는 데 매우 강력하고 적합한 프레임워크입니다.
- 모듈 기반 아키텍처: NestJS의 모듈 시스템은 애플리케이션을 기능별로 분리하고 구성하기 용이하게 하여, 자연스럽게 마이크로서비스의 경계를 정의하는 데 도움을 줍니다.
- 엔터프라이즈급 패턴 지원: NestJS는 컨트롤러, 프로바이더, 모듈 등 잘 알려진 엔터프라이즈 디자인 패턴을 적용하여, 복잡한 시스템의 구조를 명확하게 유지할 수 있습니다.
- 다양한 통신 방식 지원: NestJS는 마이크로서비스 간 통신을 위한 다양한 전송 계층(Transport Layer)을 내장하고 있습니다.
- TCP: 기본 제공, 가벼운 통신
- Redis: 메시지 브로커로 사용, Pub/Sub 지원
- Kafka: 고처리량 분산 메시징 시스템
- RabbitMQ: 강력한 메시지 큐 기능
- NATS: 고성능 메시징 시스템
- gRPC: 고성능, 언어 중립적인 RPC 프레임워크 (HTTP/2 기반)
- MQTT: IoT 환경에 최적화된 경량 메시징 프로토콜
- DI(Dependency Injection): 강력한 의존성 주입 시스템은 서비스 간의 결합도를 낮추고 테스트 용이성을 높입니다.
- TypeScript 기반: 타입 안전성을 제공하여 대규모 프로젝트에서 코드 품질과 유지보수성을 향상시킵니다.
- 통일된 개발 경험: 각 마이크로서비스가 NestJS로 구현될 경우, 개발자들은 일관된 아키텍처 패턴과 코딩 스타일을 유지할 수 있어 학습 곡선을 줄이고 생산성을 높일 수 있습니다.
NestJS 마이크로서비스 기본 구조와 통신 방식
NestJS에서 마이크로서비스를 구성하는 가장 기본적인 방법은 클라이언트(Consumer) 서비스와 서버(Provider) 서비스로 나누는 것입니다. 여기서는 가장 간단한 TCP 기반의 통신 예시를 통해 구조를 이해합니다.
시나리오: Users
서비스와 Orders
서비스가 있습니다. Orders
서비스가 주문을 처리하는 과정에서 Users
서비스에 사용자 정보를 요청해야 하는 경우.
사용자 서비스 구축
사용자 정보를 제공하는 독립적인 마이크로서비스를 생성합니다.
단계 1: 새 NestJS 프로젝트 생성 (또는 기존 프로젝트 내에 모듈로 분리)
마이크로서비스는 독립적인 프로젝트로 관리하는 것이 일반적입니다.
# 새로운 사용자 서비스 프로젝트 생성
nest new users-service --skip-install
cd users-service
npm install @nestjs/microservices
npm install
단계 2: main.ts
파일 수정 (마이크로서비스 서버 설정)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices'; // MicroserviceOptions, Transport 임포트
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.TCP, // TCP 전송 방식 사용
options: {
host: 'localhost', // 서비스가 바인딩될 호스트
port: 3001, // 서비스가 리스닝할 포트
},
});
await app.listen();
console.log('Users Microservice is listening on port 3001');
}
bootstrap();
NestFactory.createMicroservice()
: 일반적인 HTTP 서버 대신 마이크로서비스 애플리케이션을 생성합니다.transport: Transport.TCP
: TCP 프로토콜을 사용하여 메시지를 주고받도록 설정합니다. NestJS는 다양한Transport
옵션을 제공합니다.options
: 특정 전송 방식에 대한 추가 옵션을 설정합니다 (호스트, 포트 등).
단계 3: 사용자 마이크로서비스 컨트롤러 (핸들러) 구현
마이크로서비스에서는 @MessagePattern()
데코레이터를 사용하여 메시지를 처리하는 핸들러를 정의합니다.
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices'; // MessagePattern, Payload 임포트
import { UsersService } from './users.service';
interface User {
id: number;
name: string;
email: string;
}
@Controller() // 마이크로서비스 컨트롤러는 일반적으로 경로가 없습니다.
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// 'get_user_by_id' 메시지 패턴에 대한 핸들러
@MessagePattern('get_user_by_id')
getUserById(@Payload() id: number): User {
console.log(`Users Service: Received request for user ID: ${id}`);
// 실제로는 DB에서 조회
const user = this.usersService.findOne(id);
return user || null; // 사용자 없으면 null 반환
}
// 'create_user' 메시지 패턴에 대한 핸들러
@MessagePattern('create_user')
createUser(@Payload() userDto: { name: string; email: string }): User {
console.log(`Users Service: Received request to create user: ${JSON.stringify(userDto)}`);
const newUser = this.usersService.create(userDto);
return newUser;
}
}
@MessagePattern('pattern_name')
: 이 핸들러가 처리할 메시지의 패턴(이름)을 정의합니다. 클라이언트는 이 패턴을 사용하여 특정 핸들러를 호출합니다.@Payload()
: 메시지에서 전송된 데이터를 추출하여 메서드 인자로 주입합니다.
단계 4: 사용자 서비스 구현
import { Injectable } from '@nestjs/common';
interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
private nextId = 3;
findOne(id: number): User | undefined {
return this.users.find(user => user.id === id);
}
create(user: { name: string; email: string }): User {
const newUser = { id: this.nextId++, ...user };
this.users.push(newUser);
return newUser;
}
}
단계 5: UsersModule
구성
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
단계 6: AppModule
에 UsersModule
임포트
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
controllers: [],
providers: [],
})
export class AppModule {}
주문 서비스 구축
사용자 서비스의 기능을 호출하는 클라이언트(또는 다른 마이크로서비스)를 구축합니다.
단계 1: 새 NestJS 프로젝트 생성 (또는 기존 프로젝트 내에 모듈로 분리)
# 새로운 주문 서비스 프로젝트 생성
nest new orders-service --skip-install
cd orders-service
npm install @nestjs/microservices
npm install
단계 2: main.ts
파일 수정 (일반적인 HTTP 서버 유지)
주문 서비스는 클라이언트로부터 HTTP 요청을 받고, 내부적으로 사용자 서비스와 통신할 수 있습니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000); // HTTP 요청을 받을 포트
console.log('Orders Service is listening on port 3000 (HTTP)');
}
bootstrap();
단계 3: 클라이언트 프록시 생성 및 주입
ClientProxy
를 사용하여 다른 마이크로서비스에 메시지를 보냅니다. NestJS는 이를 위한 @Client()
데코레이터를 제공합니다.
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices'; // ClientsModule, Transport 임포트
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [
ClientsModule.register([
{
name: 'USERS_SERVICE', // 클라이언트 프록시의 토큰 (주입 시 사용)
transport: Transport.TCP, // Users Service와 동일한 전송 방식
options: {
host: 'localhost',
port: 3001, // Users Service가 리스닝하는 포트
},
},
]),
],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
ClientsModule.register()
: 하나 이상의 마이크로서비스 클라이언트를 등록합니다.name
: 이 클라이언트 프록시를 의존성 주입을 통해 참조할 때 사용할 토큰입니다.transport
&options
: 연결할 마이크로서비스 서버의 전송 방식과 옵션을 지정합니다.
단계 4: 주문 서비스 컨트롤러 (API Gateway 역할)
클라이언트로부터 HTTP 요청을 받고, USERS_SERVICE
프록시를 통해 사용자 서비스로 메시지를 보냅니다.
import { Controller, Get, Post, Body, Param, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; // ClientProxy 임포트
import { OrdersService } from './orders.service';
import { Observable } from 'rxjs'; // Observable 임포트 (RxJS)
import { map } from 'rxjs/operators';
interface User {
id: number;
name: string;
email: string;
}
@Controller('orders') // HTTP 엔드포인트
export class OrdersController {
constructor(
private readonly ordersService: OrdersService,
@Inject('USERS_SERVICE') private readonly usersClient: ClientProxy, // 'USERS_SERVICE' 클라이언트 주입
) {}
@Get(':orderId/user')
async getOrderUser(@Param('orderId') orderId: string): Promise<User | string> {
// 임시로 orderId를 사용자 ID로 사용한다고 가정
const userId = parseInt(orderId, 10);
// 'get_user_by_id' 패턴으로 사용자 서비스에 메시지 전송
// send() 메서드는 Observable을 반환합니다.
const userObservable: Observable<User> = this.usersClient.send('get_user_by_id', userId);
return userObservable.pipe(
map(user => {
if (!user) {
return `User for order ${orderId} not found in Users Service.`;
}
return user;
})
).toPromise(); // Observable을 Promise로 변환하여 HTTP 응답
}
@Post('/create-order-with-user')
async createOrderWithUser(@Body() orderDto: { userId: number; item: string }): Promise<any> {
const { userId, item } = orderDto;
// 사용자 서비스에 사용자 정보 요청 (Blocking call, 실제로는 비동기적으로 처리)
const user: User = await this.usersClient.send('get_user_by_id', userId).toPromise();
if (!user) {
return { message: `User with ID ${userId} not found.` };
}
const order = this.ordersService.createOrder(userId, item);
return { order, user };
}
}
@Inject('USERS_SERVICE')
:OrdersModule
에서 정의한USERS_SERVICE
클라이언트 프록시를 주입받습니다.this.usersClient.send('pattern', data)
: 마이크로서비스 서버로 메시지를 보냅니다. 첫 번째 인자는 메시지 패턴, 두 번째 인자는 전송할 데이터입니다.send()
는Observable
을 반환하므로pipe()
와toPromise()
를 사용하여 결과를 처리합니다.this.usersClient.emit('pattern', data)
: 이 메서드는 응답을 기다리지 않고 메시지를 발행만 할 때 사용합니다 (이벤트 기반 통신).
단계 5: 주문 서비스 구현
import { Injectable } from '@nestjs/common';
interface Order {
id: number;
userId: number;
item: string;
createdAt: Date;
}
@Injectable()
export class OrdersService {
private orders: Order[] = [];
private nextId = 1;
createOrder(userId: number, item: string): Order {
const newOrder = {
id: this.nextId++,
userId,
item,
createdAt: new Date(),
};
this.orders.push(newOrder);
return newOrder;
}
}
단계 6: AppModule
에 OrdersModule
임포트
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [OrdersModule],
controllers: [],
providers: [],
})
export class AppModule {}
실행 및 테스트
Users Service 시작
cd users-service
npm run start:dev
- 콘솔에
Users Microservice is listening on port 3001
메시지 확인
Orders Service 시작
cd orders-service
npm run start:dev
- 콘솔에
Orders Service is listening on port 3000 (HTTP)
메시지 확인
API 테스트 (Postman 또는 cURL)
- 사용자 정보 가져오기
GET http://localhost:3000/orders/1/user
- 응답:
{"id":1,"name":"Alice","email":"alice@example.com"}
(Users Service 콘솔에 요청 메시지 확인)
- 존재하지 않는 사용자 정보 가져오기
GET http://localhost:3000/orders/999/user
- 응답:
"User for order 999 not found in Users Service."
- 주문 생성과 사용자 정보 확인
POST http://localhost:3000/orders/create-order-with-user
- Headers:
Content-Type: application/json
- Body:
{"userId": 2, "item": "Laptop"}
- 응답:
{"order":{"id":1,"userId":2,"item":"Laptop","createdAt":"2023-06-23T...Z"},"user":{"id":2,"name":"Bob","email":"bob@example.com"}}
이 예시는 NestJS가 마이크로서비스 아키텍처를 얼마나 쉽게 구축할 수 있는지 보여줍니다. TCP 외에도 Redis, Kafka, gRPC 등 다양한 전송 계층을 사용하여 특정 요구사항에 맞는 통신 방식을 선택할 수 있습니다.
마이크로서비스는 복잡도를 증가시키지만, NestJS의 강력한 기능과 모듈성은 이러한 복잡도를 관리하고 확장 가능한 시스템을 구축하는 데 큰 도움을 줍니다.