저번 시간에는 아이템의 효과를 어떻게 적용할 것인지에 대한 포스팅을 해보았다면, 이번에는 아이템을 구매하는 로직에 대한 내용을 포스팅하려고 한다.
투두몬 서비스에는 ⭐️포인트로 구매할 수 있는 아이템과 실결제를 통해 구매할 수 있는 아이템이라는 2가지 종류의 아이템이 있다. 처음에는 이들 각각을 따로 보지 않고, 동일한 '구매' 로직으로 보고 같은 절차를 따라야 한다고 생각했다. 처음 생각한 시나리오는 다음과 같다.
포트원에 대한 내용은 과감히 생략하겠다. 공식문서나 다양한 블로그에서 이미 충분히 자세하게 다루고 있다.
예상 시나리오
- 아이템 타입에 맞게 사전검증을 수행한다.
- 아이템 유효성을 확인한다 ex) 아이템이 존재하는지, 유저가 구매 가능한 상태인지
- 검증을 수행한다.
StarPoint
의 경우- 현재 소유하고 있는 starPoint의 수와 아이템의 가격을 비교하여 구매 가능한지 여부를 확인한다.
Premium
의 경우- 결제 게이트웨이 API를 호출하여 예상 결제 금액을 포트원에 등록한다.(사전 검증 API 호출)
- 결제 수단이 유효하지 않거나 잔액이 부족한 경우, 결제를 거부하고 400 에러를 반환한다.
- 검증이 완료되면 주문서(
Order
)를 작성한다 (결제 전에 가주문 데이터를 생성)
- 클라이언트가 상점에서 아이템을 구매(
purchase
)한다.StarPoint
아이템 -> 별다른 작업 XPremium
아이템 -> pg 사를 통해 결제 인증 & 결제 요청
- 사후검증을 수행한다
StarPoint
의 경우- 유저의 ⭐️아이템 구매 기록의 결제 금액과 DB에 등록된 결제 금액이 값과 일치하는지 확인한다
- 검증이 완료되면
StarPointPaymentHistory
라는 구매 이력을 데이터베이스에 저장한다
Premium
의 경우- 포트원에 등록된 결제 금액과 DB에 등록된 결제 금액이 일치하는지 확인한다. (사후 검증 API 호출)
- 검증이 완료되면
TodomonPayment
라는 구매 이력을 데이터베이스에 저장한다. 이는 사후 검증 API의 응답값을 기반으로 생성된다.
- 이후 아이템 타입(이전 포스팅에서 다루었음)에 따라 해당 아이템을 유저에게 지급하거나 효과를 적용한다.
- 후속 처리 및 알림
- 구매 후 이메일이나 푸시 알림을 통해 구매가 완료되었음을 알린다
두 가지 방식 모두 사전 검증과 사후 검증이 필요하므로, 이를 효율적으로 관리할 수 있도록 전략 패턴을 도입하는 것이 좋다고 느꼈다. 다음은 초기 구현 코드이다.
객체지향적인 설계를 위한 Strategy 패턴 도입
포트원 APi를 호출하는 부분은 iamport-rest-client-java라는 라이브러리를 사용하였다.
PurchaseStrategyFactory
@Service
public class PurchaseStrategyFactory {
private final Map<MoneyType, PurchaseStrategy> strategyMap;
// 모든 PurchaseStrategy 구현체들을 주입받아 Map에 저장
public PurchaseStrategyFactory(List<PurchaseStrategy> strategies) {
strategyMap = strategies.stream()
.collect(Collectors.toMap(this::getStrategyType, strategy -> strategy));
}
// MoneyType 맞는 전략 반환
public PurchaseStrategy getStrategy(MoneyType moneyType) {
return strategyMap.get(moneyType);
}
// 각 전략 구현체가 처리할 MoneyType을 매핑하는 메서드
private MoneyType getStrategyType(PurchaseStrategy strategy) {
if (strategy instanceof RealMoneyPurchaseStrategy) {
return MoneyType.REAL_MONEY;
} else if (strategy instanceof StarPointPurchaseStrategy) {
return MoneyType.STARPOINT;
} else {
throw new IllegalArgumentException("지원되지 않는 전략입니다.");
}
}
}
PurchaseStrategy
public interface PurchaseStrategy {
void preValidate(Member member, Item item, PreparePaymentRequest req) throws Exception;
void postValidate(Member member, Order order, PaymentRequest req) throws Exception;
}
RealMoneyPurchaseStrategy
@RequiredArgsConstructor
public class RealMoneyPurchaseStrategy implements PurchaseStrategy {
private final IamportClient iamportClient;
private final PaymentRepository paymentRepository;
@Override
public void preValidate(Member member, Item item, PreparePaymentRequest req) throws Exception {
if (!item.getMoneyType().equals(MoneyType.STARPOINT)) throw new BadRequestException(ErrorCode.BAD_REQUEST);
PrepareData prepareData = new PrepareData(req.getMerchant_uid(), req.getAmount());
IamportResponse<Prepare> response = iamportClient.postPrepare(prepareData);
// IMP.request_pay()에 전달된 merchant_uid 가 일치하는 주문의 결제금액이 다를 경우 PG사 결제창 호출이 중단됨.
if (response.getCode() != 0) {
throw new InternalServerException(ErrorCode.INTERNAL_ERROR, "결제 정보 사전 등록 중 에러" + response.getMessage());
}
}
@Override
public void postValidate(Member member, Order order, PaymentRequest req) throws Exception {
IamportResponse<Payment> response = iamportClient.paymentByImpUid(req.getImp_uid());
Payment payment = response.getResponse();
if (!Objects.equals(payment.getAmount(), order.getTotalPrice())) {
throw new BadRequestException(ErrorCode.INVALID_PAYMENT_AMOUNT_ERROR);
}
TodomonPayment todomonPayment = TodomonPayment.builder()
.status(PaymentStatus.OK)
.impUid(req.getImp_uid())
.order(order)
.build();
// 결제 정보 저장
paymentRepository.save(todomonPayment);
}
}
StarPointPurchaseStrategy
@RequiredArgsConstructor
public class StarPointPurchaseStrategy implements PurchaseStrategy {
private final StarPointPaymentHistoryRepository starPointPaymentHistoryRepository;
@Override
public void preValidate(Member member, Item item, PreparePaymentRequest req) throws Exception {
if (isNotStarPointItem(item)) throw new BadRequestException(ErrorCode.BAD_REQUEST);
long totalPrice = item.getPrice() * req.getQuantity();
if (member.getStarPoint() < totalPrice) {
throw new BadRequestException(ErrorCode.NOT_ENOUGH_STAR_POINT);
}
}
private static boolean isNotStarPointItem(Item item) {
return !item.getMoneyType().equals(MoneyType.STARPOINT);
}
@Override
public void postValidate(Member member, Order order, PaymentRequest req) throws Exception {
if (isNotStarPointItem(order.getItem())) throw new BadRequestException(ErrorCode.BAD_REQUEST);
StarPointPaymentHistory findPaymentHistory = starPointPaymentHistoryRepository
.findByMember_IdAndMerchantUid(member.getId(), req.getMerchant_uid())
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_STAR_POINT_PAYMENT_HISTORY));
if (!Objects.equals(findPaymentHistory.getAmount(), order.getTotalPrice())) {
throw new BadRequestException(ErrorCode.INVALID_PAYMENT_AMOUNT_ERROR);
}
member.subtractStarPoint(order.getTotalPrice());
}
}
결제 유형에 따른 분리
처음에는 ⭐️포인트를 통한 구매와 실결제를 통한 구매가 모두 동일한 시퀀스를 따른다고 생각하고 전략패턴을 도입하였다. 하지만, 실제로 유저 입장에서 테스트를 해보니..
- ⭐️포인트 구매 과정이 불필요하게 길다.
- 실결제의 경우는 정합성을 위해서는 필요한 과정이었지만, ⭐️포인트 아이템의 경우 이렇게까지 정합성을 챙길 필요가 있을까 싶었다.
- 만약 ⭐️포인트 불일치 등의 문의가 생기면 유저측에서 실제 돈을 쓴 것도 아니니 롤백을 해주기만 하면 되는 문제일 것이다.
- 기록(
StarPointPurchaseHistory
)만 잘 남기는 것으로 충분하다고 느꼈다. - => 불필요한 사전 검증 및 사후 검증 작업을 없애자. 오직 구매 API만 남기자
- 위의 연장선으로 ⭐️포인트 구매 시에는
Order
데이터를 남길 필요가 없게 된다. 구매 이력(StarPointPurchaseHistory
)만 잘 남기자 - 아이템을 구매할 때 메일을 통해 알림을 주는 것은 실결제 아이템으로 충분하다고 느꼈다.
- 매번 펫을 위한 먹이를 구매할 때마다 메일로 알림을 주는 것은 불필요하다..
이러한 점들 때문에 나는 애써 도입했던 전략패턴을 버리고.. 다시 분리하는 작업을 거쳤다.
분리된 결제 과정을 간략히 설명하면 다음과 같다.
결제 진행 과정
- 상점에서 클라이언트가 결제 버튼 누름
- 사전 검증 수행 및 주문서 생성
- (사전검증 성공시) 프론트 측에서 pg사 결제 진행
- 결제 완료 시 결제 결과 서버측으로 넘김
- 사후 검증 수행 및 결제 데이터 저장, 아이템 구매 로직 수행
- 클라이언트에게 결제 완료 이메일 전송
별포인트 아이템 구매 로직
- 유저가 구매 요청
- 서버에서 구매 가능한 상태 인지 검증 후, 구매 이력을 생성하고 아이템 구매 로직 수행
더 견고한 결제 시스템을 위해..
실제 서비스 제공을 위한 결제를 다뤄본 적이 없다보니 처음에는 단순히 기능만 제공해주면 pg 사에서 알아서 잘 처리해주는 것이라고 생각했었다.
하지만, 직접 서비스를 만들고 테스트를 하는 과정에서 내 예상과 다르게 수행되는 부분이 많았다. 대표적으로 다음과 같은 문제가 있었다.
- 결제가 완료되었다고 메일이 왔는데, 실제로는 서버 오류로 인해 결제가 되지 않고 '결제 요청' 상태로 되어 있었다.
- 결제까지는 수행하고 그 즉시 창을 닫아보니 아이템이 지급되지 않고 결제 정보도 없는 경우 등이 있었다.
첫번째의 경우는 트랜잭션 경계가 불분명하고, 에러 발생 시 적절한 롤백 및 결제 취소 처리를 해주지 않아서 발생하는 문제이다.
두번째의 경우는 유저가 결제 이후 사후 검증 API를 호출해야 하는데, 이 시간을 주지 않고 즉시 창을 닫아서 발생하는 문제이다.
이를 해결하기 위해서는 트랜잭션 경계를 명확히하여 적절한 결제 취소 처리를 해주어야 할 것이며, 클라이언트가 직접 사후 검증 API를 호출하지 않도록 웹훅(Webhook)을 사용할 필요가 있었다. 순차적으로 하나씩 해결해보자!
기능 개발을 하기 전 먼저 결제에 필요한 상태를 정의하자.
상태 정리
내가 정리한 주문서 및 결제 데이터의 상태는 다음과 같다.
- 주문(
Order
) 상태- 주문 요청
- 결제 완료
- 주문 완료
- 주문 실패
- 주문 취소
- 결제(
Paymenet
) 상태- 결제 완료
- 결제 취소
- 환불 완료
- 환불 실패
public enum OrderStatus {
REQUESTED, // 주문 요청
PAID, // 결제 완료
OK, // 주문 완료
FAILED, // 주문 실패
CANCELED, // 주문 취소
}
public enum PaymentStatus {
OK, CANCELED, REFUNDED, REFUND_FAILED
}
이러한 상태를 명확히 하여 현재 결제 및 구매 과정에서의 흐름을 명확히 하자.
결제 흐름도 v1
이미 위에서 간단히 다루었지만, 다시 한번 자세히 결제 프로세스를 정리하자
- 사전 검증: 결제창을 띄우는 프론트엔드를 보여주기 전에 어떤 주문번호로 얼마만큼의 결제가 이루어져야 하는지를 사전에 등록하는 과정(위변조 원천 차단)
- 아이템의 존재 여부를 확인하고, 프리미엄 상품 구매 시 유저의 구도 여부를 체크하는 로직도 수행함
- 포트원의 사전 검증 API를 호출하여 결제 전 변조 방지를 위한 데이터를 포트원 측에 저장해둠
- 또한, 이 과정에서 '주문(
Order
)'을 '주문 요청(REQUESTED
)' 상태로 생성해둠 - 모두 결제를 요청하기 전 사전에 수행하는 검증 작업들임
- 결제 요청 및 인증: 실제 결제가 이루어지는 부분이며, 포트원 모듈이 담당한다
- 결제창 호출
- 결제 인증 요청 및 결제키 반환
- 결제키를 통한 카드사 결제 요청 및 결제 결과 반환
- 결제 승인(사후 검증): 결제 결과에 대한 사후 검증을 수행 및 최종 결제 정보 저장 과정
- 포트원 모듈을 통해 결제 정보를 조회하고 이를 DB 내용과 비교하여 사후 검증 수행
- 사후 검증이 완료되면, 결제 정보를 저장하고 '주문(
Order
)' 상태를 업데이트 - 상품 구매 및 아이템 사용 로직 수행 (+ 유저 데이터 변경)
- 모두 완료되면 결제 완료 메일 전송
이러한 결제 프로세스는 결제 기능을 제공한다는 점에서는 문제가 없지만, 발생 가능한 위험들을 전혀 고려하지 않고 있다. 이러한 과정으로는 위에서 직면한 문제를 해결할 수 없다.
현재 프로세스에 어떠한 문제가 있는지 정리해보자.
문제점
결제 사후 검증 단계에서 한번에 너무 많은 작업을 수행하고 있으며, 이로 인해 트랜잭션의 범위가 매우 크다..
이로 인한 문제점은 다음과 같다.
- Lock Condition 증가로 인한 동시성 저하
- Deadlock 위험 증가
- 에러 발생 시, 롤백 비용 증가
- IO 지연 증가로 인해 스레드 대기 상태 점유 증가 => 시스템 응답 시간 증가
- 유지보수의 어려움(응집도 관점)
- 결제 및 주문의 상태를 세세하게 기록할 수 없음 (ex. 결제는 완료되었으나 아이템 구매 로직은 수행하지 못했다는 등의 상태 표시가 어려움)
또한, 사후 검증 단계에서의 외부모듈 사용으로 인한 타임아웃 발생 가능성도 무시할 수 없다. 포트원 모듈에서의 통신이 지연되는 경우, 스레드는 계속 대기 상태로 남아있게 되고 이는 트래픽 증가 상황에서 모든 유저가 응답 지연되는 현상을 초래할 것이다.
해결 방안: 결제 흐름도 v2
위 문제를 해결하기 위해서는 너무 많은 작업을 수행하고 있던 기존의 사후 검증 로직을 다음 2단계로 분할할 필요가 있다.
- 외부 모듈을 사용하여 사후 검증을 수행하는 부분
- 상품 구매 및 아이템 사용 로직을 수행하는 부분
이렇게 트랜잭션을 2단계로 나누어 서로 다른 API로 제공하자. 그리고 각 트랜잭션은 내부 오류 발생 시 "결제 취소"를 수행해야 한다.
자세한 내용은 다음과 같다.
- 사전 검증 -> 동일
- 결제 요청 및 인증 -> 동일
- 사후 검증 단계(1단계 API)
- 포트원 모듈을 통해 결제 정보를 조회하고 이를 DB 내용과 비교하여 사후 검증 수행
- 사후 검증 성공 시, '주문(Order)' 상태를 "결제 완료(
PAID
)"로 업데이트 후, '결제 정보(TodomonPayment
)'를 "결제 완료(OK
)" 상태로 "생성" - 사후 검증 실패 혹은 예상치 못한 에러 발생 시
- 주문 상태를 "주문 실패(
FAILED
)"로 업데이트 - 결제 취소 로직 호출
- 결제 상태를 "결제 취소(
CANCELED
)"로 업데이트
- 주문 상태를 "주문 실패(
- 처음에는 단순히 롤백을 해주기만 하면 될 것이라고 생각했으나, 그럴 경우 실패한 주문 및 결제의 상태를 나타낼 수 없게되고, 결제 정보 자체도 저장되지 않게 됨
- 상품 구매 처리 단계(2단계 API)
- 주문 상태가 "검증 완료(
PAID
)"인지 확인 - 상품 구매 및 아이템 사용 로직 수행(+ 유저 상태 업데이트)
- 완료 후 주문 상태를 "주문 완료"로 업데이트
- 작업 실패 시
- 상태 롤백
- 주문 상태를 "주문 실패(
FAILED
)"로 업데이트 - 결제 취소 로직 호출
- 결제 상태를 "결제 취소(
CANCELED
)"로 업데이트
- 주문 상태가 "검증 완료(
이렇게 되면 유저는 결제창을 통한 결제 완료 후, 사후 검증 API를 먼저 호출하고, 200 OK를 응답으로 받으면 이후 연쇄적으로 상품 구매 API까지 호출해야 한다.
트랜잭션의 분리 및 적절한 롤백을 통해 보다 견고한 시스템을 만들 수는 있었으나 여전히 유저측에서 API를 호출하지 않을 경우 발생하는 문제점을 해결하지 못하고 있다. 심지어는 그 API가 2개로 늘어나면서 위험은 더 커졌다.
이를 해결하기 위해 포트원에서 제공하는 웹훅(Webhook)을 연동해보자!
웹훅(Webhook) 연동
- 웹훅(Webhook): 특정 이벤트가 발생하였을 때 타 서비스나 응용프로그램으로 알림을 보내는 기능
- 이벤트가 발행하면 HTTP POST 요청을 생성하여 callback URL(endpoint)로 이벤트 정보를 전송함
- => 주기적으로 폴링할 필요가 없이 원하는 이벤트에 대한 정보만 수신할 수 있어서 webhook은 리소스나 통신측면에서 훨씬 더 효율적
- +) 커스텀 기능이나 다른 애플리케이션과 연동하여 기능을 확장 가능
- 웹훅 발생 이벤트
- 결제가 승인되었을 때(모든 결제 수단) - (status :
paid
) - 가상계좌가 발급되었을 때 - (status :
ready
) - 가상계좌에 결제 금액이 입금되었을 때 - (status :
paid
) - 예약결제가 시도되었을 때 - (status :
paid
orfailed
) - 관리자 콘솔에서 결제 취소되었을 때 - (status :
cancelled
) - 단, 결제 실패 시에는 웹훅이 호출되지 않음
- 결제가 승인되었을 때(모든 결제 수단) - (status :
개발 시 처리해줄 수 있는건 paid
status 뿐인 것 같다.. 아쉽다
- 주의할 점
- 웹훅 수신주소는 공개된 URL이다
- => 서버는 웹훅을 수신하고 반드시 결제내역 단건조회 API를 통해 결제건을 조회하여 웹훅의 내용을 검증해야 한다
- 웹훅이 오지 않거나 늦은 경우 결제건을 바로 취소 처리하시면 네트워크 문제가 발생했을 때 정상적으로 결제된 결제건이 환불되어 금전 피해가 발생 가능
- => 웹훅이 수신되지 않은 경우에도 결제 취소를 하기 이전에 결제내역 단건조회 API를 통해 결제건의 상태를 조회하여, 결제 상태에 따라 취소 처리
- 웹훅 수신주소는 공개된 URL이다
- 웹훅 POST 요청의 RequestBody
imp_uid
: 결제번호merchant_uid
: 주문번호status
: 결제 결과cancellation_id
: 취소내역 아이디
웹훅 연동 시의 결제 흐름 변경 사항
웹훅을 연동하게 되면, 유저의 결제 성공 시 웹훅을 통해 백엔드 서버의 API가 호출된다. 즉, 유저가 직접 사후 검증 API와 아이템 구매 API(흐름에서 3, 4번 단계)를 호출하는 일이 없어지게 된다. 사후 검증을 웹훅을 통해 처리하는 것은 좋은 현상이지만, 이렇게 되면 4번 단계를 호출할 주체가 없어지게 된다..
이를 해결하기 위해 생각해본 방법은 다음과 같다.
- 사후 검증 API 호출 완료 후, 서버에서 스스로에게 아이템 구매 API를 호출
- 장점
- 단순하고 직관적
- 관리 포인트 X
- 순차적 처리 보장(3번 완료되지 않으면 4번도 진행 X)
- 단점
- 3번과 4번 사이의 API 호출로 인한 지연 발생 가능 & 트래픽 증가 상황에서 스레드&커넥션으로 인한 문제 발생 가능
- 이후 포인트, 쿠폰, 할인 등 요구사항 변경으로 인해 4번 과정 뿐만 아니라 다른 작업이 추가된다면 로직이 더 복잡해질 수 있음 => 확장성의 한계..
- 인증 토큰을 관리해야하는 번거로움
- 장점
- 메시지 브로커 도입을 통한 비동기 이벤트 기반 처리
- 장점
- 3번 단계가 끝난 후, 이를 이벤트로 발행하고, 4번을 이벤트 리스너가 구독하여 처리하는 방식
- 지연없이 실시간으로 처리 가능
- 새로운 단계가 추가되더라도 이벤트를 추가적으로 구독하여 손쉽게 확장 가능
- 비동기 이벤트 기반 처리로 인해, 3번과 4번이 독립적으로 실행될 수 있으며 작업 실패 시 재시도 로직 구현도 쉬움
- 단점
- 메시지 브로커 구축 및 관리로 인한 관리 인프라 증가
- 디버깅의 어려움
- 장점
나는 추후의 확장성과 재시도 로직 추가를 고려하여 메시지 브로커로 카프카(Apache Kafka)를 도입하기로 결정하였다.
웹훅을 설정하는 과정은 생략한다. 공식 docs에 잘 나와있다.. 단, localhost 사용 시 ngrok을 이용하고 이를 Endpoint URL에서 사용해야 한다
최종: 결제 흐름도 v3
웹훅과 카프카를 도입한 최종 결제 흐름도는 다음과 같다. 기능적으로 변경되거나 추가된 부분은 없기에 설명은 생략하겠다
카프카 의존성 추가 및 설정
build.gradle
dependencies {
// Kafka
implementation 'org.springframework.kafka:spring-kafka'
}
application-kafka.yml
spring:
kafka:
producer:
bootstrap-servers: localhost:9092
acks: all
consumer:
bootstrap-servers: localhost:9092
listener:
type: single
- 데이터가 유실되어서는 안되므로 프로듀서의
acks
설정읠all
로 하자
docker-compose.yml
... (생략)
# === Kafka ===
zookeeper:
image: wurstmeister/zookeeper
container_name: zookeeper
ports:
- "2181:2181"
kafka:
image: wurstmeister/kafka
container_name: kafka
ports:
- "9092:9092"
- "29092:29092"
environment:
KAFKA_ADVERTISED_HOST_NAME: localhost
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- zookeeper
... (생략)
토픽 생성
$ docker exec -it kafka bash
$ cd opt/kafka
$ bin/kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 3 --topic paid_order
- 복제 개수
- 현재는 싱글 브로커로 구성된 카프카 클러스터 환경이므로 복제 개수의 최댓값은 1이다. 그러므로 여기서는 --replication-factor 값을 1로 주었다
- 상용 배포라면 복제 개수를 최소 2 이상으로 잡는 것이 좋을 것 같다.
- 파티션 개수
- 이미 결제가 완료된 건에 대해 처리를 하는 것이므로 딱히 데이터 처리 순서가 중요하지는 않다
- 또한, 추후 파티션 증설 가능성이 있으므로 메시지 키를 굳이 사용하지 않도록 하자.
웹훅 요청 받기
PaymentController
@PostMapping("/complete")
public BaseResponse completePayment(
@RequestBody @Valid WebhookPayload req
) {
paymentService.completePayment(req);
return new BaseResponse("사후 검증 성공");
}
- 기존 사후 검증 API를 웹훅 요청을 위한 API로 변경하였다.
- 내부 동작은 메세지 큐에 데이터를 적재한다는 것 외에는 거의 차이가 없기 때문이다.
SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
...
.authorizeHttpRequests(authz -> {
authz
.requestMatchers("/api/payment/complete").access((authentication, request) -> {
String clientIp = request.getRequest().getHeader("X-Forwarded-For");
return new AuthorizationDecision(ALLOWED_IPS.contains(clientIp));
})
.requestMatchers(PathRequest.toH2Console()).permitAll();
Arrays.stream(PermitAllUrls.values()).forEach(url -> {
authz.requestMatchers(url.getMethod(), url.getUrl()).permitAll();
});
authz.anyRequest().authenticated();
})
...
return http.build();
}
- 웹훅 요청을 받기 위해서는 기존 jwt 인증 방식을 사용해서는 안된다
- 그렇다고 permitAll로 열어둘 수도 없기에, 포트원의 웹훅 요청에서 사용되는 아이피들을 제외하고는 요청을 차단하도록 설정하였다.
- 조금 껄끄럽긴 하다.. 더 나은 방식이 있는지 알아보아야겠다
PaymentService
public class PaymentService {
...(중략)
@Transactional
public void completePayment(WebhookPayload req) {
this.validateWebhookStatus(req);
log.info("결제 완료 이벤트 처리");
Order order = orderReader.findByMerchantUid(req.getMerchant_uid());
Member member = order.getMember();
Item item = order.getItem();
this.checkIsPremiumItemAndMemberSubscription(item, member);
TodomonPayment todomonPayment = TodomonPayment.of(order, req.getImp_uid());
order.setPayment(todomonPayment);
try {
paymentProvider.complete(req.getImp_uid(), BigDecimal.valueOf(order.getTotalPrice()));
order.updateStatus(OrderStatus.PAID);
log.info("결제 성공! 멤버 아이디: {}, 주문 아이디: {}", order.getMember().getId(), order.getMerchantUid());
paidOrderProducer.send(member.getId(), req.getMerchant_uid());
} catch (Exception e) {
log.error("사후 검증 실패! 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", member.getId(), req.getMerchant_uid(), e.getMessage());
rollbackManager.completeStageRollback(todomonPayment, req);
throw new InternalServerException(ErrorCode.POST_VALIDATE_PAYMENT_ERROR, e.getMessage());
}
}
...(중략)
}
- 기존 PaymentReq가 WebhookPayload로 이름이 변경되었다.
- 내부 데이터는 docs를 참조하자!
- 웹훅 요청의
status
필드가paid
인지 검증하는 로직 추가 - 나머지는 모두 동일하고 try 구문 안에 결제 성공 이후,
paidOrderProducer.send
메서드 호출 부분이 추가되었다.
PaidOrderProducer
@Slf4j
@Service
@RequiredArgsConstructor
public class PaidOrderProducer {
private final ObjectMapper objectMapper;
private final KafkaTemplate<String, String> kafkaTemplate;
public void send(Long memberId, String merchantUid) {
PaidOrderMessage message = PaidOrderMessage.builder()
.memberId(memberId)
.merchantUid(merchantUid)
.build();
String messageStr = null;
try {
messageStr = objectMapper.writeValueAsString(message);
} catch (JsonProcessingException e) {
throw new InternalServerException(ErrorCode.INTERNAL_ERROR, "메시지 전환 실패");
}
CompletableFuture<SendResult<String, String>> future = kafkaTemplate.send(PAID_ORDER_TOPIC, messageStr);
future.whenComplete(((result, ex) -> {
if (ex != null) {
ex.printStackTrace();
log.error(ex.getMessage());
} else {
log.info("메시지 적재 완료: {}", result.getRecordMetadata().offset());
}
}));
}
}
PaidOrderMessage
타입의 메세지를 만든 후, 이를 직렬화하여 Kafka의PAID_ORDER_TOPIC
으로 전달한다- 이후 메시지 적재가 완료되면 로그를 남긴다
PaidOrderConsumer
@Slf4j
@Component
@RequiredArgsConstructor
public class PaidOrderConsumer {
private final PaymentService paymentService;
private final MailService mailService;
private final ObjectMapper objectMapper;
@KafkaListener(topics = PAID_ORDER_TOPIC, groupId = "paid-order-consumer-group")
public void consume(String message) throws JsonProcessingException {
log.info("처리할 주문 데이터 정보: ====> {}", message);
PaidOrderMessage paidOrderMessage = objectMapper.readValue(message, PaidOrderMessage.class);
PaymentResourceDTO dto = paymentService
.purchaseItem(paidOrderMessage.getMemberId(), paidOrderMessage.getMerchantUid());
mailService.sendPaymentMail(dto);
}
}
- message를 objectMapper를 통해 객체로 변환한 후, 이를 이전에 우리가 작성한
paymentService.purhcaseItem
메서드로 전달하여 아이템 구매 로직을 수행한다 - 이후 클라이언트에게 메일을 전송한다
타임아웃 처리
우리는 Iamport에서 제공하는 JAVA(1.7이상) Retrofit2
기반의 iamport-rest-client-java
라는 라이브러리를 사용하고 있다.
아임포트(포트원)과 통신하기 위해서 IammportClient
의 내부 코드를 보자!
public class IamportClient {
public static final String API_URL = "https://api.iamport.kr";
public static final String STATIC_API_URL = "https://static-api.iamport.kr";
protected String apiKey = null;
protected String apiSecret = null;
protected String tierCode = null;
protected Iamport iamport = null;
public IamportClient(String apiKey, String apiSecret) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.iamport = this.create(false);
}
...(중략)
protected Iamport create(boolean useStaticIP) {
OkHttpClient client = new OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.addInterceptor(new Interceptor() { //Tier Header
public okhttp3.Response intercept(Chain chain) throws IOException {
Request request = chain.request();
if (IamportClient.this.tierCode != null) {
request = request.newBuilder().addHeader("Tier", IamportClient.this.tierCode).build();
}
return chain.proceed(request);
}
})
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(useStaticIP ? STATIC_API_URL : API_URL)
.addConverterFactory(buildGsonConverter())
.client(client)
.build();
return retrofit.create(Iamport.class);
}
...
}
여기서 외부 통신을 위해 사용하는 Iamport는 내부적으로 retrofit2
를 사용하며, 또한 retrofit2는 내부적으로 OkHttpClient
사용한다.
그리고 Iamport를 생성하는 create
메서드의 구현부를 보면 readTimeout
과 connectTimeout
을 각각 30초, 10초로 설정하고 있는 것을 볼 수 있다.
하지만..
토스가 권장하는 Read-Timeout 처리 시간은 10초 + 3~5초 정도라고 한다..
라이브러리의 코드를 보니 직접 Iamport를 주입받을 수 없는 형태였다.. OkHttpClient
도 빈을 사용하지 않고 직접 생성하여 사용하고 있기 때문에 커스텀 불가능하다.
=> 직접 구현해야 할 것 같다 ㅠㅠ
하지만, 포트원의 API는 매우 친절한 편이고, 라이브러리 코드를 참조할 수 있기 때문에 어렵지 않다!(어렵지 않을 줄 알았다..)
모든 코드를 그대로 유지하고 IamportClient
부분만 직접 구현하자!
비즈니스 로직과는 관련이 없는 코드이기에 core 모듈이 아니라 별도의 infra 모듈에서 구현해주었다.
IamportClient
public class IamportClient {
private static final int CONNECT_TIMEOUT = 5;
private static final int READ_TIMEOUT = 15;
public static final String API_URL = "https://api.iamport.kr";
protected String apiKey = null;
protected String apiSecret = null;
protected OkHttpClient client = null;
public IamportClient(String apiKey, String apiSecret) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.client = this.create();
}
private OkHttpClient create() {
return new OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.build();
}
private IamportResponse<AccessToken> getAccessToken() throws IOException, IamportResponseException {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(new AuthData(this.apiKey, this.apiSecret));
RequestBody requestBody = RequestBody.create(json, MediaType.get("application/json; charset=utf-8"));
Request request = new Request.Builder()
.url(API_URL + "/users/getToken")
.post(requestBody)
.build();
Response response = this.client.newCall(request).execute();
if (!response.isSuccessful()) {
throw new IamportResponseException(getExceptionMessage(response), response.code());
}
return objectMapper.readValue(response.body().string(), new TypeReference<IamportResponse<AccessToken>>() {
});
}
private <T> IamportResponse<T> postRequestToIamport(String path, Object requestBody) throws IOException, IamportResponseException {
ObjectMapper objectMapper = new ObjectMapper();
AccessToken accessToken = this.getAccessToken().getResponse();
String json = objectMapper.writeValueAsString(requestBody);
Request request = new Request.Builder()
.url(API_URL + path)
.post(RequestBody.create(json, MediaType.get("application/json; charset=utf-8")))
.header("Authorization", accessToken.getAccess_token())
.build();
Response response = this.client.newCall(request).execute();
if (!response.isSuccessful()) {
throw new IamportResponseException(getExceptionMessage(response), response.code());
}
return objectMapper.readValue(response.body().string(), new TypeReference<IamportResponse<T>>() {
});
}
private <T> IamportResponse<T> getRequestToIamport(String path, Class<T> responseType) throws IOException, IamportResponseException {
ObjectMapper objectMapper = new ObjectMapper();
AccessToken accessToken = this.getAccessToken().getResponse();
Request request = new Request.Builder()
.url(API_URL + path)
.header("Authorization", accessToken.getAccess_token())
.build();
Response response = this.client.newCall(request).execute();
if (!response.isSuccessful()) {
throw new IamportResponseException(getExceptionMessage(response), response.code());
}
JavaType javaType = objectMapper.getTypeFactory()
.constructParametricType(IamportResponse.class, responseType);
return objectMapper.readValue(response.body().string(), javaType);
}
public IamportResponse<Prepare> prepare(PrepareData prepareData) throws IOException, IamportResponseException {
return postRequestToIamport("/payments/prepare", prepareData);
}
public IamportResponse<Payment> paymentByImpUid(String impUid) throws IOException, IamportResponseException {
return getRequestToIamport("/payments/" + impUid, Payment.class);
}
public IamportResponse<Payment> cancelPaymentByImpUid(CancelData cancelData) throws IOException, IamportResponseException {
return postRequestToIamport("/payments/cancel", cancelData);
}
protected String getExceptionMessage(Response response) {
ObjectMapper objectMapper = new ObjectMapper();
String error = null;
try {
JsonNode rootNode = objectMapper.readTree(response.body().string());
JsonNode messageNode = rootNode.get("message");
error = messageNode.asText();
} catch (IOException e) {
e.printStackTrace();
}
// "message" 필드가 없으면, 기본 메시지 사용
if (error == null) {
error = response.message();
}
return error;
}
}
사용되는 DTO는 API docs를 보면서 직접 작성했다.. Payment
클래스를 제외하면 필드 수가 많지 않아서 할만하다.. ㅋㅠㅋㅠ
최종 코드
PaymentController
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
private final MailService mailService;
@PostMapping("/prepare")
public BaseResponse preparePayment(
@AuthenticationPrincipal TodomonOAuth2User todomonOAuth2User,
@RequestBody @Valid PreparePaymentReq req
) {
paymentService.preparePayment(todomonOAuth2User.getId(), req);
return new BaseResponse("사전 검증 정보 등록 성공");
}
@PostMapping("/complete")
public BaseResponse completePayment(
@RequestBody @Valid WebhookPayload req
) {
paymentService.completePayment(req);
return new BaseResponse("사후 검증 성공");
}
@PostMapping("/cancel/{merchant_uid}")
public BaseResponse cancelPayment(
@AuthenticationPrincipal TodomonOAuth2User todomonOAuth2User,
@PathVariable(name = "merchant_uid") String merchantUid
) {
PaymentResourceDTO dto = paymentService.cancelPayment(todomonOAuth2User.getId(), merchantUid);
mailService.sendRefundMail(dto);
return new BaseResponse("결제 취소 성공");
}
}
PaymentService
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final MemberReader memberReader;
private final ItemReader itemReader;
private final OrderReader orderReader;
private final OrderWriter orderWriter;
private final RollbackManager rollbackManager;
private final RefundManager refundManager;
private final PurchaseManager purchaseManager;
private final PaidOrderProducer paidOrderProducer;
private final PaymentProvider paymentProvider;
private void checkIsPremiumItemAndMemberSubscription(Item item, Member member) {
if (item.getIsPremium() && !member.isSubscribed()) {
throw new ForbiddenException(ErrorCode.NOT_SUBSCRIPTION);
} else if (item.getName().equals("유료 플랜 구독권") && member.isSubscribed()) {
throw new BadRequestException(ErrorCode.ALREADY_SUBSCRIPTION);
}
}
@Transactional
public void preparePayment(Long memberId, PreparePaymentReq req) {
log.info("결제 요청 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
Member member = memberReader.findById(memberId);
Item item = itemReader.findItemById(req.getItemId());
this.checkIsPremiumItemAndMemberSubscription(item, member);
try {
paymentProvider.prepare(req.getMerchant_uid(), req.getAmount());
orderWriter.create(item, member, req);
log.info("사전 검증 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
} catch (Exception e) {
log.error("사전 검증 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", memberId, req.getMerchant_uid(), e.getMessage());
rollbackManager.prepareStageRollback(memberId, req);
log.error("사전 검증 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
throw new InternalServerException(ErrorCode.PREPARE_PAYMENT_ERROR, e.getMessage());
}
}
private void validateMember(Long memberId, Member member) {
if (!Objects.equals(member.getId(), memberId))
throw new BadRequestException(ErrorCode.UNAUTHORIZED);
}
@Transactional
public void completePayment(WebhookPayload req) {
log.info("웹훅 수신 === {}", req);
if (!"paid".equals(req.getStatus())) return;
log.info("결제 완료 이벤트 처리 요청 === 주문 아이디: {}, 결제 아이디: {}", req.getMerchant_uid(), req.getImp_uid());
Order order = orderReader.findByMerchantUid(req.getMerchant_uid());
Member member = order.getMember();
Item item = order.getItem();
this.checkIsPremiumItemAndMemberSubscription(item, member);
TodomonPayment todomonPayment = TodomonPayment.of(order, req.getImp_uid());
order.setPayment(todomonPayment);
try {
paymentProvider.complete(req.getImp_uid(), BigDecimal.valueOf(order.getTotalPrice()));
order.updateStatus(OrderStatus.PAID);
log.info("사후 검증 성공 === 멤버 아이디: {}, 주문 아이디: {}", order.getMember().getId(), order.getMerchantUid());
paidOrderProducer.send(member.getId(), req.getMerchant_uid());
} catch (Exception e) {
log.error("사후 검증 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", member.getId(), req.getMerchant_uid(), e.getMessage());
rollbackManager.completeStageRollback(todomonPayment, req);
log.error("사후 검증 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", member.getId(), req.getMerchant_uid());
throw new InternalServerException(ErrorCode.POST_VALIDATE_PAYMENT_ERROR, e.getMessage());
}
}
@Transactional
public PaymentResourceDTO purchaseItem(Long memberId, String merchantUid) {
log.info("프리미엄 아이템 구매 처리 요청 수신 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
Order order = orderReader.findByMerchantUid(merchantUid);
if (!order.getOrderStatus().equals(OrderStatus.PAID)) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "결제가 완료되지 않은 주문 건입니다.");
}
try {
purchaseManager.purchase(order.getMember(), order.getItem(), order.getQuantity());
order.updateStatus(OK);
log.info("프리미엄 아이템 구매 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
return this.createPaymentResourceDTO(order);
} catch (Exception e) {
log.error("프리미엄 아이템 구매 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", memberId, merchantUid, e.getMessage());
rollbackManager.purchaseStageRollback(merchantUid);
log.error("프리미엄 아이템 구매 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
throw new InternalServerException(ErrorCode.PURCHASE_ERROR, e.getMessage());
}
}
@Transactional
public PaymentResourceDTO cancelPayment(Long memberId, String merchantUid) {
log.info("환불 처리 요청 수신 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
Order order = orderReader.findByMerchantUid(merchantUid);
Member member = order.getMember();
this.validateMember(memberId, member);
if (order.getItem().getName().equals("유료 플랜 구독권")) {
log.info("구독 취소 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
member.updateIsSubscribed(false);
}
refundManager.refund(order);
TodomonPayment todomonPayment = order.getPayment();
order.updateStatus(OrderStatus.CANCELED);
todomonPayment.updateStatus(PaymentStatus.REFUNDED);
log.info("환불 성공 === 멤버 아이디: {}, 주문 아이디: {}", order.getMember().getId(), order.getMerchantUid());
return this.createPaymentResourceDTO(order);
}
private PaymentResourceDTO createPaymentResourceDTO(Order order) {
return PaymentResourceDTO.builder()
.email(order.getMember().getEmail())
.itemName(order.getItem().getName())
.quantity(order.getQuantity())
.totalPrice(order.getTotalPrice())
.build();
}
}
RollbackManager
@Component
@RequiredArgsConstructor
public class RollbackManager {
private final OrderWriter orderWriter;
private final OrderReader orderReader;
private final MemberReader memberReader;
private final ItemReader itemReader;
private final RefundManager refundManager;
@Transactional(noRollbackFor = InternalServerException.class)
public void prepareStageRollback(Long memberId, PreparePaymentReq req) {
Member member = memberReader.findById(memberId);
Item item = itemReader.findItemById(req.getItemId());
orderWriter.createFailedOrder(item, member, req);
}
@Transactional(noRollbackFor = InternalServerException.class)
public void completeStageRollback(TodomonPayment todomonPayment, WebhookPayload req) {
Order order = orderReader.findByMerchantUid(req.getMerchant_uid());
order.setPayment(todomonPayment);
order.getPayment().updateStatus(CANCELED);
order.updateStatus(FAILED);
refundManager.refund(order);
}
@Transactional(noRollbackFor = InternalServerException.class)
public void purchaseStageRollback(String merchantUid) {
Order order = orderReader.findByMerchantUid(merchantUid);
TodomonPayment todomonPayment = order.getPayment();
todomonPayment.updateStatus(CANCELED);
order.updateStatus(FAILED);
refundProvider.refund(order);
}
}
RefundManager
@Slf4j
@Component
@RequiredArgsConstructor
public class RefundManager {
private final PaymentProvider paymentProvider;
private final MailService mailService;
@Transactional(noRollbackFor = InternalServerException.class)
public void refund(Order order) {
try {
paymentProvider.refund(order.getPayment().getImpUid());
} catch (Exception e) {
TodomonPayment todomonPayment = order.getPayment();
todomonPayment.updateStatus(PaymentStatus.REFUND_FAILED);
mailService.sendEmail(order.getMember().getEmail(), "환불에 실패했습니다. 관리자에게 문의바랍니다.");
log.error("환불 실패 === 유저 아이디: {}, 주문 아이디: {}, 이유: {}", order.getMember().getId(), order.getMerchantUid(), e.getMessage());
throw new InternalServerException(ErrorCode.REFUND_FAIL, e.getMessage());
}
}
}
PurchaseManager
@Component
@RequiredArgsConstructor
public class PurchaseManager {
private final InventoryItemReader inventoryItemReader;
private final InventoryItemWriter inventoryItemWriter;
private final ItemApplier itemApplier;
public void purchase(Member member, Item item, Long quantity) {
switch (item.getItemType()) {
case CONSUMABLE -> inventoryItemReader.findOptionalByMemberIdAndItemId(member.getId(), item.getId())
.ifPresentOrElse(
existingItem ->
// 인벤토리에 해당 아이템이 있으면 수량 수정
existingItem.addQuantity(quantity)
,
() -> {
// 없다면 생성
InventoryItem newInventoryItem = InventoryItem.of(member, item, quantity);
member.addItemToInventory(newInventoryItem);
inventoryItemWriter.create(newInventoryItem);
}
);
case IMMEDIATE_EFFECT -> {
// 즉시 효과 적용
for (int i = 0; i < quantity; i++) {
itemApplier.apply(item, member, null);
}
}
}
}
}
'Project' 카테고리의 다른 글
[TODOMON] EP.7 부하 테스트 계획 (0) | 2025.02.03 |
---|---|
[TODOMON] Ep.6 Spring Batch 도입기 (+ 멀티스레드를 통한 성능 개선까지) (0) | 2025.02.02 |
[TODOMON] EP.4 아이템 기능 구현 (0) | 2025.01.30 |
[TODOMON] EP.3 펫 정보 저장 방식에 대한 고민 (0) | 2025.01.28 |
[TODOMON] EP.2 투두 달성 보상 기능 (1) | 2025.01.28 |