Spring은 다중 요청을 어떻게 처리할까?

N’oublie pas de t’aimer·2025년 4월 20일

DIVE

목록 보기
8/10

NIO: 그 원리가 도대체 무엇인가

Tomcat이 비동기 I/O라는 건 정확히 무엇이며, 8192개 연결을 200개 스레드로 어떻게 처리하는가

사용자가 영상을 업로드하면 백엔드서는 ffmpeg로 오디오를 추출하고, AWS Transcribe를 거쳐 Claude API로 피드백을 받는다. 이 과정은 한 건당 몇 분이 걸리는데, 그동안 톰캣의 스레드가 묶여 있다면 동시에 들어오는 다른 사용자의 요청은 어떻게 처리되는 걸까?

톰캣의 기본 설정은 다음과 같다.

server:
  tomcat:
    threads:
      max: 200
      min-spare: 10
    max-connections: 8192
    accept-count: 100

"max-connections는 8192인데 max-threads는 200". 이 차이가 무엇을 의미하는지부터 풀어보자.


max-connections가 8192면, 동시에 요청 8192개를 처리하는가?

처음에 가장 헷갈렸던 부분이다. 8192라는 숫자만 보면 "동시에 8192개의 HTTP 요청을 처리한다"고 오해하기 쉽다.

실제로는 그렇지 않다. 이 두 숫자는 다른 층위의 자원을 가리킨다.

설정의미비유
max-connections: 8192동시에 유지 가능한 TCP 연결 수가게에 동시에 들어와 있을 수 있는 손님 수
accept-count: 100TCP 연결이 가득 찼을 때의 OS 대기열 크기가게 앞 대기 줄
threads.max: 200동시에 요청을 처리하는 스레드 수가게의 직원 수

즉 8192명의 손님이 가게 안에 있어도, 직원은 200명뿐이다. 손님은 들어와 있긴 한데 자기 차례가 오기를 기다리고, 200명의 직원이 돌아가면서 응대한다.

스레드풀이 작업을 받아들이는 흐름을 그림으로 보면 이렇다.

스레드풀 작업 처리 흐름

첫 작업이 들어오면 core size만큼의 스레드가 만들어지고, 이후 들어오는 요청은 작업 큐에 쌓인다. 큐가 가득 차면 그제야 max threads까지 추가 스레드가 생성되며, 거기까지 모두 차면 새 요청은 거절된다.

연결과 처리는 별개의 자원이라는 게 핵심이다. 연결이 8192개 유지되어도, 그중 실제로 데이터를 주고받는 중인 건 일부일 뿐이고 나머지는 Keep-Alive로 대기 중이거나 데이터가 천천히 도착하는 중이다. 그래서 적은 수의 직원으로도 많은 손님을 응대할 수 있다.

그런데 이게 어떻게 가능한가? BIO 시절에는 연결 하나에 스레드 하나가 매여 있었다. NIO는 무엇이 다르길래 1:1 매핑을 깨뜨릴 수 있는 걸까?


BIO와 NIO, 진짜 차이는 무엇인가

Tomcat은 버전별로 사용하는 HTTP Connector가 달라져 왔다. 8.5부터는 NIO Connector가 기본값이다.

Tomcat 8.5 이후로는 BIO가 제거되었고, NIO/NIO2/APR Connector가 기본 옵션이 되었다.

BIO: 한 손님에 한 직원이 매여 있다

BIO에서는 TCP 연결이 생기면 워커 스레드가 거기에 고정된다. 손님이 가게에 들어와서 메뉴를 고민하는 동안, 직원은 그 옆에 서서 기다려야 한다. 다른 손님이 들어와도 다른 직원이 와야 한다.

연결이 8192개라면 직원도 8192명 필요하다. 그런데 손님 대부분은 메뉴만 보고 있고, 실제로 주문을 받는 시간은 짧다. 직원이 idle 상태로 대기하는 시간이 길어 자원이 낭비된다.

NIO: 한 명이 여러 손님의 주문을 받는다

NIO는 구조가 다르다. Poller라는 별도의 스레드가 모든 연결의 상태를 한꺼번에 감시한다.

Acceptor Thread (1개) — 새 손님 입장 받기
        ↓
Poller Thread (1~2개) — 누가 주문할 준비됐는지 감시
        ↓ (주문 준비된 연결만)
Worker Thread Pool (max=200) — 실제 주문 처리

Poller는 Java NIO의 Selector를 이용한다. Selector는 여러 소켓을 등록해두고, 그중 읽기/쓰기가 가능한 상태가 된 소켓이 무엇인지 한 번의 호출로 알려준다. 손님 8192명을 관리하지만, "지금 주문할 준비가 된 손님"만 골라서 처리한다.

준비된 연결이 발견되면 그제야 Worker Thread Pool에서 스레드 하나를 가져와 그 요청을 처리한다. 요청을 처리하는 동안에는 그 워커 스레드가 그 일에 묶여 있다. 처리가 끝나면 워커는 풀에 반환되고, 그 연결은 다시 Poller가 감시하는 상태로 돌아간다.

Acceptor가 연결을 받아서 Poller에 등록하면, Poller는 Selector로 여러 소켓을 동시에 감시하다가 처리 가능한 것만 Worker Thread에 할당한다.

그래서 진짜 효율이 나는 지점은

여기서 처음에 내가 헷갈렸던 부분을 짚어야 한다. NIO가 "비동기"라고 해서 워커 스레드가 I/O를 기다리는 동안 다른 일을 한다는 뜻은 아니다.

JDBC 쿼리를 던진 워커 스레드는 그 결과가 올 때까지 그 자리에서 블로킹된다. ffmpeg를 호출한 스레드는 그 프로세스가 끝날 때까지 거기 매여 있다. 워커 스레드 입장에서는 BIO나 NIO나 똑같다.

NIO의 진짜 효율은 연결 관리 단계에 있다. 8192개 연결 중 대부분은 데이터를 천천히 받고 있거나 Keep-Alive로 대기 중이고, 진짜 처리 가능한 상태는 일부다. 그 일부만 골라서 200개 스레드에 분배하니, 200개로 8192개 연결을 감당할 수 있는 것이다.

즉 NIO의 비동기성은 "연결을 관리하는 Poller 스레드가 비동기"이지, "비즈니스 로직을 실행하는 워커 스레드가 비동기"가 아니다. 워커 스레드의 시점에서 보면 여전히 블로킹 처리다.

진짜로 워커 스레드까지 비동기로 만들고 싶으면 Webflux + Netty 같은 리액티브 스택으로 가야 하는데, 그건 또 다른 이야기다.


Tomcat과 Spring은 어떻게 연결되는가

NIO 구조를 이해하고 나니 다음 질문이 자연스럽게 따라왔다. 그럼 Tomcat의 워커 스레드와 Spring의 코드는 어떻게 이어지는가?

처음에는 Tomcat이 따로 있고 Spring이 따로 있어서, 그 둘이 어떤 식으로 다리를 놓는 줄 알았다. 그런데 코드를 따라가 보니 그게 아니었다.

Spring은 자기 스레드를 따로 만들지 않는다. Tomcat이 관리하는 Worker Thread Pool(max=200) 위에서 Spring의 모든 코드가 실행된다.

Acceptor와 Poller가 연결을 처리한 뒤 Worker Thread에 작업이 넘어가면, 그 워커 스레드 위에서 DispatcherServlet과 Spring의 비즈니스 로직이 실행된다.

요청 흐름을 정리하면 이렇다.

요청 도착
  ↓
Acceptor → Poller (Tomcat NIO Connector) 
  ↓
Worker Thread (Tomcat Thread Pool) — 여기서부터 Spring이 시작
  ↓
DispatcherServlet → Controller → Service → Repository
  ↓ (모두 같은 워커 스레드 위에서 실행)
응답 반환

DispatcherServlet도, @RestController도, @Service도 전부 Tomcat의 워커 스레드가 실행한다. Spring은 별도 스레드를 만들지 않고 Tomcat의 스레드를 빌려 쓴다.

그럼 Spring이 별도로 스레드를 만드는 경우는 언제일까?

@Async, @Scheduled, WebClient, CompletableFuture.supplyAsync() 같은 비동기 처리를 쓸 때만 별도의 스레드 풀이 만들어진다. 명시적으로 비동기를 선언했을 때 비로소 Tomcat 풀과 분리된 스레드가 생긴다.

정리하며

NIO를 통해 Tomcat이 8192개 연결을 200개 스레드로 감당할 수 있다는 건 알겠다. 그런데 워커 스레드 200개도 여전히 유한한 자원이다. ffmpeg 같은 장기 작업이 그중 하나라도 점유하면, 그 한 개의 슬롯은 그 시간 내내 막혀 있다.

만약 200개 워커 모두가 동시에 ffmpeg 작업을 떠안으면 어떻게 될까. 새 요청은 Poller까지는 도달하지만 거기서 워커를 못 받고, 결국 connection-refused가 발생할 것이다.

그래서 별도 스레드 풀로 분리하는 게 답이라는 결론에 다시 도착한다. 워커 풀의 본분은 짧은 HTTP 응답이고, 장기 작업은 자기만의 풀에서 관리되어야 한다. 이 풀의 크기를 어떻게 정할지, 포화되면 어떻게 처리할지는 또 다른 글의 주제가 된다.

다음 글에서는 이 별도 스레드 풀의 크기를 어떤 근거로 결정했는지, 그리고 그 풀마저 포화될 때를 대비한 구조를 어떻게 짰는지 정리해볼 예정이다.

profile
매일 1퍼센트씩 나아지기 ୧(﹒︠ ̫ ̫̊ ̫﹒︡)୨

0개의 댓글