[AWS] Lambda + RDS Proxy + MySQL 실전 구성 노트

Yuchan Han·2026년 4월 5일

AWS

목록 보기
6/7
post-thumbnail

Lambda + RDS Proxy + MySQL 실전 구성 노트

AWS에서 서버리스 API를 구성하다 보면, Lambda에서 직접 RDS에 접속하는 경우가 많습니다. 트래픽이 적을 때는 문제가 없지만, Lambda 동시 실행이 늘어나면 DB 연결이 폭주하면서 이야기가 달라집니다. 이때 사용하는 것이 RDS Proxy입니다. 이번 글에서는 ALB → Lambda → RDS Proxy → MySQL 구조를 직접 구성하면서, 실제로 틀리기 쉬운 지점까지 함께 정리합니다.


전체 아키텍처

구성 흐름은 단순합니다.

외부 요청 → ALB (public) → Lambda (private) → RDS Proxy → RDS MySQL

각 리소스가 어디에 위치하는지가 중요합니다.

리소스서브넷이유
ALBpublic (2개 AZ)internet-facing이므로 IGW 경로가 필요
Lambdaprivate (2개 AZ)DB 접근 역할이라 외부 노출 불필요
RDS ProxyprivateLambda와 같은 VPC 내부에서만 접근
RDS MySQLprivate외부 직접 접근 차단

ALB에 DNS가 있으면 public subnet이 필요 없는 거 아닌가?
DNS는 이름일 뿐입니다. 실제 외부 트래픽을 받으려면 ALB가 IGW 경로가 있는 public subnet에 배치되어야 합니다. DNS와 네트워크 위치는 별개입니다.


리소스 이름 규칙

실습 환경 기준 리소스명입니다.

항목
Regionap-northeast-2 (Seoul)
VPCwsi-app-vpc
ALBwsi-alb-lambda
Target Groupwsi-tg-lambda ← 타입: Lambda
Lambdawsi-lambda-order
RDSwsi-mysql-db
RDS Proxywsi-rds-proxy
Secretwsi-db-secret
DB명wsi_app
테이블orders

1단계: RDS MySQL + 테이블 준비

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 인바운드 누락이 원인입니다.


2단계: RDS Proxy 생성

Lambda가 동시에 여러 개 실행되면, 각각이 DB 커넥션을 하나씩 점유합니다. 요청이 몰리면 RDS의 max_connections를 순식간에 소진합니다. RDS Proxy는 이 커넥션을 풀링해서 Lambda 쪽의 연결 폭주를 막아줍니다.

비유하자면, Lambda 인스턴스 100개가 각각 DB 문을 두드리는 대신, Proxy라는 접수 창구 하나를 거쳐서 정리된 순서로 DB에 접근하는 구조입니다.

생성 후 target health가 AVAILABLE 상태인지 반드시 확인합니다. AVAILABLE이 아니면 Lambda에서 접속 시 timeout이 발생합니다.


3단계: Secrets Manager 구성

DB 접속 정보를 코드에 하드코딩하면 안 됩니다. Secrets Manager에 저장하고 Lambda에서 런타임에 읽어옵니다.

여기서 한 가지 주의할 점이 있습니다. AWS가 자동 생성한 Secret에는 host 값이 없는 경우가 있습니다. 이 Secret은 수정이 불가능할 수도 있습니다.

해결 방법은 역할을 분리하는 것입니다.

항목출처비고
username, passwordSecrets 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를 넣어야 합니다.


4단계: Lambda 함수 작성

Lambda는 두 가지 API를 처리합니다.

메서드경로동작응답
GET/health상태 확인{"status":"OK","app":"order-api"}
POST/v1/order주문 저장{"result":"added","order_uuid":"..."}

Lambda 전체 코드

아래 코드를 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_HOSTwsi-rds-proxy.proxy-xxxx.ap-northeast-2.rds.amazonaws.comRDS Proxy endpoint
DB_NAMEwsi_app데이터베이스명
SECRET_NAMEwsi-db-secretSecrets Manager Secret 이름

pymysql 패키지

Lambda 런타임에는 pymysql이 기본 포함되어 있지 않습니다. Lambda Layer로 추가하거나, 배포 패키지에 번들해야 합니다. 이걸 빠뜨리면 No module named pymysql 에러가 나고, ALB에서는 502로 보입니다.


5단계: Lambda IAM Role 구성

Lambda에 어떤 권한을 줘야 하는지 헷갈리기 쉽습니다.

권한용도필수 여부
AWSLambdaBasicExecutionRoleCloudWatch 로그 기록필수
AWSLambdaVPCAccessExecutionRoleVPC 내 ENI 생성필수
secretsmanager:GetSecretValueSecret에서 DB 인증정보 읽기필수
AmazonRDSFullAccessRDS 관리 권한불필요

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>*"
    }
  ]
}

6단계: ALB + Target Group 구성

외부 요청의 진입점을 만듭니다.

ALB 설정


항목
이름wsi-alb-lambda
Schemeinternet-facing
ListenerHTTP:80
서브넷public subnet 2개 (서로 다른 AZ)

ALB 보안 그룹

ALB 생성 시 보안 그룹을 따로 지정하지 않으면 VPC default SG가 붙습니다. default SG는 같은 SG 내부 트래픽만 허용하기 때문에, 외부에서 ALB DNS로 요청하면 응답이 오지 않습니다.

ALB용 보안 그룹을 만들고 아래 규칙을 넣습니다.

방향프로토콜포트소스설명
인바운드TCP800.0.0.0/0외부 HTTP 요청 허용
아웃바운드전체전체0.0.0.0/0기본값 유지

ALB → Lambda는 SG가 아닌 IAM으로 제어됩니다. Lambda Target Group을 만들면 AWS가 자동으로 Lambda 리소스 기반 정책에 invoke 권한을 추가합니다. ALB SG 아웃바운드에 Lambda용 규칙을 별도로 넣을 필요는 없습니다.

Target Group 설정


Lambda 함수 선택 후 Target Group 생성하기

항목
이름wsi-tg-lambda
타입Lambda ← 여기서 자주 틀림
대상wsi-lambda-order

⚠️ Target Group 타입을 Instance나 IP로 설정하는 실수가 많습니다. Lambda를 대상으로 하려면 반드시 타입을 Lambda로 선택해야 합니다.

Listener 규칙에서 /health/v1/order 경로를 Lambda Target Group으로 라우팅합니다.


7단계: 보안 그룹(SG) 구성

SG 설정이 빠지면 연결이 timeout으로 실패합니다. 각 리소스 간 통신 경로를 열어야 합니다.

출발도착포트프로토콜설명
ALB SGLambda--ALB→Lambda는 SG가 아닌 IAM invoke 권한으로 제어
Lambda SGRDS Proxy SG3306TCPLambda → Proxy 접속
RDS Proxy SGRDS SG3306TCPProxy → 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 GatewayLambda 내부 에러 (pymysql 누락, 문법 오류 등)CloudWatch 로그에서 실제 에러 확인
POST /v1/order가 404Lambda 코드의 path/method 파싱 키가 이벤트 형식과 불일치ALB 이벤트는 event['path'], event['httpMethod'] 사용
500 AccessDeniedExceptionLambda Role에 GetSecretValue 권한 누락인라인 정책 추가
mysql 접속 시 무한 대기RDS SG 인바운드에 출발지 미허용SG 인바운드 규칙 확인
Lambda → Proxy timeoutProxy 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 내부 오류인 경우가 대부분입니다.

  1. ALB DNS로 직접 호출해서 응답 코드를 확인합니다 (502/404/200).
  2. Target Group 타입이 Lambda인지 확인합니다.
  3. Lambda 리소스 기반 정책에 elasticloadbalancing invoke 권한이 있는지 확인합니다.
  4. CloudWatch Logs에서 실제 에러를 확인합니다. ← 여기서 거의 다 잡힙니다.

직접 해보기: 실습 문제

위 내용을 읽었으면, 아래 문제를 보고 직접 구성해봅니다. 해설 없이 스스로 만들어보는 게 가장 빠르게 익히는 방법입니다.


문제

외부 요청을 ALB가 받고, Lambda가 처리하여 RDS(MySQL)에 저장하는 구조를 구성하시오. DB 연결은 반드시 RDS Proxy를 경유해야 합니다.

리소스 / 이름 규칙

항목
Regionap-northeast-2 (Seoul)
VPCwsi-app-vpc
ALBwsi-alb-lambda (internet-facing)
Target Groupwsi-tg-lambda (target type = Lambda)
Lambdawsi-lambda-order
RDS 인스턴스wsi-mysql-db
RDS Proxywsi-rds-proxy
Secretwsi-db-secret
DB명wsi_app
테이블orders

DB 스키마

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
);

API 스펙

메서드경로요청응답
GET/health-{"status":"OK","app":"order-api"}
POST/v1/order{"item_name":"jean","quantity":2}{"result":"added","order_uuid":"..."}

구성 조건

  1. ALB는 internet-facing, public subnet 2개(서로 다른 AZ)에 배치합니다.
  2. Listener는 HTTP:80, Target Group 타입은 Lambda입니다.
  3. Lambda는 RDS/Proxy와 같은 VPC의 private subnet에 연결합니다.
  4. Lambda Role에는 AWSLambdaBasicExecutionRole + AWSLambdaVPCAccessExecutionRole + secretsmanager:GetSecretValue를 부여합니다.
  5. DB 접속 시 host는 RDS Proxy endpoint를 사용합니다.
  6. Lambda에 pymysql 패키지를 포함합니다 (Layer 또는 번들).

검증 커맨드

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 GatewayLambda 내부 에러 (pymysql 누락, 문법 오류 등)CloudWatch 로그에서 실제 에러 확인
POST /v1/order가 404Lambda 코드의 path/method 파싱 키가 이벤트 형식과 불일치ALB 이벤트는 event['path'], event['httpMethod'] 사용
500 AccessDeniedExceptionLambda Role에 GetSecretValue 권한 누락인라인 정책 추가
mysql 접속 시 무한 대기RDS SG 인바운드에 출발지 미허용SG 인바운드 규칙 확인
Lambda → Proxy timeoutProxy 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 내부 오류인 경우가 대부분입니다.

  1. ALB DNS로 직접 호출해서 응답 코드를 확인합니다 (502/404/200).
  2. Target Group 타입이 Lambda인지 확인합니다.
  3. Lambda 리소스 기반 정책에 elasticloadbalancing invoke 권한이 있는지 확인합니다.
  4. CloudWatch Logs에서 실제 에러를 확인합니다. ← 여기서 거의 다 잡힙니다.

직접 해보기: 실습 문제

위 내용을 읽었으면, 아래 문제를 보고 직접 구성해봅니다. 해설 없이 스스로 만들어보는 게 가장 빠르게 익히는 방법입니다.


문제

외부 요청을 ALB가 받고, Lambda가 처리하여 RDS(MySQL)에 저장하는 구조를 구성하시오. DB 연결은 반드시 RDS Proxy를 경유해야 합니다.

리소스 / 이름 규칙

항목
Regionap-northeast-2 (Seoul)
VPCwsi-app-vpc
ALBwsi-alb-lambda (internet-facing)
Target Groupwsi-tg-lambda (target type = Lambda)
Lambdawsi-lambda-order
RDS 인스턴스wsi-mysql-db
RDS Proxywsi-rds-proxy
Secretwsi-db-secret
DB명wsi_app
테이블orders

DB 스키마

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
);

API 스펙

메서드경로요청응답
GET/health-{"status":"OK","app":"order-api"}
POST/v1/order{"item_name":"jean","quantity":2}{"result":"added","order_uuid":"..."}

구성 조건

  1. ALB는 internet-facing, public subnet 2개(서로 다른 AZ)에 배치합니다.
  2. Listener는 HTTP:80, Target Group 타입은 Lambda입니다.
  3. Lambda는 RDS/Proxy와 같은 VPC의 private subnet에 연결합니다.
  4. Lambda Role에는 AWSLambdaBasicExecutionRole + AWSLambdaVPCAccessExecutionRole + secretsmanager:GetSecretValue를 부여합니다.
  5. DB 접속 시 host는 RDS Proxy endpoint를 사용합니다.
  6. Lambda에 pymysql 패키지를 포함합니다 (Layer 또는 번들).

검증 커맨드

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에 실제로 데이터가 들어가면 완료입니다.

profile
맛있는거먹고싶어요

0개의 댓글