Skip to content

fix(afternote): 수정 시 변경하지 않은 필드를 부분 PATCH에서 생략한다 #1617

Closed

Description

@OnlyReadOneBook

작업 유형

bug — 버그·오동작

주 담당 모듈

afternote — 애프터노트

개요

수정 진입 때 읽은 전체 폼을 PATCH로 다시 보내면, 그 사이 서버에서 바뀐 곡·수신자·미디어 등 사용자가 건드리지 않은 필드까지 오래된 값으로 덮는다.

현재 결정은 버전 토큰을 추측해 도입하는 대신, 이미 있는 부분 PATCH 계약에 맞춰 원본 상세와 현재 폼을 비교해 바뀐 필드만 보내는 것이다(PR #1626).

구현 범위

  • 서버에서 받은 원본 기준값을 폼의 표시 기본값과 별도로 보관한다. 기준값이 없으면 수정 저장을 차단한다.
  • 제목·수신자·처리 방법·마지막 말씀·계정의 개별 필드·곡을 의미값으로 비교하고, 변경 없음은 키를 생략한다.
  • 사진·영상은 FieldPatch로 생략/명시적 null 삭제/새 값 교체를 구분한다. 폐기된 음성 기능을 후속 구현으로 예약하지 않는다.
  • 프로세스 복원 후 사용자가 편집한 폼을 새 프리필로 덮지 않는 처리는 #1732가 이 기준값 위에서 완성한다.

완료 조건

  • 사용자가 바꾸지 않은 필드가 PATCH에 실리지 않아 다른 기기의 변경이 유지된다.
  • 명시적으로 비운 목록·미디어는 삭제 요청으로 나간다.
  • 기준값 미확보 상태의 저장이 차단된다.
  • 부분 PATCH wire·폼 저장·미디어 삭제/재시도 회귀를 통과한다.
  • BE refactor(afternote): MasterKeyViewModel sealed Event → UI state 흡수 (#228 분할 1/9) #277 수정 배포 후 발행 PLAYLIST의 미디어만 수정하는 요청을 대상 환경에서 검증한다.

선행 관계

이 변경은 사용자가 건드리지 않은 필드의 덮어쓰기를 막는다. 같은 필드를 두 기기에서 동시에 바꾼 경우의 충돌 판정은 버전 토큰 등 별도의 서버 계약이 있어야 하며 현재 API에서 보장하지 않는다.

Activity

  1. github-actions commented on Aug 30, 2026

    @github-actions
    Contributor

    bug 이슈는 사이드바 Fields > Priority 로 심각도를 지정해야 합니다. 판정 기준:

    • Urgent: 치명 — 크래시·데이터 소실·보안 무력화·핵심 경로 차단·가짜 성공
    • High: 심각 — 실패·틀린 값의 정상 위장, 핵심 기능 무동작
    • Medium: 불편 — 우회로가 있는 오동작·틀린 표시, 핵심 흐름은 유지
    • Low: 경미 — 문구·정렬·마감 품질 흠, 기능 영향 없음

    값이 비어 있어도 이슈를 닫지는 않습니다. 이 안내는 이슈당 한 번만 답니다.

  2. OnlyReadOneBook commented on Aug 30, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    정정 — 「BE 선행이므로 결론 전 FE 구현 금지」 게이트는 이미 풀렸다

    본문 해야 할 일의 첫 항목에 「BE 선행이므로 결론 전에는 FE 구현 금지」 라고 적어 뒀지만, BE 부분 PATCH 계약은 이미 main 에 머지돼 있습니다. 이 이슈(0830)보다 일주일 먼저 들어갔습니다. 로컬 BE 체크아웃에서 실측한 근거를 남깁니다.

    근거 1 — 계약을 세운 커밋이 이슈보다 앞선다

    bbff47c8 — refactor: 애프터노트 PATCH DTO 분리 및 draft/발행 검증 계약 정리 (#200, #201), 2026-08-23 12:15 KST. origin/main 의 조상입니다.

    $ git merge-base --is-ancestor bbff47c8 origin/main && echo YES
    YES
    

    근거 2 — DTO 가 전 필드에 「생략 시 기존 값 유지」를 명시한다

    AfternoteUpdateRequest.java — 수정 전용 DTO 가 생성 DTO 에서 분리돼 있고, title·actions·leaveMessage·credentials·receivers·playlist·isDraft 가 전부 nullable 입니다. category 는 「생략 권장(변경 불가)」.

    @Schema(description = "애프터노트 수정 요청. 수정하지 않을 필드는 생략 가능. category는 생략 권장(변경 불가).")

    근거 3 — 「생략 = 유지」가 문서가 아니라 구현이다

    AfternoteService.updateAfternote 가 필드마다 null 검사로 기존 값을 보존합니다.

    String title = writeRequest.getTitle() != null ? writeRequest.getTitle() : afternote.getTitle();
    List<LeaveMessageBlock> leaveMessage = writeRequest.getLeaveMessage() != null
            ? writeRequest.getLeaveMessage()
            : afternote.getLeaveMessage();

    null(생략) = 유지 / non-null = 치환 이므로, 빈 배열은 「전부 삭제」로 그대로 통합니다. #1596·#1599 가 세운 「빈 값 = 삭제」 계약과 정면으로 맞물립니다 — 두 계약이 충돌하지 않고 서로를 완성합니다.

    근거 4 — 컨트롤러 테스트가 부분 전송 200 을 단언한다

    AfternoteControllerTest#updateAfternote_OmitCategoryAndTitle_Success 가 {"actions":["a1"]} 한 키만 보내고 200 을 단언합니다. category·title 생략이 계약으로 못박혀 있습니다.

    근거 5 — 미디어는 tri-state 다

    playlist 안의 memorialPhotoUrl·memorialVideo·memorialAudioUrl 은 필드 생략 시 유지 / JSON null 이면 삭제(DB 참조 제거 + S3 객체 삭제) / 값이 있으면 교체 입니다. PlaylistRequestDeserializer 가 「키 없음」과 「명시적 null」을 갈라 봅니다. #1596 이 FE 에서 세운 명시적 null 표현이 이 축에 정확히 대응합니다.


    그래서 어느 갈래로 가는가

    본문 해야 할 일의 세 번째 항목 — 「대안으로 dirty 필드만 보내는 부분 PATCH」 — 가 지금 그대로 구현 가능한 갈래입니다. 버전 토큰·If-Match 협의를 기다릴 필요가 없습니다.

    • 완료 조건의 두 갈래 중 뒤쪽을 만족합니다. 「애초에 그 필드가 요청에 실리지 않는다」쪽입니다.
    • 재현 경로로 든 사고 — 「곡을 건드린 적 없는데 곡이 지워진다」 — 는 만지지 않은 필드가 요청에서 빠지므로 구조적으로 사라집니다.
    • 다만 사용자가 실제로 만진 필드의 동시 편집 충돌은 여전히 last-write-wins 로 남습니다. 부분 PATCH 는 그 축을 닫지 않습니다.

    BE main 에도 낙관적 동시성 제어는 아직 없습니다 — @Version·If-Match·ETag·updatedAt 어느 것도 애프터노트 도메인에 없습니다(실측 0건). 즉 1·2번 항목은 그대로 살아 있습니다.

    그래서 부분 PATCH PR 은 Closes 가 아니라 Refs #1617 로 답니다. 이 이슈는 1·2번 항목이 남아 있으므로 열어 둡니다 — 이 축의 추적처는 계속 이 이슈입니다.

    ⚠️ 다만 머지 순서 제약이 하나 있다

    이 계약은 main 에만 있고 origin/release 에는 없습니다. AfternoteUpdateRequest.java 파일 자체가 release 에 존재하지 않습니다.

    $ git merge-base --is-ancestor bbff47c8 origin/release && echo YES || echo NO
    NO
    $ git cat-file -e origin/release:src/main/java/com/afternote/domain/afternote/dto/AfternoteUpdateRequest.java
    fatal: path ... exists on disk, but not in 'origin/release'
    

    release 시점 BE 는 수정 요청을 생성 DTO 로 받습니다. 키를 생략하면 「유지」가 아니라 @NotNull 검증 400 이거나 null 로 덮어쓰기가 됩니다. 즉 FE 부분 PATCH 가 실서버보다 먼저 나가면 수정 기능이 통째로 깨집니다.

    → FE PR 은 Draft 로 열고 본문에 머지 게이트를 명시합니다. BE 가 이 계약을 release 에 올린 뒤에 머지합니다.

  3. github-actions commented on Aug 30, 2026

    @github-actions
    Contributor

    이 이슈를 맡은 PR 은 #1626 다 (base fix/1599-empty-songs-explicit).

    base 가 기본 브랜치(develop)가 아니라서 GitHub 은 이 이슈의 Development 칸에
    아무것도 표시하지 않는다. 본문에 Closes #N 이 있어도 마찬가지다 — 그 링크는 기본 브랜치를
    target 하는 PR 에서만 만들어진다.

    PR 이 닫히거나 머지되면 이 이슈의 pr-open 라벨은 다음 리컨사일에서 떨어진다.

  4. added
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Aug 30, 2026
  5. OnlyReadOneBook commented on Sep 2, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    PR #1626 을 Closes #1617 로 올립니다. 완료 조건의 뒤 갈래(«안 만진 필드는 애초에 요청에 실리지 않는다»)를 채우고, 해야 할 일 1·2번은 대안 3번 채택으로 이 이슈의 완료 조건에 불필요해졌습니다.

    PR 이 이 이슈에 얹어 두던 두 축은 별건으로 뗐습니다.

    blocked_by #1599 는 스택(#1602 위)이 처리하므로 그대로 둡니다.

  6. 6 remaining items

  7. changed the title [-]fix(afternote): 수정 PATCH 가 stale 스냅샷을 통째로 보내 남의 변경을 조용히 덮는다 — 낙관적 동시성 제어 부재[/-] [+]fix(afternote): 수정 시 변경하지 않은 필드를 부분 PATCH에서 생략한다[/+] on Sep 8, 2026
  8. added
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    and removed
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Sep 11, 2026
  9. OnlyReadOneBook commented on Sep 13, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    Afternote-BE#277 에 코멘트로 아직 나간 적 없던 질문을 보냈다(2026-09-13).

    이 BE 이슈가 열려 있는 동안 merge-order-guard 가 #1617 을 blocked 로 판정해 PR #1626 이하 스택 8건의 머지를 막는다. 덧붙여 배포 여부를 실측했다. BE 의 Deploy 워크플로는 push 트리거가 main 이고 d34353e9 는 main 에 있으며 그 실행이 success 다. release 브랜치는 배포 경로가 아니므로 이 이슈 본문의 배포 후 검증 게이트는 이미 충족된 상태다. 남은 것은 dev 서버 실측뿐이다.

    물은 내용은 묵은 본문을 옮긴 것이 아니라 Afternote-BE main 실코드로 대조해 다시 벼린 것이다. 코드로 이미 답이 나온 축은 질문에서 빼고 사실로 적었다.

  10. OnlyReadOneBook commented on Sep 20, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    2026-09-20 배포 서버에서 미디어 단독 PATCH 계약 검증을 완료했습니다.

    기준은 현재 https://afternote.kro.kr/v3/api-docs의 AfternoteCreateRequest·AfternoteUpdateRequest·PlaylistRequest입니다. 기존 QA 계정에서 수신자 없이 새 synthetic 발행 PLAYLIST를 만들고, 곡 1개가 있는 상태를 먼저 조회했습니다.

    검증 결과
    새 발행 PLAYLIST 생성 및 상세 조회 POST 200, GET 200, isDraft=false, 곡 1개, 수신자 0명
    {"playlist":{"memorialPhotoUrl":null}} PATCH, songs 생략 HTTP 200
    미디어 PATCH 뒤 상세 재조회 기존 synthetic 곡의 제목·아티스트와 곡 수 1개 유지, 발행 상태 유지
    {"playlist":{"songs":[]}} PATCH HTTP 400, code=1610
    거절 응답 뒤 상세 재조회 기존 곡 1개 그대로 유지
    이번에 만든 테스트 노트 정리 DELETE 200, 후속 GET 404·code=1600

    실행 시각은 2026-09-20 22:10 KST이며, 이번에 생성한 노트 ID 50만 수정·삭제했습니다. 기존 사용자 노트·타인 수신자는 변경하지 않았고 공유·알림 전송 API를 호출하지 않았습니다. 업로드나 storage key를 사용하지 않았으므로 이 검증은 곡 생략 처리와 발행 노트의 빈 곡 목록 거절 범위입니다. 실제 S3 객체 삭제를 검증한 것은 아닙니다.

    BE #279 및 성공한 배포 실행의 구현·배포 근거에 더해, #1617의 남아 있던 실서버 PATCH 동작도 확인됐습니다.

  11. removed
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:afternote애프터노트 기능 영역bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions