이번에 상담쪽 기능을 구현하게되면서 상담을 진행하기 위한 재화가 필요했습니다. 이러한 재화를 구매하기 위한 결제처리 로직이 필요했습니다. 여러 결제처리 해주는 Api가 많지만 저는 카카오페이 결제 Api를 선택하였습니다. 그래서 이 글은 카카오페이 결제 Api로 단건 결제 기능을 도입하면서 고민한 내용을 중점으로 작성했습니다.
카카오페이 결제의 플로우는 엄청 간단합니다.
이렇게 두개의 작업이 존재하는데요 결제 준비 같은 경우는 사용자의 인증처리를 담당하고 있고 결제 요청 같은 경우에는 인증처리가 완료된 후 실제 결제요청을 하기 위한 작업 입니다.
좀 더 자세하게 이야기를 하자면 먼저 결제 준비 Api에 요청하게 되면 사용자 인증 처리를 위한 Uri와 tid라고 결제 고유 번호를 Response로 주는데 사용자 인증 처리를 위한 Uri를 통해 사용자 인증 처리를 하게 된 후 결제 요청 Api에 아까 결제 준비 Api Response로 받은 tid와 사용자 인증 완료 후 받는 pg_token을 기반으로 요청하게 되면 Api나 네트워크상에 문제가 발생하지 않는다면 결제가 성공하게 됩니다.
결제 준비 같은 경우는 사용자 인증 처리를 담당하고 있기 때문에 클라이언트 부분에서 처리해줘야 합니다. 하지만 결제 요청 같은 경우는 타사 Api를 사용하고 있기 때문에 클라이언트에서 처리 해줘도 되고 서버에서 처리 해줘도 됩니다. 결제 요청을 클라이언트에서 처리하는 부분과 서버에서 처리하는 부분에 대해서 한번 알아보겠습니다.
클라이언트에서 결제 요청을 처리하는 경우에는 결제 요청에 필요한 데이터들이 전부 있기 때문에 바로 결제 요청Api를 호출해서 처리하면 됩니다. 그리고 결제 요청이 완료된 경우 서버에 결제 내역을 저장하기 위한 데이터와 구매 아이템에 대한 정보를 보내줍니다. 그리고 서버에서는 해당 데이터들로 결제 내역을 저장하고 사용자 아이템 재고를 update 해줍니다. 하지만 서버에 문제가 생겨서 요청을 처리하지 못한다면 결제는 진행했지만 결제 내역이 저장되지 않아 나중에 취소할 수도 없게 되는 상황이 발생하게 됩니다. 이런 문제를 해결하기 위해 서버쪽에서 실패한 경우 결제 취소 처리를 클라이언트에서 따로 해줘야합니다. 하지만 이때 카카오페이 Api 서버에도 문제가 발생한다면 사용자의 돈은 그대로 공중분해됩니다. 이렇게 된 경우 돈을 복구하기 위한 구매기록이 카카오페이쪽에만 남기 때문에 CS 처리하기가 많이 까다로워집니다.
서버에서 결제 요청을 처리하는 경우에는 결제 요청에 필요한 데이터와 구매하고자 하는 아이템 정보를 서버에게 요청하고 결제 내역을 저장하고 사용자 아이템 재고도 update 처리해줍니다. 그 다음 결제 Api에 요청해서 결제 처리를 완료해줍니다. 만약 결제 Api에서 문제가 생겼다면 트랜잭션을 롤백 시켜주면 되기 때문에 비교적 간단하게 처리가 가능합니다. 이렇게 되는 경우 디비 작업이 오래걸린다면 결제 요청은 지연된다는 단점이 있습니다.
서버에서 결제 요청을 처리한다고 가정을하고 카카오페이 외 다른 결제 Api를 적용하는 경우에는 어떻게 처리해주는게 좋을까요? 제가 생각한 방법은
일단 크게 두가지로 두고 생각해보겠습니다. 첫번째로 결제 Api별로 각각 Api를 만들어주는 방법 아주 간단한 방법입니다. 결제 Api가 추가 될수록 Api도 늘려주면 되기때문이죠 하지만 결제 Api가 40개 50개 된다면? 난감해질정도로 많아지면 상당히 관리하기가 쉽지 않아집니다. 그래서 해당 방법은 확실하게 몇개다라고 정해져있는 경우 도입하면 괜찮은 방법인것 같습니다. 다음 하나의 Api를 만들고 보내는 데이터 형식을 일관적으로 처리하는 방법 하나의 Api를 만들고 유연하게 처리가능한 방법 같아보입니다. 하지만 모든 결제 Api에 요청하기 위한 데이터가 항상 일관적인 데이터 형식을 가지게 될까요? 결제 Api마다 차이가 있기 때문에 좀 더 생각을 해봐야합니다. 그러면 결제 Api마다 다른 데이터들을 담고있는 하나의 큰 오브젝트로 해야할까요? 이렇게 되면 Api마다 채워줘야하는 데이터가 다른데 한 오브젝트로 처리하기 때문에 유지보수성이 좋지가 않습니다.
일단 하나의 Api로 관리하는게 가장 좋아보입니다. 왜냐하면 결제 Api마다 각각 Api를 생성해주게되면 상점 구매 Api에서 결제 Api를 사용하고 다른 A라는 기능에서 결제 Api를 사용하게 되면 구매 Api를 사용하는 서비스 X 결제 Api 갯수만큼 Api를 만들어줘야하기 때문입니다. 근데 하나의 Api로 관리하게 되면 위와같은 문제점이 생기기 때문에 저는 일관적인 데이터로 따로 추상화는 하되 결제 Api마다 필요한 부수적인 데이터들은 결제 Api마다 다르게 보내주면 되겠다 라는 생각이 들었습니다. 이렇게 되면 Api에서는 결제 Api에 따라 따로 Json을 파싱해서 Object에 바인딩 시켜주면 되기 때문이죠 말로 설명하면 아무래도 이해하기 어려우니 코드를 통해 한번 알아보도록 하겠습니다.
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class PaymentDto {
private PaymentType paymentType;
}
@Builder
@Getter
public class KakaoPaymentDto extends PaymentDto {
private String tid;
private String pgToken;
}
public enum PaymentType {
KAKAO_PAY;
public static PaymentType findPaymentType(String type) {
return Arrays.stream(values())
.filter(t -> t.name().equals(type.toUpperCase()))
.findFirst()
.orElseThrow(() -> new PaymentFailException("결제 타입 오류"));
}
}
아까 말했듯이 일관적인 데이터로 추상화하기 위해 PaymentDto를 만들어주었습니다. 간단하게 만들기 위해 일단 어떤 결제 Api로 처리할 것인지 판단하기 위한 PaymentType만 추가해주었습니다. 저는 카카오페이만 도입했기 때문에 PaymentType은 KAKAO_PAY만 존재합니다.
결제 Api마다 다른 데이터를 넘겨줄 것이기 때문에 별도로 Json을 파싱해줘야합니다. 그렇기 때문에 별도의 Parser를 만들어주게 되었습니다.
public class PaymentDtoJsonParser {
public PaymentDto parse(Map<String, Object> paymentDtoJson){
PaymentType paymentType = PaymentType.findPaymentType((String)paymentDtoJson.get("paymentType"));
PaymentDto paymentDto = switch (paymentType){
case KAKAO_PAY -> parseKakaoPaymentDto(paymentDtoJson);
};
paymentDto.setPaymentType(paymentType);
return paymentDto;
}
private PaymentDto parseKakaoPaymentDto(Map<String, Object> json){
String tid = (String) json.get("tid");
String pgToken = (String) json.get("pgToken");
return KakaoPaymentDto.builder()
.tid(tid)
.pgToken(pgToken)
.build();
}
}
먼저 parse 메서드 부터 보면 PaymentType.findPaymentType을 통해 PaymentType을 찾고 KAKAO_PAY 타입인 경우 parseKakaoPaymentDto를 통해 Json을 KakaoPaymentDto로 반환 해줍니다.
@RequestBody를 사용하게 되면 추상화된 데이터만 바인딩 되기 때문에 각 결제 Api마다 필요한 데이터를 바인딩 해주기 위해서는 직접 파싱을 해주어야 하기때문에 HandlerMethodArgumentMethodResolver를 통해 파싱작업을 처리해주어서 Controller에 넘겨주도록 하였습니다.
@Slf4j
@RequiredArgsConstructor
public class PaymentDtoMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper mapper;
private final PaymentDtoJsonParser jsonParser = new PaymentDtoJsonParser();
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAnnotation = parameter.hasParameterAnnotation(Payment.class);
boolean hasPaymentDto = PaymentDto.class.isAssignableFrom(parameter.getParameterType());
return hasAnnotation && hasPaymentDto;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
try{
Map json = mapper.readValue(request.getInputStream(), Map.class);
Map paymentDtoJson = (Map) json.get("payment");
return jsonParser.parser(paymentDtoJson);
}catch (Exception e){
throw new PaymentFailException("결제 데이터 파싱 오류");
}
}
}
먼저 supportsParameter를 통해서 Payment Annotation이 있는지 그리고 PaymentDto클래스인지 확인합니다. 그리고 resolveArgument를 통해 HttpServletRequest에서 데이터를 가져오고 아까 생성한 JsonParser를 통해 파싱해주고 나온 결과물인 PaymentDto를 return 해줍니다.
다음으로는 컨트롤러 입니다.
@PostMapping("/purchase/{itemId}")
public Response purchaseItem(@PathVariable Long itemId,
@Payment PaymentDto paymentDto){
storeService.purchaseStoreItem(itemId,paymentDto);
return Response;
}
아까 MethodArgumentResolver를 통해 파싱된 데이터를 가져오는데 한 곳에서 처리하기 때문에 추상화 시킨 데이터 형식 즉 PaymentDto로 받아와주도록 합니다. 받아온 PaymentDto를 Service에 넘겨 처리해주도록 설계했습니다.
일단 구체적인 비즈니스 관련 쪽은 제외하고 결제 부분만 보겠습니다.
public class PaymentCompositeService {
private final KakaoPaymentService kakaoPaymentService;
public void payment(PaymentDto paymentDto) {
PaymentType type = paymentDto.getPaymentType();
switch (type) {
case KAKAO_PAY -> {
kakaoPaymentService.payment(paymentDto);
}
}
}
}
PaymentCompositeService에서는 모든 결제 Api 처리에 대한 것을 다루게 됩니다. 그래서 payment 메서드에서는 PaymentType에 따라 적절한 Api를 호출해서 처리해주게 됩니다. 구체적인건 XXXPaymentService에서 처리할 수 있도록 위임하도록 설계했습니다. KakaoPaymentService는 아래와 같이 구성되어있습니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class KakaoPaymentService {
private final RestTemplate restTemplate;
private final PaymentValidator validator;
private String url = "https://kapi.kakao.com/v1/payment/approve";
@Value("${kakao.admin-key}")
private String adminKey;
public void payment(PaymentDto dto) {
validator.validationPaymentDtoTypeCast(dto);
KakaoPaymentDto kakaoPaymentDto = (KakaoPaymentDto) dto;
HttpHeaders headers = getHttpHeaders();
KakaoPaymentRequest request = new KakaoPaymentRequest(kakaoPaymentDto);
MultiValueMap<String, String> param = getParam(request);
HttpEntity<MultiValueMap<String, String>> requestHttpEntity = new HttpEntity<>(param, headers);
ResponseEntity response = restTemplate.postForEntity(url, requestHttpEntity, String.class);
validator.validationPaymentResponse(response);
}
private static MultiValueMap<String, String> getParam(KakaoPaymentRequest request) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
// 파라미터 추가 예시
params.add("partner_order_id", request.getOrderId());
params.add("partner_user_id", request.getUserId());
params.add("tid", request.getTid());
params.add("cid", request.getCid());
params.add("pg_token", request.getPgToken());
return params;
}
private HttpHeaders getHttpHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Authorization", "KakaoAK " + adminKey);
return headers;
}
}
@Component
public class PaymentValidator {
public void validationPaymentDtoTypeCast(PaymentDto dto){
Class<?> type = switch (dto.getPaymentType()){
case KAKAO_PAY -> KakaoPaymentDto.class;
};
if(!type.isInstance(dto)){
throw new PaymentFailException("결제 타입이 올바르지 않습니다.");
}
}
public void validationPaymentResponse(ResponseEntity paymentResponse){
if(paymentResponse.getStatusCode() != HttpStatus.OK){
throw new PaymentFailException("결제 요청 실패");
}
}
}
먼저 PaymentValidator부터 보자면 validationPaymentDtoTypeCast와 validationPaymentResponse 메서드가 있는데 validationPaymentDtoTypeCast는 PaymentType에 맞게 캐스팅이 가능한지 검증하는 메서드 입니다. validationPaymentResponse는 결제 Api요청이 제대로 처리되지 않는 경우 검증하기 위한 메서드입니다. 그래서 KakaoPaymentService를 보면 payment 메서드에서 캐스팅 하기전에 먼저 검증을 실행하고 캐스팅 해줍니다. 그리고 캐스팅한 Dto를 기반으로 결제요청을 처리하게됩니다.
이번 결제기능을 추가하면서 설계쪽으로 많은 고민을 하게 된 것 같습니다. 그러한 과정속에서 많이 배우게 되었고 그로인해 문제에 대해 직관하는 능력이 좀 더 상승한 것 같은 느낌이 들었습니다. 여러가지 방향으로 생각하는 방식은 여러모로 도움이 많이되는 것 같아서 항상 여러가지 방향으로 고민하는 습관을 들여야겠습니다.