- [I/O 이해하기-1] 동기와 비동기 VS Blocking과 Non Blocking
- [I/O 이해하기-2] Stream vs Channel
- [I/O 이해하기-3] 톰캣의 요청 수신 방식
이전의 [I/O 이해하기 1,2]를 통해 I/O 작업에 사용되는 개념에 대해 학습을 하였다.
개념에 대한 내용은 1,2편에서 설명하였으므로 이 글에서는 개념과 용어에 대해 세세한 설명은 하지 않을 것이다.
나는 개인적으로 톰캣이 어떻게 요청을 처리하는지 알고 싶어 동기와 비동기, Blocking과 Non Blocking, Stream과 Channel을 학습하였다.
톰캣 9 이후 비동기-NonBlocking으로 동작한다고 하여 클라이언트 요청에 대한 모든 I/O 작업을 비동기-NonBlocking으로 동작할것이라 예상했지만 결과는 전혀 그렇지 않았다.
해당 내용을 이번 포스팅을 통해 소개하겠다.
톰캣의 요청 수신 방식
톰캣은 전통적인 I/O 방식인 BIO 방식에서 9버전 이후 부터는 default로 NIO 방식을 통해 I/O 작업을 진행한다.
NIO(New I/O)는 비동기-NonBlocking 방식으로 IO 작업이 이루어지는 것을 말한다.
I/O 작업이 일어나는 부분은 Endpoint라 부르며 이 EndPoint에서 모든 요청-응답에 대한 IO 작업이 이루어진다.
이로 인해 병목현상이 일어날 수 있다.
따라서 BIO와 NIO를 통한 IO 작업 방식의 차이가 어떤 효율의 차이를 일으키는지 이해하는 것이 중요하다.
BIO vs NIO
BIO
BIO 방식에서 클라이언트 요청 처리에 사용되는 스레드는 Acceptor와 Worker 스레드가 있다.
Acceptor는 클라이언트 요청을 승인하고 반환받은 클라이언트 소켓을 worker 스레드에 넘기고
Worker 스레드는 요청 데이터를 읽어들이고 클라이언트의 요청 작업을 처리한다.
NIO
NIO 방식에서 클라이언트 요청 처리에 사용되는 스레드가 3가지가 있다.
- Acceptor 스레드 : 하나의 스레드로 구성되며 클라이언트 요청을 수신하는 역할을 한다.
- Poller 스레드 : 하나의 스레도 구성되며 클라이언트의 요청 데이터가 도착했는지 여부(이벤트)를 확인하고, worker 스레드에 할당하는 역할을 한다.
- Worker 스레드 : 스레드 풀에서 관리되는 스레드로 실절적인 요청 처리를 담당한다.
위에서 톰캣이 NIO 방식의 입출력을 할 수 있게 하는 핵심은 Poller 스레드의 Selector에 있다.
Selector를 통해 IO 이벤트를 수신받고 worker 스레드에 작업을 할당할 수 있다.
worker 스레드는 소켓을 통해 요청 데이터를 읽어들이고 요청 처리를 시작한다.
이때, worker 스레드는 이미 요청 데이터가 다 도착한 상황에서 읽어들이므로 대기 없이 바로 읽어들일 수 있다.
BIO 방식에서는 요청 데이터 도착여부에 관계 없이 요청을 승인하면 스레드와 IO 자원을 할당 받고
NIO 방식에서는 요청 데이터가 모두 도착한 이후에 스레드와 IO 자원을 할당 받는다.
이로인해 NIO는 IO로 인한 대기시간을 줄여 스레드 점유시간을 줄일 수 있다.
톰캣의 사용자 요청 처리과정
1. Acceptor 스레드에서 클라이언트 요청 승인
[Acceptor 클래스]
public void run() {
// 서버 구동이 중지 되지 않는 이상 무한루프를 돔
while(!this.stopCalled) {
. . .
// 클라이언트 요청 승인
socket = this.endpoint.serverSocketAccept();
. . .
// 클라이언트 소켓 옵션 설정
if (!this.endpoint.setSocketOptions(socket)) {
this.endpoint.closeSocket(socket);
}
}
}
acceptor 스레드의 run 메서드를 보면 위와 같이 while문을 통해 무한루프를 돌며 클라이언트 요청을 승인한다.
이때 클라이언트 소켓이 반환되고 클라이언트 소켓의 옵션을 설정한다.
여기서 사용되는 endpoint의 구현체가 NioEndpoint이고 NioEndpoint의 serverSocket을 통해 요청을 승인하고 클라이언트 소켓을 반환한다.
[NioEndpoint 클래스]
protected SocketChannel serverSocketAccept() throws Exception {
// 서버소켓을 통해 요청을 승인하고 클라이언트 소켓 반환
SocketChannel result = this.serverSock.accept();
if (!JrePlatform.IS_WINDOWS && this.getUnixDomainSocketPath() == null) {
SocketAddress currentRemoteAddress = result.getRemoteAddress();
long currentNanoTime = System.nanoTime();
if (currentRemoteAddress.equals(this.previousAcceptedSocketRemoteAddress) && currentNanoTime - this.previousAcceptedSocketNanoTime < 1000L) {
throw new IOException(sm.getString("endpoint.err.duplicateAccept"));
}
this.previousAcceptedSocketRemoteAddress = currentRemoteAddress;
this.previousAcceptedSocketNanoTime = currentNanoTime;
}
return result;
}
잠깐. 서버소켓과 클라이언트 소켓의 차이
서버 소켓은 서버 프로세스에 한 개 존재하는 소켓으로 클라이언트의 요청을 승인하는 역할을 한다.
accept로 클라이언트의 요청을 승인하며 클라이언트 소켓을 생성한다.
이후 bind를 통해 클라이언트 소켓에 ip와 port번호를 할당한다.
클라이언트 소켓은 클라이언트와 서버간에 통신을 하기 위해 서버측에서 생성한 소켓이다.
때문에 서버에는 여러 클라이언트 소켓이 존재할 수 있다.
소켓 식별자는 (클라이언트 ip, port, 서버 ip, port)로 이루어져있다.
클라이언트가 자신의 ip와 소켓 port, 서버의 ip와 서버 소켓의 port 번호로 요청을 전달한다.
서버 소켓은 요청을 받고 위 정보들을 통해 클라이언트 소켓에 매핑시킨다.
이와 같은 방식으로 서버가 여러 클라이언트 소켓을 관리하며 연결을 수립할 수 있다.
2. NIO 방식으로 클라이언트 소켓 설정
accept를 통해 반환한 클라이언트 소켓을 Nio 소켓으로 래핑한다.
[NioEndpoint 클래스]
protected boolean setSocketOptions(SocketChannel socket) {
. . .
// NioChannel을 NioSocketWrapper로 래핑
NioSocketWrapper newWrapper = new NioSocketWrapper((NioChannel)channel, this);
// NioSocketWrapper에 등록한 NionChannel에 클라이언트 소켓을 주입함
((NioChannel)channel).reset(socket, newWrapper);
this.connections.put(socket, newWrapper);
// 클라이언트 소켓 NonBlocking으로 설정
socket.configureBlocking(false);
// poller에 NioSocketWrapper 등록
this.poller.register(newWrapper);
return true;
}
NioChannel을 만들어 NioSocketWrapper로 래핑하고 등록한 NioChannel에 클라이언트 소켓을 주입한다.
그리고 클라이언트 소켓을 NonBlocking으로 설정한 후, poller에 등록한다.
(NioSocektWrapper는 비동기-NonBlocking방식으로 I/O 작업을 가능하게 하는 소켓 래퍼)
3. Poller를 통한 IO 이벤트 등록
[Poller 클래스]
public class Poller implements Runnable {
// 이벤트를 수신 받는 selector 객체
private Selector selector = Selector.open();
// 이벤트 큐 공간
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue();
// 생략
public void register(NioSocketWrapper socketWrapper) {
// 데이터 읽기 이벤트를 수신 받겠다고 설정함
socketWrapper.interestOps(1);
// PollerEvent 등록 및 생성
PollerEvent pollerEvent = this.createPollerEvent(socketWrapper, 256);
this.addEvent(pollerEvent);
}
private void addEvent(PollerEvent event) {
this.events.offer(event);
}
// 생략
}
Poller는 PollerEvent를 관리한다.
PollerEvent는 단순히 NioSocket과 interestOps값을 가지고 있는데
interestOps는 데이터 읽기(1), 데이터 쓰기(4)와 같은 이벤트로 이루어져 있으며 이를 등록하여 해당 IO 이벤트 발생시 수신할 수 있다.
4. Poller를 통한 IO 이벤트 수신
[poller의 run 함수]
public void run() {
// 무한 루프를 돔
while(true) {
. . .
// 등록된 이벤트가 있는지 체크, 새로운 이벤트 등록
hasEvents = this.events();
// 이벤트 수신하고 이벤트 갯수를 keyCount에 저장
this.keyCount = this.selector.selectNow();
// 이벤트 존재시 selector를 통해 이벤트 가져옴
Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;
while(iterator != null && iterator.hasNext()) {
SelectionKey sk = (SelectionKey)iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper)sk.attachment();
if (socketWrapper != null) {
// 이벤트 처리 요청
this.processKey(sk, socketWrapper);
}
}
}
}
[poller의 events 함수]
public boolean events() {
boolean result = false;
PollerEvent pe = null;
int i = 0;
// 등록된 이벤트 확인
for(int size = this.events.size(); i < size && (pe = (PollerEvent)this.events.poll()) != null; ++i) {
...
if (sc == null) {
...
// 새로운 이벤트 등록
} else if (interestOps == 256) {
try {
// 읽기 가능 이벤트 등록
sc.register(this.getSelector(), 1, socketWrapper);
} catch (Exception var12) {
NioEndpoint.log.error(AbstractEndpoint.sm.getString("endpoint.nio.registerFail"), var12);
}
}
...
}
return result;
}
poller의 run 함수를 보면 무한루프를 돌며 Selector가 이벤트를 수신한다.
events라는 메서드를 통해 현재 등록된 소켓들의 IO 이벤트가 있는지 체크하고 새로운 소켓의 이벤트를 등록하기도 한다.
selector를 통해 수신 가능한 이벤트의 존재여부를 체크하고 이벤트가 있다면 processKey 메서드에 이벤트와 소켓을 넘긴다.
5. Worker 스레드 할당 및 request, response 생성
Poller.run()
-> processKey()
-> processSocket()
-> executor.execute(sc);
Poller의 executor.execute를 통해 Worker 스레드에 소켓을 할당한다.
SocketProcessor.run()
-> doRun()
-> state = NioEndpoint.this.getHandler().process(this.socketWrapper, this.event);
-> Http11Processor.service()
Worker 쓰레드에 사용되는 구현체가 Http11Processor이다.
여기서 소켓을 통해 inputBuffer를 가져와 요청 데이터를 읽어들여 request와 response객체를 만든다.
여기서 읽어들이는 데이터는 http 헤더이다.
this.getAdapter().service(this.request, this.response);
위 메서드를 실행시켜 CoyoteAdapter에게 request, response를 전달한다.
[잠깐] 비동기-NonBlocking IO을 사용하는 이유는 동시에 다른 작업을 하는 것만이 아니다.
Http11InputBuffer에서 http 헤더를 읽어들이는 로직을 보면 헤더를 읽는 동안 다른 작업을 진행하는 것은 아니다.
다만, 비동기-NonBlocking을 이벤트 패턴으로 구현하며 이미 데이터가 도착했다는 것을 알고 작업이 수행되기에 CPU의 대기시간을 줄일 수 있다.
이전에 1편에서 비동기-NonBlocking의 개념을 설명하며 비동기-NonBlocking의 주요한 특정이 호출 메서드 완료 여부와 상관없이 다른 작업을 수행할 수 있다고 했는데, 이는 그렇게 동작시킬 수 있다는 것이 꼭 그렇게 구현해야한다는 것은 아니다.
톰캣 9의 NIO 방식에서 헤더를 읽는 시점에, 이미 클라이언트마다 독립된 스레드를 할당하였기에 동시에 수행할말한 다른 작업이 없다. 주요 목적은 이미 데이터가 도착했다는 것을 전제로 대기 시간 없이 즉시 읽어들이는 것이다.
Poller 스레드에서 데이터 도착 이벤트를 받고 Worker 스레드에 넘겨줬기에 이러한 처리가 가능한 것이다.
비동기-NonBlocking을 사용하는 이유가 꼭 동시에 여러 작업을 순서 상관 없이 하기 위함 뿐만 아니라
성능 향상을 위한 목적으로 사용될 수도 있다.
(Http11InputBuffer.parseRequestLine 메서드 참고)
6. Filter로 요청 전달
CoyoteAdapter.service()에서 아래 메서드를 통해 Valve들을 호출한다.
this.connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
Valve는 필터처럼 체인형태의 파이프라인으로 구성되어있으며 request와 response의 전처리 작업을 수행한다.
에러를 기록하거나 SSL인증, 동시요청 스레드 제한 등의 Valve가 있다.
필터로 요청을 전달하는 Valve는 StandardWrapperValve이다.
filterChain.doFilter(request.getRequest(), response.getResponse());
StandardWrapperValve는 컨테이너에 서블릿이 등록되어있는지 검증하고 필터체인을 실행시킨다.
7. Servlet으로 요청 전달
필터체인의 마지막 필터인 ApplicationFilterChain이 서블릿을 실행시킨다.
this.servlet.service(request, response)
나의 경우 스프링 부트 프로젝트였기에 DispatcherServlet이 실행되는데
DispatcherServlet의 ArgumentResolver가 inputStream을 통해 request객체의 body를 읽어들이고
응답을 보낼땐 HttpMessageConverter를 통해 outputStream으로 응답을 보냄을 확인할 수 있다.
디버깅 자료가 필요하다면 [서블릿과 스프링 디스패처 서블릿] 역할과 동작 과정 글을 참고하면 된다.
[참고] 톰캣의 maxHttpHeaderSize는 channel의 버퍼 크기이다.
톰캣의 maxHttpHeaderSize의 기본 크기는 8KB이다.
http 프로토콜의 uri paramter가 너무 길지 않는 이상 헤더가 8kb를 넘기지 않는다고 한다.
헤더가 8kb를 넘어가는 요청의 경우 413 Request Entity Too Large 에러가 발생하게 된다.
이를 해결하기 위해 maxHttpHeaderSize의 크기를 늘리게 되면 channel의 버퍼 크기도 커지게 된다.
그렇게 되면 버퍼로 인해 점유하는 메모리가 늘어 다른 곳에 사용될 메모리가 부족해질 수 있다.
결론 및 정리
이렇게 해서 톰캣의 요청 처리 방식에 대해 알아보았다.
톰캣은 클라이언트 요청 승인과 http header는 비동기 non-blocking 방식으로 동작하지만
body는 stream을 통해 동기-blocking으로 동작한다.
이전에 대용량 데이터의 경우 스트림을 통한 io작업이 효율적일수 있다고 하였는데
톰캣은 상대적으로 적은 양의 데이터인 http header는 channel을 통해 비동기 non-blocking 방식으로 동작하고
큰 용량인 body의 경우 stream을 통해 동기 blocking으로 읽어들인다.
'Language > 자바&코틀린' 카테고리의 다른 글
Garbage Collection의 동작 방식과 종류 (0) | 2024.07.06 |
---|---|
객체지향 프로그래밍이란 : OOP (0) | 2024.06.30 |
[I/O 이해하기-2] Stream vs Channel (0) | 2024.05.09 |
[I/O 이해하기-1] 동기와 비동기 VS Blocking과 Non Blocking (0) | 2024.05.05 |
코틀린 querydsl, mapstruct 생성자에 따른 동작 방식 (0) | 2024.03.16 |