Spring @Transactional readOnly true 진짜 하는 일과 함정
@Transactional(readOnly = true)는 세 가지 일을 한꺼번에 합니다. ① Hibernate의 FlushMode를 AUTO에서 MANUAL로 바꿔 더티 체킹으로 인한 UPDATE를 아예 안 보내고, ② JDBC Connection.setReadOnly(true)를 걸어 PostgreSQL 같은 DB가 쓰기를 거부하게 만들며, ③ 스냅샷 비교 비용이 줄어 조회가 가벼워집니다. 문제는 ①이 예외 없이 조용히 무시된다는 점입니다. 아래는 Spring Framework 6.1.14 + Hibernate 6.5.3 + PostgreSQL 18.3에서 실제로 돌려 확인한 결과입니다.
1. readOnly = true 를 붙이면 실제로 뭐가 달라지나
먼저 공식 문서가 말하는 정의부터 봅니다. Spring Framework Javadoc의 readOnly() 설명은 이렇습니다.
“A boolean flag that can be set to true if the transaction is effectively read-only, allowing for corresponding optimizations at runtime. This just serves as a hint for the actual transaction subsystem; it will not necessarily cause failure of write access attempts.”
즉 스펙상으로는 "힌트"일 뿐이고, 쓰기를 막아준다는 보장은 없습니다. 그런데 JPA(Hibernate) + JpaTransactionManager 조합에서는 이 힌트가 아래처럼 구체적으로 동작합니다.
| 계층 | readOnly = true 일 때 | 결과 |
|---|---|---|
| Hibernate | FlushMode = MANUAL |
더티 체킹 UPDATE가 안 나감 (예외도 없음) |
| JDBC | Connection.setReadOnly(true) |
DB가 지원하면 쓰기 시 SQL 에러 |
| 성능 | 스냅샷 비교·플러시 생략 | 조회 전용 로직이 가벼워짐 |
FlushMode가 정말 바뀌는지 직접 찍어봤습니다.
@Transactional
public String flushModeRW() {
return String.valueOf(em.unwrap(Session.class).getHibernateFlushMode());
}
@Transactional(readOnly = true)
public String flushModeRO() {
return String.valueOf(em.unwrap(Session.class).getHibernateFlushMode());
}
read-write FlushMode = AUTO
readOnly=true FlushMode = MANUAL
2. 조용히 안 저장되는 함정 — 예외가 안 납니다
가장 많이 당하는 지점입니다. 아래 두 메서드는 본문 코드가 완전히 같고 애노테이션만 다릅니다.
@Transactional // 기본 = read-write
public void renameReadWrite(Long id, String name) {
Member m = em.find(Member.class, id);
m.setName(name); // save() 호출 없음 — 더티 체킹에 맡김
}
@Transactional(readOnly = true) // 딱 이것만 다름
public void renameReadOnly(Long id, String name) {
Member m = em.find(Member.class, id);
m.setName(name);
}
실행 결과(show_sql 켜고 찍은 로그)입니다.
### 1. readOnly=false (기본) 에서 필드 변경
Hibernate: select m1_0.id,m1_0.name from member m1_0 where m1_0.id=?
Hibernate: update member set name=? where id=?
DB 값 = read-write-변경
### 2. readOnly=true 에서 같은 코드
Hibernate: select m1_0.id,m1_0.name from member m1_0 where m1_0.id=?
DB 값 = read-write-변경 <-- 안 바뀜
이 함정이 특히 잘 걸리는 이유는 더티 체킹에 의존하는 코드라서입니다. save()를 명시적으로 호출했다면 뒤에서 설명할 JDBC 레벨 에러로라도 잡혔을 텐데, 필드만 바꾸고 커밋에 맡기면 Hibernate가 플러시를 아예 건너뛰므로 DB에 아무것도 안 갑니다. 참고로 명시적 플러시 시점이 궁금하다면 JPA save() vs saveAndFlush() 차이 글에서 flush 타이밍을 따로 정리해 뒀습니다.
3. 강제로 쓰면 어떻게 되나 — PostgreSQL은 에러를 냅니다
그럼 readOnly = true 안에서 persist() 후 flush()로 강제로 밀어넣으면 어떻게 될까요? Hibernate의 FlushMode를 무시하고 JDBC까지 도달하므로, 이번엔 DB가 거부합니다.
@Transactional(readOnly = true)
public void insertInReadOnly(String name) {
Member m = new Member();
m.setName(name);
em.persist(m);
em.flush(); // 강제로 INSERT를 DB까지 보낸다
}
Hibernate: insert into member (name) values (?) returning id
ERROR: cannot execute INSERT in a read-only transaction
org.hibernate.exception.GenericJDBCException
root: org.postgresql.util.PSQLException: ERROR: cannot execute INSERT in a read-only transaction
Spring의 JpaTransactionManager가 트랜잭션 시작 시 커넥션에 setReadOnly(true)를 걸고, PostgreSQL 드라이버가 이를 SET SESSION CHARACTERISTICS ... READ ONLY로 반영하기 때문입니다. 다만 이건 DB·드라이버가 지원할 때만입니다. Javadoc이 못박은 대로 "해석하지 못하는 트랜잭션 매니저는 예외를 던지지 않고 힌트를 조용히 무시"하므로, 이 에러가 나는 걸 안전장치로 믿으면 안 됩니다.
4. 전파(propagation) 함정 — 바깥이 readOnly면 안쪽도 readOnly입니다
실무에서 더 무서운 건 이쪽입니다. 조회 서비스에 @Transactional(readOnly = true)를 걸어두고, 그 안에서 쓰기 서비스를 호출하는 경우입니다.
@Transactional(readOnly = true) // 바깥: 조회 전용
public void outerReadOnly(Long id, String name) {
inner.writeRequired(id, name); // 안쪽: 쓰기를 기대
}
@Transactional(propagation = Propagation.REQUIRED) // 기본값
public void writeRequired(Long id, String name) {
em.find(Member.class, id).setName(name);
em.flush();
}
REQUIRED는 기존 트랜잭션에 참여하므로 readOnly 속성이 새로 적용되지 않습니다. 실행 결과입니다.
### A. 바깥 readOnly=true + 안쪽 REQUIRED(기본)
outer: readOnly? = true
inner: 현재 트랜잭션 readOnly? = true <-- 안쪽도 readOnly!
Hibernate: select ...
성공 <-- update 없이 "성공"
### B. 바깥 readOnly=true + 안쪽 REQUIRES_NEW
inner: 현재 트랜잭션 readOnly? = false
Hibernate: select ...
Hibernate: update member set name=? where id=?
성공
A는 em.flush()를 명시적으로 호출했는데도 UPDATE가 안 나갔습니다. 변경 감지 대상 엔티티가 read-only 상태로 로드돼 스냅샷을 안 들고 있기 때문입니다. 예외도 없고 "성공"으로 끝납니다. 반면 B(REQUIRES_NEW)는 새 트랜잭션을 열기 때문에 readOnly가 false로 다시 결정돼 정상 동작합니다.
REQUIRED로 참여하면 안쪽의 readOnly = false는 무시됩니다.
5. 그래서 어떻게 쓰면 되나
- 클래스에
@Transactional(readOnly = true), 쓰기 메서드에만@Transactional을 덮어쓴다. 공식 문서 예제도 이 형태입니다. 기본을 안전한 쪽으로 두는 방식입니다. - 쓰기 메서드에 애노테이션을 빠뜨리지 않았는지 확인한다. 위 패턴에서 덮어쓰기를 잊으면 그 메서드는 조용히 아무것도 저장하지 않습니다.
- 조회 서비스 안에서 쓰기 서비스를 호출하지 않는다. 구조를 바꿀 수 없다면 안쪽을
REQUIRES_NEW로 분리해야 하는데, 이 경우 트랜잭션이 분리돼 롤백이 같이 안 되는 점을 감수해야 합니다. - 성능 개선을 기대한다면 효과가 큰 곳부터 봅니다. readOnly의 이득은 스냅샷·플러시 생략인데, 애초에 조회 쿼리 수가 폭발하는 게 문제라면 JPA N+1 문제 해결 (Fetch Join vs EntityGraph) 쪽이 훨씬 큰 개선입니다.
@Service
@Transactional(readOnly = true) // 기본은 조회 전용
public class MemberService {
public Member find(Long id) { ... } // readOnly = true 적용
@Transactional // 쓰기만 덮어쓴다
public void rename(Long id, String name) { ... }
}
자주 묻는 질문 (FAQ)
Q. readOnly = true 를 붙이면 성능이 얼마나 좋아지나요?
"몇 배" 같은 수치를 기대할 만한 옵션이 아닙니다. 줄어드는 건 영속성 컨텍스트의 스냅샷 보관과 플러시 시 변경 감지 비용이라, 한 트랜잭션에서 다루는 엔티티 수가 많을수록 차이가 납니다. 엔티티 몇 건 조회하는 API에서는 체감이 거의 없습니다. 오히려 실질적 가치는 "이 메서드는 쓰기가 없다"는 의도 표시와, 읽기 전용 복제본(replica)으로 라우팅하는 기준값으로 쓸 수 있다는 점입니다.
Q. readOnly = true 인데 데이터가 저장됐습니다. 왜죠?
그 메서드에 애노테이션이 실제로 적용되지 않았을 가능성이 큽니다. 같은 클래스 안에서 this.method()로 호출하면 프록시를 안 거쳐 @Transactional 자체가 무시됩니다. 또 위 4번처럼 바깥에서 이미 read-write 트랜잭션이 열려 있으면 안쪽 readOnly = true는 REQUIRED로 참여하면서 무시됩니다.
Q. MySQL에서도 INSERT가 막히나요?
JDBC의 Connection.setReadOnly(true)를 드라이버가 어떻게 처리하느냐에 달렸습니다. 이 글에서 확인한 cannot execute INSERT in a read-only transaction 에러는 PostgreSQL 18.3 + 드라이버 42.7.4 조합의 결과입니다. Javadoc이 "힌트일 뿐이며 쓰기 실패를 반드시 유발하지는 않는다"고 못박았으므로, 쓰기 차단을 보안·정합성 장치로 쓰지 마세요. 쓰는 DB에서 직접 확인해야 합니다.
Q. JPA를 안 쓰고 MyBatis/JdbcTemplate만 쓰는데 의미가 있나요?
FlushMode 최적화는 Hibernate 전용이라 해당 없고, JDBC 커넥션의 read-only 설정만 남습니다. 즉 DB가 지원하면 쓰기가 막히는 효과 정도입니다.
마무리
정리하면 @Transactional(readOnly = true)는 Hibernate에서는 UPDATE를 조용히 생략하고, JDBC에서는 DB가 지원할 때만 쓰기를 막는 옵션입니다. 성능 최적화보다 "이 메서드는 읽기 전용"이라는 선언의 가치가 크고, 대신 쓰기 메서드에 덮어쓰기를 빠뜨리면 예외 없이 데이터가 사라집니다. 클래스 기본값으로 걸어 쓸 거라면 쓰기 메서드 목록을 한 번 훑어보고, 조회 트랜잭션 안에서 쓰기를 부르는 구조가 없는지 확인하세요.
📚 참고 출처 (2026년 7월 21일 확인 · 실행 환경: Spring Framework 6.1.14 · Hibernate ORM 6.5.3.Final · PostgreSQL 18.3 · JDBC 42.7.4)
· Spring Framework Javadoc — Transactional.readOnly()
· Spring Framework Reference — Using @Transactional

COMMENTS