Thread 종류
Acceptor Thread
- 서버 소켓에서 새로운 클라이언트 연결 (TCP Connection)을 수락하는 역할
- ServerSocket.accept()를 호출하며 블로킹 대기하는 루프 구조
- 기본적으로 스레드 1개로 동작
Poller Thread
- Acceptor가 넘긴 소켓들을 Java NIO Selector를 통해 감시
- 소켓에 읽을 데이터가 준비되면 ready, 해당 소켓을 Worker Thread Pool에 작업으로 제출
- 기본적으로 1~2개 동작
Worker Thread
- 실제 HTTP 요청을 파싱하고 서블릿을 호출하며, 응답을 생성하는 Thread
HTTP request/response 흐름
전체적인 흐름은 그림과 같습니다.

전체적인 흐름을 글로 설명해 보겠습니다.
1) TCP Connection을 맺음
2) Acceptor Thread는 accept()를 blocking 호출한 채 대기하고 있다가, Accept Queue에 연결이 들어오면 소켓을 가져옴
3) 가져온 소켓을 NIO Selector에 등록 (여기까지 Acceptor Thread의 책임)
4) Poller Thread는 selector.select()를 호출하여 등록된 소켓 중 데이터가 도착한 소켓이 있는지 커널에 확인.
없으면 있을 때까지 blocking 대기
5) 데이터가 도착한 소켓이 감지되면, 해당 소켓을 ready 상태로 바꾸고 Worker Thread Pool의 Blocking Queue에 작업을 제출
6) Waiting 상태의 Worker Thread가 Blocking Queue에서 Task를 꺼내 처리
7) 응답을 생성했다면 write()를 호출해 OS 커널에 데이터를 전달하고, 커널이 Client에게 HTTP Response를 전송
8) Worker Thread는 작업이 끝나면 close()를 호출
9) close() 호출 후 OS가 TCP Connection을 종료합니다.
이 중에 조금 더 딥하게 학습하면 좋을 것 같은 부분들에 대해 설명을 해보려고 합니다.
2) Acceptor Thread는 accept()를 blocking 호출한 채 대기하고 있다가, Accept Queue에 연결이 들어오면 소켓을 가져옴
Acceptor Thread는 어떻게 accept()를 호출하는 걸까요? waiting 상태로 기다리다가 OS로부터 작업 신호를 받고 accept()를 호출하는걸까요? 아니면 몇 초마다 지속적으로 본인이 accept()를 호출하는걸까요?
Acceptor Thread의 동작 과정에 대해 조금 더 깊게 이해해 봅시다.

그림으로 보면 이 부분인데요, TCP Connection이 생성되면 Acceptor Thread는 accept()를 호출합니다. 이 부분에 대해 조금 자세히 알아보죠.

기본적으로 Acceptor Thread는 무한 루프에서 accept()를 반복 호출하는 구조로 동작합니다.
1. accept()를 호출하면 I/O Blocking 모드로 소켓이 반환되기를 기다린다
2. 소켓이 반환되면 NIO Selector에 등록한다. (Runnable)
3. 다시 accept()를 호출하고 I/O Blocking 모드로 소켓이 반환되기를 기다린다
즉, OS Kernel에 작업이 있냐고 계속 polling 하는 형식이 아니라, 연결이 들어올 때까지 blocking 상태로 대기하는 형식입니다.
(TMI)
구체적으로 이야기해 보자면, accept()를 호출했을 때의 Java 스레드 상태가 정확히 waiting은 아닙니다.
ServerSocketChannel.accept()는 blocking mode로 동작하는데, 이건 OS 레벨의 System call에서 Blocking 되는 거라 JVM 입장에서는 RUNNABLE로 표기됩니다. Java 레벨 동기화에서 대기할 때만 WAITING을 사용하고, OS 레벨의 native I/O Blocking은 Runnable로 표기가 되는 것이죠. (이해가 안 된다면 그냥 waiting이라고 생각해도 무관할 듯합니다. 해당 그림에서도 이해를 위해 waiting으로 작성했습니다.)
Kernel에서 구체적으로 무슨 작업이 일어나는지 함께 살펴봅시다.

두 가지 경우로 나뉘는데요, 해당 그림은 Accept Queue가 비어있을 경우의 사진입니다. 흐름은 다음과 같습니다.
1. Accept Queue가 비어있는 경우(만들어진 소켓이 없는 경우) Wait Queue에 Acceptor Thread를 Sleep 상태로 등록
2. Client의 연결이 생길 경우 TCP Handshake 이후, 커널 TCP 스택이 Accept Queue에 소켓 추가
3. kernel이 Wait Queue에 있는 Acceptor Thread 깨움
4. Acceptor Thread는 Accept Queue에 있는 Socket을 들고 유저 공간으로 돌아감
Accept Queue에 Task가 있을 경우에는 2,3번을 건너뛰고 바로 4번으로 가겠죠?
4) Poller Thread는 selector.select()를 호출하여 등록된 소켓 중 데이터가 도착한 소켓이 있는지 커널에 확인.
없으면 있을 때까지 blocking 대기

그림으로 보면 이 부분입니다. Poller Thread는 NIO Selector에게 select()를 호출하고, NIO Selector 내부적으로 kernel에게 select() 요청을 보내는 것이죠. select() 또한 accept()와 마찬가지로 동작할까요? 결론부터 말하자면, select는 non-blocking + blocking 두 가지 방식으로 동작합니다. 이를 이해하기 위해서는 Acceptor Thread와 Poller Thread의 동작에 대해 조금 더 깊게 이해해야 합니다.
사실 Acceptor 스레드가 NIO Selector에 직접적으로 소켓을 등록하는 건 아닙니다. 보다 엄밀히 말하자면 Acceptor Thread는 소켓을 PollerEvent로 래핑하고, 해당 객체를 Event Queue에 등록합니다. 여기까지가 Acceptor Thread의 작업이고, EventQueue에 있는 Poller Event를 꺼내서 NIO Selector에 등록하는건 Poller Thread의 책임입니다.

Poller Thread는 앞서 말한 것처럼 Non-blocking 방식과 Blocking 방식으로 동작합니다.
Tomcat의 구현 코드인데요, wakeupCounter의 크기가 0보다 크다면 selectNow()를, 그게 아니라면 select(timeout)을 호출합니다.
이게 무슨 의미일까요??

wakeupCounter는 eventQueue의 크기를 의미합니다. 즉, EventQueue에 Poller Event가 있을 경우 selectNow(), Event가 없을 경우 select를 호출하는 것이죠.
EventQueue에 PollerEvent가 있는 경우
- 큐에서 꺼내서 Selector에 등록
- selectNow()로 ready 소켓 확인 (즉시 리턴)
EventQueue에 PollerEvent가 없는 경우
- select(timeout)으로 대기
- 깨어나는 조건: 기존 소켓 I/O ready / timeout 만료 / wakeup() 호출
- 깨어나면 루프 처음으로 돌아가서 큐 확인 → 있으면 등록 → ready 확인
왜 이런 구조로 작업하는 걸까요? 단순히 생각하면 됩니다. EventQueue에 Event가 있는 경우에는 빠르게 NIO Selector에 소켓 등록을 해줘야 하거든요. selectNow()의 핵심 역할은 소켓 등록을 빠르게 처리하고 루프를 빨리 순환시키는 것입니다.
이해를 위해 그림으로 흐름을 살펴봅시다.


이벤트 큐에 작업이 있는 경우, selectNow()가 non-blocking 형식으로 호출이 됩니다. 이벤트 큐에 작업이 있을 경우에는 NIO Selector에 소켓을 등록하고 다음 작업 사이클을 돌리는 게 더 우선시 되는 것이죠. 작업이 하나 있을 경우, 빠르게 SelectNow() 사이클 돌고, 이후에 이벤트 큐가 비어있으니 이벤트 큐가 비어있을 때의 사이클을 타는 것이죠.
이벤트 큐에 작업이 없을 경우에는 accept와 마찬가지로 blocking 형식으로 동작합니다. accept와 다른 부분은 타임아웃이 존재한다는 건데요, Poller Thread의 경우에는 Ready 상태의 소켓을 전달받는 것 외에 다른 추가 작업들을 진행합니다. ex) keep-alive가 지난 연결을 제거한다던지
다른 할 일을 처리해야 하니 accept처럼 무작정 대기를 할 수가 없는 것이죠. 그래서 Timeout이 나면 본인의 할 일을 수행한 후에, 다시 사이클을 타 waiting 상태로 대기를 진행합니다. OS kernel의 동작은 생략하겠습니다. 간단히 생각해 보면, select()의 경우에는 accept()와 비슷하게 동작하고, selectNow()의 경우에는 블로킹 자체가 안 발생하겠죠?
7) 응답을 생성했다면 write()를 호출해 OS 커널에 데이터를 전달하고, 커널이 Client에게 HTTP Response를 전송
8) Worker Thread는 작업이 끝나면 close()를 호출
write()를 호출하면 커널의 send buffer에 데이터를 복사하는 것이고, 실제 네트워크 전송은 커널이 처리합니다. close()를 호출하면 커널에 FIN 패킷을 보내달라고 요청하는 것이고, 커널은 send buffer에 남은 데이터를 모두 전송한 후 연결 종료를 진행합니다. 두 호출이 반환되면 Worker Thread의 임무는 끝납니다. 즉, worker 스레드는 단순히 kernel의 system call을 호출하고 끝나는 것이죠.
마무리하며
막상 정리를 하니깐 양이 너무 많아 후반부에는 대충 설명한 것도 있네요....
이 글을 쓰게 된 계기는 Server Send Event를 학습하며 몇 가지 해결되지 않는 의문점이 들어서 깊게 파봤습니다. ex) 하트비트가 왜 필요한 거지...? 그냥 read() 하면 되는 거 아닌가...?
이와 관련해서는 포스팅이 너무 길어지는 관계로 다음 포스팅에서 진행해 보겠습니다.
관련 글
'CS' 카테고리의 다른 글
| SSE 연결 종료 딥다이브 (0) | 2026.02.26 |
|---|