안녕하세요, 장동호입니다!
오늘은 백엔드 개발자가 꼭 알고 있어야 할 성능 개념들을 정리해보려고 합니다.
웹 서비스의 트래픽이 늘어나면서 가장 먼저 마주하게 되는 문제는 바로 성능입니다. 빠른 응답, 안정적인 처리, 그리고 병목 없는 시스템을 만들기 위해 백엔드 개발자라면 꼭 이해하고 있어야 할 개념들이 있습니다. 이 글에서는 처리량, 응답 시간, 병목 지점, 그리고 DB 커넥션 풀에 대해 정리해보겠습니다.
응답 시간과 처리량
사용자는 무언가를 실행할 때 동작하기전까지 걸린 시간으로 성능을 판단하지만 실제로는 다양한 지표가 성능과 관련되어 있습니다. 네트워크 속도, 디스크 속도, 메모리 크기, 디바이스의 CPU 속도 등이 여기에 해당됩니다. 이런 다양한 지표 중에서 서버 성능과 관련 있는 중요한 지표를 2가지 꼽자면 응답 시간과 처리량을 들 수 있습니다.
응답 시간
응답 시간은 사용자의 요청을 처리하는 데 걸리는 시간을 의미합니다.
다음 그림은 전형적인 API 호출 과정을 나타낸 것입니다. 이 그림에서 API를 호출하고 전체 JSON 응답을 받을 때까지 소요된 시간이 바로 응답 시간입니다.
응답 시간을 구성하는 요소를 좀 더 자세히 살펴보겠습니다.
하나의 API 요청을 처리하는 데 걸리는 전체 시간은 다음 그림과 같이 구성됩니다.
클라이언트가 서버로 요청을 보내는 과정은 크게 2단계로 이루어집니다.
1.
서버에 연결: TCP를 이용해서 서버에 연결합니다.
2.
데이터 전송: 정해진 규칙(프로토콜)에 따라 데이터를 서버에 전송합니다. 예를 들어, HTTP 프로토콜에 따라 POST 방식으로 JSON 데이터를 보낼 수 있습니다.
서버는 로직을 실행한 다음 응답 데이터를 클라이언트에 전송합니다. 응답 데이터를 전송할 때는 API 요청 과정에서 서버와 연결된 소켓을 이용합니다.
위 그림에서 봤듯이 응답 시간은 API 요청 시간, 서버의 처리 시간, API 응답 전송 시간으로 나뉩니다. 서버 개발자는 주로 서버의 처리 시간을 확인합니다. 서버 처리 시간은 다음과 같은 요소를 포함합니다.
•
로직 수행 (if, for 등)
•
DB 연동 (SQL 실행)
•
외부 API 연동
•
응답 데이터 생성(전송)
이 중에서 DB 연동과 외부 API 연동이 큰 비중을 차지합니다.
따라서 응답 시간을 줄일 때는 DB 연동과 API 연동 시간에 보통 집중합니다.
처리량
처리량은 단위 시간당 시스템이 처리하는 작업량을 의미하는데 흔히 TPS나 RPS로 처리량을 나타냅니다.
•
TPS: 초당 트랜잭션 수
•
RPS: 초당 요청 수
위 그림에서 화살표는 요청을 의미하고 화살표의 길이는 응답 시간을 나타냅니다.
0분 1초 구간에는 완료된 요청이 3개이므로 이 구간의 TPS는 3입니다.
0분 2초 구간에서 완료된 요청은 2개이므로 TPS는 2가 됩니다.
최대 TPS는 시스템이 처리할 수 있는 최대 요청 수를 의미합니다.
예를 들어, 서버가 한 번에 5개의 요청을 처리할 수 있다고 가정해봅시다.
이때 요청당 처리 시간이 1초라면 최대 TPS는 5가 됩니다.
동시에 들어오는 요청 수가 최대 TPS를 초과하면 서버는 초과한 요청을 나중에 처리합니다.
위 그림은 최대 TPS를 초과하는 요청이 들어왔을 때 사용자 입장에서 응답 시간이 증가하는 문제를 보여줍니다.
최대 TPS가 5인 서버에 동시에 6개의 요청이 들어오면 이 중 5개만 바로 처리할 수 있습니다.
나머지 1개는 먼저 실행된 5개의 요청이 끝난 후에야 처리할 수 있습니다.
사용자 입장에서 먼저 처리된 5개의 요청은 응답 시간이 1초지만 나중에 처리된 2개의 요청은 응답 시간이 2초가 됩니다.
응답 시간의 증가는 사용자 이탈로 이어질 수 있습니다. 이를 방지하려면 다음 2가지 방법을 고려해야 합니다.
•
서버가 동시에 처리할 수 있는 요청 수를 늘려 대기 시간 줄이기
•
처리 시간 자체를 줄여 대기 시간 줄이기
성능을 개선하려면 먼저 현재 서버의 TPS와 응답 시간을 알아야 합니다.
트래픽이 많은 시간대의 TPS와 응답 시간이 얼마인지 측정하고, 이 결과를 바탕으로 목표 TPS와 응답 시간을 설정하고 효과적인 성능 개선안을 도출해야 합니다.
TPS를 확인하는 가장 간단한 방법은 모니터링 시스템을 활용하는 것입니다. New Relic, Datadog, Prometheus + Grafana 조합과 같은 도구를 사용하면 실시간 TPS뿐 아니라 과거 특정 시점의 TPS도 확인할 수 있습니다.
많은 모니터링 시스템이 TPS를 구할 때 근사치를 사용합니다. 예를 들어 5초 간격으로 처리한 요청 수를 구한 뒤 이를 5로 나눠 TPS를 계산하는 식입니다. 더욱 정확한 TPS를 알고 싶다면 웹 서버 접근 로그를 활용하면 됩니다. 엘라스틱서치 같은 별도 시스템에 접근 로그를 수집한 뒤 집계하면 됩니다.
병목 지점
서비스 초기에는 성능 문제가 잘 발생하지 않습니다. 사용자 수, 트래픽, 데이터베이스 크기 등이 모두 작기 때문입니다. 성능 문제는 사용자가 늘면서 점차 나타납니다. 트래픽이 늘고 데이터가 쌓이면서 다음과 같은 현상이 발생하기 시작합니다.
•
순간적으로 모든 사용자 요청에 대한 응답 시간이 심각하게 느려집니다. 10초 이상 걸리는 요청이 늘어나고 다수의 요청에서 연결 시간 초과와 같은 오류가 발생합니다.
•
서버를 재시작하면 잠시 괜찮다가 다시 응답 시간이 느려지는 현상이 반복됩니다.
•
트래픽이 줄어들 때까지 심각한 상황이 계속됩니다.
트래픽이 증가하면서 성능 문제가 발생하는 주된 이유는 시스템이 수용할 수 있는 최대 TPS를 초과하는 트래픽이 유입되기 때문입니다. TPS를 높이려면 먼저 성능 문제가 발생하는 지점을 찾아야 합니다. 대부분의 모니터링 도구는 실행 시간 추적 기능을 제공하므로 이 기능을 활용하면 성능 문제가 발생하는 시점에 어떤 코드에서 실행 시간이 오래 걸렸는지 찾을 수 있습니다. 일반적으로 서버 성능 문제는 주로 DB나 외부 API를 연동하는 과정에서 발생합니다.
수직 확장과 수평 확장
성능 문제를 일으키는 원인을 찾았다면 빠르게 적용할 수 있는 개선안을 도출해야 합니다. 사용자가 서비스를 이용하지 못하는 상황에서 이를 방치한 채 시간만 오래 걸리는 개선 방안을 시도할 수는 없습니다. 일단 급한 불을 끄고 나서 근본적인 해결책을 모색해야 합니다.
수직 확장 (scale-up)
급한 불을 끄는 방법 중 하나는 수직 확장(scale-up)을 하는 것입니다. CPU, 메모리, 디스크 등의 자원을 증가시키는 것을 말합니다. 더 빠른 CPU로 바꾸거나 CPU 코어수를 늘리고 메모리를 확장하고 디스크를 SSD로 바꾸는 것만으로도 성능이 개선될 수 있습니다. 클라우드 환경에서 비교적 빠르게 시도할 수 있는 방법입니다.
수평 확장 (scale-out)
수직 확장은 즉각적인 효과를 바로 얻을 수 있지만 트래픽이 지속해서 증가하면 언젠가 결국 또다시 성능 문제가 발생합니다. 수직 확장은 비용이 많이 들고, 한 대의 장비가 감당할 수 있는 용량에도 한계가 있습니다. 따라서 트래픽이 증가하면 서버를 추가로 투입해 TPS를 높이는 방법도 고려해야 합니다. 이렇게 서버를 늘리는 방법을 수평 확장(scale-out)이라고 합니다.
TPS를 높이기 위해 무턱대고 서버를 추가해서는 안 됩니다. 실제 병목 지점이 어디인지 파악하는 게 중요합니다. DB에서 성능 문제가 발생하고 있는데 서버를 투입하면 불에 기름을 붓는 격입니다. DB에 문제가 있는 상황에서 DB를 사용하는 서버를 더 늘리면 DB에 가해지는 부하가 더 커지고 성능 문제는 더 악화됩니다.
외부 API 성능이 문제인 경우도 마찬가지입니다. 외부 API의 성능이 개선되지 않는 한 서버를 추가한다고 해도 TPS는 향상되지 않습니다. DB나 외부 API에 성능 문제가 발생하지 않는 범위 내에서만 수평 확장을 해야 효과가 있습니다.
DB 커넥션 풀
DB를 사용하려면 다음과 같이 3단계를 거칩니다.
1.
DB에 연결한다.
2.
쿼리를 실행한다.
3.
사용이 끝나면 연결을 종료한다.
서버와 DB는 네트워크 통신을 통해 연결됩니다. 이때 네트워크 연결을 생성하고 종료하는 데 걸리는 시간은 0.5초에서 1초 이상 소요되기도 합니다. 네트워크에서 DB를 연결하고 종료하는 시간은 전체 응답 시간에 영향을 줍니다. 응답 시간이 길어지면 전체 처리량은 떨어집니다. 트래픽이 증가하면 이러한 현상은 더 두드러집니다. 매 요청마다 DB를 연결하고 종료하면 트래픽이 증가할 때 급격하게 처리량이 떨어지기도 합니다.
이런 문제를 피하기 위해 DB 커넥션 풀을 사용합니다. DB 커넥션 풀은 미리 일정 수의 DB 커넥션(Connection)을 생성해 풀(Pool)에 보관해 두고, 애플리케이션이 DB 작업을 수행할 때마다 풀에서 커넥션을 가져와 사용하고, 작업이 끝나면 다시 풀에 반환합니다. 커넥션 풀을 사용하면 이미 연결된 커넥션을 재사용하기 때문에 응답 시간이 줄어드는 장점이 있습니다.
커넥션 풀은 다양한 설정을 제공합니다. 그중 중요한 설정은 다음과 같습니다.
•
커넥션 풀 크기 (최소 크기, 최대 크기)
•
풀에 커넥션이 없을 때 커넥션을 구할 때까지 대기할 시간
•
커넥션의 유지 시간 (최대 유휴 시간, 최대 유지 시간)
커넥션 풀 크기
커넥션 풀 크기는 미리 생성해둘 DB 커넥션의 개수를 지정하는 설정으로, 커넥션 풀 설정 중 가장 중요합니다. 서버는 대부분의 요청 처리 과정에서 DB와 통신하기 때문에, 커넥션 풀 크기를 적절하게 설정하지 않으면 성능에 큰 영향을 줄 수 있습니다.
예를 들어, 커넥션 풀 크기를 5로 설정한 상태에서 동시에 6개의 요청이 들어온 경우, 이 중 5개 요청은 풀에서 커넥션을 정상적으로 가져와 처리할 수 있습니다. 하지만 나머지 1개 요청은 사용 가능한 커넥션이 없기 때문에, 다른 요청이 커넥션을 반납할 때까지 대기해야 합니다.
풀에서 커넥션을 얻기 위해 대기하는 시간을 줄이려면 전체 응답 시간과 TPS를 고려하여 커넥션 풀 크기를 지정해야 합니다. 다음은 이를 설명하는 두 가지 가정입니다.
가정 1: 짧은 쿼리 실행 시간
•
커넥션 풀 크기: 5
•
하나의 요청 쿼리 실행 시간: 0.1초
•
데이터 전송 시간은 무시
가정 2: 긴 쿼리 실행 시간
•
커넥션 풀 크기: 5
•
하나의 요청 쿼리 실행 시간: 1초
•
데이터 전송 시간은 무시
커넥션 풀의 구현 방식에 따라 다르지만 일반적인 커넥션 풀은 최소 크기와 최대 크기를 설정할 수 있습니다. 예를 들어 최소 크기를 10으로, 최대 크기를 20으로 지정했다고 가정해보겠습니다. 동시에 들어오는 요청이 10개 이하면 커넥션 풀은 10개의 커넥션을 풀에 유지합니다. 그러다 어느 시점에 동시에 들어오는 요청이 10개를 초과하면 커넥션 풀은 커넥션 개수를 늘려서 요청을 처리합니다. 동시 요청 수가 증가하면 커넥션 풀은 최대 크기인 20개까지 커넥션을 늘립니다. 이후 동시 요청이 20개 이하로 줄면 커넥션 풀의 커넥션도 점차 줄어듭니다.
일반적으로 트래픽은 증가했다가 감소하는 패턴을 보입니다. 은행 서비스는 낮 시간대에 트래픽이 높고, 게임 서비스는 저녁 시간대에 트래픽이 높은 경향이 있습니다. 이러한 특성에 맞게 커넥션 풀 크기도 트래픽이 적은 시간대는 최소 크기를 유지하고 트래픽이 높은 시간대에는 최대 크기로 확장해서 커넥션 개수를 필요한 만큼만 유지할 수 있습니다.
트래픽이 순간적으로 급증하는 패턴을 보인다면 커넥션 풀의 최소 크기를 최대 크기에 맞추는 것이 좋습니다. 트래픽이 점진적으로 증가할 때는 DB 연결 시간이 성능에 큰 영향을 주지 않지만 트래픽이 급증할 경우 DB 연결 시간도 성능 저하의 주요 원인이 될 수 있기 때문입니다.
커넥션 풀 크기를 늘리면 처리량을 높일 수 있습니다. 그러나 커넥션 풀 크기를 무턱대고 크게 설정하는 것은 오히려 역효과를 낼 수 있습니다. DB 서버의 CPU 사용률이 80%에 육박하는 상황에서 커넥션 풀 크기를 늘리면 DB에 가해지는 부하가 더 커져 오히려 전체 성능이 떨어질 수 있습니다. 이러한 상태에서는 커넥션 풀 크기를 늘리기보다는 오히려 커넥션 풀 크기를 유지하거나 줄여서 DB 서버가 포화 상태에 이르지 않도록 해야 합니다.
커넥션 대기 시간
커넥션 대기 시간이란 풀에 사용할 수 있는 커넥션이 없을 때 커넥션을 얻기 위해 기다릴 수 있는 최대 시간을 의미합니다.
지정된 대기 시간 안에 커넥션을 구하지 못하면 DB 연결 실패 에러가 발생합니다.
커넥션을 얻기 위해 대기하는 시간만큼 응답 시간도 길어집니다. 따라서 응답 시간이 중요한 서비스는 커넥션 대기 시간을 가능한 한 짧게 설정해야 합니다. 트래픽의 양이나 서비스의 특성에 따라 차이는 있지만 보통의 경우라면 0.5초에서 3초 이내로 지정합니다. 참고로 HikariCP의 기본 대기 시간은 30초로 설정되어 있습니다.
에러를 응답하면 사용자의 입장에서 부정적으로 보일 수도 있습니다. 하지만 대기 시간 때문에 긴 시간 동안 무응답 상태로 유지되는 것보다는 빠르게 에러를 반환하는 것이 더 낫습니다. 예를 들어 커넥션 풀의 크기가 10이고 대기 시간이 30초라고 해보겠습니다. 이때 동시에 30개의 요청이 발생했는데 순간적으로 DB 서버에 부하가 걸리면서 쿼리 실행 시간이 10초로 늘어났습니다. 이 시점에 각 요청은 다음 상태가 됩니다.
•
요청 10개는 풀에서 커넥션을 확보하여 쿼리 실행을 시작함
•
나머지 요청 20개는 풀에서 커넥션을 확보하지 못해 대기 상태로 진입함
대기하는 사람 중 절반이 기다리지 못하고 5초 만에 요청을 취소하고 다시 요청했다고 가정해보겠습니다. 그러면 다음 그림과 같이 상태가 바뀌게 됩니다.
클라이언트가 요청을 취소하더라도 서버는 일정 시간 동안 하던 작업을 즉시 중단하지 않기 때문에, 이미 커넥션 풀에서 커넥션을 얻어 처리 중이던 요청은 그대로 계속 진행됩니다. 이와 같이 몇 초 만에 요청을 취소하고 재요청이 반복되면 동시에 처리해야 할 요청 수는 계속 증가합니다. 요청 수가 증가하면 그만큼 서버에 가해지는 부하도 커지게 됩니다. 따라서, 대기 시간을 짧게 설정하여 필요 이상으로 요청이 대기열에 머무르지 않도록 제한하는 것이 중요합니다.
최대 유휴 시간, 유효성 검사, 최대 유지 시간
커넥션 풀을 사용할 때 주의할 점은 풀에 남아있는 커넥션이 항상 정상 상태라고 보장할 수는 없다는 점입니다. 커넥션이 오랫동안 사용되지 않거나, 네트워크 문제나 DB 재시작 등의 이유로 유휴 커넥션이 끊어졌을 수 있습니다.
MySQL과 같은 DB는 클라이언트와 일정 시간 동안 상호작용이 없으면 자동으로 연결을 끊는 기능을 제공합니다. 따라서 풀에 있는 커넥션이 일정 시간 이상 사용되지 않으면 DB와의 연결이 끊어질 수 있습니다. 예를 들어 DB가 1시간 동안 상호작용이 없는 클라이언트의 연결을 종료하도록 설정되어 있다고 가정해보겠습니다. 만약 새벽 시간대에 1시간 이상 사용자가 없으면 커넥션 풀에 있는 모든 커넥션은 DB와의 연결이 끊기게 됩니다.
이때 DB와의 연결이 끊긴 커넥션을 사용하면 에러가 발생하게 됩니다. 이러한 연결 끊김으로 인해 발생하는 에러를 방지하기 위해 커넥션 풀은 다음 2가지 기능을 제공합니다.
•
최대 유휴 시간 지정
•
유효성 검사 지원
최대 유휴 시간(idleTimeout)은 사용되지 않은 커넥션을 풀에 유지할 수 있는 최대 시간을 의미합니다. 최대 유휴 시간을 30분으로 설정하면 30분 이상 사용되지 않은 커넥션은 종료되어 풀에서 제거됩니다. 이 시간을 DB에 설정된 비활성화 유지 시간보다 짧게 설정하면, DB가 연결을 끊기 전에 풀에서 커넥션을 제거할 수 있습니다.
반대로, 최대 유휴 시간이 DB의 커넥션 비활성화 유지 시간보다 길게 설정되면, DB는 먼저 커넥션을 끊고 클라이언트는 그 사실을 인지하지 못한 채 끊긴 커넥션을 재사용하려 할 수 있습니다. 따라서, 최대 유휴 시간은 DB의 커넥션 비활성화 유지 시간보다 짧게 설정하는 것이 안전합니다.
유효성 검사(validationTimeout)는 커넥션이 정상적으로 사용할 수 있는 상태인지 여부를 확인하는 절차입니다. 커넥션 풀의 구현 방식에 따라 커넥션을 풀에서 가져올 때 유효성을 검사하거나 주기적으로 검사할 수 있습니다. 이 과정을 통해 연결이 유효하지 않은 커넥션을 식별하고 풀에서 제거할 수 있습니다. 유효성 검사를 위해 커넥션 풀은 실제 쿼리를 실행하기도 합니다. 이때 SELECT 1 과 같이 간단한 쿼리를 사용합니다.
커넥션 풀이 제공하는 또 다른 설정은 최대 유지 시간(maxLifetime)입니다. 이 값이 4시간으로 설정되어 있다면, 커넥션은 생성된 시점부터 최대 4시간까지만 유지됩니다. 4시간이 지나면 커넥션이 유효하더라도 커넥션을 닫고 풀에서 제거됩니다.
최대 유휴 시간과 최대 유지 시간을 무한대로 설정하지 않는 것이 좋습니다. 커넥션 풀의 기본값을 확인한 뒤 이 두 설정의 기본값이 무제한으로 되어 있다면 DB 설정을 참고하여 알맞게 적절한 값으로 지정해야 합니다.
예를 들어, MySQL의 경우 기본적으로 DB 커넥션 비활성화 유지 시간(wait_timeout)이 8시간(28,800초)으로 설정되어 있습니다. 이 시간 동안 커넥션이 아무 작업도 하지 않으면 MySQL은 해당 커넥션을 끊어버립니다. 따라서 클라이언트 측에서는 다음과 같이 최대 유지 시간과 최대 유휴 시간을 설정하는 것이 좋습니다.
•
최대 유지 시간: 25,000초 (약 7시간)
•
최대 유휴 시간: 600초 (10분)
HikariCP 설정 항목 | 설명 |
최소/최대 커넥션 수 (minimumIdle/maximumPoolSize) | 동시 요청 수와 쿼리 처리 시간에 따라 성능 결정 |
대기 시간 (connectionTimeout) | 커넥션이 없을 때 얼마나 기다릴지 설정 (0.5~3초 권장) |
최대 유휴 시간 (idleTimeout) | 일정 시간 이상 사용되지 않은 커넥션을 제거 |
최대 유지 시간 (maxLifetime) | 커넥션이 풀에 남아있을 수 있는 최대 시간 |
유효성 검사 (validationTimeout) | 커넥션이 유효한지 확인하여 오류 방지 |
대기 처리
사용자가 순간적으로 폭증할 때가 있습니다. 대표적인 예가 콘서트 예매입니다. 예매가 시작되면 몇 분 만에 매진이 되고는 합니다. 많은 사용자가 표를 구매하기 위해 계속해서 클릭하고, 이로 인해 급격히 증가한 트래픽은 매진과 동시에 빠르게 감소합니다. 1시간도 안 되는 짧은 시간 동안만 트래픽이 폭증하는 것입니다.
이렇게 짧은 시간 동안 폭증하는 트래픽은 어떻게 처리해야 할까요? 생각할 수 있는 방법은 다음과 같습니다.
1.
서버 증설하기
2.
DB 증설하기
순간적으로 폭증하는 트래픽을 처리하기 위해 서버와 DB를 증설하는 것이 잘못된 방법은 아니지만 한 가지 문제가 있습니다. 바로 비용입니다. 짧은 시간을 버티기 위해서 투입해야 하는 비용이 크기 때문입니다. 클라우드에서 증설한 서버는 다시 줄일 수 있지만 DB는 그렇지 않습니다. 최대 트래픽에 맞춰 DB 성능을 높여 놓으면 다시 DB 성능을 줄이기가 쉽지 않습니다. 전체 서비스 시간 중 1%도 되지 않는 시간을 위해 고정 비용(DB 비용)이 커지는 격입니다.
그렇다면 이 방법은 어떨까요? 처리할 수 있는 시스템의 처리량을 무작정 늘리기보다는 수용할 수 있는 수준의 트래픽만 받아들이고 나머지는 대기 처리하는 것입니다. 이 방법의 이점은 다음과 같습니다.
•
서버를 증설하지 않고도 서비스를 안정적으로 제공할 수 있습니다.
•
사용자의 지속적인 새로 고침으로 인한 트래픽 폭증도 방지할 수 있습니다. 사용자는 새로고침할 경우 순번이 뒤로 밀리기 때문에 불필요한 새로 고침을 자제하게 됩니다.
사용자는 대기 없이 서비스를 사용하고 싶어 하지만 높은 부하로 인해 서비스 자체가 아예 안 되는 것보다는 대기하는 편이 낫습니다. 결국 자기 차례가 오면 서비스를 사용할 수 있기 때문입니다.
정리
•
응답 시간은 사용자 체감 속도에 직결되며, 서버 처리 시간 중 DB 연동과 외부 API 호출이 큰 비중을 차지합니다.
•
처리량(TPS)은 시스템이 일정 시간 동안 얼마나 많은 요청을 처리할 수 있는지를 나타내며, 최대 TPS를 초과하면 응답 시간이 급격히 증가할 수 있습니다.
•
성능 개선은 수직 확장(scale-up)으로 단기적으로 해결하고, 수평 확장(scale-out)으로 장기적인 대응을 할 수 있습니다.
•
DB 커넥션은 네트워크 연결이 필요하므로 연결/종료 시 시간 소모가 큽니다.
•
DB 커넥션 풀은 미리 커넥션을 만들어 재사용함으로써 연결 비용을 줄이고 성능을 향상시킵니다.