From e13308251e2d1f202c76304f6225b15c5e512072 Mon Sep 17 00:00:00 2001 From: "tjrwns1021@gmail.com" Date: Thu, 26 Feb 2026 14:19:10 +0900 Subject: [PATCH 01/66] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=EB=B3=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=AA=A9=EB=A1=9D=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- claudeResult/01-auth-member-plan.md | 273 ++++++++++++++++++ claudeResult/02-category-plan.md | 257 +++++++++++++++++ claudeResult/03-product-option-plan.md | 323 ++++++++++++++++++++++ claudeResult/04-order-plan.md | 323 ++++++++++++++++++++++ claudeResult/05-wish-plan.md | 367 +++++++++++++++++++++++++ 5 files changed, 1543 insertions(+) create mode 100644 claudeResult/01-auth-member-plan.md create mode 100644 claudeResult/02-category-plan.md create mode 100644 claudeResult/03-product-option-plan.md create mode 100644 claudeResult/04-order-plan.md create mode 100644 claudeResult/05-wish-plan.md diff --git a/claudeResult/01-auth-member-plan.md b/claudeResult/01-auth-member-plan.md new file mode 100644 index 00000000..9ac389c4 --- /dev/null +++ b/claudeResult/01-auth-member-plan.md @@ -0,0 +1,273 @@ +# auth-member 리팩터링 계획서 + +> 담당 패키지: `gift.auth`, `gift.member` +> 절대 조건: 작동(Behavior) 변경 금지 -- 리팩터링/정리/구조 변경만 수행 + +--- + +## 1. 기능 범위 정의 + +### 1.1 담당 클래스 목록 + +| 패키지 | 클래스 | 역할 | +|--------|--------|------| +| `gift.auth` | `AuthenticationResolver` | Authorization 헤더에서 JWT를 파싱하여 Member 조회 | +| `gift.auth` | `JwtProvider` | JWT 토큰 생성 및 검증 | +| `gift.auth` | `KakaoAuthController` | 카카오 OAuth2 로그인 플로우 (redirect + callback) | +| `gift.auth` | `KakaoLoginClient` | 카카오 API 호출 (토큰 교환, 사용자 정보 조회) | +| `gift.auth` | `KakaoLoginProperties` | 카카오 로그인 설정값 (`@ConfigurationProperties`) | +| `gift.auth` | `TokenResponse` | JWT 토큰 응답 DTO (record) | +| `gift.member` | `Member` | 회원 엔티티 (`@Entity`) | +| `gift.member` | `MemberController` | 회원 가입/로그인 REST API | +| `gift.member` | `AdminMemberController` | 관리자 회원 관리 (Thymeleaf MVC) | +| `gift.member` | `MemberRepository` | 회원 영속성 접근 (`JpaRepository`) | +| `gift.member` | `MemberRequest` | 회원 가입/로그인 요청 DTO (record) | + +### 1.2 기능 경계 -- 다른 패키지와의 의존 관계 + +**auth/member를 사용하는 쪽 (인바운드 의존):** + +| 사용처 | 사용하는 클래스 | 용도 | +|--------|----------------|------| +| `gift.wish.WishController` | `AuthenticationResolver` | 인증된 회원 추출 | +| `gift.order.OrderController` | `AuthenticationResolver`, `MemberRepository`, `Member` | 인증 + 포인트 차감 + 저장 | +| `gift.member.MemberController` | `JwtProvider`, `TokenResponse` | 가입/로그인 후 JWT 발급 | + +**auth/member가 사용하는 쪽 (아웃바운드 의존):** + +| 클래스 | 의존 대상 | 용도 | +|--------|-----------|------| +| `AuthenticationResolver` | `MemberRepository` | 이메일로 회원 조회 | +| `KakaoAuthController` | `MemberRepository` | 회원 자동가입/조회, 카카오 토큰 저장 | + +### 1.3 핵심 인터페이스 계약 + +- `AuthenticationResolver.extractMember(authorization)` -- 외부 패키지(wish, order)가 사용하는 유일한 인증 진입점. 반환: `Member` 또는 `null` +- `JwtProvider.createToken(email)` / `JwtProvider.getEmail(token)` -- 토큰 생성/파싱 +- `MemberRepository.findByEmail()`, `existsByEmail()` -- 여러 곳에서 사용 + +--- + +## 2. 현상 진단 + +### 2.1 Controller 비대 여부 + +#### MemberController (비대 -- 심각) +- `register()`: 이메일 중복 검사 + 엔티티 생성 + 저장 + JWT 발급까지 모두 Controller에서 수행 +- `login()`: 회원 조회 + 비밀번호 평문 비교 로직이 Controller에 직접 존재 +- `@ExceptionHandler`가 Controller 내부에 로컬로 정의됨 + +#### AdminMemberController (비대 -- 심각) +- `create()`: 이메일 중복 검사 + 엔티티 생성 + 저장 +- `update()`: 회원 조회 + 수정 + 저장 +- `chargePoint()`: 회원 조회 + 포인트 충전 + 저장 +- `delete()`: 직접 `deleteById` 호출 +- 모든 메서드에서 `memberRepository.findById().orElseThrow()` 패턴 반복 (DRY 위반) + +#### KakaoAuthController (비대 -- 중간) +- `callback()`: 카카오 토큰 교환 + 사용자 정보 조회 + 회원 자동가입/조회 + 카카오 토큰 갱신 + 저장 + JWT 발급 +- 비즈니스 흐름이 Controller에 직접 기술되어 있음 + +### 2.2 Service 계층 부재 + +**프로젝트 전체에 `@Service` 클래스가 단 하나도 없다.** +**프로젝트 전체에 `@Transactional` 어노테이션이 단 하나도 없다.** + +이로 인한 문제: +- 비즈니스 로직이 모두 Controller에 존재 (Controller가 Repository를 직접 호출) +- 트랜잭션 경계가 명시되지 않음 (JPA 기본 동작에 의존) +- 로직 재사용 불가 (예: 회원 가입 로직이 `MemberController.register()`와 `KakaoAuthController.callback()`에 중복) +- 단위 테스트가 어려움 (Controller를 통째로 테스트해야 함) + +### 2.3 책임 혼재 항목 + +| 위치 | 문제 | 상세 | +|------|------|------| +| `MemberController.login()` | 비밀번호 비교 로직이 Controller에 있음 | `member.getPassword().equals(request.password())` -- 평문 비교이며 도메인/서비스 책임 | +| `MemberController.register()` | 중복 이메일 검사가 Controller에 있음 | `memberRepository.existsByEmail()` -- 비즈니스 규칙 | +| `KakaoAuthController.callback()` | 회원 자동가입 + 토큰 갱신 + JWT 발급 | 3가지 유즈케이스가 한 메서드에 혼재 | +| `AdminMemberController` 전체 | CRUD 전부 Controller에서 직접 수행 | Service 위임 없음 | +| `AuthenticationResolver` | `@Component`이지만 실질적으로 Service 역할 | 토큰 파싱 + 회원 조회 로직을 포함하며 null 반환으로 에러 처리 | + +### 2.4 미사용 코드 후보 + +| 대상 | 종류 | 사용 여부 | 근거 | 결론 | +|------|------|-----------|------|------| +| `AuthenticationResolver`의 `@Autowired` | 어노테이션 | 불필요 | 생성자가 1개인 경우 Spring 4.3+에서 자동 주입. 프로젝트 내 다른 클래스(`KakaoAuthController`, `KakaoLoginClient`)는 `@Autowired` 없이 생성자 주입 사용 | **삭제** -- 스타일 통일 | +| `MemberController`의 `@Autowired` | 어노테이션 | 불필요 | 위와 동일 | **삭제** -- 스타일 통일 | +| `AdminMemberController`의 `@Autowired` | 어노테이션 | 불필요 | 위와 동일 | **삭제** -- 스타일 통일 | +| `JwtProvider`의 `@Autowired` | 어노테이션 | 불필요 | 위와 동일 | **삭제** -- 스타일 통일 | +| `org.springframework.beans.factory.annotation.Autowired` import | import | `@Autowired` 삭제 시 미사용 | 4개 파일에서 `@Autowired` 제거 후 해당 import도 제거 필요 | **삭제** | +| `org.springframework.stereotype.Component` import (AuthenticationResolver) | import | 사용 중 | `@Component` 어노테이션에서 사용 | **유지** | + +**git blame 확인 결과:** 모든 파일이 단일 커밋(`feat: set up the project`, author: wotjd243)에서 작성됨. TODO/FIXME 주석 없음. 삭제 시 이후 단계와의 충돌 가능성 없음. + +### 2.5 스타일 불일치 항목 + +| 항목 | 현재 상태 | 문제 | +|------|-----------|------| +| `@Autowired` 사용 | `AuthenticationResolver`, `JwtProvider`, `MemberController`, `AdminMemberController`에는 있고, `KakaoAuthController`, `KakaoLoginClient`에는 없음 | 불일치 -- 생성자 1개인 경우 모두 제거해야 일관적 | +| 예외 처리 패턴 | `MemberController`에만 `@ExceptionHandler` 존재, `AdminMemberController`/`KakaoAuthController`에는 없음 | 불일치 -- 글로벌 `@ControllerAdvice` 없음 | +| null 처리 | `AuthenticationResolver.extractMember()`는 null 반환, 호출자(WishController, OrderController)는 매번 `if (member == null)` 체크 | 중복 보일러플레이트 + null-safety 미흡 | +| Javadoc | `JwtProvider`, `TokenResponse`, `MemberController`, `Member`, `MemberRepository`, `MemberRequest`에는 있고, `KakaoAuthController`, `KakaoLoginClient`, `KakaoLoginProperties`에는 없음 | 불일치 | +| 주석 언어 | `Member.deductPoint()` -- 한국어(`"차감 금액은 1 이상이어야 합니다."`) / `Member.chargePoint()` -- 영어(`"Amount must be greater than zero."`) | 불일치 -- 에러 메시지 언어 혼재 | +| 주석 스타일 | `KakaoAuthController` -- `/* */` 블록 주석, 나머지 -- `/** */` Javadoc | 불일치 | +| 비밀번호 비교 | `member.getPassword().equals(request.password())` -- 평문 비교 | 보안 이슈이나 동작 변경 없이는 수정 불가, 단 비교 로직의 위치(Controller vs Domain/Service)는 변경 가능 | + +--- + +## 3. 구현해야 할 기능 목록 (체크리스트) + +### 미사용 코드 제거 +- [ ] `AuthenticationResolver`에서 불필요한 `@Autowired` 어노테이션 및 import 제거 +- [ ] `JwtProvider`에서 불필요한 `@Autowired` 어노테이션 및 import 제거 +- [ ] `MemberController`에서 불필요한 `@Autowired` 어노테이션 및 import 제거 +- [ ] `AdminMemberController`에서 불필요한 `@Autowired` 어노테이션 및 import 제거 + +### 서비스 계층 추출 +- [ ] `MemberService` 클래스 신규 생성 (`gift.member` 패키지) + - [ ] `register(email, password)` -- 중복 검사 + 저장 + 토큰 발급 + - [ ] `login(email, password)` -- 조회 + 비밀번호 검증 + 토큰 발급 + - [ ] `findById(id)` -- 회원 조회 (AdminMemberController용) + - [ ] `findAll()` -- 전체 회원 조회 (AdminMemberController용) + - [ ] `update(id, email, password)` -- 회원 정보 수정 + - [ ] `chargePoint(id, amount)` -- 포인트 충전 + - [ ] `delete(id)` -- 회원 삭제 + - [ ] `findOrCreateByKakaoEmail(email)` -- 카카오 자동가입/조회 + - [ ] 각 메서드에 `@Transactional` 적용 +- [ ] `AuthService` 또는 `KakaoAuthService` 클래스 신규 생성 (`gift.auth` 패키지) + - [ ] `kakaoLogin(code)` -- 카카오 토큰 교환 + 사용자 정보 조회 + 회원 처리 + JWT 발급 통합 + - [ ] `buildKakaoAuthUrl()` -- 카카오 인증 URL 생성 + - [ ] `@Transactional` 적용 + +### 로직 재분배 (Controller -> Service 위임) +- [ ] `MemberController.register()` -- `MemberService.register()` 위임으로 변경 +- [ ] `MemberController.login()` -- `MemberService.login()` 위임으로 변경 +- [ ] `AdminMemberController` 모든 메서드 -- `MemberService` 위임으로 변경 +- [ ] `KakaoAuthController.callback()` -- `KakaoAuthService.kakaoLogin()` 위임으로 변경 +- [ ] `KakaoAuthController.login()` -- `KakaoAuthService.buildKakaoAuthUrl()` 위임으로 변경 +- [ ] `MemberController`의 `@ExceptionHandler` 제거 후 글로벌 `@ControllerAdvice` 도입 검토 (다른 기능 팀과 조율 필요) + +### AuthenticationResolver 개선 +- [ ] 예외 발생 시 null 반환 대신 명확한 예외를 던지도록 개선 검토 (주의: 호출자 동작에 영향) +- [ ] 또는 `Optional` 반환으로 변경하여 null-safety 확보 (호출자도 함께 수정 필요 -- wish/order 팀 조율 필요) + +### 코드 스타일 통일 +- [ ] Javadoc 누락 클래스에 Javadoc 추가: `KakaoAuthController`, `KakaoLoginClient`, `KakaoLoginProperties` +- [ ] `KakaoAuthController`의 `/* */` 블록 주석을 `/** */` Javadoc으로 변경 +- [ ] 에러 메시지 언어 통일: `Member.chargePoint()`와 `Member.deductPoint()`의 에러 메시지를 한 언어로 통일 +- [ ] 주석 언어 통일: `Member.deductPoint()` 위의 영어 주석(`// point deduction for order payment`)과 한국어 에러 메시지 혼재 정리 +- [ ] import 정리: `@Autowired` 제거 후 불필요한 import 일괄 제거 확인 + +### 테스트 작성 +- [ ] `MemberService` 단위 테스트 작성 (register, login, chargePoint, deductPoint 시나리오) +- [ ] `KakaoAuthService` 단위 테스트 작성 (신규 회원 자동가입, 기존 회원 토큰 갱신 시나리오) +- [ ] `MemberController` 통합 테스트 작성 (HTTP 요청/응답 검증) +- [ ] `AdminMemberController` 통합 테스트 작성 (뷰 반환 검증) +- [ ] `KakaoAuthController` 통합 테스트 작성 (리다이렉트 + 콜백 검증) + +--- + +## 4. 전략 (단계별) + +### Step 1: 안전장치 마련 +1. 현재 코드가 정상 동작하는지 확인 (빌드 + 수동 테스트 시나리오 정리) +2. 기존 동작을 보존하기 위한 E2E/통합 테스트 시나리오 작성: + - 회원 가입 -> JWT 반환 검증 + - 로그인 -> JWT 반환 검증 + - 잘못된 비밀번호 -> 400 에러 검증 + - 중복 이메일 가입 -> 400 에러 검증 + - 카카오 콜백 -> JWT 반환 검증 (외부 API Mock) + - 관리자 회원 목록/생성/수정/삭제 -> 뷰 반환 검증 +3. git branch 생성: `refactor/auth-member` + +### Step 2: 미사용 코드 정리 +1. 4개 파일에서 불필요한 `@Autowired` 어노테이션 제거 +2. 그에 따른 `org.springframework.beans.factory.annotation.Autowired` import 제거 +3. 빌드 확인 + +### Step 3: 서비스 클래스 추출 +1. `MemberService` 클래스 생성 (`@Service`, `@Transactional`) + - `MemberController`와 `AdminMemberController`에서 비즈니스 로직을 그대로 이동 (복사 -> 위임 -> 원본 삭제) + - `MemberRepository` 의존성을 `MemberService`로 이동 + - `JwtProvider` 의존성도 필요 시 `MemberService`로 이동 (또는 별도 `AuthService`로) +2. `KakaoAuthService` 클래스 생성 (`@Service`, `@Transactional`) + - `KakaoAuthController.callback()` 로직을 이동 + - `KakaoLoginClient`, `MemberRepository` (`MemberService`), `JwtProvider` 의존 + +### Step 4: 로직 재분배 (Controller 얇게 만들기) +1. `MemberController`: + - `register()` -> `memberService.register(request)` 위임, HTTP 상태 코드만 제어 + - `login()` -> `memberService.login(request)` 위임, HTTP 상태 코드만 제어 + - `@ExceptionHandler` 제거 -> 글로벌 `@ControllerAdvice` 이동 (또는 다른 팀과 협의 후 진행) +2. `AdminMemberController`: + - 모든 메서드 -> `memberService.xxx()` 위임 + - `populateNewFormError()` 유틸 메서드는 Controller에 유지 (뷰 관련) +3. `KakaoAuthController`: + - `login()` -> URL 생성을 `KakaoAuthService`에 위임 + - `callback()` -> `kakaoAuthService.processCallback(code)` 위임 + +### Step 5: 테스트 보강 +1. `MemberService` 단위 테스트: + - 정상 가입 / 중복 이메일 / 정상 로그인 / 잘못된 비밀번호 / 포인트 충전 / 포인트 부족 +2. `KakaoAuthService` 단위 테스트: + - Mock: `KakaoLoginClient`, `MemberRepository`, `JwtProvider` + - 신규 회원 자동가입 시나리오 / 기존 회원 토큰 갱신 시나리오 +3. Controller 통합 테스트 (`@WebMvcTest`): + - Service를 Mock하고 HTTP 요청/응답만 검증 +4. Step 1에서 정의한 E2E 시나리오 재실행하여 동작 동일성 확인 + +### Step 6: 코드 스타일 정리 +1. Javadoc 통일: 누락된 3개 클래스에 Javadoc 추가 +2. 주석 스타일 통일: `/* */` -> `/** */` +3. 에러 메시지 언어 통일: 한국어 또는 영어 중 하나로 (프로젝트 컨벤션 확인 필요) +4. import 정리: IDE 자동 import 정리 수행 +5. 빌드 + 전체 테스트 실행 + +--- + +## 5. 리스크 & 작동 동일성 검증 방법 + +### 5.1 리스크 항목 + +| # | 리스크 | 영향도 | 발생 가능성 | 대응 | +|---|--------|--------|-------------|------| +| R1 | `MemberService` 추출 시 `@Transactional` 도입으로 트랜잭션 경계가 변경됨 | 중간 | 낮음 | 현재 각 Repository 호출이 개별 트랜잭션이던 것이 Service 메서드 단위로 묶임. 기존 동작에서 중간 실패 시 부분 저장되던 것이 전체 롤백으로 바뀔 수 있음 -> 이는 오히려 데이터 정합성 개선이므로 허용 가능하되, 주문 플로우(`OrderController`)처럼 여러 Repository를 호출하는 곳은 주의 필요 (이 패키지 범위 밖이므로 order 팀에 전달) | +| R2 | `AuthenticationResolver`의 null 반환을 예외로 변경할 경우, 호출자(wish/order)의 동작이 변경됨 | 높음 | 높음 | 이번 리팩터링에서는 null 반환 유지. Optional 반환으로 변경 시 wish/order 팀과 합의 필요. 문서에만 기록 | +| R3 | `@ExceptionHandler` 제거 + `@ControllerAdvice` 도입 시, 예외 응답 포맷이 변경될 수 있음 | 중간 | 중간 | 기존 응답 포맷(`String body`)을 정확히 유지하도록 `@ControllerAdvice`에서 동일 형태 반환. 도입 전 다른 Controller(`ProductController`, `OptionController`)의 `@ExceptionHandler`도 함께 이동해야 하므로 다른 팀과 조율 | +| R4 | `AdminMemberController`가 Thymeleaf 뷰를 반환하므로, 뷰 템플릿(`member/list.html` 등)과의 `model.addAttribute` 키 이름이 변경되면 뷰가 깨짐 | 높음 | 낮음 | Service 추출 시 Model 속성 이름과 구조를 절대 변경하지 않음. 뷰 템플릿 확인 후 진행 | +| R5 | 카카오 로그인 플로우에서 `MemberRepository` 직접 사용을 `MemberService` 위임으로 변경 시, 기존과 동일한 저장 순서 유지 필요 | 중간 | 낮음 | `findByEmail -> orElseGet(new) -> updateKakaoAccessToken -> save` 순서를 Service에서 그대로 유지 | + +### 5.2 작동 동일성 검증 방법 + +| 검증 항목 | 방법 | 성공 기준 | +|-----------|------|-----------| +| 회원 가입 | POST `/api/members/register` with `{"email":"test@test.com","password":"1234"}` | 201 + `{"token":"..."}` | +| 회원 가입 중복 | 동일 이메일로 재가입 | 400 + `"Email is already registered."` | +| 로그인 | POST `/api/members/login` | 200 + `{"token":"..."}` | +| 로그인 실패 | 잘못된 비밀번호 | 400 + `"Invalid email or password."` | +| 카카오 로그인 리다이렉트 | GET `/api/auth/kakao/login` | 302 + Location 헤더에 카카오 URL | +| 카카오 콜백 | GET `/api/auth/kakao/callback?code=xxx` (Mock) | 200 + `{"token":"..."}` | +| 관리자 목록 | GET `/admin/members` | 200 + HTML with member list | +| 관리자 생성 | POST `/admin/members` | 302 redirect | +| 관리자 수정 | POST `/admin/members/{id}/edit` | 302 redirect | +| 관리자 포인트 충전 | POST `/admin/members/{id}/charge-point?amount=1000` | 302 redirect | +| 관리자 삭제 | POST `/admin/members/{id}/delete` | 302 redirect | +| JWT 인증 | Authorization 헤더 -> `AuthenticationResolver.extractMember()` | 유효 토큰: Member 반환, 무효 토큰: null 반환 | + +--- + +## 6. 완료 조건 (Definition of Done) + +- [ ] **모든 테스트 GREEN**: 신규 작성된 단위 테스트 + 통합 테스트 + 기존 테스트(현재 없음) 전부 통과 +- [ ] **빌드 성공**: `./gradlew build` 성공 +- [ ] **Controller 얇기 달성**: 모든 Controller 메서드가 요청 검증/변환/Service 위임/응답 변환만 수행 + - `MemberController`: 비즈니스 로직 0줄 + - `AdminMemberController`: 비즈니스 로직 0줄, 뷰 관련 로직만 유지 + - `KakaoAuthController`: 비즈니스 로직 0줄 +- [ ] **Service 계층 존재**: `MemberService`와 `KakaoAuthService`(또는 `AuthService`)가 존재하며 `@Service` + `@Transactional` 적용 +- [ ] **미사용 코드 제거 완료**: 불필요한 `@Autowired` 4건 + 관련 import 제거, 근거 문서화 (본 문서 2.4절) +- [ ] **스타일 일관성**: Javadoc 통일, 주석 스타일 통일, 에러 메시지 언어 통일, import 정리 완료 +- [ ] **작동 동일성 확인**: 섹션 5.2의 모든 검증 항목 통과 +- [ ] **다른 패키지 영향 없음**: `gift.wish`, `gift.order` 등 외부 패키지의 기존 코드가 변경 없이 동작 + - 단, `AuthenticationResolver`의 시그니처를 변경하는 경우 wish/order 팀과 합의 후 진행 diff --git a/claudeResult/02-category-plan.md b/claudeResult/02-category-plan.md new file mode 100644 index 00000000..8f18627d --- /dev/null +++ b/claudeResult/02-category-plan.md @@ -0,0 +1,257 @@ +# Category 패키지 리팩터링 계획서 + +## 1. 기능 범위 정의 + +### 1.1 담당 패키지/클래스 목록 + +| 클래스 | 경로 | 역할 | +|---|---|---| +| `Category` | `gift.category.Category` | JPA 엔티티 (도메인 모델) | +| `CategoryController` | `gift.category.CategoryController` | REST API 컨트롤러 (`/api/categories`) | +| `CategoryRepository` | `gift.category.CategoryRepository` | JPA Repository 인터페이스 | +| `CategoryRequest` | `gift.category.CategoryRequest` | 요청 DTO (Java record) | +| `CategoryResponse` | `gift.category.CategoryResponse` | 응답 DTO (Java record) | + +### 1.2 기능 경계 및 외부 의존 관계 + +**Category를 참조하는 외부 클래스:** + +| 외부 클래스 | 참조 대상 | 참조 방식 | +|---|---|---| +| `gift.product.Product` | `Category` | `@ManyToOne @JoinColumn(name="category_id")` FK 관계 | +| `gift.product.ProductController` | `Category`, `CategoryRepository` | 카테고리 조회 후 Product 생성/수정에 사용 | +| `gift.product.AdminProductController` | `Category`, `CategoryRepository` | 카테고리 조회 후 Product 생성/수정에 사용 | +| `gift.product.ProductRequest` | `Category` | `toEntity(Category category)` 파라미터로 사용 | + +**DB 스키마 제약 (V1 마이그레이션):** +- `product` 테이블의 `category_id`가 `category(id)`를 FK로 참조 (NOT NULL, ON DELETE 제약 없음) +- `category.name`에 `UNIQUE` 제약 조건 존재 (엔티티에 미반영) + +**핵심 인터페이스 계약:** +- `CategoryRepository`가 product 패키지에서 직접 주입되어 사용됨 -- 서비스 레이어 추출 시 이 의존 관계를 함께 리팩터링해야 함 + +--- + +## 2. 현상 진단 + +### 2.1 Controller 비대 여부 (심각도: 높음) + +`CategoryController`가 `CategoryRepository`를 직접 주입받아 모든 CRUD 로직을 직접 수행하고 있다. **Service 레이어가 존재하지 않는다.** + +구체적인 문제점: +- **`getCategories()`**: Repository 호출 + 스트림 변환을 컨트롤러에서 직접 수행 +- **`createCategory()`**: `request.toEntity()` + `repository.save()` + URI 생성을 컨트롤러에서 직접 수행 +- **`updateCategory()`**: `findById` + null 체크 + `entity.update()` + `repository.save()` -- 비즈니스 로직(존재 확인, 갱신)이 컨트롤러에 존재 +- **`deleteCategory()`**: `repository.deleteById()` 직접 호출 -- 삭제 시 해당 카테고리를 참조하는 Product 존재 여부 확인 로직 부재 + +### 2.2 Service 부재 (심각도: 높음) + +프로젝트 전체에 `@Service` 어노테이션이 붙은 클래스가 **하나도 없다.** 모든 패키지(product, option, order, member, category)가 Controller에서 Repository를 직접 호출하는 동일한 안티패턴을 보인다. Category 패키지에서 Service를 먼저 추출하면 프로젝트 전체의 리팩터링 패턴을 확립할 수 있다. + +### 2.3 책임 혼재 항목 + +| 위치 | 현재 책임 | 올바른 위치 | +|---|---|---| +| `CategoryController.updateCategory()` | 엔티티 존재 확인 (null 체크) | Service | +| `CategoryController.updateCategory()` | 엔티티 상태 변경 (`category.update(...)`) | Service | +| `CategoryController.createCategory()` | DTO -> Entity 변환 호출 | Service | +| `CategoryController.deleteCategory()` | 참조 무결성 확인 없이 바로 삭제 | Service (삭제 전 Product 참조 확인 필요) | +| `CategoryRequest.toEntity()` | DTO에서 Entity 생성 | 유지 가능하나 Service에서 호출하도록 이동 | + +### 2.4 미사용 코드 후보 + +| 항목 | 종류 | 현황 | 결론 | +|---|---|---|---| +| 모든 import | import문 | 전 파일에서 사용되지 않는 import 없음 | **미사용 import 없음** | +| `Category.getColor()` | getter | `CategoryResponse.from()`에서 사용 | **사용 중 -- 유지** | +| `Category.getDescription()` | getter | `CategoryResponse.from()`에서 사용 | **사용 중 -- 유지** | +| `Category.getImageUrl()` | getter | `CategoryResponse.from()`에서 사용 | **사용 중 -- 유지** | +| `Category.update()` | 메서드 | `CategoryController.updateCategory()`에서 사용 | **사용 중 -- 유지** | + +> **결론**: category 패키지 내에 미사용 코드(import, 메서드, 필드, 상수)는 발견되지 않았다. 모든 코드가 활발히 사용되고 있으므로 삭제 대상은 없다. + +**git blame 요약**: 모든 category 파일이 동일 커밋 `55ca9e43`(wotjd243, 2026-02-18)에서 한번에 작성되었다. TODO/주석이 전혀 없으며, 이후 수정 이력도 없다. + +### 2.5 스타일 불일치 항목 + +| 항목 | 현재 상태 | 문제점 | +|---|---|---| +| **null 처리 패턴** | `findById(id).orElse(null)` + null 체크 (CategoryController:46-48) | 프로젝트 내 AdminProductController는 `orElseThrow()` 사용. 패턴 불일치 | +| **예외 처리** | `CategoryController`에 `@ExceptionHandler` 없음 | ProductController, OptionController, MemberController에는 존재. 일관성 부재 | +| **DB 제약 vs 엔티티** | DB에 `category.name UNIQUE` 제약 존재 | `Category` 엔티티에 `@Column(unique=true)` 미선언. DB-엔티티 불일치 | +| **DB 제약 vs 엔티티** | DB에 `color VARCHAR(7)`, `image_url VARCHAR(255)` NOT NULL | `Category` 엔티티에 `@Column(nullable=false)`, `@Column(length=7)` 미선언 | +| **DTO 검증** | `CategoryRequest.description`에 `@NotBlank` 없음 | DB 스키마에서 `description`은 nullable이므로 의도적. 하지만 명시적 `@Nullable` 어노테이션 부재 | +| **delete 안전성** | `deleteCategory()`가 바로 `deleteById()` 호출 | FK 제약으로 인해 Product가 참조하는 Category 삭제 시 DB 에러 발생. 적절한 에러 처리 없음 | +| **`@Transactional` 부재** | updateCategory에서 find+update+save 수행 | 트랜잭션 경계가 명시되지 않음. dirty checking 대신 명시적 save() 호출 | + +--- + +## 3. 구현해야 할 기능 목록 (체크리스트) + +### 3.1 서비스 추출 및 로직 재분배 +- [ ] `CategoryService` 클래스 신규 생성 (`@Service`, `@Transactional` 적용) +- [ ] `findAll()` 로직을 Service로 이동 (조회 시 `@Transactional(readOnly = true)`) +- [ ] `findById()` + 존재 확인 로직을 Service로 이동 +- [ ] `create()` 로직을 Service로 이동 (DTO -> Entity 변환 + 저장) +- [ ] `update()` 로직을 Service로 이동 (존재 확인 + 상태 변경). dirty checking 활용으로 명시적 `save()` 제거 검토 +- [ ] `delete()` 로직을 Service로 이동 (삭제 전 Product 참조 존재 여부 확인 로직 추가) +- [ ] `CategoryController`를 Service 위임 전용으로 변경 (Repository 의존 제거) + +### 3.2 외부 패키지 의존 정리 +- [ ] `ProductController`에서 `CategoryRepository` 직접 참조를 `CategoryService`로 변경 (product 리팩터링 시 처리 -- 여기서는 계획만) +- [ ] `AdminProductController`에서 `CategoryRepository` 직접 참조를 `CategoryService`로 변경 (product 리팩터링 시 처리 -- 여기서는 계획만) + +### 3.3 스타일 통일 +- [ ] null 처리 패턴을 `orElseThrow()` + 커스텀 예외(또는 `NoSuchElementException`)로 통일 +- [ ] `CategoryController`에 `@ExceptionHandler` 추가 또는 글로벌 `@ControllerAdvice` 도입 검토 +- [ ] `Category` 엔티티에 `@Column` 어노테이션 보강 (`nullable`, `unique`, `length` 등 DB 스키마와 일치) +- [ ] `updateCategory()`에서 dirty checking 활용 시 명시적 `save()` 제거 (Service에 `@Transactional` 적용 후) + +### 3.4 테스트 +- [ ] `CategoryService` 단위 테스트 작성 (Mockito 기반 -- Repository mock) +- [ ] `CategoryController` 통합 테스트 작성 (`@WebMvcTest` 기반) +- [ ] Category 삭제 시 Product 참조 존재 케이스에 대한 예외 테스트 +- [ ] 전체 CRUD E2E 테스트 작성 (`@SpringBootTest` + `TestRestTemplate` 또는 `MockMvc`) + +--- + +## 4. 전략 (단계별) + +### Step 1: 안전장치 확보 +**목표**: 리팩터링 전 현재 동작을 검증할 수 있는 테스트 확보 + +1. `CategoryController`에 대한 통합 테스트 작성 (`@SpringBootTest` + `MockMvc`) + - `GET /api/categories` -- 200 + 목록 반환 + - `POST /api/categories` -- 201 + Location 헤더 + body 반환 + - `PUT /api/categories/{id}` -- 200 + 갱신된 body / 존재하지 않는 id -> 404 + - `DELETE /api/categories/{id}` -- 204 +2. 테스트가 모두 green인지 확인 + +### Step 2: 미사용 코드 정리 +**목표**: 불필요한 코드 제거 + +1. 현재 분석 결과 미사용 코드가 없으므로, 이 단계는 **스킵 가능** +2. 추후 리팩터링 과정에서 불필요해지는 코드(예: Controller의 Repository 의존)는 해당 단계에서 제거 + +### Step 3: CategoryService 추출 +**목표**: 비즈니스 로직을 Service 레이어로 이동 + +1. `CategoryService` 클래스 생성 + ```java + @Service + @Transactional(readOnly = true) + public class CategoryService { + private final CategoryRepository categoryRepository; + // ... + } + ``` +2. 메서드 추출: + - `List findAll()` + - `CategoryResponse findById(Long id)` -- 존재하지 않으면 예외 + - `CategoryResponse create(CategoryRequest request)` -- `@Transactional` + - `CategoryResponse update(Long id, CategoryRequest request)` -- `@Transactional` + - `void delete(Long id)` -- `@Transactional` +3. 각 메서드 추출 후 즉시 Step 1 테스트 실행하여 green 확인 + +### Step 4: 로직 재분배 (Controller 경량화) +**목표**: Controller에서 비즈니스 로직 제거, 순수 위임만 수행 + +1. `CategoryController`의 의존을 `CategoryRepository` -> `CategoryService`로 교체 +2. 각 핸들러 메서드를 Service 위임 코드로 교체: + ```java + @GetMapping + public ResponseEntity> getCategories() { + return ResponseEntity.ok(categoryService.findAll()); + } + ``` +3. Controller에서 `CategoryRepository` import 및 필드 제거 +4. Step 1 테스트 재실행하여 green 확인 +5. 외부 패키지(ProductController, AdminProductController)에서의 `CategoryRepository` 직접 사용은 **product 리팩터링 단계에서 처리** -- category 패키지에서는 `CategoryService`를 public 인터페이스로 노출 + +### Step 5: 테스트 보강 +**목표**: Service 레이어에 대한 단위 테스트 추가 + +1. `CategoryServiceTest` 작성 (Mockito 기반) + - 정상 CRUD 케이스 + - `findById` -- 존재하지 않는 id -> 예외 발생 검증 + - `delete` -- Product 참조 존재 시 예외 발생 검증 (향후 로직 추가 시) + - `update` -- 존재하지 않는 id -> 예외 발생 검증 +2. Step 1에서 작성한 통합 테스트가 여전히 green인지 확인 + +### Step 6: 스타일 정리 +**목표**: 코드 스타일 일관성 확보 + +1. `Category` 엔티티에 `@Column` 어노테이션 보강: + ```java + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false, length = 7) + private String color; + + @Column(nullable = false) + private String imageUrl; + + // description은 nullable이므로 @Column 생략 가능 (기본값) + ``` +2. null 처리 패턴 통일: `orElse(null)` -> `orElseThrow(() -> new NoSuchElementException(...))` + - 이미 Step 3에서 Service 추출 시 적용됨 +3. 예외 처리 일관성: `@ExceptionHandler` 추가 또는 글로벌 `@ControllerAdvice` 도입 + - **권장**: 글로벌 `@ControllerAdvice`를 공통 패키지에 신설하여 프로젝트 전체 적용 + - category 단독으로 진행한다면 Controller에 `@ExceptionHandler(NoSuchElementException.class)` 추가 +4. `@Transactional` 적용 후 `updateCategory`에서 명시적 `save()` 호출 제거 (dirty checking 활용) +5. 전체 테스트 green 확인 + +--- + +## 5. 리스크 & 작동 동일성 검증 방법 + +### 5.1 리스크 항목 + +| # | 리스크 | 심각도 | 대응 방안 | +|---|---|---|---| +| R1 | Service 추출 시 트랜잭션 경계 변경으로 동작 차이 발생 | 중 | 현재 Controller에 `@Transactional` 없으므로, 각 Repository 호출이 개별 트랜잭션. Service에 `@Transactional` 추가 시 하나의 트랜잭션으로 묶임. `update()`에서 find+update가 하나의 트랜잭션이 되므로 오히려 정합성 향상. 동작 변경이 아닌 개선에 해당 | +| R2 | dirty checking 전환 시 `save()` 제거로 동작 차이 | 낮 | `@Transactional` 내에서 managed entity의 변경은 자동 flush됨. 기존 `save()` 호출과 동일 결과. 통합 테스트로 검증 | +| R3 | `orElse(null)` -> `orElseThrow()` 전환 시 응답 코드 변경 | 중 | 현재: 404 (ResponseEntity.notFound), 변경 후: 예외 -> `@ExceptionHandler`에서 404 반환. HTTP 응답 레벨에서 동일하도록 ExceptionHandler 구성 필수 | +| R4 | `deleteCategory()`에 참조 확인 로직 추가 시 기존 동작 변경 | 높 | 현재는 Product가 참조하는 Category 삭제 시 DB FK 위반 에러가 500으로 반환됨. 서비스에서 사전 확인 후 400/409 반환은 **동작 개선**이지 변경이 아님. 단, API 스펙이 변경되므로 문서화 필요 | +| R5 | 외부 패키지(ProductController, AdminProductController)가 `CategoryRepository`를 직접 사용 | 높 | category 리팩터링 단계에서는 `CategoryService`를 추가로 공개만 하고, 외부 패키지의 의존 변경은 **product 리팩터링 단계**에서 처리. `CategoryRepository`의 public 접근 제한(package-private)은 외부 의존 전환 완료 후에만 수행 | +| R6 | `Category` 엔티티에 `@Column` 어노테이션 추가 시 Flyway 마이그레이션 충돌 | 낮 | `@Column` 어노테이션은 JPA 메타데이터일 뿐, `spring.jpa.hibernate.ddl-auto`가 `validate`인 경우에만 영향. 기존 Flyway 마이그레이션과 일치하므로 문제 없음 | + +### 5.2 작동 동일성 검증 방법 + +1. **Step 1 통합 테스트를 기준선(Baseline)으로 활용** + - 모든 리팩터링 단계마다 Step 1 테스트를 실행하여 green 확인 + - 테스트 항목: 4개 엔드포인트 x 정상/비정상 케이스 = 최소 6개 테스트 + +2. **HTTP 응답 비교** + - 리팩터링 전후 동일한 요청에 대해 동일한 HTTP 상태 코드 + 응답 body 반환 확인 + - 특히 `PUT /api/categories/{존재하지않는id}` -> 404, `DELETE /api/categories/{id}` -> 204 + +3. **트랜잭션 동작 검증** + - `update` 시 DB에 실제 반영되는지 확인 (조회 -> 수정 -> 재조회) + - `create` 후 auto-generated id가 응답에 포함되는지 확인 + +4. **외부 패키지 영향 없음 확인** + - `CategoryRepository`가 여전히 public이므로 ProductController/AdminProductController 컴파일 통과 확인 + - 전체 `./gradlew build` 성공 확인 + +--- + +## 6. 완료 조건 (Definition of Done) + +- [ ] **테스트 전체 green**: `./gradlew test` 실행 시 모든 테스트 통과 +- [ ] **Controller 경량화**: `CategoryController`가 `CategoryService`에만 의존하며, Repository 직접 접근 코드 없음 +- [ ] **Service 레이어 존재**: `CategoryService`가 `@Service` + `@Transactional`로 선언되고, 모든 비즈니스 로직(존재 확인, CRUD, 참조 검증)을 포함 +- [ ] **책임 분리 준수**: + - Controller: 요청 수신 -> 검증 -> Service 위임 -> 응답 반환만 수행 + - Service: 유즈케이스 로직 + 트랜잭션 경계 관리 + - Repository: 영속성 접근만 (커스텀 쿼리 없으면 변경 없음) + - Entity: 도메인 불변식 (`update()` 메서드 유지) +- [ ] **미사용 코드 제거 근거 문서화**: 현 분석에서 미사용 코드 없음을 확인함 (본 문서 2.4절) +- [ ] **스타일 일관성**: + - `@Column` 어노테이션이 DB 스키마와 일치 + - null 처리가 `orElseThrow()` 패턴으로 통일 + - 예외 처리 패턴이 다른 컨트롤러와 일관적 +- [ ] **외부 의존 안전성**: `CategoryRepository`가 여전히 접근 가능하여 외부 패키지(product) 컴파일 에러 없음 +- [ ] **빌드 성공**: `./gradlew build` 전체 통과 +- [ ] **작동 동일성**: 리팩터링 전후 API 응답(상태 코드, body)이 동일함을 통합 테스트로 검증 완료 diff --git a/claudeResult/03-product-option-plan.md b/claudeResult/03-product-option-plan.md new file mode 100644 index 00000000..1f81a209 --- /dev/null +++ b/claudeResult/03-product-option-plan.md @@ -0,0 +1,323 @@ +# Product-Option 리팩터링 계획서 + +## 1. 기능 범위 정의 + +### 1.1 담당 패키지/클래스 목록 + +| 패키지 | 클래스 | 역할 | +|--------|--------|------| +| `gift.product` | `Product` | 상품 엔티티 (JPA `@Entity`) | +| `gift.product` | `ProductController` | REST API 컨트롤러 (`/api/products`) | +| `gift.product` | `AdminProductController` | 관리자 MVC 컨트롤러 (`/admin/products`) | +| `gift.product` | `ProductNameValidator` | 상품명 검증 유틸리티 (정적 메서드) | +| `gift.product` | `ProductRepository` | JPA Repository 인터페이스 | +| `gift.product` | `ProductRequest` | 요청 DTO (Java record) | +| `gift.product` | `ProductResponse` | 응답 DTO (Java record) | +| `gift.option` | `Option` | 옵션 엔티티 (JPA `@Entity`) | +| `gift.option` | `OptionController` | REST API 컨트롤러 (`/api/products/{productId}/options`) | +| `gift.option` | `OptionNameValidator` | 옵션명 검증 유틸리티 (정적 메서드) | +| `gift.option` | `OptionRepository` | JPA Repository 인터페이스 | +| `gift.option` | `OptionRequest` | 요청 DTO (Java record) | +| `gift.option` | `OptionResponse` | 응답 DTO (Java record) | + +### 1.2 기능 경계 -- 다른 기능과의 인터페이스/의존 + +| 외부 패키지 | 의존 방향 | 설명 | +|-------------|-----------|------| +| `gift.category` | **Product -> Category** | `Product` 엔티티가 `Category`를 `@ManyToOne`으로 참조. `ProductController`와 `AdminProductController`가 `CategoryRepository`를 직접 주입받아 사용 | +| `gift.wish` | **Wish -> Product** | `Wish` 엔티티가 `Product`를 `@ManyToOne`으로 참조. `WishController`가 `ProductRepository`를 직접 주입 | +| `gift.order` | **Order -> Option** | `Order` 엔티티가 `Option`을 `@ManyToOne`으로 참조. `OrderController`가 `OptionRepository`를 직접 주입. `KakaoMessageClient`가 `Product`를 파라미터로 받아 메시지 구성 | +| `gift.product` <-> `gift.option` | **양방향** | `Product`가 `Option`을 `@OneToMany(mappedBy, cascade=ALL, orphanRemoval=true)`로 보유. `OptionController`가 `ProductRepository`를 직접 주입 | + +> **핵심 관찰**: 현재 Service 계층이 프로젝트 전체에 존재하지 않는다. 모든 비즈니스 로직이 Controller에 직접 작성되어 있으며, 외부 패키지들이 다른 패키지의 Repository를 직접 주입받아 사용하는 구조이다. + +--- + +## 2. 현상 진단 + +### 2.1 Controller 비대 / Service 부재 + +**ProductController** (101줄) +- Repository를 직접 호출하여 CRUD 전체를 수행 +- `CategoryRepository`를 직접 주입받아 카테고리 조회까지 컨트롤러에서 처리 +- `validateName()` private 메서드로 비즈니스 검증 로직을 직접 수행 +- `@ExceptionHandler`를 컨트롤러 내부에 선언 (컨트롤러별 중복) +- `findById().orElse(null)` + null 체크 패턴으로 존재 여부를 확인 (예외 기반 패턴 미사용) + +**AdminProductController** (133줄) +- 동일한 CRUD 로직을 MVC 방식으로 중복 구현 +- `ProductNameValidator.validate(name, true)` -- `allowKakao=true`를 하드코딩하여 호출 (API 쪽은 `false`) +- `populateNewForm()`/`populateEditForm()` 두 private 메서드가 Model 속성을 세팅하는 뷰 관련 로직 (이 부분은 Controller 레벨 책임이지만, 비즈니스 로직과 혼재) + +**OptionController** (104줄) +- `ProductRepository`를 직접 주입받아 상품 존재 여부를 확인 +- 옵션 중복명 체크(`existsByProductIdAndName`) 비즈니스 규칙을 컨트롤러에서 직접 수행 +- 옵션 최소 1개 보장 규칙(`options.size() <= 1` 체크)을 컨트롤러에서 직접 수행 +- `@ExceptionHandler`를 컨트롤러 내부에 중복 선언 + +**결론**: 3개의 Controller 모두에 비즈니스 로직이 혼재되어 있으며, **Service 클래스가 프로젝트 전체에 단 하나도 존재하지 않는다**. `ProductService`와 `OptionService` 추출이 필수적이다. + +### 2.2 책임 혼재 항목 + +| 위치 | 문제 | 올바른 위치 | +|------|------|-------------| +| `ProductController.validateName()` | 비즈니스 검증 로직 | Service 또는 Validator | +| `ProductController.createProduct()` | CategoryRepository 직접 조회 + 엔티티 생성 | Service | +| `ProductController.updateProduct()` | Category 조회 + Product 조회 + update + save | Service | +| `AdminProductController.create()` | CategoryRepository 직접 조회 + 엔티티 생성 + 검증 | Service | +| `AdminProductController.update()` | 동일한 로직 중복 | Service (ProductController와 공유) | +| `OptionController.createOption()` | 상품 존재 확인 + 옵션명 중복 체크 + 엔티티 생성 | Service | +| `OptionController.deleteOption()` | 최소 1개 옵션 보장 규칙 + 소유권 검증 | Service | +| `OptionController.validateName()` | 비즈니스 검증 로직 | Service 또는 Validator | +| 각 Controller `@ExceptionHandler` | 3곳에 동일한 핸들러 중복 | `@ControllerAdvice` 글로벌 핸들러 | + +### 2.3 미사용 코드 후보 + +| 위치 | 항목 | 상태 | 결론 | +|------|------|------|------| +| `ProductController` (20줄) | `import java.util.List` | `validateName()` 메서드의 `List errors`에서 **사용 중** | 유지 (Service 추출 시 Controller에서 제거될 수 있음) | +| `Product.getOptions()` (70줄) | `List