어제 오늘 내일

[Reactor] "map과 flatMap, 도대체 뭐가 다른가요?" 마법의 연산자 정복하기 본문

IT/SpringBoot

[Reactor] "map과 flatMap, 도대체 뭐가 다른가요?" 마법의 연산자 정복하기

hi.anna 2026. 4. 1. 00:15

 

데이터를 변환할 때 우리는 습관적으로 .map()을 씁니다.
하지만 WebFlux 세계에서 DB를 조회하거나 외부 API를 호출할 때 .map()을 쓰면, 결과값으로 Mono<Mono<String>> 같은 러시아 인형(중첩 구조)이 튀어나옵니다.

이 껍질을 벗겨내고 알맹이만 쏙 빼내는 기술, flatMap이 필요한 순간입니다.


1. 단순 변환의 제왕: map

map동기적(Synchronous)이고, 1:1로 변환할 때 사용합니다.
입력 데이터 T를 받아서 U로 바꿉니다.

  • 비유: 사과를 넣으면 -> 껍질 깎은 사과가 나옴.
  • 특징: 단순히 값을 가공하거나, 객체를 다른 객체로 매핑할 때 씁니다.
// 1. 문자열을 대문자로 변환
Flux.just("a", "b", "c")
    .map(String::toUpperCase) // "a" -> "A"
    .subscribe(System.out::println);

// 2. User 엔티티를 UserDto로 변환
userRepository.findById(1L) // Mono<User>
    .map(user -> new UserDto(user.getName())); // Mono<UserDto>

여기까지는 Java Stream과 똑같습니다. 문제는 다음입니다.


2. 비동기 연쇄 호출의 핵심: flatMap

flatMap비동기적(Asynchronous)이고, 1:N(또는 1:Publisher)으로 변환할 때 사용합니다.
입력 데이터 T를 받아서 Publisher<U>(Mono나 Flux)로 바꿉니다. 그리고 그 Publisher를 구독해서 결과(U)를 꺼내줍니다(Flatten).

  • 비유: 사과를 넣으면 -> 사과나무를 심고 기다렸다가 -> 열린 사과들을 가져옴.
  • 특징: DB 조회 후에 또 DB를 조회하거나, API 호출 결과를 받아서 처리할 때 필수입니다.

❌ 잘못된 예시 (map 사용)

// 유저 ID로 유저를 찾고, 그 유저의 주문 내역을 가져오고 싶다.
userRepository.findById(userId) // Mono<User> 리턴
    .map(user -> orderRepository.findAllByUser(user)) // Flux<Order> 리턴
    // 결과 타입: Mono<Flux<Order>> (???)
    // 껍질(Mono) 안에 껍질(Flux)이 또 들어감!

✅ 올바른 예시 (flatMap 사용)

userRepository.findById(userId) // Mono<User>
    .flatMap(user -> orderRepository.findAllByUser(user)) // Flux<Order>를 리턴하지만...
    // 결과 타입: Flux<Order> (!!!)
    // flatMap이 내부의 Flux를 구독해서 알맹이(Order)만 쫙 펴줌.

핵심 요약:

  • 변환 결과가 일반 객체(String, Integer, Dto)다? -> map
  • 변환 결과가 MonoFlux다? (DB 호출 등) -> flatMap

3. 순서가 중요한가요? (flatMap vs concatMap)

flatMap은 비동기적으로 동작하기 때문에 순서를 보장하지 않습니다.
1, 2, 3번 요청을 보냈는데 응답은 2, 1, 3 순서로 올 수 있습니다. (가장 빠른 놈이 먼저 나옴)

만약 순서가 중요하다면 (예: 게시글 저장 후 -> 댓글 저장), concatMap을 써야 합니다.

  • flatMap: 병렬 처리 (빠름, 순서 X)
  • concatMap: 직렬 처리 (느림, 순서 O - 앞에게 끝나야 다음 거 실행)

4. (보너스) 스케줄러: 쓰레드 갈아타기 (subscribeOn vs publishOn)

WebFlux는 기본적으로 메인 쓰레드(혹은 Netty 쓰레드) 하나로 돕니다.
그런데 만약 "이미지 리사이징"처럼 CPU를 많이 쓰는 작업을 하면 서버 전체가 멈춥니다.
이때 "이 작업은 다른 별도 쓰레드에서 해!"라고 지정하는 게 스케줄러입니다.

  1. subscribeOn (구독 시점 결정): 파이프라인의 시작점(Source)이 실행될 쓰레드를 정합니다.
  • "데이터 가져오는 것부터 저쪽 쓰레드에서 해."
  1. publishOn (실행 시점 결정): 파이프라인 중간에 쓰레드를 바꿉니다.
  • "여기까진 내가 했는데, 다음 map 연산부터는 저쪽 쓰레드에서 해."
Flux.range(1, 10)
    .publishOn(Schedulers.boundedElastic()) // ★ 여기서부터는 별도 쓰레드풀 사용!
    .map(i -> {
        System.out.println("무거운 작업 수행 중..."); // 메인 쓰레드 방해 안 함
        return i * 10;
    })
    .subscribe();

실무 팁: 블로킹 작업(JDBC 등)을 어쩔 수 없이 써야 한다면 반드시 publishOn(Schedulers.boundedElastic())으로 격리해야 합니다. 안 그러면 서버 멈춥니다!


마치며

오늘의 결론입니다.

  1. map: 동기 변환 (A -> B). 값만 바꿀 때 쓴다.
  2. flatMap: 비동기 변환 (A -> Mono<B>). DB 호출, API 연동 등 결과를 기다려야 할 때 쓴다. (내부 Publisher를 벗겨줌)
  3. concatMap: 순서가 중요할 때 쓴다.

이제 데이터를 담고(Mono/Flux), 가공하는 법(Operator)까지 배웠습니다. 준비 운동은 끝났습니다.
이제 진짜 서버를 띄워볼 차례입니다.

다음 포스팅에서는 MVC 개발자라면 10분 만에 적응 가능한, "어노테이션 기반 WebFlux 컨트롤러 만들기"로 실전 API를 작성해 보겠습니다.

도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments