fix(S15P11A705-125): Collection published_at 백필 후 DB DEFAULT·NOT NULL 적용 - #75
Conversation
51acca2 to
aa70c27
Compare
aa70c27 to
f3c6785
Compare
|
📎 정본 문서 반영 PR — docs#22 이 PR이 DB로 내린
병합 순서: 이 PR(back#75)이 먼저입니다. 두 PR 모두 S15P11A705-125이며, docs#22가 이 티켓의 3번째이자 마지막 PR입니다. |
minyongP
left a comment
There was a problem hiding this comment.
고치려는 문제 자체에는 동의합니다. 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_at은 Spring 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이므로 스키마 검증은 깨지지 않습니다.PublishedAtMigrationTests—is_nullable = 'NO'와column_default contains now()단언을 CHECK 제약 존재 확인으로 바꾸고,is_published = false로 넣은 행이published_at IS NULL이어도 통과하는지,is_published = true인데 NULL이면 거부되는지 두 케이스를 추가하면 제약의 의도가 테스트로 고정됩니다.- BD-29 — "결정"을 CHECK 기준으로 다시 쓰고, "향후 예약 발행처럼…" 문장은 제약을 풀 필요가 없어지므로 정리합니다.
docs#22—06_데이터모델_및_무결성.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번으로 추가해두면 좋겠습니다.
|
리뷰 수용했습니다. 수용 사유세 논거가 전부 맞습니다. 특히 3번이 결정적이었습니다. 방어 대상으로 지목한 것이 "결함이 있는 쓰기 경로"인데 1번도 그대로 맞습니다. 보호 범위가 같다는 근거도 확인했습니다. Feed 후보 쿼리 세 개가 전부 항목별 처리1. 마이그레이션 — 제안하신 형태 그대로입니다. 백필 ALTER TABLE core.collection
ADD CONSTRAINT ck_collection_published_at
CHECK (NOT is_published OR published_at IS NOT NULL);파일명도 2. 3. 테스트 —
세 번째를 넣은 이유는 앞의 두 개만으로는 쌍조건도 똑같이 통과하기 때문입니다. 4. BD-29 — "결정"을
5. 남긴 것BD-23 수정은 하지 않았습니다. "이 PR 범위 밖"이라고 명시해주셔서 그대로 따랐습니다. 별도 후속으로 두 건입니다.
검증
|
|
엔티티 @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")은
그대로 둡니다.
d758fa5 to
ef45ce3
Compare
|
back#73이 먼저 병합되며 |
요약
back#58 §5
published_atnullable(정합성 항목)을 DB 불변식으로 올립니다. 기존 NULL을created_at으로 백필한 뒤DEFAULT now()·NOT NULL을 적용하고 JPA 매핑에도nullable = false를 선언합니다. Feed 계약 문서 PR(#74)과 같은 티켓에서 나왔지만 의도적으로 분리했으며, 분리 근거는 아래 "왜 Feed 문서 PR과 분리했는지"에 적었습니다.Feed 런타임 구현은 이 PR에 없습니다(S15P11A705-120의 범위).
Jira (필수)
S15P11A705-125· https://ssafy.atlassian.net/browse/S15P11A705-125관련 GitHub Issue (선택)
published_atnullable배경
Collection엔티티의@PrePersist가published_at = created_at을 채우고 있어서 실제로는 항상 값이 있습니다. 그런데 그 보증이 애플리케이션 코드 한 곳에만 있고 DB 컬럼은 nullable입니다.문제는 Feed 후보 인덱스
ix_collection_feed가(is_published, published_at DESC) WHERE deleted_at IS NULL로published_at을 정렬 키로 쓴다는 점입니다. PostgreSQL의DESC는 기본이NULLS FIRST이므로, JPA를 우회한 native INSERT나 결함 있는 쓰기 경로가 NULL을 하나라도 만들면 그 행이 최신 발행 채널 맨 앞을 차지합니다. 컴파일도 테스트도 잡아 주지 않습니다.무결성을 앱이 아니라 DB에 두는 BD-10의 적용 사례입니다. back#58도 "제약을 올리는 쪽이 방어 지점이 하나로 모여 낫다"고 봤고, 대안이었던 "Feed 쿼리에서
NULLS LAST명시"는 방어를 쿼리마다 반복해야 해서 채택하지 않았습니다(BD-29에 기록).왜 Feed 문서 PR과 분리했는지
독립 리뷰·병합이 가능한 DB 불변식이라고 판단해 분리했습니다. 근거는 셋입니다.
published_at이 NOT NULL이 아니어도 문장이 성립합니다(현행 명세가 이미 그렇습니다). 반대로 이 제약은 Feed 명세가 어떻게 확정되든 그대로 옳습니다. 자동 발행(BD-23)이 유지되는 한 값이 비어 있으면 안 되기 때문입니다. Feed가 이 PR을 기다릴 이유는 있어도, 이 PR이 Feed 문서를 기다릴 이유는 없습니다.병합 순서 제약도 없습니다. 어느 쪽이 먼저 들어가도 다른 쪽이 깨지지 않습니다.
변경 사항
마이그레이션 · 스키마
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— 백엔드 구간 현황(V2member /V3core /V4published_at)을 적어 다음 번호를 잡을 때 파일 목록을 뒤지지 않게 했습니다.Collection.java—publishedAt매핑에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-daemon— BUILD SUCCESSFUL, 테스트 186건 통과 / 실패 0 / 오류 0. 커밋된 상태에서 최신origin/dev(cc7753c) 기준으로 재실행했습니다PublishedAtMigrationTests1건 통과.FlywayMigrationTests7건,FlywayOutOfOrderTests2건 통과BD-29추가git diff --check통과PublishedAtMigrationTests는 통과만으로는 증거가 되지 않는 형태를 피했습니다. 최신 스키마에 대고 "NOT NULL이네"를 확인하면 백필 로직이 없어도 통과합니다. 그래서target("3")으로 V3까지만 적용한 별도 DB를 만들고 거기에published_at IS NULL인 행을 심은 뒤 최신 버전으로 마이그레이션해서 확인합니다.published_at이 그 행의created_at과 같습니다 (백필이 실제로 돌았습니다)information_schema에서is_nullable = 'NO'이고column_default에now()가 있습니다published_at을 생략한 native INSERT가 NULL이 아닌 값을 받습니다 (DB 기본값이 실제로 작동합니다)리뷰 포인트
배포 호환성(RollingUpdate)을 확인 부탁드립니다. 마이그레이션이 먼저 적용되고 구 Pod가 잠시 남는 구간에서, 구 코드도 모든 Collection INSERT에서
@PrePersist로 값을 채우므로NOT NULL위반이 나지 않는다고 판단했습니다. JPA를 우회해 Collection을 INSERT하는 경로가 현재 코드베이스에 없다는 전제입니다.문서 인덱스 백필을 이 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로 빼는 게 낫다고 보시면 그렇게 하겠습니다.DEFAULT now()의 타당성을 봐 주세요. 애플리케이션 생성 경로는created_at과 같은 값을 쓰고, 컬럼을 생략한 DB 직접 INSERT는 DB 시각을 씁니다. 두 값이 미세하게 다를 수 있는데, DB 직접 INSERT는 정상 경로가 아니라 방어선이므로 허용했습니다.재검토 트리거를 남겨 두었습니다. 예약 발행처럼 "발행 전 NULL"이 의미를 갖는 기능이 들어오면 이 제약과 BD-23(자동 발행)을 함께 되돌려야 합니다. BD-29에 명시했습니다.
미결 / 후속
record_count불일치 탐지는 이슈가 "지금 조치 불필요"로 둔 관찰 항목이라 손대지 않았습니다.🤖 Generated with Claude Code