우아한테크캠프 6기를 진행하며 최종 프로젝트에서 고생한 내용을 회고하며 정리한 문서입니다.
프로젝트 소개
든든킷은 우아한테크캠프 6기 마지막 최종 과제로 진행한 프로젝트입니다.
프로젝트를 준비하는 과정에서 '이왕 하는 김에 제대로 진행해보자' 라는 욕심 가득한 마음에, `토스페이 테스트 API`를 연동해 실제 주문 및 결제 과정 개발을 경험해 보자는 의견이 통과되었고, 제가 해당 부분을 맡아 개발하게 되었습니다.
프로젝트는 대략 2주 동안 진행되었습니다. 작은집 7층 강의장에서 하루 12시간을 몰두하며 노력한 결과, 일주일만에 계획했던 기능들을 모두 구현하는데 성공했습니다. 그래서 남은 일주일은 더 이상 기능 확장은 접어 두고, 기존 개발된 기능들의 성능 개선 및 예외처리에 집중하기로 했습니다.
결제 흐름 이해
주문 결제는 다음 순서에 따라 진행됩니다.
- 사용자가 토스페이 위젯을 통해 결제 진행
- 위젯 결제 성공 시 결제 키(paymentKey)를 Body에 담아 든든킷 API 서버로 POST 요청
- 상품 테이블에 비관적 락 설정, 상품 수량 차감 및 사용자 장바구니 데이터 삭제
- 토스페이 서버에 결제 승인 API 요청
- 이후 200 응답 반환 시 결제 성공 및 결제 정보 테이블에 저장
4. 결제 승인 API 요청 단계 전에 3. 상품 수량 차감 및 장바구니 삭제 단계를 먼저 수행하는 이유는 예외가 발생했을 때 복구 비용이 더 낮다고 생각해서입니다.
만약 결제 승인 API요청을 먼저 수행한 경우, 상품 수량을 차감하는 과정에서 유효성 검사에 실패해 예외가 발생하면 토스페이 측에 다시 결제 취소 요청을 보내야 합니다.
반대로 상품 수량 차감 단계를 먼저 진행하는 경우, 상품 유효성 검사 시 예외가 발생하면 이후의 토스페이 결제 로직이 실행되지 않고, 토스페이 결제 단계에서 결제가 실패해 예외가 발생하면 트랜잭션이 롤백되어 손쉽게 예외에 대한 대응을 할 수 있습니다.
성능 측정 준비
AWS
배포는 AWS 환경에 이루어졌습니다. Spring Application과 MySQL 서버를 각각 EC2 t4g.small 인스턴스 환경에 배포하였습니다. RDS를 사용하고 싶었지만, EC2 인스턴스만 제공된다는 최종 과제 제한사항에 의해 따로 EC2에 MySQL 서버를 구축하여 배포하였습니다. 메모리 부족에 대비하기 위해 각각의 인스턴스 모두 Swap Memory 2GB를 할당하였습니다.
nGrinder
성능 테스트를 위해 nGrinder를 이용해 부하를 발생시켰습니다. vUser는 20으로 설정하였고, docker를 이용해 controller와 agent를 설정하였습니다.
Prometheus + Grafana
이번 성능 테스트에서 모니터링 메트릭의 도움을 많이 받았습니다. Heap Memory, CPU Load, Tomcat Thread, 그리고 특히 HikariCP 그래프를 가장 많이 참고하였습니다.
또, 아래처럼 @Counted 어노테이션을 이용해 결제의 성공, 실패 횟수를 측정해 성능 테스트 진행 시 Grafana를 통해 실시간으로 모니터링하였습니다.
성능 문제 인식
결제 TPS 0.7
첫 성능 측정 결과, 위 그래프에서 결제 기능 TPS가 0.7, 즉 평균적으로 1초당 한 건의 결제도 처리하지 못하는 심각한 문제를 확인했습니다.
Grafana에서 HikariCP 그래프를 확인해본 결과, HikariCP 기본값인 10개의 Connection들이 모두 Active하게 할당되어 있었습니다. vUser 20명으로 진행한 테스트에서 10명의 vUser들은 커넥션 풀에서 Connection을 획득했고, 나머지 10명의 vUser들은 Connection을 획득하지 못해 Pending 된 상태였습니다.
원인 분석
외부 결제 API를 호출하는 로직이 DB Connection을 가진 상태에서 실행된 것이 문제였습니다. 심지어 상품 테이블에 비관적 락을 걸고 수행하기 때문에 요청 시간인 1.3초 당 1개의 요청만 실행되어, 1/1.3 인 약 0.76 TPS가 측정된 것으로 추측할 수 있었습니다.
그래서 외부 API 호출 로직을 트랜잭션 밖으로 분리하는 것, 즉 DB Connection을 반환한 상태에서 외부 API를 호출하기 위해 노력했습니다.
기존 코드
저희 팀은 이번 프로젝트에서 더 우아한 의존성 제어를 위해 도메인 이벤트를 사용했습니다. Application layer의 OrderService에서 트랜잭션을 관리하고, Domain layer의 Order 엔티티의 메서드를 호출합니다.
// OrderService.java
@Transactional
public Long order(final AuthPrincipal authPrincipal, final OrderCreateRequest request) {
log.info("주문 생성 memberId: {} orderId: {} paymentKey: {}", authPrincipal.getId(),
request.getOrderId(), request.getPaymentKey());
Order order = getOrderById(authPrincipal.getId(), request.getOrderId());
order.order(request.getPaymentKey());
return orderRepository.save(order).getId();
}
// Order.java
public void order(final String paymentKey) {
orderStatus = OrderStatus.WAITING_PAYMENT;
registerEvent(new OrderCompleteEvent(this, paymentKey));
log.info("주문 완료 이벤트 발행 orderId: {}", id);
}
결제, 장바구니 삭제, 상품 테이블에서 수량을 차감하는 로직들이 OrderCompleteEvent를 핸들링하는 각각의 핸들러에 의해 수행됩니다.
// PaymentHandler.java
@EventListener
public void handle(final OrderCompleteEvent event) {
// payOrder() 내부에서 결제 API 호출, 결과값을 payments 테이블에 저장
payOrder(event);
}
이 때, @EventListener를 이용해 등록한 이벤트 핸들러 로직이 OrderService에 의해 시작된 트랜잭션에 편입되고 있었습니다. 결제가 실패했을 때 수량 확보 로직에서 변경한 상품 테이블 정보를 쉽게 롤백하기 위해 의도한 것이었지만, 성능을 위해 외부 API 호출 로직을 트랜잭션 밖으로 분리하기로 결정했습니다.
성능 개선 과정
개선 1
@TransactionalEventListener
가장 먼저, 단순하게 @EventListener를 @TransactionalEventListener로 변경해 보았습니다.
// PaymentHandler.java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(final OrderCompleteEvent event) {
log.info("결제 요청 subscribe event: {}", event.getPaymentKey());
payOrder(event);
}
@TransactionalEventListener는 이벤트를 발행한 트랜잭션의 상태에 따라 이벤트를 핸들링할 수 있도록 돕는 어노테이션입니다. phase 옵션에 따라 다음과 같이 이벤트를 핸들링하는 시점을 결정할 수 있습니다.
- BEFORE_COMMIT : 이전 트랜잭션이 커밋되기 이전에 핸들링
- AFTER_COMMIT : 이전 트랜잭션이 커밋된 이후 핸들링(기본값)
- AFTER_ROLLBACK : 이전 트랜잭션이 롤백된 후 핸들링
- AFTER_COMPLETION : 이전 트랜잭션이 커밋 혹은 롤백된 후 핸들링
phase를 AFTER_COMMIT으로 설정하면 핸들러 메서드 실행 시점에 이전 트랜잭션이 커밋되었기 때문에, DB 커넥션을 반환하고 나서 핸들러 메서드를 실행할 것으로 예상했습니다. 그래서 쉽게 문제를 해결한 줄 알았는데...
성능 재측정
TPS 7까지 성능이 소폭 개선되었지만, Grafana 메트릭 확인 결과 DB 커넥션 풀에서 여전히 병목이 발생하고 있었습니다. 심지어, 원래 결제 API 호출 이후 결과값을 결제 테이블에 저장하고 있었는데, 결제 정보를 저장하는 로직이 실행은 되지만 데이터베이스에 반영되지 않는 현상이 발생했습니다.
결과 분석
커넥션 풀에서 여전히 병목이 발생하는 것으로 보아, DB 커넥션을 반환하지 않은 채로 외부 API를 호출한다고 분석할 수 있습니다. TPS가 7까지 상승한 점, 그리고 결제 정보가 저장되지 않는 점에 대해서는 Spring 공식문서의 다음 문장이 도움이 되었습니다.
@TransactionalEventListener는 TransactionSynchronization을 기반으로 동작하는데, 이 때 이전 트랜잭션이 커밋 또는 롤백되어도 트랜잭션 자원(transactional resources)이 그대로 활성화되어 있기 때문에, DB 커넥션을 반환하지 않고, 이벤트 핸들러의 코드가 이미 커밋된 이전 트랜잭션에 참여(participate)하게 되어 변경 사항이 반영되지 않았던 것입니다.
TPS가 7까지 증가한 것은 핸들러 메서드가 실행하기 전에 DB 트랜잭션이 커밋되었기 때문입니다. 외부 API 호출 이전에 비관적 락이 해제되면서 동시에 10개(최대 커넥션 수)의 결제 요청을 진행할 수 있게 되었기에 10(요청)/1.4(s) = 약 7 TPS 까지 성능이 상승한 것입니다.
개선 2
비동기 결제
팀원과 머리를 맞대고 해결책을 생각하던 중, 이벤트 핸들러를 @Async를 이용해 비동기적으로 호출하면 DB 커넥션을 반환하고 API를 요청한다는 것을 알게 되었습니다. 그래서 결제를 비동기 방식으로 진행하는 시나리오를 시도해 보았습니다.
결제를 비동기 방식으로 진행하는 경우, 사용자의 결제 요청에 대해 위 그림처럼 상품 수량 확보 및 감소, 장바구니 목록 삭제 로직만 먼저 수행한 뒤 사용자에게 응답을 반환합니다. 이후 백그라운드에서 비동기적으로 결제를 처리하고, 성공/실패 시 별도의 채널(메일, SMS 등)을 통해 유저에게 결제 성공, 실패 여부를 알려주게 됩니다.
이벤트 핸들러 메서드에 @Async만 추가하여 성능 테스트를 해 보았습니다. 그 결과 TPS 33까지 성능이 비약적으로 상승하였습니다. 개선 전에는 동기적으로 결제가 완료될 때 까지 기다렸다가 응답했지만, 이제는 결제 API의 완료를 기다리지 않고 응답하기 때문에 응답 시간도 현저히 줄어들었습니다.
결제 밀림 현상
하지만 이 방식에도 문제가 있었습니다. 결제 작업이 점점 밀리는 현상이 나타났습니다.
위 SQL 쿼리 결과는 주문 수량을 차감한 시점과 결제 정보가 저장된 시점의 시간차를 초 단위로 출력한 것입니다. 초반에는 1, 2초대로 무난한 결과를 보이다가, 점점 증가하여 나중에는 사용자가 결제하고 6분이 지나서야 결제가 승인되는 현상이 발생했습니다.
비동기 결제를 위해 지정한 스레드풀에서 병목이 발생해 해당 문제가 발생했다고 추측했고, @Async 대신 WebClient가 반환하는 Mono 스트림을 Subscribe 하는 방식으로 해결해 보았습니다. JPA를 사용했기 때문에 결제 정보를 DB에 저장하는 로직이 blocking으로 동작하기 때문에 저장 로직은 BoundedElastic풀에 publish하였습니다.
@EventListener
@Counted("order.payment.request")
public void handle(final OrderCompleteEvent event) {
log.info("결제 요청 subscribe event: {}", event.getPaymentKey());
payOrder(event);
}
private void payOrder(final OrderCompleteEvent event) {
Order order = event.getOrder();
paymentClient.validatePayment(event.getPaymentKey(), order.getUuid(), order.getTotalPrice())
.publishOn(Schedulers.boundedElastic())
.doOnSuccess(ignore -> paymentService.handlePaySuccess(event))
.doOnError(error -> paymentService.handlePayError(event, error))
.subscribe();
}
이제 상품 수량 확보 및 장바구니 삭제 트랜잭션에서 결제 로직이 분리되었기 때문에, 결제가 실패하는 경우 확보해둔 상품 수량 복구와 장바구니 복구 로직을 따로 작성해야 했습니다. 해당 로직은 paymentService.handlePayError()에 정의되어 있습니다.
결제에 성공하는 경우에는 paymentService.handlePaySuccess()가 호출되어 결제 정보를 결제 테이블에 저장하게 됩니다.
좌측 그래프는 초당 처리량, 우측 그래프는 누적 처리량을 나타낸 그래프입니다. 최대 30TPS의 성능을 보이며, 주문 배치(상품 수량 확보, 주황 선)처리량을 결제 진행(하늘색 선) 처리량이 잘 따라가며, 결제가 밀리는 현상이 개선되었음을 확인할 수 있었습니다.
문제점
데모데이를 3일 앞두고, 조금 빡빡한 일정 속에서 저희 조를 담당하신 멘토님께 저희의 개선 방향이 올바른지 평가를 부탁드렸습니다. 토론 끝에 비동기 방식으로 결제를 진행하는 경우 사용자 경험 측면에서 단점이 크다고 판단하여 다시 동기 방식으로, 결제 API 요청이 끝난 뒤에 유저에게 결제 완료 응답을 반환하기로 결정했습니다.
마감이 얼마 남지 않아 로직을 변경하는게 부담스러웠지만, 그래도 꼭 해야 한다는 생각이 들어서 전체적인 구조 변경을 진행하기로 했습니다. 아래는 결제의 동작 방식에 대해 나누었던 이야기들입니다.
- 주문 내역 창에 들어갔는데, 내 결제가 계속 PENDING 상태이면 사용자가 불안하지 않을까? 언제 결제가 완료되는거지? 언제 배송이 시작되는거지?
- 결제 성능에 문제가 생겨 1시간 후에 결제가 진행되고 실패했다면, 사용자가 불편하지 않을까?
- 오프라인 결제라고 생각해 보자. 배추 1포기를 사서 집에 왔는데 나중에 결제가 실패해서 다시 가지고 오라고 한다면?
- 1.3초 정도는 사용자도 기다릴 만 하지 않나? 그러면 몇 초까지 기다릴 만 할까?
최종 개선
다시 동기 방식으로
기존의 도메인 이벤트 방식을 사용해서는 원하는 결과를 얻기 어렵다고 판단하여, 각각의 도메인 이벤트 핸들러 로직들을 모아 도메인 서비스들을 정의하였습니다. 결과적으로 조금 더 절차지향적으로 코드를 관리하게 되었고, 각각의 도메인 서비스에서 필요에 따라 트랜잭션을 선언하여 관리합니다.
// OrderService.java (Application Layer)
public void pay(final AuthPrincipal authPrincipal, final Long orderId, final OrderPayRequest request) {
orderPlaceService.place(authPrincipal, orderId);
orderPayService.pay(orderId, request.getPaymentKey());
}
애플리케이션 계층 서비스에서는 API 호출 기능 분리를 위해 트랜잭션을 선언하지 않고, request에 따라 올바른 도메인 서비스를 실행하는 역할만을 가집니다.
// OrderPlaceService.java (Domain Layer)
@Transactional
@Counted("order.order")
public void place(final AuthPrincipal authPrincipal, final Long orderId) {
log.info("주문 수량 확보 및 장바구니 삭제 memberId: {} orderId: {}", authPrincipal.getId(), orderId);
Order order = getOrderById(authPrincipal.getId(), orderId);
order.place();
subtractProductQuantity(order);
deleteCartItems(order);
}
OrderPlaceService는 상품 수량 확보 및 장바구니 항목을 삭제하는 역할을 수행합니다. 첫 번째 트랜잭션을 시작하고 트랜잭션 내에서 주문 상태를 변경하고, 상품 수량을 차감하고, 장바구니 항목을 삭제합니다. 해당 로직이 수행된 후 첫 번째 트랜잭션은 완료(커밋 or 롤백) 됩니다.
// OrderPayService.java (Domain Layer)
@Counted("order.payment.request")
public void pay(final Long orderId, final String paymentKey) {
log.info("결제 요청 subscribe event: {}", paymentKey);
Order order = orderRepository.findById(orderId).orElseThrow(OrderNotFoundException::new);
paymentClient.validatePayment(paymentKey, order.getUuid(), order.getTotalPrice())
.publishOn(Schedulers.boundedElastic())
.doOnSuccess(ignore -> payResultHandler.save(orderId, paymentKey))
.doOnError(error -> payResultHandler.rollback(orderId, error))
.onErrorMap(IllegalArgumentException.class, InvalidPayRequestException::new)
.onErrorMap(IllegalStateException.class, PayFailedException::new)
.block(); // .subscribe()를 block()으로 변경
}
OrderPayService는 주문 건에 대해 외부 결제 API에 대해 승인 요청을 보내는 역할을 수행하는 도메인 서비스입니다. 외부 API 요청을 위해 @Transactional 어노테이션 없이 동작하며, 결제 결과에 대한 처리 역할은 PayResultHandler 클래스가 맡게 됩니다. 기존 코드에서 .subscribe()로 수행하던 부분을 .block()으로 변경했을 뿐입니다.
사실 boundedElastic을 사용하지 않고 아래 코드처럼 validatePayment() 결과 스트림을 바로 block하여 try-catch를 활용해 서블릿 스레드에서 나머지 결과 처리 작업을 수행했다면 스레드를 아낄 수 있었을 것 같습니다.
try {
paymentClient.validatePayment(...).block();
payResultHandler.save(orderId, paymentKey);
} catch (final Exception error) {
log.error(...);
payResultHandler.rollback(orderId, error);
}
당시에는 데모데이까지 시간이 별로 없었기 때문에 가장 간단하게 변경할 수 있는 방법을 택했다고 생각했는데, 사실 몇 줄 바뀌지 않지만 그냥 바꿀 걸 하는 아쉬움이 남습니다. 적용해보고 성능 테스트를 해보지 못한게 가장 아쉽습니다.
// PayResultHandler.java (Domain Layer)
@Transactional
@Counted("order.payment.success")
public void save(final Long orderId, final String paymentKey) {
log.info("결제 성공 orderId: {}, paymentKey={}", orderId, paymentKey);
Order order = findOrderById(orderId);
order.pay();
paymentSaveService.save(orderId, order.getTotalPrice(), paymentKey);
}
@Transactional
@Counted("order.payment.failure")
public void rollback(final Long orderId, final Throwable error) {
log.error("결제 실패 복구 시작 orderId: {}, message={}", orderId, error.getMessage());
Order order = findOrderById(orderId);
order.cancel();
rollbackCartItems(order);
rollbackProducts(order);
}
@Transactional 내부 호출 문제를 피하기 위해 OrderPayService와 PayResultHandler 클래스를 분리하였습니다. PayResultHandler에서 트랜잭션을 관리하고, 두번째 트랜잭션에서 성공/실패에 따라 DB 변경 작업을 수행하게 됩니다.
최종 성능 측정 결과
최종적으로 `vUser 20`명 기준 `평균 14.6 TPS`까지 성능을 개선하는 데 성공했습니다.
이후 `vUser 120`명까지 늘려 테스트한 결과 `평균 약 84 TPS` 정도가 측정되었고, Load Average도 어느 정도 버티는 모습을 보여 주었습니다.
마치며
비관적 락을 사용하지 않아도 될까?
주문 결제 시 상품 수량을 차감할 때 Second Lost Update Problem 이슈로 인해 재고 수량에 오차가 생기는 것을 막기 위해 비관적 락을 채용했습니다.
SELECT (...) FROM products FOR UPDATE
...
UPDATE products SET quantity = :quantity WHERE (...)
다른 팀들도 비슷한 방식으로 비관적 락을 이용해 이 문제를 해결했는데, 이 부분에 대해 데모데이 때 부스에 방문하신 우아한 형제들 개발자분들께서 비관적 락을 굳이 사용할 필요 없을 것 같다는 피드백을 다른 팀 부스에서 해 주셨다고 합니다. 핵심은 굳이 SELECT를 하지 않는 것이었습니다.
UPDATE products SET quantity = quantity - :difference WHERE (...)
위 쿼리를 사용하면 굳이 비관적 락을 사용하여 SELECT하지 않아도 Second Lost Update Problem를 막을 수 있을 것 같습니다. 하지만 이렇게 처리하는 경우 여러 트랜잭션이 동시에 위 쿼리를 실행하는 경우 `product` 테이블의 `quantity`가 음수까지 감소할 수 있는 문제가 있습니다.
해당 문제는 CHECK Constraint를 활용하여 `quantity`가 음수인 경우 예외를 반환하도록 하여 해결할 수 있을 것 같습니다.
ALTER TABLE products ADD CONSTRAINT check_quantity CHECK (quantity >= 0);
조금 더 생각해 볼 만한 것들
외부 결제 API 서버가 터진다면
현재 프로젝트에 결제 수단인 토스페이밖에 없기 때문에, 토스페이 API에서 장애가 발생하면 든든킷 서비스에서는 결제를 할 수 있는 방법이 없습니다. SPOF(Single Point of Failure) 문제를 피하기 위해 여러 결제 수단을 추가하여, 한 결제 수단에 장애가 생겨도 다른 결제 수단을 통해 사용자가 결제할 수 있도록 유도할 수 있습니다.
어떤 결제 수단에 장애가 발생했는지를 판단할 수 있는 정책을 세워 두는 것도 중요합니다. 예를 들어 하나의 결제 수단에 대해 연속된 n건의 요청이 실패하는 경우(잔액 부족 등의 사유를 제외하고, 예를 들어 5XX 에러가 발생하는 경우), 해당 결제 수단에 장애가 발생했다고 판단하고 서킷 브레이킹을 시도할 수 있습니다. 또 장애가 발생한 결제 수단에 대해 프론트엔드 UI에서도 결제할 수 없도록 사용자에게 알려 다른 결제 수단으로 유도할 수 있습니다.
비동기 방식 결제는 꼭 나쁠까?
불편한 UX를 이유로 저희는 프로젝트에서 비동기 방식으로 나중에 백그라운드에서 결제하던 방식을 포기하고, 결제 과정을 동기적으로 수행하도록 변경했습니다. 이와 관련해서 데모데이 부스 운영 시간에 다른 조를 담당하셨던 멘토분께서 조언을 많이 해 주셨습니다.
한국에서 운영하는 커머스는 대부분 타겟이 한국인이기 때문에, 한국 사용자들에게 가장 편안한 UX를 제공해야 합니다. 대부분의 한국 커머스 서비스는 결제를 동기 방식으로 진행하고, 한국인들이 이런 방식에 편안함을 느끼기 때문에, 마지막에 결제 방식을 변경한 저희의 판단이 옳았다는 말씀을 해 주셨습니다.
다만 엄청난 트래픽을 견뎌야해서 결제의 신뢰성보다 가용성이 더 중요한 경우에는 때에 따라 비동기 방식으로 결제를 나중에 처리하는 방식의 설계를 하는 경우도 있다고 말씀하셨습니다. 예로 들어 주신 경우가 중국이었는데, 중국의 경우 데이터의 정확성보다 14억의 트래픽을 견디는 트래픽이 중요하기 때문에, 실제로 결제를 비동기로 처리하는 사례를 본 적이 있다고 합니다.
상황에 따라, 사용자에 따라 정말 많은 고민을 해야하고, Silver Bullet은 존재하지 않는다는 말이 크게 와닿은 조언이었습니다.
'Project > 우아한테크캠프 - 든든킷' 카테고리의 다른 글
성능 테스트를 위해 외부와 의존성 분리하기 (0) | 2024.01.10 |
---|---|
Github Actions, CodeDeploy로 Spring Application CI/CD 구축하기 (0) | 2024.01.10 |