이 글은 Velog에서 이전한 글입니다. Velog 원문 보기
들어가며
EC2 상시 구조에서는 서버 한 대를 기준으로 TPS를 봤다.
- CPU가 얼마나 올라가는지
- 메모리가 어디까지 차는지
- HikariCP가 언제부터 버거워지는지
그런데 Lambda에서는 기준을 바꿔야 한다.
요청이 몰리면 서버 한 대가 버티는 게 아니라, 실행 환경이 늘어난다.
그래서 이번 실험에서는 질문을 조금 바꿨다.
현재 Lambda 설정에서, 안정적으로 처리 가능한 TPS는 어디까지인가?
초기에는 계정 동시성, DB 커넥션, Reserved Concurrency, 시작 패턴 같은 조건이 결과를 계속 바꿨다.
그래서 총 17회차까지 테스트를 반복하면서, 병목이 어디에서 드러나는지 하나씩 확인했다.
17회차의 테스트 중 유의미한 결과가 나온 테스트를 기준으로
- 어떤 실험이 결론을 바꿨는지
- 왜
constant-arrival-rate에서 보이던 실패가ramping-arrival-rate에서는 사라졌는지 - 왜 최종 결론을
안정 구간 / 준안정 구간 / 경계 구간으로 나눠 보게 됐는지
를 중심으로 다시 정리한 기록이다.
실험 전 정리
초기 실패 라운드를 겪고 나서, 먼저 테스트 조건부터 정리했다.
- 환경:
develop-shadow - 대상 API:
/api/graduation/progress - Lambda Runtime:
java17 - Lambda Memory: 최종적으로
2048MB - Lambda Reserved Concurrency: 최종적으로
180 - DB 연결: Supabase transaction pooler 사용
- Hikari 설정:
maximum-pool-size=1minimum-idle=0idle-timeout=10000max-lifetime=55000
먼저 두 가지를 정리했다.
- Lambda 실행 환경이 DB 커넥션을 과하게 늘리지 않도록 먼저 상한을 잡는다.
- 시작 burst 때문에 생기는 왜곡을 줄이기 위해, 후반 실험은
ramping-arrival-rate중심으로 본다.
이번 부하테스트는 단순히 TPS 숫자를 키우는 작업이라기보다,
Lambda 상한, DB 연결, 시작 패턴 중 무엇이 먼저 병목이 되는가
를 확인하는 과정에 더 가까웠다.
전체 실험 맵
이번에는 총 17회차 테스트를 진행했다.
본문에서는 핵심 회차만 자세히 다루고, 나머지는 아래 표로 먼저 정리한다.
| 회차 | 조건 | 한 줄 결론 |
|---|---|---|
| 1차 | 10 TPS, constant, rc20, 1024MB |
baseline 확보. 실패 23건은 초반 throttle로 해석 가능 |
| 2차 | 15 TPS, constant, rc20, 1024MB |
상한 20에 바로 걸리며 실패 1709건 발생 |
| 3차 | 15 TPS, constant, rc30, 1024MB |
실패 385건으로 감소. 병목은 여전히 상한 |
| 4차 | 15 TPS, constant, rc40, 1024MB |
실패 221건으로 더 감소 |
| 5차 | 20 TPS, constant, rc40, 1024MB |
실패 37건. constant 구간에서는 가장 안정적이었던 조건 |
| 6차 | 30 TPS, constant, rc40, 1024MB |
실패 2846건. 30 TPS가 첫 번째 큰 경계처럼 보임 |
| 7차 | 30 TPS, constant, rc50, 1024MB |
실패 1109건. 상한을 올리면 회복됨 |
| 8차 | 30 TPS, constant, rc50, 2048MB |
실패 1059건. 메모리 증설 효과는 제한적 |
| 9차 | 30 TPS, constant, rc100, 2048MB |
실패 520건. 병목은 여전히 throttle 계열 |
| 10차 | 30 TPS, constant, rc150, 2048MB |
실패 607건. 상한만 올려도 0 실패가 되지는 않음 |
| 11차 | 30 TPS, ramping, rc150, 2048MB |
실패 0건. 30 TPS는 steady-state 한계보다 시작 패턴의 영향을 크게 받았음 |
| 12차 | 40 TPS, ramping, rc150, 2048MB |
실패 0건. 40 TPS 안정 |
| 13차 | 60 TPS, ramping, rc150, 2048MB |
실패 0건. 60 TPS 안정 |
| 14차 | 80 TPS, ramping, rc150, 2048MB |
실패 0건. 80 TPS 안정 |
| 15차 | 100 TPS, ramping, rc150, 2048MB |
실패 216건(0.70%). 낮은 실패율의 준안정 구간 |
| 16차 | 120 TPS, ramping, rc150, 2048MB |
실패 4044건(12.25%). 첫 명확한 한계 구간 |
| 17차 | 120 TPS, ramping, rc180, 2048MB |
실패 740건(2.24%). rc150보다 회복됐지만 여전히 경계 구간 |
초반 2~10차는 대부분 Reserved Concurrency를 조정하며 30 TPS를 회복하는 과정이었다.11차부터는 ramping으로 바꾼 뒤 40 -> 60 -> 80 -> 100 -> 120 TPS를 올려 가며 어디서 흔들리는지 보기 시작했다.
핵심 실험 1. baseline은 어떻게 다시 잡혔나
첫 기준선은 10 TPS, constant-arrival-rate로 시작했다.
export const options = {
scenarios: {
lambda_baseline_10tps: {
executor: "constant-arrival-rate",
rate: 10,
timeUnit: "1s",
duration: "10m",
preAllocatedVUs: 30,
maxVUs: 100,
},
},
};
k6 결과는 이렇게 나왔다.
checks_total.......: 6001 9.995631/s
checks_succeeded...: 99.61% 5978 out of 6001
checks_failed......: 0.38% 23 out of 6001
http_req_duration..............: avg=354.82ms min=15.74ms med=316.16ms max=2.76s p(90)=390.9ms p(95)=555.9ms
http_req_failed................: 0.38% 23 out of 6001
http_reqs......................: 6001 9.995631/s
여기서 먼저 봐야 했던 건 숫자 자체보다 실패의 성격이었다.
- API Gateway
5xx:23 - Lambda
Throttles:23 - Lambda
Errors:0
이 23건은 애플리케이션 예외라기보다,
Reserved Concurrency 20 상한에 닿는 초반 구간에서 발생한 throttle
로 해석하는 편이 더 자연스러웠다.
다만 이 지점은 단순히 초기 burst 때문이라고 단정 짓기 보다는
지금까지의 관측을 종합하면, 적어도 아래 세 가지가 함께 작용했을 가능성이 높다.
constant-arrival-rate특성상 시작 직후에도 바로 목표 부하가 들어간다는 점- warm execution environment가 충분히 준비되기 전이라 cold start 영향이 섞일 수 있다는 점
- 당시
Reserved Concurrency 20상한에 금방 근접했다는 점
초반 실패를 하나의 원인으로 단정하기는 어려웠다.
초기 ramp 부재 + cold start + 낮은 동시 실행 상한이 겹친 결과
로 보는 쪽이 더 정확하다.
패널도 같은 방향을 가리켰다.


DB fatal은 다시 나오지 않았고, Lambda 코드 예외도 없었다.
이제부터는 DB를 보호한 상태에서 Lambda 상한과 시작 패턴을 어떻게 볼지가 다음 질문이 됐다.
핵심 실험 2. 왜 30 TPS부터 해석이 달라졌나
초반 2~10차 실험은 사실상 같은 질문을 다른 조건으로 반복하는 과정이었다.
rc20,rc30,rc40,rc50,rc100,rc150- 그리고 중간에
memory 1024 -> 2048MB
이 과정을 거치면서 확인된 건 세 가지였다.
30 TPS는 이 상태에서 아주 쉽게 무너지는 수치는 아니었다rc를 올리면 실패율은 줄어들었다- 하지만
constant-arrival-rate에서는 상한을 올려도 실패가 완전히 0이 되지 않았다
정말 중요한 건 11차였다.
이번에는 constant-arrival-rate 대신 ramping-arrival-rate로 시작 패턴만 바꿨다.
5 -> 10 -> 20 -> 30 TPS- 마지막
30 TPS를4분유지 rc150,memory 2048MB는 그대로 유지
결과는 이렇게 나왔다.
checks_total.......: 12899 21.488745/s
checks_succeeded...: 100.00% 12899 out of 12899
checks_failed......: 0.00% 0 out of 12899
http_req_duration..............: avg=340.39ms min=231.34ms med=313.96ms max=3.28s p(90)=328.37ms p(95)=348.45ms
http_req_failed................: 0.00% 0 out of 12899
같은 30 TPS인데도,
constant에서는 실패가 남았고ramping으로 바꾸자0 실패가 됐다
패널에서도 같은 차이가 보였다.


CloudWatch 기준으로도:
- API Gateway
5xx:0 - Lambda
Throttles:0 - Lambda
Errors:0
30 TPS 자체가 이 조합에서 불가능한 수치가 아니라, 부하가 올라가는 방식이 결과를 크게 바꾸고 있었던 것이다.
같은 30 TPS인데 constant에서는 실패가 남고 ramping에서는 0 실패가 나왔다.
그래서 이후 테스트는 steady-state를 먼저 보고, 시작 구간의 영향은 따로 떼어 보기로 했다.
핵심 실험 3. 이 조합은 어디까지 버틸까
11차에서 30 TPS가 ramping으로 안정화된 뒤에는 같은 패턴으로 상한을 계속 올려봤다.
여기서 한 가지는 먼저 짚고 넘어가야 했다.
ramping-arrival-rate 테스트에서 k6 summary의 평균 RPS가 목표 TPS보다 낮게 보이는 이유
예를 들어 80 TPS ramp를 돌려도 summary에는 평균이 46.97 rps처럼 보인다.
이건 테스트가 실패해서가 아니라, 전체 10분 동안
20 TPS40 TPS60 TPS- 마지막
80 TPS
를 순차적으로 거치기 때문이다.
summary의 http_reqs/s는 전체 구간 평균이고, 글에서 말하는 80 TPS, 100 TPS는 마지막 stage의 목표 TPS다.
40 TPS
checks_total.......: 18600 30.984797/s
checks_succeeded...: 100.00% 18600 out of 18600
checks_failed......: 0.00% 0 out of 18600

60 TPS
checks_total.......: 25799 42.978592/s
checks_succeeded...: 100.00% 25799 out of 25799
checks_failed......: 0.00% 0 out of 25799

80 TPS
checks_total.......: 28199 46.975485/s
checks_succeeded...: 100.00% 28199 out of 28199
checks_failed......: 0.00% 0 out of 28199

100 TPS
checks_total.......: 30599 50.953132/s
checks_succeeded...: 99.29% 30383 out of 30599
checks_failed......: 0.70% 216 out of 30599

40 TPS: 실패060 TPS: 실패080 TPS: 실패0100 TPS: 실패216건, 실패율0.70%
80 TPS까지는 실패가 없었고, 100 TPS에서도 시스템이 한 번에 무너지지는 않았다.
이걸 단순히 평균값으로만 보면 더 여유 있어 보일 수도 있다.
예를 들어 TPS × latency ≈ 필요한 평균 동시 실행 수로 생각하면,
100 TPS- 평균 응답 시간
~0.34s
기준으로는 대략 34 수준의 평균 동시 실행만 있으면 될 것처럼 보인다.
그런데 병목은 평균에서 먼저 드러나지 않는 경우가 많다.
- 시작 구간의 급격한 확장
- p95/p99 같은 tail latency
- 상한 근처에서 길어지는 일부 요청
때문에, 평균만 보면 충분해 보이는 구간도 실제로는 경계에 가까울 수 있다.
이 구간에서 같이 봐야 했던 건 DB 쪽이었다.
로그 기준:
Max client connections reached: 없음Connection is not available: 없음
이 단계에서는 병목이 DB로 돌아가지 않았다.
이 설정에서는 ramping 패턴 기준으로
80 TPS까지는 안정 구간이고,100 TPS는 낮은 실패율이 남는 준안정 구간에 가까웠다.
핵심 실험 4. 실제 한계는 어디였나
120 TPS @ rc150
100 TPS가 준안정 구간으로 보였기 때문에, 다음 확인 대상은 120 TPS였다.
checks_total.......: 32999 54.90813/s
checks_succeeded...: 87.74% 28955 out of 32999
checks_failed......: 12.25% 4044 out of 32999
http_req_failed................: 12.25% 4044 out of 32999
http_req_duration..............: avg=375.23ms ... max=5s
이번에는 이전과 달랐다.
5xx증가Throttles증가request timeout경고 다수ConcurrentExecutions가 마지막 구간에서150에 붙음


이 라운드에서는 5xx, Throttles, request timeout, ConcurrentExecutions=150이 함께 붙었다.
120 TPS @ rc180
여기서 바로 110 TPS로 내려가지 않고, 한 가지를 먼저 확인했다.
120 TPS 실패의 상당 부분이 정말 Lambda 상한
150때문이었는가?
그래서 Reserved Concurrency를 180으로 올리고 같은 120 TPS를 다시 돌렸다.
checks_total.......: 32999 54.938125/s
checks_succeeded...: 97.75% 32259 out of 32999
checks_failed......: 2.24% 740 out of 32999
http_req_failed................: 2.24% 740 out of 32999
비교하면 차이가 선명하다.
120 TPS @ rc150- 실패
4044건 - 실패율
12.25%
- 실패
120 TPS @ rc180- 실패
740건 - 실패율
2.24%
- 실패


rc150가 실패를 더 크게 만든 것은 맞다.
그렇다고 120 TPS가 곧바로 안정 구간으로 바뀐 것은 아니었다.
rc180으로 올린 뒤에도 여전히 남은 것이 있었다.
request timeoutTask timed out- 마지막 구간
ConcurrentExecutions = 180
반면 이번에도 DB 쪽은 직접 병목이 아니었다.
Max client connections reached: 없음Connection is not available: 없음
이 비교에서 읽을 수 있었던 건 두 가지였다.120 TPS 실패의 상당 부분은 실제로 Lambda 상한 150에 먼저 걸렸고, rc180으로 올리자 그 부담은 꽤 줄었다. 하지만 그 상태에서도 timeout과 일부 실패가 남았다. 그래서 120 TPS는 rc150가 만들어낸 착시라고 보기보다, 이 설정에서는 이미 한계에 가까운 구간으로 보는 편이 맞았다.
해석 1. 왜 Lambda에서 상한이 기대만큼 크게 올라가지 않았을까
여기서부터는 테스트로 직접 입증한 사실이라기보다, 지금까지 나온 결과를 가장 잘 설명하는 구조적 해석에 가깝다.
EC2 시절에는 같은 API 기준으로 약 96 TPS까지 관측한 적이 있었다.
그런데 이번 Lambda 실험에서는 구조가 더 유연해졌는데도, TPS 상한이 기대했던 만큼 크게 뛰지는 않았다.
직접적인 이유는 아래에 가깝다.
- EC2에서는 서버 프로세스 하나가 커넥션 풀과 캐시를 일관되게 공유했다
- Lambda에서는 실행 환경이 늘어날 때마다 DB connection pressure가 같이 분산 증폭된다
- 그래서 CPU/Heap보다 먼저
Lambda + DB 연결 구조에서 병목이 나타나기 쉬워진다
EC2에서는 서버 한 대의 리소스 한계를 봤다면,
Lambda에서는 동시 실행 환경 수 x 요청당 처리 비용이 더 먼저 병목을 만들 수 있다.
이번 실험에서 DB fatal이 다시 등장하지 않았는데도 120 TPS에서 한계가 먼저 드러난 이유 역시, 현재로서는 이런 구조적 특성으로 설명하는 편이 더 잘 맞는다.
해석 2. 로컬 캐시가 예전만큼 듣지 않는 이유
이전 EC2 실험에서 로컬 캐시는 꽤 중요했다.
- 공통 경로에서 DB 접근을 줄이고
- 인증 필터 비용을 줄이고
- Hikari timeout까지 줄이는 흐름이 있었다
하지만 Lambda에서는 같은 로컬 캐시라도 성격이 달라진다.
- 실행 환경마다 따로 캐시가 생긴다
- warm 상태일 때만 재사용된다
- 실행 환경이 늘어나면 캐시도 여러 조각으로 쪼개진다
- cold start가 나면 그 캐시는 사라진다
EC2에서는 캐시 한 번 채우면 이후 모든 요청이 이득을 보던 구조 였다면,
Lambda에서는 각 실행 환경이 자기 캐시를 따로 가져가는 구조에 더 가깝다.
그래서 EC2 때 유효했던 공통 경로 로컬 캐시로 DB 접근을 줄이는 전략이 Lambda에서는 같은 강도로 기대하기는 어려웠을 가능성이 높다.
이 결과를 설명하는 가설로는
- Lambda + DB 연결 구조
- 로컬 캐시 효율 저하
가 같이 작용했을 가능성이 있다.
AWS ElastiCache를 활용하는 방안
처리량을 더 끌어올리는 방법으로는 중앙 캐시를 두는 선택지가 있다.
예를 들어 AWS ElastiCache를 붙이면 공통 경로에서 DB를 직접 치는 횟수를 줄일 여지가 생긴다.
그렇게 되면 Lambda 실행 환경이 늘어날 때 DB 연결 압력이 지금보다 완만해질 가능성도 있다.
다만 이건 이번 글에서 직접 검증한 결과는 아니다.
이번 실험에서 확인한 사실은 80 TPS는 안정 구간, 100 TPS는 낮은 실패율의 준안정 구간, 120 TPS는 경계 구간이라는 점이었다.
그리고 그 과정에서 먼저 보인 것은 DB fatal보다는 Lambda 상한 근처의 throttle과 timeout이었다. ElastiCache는 이 상태를 바꿔 볼 수 있는 다음 선택지에 가깝다.
단순 계산으로도 최소 월 15$ 안팎의 고정비가 추가된다. 이번 전환의 출발점 중 하나가 비용 절감이었던 만큼, 목표로 잡았던 약 96 TPS는 이미 넘겼고, 캐시 비용을 감수해야 할 만큼 처리량이 더 필요해질 때 다시 검토하는 편이 맞다고 봤다.
Supabase 연결 방식을 direct에서 pooler로 바꾼 이유
참고 문서
Supabase: Connect to your database
Supabase: Supavisor and Connection Terminology Explained
AWS Lambda: Understanding Lambda function scaling
같은 Supabase라도, 어떤 실행 환경에서 붙느냐에 따라 더 잘 맞는 연결 방식이 달라진다.
Supabase 공식 문서도 연결 방식을 이렇게 나눈다.
direct connection: EC2 같은 persistent server에 적합pooler session mode: persistent application traffic에서 direct의 대안pooler transaction mode: serverless / edge function처럼 temporary client에 적합
EC2에서는 왜 direct가 합리적이었나
EC2 + ASG 환경에서는 JVM 서버 프로세스가 비교적 오래 살아 있고, HikariCP도 같은 프로세스 안에서 커넥션을 유지한다.
이런 구조에서는 애플리케이션이 잡은 커넥션이 곧 서버의 장기 연결이 된다.
Supabase 문서도 direct connection을 persistent servers, 예를 들면 AWS EC2 machines 같은 환경에 적합하다고 설명한다.
그래서 당시 EC2에서 direct를 사용한 판단은 충분히 합리적이었다.
조금 더 정확히 말하면, EC2에서의 선택지는 direct 아니면 session pooler에 가까웠다.
반대로 transaction pooler는 한 요청이 끝날 때마다 DB 연결을 다시 공유하는 방식이라, 오래 살아 있는 JVM 서버와 애플리케이션 풀링의 장점을 그대로 살리기 어렵다.
특히 transaction pooler는 prepared statements를 지원하지 않기 때문에, Java/Hibernate 계열에서는 direct나 session mode보다 덜 자연스러운 선택일 수 있다.
Lambda에서는 왜 transaction pooler가 더 맞았나
Lambda는 성격이 완전히 다르다.
- 요청이 늘어나면 실행 환경 수가 빠르게 늘어난다
- 각 실행 환경은 독립적으로 DB 연결을 만들 수 있다
- 동시에 살아 있는 실행 환경 수가 곧 DB 연결 압력으로 번지기 쉽다
EC2처럼 소수의 오래 사는 서버가 DB 연결을 관리하는 구조가 아니라, 짧게 많이 생기는 클라이언트가 DB에 붙으려는 구조에 가깝다.
Supabase가 transaction pooler를 temporary clients, serverless or edge functions에 맞는 방식이라고 설명하는 이유도 여기에 있다.
transaction pooler는 클라이언트 연결을 그대로 DB에 1:1로 붙이지 않고, 실제 쿼리를 수행할 때만 backend connection을 빌려 쓴다.
그래서 짧고 많은 연결이 몰리는 환경에서는 direct보다 훨씬 유리하다.
이번 실험에서도 그 점이 드러났다.
- direct connection 상한을 기준으로 보면 Lambda의 동시 확장을 그대로 받기 어렵다
- 반면 pooler를 사용한 뒤에는 적어도 이번 테스트 범위에서는 DB
max client connections오류가 주요 병목으로 다시 등장하지 않았다 - 이후에 보인 병목은 DB보다 Lambda 상한, throttle, timeout 쪽이었다
그러면 예전의 판단은 틀렸던 걸까
그렇지는 않다.
EC2에서 direct를 썼던 판단이 틀렸던 게 아니라,persistent server를 전제로 할 때와 serverless를 전제로 할 때 더 잘 맞는 연결 전략이 달라진 것이다.
정리하면 이렇게 쓰는 편이 가장 정확하다.
EC2: direct 또는 session pooler가 더 자연스러운 환경Lambda: transaction pooler가 더 자연스러운 환경
결론
이번 부하테스트는 총 17회차까지 진행했고,
그 과정에서 병목은 계속 이동했다.
초반에는
- Reserved Concurrency가 너무 낮았고
- DB 연결 전략을 먼저 정리해야 했고
- constant-arrival-rate의 초기 ramp 부재, cold start, 상한 근접이 결과를 왜곡했다
조건을 정리한 뒤에는 숫자보다 구간으로 보는 편이 결과와 더 잘 맞았다.
80 TPS: 안정 구간100 TPS: 낮은 실패율이 남는 준안정 구간120 TPS: 경계 구간
단일 숫자 하나보다 이렇게 적는 편이 이번 관측과 더 가깝다.
- Lambda에서는 steady-state와 시작 구간의 ramp 부재, cold start, 동시성 상한 영향을 분리해서 봐야 한다
- DB fatal이 안 보인다고 해서 병목이 없는 건 아니고, Lambda 상한 근처의 tail latency와 timeout이 먼저 드러날 수 있다
- EC2에서 유효했던 연결 전략과 캐시 전략은 Lambda에서 그대로 재현되지 않을 수 있다. 적어도 이번 테스트에서는 같은 효과를 기대하기 어려워 보였다.
짧은 검증 실험에서도 확인했듯이, 짧고 공격적인 시작 패턴에서는 초기 transient에 더 민감했다.
출처
Supabase: Connect to your database
Supabase: Supavisor and Connection Terminology Explained
AWS Lambda: Understanding Lambda function scaling
'Project > 척척학사' 카테고리의 다른 글
| 앱 웹뷰 도입 후 Refresh Token 구조를 다시 설계한 이유 (0) | 2026.08.05 |
|---|---|
| [척척학사] EC2에서 Lambda로 옮기며 백엔드 실행 경계를 다시 설계한 과정 (0) | 2026.08.04 |
| [척척학사] 포털 연동 과정 16초를 0.8초로 94.8% 개선한 과정 (0) | 2026.08.04 |
| 트래픽 피크형 서비스의 운영 구조를 요청 기반 구조로 바꾼 과정 (0) | 2026.08.04 |
| AWS EC2 주기적 사망 원인 분석 및 JVM 메모리 최적화 (t3.micro → t3.small) (0) | 2026.08.04 |