메인 피드에서 글이 사라졌다: published_at 도입과 마이그레이션 타이밍 함정

드래프트를 발행했는데 피드 4페이지 뒤에 있다
FullStackFamily 커뮤니티의 메인 피드는 created_at 기준으로 정렬되어 있었습니다. 대부분의 글은 작성 즉시 발행되니까 별문제가 없었는데, 블로그 글에서 이상한 일이 생겼습니다.
블로그는 DRAFT → PUBLISHED 2단계 작성을 지원하거든요. 드래프트를 만들어 두고, 며칠 뒤에 완성해서 발행하는 식입니다. 2월 20일에 드래프트를 만들고 2월 24일에 발행하면? 피드에서 2월 24일이 아니라 2월 20일 위치에 나타납니다. 방금 발행한 글이 피드 4페이지 뒤에 파묻혀 있는 겁니다.
[기존] DRAFT 생성 → PUBLISHED 전환 → 피드 정렬: created_at 2/20 2/24 2/20 위치에 노출 [변경] DRAFT 생성 → PUBLISHED 전환 → 피드 정렬: published_at 2/20 2/24 2/24 위치에 노출
다음 그림은 created_at과 published_at 기준 정렬의 차이를 보여줍니다.

왼쪽은 created_at 기준으로 정렬했을 때 드래프트 생성일(2/20)에 노출되는 문제를 보여주고, 오른쪽은 published_at 기준으로 바꾸면 실제 발행일(2/24)에 맨 위로 올라오는 모습입니다.
created_at은 드래프트가 처음 만들어진 시점이지, 발행 시점이 아닙니다. 그래서 "실제로 발행된 시점"을 별도 컬럼으로 기록하고, 피드 정렬을 그 값으로 바꾸기로 했습니다.
published_at 컬럼 설계
마이그레이션 SQL은 3단계로 구성했습니다.
-- 1. 컬럼 추가 (NULLABLE) ALTER TABLE unified_post ADD COLUMN published_at DATETIME DEFAULT NULL AFTER status; -- 2. 기존 PUBLISHED 글 백필 (created_at 값으로) UPDATE unified_post SET published_at = created_at WHERE status != 'DRAFT'; -- 3. 피드 정렬용 복합 인덱스 CREATE INDEX idx_up_status_published ON unified_post (status, published_at DESC, id DESC);
이미 발행된 글 376건은 실제 발행 시점을 알 수 없으니 created_at 값으로 채웠습니다.
인덱스는 (status, published_at DESC, id DESC) 복합으로 잡았는데, 피드 쿼리 패턴이 WHERE status = 'PUBLISHED' ORDER BY published_at DESC, id DESC이라서 이 인덱스를 타면 별도 정렬 없이 순서대로 읽을 수 있거든요.
엔티티와 쿼리 변경
UnifiedPost 엔티티에서 두 곳을 고쳤습니다. published_at이 자동으로 채워지도록요.
@Builder public UnifiedPost(..., PostStatus status) { this.status = status != null ? status : PostStatus.PUBLISHED; if (this.status == PostStatus.PUBLISHED) { this.publishedAt = LocalDateTime.now(); } } public void changeStatus(PostStatus status) { if (status == PostStatus.PUBLISHED && this.status != PostStatus.PUBLISHED) { this.publishedAt = LocalDateTime.now(); } this.status = status; }
빌더에서 status가 PUBLISHED이면 생성 시점이 곧 publishedAt이 됩니다. changeStatus()에서는 DRAFT → PUBLISHED 전환 시에만 현재 시각을 기록하고, 이미 PUBLISHED인 상태에서 다시 호출되면 덮어쓰지 않습니다.
피드 쿼리도 커서 기반 페이징 기준을 publishedAt으로 바꿨습니다.
WHERE p.status = 'PUBLISHED' AND (:cursorPublishedAt IS NULL OR p.publishedAt < :cursorPublishedAt OR (p.publishedAt = :cursorPublishedAt AND p.id < :cursorId)) ORDER BY p.publishedAt DESC, p.id DESC
첫 페이지 요청 시 cursorPublishedAt이 NULL이므로 IS NULL 조건으로 전체를 가져오고, 2페이지부터는 마지막 글의 publishedAt과 id를 커서로 넘겨서 그 이후 글만 조회하는 방식입니다.
마이그레이션 타이밍 함정
여기까지는 순탄했습니다. 문제는 배포 과정에서 생겼는데, 솔직히 이건 좀 아팠습니다.
DB 마이그레이션을 먼저 적용했는데 백엔드 코드 배포는 아직 안 한 상태였거든요. 그 사이에 누군가 블로그 글을 발행했습니다.
┌──────────────────────────────────────────────────────┐ │ 1. DB 마이그레이션 적용 │ │ → published_at 컬럼 추가 │ │ → 기존 376건 백필 완료 │ │ │ │ 2. 새 백엔드 코드 아직 미배포 │ │ → 구 코드에는 publishedAt 필드가 없음 │ │ │ │ 3. 누군가 블로그 글 작성 (PUBLISHED) │ │ → 구 코드의 빌더: publishedAt 필드 자체가 없음 │ │ → DB에 published_at = NULL로 저장됨 │ │ │ │ 4. 새 코드 배포 완료 │ │ → 피드 쿼리가 publishedAt 기준으로 전환됨 │ │ → NULL인 글은 피드에서 사라짐 │ └──────────────────────────────────────────────────────┘
다음 그림은 DB 마이그레이션과 코드 배포 사이의 위험한 간극을 보여줍니다.

DB 마이그레이션(STEP 1)과 새 코드 배포(STEP 3) 사이에 구 코드가 실행되는 DANGER ZONE이 존재합니다. 이 구간에서 작성된 글은 published_at = NULL로 저장되어, 새 코드 배포 후 피드에서 유령 글이 됩니다.
DB를 뒤져보니 377건 중 딱 1건만 published_at이 NULL이었습니다. 글 자체는 멀쩡하게 존재하는데, 피드에서는 보이지 않는 유령 글이 된 겁니다.
NULL이 피드에서 사라지는 원리
MySQL에서 NULL은 어떤 값과 비교해도 결과가 NULL(거짓)입니다.
| 비교식 | 결과 |
|---|---|
NULL < '2026-02-24 17:00:00' | NULL (거짓) |
NULL = '2026-02-24 17:00:00' | NULL (거짓) |
NULL > '2026-02-24 17:00:00' | NULL (거짓) |
ORDER BY published_at DESC에서 NULL은 맨 뒤로 밀립니다. 첫 페이지에서는 cursorPublishedAt이 NULL이라 (:cursorPublishedAt IS NULL) 조건으로 통과할 수 있긴 한데, 정렬에서 맨 뒤로 밀리니까 첫 페이지에 걸리지 않습니다.
진짜 문제는 2페이지부터입니다. 커서 값이 non-null이므로 p.publishedAt < :cursorPublishedAt 비교에서 NULL은 영원히 거짓이거든요. 어느 페이지를 넘기든 이 글은 나오지 않습니다.
첫 페이지: ORDER BY에서 맨 뒤 → 첫 페이지 밖으로 밀림 2페이지~: published_at < (non-null 커서) → NULL은 항상 거짓 → 영원히 조회 불가
다음 그림은 커서 기반 페이징에서 NULL 데이터가 어떻게 영원히 사라지는지 보여줍니다.

1페이지에서 ORDER BY published_at DESC에 의해 NULL은 맨 뒤로 밀려 페이지 밖으로 나갑니다. 2페이지부터는 커서 값이 non-null이므로 published_at < 커서값 비교에서 NULL은 항상 FALSE를 반환하여 어떤 페이지에서도 조회되지 않습니다.
해결
즉시 데이터 수정
UPDATE unified_post SET published_at = created_at WHERE id = 13882 AND published_at IS NULL;
NULL이었던 1건을 created_at 값으로 채워서 피드에 복귀시켰습니다.
코드의 안전장치는 충분했는가
HomeService에는 이미 폴백 로직이 있었습니다.
LocalDateTime displayDate = post.getPublishedAt() != null ? post.getPublishedAt() : post.getCreatedAt();
화면에 표시할 날짜를 고르는 부분인데, publishedAt이 NULL이면 createdAt으로 대체해서 보여줍니다. 문제는 이게 이미 조회된 글에만 동작한다는 겁니다. 쿼리 결과에서 빠져버린 글을 살리지는 못하죠. 안전장치가 쿼리 레벨이 아니라 표시 레벨에만 있었던 셈입니다.
재발 방지
새 코드가 배포되면 빌더와 changeStatus()에서 publishedAt을 자동으로 채우니까 NULL이 생길 일은 없습니다. DB 레벨에서 DEFAULT 값을 거는 방법도 생각해봤는데, DRAFT 글에서는 NULL이 정상이라 기본값으로 막을 수가 없었습니다. 애플리케이션 코드에서 빈틈없이 관리하는 수밖에요.
교훈: 마이그레이션과 코드 배포의 순서
돌이켜보면 마이그레이션 순서가 문제였습니다. 컬럼 추가, 데이터 백필, 쿼리 전환을 한꺼번에 해놓고 코드는 나중에 배포했거든요. 그 간극 동안 이전 버전 코드가 새 컬럼에 NULL을 넣은 겁니다.
안전한 순서는 이렇게 가야 합니다.
┌──────────────────────────────────────────────┐ │ 1. 새 컬럼 추가 (NULLABLE, 기본값 없이) │ │ 2. 새 코드 배포 (컬럼에 쓰기 시작) │ │ 3. 기존 데이터 백필 │ │ 4. 쿼리를 새 컬럼 기준으로 전환 │ │ 5. (선택) NOT NULL 제약 추가 │ └──────────────────────────────────────────────┘
다음 그림은 이번에 실수한 순서와 안전한 순서를 비교합니다.

핵심 차이는 코드 배포 타이밍입니다. 안전한 순서에서는 컬럼 추가 직후 코드를 먼저 배포하여 새 컬럼에 값을 쓰기 시작한 뒤, 백필과 쿼리 전환을 순서대로 진행합니다. 쿼리 전환은 항상 마지막이어야 NULL 데이터가 누락되는 사고를 방지할 수 있습니다.
이번에는 1 → 3 → 4를 한꺼번에 하고 2를 나중에 했습니다. 코드 배포(2번)가 쿼리 전환(4번)보다 늦었으니, 이전 버전 코드가 만든 NULL 데이터를 새 쿼리가 놓쳐버린 거죠.
| 이번 순서 | 안전한 순서 | 차이 |
|---|---|---|
| 1. 컬럼 추가 | 1. 컬럼 추가 | 동일 |
| 2. 백필 | 2. 코드 배포 | 코드가 먼저 |
| 3. 쿼리 전환 | 3. 백필 | 백필이 뒤로 |
| 4. 코드 배포 | 4. 쿼리 전환 | 쿼리 전환이 마지막 |
커서 기반 페이징은 NULL에 특히 취약합니다. 오프셋 기반이었다면 ORDER BY에서 맨 뒤로 밀리더라도 마지막 페이지에는 나왔을 겁니다. 커서 기반은 이전 페이지의 마지막 값으로 다음 데이터를 가져오는데, NULL과의 비교가 항상 거짓이니 아예 조회 자체가 안 됩니다.
COALESCE(published_at, created_at)를 쿼리에 넣으면 NULL을 방어할 수 있긴 한데, 함수를 감싸면 인덱스를 못 탑니다. 코드 레벨에서 NULL이 안 생기게 하는 게 근본적인 해결이고, 그 코드가 배포되기 전에 쿼리를 전환하면 안 된다는 게 이번에 얻은 교훈입니다.
정리
| 항목 | 내용 |
|---|---|
| 원래 문제 | DRAFT→PUBLISHED 전환 시 피드에서 created_at 기준으로 과거 위치에 노출 |
| 해결 | published_at 컬럼 추가, 피드 정렬 기준 변경 |
| 예상 못한 문제 | 마이그레이션과 코드 배포 사이 간극에서 published_at = NULL 데이터 발생 |
| NULL의 영향 | 커서 기반 페이징에서 해당 글이 어떤 페이지에서도 조회 불가 |
| 근본 원인 | 쿼리 전환(4단계)을 코드 배포(2단계)보다 먼저 적용 |
NULL 하나가 글 하나를 피드에서 완전히 지워버렸습니다. 마이그레이션 자체는 간단했는데, 배포 순서의 빈틈이 커서 기반 페이징의 NULL 취약점과 만나니까 눈에 안 보이는 버그가 됐습니다. "DB 먼저, 코드 나중에"가 항상 안전한 건 아닙니다.






댓글
댓글을 작성하려면 이 필요합니다.