🤿 숨 꾹참고 딥다이브 1 : 동기와 비동기

🔎 1. Intro

새로운 시리즈로 돌아왔습니다. 개발자 손우진 입니다. 눈 떠보니 6월이 끝이 보입니다. 2025년이 언제 어떻게 지나가는 지 조차도 잘 모르겠네요.

아마 이 글이 올라갈 때 쯤에는 7월일겁니다. 6월 까지만 해도 그렇게 덥진 않았는데 7월이 되니 확실히 덥네요

이번에는 '숨 꾹 참고 딥다이브' 라는 시리즈를 연재해볼까 합니다. 정말 가볍게 쓴다면 가볍게도 쓸 수 있는 주제지만, 어디까지 쓸 수 있는지 한번 깊게 파 보는 게 좋겠단 생각이 들었죠.

최근 들어서 동기, 비동기에 대한 여러가지 고민들을 하다가 제대로 한번 딥다이브 해보자는 생각이 들었습니다.

커피한잔 하면서 읽어주세요. 시작하겠습니다.

☕️ 2. 동기 VS 비동기

동기, 비동기 처리에 대해서는 유명한 예제가 등장합니다. 커피숍 예제죠.

+------------------------------+
|         ☕ 동기 커피숍        |
+------------------------------+
| 줄서서 기다리는 손님들       |
|                              |
|  {\___/}                     |
|  ( • ㅁ•)  "커피 주세요..."  |
|    ↓                         |
|  [👨‍🍳] ☕ 만드는 중...        |
|    ↓                         |
|  {\___/}                     |
|  ( • ㅁ•)  "고마워요!"       |
+------------------------------+

오랜만에 카공을 하려고 커피숍에 들렸습니다. 간판 부터 뭔가 괴랄한 '동기 커피숍' 이죠.

사람들이 줄을 길게 서있습니다. 주문한 커피를 받아서 각자 자리로 이동하고 있네요. 우리도 받으러 가볼까요?

손님! 100,000,000원 입니다.

줄이 꽤 깁니다. 커피 주문부터 계산까지, 그리고 수령까지 모든 과정이 함께 진행되고 있네요... 그래서 가게는 나름대로 여러 줄을 만들어서 대기할 수 있도록 조치 해 두었습니다. 마치 우리가 은행 창구에서 업무를 볼 때 처럼 말입니다.

+-----------------------------------+
|          ☕ 비동기 커피숍          |
+-----------------------------------+
|  {\___/}                          |
|  ( • ㅁ•) "주문할게요!"           |
|     → [🎫] "42번 손님입니다"     |
|                                   |
|  {\___/}                          |
|  ( • ㅁ•) (놀러감)                |
|                                   |
|  [👨‍🍳] ☕ 커피 만드는 중...        |
|                                   |
|  [🔔] "42번 커피 나왔습니다!"    |
|     ↓                             |
|  {\___/}                          |
|  ( • ㅁ•) "왔다!" ☕               |
+-----------------------------------+

동기 커피숍에서 너무 오래 줄을 서다보니 다리가 아파졌습니다. 커피를 주문해놓고 뭔가 딴 일을 하고 싶어졌어요.

그래서 우리는 '비동기 커피숍' 이라는 새로 생긴 카페에 들렀습니다. 사람들은 진동벨을 들고 각자 자리에서 할 것을 하다가 벨이 울리면 커피를 가지러 갑니다.
사람들은 각자 자리에서 다양하게 수다를 떨거나 책을 읽고 있습니다. 저희도 마찬가지로 노트북을 열심히 두들기고 있죠.

손님 100,000,000 원입니다. 나중에 진동 벨 울리면 찾으러 오세요!

이 예제에서는 동기, 비동기처리에 대한 대략적인 예시가 서술 되어 있습니다. 동기 처리는 하나의 로직에 대한 흐름이 끊임없이 이어지고, 비동기처리는 반드시 그렇지만은 않죠. 우리는 작업을 잠시 멈춰 두고 다른 일을 할 수 있습니다.

또 한가지 주목할 점은, 동기 커피숍에서는 여러 줄을 통해 대기 줄을 만들었다는 점입니다. 비동기 커피숍에서는 굳이 그렇게까지 줄을 서진 않았죠? 동기 커피숍에서 손님들의 회전률을 높히려면, 둘 중 하나입니다. 커피를 아주 빠르게 만들거나 커피 주문 줄을 늘려야죠. 전자는 음 자판기라면 가능 할 지도 모르겠습니다만, 사람을 쓰는 입장에서는 결국 그 사람의 퍼포먼스에 맡겨야 하죠.

만약 커피의 목적이 직장인들의 잠을 깨우는 목적이라면 사실 크게 상관은 없을겁니다. 커피가 나오는 데 오래 걸리지도 않을 것이구요. 사람들이 줄을 좀 서는 것에 크게 거부감이 느껴지지 않을 수 있습니다. 실제로 우리는 자판기 커피를, 혹은 테이크아웃 전문점이나 무인 카페를 갈 때 비슷한 플로우로 커피를 먹고 있습니다. 그렇게 까지 불편하진 않죠.

고객                                     자판기
{\___/}   ── 버튼 누름 ──▶         [커피 제조 중... (5초)]
( • ㅁ•)                               │
                                       ▼
                              ┌───────────────────┐
                              │   커피 완성 및 배출   │
                              └───────────────────┘
                                       │
                         ◀─── 커피 배출 완료 후 받음 ───▶
                                       │
{\___/}                                │
( • ㅁ•)   ── 커피 수령 ──▶

자판기 커피 하니까 생각난건데, 이 글을 읽고 계시는 독자분들 중 자판기 세대가 아닌 분이 계실 지 모르겠네요.

97년생인 저는 자판기의 추억을 기억 할 수 있는 세대에 속한다고 생각합니다. 가끔 자판기에 우유가 있으면 꼭 뽑아 먹었었죠.

하지만 뷰 맛집, 인스타 맛집을 노리고 있다면 얘기는 다릅니다. 데스크에서 커피 주문, 계산, 수령 까지의 모든 과정을 하나의 과정에서 처리한다면 그것 또한 감성이라고 외쳐봐야 큰 의미는 없을 것입니다. 불편하니까요. 아날로그 감성이라고 하기엔 그다지 설득력이 있지는 않습니다. 카공하려고 무거운 노트북을 가져 온 사람들이 긴 줄을 서고 싶어 할 거라고는 생각이 들지 않습니다.

이처럼 동기와 비동기는 로직의 동작 방향성에 따라 나뉠 수 있습니다. '커피를 만들어서 손님에게 판매한다.' 라는 목적은 공유하지만, 각 로직 별로 관심사가 조금 씩 다르죠.

🤔 3. 언제 사용하나요?

동기와 비동기는 필요한 상황에 따라 선택할 수 있는 옵션입니다.

1인 커피숍이라고 해도 테이크아웃 전문점이 아니라 감성 카페를 운영하고 싶다면 진동벨을 배치해야 합니다. 하지만, 작은 매장 안에 앉을 자리도 없지만 커피는 많이 팔아야하는 입장이라면 진동벨 까진 필요 없죠. 혼자서 다 하다보니까 모든 주문과 수령 프로세스를 마친 후 다음 손님을 받는 게 실수도 덜 하고 손님도 편합니다. 이런 매장은 보통 메뉴가 많지 않습니다. 빨리 만들어야 하니까요.

🐟 커피숍 예제 보단 붕어빵집 예제가 좀 더 낫지 않나 라는 생각이 잠시 드네요. 붕어빵 집이 동기 처리를 비유할 수 있는 전형적인 현실 예시지 않나 싶어요 ㅋㅋ

??? : 사장님 주문이요!
사장님 : 아니 지금 줄 안보여요? 아직 앞 손님 못받았잖아요!

물론 가게 바이 가게지만...사장님이 연륜이 있으시면 이런 패턴도 있을 수 있죠.

사장님 : 손님 몇개 주문하셨죠?

현실 세계에서 컴퓨터 세계로 한번 가보겠습니다. 로직의 성격에 따라 우리는 두 가지 옵션을 선택할 수 있죠. 한번 자세히 정리 해 볼까요?

구분동기 처리에 적합한 예시비동기 처리에 적합한 예시
처리 순서사용자 인증 처리 (토큰 발급 후만 다음 단계 진행 가능)이미지 업로드 후 썸네일 생성 (썸네일은 늦게 생성돼도 무방함)
트랜잭션결제 요청 및 승인 (모든 단계가 성공해야 최종 승인 처리 가능)결제 성공 후 알림 전송 (알림은 실패해도 결제에는 영향 없음)
데이터 정합성주문 생성 후 재고 차감 (정확한 순서로 처리 필요)사용자 행동 로그 저장 (일단 수집만 하고 나중에 배치로 처리 가능)
응답 의존성API 체인 호출 (예: A → B → C 순으로 호출하며 앞단 결과가 다음단 입력에 사용됨)이메일 발송 (응답 없이도 별도 큐에 저장 후 처리 가능)
UI 상호작용로그인 버튼 클릭 → 로그인 완료 후 이동 (반드시 완료돼야 이동 가능)검색어 자동완성 요청 (결과 늦게 와도 입력은 계속 받을 수 있음)
시스템 부하사용자 계좌 이체 처리 (하나의 처리가 끝나야 다음 사용자가 이체 가능)실시간 알림 메시지 수신 (WebSocket, SSE 등으로 비동기 전달)

우리가 프로덕트를 성공 시키기 위해 구현해야 하는 기능들은 정말 다양합니다. 로그인 부터 조회, 결제 등의 다양한 기능들을 개발하다보면, 동기와 비동기 사이에 고민이 들어가야 하는 상황이 생기죠.

하지만 좀만 찾아보면 사실 유명한 기능들은 얼추 패턴화 되어있습니다. 로직의 성격에 따라 동기와 비동기가 유리한 점이 다르죠. 보통은 모든 단계가 정확히 실행되어야지만 처리가 마무리되는 경우 동기 처리를 사용합니다. 가장 대표적인 예시가 로그인이죠. 로그인 기능을 비동기로 구현한다면 어떨까요? 로그인 요청을 보내두고 로그인이 완료 되기 전 까지 웹 페이지에서 놀 수는 있을 것 같습니다.

🖥️ 4. 백엔드 엔지니어링에서의 동기, 비동기 처리

백엔드의 세계로 한번 들어와볼까요? 우리가 현재 일반적인 Spring MVC 애플리케이션을 개발 하고 있다고 가정 해 보겠습니다.

이번 세션에서는 백엔드 엔지니어링 업무를 맡고 있는 관점에서의 동기, 비동기 처리에 대해 다뤄 보겠습니다.

⏳ 4.1 동기 + 블로킹 : 일반적인 백엔드 요청-응답 처리 패턴

최대한 코드는 제외했습니다. 이번 포스팅의 주제가 Spring 가이드 보단 동기와 비동기 처리에 대한 이야기니까요.

하지만 글을 쓰다보면 코드가 본의 아니게 자주 등장할 것 같습니다...적당히 넘겨가며 읽어주세요.

Spring MVC 는 전통적인 Servlet 기반의 동기적 요청-응답 처리 모델을 따릅니다. Apache Tomcat 과 같은 Servlet 컨테이너는 클라이언트 요청 1건 당 하나의 스레드를 할당하죠.

[Client] → [DispatcherServlet] → [Controller] → [Service] → [RestTemplate 호출 → 외부 API 응답 대기] → 응답 반환

위의 패턴은 결제 전문 연동과 같은 로직 개발에서 자주 쓰입니다. 꼭 결제 전문이 아니더라도 날씨 정보, 주소 검색 등과 같은 외부 API 무엇이든 연동할 때 정말 자주 쓰이는 국룰 패턴이죠.

이 패턴의 문제점이 뭘까요? 바로 요청 1건 당 하나의 스레드를 할당하는 것입니다. 1000명이 동시에 요청을 보내면 스레드가 1000개가 필요하죠. 그리고 대부분의 시간이 외부 시스템의 응답을 대기하는 데 쓰일 수도 있습니다. 결제 전문 혹은 통신사 전문은 우리가 어떻게 할 수 있는 영역은 아니잖아요? 그 만큼 스레드가 점유되고 있다는 건데, 스레드가 무한정 쓸 수 있는 것은 아니기 때문에 병목 (Thread Exhaustion) 이 발생할 수 있습니다.

그럼 왜 응답을 대기하고 있을까요? 기본적으로 Spring MVC 는 동기 + 블로킹 모델을 따르기 때문입니다.

🚫🔓 4.2 블로킹, 논블로킹

블로킹이라는 키워드가 나왔네요. 이 쯤에서 논 블로킹과 블로킹에 대해 조금 짚고 넘어가겠습니다.

블로킹(Blocking) 과 논 블로킹 (Non-Blocking) 은 프로그램이 I/O 작업을 처리하는 방식에 대한 개념입니다.
블로킹 방식에서는 I/O 작업 (파일 읽기, 외부 API 요청) 을 처리할 때 해당 작업이 완료될 때 까지 프로그램의 실행이 멈추게 됩니다. I/O 작업이 완료되기 전 까지 다른 어떤 작업도 수행할 수 없죠.

[Thread] ──▶ 작업 A 시작
            (API 응답 대기 중...)
            (API 응답 대기 중...)
            (API 응답 도착!)
            ▶ 작업 A 결과 처리
            ──▶ 작업 B 시작

논 블로킹 방식에서는 I/O 작업을 요청 하더라도 프로그램의 실행이 멈추지 않습니다. 즉시 다음 작업으로 넘어갈 수 있죠.

[Thread] ──▶ 작업 A 시작
            ▶ 요청만 보내고 다음으로 이동
            ──▶ 작업 B 시작
            ▶ 요청만 보내고 다음으로 이동
            ──▶ 작업 C 시작
            ▶ 요청만 보내고 다음으로 이동

블로킹, 논블로킹 : 왜 쓰는걸까요?

우리의 HTTP 요청은 어쩄든 요청 하나로 보면 단건입니다. 하지만 시스템 전체적으로 보면 수 많은 요청들에 대한 응답을 줘야하는 커피숍의 입장과도 같습니다.

        ☕ 동기 커피숍 – 하나씩 처리 중...

[Thread-1] ──┐
             │   {\___/}  ☕ 커피 제조 중... (3초 대기)
             │   ( • ㅁ•)   ← 요청 1
             ▼

[Thread-2] ──┐
             │   {\___/}  ☕ 커피 제조 중... (5초 대기)
             │   ( - ᗜ -)  ← 요청 2
             ▼

[Thread-3] ──┐
             │   {\___/}  ☕ 커피 제조 중... (2초 대기)
             │   ( ×_× )  ← 요청 3
             ▼

=== 사용 가능한 스레드 없음! ===

[대기 요청-1]  {\___/}   ← 요청 4: 커피 주문 중...
              ( •︵• )   "왜 이렇게 오래 걸리지…"

[대기 요청-2]  {\___/}   ← 요청 5: 커피 주문 시도했지만 실패
              ( •̀ ᗣ •́ ) "서버가 터졌어!!!"

멀티스레드 환경에서는 각 요청에 대해 스레드가 하나 씩 할당이 되죠. 외부 API (여기서는 커피 제조라고 합시다.) 를 각 스레드가 할당받아서 대기를 하고 있는 상황인데, 문제는 전형적인 동기 + 블로킹 패턴에서는 스레드가 요청을 응답하는 동안 아무것도 하지 못합니다. 결국 대기 줄이 생길 수 밖에 없고 어느순간 서버가 터졌다라는 CS 를 받게 됩니다.

🌐 4.3 동기 + 논 블로킹 : 조금 더 유연한 처리가 필요할 때

이 쯤에서 한 가지 질문을 할 수 있을 것 같습니다.

어쨌든 우리의 구조 상 동기 처리를 제외하지 않을 수는 없는 것 아닌가요? 저흰 지금 Spring MVC 를 쓰고 있잖아요?
이 상황에서 블로킹, 논 블로킹을 도입했을 때 어떤 차이가 있을까요?

맞습니다. Spring MVC를 쓰고 있는 한 동기 처리에서 벗어나긴 어렵죠. 하지만 전체 시스템의 구조를 크게 변화하지 않으면서 우리는 부분적인 논 블로킹을 도입해서 우리의 로직 처리에 또 다른 옵션을 줄 수 있죠.

Spring 5 부터는 WebClient 라는 논 블로킹 + 비동기 기반의 HTTP 클라이언트를 제공합니다. WebClient 를 사용하면 아래와 같은 흐름을 타게 되죠.

[Client] → [DispatcherServlet] → [Controller] → [Service] → WebClient 호출 → 리액터 이벤트 루프 등록 → 응답 오면 콜백 처리 → 응답 반환

아까와는 조금 달라졌습니다. 전체 흐름 자체는 동기 처리지만, Service 레이어에서 WebClient 를 호출하고 응답을 반환하는 과정에서 비동기 처리 + 논 블로킹이 들어갑니다. 기존과는 어떤 차이가 있을까요?

        ☕ 동기 흐름 기반, 논 블로킹 활용 커피숍 (WebClient)

[Thread-1] ──┐
             │   {\___/}  🔔 진동벨 받고 자리로 이동!
             │   ( • ㅁ•)   ← 요청 1: 주문 완료
             ▼

[Thread-2] ──┐
             │   {\___/}  🔔 진동벨 받고 자리로 이동!
             │   ( -ᗜ- )   ← 요청 2: 주문 완료
             ▼

[Thread-3] ──┐
             │   {\___/}  🔔 진동벨 받고 자리로 이동!
             │   ( ◕‿◕ )   ← 요청 3: 주문 완료
             ▼

=== 스레드 여유! 다음 요청도 바로 처리 가능 ===

[Thread-4] ──┐
             │   {\___/}  🔔 진동벨 받고 자리로 이동!
             │   ( ° ᴗ° )   ← 요청 4: 빠르게 처리됨
             ▼

☕ 커피는 백그라운드에서 제조 중...
[Reactor-thread] → 커피 완성 시 진동벨 울림 → 손님 응답 전송!

기존과 다르게 논 블로킹으로 처리하기 때문에 WebClient 를 호출하는 시점에서 스레드는 반환됩니다. 응답은 그 시점에서 끝나고 추후 다른 채널 (알림, 푸시) 등을 통해 결과를 전달하게 되죠.

여기서 Reactor thread 는 이벤트 루프에 요청들을 적재한 후 처리하게 됩니다.

Reactor?

Reactor는 Spring WebFlux와 WebClient 등에서 사용되는 비동기 논블로킹 프로그래밍 모델로, Reactor thread는 내부적으로 Netty의 이벤트 루프(Event Loop) 기반으로 작동합니다. I/O 요청을 등록만 해두고, 응답이 오면 reactor-thread가 콜백처럼 실행 흐름을 이어서 처리하는 구조입니다.

위의 예시에서 Reactor 내부에서는 아래와 같은 일이 벌어집니다.

[HTTP 요청 1] ─┐
               ▼
      [main thread]
        ─→ WebClient 요청 전송 (논블로킹)
        ─→ 작업 등록하고 반환
🧵 reactor-thread ─▶ 확인: 응답 왔나?

✔ 응답 1 도착!
→ 콜백 실행: map/filter/flatMap 등 처리 체인 실행

✔ 응답 2 도착!
→ 콜백 실행

✔ 응답 3 도착!
→ 콜백 실행

여기서 콜백에는 이벤트가 완료 되었을 때 실행되어야 할 코드들이 들어갑니다. 주로아래와 같은 로직들이 들어갑니다.

콜백에 들어갈 수 있는 것설명
후처리 로직응답 데이터 가공, 필터링, 포맷 변환
사이드 이펙트로그, DB 저장, 외부 전송 등
에러 핸들링예외 대응 또는 대체 흐름 처리
최종 소비자 응답 전송subscribe 내부에서 사용자에게 응답

EventLoop?

이벤트 루프 (Event Loop) 라는 키워드가 나왔습니다. 또 짚고 넘어가야겠죠?

이벤트 루프란 하나의 (또는 소수의) 스레드가 **등록된 작업들(I/O, 타이머 등)**을 큐에서 꺼내 차례로 실행하는 방식입니다.

이 구조는 논블로킹 환경(예: Reactor, Netty, Node.js 등)에서 스레드를 점유하지 않으면서도 높은 처리량을 제공하기 위한 핵심 메커니즘이죠.

이벤트 루프는 아래와 같은 흐름으로 동작합니다.

1단계 : 작업 등록
[Main Thread] ──▶ 등록: HTTP 요청, 타이머, 파일 읽기 등
                     ▼
               ┌────────────────┐
               │   이벤트 큐     │ ◀─────────────┐
               └────────────────┘               │
                                                │
                                      [I/O 완료/타이머 만료 시]
                                               ▼
                                 ┌─────────────────────────┐
                                 │   처리할 작업이 큐에 들어옴   │
                                 └─────────────────────────┘
2단계 : 이벤트 루프 스레드가 큐를 돌며 작업을 수행

     🧵 이벤트 루프 스레드 (reactor-thread, event-loop)

        while(true) {
            if (이벤트 큐에 작업이 있다면) {
                꺼내서 실행 (콜백, map, flatMap 등)
            }
            다음 이벤트까지 기다림 (non-blocking poll)
        }
3단계 : 처리된 결과는 콜백 / 체인으로 전달

📦 예: WebClient 요청 흐름

[HTTP 요청 등록]
     ↓
[응답 도착 → 이벤트 큐에 등록]
     ↓
[이벤트 루프가 꺼냄 → map/filter 등 체인 실행]
     ↓
[결과 → 구독자에게 전달 (`subscribe`, `onNext` 등)]
WebClient 에서는 어떻게 사용될까요?

WebClient 내부에서 그럼 이벤트 루프는 어떻게 동작할까요? 그림으로 정리 해 보았습니다.

Client → WebClient.get()          → 요청 전송 (non-blocking)
                                  → 이벤트 루프에 콜백 등록

<=== 응답 도착 ===                 → 응답 처리 로직 이벤트 큐에 등록

[reactor-thread]:
  → 이벤트 루프가 응답 작업 꺼냄
  → bodyToMono().map().flatMap() 체인 실행
  → subscribe() 로직 실행

이런 구조를 통해 WebClient 의 경우엔 매우 적은 이벤트 루프 스레드만 가지고도 많은 요청을 처리할 수 있습니다. 좀 더 구체적인 예시를 볼까요?

시나리오 : 1개의 이벤트 루프 스레드가 존재하고, 5000명의 요청이 전달되었을 때
[Client 1] ─┐
[Client 2] ─┤
[Client 3] ─┤
...         ┤
[Client 5000] ─▶ WebServer

조금은 극단적이라고 할 수 있는 예시를 볼게요. 하나의 스레드만 이벤트 루프에 할당 되었다고 가정해볼게요. 5000명이 요청을 보내고 있습니다.

각 요청은 I/O 등록만 하고 바로 반환됩니다. 이후 이벤트 루프에 콜백으로 등록되죠.

🧵 [reactor-thread-1]: 이벤트 루프 순환 시작

(1) 응답 도착: Client 128 → 콜백 실행
(2) 응답 도착: Client 3499 → 콜백 실행
(3) 응답 도착: Client 2 → 콜백 실행
(4) 응답 도착: Client 4000 → 콜백 실행
(5) 응답 도착: Client 5000 → 콜백 실행
...

==> I/O 응답이 완료된 요청만 큐에 등록

이벤트루프는 순환하며 만약 해당 요청에 대한 응답이 존재한다면, 꺼내서 콜백을 실행합니다.

주의해야 할 점

동기 + 논블로킹 패턴에서는 이벤트 루프 오염 (Event Loop Poisoning) 을 조심해야 합니다. 정작 콜백에 블로킹 코드가 들어가거나 처리가 오래걸리는 무거운 작업이 들어가게 된다면 대기중인 모든 요청의 응답 속도가 느려지게 됩니다. 예를 들면 아래와 같은 작업들이 있죠.

대표 예시
Thread.sleep, File.read, JDBC 등 블로킹 코드
암호화/해싱, PDF 생성 등 CPU 집약 작업
대규모 DB Join, 복잡한 business logic

이 경우 무거운 작업을 다른 스레드로 분리하거나 R2DBC 등 논블로킹 코드로 전환하는 등 대안을 선택해야 합니다.

어떤 상황에서 동기 + 논 블로킹 패턴이 유리할까요?

흐름은 동기적으로 흐르되, 응답을 다른 채널로 받을 수 있는 경우엔 상당히 유리합니다.

커피를 주문하고 나중에 푸쉬 알림으로 '커피가 도착했습니다.' 라는 응답이 전달된다면, 훨씬 유리합니다. 로직의 흐름 자체는 동기적으로 돌아갔고, 응답도 동기적으로 받았습니다. 하지만 그 응답이 잠시 기다리세요! 라는 차이가 있죠. 실질적인 데이터는 추후에 다른 채널로 전달 될 것입니다.

하지만 반드시 외부 API 등의 응답을 바로 요청 단에서 받아야 하는 상황에서는 어쩔 수가 없습니다. 가장 대표적으론 결제 처리와 같은 게 있겠죠.

위에서 공유드렸다시피 [동기, 비동기 에 적합한 예시](#언제 사용하나요?) 로직에 따라 동기 + 블로킹 처리가 필수 불가결인 경우도 분명 생깁니다. 이런 경우에는 호출 흐름 자체는 동기적으로 진행하되, 내부 처리에서는 논블로킹을 사용해서 스레드 점유를 최소화 할 수도 있습니다.

그림으로 표현 해 볼게요. 커피숍에서 무조건 손님이 커피 주문 요청에 대한 응답을 받아야 하는 상황을 가정 해 보겠습니다. [동기 + 논 블로킹 예시](#동기 + 논 블로킹) 에서는 손님이 알람벨을 가지고 가는 상황이었고, 이번에는 무조건 커피 요청을 받아가야 한다고 가정 해보겠습니다.

 ☕ 커피숍 – 결제 요청은 논 블로킹 전송, 대기 중 스레드는 쉬었다가 복귀

[Thread-1] ──┐
             │   {\___/}  💳 결제 요청 전송 완료
             │   ( • ㅁ•)   "응답 올 때까지 자리에서 기다릴게요" ← ⏸️ 잠시 중단
             ▼

☕ 커피 만드는 중... (결제 시스템 응답 대기)

🔔 결제 응답 도착! → Thread-1 다시 줄로 복귀

[Thread-1] ──▶ {\___/}
               ( ◕‿◕ )   "결제 완료! 커피 받았어요!"

결제 요청 만큼은 논 블로킹으로 처리하게 된다면, 어쨌든 이 논 블로킹 I/O 를 WebClient 의 .block() 등을 활용하는 등의 조치를 취해서 동기적으로 기다려야 하죠. 하지만 .block() 호출 직전 까지는 물리 스레드를 점유하지 않고 빠르게 반환합니다.

@GetMapping("/pay")
fun pay(): PaymentResponse {
    return webClient.post()
        .uri("https://payment.example.com")
        .bodyValue(paymentRequest)
        .retrieve()
        .bodyToMono(PaymentResponse::class.java)
        .block()
}

결제 처리를 하는 상황을 가정 해 볼게요. Spring MVC에서 요청을 처리하기 위해 Thread-1이 할당될겁니다.

WebClient는 Netty를 통해 논블로킹 I/O로 HTTP 요청을 전송합니다. Thread-1이 해당 요청을 Netty에 등록하고 빠지게 됩니다. 아직 .block() 호출 전이니 물리 스레드 사용량은 없거나 매우 짧습니다.

응답을 받기 위해 .block()이 호출되면,현재 스레드는 응답이 도착할 때까지 “논리적으로” 대기 상태에 진입하게 됩니다. 이 시점부터는 Thread-1이 물리적으로 점유되죠.

Netty의 이벤트 루프가 응답을 수신하게 된다면 WebClient 내부가 이를 감지하고, .block()을 호출했던 Thread-1을 다시 깨웁니다. Controller가 응답을 반환하고 Thread-1은 반환되죠.

어쨌든 전통적인 블로킹 로직 (RestTemplate) 을 쓰는 방식에 비해 아주 약간 스레드 점유 시간을 아낄 수는 있지만 큰 차이는 아닙니다. 사실 의미있는 최적화라고 보기 어려울 만큼의 차이죠. 결국 이 구조도 전체적으로 보면 동기 + 블로킹이랑 큰 차이가 없으니까요. 그럼 이 구조로는 문제를 해결 할 수는 없는걸까요?

심화 : 좀 더 스레드를 아껴볼까요? (가상스레드)

가상 스레드를 활용하면 블로킹이 반드시 들어가야만 하는 상황에서도 최적화를 할 수 있습니다.

가상 스레드는 Java 21 에서 정식 출시 된 기능으로 기존의 물리 스레드보다 훨씬 가볍게 동작하는 스레드입니다.

물리 스레드는 OS 커널 단에서 관리하게됩니다. OS 커널이 스케줄링, 문맥 전환(context switch), 동기화(synchronization) 를 모두 관리하죠. 또한 각 스레드마다 고정 크기 스택과 커널 데이터 구조 (TCB) 가 할당됩니다. 스레드가 만약 블로킹 되면 해당 OS 스레드 전체가 멈추므로 CPU 자원 활용이 불가능합니다.

가상 스레드는 처음에 아주 작은 스택 프레임만 할당합니다. 필요할 때만 스택을 확장하는 등 메모리 낭비를 최소화할 수 있습니다.

@GetMapping("/pay")
fun pay(): PaymentResponse {
    return webClient.post()
        .uri("/payment")
        .bodyValue(req)
        .retrieve()
        .bodyToMono(PaymentResponse::class.java)
        .block() // 블로킹!!
}

기존 스레드라면 이 .block() 동안 물리 스레드가 점유됩니다. 가상 스레드를 쓰려면 따로 설정을 해 주어야 합니다.

@Configuration
class VirtualThreadConfig {
    @Bean
    fun virtualThreadExecutor(): TaskExecutor =
        TaskExecutor { runnable ->
            Thread.ofVirtual().start(runnable)
        }
}

또는 yml 파일에 아래와 같이 설정해줄 수 있죠

spring:
  threads:
    virtual: enabled

가상 스레드라면 .block() 시 스레드가 중단되고 다른 요청에 할당할 수 있습니다. 동기 코드 유지하면서도 성능에서 이점을 볼 수있죠.

[VirtualThread-1] ──┐
                    │   {\___/}  ☕ 주문 보냄 후 진동벨 받고 자리로 감
                    │   ( • ㅁ• ) "앉아서 기다릴게요~"
                    ▼

[VirtualThread-2] ──┐
                    │   {\___/}  ☕ 주문 보냄 후 자리로 감
                    │   ( ◕‿◕ ) "앉아서 유튜브 봐야지"
                    ▼

[VirtualThread-3] ──┐
                    │   {\___/}  ☕ 주문 보냄 후 자리로 감
                    │   ( ˘◡˘ ) "편안~"
                    ▼

=== 커피 다 나오면 진동벨 울림 ===
→ 가상 스레드 재개 → 응답 반환
가상 스레드를 사용한다면 조심해야 할 점은?

가상스레드를 쓰면 기존의 코드를 바꾸지 않고도 현재 상황을 조금 더 개선할 수 있습니다. 기존 OS 스레드에 비해 가벼워서 훨씬 많은 숫자의 스레드를 만들 수 있고, 기존 블로킹 라이브러리와의 호환성이 높습니다.

하지만 마냥 은탄환이라고 볼 수는 없는 게 아무리 가상 스레드라고 해도 블로킹은 블로킹이고 스레드를 점유한다는 사실을 잊어선 안됩니다.

또한 가상 스레드는 요청마다 새로 생성되므로 ThreadLocal 재사용이 불가능합니다.

❓ 여기서 ThreadLocal 이란?

ThreadLocal은 “각 스레드마다 별도의 값을 저장”할 수 있게 해 주는 Java의 유틸리티 클래스입니다. 주로 요청별 컨텍스트(예: 사용자 정보, 트랜잭션 ID 등)를 스레드 전역에서 편하게 쓰고 싶을 때 사용합니다.

java.lang.Thread 클래스는 내부에 ThreadLocal.ThreadLocalMap threadLocals라는 맵을 가지고 있습니다.

ThreadLocal<T> 객체에 set() 을 호출하면, 현재 실행 중인 스레드의 이 맵에 키(ThreadLocal 객체)–값(사용자 데이터) 쌍이 저장됩니다.

get()을 호출하면, 같은 스레드의 맵에서 값을 꺼내 옵니다.

또한 JDK 21 부터 지원하는 기능이므로 외부 라이브러리와 호환성을 잘 살펴보아야 합니다.

마지막으로 OS 스레드에서만 동작하는 기능들에 대해 한계점이 존재합니다. 특히 syncronized 와 같은 모니터 락은 OS 스레드에서만 작동하죠.

🔄 4.4 비동기 + 블로킹 : 일부만 블로킹을 걸고 싶은 경우

지금까지 글을 읽으셨다면, 비동기와 블로킹을 함께 쓰면 어떤 일이 일어날 지 고민이 되실 수도 있습니다. 비동기 + 블로킹을 빠르게 구현하고 싶다면, 비동기 프레임워크 안에서 내부 로직은 여전히 블로킹 호출 (RestTemplate, JDBC) 를 사용하면 됩니다. 예를 들면 이런 상황이죠.

Mono.fromCallable(() -> {
    return jdbcTemplate.query(...); // ← 블로킹
})
.subscribeOn(Schedulers.boundedElastic())

Webflux 내에서 JDBC 를 호출하는 상황입니다. 때로는 이런 상황이 필요할 수 있습니다. 점진적으로 마이그레이션이 필요한 경우 이런 코드가 나올 수 있죠.

코드가 조금 와닫지 않는 것 같으니 다시 커피숍 예제를 가져와볼까요?

[Thread-1] ──┐
             │  고객1  {\___/}  "커피 주세요!"
             │         ( • ㅁ•)  👉 [비동기 주문 접수]
             │                    → [☕ 커피 만드는 중... (3초 블로킹)]

[Thread-1] ──┐
             │  고객2  {\___/}  "저도요!"
             │         ( - ω -)  👉 [주문 대기 중... ❗ 이전 작업 완료 대기]

[Thread-1] ──┐
             │  고객3  {\___/}  "혹시 오래 걸리나요?"
             │         ( •︵• )  👉 [대기 중...]

=== 커피 머신은 하나인데, 작업이 블로킹이라 대기행렬 발생! ===

사장님이 한분이고 주문은 순서대로 받고 있는 상황입니다. 하지만 만드는데 시간이 많이 걸려서 어쨌든 줄이 생기는 경우입니다. 주문을 키오스크로 빠르게 받고 있지만 막상 제조는 사람이 하나씩 처리하고 있죠. 진동벨이 따로 있는 상황은 아니고 받는 줄이 따로 있다고 생각하시면 이 상황이 조금 더 이해가 되실겁니다.

음 잘 와닫지 않으신다면...록 페스티벌 등 축제 현장에서의 F&B 푸드트럭을 생각해보시면 편합니다.
어딘가에서 대기를 하기 어려운 현장을 생각하보자구요. 진동벨 같은 장치를 둘 수도 없구요.

뭔가 이상해보이지만, 장점이 있습니다. 일부 블로킹 호출을 제외한 나머지 로직들은 논 블로킹으로 처리할 수 있죠. 반드시 필요한 경우에만 블로킹을 걸 수 있습니다. 만약 레거시 시스템을 개선하고 싶을 때 코드의 변경을 최소화 한 채 프레임워크만 먼저 변경하고 싶다면 이런 패턴도 괜찮습니다.

문제는 스레드를 아끼려고 사용하는 비동기 프레임워크에서 스레드 낭비가 발생할 수 있죠. 결국 블로킹은 블로킹이니까요. 만약 지연이 길어진다면, 전체 흐름이 지연되고 요청 처리량이 저하될 수 있습니다.

또한 비동기와 블로킹을 혼합하는 경우 stack trace 가 뒤엉키고 예외처리 하기 어렵습니다. 즉 병목 예측이 어려울 수 있습니다. 정말 특정 코드 때문에 병목이 생기는 지 속단 하기엔 워낙 변수가 많잖아요?

java.lang.RuntimeException: Payment failed
   at kr.co.demo.OrderController$callPaymentApi$1.apply(OrderController.kt:24)
   at reactor.core.publisher.MonoMap$MapSubscriber.onNext(MonoMap.java:120)
   at reactor.core.publisher.FluxFlatMap$FlatMapMain.innerNext(FluxFlatMap.java:1230)
   at reactor.core.publisher.FluxFlatMap$FlatMapInner.onNext(FluxFlatMap.java:985)
   at reactor.core.publisher.Operators$MonoSubscriber.complete(Operators.java:1845)
   ... (Reactor 내부 호출들 생략)
   at reactor.core.publisher.Mono.block(Mono.java:1705)  ❗ block() 호출
   at kr.co.demo.OrderController.callPaymentApi(OrderController.kt:27)
   at kr.co.demo.OrderController.processOrder(OrderController.kt:16)
   at kr.co.demo.OrderController.order(OrderController.kt:11)

WebClient 를 호출하는 로직에서 장애가 났고, .block() 메소드를 통해 블로킹을 건 케이스입니다. 정작 로직은 내부 Reactor 에서 터졌는데 로그는 생략되죠.

이 경우 성능 이슈를 방지하기 위해 boundedElastic 등을 사용해서 블로킹 I/O 에만 스레드풀을 따로 위임할 수 있습니다. 하지만 결국 이 또한 스레드풀 사이즈의 한계에 부딪칠 수 있죠.

그래서 비동기 + 블로킹 패턴을 사용해야 한다면 아래와 같은 기준으로 사용할 것이 좋습니다.

조건판단 기준
레거시 DB / API를 수정할 수 없음불가피한 경우 boundedElastic 또는 Dispatchers.IO로 감쌈
일부만 마이그레이션 진행 중전환 과정에서 임시 전략으로 사용
전체 논블로킹 구현 비용이 너무 큼성능이 중요하지 않거나 요청량이 적다면 허용 가능

🚀 4.5 비동기 + 논블로킹 : 성능은 좋지만 많은 복잡성과 제약이 늘어나는 패턴

이제 마지막 비동기 + 논블로킹 까지 왔습니다. 비동기와 논 블로킹을 함께 쓰면 상당한 성능 개선 효과를 볼 수 있습니다. 왜냐면 적은 스레드로 많은 주문들을 처리할 수 있으니까요.

[Thread-1] ──┐
             │   고객1  {\___/} "커피 주세요!"
             │           ( • ㅁ•) 👉 [주문접수 + 비동기 처리 예약] ┐
             │                                                  ▼
             │   고객2  {\___/} "저도요!"
             │           ( - ω -) 👉 [주문접수 + 비동기 처리 예약] ┐
             │                                                  ▼
             │   고객3  {\___/} "저도 주세요!"
             │           ( ^人^) 👉 [주문접수 + 비동기 처리 예약] ┐
             │                                                  ▼
             │  (요청 계속 받음...)                              ▼

[커피머신-1] ──☕ 제작 중... (고객1 커피)
[커피머신-2] ──☕ 제작 중... (고객2 커피)
[커피머신-3] ──☕ 제작 중... (고객3 커피)

📦 커피 다 만들어지면, 알림(콜백) 후 고객에게 전달 ☕

스레드는 계속해서 블로킹 없이 고객들의 커피를 주문받고 있습니다. 커피 제조는 비동기적으로 예약 되고, 제조가 완료되는 경우 콜백으로 알림이 전달되죠. 이 경우 커피를 기다리느라 스레드가 묶이지 않습니다. 즉 하나의 또는 소수의 스레드로 수백 명의 주문을 처리할 수 있죠.

지금까지 소개 해 드린 패턴 중에 가장 성능적으론 우수할 수 있습니다. 핫플 카페의 경우 이런 패턴이 아마 필수적일 지도 모릅니다.

                          ____ HOTPLACE CAFE ____
                         /                     \
                        /                       \
      [ 3F ]   ┌───────────────────────────────────────────┐
               │  {\___/}    {\___/}    {\___/}            │
               │  ( • ㅁ•)    ( • ㅁ•)    ( • ㅁ•)          │
               └───────────────────────────────────────────┘

      [ 2F ]   ┌───────────────────────────────────────────┐
               │  {\___/}    {\___/}    {\___/}            │
               │  ( • ㅁ•)    ( • ㅁ•)    ( • ㅁ•)         │
               └───────────────────────────────────────────┘

      [ 1F ]   ┌──────────── Counter & Barista ────────────┐
               │  {\___/}  주문 접수 ▶ 🔔   {\___/}        │
               │  ( • ㅁ•)                  ( • ㅁ•)       │
               │   카운터 직원               바리스타         │
               └───────────────────────────────────────────┘

손님이 바글바글 가득 차 있는 카페를 한번 생각 해볼까요? 주말에 카페에서 시간 보내길 좋아하시는 분들은 빵 굽는 냄새가 나거나 예쁜 조형물들이 함께하는 카페가 상상이 되실겁니다. 너무 손님이 많아서 건물은 2층 또는 3층 까지 올라가있죠. 자리가 꽉 차있어서 못 앉는 사람이 나오기까지 합니다.

우리가 사장님이라고 생각 해 볼게요. 이런 상황에서는 최대한 적은 비용으로 효율적으로 주문을 처리할 수 있는 구조를 택하는 게 좋습니다. 직원들을 카운터 직원, 바리스타로 분업할 수 있습니다. 카운터 직원은 주문을 받고 바로바로 진동벨을 전달합니다. 손님들은 각자 자리에서 담소를 나누거나 노트북을 받고있죠. 바리스타는 주문표에 쌓여있는 주문들을 하나씩 처리한 후 진동벨을 울립니다. 고객들은 자리에서 일어나 커피를 수령하거나, 좀 더 돈이 많은 카페라면 로봇이 서빙을 해줄 수 있죠. 우리는 수령 대기줄을 늘리지도 않고 오직 두 명의 사람으로 아주 많은 주문을 처리할 수 있죠. 이 구조는 싱글 스레드와 리엑터 이벤트 루프 구조를 가지는 전형적인 비동기 + 논 블로킹 패턴과 일맥상통 합니다. 카운터 직원이 스레드, 바리스타가 리엑터 이벤트 루프 역할을 하죠.

아니 정말 주문이 많이 밀리면 바리스타 한명으로 어렵지 않나요?

맞습니다. 바리스타를 여럿 고용해야 할 수 있죠. 프레임워크에 따라 다르지만, Netty 의 경우 옵션으로 이벤트 루프를 확장시킬 수 있습니다.

지금까지 예시에서 볼 수 있다시피 트래픽이 많은 서비스라면 비동기 + 논 블로킹 설계 패턴을 고려할 수 있습니다. 마치 주말에 사람이 미어터지는 핫플 카페처럼 말이죠. 장점으로는 당연히 스레드 효율을 극대화 할 수 있으며, 적은 리소스로 더 많은 요청을 처리할 수 있습니다. 메모리 사용량도 당연히 감소하죠.

하지만 단점 또한 존재합니다. 흐름이 복잡해지면 콜백 지옥에 빠질 수 있죠.

fun serveCoffee() {
    takeOrder("아메리카노",
        onSuccess = { order ->
            brewCoffee(order,
                onSuccess = { brewed ->
                    packCoffee(brewed,
                        onSuccess = { packed ->
                            deliverCoffee(packed,
                                onSuccess = { delivered ->
                                    println("✅ 고객에게 커피 전달 완료: $delivered")
                                },
                                onFailure = { e ->
                                    println("❌ 배달 실패: ${e.message}")
                                }
                            )
                        },
                        onFailure = { e ->
                            println("❌ 포장 실패: ${e.message}")
                        }
                    )
                },
                onFailure = { e ->
                    println("❌ 커피 제조 실패: ${e.message}")
                }
            )
        },
        onFailure = { e ->
            println("❌ 주문 실패: ${e.message}")
        }
    )
}

아마 비동기 프레임워크와 논블로킹에 익숙하지 않으시다면 이런 코드를 한번 정도는 작성 해보셨을지도 모릅니다.

또한 스레드가 고정되지 않기 때문에 SecurityContext, MDC 같은 ThreadLocal 기반 기능이 깨지기 쉽습니다.

그리고 디버깅 하기 어렵죠. stack trace 가 끊켜서 출력 될 가능성이 높습니다.

또한 기존의 블로킹 코드를 사용하는 프레임워크와 호환성이 제한됩니다. JPA, 보안필터, 로깅, 트레이싱 등 도구들이 제대로 동작하지 않을 수 있죠.

콜백 지옥에서 벗어나보자! : 코루틴

콜백지옥 코드는 우리가 조금 더 노력하면 개선이 될 수 있어요. 만약 우리가 Kotlin 을 도입 한 입장이라면 코루틴 (Coroutine) 도입을 검토해볼 수 있죠.

코루틴은 가상스레드와 비슷하게 가벼운 스레드라고 할 수 있습니다. 애플리케이션 레벨에서 실행 흐름을 중단(정지) 했다가 재개할 수 있어서 수천 ~ 수만 개의 동시 작업을 처리할 수 있죠.

OS 스레드의 무거움에 대해서는 충분히 설명드렸으니 더이상 추가로 설명드리지 않겠습니다. 코루틴으 앞서 설명드린 가상스레드와는 다른 개념이에요.

가상 스레드는 호출 스택 (call stack) 을 그대로 유지하며, 필요한 경우 OS 스레드 풀에서 실행됩니다. 코루틴은 Kotlin 컴파일러가 suspend fun 을 인지해서 호출 스택을 전부 보존하지 않는, 정지 지점마다 상태만 저장하는 기능입니다.

   [ 코 루 틴 ]                          [ 가 상 스 레 드 ]
┌───────────────────┐             ┌─────────────────────────┐
│  CoroutineScope   │             │   JVM Loom Scheduler    │
└──┬─────────────┬──┘             └─────┬──────────┬────────┘
   │             │                       │          │
   ↓             ↓                       ↓          ↓
┌───────┐     ┌───────┐              ┌───────┐  ┌───────┐
│ co1   │     │ co2   │              │ vth1  │  │ vth2  │
│state↑ │     │state↓ │              │stack↑ │  │stack↓ │
└───┬───┘     └───┬───┘              └───┬───┘  └───┬───┘
    ↓ suspend       ↓ resume             ↓ block      ↓ block
┌───────────────────────────┐       ┌───────────────────────────┐
│ Dispatcher (Default, IO…) │       │ carrier OS thread pool    │
└───────────────────────────┘       └───────────────────────────┘

호출 스택이란 프로그래밍 언어 런타임이 함수 호출 시 마다 현재 실행 상태를 저장해두는 메모리 구조입니다.

┌─────────────────┐  ← 스택 최상단 (Top)
│ f3() 의 프레임  │
├─────────────────┤
│ f2() 의 프레임  │
├─────────────────┤
│ f1() 의 프레임  │
└─────────────────┘  ← 스택 최하단 (Bottom)

그림으로 표현하면 이런 형태죠. 함수들이 호출 된 순서대로 스택에 쌓이죠. 스택 구조니까 LIFO (Last In First Out) 형태로 함수들이 빠져 나갑니다. 각 스택 프레임 (Stack Frame) 에는 함수 호출 시점의 지역 변수, 매개 변수, 그리고 복귀 주소 (리턴 위치) 등이 저장되어있습니다.

호출 스택이 있기 때문에 시스템 상에서는 함수간에 호출 구조를 추적할 수 있고, 지역변수와 매개변수 등의 값이 유지될 수 있습니다. 또한 예외가 발생하는 경우 스택 트레이스 (StackTrace) 를 통해 호출 경로를 알 수 있죠.

코루틴은 이 호출 스택에 함수의 흐름을 저장하는 게 아니라 suspend (정지) 지점마다 필요한 로컬 변수와 다음 상태 별 분기점(switch-case)과 콜백 구조를 생성해 상태 기계 (State Machine) 로 바꿉니다. 호출 스택을 쓰는 것에 비해 훨씬 적은 메모리를 사용할 수 있죠.

호출 스택에서는 함수 진입부터 복귀까지 모둔 스택 프레임을 유지해야 하는 것에 비하면 그냥 suspend 지점 마다 별도의 저장용 객체가 생성되는 샘입니다. 이를 상태 기계 (State Machine) 이라고 부릅니다. 또한 정지된 지점의 로컬 변수와 다음 실행 위치 정보를 담은 Continuation 객체가 만들어집니다. 추후 Continuation.resumeWith(...) 호출로 로컬 변수와 실행 위치가 복원되고, 함수가 이어서 실행됩니다.

호출 스택은 만약 함수의 호출 간에 재귀가 생기고, 이 재귀가 깊어지면 스택 오버플로우 (Stack Overflow) 에러가 발생할 수 있습니다. 하지만 코루틴의 경우는 상태 전환만 계속해서 이뤄지고 데이터가 어딘가에 쌓이는 게 아니므로 스택 오버플로우가 발생하지 않습니다. 그림으로 표현하면 아래와 같습니다.

[전통 스택 기반 호출]             [코루틴의 상태 기계]
┌────┐                           ┌───────────┐
│f1()│───호출───▶ f2()            │State=0    │
└────┘      └────┘                │locals {...} │
  ↑                              └───────────┘
  │pop                           ┌───────────┐
┌────┐───호출───▶ f3()            │State=1    │
│f1()│      └────┘                │locals {...} │
└────┘                           └───────────┘

물론 코루틴을 쓰는 만큼 코루틴 디버거를 따로 써야 한다는 단점이 있죠. 호출 스택은 스택 트레이스를 하나하나 살펴보면 흐름이 직관적으로 보이지만 코루틴은 그렇지는 않습니다.

코루틴에 대한 대략적인 설명은 여기까지 하고 구성 요소를 한번 살펴 보겠습니다.

구성 요소설명
CoroutineScope코루틴의 생명 주기를 관리하는 컨텍스트. scope.cancel()로 한꺼번에 취소 가능.
launch / async코루틴 빌더. launch는 Job 반환, async는 Deferred<T> 반환(결과값 필요할 때).
Dispatcher코루틴을 실행할 스레드 풀. 대표적으로 Dispatchers.Default, IO, Main 등이 있음.
suspend fun정지 지점을 가질 수 있는 함수. 내부에 다른 suspend 호출, 논블로킹 API 호출이 가능.
Job코루틴 실행 단위. 완료, 취소 상태를 체크하거나 제어 가능.
Deferred<T>Job + 결과값을 가짐. await()로 결과 획득.

간단한 예제를 하나 볼까요?

import kotlinx.coroutines.*

fun main() = runBlocking {               // ① 최상위 Scope
    val job = launch(Dispatchers.IO) {    // ② IO 스레드 풀에서 실행
        val data = fetchData()            // ③ 논블로킹으로 호출
        println("Fetched: $data")
    }
    delay(100)                            // ④ 다른 작업
    job.cancelAndJoin()                   // ⑤ 필요 시 코루틴 취소
}

suspend fun fetchData(): String {
    delay(500)  // 실제로는 네트워크/디스크 I/O 같은 suspend 함수
    return "Hello, Coroutine"
}

코틀린으로 논블로킹 REST API 호출 함수를 실행시키는 예제입니다. runBlocking이 메인 스레드를 블로킹하며 코루틴을 실행합니다. launch(Dispatchers.IO)로 I/O 스레드 풀에서 코루틴을 시작하고, fetchData() 내부의 delay는 논블로킹으로 동작합니다. 필요한 경우 (예외가 발생했다던지) job.cancelAndJoin()으로 안전하게 취소할 수 있습니다.

코루틴에 대한 사전 설명은 여기까지 하고, 저 콜백지옥 코드를 한번 걷어 내 볼까요?

먼저 콜백 기반 함수를 코루틴 방식으로 수정합니다.

suspend fun takeOrder(menu: String): String {
    // 실제 비동기 로직을 코루틴으로 감싼다고 가정
    //Do Something
    if (menu.isBlank()) throw IllegalArgumentException("메뉴가 비어있어요")
    return "Order($menu)"
}

suspend fun brewCoffee(order: String): String {
    //Do Something
    if (order.contains("fail")) throw RuntimeException("커피 제조 실패")
    return "Brewed($order)"
}

suspend fun packCoffee(brewed: String): String {
    //Do Something
    return "Packed($brewed)"
}

suspend fun deliverCoffee(packed: String): String {
    //Do Something
    return "Delivered($packed)"
}

이후 콜백 지옥에 묶여있던 serveCoffee 함수를 순차적인 코루틴 함수 호출로 수정합니다. 에러가 발생하는 경우 각 함수들이 외부로 예외를 던지로도록 처리했으니 try/catch 또한 사용할 수 있죠.

suspend fun serveCoffee() {
    try {
        val order = takeOrder("아메리카노")
        val brewed = brewCoffee(order)
        val packed = packCoffee(brewed)
        val delivered = deliverCoffee(packed)

        println("✅ 고객에게 커피 전달 완료: $delivered")
    } catch (e: Exception) {
        println("❌ 처리 실패: ${e.message}")
    }
}

이처럼 코루틴을 사용하면, 비동기 I/O 를 마치 동기식 코드를 작성하는 것 처럼 해결할 수 있습니다.

코루틴을 쓸 때 주의해야 할 점?
디버깅이 어렵다

앞서 설명 드렸다시피 디버깅이 어렵습니다. 호출 스택 기반으로 동작하는 게 아니므로 스택 트레이스를 통한 호출 경로를 추적하기 어렵죠. 즉 IDE 빨을 많이 탑니다.

그리고 결국 이것도 마찬가지로 일반 자바 블로킹 I/O(예: Thread.sleep(), Files.readAllBytes() 등)를 그대로 호출하면, 그 코루틴이 돌아가던 스레드 전체가 블로킹되어 버립니다. 결과적으로, 같은 디스패처(Dispatcher.IO 등)에 묶인 다른 코루틴들도 실행할 수 없게 되죠.

// 잘못된 예: Dispatchers.Default에 블로킹 호출
launch(Dispatchers.Default) {
    // 실제로는 I/O라면 withContext(Dispatchers.IO)로 옮겨야 하지만...
    Thread.sleep(1000)        // ❌ 스레드 전체를 1초 동안 블로킹
    println("작업 완료")
}

위 코드는 Dispatchers.Default 풀(일반 CPU 연산용 스레드)에 들어간 코루틴 하나가 1초간 묶이면서, 그 스레드에서 돌던 다른 CPU 코어 사용 코루틴도 대기해야 하는 예제입니다.

launch(Dispatchers.IO) {      // ① 블로킹 호출 전용 풀로 분리
    // 블로킹 I/O는 IO 디스패처에서만!
    val data = Files.readAllBytes(path)
    withContext(Dispatchers.Default) {
        // CPU 연산이 필요하면 다시 Default로 전환
        process(data)
    }
}

이 경우엔 반드시 Dispatchers.IO (I/O 전용 블로킹 스레드 풀) 로 분리시키고 withContext 를 통해 상황에 따라 디스패처를 전환해야 합니다. 블로킹을 정 호출하고 싶다면 절대로 Dispatcher.Default 나 Main 에 넣어서는 안됩니다.

또한 구조적 동시성 적용이 복잡하다는 이슈가 존재합니다. 코루틴은 부모-자식 관계로 묶인 스코프 (CoroutineScope) 단위로 취소와 예외 전파를 관리합니다. 스코프 밖에서 생성된 코루틴 (GlobalScope) 는 이 관계에서 벗어나 예측이 어려워지죠.

만약 GlobalScope.launch 로 시작한 코루틴이 존재한다면, 애플리케이션 종료 시점까지 이 코루틴이 죽지 않는 경우엔 리소스가 누수될 수 있습니다. 또한 만약 부모 코루틴이 실패한다고 해도, supervisorScope 를 쓰지 않으면 자식 전체가 취소되거나 반대로 자식 실패가 부모를 취소시킬 수도 있습니다.

fun CoroutineScope.loadAll(ids: List<String>) = supervisorScope {
    ids.map { id ->
        // supervisorScope 내에서 launch 하면,
        // 개별 실패가 다른 자식에 영향을 주지 않습니다.
        launch {
            fetchAndStore(id)
        }
    }.joinAll()   // 모든 자식 코루틴 완료 대기
}

그러므로 상하관계에 따라 supervisorScope 를 통해 하위 또는 상위의 에러를 격리시켜야 합니다. CoroutineScope 는 화면 전환 혹은 HTTP 요청 단위 등 큰 단위로 묶어야하죠.

예외처리가 까다롭다

다음으로 예외처리가 까다롭습니다. 예외 처리 자체는 try / catch 등을 통해 잡히기는 하겠지만, 예외가 전파되지 않는 경우가 존재합니다. 아래에서 언급되는 모든 예제들의 공통적인 이슈는 할당 된 job 이 다 같이 취소가 되어야 하는 상황에서 그러지 못하는 상황들입니다.

suspend fun main() = coroutineScope {
    val job1 = async {
        delay(100)
        println("✅ job1 완료")
        "job1"
    }

    val job2 = async {
        delay(200)
        throw RuntimeException("❌ job2 실패!")
    }

    try {
        // job2 예외가 여기서 throw 됨
        val result1 = job1.await()
        val result2 = job2.await()
        println("결과: $result1, $result2")
    } catch (e: Exception) {
        println("예외 발생: ${e.message}")
    }
}

위와 같이 병렬로 두 가지 작업을 진행하고 있는 상황에서 동시에 실행 된 다른 작업 하나가 완료되거나 진행중이라면 취소가 되지 않습니다. 이런 경우엔 예외가 발생한 시점에 다른 작업을 수동으로 취소하지 않는다면 메모리 누수가 발생할 수 있습니다.

suspend fun serve() = supervisorScope {
    launch {
        throw RuntimeException("☠️ 서브 작업 실패")
    }

    launch {
        delay(1000)
        println("✅ 다른 작업 정상 진행")
    }
}

supervisorScope 에서는 여러개의 자식이 있을 수 있는데, 자식 하나의 실패가 다른 자식에게 전파되지 않습니다. 상황에 따라서 serve() 함수 전체에 대해서는 실패가 누락되는 케이스가 발생할 수 있죠. 이 경우엔 수동으로 예외를 캐치하거나 propagate 해야 합니다.

fun main() = runBlocking {
    launch {
        throw RuntimeException("🔥 이 예외는 잡히지 않아요!")
    }

    delay(1000)
    println("✅ 여전히 살아있음")
}

마지막으로 launch 내에서 발생한 예외는 자동으로 예외가 전파되지 않습니다. 자식이 죽어도 부모는 영향을 받지 않죠. 예외를 처리하고 싶다면 CoroutineExceptionHandler 를 따로 설정해야 합니다.

그 외 고려해야 할 점

또한 suspend 함수 호출마다 만들어지는 Continuation 객체와 상태 분기 로직이 오버헤드를 미약하게 추가할 수 있습니다. 한두개라면 상관 없지만 수백만 개 이상의 코루틴을 띄우게 되면 GC에 부담이 갈 수 있죠.

다른 기능들에 비해 러닝 커브가 상당히 센 편에 속하기도 합니다. 스택리스 라는 동작 방식을 이해하는 데 시간이 걸릴 수 있죠. 그리고 코루틴에 대한 이야기를 잠시 언급하기만 했는데도 꽤나 많은 내용들이 나왔죠? 코루틴 하나만 가지고도 글이 길게 나올 정도로 알아야 할 게 많은 편입니다.

😊 코루틴에 대한 이야기는 여기까지 하겠습니다. 딥다이브 시리즈에서 또 다뤄야 하니까요. 코루틴 주제로만 아마 지금 글 만큼 분량이 나올겁니다 하하...

🎨 5. 조금 더 들어가기 : 프론트엔드 엔지니어링에서의 동기, 비동기 그리고 블로킹, 논 블로킹

지금까지 백엔드에서의 동기, 비동기 그리고 블로킹, 논 블로킹에 대해 다뤄 보았습니다.

그럼 프론트엔드에서는 이 개념들이 어떻게 사용되고 있을까요?

프론트엔드는 단일 스레드 UI 스레드 위에서 동작하기 때문에, 이 네 가지 조합이 곧바로 사용자 경험(UX)으로 직결됩니다. React 같은 라이브러리를 사용하는 이유도 결국 비동기·논블로킹 흐름을 안전하고 효율적으로 관리하기 위함임을 이해하시면 도움이 됩니다.

⛔️ 5.1 동기 + 블로킹

크게 두 가지 상황이 있을 수 있습니다.

  • 직접 DOM 을 수작업 하는 경우

    // 동기 XMLHttpRequest → 네트워크 대기 중 UI 완전 멈춤
    const xhr = new XMLHttpRequest()
    xhr.open('GET', '/api/data', false)
    xhr.send()
    document.getElementById('list').innerHTML = render(xhr.response)
  • 무거운 연산을 바로 수행하는 경우

    // 배열이 수십만 건일 때
    const result = largeArray.map(fancyCompute)
    document.body.textContent = result.join(',')

이런 상황에서는 요청, 연산 도중에 화면의 갱신이나 입력 처리 등이 모두 정지해버릴 수 있습니다. 이 경우 브라우저에는 응답없음 화면이 출력될 수 있죠.

🛠️ 5.2 동기 + 논 블로킹

function Counter() {
  const [count, setCount] = useState(0);
  // 버튼 클릭 시 바로 렌더링
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

React 등에서 단순 상태변화의 경우 이런 케이스에 속합니다. 16ms 즉 프레임 단위 안에 연산이 끝나면 상관은 없지만, 연산량이 갑자기 커지면 문제가 될 수 있죠. 이 경우는 Web Worker 도입을 고려해야 합니다.

🔌 5.3 비동기 + 블로킹

useEffect(() => {
  fetchData().then((data) => {
    // 상태 업데이트 전, 복잡 계산으로 렌더링 블로킹
    const processed = expensiveTransform(data)
    setList(processed)
  })
}, [])

useEffect 와 같이 상태에 따라 DOM 을 리로드 시키는 등 UI 에 영향을 끼칠 수 있는 Hook 과 fetcher 를 함께 쓸 때 반드시 고려해야 하는 상황입니다. 만약 패칭이 오래걸리거나, 패칭 결과에 대한 연산이 오래 걸린다면 프레임 드랍이 생길 수 있습니다.

🌟 5.4 비동기 + 논 블로킹

fetch('/api/large')
  .then((r) => r.json())
  .then((data) => {
    const worker = new Worker('worker.js')
    worker.postMessage(data)
    worker.onmessage = (e) => renderList(e.data)
  })
const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();

useEffect(() => {
  fetchData().then(data => {
    startTransition(() => {
      setList(expensiveTransform(data));
    });
  });
}, []);

return isPending
  ? <Spinner />
  : <ItemList items={list} />;

만약 패칭이 오래 걸리거나 연산이 오래 걸리는 경우 Web Worker 를 사용하거나 useTransition 을 사용해서 백그라운드로 처리 프로세스를 분리하는 것을 고려할 수 있습니다. 이 경우 네트워크 I/O 와 CPU 연산 모두 메인스레드에서 분산 처리되고 UX 가 좀 더 부드러워지는 효과가 있습니다.

🏁 6. Outro

지금까지 동기, 비동기 그리고 블로킹, 논 블로킹이라는 주제로 다양한 케이스를 살펴 보았습니다. 소프트웨어 엔지니어링에서 분야와 상관없이 반드시 한번 쯤은 부딪치고 고민할 법 한 주제를 딥다이브 해 보았습니다.

지금까지의 장황한 설명을 요약하면 아래와 같이 정리될 것 같습니다.

패턴장점단점언제 쓰나
동기 + 블로킹구현 단순, 직관적스레드 낭비, 확장성 낮음외부 API 연쇄 호출, 트랜잭션 등
동기 + 논블로킹스레드 절약, 부분 최적화거의 블로킹과 차이 작음동기 흐름 유지하며 I/O 최적화
비동기 + 블로킹점진적 마이그레이션 용이스레드풀 누수, 예외 추적 어려움레거시 호출 섞어야 할 때
비동기 + 논블로킹최고 성능, 적은 리소스로 다수 처리콜백 지옥·디버깅·호환성 문제트래픽 많은 서비스, SSE, WS 등

글이 처음이 생각했던 것에 비해 많이 길어졌습니다. 딥다이브라는 주제다보니 평소보다 훨씬 길어졌네요...다음부터는 조금 더 작은 주제로 글을 깊게 써 볼까 합니다.

도움이 되셨다면 다행이고 혹시 더 설명이 필요하시거나 피드백 하실 부분이 있다면 언제든지 요청 주세요!

📚 7. Reference