백엔드 학습을 하며 DB에 대한 학습을 진행하면 필수로 익혀야 하는 부분이 바로 트랜잭션이다.
`org.springframework.transaction.annotation` 패키지에 속한 `@Transactional` 어노테이션을 이용하면 우리가 원하는 메소드에 트랜잭션을 걸어 영속성 컨텍스트를 활용할 수 있다.
파일을 수정, 삭제를 할 때 일관성과 정합성을 위해 `@Transactional` 어노테이션을 사용하는 것은 이제는 너무 당연하게 느껴지는 사항이다. 그런데 트랜잭션의 옵션에 보면 readOnly 라는 옵션이 존재한다.

트랜잭션을 사용하는 가장 단순한 이유에서 생각해본다면, 수정 혹은 삭제 등의 변경사항이 일어났을 때 오류가 발생해 하나의 연결된 로직이 부분 수정이 되는 현상을 방지하기 위해 트랜잭션을 사용한다. 그런데, 단순히 조회를 하는 메소드에서 트랜잭션을 걸어야 할 일이 있을까? 에서부터 의문이 시작되었다.
지연 로딩을 사용할 때의 @Transactional(readOnly=true)
우리는 연관관계 매핑을 통해 다대일 관계에서 `@ManyToOne(fetch = FetchType.LAZY)`을 통해서 지연 로딩을 설정한다. 그 이후 트랜잭션 내부에서 엔티티를 조회하고 별도로 로딩을 하지 않은 연관되어 있는 엔티티의 정보를 가져오려 하면 추가 쿼리가 나가며 지연 로딩을 통해 데이터를 얻어올 수 있다(N+1 문제가 발생할 수 있는 점을 주의하자)
그런데 조회에서도 지연 로딩을 사용할 경우가 있을까?
적용을 하려면 적용할 수 있다. 다음 예시를 보자
@Transactional(readOnly = true)
public ResponseDto getData() {
List<Product> productList = productRepository.findAll();
return productList.map(ProductResponseDto::new);
}
제품에 대한 데이터를 리스트로 가져오고 이를 응답 Dto로 변환해서 내보낸다. 응답 Dto로 변환하는 내부는 다음과 같이 작성되어 있다.
public ProductResponseDto(Product product) {
this.id = product.getId();
this.title = product.getTitle();
this.image = product.getImage();
for (ProductFolder productFolder : product.getProductFolderList()) {
productFolderList.add(new FolderResponseDto(productFolder.getFolder()));
}
}
단순히 조회를 하는 것이지만, dto로 변경하는 과정에서 지연 로딩이 발생한다. 즉 이 상황에서 @Transaction이 존재하지 않는다면 지연 로딩이 발생하지 않아 원하는 데이터를 가져올 수 없는 것이다.
물론, 이건 예시의 코드이고 대부분의 경우 N+1 문제 방지를 위해 Fetch Join을 사용해 데이터를 가져와 직접 매핑하는 방식을 적용할 것이다.
@Trnasactional vs @Transactional(readOnly=true)
그렇다면, 그냥 `@Transactional` 을 적용하면 되는 것 아닌가? 당연히 문제는 없다. 하지만 옵션이 있다는 것은 분명한 차이점이 존재하는 것이다.
`@Transactional`의 경우 ACID 속성을 보장하기 위해 다음의 동작들을 한다.
- 트랜잭션 시작
- 락(Lock) 획득 준비
- 변경 감지(더티 체킹)을 위한 스냅샷 생성
- 비즈니스 로직 실행
- 더티 체킹 수행
- 변경 사항을 트랜잭션 로그에 기록
- 커밋 혹은 롤백
하지만 readOnly=true 옵션을 킨다면 다음과 같은 차이점이 존재한다.
- 변경 감지 비활성화(스냅샷 비활성화를 통해 스냅샷을 생성하기 위한 메모리를 사용하지 않아 성능 상 이점을 가져갈 수 있음)
- 모든 락(Lock)이 아닌 읽기 락(Lock)만 획득 -> 락 획득 최소화
- 트랜잭션 로그 생성 생략
- 플러시(`flush()`)모드가 수동으로 설정 돼 자동 플러시 방지 -> 읽기 전용이므로 flush가 불필요하다 판단
@Transactional(readOnly=true) 에서는 데이터 변경이 불가능할까?

여기 Test1 이라는 이름을 가진 유저를 준비했다.
@Test
@Transactional
@Rollback(value = false)
void test5() {
User user = userRepository.findById(1L).orElse(null);
user.setName("Test2");
}
트랜잭션을 걸고 setName으로 변경해봤다. 우선 읽기 전용이 아니기때문에 더티 체킹에 의해 값이 변경될 것으로 예상된다.


예상대로 이름이 잘 변경된 모습을 확인했고, 더티 체킹에 의해 update 쿼리가 나간 모습도 확인했다.
그럼 다음으로 readOnly 옵션을 주고 변경해보자
@Test
@Transactional(readOnly = true)
@Rollback(value = false)
void test5() {
User user = userRepository.findById(1L).orElse(null);
user.setName("Test3");
}


더티 체킹으로 인한 update 쿼리가 존재하지 않고 변경 또한 되지 않았다.
그럼 다음으로 JPA 메소드인 `save()` 메소드를 통해 수동 저장을 해보겠다.
@Test
@Transactional()
@Rollback(value = false)
void test5() {
User user = userRepository.findById(1L).orElse(null);
user.setName("Test3");
userRepository.save(user);
}

수동으로 `save()` 로직을 적용했기 때문에 update 쿼리가 나올 것으로 예상했지만, 예상과는 다르게 update 되지 않았다.
이번에는 EntityManger에서 강제로 `flush()`를 호출해보겠다.
@Test
@Transactional(readOnly = true)
@Rollback(value = false)
void test5() {
User user = userRepository.findById(1L).orElse(null);
user.setName("Test3");
userRepository.save(user);
entityManager.flush();
}

그래도 update 쿼리는 나오지 않은 것으로 보인다.
마지막으로는 JPA를 이용하지 않고 Native 쿼리를 이용하는 방식을 적용해보겠다.
@Test
@Transactional(readOnly = true)
@Rollback(value = false)
void test5() {
entityManager.createNativeQuery(
"UPDATE users SET name = 'Test3' WHERE id = 1"
)
.executeUpdate();
}

결과는 MySQL 커넥션이 read-only로 설정되어 있어 변경이 불가능하다는 에러가 발생하게 되었다. 실제로 `readOnly=true` 옵션을 제거하니까 데이터가 잘 변경되었다.
사실, 이 글을 작성하면서 이전에 아는 지인에게 듣기로 `readOnly=true` 옵션이 JPA에게 단순히 읽기 전용이라고 알려줘서 스냅샷 생성, 더티체킹 등은 발생하지 않아도 명시적인 `save()`를 통해서는 값을 바꿀 수 있다고 들은 적이 있어 당연히 아래 3개 테스트는 성공할 것이라고 생각했다.
하지만 점점 기술이 발전하면서 이런 부분에 대한 정합성을 보장하는 것인지 어떤 방법으로도 `readOnly=true` 옵션이 걸려 있는 트랜잭션 내부에서는 데이터를 변경하는 것이 가능하지 않았다.
참고로 이번 테스트에 실험한 DB는 MySQL이며 DB마다 조금씩 정책 및 스프링 부트의 지원방식이 다를 수 있기 때문에 명확히 알고 사용하는 것이 필요할 것이다.
'Back-End' 카테고리의 다른 글
| MSA 학습 - Spring Cloud와 Eureka (0) | 2025.09.22 |
|---|---|
| 비동기 메시징 방식(RabbitMQ vs Kafka) (0) | 2025.09.19 |
| [JPA] 외래키 주인으로 알아보는 엔티티 연관관계 (0) | 2025.09.17 |
| 테스트 코드 작성 전략 (1) | 2025.09.16 |
| 프로젝트 성능 개선기(nGrinder을 활용한 부하 테스트) (0) | 2025.04.02 |