- 스프링 부트 환경에서 Tomcat 설정 및 성능 검토하기
이번 글에서는 기본적인 TCP 웹서비스 아키텍처, 스프링 부트 환경에서 임베디드 톰캣 설정 방법, 톰캣 설정으로 웹서비스 성능을 최적화 하는 방법 등등 다양한 내용을 검토한다.
1. TCP 클라이언트-서버 통신 아키텍처
2. 테스트 시나리오
3. 스프링 부트 설정
4. 기타 의견 정리
5. 글 마무리
TCP 클라이언트-서버 통신에 대해서 정리한다.
클라이언트-서버 통신은 일반적으로 클라이언트에서 Request 요청을 하면, 서버에서 Response 응답을 하는 아키텍처이다. 단, TCP 커넥션 요청은 클라이언트에서 먼저 하지만, 커넥션 연결 종료는 서버 또는 클라이언트 어디에서든 먼저 수행할 수 있다. 클라이언트에서 먼저 커넥션 Close 요청을 한 경우는 아래와 같다. 클라이언트에서 ACTIVE_CLOSE 를 수행하였다.
서버에서 먼저 CLose() 요청을 한 경우는 아래와 같다. 서버에서 ACTIVE_CLOSE 를 수행하였다.
자세한 설명은 생략한다. 관련 참고자료를 참고하길 바란다.
TIME_WAIT 는 TCP 통신에서 ACTIVE_CLOSE 즉, close() 시스템 콜을 호출한 곳에서의 마지막 단계이다. CLOSE 요청은 클라이언트 또는 서버 어느 곳에서든 할 수 있는데, 클라이언트에서 먼저 CLOSE 요청을 했을 때는, TIME_WAIT에 대한 상태도 클라이언트가 가진다. 반대로 서버에서 먼저 CLOSE 요청을 했을 때는, TIME_WAIT에 대한 상태는 서버에서 가진다. 일반적인 웹서비스에서는(정상적으로 TCP 커넥션을 맺고 끊었다면..) TIME_WAIT 는 서버측에 남는다. 서버에서 클라이언트로 데이터를 전송 시에 ACTIVE_CLOSE 를 함께 수행하기 때문이다. 그래서 보통 서버 서버 로그에 TIME_WAIT 상태가 쌓이게 된다. 아래 캡쳐는 클라이언트에서 요청을 한 이후 서버에서 정상적으로 응답(Response)을 한 이후 서버 상태이다.
운영체제에 기본으로 설정되어 있는 시간(1분 또는 2분)이 지나면 자동으로 TIME_WAIT는 소멸된다. TIME_WAIT 가 많다고 해서 문제가 발생한 것은 아니다. "HTTP 완벽가이드 97page" 를 보면, TIME_WAIT 의 누적으로 인한 포트 고갈은 성능상의 문제가 발생한 것이 아니라고 강조한다. 더 자세한 내용은 toast 기술블로그를 참고하자.
https://meetup.toast.com/posts/55
TIME_WAIT 는 자동으로 소멸 되고, 많이 쌓여 있어도 시스템에 큰 문제는 없다고 설명하였다. 하지만 CLOSE_WAIT 는 많이 쌓여 있으면 정상적인 상황은 아니다. CLOSE_WAIT 는 정상적인 프로세스 종료를 통해서만 상태가 소멸된다. 임베디드 톰캣을 사용하는 스프링 부트 환경에서는 오류 없는 코드를 작성했다면 정상적으로 프로세스가 알아서 자동으로 종료가 될 것이다. 즉, ACTIVE_CLOSE 요청을 코드에 작성 할 필요가 없다. 프로세스가 정상적으로 실행이 되면 CLOSE_WAIT는 자동으로 소멸될 것이다. 스프링 프레임워크와 임베디드 톰캣 이 알아서 해주기 때문에 아마 대부분의 개발자들이 close()를 요청해야 한다는 걸 크게 신경쓰지 않고 비즈니스 로직에만 집중했을 것이다. 그렇지만 만약 애플리케이션에 치명적인 오류가 있어서 CLOSE_WAIT 가 소멸되지 않는 상황이라면 어떻게 될까? TIME_WAIT 처럼 많이 쌓여도 시스템에 문제가 없을까? 아니다!!그렇지 않다. 정상적으로 close 를 하지 못한 상태에서 지속적으로 CLOSE_WAIT 가 쌓여서 더 이상 쓰레드를 사용할 수 없는 한계치에 도달한다면, 시스템 먹통 상태가 될 것이며 해당 시스템을 호출하는 다른 시스템까지 장애가 전파될 것이다.
Socket(이하 소켓)은 서버-클라이언트 아키텍처에서 두 시스템 통신의 끝 지점이다. 참고로 java.net 패키지는 연결의 클라이언트 측과 서버 측을 각각 구현하는 클래스를 제공한다. 클라이언트가 서버에 요청을 하면 서버는 요청을 처리한 후 응답을 보내야 하는데, 이때 연결을 설정하기 위해 소켓이 사용된다. 애플리케이션은 소켓에 바인딩해야 하며, 서버는 클라이언트의 요청을 받기 위해서 소켓을 수신 대기해야 한다. 블록킹 IO 환경에서는 요청에 대한 응답을 처리할 때까지 Thread 가 차단된다.
만약 동시 요청을 수행하려면 여러 개의 Thread 가 필요하다. 즉, 클라이언트의 요청이 추가되면 새로운 Thread를 아래 그림처럼 신규로 할당해야 한다.
사실 우리가 운영하는 웹서비스는 대부분 이렇게 구축이 되어있을 것이다. 각 Thread는 메모리 할당이 필요하며, 클라이언트 요청이 폭발적으로 증가한다면 Thread를 관리하는 것이 부담스러워진다. 또한, 클라이언트 요청을 수신하는 여러 개의 Thread는 리소스 낭비가 될 수도 있다.
스프링5에 웹플럭스가 도입되면서 Netty 서버가 기본 스펙으로 추가되었다. Netty는 비동기 이벤트 기반의 고성능 네트워크 프레임워크 서버이다. Non-블록킹 IO는 클라이언트의 요청 각각에 서버의 Thread를 바인딩하지 않는다. 대신, 개별 버퍼를 사용해서 요청에 대한 알림을 주고받는다. Non-블록킹 IO는 여러 연결을 하나의 Thread로 처리할 수 있다.
Tomcat : 1Request = 1 Thread
Node.js : All Request = 1 Thread
Netty : Many Reqeust = 1 Thread
Netty 서버는 매우 유연한 모델을 제공한다. 이 글에서는 Netty 에 대해서는 자세하게 설명하지는 않겠다. 이 글은 Tomcat , 블록킹 IO 에 대한 이야기를 하는 글이다. 혹시라도, 스프링5 웹플럭스가 궁금하다면 필자의 글을 참고하길 바란다.
https://brunch.co.kr/@springboot/96
#내용추가#
Tomcat 최신 에서는 BIO 를 사용하지 않고 NIO, NIO2 를 주로 사용합니다. 스프링 부트 임베디드 톰캣을 실행했을 때도 Http11NioProtocol 를 사용합니다만, 필자가 처음에 Nio 가 Non-Blocking 로 착각했었는데 알고보니 New IO 의 약자였다. (일부 문서에는 Nio 가 Non-Blocking 라고 표현하는 글이 최근에 더 많아진 상황이다.) 정확히 필자가 판단하기 어려운 문제라서, 일단 넘어가겠다. Nio 를 완벽한 Non-Blocking 라고 착각하지 말자.
Tomcat Max Thread : 서버가 허용할 수 있는 최대 요청(Thread) 수, 실제 동시 접속 Active User 수
Accept Count : 요청을 대기하는 Queue 사이즈
Max Connection : 최대 커넥션 수
자세한 설정 정보는 아래 레퍼런스에서 확인하자.
https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
기본적으로 웹서비스를 구축할려면 톰캣도 설치해야 하고, 웹 애플리케이션을 톰캣에 연동을 해야 한다. 하지만, 스프링부트를 사용 하면 외장톰캣 없이 임베디드 톰캣 기반으로 "바로 실행", "독립 실행" 할 수 있는 환경을 구축할 수 있다. (참고로, 스프링 부트에서도 외장 톰캣을 연동하여 구축할 수는 있다.) 스프링 부트 관련해서는 아래 링크를 통해서 확인하길 바란다.
https://projects.spring.io/spring-boot/
스프링부트 1.X 에서는 임베디드 톰캣을 지원하였지만, 스프링 부트 2.0 이후로는 Tomcat 이외에 Netty, jetty, undertow 등 을 함께 지원한다.
MVC 스택 : spring-boot-starter-web 스타터는 기본으로 임베디드 톰캣 서버가 디펜던시로 추가된다.
Reactive 스택 : spring-boot-starter-webflux 스타터는 기본으로 임베디드 Netty 서버가 디펜던시로 추가 된다.
application.properties 에서 변경하면 된다. server.port=8081
기타 상세한 설정은 공식 레퍼런스를 참고하자.
https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
이 글의 3장에서 좀 더 상세하게 다룰 예정이다.
1장에서는 TCP, 톰캣, 스프링부트 등 관련 기술을 간략하게 검토하였다. 핵심 내용이 없이 주저리주저리 작성을 하게 되었는데... 한 줄로 정리하면 아래와 같다.
스프링 부트는 임베디드 톰캣 환경으로 독립적으로 실행할 수 있는 웹서비스를 구축할 수 있다.
장애 환경을 재현하기 위해, 테스트 시나리오를 구성하였다.
필자가 임의로 구성한 테스트 시나리오는 아래와 같다.
1. 임베디드 톰캣의 쓰레드 최대 갯수를 아주 작게 제한한다.(테스트를 위해서)
2. 애플리케이션에 강제로 지연 시간을 넣는다. (타임아웃 처리를 따로 하지 않는다)
3. 클라이언트에서 서버로 요청을 보낸다. 요청을 받은 서버는 지연시간으로 인해서 ESTABLISHED 에서 설정한 지연시간 만큼 멈춰있는다.
4. 대기하는 과정에서 클라이언트에서 강제로 연결을 끊어버린다. 여러가지 방법이 있다. 클라이언트 주체가 웹브라우저라면, 웹브라우저를 강제로 닫아도 연결이 끊긴다. PostMan 에서 Request Cancle 를 해도 된다. 필자의 환경에서는 톰캣의 앞단에 있는 Nginx 에서 자동으로 끊어졌다. 참고로 Nginx 에서는 기본으로 1분 동안 응답이 없으면 끊어버린다. 물론, 시간은 설정할 수 있다. Nginx 에서 끊기면 아래와 같이 504 Gateway Time-out 이 응답된다.
어쩃든 사용자, 즉 클라이언트는 서버의 장애를 Nginx 를 통해서 파악할 수 있다.
5. CLOSE_WAIT 상태가 소멸되지 않는 상황
서버 애플리케이션은 클라이언트로부터의(사실..nginx로 부터의) close요청을 받았기 때문에, CLOSE_WAIT 상태는 되었지만, 지연 시간에 대한 프로세스는 아직도 돌고 있을 것이다. 그렇기 때문에 CLOSE_WAIT 상태가 소멸되지 않고 쌓여있다.
CLOSE_WAIT 상태가 지속되면, Thread 를 계속 붙잡고 사용하고 있는 상황이 발생한다. 클라이언트에서 종료 요청이 왔음에도 불구하고, 서버는 제한된 Thread 를 계속 사용하고 있는 상황이다. 즉, 여유분의 Thread 가 없는 상태이다. 이 때는 일반적인 클라이언트 요청(지연을 적용하지 않은 요청)조차도 톰캣에서 받아줄 수 없는 상황이다. 왜냐면, 소켓에 바인딩할 수 있는 남아있는 톰캣 쓰레드가 없기 때문이다. 근데, 필자의 의문사항이 있다. 저렇게 비정상적으로 돌아가고 있는 쓰레드는 무한히 멈춰있는 걸까?? 아마도 필자의 의문사항은 시스템 환경에 따라서 약간 다를 수는 있을 것이지만, 프로세스를 강제로 종료하지 않는다면 계속 멈춰있을 것이다. 강제로 넣은 THread.Sleep이 한시간 이라서, 한시간이 지나면 프로세스가 정상적으로 종료가 되면서 해결될 것이다.
가장 무식한 방법이지만, 이런 경우가 발생하면 필자는 가장 먼저 진행한다. 왜냐면 사이드이펙트가 가장 적기 때문이다. 하지만, 회사에서 별로 좋아하지 않을 수는 있다. 원인을 찾기 전에 무작정 서버를 늘리는 것에 대한 회의적인 의견이 많다.
CLOSE_WAIT 상태가 많아진다해도, 신규 요청을 더 많이 받아줄 수 있으면 된다. Tomcat 의 Max Thread 조정을 통해서 최고의 성능치를 뽑아내도록 하자.
스프링 부트 & 임베디드 톰캣
application.properties 프로퍼티 설정을 추가한다. 참고로 max-threads 디폴트 값은 200 이다.
server.tomcat.accept-count=100
# Maximum queue length for incoming connection requests when all possible request processing threads are in use.
server.tomcat.max-threads=200
# Maximum amount of worker threads.
server.tomcat.min-spare-threads=10
# Minimum amount of worker threads.
최대 200개의 쓰레드로 사용자의 리퀘스트에 리스닝한 소켓에 바인딩하여 처리할 수 있다.
자세한 설정은 스프링 레퍼런스를 참고하자.
https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
스프링 & 외장 톰캣
외장톰캣에서는 server.xml 을 수정하면 된다.
<Connector enableLookups="false"
protocol="HTTP/1.1"
URIEncoding="UTF-8"
compression="on"
connectionTimeout="5000"
disableUploadTimeout="true"
maxHttpHeaderSize="8192"
maxSpareThreads="75"
maxThreads="1024"
minSpareThreads="25"
port="80"
/>
https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
동일한 로직을 반복해서 수행할 필요는 없다. 톰캣에 부하를 줄일 수 있도록 캐싱 전략을 구축하는 것이 좋다. 정적인 static 파일은 톰캣에서 서빙하지 않아도 된다. 외부 캐시 스토리지, Nginx, AWS Cloud Flount 등 다양한 방법으로 캐싱 전략을 구축하자.
가장 중요하고 확실한 방법이다. 필자가 짠 소스를 개선해보자. Future 에서 get 할 때 타임아웃 설정을 추가하자.
String result = future.get(20, TimeUnit.SECONDS);
생략...
catch(TimeoutException e){
System.out.println("타임 아웃 예외처리");
}
기존에 클라이언트에서 강제로 끊기 전에, future 의 타임아웃 설정으로 인해서 서버에서 프로세스를 종료시키면서 close 콜을 수행한다. 이제 불필요하게 CLOSE_WAIT 상태가 남지는 않을 것이다.
예외처리를 사용하여 우아(?)하게 쓰레드를 종료시킨다.
@GetMapping
public void delay(){
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(3600000);
return "작업 완료";
});
doAnotherTask();
try {
String result = future.get(20, TimeUnit.SECONDS);
System.out.println("result: " + result);
executor.shutdown();
}
catch(TimeoutException e){
System.out.println("타임 아웃 예외처리");
}
catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
}
public void doAnotherTask(){
System.out.println("작업이 진행 중");
}
테스트 시나리오를 통해서 장애 환경을 재현하고, 해결방안을 다양하게 검토하였고, 톰캣 성능을 최적화 할 수 있는 방법을 알아봤다.
2.3장에서 스프링부트 환경에서 톰캣 설정하는 예시를 잠시 다루었다. 스프링 부트 임베디드 톰캣 관련해서는 공식 레퍼런스에 자세하게 참고할 수 있다.
https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
필자의 시간 부족으로 인해서, 이번 글에서는 이정도만 정리하고 상세한 정보는 시간이 되면 나중에 다시 정리할 예정이다.
가장 중요한 내용이지만, 이 글에서는 생략한다. Visual VM 등의 툴을 활용하거나, APM 을 통해서도 모니터링이 가능하다. 모니터링 툴을 통해서 객관적인 데이터를 기반으로 성능을 개선해야 한다.
지금까지 설명한 내용은 HTTP 프로토콜 기반의 웹서비스 통신에 대한 내용이다. 최근에는 비동기 프로토콜 기반의 아키텍처가 많이 사용되고 있다. 스프링 부트 환경에서 비동기 프로토콜을 연동 가능한 여러가지 방법에 대해서 간략하게만 고민해보자.
스프링 웹플럭스는 스프링5에서 새로 등장한, 웹 애플리케이션에서 리액티브 프로그래밍을 제공하는 프레임워크이다. 자세한 내용은 필자가 작성한 글을 참고하자.
https://brunch.co.kr/@springboot/96
스프링 클라우드 스트림은 메시지 기반 마이크로 서비스를 구현하기 위한 프레임워크이다. Spring Cloud Stream은 Spring Boot를 기반으로 DevOps 친화적인 마이크로 서비스 애플리케이션을 만들고 Spring Integration은 메시지 브로커와의 연결을 제공해준다. 제사한 내용은 필자가 작성한 글을 참고하자.
https://brunch.co.kr/@springboot/2
이번 글에서는 기본적인 TCP 웹서비스 아키텍처, 스프링 부트 환경에서 임베디드 톰캣 설정 방법, 톰캣 설정으로 웹서비스 성능을 최적화 하는 방법 등등 다양한 내용을 간략하게 정리하였다. 글 내용이 깊이가 없고 수박겉핥기 글이라서 조금 아쉽지만, 이정도로 마무리하겠다. 추후에 관련해서 공부할 기회가 다시 오면 그 때 다시 상세하게 정리하겠다.