과정을 즐기자

서버가 처리할 수 있는 요청의 수는 어떻게 결정될까? 본문

Network

서버가 처리할 수 있는 요청의 수는 어떻게 결정될까?

320Hwany 2023. 8. 31. 17:15

이번 글에서는 서버가 처리할 수 있는 요청의 수는 어떻게 결정되는지 알아보겠습니다.

그 전에 먼저 전체적인 웹 서비스의 흐름을 간단하게 살펴보겠습니다.

 

우선 크게 3가지 서버로 나눌 수 있습니다. 웹 서버, WAS, DB입니다.

웹 서버는 Nginx, Apache 등이 있고 정적인 컨텐츠를 처리, 보안, 로드 밸런서 등의 역할을 합니다.

WAS는 Tomcat, Jetty 등이 있고 애플리케이션 로직같은 동적 컨텐츠를 처리합니다.

DB는 MySQL, Oracle, MongoDB 등이 있고 데이터를 저장하는 역할을 합니다.

 

사용자의 요청이 오면 웹 서버, WAS, DB를 거쳐 응답을 반환합니다.

물론 이때 DB를 거치지 않고 웹 서버나, WAS에서 바로 응답을 반환할 수도 있습니다. 

 

스프링부트를 사용하면 톰캣 서버가 내장되어 있습니다.

그러면 이 톰캣 내장 서버가 받아들일 수 있는 요청의 수는 어떠한 요소들로 결정되는 것일까요?

소켓

가장 먼저 생각해볼 수 있는 것은 소켓입니다. 소켓을 통해 데이터를 주고 받을 수 있기 때문입니다.

그렇다면 하나의 서버에서 몇 개의 소켓을 열 수 있을까요? 먼저 소켓은 무엇으로 결정되는지 알아보겠습니다.

TCP 통신에서 소켓은 실질적으로 (송신 IP, 송신 PORT, 수신 IP, 수신 PORT)로 결정됩니다.

즉, 수신 측의 IP, 포트 번호가 같더라도 송신 측의 IP나 포트 번호가 다르다면 다른 소켓으로 만들 수 있습니다.

 

서버에서 하나의 프로그램을 배포했고 포트 번호를 8080이라고 하겠습니다.

위 그림과 같이 서버의 하나의 프로세스에 소켓을 여러 개 열어 여러 개의 클라이언트와 동시에 연결을 유지할 수 있습니다.

그림의 L 소켓은 Listening 소켓으로 클라이언트의 커넥션 연결 요청을 받아들이는 소켓을 말합니다.

커넥션이 성립된 후에는 생성된 A, B 소켓으로 통신을 합니다.

 

연결한 모든 소켓이 데이터의 처리를 기다리는 상황은 아닐 것입니다. 소켓의 수신 버퍼에 데이터가 들어오는 상태일 수도 있기

때문입니다. 이때 모든 소켓에 쓰레드를 할당해 Blocking 방식으로 데이터를 처리하는 것은 비효율적일 수 있습니다.

톰캣에는 Acceptor라는 Listening 소켓이 있으며 Poller 라는 쓰레드가 소켓의 커넥션을 처리합니다.

Acceptor는 들어오는 연결 요청을 수락하는 역할을 하며 새로운 연결이 들어오면 해당 연결을 처리하기 위해 
Poller 쓰레드에 작업을 넘깁니다. 

즉, Poller 쓰레드가 소켓들을 모니터링하며 데이터에 대한 처리가 필요한 소켓에게만 쓰레드를 할당해줍니다.

Non-Blocking I/O 방식으로 처리되는 것입니다. 이러한 NIO 방식은 톰캣 8.5 버전부터 채택되었습니다. 

(이전에는 BIO 방식으로 모든 소켓에 쓰레드를 할당해 Blocking 되는 방식을 사용했습니다.)

 

그렇다면 여러 클라이언트가 서버에 요청하면 각각 소켓이 만들어질텐데 몇 개의 소켓을 만들 수 있는지 궁금해집니다.

각 요청마다 TCP 버퍼, 소켓 버퍼가 있기 때문에 요청이 많아지면 메모리가 부족해집니다.

또한 한 프로세스가 열 수 있는 파일 디스크립터의 수에는 제한이 있기 때문에 생성 가능한 소켓 수도 제한됩니다.

소켓의 수가 너무 많으면 이를 처리하는 쓰레드에게 할당하는 CPU의 부하도 증가할 수 있으며

네트워크 대역폭을 초과하는 문제가 생길 수도 있습니다. 이러한 문제점들로 인해 소켓을 무한정으로 열 수는 없습니다.

파일 디스크립터

이전에 파일 디스크립터의 수에 제한이 있다고 하였는데 조금 더 자세히 알아보겠습니다.

우선 파일 디스크립터란 운영 체제가 파일이나 다른 입출력 자원에 접근하기 위해 사용하는 추상 표현입니다.

쉽게 이야기하면 파일이나 입출력 장치를 대표하는 숫자로 파일에 대한 포인터라고 생각하면 될 것 같습니다.

 

다음 리눅스 명령어를 통해 각종 최대 리소스 제한을 확인할 수 있습니다.

ulimit -a  // soft ulimit
ulimit -aH // hard ulimit

 

참고로 리눅스 OS 환경에서는 JDK 실행시 soft ulimit의 수치를 넘더라도 자동으로 hard ulimit을 따라갑니다.

우분투 서버에서 hard ulimit을 실행한 결과는 다음과 같습니다.

여기서 파일 디스크립터와 관련된 값은 open files입니다. 현재 1048576 값을 확인할 수 있습니다.

이 말은 다른 리소스(CPU, 메모리 등)에 문제가 없는 한 10만개 이상의 소켓을 열 수 있다는 말입니다. (소켓도 파일이다)

하지만 만약 다른 리소스에 문제가 없더라도 open files의 수치가 작다면 더 이상 소켓을 열 수 없고 사용자의 요청을 받아들이지

못합니다.

 

open files 말고도 max user processes의 값도 주목해서 볼 필요가 있습니다. open files가 파일 디스크립터와 관련 있어
열 수 있는 소켓을 정하였고 사용자의 요청을 처리 수를 판단하는데 중요한 값이었습니다.

max user processes는 생성 가능한 프로세스의 수를 의미합니다. Spring과 같은 멀티 쓰레드 환경에서는 생성 가능한

쓰레드의 수라고 볼 수 있습니다. 마찬가지로 다른 리소스에 문제가 없는 한 7346개 이상의 쓰레드를 만들 수는 없다는 뜻입니다.

만약 이 수치가 지나치게 작게 설정되어 있다면 다른 리소스에 문제가 없는데 새로운 쓰레드를 생성할 수 없게됩니다.

 

정리해보면 open files는 열 수 있는 파일의 수를 의미하고 max user processes는 생성 가능한 쓰레드 수를 의미합니다.

앞에서 톰캣 8.5버전부터 NIO 방식으로 작동하기 때문에 open files (사용자의 요청 수) > max user processes (쓰레드 수)가 

성립함을 알 수도 있습니다. 과거 BIO 방식에는 open files가 아무리 크더라도 사용자의 요청처리에는 max user processes보다
커질 수 없으므로 큰 의미가 없었습니다.

TCP 성능

서버의 요청을 처리할 때 TCP 성능도 주목해봐야합니다. 먼저 TCP window scaling에 대해 알아보겠습니다.

TCP 헤더에 보면 Window Size 필드는 16비트로 구성되어 있습니다. 즉, 기본적으로 65535 바이트까지 표시 가능하여

64KB 까지 지정할 수 있습니다. 이 값은 TCP 연결에 대해 수신자가 한번에 받아올 수 있는 데이터 양을 말합니다.

대량의 데이터를 지정할 때 64KB 만 받아올 수 있다면 여러 번 받아야해서 성능에 문제가 생길 수도 있습니다.

TCP 헤더에는 Options라는 필드가 있는데 window scaling을 사용해 성능을 향상 시키기 위한 필드입니다.

이 설정을 통해 기본 receiver window size 64KB에서 최대 약 1GB까지 크기를 늘릴 수 있습니다.

 

다음 명령어를 통해 커널 파라미터를 확인할 수 있습니다. 값이 1로 설정되어 있다면 window scaling이 활성화된 것이고
0으로 설정되어 있다면 비활성화 되어 있는 것입니다.

sysctl net.ipv4.tcp_window_scaling

 

하지만 receiver window size 한계치를 늘린다고 해서 이 값이 소켓 하나 하나의 버퍼 크기보다는 커질 수 없습니다.

실제로 receiver window size를 늘릴려면 각각의 소켓 버퍼 크기도 늘려주어야 한다는 것입니다.

 

다음은 AWS EC2 2GB 메모리를 가진 서버의 기본 설정입니다.

아래의 net.core의 rmem/wmem의 default, max 수치는 소켓 각각의 수치를 나타내고 단위는 바이트입니다.

 

아래의 net.ipv4.tcp의 설정은 TCP 소켓에만 특정하여 할당되는 값입니다. min, default, max를 나타냅니다.

위에 설정된 소켓 버퍼 크기의 default 값을 TCP 소켓에 한정해서 덮어 씌우며 서버의 메모리 상황에 따라
min, max 값을 가질 수 있게 됩니다.

 

위 파라미터 값들이 각각의 소켓에 대한 값이었다면 아래의 값은 전체 TCP 소켓 값을 나타냅니다.

단위는 바이트가 아닌 페이지입니다. 리눅스에서는 1페이지당 4KB 정도 나타냅니다.

서버의 메모리 상황에 따라 min, pressure, max를 나타내며 이 크기에 따라 위에서 설정한 개별 TCP 소켓의

min, default, max 사용 여부를 결정하게 됩니다.

 

그렇다면 해당 파라미터 값들을 보고 어떻게 설정해야할 지를 정리해보겠습니다.

우선 64KB라는 기본 receiver window size의 한계치를 높이기 위해 window scaling을 활성화하여 성능을 높일 수 있습니다.

또한 이뿐만 아니라 성능을 높이기 위해서는 각각의 소켓 버퍼 크기도 늘려주어야 합니다.

현재 기본 설정이 대략 208KB(TCP는 128KB, 16KB) 로 설정되어 있습니다.

만약 성능을 더 높이고 싶다면 이 값을 키우면 될 것이고 메모리 사용량을 줄이기 위해서는 이 값을 낮추면 될 것 같습니다.

 

이때 만약 많은 데이터를 주고 받는데 해당 값이 작다면 한번에 전달하지 못해서 성능이 떨어질 수도 있으며

같은 소켓에 더 많은 CPU를 할당해줘서 CPU의 리소스를 더 사용할 것이라고 예상해볼 수 있을 것 같습니다.

쓰레드 풀

스프링은 기본적으로 멀티 쓰레드 방식입니다. 요청이 들어올 때마다 매번 쓰레드를 생성하는 것은 비효율적이기 때문에

쓰레드 풀을 생성하고 요청이 들어오면 쓰레드 풀에 있는 쓰레드를 사용하고 사용 후 반환합니다.

이때 톰캣에서 주목해서 봐야할 파라미터들이 있는데 max-threads, max-connections, accept-count입니다.

max-threads는 쓰레드풀이 가질 수 있는 최대 쓰레드 수를 의미하고 기본 값은 200입니다.

max-connections는 동시에 처리할 수 있는 최대 커넥션의 수이고 기본 값은 8192입니다.

accept-count는 max-connections 이상의 요청이 들어왔을 때 사용하는 요청 대기열 queue의 크기이고 

기본 값은 100입니다. 

 

이때 (최대 쓰레드 수 != 동시에 처리할 수 있는 최대 커넥션 수) 인 이유는 위에서 말한 것처럼 소켓의 처리가 Non-Blocking 

방식으로 이루어지기 때문입니다.

max-connections 만큼 즉, 8192만큼 커넥션이 만들어졌다면 요청을 처리하는 소켓의 수도 8192개가 만들어집니다.

톰캣이 받아들일 수 있는 커넥션 수를 초과하여 100개가 더 만들어졌다면 요청을 처리하는 소켓의 수도 100개 늘어나지만

이때의 소켓은 쓰레드를 할당받을 수 없고 요청 대기열 queue에 쌓이게 됩니다.

이 상태에서 계속 요청이 들어온다면 더 이상의 클라이언트 요청을 거절하게 됩니다.

 

accept-count가 3이고 max-connections가 3인 예시를 하나 들어보겠습니다.

 

A, B, C는 소켓이 생성되고 데이터의 처리가 필요할 때 Poller 쓰레드를 통해 쓰레드를 할당 받게 됩니다.

max-connections 이상으로 들어온 요청 D, E, F는 소켓은 생성되지만 쓰레드의 할당은 받지 못하고 queue에 계속 쌓입니다.

이제 다른 요청이 들어온다면 그 다음 요청들은 WAS가 더이상 받아들이지 못하는 것입니다.

 

그렇다면 파라미터들은 무엇을 고려하여 설정해야 할까요?

max-threads의 수가 너무 크다면 메모리를 많이 차지하게 되고 Context Switching이 많아져 CPU 오버헤드가 증가합니다.

max-threads의 수가 너무 작다면 동시에 처리할 수 있는 요청의 수가 줄어 TPS가 줄어들게 됩니다.

accept-count의 수가 너무 크다면 요청이 한꺼번에 몰릴 때 메모리 문제가 생길 수 있고

accept-count의 수가 너무 작다면 메모리 문제는 없는데 요청을 거절해버릴 수도 있습니다.

max-connections의 수는 실질적인 동시 요청 처리의 수이기 때문에 상황에 맞게 설정해야 합니다.

 

DBCP

웹 애플리케이션에서 가장 부하가 많은 것은 DB와 관련된 것입니다.

네트워크가 아무리 빨라도 DB에서의 처리가 느리면 요청에 대한 응답이 늦어질 수 밖에 없습니다.

WAS와 DB가 데이터를 주고 받기 위해 TCP를 이용합니다.

DBCP(Database connection pool)는 앞에서 이야기한 쓰레드 풀과 비슷하게 TCP 커넥션을 미리 맺어두고

커넥션이 필요하면 커넥션 풀에서 꺼내서 사용하고 사용 후 반환합니다. 

 

DB 서버에서는 max_connections, wait_timeout 파라미터를 설정할 수 있습니다.

max_connections는 DB와 클라이언트(여기서는 톰캣)와 맺을 수 있는 최대 커넥션 수입니다.

wait_timeout은 커넥션이 inactive 할 때 close하여 DBCP에 반환할 때까지 기다리는 시간을 말합니다.

 

스프링부트를 이용하여 애플리케이션을 만들면 기본적으로 HikariCP를 DBCP로 사용합니다.

DBCP의 주요 파라미터에는 maximum_pool_size, minimumIdle 등이 있습니다.

maximumPoolSize는 pool이 가질 수 있는 최대 커넥션의 수를 말합니다.

minimumIdle은 pool에서 유지하는 최소한의 idle 커넥션의 수를 말합니다.

이때 minimumIdle은 maximumPoolSize와 같은 수로 설정할 것을 권장하는데 갑자기 트래픽이 몰려올 때 

커넥션을 만들기 시작하면 요청의 처리가 늦어지기 때문입니다.

정리

지금까지 WAS가 처리할 수 있는 요청의 수는 어떤 요소들로 결정되는지를 알아보았습니다.

크게 소켓, 쓰레드 풀, DBCP로 나눠보았습니다.

서버의 트래픽 증가로 인해 발생하는 장애의 원인은 여러 가지가 있을 수 있습니다.

 

물론 정말 많은 요청이 몰려 소켓의 수가 증가하여 TCP 버퍼, 소켓 버퍼의 데이터가 증가하고 커넥션보다 많은 요청이

queue에 쌓여 처리하지 못하는 상태가 된다면 CPU와 메모리 리소스 부족으로 서버가 다운될 수 있습니다.

하지만 서버가 문제될 정도의 부하는 아니지만 쓰레드 풀, DBCP의 잘못된 설정으로 인해 요청을 계속 처리하지 못하여

문제가 발생할 수도 있습니다.

 

이렇게 많은 문제가 있기 때문에 네트워크 대역폭, 소켓, TCP 성능, CPU와 메모리 리소스, 파라미터 값 확인 등 종합적으로

모니터링하여 판단해야 할 것 같습니다.

 

참고한 자료

https://sihyung92.oopy.io/spring/1

https://www.youtube.com/watch?v=WwseO8l8rZc&t=1s&ab_channel=%EC%89%AC%EC%9A%B4%EC%BD%94%EB%93%9C

https://www.youtube.com/watch?v=um4rYmQIeRE&t=1s&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC

https://www.youtube.com/watch?v=mb-QHxVfmcs&embeds_referring_euri=https%3A%2F%2F320hwany.tistory.com%2F94&source_ve_path=MjM4NTE&feature=emb_title

https://techblog.woowahan.com/2569/

https://meetup.nhncloud.com/posts/53