💥 문제 상황
JPA를 사용하여 방탈출 예약 대기 페이지를 구현하는 미션을 진행하고 있었다. 복잡한 서비스 레이어를 테스트하기 위해 서비스 통합 테스트 코드를 작성하였다. 테스트 메서드마다 데이터베이스에 데이터가 저장되거나 삭제되는데, 이러한 변경이 다른 테스트 메서드에 영향을 주지 않기 위해 롤백 테스트를 하기로 하였다.
롤백 테스트를 하기 위해서는 다양한 방법이 있는데, 그 중에서도 @Transactional 어노테이션을 테스트 메서드에 추가하는 방법을 선택하였다.
해당 어노테이션을 붙임으로써 테스트 코드에서 복잡하게 트랜잭션을 직접 관리하지 않아도 테스트 메서드 간의 격리가 가능했기 때문이다.
그러나 리뷰어에게 다음과 같은 질문을 받았다. 부작용이라니, 롤백 테스트를 해주어서 너무 편리하기만 하다고 생각했는데 어떤 부작용이 있었던걸까?
🤨 문제에 대한 고찰
크루들과 토론을 나누고 여러 가지 문서를 참고해본 결과, 서비스 테스트의 트랜잭션이 프로덕션 코드까지 전파되는 것이 핵심적인 문제였다.
이것이 왜 문제인지에 대해서 설명하려면 영속성 컨텍스트와 트랜잭션의 관계에 대해서 공부할 필요가 있다.
🎈 영속성 컨텍스트와 트랜잭션의 관계
영속성 컨텍스트란?
JPA는 자바 애플리케이션에서 관계형 데이터베이스의 데이터를 관리하기 위한 하나의 인터페이스이다. 개발자가 쿼리를 직접 작성하지 않아도 데이터베이스에서 SELECT, INSERT 등의 연산을 할 수 있다. 이 과정에서 개발자의 요청에 의해 데이터베이스에서 데이터를 꺼내오고 저장하는 캐시 저장소가 필요하다. 애플리케이션과 데이터베이스 저장소 사이에서 캐시 역할을 하는 것을 영속성 컨텍스트 (Persistence Context) 라고 한다.
영속성 컨텍스트 내에 존재하는 영속 객체들은 엔티티 매니저 (Entity Manager)에 의해 관리된다.
트랜잭션과의 관계
트랜잭션 하나가 진행되는 동안 영속 객체의 변화가 있다면 해당 객체는 dirty 상태임으로 확인되며 트랜잭션이 끝날 때 데이터베이스에 반영된다. 따라서 영속성 컨텍스트는 기본적으로 트랜잭션이 있을 때만 존재할 수 있다. 트랜잭션이 시작될 때 새로운 영속성 컨텍스트가 새로 생성되고 트랜잭션이 끝날 때 모든 객체가 준영속 상태로 전환되어 영속성 컨텍스트도 종료된다.
🎈 서비스 테스트 코드에 @Transactional을 붙이면 생기는 일
다시 돌아와서 서비스 테스트 코드에 @Transactional을 붙인 상황에 대해 생각해보자.
테스트 메서드는 하나의 트랜잭션 내에서 작동하게 된다. 즉 하나의 영속성 컨텍스트를 가지고 서비스 코드를 테스트하게 된다. 서비스 프로덕션 코드에서는 테스트 코드에서 만들어진 영속성 컨텍스트를 통해 쓰기 지연, 변경 감지, 지연 로딩 등의 작업을 수행하게 된다.
테스트에서 자체적으로 영속성 컨텍스트를 만들어주고 이로 인해 JPA가 지원하는 편리한 기술들을 사용할 수 있다니, 테스트에서 @Transactional을 사용하지 않을 이유가 없는 것 처럼 보인다.
하지만 테스트 코드의 주요 역할은 프로덕션 코드를 있는 그대로 검증하는 것에 있다. 만약 트랜잭션이 테스트 코드 시점부터 걸려 있다면 프로덕션 역시 테스트에서 넘어온 트랜잭션을 사용할 것이고 실제 환경과 테스트 환경의 불일치가 발생한다. 이 부분이 핵심적인 문제라고 할 수 있다.
🎈 문제가 생길 수 있는 상황 가정해보기
서비스 테스트 코드에 @Transactional을 붙임으로서 프로덕션 코드의 문제를 잡아내지 못하는 상황을 가정해보자. 테스트가 프로덕션 코드를 있는 그대로 검증해야한다면, 프로덕션 코드에서 문제가 발생한다면 테스트 코드에서도 문제가 발생해야할 것이다.
서비스 테스트 코드에 @Transcatinoal을 사용했을 때 어떤 문제를 감지할 수 없는 지 미션 코드로 알아보겠다.
@Service
public class ReservationService {
private final ReservationRepository reservationRepository;
// ...
public List<ReservationResponse> getAllResponses() {
return getAllReservations().stream()
.map(ReservationResponse::from)
.toList();
}
public List<Reservation> getAllReservations() {
return StreamSupport.stream(reservationRepository.findAll().spliterator(), false)
.toList();
}
// ....
}
모든 예약을 조회하는 서비스 레이어의 메서드이다. JPA에서 제공해주는 쿼리 메서드 기능을 제공해 데이터베이스에서 데이터를 불러와서 원하는 형태의 DTO로 변환하여 반환하고 있다. 해당 메서드에는 트랜잭션 어노테이션을 붙이지 않았다.
@Entity
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY)
private ReservationTime time;
@ManyToOne(fetch = FetchType.LAZY)
private Theme theme;
@Enumerated(EnumType.STRING)
private Status status;
// ...
}
예약 객체의 내부 정보를 불러올 때 한번에 연관관계에 있는 객체의 정보를 모두 가져오는 것이 아닌 내부 필드가 명서적으로 호출되는 경우 데이터베이스에서 로딩하고 있다. 이를 지연 로딩 (Lazy Loading) 이라고 한다.
그리고 이를 검증하기 위해 서비스 테스트 클래스에서 조회 테스트를 만들고 실행해보았다. 서비스 클래스에는 기본적으로 롤백 테스트를 위해 @Transactional 을 붙여주었다.
@SpringBootTest(webEnviornment = WebEnviornment.NONE)
@Transactional
class ReservationServiceTest
@DisplayName("모든 예약을 조회한다.")
@Test
void getAllReservations() {
// when
List<ReservationResponse> responses = reservationService.getAllResponses();
// then
assertNotNull(responses);
assertEquals(5, responses.size());
}
이렇게 테스트 코드를 작성해주면 테스트가 잘 성공한다.
아마 테스트 메서드에서 트랜잭션이 시작되면서 영속성 컨텍스트가 시작되었고, 여기에서 지연 로딩이 정상적으로 진행되었기 때문이겠지.
이번에는 프로덕션 레벨에서 직접 테스트해보자. 브라우저에서 해당 서비스 메서드를 사용하는 API를 호출하였다. 하지만 서버 에러를 발생시키며 예약이 조회되지 않았다.
에러 로그를 보니 LazyInitializationException가 발생된 걸 볼 수 있다.
해당 에러는 세션이 닫힌 상태에서 데이터에 접근하여 생긴 오류이다. 세션은 애플리케이션과 하이버네이트 (데이터베이스로 가정해도 무관) 의 연결 통로로 이해하면 된다.
세션이 닫혔다는 것은 JPA를 사용해서 애플리케이션에서 데이터베이스로 접근할 수 없다는 것이다. 즉 현재 프로덕션 코드에서는 트랜잭션이 없고, 따라서 영속성 컨텍스트도 사용할 수 없는 상황이기 때문에 데이터에 접근하였을 때 지연 로딩 관련 오류가 발생한 것이다.
이렇게 테스트에서 잡지 못한 오류를 서비스 코드에서 마주한 것을 볼 수 있다. 테스트에서 정상적인 것처럼 통과하는 것이 실제 환경에서는 오류가 생기는 상황은 테스트 환경과 실제 환경이 다르기 때문이다. 따라서 서비스 테스트 코드에 @Transactional을 붙이면 제대로 된 테스트 환경을 구축할 수 없고 프로덕션 레벨에서 생길 수 있는 오류를 잡을 수 없다.
⭐해결 방법
그렇다면 테스트 환경과 실제 프로덕션 환경을 동일하게 하는 방법은 뭘까?
간단하게 서비스 테스트 코드에 @Transactional을 붙이지 않는 것이다.
하지만 나는 이 결론을 쉽게 인정할 수 없었다. 왜냐하면 롤백 테스트를 위해서는 @Transactional를 테스트 메서드에 붙이는 것이 가장 간편했기 때문이다. 그래서 @Transactional을 붙이고도 실제 환경과 테스트 환경을 동일하게 할 수 있는 방법을 모색하였다.
🎈 OSIV (Open-In-Session-View)
스프링에서는 사용자 요청이 들어온 시점에서 세션을 생성하고 애플리케이션 전반적으로 이 세션을 재사용할 수 있는 기능을 제공한다. 그리고 요청이 끝날 때 세션을 종료한다. OpenSessionInViewInterceptor가 이 역할을 수행한다.
이 기능이 있기 때문에 개발자는 세션의 존재 여부에 신경쓰지 않고 편리하게 개발할 수 있다.
OSIV 설정을 켜고 위와 같은 상황에서 예약을 조회하면 아래와 같이 잘 조회가 된다.
서비스 메서드에 트랜잭션이 없는데 어떻게 지연 로딩이 가능한걸까? 바로 스프링이 지원하는 OSIV 덕분이다.
OSIV 설정으로 인해 @Transactional 이 없는 서비스 프로덕션 코드에서도 지연 로딩을 사용할 수 있다. 왜냐하면 트랜잭션이 종료되어도 세션이 살아있기 때문에 데이터베이스 연결이 가능하기 때문이다. 즉 영속성 컨텍스트를 활용할 수 있다는 것이다.
이러한 설정이 있다면 서비스 프로덕션에서 세션을 사용하지 못할 일이 없기 때문에 테스트에 @Transactional을 붙여도 오류를 검증하지 못할 일은 없지 않을까? 하는 의문이 생겼다.
하지만 위에서 남겨놓은 리뷰와 같이 OSIV가 세션을 유지해준다는 것과 별개로 트랜잭션은 서비스 메서드에서 관리되어야 할 필요가 있다. 그리고 트랜잭션이 테스트 레이어에서 별도로 생성된다면 이를 테스트하는 과정에서 서비스 레이어의 트랜잭션은 무시되기 때문에 올바르지 않다.
트랜잭션이 서비스 레이어에서 별도로 관리될 필요가 있다라는 말에 대한 근거를 아래 예시에서 살펴보자.
🎈 서비스 레이어의 메서드에서 트랜잭션 관리를 해주어야 하는 이유
지연로딩과는 조금 다른 케이스로 변경 감지를 하는 메서드의 예시를 살펴보자.
public ReservationDeleteResponse delete(final long id) {
confirmReservationIfWaitingExists(id);
// 데이터베이스에서 삭제
return new ReservationDeleteResponse(reservationRepository.deletebyId(id));
}
private void confirmReservationIfWaitingExists(final long id) {
Reservation reservation = validateNotExitsAndReturn(id);
reservationRepository.findEarliestRegisteredWaiting(
reservation.getDate(), reservation.getTime().getId(), reservation.getTheme().getId(), Status.PENDING
).ifPresent(waiting -> waiting.setStatus(Status.RESERVED)); // 더티 체킹
}
이 메서드는 예약 대기 객체의 상태를 변경하여 예약 객체로 만드는 코드이다. 트랜잭션을 사용하지 않았고, OSIV에 의존하여 더티 체킹을 시도하고 있다. 해당 메서드는 놀랍게도 트랜잭션이 서비스에 없는데도 더티 체킹이 잘 되며 데이터베이스에도 잘 반영되고 있다.
아니 트랜잭션이 없는데 어떻게 반영이 되는거지? 어느 트랜잭션에서 update query가 날라가는지 궁금하여 트랜잭션 로깅을 찍어보았다.
변경 감지로 인한 쿼리는 전혀 다른 부분인 deleteById라는 메서드의 트랜잭션이 끝날 때 함께 flush되는 것을 알 수 있다. 즉, 애플리케이션 전반적으로 같은 영속성 컨텍스트를 공유하기 때문에 의도한 부분이 아닌 전혀 다른 메서드의 트랜잭션 여부에 의존하고 있던 것이다.
OSIV 덕분에 영속성 컨텍스트는 존재하기 때문에 영속 객체가 변경되기는 하였지만 그 변경 사항을 적용하는 역할이 외부 메서드가 의존하고 있다니. 트랜잭션을 사용하지 않는 코드로 변경되면 더티 체킹은 제 기능을 수행하지 못할 것이다.
public ReservationDeleteResponse delete(final long id) {
confirmReservationIfWaitingExists(id);
return null;
// return new ReservationDeleteResponse(reservationRepository.deleteById(id));
}
실제로 다음과 같이 deleteById를 주석처리하였더니 변경 감지가 제대로 이루어지지 않았다.
정리하자면 OSIV는 요청의 생명 주기동안 영속성 컨텍스트를 사용할 수 있도록 열어두기 때문에 변경 감지가 가능하다. 하지만 감지된 변경이 데이터베이스에 반영되려면 트랜잭션의 커밋이 필요하고, 만약 명시적으로 커밋이 되지 않는다면 변경 사항이 반영되지 않을 수 있다.
그래서 OSIV 기능에만 의존하지 말고 서비스 프로덕션 코드에 적절한 트랜잭션 관리를 해주어야 한다.
@Transactional
public ReservationDeleteResponse delete(final long id) {
confirmReservationIfWaitingExists(id);
return new ReservationDeleteResponse(reservationRepository.deleteById(id));
}
이렇게 되면 프로덕션에서 기대하는 변경 감지 반영 시점은 메서드가 끝나는 시점이다. 따라서 내가 의도한 대로 변경 감지가 메서드가 끝날 때 데이터베이스에 반영됨을 보장하기 위해 서비스 메서드에는 트랜잭션을 관리해줄 필요가 있다.
🎈 트랜잭션 전파 사용하기
트랜잭션 전파에 대한 내용은 자세히 다루지 않을 것이다. 왜냐하면 조금 바보 같은 발상이었기 때문이다. 😀
테스트의 트랜잭션이 서비스 레이어까지 전파되는 것이 문제라면 서비스에서 새로운 트랜잭션을 생성하면 되는 것 아닌가?라고 생각했었다.
하지만 테스트 해본 결과 두 가지 문제가 있었다.
- 롤백 테스트가 불가능하다. 데이터베이스 반영 결과가 롤백되기 위해서는 테스트 메서드에서 생긴 트랜잭션 영역 내에서 데이터베이스 반영이 이루어져야 한다. 하지만 트랜잭션이 새로 생기게 되면서 롤백이 불가능하게 되었다.
- 테스트에서 여러 번 서비스 메서드를 호출하였을 때 트랜잭션 커밋 시점의 불일치로 원하는 시나리오 대로 테스트가 불가능하다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationResponse registerWaiting(final ReservationSaveRequest saveRequest, final Member member) {
ReservationTime reservationTime = findReservationTimeById(saveRequest.timeId());
Theme theme = findThemeById(saveRequest.themeId());
validateAlreadyEnrolled(saveRequest, member);
Reservation reservation = saveRequest.toEntity(member, reservationTime, theme, Status.PENDING);
return ReservationResponse.from(reservationRepository.save(reservation));
}
예를 들어 예약 대기 등록을 위한 서비스 메서드를 아래와 같이 테스트한다고 하자. 현재 registerWaiting은 테스트 메서드의 트랜잭션이 넘어와도 무시하고 자신만의 트랜잭션을 새로 생성한다.
@DisplayName("예약 대기 등록에 성공한다.")
@Test
void registerWaitingSuccess() {
// given
Member member = memberRepository.save(Member.of("anna", "brown@gmail.com", "password", "MEMBER"));
ReservationSaveRequest saveRequest = new ReservationSaveRequest(1L, LocalDate.parse("2024-12-24"), 2L);
// when
ReservationResponse reservationResponse = reservationService.registerWaiting(saveRequest, member);
// then
Optional<Reservation> reservation = reservationRepository.findById(reservationResponse.id());
assertTrue(reservation.isPresent());
assertThat(reservation.get().getStatus()).isEqualTo(Status.PENDING);
}
원래 시나리오 라면 새로 저장한 회원의 ID 값을 사용해 예약 대기를 등록했을 때 사용자 정보가 내부적으로 조회되어야 하는데, 그러지 못하고 SQL Error가 발생하였다. 회원을 저장을 시도한 트랜잭션이 커밋 되기 전에 예약 대기에 대한 트랜잭션이 먼저 진행되었기 때문이다.
그래서 트랜잭션 전파를 이용한 테스트 트랜잭션 도입 정당화는 실패하였다. 🥲
😃 결론
결론으로, 서비스 프로덕션 코드에는 @Transactional을 붙여서 필요한 경우 적절하게 트랜잭션 관리를 해준다. 그리고 서비스 테스트 코드에서는 @Transactional을 붙이지 않는다. 실제 구동 환경과 테스트 구동 환경이 달라지기 때문이다. 😃
참고
Transactions with Spring and JPA | Baeldung
https://www.baeldung.com/jpa-hibernate-persistence-context
테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각 (tistory.com)
'우아한테크코스 > 레벨2' 카테고리의 다른 글
[회고] 우아한테크코스 6기 백엔드 레벨2 7주차 회고 (2) | 2024.06.03 |
---|---|
[회고] 우아한테크코스 6기 백엔드 레벨2 6주차 회고 (4) | 2024.05.27 |
[회고] 우아한테크코스 6기 백엔드 레벨2 5주차 회고 (2) | 2024.05.22 |
[회고] 우아한테크코스 6기 백엔드 레벨2 4주차 회고 (2) | 2024.05.19 |
[회고] 내가 꿈꾸는 개발자의 삶은 무엇일까? 내가 생각하는 좋은 개발자의 기준 (2) | 2024.05.19 |