😄 서론
지난 번에 deleteAll과 관련해 N+1 문제가 발생했었다. 그 과정에 @Query를 이용해 직접 JPQL을 작성하여 해결했는데 (🔗관련 글 링크) 한가지 의문이 생겼다. 해당 문제를 해결하기 위해 다양한 문서들을 찾아보았는데 많은 예제에서 JPQL을 사용할 때 @Transactional 어노테이션을 붙이고 있었다. 안 붙여도 잘 동작하는데 왜 그랬을까? 원인을 찾아보기 시작했다.
😏 Repository 구현체와 트랜잭션
우리가 JPA를 사용해서 ***Repository 클래스를 만들 때 일반적으로 Repository나 JpaRepository를 확장한다.
public interface ReminderRepository extends Repository<Reminder, Long> {
// ...
}
Custom Repository을 사용하는 곳에서 디버그 해보면 SimpleJpaRepository가 proxy 형태로 주입되는 것을 확인할 수 있다.
그렇다면 SimpleJpaRepository에서는 트랜잭션이 어떻게 처리될까? 해당 클래스를 들어가보았더니 @Transactional을 이용하는 것을 확인할 수 있었다. 전역으로 @Transactional(readOnly = true)이 걸려있었고, CUD 관련 메서드에는 @Transactional이 걸려있었다. save(), delete() 등과 같은 기본적인 메서드들은 SimpleJpaRepository를 이용해 실행되므로 트랜잭션이 잘 적용된다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
// ...
@Override
@Transactional
@SuppressWarnings("unchecked")
public void delete(T entity) { ... }
@Override
public Optional<T> findById(ID id) { ... }
}
🤔 그렇다면 @Query는?
그렇다면 SimpleJpaRepository에 메서드가 없는, @Query와 JPQL을 이용해 작성한 메서드들은 트랜잭션이 어떻게 적용될까? 결론부터 말하자면 트랜잭션이 적용되지 않는다. Spring Data JPA에서는 트랜잭션 내에서 사용하고 싶다면 @Transacational을 붙이는 것을 권장하고 있다. 아래는 Spring Docs에서 가져온 예제이다.
@Transactional(readOnly = true)
interface UserRepository extends JpaRepository<User, Long> {
List<User> findByLastname(String lastname);
@Modifying
@Transactional
@Query("delete from User u where u.active = false")
void deleteInactiveUsers();
}
헌데 내 코드에서는 @Transactional을 붙이지 않아도 여태까지 오류가 난 적이 없다. 왜 그럴까? 정답은 코드 내에서 유일하게 Repository를 호출하고 있던 Service에 있다. @Query에 트랜잭션을 별도로 처리해주지 않아도 Service에서의 트랜잭션이 이미 존재하므로 잘 동작하고 있었던 것이다. 실제로 트랜잭션이 적용되지 않는 것을 눈으로 확인해보고 싶다면 RepositoryTest를 만들어보면 된다.
public interface ReminderRepository extends Repository<Reminder, Long> {
// ...
@Modifying
@Query("delete from Reminder r where r in :reminders")
void deleteInBatch(Iterable<Reminder> reminders);
}
@SpringBootTest
class ReminderRepositoryTest {
// ...
@Test
void deleteReminder() {
// 기본 데이터 세팅
Reminder reminder = reminderRepository.save(new Reminder(member, message, LocalDateTime.now()));
reminderRepository.deleteInBatch(List.of(reminder));
}
}
org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query 라는 오류가 발생한다. update/delete를 하기 위해서는 트랜잭션이 필수적으로 존재해야하는데, 트랜잭션이 존재하지 않아서 발생한다.
😵 조회에도 @Transactional을 달아야 하나요?
@Query를 이용한 조회를 할 때도 @Transactional을 달아야하는가?에 대한 의문이 들었다. 기존에 @Query를 통한 조회 쿼리를 만들어둔 적이 있어 사용해보았다.
public interface ReminderRepository extends Repository<Reminder, Long> {
@Query("select r from Reminder r join fetch r.member join fetch r.message WHERE r.message.id = :messageId and r.member.id = :memberId")
Optional<Reminder> findByMessageIdAndMemberId(Long messageId, Long memberId);
}
이번에는 통과가 잘 된다. update query에서 발생했던 Exception을 다시 자세히 살펴보니 update/delete에서만 트랜잭션이 필수적이고 select는 해당 사항이 아닌 듯 하다.
@Transactional(readOnly = true)를 붙이는 경우와 붙이지 않는 경우 성능 차이가 발생할지에 대해 궁금해서 추가적인 테스트를 진행해보았다. readOnly를 붙인 경우가 조금 더 빨랐다. 테스트 환경이 H2를 사용하는데 H2는 readOnly 힌트를 지원하지 않는다. 그렇다면 왜 속도 차이가 날까? JPA의 경우 readOnly를 하면 더티체킹을 생략하는데 그 과정 중에서 속도가 더 빠르게 나타난 것이라고 예측해본다.
@Transactional(readOnly = true)가 있는 메서드 | @Transactional 제거한 메서드 | |
실행 시간 | 96 | 162 |
🙄 고민
우리 프로젝트에서는 Repository를 호출하는 곳은 Service에만 있다. (RepositoryTest를 별도로 만들지 않았다.) 또한 트랜잭션을 전부 Service에서 관리한다. Repository에 @Transactional을 붙여도 전파 속성이 REQUIRED이기 때문에 Service에서 호출한 트랜잭션을 가져다 사용할 것이다.
그렇다면 Repository에 꼭 @Transactional을 붙여야할까?에 대한 고민을 했다. 책임 분리의 관점에서 본다면, Service에서 호출하는 상황을 가정하지 않고 Repository만 호출하더라도 정상적으로 동작해야하는 코드를 만들어야하지 않는가? 라는 생각을 했다.
@Query와 JPQL을 이용해 정의한 메서드들에는 @Transactional을 붙이자고 건의해봐야겠다.
참고
'Develop > Spring+JPA' 카테고리의 다른 글
[Java] 스프링 부트를 제거해서 생긴 일 (2) | 2023.02.05 |
---|---|
[Spring/AOP] JDK Dynamic Proxy vs CGLIB Proxy (0) | 2022.11.23 |
[Spring] @Transactional이 동작하지 않는다?😨 (0) | 2022.10.27 |
[Spring Boot] 기본 Logging Framework는 진짜 'Logback'일까? (0) | 2022.10.13 |
[Spring] Local Transaction vs Global Transaction (8) | 2022.10.09 |