중국 지사 쪽에서 현지 공공 데이터 수집 파이프라인을 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" 구조를 고려했는데, 두 가지 이유로 버렸다.
- API 호출 latency가 들쭉날쭉이라 SCF 실행시간이 튄다. 대량 수집 시 몇 분간 한 함수가 도는데, SCF는 실행시간 상한과 메모리 제한이 있다(기본 60초, 최대 900초). 수집 중 timeout 나면 부분 반영 상태가 된다.
- 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 UPDATE의 VALUES() 문법이 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가 쏟아진다. 구현에서 쓴 세 가지.
- Semaphore로 자체 rate limit. asyncio로 동시 호출 수 제한.
- 지수 백오프 재시도. 429/5xx 응답이면 1s → 2s → 4s → 8s.
- 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만. 장애 격리와 보안 양쪽에 도움.
운영 중 만난 장애들
- Kafka partition rebalance 중 writer가 중복 commit. at-least-once 설정이라 당연한 결과지만 UPSERT가 idempotent라 DB는 멀쩡. 한동안 동일 레코드가 갱신되는 걸 보고 처음엔 당황.
- SCF Kafka trigger가 invocation 실패 반복 시 자동 재시도. 불변 에러(예: 메시지 포맷 오류)는 재시도 의미 없음. 이런 건 try/except로 직접 잡아서 DLQ로.
- CDB MySQL의 max_connections 초과. writer가 너무 많이 뜨면서 각자 커넥션 풀 들고 있다가 DB 끊김. writer 람다 동시성 상한을 Kafka 파티션 수에 맞춰 고정.
- 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로 옮기는 것. 다음 글에서 다루기로.