20250117

Tencent Cloud SCF에서 Python RDB 연결

사내 미니 프로젝트 하나를 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 붙이면 몇 가지 문제가 연달아 터진다.

  1. SCF 가 VPC 에 안 붙어 있으면 VPC 안의 TencentDB 에 접근 못 함. "connection refused" 가 아니라 SCF 가 public egress 로 나가버려서 백엔드 DB 가 못 받는다.
  2. 콜드 스타트마다 connection 새로 여니까 응답 시간이 1.5~2초대에서 왔다갔다.
  3. execute 만 해놓고 fetchall 안 하면 커넥션이 dangling 되어 다음 요청에서 이상한 결과가 섞여 나오기도 했다(같은 컨테이너에서 전역 변수로 재사용한 경우).
  4. 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}'}

포인트:

  1. 전역 변수 _conn 로 웜 컨테이너 재사용. SCF 는 Lambda 와 마찬가지로 한 번 올라온 컨테이너를 여러 요청에 재사용한다. 콜드 스타트가 아닌 호출에서는 connect 비용이 사라진다.
  2. ping(reconnect=True) 로 dead connection 감지. TencentDB 의 wait_timeout(기본 28800초지만 인스턴스에 따라 더 짧음) 이 지나면 끊겨 있다. 호출 간격이 길면 dangling.
  3. connect/read/write timeout 을 명시. 디폴트는 무한. 함수 timeout(기본 3초) 안에서 DB 가 느릴 때 재시도 기회를 주려면 짧게 잡는 게 낫다.
  4. autocommit=True. SCF 핸들러는 매번 새 논리적 트랜잭션 단위로 보고 가볍게 가는 게 편하다. 여러 statement 를 묶어야 하면 그때만 with conn: (context manager) 으로 트랜잭션 걸면 된다.
  5. 파라미터 바인딩. %s 플레이스홀더. 문자열 format/f-string 으로 쿼리 조합하면 SQL 인젝션 먹는다.
  6. 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

완화책:

  1. 예약된 동시성(Provisioned concurrency). 핵심 함수는 항상 몇 개 웜 컨테이너를 유지. 비용이 들지만 P95 레이턴시가 확연히 내려간다.
  2. Import 최소화. 핸들러 파일에서 안 쓰는 라이브러리를 top-level import 하지 말 것. boto3 같이 무거운 건 지연 import 로.
  3. 의존성 층(Layer). 코드 zip 을 가볍게.
  4. 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 쪽 문서가 희박해서 한참 헤맨 경험을 그대로 적어뒀다. 비슷한 분께 도움이 되길.