사내 미니 프로젝트 하나를 Tencent Cloud SCF(Serverless Cloud Function) 에 올리면서 RDB 붙이는 과정에서 꽤 많이 삽질했다. AWS Lambda + RDS 는 구글에 자료가 널렸지만 Tencent SCF + TencentDB 는 한글 자료가 거의 없다. 정리해둔다.
환경은 Python 3.10 런타임, TencentDB for MySQL 5.7, 같은 VPC 안에 둔 구성. 이 글은 중국 리전에서 작동 기준이고 Global 쪽(싱가포르/홍콩 리전)은 네트워크 경로가 조금 다르니 주의.
먼저 부딪혔던 것들
Lambda 를 쓰던 감각으로 pymysql 붙이면 몇 가지 문제가 연달아 터진다.
- SCF 가 VPC 에 안 붙어 있으면 VPC 안의 TencentDB 에 접근 못 함. "connection refused" 가 아니라 SCF 가 public egress 로 나가버려서 백엔드 DB 가 못 받는다.
- 콜드 스타트마다 connection 새로 여니까 응답 시간이 1.5~2초대에서 왔다갔다.
- execute 만 해놓고 fetchall 안 하면 커넥션이 dangling 되어 다음 요청에서 이상한 결과가 섞여 나오기도 했다(같은 컨테이너에서 전역 변수로 재사용한 경우).
- timeout 기본 3초인데 DB 가 초기 slow 일 때 바로 깨짐.
하나씩 풀자.
1. VPC 연결이 먼저다
SCF 콘솔 → 함수 관리 → 네트워크 설정에서 VPC 를 반드시 같은 VPC, 같은 서브넷으로 지정해야 한다. 지정 안 하면 SCF 는 Tencent 의 공용 풀에서 랜덤 IP 로 나가는데, TencentDB 는 사설 네트워크 안에 있으니 도달 불가.
VPC 를 붙이면 SCF 가 ENI(Elastic Network Interface) 를 생성해서 그 서브넷에 IP 를 잡는다. 이 ENI 생성이 콜드 스타트 시간에 영향을 준다. 수백 ms 가 추가로 붙는 걸 감안해야 한다.
그리고 TencentDB 의 보안그룹에 SCF 서브넷 대역을 허용 소스로 등록해야 한다. 안 등록하면 ENI 는 붙었는데 DB 포트(기본 3306)로 syn 이 버려진다. 증상은 "connection timeout" 이라 원인 파악이 늦어지기 쉽다.
체크 포인트 요약:
- SCF 가 VPC 에 연결돼 있나
- SCF 서브넷과 DB 서브넷이 같은 VPC 인가 (다른 VPC 면 Peering 필요)
- DB 보안그룹이 SCF 서브넷 대역을 inbound 로 허용하나
- route table 에 양방향 경로가 있나 (같은 VPC 면 자동)
2. 기본 연결 코드
의존성:
pymysql==1.1.0 SQLAlchemy==2.0.x # 필요 시
SCF 에 배포할 때는 의존성을 layer 로 묶는 게 깔끔하다. 함수 코드 zip 에 같이 넣어도 되는데, 매번 코드 바꿀 때마다 의존성까지 재업로드는 비효율.
핵심 핸들러.
import os
import pymysql
# 전역: 컨테이너 재사용 시 connection 유지
_conn = None
def _connect():
return pymysql.connect(
host = os.environ['DB_HOST'],
port = int(os.environ.get('DB_PORT', 3306)),
user = os.environ['DB_USER'],
password = os.environ['DB_PASSWORD'],
database = os.environ['DB_NAME'],
charset = 'utf8mb4',
connect_timeout = 5,
read_timeout = 10,
write_timeout = 10,
autocommit = True,
cursorclass = pymysql.cursors.DictCursor,
)
def _ensure_conn():
global _conn
if _conn is None:
_conn = _connect()
return _conn
try:
_conn.ping(reconnect=True)
except Exception:
_conn = _connect()
return _conn
def main_handler(event, context):
conn = _ensure_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id, name FROM user WHERE status = %s LIMIT %s",
('active', 100),
)
rows = cur.fetchall()
return {'statusCode': 200, 'body': rows}
except pymysql.MySQLError as e:
return {'statusCode': 500, 'body': f'db error: {e.args}'}
포인트:
- 전역 변수
_conn로 웜 컨테이너 재사용. SCF 는 Lambda 와 마찬가지로 한 번 올라온 컨테이너를 여러 요청에 재사용한다. 콜드 스타트가 아닌 호출에서는 connect 비용이 사라진다. ping(reconnect=True)로 dead connection 감지. TencentDB 의wait_timeout(기본 28800초지만 인스턴스에 따라 더 짧음) 이 지나면 끊겨 있다. 호출 간격이 길면 dangling.- connect/read/write timeout 을 명시. 디폴트는 무한. 함수 timeout(기본 3초) 안에서 DB 가 느릴 때 재시도 기회를 주려면 짧게 잡는 게 낫다.
autocommit=True. SCF 핸들러는 매번 새 논리적 트랜잭션 단위로 보고 가볍게 가는 게 편하다. 여러 statement 를 묶어야 하면 그때만with conn:(context manager) 으로 트랜잭션 걸면 된다.- 파라미터 바인딩.
%s플레이스홀더. 문자열 format/f-string 으로 쿼리 조합하면 SQL 인젝션 먹는다. - DictCursor. 기본은 tuple 리턴인데 JSON 으로 바로 내보낼 거라면 dict 가 편하다.
3. 환경변수와 시크릿
DB 비밀번호를 환경변수로 넣는 것까진 좋은데, SCF 콘솔에서 환경변수는 평문으로 보인다. 조금 더 안전하게 하려면 Tencent Secrets Manager(SSM) 에서 가져오는 식으로 바꾼다.
from tencentcloud.common import credential
from tencentcloud.ssm.v20190923 import ssm_client, models
def load_db_password():
cred = credential.Credential(
os.environ['TENCENT_SECRET_ID'],
os.environ['TENCENT_SECRET_KEY'],
)
client = ssm_client.SsmClient(cred, "ap-beijing")
req = models.GetSecretValueRequest()
req.SecretName = "db/prod/password"
req.VersionId = "v1"
resp = client.GetSecretValue(req)
return resp.SecretString
운영에서는 이 호출도 비쌀 수 있으니 콜드 스타트 때 한 번만 부르고 컨테이너 수명 동안 캐싱한다. 2025년 1월 기준으로 SCF 에서 Secrets Manager 접근은 CAM 역할 부여로 가능하고, 환경변수에 API 키를 박지 않는 쪽이 더 안전.
4. 연결 풀, 쓸까 말까
람다/SCF 에서 connection pool 은 생각보다 애매하다. 일반 웹 서버라면 pool 이 유용한데 서버리스는 한 프로세스가 한 번에 한 요청만 처리한다. 같은 컨테이너에서 동시에 여러 요청을 처리하지 않기 때문에 pool 크기 1 이면 충분하다. 사실상 "lazy singleton connection".
# SQLAlchemy 를 쓸 거면 이런 세팅이 SCF 에 적합
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
engine = create_engine(
f"mysql+pymysql://{user}:{pw}@{host}:{port}/{db}?charset=utf8mb4",
poolclass = NullPool, # 풀 없이 매 요청 connect
pool_pre_ping = True,
)
NullPool 로 두고 connection 재사용은 전역 변수 레벨에서 직접 관리하는 쪽이 헷갈림이 적다. QueuePool 을 쓰면 idle connection 이 DB 쪽 max_connections 를 갉아먹는 일이 생긴다. SCF 는 컨테이너가 100개, 1000개까지 동시에 뜰 수 있으니 각 컨테이너가 pool_size=5 를 잡고 있으면 금방 DB 한계.
정말 풀링이 필요하면 TencentDB Proxy(TDProxy) 를 앞에 두는 게 정석. AWS 의 RDS Proxy 와 같은 역할. SCF 는 프록시에 붙고, 프록시가 DB 와의 connection 을 풀링한다. 콜드 스타트마다 3way-handshake 와 MySQL auth 를 한 번씩 또 치는 오버헤드도 줄어든다.
5. 재시도 로직
TencentDB 는 유지보수 윈도우나 failover 때 수 초간 에러가 난다. 네트워크 쪽도 가끔 튄다. 조용한 실패 대신 짧은 백오프 재시도가 안전.
import time
from functools import wraps
TRANSIENT = (
pymysql.err.OperationalError,
pymysql.err.InterfaceError,
)
def retry(max_attempts=3, base=0.3):
def deco(fn):
@wraps(fn)
def wrap(*a, **kw):
last = None
for i in range(max_attempts):
try:
return fn(*a, **kw)
except TRANSIENT as e:
last = e
time.sleep(base * (2 ** i))
raise last
return wrap
return deco
@retry()
def fetch_user(user_id):
conn = _ensure_conn()
with conn.cursor() as cur:
cur.execute("SELECT * FROM user WHERE id=%s", (user_id,))
return cur.fetchone()
주의할 건 SCF 함수 전체의 timeout 이다. 기본 3초고 최대 900초. DB 재시도 백오프가 함수 timeout 을 넘어가면 의미 없이 요금만 낸다. 재시도 로직은 함수 timeout 의 1/3 안에 끝나도록 잡는 게 좋다.
6. 로깅 — CLS 연계
SCF 의 print 와 logging 모듈 출력은 CLS(Cloud Log Service) 로 전송된다. 구조화 로그를 권장.
import json, logging, os, time
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def log_db(op, sql, params, duration_ms, err=None):
logger.info(json.dumps({
'op': op,
'sql': sql[:200],
'params': str(params)[:200],
'duration_ms': duration_ms,
'err': str(err) if err else None,
'region': os.environ.get('TENCENTCLOUD_REGION'),
'request_id': os.environ.get('TENCENTCLOUD_REQUESTID', ''),
}, ensure_ascii=False))
이렇게 적어두면 CLS 에서 JSON 인덱싱으로 쿼리가 편해진다. duration_ms > 1000 같은 alert 도 쉽게 걸 수 있음.
7. 콜드 스타트 줄이기
관찰된 병목 순서(구체 수치는 이번 프로젝트 기준):
- 컨테이너 부팅 + Python import — 400~600ms
- VPC ENI 생성 — 첫 호출에 100~300ms 추가
- MySQL connect(3-way handshake + auth) — 50~150ms
- 첫 쿼리 — 5~30ms
완화책:
- 예약된 동시성(Provisioned concurrency). 핵심 함수는 항상 몇 개 웜 컨테이너를 유지. 비용이 들지만 P95 레이턴시가 확연히 내려간다.
- Import 최소화. 핸들러 파일에서 안 쓰는 라이브러리를 top-level import 하지 말 것.
boto3같이 무거운 건 지연 import 로. - 의존성 층(Layer). 코드 zip 을 가볍게.
- SDK 버전. pymysql 1.x 가 이전 0.9 보다 느릴 때가 있다. 최신이라고 무조건 낫지 않으니 실측 후 결정.
8. asyncpg/aiomysql 이 더 빠른가
"비동기가 더 빠르다" 를 기대하고 aiomysql 을 시도했는데, 서버리스 단일 요청 모델에서는 별 차이가 없다. async 의 이점은 "여러 I/O 를 한 프로세스 안에서 동시에" 인데 SCF 는 한 번에 한 요청이라 동시성이 1이다. 코드만 복잡해졌다. 한 핸들러 안에서 DB + 외부 API 를 병렬로 부른다면 asyncio 가 유리하지만, 단순 DB 조회만 하면 그냥 pymysql 이 낫다.
9. 배포 체크리스트
내가 실수한 것들 모아서 리스트로.
- SCF 함수의 VPC 네트워크 설정했는가
- DB 보안그룹 inbound 에 SCF 서브넷 CIDR 추가했는가
- 환경변수 혹은 Secrets Manager 로 DB 접속 정보 잘 들어갔는가
- 함수 timeout 이 DB 최악 응답 + 재시도 백오프 합보다 길게 잡혔는가
- 메모리 할당이 충분한가(pymysql 자체는 가볍지만 데이터 크면 다름)
- CLS 수집 역할이 붙었는가 — 로그 못 보면 디버깅 불가
- DB 쪽
max_connections가 실제 SCF 동시 실행 수를 감당할 수 있는가 - Query timeout 을 명시했는가 — DB 쪽이 슬로우 쿼리에 막히면 함수도 타임아웃
네 번째 항목이 내가 초반에 제일 많이 당한 거다. 요청 피크 때 SCF 가 갑자기 100개 컨테이너로 튀고, 각 컨테이너가 connection 한 개씩 쥐면 TencentDB 의 max_connections(기본 1000) 를 쉽게 넘어버린다. TDProxy 쓰거나 컨테이너 재사용을 극대화하도록 전역 변수 캐싱을 꼭 걸어야 한다.
마무리
서버리스 + RDB 는 원래 궁합이 안 좋다는 얘기가 있는데, 쓸 수 있다. VPC 붙여 통로 여는 단계, pooling 전략, 콜드 스타트 완화, 이 세 가지만 잡으면 대부분의 내부 백오피스 API 에 충분히 좋은 선택지다. Tencent Cloud 쪽 문서가 희박해서 한참 헤맨 경험을 그대로 적어뒀다. 비슷한 분께 도움이 되길.