Skip to content

[TMT-225] feat: 이어쓰기·임시저장 삭제·내 저장 조회·리뷰 폼 설정 실구현 — save mock 제거 - #69

Open
mingdodev wants to merge 4 commits into
mainfrom
feat/TMT-225-save-continue
Open

[TMT-225] feat: 이어쓰기·임시저장 삭제·내 저장 조회·리뷰 폼 설정 실구현 — save mock 제거#69
mingdodev wants to merge 4 commits into
mainfrom
feat/TMT-225-save-continue

Conversation

@mingdodev

@mingdodev mingdodev commented Aug 30, 2026

Copy link
Copy Markdown
Member

Related Issue

Why

SaveMockController의 나머지 4개와 ReviewFormConfigMockController가 아직 인메모리로 응답합니다. TMT-224가 POST를 가져갔으므로 이 PR로 /v1/saves 5개가 전부 실구현이 되고, 두 mock 컨트롤러를 지울 수 있게 됩니다.

What

이어쓰기와 내 저장 조회가 실 DB에서 동작합니다.

경로 내용
PUT /v1/saves/{saveId} 이어쓰기 — 완성도 판정(C4) 재실행, 매장 변경 불가(S6)
DELETE /v1/saves/{saveId} 임시저장 버리기
GET /v1/saves 미완성 저장 목록 · 커서
GET /v1/saves/{saveId} 본인 상세
GET /v1/review-form-config 시드(V2)에서 태그·제약

/v1/saves 경로에 mock이 남지 않습니다. SaveMockController·ReviewFormConfigMockController와 두 테스트를 지웠고, 공유 헬퍼(MockCursor·MockMediaUrls·ReviewFormRules)는 다른 mock이 쓰고 있어 남겼습니다.

응답 형태는 mock과 같습니다. 하나만 다릅니다 — 상세의 photoIdsp_{assetId}sp_{save_photo.id}입니다. 형식은 그대로이고 TMT-228 실구현의 ID 표기와 같아집니다.

How

POST와 공유하는 검증·티켓 발급을 SaveWriteSupport로 뺐습니다. PUT이 같은 판정을 다시 돌려야 하는데(C6) SaveCreationService에 묶여 있었습니다. 직접 등록 검증(validateNewPlace)은 POST 전용이라 서비스에 남겼습니다.

PUTprepare 훅을 쓰지 않습니다. 외부 I/O가 없고, newPlace가 오면 좌표 조회 없이 즉시 SAVE_PLACE_IMMUTABLE입니다.

DELETEsave 행을 하드 삭제하되 사진은 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) §4-2가 "4-1과 같은 형태"라고만 하고 상태 코드를 안 정했다. 생성이 아니라 교체라 200
목록 limit 기본 20 · 상한 50 §5-1은 "기본 20"만 있어 상한은 공통 규약 §5-2에서 가져왔다
pros·cons가 둘 다 null이면 aiSummary 자체를 null §6-2는 "리뷰가 아니거나 생성 전·실패면 null"까지만 정한다. 화면이 빈 섹션을 그리지 않도록

save 자식 삭제 방식SaveUpdateService에 라인 코멘트로 남겼습니다 — 명세가 CASCADE를 전제했는데 스키마에 없습니다.

검증 — CI가 보여주지 못하는 것: 새로 쓴 native 쿼리(SaveQueryRepository의 키셋·NOT EXISTS(review)·썸네일 서브쿼리)를 실제 Postgres에서 돌려보지 못했습니다. DB 통합 테스트 환경이 아직 없습니다 (#70에서 준비 중). 머지 후 그 위에 테스트를 얹는 게 후속입니다.

계약 변경 이력(Confluence)은 갱신하지 않았습니다 — mock 전환 파생분은 릴리즈 태그 시점에 한 줄로 묶기로 한 합의에 따릅니다.

Prompt Log

티켓 기준으로 초안을 만들고, 완성도 판정은 TMT-224 것을 재사용하도록 방향을 잡았습니다. mock은 /v1/saves 5개가 다 차는 시점에만 지우도록 했고, 공유 헬퍼는 확인 후 남기게 했습니다.

초안이 비소유자를 403으로, DELETE를 소프트 삭제로 구현했는데 둘 다 명세 위반이었습니다. 403은 제가 티켓 승인 기준에 "남의 저장 조회 403"이라고 잘못 적어둔 것을 따른 것이라 티켓을 먼저 고쳤고, DELETE는 명세 §5-2·§7 원문을 확인해 하드 삭제 + STAGED 복귀로 되돌렸습니다.

CASCADE 부재는 그 과정에서 드러났습니다. 마이그레이션을 임의로 추가하지 않고 보고하도록 해서, 명세 쪽을 정정하는 판단으로 이어졌습니다.

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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[must] 자식을 애플리케이션이 지우는 게 맞을까요?

명세 §7이 save_photo·save_tagON 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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

후속 — TMT-295(#70) 머지 후 통합 테스트를 추가할 예정입니다.

키셋 커서·NOT EXISTS(review)·썸네일 서브쿼리를 실제 Postgres에서 돌려보지 못했습니다. 확인해야 하는 것: 미완성 저장만 걸러지는지 · updatedAt DESC, saveId DESC 커서가 경계에서 중복·누락 없이 이어지는지 · 썸네일 서브쿼리가 N+1로 풀리지 않는지

@mingdodev mingdodev self-assigned this Aug 30, 2026
@mingdodev
mingdodev requested review from toychip and wnsvy607 August 30, 2026 16:41

@wnsvy607 wnsvy607 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.

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:63updateContent의 갱신 행 수를 버리는데, findSave와 사이에 동시 DELETE가 끼면 0행 갱신 후 insertPhotos가 FK 위반 500이 됩니다. 0이면 SAVE_NOT_FOUND가 방어적입니다(드문 경합이라 판단 위임). 비슷하게 PUT 멱등 키의 endpoint 성분이 원문 경로 문자열이라 save_99가 다른 멱등 키 공간을 타는데, 파싱된 숫자 id로 정규화를 권합니다.

@mingdodev

Copy link
Copy Markdown
Member Author

[want] 두 건 반영했습니다.

reviewId 접두 누락 — 맞습니다. 본문에 photoId만 적어서 review_Nrv_N이 빠졌습니다. 확인해보니 mock 쪽이 틀린 거였네요 — PublicIds.review(id) = "rv_$id"가 이미 main에 있고 카드·가게 상세는 rv_를 내리는데 MockStoreConfig:254"review_${seq}"로 남아 있었습니다. 본문에 추가했고, 릴리즈 태그 때 묶을 계약 이력 메모에도 넣겠습니다.

D6 문서docs/DB-SCHEMA.md의 D6에 예외를 적었습니다.

예외: 리뷰가 되지 않은 임시저장은 hard delete다 (명세 F·G·I §5-2). 드래프트는 티켓도 집계도 건드린 적이 없어 남길 근거가 없다. 자식(save_photo·save_tag)은 스키마에 CASCADE가 없어 애플리케이션이 같은 트랜잭션에서 먼저 지우고, media_assetSTAGED로 되돌려 TTL(M4)에 맡긴다

[q] 본문 길이codePointCount로 고쳤습니다. 재보니 이모지는 UTF-16에서 2유닛이라 이모지만 쓰면 250자에서 막혔습니다. FE 카운터가 [...str].lengthIntl.Segmenter를 쓰면 화면엔 480자인데 서버가 거부하는 형태라, 기존 코드를 옮겨온 것이긴 해도 고치는 게 맞다고 봤습니다.

[q] 두 건은 확인만 하고 남깁니다

리뷰 삭제와의 R6 우회 — #73이 reviewsave함께 soft delete하는 것 확인했습니다(softDeleteReviewsoftDeleteSave). 그래서 지금은 열리지 않습니다. 다만 지적하신 대로 이 전제가 어디에도 안 적혀 있어서, #73에서 그 두 줄 옆에 "save도 함께 내려야 이어쓰기 목록에 재등장하지 않는다 (R6)"를 주석으로 남기겠습니다.

updateContent 행 수 · 멱등 키 정규화 — 둘 다 타당한데 이 PR 범위를 넘어 보여 후속으로 두겠습니다. 다만 행 수를 버리는 패턴은 #73에서 실제로 물렸습니다(동시 중복 삭제에서 집계 이중 차감). 같은 계열이라 가볍게 볼 건 아니라고 보고, SaveCommandAdapter도 같이 훑어 티켓으로 정리하겠습니다.

@mingdodev

mingdodev commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

후속으로 두겠다고 한 두 건, TMT-301로 티켓 끊었습니다. 말만 하고 넘어갈 뻔했네요.

범위는 이렇습니다.

1. SaveCommandAdapter.updateSaveupdateContent의 행 수를 버립니다. findSave와 사이에 동시 DELETE가 끼면 0행 갱신 후 그대로 진행 → insertPhotos가 FK 위반으로 500입니다. 0이면 SAVE_NOT_FOUND가 맞습니다. 티켓에 @Modifying 호출부 전수 확인도 넣었습니다 — 이 자리만의 문제가 아닐 수 있어서요.

2. 멱등 키 endpoint가 경로 원문이라 save_99가 다른 키 공간을 탑니다. 파싱된 숫자 id로 정규화하고, 다른 멱등 엔드포인트도 같은 기준인지 확인합니다.

1번을 가볍게 안 본 이유를 티켓 Why에 적어뒀습니다 — 같은 패턴이 #73에서 실제로 물렸습니다. softDeleteReview의 행 수를 무시해서 동시 중복 삭제가 집계 이중 차감 + 티켓 한 장 추가 회수로 커밋되는 경로가 열려 있었고, 조회 시점의 존재 확인으로는 못 막혔습니다. 이 레포가 티켓 소비·D6에서 세운 "조건부 UPDATE의 행 수가 정본" 원칙을 안 지킨 자리가 남아 있는 셈이라, 한 번에 훑는 게 맞다고 봤습니다.

검증은 TMT-295 환경 위에서 합니다 — Fake로는 경합도 롤백도 재현이 안 됩니다(#73에서 회귀 테스트 쓰다가 확인했습니다).

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