[TMT-221] feat: 그룹 생성·편집 실구현 — 이름 유일·생성자 멤버십·태그 집합 교체 - #80
Conversation
이름은 groups.name UNIQUE가 정본 — saveAndFlush로 제약 위반을 즉시 터뜨려 경합 포함 GROUP_NAME_DUPLICATED로 변환. 생성자는 자동 멤버(G11·G13, member_count 기본 1이 생성자 몫). 대표 이미지는 attach/detach로 STAGED-ATTACHED 전이(M4·M7). 상세 조립을 GroupDetailComposer로 분리 — 상세 조회(TMT-222)가 재사용한다. GroupStatsPort 구현은 TMT-224에서 이미 들어와 있어 검증만 했다. mock의 createGroup·updateGroup 핸들러 제거. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| private fun <T> duplicateNameToError(block: () -> T): T = | ||
| try { | ||
| block() | ||
| } catch (e: DataIntegrityViolationException) { |
There was a problem hiding this comment.
[must] 이름 UNIQUE 말고 다른 제약 위반도 전부 GROUP_NAME_DUPLICATED가 됩니다.
saveAndFlush로 제약을 즉시 터뜨리는 판단 자체는 좋습니다 — 사전 체크는 경합을 못 막고 제약은 막으니까요. 다만 DataIntegrityViolationException은 이름 UNIQUE 전용이 아닙니다.
groups의 image_asset_id BIGINT REFERENCES media_asset(id)가 같은 예외로 옵니다. verifyAttachable이 앞에서 걸러주긴 하지만, 그 사이에 asset이 지워지면(TTL 정리 M4·리뷰 삭제) FK 위반이 나고 사용자는 "이미 있는 그룹명입니다" 를 봅니다. owner_id FK도 마찬가지고요.
제약 이름으로 갈라주세요.
} catch (e: DataIntegrityViolationException) {
if (e.mostSpecificCause.message?.contains("groups_name_key") == true) {
throw TmtException(ErrorCode.GROUP_NAME_DUPLICATED)
}
throw e
}V1__init.sql이 name VARCHAR(50) NOT NULL UNIQUE라 제약명은 Postgres 기본 규칙으로 groups_name_key입니다. 이름을 문자열로 물고 가는 게 걸리면 마이그레이션에서 제약에 명시적 이름을 주는 방법도 있습니다.
There was a problem hiding this comment.
반영했습니다 (ec46df4) — mostSpecificCause.message에 groups_name_key가 있을 때만 409로 바꾸고 나머지는 그대로 던집니다. 제약명은 companion 상수로 두고 V1 규칙 출처를 주석으로 남겼어요.
| ), | ||
| ) | ||
| } | ||
| regionTagRepository.saveAll(regionTagIds.map { GroupRegionTagEntity(GroupRegionTagId(group.id, it)) }) |
There was a problem hiding this comment.
[q] create에서 이름 충돌이 나면 지역 태그·멤버십은 어떻게 되나요?
saveAndFlush가 예외를 던지고 그게 트랜잭션 밖으로 나가니 전부 롤백돼서 결과적으로는 맞습니다. 다만 GroupCommandService.create가 @Transactional이라는 전제 위에 서 있습니다.
SaveMockController 계열에서 이 전제가 깨져 문제가 됐던 적이 있어서(#75 리뷰의 UserAccountAdapter도 같은 계열), 어댑터 쪽에 "이름 충돌 시 트랜잭션 롤백으로 태그·멤버십도 함께 되돌아간다"를 한 줄 남겨두면 나중에 누가 전파 속성을 바꿀 때 걸립니다.
There was a problem hiding this comment.
맞는 지적이라 어댑터에 주석으로 박았습니다 (ec46df4) — "태그·멤버십은 GroupCommandService.create의 @transactional 롤백으로 함께 되돌아간다, 이 전제(전파 속성)를 바꾸면 깨진다".
| command: GroupCommand, | ||
| currentImageAssetId: Long? = null, | ||
| ) { | ||
| if (command.name.isBlank() || command.oneLineDescription.isBlank()) { |
There was a problem hiding this comment.
[q] 그룹명 길이 상한이 검증에 없습니다.
name.isBlank()만 보고 길이는 안 봅니다. groups.name이 VARCHAR(50)이라 51자가 오면 DB에서 잘리는 게 아니라 INSERT가 실패하고, 그게 DataIntegrityViolationException으로 잡혀 위 코멘트의 GROUP_NAME_DUPLICATED가 됩니다. "이름이 너무 깁니다"가 아니라 "이미 있는 그룹명입니다"가 나갑니다.
oneLineDescription도 같습니다(VARCHAR(100)). description만 200자 검증이 있네요.
F 명세가 newPlace.name에서 같은 이유로 길이 검증을 명시하고 있는데(§7 "조립 결과가 50자를 넘으면 저장이 실패하므로 길이를 검증한다"), 여기도 같은 처리가 맞아 보입니다. D_02 명세에 상한이 적혀 있으면 그 값을, 없으면 컬럼 폭을 기준으로요.
There was a problem hiding this comment.
반영했습니다 (ec46df4) — name 50자·oneLineDescription 100자를 코드 포인트 기준으로 검증합니다(DB char_length와 같은 단위). description도 length → codePointCount로 맞췄어요. D_02에 상한 명시가 없어서 컬럼 폭을 썼고, 이것도 '명세에 없어서 정한 값'이라 변경 이력 §4에 올리겠습니다.
| imageAssetId = command.imageAssetId, | ||
| ) | ||
| // 이미지를 교체하면 이전 asset은 STAGED로 되돌려 TTL 정리 대상이 되게 한다 | ||
| if (command.imageAssetId != target.imageAssetId) { |
There was a problem hiding this comment.
[want] 이미지 교체에서 detach가 먼저입니다.
지금 순서면 이전 asset을 STAGED로 돌린 뒤 새 asset을 ATTACHED로 만드는데, 그 사이에 예외가 나면(새 asset이 남의 것이라 MEDIA_NOT_OWNED 등) 트랜잭션이 롤백되니 결과적으론 안전합니다.
다만 verifyAttachable이 validate에서 이미 돌아 여기까지 왔다면 실패 경로가 거의 없어서, 순서를 attach → detach로 뒤집어도 동작은 같고 의도는 더 분명해집니다 — "새 것을 붙이고 헌 것을 놓는다"가 읽기 자연스럽습니다. 취향이라 그대로 두셔도 됩니다.
There was a problem hiding this comment.
순서는 그대로 두겠습니다 — detach 먼저가 '이전 것을 놓고 새 것을 붙인다'로 저는 더 자연스럽게 읽혀서요. 취향 영역이라고 해주셨으니 이 답변으로 갈음합니다.
mingdodev
left a comment
There was a problem hiding this comment.
saveAndFlush로 이름 유일을 DB 제약에 맡긴 판단이 이 PR의 핵심이고 맞다고 봅니다 — 사전 체크는 경합을 못 막고 제약은 막습니다. 같은 이름 동시 8발 → 201 하나·409 일곱·DB 1행을 직접 확인해서 적어주신 것도 좋았습니다. 그게 이 설계의 전부니까요.
GroupDetailComposer를 미리 분리해 TMT-222가 컨트롤러 한 장만 얹으면 되게 해둔 것, 대표 이미지 STAGED↔ATTACHED 전이를 리뷰 사진과 같은 경로(AttachMediaUseCase)로 재사용한 것도 일관됩니다.
라인 코멘트 4개 남겼습니다. [must]는 하나입니다 — DataIntegrityViolationException을 전부 GROUP_NAME_DUPLICATED로 바꾸는 부분이고, 이름 길이 검증 부재([q])와 맞물리면 "51자 이름을 넣었더니 이미 있는 그룹명이라고 나온다" 가 실제로 재현됩니다.
과도기 주의는 곧 풀립니다
"실구현으로 만든 그룹을 mock 상세·가입이 모른다"고 적어주셨는데, TMT-222(#81)·223(#82)이 이미 올라와 있어 순서대로 머지하면 짧게 지나갑니다. main 자동 배포도 꺼져 있어(TMT-290) 그 중간 상태가 서버로 나가지 않습니다 — 릴리즈 태그 전까지는 편하게 쌓으셔도 됩니다.
명세에 반영할 것
GET /v1/groups/name-availability의 중복 판정이 대소문자·공백을 구분하는 건 #79에서 물어봤고, 이 PR의 groups.name UNIQUE가 같은 기준이라 구현은 일관됩니다. 다만 D_02 명세에 그 사실이 없으면 한 줄 적어두는 게 좋겠습니다 — TMT와 tmt가 둘 다 만들어지는 게 G6의 의도인지가 문서에 남아야 합니다.
mingdodev
left a comment
There was a problem hiding this comment.
[must] 두 건을 라인에 남겼습니다. 둘 다 조회 경로가 조용히 깨지는 형태라 머지 전에 봐주시면 좋겠습니다.
| memberCount = memberCount, | ||
| reviewCount = reviewCount, | ||
| placeCount = placeCount, | ||
| foodCategory = GroupDetailResponse.FoodCategory(foodCategoryId, FOOD_LABELS.getValue(foodCategoryId)), |
There was a problem hiding this comment.
[must] FOOD_LABELS.getValue(foodCategoryId)·REGION_LABELS.getValue(it)는 키가 없으면 NoSuchElementException이라 500이 됩니다. 검증이 쓰기 경로(GroupCommandService)에만 있어서 DB에 이미 들어간 값은 통과를 보장하지 못하는 점이 걸립니다.
- TMT-231의
V5__seed_ut2_content.sql은groups에 직접 INSERT라GroupTagCatalog검사를 거치지 않습니다. 시드의food_category_id가 카탈로그와 어긋나면 그 그룹 상세는 열 때마다 500입니다 - 카탈로그에서 태그를 빼거나 id를 바꾸면, 그 태그를 쓰던 기존 그룹의 조회가 먼저 막힙니다 (쓰기만 막힐 거라고 보기 쉬운 지점입니다)
표시용 라벨이라 ?: id 폴백이면 라벨만 못생기게 나오고 화면은 뜹니다. 그쪽이 나아 보입니다.
There was a problem hiding this comment.
반영했습니다 (ec46df4) — FOOD_LABELS[id] ?: id 폴백으로 바꿨습니다. 말씀대로 V5 시드가 카탈로그를 안 거치는 경로라, 시드 쪽은 생성기가 GroupTagCatalog와 같은 상수를 쓰지만 조회가 500이 되는 구조 자체를 없앴어요.
| @Component | ||
| class GroupDetailComposer( | ||
| private val groupDetailPort: GroupDetailPort, | ||
| @param:Value("\${tmt.media.base-url:}") private val mediaBaseUrl: String, |
There was a problem hiding this comment.
[must] tmt.media.base-url 기본값이 ""라, 프로퍼티가 없으면 모든 imageUrl·coverImages.url이 조용히 /{s3Key} 상대경로로 나갑니다.
mock의 MockMediaUrls 경로엔 없던 실패 방식입니다. 기본값을 없애고 기동 시점에 실패하게 두는 편이 안전해 보입니다. TMT-223의 GroupShareService에도 같은 형태가 있어서, 한쪽만 고치면 다른 쪽에서 재발합니다 — 두 곳을 같은 방식으로 정하면 좋겠습니다.
There was a problem hiding this comment.
|
base 브랜치(feat/TMT-220) 삭제로 자동 닫혔고 다시 열 수 없어 #87로 재상신했습니다. 이 스레드의 [must] 3건·[q] 2건은 전부 반영돼 있습니다 — 라인 답변 참조. |
TMT-221 — 그룹 생성·편집 실구현
POST /v1/groups·PUT /v1/groups/{groupId}를 실 DB로 옮깁니다. 응답 형태·ID 표기는 mock과 같습니다.설계
groups.nameUNIQUE가 정본 (G6) — 사전 체크 대신saveAndFlush로 제약 위반을 즉시 터뜨려GROUP_NAME_DUPLICATED로 변환합니다. 사전 체크는 경합을 못 막고, 제약은 경합까지 막습니다group_membershipACTIVE 행 생성,member_count기본값 1이 생성자 몫AttachMediaUseCase) 재사용GroupDetailComposer로 분리 — 생성·편집 응답과 상세 조회(TMT-222)가 같은GroupDetailView를 씁니다. 222는 컨트롤러 한 장만 얹으면 됩니다GroupStatsPort구현은 TMT-224에서 이미 들어와 있어(조건부 UPDATE) 이 PR은 검증만 합니다검증 — 로컬 E2E (승인 기준 전부)
Location: /v1/groups/group_1+ 상세 형태(라벨·isOwner·isMember), DB에 멤버십 ACTIVE·asset ATTACHEDGROUP_OWNER_REQUIRED[guro]→[gangseo, songpa]응답·DB 일치MEDIA_NOT_OWNED· 접두 붙은 assetId → 403member_count동시 +1 10발 → 정확히 +10 (조건부 UPDATE 무손실)./gradlew build통과 (신규 테스트 12개 — 서비스 8·컨트롤러 4). mock 생성·편집 핸들러와 해당 테스트 12개 제거.과도기 주의
실구현으로 만든 그룹은 mock 인메모리에 없어서 mock으로 남아 있는 상세(GET)·가입은 그 그룹을 모릅니다. TMT-222(상세)·223(공유)이 뒤따라와야 그룹 레인이 이어집니다 — 바로 진행합니다.
🤖 Generated with Claude Code