주말 동안 갑자기 서버 튜닝을 해보고 싶었다.
현재 상태
테스트를 진행하기 전 운영 환경에서 테스트를 할 수 없으니 개발 환경을 구성했다. 개발 환경은 단일 서버로 온전히 인스턴스 한 대가 허용할 수 있는 트래픽 양을 테스트할 예정이다. 그리고 외부 요인을 추가하기 위해서 저사양 외부 RDB를 사용할 예정이다.
더미 데이터 생성
부하가 발생한다면 대부분은 DB에서 데이터를 이것저것 조회/수업을 하는 경우에 발생할 테니 넉넉하게 데이터를 생성해 둔다. 현재 사용하고 있는 데이터베이스는 PostgreSQL로 더미 데이터 생성에 적합한 함수를 제공한다.
A, B, C 각각 100,000개, 200,000개, 10,000개 데이터를 생성했고, 연관 관계는 A <-B, A <-C 관계로 구성되어 있다.
인덱스
처음 시작하는 것은 인덱스를 사용하지 않는 상태로 시도할 예정이다. 그리고 개선을 하면서 대량의 데이터에 인덱스를 추가할 때 중단 없이 어떻게 인덱스를 추가할 수 있는지 테스트할 예정이다.
특이사항
현재 도메인 설계 상 언제든지 테이블이 분리될 수 있다는 가정 하에 동일한 에그리게이션이 아닌 테이블 간의 연관 관계는 FK를 맺고 있지 않다. 따라서 FK 설정 시 자동으로 생성되는 인덱스도 없는 상황!
1차 부하 테스트
부하테스트는 locust를 이용해 진행할 예정이다. 웹 UI를 제공하며, 사용자를 점진적으로 늘리며 수행되기 때문에 어느 순간부터 트래픽에 무리가 가는지 확인하기 편해서 선택하였다.
처음은 기본값을 그대로 사용하기 때문에 매 초마다 10명의 사용자가 증가하는 시나리오로 시작했다.라고 했지만 바로 connection refuse가 뜨면서 요청 수 대부분이 유실되기 때문에 1명씩 🫠
결과
분석
Requests per second(이하 RPS)가 무려 3인 엄청난 서비스를 개발해 버렸다! 전 세계 사람들이 줄을 서서 초당 3명이 사용할 수 있는 서비스를 개발하고 말았다.
위 Total requests per second(이하 TPS) 그래프를 보면 성공과 실패가 비례하며 증가하는 것을 볼 수 있다. 즉, 제한된 자원을 경쟁하며 처리되고 있다는 것을 의미한다. 또 다르게 표현하자면 요청 자체는 서버에 도달하고 있기 때문에 네트워크 상에서는 문제가 없고 서버 내에서 문제가 발생하고 있다는 것을 의미한다.
Connection pool 크기 조절
처음 실패 처리가 발생한 요청은 시작으로부터 약 30초가 흐른 지점이다. 이때 모니터링 도구에서 Connection request timed out이 발생하면서 요청이 실패하는 것을 알 수 있다.
show max_connections;
현재 이용하고 있는 데이터베이스의 max_connections는 25 이므로, 5->23(minimum-idle 설정값을 기본값으로 사용하기 때문에 최대로 사용하면 DB client 도구를 사용할 수 없다.)로 설정을 조절해 보자.
(아 사진을 수정하는 걸 깜빡했네.) 사용하고 있는 커넥션 풀 라이브러리 설정을 조절했다. 그 외로 minimum-idle 수를 기본 값을 유지해 maximum-pool-size와 동일하도록 했다.
2차 부하 테스트
DB connection pool을 DB max_connections를 최대한 활용하도록 개선
이전 세션에서는 connection-pool-size를 조절해 봤다. connection-pool은 db와 연결 시 발생하는 비용을 절약하기 위해서 이전에 연결한 커넥션을 재활용하는 것으로 이번 서버에서는 hikariCP를 사용한다.
결과
분석
이전 부하 테스트보다 약 1분 정도 시간을 벌었다. 하지만 여전히 응답시간이 기본 2초 대를 유지하며, connection-time으로 설정한 15초에서 다 끊기는 것을 보니 수행되는 쿼리 자체가 문제라는 것을 알 수 있다. 즉, connection이 길어져 발생한 장애의 경우 pool size를 늘리는 것은 해결책이 아니란 것을 알 수 있다.
인덱스 설정
수행되는 쿼리의 실행 계획을 보면 보기만 해도 배가 든든해지는 결과🍚를 볼 수 있다. 수행되는 쿼리를 대략적으로 설명하자면, 커서 기반 조회로 게시일을 기준으로 정렬 후 page size만큼 조회하는 쿼리다. 따라서 위 실행 계획은 다음과 같이 동작한다.
- 전체 테이블을 순차 탐색을 하며 비교적 효율적인 동작을 위해 woker node이 병렬 처리(deleted_at IS NULL 필터)한다.
- posted_at 기준으로 정렬하기 위해 전체 데이터가 아닌 분할된 데이터를 각 woker node가 정렬을 수행한다. (여기서 Page size 만큼 조회하기 위해서 LIMIT을 사용하기 때문에 top-N heapsort 알고리듬을 사용한다.)
- 각 그룹의 정렬된 데이터 내 상위 N개를 조회 후 병합하기 위해 Gather merge가 수행된다.
그러면 이제 적절한 인덱스를 설정해서 개선한다. 인덱스는 강력한 성능 개선 방법이지만 그만큼 비용도 존재하기에 한 번 인덱스를 설계할 경우 다양하게 활용될 수 있도록 설계하는 것이 좋다. 대상 테이블은 게시글에 해당하는 테이블로 현재 서비스 내 활용성을 생각해 보면 다음과 같다.
- 최신순/수정순 등으로 조회
- 특정 그룹에 대한 조회
- 제목/내용 검색
위 조건을 확인해 보면 대부분 "검색"에 대한 키워드라는 것을 알 수 있다. 하지만 검색의 경우 요구하는 형태가 넓고 데이터베이스 설계 상 검색 요건에 만족하는 인덱스(최신수, 수정순, 제목, 그룹, 작성자 등등)를 작성하기에는 비용이 많이 발생된다고 생각한다. 따라서 테이블의 인덱스는 해당 도메인 범위 내에서 활용될 수 있는 형태로 추가되는 것이 좋다고 생각한다. 추가로 더 한다면 제목과 같은 곳에 인덱스를 설정하고 그 이상의 복잡한 검색을 구현하려는 경우 다른 형태의 서버 구조가 필요할 것 같다. 여기서는 작성일에 해당하는 posted_at 컬럼에 대한 인덱스를 설정한다.
create index "idx_journal_posted_at_deleted_at" on "journal" ("posted_at", "deleted_at")
3차 부하 테스트
인덱스 추가로 병목 지점이 되는 API 성능 최적화
이전 테스트에서 발생한 API 성능 문제를 인덱스를 추가해 해결했다. 그리고 부하를 더 늘리기 위해서 10,000 유저가 요청하는 시나리오를 수행해 보자.
결과
분석
[그림 8]은 두 영역으로 나눠 볼 수 있는데, 왼쪽은 초당 사용자가 1씩 증가하는 시나리오이고, 오른쪽은 초당 사용자가 100씩 증가하는 시나리오이다. TPS를 보면 일정한 간격을 처리하다 대량의 요청이 실패되고 있다. 이때 초록줄과 빨간 줄 간격이 현재 서버에서 처리할 수 있는 TPS라는 것을 알 수 있다. 그리고 현재는 Connection pool size에 의존하고 있다. 먼저 응답시간문제는 뒤에서 다뤄보고, 요청이 실패하는 경우에 대한 분석을 해보자.
지금은 문제가 된 쿼리를 개선해 2차 부하 테스트보다 많은 사용자 트래픽을 견딜 수 있지만, 결국 DB connection의 제약으로 인해 일정 이상의 사용자가 들어오는 경우는 장애가 발생하게 된다.
Reader DB 추가
응답시간은 둘째 치고, 현재 상황에서는 DB connection pool 자원이 부족해서 발생하는 문제점 있다. 이 경우 Reader DB connection pool을 운영 상황에 맞게 적절히 늘려주는 방안을 검토할 수 있다. AWS를 이용하고 있다면 Aurora DB cluster를 이용해 보는 것이 좋은 선택지가 아닐까 생각이 든다.
하지만 해당 서비스를 이용하려면 비용이 발생하기 때문에 가난한 백수 중고신입(1년 7개월) 입장으로 마치 DB 병목이 생기는 것처럼 동작하도록 API를 추가할 예정이다.
@GetMapping("/test")
public ResponseEntity<String> test() {
try {
Thread.sleep((long) (Math.random() * 1950 + 50));
} catch (InterruptedException e) {
e.printStackTrace();
}
return ResponseEntity.ok("test");
}
이 지점에 RDB 자원을 무한하게 추가할 수 있는 회사에 입사했다고 생각하며 마치 랜덤 하게 50ms~2000ms가 소요되는 테스트 API를 추가해 보자.
Reader DB를 무한하게 추가하는 것이 정답일까?
당연하게도 아닐 것이다. Reader DB가 Primary DB와 데이터가 동기화되기 위해선 Primary DB가 변경사항을 binary log로 기록하고, 이를 각 Reader DB로 동기화하는 작업이 수행된다. 이 과정은 replication 구조에 따라 방식이 다를 것이지만 가장 간단한 1:N 구조로 본다면 Master DB는 각 Reader DB와 연결하고 변경사항을 매번 전달해야 한다.
그리고 비용적인 문제도 있다. 경우에 따라서는 캐시 레이어를 추가하는 것만으로 DB 인스턴스를 여러 대 추가하는 것 이상의 효과를 얻을 수 있다. 그리고 캐시 레이어도 휘발성 데이터를 어떻게 처리할지에 대한 고민점을 만들기 때문에 어느 분야든 모든 것을 증명하는 단 하나의 수식이 없듯이 다양한 서비스 구조에 통일되는 단 하나의 구조는 없는 것이다! ✨
4차 부하 테스트
DB connection이 풍부한 경우를 가정하고 진행
결과
분석
3차 부하 테스트와 비교해서 확실히 TPS가 증간한 것으로 알 수 있다. 즉, 이전 부하 테스트에서 병목 지점은 외부 자원에 대한 경쟁 상태를 해결하지 못해 발생한 문제인 것이 증명됐다.
앞서 테스트를 위한 가상 시나리오(DB pool size가 충분한 상황)를 통해 외부 자원이 부족해 요청이 실패하는 경우는 없어졌다. 하지만 여전히 요청에 대한 실패가 발생하는데 이것은 톰캣 내부 Thread pool과 관련 있다.
현재 시나리오는 초당 100명의 사용자가 증가하며 최대 10K까지 증가하는 서비스이다. 이번 분석에서는 K6도 함께 이용해 보자. 왼쪽 사진을 보면 요청 시작 후 30초 후 RPS가 티면서 이후 정상적으로 처리되는 것을 알 수 있다.
처리되는 요청의 수를 보면 매 초당 200개의 처리가 되며 30초가 지난 요청은 timeout이 발생하는 것으로 현재 서버의 thread pool의 크기를 알 수 있다.
때문에 아무리 외부 자원이 풍부한 상황이더라도 서버가 처리할 수 있는 task가 정해져 있기 때문에 그 밖의 요청들은 task queue에서 대기할 수밖에 없다는 것이다. 하지만 이 선택은 OS가 효율적으로 작업을 처리하기 위한 선택이므로 무작정 thread pool을 늘리는 것으로 해결하는 것은 적절하지 못한 선택이다.
그렇다면 Thread pool의 크기는 어느 정도가 적절한가?
앞서 여러 시도를 해보면 알 수 있듯 서버가 아무리 많은 thread를 가지고 있다고 해도, DB connection pool이 부족하거나, 꼭 MSA가 아니더라도 작은 서비스끼리 서로 통신하며 운영되는 서비스는 내부 통신을 위한 connection pool이 있을 수 있다.
또 CPU bound 작업이 많은 서비스의 경우 thread 수를 늘리면 context switch가 자주 일어나면서 비용이 증가할 수 있기 때문에 치사하지만 "적정"수준의 크기가 필요하다. 그리고 이 적정 수준은 서비스 구조와 운영 환경에 따라 달라지게 된다.
다른 방법은?
또 다른 방법은 scale-out이 될 수 있다. 하지만 앞서 언급했듯이 서버가 활용할 수 있는 자원이 충분한 상태에서 scale-out이 이뤄져야 원하는 목적을 달성할 수 있다는 것을 욘두(ꉂꉂ(ᵔᗜᵔ*)ㅋㅋㅋㅋ🛳🌊포항항항) 해야 한다.
또는 캐시 레이어를 추가하는 것으로 목적에 맞게 서버 외부에 둘 것인지, 내부에 둘 것인지를 고민해야 한다. 만약 클라이언트와 서버 사이에 캐시 레이어를 두는 경우 즉, 업데이트가 많이 없는 데이터에 대해선 서버에 도달하지 않고 캐시 레이어에서 빠르게 처리하도록 설계하는 방법도 있을 것이다. 하지만 데이터 갱신이 빈번한 요청에 대해 이런 처리를 하게 되면 사용자에게 잘못된 데이터가 노출되고 유저 경험이 떨이지기 때문에 이 또한 기능에 목적성에 맞게 잘 선택해야 한다.
돌아와서
어찌 됐든 현재 상황을 해결하기 위해 두 가지 개선안이 떠오를 수 있다.
- 단일 서버의 thread pool을 늘린다.
- 서버를 여러 대 구성한다.
사실 최대한 자원을 활용하도록 thread pool을 조절하고, 추가 트래픽이 더 발생하면 서버를 추가해 부하를 분산 처리하도록 구성할 수 있다.
단일 서버의 thread pool을 늘린다.
server:
tomcat:
threads:
max: 200
max-connections: 8192
accept-count: 100
connection-timeout: 30000
Spring boot에서 tomcat thread pool에 대한 설정은 application.yaml을 통해 쉽게 설정할 수 있다. 이 설정은 내장 tomcat에서 사용되는 nio connecto의 설정값으로 이 부분은 이후 자세하게 알아보도록 하자. 5차 부하 테스트에서는 일반적인 웹 서비스를 가정하여 I/O bound가 많은 경우를 가정해 보자.
5차 부하 테스트
요청 처리에 필요한 자원 할당
결과 1 - 단일 서버의 thread pool를 늘린다.
결론 1 분석
tomcat nio connector가 가용하는 thread 수를 조절하여 초당 처리 가능한 요청이 1K를 유지하는 것을 알 수 있다. 하지만 단일 서버 내 thread를 경쟁하며 최종적으로 thread를 할당받지 못한 요청들은 모두 실패 처리되는 것을 알 수 있다. 특히 흥미로운 점은 가장 처음 발행된 요청들이 30초 뒤 모두 timeout이 발생되는 것을 보면, 새로운 요청들에 대한 우선순위가 더 높게 처리되어 처음 들어온 요청에 대해선 starvation이 발생하는 것이 아닌가 추측해 볼 수 있다.
결과 2 - 서버를 여러 대 구성한다.
결과 2 분석
이번엔 서버를 늘린 경우와 비교하기 위해 기본값(200)으로 설정된 서버를 5대 생성한 실험 해봤다. 단일 서버에 대한 thread pool를 늘렸을 때보다 몇몇 수치가 조금씩 증가했지만 http_req_duration (max)의 수치가 획기적으로 낮아졌음을 알 수 있다. 또 첫 요청에 대해서 발생하던 request timeout 없이 처리된 것도 알 수 있다.
즉, 서로 다른 라이프 사이클을 가진 프로세스 자체가 증가하게 되면서 각 CPU scheduling에 의해 더 많이 선택되고, 내부에서 발생하는 thread 간 context switching 비용도 줄어들게 되며 당연하게 성능이 올라간 상황이다.
서버 클러스터링을 통해 최악의 경우 60초->12초로 성능이 개선된 것을 알 수 있다. 하지만 일반적으로 12초대 응답을 가진 서비스는 사용자 경험이 엄청난 영향을 미친다. 한 통계에 따르면 1초에서 3초가 넘어가면 이탈률이 30% 증가하고, 6초가 넘어가면 106% 증가한다고 한다.
테스트 API가 최악의 경우 2000ms가 걸리는 것을 고려해도, 최악의 13s가 걸리는 것은 문제가 있다. 그렇다면 이 문제는 어떻게 해결하는 것이 좋을까?
- 실패한 응답이라도 빠르게 사용자에게 반응하고, 서버 자원을 절약한다.
- 서버를 FLEX💰 해버린다.
- 병목이 생기는 기능을 개선한다.
- 실시간성이 요구되는 데이터가 아닌 경우 캐싱된 데이터를 반환한다.
이번 시나리오는 재미를 위해 서버를 확장해볼 예정이다.
현재 서버 구성은 4 core 16Gb의 노드 3대로 구성된 서버를 운영하고 있다. 총 30대의 서버 자원을 확장한 결과 13s -> 4s로 줄어든 것을 알 수 있다. 현재 테스트에 사용되는 API의 경우 최악의 경우 2s 후 응답을 주기 때문에 thread pool에서 대기하는 시간 등을 고려하면 어느 정도 합당한 응답 시간이라고 생각된다.
참고
'메모' 카테고리의 다른 글
GeoIP를 활용한 Nginx 국가별 접근 차단: 당신의 웹사이트를 지키는 글로벌 관문 🌍 🗝️ (1) | 2023.12.12 |
---|---|
N+1 발생을 쉽게 파악할 수 없을까? (0) | 2023.12.08 |
AWS에 MySQL을 설치하려다 포기한 당신을 위해 (0) | 2023.11.29 |
속도와 보안을 한 단계 업그레이드! 새로운 DNS 추가의 완벽 가이드 (0) | 2023.11.27 |
나만의 홈랩(HomeLab) 구축기 (0) | 2023.11.10 |