GraphQL은 클라이언트가 필요한 필드로 바디를 구성하여 요청하고 서버는 요청받은 필드에 대해서 응답한다. 이는 언더페칭/오버페칭이 없다는 장점이 있지만 적절한 처리가 없이는 서버의 부하를 유발할 수도 있다.
우리 팀에서는 서버 부하를 줄이기 위해 DataLoader 적용 이외에도 각 요청의 필드값을 GraphQL요청 컨텍스트 객체를 사용하여 메모리에 캐싱하여 사용하고 있었다. (Dataloader 포스팅)
예를 들어 다음과 같은 요청이 들어오면, 서버는 posts 에 대해 한 번만 처리한다.
query {
user(id: 1) {
name
posts {
id
title
}
}
posts {
id
title
}
}
캐싱 로직을 자세히 살펴보지 않고 사용하던 중, 요구사항과 매칭되지 않는 데이터가 노출되는 이슈가 발생했다. 코드 내부를 살펴보니, 인풋과 관계없이 필드명으로만 캐싱한다는 것을 발견하였다.
예를 들어, 아래 쿼리로 요청하면 두 posts 모두 같은 데이터를 응답으로 받는 문제가 발생했다.
query {
user(id: 1) {
name
posts(category: "tech") {
id
title
}
posts(category: "life") {
id
title
}
}
}
이 문제를 해결하기 위해 캐시 키로 필드명과 함께 인풋을 사용하도록 하는 유틸함수를 새로 만들었다.
디스커션
이 동작을 global하게 설정하고, 만약 제외하고 싶다면 특정 데코레이터를 붙이는 방식으로 바꾸면 어떨지 생각을 해보았다.
목적과 기능이 다른 경우 필드명을 다르게 하는 전략도 있다고 한다(예: postOfTech). 안그래도 현재 게시글 조회 로직이 너무 복잡하고 거대해져서, 이처럼 목적에 따라 분리되면 좋을 것 같다. 클라이언트의 유연성과 서버 코드 유지보수의 트레이드오프가 있는 것 같다.
유틸 함수를 사용할 때는 AI 의 도움을 받아서라도 최소한의 동작 메커니즘을 이해하는 것이 필요하다고 느꼈다.
라이브러리 내부에 로그를 심어 테스트 한 결과 발견한 원인은 다음과 같다. graphql 패키지는 Enum 타입을 Enum value 를 키로 한 맵 자료구조(GraphQLEnumType 클래스의 valueLookup 필드)로 관리하는데, 이 데이터에서 Enum key를 맵의 키로 하여 값을 찾으려다 실패한 것이다.
=> 데코레이터를 사용하지 않아 serialize 대상에서 제외하는 단순한 우회 전략이다.
Nestjs 패키지의 enum 타입(valueLookup) 처리 로직을 변경 -> (모호)
Graphql 패키지의 Enum 타입 관리 방식을 변경 -> (좋지 않음)
빠른 통합이 목적이었기에 팀에서는 우선적으로 1번의 방법을 선택하였지만 관련 부분을 더 조사하기로 했다.
근본적인 원인 분석
TypeScript 은 Enum type의 내부 값을 허용하며, 문자열 기반 Enum type은 숫자 기반 Enum과 달리 단방향 맵핑만 가능하다.
GraphQL은 Enum을 제한된 상수 값의 집합이라고 정의한다. 각 언어가 Enum 을 처리하는 방식에 상관 없이, 클라이언트와는 문자열만을 주고 받는다.
=> 즉 타입스크립트와 GraphQL 간 Enum 처리 방식 차이로 발생하는 충돌은 개발자가 명시적으로 대응해야 한다.
고찰
그렇다면 Nestjs/graphql 라이브러리가 registerEnum을 처리할 때 역맵핑도 지원하면 어떨까? 그런데 왜 Nestjs 팀은 그렇게 하지 않았을까? GraphQL 명세에 기반한 예측가능성을 해칠 수 있다. Nestjs의 명시성과 단순성 ~ 개발자에게 명확한 Enum 설계 책임이 trade-off 관계인 것 같다.
GraphQLError: Enum "EnumTypeName" cannot represent value: "EnumKey"
at GraphQLEnumType.serialize (.../graphql/type/definition.js:1121:13)
at astFromValue (.../graphql/utilities/astFromValue.js:123:29)
at astFromValue (.../graphql/utilities/astFromValue.js:45:22)
at astFromValue (.../graphql/utilities/astFromValue.js:100:26)
at astFromArg (.../@graphql-tools/merge/node_modules/@graphql-tools/utils/cjs/print-schema-with-directives.js:219:89)
at .../@graphql-tools/merge/node_modules/@graphql-tools/utils/cjs/print-schema-with-directives.js:378:42
at Array.map (<anonymous>)
at astFromField (.../@graphql-tools/merge/node_modules/@graphql-tools/utils/cjs/print-schema-with-directives.js:378:31)
at .../@graphql-tools/merge/node_modules/@graphql-tools/utils/cjs/print-schema-with-directives.js:239:62
at Array.map (<anonymous>)
at astFromObjectType (.../@graphql-tools/utils/cjs/print-schema-with-directives.js:239:49)
at getDocumentNodeFromSchema (.../@graphql-tools/merge/node_modules/@graphql-tools/utils/cjs/print-schema-with-directives.js:30:30)
at visitTypeSources (.../@graphql-tools/merge/cjs/typedefs-mergers/merge-typedefs.js:44:72)
at visitTypeSources (.../@graphql-tools/merge/cjs/typedefs-mergers/merge-typedefs.js:40:17)
at mergeGraphQLTypes (.../@graphql-tools/merge/cjs/typedefs-mergers/merge-typedefs.js:71:41)
at mergeTypeDefs (.../@graphql-tools/merge/cjs/typedefs-mergers/merge-typedefs.js:13:22)
at makeExecutableSchema (.../@graphql-tools/schema/cjs/makeExecutableSchema.js:72:58)
at mergeSchemas (.../@graphql-tools/schema/cjs/merge-schemas.js:32:63)
at transformSchema (.../src/app.module.ts:75:42)
at GraphQLFederationFactory.generateSchema (.../@nestjs/graphql/dist/federation/graphql-federation.factory.js:38:22)
at async GraphQLModule.onModuleInit (.../@nestjs/graphql/dist/graphql.module.js:109:27)
at async callModuleInitHook (.../@nestjs/core/hooks/on-module-init.hook.js:51:9)