읽기 전용 트랜잭션 — readOnly=true가 실제로 하는 일과 성능 효과
readOnly = true를 붙이면 성능이 좋아진다는데, 구체적으로 어디서 뭘 절약하는 걸까요?
조회 메서드에 @Transactional(readOnly = true)를 습관처럼 붙이는 분들이 많습니다. 저도 처음에는 "조회니까 readOnly 붙여야지" 정도의 감각으로 썼는데, 공부하다 보니 이 한 줄이 Hibernate 내부에서 꽤 많은 걸 바꾼다는 것을 알게 되었습니다.
readOnly=true의 실제 동작
readOnly=true 는 Spring이 기반 기술(Hibernate, JDBC 드라이버, DB)에 "이 트랜잭션은 읽기만 합니다"라는 힌트 를 전달하는 설정입니다.
핵심은 이것이 "강제"가 아니라 "힌트"라는 점입니다. 각 레이어가 이 힌트를 받아서 자기만의 최적화를 수행합니다.
@Transactional(readOnly = true)
│
▼
┌─────────────────────────────┐
│ Spring Transaction Manager │ → connection.setReadOnly(true)
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ Hibernate (JPA 구현체) │ → FlushMode.MANUAL 전환
│ │ → 스냅샷 생성 스킵
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ JDBC Driver │ → DB에 읽기 전용 힌트 전달
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ Database (MySQL InnoDB 등) │ → 읽기 전용 최적화 적용
└─────────────────────────────┘
Hibernate 레벨 — Flush 모드와 변경 감지 스킵
readOnly=true가 가져오는 가장 큰 효과는 Hibernate 레이어에 있습니다.
FlushMode가 MANUAL로 전환된다
일반적인 트랜잭션에서 Hibernate의 FlushMode는 AUTO입니다. 쿼리 실행 전이나 트랜잭션 커밋 시점에 변경된 엔티티를 자동으로 flush합니다.
readOnly=true가 설정되면 FlushMode가 MANUAL로 바뀝니다. 명시적으로 flush()를 호출하지 않는 한 **영속성 컨텍스트의 변경이 DB에 반영되지 않습니다 **.
변경 감지(Dirty Checking) 스킵
이게 성능에서 핵심입니다. 일반 트랜잭션에서 Hibernate는 다음을 수행합니다.
- 엔티티를 조회하면 ** 원본 스냅샷 **을 별도로 저장
- flush 시점에 현재 상태와 스냅샷을 ** 필드별로 비교** (Dirty Checking)
- 변경이 있으면 UPDATE SQL 생성
readOnly=true면 **1번 스냅샷 생성 자체를 건너뜁니다 **. 스냅샷이 없으니 2번 비교도, 3번 SQL 생성도 일어나지 않습니다.
// 일반 트랜잭션 — 엔티티 1000건 조회 시
// → 1000개의 스냅샷 객체 생성 (메모리 2배)
// → flush 시 1000번의 필드 비교 (CPU)
// readOnly=true 트랜잭션 — 엔티티 1000건 조회 시
// → 스냅샷 생성 안 함 (메모리 절약)
// → flush 자체가 없음 (CPU 절약)
대량 조회를 하는 서비스에서 이 차이는 체감할 수 있는 수준입니다. 엔티티당 수십 개의 필드가 있고, 한 번에 수백~수천 건을 조회한다면 스냅샷 메모리만 해도 상당합니다.
DB 레벨 효과 — MySQL InnoDB의 경우
Spring의 TransactionManager는 connection.setReadOnly(true)를 호출하고, JDBC 드라이버가 이를 DB에 전달합니다.
MySQL(InnoDB)에서의 최적화
MySQL 5.6.5 이상에서는 읽기 전용 트랜잭션에 대해 다음을 최적화합니다.
- ** 트랜잭션 ID 할당 생략 **: 읽기 전용으로 판단되면 트랜잭션 ID를 부여하지 않습니다.
- **undo 로그 기록 감소 **: 변경이 없으므로 undo 로그를 최소화합니다.
- ** 잠금 오버헤드 감소 **: 읽기 전용이라는 것을 알면 불필요한 잠금 메타데이터를 줄일 수 있습니다.
-- MySQL에서 실제로 실행되는 흐름
SET TRANSACTION READ ONLY;
START TRANSACTION;
SELECT * FROM orders WHERE user_id = 1;
COMMIT;
다만 이 최적화는 DB 벤더마다 다릅니다. PostgreSQL은 읽기 전용 트랜잭션에서 쓰기를 시도하면 에러를 던지지만, MySQL은 기본적으로 허용합니다.
클래스 레벨 readOnly 전략 — Vlad Mihalcea 패턴
Hibernate 전문가 Vlad Mihalcea가 권장하는 패턴이 있습니다. 대부분의 서비스 클래스는 조회 메서드가 더 많기 때문에, 클래스 레벨에 readOnly=true를 기본으로 두고 쓰기 메서드만 오버라이드 하는 전략입니다.
@Service
@Transactional(readOnly = true) // 기본: 읽기 전용
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// readOnly = true 자동 적용
public OrderDto getOrder(Long id) {
return orderRepository.findById(id)
.map(OrderDto::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// readOnly = true 자동 적용
public List<OrderDto> getOrders(Long userId) {
return orderRepository.findByUserId(userId).stream()
.map(OrderDto::from)
.toList();
}
// readOnly = true 자동 적용
public Page<OrderDto> searchOrders(OrderSearchCondition condition, Pageable pageable) {
return orderRepository.search(condition, pageable)
.map(OrderDto::from);
}
@Transactional // 쓰기 메서드만 오버라이드
public OrderDto createOrder(OrderCreateRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
return OrderDto.from(order);
}
@Transactional // 쓰기 메서드만 오버라이드
public void cancelOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
order.cancel();
}
}
이 패턴의 장점은 명확합니다.
- 조회 메서드가 많을수록 반복 설정이 줄어듦
- 쓰기 메서드가 ** 눈에 띄게 표시됨** — 코드 리뷰 시 "이 메서드는 쓰기구나"가 바로 보입니다
- readOnly를 깜빡 빠뜨리는 실수를 ** 구조적으로 방지**
주의할 점도 있습니다. 메서드 레벨에서 @Transactional로 오버라이드하면 클래스 레벨의 다른 속성(rollbackFor, timeout 등)도 기본값으로 리셋됩니다. 필요하다면 메서드 레벨에서도 명시해야 합니다.
// 주의: 클래스 레벨의 rollbackFor가 리셋됨
@Transactional(readOnly = true, rollbackFor = Exception.class) // 클래스 레벨
public class OrderService {
@Transactional // rollbackFor가 기본값(빈 배열)으로 리셋!
public void createOrder(...) { }
@Transactional(rollbackFor = Exception.class) // 이렇게 명시해야 안전
public void updateOrder(...) { }
}
readOnly는 "힌트"일 뿐이다
공부하다 보니 이 부분에서 많이 헷갈렸습니다. readOnly=true를 설정해도 ** 쓰기가 완전히 차단되지 않습니다 **.
@Transactional(readOnly = true)
public void dangerousMethod(Long id) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName("변경됨");
// 컴파일 에러? → 없음
// 런타임 에러? → 없음 (대부분의 경우)
// DB 반영? → 안 됨 (flush가 MANUAL이라서)
}
동작은 DB 드라이버와 DB에 따라 다릅니다.
| 환경 | readOnly=true에서 쓰기 시도 |
|---|---|
| Hibernate (JPA) | flush가 MANUAL이라 ** 조용히 무시** |
| PostgreSQL | ERROR: cannot execute INSERT in a read-only transaction |
| MySQL | 기본적으로 ** 허용** (에러 없음) |
| H2 | 기본적으로 ** 허용** |
PostgreSQL처럼 DB 레벨에서 막아주는 경우도 있지만, MySQL + Hibernate 조합에서는 그냥 무시됩니다. 디버깅이 매우 어려운 버그가 될 수 있으니 주의해야 합니다.
OSIV와 readOnly의 관계
OSIV(Open Session In View)는 Spring Boot에서 기본으로 켜져 있는 설정입니다(spring.jpa.open-in-view=true). 영속성 컨텍스트가 HTTP 요청의 시작부터 응답 완료까지 유지됩니다.
요청 시작 ──────── 서비스 호출 ──────── 뷰 렌더링 ──────── 응답
│ │ │
│ @Transactional(readOnly=true) │
│ 시작 ──── 종료 │
│ │
└── 영속성 컨텍스트 유지 ────────────┘ (OSIV=true)
readOnly=true 트랜잭션이 끝나면 FlushMode는 기본값으로 돌아갑니다. 뷰 렌더링 중에 지연 로딩이 발생할 수 있지만, 이때는 readOnly 최적화가 적용되지 않습니다.
실무에서는 OSIV를 끄는 것을 권장하는 경우가 많습니다. OSIV=true면 컨트롤러에서도 DB 커넥션을 물고 있어서 커넥션 풀이 빨리 고갈될 수 있기 때문입니다.
# application.yml
spring:
jpa:
open-in-view: false # 운영 환경에서는 끄는 것을 권장
실무 예시 — readOnly 적용 전후 성능 차이
대량 조회 API에서 readOnly 적용 전후를 비교해보겠습니다.
시나리오: 주문 목록 조회 (1000건, 엔티티당 필드 20개)
// Before: readOnly 미적용
@Transactional
public List<OrderDto> getOrders(Long userId) {
return orderRepository.findByUserId(userId).stream()
.map(OrderDto::from)
.toList();
}
// After: readOnly 적용
@Transactional(readOnly = true)
public List<OrderDto> getOrders(Long userId) {
return orderRepository.findByUserId(userId).stream()
.map(OrderDto::from)
.toList();
}
예상되는 차이는 다음과 같습니다.
| 항목 | readOnly=false | readOnly=true |
|---|---|---|
| 스냅샷 메모리 | 1000개 x 20필드 | 0 |
| flush 시 비교 연산 | 1000 x 20 = 20,000회 | 0 |
| flush SQL 생성 | 확인 필요 | 스킵 |
| GC 부담 | 스냅샷 객체 1000개 회수 | 없음 |
수치가 작아 보일 수 있지만, 이 API가 초당 수백 건 호출된다면 GC 압박과 CPU 사용량에서 유의미한 차이가 생깁니다.
DataSource 라우팅과 결합
readOnly의 또 다른 실무 활용은 읽기 전용 쿼리를 ** 레플리카 DB로 라우팅 **하는 것입니다.
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 현재 트랜잭션이 readOnly면 레플리카로 라우팅
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "replica"
: "primary";
}
}
이렇게 설정하면 readOnly=true 트랜잭션의 쿼리는 자동으로 레플리카 DB에서 실행됩니다. 쓰기/읽기 부하를 물리적으로 분산할 수 있는 강력한 패턴입니다.
정리
readOnly=true는 강제가 아니라 ** 힌트 **입니다. 쓰기를 시도해도 컴파일 에러는 없습니다.- Hibernate는 이 힌트를 받으면 FlushMode를 MANUAL로 전환 하고, 스냅샷 생성과 Dirty Checking을 스킵 합니다.
- MySQL InnoDB는 읽기 전용 트랜잭션에서 트랜잭션 ID 할당과 undo 로그를 최적화 합니다.
- 클래스 레벨
readOnly=true+ 쓰기 메서드만 오버라이드하는 패턴은 반복을 줄이고 의도를 명확히 합니다. - OSIV=true일 때 readOnly 최적화는 서비스 트랜잭션 범위에서만 적용됩니다.
- DataSource 라우팅과 결합하면 ** 읽기 부하를 레플리카로 분산 **할 수 있습니다.