개요
네트워크 서버를 개발할 때 가장 먼저 고민하게 되는 것은 "수많은 연결(Connection)을 어떻게 효율적으로 관리할 것인가?"이다. 이번에 진행한 HTTP 서버 프로젝트 또한 복잡한 계산보다는 데이터를 읽고 쓰는 I/O 작업 위주인 전형적인 I/O-bound 서버였다.
- Connection 단위로 상태를 가진다. { keep-alive, idle timeout, 여러 request 처리 }
- I/O가 대부분이다. { accept(), read(), write() }
- 요청 처리 시간이 짧고 예측 가능하다.
- 동시에 여러 connection을 다룬다.
- CPU-bound 작업은 거의 없다.
본 게시글에서는 전형적인 Thread-per-connection 모델의 한계를 살펴보고, Kotlin의 Coroutine이 어떻게 이 문제를 해결하는지 정리하였다.
1. 기존 Thread-per-connection 모델의 한계
가장 직관적인 방법은 클라이언트가 연결될 때마다 새로운 thread를 생성하는 것이다.
class TcpHttpServer {
fun start() {
while (true) {
val socket = serverSocket.accept()
Thread {
handleConnection(socket)
}.start()
}
}
}
이 방식의 문제점:
- 자원 낭비 : 연결 1개당 1개의 OS thread를 점유하며, idle connection 상태에서는 휴지 상태로 메모리만 낭비한다.
- Context Switching 비용 : 연결이 수천 개로 늘어나면 CPU는 context-switching에 더 많은 시간이 소요된다.
- 메모리 압박 : Thread는 생성 시 독립적인 stack 메모리를 할당받으므로, 동시 접속자가 많아질 수록 메모리 사용량이 급증한다.
2. 해결책 : Coroutine
class TcpHttpServer() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope.launch {
handleConnection(socket)
}
}
Coroutine은 Thread 위에서 실행되는 "가벼운 실행 단위"다.
Thread와 달리 OS가 아닌 라이브러리 레벨에서 관리되며, 아래와 같은 특징이 있다.
- 비차단 (Non-blocking) : Coroutine은 I/O 대기 시간 (데이터가 들어오길 기다리는 시간) 동안 thread를 반환한다.
- 저비용 : 별도의 stack을 갖지 않아 메모리 사용량이 매우 적다.
3. Coroutine 서버의 안전 장치 : Dispatcher와 SupervisorJob
I/O-bound 서버의 안정성을 위해 coroutine을 사용할 때는 두 가지 핵심 설정이 필요하다.
3-1. Dispacher.IO: "적재적소에 배치하기"
Dispatcher.IO는 네트워크 / 파일 같은 I/O 작업에 최적화된 thread pool이다.
CPU 연산용인 Default와 분리함으로써, 무거운 I/O 작업 때문에 서버의 메인 로직이 멈추는 것을 방지한다.
| Dispatcher | 용도 |
| Dispatchers.Default | CPU 연산 (계산, 로직) |
| Dispatchers.IO | 네트워크, 파일, DB |
| Dispatchers.Main | UI (Android) |
3-2. SupervisorJob() -- "실패 격리"
기본적으로 coroutine은 부모-자식 관계를 가진다.
기본 Job에서는 자식 하나가 실패하면 부모가 취소되고, 그 아래 모든 자식도 함께 취소된다.
부모 coroutine
├─ 자식 A (실패)
├─ 자식 B
└─ 자식 C
이는 서버에 치명적이다.
SupervisorJob은 다르다.
SupervisorJob()은 자식 coroutine의 실패를 부모에게 전파하지 않는다.
부모 (SupervisorJob)
├─ 자식 A (실패)
├─ 자식 B (정상)
└─ 자식 C (정상)
3-3. Dispatcher.IO + SupervisorJob()
CoroutineScope(Dispatchers.IO + SupervisorJob())
- 이 scope에서 실행되는 모든 coroutine은 I/O 전용 thread pool에서 실행되고
- 각 connection coroutine의 실패는 서버 전체에 영향을 주지 않는다.
즉, 이 조합은 네트워크 서버에서 '정상적인 실패'를 허용하기 위한 기본 설정이다.
| 요구사항 | Dispatcher.IO | SupervisorJob |
| 네트워크 I/O 최적화 | O | |
| idle connection 처리 | O | |
| 클라이언트 오류 격리 | O | |
| 서버 전체 안정성 | O |
4. HTTP/1.1 프로젝트에 coroutine이 적합했던 이유
4-1. keep-alive + idle timeout 처리의 효율성
while (true) {
val request = RequestParser.parse(input) ?: break
val response = router.route(request)
val keepAlive = shouldKeepAlive(request)
ResponseWriter.write(output, response)
if (!keepAlive) break
}
HTTP/1.1의 keep-alive는 연결을 유지하며 여러 요청을 처리한다.
- 기존 Thread 모델 : 요청이 없는 idle time 동안 thread는 아무 일도 없이 대기한다.
- 현재 Coroutine 모델 : 기다리는 동안 thread를 반환한다. 수만 개의 idle time이 있어도 서버 자원은 거의 소모되지 않는다.
4-2. 직관적인 예외 처리
Coroutine 내부에서 발생하는 예외는 해당 scope 안에서 자연스럽게 처리된다.
try {
handleConnection(socket)
} catch (e: Exception) {
// 해당 연결만 조용히 종료, 사용하던 Thread는 즉시 Pool로 반환
}
별도의 복잡한 Cleanup 로직이나 Interrupt 관리 없이도 안전하게 자원을 회수할 수 있다.
5. 요약 및 결론
| 구분 | Thread-per-connection | Coroutine 기반 관리 |
| 자원 효율 | 낮음 (1 연결 = 1 Thread) | 높은 (1 Thread = N Coroutine) |
| 확장성 | 수천 개 연결에서 한계 발생 | 수만 개 이상의 연결 처리 가능 |
| 안정성 | 에러 전파 관리 어려움 | SupervisorJob으로 에러 격리 용이 |
| 코드 가독성 | 복잡한 상태 관리 필요 | 동기 코드처럼 직관적임 |
결론적으로, 일반적인 HTTP 서버처럼 I/O가 많고 연결 지향적인 구조에서는 Coroutine이 정답이다.
적은 자원으로도 keep-alive를 안정적으로 지원하며, 유지보수가 쉬운 코드를 작성할 수 있기 때문이다.
(단, 수십만 개의 초고성능 동시 접속이나 0.001초를 다투는 low-level 처리가 필요하다면 Netty 같은 Event-loop 모델이 더 적합할 수 있다.)
Powered By. ChatGPT
'Backend' 카테고리의 다른 글
| [Backend] 10주차 내용 정리 (0) | 2026.01.22 |
|---|---|
| [Backend] Kotlin 기반 HTTP 서버 구현 (상세) (0) | 2026.01.16 |
| [Backend] 고성능 시스템에서 전송 속도를 통제하는 방법 (0) | 2025.12.31 |
| [Backend] BitTorrent Protocol : 거대 파일을 조각 내어 공유·분산하기 (1) | 2025.12.29 |
| [Backend] TCP 스트림 기반 파일 전송 기능 구현 - CHAT/FILE multiplexing, 단일 Writer, backpressure 구현 기록 (1) | 2025.12.19 |