구독 전엔 아무 일도 일어나지 않는다 - Reactor & WebFlux 멘탈모델 입문
    2026-04-01
    ReactorWebFlux
    🇺🇸 영어로 읽기

    명령형 코드에 익숙한 상태에서 WebFlux 코드를 처음 마주하면, 문법은 읽히는데 동작이 예측되지 않을 때가 있습니다. 분명히 저장 코드를 호출했는데 DB에는 아무것도 안 들어가 있거나, 아직 요청도 오지 않았는데 로그의 시각이 이상하게 찍혀 있는 식입니다.

    이런 혼란은 대개 문법을 몰라서가 아니라, 리액티브 코드를 명령형의 사고방식으로 읽기 때문에 생깁니다. 이번 글에서는 Mono, Flux 같은 Publisher가 "실행"이 아니라 "실행 계획"이라는 하나의 멘탈모델에서 출발해, 조립 시점과 구독 시점의 차이부터 실무에서 흔히 하게되는 실수까지 순서대로 살펴보겠습니다.

    왜 리액티브는 다르게 생각해야 할까

    전통적인(blocking) 서버는 요청 하나가 DB 응답이나 외부 API를 기다리는 동안 스레드 하나를 통째로 붙잡고 멈춰 있습니다. 동시 요청이 많아지면 스레드가 금방 바닥나게 됩니다. WebFlux는 기다리는 동안 스레드를 놓아주고, 결과가 준비되면 그때 이어서 처리하는 논블로킹 방식으로 적은 스레드로 많은 요청을 감당합니다.

    식당에 비유하자면 명령형 서버는 주문을 받은 종업원이 요리가 나올 때까지 주방 앞에 서서 기다리는 것과 같습니다. 종업원(스레드) 수만큼만 손님을 받을 수 있죠. 반면 리액티브 서버는 주문만 받아 적고 다른 손님을 응대하다가, 요리가 나오면(신호가 오면) 그때 가져다줍니다. 종업원 한 명이 훨씬 많은 테이블을 처리할 수 있습니다.

    Post image

    그래서 리액티브 코드는 값을 지금 계산해서 반환하지 않고, "나중에 값이 준비되면 어떻게 처리할지를 적은 계획서"를 반환합니다. 명령형에서는 한 줄이 곧 한 번의 실행이었지만, 리액티브에서는 한 줄이 계획서에 적히는 한 단계일 뿐입니다.

    계획서를 담는 두 그릇: Mono와 Flux

    Reactor에서 그 "계획서"를 담는 Publisher 타입은 두 가지입니다. 둘의 차이는 몇 개를 내보내느냐에 있습니다.

    타입방출 개수언제 쓰나명령형으로 치면
    Mono<T>0개 또는 1개회원 1명 조회, 저장 후 결과 1건 같은 단건Optional + 비동기
    Flux<T>0개 ~ N개여러 건 목록, Kafka에서 계속 들어오는 메시지 같은 다건List/Stream + 비동기

    조립 시점과 구독 시점

    리액티브가 "계획서"라는 멘탈모델을 잡고 나서, 이번엔 "Mono는 요리가 아니라 레시피다"라는 비유로 조립 시점과 구독 시점을 설명해보도록 하겠습니다.

    명령형 코드에서 save(doc)를 호출하면 그 줄에서 바로 저장이 일어납니다. 하지만 리액티브에서 save(doc)가 반환하는 Mono는 아직 아무것도 저장하지 않은 상태입니다. 그저 "저장하는 방법이 적힌 레시피"를 손에 쥔 것뿐입니다. 레시피는 누군가 요리를 시작하기 전까지, 즉 구독하기 전까지는 그냥 종이 한 장입니다.

    // 이 줄은 DB를 건드리지 않는다. "조회 레시피"를 만들 뿐이다. val recipe = repository.findById(id) // .subscribe() 하는 순간에야 비로소 실제 조회 쿼리가 나간다 recipe.subscribe { println(it) }

    레시피 모델을 이해하면 자연스럽게 따라오는 결론이 있습니다. 리액티브 코드는 서로 다른 두 순간에 걸쳐 동작한다는 것입니다.

    하나는 조립 시점(assembly) 입니다. .map(), .flatMap(), .switchIfEmpty()를 이어붙여 파이프라인의 구조를 만드는 순간을 말합니다. 이때 데이터는 흐르지 않습니다. 레시피에 조리 단계를 적고 있을 뿐입니다.

    다른 하나는 구독 시점(subscription) 입니다. .subscribe() 하는 순간, 구독 신호가 파이프라인을 아래에서 위로 타고 올라가 소스에 닿고, 그때부터 데이터가 위에서 아래로 흐르며 각 단계의 코드가 실제로 실행됩니다.

    "구독하지 않으면 아무것도 실행되지 않는다"의 실제 의미

    이런 동작은 리액티브를 설명할 때 흔히 "Nothing happens until you subscribe"라는 말로 요약됩니다. 구독하지 않은 Publisher는 실행되지 않는다는 뜻인데, 이 원칙은 실무에서 아래 세 가지 구체적인 모습으로 나타납니다.

    삼킨 Mono는 조용히 사라진다

    repository.save(doc)는 저장을 실행하는 게 아니라, "저장하는 방법이 적힌 레시피", 즉 Mono를 하나 만들어 돌려주는 함수입니다. 이 반환값을 변수로 받지도 return하지도 않으면, 그 레시피는 어디에도 연결되지 않은 채 버려집니다. 누구도 요리를 시작(구독)시켜 주지 않으니 저장 쿼리는 나가지 않죠. 잘못된 게 아니라 애초에 "하지 않은 것"이라, 예외도 로그도 남지 않습니다.

    // 반환도 구독도 안 함 — 이 저장 로직은 영원히 실행되지 않는다. repository.save(doc) // Mono를 만들어 놓고 그냥 버림 doSomethingElse() // 체인에 이어서 return -> 프레임워크가 대신 구독해 준다. return repository.save(doc) .flatMap { doSomethingElse(it) }

    구독은 대개 프레임워크가 대신 해준다

    그렇다면 우리는 왜 평소에 .subscribe()를 직접 쓰지 않을까요. Spring WebFlux가 컨트롤러의 반환값을 대신 구독해 주기 때문입니다. 컨트롤러가 Monoreturn하면, 프레임워크가 HTTP 응답을 만드는 시점에 그 Mono를 구독합니다.

    @PostMapping("/messages") fun send(@RequestBody request: MessageRequest): Mono<Void> { return messageService.save(request) // 이 Mono를 프레임워크가 구독 → 체인 전체가 실행된다 }

    여기서 중요한 건, 이때 실제로 실행되는 코드는 반환된 체인에 매달려 있는 것뿐이라는 점입니다. 그래서 저장 로직도 최종 return 체인에 이어져 있어야 프레임워크의 구독이 거기까지 닿아 실행됩니다.

    반대로 앞에서 본 것처럼 save(doc)를 호출만 하고 반환 체인에서 빠뜨리면, 프레임워크는 그 Mono의 존재조차 모르니 영영 구독하지 않습니다. Kafka 리스너나 스케줄러도 원리는 같습니다. WebFlux 환경에서는 직접 구독하지 말고, 끝까지 이어서 반환하면 됩니다.

    바꿔 말하면 명령형에서 "실행"은 함수를 호출하는 것이었지만, 리액티브에서 "실행"은 만든 Mono를 구독까지 연결하는 것입니다. 중간에 변수에 담아 두기만 하고 반환 체인에서 빠뜨리면, 그 로직은 실행 스위치가 눌리지 않은 채 버려집니다. return이 곧 실행 스위치인 셈입니다.

    "대신 구독"이 실제로 일어나는 위치는 요청 처리 파이프라인의 맨 끝을 보면 됩니다. 컨트롤러가 반환한 MonoResponseBodyResultHandler가 응답 바디로 write하는 흐름에 합쳐지고, 이렇게 조립된 요청 처리 체인 전체는 서버 어댑터인 ReactorHttpHandlerAdapter가 돌려준 Mono<Void>를 Reactor Netty가 요청마다 구독하는 순간 비로소 실행됩니다.

    조립 시점의 코드는 구독과 무관하게 이미 돌아간다

    지금까지가 "구독하지 않으면 실행되지 않는다"는 방향의 함정이었다면, 이번엔 그 반대입니다. "구독 전엔 아무 일도 안 일어난다"는 말은 어디까지나 연산자 체인을 타고 흐르는 데이터에 대한 이야기입니다. 체인을 조립하는 코드 자체는 그냥 평범한 Kotlin 코드라, 작성한 순서대로 즉시 실행됩니다.

    즉, Mono를 만드는 표현식 안에 부수 효과를 넣으면 그건 구독을 기다려 주지 않습니다.

    흔히 하는 실수: switchIfEmpty 즉시평가 함정

    알림 시스템에서 같은 요청이 두 번 들어와도 메시지가 중복 발송되지 않도록, 먼저 메시지 키로 이미 저장된 발송 기록이 있는지 조회하는 로직이 있습니다. 기록이 있으면 이미 처리된 중복 요청으로 판단하고, 없으면 신규 요청으로 발송 기록을 저장하고 메세지를 보냅니다.

    return findMessageByMessageKey(key) .flatMap { Mono.error(중복예외) } // 발송 기록이 있으면 = 중복 -> 에러 .switchIfEmpty(saveAndSendIfDue(outboundMessage.toEntity())) // 비어 있으면 = 신규 -> 저장, 발송

    문제의 핵심은 switchIfEmpty가 람다가 아니라 이미 만들어진 Mono 값을 인자로 받는다는 데 있습니다.

    Post image

    Kotlin은 함수를 호출하기 전에 인자를 먼저 평가하므로, 그 Mono를 만드는 표현식인 saveAndSendIfDue(outboundMessage.toEntity())가 구독 여부와 무관하게 조립 시점에 즉시 실행됩니다.

    // switchIfEmpty의 인자는 즉시 평가된다. // outboundMessage.toEntity() 가 구독 전에, 즉시 실행된다. .switchIfEmpty(saveAndSendIfDue(outboundMessage.toEntity()))

    saveAndSendIfDue(outboundMessage.toEntity()) 한 줄을 조립 시점 관점에서 뜯어보면, 두 가지 일이 순서대로 벌어집니다.

    먼저 인자인 outboundMessage.toEntity()가 평가됩니다. toEntity()는 리액티브와 무관한 평범한 함수라, 호출되는 즉시 본문이 전부 실행됩니다. 만약 이 안에 LocalDateTime.now()가 있다면 요청이 실제로 처리되는 시각이 아니라 파이프라인을 조립하는 시각으로 값이 찍혀 버립니다.

    그다음 그 결과를 받아 saveAndSendIfDue(...)가 호출됩니다. 하지만 이건 Mono를 반환하는 함수입니다. 호출하더라도 "저장, 발송 레시피"인 Mono를 조립해 돌려줄 뿐, 실제 저장과 발송은 구독 전까지 일어나지 않습니다.

    정리하면 같은 한 줄 안에서도, Mono를 반환하는 saveAndSendIfDue는 "호출"과 "실행"이 분리되어 안전하지만, 평범한 함수인 toEntity()는 호출이 곧 실행이라 그 자리에서 돌아버립니다. 때문에 아래와 같이 Mono.defer로 이 표현식 전체를 감싸 toEntity()까지 구독 시점으로 미뤄야 합니다.

    // defer는 람다를 받아 "구독될 때" 실행한다. // toEntity() 는 이 분기가 실제로 구독될 때만 실행된다. .switchIfEmpty(Mono.defer { saveAndSendIfDue(outboundMessage.toEntity()) })

    조립과 구독, 두 시점에 무슨 일이 벌어지나

    이 함정이 왜 위험한지는, defer 유무와 요청 종류(신규, 중복)를 바꿔가며 실행 순서를 따라가 보면 분명해집니다. 먼저 조립 시점입니다. 여기까지는 아직 구독 전이라 데이터가 한 방울도 흐르지 않은 상태입니다.

    findMessageByMessageKey() → Mono 생성 (DB 조회는 아직 안 함) .flatMap { … } → 람다라서 본문은 보관만. 실행 안 됨. .switchIfEmpty( … ) → 인자가 바로 이 시점에 평가된다 ⬅ 갈림길 .subscribe() → 구독 연결

    switchIfEmpty 한 줄에서 defer 유무가 결과를 가릅니다. defer가 있으면 인자는 defer 껍데기일 뿐이라 toEntity()는 실행되지 않고 대기합니다. defer가 없으면 인자가 즉시 평가되어 toEntity()가 지금 실행되고, now()가 조립 시각으로 찍힙니다. 심지어 이 시점엔 신규인지 중복인지도 아직 모릅니다.

    이제 구독 시점입니다. 구독 신호가 아래에서 위로 올라가 소스에 닿으면, 데이터가 위에서 아래로 흐르기 시작합니다. 이때부터는 요청 종류에 따라 결과가 갈립니다.

    상황defer 있음 (안전)defer 없음 (위험)
    신규 요청 (문서 없음)분기가 구독될 때 toEntity() 실행 → 정확한 now()로 생성toEntity()가 조립 때 이미 실행됨 → 앞당겨진 now() 사용
    중복 요청 (문서 있음)분기가 구독되지 않음 → toEntity() 아예 실행 안 됨분기는 안 타는데 toEntity()는 조립 때 헛되이 실행됨

    정리하면, defer가 없을 때 toEntity()는 신규 요청에서는 시각이 틀리게 찍히고, 중복 요청에서는 쓰이지도 않을 객체를 헛되이 만듭니다. 반대로 defer로 감싸면 신규일 때만 실행 시점의 정확한 now()로 생성되고, 중복일 때는 아예 실행되지 않습니다.

    왜 defer가 해결책인가

    Mono.defer { ... }는 "이 코드 블록을 지금 말고, 구독될 때 실행하라"는 뜻입니다. 람다({ })로 감싸는 순간 그 안의 표현식은 조립 시점에 평가되지 않고 보관만 됩니다. 그래서 중복 요청이라 이 분기를 타지 않으면 toEntity()는 실행되지 않고, 신규 요청일 때만 실행 시점의 now()로 정확하게 생성됩니다.

    매번 헷갈릴 필요 없이, 연산자가 무엇을 인자로 받는지만 보면 판별할 수 있습니다. 람다를 받으면 지연, 이미 만든 값을 받으면 즉시입니다.

    연산자인자 형태언제 실행되나
    .map { }람다 (T)->R지연 · 데이터가 흐를 때
    .flatMap { }람다 (T)->Mono지연 · 데이터가 흐를 때
    .filter { }람다 (T)->Bool지연 · 데이터가 흐를 때
    .switchIfEmpty(x)Mono즉시 · 조립 시점 ⚠
    Mono.just(x)T즉시 · 조립 시점 ⚠
    Mono.defer { }람다 ()->Mono지연 · 구독 시점
    Mono.fromCallable { }람다 ()->T지연 · 구독 시점

    즉, 부작용이 있거나 시간에 의존하는 코드(now(), DB 호출, 객체 생성)를 switchIfEmptyMono.just처럼 값을 받는 연산자에 직접 넣지 않도록 주의해야합니다. 그런 코드는 Mono.deferfromCallable로 감싸 구독 시점으로 미루면 의도치 않은 호출을 방지할 수 있습니다.

    마무리

    지금까지 Mono, Flux 같은 Publisher가 실행이 아니라 "실행 계획"이라는 멘탈모델에서 출발해, 조립 시점과 구독 시점의 차이, 구독하지 않아 조용히 사라지는 Mono, 그리고 switchIfEmpty 즉시평가로 인한 부작용까지 살펴봤습니다.

    결국 리액티브에서 중요한 건, 지금 실행하는 게 아니라 나중에 구독될 계획서를 조립하고 있다는 멘탈모델을 갖추는 것이라고 느꼈습니다. 이 글이 리액티브 프로그래밍의 관점을 잡는 데 조금이나마 도움이 되었으면 합니다.