Skip to content

fix(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용 - #75

Merged
colosair merged 4 commits into
devfrom
fix/S15P11A705-125-published-at-invariant
Jul 29, 2026
Merged

fix(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용#75
colosair merged 4 commits into
devfrom
fix/S15P11A705-125-published-at-invariant

Conversation

@colosair

@colosair colosair commented Jul 28, 2026

Copy link
Copy Markdown
Member

요약

back#58 §5 published_at nullable(정합성 항목)을 DB 불변식으로 올립니다. 기존 NULL을 created_at으로 백필한 뒤 DEFAULT now()·NOT NULL을 적용하고 JPA 매핑에도 nullable = false를 선언합니다. Feed 계약 문서 PR(#74)과 같은 티켓에서 나왔지만 의도적으로 분리했으며, 분리 근거는 아래 "왜 Feed 문서 PR과 분리했는지"에 적었습니다.

Feed 런타임 구현은 이 PR에 없습니다(S15P11A705-120의 범위).

Jira (필수)

관련 GitHub Issue (선택)

배경

Collection 엔티티의 @PrePersistpublished_at = created_at을 채우고 있어서 실제로는 항상 값이 있습니다. 그런데 그 보증이 애플리케이션 코드 한 곳에만 있고 DB 컬럼은 nullable입니다.

문제는 Feed 후보 인덱스 ix_collection_feed(is_published, published_at DESC) WHERE deleted_at IS NULLpublished_at을 정렬 키로 쓴다는 점입니다. PostgreSQL의 DESC는 기본이 NULLS FIRST이므로, JPA를 우회한 native INSERT나 결함 있는 쓰기 경로가 NULL을 하나라도 만들면 그 행이 최신 발행 채널 맨 앞을 차지합니다. 컴파일도 테스트도 잡아 주지 않습니다.

무결성을 앱이 아니라 DB에 두는 BD-10의 적용 사례입니다. back#58도 "제약을 올리는 쪽이 방어 지점이 하나로 모여 낫다"고 봤고, 대안이었던 "Feed 쿼리에서 NULLS LAST 명시"는 방어를 쿼리마다 반복해야 해서 채택하지 않았습니다(BD-29에 기록).

왜 Feed 문서 PR과 분리했는지

독립 리뷰·병합이 가능한 DB 불변식이라고 판단해 분리했습니다. 근거는 셋입니다.

  1. 의존 방향이 한쪽뿐입니다. Feed 명세는 published_at이 NOT NULL이 아니어도 문장이 성립합니다(현행 명세가 이미 그렇습니다). 반대로 이 제약은 Feed 명세가 어떻게 확정되든 그대로 옳습니다. 자동 발행(BD-23)이 유지되는 한 값이 비어 있으면 안 되기 때문입니다. Feed가 이 PR을 기다릴 이유는 있어도, 이 PR이 Feed 문서를 기다릴 이유는 없습니다.
  2. back#58이 이미 성격을 갈라 두었습니다. §1~§4는 "선행 결정 / 블로킹 / 대외 계약"이고 §5만 "정합성 — 조치가 작다"입니다. §2·§3은 AI 파트 합의가 필요하다고 명시돼 있어 리뷰어와 리뷰 축이 다릅니다.
  3. 리뷰 대상이 다릅니다. 이쪽은 마이그레이션 안전성·배포 호환성·테스트이고, 저쪽은 점수 공식과 대외 API 계약입니다. 한 PR에 섞으면 스키마 변경이 문서 리뷰에 묻힙니다.

병합 순서 제약도 없습니다. 어느 쪽이 먼저 들어가도 다른 쪽이 깨지지 않습니다.

변경 사항

마이그레이션 · 스키마

  • src/main/resources/db/migration/V4__collection_published_at_not_null.sql — 기존 NULL을 created_at으로 백필한 뒤 같은 마이그레이션에서 DEFAULT now()NOT NULL을 적용합니다. 기본값을 함께 두는 이유NOT NULL만 걸면 컬럼을 생략한 DB 직접 INSERT가 "NULL이 들어간다"에서 "실패한다"로 바뀌기 때문입니다. 방어선을 세우려다 정상 경로를 깨지 않게 했습니다.
  • src/main/resources/db/migration/README.md — 백엔드 구간 현황(V2 member / V3 core / V4 published_at)을 적어 다음 번호를 잡을 때 파일 목록을 뒤지지 않게 했습니다.
  • Collection.javapublishedAt 매핑에 nullable = false를 선언해 DB와 JPA 스키마 검증이 어긋나지 않게 했습니다.
  • 기존 (is_published, published_at DESC) 부분 인덱스는 건드리지 않았습니다.

테스트

  • PublishedAtMigrationTests (신규) — 아래 "테스트 / 검증"을 참조해 주세요.
  • FlywayMigrationTests — 적용 버전 집합 단언에 "2", "3", "4"를 추가했습니다. 기존 단언이 "1", "100", "101", "102"로 백엔드 구간을 아예 보지 않아서 백엔드 마이그레이션이 통째로 빠져도 통과하는 상태였습니다.
  • FlywayOutOfOrderTests — out-of-order 적용 후 버전 집합에 "4"를 추가했습니다.

기록

  • docs/backend/decisions/BD-29-published-at-database-invariant.md (신규) — 결정과 대안, 배포 호환성, 재검토 트리거(예약 발행 도입 시 BD-23과 함께 재검토)를 담았습니다.
  • docs/backend/implements/BI-18-2026-07-28-published-at-invariant.md (신규) — 산출물과 검증 방법입니다.
  • docs/backend/WORKLOG.md — 한 줄 추가했습니다.
  • docs/backend/decisions/README.md·implements/README.md — 위 두 건을 등재했습니다. 아래 "리뷰 포인트 2"를 참조해 주세요.

테스트 / 검증

  • ./gradlew clean check --no-daemonBUILD SUCCESSFUL, 테스트 186건 통과 / 실패 0 / 오류 0. 커밋된 상태에서 최신 origin/dev(cc7753c) 기준으로 재실행했습니다
  • DB 변경 시 PostgreSQL 통합 테스트 — Testcontainers를 사용했고 H2는 없습니다
  • migration 변경 시 빈 DB migration 테스트 — PublishedAtMigrationTests 1건 통과. FlywayMigrationTests 7건, FlywayOutOfOrderTests 2건 통과
  • API 계약 변경 시 관련 문서 갱신 — API 계약 변경 없음. 응답 스키마·엔드포인트 변화가 없습니다
  • 되돌리기 어려운 결정 → BD-29 추가
  • git diff --check 통과

PublishedAtMigrationTests통과만으로는 증거가 되지 않는 형태를 피했습니다. 최신 스키마에 대고 "NOT NULL이네"를 확인하면 백필 로직이 없어도 통과합니다. 그래서 target("3")으로 V3까지만 적용한 별도 DB를 만들고 거기에 published_at IS NULL인 행을 심은 뒤 최신 버전으로 마이그레이션해서 확인합니다.

  1. 심어 둔 NULL 행의 published_at이 그 행의 created_at과 같습니다 (백필이 실제로 돌았습니다)
  2. information_schema에서 is_nullable = 'NO'이고 column_defaultnow()가 있습니다
  3. published_at을 생략한 native INSERT가 NULL이 아닌 값을 받습니다 (DB 기본값이 실제로 작동합니다)

리뷰 포인트

  1. 배포 호환성(RollingUpdate)을 확인 부탁드립니다. 마이그레이션이 먼저 적용되고 구 Pod가 잠시 남는 구간에서, 구 코드도 모든 Collection INSERT에서 @PrePersist로 값을 채우므로 NOT NULL 위반이 나지 않는다고 판단했습니다. JPA를 우회해 Collection을 INSERT하는 경로가 현재 코드베이스에 없다는 전제입니다.

  2. 문서 인덱스 백필을 이 PR에 포함했습니다 (범위 판단이 필요합니다). decisions/README.md에 BD-28(readiness), implements/README.md에 BI-08~BI-13 행이 없었습니다. 파일은 dev에 있는데 인덱스에만 누락된 상태였습니다. BD-29·BI-18을 등재하면서 BD-27 다음이 BD-29가 되고 BI-07 다음이 BI-14가 되는 구멍이 그대로 드러나서 함께 채웠습니다. 별도 PR로 빼는 게 낫다고 보시면 그렇게 하겠습니다.

  3. DEFAULT now()의 타당성을 봐 주세요. 애플리케이션 생성 경로는 created_at같은 값을 쓰고, 컬럼을 생략한 DB 직접 INSERT는 DB 시각을 씁니다. 두 값이 미세하게 다를 수 있는데, DB 직접 INSERT는 정상 경로가 아니라 방어선이므로 허용했습니다.

  4. 재검토 트리거를 남겨 두었습니다. 예약 발행처럼 "발행 전 NULL"이 의미를 갖는 기능이 들어오면 이 제약과 BD-23(자동 발행)을 함께 되돌려야 합니다. BD-29에 명시했습니다.

미결 / 후속

  • back#58 §1~§4(구현 주체·category·region·API 계약)는 짝 PR #74에서 다룹니다.
  • back#58 §6 record_count 불일치 탐지는 이슈가 "지금 조치 불필요"로 둔 관찰 항목이라 손대지 않았습니다.
  • Feed 런타임 구현은 S15P11A705-120입니다.
  • back#63(공개·소유자 경계 통합)은 이 PR에 흡수하지 않았습니다.

🤖 Generated with Claude Code

@colosair
colosair requested a review from minyongP July 28, 2026 10:12
@colosair
colosair force-pushed the fix/S15P11A705-125-published-at-invariant branch from 51acca2 to aa70c27 Compare July 28, 2026 10:23
@colosair colosair changed the title feat(S15P11A705-125): Collection published_at을 DB 불변식으로 강제한다 feat(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용 Jul 28, 2026
@colosair
colosair force-pushed the fix/S15P11A705-125-published-at-invariant branch from aa70c27 to f3c6785 Compare July 28, 2026 10:26
@colosair colosair changed the title feat(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용 fix(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용 Jul 28, 2026
@colosair

Copy link
Copy Markdown
Member Author

📎 정본 문서 반영 PR — docs#22

이 PR이 DB로 내린 collection.published_at 불변식을 docs 정본에 반영했습니다.

  • static/06_데이터모델_및_무결성.md — §2.6 스키마 표를 TIMESTAMPTZ NOT NULL DEFAULT now()로, 자동 발행 근거와 created_at 백필 규칙을 함께 명시. §4.1 "DB가 보장하는 것" 표에 "Collection 발행 시각 누락 금지" 행 추가
  • static/07_ERD.md — COLLECTION 블록의 published_at에 제약 표기

병합 순서: 이 PR(back#75)이 먼저입니다. V4 마이그레이션이 적용되기 전에 docs#22만 병합되면, 정본은 NOT NULL을 보장한다고 선언하는데 실제 컬럼은 nullable인 구간이 생기고 정본을 읽는 Front·AI가 잘못된 전제로 작업하게 됩니다. docs#22 본문에도 같은 내용을 적어두었습니다.

두 PR 모두 S15P11A705-125이며, docs#22가 이 티켓의 3번째이자 마지막 PR입니다.

@minyongP minyongP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고치려는 문제 자체에는 동의합니다. published_at이 NULL이면 PostgreSQL의 DESC 정렬이 NULL을 앞에 두므로 Feed 최신순 후보 상단이 오염되고, 지금 그 보증이 엔티티 코드뿐이라는 것도 맞습니다(05-2_Backend_AI_접점계약.md 34줄도 같은 말을 합니다). 백필 UPDATE도 필요합니다.

다만 제약의 범위가 넘쳤다고 봅니다. NOT NULL DEFAULT now() 대신 CHECK로 바꾸는 것을 제안합니다. 오늘 얻는 보호는 완전히 같으면서 비공개 전환이 들어와도 풀 필요가 없습니다.


1. is_published가 있는 한 published_at NOT NULL은 스키마가 스스로 모순됩니다

같은 개념을 표현하는 두 컬럼이 반대되는 전제를 답니다.

컬럼 선언하는 것
is_published BOOLEAN NOT NULL DEFAULT TRUE false일 수 있다 — 그래서 컬럼이 있습니다
published_at TIMESTAMPTZ NOT NULL DEFAULT now() 발행 시각이 없는 행은 없다 = 미발행 상태는 표현 불가

이러면 is_published = false AND published_at = '2026-07-28'이 합법 상태가 됩니다. 어떤 정책으로도 해석할 수 없는 행입니다. 제약의 목적이 의미 없는 상태를 없애는 것인데, 이 제약은 하나를 새로 만듭니다.

2. BD-29가 BD-23의 결론은 인용하면서 그 논거를 뒤집습니다

BD-29는 전제로 "생성 즉시 자동 발행(BD-23)"을 씁니다. 그런데 BD-23이 is_published남기기로 한 이유가 이것입니다.

(b) … "도입 시 컬럼 추가 없이 값과 필터만 다루면 된다"
"무의미한 필터 — … 그럼에도 붙여둔다. 비공개가 도입되는 순간 빠뜨린 곳이 곧 유출 지점이 되기 때문이다."

BD-23은 "비공개는 온다"에 값을 걸고 능동적으로 컬럼을 남겼습니다. BD-29는 그 결론을 근거로 삼으면서 "비공개는 안 온다"를 DB에 못박습니다.

3. DEFAULT now()는 문서화된 불변식을 표현조차 못 합니다

BD-23과 BI-10이 적은 불변식은 published_at = created_at입니다. 그런데

  • DEFAULT now()트랜잭션 시작 시각(DB 서버 시계)
  • created_atSpring Data auditing(애플리케이션 시계)

컬럼을 생략한 DB 직접 INSERT는 두 값이 다른 행을 만듭니다. BD-29가 방어 대상으로 지목한 그 경로에서 나오는 값이 불변식을 위반합니다. 애초에 DEFAULT는 다른 컬럼을 참조할 수 없어 이 불변식을 표현할 수단이 아닙니다.

방어 논리 자체도 뒤집힙니다. "결함이 있는 쓰기 경로"를 막는 게 목적이라면 그 경로는 실패해야 합니다. DEFAULT now()는 그럴듯한 틀린 값을 채워 실패를 지웁니다.

4. 비공개 기능이 들어오면 어떻게 터지는지

3개월 비공개로 뒀다가 오늘 공개한 Collection을 가정합니다. 공개 시점에 published_at을 갱신하는 코드를 빠뜨려도 NOT NULL은 통과합니다 — 값은 이미 있으니까요.

경로 결과
recency = exp(-ageDays / 14) exp(-90/14) ≈ 0.0016 — 사실상 0
최신 발행 채널 ORDER BY published_at DESC LIMIT 100 3개월 전 위치라 100건 안에 못 듦
ix_collection_feed 같은 정렬이라 같이 어긋남

공개했는데 피드에 안 뜨고, 로그에는 아무것도 안 남습니다. #74가 최신 채널 배분을 60에서 100으로 올려 최신성 의존도를 키웠기 때문에 체감은 더 큽니다.

반대로 갱신하기로 하면 published_at의 의미가 "최초 발행"에서 "마지막 발행"으로 바뀌고, 비공개↔공개를 반복해 recency를 리셋하는 경로가 열립니다. 어느 쪽이든 결정이 필요한데 지금 문서는 이 질문을 하지 않습니다.


제안하는 형태

-- Collection은 MVP에서 생성 즉시 자동 발행된다(BD-23, BD-29).
-- 기존 NULL은 생성 시각으로 복구한다.
UPDATE core.collection
SET published_at = created_at
WHERE published_at IS NULL;

-- 발행된 행은 발행 시각을 반드시 갖는다. 컬럼 자체를 NOT NULL로 만들지 않는 이유는
-- is_published가 false일 수 있다는 전제 위에 존재하기 때문이다(BD-23).
ALTER TABLE core.collection
    ADD CONSTRAINT ck_collection_published_at
        CHECK (NOT is_published OR published_at IS NOT NULL);

쌍조건(is_published = (published_at IS NOT NULL))이 아니라 함의 한 방향만 거는 게 핵심입니다. 그래야 나중에 발행 취소가 들어와도 과거 발행 시각을 남길 수 있습니다.

NOT NULL DEFAULT now() CHECK 함의형
오늘 보호 범위 전 행 전 행 — 동일합니다. is_published가 전부 true이므로
Feed NULL 정렬 오염 막음 막음. 후보 쿼리 3개가 전부 WHERE c.is_published = true이므로 CHECK가 그 집합을 보증합니다
비공개 생성 도입 제약 해제 필요 그대로 유효
발행 취소 도입 제약 해제 필요 그대로 유효
두 컬럼 어긋남 탐지 못 함
틀린 값 은폐 now()가 채움 없음 — 안 채우면 위반으로 실패합니다

오늘 얻는 보호가 같은데 미래에 대해 거짓말을 하지 않습니다. BD-29가 실제로 원한 불변식은 "모든 행에 값이 있다"가 아니라 **"발행된 행에 값이 있다"**였고, 전자로 적어서 범위가 넘친 것으로 읽힙니다.

함께 바뀌어야 하는 것들입니다.

  • Collection.java@Column(name = "published_at", nullable = false)를 원래대로 되돌립니다. 같은 주장의 JPA 쪽 복제본입니다. ddl-auto: validate이므로 스키마 검증은 깨지지 않습니다.
  • PublishedAtMigrationTestsis_nullable = 'NO'column_default contains now() 단언을 CHECK 제약 존재 확인으로 바꾸고, is_published = false로 넣은 행이 published_at IS NULL이어도 통과하는지, is_published = true인데 NULL이면 거부되는지 두 케이스를 추가하면 제약의 의도가 테스트로 고정됩니다.
  • BD-29 — "결정"을 CHECK 기준으로 다시 쓰고, "향후 예약 발행처럼…" 문장은 제약을 풀 필요가 없어지므로 정리합니다.
  • docs#2206_데이터모델_및_무결성.md §2.6을 TIMESTAMPTZ NOT NULL DEFAULT now()가 아니라 nullable 유지 + §4.1 "DB가 보장하는 것"에 CHECK 한 줄로 바꿔야 합니다. 머지 순서가 이 PR 다음이라 여유는 있습니다.

별도 후속 (이 PR 범위 밖)

published_at왜 존재하는지에 대한 근거를 문서에서 못 찾았습니다.

  • 06_데이터모델_및_무결성.md §2.6에서 is_published("향후 공개·비공개 전환을 위해")와 record_count("N+1 없이 제공하기 위해")에는 근거 불릿이 있는데 published_at만 없습니다.
  • BD-23의 한 줄("Feed 후보 스캔 인덱스가 (is_published, published_at DESC)이므로 컬럼이 없으면 이 인덱스 형태가 성립하지 않는다")은 순환입니다. 인덱스를 (is_published, created_at DESC)로 정의하면 됩니다.
  • 실제로 값을 쓰는 곳은 @PrePersist 한 곳이고 created_at과 항상 같습니다. Feed 응답(08_API_명세.md §10.1)은 publishedAt이 아니라 createdAt을 내보냅니다.

BD-23에 "published_at은 현재 created_at의 별칭이며, 값이 갈라지는 것은 비공개 전환 도입 이후다"를 한 줄 넣어주시면 좋겠습니다. 컬럼 자체를 없앨지는 Feed 구현(S15P11A705-120) 전에 별도로 보는 게 맞다고 보고, 이 PR에서 다룰 일은 아닙니다.

덧붙여 BD-23의 재검토 트리거 3항목(세 경로 필터 / record_count 정의 / 팔로워 Library)에 published_at이 없습니다. CHECK로 가면 제약을 풀 필요는 없어지지만, "공개 시점에 published_at을 갱신한다"는 여전히 그때 결정할 항목이라 4번으로 추가해두면 좋겠습니다.

@colosair

Copy link
Copy Markdown
Member Author

리뷰 수용했습니다. d758fa5에 반영했고 docs#22ebf9a18로 함께 고쳤습니다.

수용 사유

세 논거가 전부 맞습니다. 특히 3번이 결정적이었습니다.

방어 대상으로 지목한 것이 "결함이 있는 쓰기 경로"인데 DEFAULT now()는 그 경로를 성공시킵니다. 막으려던 것을 통과시키면서 값까지 그럴듯하게 채워 넣으니, 나중에 published_at이 틀렸다는 걸 알아낼 방법도 같이 없앱니다. 제약을 걸어놓고 제약이 잡아야 할 것을 기본값으로 덮은 셈입니다.

1번도 그대로 맞습니다. published_atis_published가 false일 수 있다는 전제 위에 있는 컬럼인데, 컬럼 자체를 NOT NULL로 만들면 스키마가 자기 존재 근거를 부정합니다. 2번의 지적 — BD-23의 결론은 인용하면서 그 논거를 뒤집는다 — 은 BD-29를 다시 읽어보니 변명할 여지가 없었습니다.

보호 범위가 같다는 근거도 확인했습니다. Feed 후보 쿼리 세 개가 전부 WHERE c.is_published = true를 걸므로, CHECK가 정확히 그 집합에 발행 시각이 있음을 보증합니다. 오늘 얻는 것이 같은데 미래에 대해 거짓말을 하지 않는 쪽을 고르지 않을 이유가 없습니다.

항목별 처리

1. 마이그레이션 — 제안하신 형태 그대로입니다. 백필 UPDATE는 유지했습니다.

ALTER TABLE core.collection
    ADD CONSTRAINT ck_collection_published_at
        CHECK (NOT is_published OR published_at IS NOT NULL);

파일명도 V4__collection_published_at_invariant.sql로 바꿨습니다. _not_null이 더 이상 내용과 맞지 않는데, Flyway는 적용 이력을 파일명으로 관리하므로 먼저 확인했습니다 — devV4가 없고 이 브랜치가 미병합이라 적용된 환경이 없어 안전합니다. 버전 번호는 그대로 4FlywayMigrationTests·FlywayOutOfOrderTests는 손대지 않았습니다.

2. Collection.javanullable = false를 되돌렸습니다. ddl-auto: validate가 nullable 컬럼을 nullable 매핑으로 검증하므로 CI에서 확인됐습니다.

3. 테스트is_nullable = 'NO'·column_default 단언을 pg_constraint 조회로 바꾸고, 말씀하신 두 케이스에 하나를 더했습니다.

케이스 기대
is_published = false + published_at IS NULL 통과 — 비공개 생성이 들어와도 제약을 풀 필요가 없음
is_published = true + published_at IS NULL 거부 — 제약 이름으로 확인
is_published = false + published_at 있음 통과 — 발행 취소 후에도 과거 시각 보존

세 번째를 넣은 이유는 앞의 두 개만으로는 쌍조건도 똑같이 통과하기 때문입니다. is_published = (published_at IS NOT NULL)로 바꿔 돌려봤더니 세 번째에서만 깨졌습니다 — "함의 한 방향"이라는 결정이 이 케이스 하나에 걸려 있어서, 그게 없으면 나중에 누가 쌍조건으로 조여도 테스트가 통과합니다. 컬럼의 is_nullableYES로 남는지도 함께 확인합니다. 백필 검증은 유지했고, 백필이 빠지면 ADD CONSTRAINT 자체가 실패하므로 마이그레이션 통과도 백필의 증거가 됩니다.

4. BD-29 — "결정"을 CHECK 기준으로 다시 썼습니다. 제약을 풀 필요가 없어져 "향후 예약 발행처럼…" 문장은 걷어냈고, 대신 BD-23의 전제를 뒤집지 않고 존중하는 형태임을 명시했습니다. 막아야 할 것이 "모든 행에 값이 있다"가 아니라 "발행된 행에 값이 있다"였다는 것도 맥락에 적었습니다. 재검토 트리거는 제약이 아니라 "공개 시점에 published_at을 갱신하는가"로 바꿨습니다 — 지적하신 대로 그건 어느 쪽을 택하든 남는 질문이고, 제약 자체는 건드릴 필요가 없습니다.

BI-18에는 초안이 왜 바뀌었는지 이력을 남겼습니다. 기록을 지우지 않고 전환 근거를 남기는 편이 맞다고 봤습니다.

5. docs#2206 §2.6 스키마 표를 TIMESTAMPTZ NULL로 되돌리고, 컬럼을 NOT NULL로 만들지 않는 이유를 규칙에 적었습니다. §4.1은 "발행된 Collection의 발행 시각 누락 금지 / CHECK (...)"로 바꿨고 07 ERD 표기도 맞췄습니다. 08 API 명세의 publishedAt 응답 필드는 제약 형태와 무관해 두었습니다.

남긴 것

BD-23 수정은 하지 않았습니다. "이 PR 범위 밖"이라고 명시해주셔서 그대로 따랐습니다. 별도 후속으로 두 건입니다.

  1. "published_at은 현재 created_at의 별칭이며, 값이 갈라지는 것은 비공개 전환 도입 이후다" 한 줄 추가
  2. 재검토 트리거 4번 — "공개 시점에 published_at을 갱신하는가"

published_at 컬럼 자체의 존치 여부는 지적하신 대로 Feed 구현(S15P11A705-120) 전에 별도로 보는 게 맞다고 봅니다. 인덱스 논거가 순환이라는 것도 동의합니다 — (is_published, created_at DESC)로 정의하면 되는 게 맞습니다.

검증

./gradlew clean check --no-daemon — 186 tests, 0 failures (d758fa5). backend-ci / check pass 2m35s.

@colosair

Copy link
Copy Markdown
Member Author

published_at 존치 근거 — 별도 후속에 대한 회신

CHECK 전환은 반영했고, 마지막 절의 근거 부재 지적도 두 항목은 그대로 수용합니다. 다만 컬럼 자체는 유지가 맞다고 봅니다. 근거를 순환이 아닌 형태로 다시 세워 드립니다.

수용하는 것

06 §2.6에 근거 불릿이 없다 — 맞습니다. is_published("향후 공개·비공개 전환을 위해")와 record_count("N+1 없이 제공하기 위해")에는 있는데 published_at만 비어 있습니다.

BD-23의 근거가 순환이다 — 이것도 맞습니다. *"Feed 후보 스캔 인덱스가 (is_published, published_at DESC)이므로 컬럼이 없으면 이 인덱스 형태가 성립하지 않는다"*는 인덱스를 그렇게 정의했기 때문에 성립하는 문장입니다. 컬럼의 존재 이유가 될 수 없습니다.

사실 관계 하나를 정정합니다

Feed 응답(08_API_명세.md §10.1)은 publishedAt이 아니라 createdAt을 내보냅니다.

08_API_명세.md §7.1 Collection 생성 응답에 둘 다 있습니다.

"publishedAt": "2026-07-23T10:00:00Z",
"createdAt": "2026-07-23T10:00:00Z"

그리고 Feed 명세는 네 곳에서 명시적으로 의존합니다.

feed-scoring.md      ORDER BY c.published_at DESC        후보 쿼리 정렬 (2곳)
feed-scoring.md      (is_published, published_at DESC)   최신 채널의 전제
feed-scoring.md      recency 는 published_at 기준
feed-recommendation.md   타인 Collection 특징으로 나열

*"값을 쓰는 곳은 @PrePersist 한 곳"*은 쓰기 관점에서만 맞습니다. 읽기는 Feed가 하는데 아직 구현 전이라 코드에 없을 뿐입니다(S15P11A705-120). 명세는 이미 계약으로 고정돼 있습니다.

존치 근거 — 순환이 아닌 형태

두 컬럼은 의미가 다릅니다.

created_at     레코드가 만들어진 시각
published_at   공개된 시각

MVP에서 값이 같은 것은 BD-23의 자동 발행 때문이지 같은 개념이어서가 아닙니다. 우연한 일치를 근거로 컬럼을 없애면, 그 우연이 깨지는 순간 되돌려야 합니다.

리뷰 §4가 이 점을 이미 전제하고 있습니다.

3개월 비공개로 뒀다가 오늘 공개한 Collection을 가정합니다. 공개 시점에 published_at을 갱신하는 코드를 빠뜨려도 NOT NULL은 통과합니다

이 문장은 published_at이 "공개 시각"이라는 의미 위에서만 성립합니다. 컬럼을 없애고 created_at으로 대체하면 그 시나리오에서 영원히 3개월 전 기준이 됩니다recency = exp(-90/14) ≈ 0.0016으로 고정되고, 오늘 공개했는데 최신 채널 100건에 영영 들지 못합니다. 지적하신 장애가 "갱신을 빠뜨렸을 때"가 아니라 상시 일어납니다.

recency는 "얼마나 최근에 만들어졌나"가 아니라 "얼마나 최근에 공개됐나"를 재는 값입니다. 이것이 created_at으로 대체할 수 없는 이유이고, 인덱스 형태와 무관한 근거입니다.

BD-23이 is_published를 남긴 논리가 그대로 적용됩니다.

"무의미한 필터 — … 그럼에도 붙여둔다. 비공개가 도입되는 순간 빠뜨린 곳이 곧 유출 지점이 되기 때문이다."

is_published가 "지금은 항상 true지만 비공개는 온다"에 값을 건 것처럼, published_at도 "지금은 created_at과 같지만 갈라진다"에 값을 겁니다. 한쪽만 남기고 다른 쪽을 없애면 같은 전제 위에서 반대 결정을 하는 셈입니다.

제거 비용은 두 번 듭니다. 지금 없애려면 마이그레이션·인덱스·엔티티·Feed 명세 네 곳을 바꿔야 하고, 비공개 전환이 들어올 때 같은 작업을 되돌려야 합니다. 컬럼 유지 비용은 TIMESTAMPTZ 8바이트이고 인덱스는 어차피 필요합니다.

요청

BD-2306 §2.6은 백엔드 소유 문서라 제가 손대지 않겠습니다. 아래를 반영해 주시면 근거 부재가 해소됩니다.

BD-23 — 제안하신 한 줄에 의미 구분을 더해 주시면 좋겠습니다.

published_at은 "공개 시각"으로 created_at("레코드 생성 시각")과 의미가 다르다. MVP에서 값이 같은 것은 생성 즉시 자동 발행하기 때문이며, 비공개 전환이 도입되면 갈라진다. Feed의 recency는 공개 시각을 기준으로 하므로 created_at으로 대체할 수 없다.

재검토 트리거 4번 — 제안하신 대로 추가해 주세요. CHECK로 바꿔 제약을 풀 필요는 없어졌지만, "공개 시점에 published_at을 갱신한다"와 그때 recency가 리셋되는 것을 허용할지는 여전히 그 시점의 결정 사항입니다. 비공개↔공개를 반복해 recency를 되살리는 경로가 열리므로, 갱신 정책과 함께 정해야 합니다.

06 §2.6 — 근거 불릿 한 줄. 위 의미 구분을 요약하면 됩니다.

컬럼 제거 검토 자체에 대해

S15P11A705-120(Feed 구현) 전에 별도로 보자는 판단에 동의합니다. 다만 위 근거로 AI 파트는 유지 의견이고, 스키마 결정은 백엔드 소유이므로 다르게 판단하시면 Feed 명세 네 곳을 created_at 기준으로 바꾸겠습니다. 그 경우 비공개 전환 시 재작업이 생긴다는 점만 기록해 두시면 됩니다.

colosair and others added 3 commits July 29, 2026 11:03
엔티티 @PrePersist가 published_at = created_at을 채우고 있었지만 컬럼은
nullable이라 JPA를 우회한 native INSERT나 결함 있는 쓰기 경로가 NULL을 만들 수
있었습니다. PostgreSQL의 DESC는 NULL을 먼저 두므로 최신순 정렬 상단이 통째로
오염됩니다. 무결성을 앱이 아니라 DB에 두는 BD-10의 적용 사례입니다.

- V4__collection_published_at_not_null.sql — 기존 NULL을 created_at으로 백필한
  뒤 DEFAULT now()와 NOT NULL을 함께 적용합니다. 기본값을 같이 두는 이유는
  NOT NULL만 걸면 컬럼을 생략한 DB 직접 INSERT가 실패로 바뀌기 때문입니다
- Collection.java — publishedAt 매핑에 nullable = false를 선언했습니다
- PublishedAtMigrationTests — target("3")으로 V3까지만 적용한 별도 DB에 NULL 행을
  심고 마이그레이션해 백필·is_nullable·column_default·컬럼 생략 INSERT를
  확인합니다
- FlywayMigrationTests / FlywayOutOfOrderTests — 적용 버전 집합에 "4"를
  추가했습니다
- BD-29 / BI-18 — 결정과 구현 기록입니다. dev가 BD-28·BI-14를 이미 선점해
  재번호했습니다
- decisions/implements README — 위 두 건과 함께, dev에 파일만 있고 인덱스에
  누락돼 있던 BD-28·BI-08~BI-13 행을 채웠습니다

RollingUpdate 중 구 Pod도 모든 Collection INSERT에서 @PrePersist로 값을 채우므로
배포 호환됩니다. (is_published, published_at DESC) 부분 인덱스는 건드리지
않았습니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
back#75 리뷰(minyongP)를 수용했습니다. `NOT NULL DEFAULT now()`는 보호 범위가
넘쳤습니다. `published_at`은 "is_published가 false일 수 있다"는 BD-23의 전제 위에
있는 컬럼인데, 컬럼 자체를 NOT NULL로 만들면 스키마가 그 전제를 부정합니다.
`DEFAULT now()`는 방어 대상인 결함 있는 쓰기 경로의 실패를 그럴듯한 값으로 덮기까지
합니다. 오늘 얻는 보호는 CHECK와 동일합니다 — Feed 후보 쿼리 세 개가 전부
`is_published = true`를 걸므로 CHECK가 정확히 그 집합을 보증합니다.

- V4 마이그레이션 — `ck_collection_published_at`
  `CHECK (NOT is_published OR published_at IS NOT NULL)`로 교체했습니다. 백필
  UPDATE는 그대로입니다. 쌍조건이 아니라 함의 한 방향인 이유는 발행 취소가 들어와도
  과거 발행 시각을 남길 수 있어야 하기 때문입니다. 파일명도 내용에 맞게
  `V4__collection_published_at_invariant.sql`로 바꿨습니다 — dev에 V4가 없고 이
  브랜치가 미병합이라 적용된 환경이 없습니다
- Collection.java — `nullable = false`를 되돌렸습니다. 같은 주장의 JPA 쪽
  복제본입니다. `ddl-auto: validate`는 nullable 컬럼을 nullable 매핑으로 검증합니다
- PublishedAtMigrationTests — `is_nullable = 'NO'`·`column_default` 단언을
  `pg_constraint` 확인으로 바꾸고 세 케이스를 넣었습니다. 미발행+NULL 통과,
  발행+NULL 거부, 미발행+시각 있음 통과. 마지막 케이스가 쌍조건과 함의를 가릅니다
  (쌍조건으로 바꿔 실패를 확인했습니다)
- BD-29 — 결정을 CHECK 기준으로 다시 썼습니다. 제약을 풀 필요가 없어져 "향후 예약
  발행처럼…" 문장을 걷어내고, BD-23의 전제를 뒤집지 않고 존중하는 형태임을 밝혔습니다
- BI-18 / WORKLOG / decisions README / migration README — 위 변경에 맞췄습니다.
  BI-18에는 초안이 왜 바뀌었는지 이력을 남겼습니다

BD-23 수정(`published_at`은 현재 `created_at`의 별칭 / 재검토 트리거 4번)은 리뷰어가
이 PR 범위 밖으로 명시해 별도 후속으로 둡니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
back#73(Google 소셜 로그인)이 먼저 병합되며 V4(social_account)·BD-29·BI-18을
가져갔습니다. 마이그레이션은 파일 이름이 달라 git이 충돌로 잡지 않고, 그대로
병합되면 Flyway가 중복 버전으로 기동을 거부합니다.

dev와 열린 브랜치 전부를 조회해 미사용 번호를 확인한 뒤 V5·BD-33·BI-19로
재배정했습니다. 제약 형태(CHECK 함의형)와 검증 의도는 그대로입니다.

PublishedAtMigrationTests는 사이에 V4가 끼면서 "최신까지 적용했다"만으로는
V5가 실제로 돌았는지 알 수 없게 되어 적용 버전 단언 둘을 추가했습니다 —
NULL 행을 심는 시점이 정확히 {1,2,3}이고, 두 번째 마이그레이션에 V5가
포함된다는 것입니다. V4는 core.collection을 건드리지 않아 target("3")은
그대로 둡니다.
@colosair
colosair force-pushed the fix/S15P11A705-125-published-at-invariant branch from d758fa5 to ef45ce3 Compare July 29, 2026 02:10
@colosair

Copy link
Copy Markdown
Member Author

back#73이 먼저 병합되며 V4·BD-29·BI-18을 선점해 이 PR의 번호를 V5__collection_published_at_invariant.sql·BD-33·BI-19로 재배정하고 최신 dev(d0f7cbf) 기준으로 rebase했습니다 — 제약 형태(CHECK (NOT is_published OR published_at IS NOT NULL))와 검증 의도는 그대로입니다.

@colosair
colosair merged commit a2aad4e into dev Jul 29, 2026
2 checks passed
@colosair
colosair deleted the fix/S15P11A705-125-published-at-invariant branch July 29, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants