개발/Spring

Spring에서의 트랜잭션 개념과 적용 사례

IamBD 2025. 3. 23. 23:31

1. 트랜잭션의 개념과 특징

트랜잭션이란?

트랜잭션이란 더 이상 쪼갤 수 없는 하나의 작업 단위를 뜻합니다. 여러 데이터 변경 작업을 하나로 묶어 원자적으로 처리하여, 모든 작업이 성공하면 커밋, 한 작업이라도 실패하면 롤백되어 이전 상태로 복구됩니다.

 

일상 생활에서는 은행 계좌에서 현금을 인출할 때 현금이 계좌에서 인출되거나, 인출되지 않을 뿐이지 그 중간의 상태는 없는 것과 같습니다.

 

트랜잭션은 이러한 속성을 ACID 속성인 4가지로 분류하고 있습니다.

  • Atomicity(원자성) : 모든 작업이 완료되거나, 하나라도 실패하면 전체 작업이 취소
  • Consistency(일관성) : 트랜잭션이 끝난 후에도 데이터베이스는 항상 일관된 상태를 유지
  • Isolation(고립성) : 동시에 여러 트랜잭션이 실행되더라도 각각은 독립적으로 동작
  • Durability(지속성) : 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 반영

 

Spring에서의 트랜잭션

Spring에서는 데이터베이스 트랜잭션의 공통 패턴을 추상화하여 개발자가 일일이 Connection을 얻고 Commit이나 Rollback하는 번거로움 없이 일관되게 트랜잭션을 처리할 수 있도록 도와주고 있습니다.

 

개발에 사용할 때는 선언적 트랜잭션 관리와 명시적 트랜잭션 관리 두 가지의 방법을 사용할 수 있습니다.

 

선언적 트랜잭션 관리는 트랜잭션 경계를 코드로 직접 지정하지 않고, 어노테이션이나 XML 설정으로 선언하여 스프링이 알아서 처리하게 하는 방식입니다. 대표적으로 @Transactional 어노테이션을 사용하며, AOP를 통해 비즈니스 로직 앞뒤에 트랜잭션 시작/종료를 자동으로 수행합니다.

 

선언적 방식은 AOP를 활용하여 트랜잭션 처리 코드를 비즈니스 로직에서 분리하기 때문에 코드가 간결하고 이해하기가 쉽기 때문에 대부분의 상황에서는 선언적 방식을 사용합니다.

 

명시적 트랜잭션 관리는 개발자가 직접 트랜잭션 시작과 종료를 코드로 제어하는 방식입니다.

PlatformTransactionManager를 주입받아 **getTransaction(), commit(), rollback()**을 호출하거나, 편의 클래스인 TransactionTemplate을 사용합니다. 아래 예시는 PlatformTransactionManager를 이용한 명시적 트랜잭션 처리 코드입니다.

@Autowired
private PlatformTransactionManager txManager;

public void addUsers(List<User> users) {
    // 트랜잭션 시작
    TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
    try {
        for (User u : users) {
            userRepository.save(u);
        }
        txManager.commit(status);   // 성공 시 커밋
    } catch (Exception e) {
        txManager.rollback(status); // 실패 시 롤백
        throw e;
    }
}

 

TransactionTemplate을 이용한다면 람다 콜백을 통해 더욱 편하게 트랜잭션을 처리할 수도 있습니다.

// 주입 받은 PlatformTransactionManager로 TransactionTemplate 생성
TransactionTemplate txTemplate = new TransactionTemplate(txManager);
txTemplate.execute(status -> {
    // 이 블록 안의 코드가 트랜잭션으로 실행됨
    userRepository.save(newUser);
    userRepository.delete(oldUser);
    return null; // 트랜잭션 정상 종료 -> 자동 commit
});

 

TransactionTemplate은 내부에서 commit/rollback을 처리하며, 예외 발생시 자동 롤백됩니다.

명시적 방식은 선언적 방식에 비해 유연하지만 코드가 많아지고 휴먼 에러가 발생할 수 있기 때문에 일반적으로는 트랜잭션 경계를 동적으로 수행해야 하는 특수한 경우에만 사용합니다.

 

@Transactional 어노테이션은 어떻게 동작할까요?

Spring에서 @Transactional 어노테이션을 사용하면, 해당 클래스나 메서드가 트랜잭션 관리 대상이 됩니다. 즉, Spring은 해당 메서드가 실행될 때 트랜잭션을 시작하고, 메서드 실행이 끝난 후에는 트랜잭션을 커밋 또는 롤백하도록 처리합니다.

이를 위해 Spring은 내부적으로 "프록시 객체"를 생성하여 원본 객체를 감싸는데, 이 프록시가 클라이언트가 메서드를 호출할 때 트랜잭션을 관리하는 역할을 합니다.

  • JDK 동적 프록시
    • 인터페이스를 구현한 클래스의 경우, **java.lang.reflect.Proxy**를 사용하여 프록시 객체를 생성합니다.
    • 즉, 프록시는 원본 클래스가 구현한 인터페이스를 대신 구현하는 방식으로 동작합니다.
  • CGLIB 프록시
    • 만약 클래스에 인터페이스가 없을 경우, CGLIB(Code Generation Library)을 사용하여 원본 클래스의 서브클래스를 만들어 프록시 객체로 활용합니다.
    • 원본 객체를 직접 상속받아 새로운 객체를 생성하는 방식입니다.

프록시가 트랜잭션을 관리하는 원리는 스프링 AOP의 TransactionInterceptor를 통해 구현됩니다.

@Transactional 어노테이션의 메타데이터(전파, 격리수준, readOnly 등)는 TransactionAttrbuteSource에 의해 읽혀지고, 프록시가 메서드를 호출할 때 이 속성에 따라 TransactionManager에 트랜잭션 시작을 요청합니다. 그 후 대상 메서드 실행 결과에 따라 commit 또는 rollback을 호출합니다. 내부적으로는 다음과 같은 흐름으로 동작합니다.

  1. 프록시가 대상 메서드 호출을 가로챔 -(invoke() 단계에서 트랜잭션 시작)
  2. PlatformTransactionManager의 구현체가 트랜잭션을 시작하고 Connection을 확보
  3. 대상 메서드 실행
  4. 정상 완료 시 commit(), 예외 발생 시 **rollback()**을 호출하여 트랜잭션 종료.

스프링은 이렇게 트랜잭션 처리를 투명하게 해주므로, 개발자는 비즈니스 로직에 집중할 수 있습니다.

 

단, @Transactional이 프록시 기반으로 작동하기 때문에 자기 자신 내부에서 메서드 호출 시에는 프록시를 거치지 않아 트랜잭션이 적용되지 않는 한계가 있습니다.

 

예를 들어, 동일 클래스 내의 A메서드가 B메서드(@Transactionl 어노테이션이 붙은)를 호출하면 프록시가 개입하지 않아 트랜잭션이 시작되지 않습니다. 이런 경우는 설계를 분리하여 서로 다른 빈을 통해 호출되도록 해야합니다.

 

❌ 트랜잭션이 적용되지 않음

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    public void placeOrder() {
        processPayment(); 
    }

    @Transactional
    public void processPayment() {
        orderRepository.save(new Order("New Order"));
    }
}

위 코드를 보면 placeOrder 내부에서 processPayment를 호출하고 있는데 이 호출은 같은 클래스 내에서 이루어고 있으니 프록시를 거치지 않게 되고 트랜잭션이 적용되지 않습니다.

 

✅ 로직 분리

@Service
public class PaymentService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void processPayment() {
        orderRepository.save(new Order("New Order"));
    }
}

@Service
public class OrderService {

    @Autowired
    private PaymentService paymentService;

    public void placeOrder() {
        paymentService.processPayment(); 
    }
}

processPayment를 PaymentService라는 다른 클래스로 분리하면 서로 다른 빈을 통해 호출되므로 Spring 프록시를 거쳐 트랜잭션이 정상 동작합니다.

 

트랜잭션 전파 및 격리 수준

전파 속성이란 메서드가 트랜잭션 경계에 들어갈 때 기존에 진행 중인 트랜잭션이 있을 경우 어떻게 처리할지 결정하는 옵션이며 Spring은 이를 7가지 전파 속성으로 지원하고 있습니다.

  • REQUIRED: 기본 값으로 이미 시작된 트랜잭션이 있다면 참여하고, 없으면 새 트랜잭션을 시작합니다. 기본 값인 만큼 대부분의 경우 사용되는 적절한 옵션입니다.
  • REQUIRES_NEW: 항상 새로운 트랜잭션을 시작합니다. 만약 진행 중인 트랜잭션이 있다면 일시 중단 후 별개로 실행합니다. 내부 트랜잭션이 독립적으로 커밋/롤백되며, 바깥 트랜잭션에 영향을 주지 않습니다. (동시에 두 DB 커넥션을 이용중이라면 자원 소모가 늘어남).
  • SUPPORTS: 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행합니다.
  • MANDATORY: 반드시 기존 트랜잭션이 있어야 하며, 없으면 예외를 발생시킵니다.
  • NOT_SUPPORTED: 현재 트랜잭션이 있다면 일시 정지시키고 트랜잭선 없이 비트랜잭션 영역에서 실행합니다.
  • NEVER: 트랜잭션 없이 실행해야 하며, 이미 트랜잭션이 진행 중이면 예외를 발생시킵니다.
  • NESTED: 이미 트랜잭션이 있다면 중첩 트랜잭션(저장점 기반)을 시작합니다. 내부 트랜잭션 롤백시 외부 트랜잭션은 롤백되지 않고 Savepoint까지만 롤백할 수 있습니다. (DB가 SavePoint 지원해야 함)
더보기
더보기

저장점이란?

중첩 트랜잭션의 개념으로 외부 트랜잭션이 있고 내부 트랜잭션이 있는 구조에서 에러 발생시 내부 트랜잭션만 롤백하는 구조

 

SQL 예시

BEGIN TRANSACTION;

INSERT INTO orders VALUES (1, '주문1');

-- 저장점을 생성
SAVEPOINT order_savepoint;

INSERT INTO order_items VALUES (1, '상품1');
INSERT INTO order_items VALUES (1, '상품2');

-- 여기서 에러가 발생했다고 가정
-- 이제 order_savepoint까지 롤백
ROLLBACK TO SAVEPOINT order_savepoint;

-- 트랜잭션을 정상적으로 완료합니다.
COMMIT;

 

Spring 예시

@Transactional(propagation = Propagation.REQUIRED)
public void outerTransaction() {
    service.innerTransaction();
}

@Transactional(propagation = Propagation.NESTED)
public void innerTransaction() {
    // 중첩 트랜잭션 처리
}

 

격리 수준이란 동시에 실행되는 트랜잭션 간에 데이터 격리 정도를 설정하는 옵션입니다. 격리 수준이 높을수록 일관성은 높아지지만 성능은 떨어지는 특징을 갖고 있습니다.

 

JDBC에서 표준 격리 수준은 다음과 같습니다.

  • READ_UNCOMMITED: 커밋되지 않은 데이터를 다른 트랜잭션이 읽을 수 있음 / 가장 낮은 격리 / Dirty Read 허용
  • READ_COMMITED: 대부분 DBMS의 기본 값으로 다른 트랜잭션이 커밋한 데이터만 읽을 수 있음 / Non-repetable Read 허용
  • REPEATABLE_READ: 한 트랜잭션이 읽은 데이터가 그 트랜잭션 내에서 변경되지 않도록 보장 / Phantom Read 허용
  • SERIALIZABLE: 트랜잭션을 직렬화하여 실행하여 동시 실행 방지 / 가장 높은 격리 수준
  • Dirty Read
    1. 트랜잭션 A → 사용자 **balance = 100**에서 **balance = 200**으로 변경 (아직 커밋하지 않음).
    2. 트랜잭션 B → balance 값을 조회했을 때 **200**이 반환됨 (Dirty Read 발생).
    3. 트랜잭션 A가 롤백하면, 트랜잭션 B가 읽은 **200**은 실제 존재하지 않는 데이터가 됨.
  • 커밋되지 않은 다른 트랜잭션의 데이터 조회
  • Non-repetable Read
    1. 트랜잭션 A → SELECT balance FROM account WHERE id = 1 (balance = 100).
    2. 트랜잭션 B → **balance = 200**으로 업데이트하고 커밋.
    3. 트랜잭션 A → 다시 SELECT balance FROM account WHERE id = 1 (balance = 200 → 다른 값이 조회됨!).
  • 동일 트랜잭션 내에서 동일 데이터 조회시 값이 다름
  • Phantom Read
    1. 트랜잭션 A → SELECT * FROM users WHERE age > 30 (10명 조회됨).
    2. 트랜잭션 B → 새로운 사용자 추가 (age = 35) 후 커밋.
    3. 트랜잭션 A → 다시 SELECT * FROM users WHERE age > 30 (이전보다 더 많은 데이터가 조회됨).
  • 동일 트랜잭션 내에서 동일 조건 조회시 없던 데이터 조회

Spring의 기본 격리 설정값인 Isolation.DEFAULT는 데이터소스나 DB의 기본 격리 수준을 따른다는 의미로 보통 대부분 DBMS의 기본인 READ_COMMITTED를 사용하며 특별한 경우가 아니라면 기본 설정을 사용하고, 필요한 경우 **@Transactional(isolation = Isolation.SERIALIZABLE)**과 같이 설정하여 사용할 수도 있습니다.

 

이외에 timeout이나 readOnly 같은 세부 옵션도 지정할 수 있으며 이 역시 일반적으로는 기본 값을 사용하고 필요한 경우에만 명시적으로 설정합니다.

  • Spring 트랜잭션 관련 기본 값
    • 전파: 기본 Propagation.REQUIRED.
    • 격리수준: 기본 Isolation.DEFAULT (DB 드라이버의 기본 격리 수준 따름 – 대개 READ_COMMITTED).
    • timeout: 기본값 없음 (시스템 기본 제한 시간 혹은 무제한).
    • readOnly: 기본 false.
    • rollbackOn: RuntimeException 및 Error 에 대해 롤백.

 

트랜잭션 롤백 및 예외 처리 방법

Spring에서 선언적 트랜잭션을 사용시에는 언체크 예외 또는 Error가 발생할 경우에 롤백하고, 체크 예외나 예외 없이 동작시 커밋하는게 기본 동작입니다.

 

체크 예외 또한 예외이긴 하지만 일반적으로 비즈니스 로직에서 예상 가능한 상황으로 무조건 롤백시 오히려 문제가 발생할 수 있기 때문에 롤백하지 않습니다. 물론 이는 rollbackFor 옵션을 통해 롤백 규칙을 커스터마이징 할 수 있습니다.

// 체크 예외지만 롤백 되도록 설정
@Transactional(rollbackFor = CustomCheckedException.class)

// 언체크 예외지만 롤백되지 않도록 설정
@Transactional(noRollbackFor = IllegalArgumentException.class)

예외 처리의 경우 catch로 예외를 잡으면 Spring은 이를 알 수 없으므로 트랜잭션을 정상 commit 합니다. 만약 특정 상황에서 롤백을 해야 한다면 TransactionStats를 통해 수동 롤백해야 합니다.

 

// 정상 Commit Case
@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void placeOrder() {
        try {
            orderRepository.save(new Order("New Order"));

            if (true) {
                throw new RuntimeException("주문 생성 중 오류 발생");
            }

        } catch (Exception e) {
            System.out.println("예외를 잡았으므로 롤백되지 않음: " + e.getMessage());
        }
    }
}

// 수동 Rollback Case
@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @Transactional
    public void placeOrder() {
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            orderRepository.save(new Order("New Order"));

            if (true) {
                throw new RuntimeException("주문 생성 중 오류 발생!");
            }

        } catch (Exception e) {
            System.out.println("예외 발생, 롤백 설정: " + e.getMessage());
            status.setRollbackOnly(); // 수동 롤백 설정
        }

        transactionManager.commit(status); // 트랜잭션 종료
    }
}

2. 트랜잭션을 적용해 본 경험과 사례

작년 항해 플러스 백엔드에서 E-Commerce 프로젝트 진행간 주문 처리 로직에서 트랜잭션을 적용했었습니다. 처음에는 주문 시 잔액 차감, 주문 생성 등 보상 트랜잭션 없이 각 서비스에서 별도의 트랜잭션으로 처리했었습니다.

 

예를 들어 아래 코드처럼 각 로직 모두 각각 독립된 트랜잭션으로 구현했었습니다.

@Transactional
public void checkout(Long userId, OrderRequest request) {
    // Service는 각각 독립된 트랜잭션이 설정되어 있음
    userService.useAmount(userId, request.getTotalAmount());
    orderService.createOrder(userId, request);
    request.getProducts().forEach(product -> {
        productService.reduceStock(productId, quantity);
    });
    cartService.clearCart(userId);
}

이 방식은 잘 모르기도 했고 개발 초기에는 편리했으나 테스트나 실제 사용시 재고 부족이나 잔액 부족 등과 같이 예외 발생시 이전에 처리된 데이터가 롤백되지 않아 데이터 일관성 문제가 발생했었습니다.

 

이를 고민하던 중 트랜잭션 범위가 조금 커지긴 했으나 이를 하나의 트랜잭션으로 묶어 수정했습니다.

@Transactional
public void checkout(Long userId, OrderRequest request) {
    // 서비스 내 트랜잭션 설정 제거
    userService.useAmount(userId, request.getTotalAmount());
    Order order = orderService.createOrder(userId, request);
    orderService.createOrderItems(order.getId(), request.getItems());

    request.getItems().forEach(item -> {
        productService.reduceStock(item.getProductId(), item.getQuantity());
    });

    cartService.clearCart(userId);
}

3. 시니어 피드백

연관관계 미사용

요즘엔 연관 관계를 맺지 않고 DB처럼 각 FK만 들고 필요시 조회해서 사용한다! 라고 어디서 듣고 엔티티 구현시 모두 위 방식으로 구현했었습니다.

 

하지만 현재 코드를 보면 주문을 생성하고 해당 주문의 ID로 다시 주문 아이템을 생성하는 어떻게 보면 항상 한 쌍의 작업을 수행하고 있는데 이런 관계의 경우 차라리 Cascade 옵션을 활용하여 주문시 주문 아이템까지 함께 자동으로 영속되도록 개선하면 ID 관리의 복잡성이나 일관성을 유지하는데 더 도움이 될 수도 있어요! 라는 피드백을 받아 아래와 같이 엔티티를 수정하여 진행했습니다.

public class Order extends AbstractEntity {
	...
	@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
	private List<OrderItem> orderItems = new ArrayList<>();
}

public class OrderItem extends AbstractEntity {
	...
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "orderId")
	private Order order;
}

예외 처리 및 롤백 보장

위에 언급했듯 Spring에서의 트랜잭션은 기본적으로 RuntimeException에 대해서만 롤백됩니다. 따라서 잔액 부족 예외나 재고 부족 예외와 같이 비즈니스적으로 중요한 예외들은 RuntimeException을 상송하여 자동 롤백이 보장되도록 하는 것도 방법이라 들었습니다.

 

따라서 기본적으로 RuntimeException을 상속받은 BaseExcpetion을 구현하고 이 외 예외들은 이 BaseException을 상속하도록 구현했습니다.

@Getter
public class BaseException extends RuntimeException {
	...
}

public class OutOfStockException extends BaseException {

	public OutOfStockException() {
		super(ErrorCode.COMMON_OUT_OF_STOCK);
	}

	public OutOfStockException(String errorMsg) {
		super(errorMsg, ErrorCode.COMMON_OUT_OF_STOCK);
	}
}

이 외 직접 구현하지는 못했으나 다음과 같은 추가적인 피드백 또한 있었습니다.

  • 트랜잭션을 아무리 꼼꼼하게 설계해도 실패 가능성을 완전히 배제할 수는 없습니다. 이럴 경우를 대비해 보상 트랜잭션을 추가로 준비하는 것은 어떨까요?
  • 재고 감소와 같이 동시성 제어시 분산락을 활용하는 것도 좋은 방법입니다. 이 외에 낙관적 락이나 비관적 락과 같이 DB 레벨에서의 잠금 처리 또한 고려해보는 것은 어떨까요?
  • 도메인간의 관계나 작업 단위를 섬세하게 나누면 Facade에서 트랜잭션을 처리하더라도 비교적 작은 크기를 유지할 수 있습니다. 현재의 주문 로직에서 쪼갤 수 있는 부분은 없는지 점검 해보는 것도 좋을 것 같아요!

항해 당시 코치분들에게 귀가 아프도록 들었던 말은 트랜잭션 속성에 대해 공부하고 고민해보세요! 였습니다. 바쁘다는 핑계로 해야지.. 하다가 이 글을 정리하며 얕게나마 공부해보고 간단한 샘플 프로젝트를 통해 실제 구동까지 보는 좋은 시간이었습니다.

 

이 외에도 아직 피드백은 받았으나 고민하거나, 반영하지 못한 개선 사항들이 많이 남아있는데 새롭게 무언가 배우는 것도 좋지만 기존 코드를 발전시켜 나가는 학습 또한 중요하다 느낍니다.

'개발 > Spring' 카테고리의 다른 글

TDD의 개념과 적용 사례  (0) 2025.03.15
이제 와서 고쳐보는 2024 내 코드  (2) 2025.03.09
[Spring] json deserialize ClassCastException  (0) 2022.06.16
InvalidDefinitionException 에러  (0) 2022.06.08
Spring Data JPA - 2  (0) 2022.02.25