목차
- 87과 1000이 만나는 지점 — 문제 정의
- 기존 풀링 방식의 한계 — Lambda는 stateless다
- RDS Proxy가 푸는 부분 — multiplexing 구조
- 실제 설정 — Secrets, IAM, 프록시 생성
- 연결 핀닝 — multiplexing이 깨지는 순간
- 비용 구조 — vCPU 시간당 과금의 실체
- 측정과 알아야 할 제약
- 우선 해볼 것
AWS RDS Proxy 설정 Lambda 구조를 다룰 때 자주 부딪히는 숫자가 두 개 있다. PostgreSQL db.t3.medium의 기본 max_connections 87, 그리고 Lambda 함수 동시 실행 한도 1,000(계정당 soft limit). 두 숫자 사이의 거리가 ‘too many connections’ 오류의 본질이다. 본 글은 이 간극을 매니지드 풀러가 어떻게 메우는지, 그리고 어디서 새는지를 설정값과 출처 기준으로 따라간다.
87과 1000이 만나는 지점 — 문제 정의
db.t3.medium의 기본 max_connections는 약 87이다. 이 값은 LEAST({DBInstanceClassMemory/9531392}, 5000) 공식으로 계산된다(출처: AWS RDS User Guide 2024-12). 인스턴스 메모리에 비례하므로 작은 인스턴스에서는 100을 못 넘는다. 반면 Lambda 함수의 기본 동시 실행 한도는 계정당 1,000이고, 트래픽 스파이크 한 번에 동시 실행 200~300이 어렵지 않게 나온다.
각 Lambda 컨테이너가 콜드스타트 때 DB 연결을 새로 맺는 패턴이면 연결 수가 곧장 인스턴스 한계를 친다. 실제 에러는 이렇게 떨어진다.
psycopg2.OperationalError: FATAL: too many connections for role "app_user"
이 에러는 풀링이 없어서가 아니라, 풀링의 책임 위치가 잘못 설계된 결과다. Lambda는 stateless를 전제로 만들어졌고, 연결 풀은 본질적으로 상태(state)다. 둘이 안 맞는다.
기존 풀링 방식의 한계 — Lambda는 stateless다
물론, 세 가지 접근이 일반적으로 시도된다. 셋 다 본질을 비껴간다.
글로벌 변수로 연결 재사용
import psycopg2
conn = None # 컨테이너 재사용 시 유지됨
def lambda_handler(event, context):
global conn
if conn is None or conn.closed:
conn = psycopg2.connect(...)
# 쿼리 실행
"warm" 컨테이너에서는 동작한다. 문제는 콜드스타트마다 새 연결이 생긴다는 점이다. AWS Lambda Operator Guide는 Provisioned Concurrency 없이는 컨테이너 수명을 보장하지 않는다고 명시한다. 스케일아웃 구간에서는 결국 연결 수가 폭발한다.
Reserved Concurrency로 동시 실행 제한
또한, 함수에 Reserved Concurrency 50을 걸면 연결 한계는 안 넘는다. 단, 트래픽이 50을 초과하면 throttle 에러가 떨어진다. 사용자에게 5XX가 직접 노출된다는 뜻이다. 백오피스 잡이라면 모를까 사용자 트래픽 경로에 두기엔 부담이 있다.
RDS 인스턴스 업그레이드
db.t3.medium → db.r5.large로 올리면 max_connections가 700대로 늘어난다. 비용은 한 달 약 $50에서 $200대로 4배 가까이 뛴다(us-east-1 온디맨드, AWS Pricing 작성 시점 기준). 연결 풀링 문제를 인스턴스 비용으로 미루는 셈이다. 트래픽이 더 늘면 다시 한계에 부딪힌다.
RDS Proxy가 푸는 부분 — multiplexing 구조
RDS Proxy는 RDS 앞단에 두는 매니지드 connection pooler다. 2020년 6월 GA(출처: AWS News Blog 2020-06-30)로 출시됐고, 이후 PostgreSQL 16, Aurora Serverless v2 지원이 추가됐다. 구조는 단순하다.
Lambda(N) → RDS Proxy → RDS / Aurora
Proxy가 RDS와 영구 연결 풀을 유지한다. Lambda는 Proxy에 붙고, Proxy가 풀의 연결 하나를 빌려서 트랜잭션 동안 매핑한다. 핵심은 multiplexing이다. 1,000개의 클라이언트 요청을 50개 미만의 실제 DB 연결로 다중화한다.
매핑 단위는 transaction이다. 트랜잭션이 시작되면 풀의 연결 하나가 해당 클라이언트에 잠시 할당되고, 트랜잭션이 끝나면 풀로 반환된다. 이 방식은 pgbouncer의 transaction pooling과 동작이 사실상 같다. AWS가 새로운 알고리즘을 만든 게 아니라 매니지드로 포장한 쪽에 가깝다.
실제 설정 — Secrets, IAM, 프록시 생성
게다가, 설정 자체는 길지 않다. 다만 RDS Proxy는 DB 자격증명을 Secrets Manager에서만 읽는다. 콘솔에서 패스워드 직접 입력 불가.
aws secretsmanager create-secret \
--name rds-proxy-secret \
--secret-string '{"username":"app_user","password":"<DB_PASSWORD>"}'
IAM 역할은 Proxy가 시크릿을 읽고, 필요하면 KMS로 복호화하도록 권한을 준다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:<acct>:secret:rds-proxy-secret-*"
},
{
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "arn:aws:kms:us-east-1:<acct>:key/<key-id>"
}
]
}
그런데, 프록시와 타깃 그룹 등록은 두 줄로 끝난다.
# 프록시 생성
aws rds create-db-proxy \
--db-proxy-name app-prod-proxy \
--engine-family POSTGRESQL \
--auth "AuthScheme=SECRETS,SecretArn=arn:aws:secretsmanager:...:secret:rds-proxy-secret-XXXX,IAMAuth=DISABLED" \
--role-arn arn:aws:iam::<acct>:role/rds-proxy-role \
--vpc-subnet-ids subnet-aaa subnet-bbb \
--require-tls
# 타깃 RDS 인스턴스 연결
aws rds register-db-proxy-targets \
--db-proxy-name app-prod-proxy \
--target-group-name default \
--db-instance-identifiers prod-postgres-01
그런데, Lambda 코드 변경은 엔드포인트 한 줄. DB_HOST를 RDS 직접 엔드포인트가 아니라 Proxy 엔드포인트(*.proxy-<id>.<region>.rds.amazonaws.com)로 바꾸면 끝이다.
VPC 구성에서 자주 놓치는 것
Lambda를 VPC에 붙이지 않으면 Proxy 엔드포인트에 닿지 못한다. Proxy는 VPC 내부 엔드포인트만 노출한다. VPC Lambda의 콜드스타트 지연은 한때 큰 이슈였지만, Hyperplane ENI 도입 이후 추가 지연이 100ms 미만으로 줄었다(출처: AWS Compute Blog 2019-09-04, "Announcing improved VPC networking for AWS Lambda functions"). 현재 시점에서는 큰 부담은 아니다.
TLS는 켜는 게 기본값
--require-tls를 켜면 클라이언트도 TLS로 붙어야 한다. psycopg2면 연결 문자열에 sslmode=require 한 줄을 추가. Proxy 자체는 RDS와 TLS로 통신한다.
연결 핀닝 — multiplexing이 깨지는 순간
실제로, 여기가 처음 도입할 때 가장 헷갈리는 부분이다. RDS Proxy의 multiplexing은 transaction 단위로 작동하는데, 특정 SQL 패턴에서는 풀링이 깨진다. 이걸 session pinning이라고 부른다.
핀이 걸리는 대표 패턴:
SET명령(search_path, timezone 등 세션 변수 변경)CREATE TEMP TABLE- 명시적 prepared statement
LOCK TABLE- advisory lock 사용
핀이 걸리면 해당 클라이언트가 연결을 끊기 전까지 풀의 연결 하나가 묶인다. 1:1이 되는 셈이다. 풀링의 이점이 그만큼 사라진다.
확인은 CloudWatch 메트릭으로 한다. DatabaseConnectionsCurrentlySessionPinned 값이 0이 아니면 어딘가에서 핀이 걸린다는 뜻이다. CLI로도 빠르게 본다.
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name DatabaseConnectionsCurrentlySessionPinned \
--dimensions Name=ProxyName,Value=app-prod-proxy \
--statistics Average \
--start-time 2026-05-18T00:00:00Z \
--end-time 2026-05-19T00:00:00Z \
--period 300
게다가, ORM 환경에서 의외로 자주 발생한다. SQLAlchemy 2.x는 connection-level isolation level 설정 시 SET SESSION CHARACTERISTICS를 발행하는데, 이게 핀을 유발한다. 해결은 connection-level이 아니라 execution_options(isolation_level=...)로 트랜잭션 단위 설정으로 바꾸는 쪽이다(참고: SQLAlchemy 공식 문서 "Setting Transaction Isolation Levels including DBAPI Autocommit"). 비슷한 이유로 일부 마이그레이션 라이브러리도 핀을 유발할 수 있어서 마이그레이션 트래픽은 Proxy를 우회하도록 분리하는 편이 안전하다.
비용 구조 — vCPU 시간당 과금의 실체
RDS Proxy 비용은 부착한 DB 인스턴스의 vCPU 수에 비례하는 시간당 과금이다. us-east-1 기준 vCPU당 시간당 약 $0.015. db.t3.medium(2 vCPU)에 붙이면 시간당 $0.030, 한 달 약 $22 수준이다(작성 시점, AWS RDS Proxy Pricing 페이지).
| 구성 | 월 비용(대략) | 비고 |
|---|---|---|
| db.t3.medium RDS 단독 | ~$50 | 2 vCPU, max_connections 87 |
| db.t3.medium + RDS Proxy | ~$72 | Proxy 22달러 추가 |
| db.r5.large 업그레이드(Proxy 없이) | ~$200 | 2 vCPU, 메모리 4배 |
Proxy를 추가하는 쪽이 인스턴스 등급을 올리는 것보다 1/3 미만 비용으로 같은 문제를 푼다. 단, 매니지드 서비스가 늘 그렇듯 트래픽 거의 없는 사이드 프로젝트에는 과한 면이 있다.
낭비를 줄이는 설정 두 개:
IdleClientTimeout: 기본 1,800초. 클라이언트 연결이 30분간 idle이어도 점유한다. Lambda 트래픽이 산발적이면 300~600초로 낮추는 게 합리적이다.MaxConnectionsPercent: RDSmax_connections의 몇 %를 풀에 쓸지. 기본 100. BI 도구나 관리 콘솔이 같은 인스턴스에 붙는 환경이면 80~90으로 낮춰 여유분을 둔다.
측정과 알아야 할 제약
설정 후 봐야 하는 메트릭은 네 개 정도면 충분하다.
ClientConnections: 현재 Lambda → Proxy 연결 수DatabaseConnections: Proxy → RDS 실제 연결 수DatabaseConnectionsBorrowLatency: 풀에서 연결 빌려오는 지연(마이크로초)QueryDatabaseResponseLatency: 쿼리 응답 시간
ClientConnections가 200, DatabaseConnections가 20~30이면 풀링이 의도대로 동작 중인 상태로 보인다. 비율이 1:1에 가까우면 어딘가에서 핀이 걸리고 있다는 신호다.
BorrowLatency는 정상 시 1~5ms 수준이다. 100ms 이상으로 튀면 풀이 바닥난 상태로 봐도 된다. MaxConnectionsPercent를 올리거나 RDS 자체의 max_connections를 늘려야 한다.
특히, 쓰기 전에 알았으면 좋았을 제약들도 정리해 둘 가치가 있다.
- Aurora Serverless v1은 지원 안 됨. v2는 지원.
- MySQL은 5.7 이상.
- PostgreSQL의
LISTEN/NOTIFY는 동작이 보장되지 않는다. Pub/Sub 용도면 SNS 같은 다른 메커니즘이 안전하다. - 풀 크기를 클라이언트가 직접 정할 수 없다. 백분율 설정만 가능.
- RDS Proxy 자체 SLA는 99.9%. Aurora의 99.99%보다 한 단계 낮다(출처: AWS Service Level Agreements, 작성 시점 기준). Critical path에 둘 때는 가용성 차이를 고려하는 편이 좋다.
즉, 콜드 Lambda + 콜드 Proxy 조합에서 첫 요청이 가끔 추가로 느린 경우가 보고된다. 매번 그렇지는 않은데, 모니터링 그래프에서 가끔 튀는 지점이 있다.
우선 해볼 것
그래서, 위 내용을 처음 적용해 본다면 다음 순서가 부담이 적다.
- CloudWatch에서 RDS의
DatabaseConnections메트릭을 한 주 정도 본다. 피크가max_connections의 50% 이상으로 자주 찍히는지 확인. 그게 아니면 Proxy가 아직 급하지 않다. - Proxy 도입 전, Lambda에 글로벌 변수 재사용 패턴이 적용돼 있는지 점검한다. Proxy를 넣어도 핸들러 내부에서 매번 connect/close를 반복하면 효과가 반감된다.
- 도입 후 첫 주는
DatabaseConnectionsCurrentlySessionPinned를 매일 확인. 0이 아니면 ORM/마이그레이션 코드를 검토.
특히, 개인적으로는 분당 100건 이상 꾸준한 트래픽이 있는 서비스라면 RDS Proxy를 먼저 검토하는 쪽이 더 나은 것 같다.
관련 글
- Kubernetes VPA 설정으로 OOMKilled 해결: EKS 실무 가이드와 47→3건 감소 사례 – FastAPI 워커에서 주간 47건씩 발생하던 OOMKilled를 Kubernetes VPA 설정으로 3건까지 줄였다. HPA·수동 튜닝과…
- ElastiCache Redis 클러스터 샤딩 설정 — 단일 노드에서 클러스터 모드로 전환한 3개월 회고 – 단일 노드 ElastiCache로 버티다 메모리 한계를 만났다. 클러스터 모드 ON으로 전환하면서 샤드 슬롯, 키 해시태그, Failove…
- DynamoDB TTL 설정 TIL — 100만 건 자동 삭제, 비용 0원의 이면 – DynamoDB TTL 설정의 진짜 가치는 비용보다 운영 부담 제거에 있다. 자동 삭제 메커니즘과 실수하기 쉬운 함정 네 가지를 정리한 T…