프로젝트를 진행하면서 DB에 레코드를 추가하거나 변경할 때는 `@Transactional` 어노테이션을 적용했다. 레코드를 조회만 할 경우에는 처음엔 `@Transactional`을 적용하지 않았다. 근데 자동완성 과정에서 `@Transactional(readOnly = true)`를 발견했다.

원자성을 보장하기 위한 `@Transactional` 어노테이션을 조회만 있는 비즈니스 로직에서 사용할 필요가 있을까?
트랜잭션의 특징, ACID
트랜잭션은 특징은 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)의 4가지로 줄여서 ACID로 부른다. ACID는 짧게 정리해보자.
- 원자성(Atomicity): 트랜잭션과 관련된 작업들이 부분적으로 실행되거나 중단되지 않고, 모두 성공하거나 모두 실패하는 것을 보장한다.
- 일관성(Consistency): 트랜잭션이 성공적으로 완료되면 언제나 일관성 있는 데이터베이스 상태를 유지해야 한다. 애플리케이션 로직이 실수해도 DB가 마지막 방어선 역할을 한다.
- 격리성(Isolation): 동시에 여러 트랜잭션이 실행되더라도, 각 트랜잭션은 다른 트랜잭션의 연산에 영향을 받지 않고 독립적으로 실행되어야 한다. 격리 수준에 따라 동작이 달라진다.
- 지속성(Durability): 트랜잭션이 성공적으로 완료되면 시스템 오류가 발생하더라도 데이터는 영구적으로 저장되어야 한다. COMMIT이 성공했다면, 서버가 재시작되어도 해당 기록은 DB에 존재해야 한다.
Spring Data JPA에서 `@Transactional` 어노테이션은 동일한 트랜잭션 내에서 여러 DB 호출에도 단일 영속성 컨텍스트를 유지하도록 보장한다. 하지만 이 어노테이션은 단순히 영속성 컨텍스트를 관리하는 도구 뿐만 아니라 데이터베이스 트랜잭션에서 ACID의 특징을 적용하는 데에도 사용할 수 있다.
그럼 조회 기능에서 ACID의 어떤 부분을 보장할 수 있을까?
조회에선 격리성(I)이 중요하다
격리성은 쓰기 작업 뿐만 아니라 읽기 작업에서도 중요하다. 이는 일관된 데이터를 보기 위함이다.
예를 들어, 온라인 서점에서 고객이 도서 상세 페이지를 조회하는 시나리오를 생각해보.
`@Transactional`없이 조회한다면,
- 책의 재고 및 가격 조회 (33,000원)
- 조회 시작
- 재고 조회 ⚠️ 이 때, 책의 가격 인상!(36,000원)
- 가격 조회 (36,000원)
이런 과정을 거치게 된다. 사용자는 33,000원인 책을 보고 조회를 시작했지만 마지막엔 영문도 모른 체 상세보기에선 36,000원의 책을 조회하게 된다.
누군가는 최신 정보를 바로 보는게 좋은거 아닌가요? 라고 말할 것이다. 하지만 이는 틀렸다. 이는 부분적으로만 최신이며, 데이터 불일치와 비즈니스 로직 오류등을 발생시킬 수 있다.
`@Transactional`를 적용하면 (각 DB마다 기본 격리 수준이 다르다, 지금은 Mysql의 `REPEATABLE_READ`를 기준으로 말한다) INSERT된 새 데이터를 제외한 조회가 동일한 시점의 데이터 스냅샷을 보게 되어, 중간에 다른 트랜잭션이 데이터를 변경하더라도 영향받지 않는다. 고객은 상세보기에서 33,000원을 보게 된다.
따라서 `@Transactional`을 통해 읽기 작업에도 격리성을 보장해야 한다.
데이터베이스 최적화: readOnly = true의 실제 효과
`readOnly = true`는 DB에게 "이 트랜잭션은 데이터를 변경하지 않는다"는 메세지를 제공한다. 이는 데이터베이스와 JPA 레벨에서 실질적인 최적화를 수행하기 위함이다.
락 최적화: 동시성 향상
데이터베이스의 락은 동시에 같은 데이터에 접근하는 트랜잭션을 조율하고 배타 락과 공유 락으로 나눌 수 있다.
- 배타 락은 한 사용자만이 데이터에 접근할 수 있도록 보장한다. 배타 락이 걸린 데이터는 해당 락을 획득한 사용자만 읽거나 쓸 수 있고 다른 사용자는 해당 락이 해제될 때까지 대기해야 한다.
- 공유 락은 여러 사용자가 동시에 데이터를 읽을 수 있도록 허용한다. 공유 락이 걸린 데이터는 다른 사용자가 읽을 수 있지만, 쓰기 작업은 불가능하다.
`readOnly = true`는 배타 락을 걸지 않는 것을 보장하며, 이를 통해 쓰기 작업과의 락 경합이 차단되어 동시성이 향상된다.
일반 트랜잭션에서 실수로 엔티티를 수정하면 해당 레코드에 배타 락이 발생하고 다른 트랜잭션은 대기하는 상황이 발생하지만 , `readOnly = true`는 엔티티를 수정해도 DB에 반영이 안되기 때문에 락 경합이 없고 다른 트랜잭션과 충돌 가능성을 최소화할 수 있다.
따라서, `readOnly = true`는 조회가 많은 작업에서 동시성을 크게 향상해준다.
Master-Slave 라우팅
Master-Slave 구조는 데이터베이스 부하 분산 및 고가용성을 확보하기 위해 사용한다. Master DB는 쓰기 연산을 처리하고, Slave DB는 Master DB의 데이터를 복제하여 읽기 연산을 처리한다. 일반적인 서비스는 대게 읽기, 쓰기의 비율이 7:3 ~ 9:1 정도로 일어난다고 한다.(내가 직접 경험한 부분은 아니지만, 구글링을 하고 찾아보면 거의 모든 정보가 그렇다고 한다, 아마 실제론 서비스 특성에 따라 다르겠지.) 그럼 읽기 요청이 쓰기보다 압도적으로 많기 때문에 읽기 요청을 여러 Slave로 분산시킨다.

`readOnly = true`를 명시하고, `RoutingDataSource`를 적절히 설정하면 readOnly 트랜잭션을 Slave DB로 라우팅할 수 있다. 따라서 전체적인 시스템 처리량을 증가 시킬 수 있다.
JPA / Hibernate 최적화
더티 체킹 비활성화: CPU를 절약한다
더티 체킹은 Hibernate가 엔티티의 변경사항을 추적하는 메커니즘이다.
일반 트랜잭션에서는 Hibernate는 아래 과정을 거친다.
- 엔티티 조회 시 원본 스냅샷을 메모리에 저장
- 트랜잭션 종료 시 현재 상태와 스냅샷을 필드 하나하나 비교
- 차이가 있으면 UPDATE 쿼리 생성
2번의 비교 작업은 CPU 사용량이 매우 많다. 1000개 엔티티, 각 엔티티당 20개 필드라면? 20000번의 비교 연산이 발생한다.
`readOnly = true`여도 1차 캐시에 엔티티는 저장하지만 스냅샷을 비교 안하기 때문에 CPU를 절약하게 된다.
플러시 모드 변경
`Flush`는 영속성 컨텍스트의 변경사항을 DB에 반영하는 작업이다.
일반 트랜잭션은 쿼리 실행 전마다 자동 플러시를 수행해, 조회하려는 데이터 중 바꾼 데이터가 있는지 매번 확인한다.
`readOnly = true`를 설정하면 플러시 모드가 `FlushMode.NEVER`로 변경되고, 자동 플러시가 비활성화되어 쿼리 실행 속도도 향상되고 실수로 엔티티를 수정해도 DB에 반영되지 않아 데이터 안정성을 보장할 수 있다.
'Spring > Spring Data JPA' 카테고리의 다른 글
| [Spring Data JPA] @OneToOne 연관관계에서 Lazy Loading이 동작하지 않는 경우 (2) | 2025.07.11 |
|---|---|
| [Spring Data JPA] ID에 관하여 (2) | 2025.03.13 |
| [Spring Data JPA] JPA의 N + 1 문제 (0) | 2025.03.07 |
| [Spring Data JPA] 엔티티 매니저(Entity Manager) (0) | 2025.03.05 |
| [Spring Data JPA] 영속성 컨텍스트(Persistence Context) (0) | 2025.03.04 |