@Transactional(readOnly = true) 내부에 쓰기 코드가 있다면?

2025. 6. 17. 11:35spring

문득, @Transactional(readOnly = true) 내부에 쓰기 코드가 있다면 어떻게 될지 궁금했다.

해당 내용을 정리하기 전, @Transactional(readOnly = true) 의 장점을 분석해보자.

@Transactional(readOnly = true) 장점

이유 1. Snapshot을 따로 저장하지 않음

이는 JPA의 영속성 컨텍스트가 수행하는 변경감지와 관련이 있다.

변경 감지(Dirty Checking)란?

영속성 컨텍스트는 Entity 조회 시 초기 상태에 대한 Snapshot을 저장한다.

트랜잭션이 Commit될 때, 초기 상태의 정보를 가지는 Snapshot과 Entity의 상태를 비교해 변경된 내용에 대해 update query를 생성해 쓰지 지연 저장소에 저장한다.

그 후, 일괄적으로 쓰기 지연 저장소에 저장되어 있는 SQL query를 flush하고 데이터베이스의 트랜잭션을 Commit 함으로써 우리가 update와 같은 메서드를 사용하지 않고도 Entity의 수정이 이루어진다. 이를 변경 감지(Dirty Checking) 라고 한다.

이 때, readOnly = true를 설정하게 되면 스프링 프레임워크는 JPA의 세션 플러시 모드를 MANUAL로 설정한다.

더보기

💡  MANUAL 모드는 트랜잭션 내에서 사용자가 수동으로 flush를 호출하지 않으면 flush가 자동으로 수행되지 않는 모드이다.

즉, 트랜잭션 내에서 강제로 flush()를 호출하지 않는 한, 수정 내역에 대해 DB에 적용되지 않는다.

이로 인해 트랜잭션 Commit 시 영속성 컨텍스트가 자동으로 flush 되지 않으므로 조회용으로 가져온 Entity의 예상치 못한 수정을 방지할 수 있다.

또한, readOnly = true를 설정하게 되면 JPA는 해당 트랜잭션 내에서 조회하는 Entity는 조회용임을 인식하고 변경 감지를 위한 Snapshot을 따로 보관하지 않으므로 메모리가 절약되는 성능상 이점 역시 존재한다.

이유 2. 가독성

// 예시를 위해 간단하게 작성했다. 
// 실제 구현 시에는 Entity를 DTO로 변환하는 것이 좋다.
// 1
@Transactional
public Member getMember(Long memberId) {
    Optional<Member> member = memberRepository.findById(memberId);
    ...
    ..
    ..
    return member;
}

// 2
@Transactional(readOnly = true)
public Member getMember(Long memberId) {
    Optional<Member> member = memberRepository.findById(memberId);
    ...
    ..
    ..
    return member;
}

readOnly = true를 붙임으로써 직관적으로 해당 메서드가 조회용 메서드임을 알 수 있어 가독성 측면에서도 이점을 가진다.

이유 3. Replication 부하 분산 (Slave DB에서만 조회)

Replication 장점 (장애 대응 및 트래픽 분산)

기본적으로 간단한 프로젝트에서는 데이터베이스를 하나만 둔다.

하지만 실제 운용되는 서비스에서는 데이터베이스의 장애를 빠르게 복구하고, 트래픽을 분산하기 위해 실시간 복제본 데이터베이스를 운용하는 레플리케이션(Replication) 방식을 사용할 수 있다.

레플리케이션은 Master-Slave 구조로 복제본 DB를 함께 운용함으로써,

  1. Master DB의 장애 발생 시 Slave DB를 Master DB로 승격시켜 장애를 빠르게 복구할 수 있으며,
  2. 조회 작업은 Slave DB에서 수행하고 수정 작업은 Master DB에서 수행함으로써 트래픽을 분산할 수 있다는 장점이 있다.

이러한 데이터베이스 구조를 가져갈 때, readOnly = true가 설정되어있는 메서드의 경우 Slave DB에서 데이터를 가져오도록 동작한다. 이를 통해 레플리케이션의 목적에 맞게 트래픽 분산을 온전하게 적용할 수 있다는 추가적인 이점이 존재한다.


@Transactional(readOnly = true) 로 설정한 트랜잭션 내부에 조회가 아닌 변경 관련 코드가 있다면?

@Transactional(readOnly = true) 에 대해 읽어보면서

우연히 https://impati.github.io/posts/transaction/ 해당 링크를 공부하게 되었다.

아래부터는 위 링크에 있는 글을 퍼온 내용이다.

정말 읽기만 하는 경우 @Transactional(readOnly = true) 로 읽기 전용 트랜잭션을 사용하여 flush를 호출하지 않음으로써 성능최적화를 이뤄낼 수 있다. 이러한 읽기 전용 트랜잭션에 쓰기관련 코드가 있을 경우 DB에 아예 반영이 안되는지 궁금하였다. org.springframework.transaction.annotation 의 javadoc 에서 확인해본 결과,

A boolean flag that can be set to true if the transaction is effectively read-only, allowing for corresponding optimizations at runtime. Defaults to false. This just serves as a hint for the actual transaction subsystem; it will not necessarily cause failure of write access attempts. A transaction manager which cannot interpret the read-only hint will not throw an exception when asked for a read-only transaction but rather silently ignore the hint

@Service
public class MemberService {

    private final MemberReader memberReader;
    private final MemberCreator memberCreator;
    
    @Transactional(readOnly = true)
    public Member getMember(Long id) {
        List<Member> allMember = memberReader.getAllMember();
        return allMember.stream()
                .filter(member -> member.getId().equals(id))
                .findFirst()
                .orElseGet(memberCreator::create);
    }
}

======

@Component
public class MemberCreator {

    private final MemberRepository memberRepository;

    @Transactional
    public Member create() {
        return save(new Member("default"));
    }
}

H2

테스트에서 h2 데이터베이스를 자주 사용하는데 h2 데이터베이스를 사용하는 경우에 오류가 발생하지 않는지 확인해 보았다.

@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Test
    @DisplayName("id 로 멤버를 가져오는데 없으면 default Member 를 생성해서 가져온다.")
    void getMember() {
        Long id = 10003L;

        Member member = memberService.getMember(id);

        assertThat(member.getId()).isNotNull();
    }
}

위 테스트에서 memberService.getMember(id); 를 호출하면 예외가 발생하면서 실패하길 기대하지만 실제로 이 테스트는 성공한다. @Transactional(readOnly=true) 로 설정했지만 쓰기에 성공한 모습이다.

MySQL

MySQL 을 설정하고 위 테스트를 동일하게 실행하면 이런 문구와 함께 이 테스트는 실패한다.

could not execute statement
....
Connection is read-only. Queries leading to data modification are not allowed

테스트 DB 로 MySQL 를 사용하면 읽기 트랜잭션에서 쓰기 트랜잭션을 호출하는 경우 쿼리를 컴파일 하는 과정인 PrepareStatement 에서 예외를 던진다. MySQL 를 테스트 DB 로 사용했다면 테스트 단계에서 위 문제를 발견하고 조치할 수 있었을 것이다

MariaDB

H2 와 동일하게 위 테스트를 통과할 것으로 예상했고 실제로 통과하였다. 쿼리를 executeUpdate() 과정에서 readOnly 를 체크하고 예외를 던지는 Driver 가 있는 반면 그렇지 않은 Driver 도 있었기에 이런 차이가 발생

MySQL 의 ClientPreparedStatement 에서는 readOnly 를 체크하고 예외를 던지는 모습이다.

 

반면 MariaDB 의 ClientSidePreparedStatement 에서는 그런 과정을 찾아볼 수 없다.

위 내용을 정리해본 결과,

DB 마다 readOnly 동작이 다를 수 있다는 것이다. 개발자가 아무래도 사람인지라, 쓰기 관련 코드에 나도 모르게 @Transactional(readOnly = true) 를 선언할 수 있다. 하지만 이때, mySQL의 경우 예외가 발생하기 때문에 개발자가 잘못 작성한 것에 대해 인지할 수 있지만, h2나 mariaDB의 경우는 예외 발생없이 정상동작하기 때문에 이를 명확히 인지하고 h2나 mariaDB를 사용해야함을 알게 되었다.

'spring' 카테고리의 다른 글

정적팩토리 메소드 vs 빌더 패턴  (0) 2025.05.30