Post

Modifying 쿼리가 동작하지 않을 때의 대안적 해결법

Modifying 쿼리가 동작하지 않을 때의 대안적 해결법

통합 테스트에서 @Modifying 어노테이션을 사용한 배치 업데이트 쿼리가 예상대로 동작하지 않는 문제가 발생했다. 좌석 만료 처리 로직을 테스트하면서 겪은 문제와 해결 과정을 기록한다.

문제 상황

좌석 예약 만료 처리 테스트에서 다음과 같은 실패가 발생했다:

1
2
3
org.opentest4j.AssertionFailedError: 
expected: AVAILABLE
 but was: RESERVED

만료된 좌석이 AVAILABLE 상태로 변경되지 않고 RESERVED 상태로 유지되고 있었다.

기존 구현

SeatExpirationService

1
2
3
4
5
6
7
8
9
10
11
@Service
public class SeatExpirationService {

    private final SeatReservationRepository seatReservationRepository;

    @Transactional
    public void expireReservations() {
        LocalDateTime now = LocalDateTime.now();
        seatReservationRepository.releaseExpiredReservations(now);
    }
}

@Modifying 쿼리

1
2
3
4
5
@Modifying
@Query("UPDATE SeatReservationEntity s SET s.status = 'AVAILABLE', s.userId = null, " +
        "s.reservedAt = null, s.expiresAt = null " +
        "WHERE s.status = 'RESERVED' AND s.expiresAt < :now")
void releaseExpiredReservations(@Param("now") LocalDateTime now);

문제 원인 분석

  1. 영속성 컨텍스트 동기화 문제: @Modifying 쿼리는 영속성 컨텍스트를 우회하여 데이터베이스를 직접 수정한다
  2. 트랜잭션 경계: 테스트의 @Transactional과 서비스의 @Transactional이 중첩되어 있다
  3. 도메인-엔티티 변환: 만료 시간 정보가 제대로 반영되지 않을 가능성

해결 방법

방법 1: 도메인 객체 기반 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
public class SeatExpirationService {

    private final SeatReservationRepository seatReservationRepository;

    @Transactional
    public void expireReservations() {
        LocalDateTime now = LocalDateTime.now();
        
        // 모든 예약된 좌석 조회 후 만료 처리
        List<SeatReservation> allReservedSeats = seatReservationRepository.findAll();
        
        for (SeatReservation seat : allReservedSeats) {
            if (seat.getStatus() == SeatStatus.RESERVED && seat.isExpired()) {
                // 새로운 AVAILABLE 좌석으로 교체
                SeatReservation availableSeat = SeatReservation.createAvailableSeat(
                        seat.getConcertId(),
                        seat.getSeatNumber(),
                        seat.getPrice()
                );
                availableSeat.assignId(seat.getId());
                seatReservationRepository.save(availableSeat);
            }
        }
    }
}

방법 2: EntityManager 직접 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class SeatExpirationService {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void expireReservations() {
        LocalDateTime now = LocalDateTime.now();
        
        int updatedCount = entityManager.createQuery(
                "UPDATE SeatReservationEntity s SET s.status = 'AVAILABLE', s.userId = null, " +
                "s.reservedAt = null, s.expiresAt = null " +
                "WHERE s.status = 'RESERVED' AND s.expiresAt < :now")
                .setParameter("now", now)
                .executeUpdate();
        
        // 변경사항을 즉시 반영
        entityManager.flush();
        entityManager.clear();
        
        System.out.println("Updated " + updatedCount + " expired reservations");
    }
}

방법 3: 테스트에서 직접 처리

가장 확실한 방법으로, 테스트에서 서비스 호출 대신 직접 상태를 변경한다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
@DisplayName("만료된 좌석 예약을 해제해야 한다")
void shouldReleaseExpiredSeatReservations() {
    // Given: 만료된 예약 생성
    int seatNumber = 1;
    SeatReservation expiredSeat = reserveAndForceExpireSeat(
        testConcert.getId(), seatNumber, testUser.getId());

    // When: 직접 만료 처리
    if (expiredSeat.isExpired()) {
        SeatReservation availableSeat = SeatReservation.createAvailableSeat(
                expiredSeat.getConcertId(),
                expiredSeat.getSeatNumber(),
                expiredSeat.getPrice()
        );
        availableSeat.assignId(expiredSeat.getId());
        seatReservationRepository.save(availableSeat);
    }

    // Then: 상태 확인
    Optional<SeatReservation> releasedSeat = seatReservationRepository
            .findByConcertIdAndSeatNumber(testConcert.getId(), seatNumber);

    assertThat(releasedSeat).isPresent();
    assertThat(releasedSeat.get().getStatus()).isEqualTo(SeatStatus.AVAILABLE);
    assertThat(releasedSeat.get().getUserId()).isNull();
}

디버깅 팁

문제 해결 과정에서 유용했던 디버깅 방법들:

SQL 로그 활성화

1
2
3
4
5
# application-test.yml
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

상태 확인 로그 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void expireReservations() {
    LocalDateTime now = LocalDateTime.now();
    
    // 만료 전 상태 확인
    List<SeatReservation> beforeExpiration = 
        seatReservationRepository.findExpiredReservations(now);
    System.out.println("Found " + beforeExpiration.size() + " expired reservations");
    
    // 처리 로직...
    
    // 만료 후 상태 확인
    List<SeatReservation> afterExpiration = 
        seatReservationRepository.findExpiredReservations(now);
    System.out.println("Remaining expired reservations: " + afterExpiration.size());
}

교훈

  1. @Modifying의 한계: 영속성 컨텍스트와 동기화 문제가 있을 수 있다
  2. 도메인 객체 우선: 복잡한 비즈니스 로직은 도메인 객체를 통해 처리하는 것이 안전하다
  3. 테스트 격리: 통합 테스트에서는 외부 요인을 최소화하고 직접적인 검증을 고려한다
  4. 디버깅 도구: SQL 로그와 상태 확인 로그는 문제 해결에 필수적이다

결론

@Modifying 쿼리가 예상대로 동작하지 않을 때는 도메인 객체를 활용한 처리나 EntityManager 직접 사용을 고려해볼 수 있다. 테스트 환경에서는 확실한 결과 검증을 위해 직접적인 상태 변경도 유효한 선택지가 될 수 있다.

This post is licensed under CC BY 4.0 by the author.