김영한 자바 고급 편 완강 기념(?)으로 이번 시간에는 예전에 결제 시스템 분석하면서 만들어봤던 샘플 코드를 리펙토링 해보려고 한다. 비동기 + 멀티스레드 중심으로 리팩터링 할 요소를 찾아보겠다.
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 후기
비동기 멀티스레딩... 어렵다... 작년에 모 회사에서 인턴으로 근무할 당시, 사내 알림 서비스를 카프카로 재설계하는 업무를 맡았었습니다. 기존 서비스는 특정 시간이 되어 배치 작업이 실행
dev-jhl.tistory.com
GitHub - hyerijang/simple-payment-system: 개인 프로젝트- 간단 결제 시스템
개인 프로젝트- 간단 결제 시스템. Contribute to hyerijang/simple-payment-system development by creating an account on GitHub.
github.com
간단 결제 시스템 구현 : (1) 결제 시스템 분석해보기
인턴 퇴사후 백수 2주차, 슬슬 뭐라도 해야겠다 마음을 굳히며 원티드를 기웃거리던 도중 "기술과제 합격률을 높이는 예외처리와 테스트 전략" 이라는 백엔드 챌린지를 진행 중인걸 발견했습
dev-jhl.tistory.com
아키텍처 살펴보기

만든지 몇 달 지난 프로젝트라 다시 한번 복습해 봤다. (자세한 사항은 결제 시스템 분석해보기 참조.)
우선, 내가 만든 간단 결제 시스템은 위 그림에서 "결제 서비스 + 결제 실행자" 역할을 한다. 지갑, 원장 시스템을 생략한 상태이므로 결제 서비스와 결제 실행자를 굳이 구분하지 않았다.
- 결제 서비스는 결제 이벤트를 수신하고, 다른 내부 시스템 (원장, 지갑)과 통신하며 결제 프로세스를 조율.
- 결제 실행자는 지급지시서(PaymentOrder)를 관리하면서 실제 결제(Payment)를 수행
개발을 하다 보면 Payment , PaymentOrder 단어가 비슷해서 계속 헷갈리린다. 사실 어느 정도 겹치는 부분도 있는 것 같다. 구글에 검색해 보니 "지급지시서는 우리가 요청하는 금액, 돈이 도착하는 곳의 정보가 적힌 문서, 또 이렇게 적힌 대로 돈을 보내고 받게 해달라고 요구하는 지시, 지시서"라고 한다, 좀 더 큰 개념? 인 것 같다.
- 지급지시서(PaymentOrder) : 주문번호, 제품명, 결제수단(카드/계좌이체/네이버페이... ), 결제금액
- 결제(Payment) : 지불 방식 (카드/계좌이체/네이버페이 ... ) , 결제 상태(준비, 완료, 실패, 취소), 구매자 ID, 지불년월

또한, PG사를 활용한 결제 시스템은 대부분 위와 같은 구조를 갖게 된다. (프론트엔드 쪽은 PG사에서 제공하는 연동 가이드를 참고해서 그대로 가져다 쓰면 된다. 웹훅 등을 써서 비동기로 잘 만들어져 있다.) 참고로 "결제 창"은 왼쪽 이미지 같은 각 PG사에서 제공하는 결제 창이다.

여기에서 백엔드 개발자가 직접 구현하는 부분은 5. 결제 요청, 8. 결제 결과 처리 정도가 있다.
5. 결제 요청
- 입력 정보를 바탕으로 지급지시서(paymentOrder) 생성한다.
8. 결제 결과 처리
- PG사로부터 전달받은 결제(payment) 결과를 검증하고 처리한다.
결제 요청의 경우 단순히 전달받은 정보를 paymentOrder에 담아 저장하기만 하므로, 바로 8번으로 넘어가겠다. 원래 내 코드에서 결제 결과 처리는 다음과 같이 구성되어 있다.
private void processPayment(String impUid, String merchantUid) {
// 1. 포트원 API 엑세스 토큰 발급
String accessToken = portOneService.getAccessToken().block();
// 2. 포트원 결제내역 단건조회 API 호출
PortOnePaymentResponse payment = portOneService.getPaymentMono(impUid, accessToken).block();
// 3. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교하여 검증
PaymentOrder paymentOrder = paymentOrderService.findByMerchantUid(merchantUid);
BigDecimal amount = payment.getResponse().getAmount(); // 실제 결제 된 금액
BigDecimal amountToBePaid = paymentOrder.getAmount(); // 결제 되어야하는 금액
verifyPayment(merchantUid, amount, amountToBePaid, payment, paymentOrder);
}
비동기스럽게 Mono 쓰기
액세스 토큰 발급과 결재내역 단건조회 시에는 외부 API를 호출하게 된다. 예전에는 Mono... 그거 비동기 맞지? 하면서 아무 생각 없이 썼는데, 지금 보니 코드 중간에 block()이 보인다. 뭔가 싸한 느낌이 든다.
Mono.block()?
@Nullable
publicT block()
Subscribe to this Mono and block indefinitely until a next signal is received. Returns that value, or null if the Mono completes empty. In case the Mono errors, the original exception is thrown (wrapped in a RuntimeException if it was a checked exception).
이 Mono를 구독하고 다음 신호를 받을 때까지 무기한으로 블록 합니다. 그 값을 반환하거나, Mono가 빈 상태로 완료되면 null을 반환합니다. Mono가 에러를 발생시키는 경우, 원래의 예외가 (체크된 예외인 경우 RuntimeException으로 래핑 되어) 던져집니다.

아니나 다를까.. block을 하면 무기한으로 블록 하게 된다고 한다. 즉, 결과를 응답받은 뒤에야 다음 코드를 실행한다. 지금까지 Mono를 동기처럼 쓰고 있었다! 코드 수정을 위해 chat gpt한테 어떻게 수정하면 좋을지 물어봤다
block() 메서드를 사용하지 않고 비동기 방식으로 처리하려면 Mono 또는 Flux의 비동기 메서드를 사용해야 합니다. 예를 들어, flatMap을 사용하여 비동기 체인을 만들 수 있습니다.
음.... flatMap을 활용해서 비동기 체인을 만들 수 있다고 한다. 바로 적용해 봄.
private void processPaymentAsync1(String impUid, String merchantUid) {
long start = System.currentTimeMillis(); // FIXME 수행시간 측정 위한 임시 코드
// 1. 포트원 API 엑세스 토큰 발급
Mono<String> accessTokenMono = portOneService.getAccessToken();
// 2. 포트원 결제내역 단건조회 API 호출
accessTokenMono.flatMap(accessToken -> portOneService.getPaymentMono(impUid, accessToken)).flatMap(payment -> {
// 3. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교하여 검증
PaymentOrder paymentOrder = paymentOrderService.findByMerchantUid(merchantUid);
BigDecimal amount = payment.getResponse().getAmount(); // 실제 결제 된 금액
BigDecimal amountToBePaid = paymentOrder.getAmount(); // 결제 되어야하는 금액
verifyPayment(merchantUid, amount, amountToBePaid, payment, paymentOrder);
return Mono.empty();
})
.doOnTerminate(() -> log.info("completeAsync1 수행시간 : {}ms",
System.currentTimeMillis() - start)) // FIXME 수행시간 측정 위한 임시 코드
.subscribe();
}
수행시간 측정 결과
이전 코드 / 변경한 코드의 수행시간을 비교해 봤다. 테스트 코드 짜보려다가 그냥 간단하게 로그 찍어봐서 평균값을 구했다. (테스트 코드는 실제 핸드폰으로 결제하는 부분을 대체하려면 너무 일이 커져서 포기.... )
- 기존 코드 응답시간 : 378ms
- block 제거 후 응답 시간 : 251ms
Mono를 제대로 쓰니까 토큰발급과 단건조회가 동시에 이루어져서 확실히 빨라졌다. (33.6% 향상)
후기
사실 이 글에서 결제 시스템을 비동기 + 멀티스레드로 리펙토링 해보겠다! 했을 때는, ThreadPoolTaskExecutor 라던가.. Future를 쓰는 걸 상상했는데 ㅋㅋ
생각해 보니까
- 이미 스프링은 기본적으로 스레드 풀 생성해서 요청 하나당 스레드 하나를 할당받으므로 이미 멀티스레드 + 지금 상황에서는 요청 당 한 스레드면 충분함
- 비동기 API 통신은 Spring WebFlux라던가 워낙 잘 만들어져 있어서 그냥 가져다 쓰면 됨.
뭔가 복잡한 코드면 비동기로 할만한 게 더 있을지도 모르겠는데, 내가 만든 결제 처리 메서드는 너무 간단해서 진짜 필수 기능 (검증)만 들어있었기 때문에 딱히 비동기로 리팩터링 할 게 없었다. 지갑이나 원장 시스템을 추가해야 임계영역 신경 쓰면서 개발할 게 생길듯 싶다.
공부만 열심히 하고 할게 없을 뻔했는데, 다행히(?) 예전의 내가 실수한 걸 발견한 덕분에 ㅋㅋ 리펙토링 할게 하나 생겼다. 신난다 확실히 아는 만큼 보인다고, 공부하고 나니 예전에는 몰라서 넘어갔던걸 발견해서 수정할 수 있게 되었다.
'프로젝트 > 간단 결제 시스템 (개인)' 카테고리의 다른 글
간단 결제 시스템 구현 : (1) 결제 시스템 분석해보기 (1) | 2024.12.10 |
---|
김영한 자바 고급 편 완강 기념(?)으로 이번 시간에는 예전에 결제 시스템 분석하면서 만들어봤던 샘플 코드를 리펙토링 해보려고 한다. 비동기 + 멀티스레드 중심으로 리팩터링 할 요소를 찾아보겠다.
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 후기
비동기 멀티스레딩... 어렵다... 작년에 모 회사에서 인턴으로 근무할 당시, 사내 알림 서비스를 카프카로 재설계하는 업무를 맡았었습니다. 기존 서비스는 특정 시간이 되어 배치 작업이 실행
dev-jhl.tistory.com
GitHub - hyerijang/simple-payment-system: 개인 프로젝트- 간단 결제 시스템
개인 프로젝트- 간단 결제 시스템. Contribute to hyerijang/simple-payment-system development by creating an account on GitHub.
github.com
간단 결제 시스템 구현 : (1) 결제 시스템 분석해보기
인턴 퇴사후 백수 2주차, 슬슬 뭐라도 해야겠다 마음을 굳히며 원티드를 기웃거리던 도중 "기술과제 합격률을 높이는 예외처리와 테스트 전략" 이라는 백엔드 챌린지를 진행 중인걸 발견했습
dev-jhl.tistory.com
아키텍처 살펴보기

만든지 몇 달 지난 프로젝트라 다시 한번 복습해 봤다. (자세한 사항은 결제 시스템 분석해보기 참조.)
우선, 내가 만든 간단 결제 시스템은 위 그림에서 "결제 서비스 + 결제 실행자" 역할을 한다. 지갑, 원장 시스템을 생략한 상태이므로 결제 서비스와 결제 실행자를 굳이 구분하지 않았다.
- 결제 서비스는 결제 이벤트를 수신하고, 다른 내부 시스템 (원장, 지갑)과 통신하며 결제 프로세스를 조율.
- 결제 실행자는 지급지시서(PaymentOrder)를 관리하면서 실제 결제(Payment)를 수행
개발을 하다 보면 Payment , PaymentOrder 단어가 비슷해서 계속 헷갈리린다. 사실 어느 정도 겹치는 부분도 있는 것 같다. 구글에 검색해 보니 "지급지시서는 우리가 요청하는 금액, 돈이 도착하는 곳의 정보가 적힌 문서, 또 이렇게 적힌 대로 돈을 보내고 받게 해달라고 요구하는 지시, 지시서"라고 한다, 좀 더 큰 개념? 인 것 같다.
- 지급지시서(PaymentOrder) : 주문번호, 제품명, 결제수단(카드/계좌이체/네이버페이... ), 결제금액
- 결제(Payment) : 지불 방식 (카드/계좌이체/네이버페이 ... ) , 결제 상태(준비, 완료, 실패, 취소), 구매자 ID, 지불년월

또한, PG사를 활용한 결제 시스템은 대부분 위와 같은 구조를 갖게 된다. (프론트엔드 쪽은 PG사에서 제공하는 연동 가이드를 참고해서 그대로 가져다 쓰면 된다. 웹훅 등을 써서 비동기로 잘 만들어져 있다.) 참고로 "결제 창"은 왼쪽 이미지 같은 각 PG사에서 제공하는 결제 창이다.

여기에서 백엔드 개발자가 직접 구현하는 부분은 5. 결제 요청, 8. 결제 결과 처리 정도가 있다.
5. 결제 요청
- 입력 정보를 바탕으로 지급지시서(paymentOrder) 생성한다.
8. 결제 결과 처리
- PG사로부터 전달받은 결제(payment) 결과를 검증하고 처리한다.
결제 요청의 경우 단순히 전달받은 정보를 paymentOrder에 담아 저장하기만 하므로, 바로 8번으로 넘어가겠다. 원래 내 코드에서 결제 결과 처리는 다음과 같이 구성되어 있다.
private void processPayment(String impUid, String merchantUid) {
// 1. 포트원 API 엑세스 토큰 발급
String accessToken = portOneService.getAccessToken().block();
// 2. 포트원 결제내역 단건조회 API 호출
PortOnePaymentResponse payment = portOneService.getPaymentMono(impUid, accessToken).block();
// 3. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교하여 검증
PaymentOrder paymentOrder = paymentOrderService.findByMerchantUid(merchantUid);
BigDecimal amount = payment.getResponse().getAmount(); // 실제 결제 된 금액
BigDecimal amountToBePaid = paymentOrder.getAmount(); // 결제 되어야하는 금액
verifyPayment(merchantUid, amount, amountToBePaid, payment, paymentOrder);
}
비동기스럽게 Mono 쓰기
액세스 토큰 발급과 결재내역 단건조회 시에는 외부 API를 호출하게 된다. 예전에는 Mono... 그거 비동기 맞지? 하면서 아무 생각 없이 썼는데, 지금 보니 코드 중간에 block()이 보인다. 뭔가 싸한 느낌이 든다.
Mono.block()?
@Nullable
publicT block()
Subscribe to this Mono and block indefinitely until a next signal is received. Returns that value, or null if the Mono completes empty. In case the Mono errors, the original exception is thrown (wrapped in a RuntimeException if it was a checked exception).
이 Mono를 구독하고 다음 신호를 받을 때까지 무기한으로 블록 합니다. 그 값을 반환하거나, Mono가 빈 상태로 완료되면 null을 반환합니다. Mono가 에러를 발생시키는 경우, 원래의 예외가 (체크된 예외인 경우 RuntimeException으로 래핑 되어) 던져집니다.

아니나 다를까.. block을 하면 무기한으로 블록 하게 된다고 한다. 즉, 결과를 응답받은 뒤에야 다음 코드를 실행한다. 지금까지 Mono를 동기처럼 쓰고 있었다! 코드 수정을 위해 chat gpt한테 어떻게 수정하면 좋을지 물어봤다
block() 메서드를 사용하지 않고 비동기 방식으로 처리하려면 Mono 또는 Flux의 비동기 메서드를 사용해야 합니다. 예를 들어, flatMap을 사용하여 비동기 체인을 만들 수 있습니다.
음.... flatMap을 활용해서 비동기 체인을 만들 수 있다고 한다. 바로 적용해 봄.
private void processPaymentAsync1(String impUid, String merchantUid) {
long start = System.currentTimeMillis(); // FIXME 수행시간 측정 위한 임시 코드
// 1. 포트원 API 엑세스 토큰 발급
Mono<String> accessTokenMono = portOneService.getAccessToken();
// 2. 포트원 결제내역 단건조회 API 호출
accessTokenMono.flatMap(accessToken -> portOneService.getPaymentMono(impUid, accessToken)).flatMap(payment -> {
// 3. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교하여 검증
PaymentOrder paymentOrder = paymentOrderService.findByMerchantUid(merchantUid);
BigDecimal amount = payment.getResponse().getAmount(); // 실제 결제 된 금액
BigDecimal amountToBePaid = paymentOrder.getAmount(); // 결제 되어야하는 금액
verifyPayment(merchantUid, amount, amountToBePaid, payment, paymentOrder);
return Mono.empty();
})
.doOnTerminate(() -> log.info("completeAsync1 수행시간 : {}ms",
System.currentTimeMillis() - start)) // FIXME 수행시간 측정 위한 임시 코드
.subscribe();
}
수행시간 측정 결과
이전 코드 / 변경한 코드의 수행시간을 비교해 봤다. 테스트 코드 짜보려다가 그냥 간단하게 로그 찍어봐서 평균값을 구했다. (테스트 코드는 실제 핸드폰으로 결제하는 부분을 대체하려면 너무 일이 커져서 포기.... )
- 기존 코드 응답시간 : 378ms
- block 제거 후 응답 시간 : 251ms
Mono를 제대로 쓰니까 토큰발급과 단건조회가 동시에 이루어져서 확실히 빨라졌다. (33.6% 향상)
후기
사실 이 글에서 결제 시스템을 비동기 + 멀티스레드로 리펙토링 해보겠다! 했을 때는, ThreadPoolTaskExecutor 라던가.. Future를 쓰는 걸 상상했는데 ㅋㅋ
생각해 보니까
- 이미 스프링은 기본적으로 스레드 풀 생성해서 요청 하나당 스레드 하나를 할당받으므로 이미 멀티스레드 + 지금 상황에서는 요청 당 한 스레드면 충분함
- 비동기 API 통신은 Spring WebFlux라던가 워낙 잘 만들어져 있어서 그냥 가져다 쓰면 됨.
뭔가 복잡한 코드면 비동기로 할만한 게 더 있을지도 모르겠는데, 내가 만든 결제 처리 메서드는 너무 간단해서 진짜 필수 기능 (검증)만 들어있었기 때문에 딱히 비동기로 리팩터링 할 게 없었다. 지갑이나 원장 시스템을 추가해야 임계영역 신경 쓰면서 개발할 게 생길듯 싶다.
공부만 열심히 하고 할게 없을 뻔했는데, 다행히(?) 예전의 내가 실수한 걸 발견한 덕분에 ㅋㅋ 리펙토링 할게 하나 생겼다. 신난다 확실히 아는 만큼 보인다고, 공부하고 나니 예전에는 몰라서 넘어갔던걸 발견해서 수정할 수 있게 되었다.
'프로젝트 > 간단 결제 시스템 (개인)' 카테고리의 다른 글
간단 결제 시스템 구현 : (1) 결제 시스템 분석해보기 (1) | 2024.12.10 |
---|