[GraphQL - Apollo Federation] GraphQL이 라우팅하는 과정

2026. 3. 16. 10:56·Engine/GraphQL + Apollo Federation

Spring Boot -> GraphQL — 라우팅과 코드베이스 분석

서론

1편에서는 REST와 GraphQL의 근본적인 패러다임 차이 — 라우팅 키, 데이터 결정권, Gateway의 진화 — 를 다뤘다. 이번 편에서는 한 단계 더 들어간다.

"Controller가 없는데 요청이 어떻게 Resolver까지 도달하는가?"의 내부 동작,

"GraphQL인데 왜 GET 요청이 보이는가?"의 비밀(APQ와 3계층 캐싱),

그리고 Spring 개발자가 NestJS + GraphQL 코드베이스를 처음 열었을 때 어떤 순서로 파일을 읽어야 하는가에 대한 정리


1. Controller 없이 어떻게 라우팅할까? — GraphQL 엔진의 AST 파싱

Spring의 라우팅 복기

Spring에서 요청이 처리되는 내부 흐름을 먼저 복기하자.

HTTP 요청 → Tomcat → DispatcherServlet
  → HandlerMapping이 URL + HTTP Method를 보고 어떤 Controller 메서드인지 결정
  → HandlerAdapter가 그 메서드를 실행
  → 결과를 HttpMessageConverter가 JSON으로 직렬화

핵심은 HandlerMapping이 "URL 패턴 → 메서드" 매핑 테이블을 서버 부팅 시 만들어둔다는 것이다. @GetMapping("/posts/{id}")가 붙은 메서드를 스캔해서 테이블에 등록한다.

GraphQL 엔진의 라우팅 — 같은 원리, 다른 키

GraphQL 엔진도 정확히 같은 일을 한다. 매핑 키가 다를 뿐이다.

HTTP POST /graphql → Express/NestJS → Apollo Server 미들웨어
  → 1) 요청 body에서 query 문서를 꺼냄
  → 2) GraphQL 파서가 query 문서를 AST(추상 구문 트리)로 파싱
  → 3) AST를 스키마와 대조 — "이 필드는 어떤 타입의 어떤 필드인가?"
  → 4) 해당 필드에 매핑된 Resolver 함수를 호출
  → 5) Resolver의 반환값을 클라이언트가 요청한 형태로 직렬화

Spring의 HandlerMapping이 "URL → 메서드" 테이블이라면, GraphQL 엔진은 "타입.필드명 → Resolver 함수" 테이블이다.

예를 들어 스키마에 이렇게 선언되어 있고:

type Mutation {
    postsUpdate(boardID: ID, input: PostsUpdateInput!): PostsUpdatePayload
}

Resolver 객체에 이렇게 등록되어 있으면:

{
    Mutation: {
        postsUpdate: async (_, { boardID, input }, { user, elastic }) => { ... }
    }
}

GraphQL 엔진이 부팅 시 "Mutation.postsUpdate → 이 함수"라는 매핑을 만들어둔다. 클라이언트가 mutation { postsUpdate(input: {...}) { posts { id } } }를 보내면, 엔진이 AST를 파싱해서 "Mutation 타입의 postsUpdate 필드"를 식별하고, 매핑 테이블에서 함수를 찾아 호출한다.

레거시 Resolver가 스키마에 등록되는 체인

실무에서 GraphQL 코드베이스를 분석할 때 자주 혼란스러운 것이, @Resolver() 데코레이터 없이 일반 함수로 작성된 레거시 Resolver가 어떻게 스키마에 연결되는가이다.

NestJS 환경에서 레거시(Express 스타일)와 최신(NestJS 스타일) Resolver가 공존하는 구조라면, 등록 체인은 보통 이렇게 생겼다:

[개별 Resolver 함수]
  → Resolver 등록부에서 { Mutation: { postsUpdate } } 형태로 객체에 합침
  → 상위 Resolvers 배열로 모아짐
  → buildSubgraphSchema({ typeDefs, resolvers })로 레거시 스키마 생성
  → 루트 모듈의 transformSchema에서 NestJS 스키마와 mergeSchemas()로 합침
  → 최종 합쳐진 스키마가 Apollo Server에 로딩됨

이 체인을 역추적하려면 IDE에서 다음 키워드를 검색하면 된다:

  1. Mutation: { — 레거시 Resolver 등록 지점
  2. buildSubgraphSchema 또는 makeExecutableSchema — 레거시 스키마 빌드 진입점
  3. mergeSchemas — 레거시 + NestJS 스키마 합성 지점
  4. @Mutation( — NestJS 스타일 Resolver 식별

💡 Architect's Insight: 레거시와 최신 Resolver가 공존하는 구조에서 mergeSchemas를 쓰면, 같은 이름의 필드가 양쪽에 있을 때 나중에 지정된 스키마가 우선한다. 이것은 점진적 마이그레이션(Strangler Fig Pattern)을 가능하게 하는 핵심 메커니즘이다 — NestJS에 같은 이름의 Resolver를 만들면 자연스럽게 레거시를 대체한다.


2. GraphQL은 무조건 POST만 쓸까? — APQ와 3계층 HTTP 캐싱 전략

"전부 POST"는 절반만 맞다

GraphQL 스펙 자체는 전송 방식을 강제하지 않는다. 관례적으로 POST를 쓰는데, 쿼리 문서가 길어서 URL에 담기 어렵기 때문이다. 하지만 APQ(Automatic Persisted Queries)가 활성화되면 읽기(Query)는 GET으로, 쓰기(Mutation)는 POST로 나뉜다.

왜 이렇게 나누는가? HTTP GET 응답은 HTTP 캐싱 인프라 전체를 활용할 수 있기 때문이다. POST 응답은 HTTP 스펙상 캐싱이 안 된다.

APQ의 동작 원리 — "해시로 쿼리를 식별한다"

네트워크 탭에서 보이는 sha256Hash는 암호화가 아니다. 쿼리 텍스트의 지문(fingerprint)이다. 같은 쿼리 텍스트는 항상 같은 해시를 만든다. 목적은 두 가지다:

  • 전송량 절감: 수 KB짜리 쿼리 대신 64자 해시만 보내면 네트워크 트래픽이 줄어든다
  • HTTP 캐싱 활용: GET URL에 해시가 들어가므로, 같은 쿼리 + 같은 변수 조합이면 URL이 동일해져 캐싱이 가능하다

APQ의 2단계 핸드셰이크

APQ에서 "Automatic"이 의미하는 것은, 쿼리를 사전에 서버에 수동 등록하지 않고 운영 중에 자동으로 등록한다는 것이다.

[최초 요청 — 서버가 이 쿼리를 아직 모르는 상태]

1) 클라이언트가 GET 요청: /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
2) 서버가 캐시에서 "abc123" 해시를 찾아봄 → 없음
3) 서버가 응답: { errors: [{ message: "PersistedQueryNotFound" }] }
4) 클라이언트 라이브러리(Apollo Client)가 이 에러를 감지하고 자동으로 POST로 재시도:
   body: { query: "{ post(id: 1) { title } }", extensions: {"persistedQuery":{"sha256Hash":"abc123"}} }
5) 서버가 쿼리 텍스트를 받아서 캐시에 "abc123" → 쿼리 텍스트 매핑을 저장
6) 동시에 쿼리를 실행해서 데이터를 응답

[이후 같은 쿼리 요청 — 서버가 이미 알고 있는 상태]

7) 클라이언트가 GET 요청: /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
8) 서버가 캐시에서 쿼리 텍스트를 찾음 → 실행 → 데이터 응답
9) 이 GET 응답은 CDN/브라우저/프록시에서 캐싱 가능

여기서 중요한 것: 4번의 재시도는 브라우저가 하는 게 아니라 Apollo Client 라이브러리가 한다. "PersistedQueryNotFound 에러를 받으면 query 텍스트를 포함해서 POST로 재시도"하는 로직이 라이브러리 내부에 구현되어 있다. 브라우저의 네트워크 스택은 이런 걸 모른다.

또한 PersistedQueryNotFound는 커스텀 에러가 아니라 Apollo Server의 기본 에러 코드다. APQ가 활성화된 서버에서 자동으로 반환된다.

3계층 HTTP 캐싱 워크플로우

APQ가 활성화된 상태에서 읽기 요청의 캐싱은 3개 레이어에서 일어난다:

레이어 위치 캐싱 대상 캐싱 키 동작
1층: 브라우저 클라이언트 로컬 GET 응답 데이터 URL (해시 포함) Cache-Control 헤더 기반. 만료 전이면 서버에 안 감
2층: CDN 엣지 서버 (CloudFront 등) GET 응답 데이터 URL (해시 포함) 가까운 엣지에서 바로 응답. 원본 서버 부하 감소
3층: 서버 캐시 Redis 등 해시 → 쿼리 텍스트 매핑 sha256Hash 서버 재시작돼도 매핑 유지. 2단계 핸드셰이크 회피

전체 흐름을 합치면:

브라우저 → "GET /graphql?...sha256Hash=abc123"
  → 브라우저 캐시 확인 → HIT면 서버 안 감, 바로 응답
  → MISS면 CDN에 도달 → CDN 캐시 확인 → HIT면 CDN이 응답
  → MISS면 원본 서버에 도달
    → Apollo Server가 Redis에서 해시 조회 → 쿼리 텍스트 찾음
    → 쿼리 실행 → 응답 + Cache-Control 헤더 설정
    → CDN이 이 응답을 캐싱
    → 브라우저가 이 응답을 캐싱

POST 요청(Mutation)은 이 캐싱 체인을 전혀 탈 수 없다. 그래서 읽기는 GET(캐싱 가능), 쓰기는 POST(매번 서버까지)로 나누는 것이다.

💡 Architect's Insight: APQ + CDN 캐싱은 읽기 트래픽이 압도적인 서비스에서 강력하다. 하지만 캐시 무효화(invalidation)가 어렵다는 트레이드오프가 있다. 게시글이 수정되었는데 CDN에 이전 버전이 남아있으면 사용자에게 stale 데이터가 보인다. Cache-Control: max-age를 데이터 특성에 맞게 설정하는 것이 핵심이다 — 프로필 정보는 1시간, 실시간 피드는 10초처럼.


3. 타입 시스템의 패러다임: Kotlin의 ! vs GraphQL의 !

Nullability의 기본값이 반대다

Kotlin을 써본 개발자라면 ! 문법이 익숙할 것이다. 하지만 기본값의 방향이 정반대다.

언어/시스템 String String! / String? 기본값
Kotlin Non-Null String? = Nullable 안전한 쪽 (Non-Null)
GraphQL Nullable String! = Non-Null 유연한 쪽 (Nullable)

GraphQL이 Nullable을 기본으로 설계한 이유는 부분 실패(partial failure)를 허용하기 위해서다. 어떤 필드의 Resolver가 에러를 내더라도 나머지 필드는 정상 응답할 수 있어야 한다. !가 붙은 필드에서 null이 반환되면 GraphQL 엔진이 에러를 상위로 전파(Null Propagation)해서 부모 필드까지 null로 만들어버리기 때문에, 실무에서는 "정말 확실한 경우에만 !를 붙이고 나머지는 Nullable로 두는" 방어적 설계가 흔하다.

NestJS Code-first에서의 필드 노출 제어

NestJS의 Code-first 방식에서는 @Field() 데코레이터가 붙지 않은 필드는 GraphQL 스키마에 아예 노출되지 않는다. TypeScript 클래스에 속성이 있어도 @Field()가 없으면 클라이언트가 요청할 수 없다.

@ObjectType('User')
export class UserDto {
    @Field(() => ID)
    id: bigint;                    // GraphQL에 노출됨

    email: string | null;          // @Field 없음 → 노출 안 됨 (내부 전용)

    @Field(() => String, { nullable: true })
    phoneNumber?: string | null;   // GraphQL에 Nullable로 노출됨
}

이것은 Spring에서 DTO에 @JsonIgnore를 붙여 특정 필드를 JSON 직렬화에서 제외하는 것과 같은 역할이다.

"GraphQL은 뭉뚱그려서 처리하는 거 아닌가?"

아니다. GraphQL은 Spring보다 더 엄격한 면이 있다. 다만 엄격함의 위치가 다를 뿐이다.

Spring REST에서는 용도별 DTO를 분리하고 Bean Validation으로 검증한다:

public class UserCreateRequest {
    @NotNull String email;      // 필수
    @NotNull String password;   // 필수
    String nickname;            // 선택
}

GraphQL도 InputType을 용도별로 분리한다:

input SignupInput {
    email: String!       # 필수 (Non-Null)
    password: String!    # 필수
    nickname: String     # 선택 (Nullable)
}

input UserUpdateInput {
    email: String        # 선택
    nickname: String     # 선택
}

!가 붙은 필드를 보내지 않으면 스키마 파싱 단계에서 에러가 발생한다. Resolver까지 도달하지도 않는다. Spring의 @Valid는 Controller 메서드가 호출된 후에 Validator가 검증하지만, GraphQL은 아예 파싱 단계에서 차단한다.

즉, "뭉뚱그려서 처리"가 아니라 검증의 1차 방어선이 DTO 레벨 어노테이션에서 스키마 타입 시스템으로 이동한 것이다.

검증 시점 Spring REST GraphQL
1차 방어선 @Valid + Bean Validation (Controller 호출 후) 스키마 파싱 단계 (Resolver 호출 전)
2차 방어선 Service 레이어 비즈니스 검증 class-validator / Resolver 내부 검증
검증 기준 DTO 어노테이션 스키마 타입 시스템 (!, Enum, Custom Scalar)

💡 Architect's Insight: GraphQL의 타입 시스템은 "클라이언트와 서버 간의 정적 계약"이다. 코드 생성기(GraphQL Codegen)로 이 스키마를 읽으면 프론트엔드 TypeScript 타입이 자동 생성되어, 컴파일 타임에 잘못된 요청을 잡을 수 있다. REST에서 OpenAPI(Swagger) 스펙을 별도로 관리하는 수고가 GraphQL에서는 스키마 하나로 해결된다.


4. Spring 개발자를 위한 NestJS + GraphQL 코드베이스 분석 지도

Spring Boot 코드베이스를 분석할 때 필자는 이런 순서로 본다:

settings.gradle → build.gradle → application.yml → Domain/Entity → Service → Controller

이 순서의 본질은 "의존성/설정 → 데이터 모델 → 비즈니스 로직 → 진입점"으로 밖에서 안으로 들어가는 것이다.

NestJS + GraphQL에서도 같은 원리를 적용할 수 있다. 파일명이 달라질 뿐이다.

1단계: 의존성과 빌드 설정

Spring의 settings.gradle + build.gradle에 해당

파일 보는 이유
package.json 의존성 목록 + scripts(빌드/실행 명령). build.gradle과 동일 역할
tsconfig.json TypeScript 컴파일 설정. paths alias를 보면 모듈 구조가 파악됨
docker-compose.yml 어떤 서비스가 있고 포트가 뭔지. MSA 구조를 한눈에 파악
모노레포라면 nx.json / turbo.json 서브패키지 관계 파악. settings.gradle의 include와 동일

2단계: 설정과 부트스트랩

Spring의 application.yml + @SpringBootApplication에 해당

파일 보는 이유
.env / config.ts 환경변수 — DB 호스트, Redis, 포트 등
main.ts 서버 부팅 진입점. SpringApplication.run()과 동일
루트 모듈 (app.module.ts) 핵심 모듈 등록. @Configuration 클래스들의 집합체. 여기서 어떤 Module이 import되는지가 전체 기능 목록
Express 미들웨어 엔트리 (있다면) 미들웨어 체인 설정. Spring의 FilterChain에 해당

3단계: 데이터 모델

Spring의 Domain/Entity 클래스에 해당

파일 보는 이유
schema.prisma (Prisma) 전체 테이블 구조 + 관계가 한 파일에. JPA Entity들을 한 번에 보는 효과
*.model.ts (Sequelize) @Table, @Column 데코레이터. JPA @Entity와 1:1
.graphql 파일 또는 @ObjectType 클래스 클라이언트에 노출되는 타입. REST의 Response DTO에 해당
@InputType 클래스 클라이언트가 보내는 입력 타입. Request DTO에 해당

여기서 핵심 판단을 내릴 수 있다: ORM 모델에 비즈니스 로직(메서드)이 없고 Service에 전부 있으면 → Transaction Script 패턴.

실무에서 NestJS + Prisma 프로젝트의 대부분이 이 패턴이다.

그래서 바꾸고 싶다 .. TypeORM이나 MikroORM으로 말이다 .. ㅠ

4단계: 비즈니스 로직

Spring의 Service 계층에 해당

파일 보는 이유
*.service.ts 핵심 비즈니스 로직. Spring @Service와 1:1
*.resolver.ts GraphQL 진입점. @Controller와 역할 동일. 얇아야 정상

5단계: 스키마와 라우팅

Spring의 @GetMapping, @PostMapping — 전체 API 계약에 해당

파일 보는 이유
generated.graphql 또는 수동 작성된 .graphql 파일 전체 API 계약. Swagger/OpenAPI 스펙에 해당. 어떤 Query/Mutation이 있는지 한눈에 파악
레거시 Resolver 등록부 레거시에 남아있는 Mutation/Query 목록
buildSubgraphSchema() 유틸 레거시 스키마 빌드 진입점

6단계: 인프라/운영

파일 보는 이유
Dockerfile 빌드/배포 방식
CI/CD 설정 (.github/workflows 등) 배포 파이프라인
로깅/모니터링 설정 장애 시 어디를 보는가

레거시와 최신 코드의 경계를 빠르게 파악하는 법

혼합 스택(레거시 Express + 최신 NestJS) 환경에서는 "이 기능은 어느 쪽인가?"를 빠르게 판별하는 것이 중요하다:

  • @Resolver(), @Query(), @Mutation() 데코레이터가 있으면 → NestJS 스타일
  • IResolvers 객체에 함수를 직접 할당하면 → 레거시 Express 스타일
  • IDE에서 Mutation: {를 검색하면 레거시 Resolver 전체 목록을, @Mutation(을 검색하면 NestJS Resolver 전체 목록을 파악할 수 있다

💡 Architect's Insight: 이 분석 프레임워크의 핵심은 "어떤 코드베이스를 만나더라도 같은 순서를 적용할 수 있다"는 것이다. Spring이든 NestJS든, REST든 GraphQL이든, "의존성/설정 → 데이터 모델 → 비즈니스 로직 → 진입점" 순서로 밖에서 안으로 들어가는 습관이 복리로 쌓인다.


결론: 멘탈 모델의 전환

Spring Boot 개발자가 NestJS + GraphQL 코드베이스를 분석할 때 가져가야 할 핵심 멘탈 모델은 이것이다:

[Spring Boot 멘탈 모델]
URL → DispatcherServlet → Controller → Service → Repository → DB

[NestJS + GraphQL 멘탈 모델]
Query/Mutation 문서 → GraphQL 엔진 → 스키마 매칭 → Resolver → Service → ORM → DB

사고의 출발점이 "URL"에서 "스키마의 타입.필드"로 바뀌었을 뿐, 나머지 흐름은 대략적으로 비슷하다.

DispatcherServlet이 GraphQL 엔진으로, @Controller가 Resolver로, @GetMapping이 type Query { ... }로 바뀌었다고 생각하면 된다.


다음 글에서는 TypeScript의 타입 시스템이 Java와 어떻게 다른지(컴파일 타임 전용 타입, null vs undefined, async/await), 그리고 NestJS의 모듈 시스템이 Spring의 @ComponentScan과 어떻게 다른지를 다룰 예정이다.

 

 

'Engine > GraphQL + Apollo Federation' 카테고리의 다른 글

[GraphQL - Apollo Federation] GraphQL이 어떻게 동작하는데 ? (feat. Spring과 간단한 비교)  (0) 2026.03.16
'Engine/GraphQL + Apollo Federation' 카테고리의 다른 글
  • [GraphQL - Apollo Federation] GraphQL이 어떻게 동작하는데 ? (feat. Spring과 간단한 비교)
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (127)
      • Computer Science (23)
        • 운영체제 (7)
        • 데이터통신 (6)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (4)
        • OOP - 객체 지향 프로그래밍 (2)
        • DDD - 도메인 주도 개발 (데이터베이스 주도 .. (0)
        • EDA - 이벤트 기반 아키텍처 (1)
        • MSA - 마이크로서비스 아키텍처 (0)
        • ADD - AI 주도 개발 (1)
      • Language (2)
        • Java (0)
        • TypeScript (2)
      • Framework (12)
        • Spring (9)
        • NestJS (3)
      • Engine (3)
        • Elasticsearch (1)
        • GraphQL + Apollo Federation (2)
      • Plugin - Extension (1)
        • VS Code (1)
        • IntelliJ (0)
      • Tips (2)
        • 터미널 명령어 (1)
        • 우분투 명령어 에러 (1)
      • SSA (26)
        • Front (1)
        • Back (23)
        • DB (1)
        • 기획 (1)
      • CNU SW 아카데미 (42)
        • 1주차 (5)
        • 2주차 (5)
        • 3주차 (2)
        • 4주차 (1)
        • 5주차 (3)
        • 6주차 (2)
        • 7주차 (0)
        • 8주차 (1)
        • 9주차 (14)
        • 10주차 (0)
        • 11주차 (1)
        • 12주차 (0)
        • 13주차 (2)
        • 14주차 (2)
        • 15주차(최종 프로젝트) (3)
        • 최종 프로젝트 이후 (1)
      • 모각코 (6)
        • 2023 동계 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    생산성
    Husky
    DX(DeveloperExperience)
    ESLint
    개발자경험(DX)
    Typescript
    프론트엔드/백엔드
    아키텍처
    릴리스엔지니어링
    ci/cd
    lint-staged
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[GraphQL - Apollo Federation] GraphQL이 라우팅하는 과정
상단으로

티스토리툴바