반응형
@Transactional(readOnly = true)를 붙였더니 분명히 바꾼 값이 그대로 사라졌다 - 읽기 전용 트랜잭션과 Dirty Checking

조회 전용 메서드에는 @Transactional(readOnly = true)를 붙이라는 이야기를 많이 듣는다. 그런데 막상 이유를 물으면 "성능에 좋다더라" 정도로만 알고 넘어가는 경우가 많다. 나도 그랬다. 그래서 조회한 엔티티의 값을 실수로 바꿔놓고 저장까지 시켜버리는 버그가 있는 메서드에 readOnly = true를 붙이면 어떻게 되는지 직접 실험해봤다.

결과부터 말하면, readOnly = true는 단순히 "이 메서드는 읽기만 한다"는 의미의 주석이 아니었다. 트랜잭션 안에서 조회한 엔티티의 필드를 바꿔도 커밋 시점에 저장이 안 됐고 save()를 명시적으로 다시 호출해도 마찬가지로 반영이 안 됐다. 반대로 새 엔티티를 저장하는 건 readOnly = true여도 정상으로 처리됐다. 여기서는 이 세 가지 경우를 실제 코드로 만들어서 데이터베이스 행이 어떻게 되는지 직접 확인하고 왜 이런 차이가 생기는지 Dirty Checking 동작으로 풀어보겠다.

준비물

항목버전비고
JDK21 LTS
Spring Boot3.3.4Spring Web + Spring Data JPA
MariaDB10.11Ubuntu 24.04 apt 기본 패키지 버전
MariaDB Connector/J3.4.1JDBC 드라이버
빌드 도구Mavenmvn spring-boot:run으로 실행

MariaDB가 없다면 먼저 띄우자.

sudo apt-get update
sudo apt-get install -y mariadb-server

# systemd가 없는 컨테이너 환경이면 데몬을 직접 띄운다
sudo mkdir -p /var/run/mysqld && sudo chown mysql:mysql /var/run/mysqld
sudo -u mysql /usr/sbin/mariadbd --user=mysql &

# systemd가 있는 일반 환경이면 이걸로 충분하다
# sudo systemctl start mariadb

전용 데이터베이스와 계정을 만든다.

mysql -u root <<'EOF'
CREATE DATABASE IF NOT EXISTS rodemo CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS 'rouser'@'localhost' IDENTIFIED BY 'ropass1234';
GRANT ALL PRIVILEGES ON rodemo.* TO 'rouser'@'localhost';
FLUSH PRIVILEGES;
EOF

pom.xml에는 Spring Web, Spring Data JPA, MariaDB 드라이버만 있으면 된다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.mariadb.jdbc</groupId>
    <artifactId>mariadb-java-client</artifactId>
    <version>3.4.1</version>
</dependency>

src/main/resources/application.yml은 이렇게 잡았다. Hibernate가 실제로 어떤 SQL을 날리는지(UPDATE가 생기는지 안 생기는지) 눈으로 확인하려고 show-sql을 켰다.

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/rodemo
    username: rouser
    password: ropass1234
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    open-in-view: false
logging:
  level:
    org.hibernate.SQL: DEBUG

따라 하기

Step 1. 실험용 엔티티와 리포지토리 만들기

이름 하나만 가진 최소한의 회원 엔티티를 만든다.

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    protected Member() {}
    public Member(String name) { this.name = name; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

public interface MemberRepository extends JpaRepository<Member, Long> {
}

애플리케이션이 뜰 때 회원 한 명을 미리 넣어둔다.

@Bean
CommandLineRunner init(MemberRepository memberRepository) {
    return args -> memberRepository.save(new Member("홍길동"));
}

Step 2. 네 가지 시나리오를 서비스 메서드로 만들기

실험 방법은 단순하다. 회원을 조회한 다음 이름 필드만 바꾸고 save()를 부르지 않은 채로 메서드를 끝낸다. 기본 트랜잭션이라면 Dirty Checking이 작동해서 커밋 시점에 자동으로 UPDATE가 나가야 정상이다. 이걸 readOnly = true에서도 그대로 재현해서 차이를 비교한다.

TransactionSynchronizationManager.isCurrentTransactionReadOnly()로 지금 트랜잭션이 실제로 읽기 전용으로 열렸는지도 같이 찍는다.

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    // A: 기본 트랜잭션(readOnly=false), save() 호출 없음
    @Transactional
    public String renameDefault(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        return logAndReturn("default", member);
    }

    // B: readOnly=true, save() 호출 없음
    @Transactional(readOnly = true)
    public String renameReadOnly(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        return logAndReturn("readOnly", member);
    }

    // C: readOnly=true, save()를 명시적으로 호출
    @Transactional(readOnly = true)
    public String renameReadOnlyForcedSave(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        memberRepository.save(member);
        return logAndReturn("readOnly+save()", member);
    }

    // D: readOnly=true 안에서 신규 엔티티 저장
    @Transactional(readOnly = true)
    public String createInReadOnly(String name) {
        Member saved = memberRepository.save(new Member(name));
        return logAndReturn("readOnly+신규save()", saved);
    }

    private String logAndReturn(String tag, Member member) {
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        String msg = "[" + tag + "] isCurrentTransactionReadOnly=" + readOnly
                + ", 서비스 메서드 안에서 본 name=" + member.getName();
        System.out.println(msg);
        return msg;
    }
}

각 메서드를 호출할 컨트롤러도 만든다.

@RestController
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PutMapping("/members/{id}/rename-default")
    public String renameDefault(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameDefault(id, newName);
    }

    @PutMapping("/members/{id}/rename-readonly")
    public String renameReadOnly(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameReadOnly(id, newName);
    }

    @PutMapping("/members/{id}/rename-readonly-forced")
    public String renameReadOnlyForcedSave(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameReadOnlyForcedSave(id, newName);
    }

    @PostMapping("/members/create-in-readonly")
    public String createInReadOnly(@RequestParam String name) {
        return memberService.createInReadOnly(name);
    }
}

Step 3. 각 시나리오를 실제로 호출해서 DB로 확인하기

mvn spring-boot:run으로 띄운 다음, 순서대로 호출하면서 매번 DB를 직접 조회한다. 쿼리 파라미터에 한글을 그대로 붙이면 curl이 인코딩을 안 해줘서 요청 자체가 거절되니 --data-urlencode를 쓰자. (자세한 내용은 아래 「자주 만나는 에러와 해결」 참고.)

# A. 기본 트랜잭션 - save() 없이 필드만 변경
curl -sS -G -X PUT "http://localhost:8080/members/1/rename-default" \
  --data-urlencode "newName=A-바뀐이름"

mysql -u rouser -propass1234 --default-character-set=utf8mb4 rodemo \
  -e "SELECT * FROM member;"
여기까지 하면 이렇게 나와야 함 — member 테이블의 nameA-바뀐이름으로 실제로 바뀌어 있는 것을 확인할 수 있다. save()를 한 번도 안 불렀는데도 자동으로 반영된다.
# B. readOnly=true - save() 없이 필드만 변경
curl -sS -G -X PUT "http://localhost:8080/members/1/rename-readonly" \
  --data-urlencode "newName=B-바뀐이름"

mysql -u rouser -propass1234 --default-character-set=utf8mb4 rodemo \
  -e "SELECT * FROM member;"
여기까지 하면 이렇게 나와야 함 — name이 여전히 A-바뀐이름이다. B-바뀐이름으로 안 바뀐다.
# C. readOnly=true + save()를 명시적으로 호출
curl -sS -G -X PUT "http://localhost:8080/members/1/rename-readonly-forced" \
  --data-urlencode "newName=C-바뀐이름"

mysql -u rouser -propass1234 --default-character-set=utf8mb4 rodemo \
  -e "SELECT * FROM member;"
여기까지 하면 이렇게 나와야 함 — save()를 명시적으로 불렀는데도 name은 여전히 A-바뀐이름 그대로다.
# D. readOnly=true 안에서 신규 엔티티 저장
curl -sS -G -X POST "http://localhost:8080/members/create-in-readonly" \
  --data-urlencode "name=D-신규회원"

mysql -u rouser -propass1234 --default-character-set=utf8mb4 rodemo \
  -e "SELECT * FROM member;"
여기까지 하면 이렇게 나와야 함 — id=2, name=D-신규회원 행이 새로 생긴다. readOnly = true인데도 신규 저장은 막히지 않는다.

전체 코드 (복붙용)

Member.java

package com.example.rodemo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    protected Member() {
    }

    public Member(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

MemberRepository.java

package com.example.rodemo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

MemberService.java

package com.example.rodemo;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public String renameDefault(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        return logAndReturn("default", member);
    }

    @Transactional(readOnly = true)
    public String renameReadOnly(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        return logAndReturn("readOnly", member);
    }

    @Transactional(readOnly = true)
    public String renameReadOnlyForcedSave(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName);
        memberRepository.save(member);
        return logAndReturn("readOnly+save()", member);
    }

    @Transactional(readOnly = true)
    public String createInReadOnly(String name) {
        Member saved = memberRepository.save(new Member(name));
        return logAndReturn("readOnly+신규save()", saved);
    }

    private String logAndReturn(String tag, Member member) {
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        String msg = "[" + tag + "] isCurrentTransactionReadOnly=" + readOnly
                + ", 서비스 메서드 안에서 본 name=" + member.getName();
        System.out.println(msg);
        return msg;
    }
}

MemberController.java

package com.example.rodemo;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PutMapping("/members/{id}/rename-default")
    public String renameDefault(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameDefault(id, newName);
    }

    @PutMapping("/members/{id}/rename-readonly")
    public String renameReadOnly(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameReadOnly(id, newName);
    }

    @PutMapping("/members/{id}/rename-readonly-forced")
    public String renameReadOnlyForcedSave(@PathVariable Long id, @RequestParam String newName) {
        return memberService.renameReadOnlyForcedSave(id, newName);
    }

    @PostMapping("/members/create-in-readonly")
    public String createInReadOnly(@RequestParam String name) {
        return memberService.createInReadOnly(name);
    }
}

RodemoApplication.java

package com.example.rodemo;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class RodemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(RodemoApplication.class, args);
    }

    @Bean
    CommandLineRunner init(MemberRepository memberRepository) {
        return args -> memberRepository.save(new Member("홍길동"));
    }
}

실행 결과 확인

네 시나리오를 실제로 호출한 결과를 표로 정리했다.

시나리오isCurrentTransactionReadOnly동작DB 반영
A. 기본 트랜잭션, save() 없음false필드만 변경반영됨 (자동 UPDATE)
B. readOnly=true, save() 없음true필드만 변경미반영
C. readOnly=true, save() 명시 호출true필드 변경 + save()미반영
D. readOnly=true, 신규 엔티티 save()true새 엔티티 저장반영됨 (INSERT)

A는 Hibernate SQL 로그에 update member set name=? where id=?가 자동으로 찍혔다. save()를 부르지도 않았는데 커밋 시점에 Hibernate가 알아서 변경분을 감지해서 UPDATE를 만들어낸다. 이게 흔히 말하는 Dirty Checking이다.

B와 C는 로그에 UPDATE 자체가 아예 없었다. 눈에 띄는 건 C다. save()를 명시적으로 호출했는데도 UPDATE가 안 나갔다. save()를 깜빡해서 안 되는 게 아니라, 이미 관리되고 있는 엔티티에 save()를 다시 불러봐야 아무 효과가 없다는 뜻이다.

D는 반대로 정상 반영됐다. readOnly = true 안에서도 새 엔티티를 저장하는 INSERT는 그대로 나간다.

[TODO: 본인 환경에서 실행한 터미널 화면(curl 응답, mysql 조회 결과, Hibernate SQL 로그)을 여기에 캡처해서 추가하세요]

왜 이런 차이가 날까

Hibernate는 트랜잭션이 커밋되기 직전에 세션이 들고 있는 관리 대상 엔티티들을 훑으면서 처음 조회했을 때의 스냅샷과 지금 상태를 비교한다. 뭔가 달라졌으면 그 차이만큼 UPDATE 문을 만들어서 날린다. 이게 Dirty Checking이고 A에서 save() 없이도 값이 바뀐 이유다.

Spring이 @Transactional(readOnly = true)로 트랜잭션을 열 때는 Hibernate 세션에 읽기 전용 모드를 지정한다. 이 모드가 켜진 세션에서 조회한 엔티티는 애초에 Dirty Checking 대상에서 빠진다. 스냅샷과 비교하는 절차 자체를 건너뛰니 필드를 아무리 바꿔도 변경 감지가 안 되고 그 상태에서 save()를 다시 불러도 이미 같은 영속성 컨텍스트 안에 있는 같은 객체라 딱히 할 일이 없어서 그대로 끝난다.

반면 D처럼 완전히 새로운 엔티티를 저장하는 건 다른 이야기다. id를 데이터베이스가 자동 생성하는 전략(GenerationType.IDENTITY)을 쓰면, save()를 부르는 즉시 INSERT가 나가야 생성된 id를 알 수 있다. 그래서 이 INSERT는 커밋 시점까지 미뤄지는 게 아니라 호출 즉시 실행되고 읽기 전용 여부와 상관없이 그대로 반영된다.

정리하면 readOnly = true가 막는 건 "이미 불러온 엔티티의 값이 조용히 바뀌는 것"이지, 그 트랜잭션 안에서 아무것도 저장할 수 없게 만드는 게 아니다. 이 차이를 모르고 있으면 "readOnly인데 신규 저장은 왜 되지?" 싶은 순간이 온다.

자주 만나는 에러와 해결

curl로 한글 파라미터를 보냈는데 400 Bad Request가 뜬다

curl -sS -X PUT "http://localhost:8080/members/1/rename-default?newName=A-바뀐이름"

이렇게 쿼리 파라미터에 한글을 그대로 붙이면 서버 로그에 이런 게 찍히면서 요청 자체가 거절된다.

java.lang.IllegalArgumentException: Invalid character found in the request target
[/members/1/rename-default?newName=A-... ].
The valid characters are defined in RFC 7230 and RFC 3986

원인: HTTP 요청 줄(request line)에는 RFC 7230/3986이 허용하는 문자만 들어갈 수 있는데, 한글 같은 비 ASCII 문자를 URL 인코딩 없이 그대로 넣으면 Tomcat이 요청 자체를 파싱하다가 거부한다.

해결: curl은 --data-urlencode를 쓰면 값을 자동으로 퍼센트 인코딩해준다. -G 옵션과 같이 써서 GET 방식으로 인코딩된 쿼리스트링을 만들게 하면 된다. (-X PUT을 같이 줘도 -G가 있으면 인코딩된 쿼리스트링이 URL에 붙고 메서드는 PUT으로 나간다.)

curl -sS -G -X PUT "http://localhost:8080/members/1/rename-default" \
  --data-urlencode "newName=A-바뀐이름"

readOnly=true인데 트랜잭션 안에서 아무것도 못 하는 거 아니냐고 묻는다면

이번 실험의 D에서 확인했듯 그렇지 않다. readOnly = true가 차단하는 건 이미 조회해서 영속성 컨텍스트에 올라온 엔티티의 값 변경이 조용히 저장되는 상황이지, 트랜잭션 자체를 잠그는 게 아니다. 다만 "조회 전용이라고 표시해놓고 실수로 값을 바꾸는 코드가 섞여 들어가도 저장 자체는 막아준다"는 점에서 안전장치 역할은 한다.

DB 조회 결과에 한글이 물음표로 나온다

mysql 클라이언트를 기본 옵션으로 실행하면 터미널 인코딩과 안 맞아서 한글이 깨져 보일 수 있다. --default-character-set=utf8mb4 옵션을 붙이면 정상으로 나온다.

mysql -u rouser -propass1234 --default-character-set=utf8mb4 rodemo -e "SELECT * FROM member;"

마무리

@Transactional(readOnly = true)를 그냥 "읽기 전용이라는 표시" 정도로만 알고 있었는데, 실제로는 Dirty Checking 자체를 꺼버려서 조회한 엔티티의 값 변경이 save()를 명시적으로 다시 불러도 저장되지 않는다는 걸 직접 확인했다. 반대로 새 엔티티 저장은 readOnly = true여도 그대로 동작한다는 것도 같이 확인했다.

조회 메서드에 실수로 값 변경 코드가 섞여 들어가도 readOnly = true가 안전판 역할을 해준다는 걸 알고 나니, 왜 다들 조회 메서드에는 습관처럼 이걸 붙이라고 하는지 이제야 이해가 됐다. 동시에 "이 트랜잭션은 아무것도 못 바꾼다"는 뜻은 아니라는 것도 헷갈리지 않게 됐다.

다음에는 트랜잭션 전파 속성 중 REQUIRES_NEW가 아닌 기본값 REQUIRED가 중첩 호출에서 실제로 어떻게 동작하는지, 그리고 체크 예외(Exception)를 던졌을 때는 왜 롤백이 안 되는지를 직접 재현해볼 생각이다.

반응형