@Transactional(readOnly=true)는 왜 써야하는걸까?

2025. 9. 18. 23:53·Back-End

백엔드 학습을 하며 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 속성을 보장하기 위해 다음의 동작들을 한다.

  1. 트랜잭션 시작
  2. 락(Lock) 획득 준비
  3. 변경 감지(더티 체킹)을 위한 스냅샷 생성
  4. 비즈니스 로직 실행
  5. 더티 체킹 수행
  6. 변경 사항을 트랜잭션 로그에 기록
  7. 커밋 혹은 롤백

하지만 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
'Back-End' 카테고리의 다른 글
  • MSA 학습 - Spring Cloud와 Eureka
  • 비동기 메시징 방식(RabbitMQ vs Kafka)
  • [JPA] 외래키 주인으로 알아보는 엔티티 연관관계
  • 테스트 코드 작성 전략
dev_Mins
dev_Mins
  • dev_Mins
    천천히 빠르게!
    dev_Mins
  • 전체
    오늘
    어제
    • 분류 전체보기 (49)
      • 42Seoul (2)
      • Back-End (20)
        • Spring (8)
      • Project (14)
        • PickLab (3)
      • 끄적끄적 (3)
      • Algorithm (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Spring
    로드밸런서
    JWT
    스프링 jwt
    스프링 게이트웨이
    재고 서비스
    AWS
    Spring Cloud
    42seoul
    주문 서비스
    Servcie Discovery
    42서울
    MSA
    Kafka
    스프링
    Spring Boot
    Spring Security
    Spring Data JPA
    로드밸런싱
    MSA Gateway
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
dev_Mins
@Transactional(readOnly=true)는 왜 써야하는걸까?
상단으로

티스토리툴바