최근 트랜잭션을 다시 공부하면서, 이전까지는 단순히 "롤백을 위한 어노테이션" 정도로만 생각했던 @Transactional의 의미를 더 깊게 이해하게 되었다. 특히 읽기 작업에도 트랜잭션을 거는 이유와 readOnly 옵션의 실제 동작 방식이 궁금해서 정리해보았다.
@Transactional의 가장 기본적인 역할은 "원자성 보장"이다. 메서드가 시작되면 트랜잭션이 열리고, 정상적으로 종료되면 commit, RuntimeException이 발생하면 전체가 rollback된다.
@Transactional
public void createOrder() {
orderRepository.save(order);
orderItemRepository.save(item);
if (someCondition) {
throw new RuntimeException("문제 발생!");
}
paymentClient.request();
}
위 코드에서 예외가 발생하면 이미 실행된 save 작업들도 모두 취소된다. 이처럼 여러 쓰기 작업을 하나의 원자적 단위로 묶어주는 것이 트랜잭션의 핵심이다.
처음엔 "읽기만 하는데 롤백할 것도 없는데 왜?" 라는 생각이 들었다. 하지만 조회 작업에서도 트랜잭션이 중요한 역할을 한다는 것을 알게 되었다.
하나의 비즈니스 로직에서 여러 테이블을 조회하는 상황을 생각해보자.
public OrderDetailResponse getOrder(UUID id) {
Order order = orderRepository.findById(id); // 시점 1
List<OrderItem> items = itemRepository.findByOrderId(id); // 시점 2
User user = userRepository.findById(order.getUserId()); // 시점 3
return OrderDetailResponse.of(order, items, user);
}
트랜잭션이 없다면 각 쿼리가 실행되는 시점이 모두 다르다. 만약 시점 1과 시점 2 사이에 다른 사용자가 주문 항목을 수정한다면? 조회 결과가 일관성을 잃게 된다.
하지만 트랜잭션으로 묶으면 메서드 시작 시점의 데이터 스냅샷을 기준으로 모든 조회가 이루어진다. 즉, 중간에 다른 트랜잭션이 데이터를 변경하더라도 현재 트랜잭션 내에서는 일관된 데이터를 볼 수 있다.
JPA를 사용하다 보면 LazyInitializationException을 한 번쯤은 만나게 된다. 이 문제도 트랜잭션과 깊은 관련이 있다.
@Transactional(readOnly = true)
public OrderResponse getOrder(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
// LAZY 로딩 - 트랜잭션 내에서는 정상 동작
List<OrderItem> items = order.getOrderItems();
return OrderResponse.of(order, items);
}
연관 관계가 LAZY로 설정되어 있으면, 실제 데이터는 필요한 시점에 추가 쿼리로 가져온다. 이때 트랜잭션(정확히는 영속성 컨텍스트)이 살아있어야 추가 쿼리를 실행할 수 있다.
트랜잭션 없이 엔티티를 반환하면, 나중에 LAZY 필드에 접근할 때 영속성 컨텍스트가 이미 종료되어 예외가 발생한다. 따라서 조회 메서드에도 트랜잭션을 걸어두는 것이 안전하다.
처음엔 readOnly가 단순히 "읽기 전용이라는 표시" 정도로만 생각했다. 하지만 실제로는 여러 최적화 기능이 숨어있었다.
readOnly = true를 설정하면 JPA는 다음과 같은 최적화를 수행한다.
이런 최적화들이 모여서 조회 성능이 향상된다. 특히 대량의 데이터를 조회할 때 차이가 체감될 수 있다.
일부 데이터베이스는 트랜잭션을 READ ONLY 모드로 설정할 수 있다. PostgreSQL의 경우 다음과 같은 차이가 있다.
-- readOnly = false (기본값)
BEGIN;
-- readOnly = true
BEGIN READ ONLY;
READ ONLY 트랜잭션에서는 INSERT, UPDATE, DELETE를 시도하면 데이터베이스 레벨에서 오류가 발생한다. 실수로 데이터를 변경하는 것을 방지하는 안전장치 역할을 한다.
readOnly = true여도 강제로 flush를 호출하면 변경사항이 반영될 수 있다.
@Transactional(readOnly = true)
public void test() {
User user = userRepository.findById(1L);
user.setName("변경됨");
entityManager.flush(); // 강제 flush - UPDATE 쿼리 실행됨!
}
따라서 readOnly는 "절대적인 쓰기 금지"가 아니라 "읽기 작업에 최적화된 설정"으로 이해하는 것이 정확하다.
이번 학습을 통해 @Transactional이 단순히 롤백을 위한 도구가 아니라는 것을 깨달았다. 조회 작업에서도 데이터 일관성과 성능 최적화를 위해 중요한 역할을 한다는 점이 특히 인상적이었다.
앞으로는 조회 메서드를 작성할 때도 트랜잭션의 필요성을 고민해보고, readOnly 옵션을 적극적으로 활용해야겠다. 특히 복잡한 조회 로직이나 여러 테이블을 조인하는 경우에는 더욱 신경써야 할 것 같다.