[TMT-225] feat: 이어쓰기·임시저장 삭제·내 저장 조회·리뷰 폼 설정 실구현 — save mock 제거 - #69
[TMT-225] feat: 이어쓰기·임시저장 삭제·내 저장 조회·리뷰 폼 설정 실구현 — save mock 제거#69mingdodev wants to merge 4 commits into
Conversation
TMT-193 매장 직접 등록과 이어쓰기·조회 실구현을 합친다. SaveCreationService는 main의 resolvePlace·validateNewPlace를 남기고 공통 검증·티켓 발급은 SaveWriteSupport에 위임한다.
| // save 행을 지운다 (F·G·I §5-2). V1 FK에 ON DELETE CASCADE가 없어 자식을 먼저 지운다 | ||
| saveCommandPort.deletePhotos(saveId) | ||
| saveCommandPort.deleteTags(saveId) | ||
| saveCommandPort.deleteSave(saveId) |
There was a problem hiding this comment.
[must] 자식을 애플리케이션이 지우는 게 맞을까요?
명세 §7이 save_photo·save_tag가 ON DELETE CASCADE로 정리된다고 적고 있었는데, V1__init.sql에 CASCADE가 없습니다. 세 FK(save_photo.save_id·save_tag.save_id·review.save_id) 전부 REFERENCES save(id)뿐이고 스키마 전체에 CASCADE가 0건입니다. 명세가 틀렸고 8/31에 정정했습니다.
마이그레이션을 임의로 추가하지 않고 같은 트랜잭션에서 순서대로 지우는 쪽을 택했습니다.
- 스키마 전체가 CASCADE를 쓰지 않는 일관된 정책으로 보입니다
review.save_id까지 CASCADE를 걸면 리뷰가 딸린 Save를 지울 때 리뷰가 조용히 사라져, §5-2가 막으려던 티켓 회수 우회가 DB 레벨에서 열립니다
이 방향이 괜찮을까요? CASCADE가 낫다고 보시면 마이그레이션을 추가하겠습니다.
| LEFT JOIN review_ai_summary a ON a.review_id = r.id | ||
| WHERE s.id = :saveId AND s.deleted_at IS NULL | ||
| """, | ||
| nativeQuery = true, |
There was a problem hiding this comment.
후속 — TMT-295(#70) 머지 후 통합 테스트를 추가할 예정입니다.
키셋 커서·NOT EXISTS(review)·썸네일 서브쿼리를 실제 Postgres에서 돌려보지 못했습니다. 확인해야 하는 것: 미완성 저장만 걸러지는지 · updatedAt DESC, saveId DESC 커서가 경계에서 중복·누락 없이 이어지는지 · 썸네일 서브쿼리가 N+1로 풀리지 않는지
wnsvy607
left a comment
There was a problem hiding this comment.
mock 제거 잔여 참조 0건(남긴 공유 헬퍼는 실사용처 확인), 헥사고날 경계, 커서 조건 해시(MY_SAVES + userId), 멱등 재현 검사가 SAVE_ALREADY_REVIEWED 가드보다 앞서는 규약 §9 순서, S8 비소유자 404 일관까지 main의 기존 레인 패턴과 전부 정합합니다. mock 테스트가 덮던 시나리오도 실구현 테스트로 잘 승계됐고요. [must] 없이 approve합니다 — [want] 두 건만 머지 전 반영을 권합니다.
[want] 본문의 "mock과 다른 건 photoId 하나"가 불완전합니다 — reviewId 접두도 mock review_N → 실구현 rv_N으로 바뀝니다(구 SaveMockControllerTest가 $.reviewId == "review_1"로 고정하고 있었음). 릴리즈 태그 시점에 계약 이력을 묶어 적더라도 photoId만 적으면 이 차이가 누락돼요. 본문/이력 메모에 추가 부탁드립니다.
[want] SaveUpdateService의 임시저장 하드 삭제가 docs/DB-SCHEMA.md D6("save.deleted_at — soft delete") 서술과 어긋나는데 문서가 같은 PR에서 안 움직였습니다. 드래프트는 티켓·집계가 없어 D6의 근거를 해치지 않는 합리적 판단이라고 보지만, 규칙상 D6에 "리뷰 없는 임시저장은 예외적으로 hard delete(명세 §5-2)" 한 줄이 같은 PR에 있어야 합니다.
[q] SaveQueryRepository의 S4 가드·목록 필터가 r.deleted_at IS NULL 기준이라, 리뷰 삭제 실구현이 review만 soft delete하고 save를 남기면 그 save가 이어쓰기 목록에 재등장 → PUT 재완성 → 티켓 재발급 루프(R6 우회)가 열립니다. #73이 save도 함께 soft delete하는 걸 확인했는데, 이 전제를 두 PR 어느 쪽이든 주석/본문에 명시해 두면 좋겠습니다.
[q] SaveWriteSupport.kt:49 본문 길이 검증이 it.length(UTF-16)인데 응답 규약의 글자 수는 codePointCount입니다. 이모지 포함 시 500자 이하 본문이 거부될 수 있어요. 기존 코드를 옮겨온 것이라 이 PR의 신규 결함은 아님 — 정렬만 제안합니다.
[q] SaveCommandAdapter.kt:63 — updateContent의 갱신 행 수를 버리는데, findSave와 사이에 동시 DELETE가 끼면 0행 갱신 후 insertPhotos가 FK 위반 500이 됩니다. 0이면 SAVE_NOT_FOUND가 방어적입니다(드문 경합이라 판단 위임). 비슷하게 PUT 멱등 키의 endpoint 성분이 원문 경로 문자열이라 save_9와 9가 다른 멱등 키 공간을 타는데, 파싱된 숫자 id로 정규화를 권합니다.
|
D6 문서 —
|
|
후속으로 두겠다고 한 두 건, TMT-301로 티켓 끊었습니다. 말만 하고 넘어갈 뻔했네요. 범위는 이렇습니다. 1. 2. 멱등 키 1번을 가볍게 안 본 이유를 티켓 Why에 적어뒀습니다 — 같은 패턴이 #73에서 실제로 물렸습니다. 검증은 TMT-295 환경 위에서 합니다 — Fake로는 경합도 롤백도 재현이 안 됩니다(#73에서 회귀 테스트 쓰다가 확인했습니다). |
Related Issue
Why
SaveMockController의 나머지 4개와ReviewFormConfigMockController가 아직 인메모리로 응답합니다. TMT-224가POST를 가져갔으므로 이 PR로/v1/saves5개가 전부 실구현이 되고, 두 mock 컨트롤러를 지울 수 있게 됩니다.What
이어쓰기와 내 저장 조회가 실 DB에서 동작합니다.
PUT /v1/saves/{saveId}DELETE /v1/saves/{saveId}GET /v1/savesGET /v1/saves/{saveId}GET /v1/review-form-config/v1/saves경로에 mock이 남지 않습니다.SaveMockController·ReviewFormConfigMockController와 두 테스트를 지웠고, 공유 헬퍼(MockCursor·MockMediaUrls·ReviewFormRules)는 다른 mock이 쓰고 있어 남겼습니다.응답 형태는 mock과 같습니다. 하나만 다릅니다 — 상세의
photoId가sp_{assetId}→sp_{save_photo.id}입니다. 형식은 그대로이고 TMT-228 실구현의 ID 표기와 같아집니다.How
POST와 공유하는 검증·티켓 발급을SaveWriteSupport로 뺐습니다.PUT이 같은 판정을 다시 돌려야 하는데(C6)SaveCreationService에 묶여 있었습니다. 직접 등록 검증(validateNewPlace)은POST전용이라 서비스에 남겼습니다.PUT은prepare훅을 쓰지 않습니다. 외부 I/O가 없고,newPlace가 오면 좌표 조회 없이 즉시SAVE_PLACE_IMMUTABLE입니다.DELETE는save행을 하드 삭제하되 사진은STAGED로 되돌립니다 (§5-2). 즉시 지우면 사용자가 곧바로 다시 올려야 하고, 삭제 실패 시 Save만 사라지고 객체가 남습니다. 기존AttachMediaUseCase.detach를 재사용했습니다.비소유자는 404입니다.
GET·PUT·DELETE모두 없는 것과 남의 것을 구분하지 않습니다 (§4-2·§5-2·§6-2, S8).saveId가 순번이라 403을 주면 남의 저장 존재를 훑을 수 있습니다.Notes for Reviewer
명세에 없어서 정한 값 3개 — 이견 있으면 알려주세요.
PUT응답 200 (POST는 201)limit기본 20 · 상한 50pros·cons가 둘 다null이면aiSummary자체를nullsave자식 삭제 방식은SaveUpdateService에 라인 코멘트로 남겼습니다 — 명세가 CASCADE를 전제했는데 스키마에 없습니다.검증 — CI가 보여주지 못하는 것: 새로 쓴 native 쿼리(
SaveQueryRepository의 키셋·NOT EXISTS(review)·썸네일 서브쿼리)를 실제 Postgres에서 돌려보지 못했습니다. DB 통합 테스트 환경이 아직 없습니다 (#70에서 준비 중). 머지 후 그 위에 테스트를 얹는 게 후속입니다.계약 변경 이력(Confluence)은 갱신하지 않았습니다 — mock 전환 파생분은 릴리즈 태그 시점에 한 줄로 묶기로 한 합의에 따릅니다.
Prompt Log
티켓 기준으로 초안을 만들고, 완성도 판정은 TMT-224 것을 재사용하도록 방향을 잡았습니다. mock은
/v1/saves5개가 다 차는 시점에만 지우도록 했고, 공유 헬퍼는 확인 후 남기게 했습니다.초안이 비소유자를 403으로,
DELETE를 소프트 삭제로 구현했는데 둘 다 명세 위반이었습니다. 403은 제가 티켓 승인 기준에 "남의 저장 조회 403"이라고 잘못 적어둔 것을 따른 것이라 티켓을 먼저 고쳤고,DELETE는 명세 §5-2·§7 원문을 확인해 하드 삭제 +STAGED복귀로 되돌렸습니다.CASCADE 부재는 그 과정에서 드러났습니다. 마이그레이션을 임의로 추가하지 않고 보고하도록 해서, 명세 쪽을 정정하는 판단으로 이어졌습니다.