Spring에서 @Transactional(readOnly = true
)를 자주 보긴 했지만 단순히 “조회 전용이라서 쓴다”는 정도의 인식만 있었고 왜 그렇게 써야 하는지, 정확히 어떤 최적화가 발생하는지, 주의할 점은 무엇인지 깊이 이해하지 못한 채 넘겨왔다. 그러다 이번에 관련 코드를 리팩토링하면서 이 옵션의 동작 원리와 의도를 명확하게 정리해보고 싶다는 생각이 들어 이 글을 작성하게 되었다.
특히 JPA를 사용하는 서비스라면 이 옵션이 실제로 성능에 어떤 영향을 주는지, 무의식적으로 잘못 썼을 때 어떤 문제가 발생할 수 있는지를 확실히 짚고 넘어갈 필요가 있다.
우선 이 옵션은 Spring에서 트랜잭션을 선언할 때 사용하는 @Transactional
어노테이션의 속성 중 하나로 해당 트랜잭션이 읽기 전용(Read-Only)이라는 사실을 Spring에게 명시적으로 알려주는 것이다.
@Transactional(readOnly = true)
public List<User> findAllUsers() {
return userRepository.findAll();
}
이렇게 선언하면 Spring과 JPA는 이 메서드 내부에서 쓰기 연산(persist
, merge
, remove
등) 이 없을 것이라고 가정하게 되고 이에 따라 몇 가지 최적화를 수행하게 된다. 즉, 단순 조회만 수행하고 데이터 변경은 절대 없다는 것을 보장할 때 사용하는 기능이다.
이 부분이 핵심이다. 단순히 “읽기 전용”이라는 의미를 넘어서서 JPA와 DB 단에서 다음과 같은 성능 최적화가 자동으로 적용된다.
JPA는 Entity 객체를 조회하면 자동으로 영속성 컨텍스트(Persistence Context) 에 저장한 후 이 객체가 변경되었는지 추적하는데 이걸 더티 체킹이라고 부른다.
하지만 readOnly = true
로 선언하면 JPA는 이 과정을 생략한다. 즉, 조회된 엔티티를 변경 감지 대상으로 삼지 않고 스냅샷(초기 상태 복제)을 만들지도 않는다. 불필요한 객체 복사나 추적 로직이 생략되므로 메모리 사용량과 연산 비용이 줄어든다.
@Transactional(readOnly = true)
는 단지 JPA 내부에서의 최적화에만 그치는 것이 아니라 JDBC 트랜잭션을 통해 실제 데이터베이스에도 “이 트랜잭션은 데이터를 변경하지 않는다”는 힌트를 전달한다. PostgreSQL, Oracle, H2 등 일부 DB는 이 힌트를 통해 내부 트랜잭션 처리 방식을 최적화할 수 있다.
그 결과 다음과 같은 두 가지 주요 이점이 생긴다.
일반적으로 DB는 트랜잭션이 데이터를 수정할 가능성이 있는 경우, 해당 레코드나 테이블에 쓰기 락(Write Lock) 을 건다. 하지만 readOnly = true
트랜잭션은 애초에 변경이 일어날 수 없다고 명시된 트랜잭션이기 때문에 DB는 굳이 락을 걸지 않거나 최소 수준의 락만 사용하게 된다.
이로 인해 동시에 여러 SELECT 쿼리가 들어와도 락 충돌 없이 병렬로 처리할 수 있는 요청 수가 늘어나고 전반적인 트랜잭션 처리 성능이 향상된다. 이는 고부하 환경에서 특히 유효하다.
모든 트랜잭션은 내부적으로 Undo/Redo 로그를 생성해 트랜잭션 롤백이나 장애 복구에 대비한다. 하지만 read-only 트랜잭션은 애초에 데이터를 변경하지 않기 때문에 복구할 것도 롤백할 것도 없다. 따라서 DB는 이러한 트랜잭션에 대해 Undo/Redo 로그 생성을 최소화하거나 아예 건너뛴다. 결과적으로 디스크 I/O와 메모리 사용량이 줄어들고, 동일 시간 내 처리할 수 있는 트랜잭션 수가 많아진다.
결과적으로 @Transactional(readOnly = true)
는 단순히 코드 레벨에서 조회임을 명시하는 것이 아니라 DB 내부까지 가볍게 처리되도록 유도하는 최적화 힌트 역할을 하게 되는 셈이다.
많은 개발자들이 "조회만 하는 메서드니까 성능을 위해 readOnly를 붙이자"라는 생각으로 아무 데나 @Transactional(readOnly = true)
를 붙이지만 이 옵션은 JPA의 동시성 제어 메커니즘인 Optimistic Lock(낙관적 락) 에도 영향을 줄 수 있다는 점을 반드시 알고 있어야 한다.
JPA는 두 가지 방식으로 동시성 문제를 제어할 수 있다.
그중 Optimistic Lock은 @Version
어노테이션을 이용해 버전 번호 기반으로 충돌을 감지하는 방식이다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
@Version
private Long version;
// 기타 필드 생략
}
이렇게 설정된 엔티티는 수정 시점에 version
값을 비교해서 다른 트랜잭션이 먼저 바꿨는지를 감지한다. 만약 버전이 다르면 OptimisticLockException
을 던지고 충돌을 막는다.
만약 다음과 같은 코드가 있다면
@Transactional(readOnly = true)
public void updatePostTitle(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
post.setTitle("변경된 제목");
}
이 경우 readOnly = true
로 선언되어 있기 때문에 JPA는 flush()
를 호출하지 않거나 더티 체킹 자체를 생략하게 되고 이로 인해 @Version
필드를 통한 충돌 감지 로직이 아예 동작하지 않을 수 있다.
즉, 충돌이 발생해도 JPA는 이를 감지하지 못하고 다른 트랜잭션의 수정 내용을 조용히 덮어써버리는 상황이 발생하게 된다. 이것이 바로 readOnly 옵션이 정합성 오류의 원인이 될 수 있는 대표적인 케이스다.
결론적으로 @Transactional(readOnly = true)
는 진짜로 읽기만 할 때만 사용해야 한다. @Version
필드가 붙은 엔티티에서는 정말로 수정하지 않는다는 보장이 있을 때만 사용해야 하며 조금이라도 엔티티 상태 변경 가능성이 있다면 readOnly 옵션은 사용하지 말아야 한다.
이번에 @Transactional(readOnly = true)
에 대해 정리하면서 평소 아무 생각 없이 붙이던 옵션 하나가 실제로는 JPA 내부 처리, 동시성 제어, DB 락과 I/O 처리까지 영향을 줄 수 있다는 점에 대해 명확하게 이해할 수 있었다. 특히 낙관적 락이 적용된 엔티티를 다루거나 flush를 기대하는 로직이 들어갈 수 있는 서비스에서는 readOnly 옵션을 신중하게 사용해야 한다는 걸 다시금 느꼈다. 이 옵션은 잘만 쓰면 성능에 도움이 되지만 잘못 쓰면 데이터 충돌 감지를 무력화하거나 저장이 되지 않는 문제를 야기할 수 있는 양날의 검이라는 사실을 기억해야 한다. "읽기만 하면 OK, 쓰기 가능성 있으면 절대 NO"라는 원칙을 반드시 지켜야겠다.
참고