kkokkio - 프로젝트/트러블슈팅

@Transactional(readonly=true)가 꼭 필요한가?

파란배개 2025. 6. 16. 09:29

Spring 기반 프로젝트에서 트랜잭션 처리는 선택이 아닌 필수이다. 그중 @Transactional(readOnly = true)는 종종 ‘옵션’처럼 여겨지지만, 성능과 안전성을 동시에 확보하기 위한 중요한 습관이다.

📌 요약

  • @Transactional(readOnly = true)는 단순 조회성 트랜잭션에서 불필요한 비용을 줄이고, 읽기 전용임을 명확히 하며, DB 이중화 전략에서도 이점을 제공한다.
  • 클래스 또는 메서드 단위에서 기본값처럼 사용하는 것이 좋다.

1. @Transactional의 기본 동작

Spring에서 @Transactional은 메서드 실행 전후로 트랜잭션을 열고, 성공 시 커밋, 예외 시 롤백을 수행한다. 내부적으로는 프록시 객체를 통해 트랜잭션 경계를 제어한다.

비즈니스 로직이 몰려 있는 서비스 계층에서 주로 선언된다.

@Transactional
public UserDto findUser(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException());
}

2. readOnly = true 옵션의 역할과 효과

단순히 읽기만 할 건데 왜 트랜잭션을 걸까?

JPA에서는 데이터를 조회해도 내부적으로 영속성 컨텍스트에 Entity를 저장한다. 이때 변경 감지(Dirty Check) 등의 부가적인 오버헤드가 발생한다. readOnly = true를 붙이면 다음과 같은 최적화가 일어난다:

  • 변경 감지 비활성화: flush 대상에서 제외됨
  • 변경 감지(Dirty Check) 란? JPA는 영속성 컨텍스트에 Entity를 보관할 때 최초의 상태로 저장하는데, 이것을 스냅샷이라고 한다. 영속성 컨텍스트가 Flush 되는 시점에 저장한 스냅샷과 Entity의 현재 상태를 비교하여 변경사항을 찾는다. 만약 변경된 Entity가 있다면 쓰기지연 SQL 저장소에 변경 쿼리문을 보관해 놓았다가, 모든 작업이 끝나면 해당 쿼리들을 DB에 전달하게 된다.
  • FlushMode.MANUAL로 설정
    • 영속성 컨텍스트는 변경사항을 자동 반영하지 않음
    • 명시적으로 flush를 호출할 때만 반영함
  • Hibernate 내부 최적화: 더 적은 리소스를 사용해 조회 수행

3. readOnly = true 옵션의 이점 4가지

1. 실수로 인한 데이터 변경 방지

조회만 하는 로직인데도 실수로 Entity 필드를 바꾸면 Dirty Check를 통해 update SQL이 나가버릴 수 있다. readOnly = true는 이러한 문제를 사전에 차단한다.

2. 성능 최적화

조회성 트랜잭션에서 불필요한 flush/dirty check 과정을 생략하여, DB와의 통신 부하를 줄인다. 특히 트래픽이 높은 서비스에서 효과가 크다.

3. DB 이중화 환경에서의 이점

MySQL에서 master-slave 구조를 사용하는 경우, Spring의 AbstractRoutingDataSource 또는 Replication 설정을 통해 readOnly = true 요청은 slave로 routing되도록 설정 가능하다. 즉, 상황에 따라 DB 서버의 부하를 줄이고 약간의 최적화를 기대해볼 수 있다.

4. 코드의 명확성 향상

코드를 보는 동료에게 "이 메서드는 쓰기 작업을 하지 않는다"는 의도를 명확히 전달해주어 리뷰 속도도 빨라진다.

4. 실제 적용 시 유의사항

  • CRUD 섞인 로직에선 주의: 메서드 내에서 조회 + 저장이 동시에 일어나는 경우, readOnly = true는 저장 작업을 무효화시킨다.
  • Test에서의 적용 확인 필요: 일부 테스트 환경에서는 readOnly = true로 인해 commit이 생략되어 검증이 실패할 수 있다.

5. 결론 및 추천 전략

서비스 클래스 전체에 readOnly = true를 기본값으로 설정하고, 저장 로직만 오버라이딩하는 방식을 추천한다.

@Transactional(readOnly = true)
public class UserService {

    public UserDto getUser(Long id) { ... }

    @Transactional // 저장이 필요한 메서드는 오버라이딩
    public void registerUser(UserDto dto) { ... }
}