DEV Community

Artistrator
Artistrator

Posted on • Edited on

NestJS MSA에서 TypeORM FindOperator가 사라지는 문제 — Interceptor로 해결하기

문제: TCP로 보내면 FindOperator가 깨진다

NestJS 마이크로서비스 아키텍처에서 Gateway → Microservice 간 TCP 통신을 사용한다. 문제는 TypeORM의 FindOperator가 클래스 인스턴스라는 점이다.

// Gateway에서 이렇게 보내면
this.client.send(Messages.Order.findMany, {
  filter: {
    status: In(["READY", "SENT"]),
    createdAt: Between("2025-01-01", "2025-12-31"),
  },
});
Enter fullscreen mode Exit fullscreen mode

TCP 전송 과정에서 JSON 직렬화가 일어난다. In([...]) 같은 FindOperator는 클래스 인스턴스이므로, 직렬화되면 이렇게 바뀐다:

{
  "filter": {
    "status": {
      "_type": "in",
      "_value": ["READY", "SENT"],
      "_useParameter": true,
      "_multipleParameters": true
    },
    "createdAt": {
      "_type": "between",
      "_value": ["2025-01-01", "2025-12-31"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Microservice에서 이 데이터를 그대로 TypeORM find()에 넘기면? 프로토타입 체인이 없는 순수 객체이므로 TypeORM이 인식하지 못한다. 조건 없이 전체 조회가 되거나, 에러가 발생한다.

CTO 관점: 이 문제는 MSA 전환 시 가장 간과하기 쉬운 유형이다. 모놀리식에서는 존재하지 않던 "직렬화 경계(serialization boundary)" 문제가 서비스를 분리하는 순간 터져나온다. 단일 프로세스에서 잘 동작하던 코드가 MSA에서는 근본적으로 동작하지 않을 수 있다는 것을 팀 전체가 인식해야 한다.


발견 과정

필터가 있는 목록 API를 TCP 기반 MSA로 전환하면서 발견했다. 단일 서비스였을 때는 @Transform() 데코레이터가 생성한 FindOperator가 같은 프로세스 내에서 바로 사용되니까 문제가 없었다. MSA로 분리하는 순간, JSON 직렬화라는 벽에 부딪혔다.

핵심 인사이트: TypeORM FindOperator는 내부적으로 _type_value 프로퍼티를 갖고 있고, 이것이 JSON 직렬화를 살아남는다. 역직렬화만 해주면 된다.

직렬화 경계에서의 데이터 변환:

Gateway (클래스 인스턴스)     →  TCP (JSON)              →  Microservice (순수 객체)
In(["READY","SENT"])         →  {_type:"in",_value:[...]}  →  ??? (TypeORM 인식 불가)
                                                            ↓ Interceptor 적용
                                                          In(["READY","SENT"]) ✓
Enter fullscreen mode Exit fullscreen mode

해결: TypeORM Deserializer Interceptor

NestJS Interceptor로 RPC 요청의 페이로드를 가로채서, _type 프로퍼티가 있는 객체를 실제 FindOperator로 복원한다:

@Injectable()
export class TypeOrmDeserializerInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    if (context.getType() === "rpc") {
      const args = context.getArgs();
      if (args && args.length > 0) {
        args[0] = this.deserializeTypeOrmOperators(args[0]);
      }
    }
    return next.handle();
  }

  private deserializeTypeOrmOperators(obj: any): any {
    if (obj === null || obj === undefined) return obj;
    if (Array.isArray(obj)) {
      return obj.map((item) => this.deserializeTypeOrmOperators(item));
    }

    if (typeof obj === "object") {
      if (obj._type) {
        if (obj._type === "between" && Array.isArray(obj._value)) {
          return Between(obj._value[0], obj._value[1]);
        }
        if (obj._type === "in" && Array.isArray(obj._value)) {
          return In(obj._value);
        }
        if (obj._type === "like") {
          return Like(obj._value);
        }
        if (obj._type === "isNull") {
          return IsNull();
        }
        if (obj._type === "not") {
          // Not의 내부 값을 재귀적으로 역직렬화
          const inner = this.deserializeTypeOrmOperators(obj._value);
          return Not(inner);
        }
        // ... ArrayContains, ArrayOverlap, MoreThanOrEqual 등
      }

      // 일반 객체는 재귀적으로 모든 속성 처리
      const result: any = {};
      for (const [key, value] of Object.entries(obj)) {
        result[key] = this.deserializeTypeOrmOperators(value);
      }
      return result;
    }
    return obj;
  }
}
Enter fullscreen mode Exit fullscreen mode

동작 흐름:

Gateway                    TCP Transport              Microservice
In(["READY","SENT"])  →  {_type:"in",_value:[...]}  →  Interceptor  →  In(["READY","SENT"])
Between(a, b)         →  {_type:"between",_value:..} →  Interceptor  →  Between(a, b)
Not(IsNull())         →  {_type:"not",_value:{       →  Interceptor  →  Not(IsNull())
                           _type:"isNull"}}
Enter fullscreen mode Exit fullscreen mode

설계 결정: Interceptor를 선택한 이유는 비즈니스 코드 변경 없이 인프라 레벨에서 문제를 해결할 수 있기 때문이다. Custom Pipe나 Guard로도 가능하지만, Interceptor가 요청/응답 양쪽을 다룰 수 있어 확장성이 좋다.


Not(IsNull()) — 재귀가 필요한 이유

처음엔 단순한 _type → 연산자 매핑으로 끝날 줄 알았다. Not(IsNull())을 만나기 전까지는.

Not(IsNull())이 직렬화되면:

{
  "_type": "not",
  "_value": {
    "_type": "isNull",
    "_value": null
  }
}
Enter fullscreen mode Exit fullscreen mode

Not_value 안에 또 다른 FindOperator가 들어있다. 그래서 Not 처리 시 내부 값을 재귀적으로 역직렬화해야 한다. IsNull()_valuenull이므로, 초기 버전에서 if (obj._value) 같은 truthy 체크를 했더니 IsNull이 무시되는 버그도 있었다.

이 버그를 발견하기까지 걸린 시간: 이틀. Not(IsNull()) 조건이 있는 쿼리가 전체 데이터를 반환하는데, 필터가 아예 적용되지 않은 건지 Not 조건만 무시된 건지 구분하기 어려웠다. null이 falsy라는 JavaScript의 기본 동작이 이렇게 치명적일 수 있다는 교훈을 얻었다.


DTO와의 연동

Gateway 쪽 DTO에서 @Transform()으로 FindOperator를 생성한다:

export class OrderFilterInput {
  @Transform(({ value }) => {
    if (!value) return undefined;
    if (Array.isArray(value)) return In(value);
    if (typeof value === "string") return In([value]);
    return undefined;
  })
  status?: FindOperator<OrderStatus>;

  @Transform(({ value }: { value: DateDto }) => {
    if (!value) return undefined;
    return Between(value.start, value.end || value.start);
  })
  createdAt?: FindOperator<Date>;
}
Enter fullscreen mode Exit fullscreen mode

Microservice 컨트롤러에서 Interceptor를 적용하면, DTO의 Transform → JSON 직렬화 → Interceptor 역직렬화가 자동으로 이어진다:

@MessagePattern(Messages.Order.findMany)
@UseInterceptors(TypeOrmDeserializerInterceptor)
async findMany(body: OrderListInput): Promise<OrderListOutput> {
  // body.filter.status는 이미 In(["READY", "SENT"])로 복원됨
  return this.service.findByPagination(body);
}
Enter fullscreen mode Exit fullscreen mode

적용 범위 선택

두 가지 등록 방식을 상황에 따라 사용한다:

// 1. 모듈 전역: 모든 핸들러에 필터가 있을 때
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: TypeOrmDeserializerInterceptor },
  ],
})
export class OrderModule {}

// 2. 핸들러 단위: 특정 API만 필터를 사용할 때
@MessagePattern(Messages.Order.findMany)
@UseInterceptors(TypeOrmDeserializerInterceptor)
async findMany(body: OrderListInput) { }
Enter fullscreen mode Exit fullscreen mode
등록 방식 장점 단점 권장 상황
모듈 전역 누락 방지 불필요한 순회 발생 대부분 핸들러가 필터 사용
핸들러 단위 정확한 범위 데코레이터 누락 가능 필터 사용 핸들러가 소수

필터가 필요 없는 create, update, delete 핸들러에는 불필요한 재귀 순회를 하지 않도록 핸들러 단위 적용을 권장한다.


결과 / 배운 점

  • MSA에서 클래스 인스턴스 전송은 근본적으로 불가능하다. JSON 직렬화를 거치는 순간 프로토타입 체인이 사라진다. 이것은 TypeORM에만 국한된 문제가 아니다.
  • TypeORM FindOperator의 _type 프로퍼티가 구원이었다. 내부 구조를 들여다보니 역직렬화에 필요한 정보가 이미 있었다. 라이브러리 소스를 읽는 습관이 여기서 빛을 발했다.
  • 재귀 설계에서 edge case는 항상 중첩에서 나온다. Not(IsNull())처럼 연산자 안에 연산자가 들어가는 케이스를 놓치기 쉽다.
  • Interceptor 패턴이 인프라 레벨 문제에 적합하다. 비즈니스 코드를 전혀 수정하지 않고, 직렬화/역직렬화 문제를 한 곳에서 해결했다.

아쉬웠던 점

  1. TypeORM 버전 업데이트 시 _type 값 변경 리스크: 이 Interceptor는 TypeORM의 내부 구현(private property)에 의존한다. _type은 public API가 아니므로, TypeORM 메이저 버전 업데이트 시 이름이 바뀌거나 구조가 변경될 수 있다. 현재 TypeORM 0.3.x 기준으로 작성했는데, 0.4.x나 1.0에서 깨질 가능성을 인지하면서도 별도의 방어 장치를 두지 않았다.

  2. ArrayContains 등 일부 연산자 지원 누락 발견이 늦었음: 초기에 In, Between, Like, IsNull, Not만 구현했는데, 프로덕션에 배포한 후 한참 뒤에야 ArrayContains, ArrayOverlap, MoreThanOrEqual 등이 필요한 쿼리에서 조용히 실패하는 것을 발견했다. "조용한 실패"가 가장 위험하다 — 에러 대신 필터가 무시되어 전체 데이터가 반환되는 식이었다.

  3. 성능 오버헤드 측정 미비: 모든 RPC 페이로드를 재귀적으로 순회하는 비용을 측정하지 않았다. 현재 페이로드가 작아서 체감되지 않지만, 대형 응답 객체에 전역 적용하면 불필요한 오버헤드가 발생할 수 있다.


향후 보완할 점

  1. TypeORM 버전별 _type 매핑 자동화 테스트: CI 파이프라인에 TypeORM 버전 업그레이드 시 자동으로 돌아가는 테스트를 추가할 계획이다. 각 FindOperator를 직렬화 → 역직렬화하여 원본과 동일한 쿼리를 생성하는지 검증한다.

  2. FindOperator 커버리지 100% 단위 테스트: TypeORM이 제공하는 모든 FindOperator(Raw, Any, ArrayContains, ArrayOverlap, ILike, LessThan, MoreThan 등)에 대한 직렬화/역직렬화 테스트를 작성하고, 미지원 연산자가 들어오면 명시적으로 에러를 throw하도록 개선한다.

  3. 역직렬화 성능 벤치마크: 페이로드 크기별(100B, 1KB, 10KB, 100KB) 역직렬화 소요 시간을 측정하고, 임계치를 넘기면 경고를 내는 메트릭을 추가한다. 이를 근거로 모듈 전역 vs 핸들러 단위 적용 기준을 정량적으로 제시할 수 있다.


AI 활용 포인트

TypeORM FindOperator의 내부 구조(_type, _value)를 Claude Code로 분석했다. TypeORM 소스에서 FindOperator 클래스의 프로퍼티 목록과 각 연산자별 _type 값을 빠르게 추출해서, 지원해야 할 연산자 목록을 확정하는 데 활용했다.

Top comments (0)