본문 바로가기
Tech Notes

NestJS + gRPC 엔터프라이즈 아키텍처

by miracle-tech 2025. 12. 23.
728x90
반응형
NestJS + gRPC 엔터프라이즈 아키텍처 가이드
Architecture Guide

NestJS + gRPC 엔터프라이즈 아키텍처

확장 가능한 마이크로서비스 백엔드 구축을 위한 기본 골조

TypeScript • NestJS 10.x • gRPC • PostgreSQL • Zod

📋 개요

이 가이드는 NestJS와 gRPC를 활용한 엔터프라이즈급 백엔드 아키텍처의 기본 골조를 설명합니다. REST 대신 gRPC를 채택하여 타입 안전성과 성능을 확보하고, 계층형 아키텍처로 관심사를 명확히 분리합니다.

✅ 이 아키텍처의 장점
  • 타입 안전성: Proto → TypeScript 자동 생성
  • 성능: Binary 프로토콜로 빠른 직렬화
  • 확장성: 모듈 기반 구조
  • 테스트 용이: 계층 분리로 단위 테스트 쉬움
🛠 기술 스택
  • Runtime: Node.js 20 + TypeScript 5.4
  • Framework: NestJS 10.x + Fastify
  • Protocol: gRPC + Protocol Buffers
  • Database: PostgreSQL + TypeORM

🔄 요청 처리 흐름

클라이언트 요청은 다음 계층을 순차적으로 거쳐 처리됩니다.

Client Interceptor Guard Validation Controller Service Repository DB
계층 역할 예시
Interceptor 요청/응답 변환, 메타데이터 추출 UserMetadataInterceptor
Guard 인증/인가 검증 AuthGuard, RoleGuard
Pipe 데이터 변환 및 검증 ZodValidationPipe
Controller 요청 라우팅, gRPC 핸들러 @GrpcMethod 데코레이터
Service 비즈니스 로직 트랜잭션, 외부 API 호출
Repository 데이터 접근 추상화 TypeORM 쿼리

📁 폴더 구조

기능별 모듈 구조로 관심사를 분리합니다. 각 모듈은 독립적으로 동작할 수 있습니다.

src/
├── main.ts                     # 애플리케이션 진입점
├── app.module.ts               # 루트 모듈
│
├── config/                      # 환경 설정
│   └── app.config.ts
│
├── constants/                   # 상수 정의
│   ├── error-message.const.ts
│   └── inject-token.const.ts
│
├── modules/                     # 기능 모듈
│   ├── feature-a/              # 도메인 모듈 예시
│   │   ├── feature-a.module.ts
│   │   ├── controllers/
│   │   ├── services/
│   │   ├── repositories/
│   │   ├── entities/
│   │   ├── dtos/
│   │   ├── mappers/
│   │   ├── validations/        # Zod 스키마
│   │   └── protos/             # gRPC 정의
│   │
│   ├── database/               # DB 연결 설정
│   └── shared/                 # 공유 모듈
│       ├── guards/
│       ├── interceptors/
│       └── pipes/
│
└── utils/                       # 유틸리티 함수

gRPC 서비스 설정

1. Proto 파일 정의

서비스 인터페이스를 Protocol Buffers로 정의합니다.

example.proto
syntax = "proto3";
package example;

// 서비스 정의
service ExampleService {
  rpc GetItem(GetItemRequest) returns (GetItemResponse);
  rpc CreateItem(CreateItemRequest) returns (CreateItemResponse);
  rpc ListItems(ListItemsRequest) returns (ListItemsResponse);
}

// 메시지 정의
message GetItemRequest {
  string id = 1;
}

message GetItemResponse {
  string id = 1;
  string name = 2;
  int32 quantity = 3;
}

2. NestJS gRPC 부트스트랩

main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // gRPC 마이크로서비스 연결
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.GRPC,
    options: {
      package: 'example',
      protoPath: join(__dirname, './protos/example.proto'),
      url: '0.0.0.0:5000',
      maxReceiveMessageLength: 10 * 1024 * 1024, // 10MB
    },
  });

  await app.startAllMicroservices();
  await app.listen(3000);
}

3. Controller 구현

example.controller.ts
import { Controller, UseGuards } from '@nestjs/common';
import { GrpcMethod, Payload, Metadata } from '@nestjs/microservices';

@Controller()
export class ExampleController {
  constructor(private readonly exampleService: ExampleService) {}

  @GrpcMethod('ExampleService', 'GetItem')
  @UseGuards(AuthGuard)
  async getItem(
    @Payload(new ZodValidationPipe(getItemSchema)) request: GetItemRequest,
    @Metadata() metadata: GrpcMetadata,
  ): Promise<GetItemResponse> {
    return this.exampleService.getItem(request.id);
  }
}

🛡️ Zod 런타임 검증

TypeScript의 컴파일 타임 검증을 넘어, Zod로 런타임에도 요청 데이터를 검증합니다.

💡 왜 Zod인가?

class-validator 대비 더 간결한 문법, 자동 타입 추론, 그리고 복잡한 검증 로직을 쉽게 표현할 수 있습니다.

검증 스키마 정의

validations/get-item.schema.ts
import { z } from 'zod';

export const getItemSchema = z.object({
  id: z.string().uuid('유효한 UUID 형식이 아닙니다'),
});

export const createItemSchema = z.object({
  name: z.string().min(1).max(100),
  quantity: z.number().int().positive(),
  tags: z.array(z.string()).optional(),
  metadata: z.record(z.string()).optional(),
});

// 타입 자동 추론
export type CreateItemInput = z.infer<typeof createItemSchema>;

ValidationPipe 구현

shared/pipes/zod-validation.pipe.ts
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
  constructor(private schema: ZodSchema<T>) {}

  transform(value: unknown): T {
    const result = this.schema.safeParse(value);
    
    if (!result.success) {
      const errors = result.error.errors.map(err => ({
        field: err.path.join('.'),
        message: err.message,
      }));
      throw new BadRequestException({ errors });
    }
    
    return result.data;
  }
}

🔐 Guard를 활용한 인증/인가

Guard는 요청이 Controller에 도달하기 전에 권한을 검증합니다.

shared/guards/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly userRepository: UserRepository,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const rpcContext = context.switchToRpc();
    const metadata = rpcContext.getContext();
    
    // gRPC 메타데이터에서 인증 정보 추출
    const userId = metadata.get('user-id')?.[0];
    const token = metadata.get('authorization')?.[0];
    
    if (!userId || !token) {
      return false;
    }
    
    // 사용자 검증 로직
    const user = await this.userRepository.findById(userId);
    return user !== null && user.isActive;
  }
}

🔄 Interceptor로 메타데이터 처리

Interceptor는 요청 전후에 공통 로직을 실행합니다. 메타데이터 추출, 로깅, 응답 변환에 활용됩니다.

shared/interceptors/user-metadata.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class UserMetadataInterceptor implements NestInterceptor {
  constructor(
    private readonly userProfileRepository: UserProfileRepository,
  ) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const rpcContext = context.switchToRpc();
    const metadata = rpcContext.getContext();
    
    // 1. 메타데이터에서 사용자 ID 추출
    const userId = metadata.get('user-id')?.[0];
    
    if (userId) {
      // 2. 사용자 프로필 조회
      const profile = await this.userProfileRepository.findByUserId(userId);
      
      // 3. 메타데이터에 추가 정보 설정
      metadata.set('user-profile', profile);
      metadata.set('user-role', profile?.role);
    }
    
    return next.handle();
  }
}

💾 Repository 패턴

데이터 접근 로직을 Service에서 분리하여 테스트 용이성과 유지보수성을 높입니다.

repositories/example.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class ExampleRepository {
  constructor(
    @InjectRepository(ExampleEntity, 'main') // 연결 이름 지정
    private readonly repository: Repository<ExampleEntity>,
  ) {}

  async findById(id: string): Promise<ExampleEntity | null> {
    return this.repository.findOne({ 
      where: { id },
      relations: ['relatedEntity'],
    });
  }

  async findByCondition(params: FindParams): Promise<ExampleEntity[]> {
    const query = this.repository
      .createQueryBuilder('e')
      .where('e.status = :status', { status: params.status });
    
    if (params.startDate) {
      query.andWhere('e.createdAt >= :start', { start: params.startDate });
    }
    
    return query.getMany();
  }

  async save(entity: ExampleEntity): Promise<ExampleEntity> {
    return this.repository.save(entity);
  }
}

🗄️ 다중 데이터베이스 연결

TypeORM으로 여러 PostgreSQL 인스턴스에 동시 연결합니다.

modules/database/database.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';

@Module({
  imports: [
    // 주 데이터베이스
    TypeOrmModule.forRootAsync({
      name: 'main',
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('MAIN_DB_HOST'),
        port: config.get('MAIN_DB_PORT'),
        database: config.get('MAIN_DB_NAME'),
        entities: [MainEntity, AnotherEntity],
        synchronize: false,
      }),
      inject: [ConfigService],
    }),

    // 읽기 전용 데이터베이스
    TypeOrmModule.forRootAsync({
      name: 'readonly',
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('READONLY_DB_HOST'),
        // ... 설정
      }),
      inject: [ConfigService],
    }),
  ],
})
export class DatabaseModule {}

📐 핵심 아키텍처 원칙

🧱 계층형 아키텍처

Controller → Service → Repository → Entity 순서로 의존성을 단방향으로 유지합니다.

💉 의존성 주입

NestJS IoC 컨테이너로 느슨한 결합을 유지하고 테스트 시 Mock 주입이 용이합니다.

🎯 단일 책임

각 클래스는 하나의 책임만 가집니다. Controller는 라우팅, Service는 비즈니스 로직만 담당합니다.

🔄 Mapper 패턴

Entity와 DTO 간 변환을 Mapper 클래스로 분리하여 계층 간 결합도를 낮춥니다.

패턴 적용 위치 목적
Guard 패턴 요청 전처리 인증/인가 검증
Interceptor 패턴 요청/응답 가로채기 로깅, 메타데이터 처리, 응답 변환
Pipe 패턴 데이터 변환 검증, 타입 변환
Repository 패턴 데이터 접근 DB 로직 캡슐화
Factory 패턴 객체 생성 파일 생성기 등 동적 객체 생성

정리

이 아키텍처는 다음과 같은 상황에 적합합니다:

  • 마이크로서비스 간 gRPC 통신이 필요한 경우
  • 타입 안전성이 중요한 엔터프라이즈 환경
  • 복잡한 인증/인가 로직이 필요한 경우
  • 여러 데이터베이스에 동시 접근해야 하는 경우
  • 확장 가능한 모듈 구조가 필요한 경우
🚀 다음 단계

실제 프로젝트 구성을 위해서는 package.json, tsconfig.json, Docker Compose 등의 상세 설정이 담긴 설치 가이드 문서가 추가로 필요합니다.

NestJS + gRPC Enterprise Architecture Guide

이 문서는 프레임워크 구조 설명 목적으로 작성되었습니다.

728x90