Skip to content

feat: 휴지통 템플릿 복구 API (#67) - #69

Merged
oTAETAEo merged 11 commits into
developfrom
feat/67-trash-restore
Nov 7, 2026
Merged

oTAETAEo merged 11 commits into
developfrom
feat/67-trash-restore

Conversation

@oTAETAEo

@oTAETAEo oTAETAEo commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

관련 이슈

작업 내용

명세 16번 POST /api/v1/trash/templates/{templateId}/restore를 구현했습니다. 한도 검사, 자동 번호, 저장 규칙은 생성·갤러리 저장과 같은 코드를 씁니다.

커밋별로 보기

  1. 엔티티: Template.restore(Shortcut). 휴지통이 아닌 템플릿에 부르면 IllegalStateException(코드 실수)
  2. 리팩터: TemplateCommandService의 한도 검사·자동 번호·저장을 패키지 범위로 열고, 자동 번호는 assignShortcut으로 뽑음 (동작 변경 없음)
  3. 서비스: TrashCommandService.restore, TrashRestoreCommand, TRASH404_1
  4. API: TrashController에 추가, TRASH200_2, TrashRestoreResponse
  5. 동시성 테스트. 동시 실행 도우미는 ConcurrentRunner로 뽑아 기존 생성 동시성 테스트와 같이 씀
  6. 수정: 복구할 템플릿을 FOR UPDATE로 잠가 읽음 (TemplateRepository.findTrashedByIdAndUserIdForUpdate 추가)
  7. 테스트: 숫자가 아닌 templateId → COMMON400_1
  8. 리팩터: ConcurrentRunner를 support에서 template 서비스 테스트 패키지로 옮기고 패키지 범위로 좁힘 (템플릿 동시성 테스트만 씀)
  9. 문서: 명세 16·21번 에러 표와 비고에 동시 수정 충돌 TEMPLATE409_1 추가
  10. 리팩터: TrashCommandService의 클래스 수준 @Transactional(readOnly = true) 제거 (리뷰 반영, TemplateCommandService와 같은 방식)
  11. 문서: CLAUDE.md 트랜잭션 규칙을 "조회 서비스는 클래스에 readOnly, 명령 서비스는 쓰기 메서드에만 @Transactional"로 변경

처리 순서 (한 트랜잭션)

순서 내용 실패
1 회원 행 잠금 (lockUser). 트랜잭션의 첫 조회
2 본인 소유 + 휴지통에 있는 템플릿을 잠가 조회 (FOR UPDATE) TRASH404_1
3 휴지통 제외 개수가 한도(50) 이상인지 TEMPLATE403_1
4 삭제 당시 단축어가 활성 템플릿과 겹치면 2~99 자동 번호 TEMPLATE409_2
5 deleted_at NULL, 번호가 붙었으면 shortcut·shortcut_key도 변경
6 저장. 유니크 위반은 기존 save()로 변환 TEMPLATE409_1
  • edited_at·last_used_at·created_at은 삭제 전 값 그대로, updated_at만 감사 기능이 갱신합니다.
  • 템플릿을 잠금 뒤에 읽으므로, 같은 템플릿을 동시에 복구하면 뒤에 온 요청은 이미 복구된 행을 보고 TRASH404_1이 됩니다.
  • 템플릿 행도 잠가 읽습니다. 정리 배치·영구 삭제는 회원을 잠그지 않아서, 일반 조회로 읽으면 저장 전에 행이 지워져 UPDATE가 0행이 되고 500이 났습니다(테스트로 재현). 잠가 읽으면 지우는 쪽을 기다렸다가 TRASH404_1입니다. 잠금 조회는 스냅샷을 만들지 않아 이어지는 개수 검사에는 영향이 없습니다.
  • 잠금 순서는 회원 행 → 템플릿 행입니다. 17·18번에서 템플릿을 먼저 잠그고 회원을 잠그면 교착이 날 수 있습니다.

명세와 달라진 점

  • 명세 16·21번 에러 표에 TEMPLATE409_1을 추가했습니다(리뷰 반영, docs 커밋). 잠금을 잡지 않는 수정 요청이 조회와 저장 사이에 같은 단축어로 바꿀 때만 나며, 화면은 입력창 에러가 아니라 "다시 시도" 안내로 처리합니다. 다시 요청하면 자동 번호가 붙어 성공합니다.
  • TEMPLATE409_2는 지금 한도로는 실제 행으로 닿을 수 없습니다. 번호 후보는 최소 91개인데 한도가 50개라 활성 49개에서도 번호가 남습니다. 테스트는 개수 조회만 바꿔 확인했습니다.

테스트

  • TrashRestoreControllerTest (테스트컨테이너 MySQL, 고정 시계)
    • 성공 응답·필드 순서·no-store, DB의 deleted_at NULL, edited_at·last_used_at·created_at 유지, updated_at 갱신
    • 겹치지 않으면 원본 그대로(shortcutChanged: false), 겹치면 /견적 → /견적2, 대소문자만 다른 경우, 자모 분리형 입력, 10자 자르기
    • 휴지통에 남은 같은 단축어는 충돌이 아님
    • 한도 49 → 성공(50개), 50 → TEMPLATE403_1(휴지통에 그대로), 한도와 번호 소진이 겹치면 403_1이 먼저
    • 없는 ID, 다른 회원의 활성·휴지통 템플릿, 휴지통에 없는 내 템플릿 TRASH404_1, 토큰 없음 COMMON401_1, 숫자가 아닌 templateId COMMON400_1
    • 복구 후 15번 휴지통 목록·개수에서 빠지고 활성 개수에 들어감
  • TrashRestoreConflictTest: 번호 소진 TEMPLATE409_2, 저장 시 유니크 위반 TEMPLATE409_1 (500으로 새지 않고 휴지통에 그대로)
  • TrashRestoreConcurrencyTest (실제 커밋)
    • 49개에서 복구 2건 → 1건 성공·1건 403_1, 최종 50개
    • 생성 + 복구 → 1건 성공·1건 403_1, 최종 50개
    • 같은 템플릿 복구 2건 → 1건 성공·1건 TRASH404_1
    • 같은 단축어의 두 템플릿 복구 → 둘 다 성공, /견적·/견적2
    • 영구 삭제가 행을 잡고 있는 동안 복구 → 기다렸다가 TRASH404_1 (고치기 전에는 ObjectOptimisticLockingFailureException으로 500)
    • lockUser를 템플릿 조회 뒤로 옮기면 네 건이 모두 실패하는 것을 확인했습니다
  • TrashTemplateRepositoryTest: 복구용 잠금 조회는 본인 휴지통만 찾음 (활성·다른 회원 제외)
  • TrashCommandServiceTest (모키토): 호출 순서(잠금 → 조회 → 한도 → 번호 → 저장), 주요 분기, 실패하면 휴지통에 그대로
  • TemplateTest: restore 시각 컬럼 유지, 번호 단축어, 휴지통이 아닐 때 IllegalStateException, 단축어 없음 IllegalArgumentException
  • TrashErrorCodeTest: TRASH404_1 코드와 문구
  • 전체 940개 통과

확인 체크리스트

  • ./gradlew build 통과
  • 테스트 포함
  • 변경 400줄 안팎 → 1,083줄 (본 코드 154줄, 테스트 929줄). 테스트가 대부분이고 기능을 나눌 수 없어 그대로 뒀습니다
  • 병합된 마이그레이션 수정 안 함
  • 비밀 값·개인정보 없음
  • global 공통 코드 변경 없음
  • CLAUDE.md 트랜잭션 규칙 변경 (팀 공유 필요)

리뷰어에게

  1. 공유 방식: 한도 검사·자동 번호·save()를 복사하지 않고 TemplateCommandService의 패키지 범위 메서드로 열었습니다. #50에서 합의한 TemplateQueryService.getActiveTemplate과 같은 모양이고, 다른 도메인에서는 부를 수 없습니다. 별도 구성 요소로 뽑는 방법도 있지만, 그러면 기존 TemplateCommandServiceTest의 준비 코드를 바꿔야 해서 변경을 줄이는 쪽을 골랐습니다
  2. 본문 복호화 실패 행은 복구도 500입니다: 복구는 엔티티를 고쳐야 해서 엔티티를 읽습니다. 휴지통 목록(15번)은 TrashTemplateRow라 영향이 없습니다. 17·18번과 정리 배치는 엔티티를 읽지 않고 지울 예정입니다
  3. TemplateRepository는 메서드 추가만 했습니다: 복구용 잠금 조회 하나입니다. #68이 같은 파일을 고치므로 병합할 때 겹칠 수 있지만 둘 다 추가라 정리가 쉽습니다
  4. 휴지통 목록 테스트의 흉내 낸 복구: TrashControllerTest는 아직 네이티브 SQL로 복구를 흉내 냅니다. 이번 PR 범위를 넘으므로 그대로 뒀습니다

🤖 Generated with Claude Code

@oTAETAEo oTAETAEo added feature 기능 개발 template 내 템플릿·휴지통 labels Nov 5, 2026
@oTAETAEo oTAETAEo self-assigned this Nov 5, 2026
@oTAETAEo oTAETAEo changed the title [feat] 휴지통 템플릿 복구 API (#67) feat: 휴지통 템플릿 복구 API (#67) Nov 5, 2026
@oTAETAEo
oTAETAEo marked this pull request as ready for review November 5, 2026 06:04
chjpooh2002
chjpooh2002 previously approved these changes Nov 6, 2026

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

리뷰 남겨두었으니 한 번 확인 부탁드립니다!

Shortcut original = template.getShortcut();
template.restore(templateCommandService.assignShortcut(command.userId(), original));

return TemplateShortcutAssignResult.of(templateCommandService.save(template), original);

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.

수정 API는 회원을 잠그지 않아서, assignShortcut으로 번호를 고른 뒤 저장 전에 다른 탭에서 같은 단축어로 수정하면 여기서 유니크 위반이 나고 save()가 TEMPLATE409_1로 바꿔 내보낼 것 같아요!
그런데 명세 16번 에러 표에는 409_1이 없고, 복구는 사용자가 단축어를 고르는 게 아니라서 "이미 사용 중인 단축어예요." 문구도 안 맞아요. 갤러리 저장(21번)도 같은 경로라 같은 상황이고요.

거의 안 일어나는 경우라 코드를 바꾸기보다는, 명세 16번·21번 에러 표에 409_1을 "동시 수정과 겹친 경우, 다시 시도하면 됨"으로 추가하고 프론트에 공유하는 정도면 충분할 것 같은데 어떻게 생각하시나요?!

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.

좋습니다 해당 부분은 명세 16, 21번에 반영하구 프론트에 공유 드리겠습니다 👏

Comment on lines +126 to +145
/*
* 아래 패키지 범위 메서드 3개는 휴지통 복구(TrashCommandService)가 같이 쓴다. 생성·갤러리 저장과 규칙이 같아야 하기 때문이다.
* 셋 다 트랜잭션을 따로 열지 않으므로, 호출자가 lockUser를 첫 조회로 부른 트랜잭션 안에서 불러야 한다.
* save는 엔티티를 받고 돌려주고, 순서를 지키지 않으면 한도를 넘을 수 있어 template 도메인 안에서만 부른다
* (패키지 범위라 service 패키지 밖에서는 부를 수 없다).
*/

/** 활성 템플릿(휴지통 제외)이 한도 이상이면 TEMPLATE403_1 */
void validateTemplateLimit(Long userId) {
if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= UsageLimits.TEMPLATE_LIMIT) {
throw new BusinessException(TemplateErrorCode.TEMPLATE_LIMIT_REACHED);
}
}

/** 활성 템플릿과 겹치지 않으면 원본, 겹치면 자동 번호를 붙인 단축어. 2~99가 모두 차면 TEMPLATE409_2 */
Shortcut assignShortcut(Long userId, Shortcut original) {
Set<String> usedKeys = templateRepository.findActiveShortcutKeys(userId, original.candidateKeys());
return original.findAvailable(usedKeys)
.orElseThrow(() -> new BusinessException(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED));
}

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.

(선택, 후속 이슈로 넘겨도 돼요) 한도 검사·자동 번호·저장을 이제 세 곳(생성·갤러리 저장·복구)이 같은 규칙으로 쓰게 됐는데, 지금은 TrashCommandService가 TemplateCommandService의 패키지 범위 메서드를 직접 부르는 구조라 서비스끼리 의존하게 됐어요.
이 세 메서드를 패키지 범위 컴포넌트(예: TemplateSaveSupport)로 따로 빼면, 두 서비스가 같은 도우미에 기대는 형태가 되고 "회원 잠금을 먼저 잡은 트랜잭션 안에서 불러야 한다"는 규칙도 한 클래스 주석에 모을 수 있을 것 같아요.
동작은 지금도 문제없어서 이번 PR에서 꼭 바꿀 필요는 없다고 생각합니다!

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.

의견 감사합니당 🙂

확인해 보니 이 세 메서드를 쓰는 곳은 생성, 갤러리 저장, 복구 세 곳으로 끝이고 지금은 같은 패키지 안에서만 부르고
있다보니 괜찮을것 같습니다 !

쓰는 곳이 더 생기거나 TemplateCommandService가 커지면 그때 분리하는것도 좋을것 같아요 !

해당 부분은 남은 API 추가 후 리팩토링 지점으로 두겠습니다


@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)

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.

(사소) 명령 서비스인데 클래스에 readOnly = true가 붙어 있어서, 나중에 쓰기 메서드를 추가하면서 @Transactional을 빠뜨리면 읽기 전용 트랜잭션에서 쓰기가 실행될 수 있을 것 같아요.
TemplateCommandService처럼 클래스 수준 어노테이션은 빼고 메서드에만 두면 두 명령 서비스 방식도 맞춰질 것 같습니다!

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.

좋습니다 해당 부분은 CLAUDE.md 파일에 규칙이 있는것 같아서 같이 제거 해서 반영 하겠습니다 !

@oTAETAEo
oTAETAEo merged commit e83e6c1 into develop Nov 7, 2026
1 check passed
@oTAETAEo
oTAETAEo deleted the feat/67-trash-restore branch November 7, 2026 04:14
@oTAETAEo oTAETAEo mentioned this pull request Nov 7, 2026
6 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 기능 개발 template 내 템플릿·휴지통

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 휴지통 템플릿 복구 API

2 participants