새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Study

(CS Study) Tomcat Internals : TCP Accept 부터 DispatcherServlet까지 (feat. Tomcat 소스코드로 추적하기)

  • -

👀 들어가기 전에

Spring 서버는 동시에 수천 개의 요청을 어떻게 처리할까?
Tomcat은 어떤 스레드가 요청을 처리할까?

 

저는 주로 Spring Framework를 기반으로 개발하고 있습니다. 일반적으로 Spring 어플리케이션은 Tomcat과 같은 WAS(Servlet Container) 위에서 동작하며 외부에서 들어온 HTTP 요청은 Tomcat을 거쳐 Servlet → Spring DispatcherServlet → Controller 순으로 전달됩니다. 하지만 실제로 개발을 하다보면 이런 흐름을 개념적으로만 알고 사용하는 경우가 많습니다.

예를 들어,

  • HTTP 요청이 어떤 컴포넌트를 거쳐 Controller까지 전달되는지
  • Tomcat 내부에서는 어떤 스레드가 어떤 요청을 하는지
  • Connection, Request, Thread Pool이 각각 어떤 구조로 동작하는지
  • Spring이 Tomcat의 Servlet 구조 위에서 어떻게 동작을 확장하는지

같은 부분들은 보통 깊이 있게 살펴보지 않고 넘어가는 경우가 많습니다.

하지만, 실무를 하다보니 아래와 같은 일들이 발생합니다.

  • 특정 API 하나 때문에 전체 서버 속도 저하
  • 동시 요청이 증가하면서 Timeout이나 Connection의 오류 발생

그래서 이런 문제들을 겪으며 의문이 들었습니다.

 

도대체 어떻게 Spring Controller까지 요청이 도달하고
Spring에서 요청을 받기 전 근본 원인인 Tomcat에서는 들어온 요청을 어떻게 처리할까?

 

그래서 이번 글에서는 Spring Controller까지 요청이 도달하기까지의 전체 흐름을 Tomcat 내부 구조를 직접 따라가며 분석해보려 합니다. 왜 굳이 Tomcat 내부까지 까보냐라고 물어보신다면, 그냥 궁금했기 때문입니다 ☺️

하지만 단순한 궁금증에서 시작했더라도 내부 구조를 따라가다보면 서버의 동작 방식과 성능 특성을 이해하는데 굉장히 많은 도움을 받을 수 있을 것이라 생각됩니다.

 

특히 저는 이 과정에서 다음 질문에 대한 답을 찾아보고 싶었습니다.

  • Spring 기반 서버는 어떻게 Multi Thread 환경에서 동시 요청을 처리할까?
  • Tomcat의 Thread Pool은 어떤 방식으로 요청을 분배할까?
  • 왜 Java + Spring 환경에서는 동시 요청 처리 구조가 자연스럽게 만들어질까?

그리고 이 과정에서 자연스럽게 떠오르는 비교 대상이 하나 있습니다.

바로 Node.js입니다.

Node.js는 Single Thread 기반 Event Loop 모델로 동작하는 것으로 잘 알려져 있습니다.

반면 Java 기반 서버(Tomcat)는 Thread-per-request 모델을 기반으로 동작합니다.

 

이 두 모델은 모두 동시 요청을 처리할 수 있는 구조이지만, 내부 동작 방식은 상당히 다릅니다.

 

따라서 이 글에서는 다음과 같은 흐름으로 내용을 정리해보려 합니다.

  1. HTTP 요청이 서버로 들어오는 순간 어떤 일이 발생하는가 (OS / Socket 관점)
  2. Tomcat 내부에서 요청이 처리되는 구조
  3. Thread Pool과 Request 처리 흐름
  4. Servlet → Dispatcher Servlet → Controller 까지의 호출 과정
  5. Java Multi Thread 모델 vs. Node.js Event Loop 모델 비교

Tomcat 내부 코드 흐름을 따라가면서 실제 요청이 처리되는 경로를 하나씩 추적해보겠습니다.

이를 통해 Spring 기반 서버가 어떻게 동시 요청을 처리하는지, 그리고 Node.js와는 구조적으로 무엇이 다른지까지 함께 살펴보겠습니다.

(이번 글은 매우 길지만, 다 이해하시게 된다면 분명히 도움이 될 수 있을 것이라 생각합니다.)


👀 본론

HTTP 요청이 서버로 들어오는 순간 (OS/Socket 관점)

1) HTTP 요청은 사실 커널에서의 TCP 연결로 시작된다?

클라이언트가 GET 요청을 날리는 순간을 떠올리면 우리는 흔히 "HTTP 요청이 서버로 들어왔다"고 말합니다.

하지만, Tomcat 입장에서는 요청의 시작은 HTTP 요청이 아닙니다.

RFC 9293 Section 3.5

 

RFC 9293 (TCP Specification)에서도 TCP 연결이 성립되기 위해서는 3-way handshake 과정을 통해 성립됩니다.

(SYN → SYN/ACK → ACK)

 

이 과정을 통해 TCP 연결이 성립되면 운영체제 커널은 해당 연결을 accept 가능한 상태로 준비합니다.

이때 발생하는 흐름을 정리하면 아래와 같습니다.

  1. 클라이언트가 TCP SYN 패킷을 보낸다
  2. 서버 커널이 SYN Queue / Accept Queue에서 연결을 관리한다
  3. TCP 3-way handshake (SYN → SYN/ACK → ACK)가 완료된다.
  4. 커널은 이 연결을 accept 가능한 상태로 만든다
  5. Tomcat은 accept()를 호출해 커널이 준비해둔 연결을 가져온다.

즉, Tomcat이 받는 것은 HTTP Request 객체가 아니라 TCP Socket입니다.

 


⭐️ Tomcat이 내부에서 처리되는 구조 

아래 코드를 통해 Tomcat 코드에서 accept()가 어디서 호출되고 있는지 확인해보겠습니다.

Tomcat 내부 코드(NioEndpoint.java)

 

NioEndpoint.startInternal()에서 startAcceptorThread를 호출합니다.

(여기서 pollerThread와 acceptorThread 모두 호출합니다. 여기서 pollerThread의 존재 잊어버리시면 안됩니다.⭐️)

Tomcat 내부 코드 (AbstractEndpoint.java)

 

위 사진을 보면, acceptor Thread를 만들어서 시작이 됩니다.

이때, new Thread(acceptor, threadname) 후, t.start()를 하면서 플랫폼 스레드 경로를 타서(JDK 21 코드를 참고해보았습니다.)

아래의 메소드에 도착합니다.

Open JDK21 코드 (Thread.java)

여기서 start0를 호출하면

Open JDK (Thread.java)

 

이 호출로 인해서 JNI로 JVM_StartThread에 매핑됩니다. (아래 C언어 코드 참고)

Open JDK21 (Thread.c)

 

Open JDK21 코드 (jvm.cpp)

 

여기서 JVM이 새 스레드를 만들고, (여기에 대해서도 할 말이 많습니다......)

Open JDK21 코드 (jvm.cpp)

 

Thread :: start(native_thread)로 JVM이 새 Java Thread를 만들고 시작합니다.

Open JDK21 코드 (jvm.cpp)

 

이 새 스레드 엔트리에서 Thread.run()을 가상호출 하게 됩니다.

Open JDK 21 코드 (Thread.java)

 

코드에서 보이시는 바와 같이 Thread.run()이 내부 task(Runnable)을 실행하게 되는데, 그 task가 Tomcat의 Acceptor라서

그 스레드에서 Acceptor.run()루프가 실행됩니다.


[잠깐!!!] 아까 여기서 JVM이 새 스레드를 만들고,
(여기에 대해서도 할 말이 많습니다......)라고 한 부분에서 할 말을 이제 해보겠습니다!

 

분명히 자바 코드였는데 갑자기 C코드, C++ 코드로 왔다갔다를 했는데, 이를 보며 신기해서 너무 궁금한 것들이 생겼던 것입니다.


왜 자반데, C레벨로 내려가는거지? 자바는 커널에 직접적인 요청을 못하나?

 

우선 생성 과정 부터 설명하면, JVM이 C++ 코드로 Java Thread같은 VM 내부 객체를 만들고, 이어서 OS API (pthread_create 같은) 를 호출해서 커널에 스레드 생성요청을 합니다. 커널이 native thread를 만들고 스케줄링합니다.

 

Java 코드는 직접 커널에 syscall을 못합니다. (안전과 이식성 때문에) 그래서 JVM이 제공하는 native 경계 (JNI/JVM 내부 함수)를 통해 내려갑니다. Thread.start() → start0() (⭐️ 여기가 JNI 레벨) → JVM C/C++ → OS API (pthread_create 등) 순서로 가는 이유가 위와 같은 이유 때문입니다. 언어/vm 설계상의 의도로 직접 접근을 막고 JVM이 대신 수행하는 구조인 것입니다.


다시 톰캣으로 돌아와보면, 결국 위의 일련의 과정들을 통해서 Acceptor 스레드의 run이 실행됩니다. 😄

Tomcat 내부 코드 (Acceptor.java)

 

Acceptor 스레드가 run() 루프에서 (위 코드가 run루프의 코드 중 일부입니다.) endpoint.serverSocketAccept를 호출합니다.

이 serverSocketAccept()는 NioEndpoint.java 파일에서 확인 가능합니다.

Tomcat 내부 코드 (NioEndpoint.java)


위 코드에서 세 번째 줄을 보게 되면, Acceptor 스레드 내부에서 severSock.accept()를 확인할 수 있습니다.


 

이 메소드는 다시 JDK NIO API로 돌아가서 커널이 준비한 연결을 받아옵니다. 이 과정도 JDK 21에서 코드를 보겠습니다.

JDK 21 코드 (ServerSocketChannelImpl.java)

 

이 내부에서 Net.accept가 발생하게 되는데, 그 전에 구현 코드 기준으로 하나하나 살펴보자면,

우선 해당 채널 객체에서 동시에 여러 스레드가 accept() 들어오는 것을 막기 위해 Lock을 걸어주고 (자바 레벨 동기화 락입니다.) 

현재 채널이 blocking 모드인지를 확인하고(isBlocking()), configureSocketNonBlockingIfVirtualThread()는 현재 실행 스레드가 virtual일때만 소켓을 non-blocking으로 강제합니다. virtualThread면 fd를 non-blocking으로 바꾸게 됩니다.

그래서 implAccept()가 한 번에 성공하지 않고 UNAVAILABLE / INTERRUPTED 같은 재시도 가능한 상태를 줄 수 있습니다.

(이 때문에 이후에 if(blocking)블록 안에서 implAccept()를 다시 호출해 blocking semantics를 유지합니다. (채널은 여전히 blocking을 유지해야 하므로))

 

톰캣은 플랫폼 스레드였기 때문에 따로 보정이 걸리지 않고 여기서 넘어가게 됩니다.

 

그 후 바로 n= implAccept로 Net.accept가 발생되는 것입니다.

JDK 21 코드 (ServerSocketChannelImpl.java)

 

Net.accept를 따라가보면, JNI Native 메소드인 점을 확인할 수 있습니다.

JDK 21 코드 (Net.java)

 

여기서 JNI 구현을 따라가보게 되면, 

JDK 21 코드 (Net.c)

 

C 코드에서 실제 OS accept 시스템을 호출하게 됩니다. (newfd = accept(~~) 부분)

이후 accept 결과가 다시 위에서 봤던 JDK21의 Accept 메소드로 들어가고 SocketChannel을 반환하게 됩니다.

Tomcat 코드 (Acceptor.java)

 

다시 Tomcat으로 돌아와 여기서 나온 accept의 결과에 따라 성공하게될 경우,

setSocketOptions로 반환 받은 socket의 option을 정하며,

실패할 경우 지연이면 backoff 전략으로 돌고 종료 중이면 break 문을 타게 됩니다.

 

여기서 성공을 기준으로의 동작을 살펴보겠습니다!!

Tomcat 코드 (NioEndpoint.java)

 

connections.put으로 해당 소켓을 newWrapper와 매핑합니다.

newWrapper는 Tomcat이 SocketChannel을 감싸는 연결 컨텍스트 객체입니다.

 

이건 왜 쓰일까??

 

raw SocketChannel만으로는 Tomcat이 처리에 필요한 상태 (타임아웃, keep-alive 카운트, read/write 상태, poller 참조 등)를 관리하기 어렵기 때문입니다. 그래서 socket → wrapper를 저장해두고 이후 Poller/Processor가 이 wrapper를 기준으로 동작하게 됩니다.

(뒤에서 더 자세히 설명하겠습니다.) 


중간 정리 

이렇게 여기까지가 Tomcat이 accept()를 호출해 커널이 준비해둔 연결을 가져오는 과정을 알아봤습니다.

잠시 정리를 하고 넘어가면, Tomcat은 startAcceptorThread()를 호출하고, 이때, pollerThread와 acceptorThread 모두 호출합니다.

acceptorThread도 쓰레드이기에 새 Thread를 만드는 과정이 JVM 내부적으로 동작합니다. 내부적으로 동작하며 JNI 로 내려도 가보고 JNI에서 C++ → C → 커널 과정에서 Thread 생성을 요청하고, AcceptorThread의 run이 실행됩니다.

이후, 해당 스레드 내부적으로 (acceptor Thread 내부적으로) serverSocket을 Accept 시켜야하기에 다시 JNI로 내려가서 OS에 accept를 요청하고, 동기/비동기, accept 결과에 따라 성공하게 되면 Socket의 옵션들을 설정합니다. 

즉, 저희가 지금까지 알아본 과정은 딱

  1. Tomcat 시작시 startAcceptorThread로 Acceptor 스레드 시작
  2. 클라이언트가 TCP SYN 전송
  3. 커널이 SYN/Accept 큐 관리, 3-way handshake 완료 (SYN → SYN/ACK → ACK)
  4. 커널은 이 연결을 accept 가능한 상태로 둔다.
  5. Acceptor가 serverSocketAccept() 호출 JDK accept() 호출
  6. 커널 큐에서 연결을 꺼내와 SocketChannel 획득

즉, Tomcat이 받는 첫 단위는 HTTP 객체가 아니라 TCP 소켓인 것입니다.


다시 톰캣으로 돌아와서

다시 톰캣으로 돌아와보겠습니다.

Tomcat 코드 (NioEndpoint.java)

 

아까 위에서 보여드렸던 코드입니다. socket → wrapper를 저장해두고 이후 Poller/Processor가 이 wrapper를 기준으로 동작하게 되는데,


이때, Wrapper란 Tomcat이 SocketChannel을 감싸는 연결 컨텍스트 객체라고 하였습니다.

이 객체가 어떻게 생겨먹은 놈인지 한 번 보겠습니다!

Tomcat 코드 (NioEndpoint.java)

 

NioSocketWrapper는 NioChannel과 NioEndpoint로 구성되며, super로 상위 channel과 endpoint를 받습니다.

만약, unixDomainSocketPath() 즉, TCP/IP 처럼 네트워크 포트를 쓰는게 아니라, 파일 경로 기반 소켓으로 통신하게 되는 경우127.0.0.1/localhost를 넣게 되는 것입니다.

 

왜???? Localhost인거지???

왜 하필 localhost/127.0.0.1이어야 했을까요? UDS엔 원래 IP나 Port개념이 없기에 기존 코드가 remoteAddr, localAddr 같은 값을 기대할 때, 깨지지 않게 하기 위해서입니다. 즉, Tomcat 내부/상위 API 중 일부가 remoteAddr / localAddr 같은 문자열을 기대할 수 있기에, null/빈값으로 인한 호환성 문제를 피하기 위해 호환용 기본값을 넣은 것입니다.


다시 poller.register()로 돌아가겠습니다.

사진 poller.register() 옆 맨 마지막 줄에 Poller에 등록해서 이벤트 기반 처리 시작이라는 이야기가 있습니다.

 

여기서 Poller는 어디서 나온걸까?

poller를 찾아 떠나보았습니다.

아까 맨처음에 startInternal() 시작시 나온 코드를 기억하시나요?

Tomcat 내부 코드(NioEndpoint.java)

 

보시면, PollerThread를 start 시키는 코드를 확인할 수 있습니다.

여기서 new Poller()로 생성하고 new Thread(poller, getName() + "-Poller"); 로 PollerThread를 만드는 것을 확인할 수 있습니다.

이렇게 Poller 루프를 띄웁니다. 


왜 Poller Thread를 먼저 띄운걸까?

 

이제는 이것에 대한 의문이 조금은 풀렸을 것 같습니다. (사실 아까 처음에 startAcceptorThread() 보는 내내 궁금했습니다..)

Acceptor가 accept() 성공 직후 setScoketOptions()를 하며 바로 poller.register()를 호출하기 때문입니다.

 

Poller가 아직 없거나 안떠있으면 등록/이벤트 처리가 꼬일 수 있기 때문에 소켓 누락 전에 이벤트 소비자인 Poller를 먼저 준비하는 것입니다. 

 

이후 스레드 생성은 이전에 본 것과 동일한 로직으로 생성하게 됩니다. (까먹으셨다면 위로 올라가서 다시 보고 오시는 것을 추천합니다!)

🙏 추가적으로, 여기서 Poller루프를 띄웠다 → PollerThread를 생성했다 → PollerThread.start() → pollerThread.run() 이 된다는 것을 기억하고 계셔야 합니다. (뒤에 또나옵니다....!!)


다시 acceptorThread()가 돌아가며 ServerSocket Option을 설정하는 부분으로 들어가보겠습니다.

poller.register() 메소드를 실행시키고,

Tomcat 내부 코드 (NioEndpoint.java)

 

위 코드와 같이, register를 진행하게 됩니다. 

이때, 

Tomcat 내부 코드 (NioEndpoint.Java)

 

interestOps를 생성하게 됩니다.


InterestOps란 뭘까?

interstOps는 Selector에 "이 소켓에서 어떤 이벤트를 보고 싶다"를 등록하는 비트마스크입니다.

예를 들면 아래와 같습니다. 

NIO 기준으로 대표 값을 보겠습니다.

  • SelectionKey.OP_READ : 읽기 가능 이벤트 관심
  • SelectionKey.OP_WRITE : 쓰기 가능 이벤트 관심
  • 서버 소켓이라면, OP_ACCEPT, 클라이언트 채널이면 OP_CONNECT도 있습니다.

Tomcat흐름에선 새 연결 등록 시 (accept된 클라이언트 SocketChannel 등록시) OP_READ 값으로 시작하게 됩니다. (아래 register 코드 참고)

Tomcat 내부 코드 (NioEndpoint.java)

 

그렇게, pollerEvent를 만들게 됩니다.

Poller Event 객체의 코드는 아래와 같습니다.

Tomcat 코드 (NioEndpoint.java)

 

보시는 것과 같이, pollerEvent는 socketWrapper와 interestOps가 담기게 되는 큐 객체입니다.


생성된 pollerEvent 객체로 addEvent가 이루어지게 됩니다. 

Tomcat 내부 코드 (NioEndpoint.java)
Tomcat 코드 (NioEndpoint.java)

 

이 요청은 비동기적으로 이루어집니다. (이를 안전하게 만들기 위해 events 큐는 SynchronizedQueue로 구성되어 있습니다.)

Tomcat 코드 (NioEndpoint.java)

 

즉, 정리를 해보면, addEvent()를 통해 일단 큐에 add해놓기만 하고, 다시 setSocketOptions로 돌아가

큐잉까지 성공하면 return true;를 하게 됩니다.

 

addEvent()가 큐에 넣고, 필요하면 selector.wakeup() 으로 Poller를 깨우게 됩니다.

(if블록 안을 보시면 확인할 수 있습니다.)

Poller 스레드 내부 run()

 

이 pollerThread가 왜 run인지는 위에서 이야기했던 부분을 참고하시면 됩니다. 

이해가 안가신다면 말씀해주시면 바로 다시 돌아가 설명드리겠습니다.

(블로그 보시는 분들은 댓글로 남겨주시면 차근히 설명드리겠습니다!)

 

여기까지를 정리해보면, PollerThread먼저 start를 시켰고, AcceptorThread가 start되어서

Acceptor가 accept()로 커널 준비 연결을 받아오고 setSocketOptions에서 wrapper와 소켓을 설정하고

poller.register() → addEvent()로 Poller 큐에 등록을 요청합니다.

 

이때, 필요하다면, 이미 돌고 있던 PollerThread가 큐를 소비해서 selector에 등록처리하게 되는 것입니다!!!!

 

필요하다면??? 필요하다면????

 

아래 코드를 통해 그 "필요하다면"의 의미를 구체화하겠습니다.

Tomcat 코드 (NioEndpoint.java)

 

events.offer(event)로 큐에 넣고 필요하다면 즉, wakeupCounter.incrementAndGet() ==0일때만 selector.wakeup()을 호출합니다.

 

Poller가 select(0)에 블록 중일때만 깨우려는 최적화입니다.

Poller가 아직 안자고 selectNow()로 처리 가능한 상태에 있으면 굳이 wakeup() 시스템콜을 날리지 않습니다.

 


 

Selector가 뭘까요?

 

Selector는 Poller내부의 인스턴스입니다.

JDK 21 코드 (Selector.java)

 

이 Selector로 구현이 되어있는 것이 바로 Tomcat의 Poller입니다.

Poller객체를 보겠습니다.

Tomcat 코드 (NioEndpoint.java)

 

Tomcat 코드 (NioEndpoint.java)

 

즉, PollerEvent는 Poller로 보낼 이벤트 데이터 객체이며, Poller는 Tomcat 내부 클래스이고 내부에 JDK Selector를 들고

이벤트 루프를 돌립니다. 여기서 중요한 점은!! 꼭 기억해야하는 점은!! 

PollerEvent = 큐 메시지(소켓 + interestOps)인 것이고, Poller = 큐 Consume + Selector 기반 I/O Multiplexing 실행체입니다.

 


이벤트 루프가 돌아가는 과정을 보겠습니다.!

Tomcat 코드 (NioEndpoint.java)

 

이벤트 루프는 events.size()만큼만 돌아가도록 합니다. 처음 for문을 들어갈때 잡아두었던 size만큼만 돌리도록 하는 것입니다.

이는, poller가 중간에 계속 들어오는 evnet만 처리하다 끝나지 않도록 하기 위함입니다.

그렇게 PollerEvent 큐 내에 들어있던, NioSocketWrapper와 SocketChannel, interestsOps를 꺼내게 됩니다.

만약 이번이 첫 등록이라면, OP_READ로 등록하게 됩니다. 처음이 아니라면, 기존 이벤트에 새 interestOps를 추가합니다.

ex) 기존이 OP_READ였고, 이제 WRITE도 원한다면, | READ | WRITE | 와 같이 큐 내부에 구현되는 것입니다.

 

그 이후, key.interestOps(ops)를 통해 Selector 쪽 실제 등록 상태를 업데이트합니다.

동시에 Tomcat wrapper 내부 관심값도 같이 업데이트 됩니다. 이는 attachment.interestOps(ops)로 업데이트됩니다.

 

그렇게 하나라도 이벤트가 처리되었다면, 반환값이 true로 나가며 Poller가 이번 루프에서 큐 이벤트 작업을 진행한 것을 나타냅니다.


Tomcat 코드 (NioEndpoint.java)

 

다시 돌아와서, slector.selectNow() 혹은 selector.select(timeout)을 호출합니다

JDK 코드 (Selector.java)

 

그리고 selectedKeys를 순회하며 각 key마다 processKey(sk, socketWrapper)를 호출합니다.

Tomcat (NioEndpoint.java)

 

위 코드를 보면, selectedkeys를 Iterator로 순회하며, processKey를 호출하는 것을 확인할 수 있습니다.


SelectedKeys는 뭘까요?

 

지금 I/O가 준비된 채널들의 (interestOps가 등록된 채널 중 이번 select 시점에 readyOps가 발생한) SelectionKey 집합입니다.

즉, READ 관심이 등록되어있으며, isReadable()로 준비가 된 애들만 들어오게 됩니다.


이때, processKey 내부를 보면, 아래와 같습니다.!! 여기가 중요합니다!

여기가 중요한 이유는, 여기서부터 톰캣 내부에서 workerThread의 분배 작업이 일어나기 때문입니다!! 

Tomcat (NioEndpoint.java)

 

processKey에서 read와 write의 ready분기가 일어나게 됩니다. (둘 다 준비되었는지 확인!)

이후, 일반 요청은

processSocket(...., true)로 Worker(threadPool) dispatch가 일어납니다.

즉, processKey는 이벤트를 Worker로 넘기거나 (file전송 포함) 즉시 처리하는 라우터의 역할을 합니다.

 

만약, FileData가 있는 경우 (정적 파일 또는 Large Response Body)에는 user-space에서 read → write로 복사하지 않으며 커널/채널  레벨에서 빠르게 밀어넣게 됩니다. 아래 코드를 통해 확인해보겠습니다.

Tomcat 코드 (NioEndpoin.java)

 

파일 채널을 준비하고, 네트워크 출력 채널을 선택하게 됩니다.

이때, TLS/SSL이면, 커널이 소켓으로 밀어넣어서 해결하는 빠른 전송의 이점은 사라지게 됩니다.

 

암호화 방법은?

SSLEngine 또는 JSSE가 처리하게 됩니다.

흐름을 보게 되면 Plaintext buffer → SSLEngine.wrap() → Encrypted TLS record → SocketChannel.write를 하게 됩니다.

AES-GCM 혹은 ChaCha20-Poly1305와 같은 TLS cipher suite가 사용됩니다.

사실상, 파일을 커널에 그대로 소켓 전송과 동일한 수준의 순수 sendfile 경로로 보기는 어렵습니다.


다시 돌아와서, 먼저 outbound buffer가 남아있으면 flush를 먼저 진행하게 됩니다.

Tomcat은 sendfile 중에도 이전에 쌓여있던 응답 버퍼가 남아있을 수 있기에 더 보내기 전에 이전 buffer를 flush합니다.

이후 outbound가 비어있게 된다면 FileChannel.transferTo로 파일을 전송하게 됩니다.

transferTo 파일 전송 로직을 보도록 하겠습니다.

JDK21 코드 (FileChannelImpl.java)

 

이때, long n; 아래쪽을 보면, transferToDirectly로직을 호출하는 것을 확인할 수 있습니다.

JDK21 코드 (FileDispatcherImpl.java)

 

여기서 실제 JNI 함수 내부 native 함수인 transferTo0를 호출하고 

JNI 코드 (FileDispatcherImpl.java)

 

 

JNI 레벨 코드 (FileDispatcherImpl.c)

이 JNI 내부 함수에서 C 함수를 호출하게 됩니다.

C레벨 코드 (socket.h)

 

그리고 이 C 함수가 결국 OS에 직접적으로 파일 전송을 담당하게 되는 것입니다.

엥 그럼 Poller 스레드와 Worker 스레드가 동시에 건드리면 동시성 문제가 발생하지 않나?

 

 

그렇기에, Poller 스레드와 Worker 스레드가 Selector를 동시에 건드리지 않도록 calledByProcessor 플래그로 등록 책임을 분리하며, 전송 완료 후에는 keep-alive 상태에 따라 연결을 종료하거나 OP_READ로 재등록해 다음 요청을 처리하도록 되어있습니다.

 

다시 돌아가 보겠습니다.

Tomcat (NioEndpoint.java)

 

만약,

  • isReadable()이면 processSocket(OPEN_READ,true) 
  • isWritable()이면 processSocket(.OPEN_WRITE,true)

를 진행하게 됩니다.

 

아래 코드를 통해 자세한 processSocket의 동작을 알아보겠습니다.

Tomcat 코드 (AbstractEndpoint.java)

 

작업 객체를 만들고, Executor로 내보낼지, 즉시 실행할지를 결정합니다.

만약, processorCache가 있으면 pop하고 재사용하며, 없으면 새로 생성합니다.

이때, execute.execute로 워커풀에 위임하거나, 현재 스레드에서 즉시 실행시키게 됩니다.

(이를 판단하는 기준은 코드로 확인 부탁드립니다...)

 

(createExecutor를 통해) 여기서 Executor를 생성하고 

AbstractEndpoint.java

 

여기서 (VirtualThread 미사용이라면) TaskQueue에 작업으로 들어가거나, 풀의 워커스레드가 가져가서 SocketProcessor.run()으로 실행됩니다.

Tomcat 코드 (NioEndpoint.java)

 

이 코드를 보다보면 굉장히 익숙한 단어가 등장하게 됩니다.

아래 메인로직 부분의 코드와 함께 설명드리겠습니다.

Tomcat코드 (NioEndpoint.java)

 

여기서도 handshake가 보이게 됩니다.

여기서 드는 의문점이 한가지 있으시리라 생각되어집니다.

TCP 3-way handshake는 이미 끝난건데?

 

예상하신 바와 같이 이는 tcp handshake가 아닌 TLS handshake입니다.

연결 성립 후, 어플리케이션 레벨에서 암호화 세션을 협상하는 단계입니다.

즉, HTTP면 바로 통과가 되지만, TLS 암호화를 시키는 HTTPS 코드이면 이 handshake의 과정을 겪게 되는 것입니다.

 

우선 Poller/Acceptor 스레드로 부터 올라온 socketWrapper + event를 워커 스레드(Tomcat 내부 Executor 스레드)가 받습니다.

이를 위와 같이 handshake값을 통해 TLS 완료 여부를 체크하고, 아래 코드로 HTTP 처리로 들어가게 됩니다. (드디어!!!!)

Tomcat 코드 (NioEndpoint.java)

 

그렇게 들어온 process()메소드에서 HTTP/1.1이면 Http11Processor가 request line/header를 파싱합니다.

Tomcat 코드 (AbstractProtocol.java)
Tomcat 코드 (AbstractHttp11Protocol.java)

 

create 후, 아래 코드와 같이 process() 를 진행하게 됩니다.

Tomcat 코드 (AbstractProtocol.java)

 

여기서, 상태에 따라 다양한 분기로 들어가게 됩니다.

Tomcat 코드 (AbstractProcessLight.java)

 

Status가 OPEN_READ면 service(socketWrapper)를 타게 됩니다.

 

여기서, HTTP/1.1인 경우, 아까 생성해놓은 processor가 Http11Processor이기에 Http11Processor.service가 실행됩니다.

여기서 여러 작업들이 이루어지는데, 가장 중요한 Spring으로의 요청 전달이 여기서 이루어집니다.!!

 

Tomcat 코드 (HTTP11Processer.java)

 

여기서 소켓에서 바이트를 읽어서 Request를 파싱하고, keepAlive가 true일 경우 이미 연결이 유지중인 상태에서 다음요청이므로 타임아웃 처리가 달라지게 됩니다.

이후, 헤더를 끝까지 못읽었다면 이 요청은 소켓을 열린 채로 다음 이벤트에서 이어서 읽게 됩니다.

그래서 openSocket=true,readComplete = false로 루프를 break시킵니다.

⭐️ 이게 바로 Tomcat NIO가 "읽을 데이터가 더 들어올 때까지 Poller 스레드로 돌아가는 방식입니다!!

 

이후 Upgrade 처리를 하며, Request를 준비합니다.

그리고 아래의 코드에서 Tomcat → Servlet Container (→ Spring)으로 진입하게 되는 코드를 확인할 수 있습니다.

Tomcat 코드 (HTTP11Processer.java)

 

getAdapter().service(request,response); 이 한줄이 결국 Coyote (Http 처리 계층) → Catalina (서블릿 컨테이너)로 넘어가는 브릿지가 되는 것입니다.

 

Adapter가 뭔데??

 

Tomcat 구조에서 대략적으로 설명을 하면,

  • Coyote : 네트워크/프로토콜 (HTTP) 파싱 계층
  • Catalina : Servlet spec 구현 (서블릿 컨테이너) 가 됩니다.
    (Spring만 있는 것이 아닙니다. Catalina 위에서 동작하는 모든 프레임워크가 해당됩니다.)

그리고, 이 둘을 연결하는게 Adapter 가됩니다.

Http11Processor.service()
  └─ getAdapter().service(request, response)  // CoyoteAdapter
       └─ (Catalina Engine/Host/Context 파이프라인)
            └─ StandardWrapperValve.invoke()
                 └─ servlet.service(req, res)
                      └─ (여기서 대상 servlet이 DispatcherServlet이면 Spring으로 진입)

 

위와 같은 계층 구조인 것입니다.! 코드로 보자면,

Tomcat 코드 (GetAdapter.java)

 

위 라인에서 Coyote → Catalina 파이프라인으로 진입하게 되고,

 

Tomcat 코드 (StandardEngineValv.java)

 

여기서 엔진에서 Host로의 라우팅이 발생하게 됩니다.

이후,

Tomcat 코드 (ApplicationFilterChain.java)

 

최종 Servlet을 호출하게 됩니다.


최종 정리 !!!

지금까지 Tomcat에서 TCP 요청부터 시작하여 HTTP 요청까지 어떻게 Spring으로 요청을 보내는지를 확인해봤습니다.

이를 정리해보면,

  1. Tomcat 시작 시 NioEndpoint.startInteranal에서 Poller스레드를 먼저 띄우고, 그 다음 Acceptor 스레드를 띄웁니다.
  2. 클라이언트가 TCP 연결을 시도하면 커널에서 3-way handshake를 완료하고 accept 가능한 연결로 큐에 둡니다.
  3. Acceptor.run()이 serverSocketAcceptor를 호출해 연결을 가져옵니다.
  4. 이 호출은 NioEndpoint.serverSock.accpet() → JDK NIO의 ServerSocketChannel.accpet()를 통해 → JNI → OS accept()로 내려갑니다.
  5. accept 성공 후 setSocketOptions()에서 NioSocketWrapper를 만들고 소켓 옵션/타임아웃/keep-alive 값을 세팅합니다.
  6. 이어서 poller.register(socketWrapper)로 Poller에 등록을 요청하고 PollerEvent를 큐에 넣습니다.
  7. Poller 스레드가 events()로 큐를 consume하고 Selector에 실제 SelectionKey(OP_READ 등)를 등록합니다.
  8. Poller는 select/selectNow로 ready 이벤트를 기다리다가 selectedKeys를 순회하며 processKey()를 호출합니다.
  9. processKey()는 이벤트를 분기해서 일반 요청이면 processSocket(..., true)로 worker thread pool에 디스패치합니다.
  10. worker 스레드의 SocketProcessor.doRun()에서 (필요시 TLS handshake 처리 후) getHandler().process()로 Coyote 처리에 들어갑니다.
  11. AbstractProtocol.ConnectionHandler.process()가 Http11Processor를 만들거나 재사용하고 processor.process(..)를 실행합니다.
  12. Http11Processor.service()에서 request line/header를 파싱하고 getAdapter().service()로 Catalina로 넘깁니다.
  13. Catalina 파이프라인 (Engine → Host → Context → Wrapper → FilterChain)을 거쳐 servlet.service()가 호출됩니다.
  14. 대상 서블릿이 DispatcherServlet이면 여기서부터 Spring MVC 내부 (HandlerMapping → Controller)로 진행됩니다.
  15. 응답 후 keep-alive 조건이면 연결을 재사용하고 아니면 소켓을 닫습니다.

한 번의 요청에 생각보다 내부적으로 많은 일들이 일어나고 있었습니다..


Tomcat Thread Pool은 어떻게 관리되는가?

여기까지 이해할 수 있었다면 앞서 말한 질문들이 어느정도 해결될 수 있었다고 생각합니다.

그 중 Tomcat Thread Pool의 관리는 한 번 짚고 넘어가면 좋을 것 같아서 다시금 정리하려 합니다.

Tomcat NIO Connector 기준으로 스레드의 역할이 분리됩니다.

  1. Acceptor Thread
    - 서버 소켓에서 accept()로 연결 수락
    - 새 연결을 setSocketOptions() 후 Poller 등록

  2. Poller Thread
    - Selector 기반으로 I/O readiness 감시 (select / selectNow)
    - ready 이벤트를 processKey()에서 해석해 Worker로 디스패치

  3. Worker Thread Pool
    - 실제 요청 처리를 진행 (Socket Processor)
    - processSocket(..., true) → executor.execute(sc)로 분배

그렇기 때문에, 내부 Executor를 쓰면 createExecutor()에서 생성되며 일반적으로 TaskQueue(Runnable 작업 큐) + ThreadPoolExecutor(관리하는 worker thread = 재사용 스레드) + TaskThreadFactory (스레드 생성)를 사용합니다. 이는 설정값 기반으로 관리하게 됩니다.


Java Multi-Thread 모델 vs. Node.js Event Loop 모델 비교

실행 모델 자체가 Java / Tomcat은 Multi 스레드 입니다. (요청 처리를 worker thread에 분배하는 방식이죠)

하지만, Node.js는 단일 이벤트 루프로 돌아가며 비동기 I/O 콜백을 사용하게 됩니다.

 

그렇기에, I/O 처리 작업도 달라집니다.

Java/Tomcat은 위에서 살펴본 바와 같이, Poller가 readiness 감시 후 worker로 위임하는 형식입니다.

이때, Node.js는 libuv 이벤트 루프가 콜백/Promise 체인으로 처리한다고 합니다.

그렇기에 Java는 worker thread들이 병렬 실행 가능하며 Node.js 는 메인 이벤트 루프를 막게 되면 전체 지연이 증가한다고 합니다. 

(이것도 알아보니 어떻게 처리하는 방법이 있다고 들었습니다...)

 


👀 마무리하며

Tomcat 소스 코드를 하나씩 따라가며 요청 처리 흐름을 분석하고, 이를 글로 정리하는 데에만 약 8시간 이상이 소요되었습니다.

그래도 이전에 오픈소스 기여해본 경험이 아주아주아주 큰 도움이 되었습니다.

 

처음에는 단순히 "Spring Controller까지 요청이 어떻게 전달될까" 라는 궁금증에서 시작했지만, 코드를 따라가다 보니 그 범위가 자연스럽게 OS의 TCP 연결 처리, NIO 기반 소켓 이벤트 처리 구조, 그리고 JVM 내부의 네트워크 처리 방식 (JDK/JNI)까지 이어졌습니다.

 

특히 Acceptor → Poller → Worker Thread → Http11Processor → Adapter → Servlet → DispatcherSevlet으로 이어지는 전체 흐름을 실제 코드 기반으로 확인해보면서, 평소 당연하게 사용하던 Spring 어플리케이션이 어떤 레이어 위에서 동작하고 있는지를 훨씬 명확하게 이해할 수 있었습니다. 무엇보다도, 이렇게 내부 구조를 하나씩 따라가며 이해하는 과정 자체가 굉장히 흥미로웠습니다.

 

평소에는 단순히 프레임워크를 사용하는 입장이었다면, 이번에는 그 내부가 어떤 방식으로 설계되어 있고 어떤 흐름으로 동작하는지를 직접 확인해볼 수 있었기 떄문입니다. 

 

앞으로도 기회가 된다면 Tomcat 뿐만 아니라 JVM, 네트워크 스택, 다양한 오픈소스 프레임워크의 내부 구조도 계속 알아보고 기회가 된다면, 기여도 해보고 싶습니다 😄

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.