
AWS에서 서버리스 API를 구성하다 보면, Lambda에서 직접 RDS에 접속하는 경우가 많습니다. 트래픽이 적을 때는 문제가 없지만, Lambda 동시 실행이 늘어나면 DB 연결이 폭주하면서 이야기가 달라집니다. 이때 사용하는 것이 RDS Proxy입니다. 이번 글에서는 ALB → Lambda → RDS Proxy → MySQL 구조를 직접 구성하면서, 실제로 틀리기 쉬운 지점까지 함께 정리합니다.
구성 흐름은 단순합니다.
외부 요청 → ALB (public) → Lambda (private) → RDS Proxy → RDS MySQL
각 리소스가 어디에 위치하는지가 중요합니다.
| 리소스 | 서브넷 | 이유 |
|---|---|---|
| ALB | public (2개 AZ) | internet-facing이므로 IGW 경로가 필요 |
| Lambda | private (2개 AZ) | DB 접근 역할이라 외부 노출 불필요 |
| RDS Proxy | private | Lambda와 같은 VPC 내부에서만 접근 |
| RDS MySQL | private | 외부 직접 접근 차단 |
ALB에 DNS가 있으면 public subnet이 필요 없는 거 아닌가?
DNS는 이름일 뿐입니다. 실제 외부 트래픽을 받으려면 ALB가 IGW 경로가 있는 public subnet에 배치되어야 합니다. DNS와 네트워크 위치는 별개입니다.
실습 환경 기준 리소스명입니다.
| 항목 | 값 |
|---|---|
| Region | ap-northeast-2 (Seoul) |
| VPC | wsi-app-vpc |
| ALB | wsi-alb-lambda |
| Target Group | wsi-tg-lambda ← 타입: Lambda |
| Lambda | wsi-lambda-order |
| RDS | wsi-mysql-db |
| RDS Proxy | wsi-rds-proxy |
| Secret | wsi-db-secret |
| DB명 | wsi_app |
| 테이블 | orders |
Lambda가 데이터를 저장할 대상이 있어야 합니다. RDS MySQL 인스턴스를 생성하고, orders 테이블을 만듭니다.
아래 SQL을 실행합니다.
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
order_uuid VARCHAR(64) NOT NULL, -- Lambda에서 생성하는 UUID
item_name VARCHAR(100) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
⚠️ RDS SG가 접속 출발지(CloudShell 등)를 허용하지 않으면, 비밀번호 입력 후 아무 반응 없이 멈춥니다. timeout처럼 보이지만 SG 인바운드 누락이 원인입니다.
Lambda가 동시에 여러 개 실행되면, 각각이 DB 커넥션을 하나씩 점유합니다. 요청이 몰리면 RDS의 max_connections를 순식간에 소진합니다. RDS Proxy는 이 커넥션을 풀링해서 Lambda 쪽의 연결 폭주를 막아줍니다.
비유하자면, Lambda 인스턴스 100개가 각각 DB 문을 두드리는 대신, Proxy라는 접수 창구 하나를 거쳐서 정리된 순서로 DB에 접근하는 구조입니다.
생성 후 target health가 AVAILABLE 상태인지 반드시 확인합니다. AVAILABLE이 아니면 Lambda에서 접속 시 timeout이 발생합니다.
DB 접속 정보를 코드에 하드코딩하면 안 됩니다. Secrets Manager에 저장하고 Lambda에서 런타임에 읽어옵니다.
여기서 한 가지 주의할 점이 있습니다. AWS가 자동 생성한 Secret에는 host 값이 없는 경우가 있습니다. 이 Secret은 수정이 불가능할 수도 있습니다.
해결 방법은 역할을 분리하는 것입니다.
| 항목 | 출처 | 비고 |
|---|---|---|
| username, password | Secrets Manager에서 읽기 | secretsmanager:GetSecretValue |
| host (DB 접속 주소) | Lambda 환경변수 DB_HOST | 값 = RDS Proxy endpoint |
# 정리하면 이렇게 됩니다
username/password → Secret에서 읽기
host → DB_HOST 환경변수 (Proxy endpoint)
⚠️
DB_HOST에 RDS endpoint를 넣으면 Proxy를 우회합니다. 반드시 Proxy endpoint를 넣어야 합니다.
Lambda는 두 가지 API를 처리합니다.
| 메서드 | 경로 | 동작 | 응답 |
|---|---|---|---|
| GET | /health | 상태 확인 | {"status":"OK","app":"order-api"} |
| POST | /v1/order | 주문 저장 | {"result":"added","order_uuid":"..."} |
아래 코드를 lambda_function.py에 붙여넣습니다.
import json
import os
import uuid
import pymysql
import boto3
# Secrets Manager에서 DB 인증정보 조회
def get_secret():
client = boto3.client("secretsmanager")
resp = client.get_secret_value(SecretId=os.environ["SECRET_NAME"])
return json.loads(resp["SecretString"])
# DB 커넥션 생성 (host는 환경변수에서, 인증정보는 Secret에서)
def get_connection():
secret = get_secret()
return pymysql.connect(
host=os.environ["DB_HOST"], # RDS Proxy endpoint
user=secret["username"],
password=secret["password"],
database=os.environ.get("DB_NAME", "wsi_app"),
cursorclass=pymysql.cursors.DictCursor
)
# ALB 호환 응답 포맷 — 이 형식을 지키지 않으면 502가 발생합니다
def response(status_code, body):
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body)
}
def lambda_handler(event, context):
# ALB 이벤트에서 method, path 파싱
method = event.get("httpMethod", "")
path = event.get("path", "")
# GET /health — 상태 확인
if method == "GET" and path == "/health":
return response(200, {"status": "OK", "app": "order-api"})
# POST /v1/order — 주문 저장
if method == "POST" and path == "/v1/order":
body = json.loads(event.get("body", "{}"))
item_name = body.get("item_name", "")
quantity = body.get("quantity", 0)
order_uuid = str(uuid.uuid4())
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO orders (order_uuid, item_name, quantity) VALUES (%s, %s, %s)",
(order_uuid, item_name, quantity)
)
conn.commit()
finally:
conn.close()
return response(200, {"result": "added", "order_uuid": order_uuid})
# 그 외 경로는 404
return response(404, {"error": "not found"})
⚠️ ALB 연동 시 응답은 반드시
{"statusCode", "headers", "body"}형식이어야 합니다. 이 포맷을 지키지 않으면 Lambda가 정상 실행되더라도 ALB가 502 Bad Gateway를 반환합니다.
코드에서 주의할 포인트를 정리합니다.
| 포인트 | 설명 |
|---|---|
event["httpMethod"] | ALB 이벤트 형식 기준. API Gateway(HTTP API)는 event["requestContext"]["http"]["method"]로 키가 다름 |
event["path"] | 마찬가지로 ALB 전용 키. API Gateway는 event["rawPath"] 사용 |
event["body"] | ALB는 body를 문자열로 넘기므로 json.loads() 필요 |
DB_HOST 환경변수 | RDS Proxy endpoint를 넣어야 함. RDS endpoint를 넣으면 Proxy를 우회 |
SECRET_NAME 환경변수 | Secrets Manager의 Secret 이름 (예: wsi-db-secret) |
Lambda 콘솔에서 환경변수를 아래와 같이 설정합니다.
| 환경변수 | 값 | 비고 |
|---|---|---|
DB_HOST | wsi-rds-proxy.proxy-xxxx.ap-northeast-2.rds.amazonaws.com | RDS Proxy endpoint |
DB_NAME | wsi_app | 데이터베이스명 |
SECRET_NAME | wsi-db-secret | Secrets Manager Secret 이름 |
Lambda 런타임에는 pymysql이 기본 포함되어 있지 않습니다. Lambda Layer로 추가하거나, 배포 패키지에 번들해야 합니다. 이걸 빠뜨리면 No module named pymysql 에러가 나고, ALB에서는 502로 보입니다.
Lambda에 어떤 권한을 줘야 하는지 헷갈리기 쉽습니다.
| 권한 | 용도 | 필수 여부 |
|---|---|---|
AWSLambdaBasicExecutionRole | CloudWatch 로그 기록 | 필수 |
AWSLambdaVPCAccessExecutionRole | VPC 내 ENI 생성 | 필수 |
secretsmanager:GetSecretValue | Secret에서 DB 인증정보 읽기 | 필수 |
AmazonRDSFullAccess | RDS 관리 권한 | 불필요 |
RDS FullAccess가 필요하지 않은 이유:
Lambda는 RDS API를 호출하는 게 아니라, pymysql로 MySQL 프로토콜을 통해 직접 접속합니다. IAM 인증이 아닌 username/password 인증이므로 RDS 관련 IAM 권한은 필요 없습니다.
secretsmanager:GetSecretValue는 인라인 정책으로 추가합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:<secret-name>*"
}
]
}
외부 요청의 진입점을 만듭니다.


| 항목 | 값 |
|---|---|
| 이름 | wsi-alb-lambda |
| Scheme | internet-facing |
| Listener | HTTP:80 |
| 서브넷 | public subnet 2개 (서로 다른 AZ) |
ALB 생성 시 보안 그룹을 따로 지정하지 않으면 VPC default SG가 붙습니다. default SG는 같은 SG 내부 트래픽만 허용하기 때문에, 외부에서 ALB DNS로 요청하면 응답이 오지 않습니다.
ALB용 보안 그룹을 만들고 아래 규칙을 넣습니다.
| 방향 | 프로토콜 | 포트 | 소스 | 설명 |
|---|---|---|---|---|
| 인바운드 | TCP | 80 | 0.0.0.0/0 | 외부 HTTP 요청 허용 |
| 아웃바운드 | 전체 | 전체 | 0.0.0.0/0 | 기본값 유지 |
ALB → Lambda는 SG가 아닌 IAM으로 제어됩니다. Lambda Target Group을 만들면 AWS가 자동으로 Lambda 리소스 기반 정책에 invoke 권한을 추가합니다. ALB SG 아웃바운드에 Lambda용 규칙을 별도로 넣을 필요는 없습니다.


Lambda 함수 선택 후 Target Group 생성하기
| 항목 | 값 |
|---|---|
| 이름 | wsi-tg-lambda |
| 타입 | Lambda ← 여기서 자주 틀림 |
| 대상 | wsi-lambda-order |
⚠️ Target Group 타입을 Instance나 IP로 설정하는 실수가 많습니다. Lambda를 대상으로 하려면 반드시 타입을 Lambda로 선택해야 합니다.
Listener 규칙에서 /health와 /v1/order 경로를 Lambda Target Group으로 라우팅합니다.
SG 설정이 빠지면 연결이 timeout으로 실패합니다. 각 리소스 간 통신 경로를 열어야 합니다.
| 출발 | 도착 | 포트 | 프로토콜 | 설명 |
|---|---|---|---|---|
| ALB SG | Lambda | - | - | ALB→Lambda는 SG가 아닌 IAM invoke 권한으로 제어 |
| Lambda SG | RDS Proxy SG | 3306 | TCP | Lambda → Proxy 접속 |
| RDS Proxy SG | RDS SG | 3306 | TCP | Proxy → RDS 접속 |
RDS Proxy SG의 인바운드에 Lambda SG를 source로 3306을 열지 않으면, Lambda에서 Proxy 접속 시 timeout이 발생합니다. 에러 메시지 없이 그냥 멈추기 때문에 원인 파악이 어렵습니다.
Lambda SG 인바운드에 443을 열어야 하나?
보통 필요 없습니다. Lambda는 외부 요청을 직접 수신하는 서버가 아니라 호출 주체입니다. 443은 Lambda가 AWS 서비스(Secrets Manager 등)에 접근할 때 필요한데, 이건 Lambda SG의 아웃바운드 문제이지 인바운드가 아닙니다.
# 상태 확인
curl http://<ALB-DNS>/health
# 기대 응답: {"status":"OK","app":"order-api"}
# 주문 생성
curl -X POST http://<ALB-DNS>/v1/order \
-H 'content-type: application/json' \
-d '{"item_name":"jean","quantity":2}'
# 기대 응답: {"result":"added","order_uuid":"..."}
⚠️ ALB Listener가 HTTP:80으로만 열려 있으면, https://가 아닌 http://로 호출해야 합니다.
https로 호출하면 응답이 오지 않습니다.
검증 후 DB에서도 실제로 데이터가 들어갔는지 확인합니다.
SELECT * FROM orders ORDER BY created_at DESC LIMIT 5;
실습하면서 실제로 만났던 에러들입니다. 대부분 "왜 안 되지?" 하고 한참 헤매게 만드는 것들입니다.
| 증상 | 원인 | 해결 |
|---|---|---|
| ALB 호출 시 502 Bad Gateway | Lambda 내부 에러 (pymysql 누락, 문법 오류 등) | CloudWatch 로그에서 실제 에러 확인 |
| POST /v1/order가 404 | Lambda 코드의 path/method 파싱 키가 이벤트 형식과 불일치 | ALB 이벤트는 event['path'], event['httpMethod'] 사용 |
| 500 AccessDeniedException | Lambda Role에 GetSecretValue 권한 누락 | 인라인 정책 추가 |
| mysql 접속 시 무한 대기 | RDS SG 인바운드에 출발지 미허용 | SG 인바운드 규칙 확인 |
| Lambda → Proxy timeout | Proxy SG 인바운드에 Lambda SG 미허용 (3306) | Proxy SG에 Lambda SG source로 3306 추가 |
| Proxy를 만들었는데 접속 불가 | Proxy target health가 AVAILABLE이 아님 | 콘솔에서 상태 확인, 수 분 대기 |
| DB에 데이터가 안 들어감 | DB_HOST에 Proxy endpoint가 아닌 RDS endpoint 사용 | 환경변수 값을 Proxy endpoint로 변경 |
502가 나왔을 때 ALB를 의심하기 쉽지만, 실제로는 Lambda 내부 오류인 경우가 대부분입니다.
elasticloadbalancing invoke 권한이 있는지 확인합니다.위 내용을 읽었으면, 아래 문제를 보고 직접 구성해봅니다. 해설 없이 스스로 만들어보는 게 가장 빠르게 익히는 방법입니다.
외부 요청을 ALB가 받고, Lambda가 처리하여 RDS(MySQL)에 저장하는 구조를 구성하시오. DB 연결은 반드시 RDS Proxy를 경유해야 합니다.
| 항목 | 값 |
|---|---|
| Region | ap-northeast-2 (Seoul) |
| VPC | wsi-app-vpc |
| ALB | wsi-alb-lambda (internet-facing) |
| Target Group | wsi-tg-lambda (target type = Lambda) |
| Lambda | wsi-lambda-order |
| RDS 인스턴스 | wsi-mysql-db |
| RDS Proxy | wsi-rds-proxy |
| Secret | wsi-db-secret |
| DB명 | wsi_app |
| 테이블 | orders |
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
order_uuid VARCHAR(64) NOT NULL,
item_name VARCHAR(100) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | /health | - | {"status":"OK","app":"order-api"} |
| POST | /v1/order | {"item_name":"jean","quantity":2} | {"result":"added","order_uuid":"..."} |
AWSLambdaBasicExecutionRole + AWSLambdaVPCAccessExecutionRole + secretsmanager:GetSecretValue를 부여합니다.curl http://<ALB-DNS>/health
curl -X POST http://<ALB-DNS>/v1/order \
-H 'content-type: application/json' \
-d '{"item_name":"jean","quantity":2}'
두 요청 모두 200 응답이 오고, DB에 실제로 데이터가 들어가면 완료입니다.
실습하면서 실제로 만났던 에러들입니다. 대부분 "왜 안 되지?" 하고 한참 헤매게 만드는 것들입니다.
| 증상 | 원인 | 해결 |
|---|---|---|
| ALB 호출 시 502 Bad Gateway | Lambda 내부 에러 (pymysql 누락, 문법 오류 등) | CloudWatch 로그에서 실제 에러 확인 |
| POST /v1/order가 404 | Lambda 코드의 path/method 파싱 키가 이벤트 형식과 불일치 | ALB 이벤트는 event['path'], event['httpMethod'] 사용 |
| 500 AccessDeniedException | Lambda Role에 GetSecretValue 권한 누락 | 인라인 정책 추가 |
| mysql 접속 시 무한 대기 | RDS SG 인바운드에 출발지 미허용 | SG 인바운드 규칙 확인 |
| Lambda → Proxy timeout | Proxy SG 인바운드에 Lambda SG 미허용 (3306) | Proxy SG에 Lambda SG source로 3306 추가 |
| Proxy를 만들었는데 접속 불가 | Proxy target health가 AVAILABLE이 아님 | 콘솔에서 상태 확인, 수 분 대기 |
| DB에 데이터가 안 들어감 | DB_HOST에 Proxy endpoint가 아닌 RDS endpoint 사용 | 환경변수 값을 Proxy endpoint로 변경 |
502가 나왔을 때 ALB를 의심하기 쉽지만, 실제로는 Lambda 내부 오류인 경우가 대부분입니다.
elasticloadbalancing invoke 권한이 있는지 확인합니다.위 내용을 읽었으면, 아래 문제를 보고 직접 구성해봅니다. 해설 없이 스스로 만들어보는 게 가장 빠르게 익히는 방법입니다.
외부 요청을 ALB가 받고, Lambda가 처리하여 RDS(MySQL)에 저장하는 구조를 구성하시오. DB 연결은 반드시 RDS Proxy를 경유해야 합니다.

| 항목 | 값 |
|---|---|
| Region | ap-northeast-2 (Seoul) |
| VPC | wsi-app-vpc |
| ALB | wsi-alb-lambda (internet-facing) |
| Target Group | wsi-tg-lambda (target type = Lambda) |
| Lambda | wsi-lambda-order |
| RDS 인스턴스 | wsi-mysql-db |
| RDS Proxy | wsi-rds-proxy |
| Secret | wsi-db-secret |
| DB명 | wsi_app |
| 테이블 | orders |
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
order_uuid VARCHAR(64) NOT NULL,
item_name VARCHAR(100) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | /health | - | {"status":"OK","app":"order-api"} |
| POST | /v1/order | {"item_name":"jean","quantity":2} | {"result":"added","order_uuid":"..."} |
AWSLambdaBasicExecutionRole + AWSLambdaVPCAccessExecutionRole + secretsmanager:GetSecretValue를 부여합니다.curl http://<ALB-DNS>/health
curl -X POST http://<ALB-DNS>/v1/order \
-H 'content-type: application/json' \
-d '{"item_name":"jean","quantity":2}'
두 요청 모두 200 응답이 오고, DB에 실제로 데이터가 들어가면 완료입니다.