Skip to content

위시·토너먼트 등록 경로 정리와 주석 인지부채 제거 - #1030

Merged
m-a-king merged 11 commits into
devfrom
refactor/comment-debt
Sep 4, 2026
Merged

위시·토너먼트 등록 경로 정리와 주석 인지부채 제거#1030
m-a-king merged 11 commits into
devfrom
refactor/comment-debt

Conversation

@m-a-king

@m-a-king m-a-king commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • 코드를 읽다 보니 주석이 이해를 돕는 게 아니라 방해하고 있었다. 실측해 보니 item 패키지가 주석 30퍼센트로 저장소 평균(17퍼센트)의 두 배였고, 파싱 기계 쪽은 파일에 따라 50퍼센트를 넘었다.
  • 문제는 양이 아니라 종류였다. 상당수가 코드가 이미 말하는 것을 한 번 더 말하고 있었고, 그 사이에 정말 필요한 정보가 묻혀 있었다.
  • 위시 등록 진입점(POST /wishlists)부터 영속화 바닥까지 한 체인을 따라가며 정리했고, 주석을 걷어내는 과정에서 구조 문제 몇 가지가 드러났다.

Task

  • 주석 판정 기준을 하나로 세운다: 숙련 개발자가 이 주석 없이 놓칠 정보가 있는가.
  • 주석이 변호하고 있던 구조는 주석이 아니라 구조를 고친다.
  • 등록 경로(URL·이미지)를 위시와 토너먼트 양쪽에서 같은 골격으로 맞춘다.

Action

주석이 가리키던 구조 문제

발견 신호 처리
위시·토너먼트가 등록 서두를 각자 베낌 두 곳의 주석이 거의 글자 그대로 같음 ItemRegistrar 로 관문 추출
content-type 검증이 두 번 돎 "이 중복은 무해하다" 는 변호 주석 UploadFormat 이 검증 결과를 실어 나름
persist 가 저장을 약속하며 다섯 가지를 함 이름과 본문의 불일치 붙는 길·새로 세우는 길 두 갈래로 분리
persist 가 링크 없는 Item 을 분기 널이 두 이유로 뭉개져 읽기 어려움 호출자 16곳 전수 확인 후 ProductLink 를 직접 받음

등록 관문

  • 링크 등록의 서두(형식·정책 판정과 몫 확보)를 ItemRegistrar 한 곳으로 모았다. 두 호출자가 이 순서를 각자 외우던 동안 자격 검사 위치가 이미 어긋나 있었다(위시는 맨 앞, 토너먼트는 세 번째). 순서를 맞췄다.
  • 중복 판정은 관문 밖에 남겼다. 기준이 도메인마다 다르고(내 위시 대 이 토너먼트) 차감 앞에 와야 해서, 인자로도 밖으로도 뺄 수 없는 자리다.

한도 코드 통합

  • WISH-010TOURNAMENT-037ITEM-006 하나로 합쳤다. 카운터가 하나인데 담는 자리마다 code 를 나눌 이유가 없고, 이 인자가 필요했던 유일한 이유가 code 가 둘이라는 것이었다.
  • 문구는 몫의 주인을 드러내지 않는 쪽으로 골랐다. 토너먼트는 오너 몫에서 깎지만 응답은 참여 게스트도 받으므로, 옛 위시 문구를 그대로 쓰면 남의 사용량이 새는 자리가 된다.

탈퇴 경합 가드 제거

WishPersistenceService.persistrejectIfWithdrawnForUpdate 를 걷어냈다. 검토 내용은 아래와 같다.

이 가드가 답하는가
cascade 가 wishes 를 지운 뒤 INSERT 가 끼어드는 것 막는다
죽은 유저를 위한 파싱 실행 못 막는다(item/service 에 유저 검사 0건)
한도 차감 못 막는다(persist 앞이라 락 밖)
고아 snapshot 못 막는다(공유 자원이라 cascade 대상 아님)
  • 한 구멍만 막고 "탈퇴 경합을 막는다" 로 읽히면 보장 범위를 실제보다 넓게 믿게 된다. 활성 유저 확인과 쓰기 사이의 check-then-use 경합 차단 #776 본문도 이 선택지를 "경합 자체를 없애진 못하고 창을 좁힌다" 로 적어 두고 있었다.
  • 탈퇴 후 남는 wish 행은 배치 정리로 푼다(후속).
  • FCM 기기 등록과 지연 이미지 등록의 같은 가드는 남긴다. 후자는 스케줄러 공용이라 예외를 던지면 롤백이 claim 을 되살려 무한 재시도가 되는, 성격이 다른 가드다.

이미지 등록 플로우

  • ImagePresignService 26줄, 폴링 스케줄러 30줄, claimer 5줄이던 주석을 각각 3·7·2줄로 줄였다.
  • existsOrNulluploadedOrUnknown 으로 바꿔 "null 은 판단 못 함" 이라는 주석을 이름으로 옮겼다.
  • UploadFormat@ConsistentCopyVisibility 를 붙였다. data class 는 생성자가 private 이어도 copy() 가 공개라 검증을 우회한다.

남긴 주석의 네 부류

  • 지시: @Async 로 바꾸지 말 것. 하면 재진입 가드가 async body 로 들어가 무력해지고 fixedDelayfixedRate 가 된다. 코드에 존재할 수 없는 정보다.
  • 부재: 등록에 못 매인 raw 를 여기서 안 지운다는 것. 없는 동작은 코드가 못 말한다.
  • 착시: presign 이 로컬 계산이라 트랜잭션에 묶어도 된다는 것. 외부 호출처럼 보인다.
  • 전파 속성: 삭제가 곧 claim 이고 자기 트랜잭션을 열면 안 된다는 것. 코드에 안 보인다.

Result

  • 위시 등록 진입점이 다섯 줄이 됐다. 회원 확인, 링크 파싱, 중복 거절, 관문 통과, 저장.
  • 토너먼트 등록도 같은 골격으로 읽힌다. 두 경로가 관문을 공유하므로 세 번째 등록 경로가 생겨도 순서를 다시 외울 필요가 없다.
  • ITEM-006 은 새 code 라 클라이언트가 모른다. 현재 클라는 이 code 들로 분기하지 않고 detail 만 표시하므로(client repo 에서 WISH-010·TOURNAMENT-037 참조 0건 확인) 화면은 그대로 동작하지만, 배포 전 공유가 필요하다.
  • 파싱 위치를 역직렬화로 옮기는 안을 시도했다가 되돌렸다. 그 과정에서 LINK-001COMMON-INVALID-INPUT 으로 뭉개지는 것을 발견해, 현재 계약(형식·스킴·빈 값·길이 네 갈래)을 HTTP 레벨 테스트로 고정해 두었다. 다음에 같은 시도를 할 때 이 테스트가 먼저 깨진다.
  • 후속으로 남은 것: 탈퇴 유저 잔여 행의 배치 정리, 폴링이 안 올라온 key 를 7분 동안 초당 한 번씩 확인하는 문제(key 당 HEAD 약 400회), 컨트롤러가 SourcePlatformResolver 를 직접 부르는 계층 위반.

연관 이슈

- 판정 기준은 "숙련 개발자가 이 주석 없이 놓칠 정보가 있는가" 하나로 뒀다
- toResponse 위 두 줄은 앞 문장이 함수명 복창이고 뒷 문장은 DTO companion 이 빈을 못 쓴다는 Spring 기본기라 통째로 삭제
- registerFromUrl 의 attach 메타 설명은 fromRegistration 이라는 이름이 이미 등록 전용임을 말하고 실리는 값은 DTO 를 열면 보여 삭제
- confirmImageRegistration 의 201 설명은 바로 아랫줄 @ResponseStatus(CREATED) 의 복창이라 삭제
- presign 의 200 사유만 한 줄로 압축해 남겼다. 애노테이션의 부재는 실수와 구별되지 않아, 없으면 다음 사람이 빠뜨린 줄 알고 201 을 붙인다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 위시(registerFromUrl)와 토너먼트(addItemFromLink)가 parse + verifyRegistrable 을 각자 베껴 쓰고 있었다. 주석까지 거의 글자 그대로 같아, 같은 지식이 두 곳에 있다는 신호로 보고 관문을 뽑았다
- 이 관문의 존재 이유는 순서다. 형식·정책 위반과 중복은 차감 앞에서 걸러야 한다(#973) — 뒤로 가면 등록되지도 않을 요청이 사용자 몫을 깎는다. 두 호출자가 그 순서를 각자 외우던 동안 자격 검사 위치가 이미 어긋나 있었다(위시는 맨 앞, 토너먼트는 verifyRegistrable 뒤)
- 차감은 처음엔 도메인에 두려 했다. 주인과 에러 코드가 도메인마다 달라 옮겨도 인자로 되돌아온다고 봤기 때문인데, 지켜야 할 것이 인자가 아니라 순서라 관문 안으로 넣었다
- 중복 판정만 콜백으로 남겼다. 기준이 도메인마다 다르고(내 위시 대 이 토너먼트) 차감 앞에 와야 해서, 인자로도 밖으로도 뺄 수 없는 자리다
- 반환형은 Item 이 아니라 ProductLink 로 했다. 저장하지 않은 Item 을 돌려주면 만든 것처럼 읽히고, persistLinkItem 이 link 를 받아 되레 풀어야 했다
- 토너먼트의 verifyCanAddItems 를 관문 앞으로 당겨 위시와 순서를 맞췄다. 참여자가 아닌 사람이 차단 도메인 URL 을 넣으면 이전에는 400(미지원 플랫폼), 이제는 권한 오류가 먼저 난다
- DomainAccessPolicy 의존이 위시·토너먼트 양쪽에서 사라졌다. ItemQuotaGuard 는 이미지 presign 이 장수만큼 따로 차감해 남는다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- ItemRegistrar.accept 이 quotaOwner 와 quotaErrorCode 를 따로 받고 있었는데, 관문은 둘을 쓰지 않고 ItemQuotaGuard 에 그대로 흘려보내기만 했다. 순수한 통과 인자 둘이 시그니처에 새어 있던 셈이다
- 둘은 함께 정해져야 하는 한 덩어리다. 문구가 주인을 전제하기 때문이다 - TOURNAMENT-037 은 차감 주체가 오너인데 응답은 게스트 참여자도 받으므로 남의 사용량을 감추는 문구를 쓴다. 따로 넘기면 위시 주인에 토너먼트 코드를 실어도 컴파일된다
- 두 code 의 문구가 실제로 다른 것을 확인하고 파라미터화를 유지했다. 주인이 요청자인지 남인지 하나로 갈라 문구를 공통화하는 안도 있으나 와이어 계약 변경이라 남겨 둔다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 관문이 콜백을 받던 모양을 걷어내고 판정 전용으로 좁혔다. 호출부가 parse - 중복 - accept - persist 로 위에서 아래로 읽힌다
- 차감을 호출 도메인에 두는 안도 검토했으나, 한도는 아이템 등록의 관심사라 위시·토너먼트로 다시 올리는 것은 방향이 반대다. 관문 안에 두고 몫의 주인만 인자로 받는다
- WISH-010 과 TOURNAMENT-037 을 ITEM-006 하나로 합쳤다. 카운터가 하나인데 담는 자리마다 code 를 나눌 이유가 없고, 이 인자가 있어야 했던 유일한 이유가 code 가 둘이라는 것이었다
- 합친 문구는 몫의 주인을 드러내지 않는 쪽으로 골랐다. 토너먼트는 오너 몫에서 깎지만 응답은 참여 게스트도 받으므로, 옛 위시 문구("더 담을 수 없어요")를 그대로 쓰면 남의 사용량이 새는 자리가 된다
- client repo 에서 두 code 참조가 0건인 것을 확인하고 진행했다. 응답 code 값이 바뀌는 와이어 변경이다
- 중복 판정이 관문 밖으로 나오면서 차감 앞이라는 순서는 호출자가 진다. 두 호출자 모두 그 순서를 지키고 있다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- persist 가 이름은 저장을 약속하면서 안에서 다섯 가지를 했다. 붙는 길(attachToShared)과 새로 세우는 길(createFresh)로 갈라 본체를 세 줄로 줄였다
- manualEdit·updateMemo·refresh 가 snapshot·item 을 각자 조회하며 같은 error 문구를 세 벌 쓰고 있어 activeVersionOf 로 묶었다. updateMemo 는 본체가 네 줄이 됐다
- 주석 65줄을 32줄로 줄였다. 남긴 것은 코드가 답할 수 없는 것들이다 - 사전 확인이 락 밖이라 근사치라는 것(중복 검사가 두 번 도는 이유), 중복 판정 기준이 shared 가 아니라 attachment.item 이라는 것(병합 경합의 승자·패자), updateMemo 가 포인터를 안 바꾸는데도 락이 필요한 이유(전 컬럼 UPDATE 라 lost update), saveAll 반환 순서가 계약이 아니라는 것
- persist 의 rejectIfWithdrawnForUpdate 를 지웠다. 이 가드가 실제로 보장하는 것은 "cascade 가 wishes 를 지운 뒤 새 wish 행이 끼어들지 않는다" 하나뿐인데, 파싱 실행·한도 차감·고아 snapshot 은 전부 락 밖이라 못 막는다(item/service 에 유저 검사 0건). #776 본문도 이 선택지를 "경합 자체를 없애진 못하고 창을 좁힌다" 로 적어 뒀다
- 한 구멍만 막고 "탈퇴 경합을 막는다" 로 읽히면 보장 범위를 실제보다 넓게 믿게 된다. 탈퇴 후 남는 wish 행은 배치 정리로 푼다
- 그에 맞춰 UserWithdrawalRaceConcurrencyIntegrationTest 의 URL 등록 경합 케이스를 걷어냈다. 프로필·지연 이미지·FCM 세 경합은 가드가 그대로라 유지된다
- 지연 이미지 경로의 isActiveForUpdate 는 남긴다. 스케줄러 공용이라 예외를 던지면 롤백이 claim 을 되살려 무한 재시도가 되는, 성격이 다른 가드다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- persist 는 Item 을 받으면서 link 가 null 인 경우를 분기하고 있었으나, 호출자 16곳이 전부 Item(link) 를 만들어 넘긴다. 이미지 경로는 persistImagesInternal 로 따로 가므로 link 없는 Item 은 들어올 수 없다
- 받을 수 없는 값을 받는다고 선언해 둔 탓에 널 분기가 생겼고, null 이 두 가지 이유(링크가 없다·붙을 데가 없다)로 뭉개져 읽기 어려웠다. 링크를 직접 받게 하니 본체가 한 줄이 된다
- Item 생성이 createFresh 안으로 들어갔다. 위시는 서비스에서 Item 을 만들고 토너먼트는 영속화 안에서 만들던 비대칭이 함께 정리된다
- WishlistService 는 Item 을 더 이상 알지 않는다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- LINK-001(형식)·LINK-002(스킴)·빈 값·길이 네 갈래가 각각 다른 code·detail 로 나가는데, HTTP 레벨 검증이 없어 파싱 위치를 옮기면 조용히 뭉개진다. 실제로 ProductLink 파싱을 역직렬화로 옮겨 보니 LINK-001 이 COMMON-INVALID-INPUT 으로 바뀌는 것을 이 테스트가 잡았다
- 스킴 없는 상대 URI("example.com/...")는 URI.create 가 통과시켜 형식이 아니라 스킴 오류로 떨어진다. 형식 오류를 재려면 host 에 공백이 든 입력이어야 한다는 것도 함께 고정한다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 두 진입점이 content-type 을 검증해 결과를 버리고, presignRawUploads 가 같은 검증을 다시 했다. "이 중복은 무해하다" 는 주석이 그 구조를 변호하고 있었다
- UploadFormat 을 두어 검증 결과(확장자)를 실어 나른다. 검증이 차감 앞이라는 순서는 그대로고, 결과를 쓰니 중복이 사라져 변호할 주석도 없어졌다
- ImagePresignService 26줄 -> 6줄. 남긴 것은 raw 회수를 여기서 안 한다는 것(부재라 코드가 못 말한다), presign 이 로컬 계산이라 트랜잭션에 묶어도 되는 근거, 두 발급 메서드의 차이, key 정규식이 ProductImage 에서 파생된다는 것
- PendingUploadPollingScheduler 는 @async 로 바꾸면 안 되는 이유만 남겼다. 재진입 가드가 async body 로 들어가 무력해지고 fixedDelay 가 fixedRate 가 되는데, 코드만 봐서는 executor.execute 를 @async 로 "정리" 하고 싶어진다
- 지운 것은 메서드 이름이 이미 말하던 것들이다. 만료 경로의 네 갈래 열거는 아래 if/else 와 로그가 같은 말을 하고 있었다
- PendingUploadClaimer 는 삭제가 곧 claim 이라는 것과 자기 트랜잭션을 열면 안 되는 이유만 남겼다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- 앞 커밋이 공유 프리미티브만 정리하고 진입점 두 곳을 빠뜨려 기준이 반쪽으로 적용돼 있었다
- 지운 것은 세 부류다. 메서드 이름과 본문이 이미 말하는 것(발급 설명·확정 설명·만료 처리 요약), 바로 아래 로그가 같은 말을 하는 것(영구 사유·재시도), 다른 주석과 같은 사실을 두 번 말하는 것(정원 판정이 갈린다)
- existsOrNull 을 uploadedOrUnknown 으로 바꿔 "null 은 판단 못 함" 주석을 이름으로 옮겼다
- UploadFormat 은 private 생성자와 of 팩토리가 그 말을 하고 있어 주석을 없앴다
- 남은 것은 넷뿐이다. @async 로 바꾸지 말 것(하지 말라는 지시라 코드에 없다), 등록에 못 매인 raw 를 여기서 안 지운다는 것(부재), presign 이 로컬 계산이라 트랜잭션에 묶어도 된다는 것(외부 호출처럼 보이는 착시), 삭제가 곧 claim 이고 자기 트랜잭션을 열면 안 된다는 것(전파 속성이 코드에 안 보인다)
- 조회·수기수정·삭제 경로의 주석은 별개 플로우라 이번 범위에서 제외했다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
- data class 는 생성자가 private 이어도 copy() 가 공개라 검증을 우회한다. @ConsistentCopyVisibility 로 막는다
- requireMember 는 모든 진입 메서드가 첫 줄에서 부르는 손으로 짠 애스펙트라 TODO 로 표시했다
- 발급 시점 차감 근거는 커밋 이력과 이슈에 남으므로 코드에서 뺀다

Claude-Session: https://claude.ai/code/session_011f4kWuNL7ritPmuwz7R4c9
# Conflicts:
#	src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt
#	src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt
#	src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt
@m-a-king m-a-king added the refactor 구조 개선, 외부 동작 불변 label Sep 4, 2026
@m-a-king m-a-king self-assigned this Sep 4, 2026
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Team

Run ID: dd29bbdd-8989-47e5-b694-f2da2d401a20


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@m-a-king
m-a-king merged commit 6128345 into dev Sep 4, 2026
11 checks passed
@m-a-king
m-a-king deleted the refactor/comment-debt branch September 4, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor 구조 개선, 외부 동작 불변

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant