-
Spring Boot가 다중 요청을 처리하는 방법Spring Framework 2023. 9. 14. 00:01728x90
개요
Tomcat의 Thread에 대해 알아보다가 accept-count (작업큐의 사이즈)를 1로 설정했는데도 여러 개의 요청을 처리하는 모습을 보고 내부가 어떻게 동작하길래 이런 일이 가능한 건지 궁금해져서 알아보고자 합니다.
accept-count란?
max-connections 개수만큼 Tomcat 커넥터가 TCP 커넥션을 가지고 있을 때, 추가적인 요청이 오면 accept-count의 개수만큼 백로그에 저장합니다. (커넥션이 늘어나지는 않음)
스레드풀을 활용하여 요청을 처리한다!
Queue에 Task를 담아두고 놀고 있는 스레드가 존재하면 해당 작업을 수행합니다.
여기서 accept-count는 Queue의 크기에 해당합니다.
이때 Queue에 크기를 1으로 설정하고, Thread의 크기를 1로 설정한다면?
"요청을 하나씩 처리하면서 동시요청을 처리하지 못하겠지"라는 생각을 하게 되었습니다.
"심지어 요청 1개 Queue에 대기 중일 때 새로운 요청이 들어오면 이 요청은 거부되어야 해"라는 생각을 하게 되었습니다.
설정해 놓고 테스트해 보기
server: tomcat: threads: max: 1 accept-count: 1
이후 5초를 sleep 하는 api를 만들어보면 응답을 5초 단위로 텀을 두고 순차적으로 처리되는 모습을 볼 수 있습니다.
Thread의 크기는 1건이라 순차적으로 처리하는 건 알겠는데 Queue의 크기는 1개인데 어떻게 나머지 요청을 받아놓은 걸까요?
심지어 max-connections도 1로 설정해 두었는데..
NIO Connector
톰캣 8.0부터는 NIO(NonBlocking I/O) Connector가 기본으로 채택되고 있습니다.
Connector는 소켓 연결을 수립하고 데이터 패킷을 획득하여 HttpServletRequest 객체로 변환하고, Servlet 객체에 전달하는 역할을 수행합니다.
NIO Connector를 간단하게 소개하면 Poller라는 단일 스레드가 Selector를 이용하여 처리가 가능한 순간에만 Thread를 활성화시키면서 idle Thread를 줄입니다. (BIO Connector는 Response를 보내고 끝내는 것이 아니라 TCP Connection이 만료될 때까지 Thread가 활성 상태로 남아있음)
이때 NIO Connector는 Poller라고 불리는 별도의 스레드가 커넥션을 처리합니다.
Socket을 캐시로 들고 있다가 해당 Socket에서 data에 대한 처리가 가능한 순간에만 thread를 할당하는 방식을 사용해서 thread가 idle 상태로 낭비되는 시간을 줄여줍니다.
Acceptor
Socket Connection을 승인(accept)합니다.
Socket에서 Socket Channel 객체를 얻어 톰캣의 NioChnnael 객체로 변환합니다.
NioChnnal 객체를 PollerEvent라는 객체로 한번 더 캡슐화하여 event queue에 넣게 됩니다.
즉, Acceptor는 event Queue의 producer입니다.
Poller
poller는 event Queue의 consumer입니다.
하나의 Poller라는 단일 스레드가 Selector를 사용하여 하나의 스레드로 여러 채널을 처리합니다.
Selector에 PollerEvent에서 받은 Chaanel을 등록하며, select() 동작으로 데이터를 읽을 수 있는 소켓을 얻고, Worker Thread Pool에서 이용할 수 있는 Woker Thread를 얻어 해당 소켓을 넘깁니다.
Selector를 사용하여 data 처리가 가능할 때만 Thread를 사용하기 때문에 idle 상태로 낭비되는 Thread가 줄어들게 됩니다.
디버깅 - Acceptor
public class Acceptor<U> implements Runnable { @Override public void run() { while(!stopCalled) { ... U socket = null; try { // Accept the next incoming connection from the server // socket socket = endpoint.serverSocketAccept(); } catch (Exception ioe) { ... } // Successful accept, reset the error delay errorDelay = 0; // Configure the socket if (!stopCalled && !endpoint.isPaused()) { if (!endpoint.setSocketOptions(socket)) { endpoint.closeSocket(socket); } } else { endpoint.destroySocket(socket); } } } }
Acceptor는 tomcat-embed-core 라이브러리에 존재하는 클래스입니다, Runnable을 상속받고 있는 Thread로 볼 수 있습니다.
SpringBoot Application이 실행될 때 run 메서드가 실행되며 shutdown command가 수신될 때까지 while문을 수행합니다.
많은 부분을 중략하였는데 다음과 같은 일을 수행합니다.
- max-Connections에 도달하였는지 검사한다.(AbstractEndpoint 클래스의 countUpOrAwaitConnection메서드)
- maxConnections 세팅값으로 LimitLanch 클래스를 생성하고 maxConnections이 넘어가는 순간 대기하면서 기다린다
- serverSocketAccept를 진행한다 (Socket Channel으로 반환)
- setSocketOptions메서드에 Socket Channel -> NioChannel -> NioSocketWrapper로 변환합니다.
- poller.register 메서드를 통해 PollerEvent라는 객체를 만들고, queue를 하나 만들어 넣는대 이때 queue의 크기가 128입니다.
- 이때 poller는 Selector 객체를 가지고 있기 때문에 이를 통해 활용하여 데이터 처리가 가능할 때만 Worker Thread를 사용합니다.
이와 같은 이유로 poller queue에 미리 PollerEvent객체로 저장하여 필요시에 사용하기 때문에 사용할 수 있는 스레드수와는 관계없이 더 많은 요청받아놓고 기회가 된다면 수행할 수 있습니다.
그러면 Accpet Count의 설정은 어디에 쓰이는 거지?
private SocketAddress netBind(SocketAddress local, int backlog) throws IOException { InetSocketAddress isa; if (local == null) { isa = new InetSocketAddress(Net.anyLocalAddress(family), 0); } else { isa = Net.checkAddress(local, family); } @SuppressWarnings("removal") SecurityManager sm = System.getSecurityManager(); if (sm != null) sm.checkListen(isa.getPort()); NetHooks.beforeTcpBind(fd, isa.getAddress(), isa.getPort()); Net.bind(family, fd, isa.getAddress(), isa.getPort()); Net.listen(fd, backlog < 1 ? 50 : backlog); return Net.localAddress(fd); }
Spring Boot 기동시 최초 초기화 과정에서 ServerSocketChannelImpl클래스의 netBind에서 backlog가 바로 accetp-count 설정을 반영합니다.
Net.listen 메서드를 호출할 때 backlog가 1보다 작은 경우에는 50으로 설정하여 호출합니다.
listent 메서드는 C/ C++으로 작성된 native입니다.
static native void listen(FileDescriptor fd, int backlog) throws IOException;
accept-count는 그럼 필요 없는 옵션인가?
수동으로 순차적으로 api를 호출하였고, poller queue에 적재되는 속도가 빨랐기 때문에 문제없이 동작했으므로 추측하였습니다.
실제 성능테스트로 동시요청을 주게 되면 connection refuse가 발생하는지 궁금하여 테스트해보고자 합니다.
다음과 같은 설정을 주고 Jmeter로 10개의 스레드로 성능테스트를 수행해 보았습니다.
server: tomcat: threads: max: 1 # 생성할 수 있는 thread의 총 개수 accept-count: 1 # 활성 스레드가 없을 때 대기열의 개수 max-connections: 1 #연결할 수 있는 Connector 수
9건은 실패할 것으로 예상했지만 예상과는 다르게 Connect Time이 50초가 초과된 경우에만 timeout이 발생합니다.
server.tomcat.connect-timeout: 1s로 지정후 다시 테스트 -> 동일한 결과
server.tomcat.connect-timeout: -1로 지정후 다시 테스트 -> 동일한 결과
accept-count를 조금 더 늘려서 테스트
server: tomcat: threads: max: 1 # 생성할 수 있는 thread의 총 개수 accept-count: 10 # 활성 스레드가 없을 때 대기열의 개수 max-connections: 1 #연결할 수 있는 Connector 수
이번에는 에러 없이 모든 요청이 잘 처리되었습니다.
이로써 accept-count가 1로 작게 설정되어 대기열이 가득차있는 경우에는 요청이 거부되어 timout이 발생했었음을 확인할 수 있었습니다.
마지막으로 Jmeter 30개 쓰레드로 주고 테스트
다시 timeout 에러가 발생하는 모습을 볼 수 있습니다.
참고자료
https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
'Spring Framework' 카테고리의 다른 글
Spring @Transactional의 rollback 동작과정 디버깅 (0) 2023.10.30 Spring Event 동작 원리 - 4 (0) 2023.09.16 Spring Event 동작 원리 -3 (0) 2023.09.13 Spring Event 동작 원리 - 2 (0) 2023.09.12 Spring Event 동작 원리 - 1 (0) 2023.09.11 - max-Connections에 도달하였는지 검사한다.(AbstractEndpoint 클래스의 countUpOrAwaitConnection메서드)