fix(live): 라이브 예약 트랜잭션 경계를 보강한다
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
### Phase 1: LazyInitializationException 재현
|
### Phase 1: LazyInitializationException 재현
|
||||||
|
|
||||||
- [ ] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성**
|
- [x] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성**
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
|
||||||
- RED: `@SpringBootTest`와 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 사용해 테스트 자체 트랜잭션이 서비스 경계를 가리지 않게 한다.
|
- RED: `@SpringBootTest`와 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 사용해 테스트 자체 트랜잭션이 서비스 경계를 가리지 않게 한다.
|
||||||
- RED: `EmbeddedRedisInitializer`를 명시적으로 적용하고 클래스 종료 후 Context를 정리한다.
|
- RED: `EmbeddedRedisInitializer`를 명시적으로 적용하고 클래스 종료 후 Context를 정리한다.
|
||||||
@@ -61,13 +61,17 @@ assertEquals(fixture.memberId, reservation.member!!.id)
|
|||||||
- 기대 결과: production code 수정 전 `reservation.room = room`에서 `LiveRoom.reservations`를 초기화하려다 `LazyInitializationException`으로 실패한다.
|
- 기대 결과: production code 수정 전 `reservation.room = room`에서 `LiveRoom.reservations`를 초기화하려다 `LazyInitializationException`으로 실패한다.
|
||||||
- GREEN: 이 task에서는 production code를 변경하지 않는다.
|
- GREEN: 이 task에서는 production code를 변경하지 않는다.
|
||||||
- REFACTOR: fixture와 결과 검증용 타입은 테스트 파일 내부 private data class로 제한하고, request context는 `@AfterEach`에서 해제한다.
|
- REFACTOR: fixture와 결과 검증용 타입은 테스트 파일 내부 private data class로 제한하고, request context는 `@AfterEach`에서 해제한다.
|
||||||
- 검증 기록: 구현 중 누적한다.
|
- 검증 기록:
|
||||||
|
- 무엇: 트랜잭션 없는 테스트 메서드에서 detached `LiveRoom`을 다시 조회하는 실제 Spring `LiveReservationService` 빈을 호출했다.
|
||||||
|
- 왜: 테스트 트랜잭션이나 OSIV가 결함을 가리지 않은 상태에서 운영 오류와 같은 lazy 컬렉션 접근을 재현하기 위해서다.
|
||||||
|
- 어떻게: production code 수정 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다.
|
||||||
|
- 결과: `LiveReservationServiceIntegrationTest.kt:55`에서 `failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`으로 실패해 RED를 확인했다. Stack trace는 `PersistentBag.add`를 가리켰다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: 서비스 쓰기 트랜잭션 적용
|
### Phase 2: 서비스 쓰기 트랜잭션 적용
|
||||||
|
|
||||||
- [ ] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가**
|
- [x] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가**
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
|
||||||
- Consumes: `LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse`
|
- Consumes: `LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse`
|
||||||
- Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름
|
- Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름
|
||||||
@@ -81,13 +85,17 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi
|
|||||||
- 통과 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
- 통과 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`이며 저장된 예약의 방 ID와 회원 ID가 fixture와 일치한다.
|
- 기대 결과: `BUILD SUCCESSFUL`이며 저장된 예약의 방 ID와 회원 ID가 fixture와 일치한다.
|
||||||
- REFACTOR: 불필요한 fetch 전략, setter, 응답 로직 변경이 없는지 `git diff`로 확인한다.
|
- REFACTOR: 불필요한 fetch 전략, setter, 응답 로직 변경이 없는지 `git diff`로 확인한다.
|
||||||
- 검증 기록: 구현 중 누적한다.
|
- 검증 기록:
|
||||||
|
- 무엇: `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 추가했다.
|
||||||
|
- 왜: 라이브방 조회부터 lazy 컬렉션 접근, 결제, 예약 저장까지 같은 영속성 컨텍스트와 트랜잭션에서 처리하기 위해서다.
|
||||||
|
- 어떻게: RED와 같은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 재실행했다.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 51s`로 통과했고 저장된 예약의 방 ID와 회원 ID가 fixture와 일치했다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: 회귀 및 문서 검증
|
### Phase 3: 회귀 및 문서 검증
|
||||||
|
|
||||||
- [ ] **Task 3.1: 관련 테스트와 저장소 규칙 검증**
|
- [x] **Task 3.1: 관련 테스트와 저장소 규칙 검증**
|
||||||
- Verify: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
- Verify: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||||
- Verify: `./gradlew --no-daemon ktlintCheck`
|
- Verify: `./gradlew --no-daemon ktlintCheck`
|
||||||
- Verify: `./gradlew --no-daemon tasks --all`
|
- Verify: `./gradlew --no-daemon tasks --all`
|
||||||
@@ -95,7 +103,13 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi
|
|||||||
- 기대 결과: 모든 Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 exit code 0이다.
|
- 기대 결과: 모든 Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 exit code 0이다.
|
||||||
- RED/GREEN: Phase 1과 Phase 2의 실패 및 통과 결과를 다시 확인한다.
|
- RED/GREEN: Phase 1과 Phase 2의 실패 및 통과 결과를 다시 확인한다.
|
||||||
- REFACTOR: 이번 요청과 무관한 코드 및 문서 변경이 없는지 확인한다.
|
- REFACTOR: 이번 요청과 무관한 코드 및 문서 변경이 없는지 확인한다.
|
||||||
- 검증 기록: 구현 중 누적한다.
|
- 검증 기록:
|
||||||
|
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`: GREEN 확인 실행은 `BUILD SUCCESSFUL in 51s`, 최종 재실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
||||||
|
- `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL in 23s`로 통과했다.
|
||||||
|
- `./gradlew --no-daemon tasks --all`: `BUILD SUCCESSFUL in 6s`로 통과했다.
|
||||||
|
- `./gradlew --no-daemon test`: 전체 테스트 스위트가 `BUILD SUCCESSFUL in 5m 48s`로 통과했다.
|
||||||
|
- `git diff --check`: 출력 없이 통과했다.
|
||||||
|
- `git diff`: production code 변경이 `makeReservation()`의 `@Transactional` 한 줄뿐이며 fetch 전략, setter, API 응답 로직은 변경하지 않았음을 확인했다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,3 +120,8 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi
|
|||||||
- sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다.
|
- sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다.
|
||||||
- 승인 실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
- 승인 실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
||||||
- 2026-07-15: `git diff --check`가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다.
|
- 2026-07-15: `git diff --check`가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다.
|
||||||
|
- 2026-07-15: production code 수정 전 단일 통합 테스트가 예상한 `LiveRoom.reservations`의 `LazyInitializationException`으로 실패해 RED를 확인했다.
|
||||||
|
- 2026-07-15: `makeReservation()`에 `@Transactional`을 추가한 뒤 같은 통합 테스트가 통과해 GREEN을 확인했다.
|
||||||
|
- 2026-07-15: 관련 단일 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 최종 통과했다.
|
||||||
|
- 2026-07-15: 완료 선언 전 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행해 캐시 없이 `BUILD SUCCESSFUL in 3m 39s`를 확인했다. 출력된 deprecation/unchecked cast 경고는 기존 파일에서 발생했으며 이번 변경 파일과 무관하다.
|
||||||
|
- 2026-07-15: 브랜치 완료 전 전체 회귀 검증으로 `./gradlew --no-daemon test`를 실행해 `BUILD SUCCESSFUL in 5m 48s`를 확인했다.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class LiveReservationService(
|
|||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) {
|
) {
|
||||||
|
@Transactional
|
||||||
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
|
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
|
||||||
val room = liveRoomRepository.findByIdOrNull(id = request.roomId)
|
val room = liveRoomRepository.findByIdOrNull(id = request.roomId)
|
||||||
?: throw SodaException(messageKey = "live.reservation.invalid_request_retry")
|
?: throw SodaException(messageKey = "live.reservation.invalid_request_retry")
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package kr.co.vividnext.sodalive.live.reservation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:live-reservation-service-integration;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
class LiveReservationServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val service: LiveReservationService,
|
||||||
|
private val repository: LiveReservationRepository,
|
||||||
|
private val transactionTemplate: TransactionTemplate,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@BeforeEach
|
||||||
|
fun setUpRequestContext() {
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun resetRequestContext() {
|
||||||
|
RequestContextHolder.resetRequestAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 예약은 OSIV off 환경에서도 lazy reservations 접근까지 완료된다")
|
||||||
|
fun shouldMakeReservationThroughTransactionalServiceProxyWhenOpenInViewIsDisabled() {
|
||||||
|
val fixture = createFixture()
|
||||||
|
|
||||||
|
val response = service.makeReservation(
|
||||||
|
request = MakeLiveReservationRequest(
|
||||||
|
roomId = fixture.roomId,
|
||||||
|
container = "web",
|
||||||
|
timezone = "Asia/Seoul"
|
||||||
|
),
|
||||||
|
memberId = fixture.memberId
|
||||||
|
)
|
||||||
|
|
||||||
|
val savedReservation = transactionTemplate.execute {
|
||||||
|
val reservation = repository.findById(response.reservationId).orElseThrow()
|
||||||
|
SavedReservation(
|
||||||
|
roomId = reservation.room!!.id!!,
|
||||||
|
memberId = reservation.member!!.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
|
||||||
|
assertEquals(fixture.roomId, savedReservation.roomId)
|
||||||
|
assertEquals(fixture.memberId, savedReservation.memberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val creator = Member(
|
||||||
|
email = "live-reservation-creator@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "live-reservation-creator"
|
||||||
|
)
|
||||||
|
entityManager.persist(creator)
|
||||||
|
|
||||||
|
val member = Member(
|
||||||
|
email = "live-reservation-member@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "live-reservation-member"
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
|
||||||
|
val room = LiveRoom(
|
||||||
|
title = "예약 라이브",
|
||||||
|
notice = "예약 라이브 안내",
|
||||||
|
beginDateTime = LocalDateTime.now().plusDays(1),
|
||||||
|
numberOfPeople = 10,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
room.member = creator
|
||||||
|
entityManager.persist(room)
|
||||||
|
|
||||||
|
entityManager.flush()
|
||||||
|
val fixture = Fixture(
|
||||||
|
roomId = room.id!!,
|
||||||
|
memberId = member.id!!
|
||||||
|
)
|
||||||
|
entityManager.clear()
|
||||||
|
fixture
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val roomId: Long,
|
||||||
|
val memberId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SavedReservation(
|
||||||
|
val roomId: Long,
|
||||||
|
val memberId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user