
현재 일하는 곳에서는 프로시저를 이용한 처리를 좀 더 자주 사용한다.
일괄 처리를 할 때 자주 사용하는게 트랜잭션인데, 사실 공부를 하지 않을 때에는 트랜잭션의 중요성을 알지 못했다.
트랜잭션이 주는 이점보다는 트랜잭션으로 인해 다른 처리가 락이 걸려버리는 상황이 자주 있어서 '안쓰느니만 못한 것'이라고 인식하던 때가 있었기 때문이다.
하지만 트랜잭션은 잘못없어!!!!
잘못한건 트랜잭션을 올바르지 않은 위치에 건 나지.😭
모든 기능이 잘 쓰면 약, 못 쓰면 독이다.
Spring에서 제공하는 @Transactional은 다양한 옵션을 제공하고 있고, 옵션에 따라 성능 저하까지 초래할 수 있다고 하는데!!??
지피지기면 백전백승이라고 진짜 제대로 알아보자!
트랜잭션(Transaction)은 프로그래밍, 데이터베이스, 가산자산 등에서 사용되는 용어로,
연속적인 작업의 묶음을 뜻한다.
Spring에는 @Transactional(선언적 트랜잭션)이라는 어노테이션을 제공하는데,
이 녀석의 장점 중 하나가 여러 트랜잭션을 묶어서 커다란 하나의 트랜잭션 경계를 만들 수 있다는 점이다.
트랜잭션을 시작하는 방법은 하나이지만, 끝나는 방법은 rollback / commit 두 개가 존재한다.
트랜잭션을 시작하고 끝내는 작업을 설정하는 걸 트랜잭션 경계설정 이라고 하고, 트랜잭션 경계 내에서 논리적으로 묶이길 원하는 로직들이 실행되게 된다.
트랜잭션 전파 속성?
👉🏻 트랜잭션이 진행중일 때 추가 트랜잭션을 어떻게 할지 결정하는 것
@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의 서브타입이다.
트랜잭션의 종류를 알아보았다면,
이전에 작성했던 이커머스 프로젝트의 장바구니기능을 예시로 들어보자.
@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()
);
}
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())
트랜잭션이 성공하든 실패하든 반드시 락을 해제해야 함
만약 구매 프로세스에서 장바구니에 담기 > 장바구니에 있는 항목에서 바로 구매로 이루어 진다고 해보자.
계속해서 트랜잭션이 구매 트랜잭션과 묶이게 된다면, 구매가 실패하면 장바구니에 담긴 상품도 롤백될 위험이 있다.
즉, 장바구니는 구매와는 독립적으로 관리되어야 하기 때문에 Propagation.REQUIRES_NEW 를 사용하여 기존 트랜잭션과 별도로 관리하도록 설계한 것이다.

시니어 코치의 피드백 :
처음 트랜잭션을 사용했을 때 디폴트 속성인 REQUIRED를 사용했다.
하지만 장바구니와 구매는 독립적인 비즈니스 영역이므로 REQUIRED_NEW를 사용하는 것을 권고받고 수정하였다.
여러 트랜잭션이 진행될 때에 트랜잭션의 작업 결과를 타 트랜잭션에게 어떻게 노출할지 결정.
READ_UNCOMMITTED > READ_COMMITTED > REPEATABLE_READ > SERIALIZABLE
격리 수준에 대한 내용은 이 포스트를 확인하면 된다.
Spring에서의 @Transational은 바르게 사용하면 강력한 도구지만, 잘못 사용하면 불필요한 락과 성능 저하를 초래할 수 있다.
트랜잭션 전파 속성을 잘 활용하면 성능 최적화와 데이터 무결성을 동시에 보장할 수 있다.
[10분 테코톡] 키아라의 스프링 트랜잭션 전파
[10분 테코톡] 후니의 스프링 트랜잭션
물리 트랜잭션과 논리 트랜잭션, 그리고 트랜잭션 전파에 관하여