Repository navigation
[fix] 애프터노트 PATCH 발행 검증이 playlist 안 songs 생략을 «곡 없음» 으로 읽는다 — 발행된 추억 노트의 미디어만 수정하면 1610 #277
Description
문제
PATCH /api/v1/afternotes/{afternoteId} 의 발행 필수값 검증 AfternoteValidator.validatePlaylistPublish 가 요청에 playlist 키가 있으면 songs 가 null 이거나 비었을 때 곧장 PLAYLIST_SONGS_REQUIRED(400, 1610) 를 던진다. 기존 엔티티의 곡(afternote.getPlaylist().getItems())은 playlist 키가 없을 때만 본다.
private void validatePlaylistPublish(AfternoteCreateRequest request, Afternote afternote) {
if (request.getPlaylist() != null) {
if (request.getPlaylist().getSongs() == null || request.getPlaylist().getSongs().isEmpty()) {
throw new CustomException(ErrorCode.PLAYLIST_SONGS_REQUIRED); // songs 생략도 여기로 온다
}
return;
}
if (afternote.getPlaylist() == null || items == null || items.isEmpty()) {
throw new CustomException(ErrorCode.PLAYLIST_REQUIRED); // 기존 곡은 playlist 키가 없을 때만 본다
}
}그런데 같은 요청의 병합은 songs == null 을 «유지» 로 다룬다 — PlaylistRelationStrategy.update 는 getSongs() != null 일 때만 items 를 갈아 끼운다. 저장 로직은 «생략 = 유지» 를 지키는데 검증기만 «생략 = 없음» 으로 읽어, 실제로는 곡이 그대로 남을 요청을 거절한다.
DTO 문서와도 어긋난다. AfternoteUpdateRequest.playlist 설명은 «playlist 객체 생략 시 플레이리스트 전체 유지. 정식 등록 상태면 요청·기존 합쳐 필수» 인데, 합치는 건 playlist 키가 없는 경우뿐이다. 같은 파일의 credentials 발행 검증 validateCredentialsPublish 는 요청 값 또는 기존 secureContents 로 판정해 약속대로 합친다 — playlist 만 빠졌다.
재현 (발행된 PLAYLIST 노트, 곡 1개 이상 저장돼 있음)
PATCH /api/v1/afternotes/{afternoteId}
Content-Type: application/json
{"playlist":{"memorialPhotoUrl":null}}- 기대: 200. 영정 사진만 삭제되고 곡은 그대로.
- 실제(코드 경로): 400
1610 플레이리스트에는 최소 1곡 이상이 필요합니다.
{"playlist":{"memorialVideo":null}}, {"playlist":{"memorialAudioUrl":null}}, 미디어 교체("memorialPhotoUrl":"afternotes/…") 도 같다 — playlist 안에 songs 를 함께 싣지 않는 모든 수정이 발행 노트에서 막힌다. 요청에 isDraft 가 없으면 저장값(false)으로 발행 상태가 정해지므로 클라이언트 쪽 우회도 없다. draft 노트는 검증기가 돌지 않아 무사하다.
AfternoteValidatorTest 의 수정·발행 테스트 3건은 기존 노트가 전부 draft(draftPlaylist()) 라서 «발행 노트 + 기존 곡 있음 + 요청에 songs 생략» 조합이 없다.
판정 리비전: origin/main 6364a6c (0831 deploy 성공 SHA, 실서버 동일). 코드 경로로 판정했고 실서버 호출 실측은 하지 않았다.
클라이언트 영향
FE 는 지금까지 수정 시 playlist 전체(songs 포함, 기본값 emptyList())를 통째로 보내서 이 검증을 우연히 통과했다. Afternote/Afternote-FE#1626 이 «안 건드린 필드는 키째 생략» 하는 부분 PATCH 로 바꾸면(Afternote/Afternote-FE#1617 lost update 대응) 사진·영상만 고친 요청이 아래 모양으로 나가고, 발행된 추억 노트의 미디어만 수정하는 경로가 전부 1610 이 된다.
{"category":"PLAYLIST","playlist":{"memorialPhotoUrl":null}}FE 가 songs 를 늘 함께 싣는 우회는 FE#1617 이 없애려는 곡 축의 덮어쓰기(다른 기기가 그 사이 추가한 곡이 stale 스냅샷으로 지워짐)를 되살리므로, 서버 쪽에서 닫는 것이 맞다. 요청 모양은 PR 의 AfternoteUpdatePartialPatchWireTest 가 JSON 문자열로 고정해 두었다.
제안
validatePlaylistPublish 에서 요청 songs 가 null(생략) 이면 기존 afternote.getPlaylist().getItems() 로 대체해 최종 상태를 판정한다. 빈 배열 [] 은 «전부 삭제» 의사이므로 지금처럼 1610 을 유지한다.
private void validatePlaylistPublish(AfternoteCreateRequest request, Afternote afternote) {
boolean hasExisting = afternote.getPlaylist() != null
&& afternote.getPlaylist().getItems() != null
&& !afternote.getPlaylist().getItems().isEmpty();
if (request.getPlaylist() == null) {
if (!hasExisting) throw new CustomException(ErrorCode.PLAYLIST_REQUIRED);
return;
}
List<AfternoteCreateRequest.SongRequest> songs = request.getPlaylist().getSongs();
if (songs == null) { // 생략 = 유지 → 기존 곡으로 판정
if (!hasExisting) throw new CustomException(ErrorCode.PLAYLIST_SONGS_REQUIRED);
return;
}
if (songs.isEmpty()) { // [] = 전부 삭제 → 발행 노트에선 거절
throw new CustomException(ErrorCode.PLAYLIST_SONGS_REQUIRED);
}
}완료 기준
- 발행된 PLAYLIST 노트에
{"playlist":{"memorialPhotoUrl":null}}처럼songs없는 playlist 패치를 보내면 200 이고 기존 곡이 유지된다. {"playlist":{"songs":[]}}는 발행 노트에서 지금처럼 1610 이다.- draft→발행 전환(
isDraft:false)에서 요청·기존 어느 쪽에도 곡이 없으면 지금처럼 1609/1610 이다. AfternoteValidatorTest에 «발행 노트 + 기존 곡 있음 + songs 생략 → 통과» 와 «발행 노트 + songs[]→ 1610» 이 추가된다.AfternoteUpdateRequest.playlist설명의 «요청·기존 합쳐 필수» 가 실제 동작과 일치한다.
관련
- 계약 도입 커밋: bbff47c ([refactor] 애프터노트 수정 API에서 category 필수 입력 제거 #200, [refactor] 애프터노트 임시저장·발행 상세 응답 타입 분리 #201). [refactor] 애프터노트 임시저장·발행 상세 응답 타입 분리 #201 은 «발행 시 플레이리스트를 필수로 검증하는 기존 비즈니스 규칙 변경» 을 제외 범위로 뒀다 — 이 이슈는 그 규칙을 바꾸는 게 아니라, 부분 수정에서 판정 대상을 «요청만» 에서 «요청·기존 합친 최종 상태» 로 맞추는 것이다.
- 같은 취지의 선례: [fix] 정식 애프터노트 actions 최소 1개 검증 누락 — 계정 정보·처리 방법 필수 계약 정합성 #204 는
actions에 대해 «PATCH 는 부분 수정이므로 요청 값과 기존 저장값을 합친 최종 상태를 검증» 을 기대 계약으로 적었다. - FE: fix(afternote): 수정 시 변경하지 않은 필드를 부분 PATCH에서 생략한다 Afternote-FE#1617 (lost update), fix(afternote): 수정 PATCH 가 안 건드린 필드를 싣지 않게 한다 (#1617) Afternote-FE#1626 (부분 PATCH PR — 이 경로를 드러낸다).
Activity
OnlyReadOneBook commentedon Sep 13, 2026
@Hwanggyuun 님, 확인 두 가지만 부탁드립니다.
"songs": []만 1610. (맞다 / 다르다)배포 시점은 따로 여쭙지 않습니다. 이미 확인했습니다. PR #279 가 2026-09-09 14:36:54Z 에 main 에 머지됐고(머지 커밋
d34353e979702e3065a949ab584c3415d9c9b86c), 그 커밋의 Deploy 워크플로 실행이 success 로 끝났습니다. main 의AfternoteValidator.validatePlaylistPublish도 songs 가 null 이고 기존 곡이 있으면 통과,songs.isEmpty()면PLAYLIST_SONGS_REQUIRED로 갈라져 있습니다. 이슈만 OPEN 으로 남은 건 PR 본문에 Closes 가 걸리지 않아서로 보입니다. 코멘트도 0건입니다.맥락으로만 덧붙입니다. 이 이슈가 열려 있는 동안 FE 의 merge-order-guard 가 #1617 을 열린 #277 에 blocked 로 판정해 스택 8건(#1626, #1839, #1837, #1838, #1971, #1972, #2021, #1768)의 머지를 막습니다. 다만 같은 가드가 FE 자체 이슈 #1762 도 함께 걸고 있어서, #277 이 닫혀도 FE 쪽 선행이 하나 남습니다. 재촉이 아니라 저희가 풀 게 하나 줄어든다는 뜻입니다.
OnlyReadOneBook commentedon Sep 13, 2026
PR #279 로 해소된 것을 확인해 닫습니다. 근거를 어느 층까지 확인했는지 함께 적습니다.
완료 기준 대조 (기준 리비전 main 3593aa3)
AfternoteValidator.validatePlaylistPublish가songs == null이면hasExistingPlaylistSongs(afternote)로 갈라 통과시킵니다. 이 이슈 본문이 제안한 코드와 분기·에러코드가 같습니다.songs.isEmpty()→PLAYLIST_SONGS_REQUIRED.PLAYLIST_REQUIRED(1609), playlist 키가 있고 songs 생략이면PLAYLIST_SONGS_REQUIRED(1610).ErrorCode.java:145-146에서 번호를 확인했습니다.AfternoteValidatorTest에update_PublishedPlaylist_OmitSongs_KeepsExisting과update_PublishedPlaylist_EmptySongs_Fails가 있습니다. NPE 가드 2건도 함께 들어가 있습니다.AfternoteUpdateRequest.playlist설명에 songs 생략은 기존 곡 유지, 빈 배열은 전부 삭제라는 문장이 들어갔고 컨트롤러@Operation과PlaylistRequest.songs의@ArraySchema에도 같은 내용이 반영됐습니다. 요청드린 한 곳보다 넓게 고쳐 주셨습니다.저장 쪽과도 맞물립니다.
PlaylistRelationStrategy.update가songs != null일 때만 items 를 갈아 끼우므로, 검증기의 통과 조건이 저장의 유지 조건과 같은 축이 됐습니다. 이 이슈가 지적한 불일치가 사라졌습니다.배포도 확인했습니다.
deploy.yml트리거는push: main과workflow_dispatch뿐이고 워크플로 6개를 전수로 봐도 release 를 배포하는 경로는 없습니다. 머지 커밋d34353e979의 Deploy 실행은 build·deploy·smoke·contract 네 job 전부 success 였고, 이후 main 에 올라간3593aa3의 배포도 success 이며 그 시점 코드에도 고친 분기가 그대로 있습니다.확인하지 못한 것
근거는 main 실코드와 단위 테스트 두 층입니다. 실서버에 PATCH 를 쏘아 본 기록은 아직 없습니다.
deploy.yml의 빌드는-x test로 테스트를 빼고scripts/contract-test.sh는--include-method GET으로만 돕니다. Deploy success 는 해당 SHA 로 빌드한 이미지가 떠서 health 가 UP 이라는 것까지만 증명합니다.PR #279 에 Closes 가 걸리지 않아 자동으로 닫히지 않은 것으로 보여 대신 닫습니다. 판단이 다르시면 다시 열어 주세요.
참고로 Afternote-FE 의 merge-order-guard 가 이 이슈를 선행으로 보고 PR #1626 이하 스택 8건을 막고 있었습니다. 이제 그쪽 선행은 FE 자체 이슈 하나만 남습니다.