해당 아임포트 문서가 이전 글에서도 알 수 있듯이 프런트에서 구현이 가능한 부분을 알리고 있고 스프링은 따로 알리고 있지 않았다. 하지만 꾸준히 검색과 찾기를 반복한 결과 하나의 사이트를 아임포트에서 제공하는 깃허브 사이트를 발견하였다. 그리고 자바를 지원하는 레포지토리를 찾게 되었다.
iamport REST Client for JAVA
JAVA 사용자를 위한 아임포트 REST API 연동 모듈입니다.
- com.squareup.retrofit2 모듈을 기반으로 만들어진 버전
- maven plugin 형태로 제공
요구사항
JAVA 1.7이상의 버전을 요구합니다.
(dependency관계에 있는 com.squareup.retrofit2 이 JAVA 1.7이상의 버전을 요구합니다)설치
JitPack 을 통해 maven설정을 하실 수 있습니다.
pom.xml에 아래의 내용을 추가해주세요.
... 중략예제
src/test/java 의 IamportRestTest.java를 참조해주세요
그런데 음... 사용법은 왜 안가르쳐줘?
처음 이 문서를 만났을 때의 나의 심정은 말로 표현하기 힘들었다. 이렇게 API도 만들고 문서도 만들었는데 왜 사용법까지 문서화하지 않은거지? 이 문서가 특이한 경우인지 몰라도 이제까지 내가 개발하면서 만난 대부분의 아니 모든 문서는 사용법을 굉장히 친절하게 작성해주었었다. 그런데 이 녀석은 왜???
해당 문서의 마지막 양심이라고 할 수 있는 "예제의 IamportRestTest.java를 참조해주세요"를
따라 테스트 코드로 가보기로 하였다.
public class IamportRestTest {
IamportClient client;
private IamportClient getNaverTestClient() {
String test_api_key = "5978210787555892";
String test_api_secret = "9e75ulp4f9Wwj0i8MSHlKFA9PCTcuMYE15Kvr9AHixeCxwKkpsFa7fkWSd9m0711dLxEV7leEAQc6Bxv";
return new IamportClient(test_api_key, test_api_secret);
}
private IamportClient getBillingTestClient() {
String test_api_key = "7544324362787472";
String test_api_secret = "9frnPjLAQe3evvAaJl3xLOODfO3yBk7LAy9pRV0H93VEzwPjRSQDHFhWtku5EBRea1E1WEJ6IEKhbAA3";
return new IamportClient(test_api_key, test_api_secret);
}
@Before
public void setup() {
String test_api_key = "imp_apikey";
String test_api_secret = "ekKoeW8RyKuT0zgaZsUtXXTLQ4AhPFW3ZGseDA6bkA5lamv9OqDMnxyeB9wqOsuO9W3Mx9YSJ4dTqJ3f";
client = new IamportClient(test_api_key, test_api_secret);
}
@Test
public void testGetToken() {
try {
IamportResponse<AccessToken> auth_response = client.getAuth();
assertNotNull(auth_response.getResponse());
assertNotNull(auth_response.getResponse().getToken());
} catch (IamportResponseException e) {
System.out.println(e.getMessage());
switch (e.getHttpStatusCode()) {
case 401:
//TODO
break;
case 500:
//TODO
break;
}
} catch (IOException e) {
//서버 연결 실패
e.printStackTrace();
}
}
... 중략
처음 코드를 본 순간 음... 일단 모르는 문법은 없다고 생각하여 안도감 약간은 개뿔 모든 코드가 머리 속으로 들어오지 않고 튕겨나갔다. 내가 일말의 지식도 없는 남이 만든 프로젝트의 코드를 분석해야하는 상황이 처음이기에 변수, 메서드 하나 하나 전부 다 외계어처럼 다가왔다. 하지만 모른다고 물러설 상황이 아니었기에 한줄 한줄 코드를 열심히 분석할 수밖에 없었다.
일단 get~~ 으로 되어있는 private 함수 두 개는 일단 나랑 관련이 적어보이니 넘기고 Before을 보면 초기 설정을 하는 부분으로 보인다. 일단 테스트에 있는 IamportClient 분석해보았었다.
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);
}
... 중략
public IamportResponse<Payment> paymentByImpUid(String impUid) throws IamportResponseException, IOException {
AccessToken auth = getAuth().getResponse();
Call<IamportResponse<Payment>> call = this.iamport.payment_by_imp_uid(auth.getToken(), impUid);
Response<IamportResponse<Payment>> response = call.execute();
if ( !response.isSuccessful() ) throw new IamportResponseException( getExceptionMessage(response), new HttpException(response) );
return response.body();
}
public IamportResponse<PagedDataList<Payment>> paymentsByStatus(String status) throws IamportResponseException, IOException {
AccessToken auth = getAuth().getResponse();
Call<IamportResponse<PagedDataList<Payment>>> call = this.iamport.payments_by_status(auth.getToken(), status);
Response<IamportResponse<PagedDataList<Payment>>> response = call.execute();
if ( !response.isSuccessful() ) throw new IamportResponseException( getExceptionMessage(response), new HttpException(response) );
return response.body();
}
... 중략
}
IamprotClient 클래스를 보면 내가 원하는 것들을 많이 발견하였다. 일단 paymentByImpUid() 메서드 이름부터 주문번호로 부터 결제를 가져오는 그런 내용의 함수로 보였기 때문이다. 이렇게 내게 도움이 되는 코드들을 발견해나가며 코드를 분석해보았다. 코드들을 보면 어디선가 정보를 받아오고 토큰도 사용하고 있는 메서드가 많이 보였다. 하지만 http 요청을 보내는 부분이 해당 클래스에 직접 드러나지는 않았기에 조금 더 파보았다.
결국 외부 URL을 통해 통신하는 메서드를 찾은 결과 Iamport 인터페이스를 찾게 되었다.
public interface Iamport {
@POST("/users/getToken")
Call<IamportResponse<AccessToken>> token(
@Body AuthData auth);
@GET("/payments/{imp_uid}/balance")
Call<IamportResponse<PaymentBalance>> balance_by_imp_uid(
@Header("Authorization") String token,
@Path("imp_uid") String imp_uid
);
@GET("/payments/{imp_uid}")
Call<IamportResponse<Payment>> payment_by_imp_uid(
@Header("Authorization") String token,
@Path("imp_uid") String imp_uid
);
... 중략
}
일단 단순히 생긴거로만 봐도 Http 통신을 하기위한 메서드라는 것을 알 수 있었다. 하지만 나는 이런 코들르 자바에서 본적이 없기에 일단 Call 클래스를 먼저 살펴보았다.
public interface Call<T> extends Cloneable {
Response<T> execute() throws IOException;
void enqueue(Callback<T> var1);
boolean isExecuted();
void cancel();
boolean isCanceled();
Call<T> clone();
Request request();
}
사실 별 내용이 없다고 봐도 무방한 코드였기에 추가적인 정보를 위해 package 구조를 확인하자 retrofit2라는 외부 라이브러리를 사용한 것을 확인했다.
여기서는 gpt의 도움을 빌렸다. 당시 내가 프로젝트를 진행하는 상황에서는 사실 gpt의 도움을 크게 받지 않았었다. 정확성이 떨어지는 정보를 자주 주었기 때문에 사용을 자제했었다. 하지만 gpt는 지식을 학습하는 첫 단계에서는 가장 뛰어나다고 생각하였기에 gpt를 통해 retrofit를 학습했었다.
일단 공식문서의 간단한 소개글을 보자면
A type-safe HTTP client for Android and Java
자바와 안드로이드를 위한 안전한 Http 라이브러리를 제공하는 것 같았다. 사용 방법이나 해당 코드가 어떻게 작동하는 지는 gpt를 통해 학습했었는데, 해당 내용을 기록으로 남기지 않아 조금 아쉽다.
여튼 구체적인 사용 방법은 몰라도 단순히 코드만 봐도 굉장히 직관적이었기에 해당 메서드를 실행하면 http 메서드, 헤더나 바디에 실을 정보들을 설정할 수 있기에 전체적인 코드를 이해하는데는 큰 문제가 없었다.
IamportClient를 통해 PG사와 통신을 주고 받으면서 PG사로부터 원하는 정보를 얻을 수 있는 라이브러리라는 것을 알게 되었다. 그리고 받은 정보는 response로 받아오며 해당 Payment 클레스에 내가 원하는 결제와 관련된 정보가 전부 나열되어 있었다.
public class Payment {
@SerializedName("imp_uid")
String imp_uid;
@SerializedName("merchant_uid")
String merchant_uid;
@SerializedName("pay_method")
String pay_method;
@SerializedName("channel")
String channel;
@SerializedName("pg_provider")
String pg_provider;
@SerializedName("emb_pg_provider")
String emb_pg_provider;
... 중략
}
내가 주입하여 사용해야할 IamportClient와 메서드들, http통신이 어떻게 이루어지는지 확인할 Iamport 인터페이스, 그리고 메서드를 통해서 받아온 정보들의 내역을 알게되자 해당 라이브러리를 사용할만큼 분석했다는 생각이 들었다.
앞의 학습 내용을 생각하면 조금 잘못된 말일 수도 있으나 막상 코드를 구현하는 과정에서는 앞의 카카오페이보다 수월하게 진행했다. 일단 http통신 부분을 라이브러리로 제공해주니 내가 작성해야하는 코드들이 상당히 많이 줄어들게 되었다.
@Service
@RequiredArgsConstructor
@Transactional
public class IamportPayService {
private final String api_key = "2160027041337455";
private final String api_secret = "E5BLH8wqTt3JuwMsXGxfkrZiXPF2dwcUBKnUNhyh0gRfHblsiqnNrXC9SWDwXToLlC0LDZ68c2ZnvV24";
private final OrderRepository orderRepository;
/**
* 아임포트 연결
*
* @return
*/
private IamportClient getClient() {
return new IamportClient(api_key, api_secret);
}
/**
* 유저 검증
*
* @param email
*/
public void verifyEmail(String email, String merchant_uid) {
// DB 정보 얻어오기
Orders order = this.getOrdersByMerchant_uid(merchant_uid);
// 주문 DB, 로그인 eamil 비교
String loginEmail = email;
String pgEmail = order.getUser().getEmail();
if (!loginEmail.equals(pgEmail)) { // 다를 경우 주문 취소
this.cancelPaymentByImpUid(order.getPgUid());
throw new IamportSingleIamportPaymentVerificationEmailException();
}
}
/**
* 금액 검증
*
* @param amount
* @param imp_uid
*/
public void verifyAmount(int amount, String imp_uid) {
try {
IamportClient client = getClient();
// pg사 정보 받기
IamportResponse<Payment> payment_response = client.paymentByImpUid(imp_uid);
// pg사에 저장된 금액
int iamportPaymentAmount = payment_response.getResponse().getAmount().intValue();
if (amount != iamportPaymentAmount) { // 금액이 다를 경우
this.cancelPaymentByImpUid(imp_uid);
throw new IamportSingleIamportPaymentVerificationAmountException();
}
} catch (IamportResponseException | IOException e) {
this.cancelPaymentByImpUid(imp_uid);
throw new IamportSingleIamportPaymentConnectionInfoException();
}
}
/**
* 주문 번호 검증
*
* @param requestDTO
*/
public void verifyUid(IamportCallbackDTO requestDTO) {
IamportClient client = getClient();
try {
// 아임포트 정보 받기
IamportResponse<Payment> payment_response = client.paymentByImpUid(requestDTO.getImp_uid());
// DB 주문 정보
Orders order = this.getOrdersByMerchant_uid(requestDTO.getMerchant_uid());
// pg사 주문 번호
String pgMerchantUid = payment_response.getResponse().getMerchantUid();
// db 주문 정보
String orderMerchantUid = order.getPgUid();
if (!pgMerchantUid.equals(orderMerchantUid)) { // 다를 경우
this.cancelPaymentByImpUid(requestDTO.getImp_uid());
throw new IamportSingleIamportPaymentVerificationMerchantUidException();
}
} catch (IamportResponseException | IOException e) {
this.cancelPaymentByImpUid(requestDTO.getImp_uid());
throw new IamportSingleIamportPaymentConnectionInfoException();
}
}
/**
* 특정 금액 환불 로직
*
* @param requestDTO
*/
public void cancelAmount(IamportCancelRequestDTO requestDTO) {
IamportClient client = getClient();
// 환불 금액
BigDecimal cancelAmount = BigDecimal.valueOf(requestDTO.getCancel_amount());
// 환불 데이터
CancelData cancel_data = new CancelData(requestDTO.getMerchant_uid(), false, cancelAmount);
// checksum 으로 검증 추가
cancel_data.setChecksum(cancelAmount);
// DB 검증
Orders order = this.getOrdersByMerchant_uid(requestDTO.getMerchant_uid());
int dbAmount = order.getTotalPrice();
if (requestDTO.getCancel_amount() > dbAmount) { // DB 총 금액과 request 환불 금액 비교
throw new IamportRefundVerificationAmountExceptionIamport();
}
try {
// 환불 실행
IamportResponse<Payment> payment_response = client.cancelPaymentByImpUid(cancel_data);
} catch (IamportResponseException | IOException e) {
throw new IamportSingleIamportPaymentCancelException();
}
// DB 반영
order.setTotalPrice(dbAmount - requestDTO.getCancel_amount());
orderRepository.save(order);
// TODO: 2023-05-19 특정 금액 환불 시 재고 DB반영 여부 모름
}
/**
* 주문 취소
*
* @param imp_uid
*/
private void cancelPaymentByImpUid(String imp_uid) {
try {
IamportClient client = getClient();
// imp_uid를 이용한 전액 환불
CancelData cancel_data = new CancelData(imp_uid, true);
IamportResponse<Payment> cancel_response = client.cancelPaymentByImpUid(cancel_data);
Orders order = this.getOrdersByPGUid(imp_uid);
order.setStatus(OrderStatus.CANCELED);
orderRepository.save(order);
// TODO: 2023-05-19 전액 환불 or 주문 취소 시 재고 DB 반영 추가 여부 모름
} catch (Exception e) {
// TODO: 2023-05-19 전액 환불 로직에서 문제가 생긴다면 어떻게 처리해야할까?
throw new IamportSingleIamportPaymentCancelException();
}
}
/**
* 가맹점 주문 번호를 이용하여 DB의 주문 테이블 정보 얻기
*
* @param merchant_uid
* @return
*/
private Orders getOrdersByMerchant_uid(String merchant_uid) {
// DB 접근하여 주문 정보 가져오기
Optional<Orders> result = orderRepository.findByNumber(merchant_uid);
return result.orElseThrow(IamportDBConnectionByMerchantUidExceptionIamport::new);
}
/**
* pg사 주문 번호를 이용하여 DB의 주문 테이블 정보 얻기
*
* @param pgUid
* @return
*/
private Orders getOrdersByPGUid(String pgUid) {
// DB 접근하여 주문 정보 가져오기
Optional<Orders> result = orderRepository.findByPgUid(pgUid);
return result.orElseThrow(IamportDBConnectionByPgUidExceptionIamport::new);
}
/**
* pg사 주문 번호 DB 반영
*
* @param requestDTO
*/
public void saveImpUid(IamportCallbackDTO requestDTO) {
// 주문 정보 가져오기
Orders order = this.getOrdersByMerchant_uid(requestDTO.getMerchant_uid());
order.setPgUid(requestDTO.getImp_uid());
orderRepository.save(order);
}
/**
* 결제 완료 DB 반영
*
* @param requestDTO
*/
public void savePurchased(IamportVerificaitonDTO requestDTO) {
// 주문 정보 가져오기
Orders order = this.getOrdersByMerchant_uid(requestDTO.getMerchant_uid());
order.setStatus(OrderStatus.PURCHASED);
orderRepository.save(order);
// TODO: 2023-05-20 결제 완료시 재고 관련 DB 작업 여부 모름
}
}
해당 프로젝트를 진행하며 가장 큰 시간을 들여 학습했던 내용이 바로 이 아임포트 결제였다. 아무래도 한글로 친절하게 사용법을 설명해주던 문서들에 익숙해져 있었기에 더 시간이 많이 걸렸다. 거기다가 내가 모르는 사람이 모르는 기술 스택을 사용한 프로젝트를 분석하기란 쉽지 않았다. 그래도 한줄 한줄 코드를 분석해나가는 과정이 힘들지만 보람되었다고 생각한다. 한번 간 길은 두번 가기도 쉽다는 말처럼 남이 작성한 코드, 모르는 기술이 적용된 코드라도 겁먹지 않고 힘껏 부딪힐 용기를 얻었던 것 같다.
공부하면서 많이 듣던 내용 중 하나가
잘 작성된 테스트 코드는 문서를 대체한다.
였는데 이번 기회에 테스트 코드를 잘 작성하는 것이 중요하다고 느꼈다. 공부를 많이 해보면 테스트 코드를 의무적으로 작성하기를 강요받는데 사실 크게 와닿는 느낌이 없었다. 내가 실전에서 테스트를 돌리지 않는 것도 큰 이유였지만 내가 작성한 테스트 코드를 남이 보았을 때의 느낌을 잘 모르는 것이 더 큰 이유였다.
앞으로는 테스트 코드를 더 친절하게 작성해야할 것 같다. 개발자란 결국 협업이 기본이기에 내가 작성한 코드를 다른 동료 개발자가 보고 사용하는 경우가 많다고 생각한다. 앞으로는 실제 코드를 작성하는 것 만큼이나 친절한 테스트 코드를 작성하는 것을 신경쓰면서 개발을 이어나갈 것이다.