FireDrago
[치지직 채팅] 3. 웹소켓 클라이언트와 스레드 본문
왜 웹소켓 클라이언트에서는 스레드를 직접 관리해야 할까?
본격적인 채팅 분석 시스템의 설계에 앞서, 반드시 짚고 넘어가야 할 핵심 개념이 있다.
바로 웹소켓 클라이언트와 스레드의 관계다.
Spring으로 간단한 웹 서버를 개발할 때,
스레드를 어떻게 생성하고 관리할지 고민하지 않는다.
Spring Boot 내장 톰캣이 1 요청 1스레드 (Thread-per-Request) 모델을 통해
멀티 스레딩을 알아서 처리해주기 때문이다.
하지만 웹소켓 클라이언트는 다르다.
연결은 오래 유지되고, 메시지는 언제 어떤 스레드에서 도착할지 보장되지 않는다.
이 순간부터 스레드 관리는 프레임워크가 아니라, 개발자의 책임이 된다.
이번 글에서는 톰캣이 숨겨준 스레드 처리방식을 알아보고,
이를 통해 채팅 봇 과 분석 서비스에서 구현해야 할 비동기 처리 전략을 정리해보자
톰캣 요청 처리 방식

위 다이어그램은 톰캣이 요청을 처리하는 3단계를 보여준다. 각 단계의 역할을 자세히 살펴보자.
1. Acceptor (문지기)
TCP 연결 요청이 들어오면 Acceptor는 핸드쉐이크만 처리한 뒤,
해당 소켓을 즉시 Poller의 큐(Queue)로 넘긴다.
Acceptor는 오직 '연결 수립'에만 집중하기 때문에,
단 1~2개의 스레드만으로도 수만 개의 연결 요청을 받아낼 수 있다.
2. Poller (감시자)
Poller 스레드는 넘겨받은 소켓들을 감시(Selector)하다가,
실제 데이터(Payload)'가 도착하는 순간을 포착한다.
연결만 되어 있고 데이터는 없는 '유령 연결'들 때문에 스레드가 낭비되는 것을 막아주는 핵심 단계다.
데이터가 준비된 소켓은 즉시 Worker Thread에게 전달된다.
고작 2명의 Poller가 수천 개의 연결을 감시한다
3. Worker Thread (요리사)
여기서부터가 우리가 흔히 설정하는 '스레드 풀'의 영역이다.
할당된 스레드는 톰캣의 핵심 엔진인 Coyote를 통해 바이너리 데이터를 HttpServletRequest 객체로 변환한다.
그리고 이 객체를 Spring의 DispatcherServlet으로 넘기면서 비로소 비즈니스 로직이 시작된다.
우리가 Spring으로 개발할 때 복잡한 스레드 관리를 신경 쓰지 않아도 되었던 이유는,
톰캣이 보이지 않는 곳에서 NIO(Non-blocking I/O) 방식으로 연결을 효율적으로 제어하고,
요청 당 스레드(Thread-per-Request)를 자동으로 할당해주고 있었기 때문이다.
Java Websocket 의 스레드 관리
우리가 흔히 사용하는 톰캣(Tomcat)은
8080 포트로 들어오는(Inbound) 요청에 대해 자동으로 스레드를 할당하고 관리해준다.
하지만 WebSocket Client는 상황이 다르다.
치지직 서버(wss://...)는 443 포트를 사용하지만,
내 봇이 연결을 시도할 때는 OS로부터 랜덤한 임시 포트(Outbound)를 할당받아 나간다.
즉, 이 연결은 톰캣의 관리 영역 밖이며, 톰캣의 스레드 풀 지원을 전혀 받을 수 없다는 뜻이다.
실제로 Java-WebSocket 라이브러리 내부 코드를 뜯어보면 이 사실을 명확히 알 수 있다.
// WebSocketClient.java
public void connect() {
if (writeThread != null)
throw new IllegalStateException("WebSocketClient objects are not reuseable");
// 딱 하나의 스레드(writeThread)를 라이브러리가 직접 생성한다.
// 톰캣의 스레드 풀을 쓰지 않고, 독자적인 스레드(Thread-N)를 만든다.
writeThread = new Thread(this);
writeThread.setName("WebSocketConnectReadThread-" + writeThread.getId());
// run() 메서드가 실행됨
writeThread.start();
}
위 코드는 `connect()` 최초 웹소켓 연결 호출 시 실행되는 로직이다.
`new Thread(this)`를 통해 명시적으로 스레드를 생성하는 것을 볼 수 있다.
만약 톰캣의 관리를 받았다면 이런 코드는 필요 없었을 것이다.
그렇다면 이 단 하나의 스레드는 내부에서 어떻게 동작할까?
// WebSocketClient.java
@Override
public void run() {
InputStream stream = getInputStream();
try {
// 봇이 살아있는 동안 계속 도는 무한 루프
while (!isClosing() && !isClosed()) {
// 1. 데이터 수신 (Blocking Read)
// 서버에서 데이터가 올 때까지 여기서 스레드가 대기한다.
int readBytes = stream.read(readBuffer);
if (readBytes > 0) {
// 2. 데이터가 오면 '해석(Decode)' 하라고 명령
// 여기서 중요한 건, 별도 스레드를 안 부르고 직접 호출하고 있다.
engine.decode(ByteBuffer.wrap(readBuffer, 0, readBytes));
}
}
// ... (연결 종료 처리)
} catch (Exception e) {
onWebsocketError(this, e);
engine.closeConnection(CloseFrame.ABNORMAL_CLOSE, e.getMessage());
}
}
핵심 실행 로직인 `run()` 메서드를 보면,
while 문을 돌며 데이터 수신(read) → 파싱(decode) → 로직 실행(onMessage) 까지의
모든 과정이 동기적(Synchronous)으로 처리됨을 알 수 있다.
코드 그 어디에도 new Thread나 ExecutorService 같은 비동기 처리가 보이지 않는다.
이는 자바 라이브러리임에도 불구하고 마치 자바스크립트(JS)의 싱글 스레드 이벤트 루프처럼 동작한다는 것을 의미한다.
이 구조에서는 파싱 로직이 늦어지면, stream.read를 제때 수행하지 못하게 되고,
결국 전체 네트워크 통신이 마비되는 병목 현상이 발생하게 된다.
싱글 스레드 방식을 사용한 이유?
Java는 태생부터 멀티 스레드에 강력한 언어인데,
왜 Java-WebSocket 라이브러리는 굳이 싱글 스레드 방식을 택했을까?
순서 보장과 스레드 안전성을 위한 의도된 설계다.
첫째, 메시지 처리 순서의 보장이다.
TCP/WebSocket은 연결 지향 프로토콜로, 데이터의 순서가 생명이다.
만약 멀티 스레드를 사용하여 들어오는 메시지를 병렬로 처리한다면,
먼저 도착한 '로그인 요청'보다 나중에 도착한 '채팅 전송'이 먼저 처리되는 순서 역전이 발생할 수 있다.
싱글 스레드 큐(Queue) 방식은 이를 원천적으로 차단한다.
둘째, 복잡한 동기화 문제 해결이다.
싱글 스레드 환경에서는 개발자가 synchronized나 Lock 같은 복잡한 동기화 처리를 고민할 필요가 없다.
라이브러리는 가장 안전하고 가벼운 방법을 제공하고, 성능 확장은 개발자의 몫으로 남겨둔 것이다.
비동기 처리가 필요한 이유
라이브러리가 데이터 무결성을 위해 싱글 스레드를 선택했다는 것은 이해했다.
그렇다면 채팅을 수신한 후, LLM 연결이나 외부 API 연동 같은 무거운 비즈니스 로직을
별도의 스레드 없이 처리하면 어떻게 될까?
앞서 코드에서 확인했듯, onMessage 메서드는 소켓을 읽는 스레드(IO Thread) 위에서 동기적으로 실행된다.
만약 여기서 3초가 걸리는 LLM 요청을 수행한다면 다음과 같은 치명적인 문제가 발생한다.
- 전체 봇의 마비 (Blocking): 3초 동안 봇은 아무것도 할 수 없다. 다른 시청자의 채팅을 읽을 수도, 처리할 수도 없다.
- 연결 끊김 (Heartbeat Fail): 가장 큰 문제는 Ping/Pong 처리다. 서버가 "너 살아있니?"라고 핑을 보냈을 때,
봇이 LLM 응답을 기다리느라 3초간 대답을 못 하면 서버는 연결을 끊어버린다.
따라서 우리는 '수신(IO)'과 '처리(Worker)'를 분리해야 한다.
onMessage에서는 데이터를 받자마자 별도의 스레드(스레드 풀)에게 작업을 넘기고,
즉시 리턴하여 다음 메시지를 받을 준비를 해야 한다.
이것이 바로 자바 웹소켓 봇 개발에서 ExecutorService를 활용한 비동기 설계가 필수적인 이유다.
ExecutorService를 활용한 비동기 설계

문제의 핵심은 데이터 입출력과(IO)와 분석 비지니르 로직이(Logic) 하나의 스레드로 처리되기 때문에 발생한 병목이었다.
이를 해결하기 위해 나는 `ExecutorService`를 도입하여 두 영역을 물리적으로 분리하는 전략을 택했다.
구체적인 구현은 생산자-소비자(Producer-Consumer) 패턴을 따른다.
- IO 스레드 (생산자): onMessage가 호출되면, 무거운 작업을 직접 수행하지 않는다.
대신 작업을 캡슐화하여 스레드 풀의 대기열(Queue)에 제출(submit)만 하고 즉시 리턴한다.
IO 스레드는 바로 다음 네트워크 패킷(Ping 등)을 처리하러 갈 수 있다. - 작업 스레드 (소비자): 스레드 풀에 대기 중인 별도의 작업 스레드(Worker Thread)가 큐에서 작업을 꺼내어
실제 LLM 통신을 수행한다.
여기서 3초가 걸리든 10초가 걸리든, 앞단의 IO 스레드에는 아무런 영향을 주지 않는다.
// [After] 비동기 처리: 일감만 던지고 즉시 복귀 -> 봇 생존
private final ExecutorService executor = Executors.newFixedThreadPool(10);
@Override
public void onMessage(String message) {
// IO 스레드는 '제출'만 하고 바로 리턴 (Non-blocking)
executor.submit(() -> {
// 실제 무거운 작업은 별도의 스레드가 수행
String response = llmService.ask(message);
send(response);
});
}'프로젝트' 카테고리의 다른 글
| [채팅 감정 분석] 5. MVP 완성 및 트러블 슈팅 (설계 변경) (0) | 2025.12.25 |
|---|---|
| [채팅 감정 분석] 4. 실시간 분석 데이터 파이프라인 설계 (0) | 2025.12.21 |
| [치지직 채팅] 2. 치지직 채팅 웹소켓 접속하기 (0) | 2025.12.09 |
| [치지직 채팅] 1. 치지직 채팅 웹소켓 프로토콜 분석 (0) | 2025.12.05 |
| [Moment] 프로젝트 쿼리 실행속도 50초 단축하기 (0) | 2025.10.19 |
