@Transactional(readOnly = true)를 사용하는 이유와 주의할 점

송현진·2025년 6월 23일
0

Spring Boot

목록 보기
21/23

Spring에서 @Transactional(readOnly = true)를 자주 보긴 했지만 단순히 “조회 전용이라서 쓴다”는 정도의 인식만 있었고 왜 그렇게 써야 하는지, 정확히 어떤 최적화가 발생하는지, 주의할 점은 무엇인지 깊이 이해하지 못한 채 넘겨왔다. 그러다 이번에 관련 코드를 리팩토링하면서 이 옵션의 동작 원리와 의도를 명확하게 정리해보고 싶다는 생각이 들어 이 글을 작성하게 되었다.

특히 JPA를 사용하는 서비스라면 이 옵션이 실제로 성능에 어떤 영향을 주는지, 무의식적으로 잘못 썼을 때 어떤 문제가 발생할 수 있는지를 확실히 짚고 넘어갈 필요가 있다.

@Transactional(readOnly = true)란?

우선 이 옵션은 Spring에서 트랜잭션을 선언할 때 사용하는 @Transactional 어노테이션의 속성 중 하나로 해당 트랜잭션이 읽기 전용(Read-Only)이라는 사실을 Spring에게 명시적으로 알려주는 것이다.

@Transactional(readOnly = true)
public List<User> findAllUsers() {
    return userRepository.findAll();
}

이렇게 선언하면 Spring과 JPA는 이 메서드 내부에서 쓰기 연산(persist, merge, remove 등) 이 없을 것이라고 가정하게 되고 이에 따라 몇 가지 최적화를 수행하게 된다. 즉, 단순 조회만 수행하고 데이터 변경은 절대 없다는 것을 보장할 때 사용하는 기능이다.

내부적으로 어떤 최적화가 일어날까?

이 부분이 핵심이다. 단순히 “읽기 전용”이라는 의미를 넘어서서 JPA와 DB 단에서 다음과 같은 성능 최적화가 자동으로 적용된다.

JPA 레벨 최적화: 더티 체킹(DIRTY CHECKING) 생략

JPA는 Entity 객체를 조회하면 자동으로 영속성 컨텍스트(Persistence Context) 에 저장한 후 이 객체가 변경되었는지 추적하는데 이걸 더티 체킹이라고 부른다.

하지만 readOnly = true로 선언하면 JPA는 이 과정을 생략한다. 즉, 조회된 엔티티를 변경 감지 대상으로 삼지 않고 스냅샷(초기 상태 복제)을 만들지도 않는다. 불필요한 객체 복사나 추적 로직이 생략되므로 메모리 사용량과 연산 비용이 줄어든다.

DB 레벨 최적화: READ ONLY 트랜잭션 힌트 전달

@Transactional(readOnly = true)는 단지 JPA 내부에서의 최적화에만 그치는 것이 아니라 JDBC 트랜잭션을 통해 실제 데이터베이스에도 “이 트랜잭션은 데이터를 변경하지 않는다”는 힌트를 전달한다. PostgreSQL, Oracle, H2 등 일부 DB는 이 힌트를 통해 내부 트랜잭션 처리 방식을 최적화할 수 있다.

그 결과 다음과 같은 두 가지 주요 이점이 생긴다.

1. 락 경합(RW Lock Contention) 감소

일반적으로 DB는 트랜잭션이 데이터를 수정할 가능성이 있는 경우, 해당 레코드나 테이블에 쓰기 락(Write Lock) 을 건다. 하지만 readOnly = true 트랜잭션은 애초에 변경이 일어날 수 없다고 명시된 트랜잭션이기 때문에 DB는 굳이 락을 걸지 않거나 최소 수준의 락만 사용하게 된다.

이로 인해 동시에 여러 SELECT 쿼리가 들어와도 락 충돌 없이 병렬로 처리할 수 있는 요청 수가 늘어나고 전반적인 트랜잭션 처리 성능이 향상된다. 이는 고부하 환경에서 특히 유효하다.

2. Undo/Redo 로그 감소

모든 트랜잭션은 내부적으로 Undo/Redo 로그를 생성해 트랜잭션 롤백이나 장애 복구에 대비한다. 하지만 read-only 트랜잭션은 애초에 데이터를 변경하지 않기 때문에 복구할 것도 롤백할 것도 없다. 따라서 DB는 이러한 트랜잭션에 대해 Undo/Redo 로그 생성을 최소화하거나 아예 건너뛴다. 결과적으로 디스크 I/O와 메모리 사용량이 줄어들고, 동일 시간 내 처리할 수 있는 트랜잭션 수가 많아진다.

결과적으로 @Transactional(readOnly = true)는 단순히 코드 레벨에서 조회임을 명시하는 것이 아니라 DB 내부까지 가볍게 처리되도록 유도하는 최적화 힌트 역할을 하게 되는 셈이다.

주의할 점

많은 개발자들이 "조회만 하는 메서드니까 성능을 위해 readOnly를 붙이자"라는 생각으로 아무 데나 @Transactional(readOnly = true)를 붙이지만 이 옵션은 JPA의 동시성 제어 메커니즘인 Optimistic Lock(낙관적 락) 에도 영향을 줄 수 있다는 점을 반드시 알고 있어야 한다.

Optimistic Lock과 readOnly 옵션의 관계

JPA는 두 가지 방식으로 동시성 문제를 제어할 수 있다.

  • Pessimistic Lock (비관적 락): 데이터를 먼저 락 걸고 작업
  • Optimistic Lock (낙관적 락): 충돌이 나지 않을 것이라 가정하고 나중에 버전 비교

그중 Optimistic Lock은 @Version 어노테이션을 이용해 버전 번호 기반으로 충돌을 감지하는 방식이다.

@Entity
public class Post {

    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;

    // 기타 필드 생략
}

이렇게 설정된 엔티티는 수정 시점에 version 값을 비교해서 다른 트랜잭션이 먼저 바꿨는지를 감지한다. 만약 버전이 다르면 OptimisticLockException 을 던지고 충돌을 막는다.

문제는 readOnly 옵션이 이 과정을 무력화할 수 있다는 것이다.

만약 다음과 같은 코드가 있다면

@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"라는 원칙을 반드시 지켜야겠다.


참고

profile
개발자가 되고 싶은 취준생

0개의 댓글