Kim-Baek 개발자 이야기

I/O - 블로킹, 논블로킹, 동기, 비동기의 진실 본문

컴퓨터 공학/운영체제

I/O - 블로킹, 논블로킹, 동기, 비동기의 진실

김백개발자 2026. 1. 8. 23:51
반응형

서버가 80%의 시간을 놀고 있었다

Court Alarm API 서버의 모니터링 대시보드를 보고 있었다. 이상한 점이 있었다.

CPU 사용률: 20%
메모리 사용률: 40%
응답 시간: 평균 2초
동시 접속자: 500명

리소스는 충분한데 응답이 느렸다. 뭔가 이상했다.

스레드 덤프를 떠서 분석했다.

jstack <pid> | grep "WAITING\|BLOCKED" | wc -l
# 결과: 190 (전체 200 스레드 중)

190개 스레드가 대기 중이었다! 무엇을 기다리고 있을까?

"http-nio-8080-exec-42" #42 waiting on condition
  at java.net.SocketInputStream.socketRead0(Native Method)
  at java.net.SocketInputStream.read()
  at ExternalApiClient.call()

"http-nio-8080-exec-43" #43 waiting on condition
  at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
  at DatabaseConnection.query()

외부 API 호출, DB 쿼리를 기다리며 블로킹되어 있었다. 스레드들은 80%의 시간을 그냥 기다리기만 했다.

이 문제를 해결하면서 블로킹/논블로킹, 동기/비동기의 차이를 제대로 이해하게 됐다. 그리고 5년 차에게도 이 개념들이 얼마나 헷갈리는지 깨달았다.

블로킹 vs 논블로킹

블로킹 I/O

호출한 함수가 완료될 때까지 제어권을 반환하지 않음:

// 블로킹 소켓 읽기
val socket = Socket("example.com", 80)
val input = socket.getInputStream()

println("읽기 시작")
val data = input.read()  // 여기서 멈춤! 데이터 올 때까지 대기
println("읽기 완료: $data")

실행 흐름:

시간 →

[애플리케이션]  read() 호출 ──→ 대기중... ──→ 데이터 도착 ──→ 반환
                              (제어권 없음)
                              
[커널]          데이터 기다림 ──→ 데이터 수신 ──→ 버퍼 복사
                              
                0ms           500ms          501ms

특징:

  • 함수가 반환될 때까지 아무것도 못 함
  • 구현 간단
  • 스레드 낭비

논블로킹 I/O

호출한 함수가 즉시 반환:

// 논블로킹 소켓 설정
val channel = SocketChannel.open()
channel.configureBlocking(false)  // 논블로킹 모드
channel.connect(InetSocketAddress("example.com", 80))

println("읽기 시도")
val buffer = ByteBuffer.allocate(1024)
val bytesRead = channel.read(buffer)  // 즉시 반환!

when {
    bytesRead > 0 -> println("데이터 읽음: $bytesRead 바이트")
    bytesRead == 0 -> println("데이터 없음")
    bytesRead == -1 -> println("연결 종료")
}

실행 흐름:

시간 →

[애플리케이션]  read() 호출 ──→ 즉시 반환 (0) ──→ 다른 작업 ──→ read() 재시도
                              (제어권 유지)
                              
[커널]          데이터 없음 ──────────────────→ 데이터 수신
                              
                0ms           1ms            500ms

특징:

  • 즉시 반환 (데이터 없으면 0 또는 -1)
  • 다른 작업 가능
  • 반복 확인 필요 (폴링)

비교 예시

블로킹 I/O로 3개 소켓 처리:

// ❌ 블로킹: 순차 처리 (느림)
fun blockingRead() {
    val socket1 = Socket("api1.com", 80)
    val socket2 = Socket("api2.com", 80)
    val socket3 = Socket("api3.com", 80)
    
    val start = System.currentTimeMillis()
    
    // 각각 500ms 걸림
    val data1 = socket1.getInputStream().read()  // 0~500ms 대기
    val data2 = socket2.getInputStream().read()  // 500~1000ms 대기
    val data3 = socket3.getInputStream().read()  // 1000~1500ms 대기
    
    val duration = System.currentTimeMillis() - start
    println("총 시간: ${duration}ms")  // 약 1500ms
}

논블로킹 I/O로 3개 소켓 처리:

// ✅ 논블로킹: 병렬 처리 (빠름)
fun nonBlockingRead() {
    val channel1 = SocketChannel.open().apply { configureBlocking(false) }
    val channel2 = SocketChannel.open().apply { configureBlocking(false) }
    val channel3 = SocketChannel.open().apply { configureBlocking(false) }
    
    channel1.connect(InetSocketAddress("api1.com", 80))
    channel2.connect(InetSocketAddress("api2.com", 80))
    channel3.connect(InetSocketAddress("api3.com", 80))
    
    val buffer = ByteBuffer.allocate(1024)
    val start = System.currentTimeMillis()
    
    var data1: Int? = null
    var data2: Int? = null
    var data3: Int? = null
    
    // 3개를 번갈아 확인
    while (data1 == null || data2 == null || data3 == null) {
        if (data1 == null) {
            buffer.clear()
            val read = channel1.read(buffer)
            if (read > 0) data1 = read
        }
        
        if (data2 == null) {
            buffer.clear()
            val read = channel2.read(buffer)
            if (read > 0) data2 = read
        }
        
        if (data3 == null) {
            buffer.clear()
            val read = channel3.read(buffer)
            if (read > 0) data3 = read
        }
        
        Thread.sleep(10)  // CPU 100% 방지
    }
    
    val duration = System.currentTimeMillis() - start
    println("총 시간: ${duration}ms")  // 약 500ms (3배 빠름!)
}

결과:

  • 블로킹: 1500ms (순차)
  • 논블로킹: 500ms (병렬)

동기 vs 비동기

동기 (Synchronous)

호출한 함수의 결과를 호출자가 직접 확인:

// 동기 방식
fun syncRequest(): String {
    println("[1] 요청 시작")
    val result = apiClient.call()  // 결과가 올 때까지 대기
    println("[2] 응답 받음: $result")
    return result
}

fun main() {
    val result = syncRequest()
    println("[3] 결과 사용: $result")
}

실행 순서:

[1] 요청 시작
   ... 대기 ...
[2] 응답 받음: data
[3] 결과 사용: data

비동기 (Asynchronous)

호출한 함수의 결과를 콜백으로 받음:

// 비동기 방식
fun asyncRequest(callback: (String) -> Unit) {
    println("[1] 요청 시작")
    apiClient.callAsync { result ->
        println("[2] 응답 받음: $result")
        callback(result)
    }
    println("[3] 요청 후 즉시 반환")
}

fun main() {
    asyncRequest { result ->
        println("[4] 결과 사용: $result")
    }
    println("[5] 다른 작업")
}

실행 순서:

[1] 요청 시작
[3] 요청 후 즉시 반환
[5] 다른 작업
   ... 나중에 ...
[2] 응답 받음: data
[4] 결과 사용: data

차이점:

  • 동기: 결과를 직접 받음 (return)
  • 비동기: 결과를 콜백으로 받음 (callback)

4가지 조합

1. 동기 + 블로킹 (가장 흔함)

결과를 직접 받음 + 대기함:

// 동기 블로킹
fun syncBlocking(): String {
    val socket = Socket("api.com", 80)
    val input = socket.getInputStream()
    
    println("[1] 읽기 시작")
    val data = input.read()  // 블로킹: 데이터 올 때까지 대기
    println("[2] 읽기 완료")
    
    return data.toString()  // 동기: 결과 직접 반환
}

특징:

  • 구현 간단
  • 이해하기 쉬움
  • 성능 나쁨 (스레드 낭비)

사용:

  • 단순한 클라이언트
  • 동시성 낮은 서버

2. 동기 + 논블로킹

결과를 직접 받음 + 즉시 반환:

// 동기 논블로킹
fun syncNonBlocking(): String {
    val channel = SocketChannel.open()
    channel.configureBlocking(false)
    channel.connect(InetSocketAddress("api.com", 80))
    
    val buffer = ByteBuffer.allocate(1024)
    
    println("[1] 읽기 시작")
    
    // 논블로킹: 즉시 반환되지만
    // 동기: 결과가 올 때까지 반복 확인 (폴링)
    while (true) {
        val bytesRead = channel.read(buffer)
        
        if (bytesRead > 0) {
            println("[2] 읽기 완료")
            buffer.flip()
            return Charsets.UTF_8.decode(buffer).toString()
        } else if (bytesRead == -1) {
            throw IOException("연결 종료")
        }
        
        // 데이터 없으면 잠시 대기 후 재시도
        Thread.sleep(10)
    }
}

특징:

  • 즉시 반환하지만 계속 확인 (Busy Waiting)
  • CPU 낭비
  • 잘 안 씀

3. 비동기 + 블로킹 (거의 안 씀)

결과를 콜백으로 받음 + 대기함:

// 비동기 블로킹 (이상한 조합)
fun asyncBlocking(callback: (String) -> Unit) {
    thread {
        val socket = Socket("api.com", 80)
        val input = socket.getInputStream()
        
        println("[1] 읽기 시작 (스레드: ${Thread.currentThread().name})")
        val data = input.read()  // 블로킹: 대기
        println("[2] 읽기 완료")
        
        callback(data.toString())  // 비동기: 콜백 호출
    }
    
    println("[3] 함수 반환 (메인 스레드)")
}

특징:

  • 별도 스레드에서 블로킹
  • 복잡함
  • 거의 사용 안 함

4. 비동기 + 논블로킹 (최고 성능)

결과를 콜백으로 받음 + 즉시 반환:

// 비동기 논블로킹
fun asyncNonBlocking(callback: (String) -> Unit) {
    val channel = SocketChannel.open()
    channel.configureBlocking(false)
    channel.connect(InetSocketAddress("api.com", 80))
    
    val selector = Selector.open()
    channel.register(selector, SelectionKey.OP_READ)
    
    println("[1] 읽기 등록")
    
    // 별도 스레드에서 이벤트 감시
    thread {
        selector.select()  // 이벤트 대기 (블로킹이지만 여러 채널 감시)
        
        val keys = selector.selectedKeys()
        keys.forEach { key ->
            if (key.isReadable) {
                val ch = key.channel() as SocketChannel
                val buffer = ByteBuffer.allocate(1024)
                
                val bytesRead = ch.read(buffer)  // 논블로킹
                
                if (bytesRead > 0) {
                    println("[2] 데이터 수신")
                    buffer.flip()
                    val data = Charsets.UTF_8.decode(buffer).toString()
                    callback(data)  // 비동기: 콜백
                }
            }
        }
    }
    
    println("[3] 함수 즉시 반환")
}

// 사용
fun main() {
    asyncNonBlocking { result ->
        println("[4] 결과: $result")
    }
    
    println("[5] 다른 작업")
    Thread.sleep(2000)  // 콜백 기다림
}

실행 순서:

[1] 읽기 등록
[3] 함수 즉시 반환
[5] 다른 작업
   ... 나중에 ...
[2] 데이터 수신
[4] 결과: data

특징:

  • 최고 성능 (스레드 적게, CPU 효율적)
  • 복잡함
  • 고성능 서버 필수

사용:

  • Node.js
  • Netty
  • Spring WebFlux

실전 비교: Court Alarm API 최적화

초기: 동기 블로킹

// ❌ 동기 블로킹 방식
@RestController
class CourtController(
    private val weatherApi: WeatherApi,
    private val courtRepository: CourtRepository
) {
    
    @GetMapping("/courts/{id}")
    fun getCourt(@PathVariable id: Long): CourtDto {
        // 1. DB 조회 (블로킹, 50ms)
        val court = courtRepository.findById(id)
        
        // 2. 날씨 API 호출 (블로킹, 500ms)
        val weather = weatherApi.getWeather(court.location)
        
        // 3. 응답 생성 (10ms)
        return CourtDto(
            id = court.id,
            name = court.name,
            weather = weather
        )
    }
}

성능:

총 시간: 50ms + 500ms + 10ms = 560ms
스레드: 560ms 동안 점유
TPS: 200개 스레드 / 0.56초 = 357 TPS

개선 1: 비동기 블로킹

// ✅ 비동기 블로킹 (CompletableFuture)
@RestController
class CourtController(
    private val weatherApi: AsyncWeatherApi,
    private val courtRepository: CourtRepository,
    private val executor: Executor
) {
    
    @GetMapping("/courts/{id}")
    fun getCourt(@PathVariable id: Long): DeferredResult<CourtDto> {
        val result = DeferredResult<CourtDto>(3000L)
        
        // 비동기로 처리
        CompletableFuture.supplyAsync({
            // 1. DB 조회 (블로킹, 50ms)
            courtRepository.findById(id)
        }, executor).thenCompose { court ->
            // 2. 날씨 API 호출 (비동기, 500ms)
            weatherApi.getWeatherAsync(court.location)
                .thenApply { weather ->
                    // 3. 응답 생성
                    CourtDto(
                        id = court.id,
                        name = court.name,
                        weather = weather
                    )
                }
        }.whenComplete { dto, error ->
            if (error != null) {
                result.setErrorResult(error)
            } else {
                result.setResult(dto)
            }
        }
        
        return result
    }
}

성능:

총 시간: 560ms (동일)
스레드 점유: 50ms + 10ms = 60ms (날씨 API 대기 중 해제)
TPS: 200개 스레드 / 0.06초 = 3,333 TPS (9배 증가!)

개선 2: 비동기 논블로킹 (WebFlux)

// ✅ 비동기 논블로킹 (WebFlux)
@RestController
class ReactiveCourtController(
    private val weatherApi: ReactiveWeatherApi,
    private val courtRepository: ReactiveCourtRepository
) {
    
    @GetMapping("/courts/{id}")
    fun getCourt(@PathVariable id: Long): Mono<CourtDto> {
        return courtRepository.findById(id)  // 논블로킹 DB
            .flatMap { court ->
                weatherApi.getWeather(court.location)  // 논블로킹 API
                    .map { weather ->
                        CourtDto(
                            id = court.id,
                            name = court.name,
                            weather = weather
                        )
                    }
            }
            .timeout(Duration.ofSeconds(3))
    }
}

WebFlux 설정:

@Configuration
class WebFluxConfig {
    
    @Bean
    fun webClient(): WebClient {
        return WebClient.builder()
            .baseUrl("https://api.weather.com")
            .build()
    }
}

@Service
class ReactiveWeatherApi(private val webClient: WebClient) {
    
    fun getWeather(location: String): Mono {
        return webClient.get()
            .uri("/forecast?location=$location")
            .retrieve()
            .bodyToMono(WeatherResponse::class.java)
            .map { response ->
                Weather(
                    temperature = response.temp,
                    condition = response.condition
                )
            }
    }
}

성능:

총 시간: 560ms (동일)
스레드 수: 8개 (이벤트 루프)
스레드 점유: 거의 0 (전부 논블로킹)
TPS: 거의 무제한 (연결 수에만 의존)
실제 측정: 12,000 TPS (33배 증가!)

성능 비교 요약

방식 스레드 수 스레드 점유 TPS 특징

동기 블로킹 200 560ms 357 간단, 느림
비동기 블로킹 200 60ms 3,333 중간 복잡도
비동기 논블로킹 8 ~0ms 12,000 복잡, 빠름

I/O 멀티플렉싱 깊이 파기

select()

// C 코드 (개념 이해용)
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(socket1, &readfds);
FD_SET(socket2, &readfds);
FD_SET(socket3, &readfds);

// 하나라도 준비될 때까지 블로킹
int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);

// 어느 소켓이 준비됐는지 확인
if (FD_ISSET(socket1, &readfds)) {
    // socket1 읽기
}
if (FD_ISSET(socket2, &readfds)) {
    // socket2 읽기
}

동작:

애플리케이션: select() 호출
              ↓
커널:         3개 소켓 모두 확인
              대기...
              socket2에 데이터 도착!
              ↓
애플리케이션: select() 반환 (ready = 1)
              FD_ISSET로 socket2 확인
              socket2.read()

장점:

  • 하나의 스레드로 여러 소켓
  • 데이터 없으면 블로킹 (CPU 안 씀)

단점:

  • 최대 1,024개 제한
  • 매번 전체 소켓 확인 O(n)
  • fd_set 복사 오버헤드

epoll() (Linux)

// epoll 생성
int epoll_fd = epoll_create1(0);

// 소켓 등록
struct epoll_event ev;
ev.events = EPOLLIN;  // 읽기 이벤트
ev.data.fd = socket1;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket1, &ev);

// 이벤트 대기
struct epoll_event events[10];
int ready = epoll_wait(epoll_fd, events, 10, -1);

// 준비된 소켓만 반환됨!
for (int i = 0; i < ready; i++) {
    int fd = events[i].data.fd;
    // fd 처리
}

동작:

애플리케이션: epoll_ctl로 소켓 등록
커널:         관심 목록에 추가
              
애플리케이션: epoll_wait() 호출
커널:         이벤트 대기...
              socket2에 데이터!
              준비된 소켓만 반환 ← O(1)!
              
애플리케이션: events[0].data.fd = socket2
              socket2.read()

장점:

  • 개수 제한 없음
  • O(1) 성능 (준비된 것만 반환)
  • 복사 없음

단점:

  • Linux 전용

사용:

  • Nginx
  • Redis
  • Netty

Java NIO Selector

// Java의 epoll 래퍼
val selector = Selector.open()

// 소켓 등록
val channel = SocketChannel.open()
channel.configureBlocking(false)
channel.register(selector, SelectionKey.OP_READ)

while (true) {
    // 이벤트 대기 (epoll_wait)
    val ready = selector.select()
    
    if (ready == 0) continue
    
    // 준비된 채널만 처리
    val keys = selector.selectedKeys()
    val iterator = keys.iterator()
    
    while (iterator.hasNext()) {
        val key = iterator.next()
        iterator.remove()
        
        if (key.isReadable) {
            val channel = key.channel() as SocketChannel
            val buffer = ByteBuffer.allocate(1024)
            channel.read(buffer)
            // 처리...
        }
    }
}

내부 동작:

  • Linux: epoll 사용
  • Windows: IOCP 사용
  • macOS: kqueue 사용

Reactor 패턴

기본 구조

단일 스레드로 여러 이벤트 처리:

         ┌──────────────────┐
         │   Dispatcher     │
         │   (Selector)     │
         └────────┬─────────┘
                  │
         이벤트 루프
         while (true) {
           select()
           dispatch()
         }
                  │
    ┌─────────────┼─────────────┐
    │             │             │
┌───▼───┐   ┌───▼───┐   ┌───▼───┐
│Handler│   │Handler│   │Handler│
│   1   │   │   2   │   │   3   │
└───────┘   └───────┘   └───────┘

구현

class Reactor(private val port: Int) {
    
    private val selector = Selector.open()
    private val serverChannel: ServerSocketChannel
    
    init {
        serverChannel = ServerSocketChannel.open()
        serverChannel.bind(InetSocketAddress(port))
        serverChannel.configureBlocking(false)
        
        // ACCEPT 이벤트 등록
        serverChannel.register(selector, SelectionKey.OP_ACCEPT)
    }
    
    fun run() {
        println("Reactor 시작: 포트 $port")
        
        // 이벤트 루프
        while (true) {
            // 이벤트 대기
            selector.select()
            
            // 준비된 이벤트 처리
            val keys = selector.selectedKeys()
            val iterator = keys.iterator()
            
            while (iterator.hasNext()) {
                val key = iterator.next()
                iterator.remove()
                
                try {
                    dispatch(key)
                } catch (e: Exception) {
                    key.cancel()
                    key.channel().close()
                }
            }
        }
    }
    
    private fun dispatch(key: SelectionKey) {
        when {
            key.isAcceptable -> handleAccept(key)
            key.isReadable -> handleRead(key)
            key.isWritable -> handleWrite(key)
        }
    }
    
    private fun handleAccept(key: SelectionKey) {
        val server = key.channel() as ServerSocketChannel
        val client = server.accept()
        
        client.configureBlocking(false)
        client.register(selector, SelectionKey.OP_READ)
        
        println("새 연결: ${client.remoteAddress}")
    }
    
    private fun handleRead(key: SelectionKey) {
        val client = key.channel() as SocketChannel
        val buffer = ByteBuffer.allocate(1024)
        
        val bytesRead = client.read(buffer)
        
        if (bytesRead > 0) {
            buffer.flip()
            
            // Echo 응답 준비
            key.attach(buffer)
            key.interestOps(SelectionKey.OP_WRITE)
            
        } else if (bytesRead == -1) {
            client.close()
            println("연결 종료: ${client.remoteAddress}")
        }
    }
    
    private fun handleWrite(key: SelectionKey) {
        val client = key.channel() as SocketChannel
        val buffer = key.attachment() as ByteBuffer
        
        client.write(buffer)
        
        if (!buffer.hasRemaining()) {
            // 전송 완료
            key.interestOps(SelectionKey.OP_READ)
        }
    }
}

// 실행
fun main() {
    val reactor = Reactor(8080)
    reactor.run()
}

특징:

  • 단일 스레드
  • 10,000+ 동시 연결
  • CPU 코어 하나만 사용

Multi-Reactor 패턴

여러 Reactor 스레드 사용:

class MultiReactor(
    private val port: Int,
    private val workerCount: Int = Runtime.getRuntime().availableProcessors()
) {
    
    private val acceptor = Selector.open()
    private val workers = Array(workerCount) { Selector.open() }
    private var nextWorker = 0
    
    fun run() {
        // Accept 전용 스레드
        thread(name = "Acceptor") {
            runAcceptor()
        }
        
        // Worker 스레드들
        workers.forEachIndexed { index, selector ->
            thread(name = "Worker-$index") {
                runWorker(selector)
            }
        }
    }
    
    private fun runAcceptor() {
        val serverChannel = ServerSocketChannel.open()
        serverChannel.bind(InetSocketAddress(port))
        serverChannel.configureBlocking(false)
        serverChannel.register(acceptor, SelectionKey.OP_ACCEPT)
        
        println("Acceptor 시작")
        
        while (true) {
            acceptor.select()
            
            val keys = acceptor.selectedKeys()
            keys.forEach { key ->
                if (key.isAcceptable) {
                    val server = key.channel() as ServerSocketChannel
                    val client = server.accept()
                    
                    client.configureBlocking(false)
                    
                    // 라운드 로빈으로 Worker에 할당
                    val worker = workers[nextWorker]
                    nextWorker = (nextWorker + 1) % workerCount
                    
                    // Worker에 등록
                    worker.wakeup()
                    client.register(worker, SelectionKey.OP_READ)
                    
                    println("새 연결 → Worker-$nextWorker")
                }
            }
            keys.clear()
        }
    }
    
    private fun runWorker(selector: Selector) {
        println("Worker 시작: ${Thread.currentThread().name}")
        
        while (true) {
            selector.select()
            
            val keys = selector.selectedKeys()
            val iterator = keys.iterator()
            
            while (iterator.hasNext()) {
                val key = iterator.next()
                iterator.remove()
                
                try {
                    if (key.isReadable) {
                        handleRead(key)
                    } else if (key.isWritable) {
                        handleWrite(key)
                    }
                } catch (e: Exception) {
                    key.cancel()
                    key.channel().close()
                }
            }
        }
    }
    
    private fun handleRead(key: SelectionKey) {
        val client = key.channel() as SocketChannel
        val buffer = ByteBuffer.allocate(1024)
        
        val bytesRead = client.read(buffer)
        
        if (bytesRead > 0) {
            buffer.flip()
            key.attach(buffer)
            key.interestOps(SelectionKey.OP_WRITE)
        } else if (bytesRead == -1) {
            client.close()
        }
    }
    
    private fun handleWrite(key: SelectionKey) {
        val client = key.channel() as SocketChannel
        val buffer = key.attachment() as ByteBuffer
        
        client.write(buffer)
        
        if (!buffer.hasRemaining()) {
            key.interestOps(SelectionKey.OP_READ)
        }
    }
}

// 실행 (CPU 코어 수만큼 Worker)
fun main() {
    val reactor = MultiReactor(8080)
    reactor.run()
}

구조:

    ┌─────────────┐
    │  Acceptor   │ ← 새 연결 수락
    │  (Thread 1) │
    └──────┬──────┘
           │ 라운드 로빈
    ┌──────┴──────┬──────────┬──────────┐
    │             │          │          │
┌───▼────┐  ┌───▼────┐  ┌──▼─────┐  ┌─▼──────┐
│Worker 1│  │Worker 2│  │Worker 3│  │Worker 4│
│(Thread)│  │(Thread)│  │(Thread)│  │(Thread)│
└────────┘  └────────┘  └────────┘  └────────┘

성능:

  • CPU: 100% 활용 (코어 수만큼)
  • 연결: 10,000+ 동시 처리
  • TPS: 단일 Reactor의 4배 (4코어 기준)

사용:

  • Netty (Boss + Worker Group)
  • Nginx (Master + Worker Process)

실전: WebFlux vs MVC 벤치마크

테스트 환경

서버: AWS EC2 t3.medium (2 vCPU, 4GB RAM)
동시 사용자: 1,000명
요청: 코트 상세 조회 (외부 API 호출 포함)
외부 API 지연: 평균 500ms

Spring MVC (Tomcat)

// application.yml
server:
  tomcat:
    threads:
      max: 200
      min-spare: 10

// Controller
@RestController
class MvcCourtController(
    private val weatherApi: WeatherApi  // 블로킹
) {
    @GetMapping("/courts/{id}")
    fun getCourt(@PathVariable id: Long): CourtDto {
        val court = courtRepository.findById(id)
        val weather = weatherApi.getWeather(court.location)  // 블로킹 500ms
        return CourtDto(court, weather)
    }
}

결과:

처리량: 357 req/sec
평균 응답시간: 2.8초
최대 응답시간: 5.2초
CPU: 25%
메모리: 1.2GB
스레드: 200개

Spring WebFlux (Netty)

// application.yml
spring:
  webflux:
    base-path: /api

// Controller
@RestController
class WebFluxCourtController(
    private val weatherApi: ReactiveWeatherApi  // 논블로킹
) {
    @GetMapping("/courts/{id}")
    fun getCourt(@PathVariable id: Long): Mono<CourtDto> {
        return courtRepository.findById(id)
            .flatMap { court ->
                weatherApi.getWeather(court.location)  // 논블로킹 500ms
                    .map { weather -> CourtDto(court, weather) }
            }
    }
}

결과:

처리량: 1,847 req/sec (5.2배)
평균 응답시간: 0.54초 (5.2배 빠름)
최대 응답시간: 0.82초 (6.3배 빠름)
CPU: 68%
메모리: 850MB
스레드: 8개 (25배 적음)

분석

왜 WebFlux가 빠른가:

  1. 스레드 효율:
    • MVC: 200 스레드 × 560ms = 112 스레드·초
    • WebFlux: 8 스레드 × ~0ms = 거의 0
  2. 컨텍스트 스위칭:
    • MVC: 200 스레드 간 전환 (오버헤드)
    • WebFlux: 8 스레드만 (최소 오버헤드)
  3. 메모리:
    • MVC: 200 스레드 × 1MB Stack = 200MB
    • WebFlux: 8 스레드 × 1MB = 8MB

언제 WebFlux를 쓸까:

  • ✅ I/O-bound 작업 많음 (외부 API, DB)
  • ✅ 높은 동시 접속 필요
  • ✅ 마이크로서비스 (서비스 간 호출)

언제 MVC를 쓸까:

  • ✅ CPU-bound 작업 많음
  • ✅ 블로킹 라이브러리 많음 (JDBC 등)
  • ✅ 간단한 CRUD

면접에서 이렇게 답하자

1단계: 기본 개념 (30초)

"블로킹은 함수가 완료될 때까지 제어권을 반환하지 않고, 논블로킹은 즉시 반환합니다. 동기는 결과를 직접 받고, 비동기는 콜백으로 받습니다. 4가지 조합 중 비동기 논블로킹이 최고 성능입니다."

2단계: 차이점 명확히 (1분)

"블로킹/논블로킹은 제어권 반환 시점이고, 동기/비동기는 결과 전달 방식입니다. 예를 들어 동기 블로킹은 read()가 데이터 올 때까지 대기하고 결과를 return으로 받습니다. 비동기 논블로킹은 read() 등록만 하고 즉시 반환되며, 데이터가 오면 콜백으로 받습니다."

3단계: 실무 경험 (2분 30초)

"Court Alarm에서 동기 블로킹 방식으로 외부 API를 호출했을 때 200개 스레드로 357 TPS밖에 못 냈습니다. 각 요청이 500ms 동안 스레드를 점유했기 때문입니다."

"CompletableFuture로 비동기화해서 외부 API 대기 중에 스레드를 해제했더니 TPS가 3,333으로 9배 증가했습니다. 추가로 Spring WebFlux로 완전한 논블로킹 스택을 구축하니 8개 스레드로 12,000 TPS를 달성했습니다. 33배 개선이었습니다."

"벤치마크 결과 WebFlux는 MVC 대비 처리량 5.2배, 응답시간 5.2배 빠름, 스레드는 25배 적게 사용했습니다. 특히 I/O-bound 작업이 많은 마이크로서비스 환경에서 WebFlux의 장점이 극대화됐습니다."

4단계: 기술 상세 (1분)

"내부적으로 Linux epoll을 사용하는 Reactor 패턴입니다. Acceptor 스레드가 새 연결을 받고, Worker 스레드들이 I/O 이벤트를 처리합니다. epoll_wait()로 준비된 소켓만 O(1)로 가져오기 때문에 10,000개 연결도 효율적으로 처리합니다."

핵심 팁

명확한 용어 구분:

  • ❌ "비동기로 했어요"
  • ✅ "CompletableFuture로 비동기화하고, WebFlux로 논블로킹 I/O까지 적용"

구체적인 숫자:

  • ❌ "성능이 많이 좋아졌어요"
  • ✅ "TPS 357 → 12,000 (33배), 스레드 200 → 8 (25배 감소), 응답시간 2.8초 → 0.54초"

트레이드오프 언급:

  • 장점: 높은 처리량, 적은 자원
  • 단점: 복잡한 코드, 디버깅 어려움
  • 선택: I/O-bound 작업에 적합

정리

I/O 모델은 고성능 서버의 핵심이다.

핵심 개념:

블로킹 vs 논블로킹:

  • 블로킹: 완료까지 대기 (제어권 없음)
  • 논블로킹: 즉시 반환 (제어권 유지)

동기 vs 비동기:

  • 동기: 결과 직접 받음 (return)
  • 비동기: 결과 콜백으로 (callback)

4가지 조합:

  1. 동기 블로킹: 간단, 느림
  2. 동기 논블로킹: 폴링, CPU 낭비
  3. 비동기 블로킹: 거의 안 씀
  4. 비동기 논블로킹: 복잡, 최고 성능

I/O 멀티플렉싱:

  • select(): 최대 1,024, O(n)
  • poll(): 무제한, O(n)
  • epoll(): 무제한, O(1) ← 최고

Reactor 패턴:

  • 이벤트 루프로 여러 연결 처리
  • Multi-Reactor로 CPU 100% 활용

실무 선택:

상황 선택

I/O-bound, 높은 동시성 WebFlux (비동기 논블로킹)
CPU-bound MVC (동기 블로킹)
블로킹 라이브러리 많음 MVC + CompletableFuture
마이크로서비스 WebFlux

핵심 포인트:

  1. 블로킹은 스레드 낭비
  2. 논블로킹은 이벤트 기반
  3. 비동기는 콜백으로 결과
  4. epoll로 O(1) 성능
  5. Reactor로 CPU 활용 극대화
반응형
Comments