20250117

텐센트 클라우드 서버리스 API 데이터 수집 시스템 설계

중국 지사 쪽에서 현지 공공 데이터 수집 파이프라인을 Tencent Cloud 위에 세팅하면서 배운 것들. AWS나 NCP는 익숙한데 텐센트는 처음이라 시행착오가 좀 있었다. 이번 글은 Tencent Cloud의 SCF(Serverless Cloud Function), CKafka, CDB(RDS)로 구성한 API 수집 시스템의 아키텍처와 함정을 정리한다. AWS Lambda + MSK + RDS의 텐센트판이라고 보면 된다.

요구사항

  • 외부 공공 API를 주기적으로 호출해 데이터 수집
  • API마다 rate limit, auth, response schema가 제각각
  • 초기 수집 → 증분 수집 전환. 초기는 대량, 증분은 가벼움.
  • 수집량 확장성 필요: 하루 수백만 row까지 갈 수 있음
  • 장애 복구, 재실행 가능해야 함 (retry, idempotency)

아키텍처 선택

초반에는 단순하게 "SCF cron이 직접 API 호출해서 CDB에 INSERT" 구조를 고려했는데, 두 가지 이유로 버렸다.

  1. API 호출 latency가 들쭉날쭉이라 SCF 실행시간이 튄다. 대량 수집 시 몇 분간 한 함수가 도는데, SCF는 실행시간 상한과 메모리 제한이 있다(기본 60초, 최대 900초). 수집 중 timeout 나면 부분 반영 상태가 된다.
  2. API 호출과 DB INSERT를 같은 함수에 두면 실패 격리가 어렵다. API는 성공했는데 DB가 잠시 장애면 데이터 손실.

그래서 수집 함수(fetch)저장 함수(writer) 사이에 CKafka를 놓는 구조로 바꿨다.

[Timer Trigger]
      │
      ▼
[SCF: scheduler]  ──▶ 대상 API 목록/페이지 생성
      │                    │
      ▼                    ▼
[SCF: fetcher] (병렬)   [Kafka: tasks]
   ↓ API 호출
   ↓ 정규화
   ↓ produce
[Kafka: raw_records]
      │
      ▼
[SCF: writer] (Kafka trigger)
      │
      ▼
[CDB MySQL] (UPSERT)

이 구조의 이점:

  • fetcher와 writer가 독립적으로 스케일. writer가 지연돼도 fetcher가 멈추지 않음.
  • writer가 실패해도 메시지가 Kafka에 남아있어 재처리 가능.
  • backpressure를 Kafka가 자연스럽게 흡수.
  • 새 consumer(예: 분석용 S3 적재)를 나중에 추가 붙이기 쉬움.

SCF의 특이점

AWS Lambda와 비교했을 때 주의할 점.

  • 콜드 스타트가 조금 더 길다. Python 3.10 기준 첫 호출에 2~3초. 지속적인 호출이 없는 함수는 provisioned concurrency(예약 동시성) 유료 옵션으로 해결.
  • 실행 리전 분리. 국내 계정으로 쓰는 "Tencent Cloud International"과 중국 본토용 계정이 분리되어 있다. 중국 본토 API를 쏠 거면 반드시 본토 리전 계정으로. 한국에서 국제 계정으로 보냈다가 해당 공공 API가 해외 IP를 막아놓아서 전부 403 맞은 적이 있다.
  • 환경변수 암호화. KMS 연동으로 secret 보관. 하드코딩 금지.

CKafka 사용의 포인트

CKafka는 Apache Kafka 호환 managed 서비스. 2.x 계열 프로토콜 그대로 쓸 수 있다. 클라이언트는 공식 kafka-python이나 confluent-kafka-python 모두 동작.

주의사항:

  • Kafka trigger가 SCF에 있다. consumer를 별도로 띄울 필요 없이 SCF 함수가 Kafka 메시지를 받아 실행되는 구조. batch size, start offset(latest/earliest/timestamp) 설정 가능.
  • 파티션 수 = 최대 consumer 병렬도. 처음에 3으로 시작했는데 writer가 병목이라 12로 증설. 파티션 증설은 사후 가능하지만 기존 메시지 분배는 다시 안 되므로 미리 넉넉히.
  • 동일 key 동일 파티션 → 순서 보장. 레코드에 ID(예: 사업자번호)를 key로 주면 같은 ID에 대한 업데이트 순서가 깨지지 않는다. 그렇게 안 하면 중간에 옛날 값이 최신을 덮는 race.
  • exactly-once는 어렵다. at-least-once가 현실. writer는 idempotent하게 작성(UPSERT).

CDB(MySQL) UPSERT

MySQL 8.0 기준. writer가 받은 레코드를 INSERT ... ON DUPLICATE KEY UPDATE로 넣는다.

INSERT INTO companies
    (biz_no, name, address, capital, updated_at)
VALUES
    (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
    name       = VALUES(name),
    address    = VALUES(address),
    capital    = VALUES(capital),
    updated_at = VALUES(updated_at);

Batch INSERT로 1 statement에 100~500건씩 묶으면 single-row INSERT보다 5~10배 빠르다. writer 람다에서 Kafka 메시지 수신 batch size를 200으로 설정해 한 번에 몰아 쓰는 구조.

MySQL 8.0부터는 INSERT ... ON DUPLICATE KEY UPDATEVALUES() 문법이 deprecated. 대신 alias를 쓰는 새 문법이 권장된다.

INSERT INTO companies (biz_no, name, ...)
VALUES (%s, %s, ...) AS new
ON DUPLICATE KEY UPDATE
    name = new.name,
    ...;

레이트 리밋과 재시도

공공 API 쪽에 쿼터 한도가 빡빡하다. 초당 10 req 수준. fetcher를 단순 병렬로 띄우면 금방 429가 쏟아진다. 구현에서 쓴 세 가지.

  1. Semaphore로 자체 rate limit. asyncio로 동시 호출 수 제한.
  2. 지수 백오프 재시도. 429/5xx 응답이면 1s → 2s → 4s → 8s.
  3. Retry 한도 넘으면 DLQ(dead letter queue)로 보냄. 별도 Kafka 토픽. 수동 확인 후 재처리.
import httpx, asyncio, random

async def fetch_with_retry(client, url, max_retries=5):
    for attempt in range(max_retries):
        try:
            r = await client.get(url, timeout=15)
            if r.status_code == 200:
                return r.json()
            if r.status_code in (429, 500, 502, 503, 504):
                sleep = (2 ** attempt) + random.random()
                await asyncio.sleep(sleep)
                continue
            r.raise_for_status()
        except httpx.ReadTimeout:
            await asyncio.sleep(2 ** attempt)
    raise RuntimeError(f"fetch failed: {url}")

observability

Tencent Cloud Monitor + CLS(Cloud Log Service)로 로깅. SCF 함수마다 로그 그룹이 자동 생성된다. 운영에서 관심있는 메트릭:

  • fetcher invocations / sec, error rate
  • Kafka consumer lag (raw_records 토픽) - writer가 밀리고 있는지의 직접 지표
  • CDB slow queries (writer UPSERT가 index 없이 FULL SCAN 되면 장애)
  • DLQ 증가 추세 (수동 점검 필요 신호)

IAM 관점

SCF 함수마다 최소권한 IAM role 분리. fetcher는 outbound 인터넷 접근 + Kafka produce만, writer는 Kafka consume + CDB write만. scheduler는 SCF invoke만. 장애 격리와 보안 양쪽에 도움.

운영 중 만난 장애들

  1. Kafka partition rebalance 중 writer가 중복 commit. at-least-once 설정이라 당연한 결과지만 UPSERT가 idempotent라 DB는 멀쩡. 한동안 동일 레코드가 갱신되는 걸 보고 처음엔 당황.
  2. SCF Kafka trigger가 invocation 실패 반복 시 자동 재시도. 불변 에러(예: 메시지 포맷 오류)는 재시도 의미 없음. 이런 건 try/except로 직접 잡아서 DLQ로.
  3. CDB MySQL의 max_connections 초과. writer가 너무 많이 뜨면서 각자 커넥션 풀 들고 있다가 DB 끊김. writer 람다 동시성 상한을 Kafka 파티션 수에 맞춰 고정.
  4. SCF 배포 후 콜드 스타트 폭주. 배포 순간 기존 인스턴스가 전부 drain되고 새 인스턴스가 부트. 트래픽이 높은 시간엔 배포 시점에 초 단위로 타임아웃이 난다. 가중치 기반 canary 배포로 완화.

비용 메모

CKafka가 전체 비용의 절반 이상을 차지했다. 소규모에서는 managed Kafka 대신 "단순 SQS/CMQ" 또는 "SCF → SCF direct invoke"로 풀 수 있는 경우도 있다. 이번 요구는 멀티 consumer + 재생 가능성이 있어서 Kafka가 맞았지만, 모든 경우에 Kafka가 정답은 아니다.

결론

Serverless + Kafka + managed MySQL의 조합은 AWS에서 수없이 검증된 패턴이지만, 텐센트 위에서도 거의 동일한 설계로 돌아간다. 플랫폼 특유의 리전 분리, 콜드 스타트, 쿼터만 챙기면 된다. 국내 API들과는 비교 안 될 정도로 많은 양의 데이터를 다뤄야 할 때 텐센트(중국본토 내) SCF의 실행 비용이 AWS보다 낮아서 예산 관점에서도 나쁘지 않다. 3개월 운영하면서 수천만 건 수집했는데 큰 문제는 없었다. 다음 단계는 raw → normalized 변환 파이프라인을 Flink on CKafka로 옮기는 것. 다음 글에서 다루기로.