Query와 Mutation은 요청-응답 구조다. 클라이언트가 물어봐야 답이 온다. 하지만 "새 책이 등록되면 즉시 알려줘"처럼 서버가 먼저 데이터를 밀어주는 상황이 필요할 때가 있다. Subscription이 이 역할을 한다. 이번 편에서는 Subscription의 동작 원리와 함께, GraphQL의 에러 응답 구조도 살펴본다.

Subscription이 필요한 이유

Query와 Mutation은 HTTP 요청-응답 모델을 따른다. 클라이언트가 요청을 보내야만 서버가 응답한다. 이 구조에서 "실시간 알림"을 구현하려면 폴링을 돌려야 한다. 주기적으로 "새 데이터 있어?" 하고 묻는 방식이다.

폴링의 문제는 명확하다. 데이터가 없어도 계속 요청이 가고, 데이터가 생겨도 다음 폴링 주기까지 기다려야 한다. 낭비와 지연이 동시에 발생한다.

Subscription은 이 문제를 해결한다. 클라이언트가 한 번 구독하면, 서버에서 이벤트가 발생할 때마다 즉시 데이터를 받을 수 있다.

sequenceDiagram autonumber participant C1 as 구독자 (클라이언트 A) participant S as GraphQL 서버 participant C2 as 작성자 (클라이언트 B) rect rgb(232, 248, 232) Note over C1, S: 구독 연결 C1->>S: subscription { bookAdded { title } } Note right of S: WebSocket 연결 유지 end rect rgb(240, 248, 255) Note over C2, S: 이벤트 발생 C2->>S: mutation { createBook(...) } S-->>C2: { createBook: { id: 3 } } end rect rgb(232, 248, 232) Note over C1, S: 실시간 푸시 S--)C1: { bookAdded: { title: "새 책" } } end

초록 영역에서 구독자가 연결을 맺고, 파란 영역에서 다른 클라이언트가 책을 생성하면, 서버가 자동으로 구독자에게 데이터를 푸시한다.

WebSocket 기반 통신

Subscription은 WebSocket 위에서 동작한다. HTTP는 요청이 있어야 응답이 오는 단방향 구조지만, WebSocket은 한 번 연결되면 양쪽에서 자유롭게 메시지를 보낼 수 있는 양방향 구조다.

Spring GraphQL에서 WebSocket을 사용하려면 두 가지가 필요하다.

의존성 추가:

implementation 'org.springframework.boot:spring-boot-starter-websocket'

WebSocket 경로 설정:

spring:
  graphql:
    websocket:
      path: /graphql

이렇게 설정하면 /graphql 경로로 HTTP 요청과 WebSocket 요청을 모두 받을 수 있다.

Reactive Streams — Flux와 Sinks

Subscription을 이해하려면 Reactive Streams의 두 가지 개념을 알아야 한다.

Flux — 데이터 스트림

Flux0개 이상의 데이터가 시간에 걸쳐 흘러오는 스트림이다. Java의 List가 데이터를 한 번에 담고 있다면, Flux는 데이터가 하나씩 도착하는 파이프라인이다.

Subscription에서 Flux<Book>을 반환하면, "Book 데이터가 생길 때마다 하나씩 내려보내겠다"는 뜻이 된다.

Sinks — 이벤트 발행기

SinksFlux에 데이터를 밀어 넣는 입구다. 라디오 방송국에 비유하면 이해하기 쉽다.

  • Sinks = 방송국의 마이크. 여기에 대고 말하면 전파를 탄다.
  • Flux = 라디오 주파수. 청취자(구독자)가 이 주파수에 맞추면 방송이 들린다.
  • tryEmitNext() = 마이크에 대고 말하는 행위. 모든 청취자에게 동시에 전달된다.
flowchart LR subgraph Publish ["이벤트 발행"] direction TB MR["MutationResolver
createBook()"] SINK["Sinks.Many<Book>
(마이크)"] end subgraph Stream ["데이터 스트림"] direction TB FLUX["Flux<Book>
(주파수)"] end subgraph Subscribe ["구독자"] direction TB C1["클라이언트 A"] C2["클라이언트 B"] C3["클라이언트 C"] end MR -->|"tryEmitNext(book)"| SINK SINK -->|"asFlux()"| FLUX FLUX -->|"WebSocket"| C1 FLUX -->|"WebSocket"| C2 FLUX -->|"WebSocket"| C3 style Publish fill:#FFF3E0,stroke:#FF9800 style Stream fill:#E8F4F8,stroke:#2196F3 style Subscribe fill:#E8F8E8,stroke:#4CAF50

왼쪽에서 Mutation이 데이터를 Sinks에 넣으면, 중간의 Flux를 타고, 오른쪽의 모든 구독자에게 전달된다.

@SubscriptionMapping 구현

실제 코드를 보자. Subscription Resolver는 Sinks와 Flux를 조합해서 만든다.

@Controller
public class BookSubscriptionResolver {

    private final Sinks.Many<Book> bookSink =
            Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Book> bookAdded() {
        return bookSink.asFlux();
    }

    public void publishBookAdded(Book book) {
        bookSink.tryEmitNext(book);
    }
}

Sinks.many().multicast().onBackpressureBuffer()가 하는 일을 분해하면 이렇다.

  • many() : 여러 개의 데이터를 발행할 수 있다 (1회성이 아님)
  • multicast() : 여러 구독자에게 동시에 전달한다 (브로드캐스트)
  • onBackpressureBuffer() : 구독자가 처리하지 못한 데이터를 버퍼에 쌓아둔다

이 Resolver를 Mutation과 연결하면 실시간 알림이 완성된다.

@MutationMapping
public Book createBook(@Argument CreateBookInput input) {
    Book book = bookService.createBook(input);
    subscriptionResolver.publishBookAdded(book);  // 이벤트 발행
    return book;
}

createBook Mutation이 실행될 때마다 publishBookAdded()가 호출되고, 구독 중인 모든 클라이언트에게 새 책 정보가 전달된다.

Subscription의 라이프사이클

클라이언트가 구독을 시작하면 WebSocket 연결이 유지되고, Flux가 데이터를 내려보낸다. 클라이언트가 연결을 끊으면 해당 구독이 자동으로 해제된다. 서버 측에서 별도로 구독자를 관리할 필요가 없다.

GraphQL 예외 처리

REST에서는 HTTP 상태 코드(404, 500 등)로 에러를 구분한다. GraphQL은 다르다. HTTP 응답은 항상 200 OK이고, 에러 정보는 응답 본문의 errors 필드에 담긴다.

{
  "data": null,
  "errors": [
    {
      "message": "도서를 찾을 수 없습니다: ID=999",
      "extensions": {
        "classification": "NOT_FOUND"
      }
    }
  ]
}

이 구조를 이해하지 못하면 에러 처리에서 혼란이 생긴다. GraphQL에서는 "데이터 일부만 성공하고 일부만 실패"하는 부분 에러도 가능하기 때문에, HTTP 상태 코드 하나로 결과를 표현할 수 없다.

커스텀 예외 만들기

Spring에서 하던 것처럼 도메인 전용 예외를 만든다.

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) {
        super("도서를 찾을 수 없습니다: ID=" + id);
    }
}

Service에서 이 예외를 던진다.

public Book getBookById(Long id) {
    return bookRepository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
}

@GraphQlExceptionHandler로 전역 처리

REST에서 @ExceptionHandler를 사용하듯, GraphQL에서는 @GraphQlExceptionHandler로 예외를 잡아 GraphQL 에러 응답으로 변환한다.

@ControllerAdvice
public class GraphQLExceptionHandler {

    @GraphQlExceptionHandler
    public GraphQLError handleNotFoundException(BookNotFoundException e) {
        return GraphQLError.newError()
                .message(e.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .extensions(Map.of(
                        "errorCode", "ERR-BOOK-001",
                        "httpStatus", "404"
                ))
                .build();
    }
}

이 핸들러가 하는 일을 시퀀스로 보면 이렇다.

sequenceDiagram autonumber participant C as 클라이언트 participant R as Resolver participant S as BookService participant H as ExceptionHandler C->>R: { book(id: 999) { title } } R->>S: getBookById(999) rect rgb(255, 230, 230) Note over S, H: 예외 발생 및 처리 S--xR: BookNotFoundException 발생 R->>H: 예외 전달 H-->>R: GraphQLError 반환 end R-->>C: { data: null, errors: [{message, extensions}] }

분홍 영역에서 Service가 예외를 던지면, ExceptionHandler가 잡아서 GraphQLError로 변환한다. 클라이언트는 구조화된 에러 응답을 받는다.

ErrorType과 extensions

GraphQLError를 구성하는 두 가지 핵심 요소가 있다.

ErrorType — 에러 분류

Spring GraphQL이 제공하는 ErrorType enum이다.

ErrorType의미REST 대응
NOT_FOUND리소스 없음404
BAD_REQUEST잘못된 요청400
UNAUTHORIZED인증 필요401
FORBIDDEN권한 없음403
INTERNAL_ERROR서버 에러500

extensions — 추가 메타데이터

extensions에 에러 코드, HTTP 상태, 추가 정보를 담을 수 있다. 클라이언트가 에러를 프로그래밍적으로 처리할 때 유용하다.

.extensions(Map.of(
    "errorCode", "ERR-BOOK-001",
    "httpStatus", "404"
))

응답에 이렇게 포함된다.

{
  "errors": [{
    "message": "도서를 찾을 수 없습니다: ID=999",
    "extensions": {
      "errorCode": "ERR-BOOK-001",
      "httpStatus": "404",
      "classification": "NOT_FOUND"
    }
  }]
}

classificationErrorType에서 자동으로 추가된다.

자주 하는 실수

예외를 처리하지 않고 그냥 던지는 것

@GraphQlExceptionHandler가 없으면 모든 예외가 INTERNAL_ERROR로 처리되고, 예외 메시지가 클라이언트에 그대로 노출된다. 내부 구현이 드러나는 보안 문제가 생길 수 있다. 도메인 예외는 반드시 핸들러에서 잡아 적절한 메시지로 변환해야 한다.

[!DANGER] Subscription에서 에러 발생 시 스트림이 끊기는 것

Flux 스트림에서 에러가 발생하면 해당 스트림은 종료된다. 구독자의 WebSocket 연결이 끊어지는 것이다. publishBookAdded() 내부에서 예외가 발생하지 않도록 방어 코드를 넣거나, onErrorResume()으로 에러를 흡수해야 한다.

면접 Q&A

GraphQL에서 HTTP 상태 코드가 항상 200인 이유는?

GraphQL은 하나의 요청에 여러 필드를 조회할 수 있고, 일부 필드만 실패하는 "부분 에러"가 가능하다. 이때 HTTP 상태 코드로는 "부분 성공"을 표현할 수 없기 때문에, 항상 200을 반환하고 에러 정보는 응답 본문에 넣는 구조를 택한 것이다.

[!QUESTION] @BatchMapping과 DataLoader의 차이는?

둘 다 N+1을 해결하는 배치 로딩 메커니즘이다. DataLoader는 GraphQL Java 라이브러리가 제공하는 저수준 API로, DataLoaderRegistry에 직접 등록해야 한다. @BatchMapping은 Spring GraphQL이 제공하는 어노테이션 기반 추상화로, 내부적으로 DataLoader를 사용하지만 보일러플레이트 없이 선언적으로 사용할 수 있다.

[!QUESTION] (함정) GraphQL Subscription은 SSE로 구현할 수 있나?

기술적으로는 가능하지만, GraphQL 표준 프로토콜은 Subscription 전송에 WebSocket을 사용하도록 정의하고 있다. SSE는 서버→클라이언트 단방향이라 구독 해제 같은 양방향 제어가 어렵다. Spring GraphQL도 Subscription은 WebSocket만 지원한다.

[!QUESTION] (함정) @BatchMapping이 항상 성능이 좋은가?

아니다. 단건 조회(book(id: 1))에서 author를 가져올 때는 @BatchMapping이 필요 없다. 리스트 1개짜리 배치 호출이 되어 오히려 코드가 복잡해질 뿐이다. 목록 조회에서 관계 필드를 포함할 때만 의미가 있다.