예제 및 테스트 코드는 github 에서 확인 가능합니다.
Apache Tomcat 설정에 대해 알아보자
안녕하세요 이번에는 스프링에서 쓰이는 Apache Tomcat 의 설정 옵션들
그리고 Thread 설정에 따라 어떻게 동작하는지에 대해 한번 알아보려합니다.
Tomcat 의 설정 옵션
application.yml
server:
tomcat:
max-connections: 8192
accept-count: 100
threads:
max: 200
min-spare: 10
다음은 스프링 부트에서 톰캣을 설정에 대한 옵션입니다.
각 옵션별로 하나씩 알아보겠습니다.
threads.max
생성할수있는 최대 thread 의 갯수이며 실제 Active User 수를 뜻합니다.
즉, 순간 처리가능한 트랜잭션의 수를 의미합니다.
threads.min-spare
항상 활성화 되어있는(idle) 스레드 갯수입니다.
accept-count
maxConnections 이상의 요청이 들어왔을때 대기하는 대기열(Queue)의 길이입니다.
큐가 꽉 찼을 때 수신된 모든 요청은 거부됩니다.
해당 설정은 OS 에서 관리하는 설정이며 무시될 수도 있습니다.
max-connections
서버가 수락하고 처리할 수 있는 최대 연결수를 의미합니다. 이 숫자에 도달하면 서버는 연결을 추가로 수락하지만 처리는 하지 않습니다.
처리 중인 연결 개수가 maxConnections 아래로 떨어지면 다시 새 연결을 수락하고 처리하기 시작합니다.
(주의할 점은 이 수는 실제 연결된 Connection 수가 아닌 Socket File Descriptor 의 수 입니다.)
maxConnections 에 연결이 가득 차더라도 운영체제는 acceptCount 만큼 추가 연결을 허용합니다.
Socket 은 Connection 이 끝난 후 바로 반환되지 않고 Connection 종료 후 FIN 신호, TIME_WAIT 시간을 거쳐서
Socket 의 Connection 을 종료합니다.
그리고 Http 의 KeepAlive 옵션을 사용하게되면 요청을 처리하지 않는 Connection 수도 유지되기에, 요청 처리 수보다
실제 연결되어있는 Connection 수가 높을 수 있습니다.
이러한 이유때문에 maxConnections 은 넉넉하게 설정하는것이 좋습니다.
NIO/NIO2의 경우에는 10000개, APR/native의 경우에는 8192개가 기본값입니다.
정리하자면 Tomcat 의 스레드 풀 매커니즘은 다음과 같습니다.
1) Tomcat 서버가 처음 실행될때 스레드 풀에 min-spare 만큼의 유휴 스레드를 생성합니다.
2) 이후 요청이 유휴 스레드보다 많은 요청이 들어오게 되면 maxConnections 이 증가하고 요청을 처리할 스레드 수가 증가합니다.
(최대 maxThreads 만큼 증가)
3) 요청이 많아 maxConnections 가 가득차고 이후 추가 요청이 들어온다면 acceptCount 만큼 추가로 연결을 허용합니다.
Java 의 ThreadPoolExecutor 의 경우 queueSize 가 가득차면 이후 스레드가 증가하는것과는 다른 방식입니다.
Http Connector
Tomcat 서버는 HttpConnector 를 통해 요청을 수신하고 처리합니다.
그리고 Tomcat 버전별로 사용하는 HttpConnector 가 상이한데요 아래와 같습니다.
BIO(Blocking I/O) Connector
요청이 들어와 TCP 커넥션이 생성되면 하나의 Thread 가 할당됩니다.
그리고 요청에 대해 응답하기까지 하나의 Thread 에서 처리되며 응답 이후 소켓 연결이 닫히고 나서야 Thread 는 Pool 에 반환됩니다.
즉, 커넥션과 Thread 가 1:1 로 매핑됩니다.
이는 Thread 가 Idle 상태로 있는 시간이 길어져서 낭비되는 시간이 많을 수 있습니다.
만약 요청이 들어왔을때 할당할 수 있는 Idle Thread 가 없다면 요청은 Block 됩니다.
NIO(Non Blocking I/O) Connector
BIO 와 달리 소켓 연결을 담당하는 Poller 라는 Thread 가 존재합니다.
요청이 들어오면 Poller 는 Socket 들을 캐시로 들고있다가 처리가 가능한 순간에만 Thread 를 할당합니다.
그리고 NIO Connector 는 Thread 는 응답을 보내면 바로 Pool 로 반환됩니다.
그렇기에 BIO Connector 에 비해 Thread 가 Idle 상태로 있는 시간이 짧기에 효율적입니다.
Tomcat 설정옵션에 따른 테스트
SpringBoot version: 3.3.2
Tomcat version: 10.1.26
Tomcat 은 10.1.26 버전이기에 NIOConnector 를 사용합니다.
(BIO Connector 는 Tomcat 9 버전부터 Deprecated 되었습니다)
모든 테스트는 10개의 스레드를 동시에 요청하는것으로 진행하겠습니다.
ThreadPoolController.java
@Slf4j
@RestController
@RequestMapping(value = "/")
public class ThreadPoolController {
@GetMapping
public ResponseEntity<String> api() {
try {
Thread.sleep(5000);
log.info("http call");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return new ResponseEntity<>("OK", HttpStatus.OK);
}
}
요청이 시간의 흐름에 따라 어떻게 처리되는지 확인하기 위해 컨트롤러에서 5초간 sleep 하도록 하겠습니다.
ThreadPoolTest.java
public class ThreadPoolTest {
private static final HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.build();
@Test
void test() {
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8080"))
.build();
Supplier<HttpResponse<String>> task = () -> {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
throw new RuntimeException(e);
}
};
List<CompletableFuture<HttpResponse<String>>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
CompletableFuture<HttpResponse<String>> future = CompletableFuture.supplyAsync(task);
futures.add(future);
}
CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
combinedFuture.join();
}
}
1. maxConnections 과 aceeptCount, maxThreads 가 모두 충분한 경우
server:
tomcat:
max-connections: 10
accept-count: 10
threads:
max: 10
10개 요청 모두 한번에 잘 처리되었습니다.
2. acceptCount 는 부족하고 __maxConnections 와 maxThreads 가 충분한 경우
server:
tomcat:
max-connections: 10
accept-count: 2
threads:
max: 10
maxConnections 의 수가 10개 요청은 한번에 처리 가능한 수여서 모두 한번에 처리된것을 확인할 수 있습니다.
3. maxConnections 는 부족하고 aceeptCount 와 maxThreads 가 충분한 경우
server:
tomcat:
max-connections: 2
accept-count: 10
threads:
max: 10
65초 간격으로 두개의 요청이 들어온것을 확인할 수 있습니다.
10개의 요청이 오면 우선 maxConnections 에 두개의 요청이 Queue 로 들어가고 나머지 요청은
acceptCount 에서 대기하게 됩니다.
그리고 두개가 처리 완료되었다면 acceptCount 에서 maxConnections 로 두개의 요청이 다시 이동해야 합니다.
그렇다면 5초간격으로 수행되어야 할것같은데 그렇지 않았습니다.
왜 이렇게 동작한걸까요??
maxConnections 은 요청의 Connection 이 끝나더라도 Socket 이 바로 종료되지 않는다고 위에서 이야기 했습니다.
Socket 은 Connection 연결해제 후 종료되기까지 FIN 신호 및 TIME_WAIT 시간을 거쳐서 종료되는데요.
이때 TIME_WAIT 이 과정이 60초가 소요된 것입니다.
그리고 컨트롤러에서 걸어놓은 sleep 시간은 5초입니다. 그래서 maxConnections 에 대해 Socket 이 종료되고
다음 요청에 대한 로그가 찍히기 까지 65초의 간격을 두고 요청을 처리하게 된겁니다.
WireShark 로 패킷 확인해보기
이 과정을 좀 더 자세히 보기 위해 WireShark 를 이용해 패킷이 어떻게 요청되고 반환되는지 확인해보겠습니다.
52546 port 를 기준으로 따라보겠습니다.
1) TCP 연결 요청
52546 Port 가 8080 Port 에게 TCP 요청을 하기 위해 3-Way-Handshake 를 진행하는것을 확인할 수 있습니다.
이때 시간은 4.43초 입니다.
2) HTTP 요청
TCP 연결에 성공했으니 이후 HTTP 요청을 진행합니다. 이후 서버는 요청을 잘 받았다는 신호로 ACK 를 응답합니다.
이때 시간은 4.44초 입니다.
3) HTTP 응답 반환 및 TCP 연결 해제 요청
컨트롤러에서 5초간 Sleep 후에 HTTP 응답을 반환합니다.
이때 시간은 요청 후 약 5초가 지난 9.46초 입니다.
그리고 마지막 패킷 로그를 보시면 응답을 받자마자 TCP 연결 해제를 위해 서버에게 ACK 신호를 전달합니다.
이 과정이 4-Way-Handshake 의 시작입니다.
4) TCP 연결 해제
TCP 연결 해제를 위해 4-Way-Handshake 를 진행합니다.
연결해제 요청을 9.46초에 보냈지만 FIN, ACK 응답이 60초 뒤인 69초에 온것을 볼 수 있습니다.
이때 서버는 자신의 통신이 끝날때까지 기다리는 TIME_WAIT 상태를 거치게 되는데 이 시간이 60초가 걸려서 그렇습니다.
그리고 클라이언트는 FIN,ACK 응답을 받고 확인했다는 ACK 를 서버에게 보냅니다.
마지막으로 서버는 ACK 신호를 받고 Socket을 종료합니다.
4. maxConnections, acceptCount 모두 부족하고 maxThreads 는 충분한 경우
server:
tomcat:
max-connections: 5
accept-count: 2
threads:
max: 10
처음 maxConnections 만큼 5개의 요청을 처리하고 이후 acceptCount 만큼의 2개의 요청을 처리한것을 확인할 수 있습니다.
총 요청은 10개이지만 수신할 수 있는 큐의 사이즈는 총 7개로 제한되어서 7개의 요청만 처리되었습니다.
나머지 요청에 대해서는 IOException 이 발생했습니다.
5. maxConnections, acceptCount 는 충분하지만 maxThreads 가 부족한 경우
server:
tomcat:
max-connections: 10
accept-count: 10
threads:
max: 2
maxThreads 만큼 5초 간격으로 10개의 요청을 모두 처리한것을 확인할 수 있습니다.
Tomcat 스레드 갯수를 어떻게 설정해야할까?
어플리케이션의 성격과 요구사항 그리고 처리량에 따라 다르겠지만 다음 부분을 고민해보면 좋을것 같습니다.
CPU Bound, I/O Bound
어플리케이션의 성격이 주로 어떤 작업을 하는지에 대해 고민해볼 수 있습니다.
CPU Bound
- CPU 연산에 의존하는 작업 (CPU Burst)
- 머신 러닝 작업, 동영상 편집 작업 ..
I/O Bound
- I/O 작업을 요청하고 결과를 기다리는 작업 (I/O Burst)
- DB 접근, 네트워크 요청, 파일 입출력 ..
일반적으로 CPU Bound 의 경우 대기시간이 거의 없기때문에 많인 스레드 수가 필요하지 않습니다.
그렇기에 코어수 만큼의 스레드 수가 있어도 충분합니다.
하지만 I/O Bound 의 경우 대기시간이 많기에 대기시간을 효율적으로 처리할만큼의 스레드 갯수가 있는게
작업을 더 효율적으로 처리할 수 있습니다.
Brian Goetz 의 Java Concurrency in Practice 라는 책에서 다음과 같은 공식을 권장합니다.
여기서 대기시간은 스레드가 대기하는 시간과 I/O 작업시 대기하는 시간을 모두 포함합니다.
서비스 시간은 대기시간을 제외한 실제 작업이 수행되는 시간을 뜻합니다.
TPS 와 성능 테스트
스레드 개수에 정답은 없으므로 가장 중요한것은 어플리케이션의 성격과 현재 서비스의 상황에 맞게
적정 스레드 개수를 찾는것이 중요하지 않을까 싶습니다.
평소엔 잠잠하다가 특정 시간에만 TPS 가 급증하는 서비스도 있고, 전체적으로 적당량의 수치를 유지하는 서비스도 존재합니다.
서비스 성격에 알맞는 TPS 와 AutoScaling 을 고려한 성능 테스트를 진행하며 서비스에 맞는 적정 스레드 개수를
찾아내는것이 가장 좋은 방법이라 생각합니다.
감사합니다.
reference
'Spring' 카테고리의 다른 글
테스트 성능 및 정합성 개선하기(with ContextCaching, TestContainer) (0) | 2024.11.11 |
---|---|
[Spring] @Async 사용 방법 및 TaskExecutor, ThreadPool (0) | 2024.07.07 |
[Spring] Spring Event 를 이용한 비동기 이벤트 처리 (0) | 2024.06.24 |
[Spring] ContextCaching 으로 Test 성능 개선하기 (@MockBean, @SpyBean) (0) | 2024.06.02 |
[Spring] @Component vs @Configuration (0) | 2024.05.21 |