일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 알고리즘
- Linux
- C
- programmers
- springboot
- 스프링부트
- Spring
- 엘라스틱서치
- Java
- Kakao
- 자바
- 프로그래머스 #카카오 #IT #코딩테스트
- 개발자
- IT
- Python
- 운영체제
- 프로그래머스
- DPDK
- 코딩테스트
- 네트워크
- docker
- 카카오
- 캐시
- 도커
- Elasticsearch
- 백엔드
- 리눅스
- 쿠버네티스
- 스프링
- 파이썬
- Today
- Total
저고데
[Spring Boot] @Transactional 어노테이션의 propagation = Propagation.REQUIRES_NEW 옵션 본문
[Spring Boot] @Transactional 어노테이션의 propagation = Propagation.REQUIRES_NEW 옵션
진철 2025. 2. 23. 23:34들어가며
필자가 사이드 프로젝트를 진행하면서 발생한 문제이다.
서비스의 기능 중, 지정가 주문을 걸어놓으면 현재 비트코인의 가격과 지정가 가격이 일치할 때, 채결이 되는 기능이 있다.
지정가 주문이 채결되는 과정은 다음과 같다.
먼저 사용자가 지정가 주문을 하게 되면 NoSQL 저장소에 "{지정가 가격}:{사용자 ID}" 형식으로 지정가 주문이 저장된다.
Kafka로부터 전달된 비트코인 현재 가격에 의해서 지정가 가격과 동일한 데이터를 NoSQL에서 삭제하고, RDBMS에 저장하여 해당 거래가 채결되었음을 알리는 구조이다.
그리고 해당 코드는 아래와 같다.
@Transactional
public void executeOrder(String currentBtcPrice) {
String orderPattern = currentBtcPrice + ":*";
Set<String> matchingOrders = noSqlService.getKeys(orderPattern);
if (matchingOrders.isEmpty()) {
log.info("{} 가격에 대한 주문 정보가 없습니다.", currentBtcPrice);
throw new MockBitException(MockbitErrorCode.PRICE_NOT_FOUND));
}
List<OrderResult> orderResults = new ArrayList<>();
for (String key : matchingOrders) {
Order order = Optional.ofNullable((Order) redisService.getData(key))
.orElseThrow(() -> new MockBitException(MockbitErrorCode.ORDER_NOT_FOUND));
orderResults.add(OrderResult.fromOrder(order));
accountService.completeOrder(order);
redisService.deleteData(key);
log.info("지정가 주문이 완료되었습니다. - User: {}, Price: {}", order.getUserId(), order.getPrice());
}
}
문제 상황 1: 지정가 주문이 하나라도 실패할 경우
하지만, 해당 코드의 치명적인 문제점을 가지고 있었다.
현재 코드는 전체 데이터를 배치 처리하기 때문에 하나의 데이터라도 오류가 발생하면 모든 데이터의 트랜잭션이 롤백되는 문제이다.
이를 알기 위해서는 @Transactional 어노테이션에 대해서 알고 있어야 한다.
@Transactional
보편적으로 트랜잭션(Transaction)은 데이터베이스에서 하나의 작업 단위를 의미한다.
데이터를 다루는 작업이기에 ACID와 같은 원칙을 지키는 것이 매우 중요한데, 해당 어노테이션은 이러한 원칙들을 지키면서 작업을 수행하게끔 도와준다.
클래스나 메서드 위에 붙여서 어노테이션이 작동되는 구역임을 알리고, 해당 메서드가 정상적으로 완료되면 commit(영속화) 시키고, 그렇지 않다면 모든 내용을 다시 롤백시킨다.
그렇다면 궁금증이 있을 수 있다.
필자의 코드와 같은 for문이 사용된 로직에서 하나씩 데이터를 save() 메서드를 사용하여 DB에 저장한다고 하자.
만약 마지막 데이터의 삽입 과정에서 오류가 발생하면 롤백이 발생할 것이다.
그렇다면 이미 DB에 삽입된 데이터를 일일이 찾아서 삭제시켜서 롤백시키는 것일까?
결론은 아니다.
이는 영속화 컨텍스트라는 개념을 알아야 하는데, 중요하고 복잡한 내용이기에 분량 상 추후에 자세히 포스팅하도록 하겠다.
따라서, 여기서는 save()와 같이 데이터를 DB에 저장하는 메서드는 DB에 바로 저장하는 것이 아니라, 중간에 임시 공간에 저장한다라고 생각하면 되겠다.
간단하게 @Transactional 어노테이션에 대해서 알아보았고, 이제는 필자의 해결 방법을 서술하겠다.
우선 성능은 느리지만 배치 처리가 아닌 개별 처리를 통해서 문제를 해결하려고 했다.
더군다나 현재 주문 채결 오류 시, 원금을 반환해주는 취소 로직도 존재하지 않았기에 해당 로직을 추가하고 테스트를 진행하였다.
@Transactional
public void executeOrder(String currentBtcPrice) {
String orderPattern = currentBtcPrice + ":*";
Set<String> matchingOrders = noSqlService.getKeys(orderPattern);
if (matchingOrders.isEmpty()) {
log.info("{} 가격에 대한 주문 정보가 없습니다.", currentBtcPrice);
throw new MockBitException(MockbitErrorCode.PRICE_NOT_FOUND));
}
for (String key : matchingOrders) {
Order order = Optional.ofNullable((Order) redisService.getData(key))
.orElseThrow(() -> new MockBitException(MockbitErrorCode.ORDER_NOT_FOUND));
try {
accountService.completeOrder(order);
noSqlService.deleteData(key);
log.info("지정가 주문이 완료되었습니다. - User: {}, Price: {}", order.getUserId(), order.getPrice());
} catch {
accountService.cancelOrder(order); // 주문 금액을 환불해주는 로직
}
}
}
문제 상황 2: 이전과 다를 게 없다.
하지만, 수정된 코드도 마찬가지였다.
배치 처리를 하던, 개별 처리를 하던 결국에는 로직이 @Transactional 어노테이션 아래에서 수행되기 때문에 하나가 실패하더라도 모두가 롤백되는 문제가 해결되지 않았다.
따라서, 아예 지정가 주문을 채결하는 부분을 분리하여 문제를 해결할 수 있었다.
그리고 더불어서 @Transactional의 원본 코드를 살펴보던 중, 하나의 옵션 기능을 찾을 수 있었는데, 이는 다음과 같다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional 어노테이션에서 사용되는 옵션들은 트랜잭션 간의 관계를 지정하는데, 그 중 REQUIRES_NEW는 기존 트랜잭션의 영향을 받지 않고 항상 새로운 트랜잭션을 생성하는 특성을 가진다.
항상 새로운 트랜잭션을 시작하는 것이다. (이와 반대로 현재 진행 중인 트랜잭션이 있으면 그 트랜잭션을 사용하고, 없으면 새로운 트랜잭션을 생성하는 Propagation.REQUIRED 옵션이 존재한다.)
기존에 진행 중인 트랜잭션이 있더라도 현재 트랜잭션을 중단시키고 새로운 트랜잭션을 시작한다.
기존 트랜잭션이 무엇이든 영향을 받지 않으며, 새로운 트랜잭션에서 발생한 오류는 그 트랜잭션만 롤백된다.
외부 트랜잭션에는 영향을 주지 않기 때문에, 하나의 트랜잭션 내에서 실패한 작업이 전체 작업을 영향을 미치지 않도록 할 수 있다.
따라서, 이 옵션을 사용하면 내부 트랜잭션에서의 실패가 외부 트랜잭션에 영향을 미치지 않기 때문에, 복잡한 트랜잭션 연쇄에서 한 작업의 오류로 인해 전체 작업이 롤백되는 상황을 방지할 수 있다.
앞서, 하나의 트랜잭션에서 하나의 오류가 발생하면 내부의 트랜잭션이 모두 롤백된다고 했다.
하지만, 해당 옵션을 사용하여 여러 주문을 처리하는 경우에 한 주문의 실패가 전체 주문 처리에 영향을 주지 않도록 각 주문을 개별적인 트랜잭션으로 처리하는 것이 가능하다는 것이다.
최종 코드를 같이 보면서 분석해보자.
public void executeOrder(String currentBtcPrice) {
String orderPattern = currentBtcPrice + ":*";
Set<String> matchingOrders = noSqlService.getKeys(orderPattern);
if (matchingOrders.isEmpty()) {
log.info("{} 가격에 대한 주문 정보가 없습니다.", currentBtcPrice);
return;
}
for (String key : matchingOrders) {
((OrderResultService) AopContext.currentProxy()).processSingleOrder(key);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingleOrder(String key) {
Order order = Optional.ofNullable((Order) noSqlService.getData(key))
.orElseThrow(() -> new MockBitException(MockbitErrorCode.ORDER_NOT_FOUND));
OrderResult orderResult = OrderResult.fromOrder(order);
try {
accountService.completeOrder(orderResult);
noSqlService.deleteData(key);
orderResultRepository.save(orderResult);
log.info("지정가 주문이 완료되었습니다. - User: {}, Price: {}", order.getUserId(), order.getPrice());
} catch (Exception e) {
try {
accountService.cancelOrder(order.getUserId(), new BigDecimal(order.getOrderPrice()));
log.info("주문 처리 실패로 인한 환불이 완료되었습니다. - User: {}, Price: {}", order.getUserId(), order.getPrice());
} catch (Exception refundEx) {
log.error("환불 처리 중 오류 발생 - User: {}, Price: {}. 오류: {}", order.getUserId(), order.getPrice(), refundEx.getMessage());
}
log.error("지정가 주문 처리 중 오류 발생 - User: {}, Price: {}. 오류: {}", order.getUserId(), order.getPrice(), e.getMessage());
}
}
수정된 코드에서는 주문을 처리하는 processSingleOrder 메소드를 별도의 트랜잭션으로 실행하기 위해 @Transactional(propagation = Propagation.REQUIRES_NEW) 옵션을 사용했다.
따라서 이를 통해 각 주문의 독립성 보장되는데, 기존 코드에서는 하나의 트랜잭션 범위 내에서 모든 주문을 처리하여, 한 주문의 예외가 전체 롤백으로 이어질 위험이 있었다.
하지만 수정된 코드에서는 processSingleOrder 메소드가 각각 새로운 트랜잭션으로 실행되기 때문에, 한 주문 처리에서 오류가 발생해도 해당 주문의 롤백만 발생하며, 다른 주문에는 영향을 주지 않는다.
AOP 프록시로 메서드를 호출한 이유
또한, 이전 코드와 다르게 AOP 프록시를 통해 processSingleOrder 메소드를 실행하였다.
직접 실행하지 않고, AOP 프록시를 사용한 이유는 무엇일까?
AOP를 사용하는 이유는 스프링의 트랜잭션은 AOP 프록시를 통해 동작하기 때문에, 동일한 빈 내에서 메서드를 직접 호출하면 프록시를 거치지 않아 트랜잭션 어노테이션이 적용되지 않는다. (실제로 log를 통해서 트랜잭션이 이루어지는 빈을 출력하면 프록시 빈이 호출되는 것을 알 수 있다.)
따라서, AopContext.currentProxy() 메서드를 통해 현재 프록시 객체를 획득한 후, 해당 프록시를 통해 processSingleOrder를 호출함으로써, REQUIRES_NEW와 같은 트랜잭션 전파 속성이 제대로 적용되도록 하는 것이다.
'Spring Boot' 카테고리의 다른 글
[Spring Boot] 18. 리액티브 프로그래밍 좀 더 알아보기 (0) | 2024.02.03 |
---|---|
[Spring Boot] 17. 조립식 컴퓨터로 알아보는 DI (0) | 2024.01.29 |
[Spring Boot] 16. 자바는 패키지 안에 있어야 실행이 된다! (0) | 2024.01.29 |
[Spring Boot] 15. ORM이란? (0) | 2024.01.28 |
[Spring Boot] 14. Optional에 대해여 (0) | 2024.01.21 |