얼마 전 회사에서 데이터 동기화 요구사항을 하나 만났습니다. 발단은 프런트엔드가 로그인 처리에서 사용자의 부서(dept) 이름을 도무지 제대로 못 가져온다는 것. 그래서 DBA가 해법을 하나 냈습니다. 아예 가장 아래층부터 손대서, 데이터 파이프라인으로 데이터베이스에 있는 기존 member 레코드를 뽑아 sync하고, 그 김에 빠진 dept 정보까지 채워 넣자는 것. 이 요구사항을 위해 우리는 로컬에 PostHog Data Pipeline을 한 벌 세우기로 했습니다.

다만 이 시스템은 좀 신기한 환경에서 돕니다. 기반 서비스가 전부 안쪽에 있고, 가장 바깥에는 를 한 겹 씌워 트래픽을 라우팅합니다. 왜 이렇게 여러 겹을 쌓는지는 솔직히 저도 잘 모릅니다. 아마 회사의 대외 주소가 ISP가 준 고정 IP 하나뿐이라, 그 하나의 물리 IP 위에서 DNS 도메인별로 다른 서비스로 분배하려고 이 Caddy를 위에 덧발랐겠죠.

아무튼 이 DinD에 Caddy를 얹은 환경에서 를 포함한 마이크로서비스 한 벌을 돌린다—공식 Docker Compose를 베껴서, 띄우고, DB만 물리면 끝이겠지 하고 얕봤습니다. 그런데 거기서부터가 삽질 지옥의 시작이었습니다.

ODBC 드라이버와 인증서 문제#

모든 것의 시작은 PostHog UI에서 데이터베이스 동기화 작업이 차갑게 「연결 오류」 한마디만 뱉은 것. 상세 로그도, 스택 트레이스도 없었습니다.

curl과 Python의 pymssql로 컨테이너에 파고들어 테스트하니 TCP도 직접 연결도 아주 멀쩡. 그런데 PostHog가 내부에서 의존하는 로 바꾸는 순간 file not found가 날아왔습니다.

컨테이너 안 설정(/etc/odbcinst.ini)을 들여다보니 진상이 드러났습니다. PostHog의 Image는 기본으로 을 깔아두는데, Microsoft는 Driver 18에서 꽤 공격적인 변경을 했습니다. 기본으로 연결 암호화를 강제하는 것(Encrypt=yes). 우리처럼 신뢰된 TLS 인증서를 설정하지 않은 로컬 개발 환경에서는 연결이 당연히 곧장 거부됩니다.

TIP

해법은 직구. UI의 고급 연결 문자열에 TrustServerCertificate=yes를 하나 쑤셔 넣어 드라이버가 서버 인증서를 억지로 믿게 하면, 연결이 순식간에 뚫립니다. 이건 Driver 17에서 18로 올릴 때 가장 잘 밟는 함정이기도 합니다—어제까지 멀쩡하던 같은 설정이, 드라이버만 올리면 연결이 안 되죠.

마이크로서비스 속에서 길을 잃은 네트워크 요청#

데이터베이스는 붙었는데, "Reload"를 눌러도 작업이 감감무소식이었습니다.

Django API(Web 컨테이너)와 Temporal Server 사이의 통신이 끊긴 게 아닐까 의심했습니다. 안에서 ping 한 방—ping: temporal: Name or service not known. Web 컨테이너는 애초에 temporal이 누구인지 모릅니다. 이건 Docker Compose 세계에서 치명적인데, 환경 변수가 제대로 주입되지 않으면 앱은 그저 우직하게 localhost를 찾아가기 때문입니다.

.env와 설정을 열어 보니 역시 뭔가 빠져 있었습니다. 이 두 줄을 넣고 재시작:

env
TEMPORAL_HOST=temporal
TEMPORAL_PORT=7233

다시 curl temporal:7233—뚫렸습니다. 로그 속 작업도 무사히 제출되어, 상태가 드디어 Running으로 바뀌었습니다.

사라진 Worker, 그리고 하드코어 디버깅 실록#

상태는 Running이 됐는데, 작업은 블랙홀에 빠진 듯 전혀 진행되지 않았습니다. UI에는 Member / Dept 동기화가 한 줄, 멈춰 있거나 대놓고 Failed. 클릭해 들어가 봐도 알 수 없는 TypeError 하나만 뜰 뿐, 어디서 죽었는지 도무지 감이 안 왔습니다.

두루뭉술한 오류에서는 아무것도 못 캐내니, 곧장 를 뒤지러 가서 어느 단계가 막혔는지 확인하기로 했습니다. 먼저 현재 Running 상태인 워크플로를 나열:

bash
docker exec -it deploy-temporal-admin-tools-1 temporal workflow list \
  --address temporal:7233 --query "ExecutionStatus='Running'"

출력된 표에서 external-data-job 작업을 찾아 그 WorkflowId를 복사하고, 상세 오류와 실행 이력을 봤습니다:

text
temporal workflow show --address temporal:7233 -w external-data-job(...)

  ID          Time                    Type
   1  2026-02-23T09:56:10Z  WorkflowExecutionStarted
   2  2026-02-23T09:56:10Z  WorkflowTaskScheduled

이 두 줄의 로그를 읽어낸 것이 사건 해결의 열쇠였습니다:

  • Started: Temporal 서버가 external-data-job(즉 DB 파이프라인) 시작 요청을 제대로 받았다.
  • Scheduled: Temporal이 그 작업을 「태스크 큐(Task Queue)」에 넣고, Worker가 가져가기를 기다리는 중이다.

문제는 바로 여기: 그 뒤가 없다. 정상이라면 다음에 곧바로 WorkflowTaskStarted(Worker가 처리를 시작함)가 이어져야 합니다. 이게 말해 주는 건 하나—이 작업을 가져갈 Worker가 애초에 없다.

정확히 어느 큐에서 기다리는지 확인하려고 -o json을 붙이고 taskqueue로 걸렀습니다:

bash
temporal workflow show ... -o json | grep -i "taskqueue"

# 출력 결과:
# TaskQueue:{Name:data-warehouse-task-queue, Kind:Normal}

IMPORTANT

진상 규명! 작업은 data-warehouse-task-queue로 보내졌는데, 이 환경의 Worker 시작 로그를 보니 듣고 있는 건 general-purpose-task-queue뿐이었습니다. 알고 보니 이 구성(공식 hobby 개인판 기본 파일로 보이는)은 데이터 웨어하우스 전용 Worker가 아예 하나 빠져 있던 것—작업은 아무도 듣지 않는 큐에 떨어지니, 당연히 언제까지나 Scheduled에 멈춰 있었죠.

이 「아무도 안 받는 큐」라는 교착을 그림으로 그리면 한눈에 들어옵니다:

빠진 게 뭔지 알았으니, 이제 직접 채우면 됩니다. 기존 Worker 컨테이너에 들어가 시작 스크립트 설명을 봤습니다:

bash
docker exec -it deploy-temporal-django-worker-1 \
  python manage.py start_temporal_worker --help

#  --task-queue TASK_QUEUE
#                        Task queue to service

시작 명령에 --task-queue data-warehouse-task-queue만 붙이면 됩니다.

곁가지: 남의 Config를 고치는 서러움#

해법은 나왔고, 이제 docker-compose.yml을 고쳐 이 전용 Worker를 추가하기만 하면 됩니다. 그런데 여기서 한 번 투덜대야겠습니다.

아무튼 이 새 서비스 정의를 docker-compose.yml에 넣었습니다:

yaml
temporal-data-warehouse-worker:
    extends:
        file: docker-compose.base.yml
        service: temporal-django-worker
    command: python manage.py start_temporal_worker --task-queue data-warehouse-task-queue
    environment:
        SITE_URL: https://$DOMAIN
        # 아래 몇 개가 나중에 채운 큰 함정:
        ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
        CDP_REDIS_HOST: redis
        REDIS_URL: redis://posthog-redis:6379
    depends_on:
        - db
        - redis
        - clickhouse
        - kafka
        - temporal

이 전용 Worker를 띄우자, Pipeline이 드디어 진짜로 돌기 시작했습니다.

위장의 예술: MinIO와 S3 자격 증명#

앞선 일련의 씨름 끝에 드디어 member와 dept 데이터를 뽑을 수 있게 됐습니다. 그런데 마지막 단계, MinIO 데이터 레이크에 쓰는 대목에서 또 credential provider was not enabledNoSuchBucket이 떴습니다.

PostHog는 내부에서 라이브러리로 데이터를 쓰는데, 이 녀석은 AWS 자격 증명만 인정하는 고집쟁이입니다. 우리는 오픈소스 MinIO를 쓰니, Worker의 환경 변수에서 약간의 「위장술」을 부려야 합니다—이름만 AWS에 맞추고 실체는 로컬 MinIO를 가리키는 가짜 자격 증명을 꽂아 넣는 것:

yaml
environment:
  - AWS_ACCESS_KEY_ID=minioadmin
  - AWS_SECRET_ACCESS_KEY=minioadmin
  - AWS_REGION=us-east-1
  - AWS_S3_ENDPOINT=http://minio:9000

이어서 간단한 Boto3 스크립트로 컨테이너에 들어가, 빠진 data-warehouse Bucket을 손으로 만듭니다:

python
import boto3

s3 = boto3.client(
    's3',
    endpoint_url='http://minio:9000',
    aws_access_key_id='minioadmin',
    aws_secret_access_key='minioadmin'
)

s3.create_bucket(Bucket="data-warehouse")

NOTE

여기서 핵심은 자격 증명이 진짜냐가 아니라 변수 이름이 맞아떨어지느냐입니다. deltalake는 그저 AWS 관례대로 AWS_* 환경 변수를 융통성 없이 찾을 뿐이고, 찾기만 하면 순순히 우리가 가리킨 endpoint(로컬 MinIO)에 데이터를 씁니다. 「위장」한 것은 정체가 아니라 인터페이스인 셈이죠.

MinIO 관리 화면에 드디어 효율적인 Parquet 파일이 한 줄 또 한 줄 돋아나는 걸 보며, 데이터는 마침내 레이크에 자리 잡았습니다.

곁가지: 로그를 노려보며 인생을 의심한 한 시간#

모든 설정을 채우고 Bucket도 다 만든 뒤, 자신만만하게 UI에서 "Sync"를 눌렀습니다.

작업은 무사히 시작됐고, Temporal Worker는 미친 듯이 로그를 뱉기 시작했습니다. 처음엔 데이터가 한 줄 한 줄 긁혀 오는 걸 보는 게 힐링이기도 했지만, 10분, 20분, 30분… 로그는 끝없이 스크롤되고 상태는 여전히 Running.

바깥에 DinD와 Caddy가 감싸여 네트워크가 복잡한 만큼, 저는 어딘가에서 TCP 타임아웃이 걸린 게 아닐까, 아니면 Worker가 조용히 나서 어느 루프에 갇힌 게 아닐까 의심했습니다. 심지어 다른 터미널을 하나 더 열어 Docker daemon 전체를 재시작할 준비까지 했죠.

그런데 진상은 내내 docker stats에 적혀 있었습니다:

그렇게 한 시간 가까이 조마조마하며 터미널을 노려본 끝에, 갑자기 한 줄이 튀어나왔습니다: Sync completed successfully. 그제야 퍼뜩 깨달았죠. 아 참, 이건 Full Sync구나. 회사가 몇 년이나 쌓아 온 member와 dept 이력이 워낙 방대해서, Worker는 그저 묵묵히 그 데이터를 청크로 자르고(chunking), 형식을 바꿔 MinIO에 쓰고 있었을 뿐. 완전히 제가 저를 겁준 셈이었습니다.

핵심 해부: 이 구조에서 데이터는 결국 어떻게 흐르나#

DinD와 Caddy에 감싸인 이 데이터 흐름은 확실히 좀 빙 돕니다. PostHog의 Data Warehouse 설계 철학은, 외부 데이터를 자기 관계형 DB에 단순히 「복제」하는 게 아니라, 현대적인 아키텍처를 택합니다. member와 dept 동기화를 트리거하면, 데이터의 실제 경로는 이렇습니다:

  1. 추출·변환(Extract & Transform): Temporal DW Worker가 스케줄을 받으면 ODBC 연결로 외부 데이터베이스에 들어가, 방대한 member와 dept 데이터를 뽑아, 메모리에서 열 지향()의 Parquet 형식으로 변환한다.
  2. 상태 추적(State Tracking): 한 시간짜리 이런 작업이 중단돼도 이어서 할 수 있도록, Worker는 끊임없이 진행과 커서를 CDP_REDIS_HOST가 가리키는 Redis에 쓴다.
  3. 데이터 레이크에 쓰기(Load to Data Lake): 변환된 데이터는 Postgres로 가지 않고, 다량의 .parquet 파일로 묶여, 방금 위장해 둔 S3 API를 통해 곧장 MinIO의 data-warehouse Bucket에 꽂힌다.
  4. 쿼리 엔진 등장(Query Engine): 진짜 대단한 건 여기—PostHog 내부의 고성능 분석 엔진 가 MinIO 안의 이 Parquet 파일들을 그대로 외부 테이블(External Tables)로 매핑한다.
  5. 프런트 표시(UI & Dashboard): 사용자가 PostHog UI에서 차트를 뽑거나 dept로 사용자 이벤트를 거르려 할 때, ClickHouse는 엄청난 속도로 로컬 이벤트 데이터(Events)와 MinIO에 막 동기화한 member/dept 데이터를 JOIN해 결과를 Web API로 돌려준다.

가장 바깥의 Caddy, 그 애증의 DinD, 그리고 그 안의 데이터 레이크 파이프라인을 한 장에 다 그리면 대략 이렇습니다:

주말을 통째로 씨름하며 얻은 가장 큰 깨달음은 이겁니다. 마이크로서비스의 「느슨한 결합」은 정말이지 양날의 검이라는 것. 거기에 회사 같은 Caddy와 DinD를 얹은 복잡한 인프라가 더해지면 디버깅 비용은 일직선으로 치솟습니다—단 하나의 환경 변수 누락, 하나의 하부 드라이버 기본값 상향, 아무도 듣지 않는 큐 하나, 그중 나사 하나만 풀려도 파이프라인 전체가 변변한 오류 하나 안 뱉고 조용히 멈춰 버립니다.