Tomcat의 구조
- Server: tomcat의 최상위 인터페이스로 전체 컨테이너를 표현한다.
- Service: Server 안에 존재하며 Connector와 Engine을 연결해준다.
- Engine: 실제 요청을 처리하는 역할을 담당한다. Connector를 통해 요청을 받아 이를 처리한 후 응답을 보낸다.
- Host: 네트워크 이름을 나타낸다.
- Connector: 클라이언트와의 커뮤니케이션을 담당한다(이름 그대로 Connection을 처리한다).
- Context: Web application을 표현한다.
톰캣 튜닝을 할 때 우리가 주로 살펴볼 곳은 Connector이다. Connector의 주요 역할은 브라우저로부터 TCP 커넥션을 받아 Request, Response 객체를 생성하여 쓰레드가 이를 처리할 수 있도록 만드는 것이다.
Connector Options
Connector의 옵션(속성)은 공식 문서에서 확인 가능하다. 여기서 살펴볼 속성은 maxThread
, maxConnections
, acceptCount
이다.
비동기 요청이 아닌 경우, 해당 요청이 처리되는 동안 하나의 쓰레드가 필요하다. 만약 현재 쓰레드의 수보다 더 많은 요청이 동시에 들어오게 되면 우선 maxThread 속성에 지정된 값까지 쓰레드를 생성한다.
만약 그보다 더 많은 요청이 들어오면, 톰캣은 maxConnections에 설정된 수만큼 새로운 커넥션을 accept하고 이를 Connector에 의해 생성된 server socket에 큐잉해둔다.
만약 현재 요청이 maxConnections에 도달했는데, 추가적으로 더 요청이 들어온다면 운영체제가 추가 연결을 큐에 저장한다. 운영체제가 제공하는 큐의 사이즈는 acceptCount에 설정된 값에 의해 결정된다.
정리하면
- maxThread: 서버에서 생성할 수 있는 최대 Thread 수
- maxConnections: 서버에서 한 번에 열어둘 수 있는 Connection 수
- acceptCount: maxConnections보다 더 많은 요청이 들어올 때, Connection을 담아둘 대기열의 크기
Tomcat 9.0 기준으로 maxConnections의 default 값은 8192개다. maxConnections 값을 설정할 때는 시스템의 open file descriptor의 개수를 고려해야한다. 이것보다 maxConnections의 값을 높게 설정해도 의미가 없다. 어차피 limit에 도달하면 소켓을 할당할 file descriptor가 시스템에 없기 때문이다(또 다른 프로그램도 file descriptor 자원을 필요로 한다는 것을 고려해야한다).
실제로 확인해보기
이제 샘플 어플리케이션으로 해당 수치들을 조정하면서 실제로 어떻게 동작하는지 확인해보자.
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class ResponseController {
static final AtomicLong atomic = new AtomicLong(0L);
@GetMapping("/test")
public String response() throws InterruptedException {
Thread.sleep(1000); // 요청 처리에 1초가 걸린다고 가정
long count = atomic.incrementAndGet();
log.info("request processed = {}", count);
return "Hello " + count + "\n";
}
}
위와 같이 http://localhost:8080/test
로 GET 요청이 오면 서버에 로그를 찍고 문자열을 반환하도록 만들었다. 하나의 요청을 처리하는데 1초가 걸린다고 가정하였다.
SpringBoot를 사용한다면 별다른 설정이 없을 때 내장 톰캣을 사용한다. application.yml
설정을 통해 여러 속성 값을 지정할 수 있다.
server:
tomcat:
max-connections: 5
accept-count: 3
threads:
max: 1
설정을 살펴보면, 서버에서는 한번에 최대 5개까지 connection을 accept할 수 있으며, 그보다 더 많은 요청이 오면 3개까지 큐에 담을 수 있다. 그리고 서버에서 사용할 수 있는 쓰레드의 수는 1개로 제한했다.
이제 요청을 보내보자. curl
을 이용하여 간단하게 요청을 보내는 스크립트를 작성했다.
원래 java.net.http.HttpClient 를 사용했는데, maxConnections 이상의 요청을 한번에 보내면 뒤의 요청이 time-out으로 설정한 시간까지 pending되다가 HttpTimeoutException이 발생하는 버그가 있어서 그냥 curl을 사용했다.
test.sh
#!/bin/bash %
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://loca %lhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
curl --max-time 15 http://localhost:8080/test &
10개의 요청을 동시에 보내도록 작성했다. 하나의 요청이 처리되는데 1초가 걸리므로 timeout으로 인해 요청이 drop되지 않도록 max-time을 15초로 설정했다.
결과는 어떻게 될까? 우선 첫번째 요청을 처리하는데 1초가 걸리는데, 가용할 수 있는 쓰레드가 하나밖에 없으므로 동시에 여러 요청이 들어오면 나머지 요청은 대기해야한다.
서버에서 5개의 요청까지 accept할 수 있고 추가적인 요청이 들어오면 3개까지 큐에 담아 놓을 수 있기 때문에 결과적으로 8개의 요청만 처리할 수 있다.
server:
tomcat:
max-connections: 5
accept-count: 3
threads:
max: 1
만약 위와 같이 설정을 바꾸고 다시 10개의 요청을 보내면 어떻게 될까?
서버에서 한번에 accept할 수 있는 요청은 5개이고 최대 5개까지 쓰레드를 생성할 수 있으니, 5개의 요청이 동시에 처리될 수 있을 것이다. 또 5개의 쓰레드가 작업을 하는동안 최대 5개의 요청까지 큐에 담아 놓을 수 있으니 2초동안 10개의 요청을 처리할 수 있을 것이다.
부하테스트를 통해 적절한 설정값 찾기
그럼 실제로 톰캣으로 WAS를 구성할 경우 Connector의 설정 값을 어떻게 주는 것이 적절할까? 그냥 디폴트 설정을 쓰면 되는걸까?(Tomcat의 maxThreads 디폴트는 200이다) 검색을 해봐도 속 시원한 답을 찾기가 어려웠다. 그래서 실제로 설정을 변경해가면서 실제 부하를 부어보면서 상태를 관찰해보기로 했다.
내가 진행하고 있는 프로젝트의 운영 서버와 똑같은 환경으로 테스트 환경을 구성하였다. 다만 운영 환경에서 사용하고 있는 리버스 프록시는 적용하지 않았는데, 부하테스트 툴에서 설정한 부하량이 가능한 온전히 대상 서버에 도달하도록하기 위함이다. 원래는 네트워크의 간섭을 최대한 줄이기 위해 테스트 대상 시스템과 같은 네트워크에 속한 서버에서 부하를 가하는 것이 좋다고 하나(아마존 웹 서비스 부하테스트 입문 참고) 여건이 되지 않아 로컬에 부하테스트 툴을 설치하여 진행했다.
- 테스트 대상 서버 (AWS EC2 t4g.micro, tomcat 9.x)
- DB 서버 (AWS EC2 t4g.micro)
- DataSource: HikariCP, Connection Pool Size는 기본값(10)
- 부하테스트 툴: Ngrinder
- 모니터링 툴: Pinpoint
테스트 시나리오는 우리 서비스에서 가장 중요한 부분인 체크리스트 체크 로직으로 구성했다. 현재 실제로 제공하고 있는 데이터 양만큼 더미데이터를 넣고 진행했다.
테스트 스크립트를 실행하고 접속을 해봤는데 잘 돌아가는 것 같다. 아래처럼 사용자가 무작위로 체크리스트를 클릭하는 상황을 구성했다.
우선 maxThreads를 기본 설정 값인 200으로 두고 VUser를 50으로 설정하여 테스트를 진행했다. 처음에는 우아한테크코스 크루 인원(150명) 전체가 사용하는 상황을 가정하여 VUser를 2로 설정했는데, 부하가 적절하게 가해지지 않아서 TPS가 더이상 오르지 않을 때까지 VUser를 높여가며 최대로 버틸 수 있는 부하를 찾아갔다.
maxThreads를 기본 값인 200에서 점점 줄여가면서 TPS와 응답 시간을 모니터링 했는데, 아래처럼 쓰레드 개수가 줄어들수록 TPS는 큰 차이가 없는데 응답 시간은 줄어드는 것을 확인할 수 있었다.
maxThreads = 200
maxThreads = 100
maxThreads = 10
위처럼 maxThreads가 10일 때 응답 시간이 가장 안정적으로 측정되었다. 원인을 살펴보니 쓰레드 수가 많아질수록, getConnection()을 호출하는 부분에서 병목이 발생하는 것을 확인할 수 있었다. DB의 Connection Pool 사이즈가 10으로 설정되어 있기 때문에 요청을 받아들이더라도 DB에 접근할 수 없어 응답 시간이 느려지는 것이라 예상할 수 있었다. 테스트를 진행하며 최대 쓰레드 수와 DB 커넥션 수를 비슷하게 맞춰주는 것이 좋다는 자료를 본 적이 있는데, 왜 그런지 직접 눈으로 확인해볼 수 있었다.
생각보다 고려해야할 점이 너무 많아서 이 정도로 마무리하고 추가적으로 학습해야할 항목들만 점검해보고 마무리했다.
부하테스트를 통해 적절한 성능을 찾으려면 하드웨어 스펙, WAS 및 DBCP 설정, 네트워크 등 고려해야할 변수가 너무 많은 것 같다. 하지만 실제로 OS, JVM, DB 등의 상태를 모니터링하면서 책으로는 얻을 수 없는 경험적 학습을 할 수 있어서 좋았다.
BIO connector vs NIO connector
Connector 설정과 관련해서 공부하다보니 자연스레 Nio Connector가 어떤 방식으로 동작하는지 궁금하여 공부하게 되었다. 공부한 내용을 간략하게 정리한다.
Tomcat 6.0 부터 java Nio를 이용한 NIO Connector가 추가되고 9.0부터는 BIO Connector가 아예 사라졌다.
package org.apache.catalina.connector;
public class Connector extends LifecycleMBeanBase {
...
public Connector(ProtocolHandler protocolHandler) {
protocolHandlerClassName = protocolHandler.getClass().getName();
configuredProtocol = protocolHandlerClassName;
this.protocolHandler = protocolHandler;
// Default for Connector depends on this system property
setThrowOnFailure(Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE"));
}
}
생성자에 어떤 Protocol 구현체를 넣느냐에 따라 달라진다. Http11Protocol 인스턴스를 넣으면 BIO, Http11NioProtocol 구현체를 넣으면 NIO.
BIO 방식에서는 Http Connection과 작업 쓰레드가 1대1로 매핑되어 처리를 전담한다. 문제는 커넥션이 열리고 데이터를 읽는 시간동안 작업 쓰레드가 idle 상태가 된다는 것이다(커넥션이 열리고 데이터가 available한 상태가 될 때까지 작업 쓰레드는 하는 일 없이 block된다).
NIO의 경우 Poller
라는 중간 계층 쓰레드에 커넥션을 캐싱해놓고, 해당 소켓의 데이터를 읽을 수 있는 상태일때만 작업 쓰레드에 할당한다.
출처: https://www.programmersought.com/article/1699692284/
BIO Connector의 경우 중간에 Poller라는 컴포넌트 없이 Acceptor와 Worker가 바로 이어진다고 보면 된다.
따라서 기존의 BIO와 비교할 때, 쓰레드가 지금 당장 데이터를 읽을 수 없음에도 불구하고 쓸데없이 idle되는 일이 없다.
NIO Endpoint
출처: https://www.baeldung.com/spring-webflux-concurrency
Acceptor
는 이름 그대로 소켓을 accept하는 일을 전담하는 쓰레드이다.
Acceptor에서 소켓을 accept하고 이를 PollerEvent로 감싼 후 이를 Queue에 담아 놓는다.
package org.apache.tomcat.util.net;
public class Acceptor<U> implements Runnable {
...
@Override
public void run() {
try {
// Loop until we receive a shutdown command
while (!stopCalled) {
...
//if we have reached max connections, wait
endpoint.countUpOrAwaitConnection();
try {
...
socket = endpoint.serverSocketAccept();
} catch (Exception ioe) {}
// Successful accept, reset the error delay
errorDelay = 0;
// Configure the socket
if (!stopCalled && !endpoint.isPaused()) {
// setSocketOptions() will hand the socket off to
// an appropriate processor if successful
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
}
}
}
}
Nio 방식을 사용하는 경우, endpoint 구현체는 NioEndpoint 클래스인데 여기 정의된 setSocketOption() 메서드를 보면 PollerEvent
큐에 소켓을 등록하는 것을 확인할 수 있다.
NIO Connector의 경우 이렇게 하나의 쓰레드가 소켓들을 큐에 담아놓고 작업이 가능할 때 작업 쓰레드가 하나씩 가져가 처리할 수 있기 때문에 쓰레드를 더 적게 사용할 수 있다.
쉽게 말하면, BIO의 경우 커넥션이 열릴 때마다 작업 쓰레드를 생성해야되는데 NIO는 커넥션을 큐에 담아놓으면 idle 상태인 작업 쓰레드가 꺼내서 처리하면 된다.
같은 쓰레드 개수를 사용한다면 NIO 방식이 더 많은 커넥션을 처리할 수 있다. 실제로 tomcat 7.0버전의 문서를 보면 maxConnections 의 default 값이 Nio가 훨씬 더 크다.
참고 자료
https://tomcat.apache.org/tomcat-9.0-doc/config/http.html
https://www.programmersought.com/article/1699692284/
https://howtodoinjava.com/tomcat/tomcats-architecture-and-server-xml-configuration-tutorial/
https://www.baeldung.com/spring-webflux-concurrency
https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat
'웹 개발' 카테고리의 다른 글
[IntelliJ] 인텔리제이(얼티밋)에서 JSP/Servlet 개발환경 설정하는 법 (0) | 2021.02.16 |
---|
댓글