Nodejs

 

[1편] NestJS 마이크로서비스 통신(TCP) 개요 · 아키텍처 · 환경설정

 

 

시리즈 목표: User · Product · Payment 마이크로서비스와 Order APINestJS TCP Transport로 연결하여, 주문 생성부터 결제 승인까지의 동기 RPC 통신 흐름을 구현

0. 왜 TCP 마이크로서비스인가?

  • 간결함: AMQP/Redis 없이 Nest 기본 제공 Transport.TCP로 요청-응답(RPC) 구현 가능

  • 속도: 브로커 레이어가 없어 낮은 지연

  • 한계: 서비스 간 결합↑, 다운스트림 장애 전파. 타임아웃/재시도/서킷브레이커 필요

실무에서는 메시지 브로커(예: RabbitMQ, Kafka) 또는 gRPC를 선택하는 경우가 많습니다. 본 시리즈는 TCP로 개념과 패턴을 먼저 익힌 뒤, 이후 다른 전송으로 확장 가능한 구조를 목표로 합니다.

 

1. 전체 아키텍처

[Client] --HTTP--> [Order API]
                     |\
                     | \--TCP--> [User MS]      (사용자 인증/조회)
                     |---TCP-->  [Product MS]   (상품 조회)
                     \---TCP-->  [Payment MS]   (결제 처리)

DBs:  Order(MongoDB/Mongoose),  Payment(PostgreSQL/TypeORM)
  • Order API: 외부 HTTP 엔드포인트(/order) 제공. 내부적으로 TCP 클라이언트 3개를 통해 각 MS RPC 호출

  • 각 MS(User/Product/Payment): @MessagePattern() 으로 명령 패턴(cmd)을 수신

 

 

2. 서비스별 역할

  • User MS: Bearer 토큰 파싱 → 사용자 sub 확인 → get_user_info 응답

  • Product MS: get_products_info 로 다건 조회(가격, 이름 등) → Order가 총액 계산

  • Payment MS: make_payment 로 승인/거절 처리, DB에 영속화(상태: approved|rejected)

  • Order API: 흐름 오케스트레이션(검증 → 주문 생성 → 결제 요청 → 상태 반영)

 

 

3. 환경 변수(.env) 예시

# Order API
HTTP_PORT=3004
TCP_PORT=4004
MONGO_URI=mongodb://mongo:27017/order

# Downstream Microservices (TCP Server)
USER_HOST=users
USER_TCP_PORT=4010
PRODUCT_HOST=products
PRODUCT_TCP_PORT=4020
PAYMENT_HOST=payments
PAYMENT_TCP_PORT=4030

# Payment
DB_URL=postgres://postgres:postgres@postgres_payment:5432/postgres

# Auth
JWT_SECRET=your-secret-key
ACCESS_TOKEN_SECRET=your_access_token_secret
REFRESH_TOKEN_SECRET=your_refresh_token_secret

주의: 포트명은 일관성 있게 HTTP_PORT/TCP_PORT로 구분해 사용하세요. 소문자 process.env.port 같은 혼용은 버그 원인입니다.

 

 

4. 공통 패키지(@app/common) 분리 제안

  • 토큰 파서 메시지 패턴, 인터셉터(RPC 표준 응답), 상수(SERVICE TOKENS) 를 공통 패키지로 분리하면 앱 간 의존 관계가 단순해집니다.

  • 예: export const USER_SERVICE = 'USER'; 와 같은 토큰 문자열, RpcInterceptor, RpcExceptionFilter 등

 

 

5. Order API에 TCP 클라이언트 등록

// app.module.ts (Order)
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as Joi from 'joi';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrderModule } from './order/order.module';
import { PAYMENT_SERVICE, PRODUCT_SERVICE, USER_SERVICE } from '@app/common';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        HTTP_PORT: Joi.number().required(),
        TCP_PORT: Joi.number().required(),
        MONGO_URI: Joi.string().required(),
        USER_HOST: Joi.string().required(),
        USER_TCP_PORT: Joi.number().required(),
        PRODUCT_HOST: Joi.string().required(),
        PRODUCT_TCP_PORT: Joi.number().required(),
        PAYMENT_HOST: Joi.string().required(),
        PAYMENT_TCP_PORT: Joi.number().required(),
      }),
    }),
    MongooseModule.forRootAsync({
      useFactory: (cs: ConfigService) => ({ uri: cs.getOrThrow('MONGO_URI') }),
      inject: [ConfigService],
    }),
    ClientsModule.registerAsync({
      isGlobal: true,
      clients: [
        {
          name: USER_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('USER_HOST'), port: cs.getOrThrow('USER_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
        {
          name: PRODUCT_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('PRODUCT_HOST'), port: cs.getOrThrow('PRODUCT_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
        {
          name: PAYMENT_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('PAYMENT_HOST'), port: cs.getOrThrow('PAYMENT_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
      ],
    }),
    OrderModule,
  ],
})
export class AppModule {}

 

 

6. Order 서버 부트스트랩(HTTP + 내부 TCP)

// main.ts (Order)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

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

  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: parseInt(process.env.TCP_PORT ?? '4004', 10) },
  });

  await app.startAllMicroservices();
  await app.listen(parseInt(process.env.HTTP_PORT ?? '3004', 10));
}
bootstrap();

 

 

7. 테스트용 Docker Compose (요지)

실제 포트/서비스명은 현 프로젝트에 맞게 치환

version: '3.8'
services:
  order:
    build: ./apps/order
    environment:
      - HTTP_PORT=3004
      - TCP_PORT=4004
      - MONGO_URI=mongodb://mongo:27017/order
      - USER_HOST=users
      - USER_TCP_PORT=4010
      - PRODUCT_HOST=products
      - PRODUCT_TCP_PORT=4020
      - PAYMENT_HOST=payments
      - PAYMENT_TCP_PORT=4030
    depends_on: [mongo, users, products, payments]
    ports: ['3004:3004']

  users:
    build: ./apps/user
    environment:
      - TCP_PORT=4010
    ports: ['4010:4010']

  products:
    build: ./apps/product
    environment:
      - TCP_PORT=4020
    ports: ['4020:4020']

  payments:
    build: ./apps/payment
    environment:
      - TCP_PORT=4030
      - DB_URL=postgres://postgres:postgres@postgres_payment:5432/postgres
    depends_on: [postgres_payment]
    ports: ['4030:4030']

  mongo:
    image: mongo:6
    ports: ['27017:27017']

  postgres_payment:
    image: postgres:15
    environment:
      - POSTGRES_PASSWORD=postgres
    ports: ['5432:5432']

 

 

 

 

[2편] Order API — DTO·검증·오케스트레이션·예외 처리

목표: HTTP 요청을 받아 User/Product/Payment 마이크로서비스로 TCP RPC를 순차 호출하여 주문 생성 → 결제 처리까지 오케스트레이션하는 전 과정을 코드로 해부합니다.

1. DTO · 유효성 검증

// dto/create-order.dto.ts
import { Type } from 'class-transformer';
import { IsArray, ArrayNotEmpty, IsString, ValidateNested, IsNotEmpty } from 'class-validator';
import { PaymentDto } from './payment.dto';

class AddressDto {
  @IsString() @IsNotEmpty() name: string;
  @IsString() @IsNotEmpty() street: string;
  @IsString() @IsNotEmpty() city: string;
  @IsString() @IsNotEmpty() postalCode: string;
  @IsString() @IsNotEmpty() country: string;
}

export class CreateOrderDto {
  @IsArray() @ArrayNotEmpty() productIds: string[];
  @ValidateNested() @Type(() => AddressDto) address: AddressDto;
  @ValidateNested() @Type(() => PaymentDto) payment: PaymentDto;
}
// dto/payment.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
export type PaymentMethod = 'CreditCard' | 'VirtualAccount';

export class PaymentDto {
  @IsString() @IsNotEmpty() paymentMethod: PaymentMethod;
  @IsString() @IsNotEmpty() paymentName: string;
  @IsString() @IsNotEmpty() cardNumber: string;
  @IsString() @IsNotEmpty() expiryYear: string;
  @IsString() @IsNotEmpty() expiryMonth: string;
  @IsString() @IsNotEmpty() birthOrRegistration: string;
  @IsString() @IsNotEmpty() passwordTwoDigits: string;
  @IsNumber() @IsNotEmpty() amount: number;
}

 

 

2. 컨트롤러 — Authorization 헤더 수신

// order.controller.ts
import { Body, Controller, Headers, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { OrderService } from './order.service';
import { CreateOrderDto } from './dto/create-order.dto';

@Controller('order')
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @Post()
  @UsePipes(new ValidationPipe({ transform: true }))
  async create(
    @Headers('authorization') auth: string,
    @Body() dto: CreateOrderDto,
  ) {
    return this.orderService.createOrder(dto, auth ?? '');
  }
}

: 인증 데코레이터를 별도 공통 패키지로 제공해도 좋지만, 배포 분리를 위해 단순히 헤더를 읽고 User MS로 위임하는 방식이 안전합니다.

 

3. 서비스 — 오케스트레이션 핵심

// order.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { lastValueFrom, timeout } from 'rxjs';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { PAYMENT_SERVICE, PRODUCT_SERVICE, USER_SERVICE } from '@app/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { Order, OrderDocument, OrderStatus } from './entities/order.entity';

@Injectable()
export class OrderService {
  constructor(
    @Inject(USER_SERVICE) private readonly userSvc: ClientProxy,
    @Inject(PRODUCT_SERVICE) private readonly productSvc: ClientProxy,
    @Inject(PAYMENT_SERVICE) private readonly paymentSvc: ClientProxy,
    @InjectModel(Order.name) private readonly orderModel: Model<OrderDocument>,
  ) {}

  async createOrder(dto: CreateOrderDto, authorization: string) {
    // 1) 사용자 확인 (토큰 파싱 → 사용자 조회)
    const user = await this.getUserFromAuthHeader(authorization);

    // 2) 상품 조회
    const products = await this.getProductsByIds(dto.productIds);

    // 3) 총액 계산 & 금액 일치 검증
    const total = products.reduce((sum, p) => sum + p.price, 0);
    if (total !== dto.payment.amount) {
      throw new Error('결제 금액이 상품 총액과 일치하지 않습니다. 장바구니를 새로고침 해주세요.');
    }

    // 4) 주문 생성 (초기 상태: created)
    const order = await this.orderModel.create({
      status: OrderStatus.created,
      customer: { userId: user.id, email: user.email, name: user.name },
      items: products.map((p) => ({ productId: p.id, name: p.name, price: p.price })),
      deliveryAddress: dto.address,
      payment: dto.payment,
    });

    // 5) 결제 처리 (orderId 포함 — 멱등/추적)
    await this.processPayment(order.id, dto.payment, user.email);

    // 6) 결과 조회 반환
    return this.orderModel.findById(order.id).lean();
  }

  private async getUserFromAuthHeader(authorization: string) {
    const parsed = await lastValueFrom(
      this.userSvc.send({ cmd: 'parse_bearer_token' }, { token: authorization }).pipe(timeout(5000)),
    );
    if (parsed?.status === 'error') throw new Error('토큰 파싱 실패');

    const user = await lastValueFrom(
      this.userSvc.send({ cmd: 'get_user_info' }, { userId: parsed.data.sub }).pipe(timeout(5000)),
    );
    if (user?.status === 'error') throw new Error('사용자 조회 실패');
    return user.data;
  }

  private async getProductsByIds(productIds: string[]) {
    const resp = await lastValueFrom(
      this.productSvc.send({ cmd: 'get_products_info' }, { productIds }).pipe(timeout(5000)),
    );
    if (resp?.status === 'error') throw new Error('상품 조회 실패');
    return resp.data;
  }

  private async processPayment(orderId: string, payment: any, userEmail: string) {
    const resp = await lastValueFrom(
      this.paymentSvc
        .send({ cmd: 'make_payment' }, { ...payment, userEmail, orderId })
        .pipe(timeout(8000)),
    );

    const approved = resp?.paymentStatus === 'Approved' || resp?.paymentStatus === 'approved';
    await this.orderModel.findByIdAndUpdate(orderId, {
      status: approved ? OrderStatus.paymentProcessed : OrderStatus.paymentFailed,
    });

    if (!approved) throw new Error('결제 실패');
    return resp;
  }
}

 

 

4. 주문 엔티티 (Mongoose)

// entities/order.entity.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export enum OrderStatus {
  created = 'created',
  paymentProcessed = 'paymentProcessed',
  paymentFailed = 'paymentFailed',
}

@Schema({ timestamps: true })
export class Order {
  @Prop({ type: String, enum: OrderStatus, default: OrderStatus.created })
  status: OrderStatus;

  @Prop({ type: Object })
  customer: { userId: string; email: string; name: string };

  @Prop({ type: Array })
  items: Array<{ productId: string; name: string; price: number }>;

  @Prop({ type: Object })
  deliveryAddress: any;

  @Prop({ type: Object })
  payment: any;
}

export type OrderDocument = HydratedDocument<Order>;
export const OrderSchema = SchemaFactory.createForClass(Order);

 

5. 타임아웃/재시도/로깅 팁

  • timeout(ms): 다운스트림 지연이 체인 전체를 블로킹하지 않도록 각 RPC에 타임아웃 설정

  • retryWhen: 네트워크 일시 장애에 재시도(단, 결제는 멱등키 필요)

  • Correlation ID: orderId를 로그/메타데이터로 전파하여 트레이싱

 

 

6. 요청 예시

curl -X POST http://localhost:3004/order \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <JWT>" \
  -d '{
    "productIds": ["8cc2...", "39a9..."],
    "address": {"name":"홍길동","street":"도산대로 14","city":"서울","postalCode":"123123","country":"KR"},
    "payment": {"paymentMethod":"CreditCard","paymentName":"법인카드","cardNumber":"123123",
                 "expiryYear":"26","expiryMonth":"12","birthOrRegistration":"9",
                 "passwordTwoDigits":"12","amount":3000}
  }'

 

 

 

[3편] User·Product·Payment MS — TCP 핸들러, DTO/엔티티, DB 연동

 

목표: 각 마이크로서비스가 TCP 서버로서 @MessagePattern 요청을 수신/응답하는 방식을 코드로 정리하고, Payment는 TypeORM + PostgreSQL, User/Product는 임시/예시 저장소를 통해 흐름을 완성합니다.

1. 공통 — 마이크로서비스 부트스트랩

// main.ts (각 MS 공통)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: parseInt(process.env.TCP_PORT ?? '4010', 10) },
  });
  await app.listen();
}
bootstrap();

 

 

2. User MS — 토큰 파싱 & 사용자 조회

// user.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';

@Controller()
export class UserController {
  @MessagePattern({ cmd: 'parse_bearer_token' })
  parseBearerToken(@Payload() { token }: { token: string }) {
    // 실제로는 JWT 검증 로직 수행
    const raw = (token ?? '').replace(/^Bearer\s+/i, '');
    // 데모: sub=mock-user
    return { status: 'ok', data: { sub: 'mock-user', raw } };
  }

  @MessagePattern({ cmd: 'get_user_info' })
  getUserInfo(@Payload() { userId }: { userId: string }) {
    // 데모: 임시 사용자
    return { status: 'ok', data: { id: userId, email: 'user@example.com', name: '홍길동' } };
  }
}

실제 구현에서는 jsonwebtoken 또는 @nestjs/jwt로 서명 검증, 블랙리스트 확인, 권한 조회 등을 수행합니다.

 

3. Product MS — 다건 상품 조회

// product.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';

const mockProducts = new Map<string, { id: string; name: string; price: number }>([
  ['8cc28226-076e-4472-8b08-4e5c2b51ebc5', { id: '8cc2...', name: 'USB-C 케이블', price: 1000 }],
  ['39a9fe98-091a-4dba-a314-195fea19f48c', { id: '39a9...', name: '마우스 패드', price: 2000 }],
]);

@Controller()
export class ProductController {
  @MessagePattern({ cmd: 'get_products_info' })
  getProducts(@Payload() { productIds }: { productIds: string[] }) {
    const result = (productIds ?? []).map((id) => mockProducts.get(id)).filter(Boolean);
    if (!result.length) return { status: 'error', message: '상품 없음' };
    return { status: 'ok', data: result };
  }
}

실제 환경에서는 캐시(Redis)와 읽기 모델을 두어 Order의 리스트 조회 부하를 줄입니다.

 

4. Payment MS — TypeORM + PostgreSQL

4.1 엔티티

// entity/payment.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

export enum PaymentStatus {
  approved = 'Approved',
  rejected = 'Rejected',
}

@Entity('payments')
export class Payment {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() paymentMethod: string;
  @Column() paymentName: string;
  @Column() cardNumber: string;
  @Column() expiryYear: string;
  @Column() expiryMonth: string;
  @Column() birthOrRegistration: string;
  @Column() passwordTwoDigits: string;
  @Column('int') amount: number;

  @Column() userEmail: string;
  @Column() orderId: string; // 멱등/추적

  @Column({ type: 'varchar', default: PaymentStatus.approved })
  paymentStatus: PaymentStatus;

  @CreateDateColumn() createdAt: Date;
  @UpdateDateColumn() updatedAt: Date;
}

 

4.2 DTO

// dto/make-payment.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';

export class MakePaymentDto {
  @IsString() @IsNotEmpty() paymentMethod: string;
  @IsString() @IsNotEmpty() paymentName: string;
  @IsString() @IsNotEmpty() cardNumber: string;
  @IsString() @IsNotEmpty() expiryYear: string;
  @IsString() @IsNotEmpty() expiryMonth: string;
  @IsString() @IsNotEmpty() birthOrRegistration: string;
  @IsString() @IsNotEmpty() passwordTwoDigits: string;
  @IsNumber() @IsNotEmpty() amount: number;
  @IsString() @IsNotEmpty() userEmail: string;
  @IsString() @IsNotEmpty() orderId: string;
}

 

 

4.3 모듈 & 부트스트랩

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Payment } from './entity/payment.entity';
import { PaymentModule } from './payment.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (cs: ConfigService) => ({
        type: 'postgres',
        url: cs.getOrThrow('DB_URL'),
        autoLoadEntities: true,
        synchronize: true, // 운영에서는 마이그레이션 권장
      }),
    }),
    PaymentModule,
  ],
})
export class AppModule {}

 

4.4 컨트롤러 & 서비스

// payment.controller.ts
import { Controller, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { RpcInterceptor } from '@app/common/interceptor';
import { PaymentService } from './payment.service';
import { MakePaymentDto } from './dto/make-payment.dto';

@Controller()
export class PaymentController {
  constructor(private readonly paymentService: PaymentService) {}

  @MessagePattern({ cmd: 'make_payment' })
  @UsePipes(new ValidationPipe())
  @UseInterceptors(RpcInterceptor)
  makePayment(@Payload() dto: MakePaymentDto) {
    return this.paymentService.makePayment(dto);
  }
}
// payment.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Payment, PaymentStatus } from './entity/payment.entity';
import { MakePaymentDto } from './dto/make-payment.dto';

@Injectable()
export class PaymentService {
  constructor(@InjectRepository(Payment) private readonly repo: Repository<Payment>) {}

  async makePayment(dto: MakePaymentDto) {
    // 1) 영속화 (초기 상태: Rejected → 승인 성공 시 Approved로 갱신하는 전략도 가능)
    const saved = await this.repo.save({ ...dto, paymentStatus: PaymentStatus.approved });

    // 2) 외부 PG 호출 모킹
    await new Promise((r) => setTimeout(r, 1000));

    // 3) 승인으로 상태 확정
    await this.repo.update({ id: saved.id }, { paymentStatus: PaymentStatus.approved });

    // 4) 결과 반환
    return this.repo.findOneBy({ id: saved.id });
  }
}

 

5. 운영 팁

  • 멱등성: 동일 orderId 중복 결제 방지 → unique index(orderId) + 상태 체크

  • 보상 트랜잭션: 결제 실패 시 Order 상태 paymentFailed로 업데이트, 재시도 경로 제공

  • 관측성: 각 요청에 orderId를 로깅하여 분산 트레이싱 기본 확보

 

 

 

 

[4편] 운영 품질·테스트·트러블슈팅 — 타임아웃·재시도·멱등성·로그·서킷브레이커

목표: 개발한 TCP 통신 기반 마이크로서비스를 운영 품질까지 끌어올립니다. 타임아웃/재시도, 멱등성/보상 트랜잭션, 로깅/트레이싱, 테스트/디버깅, 배포 체크리스트를 제공합니다.

1. 타임아웃 & 재시도 & 서킷브레이커

1.1 타임아웃

// 예: Order → Product 호출에 3초 타임아웃
const products = await lastValueFrom(
  this.productSvc
    .send({ cmd: 'get_products_info' }, { productIds })
    .pipe(timeout(3000)),
);
  • 다운스트림 지연이 체인 전체 블로킹을 유발하지 않도록 모든 RPC에 적용

  • UX 관점에서도 1~3초 선에서 피드백 → 프론트는 재시도/로딩 상태로 전환

 

1.2 재시도

import { retryWhen, delay, scan } from 'rxjs/operators';

const resp$ = this.userSvc.send(pattern, data).pipe(
  retryWhen((errors) => errors.pipe(
    scan((acc, err) => { if (acc >= 3) throw err; return acc + 1; }, 0),
    delay(300),
  )),
);
  • 비결제성 트랜잭션에만 보수적으로 적용 (결제는 멱등키/중복검사 필수)

 

1.3 서킷브레이커(간단 패턴)

  • 실패율/타임아웃이 일정 임계치를 넘으면 OPEN → 일정 시간 후 HALF-OPEN → 성공 시 CLOSE

  • 라이브러리 없이도 전역 Interceptor/서비스 레벨 상태값으로 단순 구현 가능

 

 

2. 멱등성 & 보상 트랜잭션

2.1 결제 멱등성

  • orderId에 unique 인덱스(+ paymentStatus)를 두고, 동일 요청은 읽기 반환

  • Payment MS에서 orderId 기준 조회 후 이미 Approved면 즉시 승인 응답, Rejected면 사유 전달

2.2 보상 트랜잭션(Saga 사고방식)

  • 결제 실패 → Order: paymentFailed

  • 재시도 허용 정책: 사용자 확인 후 재결제 라우트 제공

 

 

3. 로깅 & 분산 트레이싱

3.1 Correlation ID 설계

  • orderId를 전 구간 로그에 포함

  • Interceptor로 요청마다 context.set('cid', orderId) 저장 후 로거 포맷에 포함

3.2 표준 응답 포맷(RPC)

// 예시 형태
{
  status: 'ok' | 'error',
  data?: any,
  message?: string,
  code?: string,
}
  • 핸들러/서비스 전반에 동일 포맷을 유지하면, Order에서 에러 분기가 간단해집니다.

 

 

4. 테스트 시나리오

4.1 단위 테스트

  • DTO 유효성 → invalid payload에 대한 ValidationPipe 동작

  • 서비스 메서드에서 총액 계산/검증 로직

 

 

4.2 통합 테스트

  • TestModule에서 TCP 서버/클라이언트 실제 바인딩(테스트 포트)

  • Order → User/Product/Payment 순차 호출 성공/실패 케이스

4.3 E2E 테스트

  • docker-compose로 모든 서비스 기동

  • curl 또는 supertest 로 /order 호출 → 승인/거절 플로우

 

 

5. 트러블슈팅 가이드

증상원인해결

ECONNREFUSED대상 MS 미기동/포트 불일치TCP_PORT·HOST·네트워크 확인, Docker 서비스 종속성 설정

응답 지연 → 전체 타임아웃다운스트림 지연각 RPC에 timeout() 적용, 프론트 피드백 강화

결제 중복 승인멱등 처리 누락Payment에 orderId unique index, 승인 시 빠른 반환

DTO 불일치Order↔Payment 스키마 상이공통 패키지로 DTO 공유 또는 계약 테스트 추가

예외 메시지 오타/클래스명 오류타이핑 실수CI에서 빌드/TS 검사, ESLint/TS-ESLint 규칙 강화

 

 

6. 배포 체크리스트

  • 환경변수 키 일관성 (HTTP_PORT/TCP_PORT 등)

  • 로그 수준/포맷 통일, Correlation ID 포함

  • RPC 타임아웃/재시도 파라미터 환경변수화

  • Payment 멱등성 + 고유 인덱스 적용

  • DTO 계약 테스트(Contract Test) 마련

 

 

7. 마무리

  • 핵심 요지: TCP 통신은 빠르고 간단하지만 결합이 커집니다

  • 다음 단계: 동일 구조를 gRPC/RabbitMQ로 바꾸어도 패턴은 유지됩니다(요청-응답/이벤트 발행, DTO 계약, 멱등/보상).

 

 

 

 

about author

PHRASE

Level 60  라이트

첫딸은 세간 밑천이라 , 첫딸은 집안의 모든 일에 도움이 된다는 뜻으로 첫딸을 낳은 서운함을 위로하는 말.

댓글 ( 0)

댓글 남기기

작성