문제: 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"),
},
});
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"]
}
}
}
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"]) ✓
해결: 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;
}
}
동작 흐름:
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"}}
설계 결정: Interceptor를 선택한 이유는 비즈니스 코드 변경 없이 인프라 레벨에서 문제를 해결할 수 있기 때문이다. Custom Pipe나 Guard로도 가능하지만, Interceptor가 요청/응답 양쪽을 다룰 수 있어 확장성이 좋다.
Not(IsNull()) — 재귀가 필요한 이유
처음엔 단순한 _type → 연산자 매핑으로 끝날 줄 알았다. Not(IsNull())을 만나기 전까지는.
Not(IsNull())이 직렬화되면:
{
"_type": "not",
"_value": {
"_type": "isNull",
"_value": null
}
}
Not의 _value 안에 또 다른 FindOperator가 들어있다. 그래서 Not 처리 시 내부 값을 재귀적으로 역직렬화해야 한다. IsNull()의 _value는 null이므로, 초기 버전에서 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>;
}
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);
}
적용 범위 선택
두 가지 등록 방식을 상황에 따라 사용한다:
// 1. 모듈 전역: 모든 핸들러에 필터가 있을 때
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: TypeOrmDeserializerInterceptor },
],
})
export class OrderModule {}
// 2. 핸들러 단위: 특정 API만 필터를 사용할 때
@MessagePattern(Messages.Order.findMany)
@UseInterceptors(TypeOrmDeserializerInterceptor)
async findMany(body: OrderListInput) { }
| 등록 방식 | 장점 | 단점 | 권장 상황 |
|---|---|---|---|
| 모듈 전역 | 누락 방지 | 불필요한 순회 발생 | 대부분 핸들러가 필터 사용 |
| 핸들러 단위 | 정확한 범위 | 데코레이터 누락 가능 | 필터 사용 핸들러가 소수 |
필터가 필요 없는 create, update, delete 핸들러에는 불필요한 재귀 순회를 하지 않도록 핸들러 단위 적용을 권장한다.
결과 / 배운 점
- MSA에서 클래스 인스턴스 전송은 근본적으로 불가능하다. JSON 직렬화를 거치는 순간 프로토타입 체인이 사라진다. 이것은 TypeORM에만 국한된 문제가 아니다.
-
TypeORM FindOperator의
_type프로퍼티가 구원이었다. 내부 구조를 들여다보니 역직렬화에 필요한 정보가 이미 있었다. 라이브러리 소스를 읽는 습관이 여기서 빛을 발했다. -
재귀 설계에서 edge case는 항상 중첩에서 나온다.
Not(IsNull())처럼 연산자 안에 연산자가 들어가는 케이스를 놓치기 쉽다. - Interceptor 패턴이 인프라 레벨 문제에 적합하다. 비즈니스 코드를 전혀 수정하지 않고, 직렬화/역직렬화 문제를 한 곳에서 해결했다.
아쉬웠던 점
TypeORM 버전 업데이트 시
_type값 변경 리스크: 이 Interceptor는 TypeORM의 내부 구현(private property)에 의존한다._type은 public API가 아니므로, TypeORM 메이저 버전 업데이트 시 이름이 바뀌거나 구조가 변경될 수 있다. 현재 TypeORM 0.3.x 기준으로 작성했는데, 0.4.x나 1.0에서 깨질 가능성을 인지하면서도 별도의 방어 장치를 두지 않았다.ArrayContains등 일부 연산자 지원 누락 발견이 늦었음: 초기에In,Between,Like,IsNull,Not만 구현했는데, 프로덕션에 배포한 후 한참 뒤에야ArrayContains,ArrayOverlap,MoreThanOrEqual등이 필요한 쿼리에서 조용히 실패하는 것을 발견했다. "조용한 실패"가 가장 위험하다 — 에러 대신 필터가 무시되어 전체 데이터가 반환되는 식이었다.성능 오버헤드 측정 미비: 모든 RPC 페이로드를 재귀적으로 순회하는 비용을 측정하지 않았다. 현재 페이로드가 작아서 체감되지 않지만, 대형 응답 객체에 전역 적용하면 불필요한 오버헤드가 발생할 수 있다.
향후 보완할 점
TypeORM 버전별
_type매핑 자동화 테스트: CI 파이프라인에 TypeORM 버전 업그레이드 시 자동으로 돌아가는 테스트를 추가할 계획이다. 각 FindOperator를 직렬화 → 역직렬화하여 원본과 동일한 쿼리를 생성하는지 검증한다.FindOperator 커버리지 100% 단위 테스트: TypeORM이 제공하는 모든 FindOperator(
Raw,Any,ArrayContains,ArrayOverlap,ILike,LessThan,MoreThan등)에 대한 직렬화/역직렬화 테스트를 작성하고, 미지원 연산자가 들어오면 명시적으로 에러를 throw하도록 개선한다.역직렬화 성능 벤치마크: 페이로드 크기별(100B, 1KB, 10KB, 100KB) 역직렬화 소요 시간을 측정하고, 임계치를 넘기면 경고를 내는 메트릭을 추가한다. 이를 근거로 모듈 전역 vs 핸들러 단위 적용 기준을 정량적으로 제시할 수 있다.
AI 활용 포인트
TypeORM FindOperator의 내부 구조(_type, _value)를 Claude Code로 분석했다. TypeORM 소스에서 FindOperator 클래스의 프로퍼티 목록과 각 연산자별 _type 값을 빠르게 추출해서, 지원해야 할 연산자 목록을 확정하는 데 활용했다.
Top comments (0)