docs(chat): 관리자 채팅 배너 lazy 수정 계획을 추가한다
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# 관리자 채팅 배너 LazyInitializationException 수정 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` 또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 채팅 배너 목록 조회가 `ChatCharacterBanner.chatCharacter` lazy proxy 접근 때문에 실패하지 않게 한다.
|
||||
|
||||
**Architecture:** 기존 관리자 API 응답 DTO와 URL은 유지한다. `ChatCharacterBannerService` 클래스 레벨에 read-only 트랜잭션을 적용하고, `getActiveBanners(...)`가 그 경계 안에서 배너 조회와 `ChatCharacterBannerListPageResponse` 생성을 완료하게 하여 lazy proxy 접근을 서비스 트랜잭션 경계 내부로 이동한다. 컨트롤러는 pageable 생성과 `ApiResponse.ok(...)` 래핑만 담당한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, JUnit 5, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- API 응답 스키마는 변경하지 않는다.
|
||||
- OSIV 설정을 켜지 않는다.
|
||||
- `ChatCharacterBanner.chatCharacter`를 eager로 바꾸지 않는다.
|
||||
- `getDisplayBanners(...)` 등 공개 사용자용 배너 조회 흐름은 변경하지 않는다.
|
||||
- 리포지토리 fetch join/projection 전면 개편은 이번 범위에서 제외한다.
|
||||
- 원인 확인:
|
||||
- `AdminChatBannerController.getBannerList(...)`가 컨트롤러에서 DTO 변환을 수행한다.
|
||||
- `ChatCharacterBanner.chatCharacter`는 lazy association이다.
|
||||
- `ChatCharacterBannerResponse.from(...)`이 `banner.chatCharacter.id/name`을 읽는다.
|
||||
- OSIV off 환경에서는 컨트롤러 변환 시점에 영속성 컨텍스트가 없어 lazy proxy 초기화가 실패할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt`
|
||||
- `getActiveBanners(...)`가 `imageHost`를 받아 `ChatCharacterBannerListPageResponse`를 반환하도록 변경한다.
|
||||
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
|
||||
- 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
|
||||
- `ChatCharacterBannerResponse.from(..., appendLanguageToCharacterName = true)`로 기존 목록 응답 표시를 유지한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt`
|
||||
- `getBannerList(...)`에서 컨트롤러 내 DTO 변환을 제거하고 서비스 응답을 `ApiResponse.ok(...)`로 반환한다.
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt`
|
||||
- mock 기반 목록 테스트 `shouldAppendBannerLanguageToCharacterNameInBannerList`를 제거한다.
|
||||
- 배너 등록/언어 역직렬화 단위 테스트는 유지한다.
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt`
|
||||
- OSIV off JPA 환경에서 서비스가 관리자 배너 목록 response를 생성할 때 lazy 초기화 예외가 발생하지 않는지 검증한다.
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerIntegrationTest.kt`
|
||||
- 실제 Spring Context, `MockMvc`, JPA fixture로 `/admin/chat/banner/list` API 응답과 보안 권한을 검증한다.
|
||||
- Verify: `src/test/resources/application.yml`
|
||||
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: LazyInitializationException 재현 테스트
|
||||
|
||||
- [x] **Task 1.1: 서비스 통합 실패 테스트 작성**
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt`
|
||||
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])` 통합 테스트를 추가한다.
|
||||
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
|
||||
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 테스트 트랜잭션이 lazy 문제를 가리지 않게 한다.
|
||||
- RED: `ChatCharacterRepository`로 활성 `ChatCharacter`를 저장한다.
|
||||
- RED: `ChatCharacterBannerRepository`로 활성 `ChatCharacterBanner`를 저장한다.
|
||||
- RED: `service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")`를 호출해 `totalCount`, `characterId`, `characterName`, `imagePath`를 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
|
||||
- 기대 결과: production code 수정 전에는 `LazyInitializationException` 또는 기존 메서드 시그니처 불일치로 테스트가 실패한다. 메서드 시그니처를 먼저 테스트 기대 형태로 작성한 경우에는 lazy proxy 초기화 실패가 RED 기준이다.
|
||||
- 구현 기록(2026-06-29): `ChatCharacterBannerServiceIntegrationTest`를 추가해 OSIV off 환경에서 서비스가 관리자 배너 목록 response를 생성하도록 테스트했다.
|
||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` 실행 결과 기존 `getActiveBanners(pageable)` 시그니처와 반환 타입 불일치로 `compileTestKotlin`이 실패했다.
|
||||
- 보정: 실제 DB 제약에 맞게 `ChatCharacter.creatorMember` fixture를 추가했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 서비스 응답 생성과 트랜잭션 보강
|
||||
|
||||
- [x] **Task 2.1: 서비스 클래스 레벨 read-only 트랜잭션과 관리자 목록 response 반환 적용**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt`
|
||||
- GREEN: `ChatCharacterBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
|
||||
- GREEN: `getActiveBanners(pageable: Pageable, imageHost: String): ChatCharacterBannerListPageResponse` 형태로 변경한다.
|
||||
- GREEN: `registerBanner`, `updateBanner`, `deleteBanner`, `updateBannerOrders`의 기존 메서드 레벨 `@Transactional`은 유지한다.
|
||||
- GREEN: `bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)` 결과를 같은 메서드 안에서 `ChatCharacterBannerListPageResponse`로 변환한다.
|
||||
- GREEN: `ChatCharacterBannerResponse.from(banner = it, imageHost = imageHost, appendLanguageToCharacterName = true)`를 사용한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
|
||||
- 기대 결과: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
|
||||
- 구현 기록(2026-06-29): `ChatCharacterBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 추가하고 `getActiveBanners(pageable, imageHost)`가 `ChatCharacterBannerListPageResponse`를 반환하도록 변경했다.
|
||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 검증 이유: 서비스 트랜잭션 안에서 `ChatCharacterBanner.chatCharacter` lazy proxy를 읽어 관리자 목록 response를 생성하는지 확인했다.
|
||||
|
||||
- [x] **Task 2.2: 컨트롤러 목록 응답 조립 제거**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt`
|
||||
- GREEN: `getBannerList(...)`에서 `val response = bannerService.getActiveBanners(pageable, imageHost)`만 호출한다.
|
||||
- GREEN: 컨트롤러의 `banners.content.map { ... }` 변환 코드를 제거한다.
|
||||
- GREEN: 사용하지 않게 된 `ChatCharacterBannerListPageResponse`, `ChatCharacterBannerResponse` import를 제거한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
|
||||
- 기대 결과: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 컨트롤러의 다른 register/update/detail 응답 생성 흐름은 변경하지 않는다.
|
||||
- 구현 기록(2026-06-29): `AdminChatBannerController.getBannerList(...)`에서 DTO 변환을 제거하고 `bannerService.getActiveBanners(pageable, imageHost)` 응답을 그대로 반환하도록 변경했다.
|
||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
|
||||
|
||||
- [x] **Task 3.1: mock 기반 목록 테스트 제거**
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt`
|
||||
- GREEN: `shouldAppendBannerLanguageToCharacterNameInBannerList` 테스트를 제거한다.
|
||||
- GREEN: 목록 테스트 제거 후 사용하지 않게 된 `PageImpl`, `PageRequest` import를 제거한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
|
||||
- 기대 결과: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 기존 배너 등록 언어 테스트와 `Lang` 역직렬화 테스트는 변경하지 않는다.
|
||||
- 구현 기록(2026-06-29): mock 기반 목록 테스트 `shouldAppendBannerLanguageToCharacterNameInBannerList`를 제거하고, 기존 등록/언어 역직렬화 단위 테스트는 유지했다.
|
||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- [x] **Task 3.2: 관리자 목록 API 통합 실패 테스트 작성**
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerIntegrationTest.kt`
|
||||
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])`를 사용한다.
|
||||
- RED: `@AutoConfigureMockMvc`를 사용해 실제 controller/service/repository bean을 연결한다.
|
||||
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
|
||||
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않는다. 테스트 데이터 생성은 `TransactionTemplate` 안에서 수행하고 `EntityManager.flush()`, `EntityManager.clear()`로 영속성 컨텍스트를 비운다.
|
||||
- RED: `ChatCharacter`와 `ChatCharacterBanner(lang = Lang.JA, imagePath = "banner/jp.png")`를 저장한다.
|
||||
- RED: `MockMvc`로 `GET /admin/chat/banner/list?page=0&size=20`을 호출하고 `with(user("admin").roles("ADMIN"))`로 관리자 권한을 부여한다.
|
||||
- RED: `$.success = true`, `$.data.totalCount = 1`, `$.data.content[0].characterName = "character-admin-banner (일본어)"`, `$.data.content[0].imagePath = "https://cdn.test/banner/jp.png"`를 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest`
|
||||
- 기대 결과: production code 수정 전에는 `LazyInitializationException` 또는 기존 서비스 시그니처/응답 생성 위치 불일치로 테스트가 실패한다.
|
||||
- 구현 기록(2026-06-29): `AdminChatBannerControllerIntegrationTest`를 추가해 실제 Spring Context, `MockMvc`, JPA fixture로 관리자 목록 API를 검증했다.
|
||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 검증 이유: mock 없이 `/admin/chat/banner/list` 요청이 OSIV off 환경에서 lazy 초기화 예외 없이 기존 응답 스키마와 언어 라벨을 반환하는지 확인했다.
|
||||
|
||||
- [x] **Task 3.3: 관련 검증 실행 및 문서 기록**
|
||||
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
|
||||
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
|
||||
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest`
|
||||
- Verify: `./gradlew --no-daemon ktlintCheck`
|
||||
- Verify: `./gradlew --no-daemon tasks --all`
|
||||
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
|
||||
- 기대 결과: 모든 명령이 `BUILD SUCCESSFUL`로 종료된다.
|
||||
- 구현 기록(2026-06-29): 관련 테스트, ktlint, Gradle task 목록 검증을 실행했다.
|
||||
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- ktlint 1차: `./gradlew --no-daemon ktlintCheck` 실행 결과 새 통합 테스트 2개 파일의 datasource URL line length 초과로 실패했다.
|
||||
- ktlint 재실행: line length를 수정한 뒤 `./gradlew --no-daemon ktlintCheck`를 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 명령 유효성 1차: `./gradlew --no-daemon tasks --all` 실행 결과 sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
|
||||
- 명령 유효성 재실행: 같은 명령을 escalated로 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
|
||||
- 2026-06-29: 문서 변경 후 명령 유효성 확인을 위해 `./gradlew --no-daemon tasks --all`을 실행했다.
|
||||
- 1차 실행: sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
|
||||
- escalated 재실행: `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-29: 최종 관련 테스트 묶음 `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-29: `./gradlew --no-daemon ktlintCheck` 1차 실행은 line length 위반으로 실패했고, 수정 후 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-29: `./gradlew --no-daemon tasks --all`은 sandbox에서 wrapper lock 접근 제한으로 실패했고, escalated 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-29: 마무리 검증으로 `./gradlew --no-daemon test`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||
Reference in New Issue
Block a user