검색어를 입력하세요.

PostgreSQL 논리복제와 FDW로 운영 서버를 두 번 죽여본 개발자의 실시간 데이터 동기화 생존기

간지뽕빨리턴님 2026. 1. 24. 21:49
반응형

현기증 난단 말이에요... 분산 환경에서 살아남기 위한 처절한 기록


서론 : 서버가 죽었다, 그것도 두 번이나

개발자의 심장을 가장 빠르게 뛰게 하는 것은 무엇일까? 연봉 협상? 코드 리뷰? 갑자기 날아오는 기획 변경? 아니다. 단연코 "서버가 죽었습니다"라는 알림이다.

 

나는 최근 이 경험을 두 번이나 했다. 그것도 같은 목적을 위해 다른 기술을 사용하다가. PostgreSQL의 논리복제(Logical Replication)로 한 번, FDW(Foreign Data Wrapper)로 또 한 번. 같은 실수를 두 번 하면 그건 실수가 아니라 실력이라던데... 그래, 인정한다. 이건 내 실력이다. 😇

🧑‍💻 "어? 서버 왜 이렇게 느려요?"
👨‍💻 "잠깐만... 뭐지?"
🖥️ Connection refused
🧑‍💻 "..."
👨‍💻 "..."
🧑‍💻👨‍💻 "현기증 난단 말이에요!!!"

이 글은 화려한 성공 스토리가 아니다. 처절한 실패와 현재 진행 중인 도전, 그리고 앞으로 어떻게 이 문제를 해결해 나갈 것인지에 대한 솔직한 기록이다.

 

이전에 논리복제와 FDW에 대한 글을 썼었는데, 막상 실무에서 적용하다 보니 글로 정리한 것과 현실은 꽤나 달랐다. 블로그에 "이렇게 하면 됩니다~"라고 써놓고 정작 내가 그걸로 서버를 죽이다니. 아이러니하다.

 

비슷한 고민을 하는 개발자들에게 "아, 나만 삽질하는 게 아니구나"라는 위안과 함께, 실질적인 도움이 되길 바란다. 적어도 나처럼 같은 실수를 두 번 하진 말자.


프로젝트 배경 : 왜 실시간 동기화가 필요했나

우리가 만들고 있는 것

현재 진행 중인 솔루션은 고객 사업장의 계측 데이터를 실시간으로 수집하여 중앙 서버에서 처리하고, 사용자에게 보여주는 시스템이다.

 

쉽게 말하면 IoT 데이터 수집 시스템이다. 공장이나 사업장에 설치된 각종 계측기(온도, 습도, 전력량 등)에서 데이터를 모아 중앙에서 모니터링하고 분석하는 것. 간단해 보이지만, 막상 구현하려니 온갖 문제가 터져 나왔다.

🏭 사업장 A (Gathering PC x2)

수십 개의 계측기에서 1분 단위로 데이터 생성. 두 대의 게더링 PC가 각각 다른 구역의 계측기를 담당하고 있다. 한 대가 죽어도 다른 구역은 살아있어야 하니까.

🏢 사업장 B (Gathering PC x1)

단일 접점에서 지속적인 트래픽 발생. 규모는 작지만 데이터 발생 빈도는 A 사업장과 비슷하다. 작다고 무시하면 안 된다.

시스템 규모와 요구사항

항목 상세 체감 난이도
연결된 게더링 PC 3대 (사업장 A : 2대, 사업장 B : 1대) ⭐⭐
총 계측기 수 약 40~50개 ⭐⭐⭐
데이터 생성 주기 약 1분마다 (계측기당) ⭐⭐⭐
일일 예상 데이터량 약 60,000~70,000건/일 ⭐⭐⭐⭐
핵심 요구사항 실시간성 확보 (지연 1분 이내) ⭐⭐⭐⭐⭐ (지옥)

단순히 "3대 PC 연결"이라고 하면 쉬워 보이지만, 각 PC에 연결된 수십 개의 계측기가 1분마다 데이터를 쏟아내고, 이걸 실시간으로 중앙 서버에 전송해야 한다.

 

여기서 "실시간"이라는 단어가 모든 재앙의 시작이었다. 고객은 "실시간으로 보고 싶어요"라고 했고, 나는 "네, 가능합니다"라고 대답했다. 그때의 나를 때리고 싶다.

"실시간이요? 그냥 DB 복제하면 되는 거 아니에요?"

— 과거의 순진했던 나 (지금 생각하면 눈물이 난다)


시스템 구성 : Tailscale VPN을 활용한 보안 연결

왜 VPN을 사용했나

사업장의 게더링 PC에서 중앙 서버 DB로 직접 연결하려면 공인 IP가 필요하다. 하지만 보안상 DB 포트(5432)를 인터넷에 직접 노출시키는 건... 음, 그건 좀 아니지 않나?

 

PostgreSQL 포트를 공개 인터넷에 열어두는 건 해커들에게 "어서 오세요, 환영합니다. 비밀번호는 postgres입니다" 하는 것과 다를 바 없다. (설마 아직도 기본 비밀번호 쓰는 분은 없겠지...?)

 

그래서 선택한 것이 Tailscale VPN이다.

Tailscale을 선택한 이유

  • Zero-config VPN: 복잡한 설정 없이 쉽게 구축 가능. OpenVPN처럼 인증서 관리하고 config 파일 만들고 할 필요 없다. 설치하고 로그인하면 끝.
  • WireGuard 기반: 빠르고 안정적인 연결. IPSec보다 코드베이스가 작아서 보안 취약점도 적다. 레이턴시도 낮은 편.
  • NAT 통과: 사업장 네트워크 환경이 복잡해도 연결 가능. 방화벽 뒤에서도 잘 동작한다. CGNAT도 뚫는다.
  • 무료 플랜: 개인 사용자는 100대까지 무료. 우리처럼 소규모 구성에서는 무료로 충분하다. 이게 제일 중요하다 💰

전체 네트워크 구성도

                         [ 인터넷 ]
                              │
             ┌────────────────┴────────────────┐
             │                                 │
             ▼                                 ▼
    ┌─────────────────┐              ┌─────────────────┐
    │   사업장 A       │              │   사업장 B       │
    │   (NAT 환경)    │              │   (NAT 환경)    │
    │                 │              │                 │
    │ ┌─────────────┐ │              │ ┌─────────────┐ │
    │ │게더링 PC 1  │ │              │ │게더링 PC 1  │ │
    │ │ Tailscale   │ │              │ │ Tailscale   │ │
    │ │ 100.x.x.10  │ │              │ │ 100.x.x.30  │ │
    │ │ PostgreSQL  │ │              │ │ PostgreSQL  │ │
    │ └─────────────┘ │              │ └─────────────┘ │
    │ ┌─────────────┐ │              └─────────────────┘
    │ │게더링 PC 2  │ │
    │ │ Tailscale   │ │      Tailscale Mesh Network
    │ │ 100.x.x.20  │ │     ━━━━━━━━━━━━━━━━━━━━━━━
    │ │ PostgreSQL  │ │               │
    │ └─────────────┘ │               │
    └─────────────────┘               │
                                      ▼
                            ┌─────────────────┐
                            │   중앙 서버      │
                            │   Tailscale      │
                            │   100.x.x.1     │
                            │                 │
                            │  ┌───────────┐  │
                            │  │PostgreSQL │  │
                            │  │   :5432   │  │
                            │  └───────────┘  │
                            └─────────────────┘

※ 공인 IP 노출 없이 Tailscale 내부 IP(100.x.x.x)로 안전하게 연결
※ 각 게더링 PC에 로컬 PostgreSQL 설치 (데이터 1차 저장)

이렇게 구성하면 공인 IP 없이도 안전하게 DB에 접근할 수 있다. Tailscale 덕분에 마치 같은 사설 네트워크에 있는 것처럼 연결된다.

보안 걱정은 덜었다. Tailscale은 믿을 만하다. 문제는... 보안이 아니라 완전히 다른 곳에서 터졌다.


첫 번째 시도 : 논리복제(Logical Replication)의 배신

왜 논리복제를 선택했나

PostgreSQL의 논리복제(Logical Replication)는 PostgreSQL 10부터 도입된 기능으로, 물리복제와 달리 테이블 단위로 선택적 복제가 가능하다. 전체 DB를 복제할 필요 없이 필요한 테이블만 골라서 복제할 수 있다는 점이 매력적이었다.

 

게다가 Publisher-Subscriber 모델이라 직관적이다. 로컬 DB가 Publisher가 되어 데이터를 발행하고, 중앙 서버가 Subscriber가 되어 구독하면 끝. 심플하지 않은가?

 

이전에 논리복제에 대해 정리한 글도 있다. (이때까지만 해도 희망에 차 있었다...)


👉 PostgreSQL 논리복제 실무 가이드

논리복제의 기본 구조

-- [로컬 게더링 PC] postgresql.conf 설정
-- 이것부터 해야 논리복제가 가능하다
wal_level = logical
max_replication_slots = 4
max_wal_senders = 4

-- 설정 변경 후 PostgreSQL 재시작 필수!
-- sudo systemctl restart postgresql
-- [로컬 게더링 PC] Publication 생성
CREATE PUBLICATION measurement_pub 
FOR TABLE measurement_data, sensor_log;

-- 복제용 사용자 생성 (REPLICATION 권한 필요)
CREATE USER repl_user WITH REPLICATION PASSWORD 'secure_password';
GRANT SELECT ON measurement_data, sensor_log TO repl_user;
-- [중앙 서버] Subscription 생성
-- Tailscale IP(100.x.x.10)로 연결
CREATE SUBSCRIPTION measurement_sub
CONNECTION 'host=100.x.x.10 port=5432 dbname=gathering user=repl_user password=secure_password'
PUBLICATION measurement_pub;

-- 이론상 이렇게 하면 끝...
-- 로컬에 INSERT하면 자동으로 서버에도 INSERT된다
-- 완벽하다! ...이론상은...

처음 며칠은 잘 됐다

설정하고 테스트해보니 진짜 잘 됐다. 로컬 DB에 데이터 넣으면 몇 초 안에 서버에서도 보인다. "오 이거 되는데?" 싶었다. 퇴근길 발걸음이 가벼웠다.

 

그렇게 며칠이 지났다...

⚠️ 결과 : 운영 서버 DB 다운

어느 날 아침, 서버가 점점 느려지더니 결국 DB가 완전히 멈춰버렸다. 다른 서비스들도 같은 DB를 쓰고 있었는데, 전부 먹통이 됐다. 출근하자마자 전화가 빗발쳤다.

논리복제의 함정들

1복제 슬롯(Replication Slot)의 저주

논리복제는 replication slot이라는 것을 사용한다. 이게 뭐냐면, Subscriber가 어디까지 데이터를 받았는지 추적하는 북마크 같은 거다.

 

문제는 Subscriber가 오프라인 상태가 되면 Publisher가 "언젠간 얘가 돌아오겠지"라며 WAL(Write-Ahead Log) 로그를 무한정 보관한다는 것이다. Subscriber가 다시 연결되면 그동안 쌓인 변경사항을 보내줘야 하니까.

 

근데 사업장 네트워크가 불안정하거나, 게더링 PC가 재부팅되거나, VPN 연결이 끊어지면? Subscriber는 오프라인이 되고, WAL 로그는 계속 쌓인다. 하루, 이틀, 사흘... 디스크가 꽉 차면? DB 사망.

-- 복제 슬롯 상태 확인
-- 이걸 자주 확인했어야 했다... 😭
SELECT slot_name, 
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       active
FROM pg_replication_slots;

-- 실제로 내가 본 결과:
-- slot_name       | retained_wal | active
-- ----------------+--------------+--------
-- measurement_sub | 47 GB        | f
--
-- active가 'f'(false)인데 47GB나 쌓여있었다...
-- 서버 디스크 용량이 100GB였는데 절반을 WAL이 먹고 있었던 것

2초기 동기화(Initial Sync)의 공포

Subscription을 생성하면 기본적으로 copy_data = true 옵션이 켜져 있다. 이게 뭐냐면, 기존에 테이블에 있던 데이터를 전부 복사해서 보내겠다는 거다.

 

테스트 환경에서는 데이터가 몇 건 없어서 금방 끝났는데, 운영 환경에서 100만 건 있는 테이블에 이걸 했다가는... 복사하느라 CPU 100%, 네트워크 포화, 다른 쿼리는 전부 대기. 경험담이다.

-- 초기 데이터 복사 없이 Subscription 생성하려면
CREATE SUBSCRIPTION measurement_sub
CONNECTION '...'
PUBLICATION measurement_pub
WITH (copy_data = false);  -- 기존 데이터 복사 안 함

-- 단, 이러면 구독 시작 시점 이후의 데이터만 복제됨
-- 기존 데이터는 별도로 마이그레이션 필요

3충돌 해결 메커니즘의 부재

논리복제는 기본적으로 충돌 해결 메커니즘이 없다. 예를 들어, 같은 Primary Key로 데이터가 양쪽에 INSERT되면? 에러 나고 복제가 완전히 멈춘다.

 

자동 복구? 그런 거 없다. 사람이 직접 충돌 해결하고, 복제 다시 시작해줘야 한다. 그것도 새벽에 서버 죽으면 새벽에 해야 한다.

-- 에러 로그에서 자주 보게 될 메시지
ERROR : duplicate key value violates unique constraint "measurement_data_pkey"
DETAIL : Key (id)=(12345) already exists.
CONTEXT : processing remote data for replication origin "pg_16389" during "INSERT"

-- 이 에러 보면 복제가 멈춘 거다
-- 복제 상태 확인
SELECT * FROM pg_stat_subscription;

-- last_msg_receipt_time이 안 바뀌면 복제가 멈춘 것
-- 야근 확정 😭

"논리복제 쓰면 실시간 동기화 쉽게 되지 않아요?"

— 과거의 나. 지금 보면 참 순진했다.


두 번째 시도 : FDW + 트리거, 되다가 갑자기 죽는 공포

논리복제가 안 되면 FDW다!

논리복제의 처참한 실패 후, 좀 더 제어 가능한 방식을 찾았다. PostgreSQL의 FDW(Foreign Data Wrapper)는 Oracle의 DB Link와 비슷한 개념으로, 외부 데이터베이스를 마치 로컬 테이블처럼 접근할 수 있게 해준다.

 

Oracle에서 SELECT * FROM remote_table@dblink 하는 것처럼, PostgreSQL에서도 외부 DB의 테이블을 로컬처럼 쓸 수 있다. 이전에 정리해둔 글이 있다.


👉 PostgreSQL FDW(Foreign Data Wrapper) 사용법

FDW vs Oracle DB Link 비교

Oracle DB Link 쓰다가 PostgreSQL로 넘어온 분들을 위해 비교표를 만들어봤다.

구분 PostgreSQL FDW Oracle DB Link
설치 방법 CREATE EXTENSION postgres_fdw 내장 기능
외부 테이블 접근 FOREIGN TABLE 생성 필요 테이블명@링크명으로 바로 접근
분산 트랜잭션 (2PC) 기본 미지원 (별도 설정 필요) 지원
쿼리 푸시다운 지원 (WHERE 절 등 원격 실행) 제한적
설정 복잡도 SERVER → USER MAPPING → FOREIGN TABLE 순서 CREATE DATABASE LINK 한 줄
체감 난이도 😅 생각보다 까다로움 😐 그냥저냥

FDW + 트리거 조합 아이디어

아이디어는 이랬다.

  1. 로컬 DB에 데이터가 INSERT되면
  2. AFTER INSERT 트리거 발동
  3. 트리거 함수에서 FDW를 통해 서버 DB에 즉시 전송

실시간성 완벽! 데이터가 들어오는 즉시 서버로 전송되니까. ...라고 생각했다.

-- 1. FDW 확장 설치
CREATE EXTENSION postgres_fdw;

-- 2. 외부 서버 정의 (Tailscale IP 사용)
CREATE SERVER central_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '100.x.x.1', dbname 'production', port '5432');

-- 3. 사용자 매핑 (로컬 사용자 → 원격 사용자)
CREATE USER MAPPING FOR local_user
SERVER central_server
OPTIONS (user 'sync_user', password '********');

-- 4. 외부 테이블 정의 (서버의 테이블을 로컬에서 접근)
CREATE FOREIGN TABLE remote_measurement (
    id BIGINT,
    sensor_id INTEGER,
    value NUMERIC(10,2),
    measured_at TIMESTAMP,
    created_at TIMESTAMP
) SERVER central_server
OPTIONS (schema_name 'public', table_name 'measurement_data');
-- 5. 실시간 동기화 트리거 함수
CREATE OR REPLACE FUNCTION sync_to_server()
RETURNS TRIGGER AS $$
BEGIN
    -- 원격 테이블에 INSERT
    INSERT INTO remote_measurement (id, sensor_id, value, measured_at, created_at)
    VALUES (NEW.id, NEW.sensor_id, NEW.value, NEW.measured_at, NEW.created_at);
    
    RETURN NEW;
    
EXCEPTION WHEN OTHERS THEN
    -- 에러 발생해도 로컬 INSERT는 성공하도록
    RAISE WARNING 'Remote sync failed : %', SQLERRM;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 6. 트리거 생성
CREATE TRIGGER trg_sync_measurement
AFTER INSERT ON measurement_data
FOR EACH ROW
EXECUTE FUNCTION sync_to_server();

처음에는 진짜 잘 됐다

테스트해보니 완벽했다. 로컬에 INSERT하면 바로 서버에서 보인다. 논리복제처럼 복잡한 설정도 없고, 복제 슬롯이니 WAL이니 신경 쓸 것도 없다. 트리거로 직접 밀어넣으니까.

 

"이번엔 진짜 성공인가?" 싶었다. 동료들한테 자랑도 했다. "FDW로 실시간 동기화 구현했어요. 깔끔하죠?" 그렇게 며칠이 지났다...

⚠️ 결과 : 또 서버 다운

처음에는 잘 됐다. 정말 잘 됐다. 그런데 며칠 지나니까 되다가 갑자기 죽고, 또 되다가 갑자기 죽고... 패턴도 없이 랜덤하게 죽었다.

이게 더 미치는 거였다. 논리복제는 그래도 WAL 로그 쌓이는 거 보면서 "아, 이거 위험하겠다" 예측이라도 됐는데, FDW + 트리거 조합은 아무런 전조 증상 없이 갑자기 죽었다.

 

어제까지 멀쩡했는데 오늘 갑자기 서버가 죽어있다. 왜? 모른다. 로그 봐도 뭔가 쌓이다가 갑자기 뻗은 것 같은데, 명확한 원인을 찾기 어려웠다.

📅 장애 발생 패턴 (패턴이 없다)

Day 1 : ✅ 정상 동작 - "오 잘 되네!"
Day 2 : ✅ 정상 동작 - "역시 FDW 짱"
Day 3 : ✅ 정상 동작 - "이번엔 성공인가?"
Day 4 : ✅ 정상... 어라?
Day 4 : ⚠️ 서버 점점 느려짐
Day 4 : ❌ 서버 다운
Day 5 : ✅ 복구 후 정상
Day 6 : ✅ 정상
Day 7 : ❌ 또 다운 (왜?!)
현재 나의 멘붕 지수
 

"현기증 난단 말이에요..."

논리복제로 한 번, FDW로 또 한 번...
이쯤 되면 나한테 문제가 있는 거 아닌가?


사건의 전말 : 운영 서버가 죽기까지

그날의 상황을 복기해보자. 나중에 포스트모템(Post-mortem) 자료로도 쓸 수 있으니까. 사실 이 글 자체가 포스트모템이긴 하다.

D-Day 타임라인

🕐 14:00 - 평화로운 오후. 커피 한 잔 하면서 다른 작업 중. 오늘따라 날씨도 좋고, 퇴근 후 뭐 먹을지 고민하던 중이었다. 인생 뭐 있나, 평온하다.
🕑 14:23 - 동료 : "어? 시스템 좀 느린 것 같지 않아요?"
나 : "그래요? 잠깐 볼게요." (이때까지만 해도 대수롭지 않게 생각함. 가끔 느릴 때 있으니까.)
🕓 14:28 - 서버 접속. 평소보다 확실히 느리다. 터미널 응답도 늦고, 쿼리도 오래 걸린다. 뭔가 심상치 않은 느낌...
🕔 14:30 - DB 상태 확인 시작. 여기서부터 식은땀이 나기 시작했다.
-- 14:30 현재 실행 중인 쿼리 확인
SELECT pid, 
       now() - pg_stat_activity.query_start AS duration, 
       state,
       wait_event_type,
       left(query, 80) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC
LIMIT 10;

-- 결과 (대략적으로 재현):
-- pid   | duration  | state  | wait_event_type | query_preview
-- ------+-----------+--------+-----------------+---------------------------
-- 12345 | 00:12:34  | active | Client          | INSERT INTO remote_measurement...
-- 12346 | 00:11:22  | active | Client          | INSERT INTO remote_measurement...
-- 12347 | 00:10:15  | active | Client          | INSERT INTO remote_measurement...
-- ... (수십 개가 더 있었다)
--
-- wait_event_type이 'Client' → 원격 서버 응답 대기 중
-- 10분 넘게 대기 중인 쿼리가 수십 개... 😰
🕕 14:35 - 트리거 실행 상태 점검. FDW를 통한 원격 INSERT들이 타임아웃 없이 무한 대기 상태. 트리거에 타임아웃 설정을 안 했던 것이다.
🕖 14:38 - 혹시나 해서 논리복제 상태도 확인. (이전에 테스트하다 남겨둔 게 있었다) 여기도 WAL이 수십 GB 쌓여있었다. 설상가상.
-- 14:42 커넥션 풀 상태 확인
SELECT 
    count(*) AS current_connections,
    (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections,
    round(count(*) * 100.0 / (SELECT setting::int FROM pg_settings WHERE name = 'max_connections'), 1) AS usage_pct
FROM pg_stat_activity;

-- 결과:
-- current_connections | max_connections | usage_pct
-- --------------------+-----------------+-----------
-- 198                 | 200             | 99.0
--
-- 거의 다 찼다! 새로운 연결이 거의 불가능한 상태
-- 이러니까 다른 서비스도 DB 연결이 안 됐던 것...
🕘 14:45 - 동료들 : "다른 서비스도 안 돼요! 로그인이 안 돼요! 뭐 하신 거예요?!"
나 : (식은땀 줄줄) "아... 잠깐만요..." 다른 서비스들도 같은 DB를 쓰고 있었는데, 커넥션이 다 차버려서 새 연결이 안 되는 거였다.
🕚 14:50 - 결단의 순간. 더 이상 분석하고 있을 시간이 없다. 일단 불부터 끄자.
-- 14:50 응급 처치 시작 (순서대로)

-- 1. 문제가 되는 트리거 즉시 비활성화
ALTER TABLE measurement_data DISABLE TRIGGER trg_sync_measurement;
-- 이제 새로운 INSERT는 원격 전송 시도 안 함

-- 2. 5분 이상 실행 중인 쿼리 강제 종료
SELECT pg_terminate_backend(pid) 
FROM pg_stat_activity 
WHERE now() - query_start > interval '5 minutes'
  AND state != 'idle'
  AND pid != pg_backend_pid();  -- 내 세션은 제외
-- 약 50개 세션 종료됨

-- 3. 복제 슬롯 삭제 (WAL 디스크 해방)
SELECT pg_drop_replication_slot(slot_name)
FROM pg_replication_slots
WHERE active = false;
-- 비활성 슬롯 삭제 → 수십 GB WAL 해방

-- 4. 오래된 idle 커넥션 정리
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle'
  AND state_change < now() - interval '30 minutes';
🕛 15:10 - 서버 정상화. 커넥션 수 정상 범위로 돌아오고, 다른 서비스들도 복구됨.
하지만 마음의 상처는 회복되지 않았다... 그리고 이제 원인 분석과 재발 방지 대책을 세워야 한다. 야근 확정.

💭 그날의 교훈

"트리거 문제, 논리복제 문제 다 겪어보니 식은땀과 정신이 혼미해지네..."

"현기증 난단 말이에요!!!"

진짜 현기증 났다. 비유가 아니라.


기술적 분석 : 도대체 왜 죽은 걸까

사건이 진정된 후, 원인을 철저히 분석했다. 야근하면서. 커피 다섯 잔 마시면서.

결론부터 말하면, 두 가지 방식 모두 동기식 처리의 한계라는 같은 문제를 안고 있었다.

⚡ FDW + Trigger의 한계

  • 동기적 처리: 트리거 안에서 원격 쿼리 실행. 네트워크가 느려지면 SQL 실행 자체가 멈춘다. 원격 쿼리가 끝날 때까지 로컬 트랜잭션도 대기.
  • VPN 오버헤드: Tailscale 암호화 터널을 지나는 매 쿼리마다 약간의 지연 발생. 쿼리 한두 개면 모르는데, 1분에 수십 개씩 쌓이면 누적된다.
  • 트리거 락: AFTER INSERT 트리거가 끝날 때까지 해당 행에 Lock이 유지된다. 트리거가 느리면 다른 INSERT도 대기.
  • 커넥션 고갈: FDW는 원격 쿼리마다 커넥션을 사용한다. 동시에 여러 트리거가 실행되면 커넥션 풀이 순식간에 소진.

🔄 논리 복제의 함정

  • WAL 무한 증식: Subscriber가 오프라인이면 Publisher의 WAL 로그가 계속 쌓인다. 디스크 풀 → DB 사망 루트.
  • 스키마 민감도: Publisher와 Subscriber의 테이블 구조가 조금만 달라도 복제 중단. 컬럼 추가하려면 양쪽 다 해야 한다.
  • 충돌 미해결: PK 충돌 시 복제가 완전히 멈춘다. 자동 해결 없음. 사람이 직접 해결해야 함.
  • 리소스 경합: 복제 프로세스(wal sender, logical replication worker)가 CPU와 I/O를 꽤 잡아먹는다.

핵심 문제 : 동기식 처리의 한계

💡 핵심 깨달음

"원격 작업이 로컬 작업을 블로킹하면 안 된다!"

실시간이라고 해서 반드시 동기식으로 처리할 필요는 없다. 서버에 문제가 생겨도 로컬 시스템은 정상 동작해야 한다. 이게 분산 시스템의 기본 원칙인데, 나는 이걸 망각했다.

/* 문제의 흐름 - 동기식 처리의 위험 */

[계측기] → [로컬 DB INSERT] → [트리거 발동] → [FDW 원격 INSERT]
                                                            │
                                                            ▼
                                                  🔴 네트워크 지연 발생!
                                                            │
                                                            ▼
                                        🔴 로컬 트랜잭션도 대기 상태로 전환
                                                            │
                                                            ▼
                                            🔴 다음 INSERT도 대기열에 쌓임
                                                            │
                                                            ▼
                                         🔴 커넥션 풀 고갈 → DB 다운

핵심 : 원격 장애가 로컬 시스템 전체를 멈추게 하면 안 된다!

사업장의 인터넷이 느려지거나, 중앙 서버가 잠깐 바빠지거나, VPN이 불안정해지면 그 영향이 로컬 시스템까지 전파되는 구조였다. 이건 설계 자체가 잘못된 것이다.


해결을 향한 여정 : 앞으로의 접근 방법

솔직히 고백하면, 아직 완벽한 해결책을 찾지 못했다. 하지만 여러 가지 방법을 조사하고 테스트하면서, 앞으로 이런 방향으로 접근하려고 한다.

💡 핵심 원칙

"로컬 작업과 원격 동기화를 분리하자"
원격 서버에 문제가 생겨도 로컬 시스템은 정상 동작해야 한다. 이게 이번 삽질에서 얻은 가장 큰 교훈이다.

1'동기'에서 '비동기'로의 전환

트리거에서 즉시 원격 서버로 쏘지 말고, 로컬에 임시 테이블(큐)을 두거나 메시지 큐(MQTT, RabbitMQ)를 사용해 서버가 한가할 때 가져가게 하는 방식이 안전하다.

-- 동기화 큐 테이블 생성
CREATE TABLE sync_queue (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    record_id BIGINT NOT NULL,
    operation VARCHAR(10) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    retry_count INTEGER DEFAULT 0,
    last_error TEXT,
    status VARCHAR(20) DEFAULT 'pending'
);

CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at);

-- 트리거는 큐에 넣기만 한다 (매우 빠름!)
CREATE OR REPLACE FUNCTION queue_for_sync()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO sync_queue (table_name, record_id, operation, payload)
    VALUES (TG_TABLE_NAME, NEW.id, TG_OP, row_to_json(NEW)::jsonb);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

이렇게 하면 로컬 INSERT는 로컬 테이블에 한 줄 추가하는 것으로 끝난다. 밀리초 단위로 끝난다. 원격 서버가 죽어있든 말든 상관없다.

별도의 워커 프로세스가 주기적으로 큐를 확인하고 서버로 전송한다. 실패하면 retry_count 올리고 나중에 다시 시도.

2배치(Batch) 인서트 활용

데이터가 1분에 수십 개씩 발생한다면, 건건이 쏘지 말고 5분치 데이터를 모아서 한 번에 서버로 COPY 명령어를 날리는 것이 훨씬 효율적이다.

-- PostgreSQL COPY는 대량 INSERT보다 훨씬 빠름
-- 100건 INSERT하는 것보다 COPY로 100건 넣는 게 10배는 빠르다

-- 방법 1 : CSV 파일로 내보내고 COPY
COPY (
    SELECT id, sensor_id, value, measured_at 
    FROM sync_queue 
    WHERE status = 'pending'
    LIMIT 1000
) TO '/tmp/batch_data.csv' WITH CSV;

-- 방법 2 : INSERT ... SELECT로 배치 처리 (FDW 사용)
INSERT INTO remote_measurement (id, sensor_id, value, measured_at)
SELECT (payload->>'id')::bigint,
       (payload->>'sensor_id')::int,
       (payload->>'value')::numeric,
       (payload->>'measured_at')::timestamp
FROM sync_queue 
WHERE status = 'pending'
LIMIT 500;

네트워크 왕복 횟수가 대폭 줄어든다. 100번 왕복할 거 1번으로 끝. 서버 부하도 분산된다.

3헬스 체크 및 모니터링 강화

서버가 느려질 때 바로 알 수 있도록 모니터링 시스템을 구축해야 한다. 문제가 터지기 전에 감지하는 게 최선이다.

-- 모니터링 필수 쿼리들 (크론으로 1분마다 실행)

-- 1. 복제 슬롯 WAL 누적량 (1GB 이상이면 경고)
SELECT slot_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       active,
       CASE 
           WHEN pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 1073741824 
           THEN '⚠️ WARNING'
           ELSE '✅ OK'
       END AS status
FROM pg_replication_slots;

-- 2. 커넥션 사용량 (80% 이상이면 경고)
SELECT 
    count(*) AS current_conn,
    (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_conn,
    round(count(*) * 100.0 / (SELECT setting::int FROM pg_settings WHERE name = 'max_connections'), 1) AS pct
FROM pg_stat_activity;

-- 3. 장시간 실행 쿼리 (5분 이상이면 경고)
SELECT count(*) AS long_running_queries
FROM pg_stat_activity
WHERE state != 'idle' 
  AND now() - query_start > interval '5 minutes';

Prometheus + Grafana 조합이 좋다. postgres_exporter를 붙이면 PostgreSQL 메트릭을 자동으로 수집해준다. 대시보드도 이쁘게 만들 수 있다.

4CDC(Change Data Capture) 검토

Debezium 같은 CDC 도구를 사용하면 PostgreSQL의 WAL 로그를 직접 읽어서 변경 이벤트를 발행할 수 있다. 논리복제보다 더 유연하고, 트리거보다 안전하다.

/* CDC 기반 아키텍처 */

[PostgreSQL] ──WAL──▶ [Debezium] ──▶ [Kafka] ──▶ [Consumer] ──▶ [서버 DB]
      │                      │
      │                      └── 변경 이벤트를 JSON으로 발행
      │
      └── 애플리케이션 코드 수정 불필요!
           기존 INSERT/UPDATE/DELETE 그대로 사용

장점은 애플리케이션 코드를 전혀 수정하지 않아도 된다는 것. 단점은 인프라가 복잡해진다는 것. Debezium + Kafka + ZooKeeper... 소규모에는 오버스펙일 수 있다.

5하이브리드 전략

모든 데이터를 똑같이 처리할 필요는 없다. 데이터 특성에 따라 다른 전략을 사용하는 것이 현실적이다.

/* 하이브리드 전략 */

[긴급 알람 데이터] ← 온도 이상, 장비 고장 등
  └── REST API 직접 전송 (동기, 하지만 타임아웃 설정)
  └── 실패 시 로컬 큐에 저장 후 재시도
  └── 지연 허용 : 0~5초

[일반 계측 데이터] ← 1분마다 쌓이는 센서 데이터
  └── 로컬 DB에 저장
  └── 5분마다 배치로 서버 전송
  └── 지연 허용 : ~5분

[이력/로그 데이터] ← 기기 로그, 작업 이력 등
  └── 1시간마다 또는 하루에 한 번 동기화
  └── 실시간성 불필요
  └── 지연 허용 : ~24시간

핵심 질문 : 정말 모든 데이터에 "실시간"이 필요한가? 🤔

고객한테 다시 물어봐야 한다. "정말 모든 데이터가 실시간으로 필요하신 건가요? 아니면 알람만 실시간이면 되나요?" 요구사항을 다시 정의하는 것도 방법이다.

6FDW 개선 (당장 적용 가능)

당장 구조를 바꾸기 어렵다면, 최소한 타임아웃 설정과 예외 처리 강화는 해야 한다. 이건 오늘이라도 할 수 있다.

-- FDW 연결에 타임아웃 설정 (필수!)
ALTER SERVER central_server OPTIONS (
    ADD connect_timeout '10',
    ADD options '-c statement_timeout=30000'
);

-- connect_timeout : 연결 타임아웃 10초
-- statement_timeout : 쿼리 타임아웃 30초 (30000ms)
-- 이 설정 없으면 무한 대기할 수 있다!
-- 트리거에서 예외 처리 강화 + 실패 로깅
CREATE OR REPLACE FUNCTION sync_to_server_safe()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO remote_measurement (id, sensor_id, value, measured_at)
    VALUES (NEW.id, NEW.sensor_id, NEW.value, NEW.measured_at);
    
    RETURN NEW;
    
EXCEPTION WHEN OTHERS THEN
    -- 실패해도 로컬 INSERT는 성공하도록!
    INSERT INTO sync_failure_log (
        table_name, record_id, error_message, created_at
    ) VALUES (
        'measurement_data', NEW.id, SQLERRM, NOW()
    );
    
    RETURN NEW;  -- 에러 나도 RETURN NEW!
END;
$$ LANGUAGE plpgsql;

🎯 단계별 실행 계획

단계 내용 예상 기간 우선순위
즉시 FDW 타임아웃 설정 + 예외 처리 강화 + 모니터링 쿼리 작성 1주 🔴 긴급
단기 sync_queue 테이블 도입 + 워커 프로세스 개발 2-3주 🟠 높음
중기 RabbitMQ 또는 MQTT 도입 검토 및 테스트 1-2개월 🟡 중간
장기 데이터 특성별 하이브리드 전략 완성 + CDC 검토 3개월+ 🟢 개선

💪 현재 상태

솔직히 말하면, 여러 가지 방법을 시도 중이고 아직도 삽질하는 중이다. 하지만 반드시 성공할 것이다.

이 문제를 해결하면 다시 성공 사례를 공유하겠다. 그때는 "이렇게 하면 됩니다~"라고 자신 있게 말할 수 있을 거다. 아마도.


마무리 : 실패도 자산이다

기술 블로그에는 보통 성공 사례만 올라온다. "이렇게 하면 됩니다", "이 방법으로 해결했습니다" 같은 글들이 대부분이다. 나도 그런 글을 썼었다.

 

하지만 현실에서는 실패가 훨씬 많다. 10번 시도해서 1번 성공하면 그나마 다행이다. 나머지 9번의 실패는 어디로 갔을까? 블로그에는 안 올라온다. 부끄러우니까.

 

나는 실패 경험도 중요한 자산이라고 생각한다. 적어도 이런 효과는 있다.

  • 다른 개발자들이 같은 실수를 반복하지 않도록 도움
  • "나만 이런 거 아니구나"라는 위안 제공
  • 글로 정리하면서 스스로 원인과 해결책을 명확히 정리
  • 나중에 비슷한 상황에서 참고 자료로 활용

📝 이 글의 핵심 요약

  1. 논리복제 : 강력하지만 복제 슬롯 관리 실패하면 WAL이 쌓여서 서버가 죽는다
  2. FDW + 트리거 : 편리하지만 타임아웃 없으면 되다가 갑자기 죽는다
  3. Tailscale VPN : 보안 연결은 완벽했다. 문제는 그 위의 레이어(DB 동기화)였다
  4. 동기식 처리의 한계 : 원격 장애가 로컬 시스템에 영향 주면 안 된다
  5. 비동기 방식 : 메시지 큐, CDC, 애플리케이션 큐 등 대안이 많다
  6. 모니터링 : 문제가 터지기 전에 감지하는 게 최선이다

"실패는 성공의 어머니다. 단, 같은 실패를 반복하지 않을 때만."

— 서버 두 번 죽여본 개발자의 교훈

이 글을 읽는 누군가가 비슷한 상황에서 조금이라도 도움이 되었으면 좋겠다. 그리고 제발, 나처럼 서버를 두 번 죽이진 말자.

나중에 이 문제를 해결하면 그때 다시 성공 사례를 공유하겠다. 그때까지는... 삽질 일지를 계속 써나가야겠다. 화이팅. 🔥

🧑‍💻 트리거 문제, 논리복제 문제 다 겪어보니
식은땀과 정신이 혼미해지네...

"현기증 난단 말이에요!!!"

관련 포스팅

궁금한 점이나 비슷한 경험이 있다면 댓글로 공유 부탁드립니다. 함께 고민하면 더 좋은 해결책을 찾을 수 있을 것입니다. 🙏


Tags

#PostgreSQL #논리복제 #LogicalReplication #FDW #실시간데이터동기화 #TailscaleVPN #분산데이터베이스 #데이터베이스장애 #개발삽질기 #IoT데이터수집

 

 

커피 한 잔의 힘

이 글이 도움이 되셨다면, 커피 한 잔으로 응원해주세요!
여러분의 작은 후원이 더 좋은 콘텐츠를 만드는 큰 힘이 됩니다.