팀 프로젝트에서 예약/결제 모듈을 담당해서 구현했었습니다. 시간이 조금 흐르고 난 뒤 코드를 다시 보니 지나칠 수 없는 코드들이 보였습니다. 그렇게 시작한 리팩토링의 과정을 기록으로 남깁니다.
이전에(Iamport일 당시) PortOne API를 사용해 구현한 결제 모듈의 흐름입니다. (아직 UML과같은 방법론을 제대로 배우지 않았고 시간도 여유롭지 못해 겨우 이해를 도울 정도의 그림이니 가볍게 보고 지나가주세요)
다시 결제의 흐름을 이해하기 위해 PortOne에서 제공하는 아주 친절한 문서를 읽었습니다. 문서를 읽으며 그 당시 동작하는 코드를 작성하는 것에만 집중하여 PortOne이 제공하는 편리한 API를 놓친것이 몇몇 있었는데요, 이 흐름 속 리팩토링이 시급한 코드들을 4군데로 추려 정리했습니다.
결제 데이터를 처리하는데에 사용되는 교유한 키값의 생성 코드를 프론트 단에서 서버로 옮겼습니다.
결제 데이터 처리 흐름.jpg
DB의 요구사항을 정의하고 설계할 때, Pay 테이블 결제를 시도할 때마다 row가 생성되도록 설계를 했습니다. 결제 도중이나 후에 문제발생 시 서비스를 제공하는 측에서 문제의 원인과 책임을 특정할 수 있는 근거가 있어야하기 때문입니다.이전에 구현할 당시, 저는 결제 데이터를 저장하기 위해 만든 Pay테이블에 정보를 어느 시점에 저장해야하는지 고민했었습니다. 결국 프론트 단에서 주문번호를 생성하고, 결제가 완료된 후에 Pay테이블에 데이터를 저장하는 실수를 했습니다.
하지만 2차 개발 시 Pay테이블을 primary 키 값인 주문번호는 고유하게 DB상에 저장해야하는 중요한 값이기 때문에 front = >server 로 주문번호 생성 코드를 옮겼습니다. 그리고 front단에 노출된 값이 아닌 DB에 저장 된 값을 최대한 사용하기 위해 결제 전에 결제의 후처리에 필요한 값들을 Pay 테이블에 먼저 저장하고 결제할 수 있도록 변경했습니다.
이번 리팩토링 전, 가장 구현에 있어서 헷갈렸던 부분 중 하나는 위변조를 방지와 관련된 부분이었습니다. 위변조의 가능성을 생각하며 코드를 작성했으나 이렇게 구현하면 프론트 단에서의 스크립트 조작은 막을 수 없음을 알았기 때문입니다.
위변조 검증을 결제 전과 후, 총 두 번을 해서 원천적으로 차단하였습니다.
PortOne에서는 클라이언트에서는 원천적으로 막을 수 없는 스크립트 조작을 방지하기 위해
@ResponseBody
@PostMapping("/url")
public String preparePay(@RequestBody PayDto payDto, ...){
//주문번호 생성
try {
PrepareData prepareData = new PrepareData(merchant_uid, new BigDecimal(amount));
//PortOne서버에 미리 등록해놓기
client.postPrepare(prepareData);
} catch (Exception e) {
...
}
//서버에 결제 데이터 미리 저장
payService.savePayInfo(payDto);
...
}
@ResponseBody
@PostMapping("/saveResult2")
public String savePayResult2(@RequestBody PayDto payDto, HttpSession session){
String imp_uid = payDto.getImp_uid();
PayResultDto payResultDto;
long amount = 0;
...
try {
IamportResponse<Payment> payment_response = client.paymentByImpUid(imp_uid);
amount = payment_response.getResponse().getAmount().longValue();
//DB에 저장된 지불해야하는 값 가져오기
PayViewDto payViewDto = payService.getMlgAndPrdInfo(payDto.getPay_no());
int used_mlg = payViewDto.getUsed_mlg();
long pay_ftr_prc = payViewDto.getPay_ftr_prc();
double amountToBePaid = pay_ftr_prc - used_mlg;
if(amount==amountToBePaid){
payResultDto = new PayResultDto(payDto.getPay_no(),
amount,
new Date(),
"card",
PAY_STT_COMPLETE,
PAY_APPV_SUCCESS,
RESERV_COMPELET,
payDto.getImp_uid());
payService.savePayResult(payResultDto);
...
jsonResult.addProperty("status", "success");
} else {
throw new PayForgeryException("결제금액 불일치");
}
} catch (Exception e){
...
}
return jsonResult.toString();
}
PortOne에서 제공하는 사전검증 기능을 사용하지 않았기 때문에 위변조 코드를 구현할 때 겪은 어려움은 당연한 것이었습니다. (친절한 예제 코드가 PortOne Doc, github에 있으니 참고하시길 바랍니다.)
IamportClient는 PortOne 서버에 httpRequest를 보내어 token과 paymentData를 받아오는 작업을 간단히, 1줄로 끝낼 수 있게하는 PortOne에서 제공하는 API Client입니다. 하지만 저는 IamportClient의 존재는 알았지만 정확힌 쓰임을 파악하지 못했고, 서버에서 직접 요청을 생성해서 보내고 받는 아래의 코드들을 작성했었습니다.
//token을 받기위해 요청을 생성하고
ObjectMapper objectMapper = new ObjectMapper();
String requestBody = objectMapper
.writerWithDefaultPrettyPrinter()
.writeValueAsString(map);
HttpRequest request = HttpRequest.newBuilder()
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.uri(URI.create("https://api.iamport.kr/users/getToken"))
.timeout(Duration.ofMinutes(2))
.build();
//응답을 받아 필요한 값을 꺼내기
CompletableFuture<HttpResponse<String>> response = HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString());
String result = response.thenApply(HttpResponse::body).get(5, TimeUnit.SECONDS);
Gson gson = new Gson();
String stringResponse = gson.fromJson(result, Map.class).get("response").toString();
token = gson.fromJson(stringResponse, Map.class).get("access_token").toString();
하지만 IamportClient를 사용하면 아래와 같이 직접 PortOne서버에 값을 요청하는 코드를 작성하지 않아도 됩니다. 심지어 token을 요청하는 코드도 생략할 수 있습니다.
@Controller
@RequestMapping("/url")
public class PayController {
@Autowired
ReservService reservService;
@Autowired
PayService payService;
private final IamportClient client;
public PayController(ImportAPI api){
String impKey = api.getIMP_KEY();
String impSecret = api.getIMP_SECRET();
client = new IamportClient(impKey, impSecret);
}
...
@ResponseBody
@PostMapping("/path이름")
public String savePayResult2(@RequestBody PayDto payDto, HttpSession session){
...
try {
//token을 직접 요청하지 않아도 됩니다!
IamportResponse<Payment> payment_response = client.paymentByImpUid(imp_uid);
amount = payment_response.getResponse().getAmount().longValue();
String status = payment_response.getResponse().getStatus();
...
if(amount==amountToBePaid){
payResultDto = new PayResultDto(payDto.getPay_no(),
amount,
new Date(),
"card",
PAY_STT_COMPLETE,
PAY_APPV_SUCCESS,
RESERV_COMPELET,
payDto.getImp_uid());
...
위에 보시다시피 impkey와 impSecret을 노출하고 싶지 않아서 properties파일에 저장하고 생성자가 호출되었을 때 값을 가져오도록 구현했습니다. @PropertySource와 @Value를 사용해서 구현을 하는데, 계속 impkey, impSecret이 null값이 나오는 문제가 발생했습니다. 디버깅을 하니까 PayController의 생성자 실행 이후 @Value에서 값을 가져오는 것이 원인이더라구요. s2ljeun님의 글을 읽고 API키를 propertySource에서 가져오는 클래스를 따로 만들어서 문제를 해결했습니다.
IamportClient를 사용하면 너무나 쉽게 기능을 구현할 수 있습니다. 하지만, java 서버 내에서도 요청과 응답을 할 수 있음을 처음 배운 때이기도했고 직접 검색하며 코드를 작성할 수 있는 유익한 경험이었다고 생각합니다.
github에 테스트 코드나 여려 상황에서 쓰이는 api가 설명되어있으니, 잘 찾아 사용하시길 바랍니다. 다른 목적이 없는 한, API를 적극 활용하셨으면 좋겠습니다. 여러분의 시간은 소중하니까요!
웹훅은 선택사항입니다. 웹훅은 기본적으로 외부에서 접근 가능한 도메인에서 테스트가 가능하기 때문입니다. 하지만 ngrok이라는 서비스로 쉽게 localhost를 외부망에서 접근 가능하게 포워딩할 수 있기 때문에 한 번 해보시는 것을 추천드립니다.
와이파이 신호가 끊어지는 등의 문제로 클라이언트가 결제에 대한 응답을 받지 못하는 경우 클라이언트에서 수행해야하는 결제 이후의 로직이 실행되지않는 사고가 발생할 수 있습니다. 결제 성공시 PortOne서버에 저장된 결제 데이터가 저장되어있지만, 가맹점DB에는 결제 데이터가 없을 수 있는 것입니다.
이를 방지하기위해 PortOne에서 제공하는 웹훅 기능이 있습니다. 웹훅이란 특정 이벤트가 발생했을 때 서비스로 알림을 보내는 기능입니다. 알림을 보내는 특정 이벤트들이 몇 가지가 있는데요, 그 중에 결제가 승인됐을 때 웹훅은 POST request를 가맹점의 서버(내 서버)에 보냅니다. 결제이벤트가 발생하면 PortOne에서 직접 서버로 요청을 보내어 이후의 로직을 서버에서 바로 수행할 수 있습니다.
저는 localhost로 호출을 테스트하기 위해 ngrok 서비스를 이용했습니다. Portone에서도 이 서비스를 추천합니다. 포트포워딩을 직접해서 테스트할 수도 있겠지만, 로컬 서버를 더 안전하게 노출할 수 있는 방법이기도 하고 쉽고 돈도 들지 않아 테스트하기 좋은 도구로 사용할 수 있었습니다.
ngrok.exe을 실행하고 인증을 한 후에, 자신의 프로젝트 로컬 서버 포트가 8080이라면
ngrok http 8080
을 입력하면 아래와 같은 화면이 뜹니다
주황색으로 칠해놓은 부분에 localhost 웹 서버를 외부에서 안전하게 접근할 수 있도록 할당된 public URL이 있습니다.
PortOne의 관리자 콘솔에서 webhook을 보낼 URL을 지정하고 테스트를 해보았습니다.
성공!!
이후의 로직은 웹훅을 사용하지 않을 때와 거의 비슷합니다. front에서 서버에 직접 요청할 것을 portOne이 대신 요청한 것이니까요. 다만 웹훅은 결제 실패 시 발송되지 않습니다. 따라서 결제가 실패했을 때 결제 결과를 저장하는 등의 후처리를 하는 코드를 따로 작성해야합니다.
IMP.request_pay({ /** 요청 객체를 추가해주세요 */ },
function (rsp) {
if (rsp.success) {
// 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
// jQuery로 HTTP 요청
jQuery.ajax({
url: "{서버의 결제 정보를 받는 가맹점 endpoint}",
method: "POST",
headers: { "Content-Type": "application/json" },
data: {
imp_uid: rsp.imp_uid, // 결제 고유번호
merchant_uid: rsp.merchant_uid // 주문번호
}
}).done(function (data) {
// 가맹점 서버 결제 API 성공시 로직
})
} else {
//실패 시 후처리 코드
jQuery.ajax({
url: "{싪패 시 서버의 결제 정보를 받는 가맹점 endpoint}",
method: "POST",
contentType: "application/json; charset=utf-8",
data: JSON.stringify(payDto)
}).done(function (result) {
if(result){
alert("결제가 처리되지 않았습니다. 에러 내용: " + rsp.error_msg);
} else {
alert("결제가 정상적으로 처리되지 않았습니다. 000-0000 으로 문의해주시기 바랍니다.");
}
...
});
}
});
웹훅이 없는 결제의 흐름은 아래의 그림과 같습니다.
첫번째와 비교하면 결제전 등록과 데이터 저장이 가장 크게 달라진 부분이겠네요. 이번 리팩토링을 하면서 크게 배운 것은 API를 사용할 때는 문서를 꼼꼼히 살펴야한다는 것과 처음 요구사항을 정의하고 DB를 설계할 때 의도한 부분들을 빠짐없이 구현해야한다는 것입니다. 맨처음에는 결제 전 데이터 저장과 검증을 하려고 했지만
첫 설계시 고민의 흔적들.jpg
중요하지 않은 이유로 생략을 했고 이후의 기능 구현에 크고 작은 혼란이 있었기 때문입니다.
저와 같은 실수를 하기 직전이신 분들에게 이 글이 도움이 되기를 바랍니다. 읽어주셔서 감사합니다.