포스트

PostgreSQL 커뮤니티 패치 리뷰 — XLOG_SWITCH 반환값 일관성 (#6680)

DBA로서 PostgreSQL 오픈소스 커뮤니티에 처음 기여하며 마주한 #6680 패치 — WAL 구조의 기본부터 XLOG_SWITCH 반환값 일관성 문제, v1→v2 진화 과정과 메일 토론을 단계별로 따라가며 정리합니다.

PostgreSQL 커뮤니티 패치 리뷰 — XLOG_SWITCH 반환값 일관성 (#6680)

이 글에 등장하는 V1/V2 패치 비교와 메일 인용은 PostgreSQL 커뮤니티 메일링 리스트 아카이브(pgsql-hackers Commitfest PG20-1, thread #6680)에서 확인할 수 있습니다.

DBA로서 PostgreSQL 오픈소스 커뮤니티에 처음 기여하며 배운 것들. WAL 구조의 기본부터 v1 → v2 패치 진화 과정까지 전 과정을 기록합니다.

들어가며

Oracle이나 MySQL과 달리 PostgreSQL은 완전히 오픈된 커뮤니티가 개발합니다. 특정 회사 소유가 아니고, 전 세계 개발자들이 메일링 리스트에서 토론하며 코드를 만들어갑니다.

이 글은 제가 그 문을 두드려본 경험을 정리한 것입니다. PostgreSQL master 브랜치에 제안된 패치(#6680) 하나를 리뷰하면서 WAL의 내부 구조부터 커뮤니티 협업 프로세스까지 꽤 많은 것을 배웠고, DBA 관점에서의 경험과 생각들을 정리해두면 개인적으로 도움이 될 것 같았습니다.

다룰 내용

  1. WAL 구조의 기본 (페이지, 세그먼트, LSN)
  2. XLOG_SWITCH가 무엇이고 왜 특별한가
  3. 패치 #6680이 다루는 실제 문제
  4. V1 → V2, 패치의 진화 과정
  5. 비교 실험에서 얻은 통찰
  6. DBA 관점에서 무엇을 배웠나

1. WAL 구조의 기본

본론에 들어가기 전에 WAL이 디스크에 어떻게 저장되는지 짚고 넘어가야 합니다.

계층 구조

PostgreSQL의 WAL은 세 단계로 쌓여 있습니다 — 세그먼트가 페이지를 담고, 페이지가 헤더 + 레코드 영역을 담는 중첩 구조입니다.

세그먼트 파일 (16 MB)예: 000000010000000000000001
페이지 1 (8 KB)
페이지 헤더(24 또는 40 B)
[rec A][rec B][rec C][rec D] …
페이지 2 (8 KB)
페이지 헤더(24 B)
[rec E][rec F][rec G] …
… (총 2048 페이지)
단위크기설명
세그먼트16 MB파일 하나. pg_wal/ 디렉토리에 쌓임
페이지8 KB세그먼트 하나에 2048개
레코드가변실제 변경 정보. 여러 페이지에 걸쳐 쓸 수도 있음

페이지 헤더 두 종류

페이지마다 시작에 헤더가 있는데, 크기가 두 종류입니다 — 세그먼트 첫 페이지에만 큰 헤더가 들어가고, 나머지는 작은 헤더가 들어갑니다.

세그먼트 N (16 MB · 2048 페이지)
Page 0 — 세그먼트 첫 페이지
Long Page Header(40 B) · sysid, seg_size 등
레코드 영역
Page 1 ~ 2047 — 나머지 페이지들
Short Page Header(24 B)
레코드 영역
헤더 타입크기사용처
SizeOfXLogLongPHD40 바이트세그먼트의 첫 페이지만
SizeOfXLogShortPHD24 바이트나머지 모든 페이지

긴 헤더에는 시스템 식별자, 세그먼트 크기 같은 메타 정보가 추가로 들어갑니다.

LSN (Log Sequence Number)

WAL의 모든 위치는 LSN으로 표현됩니다.

1
2
3
4
LSN: 0/03000060
     │  │     └── 세그먼트 내 오프셋 (16진수)
     │  └──────── 세그먼트 번호 (16진수)
     └─────────── 상위 비트
  • 0/030000603번 세그먼트의 0x60(=96) 바이트 위치입니다
  • 0/040000004번 세그먼트의 시작입니다
  • 두 LSN의 차이 = WAL 양 (바이트)

DBA로서 LSN이 중요한 이유

LSN은 PostgreSQL 내부의 모든 동기화 메커니즘의 기준점입니다.

1
2
3
4
5
6
7
8
9
10
11
-- 현재 WAL 쓰기 위치
SELECT pg_current_wal_lsn();
-- 결과: 0/03000060

-- 복제 지연 (바이트)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;

-- 이 LSN이 있는 WAL 파일 이름
SELECT pg_walfile_name(pg_current_wal_lsn());
-- 결과: 000000010000000000000003

LSN 계산이 틀어지면 백업·복제·아카이브가 모두 꼬입니다. 그래서 WAL 관련 버그는 DBA에게 직접 영향이 있습니다.


2. XLOG_SWITCH — 특별한 레코드

일반 레코드 vs XLOG_SWITCH

WAL 레코드에는 여러 종류가 있습니다. INSERT, UPDATE, DELETE, CHECKPOINT… 이들은 모두 일반 레코드입니다.

일반 레코드 구조
Header (24 B)
Payload (실제 변경)
총 크기: 24 바이트 ~ 수 KB (내용에 비례)

일반 레코드는 WAL 버퍼의 빈 공간에 차곡차곡 쌓입니다.

페이지 헤더
rec A
rec B
rec C
rec D
빈 공간 (fill) → 다음 레코드 쓸 위치

페이지 내부 — 헤더 다음에 레코드들이 적재되고 끝쪽에 빈 공간이 남는다

반면 XLOG_SWITCH 는 완전히 다릅니다.

XLOG_SWITCH 레코드 구조
Header (24 B)
← 페이로드 없음!
총 크기: 딱 24 바이트

크기는 일반 레코드의 헤더와 같지만, 행동이 완전히 다릅니다.

XLOG_SWITCH 가 필요한 운영 시나리오

XLOG_SWITCH는 이런 명령입니다.

“현재 세그먼트를 여기서 끝내고, 다음 세그먼트로 넘어가라.”

pg_switch_wal() 함수가 이걸 트리거합니다. 실행하면 세그먼트의 남은 공간이 전부 버려집니다. XLOG_SWITCH는 그저 “여기서 끊는다”는 마커일 뿐입니다.

BEFORE — 세그먼트 N (16 MB)

페이지 헤더
일반 레코드들 (3 MB 사용)
아직 13 MB 남음

AFTER pg_switch_wal() — 세그먼트 N (16 MB)

페이지 헤더
기존 레코드들
SW
버려지는 padding

SW = XLOG_SWITCH 레코드 (24 B). 그 뒤 13 MB 공간은 통째로 버려진다.

세그먼트 N+1 (16 MB) — 새로 시작

Long Page Header (40 B)
다음 레코드들 …

실제로 XLOG_SWITCH 가 쓰이는 상황은 셋입니다.

1) 백업 경계 맞출 때

1
2
3
SELECT pg_backup_start('daily_backup');
-- 백업 실행 …
SELECT pg_backup_stop();  -- 내부적으로 pg_switch_wal() 호출

백업이 끝나면 새 세그먼트에서 시작하도록 강제해서, 백업 아카이브의 경계를 명확하게 만들기 위해 XLOG_SWITCH를 호출합니다.

2) archive_timeout 도달할 때

1
2
# postgresql.conf
archive_timeout = 60min

트래픽이 적어도 60분마다 세그먼트를 강제 전환 → 아카이브가 정기적으로 흐르게 함. 내부적으로 XLOG_SWITCH 를 호출합니다.

3) 복제 / DR 상황일 때

1
2
-- Primary가 죽기 직전, Replica에 최신 상태 강제로 보내기
SELECT pg_switch_wal();

“레코드의 끝” 이 두 가지 의미를 가진다

XLOG_SWITCH 의 미묘한 점은 끝 위치가 두 가지라는 것입니다.

세그먼트 N — 두 개의 "끝"

페이지 헤더
기존 레코드들
SW
padding
논리적 끝
레코드 자체 끝
물리적 끝
세그먼트 끝
개념위치
논리적 끝XLOG_SWITCH 의 24 B 뒤
물리적 끝세그먼트의 마지막 바이트

일반 레코드는 논리적 끝과 물리적 끝이 같습니다. XLOG_SWITCH 만 다릅니다. XLogInsertRecord() 가 반환해야 할 값은 “논리적 끝” — 호출자가 “이 레코드 다음에 올 레코드의 시작 위치”를 올바르게 계산하려면 이 값이 정확해야 합니다. 패치 #6680이 손대는 것이 바로 이 계산 로직입니다.


3. 패치 #6680이 다루는 문제

원본 코드 구조

src/backend/access/transam/xlog.cXLogInsertRecord() 함수는 모든 WAL 레코드를 쓰는 공통 관문입니다. 일반 레코드든 XLOG_SWITCH 든 이 함수를 거칩니다.

하지만 내부에서 두 경로가 다르게 처리됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 일반 레코드 경로 (단순) */
EndPos = XLogBytePosToEndRecPtr(...);  // 헬퍼 함수 하나로 끝

/* XLOG_SWITCH 경로 (복잡, 12줄) */
if (inserted)
{
    EndPos = StartPos + SizeOfXLogRecord;
    if (StartPos / XLOG_BLCKSZ != EndPos / XLOG_BLCKSZ)
    {
        uint64 offset = XLogSegmentOffset(EndPos, wal_segment_size);
        if (offset == EndPos % XLOG_BLCKSZ)
            EndPos += SizeOfXLogLongPHD;   // 세그먼트 첫 페이지면 긴 헤더
        else
            EndPos += SizeOfXLogShortPHD;  // 아니면 짧은 헤더
    }
}

저자의 문제 제기

패치 저자([email protected])는 이 불일치를 지적했습니다.

“같은 함수 안에서 두 경로가 이렇게 다를 필요가 있나?”

합리적인 문제 제기입니다. 일관성은 유지보수성과 직접 연관됩니다.

왜 XLOG_SWITCH 만 복잡한가

24 B 레코드가 페이지 경계를 넘어갈 수 있기 때문입니다. 넘어가면 다음 페이지의 헤더 크기(24 또는 40 B)를 EndPos 에 더해야 합니다.

Case 1 — 같은 페이지에 완전히 들어감

페이지 헤더
레코드들
SW
빈 공간

EndPos = SW 끝 (페이지 경계 보정 없음)

Case 2 — 페이지 경계를 넘어감

페이지 A 헤더
레코드들
SW
페이지 B 헤더
(다음 레코드 영역)

EndPos 에 다음 페이지의 헤더 크기(24 또는 40 B)를 더해야 한다

이 “페이지 경계 보정” 로직이 일반 레코드와 XLOG_SWITCH 가 다른 이유입니다.


4. V1 → V2, 패치의 진화

V1: “헬퍼 함수를 재사용하자”

저자의 첫 제안(V1)은 심플했습니다. 12줄을 2줄로 줄이고 일반 레코드와 같은 헬퍼 함수를 공유하는 접근.

장점은 명확했습니다 — 코드 간결, 패턴 통일. 다만 한 가지가 걸렸습니다. 원본에는 없던 MAXALIGN() 이 추가되었는데 저자가 따로 설명을 달지 않았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  if (inserted)
  {
-     EndPos = StartPos + SizeOfXLogRecord;
-     if (StartPos / XLOG_BLCKSZ != EndPos / XLOG_BLCKSZ)
-     {
-         uint64 offset = XLogSegmentOffset(EndPos, wal_segment_size);
-         if (offset == EndPos % XLOG_BLCKSZ)
-             EndPos += SizeOfXLogLongPHD;
-         else
-             EndPos += SizeOfXLogShortPHD;
-     }
+     EndPos = XLogBytePosToEndRecPtr(XLogRecPtrToBytePos(StartPos) +
+                                     MAXALIGN(SizeOfXLogRecord));
  }

제가 던진 질문

DBA로서 코드를 짤 수는 없지만, “운영에서 문제가 될 수 있는 지점” 은 짚을 수 있습니다. 질문은 단순했습니다.

“이 MAXALIGN() 은 실제 버그 수정인가요, 방어적 no-op 인가요, 아니면 동작 변화라 commit 메시지에 별도 언급이 필요한가요?”

패치의 의도를 저자가 명확히 밝혀줘야 나중에 누가 비슷한 코드를 고칠 때 혼란이 없습니다. 이게 리뷰어가 할 수 있는 “작지만 중요한 일” 입니다.

V2: 저자가 접근 자체를 재고

그날 저녁 저자의 답장이 왔습니다. “MAXALIGN 은 ReserveXLogSwitch() 와 일관성 때문이고 실제 값은 안 변한다. 더 효율적인 V2 를 첨부한다.”

놀랍게도 V2 는 V1 의 2줄 헬퍼 방식을 버리고 원본 구조로 회귀했습니다. 대신 두 가지가 추가되었습니다.

  1. 페이지 경계 엣지 케이스 명시 처리 — EndPos 가 정확히 페이지 경계에 떨어지는 상황 (다음 페이지를 아직 안 건드렸으니 헤더 크기를 더하면 안 됨)
  2. 주석으로 의도 설명 — 왜 이 조건이 필요한지 XLogBytePosToEndRecPtr() 와의 연관성 명시

V1 이 simplify 였다면 V2 는 “원본 유지 + 방어적 보완” — 접근 방향이 완전히 달라졌습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  if (inserted)
  {
-     EndPos = StartPos + SizeOfXLogRecord;
-     if (StartPos / XLOG_BLCKSZ != EndPos / XLOG_BLCKSZ)
+     EndPos = StartPos + MAXALIGN(SizeOfXLogRecord);
+
+     /*
+      * If the XLOG_SWITCH record crosses a page boundary and actually has
+      * a part on the next page, we need to consider the page header. This
+      * is consistent with XLogBytePosToEndRecPtr().
+      */
+     if (StartPos / XLOG_BLCKSZ != EndPos / XLOG_BLCKSZ &&
+         EndPos % XLOG_BLCKSZ != 0)
      {
          uint64 offset = XLogSegmentOffset(EndPos, wal_segment_size);
          ...

세 버전 비교

항목원본 masterV1V2
코드 스타일수동 로직2줄 헬퍼수동 로직
페이지 경계 엣지 케이스❌ 미처리❌ (헬퍼가 암묵 처리)✅ 명시 처리
주석으로 의도 설명
함수 호출 오버헤드없음있음없음

5. 비교 실험에서 얻은 통찰

실제 버그일까, 방어적 개선일까

V2 테스트를 돌리며 궁금해졌습니다. “이 수정이 진짜 버그를 고치는 건가?”

확인 방법은 단순합니다. V2 적용본원본 master 에서 같은 시나리오(pg_switch_wal() 반복 호출)를 돌려 LSN 동작을 비교하는 거죠.

결과는 의외였습니다

두 버전이 완전히 동일하게 동작했습니다. pg_switch_wal() 을 세그먼트 경계에서 반복 호출해도 멱등(idempotent)했고, 관찰 가능한 버그 증상이 없었습니다.

왜 원본도 멀쩡한가? 호출 체인을 따라가 보았습니다.

1
2
3
4
5
pg_switch_wal()
    ↓
ReserveXLogSwitch()  ← 여기서 이미 경계 체크
    ↓
경계에 있으면 → inserted = false → 문제 경로 진입 안 됨

상위 호출자가 이미 가드하고 있어서, XLogInsertRecord() 의 잠재적 버그 경로가 실제로는 도달 불가능했던 겁니다.

그럼 V2 는 의미가 없는가?

그렇지 않습니다. “지금 잘 동작하는 것”과 “앞으로도 안전한 것”은 다릅니다.

V2 는 관찰 가능한 버그를 수정하진 않지만:

  • 상위 호출자의 계약에 의존하지 않고 함수 내부에서 스스로 불변 조건을 보장
  • 주석으로 의도를 명시해서 코드 가치를 높임
  • 나중에 XLogInsertRecord() 를 다른 경로에서 직접 호출해도 안전

이게 방어적 프로그래밍(defensive-in-depth) 의 전형입니다.

Follow-up 리뷰 메일

이 통찰을 바탕으로 저자와 커미터에게 follow-up 을 보냈습니다.

“V2 의 새 가드는 현재 master 의 관찰 가능한 버그를 수정하진 않습니다. ReserveXLogSwitch() 가 상위에서 이미 막고 있기 때문입니다. 하지만 이 가드는 invariant 를 XLogInsertRecord() 자체에 국한시키고, 주석으로 의도를 명시함으로써 방어적 개선으로서 가치가 있습니다.”


6. DBA 관점에서 배운 것들

1) “잘 동작하는 것” 과 “안전한 것” 은 다르다

원본 코드는 잘 동작합니다. 하지만 안전하지는 않습니다. 상위 호출자가 가드를 풀거나, 다른 경로에서 XLogInsertRecord() 를 직접 부르는 순간 잠재 결함이 드러납니다.

2) WAL 관련 버그가 DBA 에게 미치는 영향

WAL 의 EndPos 계산이 단 1 바이트라도 어긋나면, archive·backup·replication 의 동기화 기준점이 동시에 비뚤어집니다. 한 곳을 잘못 짚는 게 아니라 운영 면 전체가 무너지는 종류의 버그입니다.

XLOG_SWITCH 의 EndPos 계산이 틀어지면 이론적으로 어떤 일이 벌어질까요?

서비스영향
archive_command잘못된 LSN 을 받아 아카이브 타이밍 꼬임
pg_basebackup백업 시작/끝 LSN 불일치 가능
Streaming replicationReplica 가 Primary 의 WAL 위치를 오해
pg_waldumpWAL 분석 도구가 잘못된 위치를 파싱
Logical replication슬롯이 잘못된 LSN 을 따라감

지금 이 패치는 관찰 가능한 버그를 수정하지 않지만, 이런 엣지 케이스들을 방어적으로 처리하는 것 자체가 운영 안정성에 큰 역할을 한다고 생각합니다.

3) 질문이 패치를 바꾼다

리뷰어가 던진 한 마디 질문이 패치 접근 자체를 바꿀 수 있습니다. V1 에서 제가 던진 단순한 의문(“MAXALIGN 이 뭐 하는 거죠?”)이 저자로 하여금 재고하게 만들었고, 그 결과가 V2 의 방향 전환이었습니다.

코드를 고친 건 아니지만, 질문 하나가 패치 품질에 영향을 줬습니다. DBA 로서 C 코드를 못 써도 기여할 수 있는 지점이 분명히 있다는 걸 체감했어요.


7. 마치며

패치 하나를 리뷰하면서 WAL 구조, XLOG_SWITCH 의 특수성, 저자와의 기술 대화, 비교 실험까지 — 이 모든 게 하루이틀 사이에 일어난 일입니다.

이 경험이 특별했던 건 “내가 운영하는 소프트웨어를 만들어가는 과정에 실제로 참여했다” 는 경험입니다. 사용자에서 기여자로 한 발 넘어간 느낌.

잘 동작한다고 안전한 것은 아니다 — 상위 호출자의 가드에 의존하지 않고 함수 내부에서 불변 조건을 보장하는 게 방어적 프로그래밍의 본질이고, DBA 가 코드 리뷰에서 가장 잘 짚을 수 있는 지점이기도 하다고 생각합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.