제 10회 K-해커톤 대회에서 친환경제품 크라우드 펀딩 앱을 개발하였고 이 앱에서 가장 필요로 했던것은 바로 결제시스템이었다.
PG 결제 시스템인 아임포트나 부트페이를 이용하여 결제시스템을 구축할까 고민했는데 팀원과 상의 끝에 카카오페이 API를 이용하기로 결정했다.
왜 서버단에서 카카오페이 API를 호출해야할까?
이 부분때문에 가장 스트레스 받았다. 다른 블로그들을 살펴보면 약 70~80%가 클라이언트단에서 호출하였다. 하지만 우리 프로젝트에서는 그럴 수 없었다.
그 이유는 바로 cors 정책 때문이다.
cors(Cross-Origin Resource Sharing)
브라우저에서는 보안적인 이유로 cross-origin HTTP 요청들을 제한한다. 그래서 cross-origin 요청을 하려면 서버의 동의가 필요하다. 만약 서버가 동의한다면 브라우저에서는 요청을 허락하고, 동의하지 않는다면 브라우저에서 거절한다. 이러한 허락을 구하고 거절하는 메커니즘을 HTTP-header를 이용해서 가능한데, 이를 CORS(Cross-Origin Resource Sharing)라고 부른다. 그래서 브라우저에서 cross-origin 요청을 안전하게 할 수 있도록 하는 메커니즘이다.
예를들어, 외부서버에 ajax요청을 했을 때 cors라는 Cross-Origin Resource Sharing 정책 위반 이슈가 발생한다. 웹페이지에서 ajax를 동일서버에 요청하는 건 괜찮지만 외부서버에 요청하는 순간 동일 origin이 아니기때문에 이 요청을 거부하는 보안관련 이슈가 생긴다.
cross-origin이란 다음 중 한 가지라도 다른 경우를 말한다.
- 프로토콜 - http와 https는 프로토콜이 다르다.
- 도메인 - domain.com과 other-domain.com은 다르다.
- 포트 번호 - 8080포트와 3000포트는 다르다.
돈과 관련된 api는 보안이슈로 인해 cors를 닫아놓기때문에 우회해서 요청해야한다.
그렇다면 cors를 어떻게 우회해야 할까
CORS는 브라우저의 구현 스펙에 포함되는 정책이기 때문에, 브라우저를 통하지 않고 서버 간 통신을 할 때는 이 정책이 적용되지 않는다. 따라서 웹페이지에서는 요청을 우리 서버로 보내고 우리 서버에서 외부서버로 요청을 보내서 우회하면 CORS에 걸리지 않게 된다. 여기서 사용하는것이 RestTemplate 객체이다. (이는 모바일도 마찬가지)
카카오페이 단건결제 구현과정(앱에서)
결제과정 3가지
- 결제요청준비
- 결제 요청(결제 대기)
- 결제 승인
아래는 클라이언트-서버-카카오(외부서버) 이 3개가 값을 주고 받는 과정이다.(클라이언트가 앱임)
1. 클라이언트에서 서버로 주문관련정보를 POST
2. 서버는 클라이언트에서 받은 값을 카카오 결제 준비 API(/v1/payment/ready)로 POST
3. 요청이 성공하면 카카오는 tid(결제코드), 카카오톡 결제 페이지 Redirect URL, 카카오페이 결제 화면으로 이동하는 Android 앱 스킴(Scheme)을 서버에 주고 서버는 클라이언트에 redirect url을 던져준다.
4. 클라이언트는 next_redirect_app_url 값으로 결제 대기 화면 웹뷰를 띄운다. 카카오톡 결제 화면으로 이동하는 커스텀 앱 스킴(Custom App Scheme)은 자동 호출된다. 사용자는 결제 화면으로 이동해, 결제 수단을 선택하고 비밀번호를 입력해 결제를 진행한다.
(안드로이드에서 next_redirect_app_url을 인텐트에 넣어서 열었다. 암시적 인텐트라는 것 같다. by 안드로이드 팀원)
5. 결제가 성공적으로 진행되면 결제 준비 API 요청 시 전달 받은 approval_url에 pg_token 파라미터를 붙여 리다이렉트 된다.
6. 서버는 카카오 결제 승인 API(/v1/payment/approve)로 POST
7. 결제 승인 완료!
이 과정은 내가 공부하면서 적은것으로 가장 확실한것은 공식 문서를 보며 구현하는 것이다.
코드
DTO
1. PostOrderDTO -> 주문정보 DTO (이 정보를 서버로 전달)
@Log4j2
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class PostOrderDTO {
private Long projectId;
private Long rewardId;
private String rewardName;
private int price;
private String deliveryAddress;
}
2. ReadyResponse -> 카카오로 결제 준비 API 요청 후 받아오는 정보
@Getter
@Setter
@ToString
public class ReadyResponse {
private String tid;
private String next_redirect_app_url;
private String android_app_scheme;
}
3. ApproveResponse -> 카카오로 결제 승인 API 요청할 때 전달하는 정보
@Getter
@Setter
@ToString
public class ApproveResponse {
private String aid;
private String tid;
private String cid;
private String sid;
private String partner_order_id;
private String partner_user_id;
private String payment_method_type;
private String item_name;
private String item_code;
private int quantity;
private LocalDateTime created_at;
private LocalDateTime approved_at;
private String payload;
private Amount amount;
}
Controller
@RequiredArgsConstructor
@RestController
@Log4j2
@RequestMapping("/app/orders")
public class OrderController {
private final OrderService orderService;
private final UserService userService;
@PostMapping("/pay")
public ResponseEntity payKakaoPay(@RequestBody PostOrderDTO postOrderDTO) {
log.info("kakaoPay post............................................");
Long userId = userService.findUserId();
ReadyResponse readyResponse = orderService.payReady(userId, postOrderDTO);
Message message = Message.builder()
.result(readyResponse.getNext_redirect_app_url())
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
// 결제승인요청
@GetMapping("/pay/completed")
public ResponseEntity payCompleted(@RequestParam("pg_token") String pgToken) {
log.info("결제 요청");
log.info(pgToken);
// 카카오 결재 요청하기
ApproveResponse approveResponse = orderService.payApprove(pgToken);
orderService.saveOrder(approveResponse);
Message message = Message.builder()
.result("KakaoPay Completed")
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
@GetMapping("/pay/cancel")
public ResponseEntity payCanceled() {
Message message = Message.builder()
.result("KakaoPay Canceled")
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
@GetMapping("/pay/fail")
public ResponseEntity payFailed() {
Message message = Message.builder()
.result("KakaoPay Canceled")
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
}
Service
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final RewardRepository rewardRepository;
private final OrdersRepository ordersRepository;
private final ProjectRepository projectRepository;
private PostOrderDTO postOrderDTO;
private Long userId;
private ReadyResponse readyResponse;
@Override
public ReadyResponse payReady(Long userId, PostOrderDTO postOrderDTO) {
this.postOrderDTO = postOrderDTO;
this.userId = userId;
String itemName = postOrderDTO.getRewardName();
String order_id = userId+"_" + itemName;//
// 카카오가 요구한 결제요청request값을 담아줍니다.
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
parameters.add("cid", "TC0ONETIME");
parameters.add("partner_order_id", order_id); //
parameters.add("partner_user_id", "Econg"); //
parameters.add("item_name", itemName);
parameters.add("quantity", "1");
parameters.add("total_amount", String.valueOf(postOrderDTO.getPrice()));
parameters.add("tax_free_amount", "0");
parameters.add("approval_url", "https://isileeserver.shop/app/orders/pay/completed"); // 결제승인시 넘어갈 url
parameters.add("cancel_url", "https://isileeserver.shop/app/orders/pay/cancel"); // 결제취소시 넘어갈 url
parameters.add("fail_url", "https://isileeserver.shop/app/orders/pay/fail"); // 결제 실패시 넘어갈 url
log.info("파트너주문아이디:"+ parameters.get("partner_order_id")) ;
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());
// 외부url요청 통로 열기.
RestTemplate template = new RestTemplate();
try{
String url = "https://kapi.kakao.com/v1/payment/ready";
// template으로 값을 보내고 받아온 ReadyResponse값 readyResponse에 저장.
readyResponse = template.postForObject(url, requestEntity, ReadyResponse.class);
log.info("결재준비 응답객체: " + readyResponse);
// 받아온 값 return
return readyResponse;
}catch (RestClientException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
// 결제 승인요청 메서드
@Override
public ApproveResponse payApprove(String pgToken) {
// 주문명 만들기.
String itemName = postOrderDTO.getRewardName();
String order_id = userId+"_" + itemName;
// request값 담기.
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
parameters.add("cid", "TC0ONETIME");
parameters.add("tid", readyResponse.getTid());
parameters.add("partner_order_id", order_id); // 주문명
parameters.add("partner_user_id", "Econg");
parameters.add("pg_token", pgToken);
// 하나의 map안에 header와 parameter값을 담아줌.
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());
// 외부url 통신
RestTemplate template = new RestTemplate();
template.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() {
public boolean hasError(ClientHttpResponse response) throws IOException {
HttpStatus statusCode = response.getStatusCode();
return statusCode.series() == HttpStatus.Series.SERVER_ERROR;
}
});
String url = "https://kapi.kakao.com/v1/payment/approve";
// 보낼 외부 url, 요청 메시지(header,parameter), 처리후 값을 받아올 클래스.
ApproveResponse approveResponse = template.postForObject(url, requestEntity, ApproveResponse.class);
log.info("결재승인 응답객체: " + approveResponse);
return approveResponse;
}
//결제 정보 DB 저장
@Transactional
@Override
public void saveOrder(ApproveResponse approveResponse) {
User user = User.builder().id(userId).build();
Project project = projectRepository.findById(postOrderDTO.getProjectId()).orElseThrow(()->new IdNotFoundException("projectid is not found"));
Reward reward = rewardRepository.findById(postOrderDTO.getRewardId()).orElseThrow(()->new IdNotFoundException("rewardid is not found"));
log.info("order 저장");
Orders order = Orders.builder()
.orderName(approveResponse.getItem_name())
.donation(postOrderDTO.getPrice())
.orderStatus("PAYCOMPLETED")
.deliveryAddress(postOrderDTO.getDeliveryAddress())
.paymentMethodType(approveResponse.getPayment_method_type())
.paymentTid(approveResponse.getTid())
.project(project)
.reward(reward)
.user(user)
.build();
ordersRepository.save(order);
reward.changeSoldQuantity();
project.changeTotalAmount(reward.getPrice());
project.changeAchievedRate();
rewardRepository.save(reward);
projectRepository.save(project);
}
private HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "KakaoAK 키가여기에와야됨");
headers.set("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
return headers;
}
}
문제점 발생
approve_url로 리다이렉트가 안되는 문제점이 발생했다.
따라서, 밑 사진 처럼 redirect uri를 설정했더니 리다이렉트 문제가 해결되었다.
참고문서
- https://developers.kakao.com/docs/latest/ko/kakaopay/common
- https://developers.kakao.com/docs/latest/ko/kakaopay/single-payment#request-common
- https://velog.io/@ggujunhee/스프링부트에서-카카오-페이-API-연동하기
- https://evan-moon.github.io/2020/05/21/about-cors/#cors%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B8%B0%EB%B3%B8%EC%A0%81%EC%9D%B8-%EB%82%B4%EC%9A%A9
- https://velog.io/@pwk921110/CORS%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80