icon

마이크로서비스 개념과 NestJS 적용


 마이크로서비스 아키텍처는 대규모 애플리케이션을 작고 독립적인 서비스로 분할하여 개발 및 배포하는 접근 방식입니다.

 이는 모놀리식 아키텍처와 대비되는 개념으로, 각 서비스가 자체 프로세스에서 실행되고 경량 메커니즘으로 통신합니다.

마이크로서비스 vs 모놀리식 아키텍처

 장점

  1. 확장성 : 개별 서비스 단위로 독립적 확장 가능
  2. 유연성 : 기술 스택을 서비스별로 선택 가능
  3. 장애 격리 : 한 서비스의 장애가 전체 시스템에 영향을 미치지 않음
  4. 배포 용이성 : 개별 서비스 단위의 빠른 배포 가능

 단점

  1. 복잡성 증가 : 분산 시스템 관리의 어려움
  2. 네트워크 지연 : 서비스 간 통신으로 인한 성능 저하 가능성
  3. 데이터 일관성 : 분산 트랜잭션 관리의 어려움
  4. 테스팅 복잡성 : 통합 테스트의 어려움 증가

NestJS의 마이크로서비스 적합성

 NestJS는 다음과 같은 특징으로 마이크로서비스 구현에 적합합니다.

  1. 모듈화된 구조 : 서비스 분리와 재사용성 향상
  2. 의존성 주입 : 느슨한 결합과 테스트 용이성
  3. 다양한 전송 계층 지원 : TCP, Redis, MQTT 등
  4. 내장 패턴 : 서비스 간 통신을 위한 다양한 패턴 제공

NestJS 마이크로서비스 모듈 소개

 @nestjs/microservices 모듈의 주요 특징

  1. 다양한 전송 계층 지원
  2. 하이브리드 애플리케이션 구축 가능 (HTTP + 마이크로서비스)
  3. 장애 허용 및 로드 밸런싱 기능
  4. 쉬운 확장성과 플러그인 아키텍처

마이크로서비스 생성 및 구성

  1. 의존성 설치
npm install @nestjs/microservices
  1. 마이크로서비스 생성
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module';
 
async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.TCP,
      options: { host: 'localhost', port: 3001 },
    },
  );
  await app.listen();
}
bootstrap();
  1. 컨트롤러 구현
import { Controller } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';
 
@Controller()
export class MathController {
  @MessagePattern({ cmd: 'sum' })
  accumulate(data: number[]): number {
    return (data || []).reduce((a, b) => a + b);
  }
}

서비스 디스커버리와 로드 밸런싱

 NestJS는 기본적으로 클라이언트 측 서비스 디스커버리를 지원합니다.

const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
  transport: Transport.TCP,
  options: {
    host: 'localhost',
    port: 3001,
    clientsOptions: [
      { host: 'localhost', port: 3001 },
      { host: 'localhost', port: 3002 },
    ],
  },
});

 로드 밸런싱은 라운드 로빈 방식으로 자동 수행됩니다.

마이크로서비스 간 통신 패턴

  1. 동기식 (요청-응답)
@Client({ transport: Transport.TCP })
client: ClientProxy;
 
@Get()
async execute() {
  const pattern = { cmd: 'sum' };
  const payload = [1, 2, 3];
  return this.client.send<number>(pattern, payload);
}
  1. 비동기식 (이벤트 기반)
@EventPattern('user_created')
handleUserCreated(data: any) {
  // 이벤트 처리 로직
}
 
// 이벤트 발행
this.client.emit<any>('user_created', { userId: 1, name: 'John' });

분산 트랜잭션 관리

 NestJS에서는 Saga 패턴을 구현하여 분산 트랜잭션을 관리할 수 있습니다.

@Injectable()
class OrderSaga {
  @Saga()
  orderProcess = (events$: Observable<any>): Observable<ICommand> => {
    return events$.pipe(
      ofType(OrderCreatedEvent),
      map((event) => new ProcessPaymentCommand(event.orderId)),
      catchError((error) => of(new CancelOrderCommand(event.orderId)))
    );
  }
}

마이크로서비스 테스팅

 단위 테스트

describe('MathController', () => {
  let controller: MathController;
 
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [MathController],
    }).compile();
 
    controller = module.get<MathController>(MathController);
  });
 
  it('should accumulate values', () => {
    expect(controller.accumulate([1, 2, 3])).toBe(6);
  });
});

 통합 테스트

describe('Math Microservice (e2e)', () => {
  let app;
 
  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
 
    app = moduleFixture.createNestMicroservice({
      transport: Transport.TCP,
    });
    await app.init();
  });
 
  it('should return sum of numbers (TCP)', () => {
    return app
      .inject()
      .send({ cmd: 'sum' }, [1, 2, 3])
      .then(result => {
        expect(result).toBe(6);
      });
  });
 
  afterAll(async () => {
    await app.close();
  });
});

Best Practices와 주의사항

  1. 서비스 경계 정의 : 도메인 주도 설계(DDD) 원칙 활용
  2. 데이터 관리 : 각 서비스의 독립적 데이터 저장소 사용
  3. 통신 프로토콜 : gRPC 고려 (성능 최적화)
  4. 로깅 및 모니터링 : 분산 로깅 시스템 구축 (예 : ELK 스택)
  5. 보안 : 서비스 간 통신 암호화 및 인증 구현
  6. 버전 관리 : API 버전 관리 전략 수립
  7. 문서화 : API 문서 자동화 (예 : Swagger 통합)
  8. 컨테이너화 : Docker 활용한 배포 일관성 확보
  9. 오케스트레이션 : Kubernetes 등의 도구 활용
  10. 장애 대응 : 서킷 브레이커 패턴 구현
import {
  Inject,
  Injectable,
  OnModuleDestroy,
  OnModuleInit,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { CircuitBreaker } from 'opossum';
 
@Injectable()
export class UserService implements OnModuleInit, OnModuleDestroy {
  private circuitBreaker: CircuitBreaker;
 
  constructor(@Inject('USER_SERVICE') private client: ClientProxy) {}
 
  onModuleInit() {
    this.circuitBreaker = new CircuitBreaker(this.getUserById, {
      timeout: 3000,
      errorThresholdPercentage: 50,
      resetTimeout: 30000,
    });
  }
 
  onModuleDestroy() {
    this.circuitBreaker.shutdown();
  }
 
  getUserById(id: string) {
    return this.client.send({ cmd: 'get_user' }, { id });
  }
 
  async getUser(id: string) {
    try {
      return await this.circuitBreaker.fire(id);
    } catch (error) {
      // 폴백 로직
    }
  }
}

 NestJS를 사용한 마이크로서비스 아키텍처 구현은 확장성과 유연성이 높은 시스템을 구축할 수 있게 해줍니다. 모듈화된 구조와 의존성 주입 시스템은 서비스의 독립성을 보장하며, 다양한 전송 계층 지원은 서비스 간 통신을 효율적으로 구현할 수 있게 합니다.

 서비스 디스커버리와 로드 밸런싱 기능을 활용하면 시스템의 확장성과 가용성을 향상시킬 수 있습니다. 동기식과 비동기식 통신 패턴을 적절히 조합하여 사용하면 다양한 요구사항에 대응할 수 있습니다.

 분산 트랜잭션 관리는 마이크로서비스 아키텍처에서 가장 까다로운 부분 중 하나입니다. Saga 패턴을 구현하여 데이터 일관성을 유지하면서도 서비스 간 결합도를 낮출 수 있습니다.

 테스팅 전략은 단위 테스트부터 통합 테스트, E2E 테스트까지 다양한 수준에서 이루어져야 합니다. NestJS의 테스팅 모듈을 활용하면 각 서비스와 전체 시스템의 동작을 효과적으로 검증할 수 있습니다.

 마지막으로, 마이크로서비스 아키텍처를 성공적으로 구현하기 위해서는 서비스 경계 정의, 데이터 관리, 통신 프로토콜 선택, 로깅 및 모니터링, 보안, 버전 관리 등 다양한 측면을 고려해야 합니다. 컨테이너화와 오케스트레이션 도구를 활용하면 배포와 운영을 효율적으로 관리할 수 있으며, 장애 대응을 위한 패턴을 구현하여 시스템의 안정성을 높일 수 있습니다.

 NestJS의 마이크로서비스 모듈은 이러한 복잡한 요구사항을 효과적으로 다룰 수 있는 도구와 패턴을 제공합니다. 하지만 마이크로서비스 아키텍처의 복잡성을 고려할 때, 팀의 역량과 프로젝트의 요구사항을 신중히 평가하여 도입을 결정해야 합니다. 적절한 설계와 구현, 그리고 지속적인 모니터링과 최적화를 통해 확장 가능하고 유연한 시스템을 구축할 수 있습니다.