왜 이런 고민을 하게 되었는가
카카오페이 결제 API 서비스를 개발하는 과정에서 계속 구매 내역 조회가 500이 뜬다는 제보가 자주 들어왔다. 항상 DB를 확인해보면 고객이 구매한 코인과 구매내역이 양방향 매핑이 되어있어야 하는데 하나가 Null 이 되는 이슈가 있었다.
결제를 처음으로 시도해봐서 동시성 문제라던가에 대해 감이 잘 잡히지 않아 설계를 좀 이상하게 한게 문제였다...
일단 우리의 로직은 이렇다.
1. 프론트가 백엔드에 코인 구매 요청을 보낸다.
2. 백엔드가 이 요청을 보고 구매 물품이나 가격을 카카오페이에 요청한다.
3. 프론트의 redirect URL 로 카카오페이가 pg Token을 보낸다.
4. 프론트가 이것을 보고 pg Token을 백엔드에 다시 보낸다.
5. 백엔드가 다시 진짜 찐 결제 승인 요청을 보낸다.
6. 5번이 성공하면 Coin 엔티티를 생성하게 된다.
7. 그러면 CoinPaymentHistory 엔티티를 생성해 Coin이랑 1대1 매핑해준다.
8. 이것이 성공하면 Coin 엔티티에도 CoinPaymentHistory 를 매핑한다.
9. 마지막으로 구매 완료 문자를 고객에게 보낸다.
이 모든 코드가 컨트롤러 안에 각각 실행되어 있었다.
그 말은 즉슨, 6,7,8 이 모두 개별적인 트랜잭션이라는 의미이다.
6번에서 코인이 생성 돼 성공적으로 커밋되면 DB에 저장이 될 것이다. 그러나? 7번에서 에러가 나거나 8번에서 에러가 나면 양방향 매핑에 실패하게 된다. 각각 개별적인 트랜잭션이므로 에러가 나도 본인들 안에서만 롤백을 할 거라는 의미이다.
하지만 트랜잭션의 ACID 중 하나는 원자성이다. do or nothing = 하거나 안 하거나 라는 것이다. 내 설계는 원자성을 지키지 못했다.
해결?
해결 방안으로 가장 먼저 떠오른 것은 그냥 전체 처리 서비스를 하나 더 파서 6,7,8을 묶는 것이였다. @Transactional 만 위에다 쓰면 되니까 아주 쉽다..^^
하지만 그렇게 된다면 SRP(단일 책임 원칙)에 위배되며, 서비스가 더 복잡해진다면, 예를 들어 멤버 쿠폰이 생긴다거나 할인 정책이 적용되면? 서비스 레어어들은 점점 처리 서비스에 의존하게 될 것이다. 이렇게 되면 비즈니스 로직을 담당하는 서비스 로직끼리 끈끈하게 되어 객체지향적이지 못하게 된다.
고민
가장 먼저 떠오른 것은 카프카를 이용한 메세지 큐였다. 최근에 스칼라에서 듣기도 했고... 새로운 기술을 적용시켜보고 싶었다. 그런데 생각해 보니깐 카프카는 DB가 여러개 있고 각각 다른 서버일때나 의미가 있지 않을까...? 라는 생각이 들었고 좀 무거운 것 같았다...
일단 여기서 생각이 이어져 아웃박스 패턴 느낌을 꼭 활용해보고 싶다고 생각해보게 되었다.
아웃박스 패턴이란?
아웃박스 패턴이란 트랜잭션 일관성을 유지하면서 이벤트를 안전하게 발행하는 방법 중 하나로, 데이터 베이스와 메세지 브로커 간의 일관성을 보장하기 위해 사용된다.
트랜잭션 내에서 Outbox 테이블에 이벤트를 저장하고, 별도 프로세스가 이를 읽어 메시지를 브로커로 전송하는 방식이다.
1. 주문을 받아 주문 엔티티가 생성됐다. 사장님은 주문을 배달에 맡겼다.
2. 하지만 갑자기 배달이 불가한 상황이 됐다.
3. 하지만 이미 주문이 들어갔으므로 철회할 수가 없다.
4. 그렇다면 중간 수첩(outbox) 에 주문을 적어놓고, 바로 주문이 들어오자마자 배달을 요청하는 것이 아닌, 취소된 주문이면 수첩을 보고 배달을 하지 않는 방식으로 하면 된다.
즉, 아웃박스 패턴은 데이터베이스에 먼저 안전하게 기록하고, 나중에 메시지를 보낼 수 있도록 하는 방법이라고 볼 수 있다.
아웃박스 패턴은 MSA(마이크로서비스아키텍쳐) 가 활발해진 요즘 엄청 중요하게 떠오르고 있는 것 같다.
MSA 환경을 사용하게 되면 트랜잭션 원자성을 유지하기 어려운 문제가 커져, 이벤트 기반으로 많이 통신을 한다고 한다.
해결 방법: 스프링 이벤트 활용하기
아무리 생각해도 너무 어려운 것 같아서 스프링에서 편하게 이벤트를 발생시켜주는 스프링 이벤트를 활용했다.
이벤트를 이용하면 도메인 로직과 부가적인 작업(예: 알림 전송)을 분리할 수 있어 코드의 가독성이 향상되고 유지보수도 편리해진다.
1. approve() 메서드에서 이벤트 발생시키기
카카오페이 결제가 승인되면, CoinSuccessEvent를 발생시켜 트랜잭션이 정상적으로 처리될 수 있도록 했다.
public KakaoPaymentApproveDTO approve(Member member, String pgToken, String tid) {
WebClient kakao = getKakaoClient();
SinglePayApproveRequestDTO request = new SinglePayApproveRequestDTO(
cid,
tid,
orderId,
userId,
pgToken
);
Mono<KakaoPaymentApproveDTO> response = kakao.post()
.uri(BASE_URL + "/approve")
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::isError, this::handleError)
.bodyToMono(KakaoPaymentApproveDTO.class)
.doOnError((e) -> {
log.error("API Error {}", e.getMessage());
});
KakaoPaymentApproveDTO dto = response.block();
eventPublisher.publishEvent(new CoinSuccessEvent(member, dto));
return dto;
}
2. CoinSuccessEvent를 이용한 트랜잭션 처리
카카오페이 결제 승인 후 CoinSuccessEvent를 발생시키고, 이벤트 리스너에서 코인 엔티티 생성, 구매내역 생성, 양방향 매핑 처리를 한 트랜잭션으로 묶었다.
@EventListener
@Transactional
public void handle(CoinSuccessEvent event) {
Coin coin = coinService.createNewCoin(event.member(), event.dto());
CoinPaymentHistory history = coinPaymentHistoryService.createNewCoinPayment(event.member(), event.dto(), coin);
coinService.setCoinAndCoinPayment(coin, history);
}
- 원래 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 사용해 트랜잭션이 완료된 후 이벤트를 처리하려 했으나, 이 방식에서는 개별 트랜잭션이 실행되므로 coin.setHistory(history);가 반영되지 않았다.
- 따라서 이벤트 리스너에서 @EventListener와 @Transactional을 함께 사용하여 같은 트랜잭션 내에서 실행되도록 변경했다.
- 또한, 카카오페이 승인 후에 이벤트를 보내는 과정에서는 별도로 트랜잭션 커밋을 하지 않으므로, 이벤트 리스너를 활용해 트랜잭션 내에서 자연스럽게 처리하도록 하였다.
- 이렇게 함으로써 트랜잭션이 끊기지 않고 한 흐름 안에서 코인과 구매내역이 함께 저장되도록 개선했다.
2-1. 왜 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)에서 트랜잭션이 적용되지 않았는가?
@Component
@RequiredArgsConstructor
@Slf4j
public class CoinPaymentEventListener {
private final SmsCertificationUtil smsCertificationUtil;
private final CoinPaymentHistoryService coinPaymentHistoryService;
private final CoinService coinService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(CoinSuccessEvent event) {
log.info("CoinSuccessEvent 처리 시작: memberId={}, coinId={}", event.member().getId(), event.coin().getId());
coinPaymentHistoryService.createNewCoinPayment(event.member(), event.dto(), event.coin());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(CoinApprovedEvent event) {
log.info("CoinApprovedEvent 처리 시작: coinId={}", event.coin().getId());
coinService.setCoinAndCoinPayment(event.coin(), event.history());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(CoinPaymentAllSuccessEvent event) {
log.info("CoinPaymentAllSuccessEvent 처리: {}", event.phoneNumber());
smsCertificationUtil.sendCoinPaymentSMS(event.phoneNumber(), event.quantity(), event.totalAmount());
}
}
처음 코드는 이랬다.
이유는 코인 엔티티 생성 후 구매 내역을 생성하고 싶었고, 그다음에 문자를 보내고 싶었기 때문에,,
위 사진을 보면 결제 내역 커밋 후 99번 코인이 생성이 완료라고는 떴으나 실제로 db를 보니 매핑되지 않았었고 뒤에 실행한 로그가 찍힌 것을 보니 실행은 된 것이다. 즉 트랜잭션 자체가 시작하지 않아서 그냥 에러도 없었고 롤백할 것도 없었다는 거다...
이 이유를 아려면 트랜잭션 이벤트 리스너에 대해 알아야 한다.
TransactionalEventListener 의 phase 종류
- BEFORE_COMMIT: 트랜잭션이 커밋되기 전에 이벤트 처리
- AFTER_COMMIT(Default): 트랜잭션 커밋된 직후 이벤트 처리
- AFTER_ROLLBACK: 트랜잭션 롤백 직후 이벤트 처리
- AFTER_COMPLETION: 트랜잭션이 완료된 뒤 이벤트 처리,
트랜잭션 완료란 트랜잭션이 커밋되거나 롤백될때를 이야기함
- @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)은 트랜잭션이 완전히 커밋된 후에 실행되는 이벤트 리스너이다. 따라서 주의해야한다.
- 바로 이전에 트랜잭션이 커밋되었기 때문에, 이벤트 후 각각 메소드에 추가적인 트랜잭션 작업을 하려면 각 트랜잭션에 @Transactional(propagation = Propagation.REQUIRES_NEW) 속성 = 트랜잭션을 새로 생성하는 속성이 필요하다.
- 즉, 트랜잭션이 종료된 후 별도의 트랜잭션 컨텍스트에서 실행되므로, 기존 트랜잭션과 독립적으로 동작한다.
- 따라서 coin.setHistory(history); 호출이 기존 트랜잭션의 영향권 밖에서 수행되어 변경사항이 반영되지 않았다.
- 반면 @EventListener와 @Transactional을 함께 사용하면, 이벤트가 기존 트랜잭션 내에서 실행되므로 트랜잭션이 유지되면서 정상적으로 변경사항이 반영된다.
왜 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 사용하지 않았는지?
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새로운 트랜잭션으로 실행
public void handle(CoinSuccessEvent event) {
CoinPaymentHistory history = coinPaymentHistoryService.createNewCoinPayment(event.member(), event.dto(), event.coin());
coinService.setCoinAndCoinPayment(event.coin(), history);
}
어차피 저러면 개별 트랜잭션이 되기 때문이다.
내가 원하는 것은 코인엔티티 + 구매 내역 엔티티 + 둘의 양방향 매핑의 같은 트랜잭션 내에 위치하는 것이었는데, 저렇게 하면 돌아가긴 하지만 (직접 해봄) 트랜잭션을 전파하고 싶진 않았다.
참고로 @TransactionalEventListener 을 사용하게되면 그냥 @Transactional 은 사용할 수 없다. 실행 시에 에러 뜸
@TransactionalEventListener와 일반 @EventListener의 차이점
@TransactionalEventListener와 일반 @EventListener의 차이점은 이벤트가 트랜잭션과 어떻게 연관되는지에 있다.
1. @EventListener
- 일반적인 이벤트 리스너로, 트랜잭션 상태와 관계없이 즉시 실행됨.
- 이벤트를 게시(ApplicationEventPublisher.publishEvent())하는 순간 리스너가 실행됨.
- 같은 트랜잭션 내에서 실행되므로, 트랜잭션이 롤백되면 이벤트의 실행 결과도 영향을 받을 수 있음.
2. @TransactionalEventListener
- 트랜잭션의 상태에 따라 이벤트 실행 시점을 조정할 수 있음.
- 트랜잭션이 커밋된 후, 롤백된 후, 트랜잭션이 시작되기 전에 실행할 수 있음.
- phase 옵션을 통해 실행 시점을 설정할 수 있음.
@EventListener | @TransactionalEventListener | |
트랜잭션 롤백 영향 | 바로 실행, 트랜잭션 내에서 실행되므로 롤백 영향 받음 | 트랜잭션 상태에 따라 실행, 트랜잭션 커밋 후 실행되므로 롤백 영향 없음 (AFTER_COMMIT 기준) |
주로 사용하는 경우 | 트랜잭션 내부에서 로직을 처리할 때 | 트랜잭션이 성공적으로 완료된 후 후속 작업(예: 이메일, 알림 전송 등)이 필요할 때 |
- 트랜잭션 내에서 실행해야 하는 경우
→ @EventListener + @Transactional- 코인과 구매내역을 함께 저장하는 등의 작업처럼, 같은 트랜잭션 내에서 실행되어야 하는 경우 사용.
- 트랜잭션 완료 후 실행해야 하는 경우
→ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)- 알림, SMS 전송, 캐시 갱신, 로그 저장 등 트랜잭션이 커밋된 후에 실행해야 하는 작업에 사용.
3. 문자 발송은 트랜잭션 커밋 후 비동기 처리
문자 발송은 결제 및 코인 적립이 정상적으로 완료된 후 진행되어야 하므로, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 을 사용하여 트랜잭션이 정상적으로 끝난 후 실행되도록 했다. 또한 문자 발송은 시간이 걸릴 수 있어 @Async를 사용하여 비동기 처리했다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handle(CoinPaymentAllSuccessEvent event) {
smsCertificationUtil.sendCoinPaymentSMS(event.phoneNumber(), event.quantity(), event.totalAmount());
log.info("{} 에게 문자 발송 완료", event.phoneNumber());
}
4. setCoinAndCoinPayment()에서 최종 이벤트 발생
트랜잭션이 정상적으로 끝난 후 CoinPaymentAllSuccessEvent를 발생시켜 문자 발송을 트리거한다.
public void setCoinAndCoinPayment(Coin coin, CoinPaymentHistory history) {
coin.setHistory(history);
coinRepository.save(coin);
eventPublisher.publishEvent(
new CoinPaymentAllSuccessEvent(coin.getMember().getPhoneNumber(), history.getCoinCount(), history.getPaymentPrice())
);
}
이렇게 해결한 후 더 이상 트랜잭션 원자성 문제로 인한 데이터 불일치가 발생하지 않았으며, 비동기 문자 발송도 안정적으로 동작했다!
정리
코인 엔티티와 구매내역 엔티티를 한 트랜잭션으로 묶어 원자성을 보장했고, @TransactionalEventListener와 @Async를 활용하여 문자 발송을 트랜잭션 종료 후 비동기로 처리 해 문자 발송에 지체가 될 일을 줄였다.
스프링 이벤트를 활용하여 코드의 가독성을 높이고 유지보수성을 향상할 수 있었던 것 같다. 그 전엔 컨트롤러의 코드 길이가 엄청 길었다..ㅎㅎ
트랜잭션의 범위가 얼마나 중요한지, 그리고 설계를 좀 더 넓게 봐야함의 중요성을 깨달았다.
'BACKEND' 카테고리의 다른 글
쿠버네티스에 대해서 (0) | 2025.02.06 |
---|---|
nginx 의 SSL 인증서를 옮겨오는 법..? (0) | 2025.01.29 |
Nginx 프록시 설정 중 SSL 발급 오류 해결 (0) | 2025.01.29 |
제목: 엔지닉스에 OPTION 처리 달지말자 (0) | 2024.12.02 |