Mono 완전 정복 — 핵심 정리

2026-05-03AWS SAA-C03 스터디

Java Reactive Programming 핵심 정리 시리즈 2편. Mono.just의 eager 함정부터 defer의 lazy 동작, map과 flatMap의 차이, zipWith로 두 Mono 결합, onError* 에러 처리 3종 세트, block()의 데드락 위험까지 — 한 잔만 따라주는 자판기 비유로 단건 비동기의 모든 패턴을 친절하게 풀어쓴 2편.

📚 Java Reactive Programming 핵심 정리 · 2편 / 14편 — 핵심 정리

이 글은 Java Reactive Programming 핵심 정리 시리즈의 두 번째 편입니다. 1편에서 Reactive Streams 명세와 Publisher·Subscriber·Subscription 4대 인터페이스를 잡았다면, 이번 편에서는 실전에서 가장 자주 만나는 타입 Mono 를 완전히 해부합니다.

Mono는 "한 잔만 따라주는 자판기" 예요. 동전(구독)을 넣으면 음료가 나오거나(onNext + onComplete), 아무것도 안 나오거나(onComplete만), 고장났다고 알림이 오거나(onError) — 딱 세 가지 시나리오뿐입니다. 단건 DB 조회, 외부 API 단일 응답, 인증 토큰 발급처럼 "결과가 0개 또는 1개인 비동기 작업"은 전부 Mono로 표현해요.

📚 학습 노트

이 시리즈는 Project Reactor 공식 문서, Reactive Streams 명세, 여러 비동기 프로그래밍 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

코드를 직접 돌려 보면 머리에 훨씬 잘 박혀요. Project Reactor 공식 문서와 함께 보시면 더 효과적입니다.

Mono가 처음엔 왜 헷갈릴까요

이유는 네 가지예요.

첫째, Mono.just()가 eager라는 사실을 모릅니다. Mono.just(getName()) 이라고 쓰면 getName()은 Mono를 생성하는 순간 즉시 실행돼요. 구독하지 않아도요. 처음엔 "아직 subscribe를 안 했는데?"라며 당황하게 됩니다.

둘째, mapflatMap이 헷갈립니다. 둘 다 변환 연산자인데, map 안에서 다른 Mono를 반환하면 Mono> 라는 이상한 타입이 나와요. 컴파일은 되지만 의도와 완전히 달라집니다.

셋째, block()이 언제 위험한지 모릅니다. 테스트 코드나 메인 메서드에서는 괜찮은데, WebFlux 핸들러 안에서 호출하면 데드락이 발생할 수 있어요. "그냥 결과 꺼내는 메서드 아닌가?"라고 생각하면 큰코 다칩니다.

넷째, 에러 처리 3종 세트(onErrorReturn / onErrorResume / onErrorMap)의 차이를 모릅니다. 언제 무엇을 써야 하는지 감이 안 잡히면 에러 처리 코드가 중복되거나 에러가 조용히 삼켜집니다.

해결법은 한 가지예요. Mono를 "한 잔만 따라주는 자판기" 로 생각하면 갑자기 명확해집니다. 자판기는 동전(구독)을 넣어야 작동하고, 팩토리 메서드는 "어떤 종류의 자판기를 만드느냐"를 결정하고, 연산자는 "음료가 나오는 파이프 사이에 달린 필터나 믹서"예요. 이 비유를 따라 처음부터 풀어 갑니다.

Mono의 세 가지 완료 시나리오

Mono를 파악하는 출발점은 완료 시나리오 세 가지를 외우는 거예요.

시나리오신호 순서예시
값 있는 정상 완료onNext(값) → onCompleteDB 단건 조회 성공
값 없는 정상 완료onComplete만조회 결과 0건
에러 발생onError(throwable)DB 연결 실패

자판기 비유로 — 음료가 나오면 첫 번째, 재고 없음 표시등이 켜지면 두 번째, 고장 경보가 울리면 세 번째예요. onNextonError·onComplete는 절대 같이 오지 않아요.

Mono 팩토리 메서드 — 어떤 자판기를 만들 것인가

핵심 선택 기준
이미 값이 있다 → Mono.just()  |  지연 계산 필요 → fromSupplier()  |  체크드 예외 가능 → fromCallable()  |  구독마다 다른 Mono → defer()

Mono.just() — eager 자판기

// 이미 가지고 있는 값으로 Mono 생성
Mono<String> mono = Mono.just("Hello, Reactive!");

mono.subscribe(
    value -> log.info("수신: {}", value),   // onNext
    error -> log.error("에러: {}", error),  // onError
    () -> log.info("완료!")                  // onComplete
);
// 출력: 수신: Hello, Reactive!
//       완료!

// 주의: Mono.just()는 인자를 즉시 평가한다
Mono<String> mono2 = Mono.just(getName()); // getName()이 Mono 생성 시 즉시 실행됨!

여기서 시험 함정이 하나 있어요. Mono.just()는 인자를 즉시 평가(eager) 합니다. Mono.just(expensiveDbQuery()) 처럼 쓰면 아직 구독도 하지 않았는데 DB 쿼리가 실행돼요. 상수 값이 이미 있을 때만 just()를 쓰고, 계산이 필요하다면 fromSupplier()fromCallable()을 쓰세요.

Mono.fromSupplier() — lazy 자판기

// 잘못된 방식 — Mono 생성 시 즉시 실행됨
Mono<Integer> eagerMono = Mono.just(expensiveCalculation()); // 구독 전에도 실행!

// 올바른 방식 — 구독 시점에만 실행됨
Mono<Integer> lazyMono = Mono.fromSupplier(() -> expensiveCalculation());
log.info("아직 구독 안 함"); // 여기서는 계산 안 함

lazyMono.subscribe(result -> log.info("결과: {}", result));
// 여기서 expensiveCalculation() 실행

Mono.fromCallable() — 체크드 예외도 처리하는 자판기

fromSupplier()와 거의 같지만 체크드 예외(checked exception)를 던질 수 있다는 차이가 있어요. IOException, SQLException 같은 예외가 발생할 수 있는 작업에 적합합니다. 예외는 자동으로 onError 신호로 변환돼요.

Mono<String> mono = Mono.fromCallable(() -> {
    return Files.readString(Path.of("config.txt")); // IOException 가능
});

mono.subscribe(
    value -> log.info("파일 내용: {}", value),
    error -> log.error("파일 읽기 실패: {}", error.getMessage())
);

Mono.defer() — 구독마다 새로운 자판기

AtomicInteger counter = new AtomicInteger(0);

// just()는 처음 한 번만 평가
Mono<Integer> justMono = Mono.just(counter.incrementAndGet());
justMono.subscribe(v -> log.info("just 1: {}", v));  // 1
justMono.subscribe(v -> log.info("just 2: {}", v));  // 1 (같은 값!)

// defer()는 구독마다 새로 평가
Mono<Integer> deferMono = Mono.defer(() -> Mono.just(counter.incrementAndGet()));
deferMono.subscribe(v -> log.info("defer 1: {}", v));  // 2
deferMono.subscribe(v -> log.info("defer 2: {}", v));  // 3 (다른 값!)

여기서 시험 함정이 하나 있어요. Mono.just()는 처음 한 번만 평가되고, defer()는 구독마다 새로 평가 됩니다. "현재 시각"처럼 구독할 때마다 최신 값이 필요한 경우에 defer()가 답이에요.

한 줄 정리 — 팩토리 메서드의 핵심: 상수 → just, 지연 계산 → fromSupplier/fromCallable, 구독마다 다른 Mono → defer

map vs flatMap — 파이프 사이의 필터 vs 새 파이프 연결

파이프라인 비유로 설명하면 — map은 파이프 중간의 필터(물의 성질만 바꿈), flatMap은 파이프를 끊고 새 파이프를 연결하는 것(완전히 새 비동기 파이프라인으로 교체)이에요.

// map: 동기적 1:1 변환 (T → R)
Mono.just("  Hello World  ")
    .map(String::trim)
    .map(String::toLowerCase)
    .map(s -> s.replace(" ", "_"))
    .subscribe(log::info);
// 출력: hello_world

// flatMap: 비동기 1:1 변환 (T → Mono<R>)
// map에서 Mono를 반환하면 Mono<Mono<T>>가 되는 문제를 해결
Mono<String> correct = Mono.just("userId")
    .flatMap(id -> userService.findUser(id));  // Mono<User> 반환 → 자동 평탄화

// 연쇄 비동기 호출
Mono<String> result = Mono.just(orderId)
    .flatMap(id -> orderService.findOrder(id))
    .flatMap(order -> inventoryService.checkStock(order))
    .flatMap(stock -> paymentService.process(stock));

여기서 시험 함정이 하나 있어요. map 안에서 Mono를 반환하면 Mono> 타입이 됩니다. 컴파일은 되지만 실제로 내부 Mono가 구독되지 않아요. 비동기 서비스를 호출할 때는 반드시 flatMap을 쓰세요.

// 잘못된 사용 - Mono<Mono<User>> 타입 오류
Mono<Mono<User>> wrong = Mono.just("userId")
    .map(id -> userService.findUser(id));  // 컴파일은 되지만 의도와 다름!

// 올바른 사용 - flatMap이 자동으로 내부 Mono를 평탄화
Mono<User> correct = Mono.just("userId")
    .flatMap(id -> userService.findUser(id));
한 줄 정리 — 동기 변환이면 map, 비동기 서비스 호출이면 flatMap. map에서 Mono 반환하면 flatMap으로 교체.

zipWith — 두 자판기에서 동시에 음료 받기

zipWith는 두 Mono를 병렬로 실행하고 결과를 합치는 연산자예요. 두 자판기에 동전을 동시에 넣고, 두 음료가 모두 나오면 트레이에 올려 제공하는 이미지입니다.

Mono<String> firstName = Mono.just("Alice");
Mono<String> lastName = Mono.just("Kim");

// BiFunction으로 결합
Mono<String> fullName = firstName.zipWith(lastName, (f, l) -> f + " " + l);
fullName.subscribe(name -> log.info("이름: {}", name));
// 출력: 이름: Alice Kim

// 실제 사용: 여러 서비스 병렬 조회 후 합치기
Mono<UserProfile> profile = userService.getProfile(userId);
Mono<List<Order>> orders = orderService.getRecentOrders(userId);

Mono<Dashboard> dashboard = profile.zipWith(orders,
    (p, o) -> new Dashboard(p, o));

주의: 두 Mono 중 하나라도 empty이면 전체 결과가 empty, 하나라도 onError이면 전체 onError입니다.

에러 처리 3종 세트 — 자판기 고장 시 대응

에러 처리 연산자는 세 가지가 있고, 각각 역할이 다릅니다.

연산자에러 후 상태언제 쓰나
onErrorReturn(기본값)정상 완료로 전환단순 기본값 반환
onErrorResume(fn)대체 Mono로 전환다른 소스에서 재조회
onErrorMap(fn)에러 타입 변환에러 추상화
// 1. onErrorReturn: 에러 시 기본값 반환
Mono.error(new RuntimeException("서버 오류"))
    .onErrorReturn("기본값")
    .subscribe(log::info);
// 출력: 기본값 (에러 없이 정상 완료)

// 2. onErrorResume: 에러 시 대체 Mono로 교체
Mono.error(new RuntimeException("DB 오류"))
    .onErrorResume(e -> {
        log.warn("DB 오류, 캐시에서 조회: {}", e.getMessage());
        return cacheService.get("user:1");
    })
    .subscribe(log::info);

// 3. onErrorMap: 에러 타입 변환 (에러는 계속 흘러감)
Mono.error(new SQLException("DB 연결 실패"))
    .onErrorMap(SQLException.class,
        e -> new ServiceException("데이터 서비스 오류: " + e.getMessage(), e))
    .subscribe(
        log::info,
        e -> log.error("서비스 에러: {}", e.getMessage())
    );

에러 처리 순서도 중요해요. 더 구체적인 예외 타입을 위쪽에, 더 넓은 타입을 아래쪽에 배치하세요.

한 줄 정리 — 값 대체: onErrorReturn, 소스 대체: onErrorResume, 타입 변환: onErrorMap

block() — 자판기 앞에서 꼼짝 않고 기다리기

block()은 Mono를 동기적으로 블로킹하며 결과를 기다리는 메서드예요. 테스트나 메인 메서드 최외곽에서는 괜찮지만, 잘못 쓰면 치명적입니다.

// 기본 사용 (테스트, 메인 메서드 최외곽에서만)
String result = Mono.just("Hello")
    .map(String::toUpperCase)
    .block();  // 블로킹으로 결과 반환: "HELLO"

// 타임아웃 설정
String resultWithTimeout = someSlowMono
    .block(Duration.ofSeconds(5));  // 5초 초과 시 예외

// empty Mono → null 반환
String nullResult = Mono.<String>empty().block();  // null 반환

여기서 시험 함정이 하나 있어요. WebFlux 핸들러나 리액티브 파이프라인 내에서 block() 호출은 데드락을 유발할 수 있습니다. 리액티브 스레드 풀의 스레드가 block() 때문에 점유되면 다른 신호를 처리할 스레드가 없어져 전체가 멈춰요. 파이프라인 내에서는 절대 block()을 쓰지 말고, flatMap으로 체이닝하세요.

// 절대 금지: WebFlux 핸들러에서 block()
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable String id) {
    User user = userService.findUser(id).block(); // 데드락 위험!
    return Mono.just(user);
}

// 올바름: 파이프라인 유지
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable String id) {
    return userService.findUser(id);
}

subscribeOn — 어느 스레드에서 자판기를 돌릴 것인가

기본적으로 Mono의 모든 처리는 현재 스레드에서 실행돼요. subscribeOn(Schedulers.boundedElastic())을 붙이면 구독 자체가 다른 스레드 풀에서 시작됩니다. 블로킹 I/O(파일 읽기, JDBC 등)를 리액티브 파이프라인에 통합할 때 쓰는 패턴이에요.

Mono.fromCallable(() -> blockingDbQuery())
    .subscribeOn(Schedulers.boundedElastic()) // 블로킹 작업 전용 스레드 풀
    .subscribe(result -> log.info("결과: {}", result));

Mono 완전 정복 — 시험 직전 압축 노트

  • Mono = 0~1개 아이템을 비동기로 처리 — Optional의 비동기 버전
  • 완료 시나리오 3가지 — onNext+onComplete / onComplete만 / onError
  • Mono.just(expr)EAGER: expr이 Mono 생성 시 즉시 평가
  • Mono.fromSupplier(() -> expr)LAZY: expr이 subscribe 시에만 평가 ← 항상 이것을 우선 고려
  • Mono.fromCallable(() -> expr)LAZY + 체크드 예외 처리 가능
  • Mono.defer(() -> monoSupplier) → 구독마다 새로운 Mono 생성
  • Mono.empty() → null 대신 사용 (Mono.just(null)은 NPE!)
  • map — 동기적 1:1 변환 (T → R)
  • flatMap — 비동기 1:1 변환 (T → Mono), 내부 Mono 자동 평탄화
  • map에서 Mono 반환 = Mono> 타입 오류 → flatMap으로 교체
  • zipWith — 두 Mono 병렬 실행 후 결합 (하나라도 empty면 전체 empty)
  • onErrorReturn — 에러 시 기본값 반환, 정상 완료로 전환
  • onErrorResume — 에러 시 대체 Mono로 교체
  • onErrorMap — 에러 타입만 변환, 에러는 계속 흘러감
  • block() 금지 구역: WebFlux 핸들러, 리액티브 파이프라인 내부
  • subscribeOn(Schedulers.boundedElastic()) — 블로킹 I/O 통합 패턴
  • doOnNext/doOnError/doFinally — 스트림 변경 없이 로깅·메트릭 수집만
  • switchIfEmpty — Mono가 empty일 때 대체 Mono 반환
  • defaultIfEmpty — Mono가 empty일 때 기본값 반환

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

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

답글 남기기

error: Content is protected !!