한 줄 정의
느려진 서비스를 고치는 일은 코드를 감으로 뜯어보는 게 아니라, 응답 시간과 처리량을 측정해 병목 지점을 찾고 그 지점의 대기 시간과 처리 시간을 줄이는 작업입니다.
쉽게 말하면
창구가 5개인 은행 지점을 떠올리면 이 장 전체가 한 줄로 꿰어집니다.
한 명당 업무가 1분이면 이 지점은 분당 5명을 처리합니다. 이것이 최대 TPS 입니다. 손님이 7명 오면 2명은 앞사람이 끝날 때까지 기다려야 하고, 그 손님이 체감하는 시간은 업무 1분이 아니라 대기 1분 + 업무 1분입니다. 이것이 응답 시간 입니다. “느리다”는 말은 대부분 업무가 느려졌다는 뜻이 아니라 줄이 길어졌다 는 뜻입니다.
줄을 줄이는 방법은 셋뿐입니다. 창구를 늘리거나(수직·수평 확장, 커넥션 풀 크기), 한 명당 업무 시간을 줄이거나(캐시, 압축, CDN), 아예 번호표를 나눠 주고 수용 가능한 만큼만 들이거나(대기 처리).
그리고 업무 시간의 대부분은 직원이 지하 서고(DB)와 옆 건물(외부 API)까지 왕복하는 데 쓰입니다. 이 장의 기법 대부분이 그 왕복을 없애거나(캐시), 문을 열어 둔 채 재사용하거나(커넥션 풀), 애초에 서고까지 가지 않게 만드는 것(브라우저 캐시·CDN) 인 이유가 여기 있습니다.
왜 중요한가?
성능 문제는 서서히 오다가 한계선에서 한꺼번에 터집니다. 서비스 초기에는 사용자도 데이터도 적어 문제가 드러나지 않다가, 트래픽이 늘고 데이터가 쌓이면서 간헐적인 지연으로 시작해 결국 다음과 같은 형태로 폭발합니다.
- 모든 요청의 응답 시간이 순간적으로 심각하게 느려지고 10초 이상 걸리는 요청과 연결 시간 초과 오류가 늘어납니다
- 서버를 재시작하면 잠시 괜찮아졌다가 다시 느려지는 현상이 반복됩니다
- 트래픽이 줄어들 때까지 이 상태가 계속됩니다
근본 원인은 대부분 하나입니다. 시스템이 감당할 수 있는 최대 TPS를 초과하는 트래픽이 들어오는 것 입니다. 최대 TPS를 올리지 않으면 어떤 임시 조치도 같은 지점으로 되돌아옵니다.
응답 시간이 길어지면 사용자가 이탈합니다. 구글의 ‘Speed Matters’ 자료에 따르면 검색 지연이 100ms일 때 사용자당 검색 횟수가 0.2%, 400ms일 때 0.6% 감소했고, 지연 시간을 원래대로 복구한 뒤에도 감소세가 지속 되었습니다. 아마존도 응답 시간이 100ms 증가할 때마다 매출이 1% 감소한다고 발표했습니다. 응답 시간을 줄인다고 매출이 반드시 늘지는 않지만, 길어지면 확실히 나빠집니다.
핵심 내용
응답 시간과 처리량
응답 시간의 구성
응답 시간은 사용자의 요청을 처리하는 데 걸리는 전체 시간이며, 다음 네 구간의 합입니다.
flowchart LR A["서버에 연결<br/>(TCP)"] --> B["서버로<br/>데이터 전송"] --> C["서버 실행<br/>로직·SQL·응답 생성"] --> D["클라이언트로<br/>데이터 전송"]
응답 데이터가 도착하는 시점을 어디로 잡느냐에 따라 측정값이 갈립니다.
| 지표 | 기준 |
|---|---|
| TTFB(Time to First Byte) | 응답 데이터의 첫 번째 바이트가 도착할 때까지 |
| TTLB(Time to Last Byte) | 응답 데이터의 마지막 바이트가 도착할 때까지 |
응답 데이터가 작으면 둘의 차이는 크지 않지만, 파일 다운로드처럼 데이터가 크거나 네트워크가 느리면 크게 벌어집니다. 데이터 특성과 네트워크 환경을 보고 적절한 쪽을 골라 측정해야 서버 성능을 올바르게 평가할 수 있습니다.
서버 처리 시간의 실제 분포
서버 개발자가 직접 통제할 수 있는 구간은 위 그림의 세 번째, 서버 처리 시간입니다. 이 구간은 로직 수행, DB 연동, 외부 API 연동, 응답 데이터 생성으로 나뉘는데, 실제 측정 결과는 어디를 봐야 하는지를 분명하게 알려 줍니다.
| 구간 | 시간 | 비중 |
|---|---|---|
| API 연동 1 (외부 네트워크) | 186ms | 53% |
| DB 연동 (SQL 6회) | 101ms | 29% |
| API 연동 2 (내부 네트워크) | 44ms | 13% |
| 로직 수행 | 17ms | 5% |
| 전체 | 348ms | 100% |
API 연동 두 번이 전체의 66%, DB가 29%를 차지하는 반면 로직 수행은 5%에 불과합니다. API 연동이 없는 서비스라면 DB 연동이 전체의 70~80%를 차지합니다. 응답 시간 개선의 초점을 알고리즘이 아니라 DB·API 연동에 두어야 하는 근거가 여기 있습니다.
처리량과 최대 TPS
처리량은 단위 시간당 처리하는 작업량이며 TPS(transaction per second) 로 표현합니다. 중요한 것은 완료된 요청 수를 센다는 점입니다.
최대 TPS 는 시스템이 처리할 수 있는 최대 요청 수입니다. 서버가 한 번에 5개를 처리할 수 있고 요청당 처리 시간이 1초라면 최대 TPS는 5입니다. 여기에 동시에 7개 요청이 들어오면 5개만 바로 처리되고, 나머지 2개는 앞의 요청이 끝난 뒤에야 시작됩니다. 먼저 처리된 5개의 응답 시간은 1초, 나중 2개는 대기 1초 + 처리 1초로 2초 가 됩니다.
여기서 TPS를 높이는 방법이 두 가지로 정리됩니다.
- 동시에 처리할 수 있는 요청 수를 늘려 대기 시간을 줄이기
- 처리 시간 자체를 줄여 대기 시간을 줄이기
이 장의 나머지 기법은 전부 이 두 갈래에 속합니다.
먼저 측정한다
막연히 느리다고 하면서 이것저것 시도하면 안 됩니다. 트래픽이 많은 시간대의 TPS와 응답 시간을 먼저 측정하고, 그 결과를 근거로 목표치를 정한 뒤 개선안을 도출해야 합니다. 스카우터, 핀포인트, 뉴렐릭 같은 모니터링 도구를 쓰면 실시간 TPS뿐 아니라 과거 특정 시점의 TPS도 확인할 수 있습니다.
모니터링 도구의 TPS는 근사치입니다
많은 모니터링 시스템이 5초 간격으로 처리한 요청 수를 구한 뒤 5로 나누는 식으로 TPS를 계산합니다. 더 정확한 값이 필요하면 웹 서버 접근 로그를 엘라스틱서치 같은 곳에 수집해 집계하거나, 도구가 없다면 리눅스 명령어로 접근 로그를 파싱해도 됩니다.
병목 지점 찾기
문제 지점을 찾는 가장 간단한 방법은 처리 시간이 오래 걸리는 작업을 식별하는 것 입니다. 경험으로 오래 걸릴 것 같은 코드를 추측할 수도 있지만, 가능하면 실제 실행 시간을 측정해야 합니다. 대부분의 모니터링 도구가 실행 시간 추적을 제공하므로 성능 문제가 발생한 시점에 어떤 코드가 오래 걸렸는지 찾을 수 있고, 도구가 없다면 의심되는 코드의 실행 시간을 로그로라도 남겨 둬야 다음 장애 때 근거가 생깁니다.
그리고 앞의 측정 데이터가 말해 주듯, 성능 문제는 주로 DB 연동과 외부 API 연동 과정 에서 발생합니다.
수직 확장과 수평 확장
원인을 찾았다면 빠르게 적용할 수 있는 개선안이 먼저입니다. 사용자가 서비스를 못 쓰는 상황에서 시간이 오래 걸리는 근본 대책만 붙들고 있을 수는 없습니다. 일단 급한 불을 끄고 나서 근본 해결책을 모색합니다.
수직 확장(scale-up) 은 CPU·메모리·디스크 같은 자원을 키우는 방법입니다. 더 빠른 CPU로 교체하거나 코어 수를 늘리고, 메모리를 증설하고, 디스크를 SSD로 바꾸는 것만으로 성능이 개선될 수 있습니다. 클라우드 환경에서는 비교적 빠르게 시도할 수 있어 급한 불을 끄는 데 유용합니다.
쿼리와 테이블 설계 등 여러 면에서 문제가 있어 근본 해결에 오랜 시간이 필요했던 서비스에서, DB 장비를 수직 확장해 일단 서비스를 재개한 사례 가 대표적입니다. 완전히 중단되는 것보다 다소 느리더라도 서비스를 제공하는 편이 낫고, 그동안 원인을 해소할 시간도 벌 수 있습니다. 서버 쪽도 마찬가지여서, 메모리 512MB로 동시 200개를 처리하던 서버에 250개가 들어와 응답 시간이 늘기 시작했다면 메모리를 1GB로 증설하는 것만으로 TPS를 올릴 수 있습니다.
다만 수직 확장은 비용이 많이 들고 한 대가 감당할 수 있는 용량에도 한계가 있습니다. 트래픽이 계속 늘면 결국 서버를 추가로 투입하는 수평 확장(scale-out) 이 필요하고, 서버가 두 대 이상이면 트래픽을 고르게 나눠 줄 로드 밸런서 가 함께 필요합니다.
| 분산 방식 | 동작 |
|---|---|
| 정적 — 라운드 로빈 | 요청을 각 서버에 순차적으로 분배 |
| 정적 — IP 해시 | 클라이언트 IP의 해시값으로 서버를 결정해 동일 클라이언트를 항상 같은 서버로 연결 |
| 동적 | 서버의 현재 상태를 보고 분배. 연결 수가 적거나 응답 시간이 짧은 서버로 요청 전달 |
무턱대고 서버를 추가하면 안 됩니다
DB에서 성능 문제가 발생하는 상황에서 서버를 늘리면 DB에 가해지는 부하가 더 커져 문제가 악화됩니다. 불에 기름을 붓는 격입니다. 외부 API가 병목일 때도 마찬가지로, API 성능이 개선되지 않는 한 서버를 늘려도 TPS는 올라가지 않습니다. 수평 확장은 DB와 외부 API에 여유가 있는 범위에서만 효과가 있습니다.
DB 커넥션 풀
DB를 쓰려면 연결 → 쿼리 실행 → 연결 종료의 3단계를 거치는데, 네트워크 연결을 생성하고 종료하는 데 0.5초에서 1초 이상 걸리기도 합니다. 실행 시간 10ms짜리 짧은 쿼리에 연결·종료로 50ms가 든다면 전체 60ms 중 80% 이상이 쿼리가 아니라 연결에 쓰이는 셈입니다. 매 요청마다 연결하고 종료하면 트래픽이 증가할 때 처리량이 급격하게 떨어집니다.
커넥션 풀 은 DB에 연결된 커넥션을 미리 만들어 보관해 두고, 애플리케이션이 필요할 때 빌려 쓰고 반환하게 합니다. 이미 연결된 커넥션을 재사용하므로 연결 비용이 응답 시간에서 사라집니다. 스프링 부트는 HikariCP를 쓰고 Go는 언어 차원에서 지원할 정도로 사실상 필수 입니다.
커넥션 풀 크기
풀에 미리 만들어 둘 커넥션 개수이며, 커넥션 풀 설정 중 가장 중요합니다. 풀 크기가 곧 DB를 쓰는 요청의 동시 처리 한도 이기 때문입니다.
| 풀 크기 | 쿼리 실행 시간 | 초당 처리 가능 요청 | 동시 50개 요청의 처리 시간 |
|---|---|---|---|
| 5 | 0.1초 | 50 (1초 / 0.1초 × 5) | 1초 |
| 5 | 0.5초 | 10 (1초 / 0.5초 × 5) | 5초 |
| 50 | 0.5초 | 100 | 0.5초 |
같은 풀 크기라도 쿼리가 느려지면 처리량이 그대로 무너집니다. 풀 크기는 응답 시간과 목표 TPS를 함께 놓고 계산해야 하는 값 이지 관행적으로 정할 값이 아닙니다.
일반적인 커넥션 풀은 최소 크기와 최대 크기를 따로 설정합니다. 최소 10, 최대 20이라면 동시 요청이 10개 이하일 때는 10개를 유지하다가 10개를 넘으면 최대 20개까지 늘리고, 다시 줄어들면 점차 줄입니다. 은행 서비스는 낮에, 게임 서비스는 저녁에 트래픽이 높은 것처럼 트래픽 패턴에 맞춰 커넥션을 필요한 만큼만 유지 하려는 설정입니다.
급증형 트래픽이라면 최소 = 최대
트래픽이 순간적으로 급증하는 패턴이라면 최소 크기를 최대 크기에 맞추는 편이 좋습니다. 점진적으로 증가할 때는 DB 연결 시간이 성능에 큰 영향을 주지 않지만, 급증할 때는 연결을 새로 만드는 시간 자체가 성능 저하의 주요 원인 이 되기 때문입니다.
풀 크기는 DB 상태를 보고 늘립니다
DB 서버의 CPU 사용률이 80%에 육박하는 상황에서 커넥션 풀 크기를 늘리면 DB 부하가 더 커져 쿼리 실행 시간이 급격히 증가합니다. 이때는 오히려 크기를 유지하거나 줄여 DB가 포화 상태에 이르지 않게 해야 합니다. 서버를 수평 확장하는 것도 결국 전체 커넥션 수를 늘리는 것과 같으므로 마찬가지로 DB 상태를 먼저 확인해야 합니다.
커넥션 대기 시간
풀에 쓸 수 있는 커넥션이 없을 때 기다릴 최대 시간이며, 이 시간 안에 커넥션을 얻지 못하면 DB 연결 실패 에러가 발생합니다. 대기한 시간만큼 응답 시간도 길어지므로, HikariCP의 기본값 30초는 최악의 경우 응답 시간이 30초를 넘을 수 있다는 뜻 입니다. 응답 시간이 중요한 서비스라면 보통 0.5초에서 3초 이내로 지정합니다.
대기 시간을 짧게 잡으면 풀이 모두 사용 중일 때 사용자에게 빠르게 ‘일시적 오류’를 응답하게 됩니다. 에러를 보여 주는 게 부정적으로 보일 수 있지만, 긴 시간 무응답 상태로 두는 것보다 낫고 서버 부하가 눈덩이처럼 불어나는 것도 막아 줍니다. 다음 시나리오가 그 이유를 보여 줍니다.
풀 크기 10, 대기 시간 30초인 서버에 동시에 30개 요청이 들어왔고, 순간적인 DB 부하로 쿼리 실행 시간이 10초로 늘어난 상황입니다.
- 10개는 커넥션을 확보해 쿼리를 시작하고, 20개는 대기 상태로 들어갑니다
- 5초 뒤, 기다리다 못한 사용자 10명이 요청을 취소하고 다시 요청합니다
- 클라이언트가 취소해도 서버는 하던 작업을 즉시 중단하지 않으므로 대기 중인 요청은 20개가 아니라 30개가 됩니다
- 서버가 동시에 처리해야 할 요청 수가 30개에서 40개로 늘고, 재요청이 반복될수록 계속 증가합니다
같은 상황에서 대기 시간이 1초라면 2초 시점에 10개는 쿼리를 실행 중이고 20개는 이미 오류 응답을 받은 상태입니다. 서버가 실제로 붙들고 있는 동시 요청은 10개로 유지되고, 새 요청이 들어와도 1초 뒤 바로 에러를 응답합니다. 부하가 일정 수준을 넘지 않으므로 서버를 안정적으로 운영할 수 있습니다.
유휴 시간, 유효성 검사, 최대 유지 시간
새벽처럼 요청이 거의 없는 시간대에는 풀의 커넥션도 쓰이지 않습니다. 문제는 MySQL 같은 DB가 일정 시간 상호작용이 없는 클라이언트의 연결을 자동으로 끊는다 는 점입니다. DB가 1시간 기준으로 설정되어 있다면 새벽에 1시간 이상 사용자가 없을 때 풀의 모든 커넥션이 끊기고, 아침에 그 커넥션을 꺼내 쓰는 순간 에러가 납니다.
| 설정 | 역할 |
|---|---|
| 최대 유휴 시간 | 사용되지 않는 커넥션을 풀에 유지할 최대 시간. DB의 비활성 연결 종료 시간보다 짧게 잡아야 DB가 끊기 전에 풀에서 먼저 제거됨 |
| 유효성 검사 | 커넥션이 정상인지 확인하는 절차. 풀에서 꺼낼 때 검사하거나 주기적으로 검사하며, SELECT 1 같은 간단한 쿼리를 실제로 실행하기도 함 |
| 최대 유지 시간 | 생성 시점부터 유지할 최대 시간. 4시간으로 설정하면 유효한 커넥션이라도 4시간 뒤 닫고 제거 |
Note
최대 유휴 시간과 최대 유지 시간을 무한대로 두지 않는 것이 좋습니다. 커넥션 풀의 기본값을 확인한 뒤, 무제한이라면 DB 설정을 참고해 알맞은 값으로 지정해야 합니다.
서버 캐시
DB 서버를 확장하는 데는 비용이 많이 들고, 수평 확장하더라도 처리량은 늘지언정 쿼리 실행 시간 자체가 획기적으로 줄지는 않습니다. DB를 건드리지 않고 응답 시간과 처리량을 함께 개선하려는 선택지가 캐시입니다.
캐시는 (키, 값) 쌍을 저장하는 Map 형태의 데이터 저장소이고, 동작은 단순합니다. 캐시에서 키를 조회해 값이 있으면 바로 쓰고, 없으면 DB에서 조회한 뒤 캐시에 저장하고 씁니다. 이후 같은 요청은 DB까지 가지 않으므로 DB 연동 시간이 응답 시간에서 통째로 빠집니다. DB 조회 결과뿐 아니라 복잡한 계산 결과나 외부 API 연동 결과도 같은 방식으로 캐시할 수 있습니다.
키 선택
저장 대상에 맞는 키를 골라야 합니다. 게시글 상세는
articles:번호, 최신 인기 글은articles:hot10같은 형태입니다. 캐시 키가 서로 겹치지 않도록 주의합니다.
적중률과 삭제 규칙
적중률(hit rate) = 캐시에 존재한 건수 / 캐시에서 조회를 시도한 건수 입니다. 100번 조회해 87번 존재했다면 87%입니다. 적중률이 높을수록 DB 연동이 줄어들어 응답 시간 감소, 처리량 증가, DB 부하 감소로 이어집니다.
적중률을 높이는 가장 단순한 방법은 많이 저장하는 것이지만 캐시는 메모리를 쓰므로 개수와 크기에 제한이 있습니다. 가득 찬 상태에서 새 데이터를 넣으려면 기존 데이터 하나를 골라 제거해야 합니다.
| 규칙 | 제거 대상 |
|---|---|
| LRU(Least Recently Used) | 가장 오래전에 사용된 데이터 |
| LFU(Least Frequently Used) | 가장 적게 사용된 데이터 |
| FIFO(First In First Out) | 먼저 추가된 데이터 |
많은 서비스에서 최신 데이터가 더 자주 조회되고 오래된 데이터는 캐시에 있어도 쓰이지 않을 가능성이 큽니다. 그래서 캐시가 가득 차지 않았더라도 유효 시간(만료 시간)을 설정해 오래된 데이터를 미리 제거 하는 방식을 함께 씁니다.
로컬 캐시와 리모트 캐시
| 로컬 캐시 | 리모트 캐시 | |
|---|---|---|
| 저장소 | 서버 프로세스와 동일한 메모리 | 별도 프로세스 |
| 구현 | Caffeine(자바), go-cache(Go), node-cache(Node.js) | 레디스 |
| 속도 | 같은 메모리 공간이라 빠름 | 네트워크 통신이 필요해 상대적으로 느림 |
| 크기 | 프로세스 메모리 한계에 묶임 | 레디스 수평 확장으로 유연하게 확장 |
| 재시작 | 캐시 데이터가 모두 사라져 적중률이 순간 하락 | 서버를 재시작해도 데이터 유지 |
| 구조 | 외부 연동이 없어 단순 | 별도 장비·프로세스가 필요해 복잡 |
절대적인 기준은 없고 데이터 규모, 변경 빈도, 응답 시간, 처리량 을 기준으로 판단합니다.
- 규모가 작고 변경 빈도가 낮으면 로컬로 충분합니다. 홈 화면의 최신 공지글 목록은 많아야 10개 미만이고 몇 시간에서 며칠 동안 동일합니다
- 데이터 규모가 크면 리모트를 써야 합니다. 대형 쇼핑 사이트의 개별 제품 정보는 로컬 캐시의 메모리 한계로 감당하기 어렵고, 수시로 변경되면 효율도 떨어집니다
- 하루에도 몇 번씩 배포하는 서비스라면 리모트를 적극적으로 고려합니다. 로컬 캐시는 재시작할 때마다 처음부터 다시 쌓아야 해서 배포 직후 적중률과 응답 시간이 함께 나빠집니다
인-메모리 캐시
로컬 캐시는 메모리에 데이터를 보관하므로 인-메모리 캐시라고도 불립니다. 그런데 리모트 캐시로 많이 쓰는 레디스도 인-메모리 데이터 저장소라고 불려 혼동이 생깁니다. 이 책은 혼동을 줄이기 위해 ‘로컬 캐시’라는 용어를 씁니다.
사전 적재
트래픽이 순간적으로 급증하는 패턴이라면 데이터를 미리 넣어 두는 것이 효과적입니다. 사용자 300만 명에게 매달 정해진 날 요금 정보를 푸시로 안내하는 서비스를 가정해 보면 이유가 분명해집니다.
푸시를 받자마자 50%가 확인한다면 단시간에 150만 명이 동시에 접속합니다. 그런데 개별 요금 정보는 사용자가 한 번이라도 조회해야 캐시에 저장되므로, 푸시 직후 첫 조회 시점의 적중률은 0%에 가깝습니다. 응답 시간이 느려질 뿐 아니라 DB로 전달되는 부하도 급격히 치솟습니다.
푸시를 보내기 전에 각 사용자의 요금 정보를 캐시에 저장해 두면 트래픽이 한꺼번에 몰려도 적중률을 99%에 가깝게 유지할 수 있습니다.
무효화
캐시에 보관된 데이터의 원본이 바뀌면 캐시도 함께 변경하거나 삭제해야 합니다. 갱신되지 않으면 사용자는 오래된 잘못된 정보를 보게 됩니다. 게시글을 수정했는데 캐시가 그대로면 작성자는 수정이 반영되지 않았다고 생각해 오류 신고를 하거나 서비스 신뢰도를 낮게 평가합니다.
무효화 시점은 데이터 특성에 따라 갈립니다.
- 가격 정보, 게시글 내용처럼 민감한 데이터는 변경 즉시 무효화 합니다
- 변경에 민감하지 않고 크기가 작다면 유효 시간을 설정해 주기적으로 갱신합니다. 인기 글 목록이라면 유효 시간 10분으로 10분 주기 갱신 효과를 얻고, 더 빨리 갱신하려면 1분으로 줄이면 됩니다
변경에 민감한 데이터는 리모트 캐시에
로컬 캐시는 자신의 데이터만 변경하고 다른 서버의 로컬 캐시는 건드리지 않습니다. A 서버에 연결한 사용자는 변경된 가격을 보고 B 서버에 연결한 사용자는 변경 전 가격을 보게 되어 서비스 신뢰성에 큰 영향을 줍니다.
메모리와 가비지 컬렉터
자바·Go·파이썬 등은 사용이 끝난 객체를 즉시 지우지 않고 가비지 컬렉터가 정해진 규칙에 따라 회수합니다. 개발자의 메모리 관리 부담과 보안 이슈를 줄여 주지만 응답 시간에는 영향을 줍니다. 자바에서는 GC가 실행되는 동안 애플리케이션 실행이 일시 중단되며 이를 Stop-The-World 라고 부릅니다.
핵심은 메모리를 많이 쓰고 객체가 많을수록 GC 실행 시간이 길어진다 는 점입니다. 반대로 JVM 최대 힙 크기를 4GB에서 2GB로 줄이면 검사할 객체 수가 줄어 GC 시간도 짧아집니다. 물론 실제로 4GB에 가까운 메모리가 필요한 애플리케이션을 2GB로 줄이면 메모리 부족 에러가 나므로, 실제 사용 패턴에 맞춰 조정해야 합니다.
그래서 실무에서 더 중요한 것은 한 번에 대량의 객체를 만들지 않는 것 입니다.
- 게시글 하나가 0.5KB일 때 10만 개를 반환하는 조회 API는 약 50MB를 씁니다. 동시에 100명이 호출하면 약 4.9GB가 필요해지고, 최대 메모리가 4GB라면 GC를 아무리 돌려도 부족한 상태가 지속됩니다
- 방지책은 조회 범위와 개수를 제한하는 것 입니다. 10년 치 거래 내역을 한 번에 조회하게 두는 대신 최대 3개월로 제한하는 식이며, 한 번에 조회할 수 있는 개수도 트래픽 규모와 메모리 크기에 맞춰 제한합니다
- 파일 처리는 스트림을 씁니다.
Files.readAllBytes로 전체를 메모리에 올리면 30MB 파일을 100명이 동시에 받을 때 약 3GB가 필요하지만, 8KB 버퍼로 끊어 읽으면 800KB로 끝납니다
// 파일 전체를 한 번에 메모리에 로딩 — 동시 100명이면 약 3GB
byte[] bytes = Files.readAllBytes(Path.of("path"));
out.write(bytes);
// 8KB씩 끊어 읽기 — 동시 100명이어도 800KB
InputStream is = Files.newInputStream(Path.of("path"));
byte[] buffer = new byte[8192];
int read;
while ((read = is.read(buffer, 0, 8192)) >= 0) {
out.write(buffer, 0, read);
}대량 데이터를 메모리에 올려 서버가 중지된 사례
관리 사이트 서버가 응답하지 않아 확인해 보니 JVM 최대 힙을 모두 쓰고 있었고, 풀 GC가 여러 번 실행되었는데도 메모리가 포화 상태에 머물다 결국 OOME가 발생했습니다. 힙 덤프를 분석하니 원인은 엑셀 생성 객체였습니다. 수백만 건을 조회해 엑셀을 만드는 동안 모든 데이터가 메모리에 올라갔고, 생성이 길어지자 사용자가 취소 후 재시도하면서 같은 데이터가 다시 메모리에 쌓인 것입니다.
조치는 두 가지였습니다. 엑셀을 메모리가 아닌 로컬 파일에 스트림 형태로 생성 하도록 바꿨고, DB 조회 결과를 리스트로 한 번에 받는 대신 스트림으로 받아 순차 처리 하도록 고쳤습니다.
디스코드가 러스트로 간 이유
디스코드는 Go로 구현한 서버를 러스트로 재구현한 적이 있습니다. 전환의 주된 이유가 GC였는데, GC로 인해 응답 시간이 증가하거나 CPU 사용률이 급격히 튀는 문제가 있었기 때문입니다.
이 사례를 듣고 러스트로 다 개발해야겠다는 생각은 일단 접어 두는 편이 좋습니다. 대량의 트래픽이나 높은 성능이 반드시 요구되는 상황이 아니라면 GC로 인한 성능 영향은 비교적 크지 않은 경우가 많고, GC 성능도 계속 개선되고 있습니다. 다른 성능 개선 방법을 먼저 검토하고, 그럼에도 GC 영향을 줄일 수 없을 때 GC 없는 언어를 고려합니다.
전송 구간 줄이기
응답 시간에는 데이터 전송 시간이 포함되고, 이 시간은 네트워크 속도와 전송 데이터 크기의 영향을 받습니다. 서버는 사용자의 네트워크 속도를 제어할 수 없지만 데이터 크기는 제어할 수 있습니다.
응답 데이터 압축
HTML, CSS, JS, JSON처럼 텍스트로 구성된 응답은 gzip으로 압축하면 70% 이상 크기를 줄일 수 있고, 줄어든 만큼 전송 시간도 빨라집니다. 아파치나 Nginx가 압축 기능을 제공하므로 약간의 설정만 추가하면 즉시 효과를 봅니다.
압축은 응답 시간뿐 아니라 비용에도 영향을 줍니다. 클라우드 환경에서는 트래픽 자체가 비용으로 직결되므로 트래픽이 증가하는 추세라면 노력한 만큼 비용 절감 효과가 나옵니다.
압축할 때 확인할 점이 두 가지 있습니다. jpeg 이미지나 zip처럼 이미 압축된 데이터는 다시 압축해도 효과가 없으므로 텍스트 형식에만 적용합니다. 그리고 웹 서버에 압축을 설정했는데도 실제 응답이 압축되지 않았다면 방화벽이 압축을 해제해 응답하고 있을 수 있으므로 방화벽 설정도 확인해야 합니다.
Accept-Encoding과 Content-Encoding
클라이언트는
Accept-Encoding: gzip, deflate헤더로 자신이 풀 수 있는 압축 알고리즘을 알립니다. 웹 서버는 그중 자신이 지원하는 방식으로 응답을 압축하고, 사용한 알고리즘을Content-Encoding응답 헤더로 알려 줍니다.
정적 자원과 브라우저 캐시
서버가 응답하는 데이터는 두 종류로 나뉩니다. 동적 자원 은 요청할 때마다 결과가 바뀌는 데이터(제품 목록 HTML, 제품 상세 JSON)이고, 정적 자원 은 같은 URL에 항상 같은 데이터를 응답하는 콘텐츠(이미지, JS, CSS)입니다.
정적 자원은 전체 트래픽에서 상당한 비중을 차지합니다. 실제로 측정한 온라인 쇼핑몰 첫 페이지는 전체 4MB 중 이미지가 74.84%(3.0MB), 스크립트가 19.49%(775.4KB)로 둘이 합쳐 약 94% 를 차지했습니다.
같은 사용자가 상품 상세 페이지를 10번 방문할 때 동일한 이미지와 JS를 10번 내려받으면 불필요한 트래픽과 비용이 발생하고, 브라우저도 매번 새로 받느라 화면 표시가 느려집니다. HTTP의 Cache-Control 이나 Expires 헤더로 클라이언트가 응답을 일정 시간 보관하게 하면 이 왕복이 사라집니다.
Cache-Control: max-age=60
이 응답을 받은 브라우저는 파일을 로컬 캐시(메모리나 디스크)에 보관하고, 60초 이내에 같은 주소를 다시 요청하면 서버로 요청을 보내지 않고 로컬 데이터를 사용합니다. 표시 속도가 빨라지고 서버 쪽 네트워크 전송 비용도 함께 줄어듭니다.
정적 자원과 CDN
브라우저 캐시는 브라우저 단위로 동작하므로 동시에 많은 사용자가 접속하면 순간적으로 많은 양의 이미지·JS·CSS가 전송됩니다. 4차선 도로에 차가 한두 대 지날 때는 문제없다가 수십 대가 몰리면 순식간에 막히는 것과 같습니다.
CDN(Content Delivery Network) 은 콘텐츠 제공을 위한 별도 네트워크로, CloudFront·Akamai·Cloudflare 등이 대표적입니다.
flowchart LR C1["클라이언트"] --> E1["CDN 엣지 서버"] C2["클라이언트"] --> E2["CDN 엣지 서버"] E1 --> O["오리진 서버"] E2 --> O
사용자는 CDN이 제공하는 URL로 콘텐츠에 접근합니다. 엣지 서버에 콘텐츠가 없으면 오리진 서버에서 읽어와 제공하고 그 결과를 캐시하므로, 동일한 콘텐츠를 여러 번 요청해도 오리진 서버는 한 번만 처리하면 됩니다. 나머지는 CDN이 맡습니다.
엣지 서버는 지역별로 배치되어 사용자는 지리적으로 가까운 서버에서 콘텐츠를 받습니다. 이미지와 JS가 빠르게 로드되는 만큼 브라우저도 화면을 빨리 표시하고, 오리진에서 직접 제공하는 것보다 트래픽 비용도 적게 듭니다. 오리진 트래픽 감소, 빠른 콘텐츠 제공, 비용 절감 세 가지가 동시에 걸리는 선택지입니다.
정적 파일의 크기 관리
실수로 30MB짜리 이미지 파일을 올려 비용 청구가 올라간 사례가 있습니다. 계약 조건에 따라 특정 트래픽을 초과하지 못하게 막는 경우에는 용량 초과로 서비스가 불능 상태에 빠지기도 합니다. 아파치 웹 서버처럼 파일이 일정 크기를 초과하면 에러를 응답하도록 웹 서버에 크기 제한을 설정 해 두는 것이 좋습니다.
대기 처리
콘서트 예매처럼 사용자가 순간적으로 폭증하는 서비스가 있습니다. 예매 시작 후 몇 분 만에 매진되고 트래픽도 함께 사라지는, 1시간도 안 되는 짧은 시간만 폭증하는 패턴 입니다.
서버와 DB를 미리 증설하는 것이 틀린 방법은 아니지만 문제는 비용입니다. 클라우드 서버는 나중에 줄일 수 있어도 DB는 최대 트래픽에 맞춰 성능을 올려 놓으면 다시 낮추기가 쉽지 않습니다. 30일 중 1시간은 전체의 0.14%인데, 그 시간을 위해 고정 비용이 커지는 셈입니다.
반대 방향의 접근이 대기 처리 입니다. 처리량을 무작정 늘리는 대신 수용할 수 있는 수준의 트래픽만 받아들이고 나머지는 대기시키는 방식으로, 은행 창구에서 고객을 응대하는 것과 같습니다. 모든 창구가 차 있으면 나머지는 기다리고, 한 창구가 비면 대기 순번이 빠른 고객이 그 창구로 갑니다.
이 방식의 이점은 두 가지입니다.
- 서버를 증설하지 않고도 서비스를 안정적으로 제공 할 수 있습니다
- 사용자의 지속적인 새로 고침으로 인한 트래픽 폭증도 방지 됩니다. 새로 고치면 순번이 뒤로 밀리기 때문에 사용자가 스스로 자제하게 됩니다
사용자는 대기 없이 쓰고 싶어 하지만, 높은 부하로 서비스 자체가 아예 안 되는 것보다는 기다리는 편이 낫습니다. 자기 차례가 오면 결국 서비스를 쓸 수 있기 때문입니다.
Note
서버 개발자라면 대규모 트래픽을 처리하는 아키텍처를 한 번쯤 경험해 보고 싶은 마음이 생기지만, 개발자라면 비용도 함께 생각해야 합니다. 그 관점에서 대기 제어는 매우 합리적인 방식이며, 직접 구현할 필요 없이 이미 여러 솔루션이 존재해 빠르게 도입할 수 있다는 것도 장점입니다.
비교 / 트레이드오프
TPS를 높이는 세 가지 방향은 무엇을 늘리고 무엇을 포기하느냐에서 갈립니다.
| 동시 처리 수 늘리기 | 처리 시간 줄이기 | 수용량 제한하기 | |
|---|---|---|---|
| 대표 기법 | 수직·수평 확장, 커넥션 풀 크기 | 캐시, 압축, CDN, 조회 범위 제한 | 대기 처리 |
| 효과가 나는 속도 | 즉각적 | 적용 범위에 따라 다름 | 즉각적 |
| 비용 | 장비 비용이 지속적으로 증가 | 캐시 서버·CDN 비용, 무효화 복잡도 | 낮음 |
| 한계 | DB·외부 API가 병목이면 오히려 악화 | 실시간성이 중요한 데이터에는 적용 어려움 | 사용자가 기다려야 함 |
| 적합한 상황 | 병목이 애플리케이션 서버일 때 | 반복 조회가 많고 변경이 잦지 않을 때 | 짧은 시간만 폭증하는 트래픽 |
내 생각
- “느리다”의 90%는 사실 “줄이 길다”입니다. 응답 시간이 늘었을 때 코드부터 뜯어보는 습관이 있는데, 최대 TPS를 넘긴 순간부터는 코드가 아니라 대기 시간이 늘어난 것이므로 아무리 로직을 최적화해도 그래프가 내려오지 않습니다. 로직이 전체의 5%라는 측정 데이터를 늘 기억할 필요가 있습니다.
- HikariCP 대기 시간 30초 기본값은 사실상 장애 증폭기입니다. 대기 시간이 길면 취소·재요청이 쌓여 서버가 붙들고 있는 요청 수가 계속 늘어납니다. 빠른 실패가 사용자 경험을 해치는 것처럼 보이지만, 실제로는 서버 부하의 상한을 만들어 주는 유일한 장치 입니다.
- 커넥션 풀 크기와 수평 확장은 같은 다이얼입니다. 서버를 2배로 늘리면 DB가 받는 커넥션도 2배가 됩니다. 서버 대수를 늘리기 전에 DB의
max_connections와 CPU 여유를 먼저 확인하지 않으면, 스케일 아웃이 곧 DB 다운으로 이어집니다. - 캐시의 진짜 비용은 도입이 아니라 무효화입니다. 넣는 코드는 몇 줄이지만 “원본이 바뀌는 모든 경로”를 빠짐없이 찾아 무효화하는 일은 계속 늘어납니다. 변경이 잦은 데이터라면 애초에 캐시하지 않거나 짧은 TTL로 타협하는 편이 낫습니다.
- 로컬 캐시는 배포 빈도와 상극입니다. CI/CD로 하루에 여러 번 배포하는 환경에서 로컬 캐시를 쓰면 배포할 때마다 적중률이 0으로 떨어지고 그 직후 DB가 부하를 받습니다. 배포 파이프라인을 자랑스러워할수록 리모트 캐시가 필요해집니다.
- 정적 자원 최적화는 백엔드가 손대기 가장 쉬운 개선입니다. gzip 설정 한 줄과
Cache-Control헤더만으로 전체 트래픽의 90% 이상을 차지하는 구간이 줄어듭니다. 코드 변경 없이 응답 시간과 비용을 함께 낮출 수 있는 몇 안 되는 지점입니다.
관련 개념
- 웹 성능 지표 — TTFB를 포함한 사용자 체감 성능 지표
- Ch04 가비지 컬렉션 이해하기 — Stop-The-World와 GC 알고리즘의 동작 원리
- Ch01 최적화와 성능 정의 — 처리량·지연 시간 등 성능 지표의 정의