[Spring] 트랜잭션(@Transactional) 막 쓰면 성능 저하를 초래할 수 있다?

nana·2025년 3월 22일

Spring

목록 보기
9/9
post-thumbnail

o. 들어가기에 앞서

현재 일하는 곳에서는 프로시저를 이용한 처리를 좀 더 자주 사용한다.
일괄 처리를 할 때 자주 사용하는게 트랜잭션인데, 사실 공부를 하지 않을 때에는 트랜잭션의 중요성을 알지 못했다.

트랜잭션이 주는 이점보다는 트랜잭션으로 인해 다른 처리가 락이 걸려버리는 상황이 자주 있어서 '안쓰느니만 못한 것'이라고 인식하던 때가 있었기 때문이다.

하지만 트랜잭션은 잘못없어!!!!
잘못한건 트랜잭션을 올바르지 않은 위치에 건 나지.😭

모든 기능이 잘 쓰면 약, 못 쓰면 독이다.
Spring에서 제공하는 @Transactional은 다양한 옵션을 제공하고 있고, 옵션에 따라 성능 저하까지 초래할 수 있다고 하는데!!??

지피지기면 백전백승이라고 진짜 제대로 알아보자!


1. 트랜잭션이란?

트랜잭션(Transaction)은 프로그래밍, 데이터베이스, 가산자산 등에서 사용되는 용어로,
연속적인 작업의 묶음을 뜻한다.

Spring에는 @Transactional(선언적 트랜잭션)이라는 어노테이션을 제공하는데,
이 녀석의 장점 중 하나가 여러 트랜잭션을 묶어서 커다란 하나의 트랜잭션 경계를 만들 수 있다는 점이다.

트랜잭션을 시작하는 방법은 하나이지만, 끝나는 방법은 rollback / commit 두 개가 존재한다.
트랜잭션을 시작하고 끝내는 작업을 설정하는 걸 트랜잭션 경계설정 이라고 하고, 트랜잭션 경계 내에서 논리적으로 묶이길 원하는 로직들이 실행되게 된다.

2. 트랜잭션 전파속성(Propagation) 종류

트랜잭션 전파 속성?
👉🏻 트랜잭션이 진행중일 때 추가 트랜잭션을 어떻게 할지 결정하는 것

@Transactional(propagation = Propagation.전파타입)


스프링에서 외부 트랜잭션과 내부 트랜잭션을 묶어준다. => "내부 트랜잭션이 외부 트랜잭션에 참여한다."라고 표현한다.


물리 트랜잭션 : 실제 데이터베이스에 적용되는 트랜잭션
실제 커넥션을 통해 트랜잭션을 시작하고 종료(커밋, 롤백)하는 단위

논리 트랜잭션도 커밋과 롤백을 요청할 수는 있지만 실제 데이터베이스에 적용되지는 않는다.

✨모든 논리 트랜잭션이 커밋돼야 물리 트랜잭션이 커밋된다.
= 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

신규 트랜잭션만이 물리 트랜잭션을 종료(커밋, 롤백)할 수 있다.

public interface TransactionDefinition {

	int PROPAGATION_REQUIRED = 0;

	int PROPAGATION_SUPPORTS = 1;

	int PROPAGATION_MANDATORY = 2;

	int PROPAGATION_REQUIRES_NEW = 3;

	int PROPAGATION_NOT_SUPPORTED = 4;

	int PROPAGATION_NEVER = 5;

	int PROPAGATION_NESTED = 6;
    
    // ...
}

✚ @Transactional 어노테이션을 사용하는 경우 속성 값은 RuleBasedTransactionAttribute라는 객체로 변환되는데, 이는 TransactionDefinition의 서브타입이다.

2-1. REQUIRED (Default)

  • 트랜잭션이 존재하는 경우 해당 트랜잭션을 사용하고, 트랜잭션이 없는 경우 트랜잭션을 생성한다.

2-2. REQUIRED_NEW

  • 트랜잭션이 존재하는 경우 트랜잭션을 잠시 보류시키고, 신규 트랜잭션을 생성하여 사용한다.

2-3. MANDATORY

  • 트랜잭션이 반드시 있어야 한다.
  • 트랜잭션이 없다면 예외가 발생한다.
  • 만약 트랜잭션이 존재한다면 해당 트랜잭션을 사용한다.

2-4. SUPPORTS

  • 트랜잭션이 존재하는 경우 트랜잭션을 잠시 보류하고, 트랜잭션이 없는 상태로 처리한다.

2-5. NESTED

  • 트랜잭션이 있다면 SAVEPOINT를 남기고 중첩 트랜잭션을 시작한다.
  • 만약 없는 경우에는 새로운 트랜잭션을 시작한다.
  • 부모 트랜잭션 커밋, 롤백엔 영향을 받음
  • 자신의 커밋, 롤백은 부모 트랜잭션에 영향 못 줌

2-6. NEVER

  • 트랜잭션이 존재하는 경우 예외를 발생시키고, 트랜잭션이 없다면 생성하지 않는다.

3. 트랜잭션 적용 사례: 장바구니 기능 & 피드백

트랜잭션의 종류를 알아보았다면,
이전에 작성했던 이커머스 프로젝트의 장바구니기능을 예시로 들어보자.

3-1. 시나리오

  1. 장바구니에 상품을 담을 때 동일한 상품이 담겨있는지 확인한다.
  2. 기존에 들어있는 상품인 경우 수량을 업데이트 해 준다.
  3. 새로운 상품인 경우 장바구니 테이블에 추가해준다.
  4. 품절된 상품은 담을 수 없다.
  5. 구매 시 장바구니를 거쳐 구매 과정이 이루어진다.

3-2. 예시 코드

@Transactional(propagation = Propagation.REQUIRES_NEW)
public CartResult addCartProducts(Long customerId, List<CartInfo> cartInfos) {
    List<CartDetailResponse> responseProduct = new ArrayList<>();

    try {
        // 1️⃣ Named Lock을 획득하여 같은 productId에 대한 동시 접근을 방지
        cartRepository.getLock(cartInfos.getFirst().productId().toString());

        // 2️⃣ 고객의 최신 장바구니 조회
        List<Cart> carts = getCartList(customerId);
        log.info("최신 카트 : " + carts.getFirst().getProduct());

        for (CartInfo cr : cartInfos) {
            Optional<Cart> exists = carts.stream()
                    .filter(c -> c.getProduct().getProductId().equals(cr.productId()))
                    .findFirst();

            log.info("exists ? : " + exists.toString());

            if (exists.isPresent()) {
                // 3️⃣ 기존에 장바구니에 있는 경우 수량 추가
                Cart existingCart = exists.get();
                existingCart.addCartAmount(cr.amount());
                log.info(existingCart.toString());
                cartRepository.save(existingCart);
                responseProduct.add(setProductDetail(cr.productId(), existingCart.getAmount()));
            } else {
                // 4️⃣ 장바구니에 없는 상품이면 새롭게 추가
                addProductsToCart(customerId, cr.productId(), cr.amount());
                responseProduct.add(setProductDetail(cr.productId(), cr.amount()));
            }
        }
    } finally {
        // 5️⃣ Named Lock 해제 (중요: 반드시 finally에서 실행)
        cartRepository.releaseLock(cartInfos.getFirst().productId().toString());
    }

    return new CartResult(
            customerId,
            responseProduct,
            LocalDateTime.now()
    );
}

3-3. 코드 분석

1️⃣ Named Lock 사용 (cartRepository.getLock())

특정 productId 기준으로 Named Lock을 획득하여 동시에 동일한 상품이 중복 추가되는 것 방지
SQL에서 GET_LOCK(productId, 10); 같은 방식으로 처리될 가능성이 높음

2️⃣ REQUIRES_NEW 트랜잭션 사용

장바구니에 상품을 추가하는 로직이 기존 트랜잭션과 독립적으로 실행되어야 함
REQUIRES_NEW를 사용하면 부모 트랜잭션의 성공 여부와 관계없이 장바구니에 상품 추가 과정이 독립적으로 커밋되므로, Named Lock 해제 로직이 안전하게 실행된다.

3️⃣ finally 블록에서 Named Lock 해제 (cartRepository.releaseLock())

트랜잭션이 성공하든 실패하든 반드시 락을 해제해야 함

3-4. REQUIRES_NEW 사용 이유?

만약 구매 프로세스에서 장바구니에 담기 > 장바구니에 있는 항목에서 바로 구매로 이루어 진다고 해보자.

계속해서 트랜잭션이 구매 트랜잭션과 묶이게 된다면, 구매가 실패하면 장바구니에 담긴 상품도 롤백될 위험이 있다.

즉, 장바구니는 구매와는 독립적으로 관리되어야 하기 때문에 Propagation.REQUIRES_NEW 를 사용하여 기존 트랜잭션과 별도로 관리하도록 설계한 것이다.

3-5. 트랜잭션 전파 속성에 따른 장바구니 트랜잭션 처리 비교

시니어 코치의 피드백 :
처음 트랜잭션을 사용했을 때 디폴트 속성인 REQUIRED를 사용했다.
하지만 장바구니와 구매는 독립적인 비즈니스 영역이므로 REQUIRED_NEW를 사용하는 것을 권고받고 수정하였다.

4. 트랜잭션 격리수준(Isolation)종류

여러 트랜잭션이 진행될 때에 트랜잭션의 작업 결과를 타 트랜잭션에게 어떻게 노출할지 결정.

4-1. DEFAULT

  • 사용하는 데이터 접근 기술, DB드라이버의 기본 설정
  • Oracle은 READ_COMMITED, Mysql은 REPEATABLE_READ를 기본 격리 수준으로 가진다.

4-2. 격리 수준 종류

READ_UNCOMMITTED > READ_COMMITTED > REPEATABLE_READ > SERIALIZABLE
격리 수준에 대한 내용은 이 포스트를 확인하면 된다.

5. 기타 트랜잭션 옵션

5-1. Timeout

  • 트랜잭션을 수행하는 제한 시간을 설정.
  • 기본 옵션에는 제한시간이 없음

5-2. readOnly

  • 트랜잭션 내에서 데이터를 조작하려는 시도를 막음.
  • 데이터 접근 기술, 사용 DB에 따라 (힌트를 구현하지 않았다면) 적용 차이가 있음.
    * 구현이 되어있다면 트랜잭션 ID관련 설정에서의 오버헤드를 줄여 실제 읽기 행동 시 참여하는 데이터구조를 감소시켜 성능을 개선시킬 수 있다.

5-3. rollback-for

  • 기본적으로 RuntimeException시 롤백
  • 체크 예외지만 롤백 대상으로 삼고 싶다면 사용

5-4. no-rollback-for

  • 롤백 대상인 RuntimeException을 커밋 대상으로 지정.

6. 마무리

Spring에서의 @Transational은 바르게 사용하면 강력한 도구지만, 잘못 사용하면 불필요한 락과 성능 저하를 초래할 수 있다.
트랜잭션 전파 속성을 잘 활용하면 성능 최적화와 데이터 무결성을 동시에 보장할 수 있다.


[10분 테코톡] 키아라의 스프링 트랜잭션 전파
[10분 테코톡] 후니의 스프링 트랜잭션
물리 트랜잭션과 논리 트랜잭션, 그리고 트랜잭션 전파에 관하여

profile
BackEnd Developer, 기록의 힘을 믿습니다.

0개의 댓글