Reactive GraphQL — Mono/Flux·WebFlux 통합

2026-05-03확률과 통계 마스터 노트

Reactive GraphQL 마스터 노트 시리즈 5편. Reactive GraphQL이 WebFlux 환경에 자연스러운 이유, Mono·Flux 반환 자동 처리, R2DBC·Reactive MongoDB 통합, Reactor 합성·flatMap 패턴, BatchMapping의 Reactive 버전, Subscription에서 Backpressure까지.

이 글은 Reactive GraphQL 마스터 노트 시리즈의 다섯 번째 편입니다. 4편(Spring)에서 어노테이션 기반을 봤다면, 이번엔 Reactive 깊은 통합 — Mono/Flux·WebFlux·R2DBC.

Spring GraphQL = Reactive 우선 설계. WebFlux + R2DBC + GraphQL 자연스러운 조합. 처리량·확장성에 강력.

처음 Reactive GraphQL이 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, Mono·Flux 반환 시 어떻게 처리되는지 막연합니다. 둘째, R2DBC와 결합이 막연합니다.

해결법은 한 가지예요. "Reactive = 그냥 Mono/Flux 반환" 한 줄. Spring GraphQL이 자동 처리. 합성·subscribe 신경 X.

Mono/Flux 반환

@QueryMapping
public Mono<User> user(@Argument String id) {
    return userService.findByIdReactive(id);
}

@QueryMapping
public Flux<User> users() {
    return userService.findAllReactive();
}

@QueryMapping
public Mono<List<User>> usersList() {
    return userService.findAllReactive().collectList();
}

여기서 정말 중요한 시험 함정 — Spring GraphQL이 Mono/Flux 자동 처리. block() 호출 X. WebFlux 친화·논블로킹.

R2DBC 통합

public interface UserRepository extends ReactiveCrudRepository<User, String> {
    Mono<User> findByEmail(String email);
    Flux<User> findByActiveTrue();
}

@Controller
public class UserController {
    
    @Autowired
    private UserRepository repo;
    
    @QueryMapping
    public Mono<User> user(@Argument String id) {
        return repo.findById(id);
    }
    
    @QueryMapping
    public Flux<User> activeUsers() {
        return repo.findByActiveTrue();
    }
}

자연스럽게 통합. DB 쿼리 = Reactive 그대로 GraphQL 응답.

flatMap 합성

@QueryMapping
public Mono<UserWithStats> userWithStats(@Argument String id) {
    return userRepo.findById(id)
        .flatMap(user ->
            statsService.getStats(id)
                .map(stats -> new UserWithStats(user, stats))
        );
}

여러 비동기 호출 합성. Mono·Flux 표준.

SchemaMapping + Reactive

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}
@SchemaMapping(typeName = "User", field = "posts")
public Flux<Post> posts(User user) {
    return postRepo.findByAuthorId(user.getId());
}

자연스럽게 Flux 반환.

BatchMapping + Reactive

@BatchMapping(typeName = "User", field = "posts")
public Mono<Map<User, List<Post>>> userPosts(List<User> users) {
    List<String> userIds = users.stream().map(User::getId).toList();
    
    return postRepo.findByAuthorIdIn(userIds)
        .collectList()
        .map(posts -> {
            Map<String, List<Post>> byUser = posts.stream()
                .collect(Collectors.groupingBy(Post::getAuthorId));
            
            return users.stream()
                .collect(Collectors.toMap(
                    user -> user,
                    user -> byUser.getOrDefault(user.getId(), List.of())
                ));
        });
}

여기서 시험 함정이 하나 있어요. BatchMapping도 Mono 반환 가능. Reactive 환경에서 자연스러움. 동기 Map 반환과 둘 다 OK.

Subscription Backpressure

@SubscriptionMapping
public Flux<Post> postCreated() {
    return sink.asFlux()
        .onBackpressureBuffer(1000)
        .delayElements(Duration.ofMillis(10));   # 옵션 — 처리량 제어
}

구독자 처리 못 따라가면? Backpressure 자동·버퍼·drop·error 정책.

Mono.zip — 병렬 호출

@QueryMapping
public Mono<DashboardData> dashboard(@AuthenticationPrincipal UserDetails user) {
    String userId = user.getUsername();
    
    return Mono.zip(
        userRepo.findById(userId),
        statsService.getStats(userId),
        notificationRepo.findUnread(userId).collectList()
    ).map(tuple -> new DashboardData(
        tuple.getT1(),  # User
        tuple.getT2(),  # Stats
        tuple.getT3()   # Notifications
    ));
}

3 비동기 호출 동시. 가장 느린 시간만큼.

Reactive Mongo

public interface PostRepository extends ReactiveMongoRepository<Post, String> {
    Flux<Post> findByCategory(String category);
    Mono<Post> findBySlug(String slug);
}

R2DBC와 같은 패턴. MongoDB 비동기.

트랜잭션

@MutationMapping
@Transactional
public Mono<Order> placeOrder(@Argument PlaceOrderInput input) {
    return orderService.validate(input)
        .flatMap(orderRepo::save)
        .flatMap(order -> 
            inventoryService.reserve(order)
                .thenReturn(order)
        );
}

R2DBC @Transactional = TransactionalOperator 자동. Reactive 트랜잭션.

여기서 정말 중요한 시험 함정 — Reactive 트랜잭션 한계 — JPA처럼 풍부 X. R2DBC·MongoDB 별도 동작. 분산 트랜잭션은 Saga·Outbox.

에러 처리 — Reactive 패턴

@QueryMapping
public Mono<User> user(@Argument String id) {
    return userRepo.findById(id)
        .switchIfEmpty(Mono.error(new NotFoundException(id)))
        .onErrorMap(DataAccessException.class,
            e -> new ServiceException("DB error", e));
}

Reactor 표준 에러 처리. @GraphQlExceptionHandler와 결합.

캐싱 — Reactor Cache

@QueryMapping
public Mono<User> user(@Argument String id) {
    return cacheManager.get("user-" + id)
        .switchIfEmpty(
            userRepo.findById(id)
                .doOnSuccess(u -> cacheManager.put("user-" + id, u, Duration.ofMinutes(5)))
        );
}

또는 CacheMono.lookup:

@QueryMapping
public Mono<User> user(@Argument String id) {
    return CacheMono.lookup(this::lookupCache, id)
        .onCacheMissResume(() -> userRepo.findById(id))
        .andWriteWith(this::writeCache);
}

Reactor Cache Add-ons. 4편 (Reactive Redis 시리즈) 참조.

Context — Reactor Context

@QueryMapping
public Mono<User> me() {
    return Mono.deferContextual(ctx -> {
        String userId = ctx.get("userId");
        return userRepo.findById(userId);
    });
}

@Bean
public WebFilter contextFilter() {
    return (exchange, chain) -> {
        String userId = extractUserId(exchange);
        return chain.filter(exchange)
            .contextWrite(Context.of("userId", userId));
    };
}

Reactor Context로 사용자 정보·trace ID 등 비동기 전파.

부분 데이터 + 에러

{
  "data": {
    "user": {
      "name": "Alice",
      "posts": null         # 부분 실패
    }
  },
  "errors": [
    {
      "message": "Failed to load posts",
      "path": ["user", "posts"]
    }
  ]
}

GraphQL = 부분 응답 가능. 한 필드 실패 = 다른 필드는 응답.

여기서 시험 함정이 하나 있어요. 부분 실패 처리 — Reactive Resolver에서 onErrorReturn·onErrorResume으로 빈 응답 가능.

WebFlux 환경 + GraphQL

spring:
  main:
    web-application-type: reactive    # WebFlux 강제
  graphql:
    path: /graphql
    websocket:
      path: /graphql
@SpringBootApplication
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

Spring Boot 3.x + WebFlux + GraphQL = 자동 통합.

성능 — Reactive 효과

시나리오: 100 동시 요청, 각 1초 DB 대기

WebFlux + R2DBC + GraphQL:
  처리 시간: ~1초 (모두 동시)
  메모리: 적음
  
Spring MVC + JPA + GraphQL:
  처리 시간: ~5초 (Pool 200)
  메모리: 많음

WebFlux 효과 = Virtual Thread + Spring MVC와 비슷.

운영 권장 패턴

✓ WebFlux + R2DBC + GraphQL (Reactive 일관)
✓ BatchMapping (N+1 자동 해결)
✓ Mono.zip (병렬 호출)
✓ Backpressure (Subscription)
✓ @Transactional (Reactive)
✓ Caching (Reactor Cache)
✓ Reactor Context (사용자·trace)
✓ 부분 응답 + 에러 (onErrorReturn)
✓ Virtual Thread 또는 WebFlux

Reactive vs Virtual Thread

Reactive (WebFlux):
  - 명시적 Mono/Flux
  - 백프레셔 표준
  - 학습 곡선 ↑
  - 합성 강력
  
Virtual Thread (Spring MVC + Java 21):
  - 동기 코드 그대로
  - 학습 곡선 ↓
  - 디버깅 쉬움
  - 일부 백프레셔 X

여기서 정말 중요한 시험 함정 — GraphQL은 둘 다 OK. Reactive 권장 (Subscription 자연스러움) but Virtual Thread도 가능. 팀 친화도로 결정.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 5편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Mono/Flux 반환만 — Spring GraphQL 자동 처리
  • block() 호출 X
  • R2DBC = Reactive DB 통합 자연스러움
  • ReactiveCrudRepository → Mono·Flux
  • flatMap 합성 = 여러 비동기 호출 연결
  • Mono.zip = 병렬 호출 (가장 느린 시간만큼)
  • BatchMapping + Mono = N+1 자동 해결 (Reactive)
  • Subscription Backpressure — onBackpressureBuffer·delayElements
  • Reactive 트랜잭션 한계 — Saga·Outbox 필요
  • 에러 처리 — switchIfEmpty·onErrorMap·onErrorResume
  • 캐싱 — Reactor Cache Add-ons (CacheMono.lookup)
  • Reactor Context = 사용자 정보·trace ID 전파
  • WebFilter로 컨텍스트 주입
  • 부분 응답 + 에러 = 한 필드 실패 시 다른 응답
  • WebFlux + R2DBC + GraphQL = 자연 통합
  • Reactive 효과 — I/O Bound 처리량·메모리
  • Reactive vs Virtual Thread — 둘 다 OK
  • Reactive = Subscription 자연스러움
  • Virtual Thread = 동기 코드·학습 곡선 ↓

시리즈 다른 편

공식 문서: Reactor / R2DBC 에서 더 깊이.

다음 글(6편)에서는 Security·Testing — 인증·인가·필드 보안·통합 테스트·@GraphQlTest까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!