본문 바로가기
삵
Cloud Computing & MSA

Debezium로 PostgreSQL CDC 스트리밍하기

후후아빠·2026년 10월 23일·조회 0

운영 중인 PostgreSQL의 데이터가 바뀔 때마다 그 변경분을 다른 서비스로 흘려보내야 하는 요구는 자주 생긴다. 검색 인덱스 갱신, 캐시 무효화, 데이터 웨어하우스 적재, 마이크로서비스 간 이벤트 발행 같은 것들이다. 예전에는 애플리케이션 코드에서 저장 로직과 함께 메시지를 같이 발행하다가 둘 중 하나만 성공하는 사고를 겪곤 했다. 그래서 요즘은 DB 변경 로그 자체를 원천으로 삼는 CDC 쪽으로 넘어왔다.

결론부터 말하자면, Debezium PostgreSQL 커넥터를 Kafka Connect 위에 띄우고 wal_level=logical로 둔 PostgreSQL에 논리 복제 슬롯을 하나 물리면, 테이블의 INSERT/UPDATE/DELETE가 거의 실시간으로 Kafka 토픽에 들어온다. 핵심 함정은 세 군데다. plugin.name을 생략하면 기본값 decoderbufs로 떨어진다는 점, 복제 슬롯이 WAL을 붙잡아 디스크를 채운다는 점, 그리고 DDL은 복제되지 않는다는 점이다.

1. CDC와 논리적 복제 슬롯이 무엇인가

CDC(Change Data Capture)는 데이터베이스에서 바뀐 행을 잡아내 다른 곳으로 전달하는 방식이다. PostgreSQL에서 이걸 가능하게 하는 토대가 WAL과 논리적 디코딩이다.

WAL(Write-Ahead Log)은 PostgreSQL이 데이터 파일을 건드리기 전에 변경 내용을 먼저 기록해 두는 장애 복구용 로그다. 논리적 디코딩은 이 WAL을 읽어 '어느 테이블의 어떤 행이 어떻게 바뀌었는지'를 사람이 이해할 수 있는 형태로 풀어 주는 기능이다.

논리 복제 슬롯은 그 디코딩 결과를 소비하는 구독자(여기서는 Debezium)가 어디까지 읽었는지를 서버가 기억하는 장치다. 슬롯은 소비자가 아직 못 읽은 WAL을 서버가 지우지 못하게 붙잡아 둔다. 이 덕분에 커넥터가 잠깐 죽었다 살아나도 끊긴 지점부터 이어 읽을 수 있다. 반대로 슬롯을 만들어 놓고 소비자가 사라지면 WAL이 계속 쌓여 디스크를 채운다. 뒤에서 다시 다룬다.

2. PostgreSQL 쪽 준비

먼저 서버가 논리적 디코딩을 할 수 있어야 한다. postgresql.conf에서 wal_level을 logical로 올린다. 이 값은 재시작이 필요하다.

wal_level = logical
max_wal_senders = 10
max_replication_slots = 10

적용 후 확인한다.

postgres=# SHOW wal_level;
 wal_level
-----------
 logical
(1 row)

Debezium이 접속할 전용 계정을 만든다. 논리 복제를 하려면 REPLICATION 권한이 필요하고, 대상 테이블에 SELECT, 그리고 퍼블리케이션을 자동으로 만들게 하려면 테이블 소유 혹은 그에 준하는 권한이 있어야 한다.

CREATE ROLE debezium WITH REPLICATION LOGIN PASSWORD 'change_me';
GRANT CONNECT ON DATABASE sarc TO debezium;
GRANT USAGE ON SCHEMA public TO debezium;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO debezium;

여기서 한 번 걸린다. UPDATE와 DELETE 이벤트에 '바뀌기 전 값(before 이미지)'이 필요하면 테이블의 REPLICA IDENTITY를 손봐야 한다. 기본값에서는 변경 전 행 전체가 아니라 기본키만 들어오거나 before가 비는 경우가 생긴다. 전체 before 이미지가 필요하면 테이블마다 다음을 적용한다.

ALTER TABLE public.orders REPLICA IDENTITY FULL;

FULL은 WAL에 기록되는 양을 늘리므로, 변경이 잦은 큰 테이블에 전부 걸면 WAL이 부푼다. before 이미지가 실제로 필요한 테이블에만 선택적으로 적용하는 쪽을 권한다.

3. 디코딩 플러그인 선택과 plugin.name 함정

Debezium은 WAL을 두 가지 플러그인으로 디코딩할 수 있다. pgoutput은 PostgreSQL 10 이상에 내장되어 논리 복제에 쓰이는 표준 출력 플러그인으로, 서버에 따로 설치할 게 없다. decoderbufs는 Debezium 커뮤니티가 관리하는 Protobuf 기반 플러그인으로, 서버에 확장을 별도로 설치해야 동작한다.

여기가 반려 사유가 됐던 지점이다. Debezium 커넥터의 plugin.name 기본값은 decoderbufs다. 즉 이 속성을 아예 생략하면 커넥터는 pgoutput이 아니라 decoderbufs로 접속을 시도한다. 서버에 protobuf 확장이 설치돼 있지 않으면 슬롯 생성 단계에서 기동이 실패한다.

ERROR: could not access file "decoderbufs": No such file or directory

특별한 이유가 없으면 pgoutput을 명시적으로 지정하는 쪽을 권한다. 내장 플러그인이라 운영 부담이 적다. 다만 기본값이 아니므로 반드시 plugin.name을 직접 적어야 한다.

4. 커넥터 설정

Kafka Connect가 떠 있다고 가정하고, REST로 커넥터를 등록한다. 아래는 pgoutput을 쓰는 최소 설정이다.

{
  "name": "sarc-postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "10.0.0.21",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "change_me",
    "database.dbname": "sarc",
    "topic.prefix": "sarc",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_sarc",
    "publication.name": "dbz_sarc",
    "publication.autocreate.mode": "filtered",
    "table.include.list": "public.orders,public.members",
    "snapshot.mode": "initial"
  }
}

등록은 이렇게 한다.

$ curl -s -X POST http://localhost:8083/connectors \
    -H 'Content-Type: application/json' \
    -d @sarc-connector.json

$ curl -s http://localhost:8083/connectors/sarc-postgres-connector/status | jq .connector.state
"RUNNING"

몇 가지 속성을 끊어서 본다.

slot.name

복제 슬롯 이름이다. 기본값은 debezium이다. 한 서버에 커넥터를 여러 개 띄울 거라면 슬롯 이름이 겹치지 않게 반드시 다르게 줘야 한다. 같은 이름을 쓰면 한쪽만 슬롯을 점유하고 나머지는 붙지 못한다.

publication.name과 publication.autocreate.mode

pgoutput은 '퍼블리케이션'이라는 PostgreSQL 객체로 어떤 테이블을 복제할지 정한다. publication.name 기본값은 dbz_publication이다. publication.autocreate.mode를 filtered로 두면 table.include.list에 적은 테이블만 담은 퍼블리케이션을 커넥터가 알아서 만든다. all_tables는 모든 테이블을 대상으로 만들려 하는데, 이때 커넥터 계정에 상위 권한이 필요하다. 권한을 최소로 주고 싶으면 filtered가 맞다.

topic.prefix

생성될 토픽 이름의 접두사다. 위 설정이면 sarc.public.orders 같은 토픽이 테이블마다 하나씩 생긴다.

5. 초기 스냅샷의 동작과 함정

snapshot.mode 기본값은 initial이다. 커넥터가 처음 뜨면서 저장된 오프셋이 없으면, 대상 테이블의 현재 전체 데이터를 한 번 읽어 토픽에 쏟아붓고(스냅샷), 그 뒤부터 WAL을 따라 실시간 변경을 흘린다. 덕분에 소비자는 '현재 상태 + 이후 변경'을 끊김 없이 받는다.

함정은 큰 테이블이다. 수천만 행짜리 테이블을 initial로 잡으면 스냅샷이 오래 걸린다. 이 사이에도 WAL은 계속 쌓이고 슬롯이 그걸 붙잡는다. 스냅샷이 끝날 때까지 디스크가 버티는지 미리 봐야 한다.

스냅샷 없이 지금 이후 변경만 받고 싶으면 snapshot.mode를 no_data(구버전의 never)로 둔다. 다만 이러면 소비자는 과거 데이터를 전혀 못 받으니, 이미 적재가 끝난 대상에만 쓴다. 큰 테이블의 스냅샷 부하를 쪼개고 싶으면 증분 스냅샷(incremental) 기능을 검토한다.

스냅샷을 다시 돌리겠다고 커넥터만 지웠다가 다시 만들면 소용없을 때가 있다. 오프셋이 Kafka Connect의 오프셋 토픽에 남아 있으면 커넥터는 스냅샷을 건너뛰고 이어 읽는다. 처음부터 다시 받으려면 오프셋과 슬롯을 함께 정리해야 한다.

6. 스키마 변경 다루기

논리적 디코딩은 DDL을 복제하지 않는다. ALTER TABLE ... ADD COLUMN 같은 구문 자체는 WAL의 논리 스트림에 이벤트로 오지 않는다. 그래서 Debezium은 DDL 변경 이벤트를 소비자에게 따로 내보내지 못한다.

대신 커넥터는 바뀐 구조의 행 이벤트가 처음 들어올 때 스키마를 갱신한다. 문제는 순서다. 컬럼을 추가한 직후 그 테이블에 쓰기가 들어오면 새 구조로 인식하지만, 퍼블리케이션에 테이블이 포함되는 방식과 타이밍에 따라 새 컬럼이 이벤트에 늦게 반영될 수 있다.

운영에서 안전하게 가려면 컬럼 추가는 nullable 또는 기본값이 있는 형태로 하고, 컬럼 삭제나 타입 변경처럼 호환이 깨지는 변경은 소비자 쪽 역직렬화와 함께 배포 순서를 맞춰야 한다. Avro와 Schema Registry를 함께 쓴다면 호환성 규칙으로 이 사고를 상당 부분 막을 수 있다.

참고로 Debezium PostgreSQL 커넥터는 UTF-8 인코딩 데이터베이스만 지원한다. 구형 인코딩을 쓰는 DB라면 이 전제부터 확인한다.

7. 복제 슬롯 모니터링

CDC를 띄워 놓고 가장 자주 사고가 나는 지점이 슬롯이다. 커넥터가 죽거나 Kafka Connect가 멈추면 슬롯은 WAL을 계속 붙잡고, PostgreSQL의 pg_wal 디렉터리가 부풀어 끝내 디스크가 찬다. 주기적으로 슬롯 상태를 봐야 한다.

postgres=# SELECT slot_name, active, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
   slot_name    | active | restart_lsn | retained
----------------+--------+-------------+----------
 debezium_sarc  | t      | 3C/A80001F0 | 12 MB
(1 row)

restart_lsn은 서버가 이 슬롯을 위해 보관해야 하는 WAL의 가장 오래된 지점이다. retained 값이 계속 커지면 소비가 밀리고 있다는 뜻이다. active가 f인데 값이 커지면 커넥터가 떨어진 채 슬롯만 남아 WAL을 잡고 있는 상황이다.

쓰지 않을 커넥터를 내릴 때는 슬롯도 반드시 지운다. 안 그러면 조용히 디스크를 먹는다.

postgres=# SELECT pg_drop_replication_slot('debezium_sarc');

보관량이 무한정 늘지 않게 상한을 두고 싶으면 PostgreSQL 13 이상에서 max_slot_wal_keep_size를 설정한다. 상한을 넘으면 서버가 슬롯을 무효화하는데, 이 경우 그 슬롯을 쓰던 커넥터는 다시 스냅샷부터 가야 할 수 있으니 트레이드오프를 이해하고 쓴다.

8. 변경 이벤트 확인

커넥터가 뜨면 테이블에 한 건 바꿔 보고 토픽을 들여다본다.

$ kafka-console-consumer.sh --bootstrap-server localhost:9092 \
    --topic sarc.public.orders --from-beginning --max-messages 1 | jq '.payload | {op, before, after}'
{
  "op": "c",
  "before": null,
  "after": { "id": 1024, "status": "PAID" }
}

op는 c(생성), u(수정), d(삭제), r(스냅샷 읽기)로 온다. UPDATE인데 before가 비어 있다면 2절의 REPLICA IDENTITY를 다시 본다.

9. 정리

Debezium PostgreSQL CDC를 안정적으로 돌리는 핵심은 네 가지다. wal_level=logical로 서버를 준비하고, plugin.name을 pgoutput으로 명시해(기본값 decoderbufs에 당하지 말고) 내장 플러그인을 쓰고, 초기 스냅샷의 크기와 소요 시간을 미리 가늠하고, 복제 슬롯의 WAL 보관량을 상시 감시하는 것이다. DDL은 복제되지 않는다는 전제 아래 스키마 변경은 호환 가능한 방향과 배포 순서로 관리한다.

자주 묻는 질문

plugin.name을 생략하면 어떻게 되나?

Debezium PostgreSQL 커넥터의 plugin.name 기본값은 decoderbufs다. 생략하면 pgoutput이 아니라 decoderbufs로 접속을 시도한다. 서버에 protobuf 기반 decoderbufs 확장이 설치돼 있지 않으면 슬롯 생성 단계에서 기동이 실패한다. 내장 플러그인을 쓰려면 plugin.name을 pgoutput으로 명시해야 한다.

pgoutput과 decoderbufs 중 무엇을 써야 하나?

특별한 이유가 없으면 pgoutput을 권한다. PostgreSQL 10 이상에 내장돼 서버에 따로 설치할 게 없고 운영 부담이 적다. decoderbufs는 Protobuf 기반으로 성능 이점이 있을 수 있으나 서버에 확장을 별도로 설치해야 한다.

커넥터를 내렸는데 디스크가 계속 차는 이유는?

논리 복제 슬롯이 남아 WAL을 붙잡고 있기 때문이다. 슬롯은 소비자가 아직 못 읽은 WAL을 서버가 지우지 못하게 막는다. 쓰지 않을 커넥터는 pg_drop_replication_slot으로 슬롯을 지우고, pg_replication_slots에서 active와 restart_lsn을 상시 감시한다.

UPDATE 이벤트의 before 값이 비어 있다.

테이블의 REPLICA IDENTITY 때문이다. 변경 전 행 전체가 필요하면 해당 테이블에 ALTER TABLE ... REPLICA IDENTITY FULL을 적용한다. FULL은 WAL 기록량을 늘리므로 before 이미지가 실제로 필요한 테이블에만 선택적으로 건다.

ALTER TABLE 같은 스키마 변경도 Kafka로 전달되나?

아니다. PostgreSQL 논리적 디코딩은 DDL을 복제하지 않으므로 Debezium도 DDL 변경 이벤트를 따로 내보내지 못한다. 커넥터는 바뀐 구조의 행 이벤트가 들어올 때 스키마를 갱신한다. 컬럼 추가는 nullable이나 기본값이 있는 형태로 하고, 호환이 깨지는 변경은 소비자 배포 순서를 맞춘다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.