Skip to content

[TMT-221] feat: 그룹 생성·편집 실구현 — 이름 유일·생성자 멤버십·태그 집합 교체 - #80

Closed
wnsvy607 wants to merge 1 commit into
feat/TMT-220from
feat/TMT-221
Closed

[TMT-221] feat: 그룹 생성·편집 실구현 — 이름 유일·생성자 멤버십·태그 집합 교체#80
wnsvy607 wants to merge 1 commit into
feat/TMT-220from
feat/TMT-221

Conversation

@wnsvy607

Copy link
Copy Markdown
Contributor

TMT-221 — 그룹 생성·편집 실구현

스택 PR — base가 feat/TMT-220(#79)입니다. #79 머지 후 base가 main으로 넘어갑니다. 이 PR의 diff는 221 분량만 보입니다.

POST /v1/groups · PUT /v1/groups/{groupId}를 실 DB로 옮깁니다. 응답 형태·ID 표기는 mock과 같습니다.

설계

  • 이름 유일은 groups.name UNIQUE가 정본 (G6) — 사전 체크 대신 saveAndFlush로 제약 위반을 즉시 터뜨려 GROUP_NAME_DUPLICATED로 변환합니다. 사전 체크는 경합을 못 막고, 제약은 경합까지 막습니다
  • 생성자는 자동 멤버 (G11·G13) — group_membership ACTIVE 행 생성, member_count 기본값 1이 생성자 몫
  • 대표 이미지 STAGED↔ATTACHED 전이 (M4·M7) — 생성 시 attach, 교체 시 이전 detach·새것 attach, 그대로 두면 재부착 허용 집합으로 검증만. 리뷰 사진과 같은 업로드 경로(TMT-202 AttachMediaUseCase) 재사용
  • 지역 태그는 집합 교체 — delete + insert
  • 상세 조립을 GroupDetailComposer로 분리 — 생성·편집 응답과 상세 조회(TMT-222)가 같은 GroupDetailView를 씁니다. 222는 컨트롤러 한 장만 얹으면 됩니다
  • GroupStatsPort 구현은 TMT-224에서 이미 들어와 있어(조건부 UPDATE) 이 PR은 검증만 합니다

검증 — 로컬 E2E (승인 기준 전부)

  • 생성: 201 + Location: /v1/groups/group_1 + 상세 형태(라벨·isOwner·isMember), DB에 멤버십 ACTIVE·asset ATTACHED
  • 같은 이름 동시 8발 → 201 하나, 409 일곱, DB에 1행 (경합 기준 충족)
  • 비생성자 편집 → 403 GROUP_OWNER_REQUIRED
  • 태그 집합 교체: [guro][gangseo, songpa] 응답·DB 일치
  • 이미지 교체: 이전 asset STAGED 복귀, 새것 ATTACHED
  • 편집으로 남의 그룹 이름 뺏기 → 409 · 남의 asset → 403 MEDIA_NOT_OWNED · 접두 붙은 assetId → 403
  • member_count 동시 +1 10발 → 정확히 +10 (조건부 UPDATE 무손실)

./gradlew build 통과 (신규 테스트 12개 — 서비스 8·컨트롤러 4). mock 생성·편집 핸들러와 해당 테스트 12개 제거.

과도기 주의

실구현으로 만든 그룹은 mock 인메모리에 없어서 mock으로 남아 있는 상세(GET)·가입은 그 그룹을 모릅니다. TMT-222(상세)·223(공유)이 뒤따라와야 그룹 레인이 이어집니다 — 바로 진행합니다.

🤖 Generated with Claude Code

이름은 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[must] 이름 UNIQUE 말고 다른 제약 위반도 전부 GROUP_NAME_DUPLICATED가 됩니다.

saveAndFlush로 제약을 즉시 터뜨리는 판단 자체는 좋습니다 — 사전 체크는 경합을 못 막고 제약은 막으니까요. 다만 DataIntegrityViolationException은 이름 UNIQUE 전용이 아닙니다.

groupsimage_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.sqlname VARCHAR(50) NOT NULL UNIQUE라 제약명은 Postgres 기본 규칙으로 groups_name_key입니다. 이름을 문자열로 물고 가는 게 걸리면 마이그레이션에서 제약에 명시적 이름을 주는 방법도 있습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

반영했습니다 (ec46df4) — mostSpecificCause.messagegroups_name_key가 있을 때만 409로 바꾸고 나머지는 그대로 던집니다. 제약명은 companion 상수로 두고 V1 규칙 출처를 주석으로 남겼어요.

),
)
}
regionTagRepository.saveAll(regionTagIds.map { GroupRegionTagEntity(GroupRegionTagId(group.id, it)) })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[q] create에서 이름 충돌이 나면 지역 태그·멤버십은 어떻게 되나요?

saveAndFlush가 예외를 던지고 그게 트랜잭션 밖으로 나가니 전부 롤백돼서 결과적으로는 맞습니다. 다만 GroupCommandService.create@Transactional이라는 전제 위에 서 있습니다.

SaveMockController 계열에서 이 전제가 깨져 문제가 됐던 적이 있어서(#75 리뷰의 UserAccountAdapter도 같은 계열), 어댑터 쪽에 "이름 충돌 시 트랜잭션 롤백으로 태그·멤버십도 함께 되돌아간다"를 한 줄 남겨두면 나중에 누가 전파 속성을 바꿀 때 걸립니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

맞는 지적이라 어댑터에 주석으로 박았습니다 (ec46df4) — "태그·멤버십은 GroupCommandService.create의 @transactional 롤백으로 함께 되돌아간다, 이 전제(전파 속성)를 바꾸면 깨진다".

command: GroupCommand,
currentImageAssetId: Long? = null,
) {
if (command.name.isBlank() || command.oneLineDescription.isBlank()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[q] 그룹명 길이 상한이 검증에 없습니다.

name.isBlank()만 보고 길이는 안 봅니다. groups.nameVARCHAR(50)이라 51자가 오면 DB에서 잘리는 게 아니라 INSERT가 실패하고, 그게 DataIntegrityViolationException으로 잡혀 위 코멘트의 GROUP_NAME_DUPLICATED가 됩니다. "이름이 너무 깁니다"가 아니라 "이미 있는 그룹명입니다"가 나갑니다.

oneLineDescription도 같습니다(VARCHAR(100)). description만 200자 검증이 있네요.

F 명세가 newPlace.name에서 같은 이유로 길이 검증을 명시하고 있는데(§7 "조립 결과가 50자를 넘으면 저장이 실패하므로 길이를 검증한다"), 여기도 같은 처리가 맞아 보입니다. D_02 명세에 상한이 적혀 있으면 그 값을, 없으면 컬럼 폭을 기준으로요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

반영했습니다 (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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[want] 이미지 교체에서 detach가 먼저입니다.

지금 순서면 이전 asset을 STAGED로 돌린 뒤 새 asset을 ATTACHED로 만드는데, 그 사이에 예외가 나면(새 asset이 남의 것이라 MEDIA_NOT_OWNED 등) 트랜잭션이 롤백되니 결과적으론 안전합니다.

다만 verifyAttachablevalidate에서 이미 돌아 여기까지 왔다면 실패 경로가 거의 없어서, 순서를 attach → detach로 뒤집어도 동작은 같고 의도는 더 분명해집니다 — "새 것을 붙이고 헌 것을 놓는다"가 읽기 자연스럽습니다. 취향이라 그대로 두셔도 됩니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

순서는 그대로 두겠습니다 — detach 먼저가 '이전 것을 놓고 새 것을 붙인다'로 저는 더 자연스럽게 읽혀서요. 취향 영역이라고 해주셨으니 이 답변으로 갈음합니다.

@mingdodev mingdodev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 명세에 그 사실이 없으면 한 줄 적어두는 게 좋겠습니다 — TMTtmt가 둘 다 만들어지는 게 G6의 의도인지가 문서에 남아야 합니다.

@mingdodev mingdodev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[must] 두 건을 라인에 남겼습니다. 둘 다 조회 경로가 조용히 깨지는 형태라 머지 전에 봐주시면 좋겠습니다.

memberCount = memberCount,
reviewCount = reviewCount,
placeCount = placeCount,
foodCategory = GroupDetailResponse.FoodCategory(foodCategoryId, FOOD_LABELS.getValue(foodCategoryId)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[must] FOOD_LABELS.getValue(foodCategoryId)·REGION_LABELS.getValue(it)는 키가 없으면 NoSuchElementException이라 500이 됩니다. 검증이 쓰기 경로(GroupCommandService)에만 있어서 DB에 이미 들어간 값은 통과를 보장하지 못하는 점이 걸립니다.

  • TMT-231의 V5__seed_ut2_content.sqlgroups에 직접 INSERT라 GroupTagCatalog 검사를 거치지 않습니다. 시드의 food_category_id가 카탈로그와 어긋나면 그 그룹 상세는 열 때마다 500입니다
  • 카탈로그에서 태그를 빼거나 id를 바꾸면, 그 태그를 쓰던 기존 그룹의 조회가 먼저 막힙니다 (쓰기만 막힐 거라고 보기 쉬운 지점입니다)

표시용 라벨이라 ?: id 폴백이면 라벨만 못생기게 나오고 화면은 뜹니다. 그쪽이 나아 보입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

반영했습니다 (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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[must] tmt.media.base-url 기본값이 ""라, 프로퍼티가 없으면 모든 imageUrl·coverImages.url이 조용히 /{s3Key} 상대경로로 나갑니다.

mock의 MockMediaUrls 경로엔 없던 실패 방식입니다. 기본값을 없애고 기동 시점에 실패하게 두는 편이 안전해 보입니다. TMT-223의 GroupShareService에도 같은 형태가 있어서, 한쪽만 고치면 다른 쪽에서 재발합니다 — 두 곳을 같은 방식으로 정하면 좋겠습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

반영했습니다 (ec46df4) — MediaUrlResolver를 신설해 빈 base-url이면 기동 실패로 바꿨고, GroupDetailComposer·GroupExploreService·ReviewCardComposer(기존 nearby 경로도 같은 문제였음) 세 곳을 전부 이걸로 통일했습니다. #82의 GroupShareService도 같은 커밋 패턴으로 교체했어요(d2a34b6). 로컬은 application-local.yml에 공개 base URL을 뒀습니다(버킷이 공개 읽기라 시크릿 아님).

@wnsvy607

wnsvy607 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

base 브랜치(feat/TMT-220) 삭제로 자동 닫혔고 다시 열 수 없어 #87로 재상신했습니다. 이 스레드의 [must] 3건·[q] 2건은 전부 반영돼 있습니다 — 라인 답변 참조.

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