서버리스로 API를 옮기고 나면 트래픽이 몰리는 시간대에 꼭 한 번은 만나는 에러가 있다. 애플리케이션 로그에는 문제가 없는데 DB 쪽에서 FATAL: too many connections 또는 ERROR 1040 (HY000): Too many connections가 뜨는 경우다. 코드를 아무리 봐도 커넥션을 안 닫은 곳은 없는데 왜 터지는지 감이 안 잡힌다. 이 글은 그 원인과 AWS RDS Proxy로 푸는 방법을 정리한 것이다.
결론부터 말하자면, 원인은 Lambda의 동시 실행 모델과 RDS의 커넥션 상한이 안 맞아서다. Lambda는 요청이 몰리면 실행 환경을 수백, 수천 개까지 늘리는데 각 환경이 DB 커넥션을 따로 연다. RDS Proxy를 앞에 두면 프록시가 커넥션 풀을 유지하며 재사용하므로, Lambda가 아무리 늘어도 실제 DB 커넥션 수는 상한 안에서 관리된다. 페일오버 시 커넥션 유지와 IAM 인증까지 관리형으로 얹을 수 있다.
1. 왜 서버리스에서 커넥션이 폭주하는가
RDS의 최대 커넥션 수는 max_connections 파라미터로 정해진다. PostgreSQL은 인스턴스 메모리에 따라 계산되고(예: LEAST({DBInstanceClassMemory/9531392}, 5000)), MySQL도 비슷하게 메모리 기반이다. 작은 인스턴스는 커넥션 상한이 수백 개 수준이다.
Lambda는 요청 하나당 실행 환경 하나를 쓴다. 동시 요청이 500개면 실행 환경도 그만큼 뜨고, 각 환경이 커넥션을 한 개씩만 열어도 500개다. 컨테이너 재사용으로 커넥션이 살아 있어도, 스케일이 튀는 순간 새 환경들이 동시에 커넥션을 열면서 상한을 넘긴다. 이때 나는 게 too many connections다.
애플리케이션 서버라면 커넥션 풀(HikariCP 같은)이 이 문제를 흡수한다. 하지만 Lambda 실행 환경은 서로 메모리를 공유하지 않아서 풀을 공유할 수 없다. 그래서 풀링을 프로세스 밖으로 빼야 하고, 그 역할을 하는 게 RDS Proxy다.
2. RDS Proxy가 하는 일
RDS Proxy는 애플리케이션과 RDS 사이에 놓이는 완전관리형 커넥션 풀러다. 클라이언트는 DB 대신 프록시 엔드포인트에 붙고, 프록시가 DB 쪽 커넥션 풀을 유지하며 여러 클라이언트 요청에 돌려쓴다. 이 재사용을 멀티플렉싱(multiplexing)이라고 부른다. 한 트랜잭션이 끝나면 그 DB 커넥션을 다른 클라이언트 요청이 이어받는 방식이다.
핵심 기능은 세 가지다.
- 커넥션 풀링: Lambda가 수천 개로 늘어도 DB로 나가는 실제 커넥션은 상한 안에서 유지된다. 상한을 넘는 요청은 즉시 실패시키지 않고 큐에 넣거나 스로틀링한다.
- 페일오버: Multi-AZ 인스턴스에서 장애가 나 스탠바이로 넘어갈 때, 프록시가 새 인스턴스로 다시 붙는 동안 클라이언트 커넥션을 유지한다. 페일오버 감지 시간을 줄여준다.
- IAM 인증: 클라이언트가 DB 비밀번호 대신 IAM 토큰으로 프록시에 인증한다. 자격증명을 코드나 환경변수에 박지 않아도 된다.
대부분 코드 변경 없이 접속 대상만 프록시 엔드포인트로 바꾸면 붙는다. 지원 엔진은 RDS for MySQL, MariaDB, PostgreSQL, SQL Server와 Aurora다.
3. 사전 준비
프록시를 만들기 전에 세 가지가 필요하다.
- 프록시는 DB와 같은 VPC에 있어야 한다. 프록시는 퍼블릭으로 열 수 없다(DB는 퍼블릭이어도 됨).
- DB 자격증명을 Secrets Manager에 저장해 둬야 한다. 프록시는 클라이언트가 IAM으로 붙든 비밀번호로 붙든, DB로 나갈 때는 Secrets Manager에서 꺼낸 자격증명으로 접속한다.
- 프록시가 그 시크릿을 읽을 수 있는 IAM 역할이 필요하다.
시크릿부터 만든다. PostgreSQL 예시다.
$ aws secretsmanager create-secret \
--name rds-proxy/appuser \
--secret-string '{"username":"appuser","password":"CHANGE_ME"}'
{
"ARN": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:rds-proxy/appuser-a1B2c3",
"Name": "rds-proxy/appuser"
}
프록시가 이 시크릿을 읽도록 신뢰 정책이 rds.amazonaws.com인 역할을 만들고, secretsmanager:GetSecretValue와 kms:Decrypt를 붙인다. 여기서 한 번 걸리기 쉬운데, KMS 기본 키가 아니라 별도 CMK로 시크릿을 암호화했다면 kms:Decrypt를 빠뜨려 프록시가 Available로 못 올라오고 자격증명 오류로 멈춘다.
4. 프록시 생성
콘솔에서는 RDS > 프록시 > 프록시 생성 메뉴로 만들 수 있다. CLI로는 create-db-proxy다. 엔진 계열은 MYSQL, POSTGRESQL, SQLSERVER 중 하나로 지정한다.
$ aws rds create-db-proxy \
--db-proxy-name app-proxy \
--engine-family POSTGRESQL \
--auth '[{"AuthScheme":"SECRETS","SecretArn":"arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:rds-proxy/appuser-a1B2c3","IAMAuth":"REQUIRED"}]' \
--role-arn arn:aws:iam::123456789012:role/rds-proxy-secrets-role \
--vpc-subnet-ids subnet-0aaa subnet-0bbb \
--require-tls
{
"DBProxy": {
"DBProxyName": "app-proxy",
"Status": "creating",
"EngineFamily": "POSTGRESQL",
"RequireTLS": true,
"IdleClientTimeout": 1800
}
}
IAMAuth를 REQUIRED로 두면 클라이언트는 반드시 IAM 토큰으로 붙어야 한다. IAM 인증을 쓸 때는 --require-tls가 사실상 필수다. TLS 없이 IAM으로 붙으려 하면 인증이 거부된다.
다음으로 프록시를 실제 DB 인스턴스에 등록한다.
$ aws rds register-db-proxy-targets \
--db-proxy-name app-proxy \
--db-instance-identifiers my-postgres-db
등록하면 프록시가 DB에 rdsproxyadmin 사용자를 자동으로 만든다. 이 계정은 프록시 동작에 필수이니 건드리지 않는다. 엔드포인트는 이렇게 확인한다.
$ aws rds describe-db-proxies \
--query '*[*].{Name:DBProxyName,Endpoint:Endpoint,Status:Status}'
[[{
"Name": "app-proxy",
"Endpoint": "app-proxy.proxy-abc123.ap-northeast-2.rds.amazonaws.com",
"Status": "available"
}]]
5. Lambda에서 프록시에 붙기
Lambda 함수는 DB와 같은 VPC에 배치해야 프록시에 닿는다. 프록시의 보안 그룹은 Lambda 보안 그룹으로부터 5432(PostgreSQL) 또는 3306(MySQL) 인바운드를 허용해야 한다.
접속 대상만 프록시 엔드포인트로 바꾸면 된다. 비밀번호 방식이면 기존 코드 그대로다. IAM 인증이면 비밀번호 자리에 토큰을 넣는다. 토큰은 CLI로 이렇게 뽑아 확인할 수 있다.
$ aws rds generate-db-auth-token \
--hostname app-proxy.proxy-abc123.ap-northeast-2.rds.amazonaws.com \
--port 5432 \
--region ap-northeast-2 \
--username appuser
app-proxy.proxy-abc123.ap-northeast-2.rds.amazonaws.com:5432/?Action=connect&DBUser=appuser&X-Amz-Algorithm=...
실제 Lambda 코드에서는 SDK로 토큰을 만들어 커넥션에 넘긴다. Python(psycopg2) 예시다.
import os, boto3, psycopg2
ENDPOINT = os.environ["DB_PROXY_ENDPOINT"]
PORT = 5432
USER = os.environ["DB_USER"]
REGION = os.environ["AWS_REGION"]
# 커넥션은 핸들러 밖에서 만들어 재사용 (컨테이너 재사용 시 유지)
_conn = None
def get_conn():
global _conn
if _conn is None or _conn.closed:
client = boto3.client("rds", region_name=REGION)
token = client.generate_db_auth_token(
DBHostname=ENDPOINT, Port=PORT, DBUsername=USER)
_conn = psycopg2.connect(
host=ENDPOINT, port=PORT, user=USER,
password=token, dbname="appdb", sslmode="require")
return _conn
def handler(event, context):
conn = get_conn()
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM posts")
return {"count": cur.fetchone()[0]}
Lambda 실행 역할에는 rds-db:connect 권한이 필요하다. 리소스 ARN에 프록시 ID를 지정한다.
{
"Effect": "Allow",
"Action": "rds-db:connect",
"Resource": "arn:aws:rds-db:ap-northeast-2:123456789012:dbuser:prx-0abc123def456/appuser"
}
여기서 자주 막힌다. 리소스에 DB 인스턴스 ID(db-XXXX)를 넣으면 안 되고 프록시 ID(prx-XXXX)를 넣어야 한다. 잘못 넣으면 PAM authentication failed로 접속이 거부된다.
6. 핀닝, 가장 흔한 함정
프록시를 붙였는데도 DB 커넥션 수가 안 줄면 십중팔구 핀닝(pinning) 때문이다. 핀닝은 프록시가 특정 클라이언트 커넥션을 특정 DB 커넥션에 고정해 버리는 상태다. 세션 상태가 바뀌어 다른 세션과 공유하면 안 될 때 프록시가 안전을 위해 고정한다. 고정된 커넥션은 세션이 끝날 때까지 재사용되지 않으므로 멀티플렉싱 효과가 사라진다.
엔진과 상관없이 16KB를 넘는 SQL문은 무조건 핀닝을 일으킨다. 그 밖에 엔진별로 핀닝을 부르는 대표 동작은 이렇다.
- PostgreSQL:
SET명령,PREPARE/DEALLOCATE/EXECUTE로 다루는 프리페어드 스테이트먼트, 임시 테이블/시퀀스/뷰 생성, 커서 선언,DISCARD ALL, 어드바이저리 락(pg_advisory_lock) 등. - MySQL/MariaDB: 사용자/시스템 변수
SET, 임시 테이블 생성, 프리페어드 스테이트먼트,LOCK TABLES,GET_LOCK등.
핀닝을 줄이는 방법은 다음과 같다.
- 매 커넥션마다 돌리는
SET초기화문은 애플리케이션에서 빼고 프록시의 초기화 쿼리(initialization query)로 옮긴다. 그러면 프록시가 커넥션을 셋업할 때 한 번만 적용하고 멀티플렉싱을 유지한다. - PostgreSQL JDBC를 쓰면 접속 파라미터에
assumeMinServerVersion=9.0과ApplicationName을 지정한다. 드라이버가 접속할 때SET extra_float_digits,SET application_name을 몰래 날려 핀닝을 유발하는 것을 막아준다. - MySQL 계열이라면 프록시의 세션 핀닝 필터로 세션 변수 설정을 핀닝 대상에서 제외할 수 있다(동작에 영향 없다고 확신할 때만).
핀닝 발생량은 CloudWatch 지표 DatabaseConnectionsCurrentlySessionPinned로 본다. 이 값이 계속 높으면 위 항목부터 점검한다.
7. 커넥션 상한과 모니터링
프록시가 DB로 여는 커넥션 수는 MaxConnectionsPercent로 제어한다. DB의 max_connections에 대한 백분율이고 기본값은 100이다. 유휴 커넥션 비율은 MaxIdleConnectionsPercent로, 기본값은 50이다. 한 RDS를 여러 애플리케이션이 공유한다면 프록시별로 이 값을 낮춰 DB를 나눠 쓰게 할 수 있다.
$ aws rds modify-db-proxy-target-group \
--db-proxy-name app-proxy \
--target-group-name default \
--connection-pool-config '{"MaxConnectionsPercent":75,"MaxIdleConnectionsPercent":30}'
운영 중에는 다음 CloudWatch 지표를 같이 본다.
DatabaseConnections: 프록시가 실제로 DB에 연 커넥션 수. 이 값이 상한 근처에서 안정적이면 풀링이 제대로 도는 것이다.ClientConnections: 클라이언트(Lambda)가 프록시에 붙은 커넥션 수. 이 값이DatabaseConnections보다 훨씬 크면 멀티플렉싱이 잘 되고 있다는 뜻이다.DatabaseConnectionsBorrowLatency: 풀에서 커넥션을 빌려오는 데 걸린 시간. 튀면 풀이 부족하거나 핀닝이 심한 신호다.
8. 언제 쓰고 언제 안 쓰나
Lambda나 Fargate처럼 커넥션 풀을 프로세스 안에 둘 수 없는 서버리스에서 RDS와 붙는다면 RDS Proxy를 쓰는 것을 권한다. 특히 트래픽이 튀는 서비스, 그리고 Multi-AZ 페일오버 시간을 줄이고 싶은 경우에 효과가 분명하다.
반대로 커넥션 수가 고정적인 상시 EC2/ECS 서버 몇 대에 애플리케이션 풀(HikariCP 등)이 이미 잘 물려 있다면, 프록시를 굳이 끼워 지연 계층을 하나 더 만들 이유는 약하다. 그리고 프리페어드 스테이트먼트나 세션 변수에 크게 기대는 워크로드라면 핀닝으로 이득이 깎이니, 도입 전에 6절의 핀닝 조건을 코드와 대조해 보길 권한다.
프록시는 시간당 vCPU 기준으로 과금되므로, 붙이기 전에 예상 비용도 한 번 확인해 두면 좋다.