개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › Spring

@Transactional

@Transactional은 무엇을 묶어 주는가

@Transactional은 메서드 위에 적는 표시(애너테이션)입니다. 이 실습의 order() 메서드는 jdbc.update로 DB에 저장을 두 번 합니다(orders 테이블에 주문, payment 테이블에 결제). 두 저장 사이에는 stock 확인이 있고, stock이 0이면 예외를 던집니다. 아무 표시가 없으면 DB는 저장을 받는 대로 반영하므로, 예외가 나도 앞의 저장은 그대로 남습니다. order() 위에 @Transactional을 적으면 스프링이 그 메서드 안의 저장을 한 묶음(트랜잭션)으로 다루어, 메서드가 정상으로 끝나면 모두 반영(커밋)하고 런타임 예외로 끝나면 모두 취소(롤백)합니다. 묶음을 시작하고 반영하거나 취소하는 코드는 우리가 쓰지 않고, 스프링이 order()를 부르는 앞뒤에서 합니다. 이 실습은 @Transactional을 지우거나 stock에 담는 값을 바꿔 가며 같은 order()를 부르고, 호출이 끝난 뒤 두 테이블에 남은 행을 봅니다.

실행으로 대조 · Spring 7.0.9와 H2 2.4.240에서 네 가지 조합을 실제 @Transactional 프록시로 실행해 행 수와 단계 문장의 근거를 대조

이 실습에 나오는 개념

@Transactional
메서드나 클래스에 붙여 그 범위를 하나의 트랜잭션으로 실행하게 하는 애너테이션이다. 프록시가 메서드 실행 전에 트랜잭션을 시작하고, 정상적으로 끝나면 커밋, 예외가 발생하면 규칙에 따라 롤백한다. 트랜잭션을 시작하고 끝내는 코드를 직접 쓰지 않아도 되어 선언적 트랜잭션이라고 한다.
트랜잭션
여러 데이터 변경 작업을 하나의 단위로 묶어, 전부 반영되거나(커밋) 전부 취소되게(롤백) 하는 것이다. 계좌 이체에서 출금과 입금 중 하나만 반영되는 중간 상태를 막는다. 데이터베이스가 정합성을 지키는 가장 기본적인 수단이다.
커밋과 롤백
커밋(commit)은 트랜잭션 안에서 한 데이터 변경을 모두 DB에 확정해 반영하는 것이고, 롤백(rollback)은 그 변경을 모두 취소해 트랜잭션을 시작하기 전 상태로 되돌리는 것이다. 트랜잭션은 끝날 때 둘 중 하나로 끝나며, 이미 커밋한 변경은 롤백으로 되돌릴 수 없다. 스프링에서 @Transactional이 붙은 메서드는 정상으로 끝나면 스프링이 커밋하고, 예외로 끝나면 롤백 규칙을 보고 롤백한다. 기본 규칙에서는 RuntimeException과 Error가 메서드 밖으로 던져지면 롤백하고 체크 예외는 롤백하지 않는다. 트랜잭션이 없으면 JDBC 연결의 기본 설정인 자동 커밋에 따라 저장 문장마다 곧바로 커밋되므로, 뒤에서 예외가 나도 앞의 저장은 롤백할 수 없다.
프록시(Proxy)
실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 프로그램의 main이 스프링이 만들어 둔 OrderService 객체의 order() 메서드를 부릅니다. 이 객체는 우리가 쓴 클래스 그대로가 아니라 스프링이 만든 다른 클래스의 객체(프록시)라서, 호출은 order()의 첫 줄에 닿기 전에 스프링의 코드를 먼저 지납니다. 두 테이블 orders와 payment에는 아직 행이 없습니다.
  2. 스프링이 order() 메서드에 들어가기 전에 묶음(트랜잭션)을 시작합니다. order() 위에 @Transactional이 있기 때문입니다. 이제 order() 안에서 하는 저장은 한 묶음에 속하고, 묶음이 끝나기 전에는 DB에 확정되지 않습니다. 묶음을 시작하는 줄은 우리가 쓴 코드에 없습니다.
  3. jdbc.update로 orders 테이블에 주문 한 행(1, 키보드)을 저장합니다. 이 저장은 묶음 안에 있어서 아직 확정되지 않았습니다. 같은 DB에 따로 연결해 orders를 세어 보면 0행입니다.
  4. int stock = 0이고 다음 줄의 if (stock == 0) 조건이 맞으므로, IllegalStateException("재고 없음")이 던져집니다. order() 메서드는 결제 저장 줄을 실행하지 않은 채 예외로 끝납니다.
  5. 스프링이 order()가 예외로 끝난 것을 보고 묶음 전체를 취소(롤백)합니다. 앞에서 저장한 주문 행도 없던 일이 되어 orders에서 사라집니다. IllegalStateException은 RuntimeException이라서 스프링의 기본 규칙대로 취소했습니다. 취소하는 줄도 우리가 쓴 코드에 없고, 예외는 그대로 order()를 부른 main까지 전달됩니다.
  6. main이 order()가 끝난 뒤 두 테이블을 조회합니다. orders는 0행, payment는 0행입니다. 주문도 결제도 남지 않았습니다.

실행 결과

코드

@Service
public class OrderService {
    private final JdbcTemplate jdbc;

    public OrderService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    // 묶음: 이 메서드 안의 저장은 모두 반영되거나 모두 취소된다
    @Transactional
    public void order() {
        jdbc.update("INSERT INTO orders VALUES (1, '키보드')");
        int stock = 0;
        if (stock == 0) {
            throw new IllegalStateException("재고 없음");
        }
        jdbc.update("INSERT INTO payment VALUES (1, 30000)");
    }
}

이 모형의 전제

Spring Framework 7.0.9를 JDK 21에서, H2 2.4.240(메모리 DB)에 JDBC로 연결해 실행해 대조했고 MySQL에서의 실행 대조는 하지 않았습니다. 네 가지 조합을 모두, 화면의 OrderService와 같은 소스(package와 import 줄, 그리고 끝에 PROBE 표시를 붙인 측정 줄 두 개만 더함)를 컴파일하고 자바 프로세스를 조합마다 새로 띄워 확인했습니다. 설정 클래스에는 AnnotationConfigApplicationContext가 읽는 @EnableTransactionManagement, H2의 DataSource(HikariCP), JdbcTemplate, DataSourceTransactionManager(시작, 반영, 취소 횟수를 세는 줄만 더한 하위 클래스)를 두었고, OrderService는 @Service로 스프링이 컴포넌트 스캔으로 찾게 했습니다. 자동 커밋은 JDBC 기본값(켜짐) 그대로이고 조합마다 DB와 두 테이블(orders, payment)을 새로 만들었습니다. 대조한 것은 order()가 끝난 방식(IllegalStateException과 그 메시지, 또는 정상)과, 호출이 끝난 뒤 별도의 조회로 센 orders와 payment의 행 수입니다. 단계 문장이 기대는 사실로 order() 안에서 트랜잭션이 활성인지(@Transactional이 있을 때만 활성), main이 받은 OrderService 빈의 클래스(@Transactional이 있으면 스프링이 만든 CGLIB 프록시 클래스, 없으면 우리가 쓴 클래스 그대로), 첫 저장 직후 따로 연결한 DB 연결에서 센 orders의 행 수(@Transactional이 있으면 0행, 없으면 1행), 트랜잭션 매니저가 묶음을 시작하고 반영하고 취소한 횟수(예외로 끝나면 시작 1번과 취소 1번, 정상으로 끝나면 시작 1번과 반영 1번, @Transactional이 없으면 모두 0번)도 실행으로 확인했습니다. 둘째 저장이 묶음 안에서 확정되지 않는다는 것과 없을 때 곧바로 반영된다는 것은 첫 저장으로 잰 값과 최종 행 수에서 이끌어 낸 것이고, 둘째 저장 직후를 따로 재지는 않았습니다. 문서가 정한 것은 스프링 프레임워크 7.0 문서 Data Access의 Declarative Transaction Management 절(선언적 트랜잭션이 AOP로 이루어진다는 것), Understanding the Spring Framework's Declarative Transaction Implementation 절(TransactionInterceptor와 트랜잭션 매니저가 메서드 호출 둘레에서 트랜잭션을 이끈다는 것), Using @Transactional 절(@EnableTransactionManagement로 켜야 하고 기본은 프록시 방식이라는 것, 기본으로 RuntimeException과 Error는 롤백하고 체크 예외는 롤백하지 않는다는 것), Rolling Back a Declarative Transaction 절(기본 설정에서 런타임 예외에만 롤백 표시를 한다는 것)입니다. 다만 메서드 앞에서 시작하고 정상이면 반영하며 예외면 취소하는 순서를 문서가 한 문단으로 풀어 쓰고 있지는 않아서, 그 순서는 Probe가 센 횟수와 행 수로 확인한 관찰입니다. 화면의 코드는 package와 import 줄을 줄였고, order()를 부르는 main, 테이블을 만드는 CREATE TABLE, DB 연결과 스프링 설정은 보이지 않습니다. 이 실습은 RuntimeException이 던져지는 한 가지 예외만 다루며, 체크 예외, 같은 클래스 안에서 부르는 경우, readOnly는 다루지 않습니다. 체크 예외와 rollbackFor, 전파 방식은 "트랜잭션 전파" 실습에서, 트랜잭션 자체(격리 수준 포함)는 "트랜잭션 기본" 실습에서 다룹니다.

앞 실습: JDBC · 다음 실습: 스프링 부트

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요