이전에 사이드 프로젝트를 진행하면서 IOS 인앱 결제 서버 측 로직을 구현한 적이 있다.
간단한 로직은 이렇다.

결제 구간은 클라이언트 측에서 진행하고, 결제 완료 후 서버 측에 결제 정보를 전달해주면 이를 서버가 영수증을 요청해 비교를 하여 맞는 결제인지 검증을 한다. 그 후 클라이언트 측에 결제 완료 response를 내려주게 된다.
보통 구글에 검색하면 POST https://buy.itunes.apple.com/verifyReceipt 이 api를 사용해 구현을 한 글들이 많은데 애플 디벨로퍼를 보면 아쉽게도....

Deprecated 되어 더이상 사용을 안하길 권고한다. 그래서 다른 api를 사용해야하는데 바로 이 api 이다.
https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}
transactionId를 사용함에 따라 좀 많이 복잡하게 변했다. 구현한 로직을 차례대로 설명하겠다.
@Transactional
public Boolean checkTransactionVerify(String transactionId) throws IOException {
// api 호출에 필요한 토큰 생성
String token = generateAppleJwt();
// 트랜잭션 api를 통해 암호화된 인앱 결제 트랜잭션 response를 리턴 받아옴
IosTransactionInfoDto transaction = getSignedTransactionInfo(token, transactionId);
// 트랜잭션 데이터를 복호화한 후 복호화된 데이터로 업데이트
String payload = getPayload(transaction.getSignedTransactionInfo());
getTransaction(transaction, payload, IosTransactionInfoDto.class);
// 유효한지 검증
verifyCheck(transaction);
// 자사 서비스 구독 처리
subscribeStory(transaction);
return true;
}
주석을 보면 대충 알겠지만 저런 흐름으로 로직을 구성해주었다. 우선 애플 api를 호출하기 위해선 JWT 토큰이 헤더에 필요하다. 토큰 생성은 apple developer 에서 privateKey, issuerId, appBundelId, 그리고 SubscriptionKey 파일이 필요하다. 아래는 구현 로직이다.
private String generateAppleJwt() throws IOException {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "ES256");
headers.put("kid", privateKey);
headers.put("typ", "JWT");
Date issuedAt = Date.from(Instant.now());
Date expiredAt = Date.from(Instant.now().plus(Duration.ofMinutes(10L)));
PrivateKey privateKey = getPrivateKey(privateFilePath, "EC");;
String token = Jwts.builder()
.setHeader(headers)
.setIssuer(issuerId)
.setAudience("appstoreconnect-v1")
.setExpiration(expiredAt)
.setIssuedAt(issuedAt)
.claim("bid",appBundleId)
.signWith(privateKey, SignatureAlgorithm.ES256)
.compact();
return token;
}
private PrivateKey getPrivateKey(String filename, String algorithm) throws IOException {
ClassPathResource resource = new ClassPathResource(filename);
String content;
InputStream inputStream = resource.getInputStream();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream) )) {
content = reader.lines().collect(Collectors.joining("\n"));
}
try {
String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
KeyFactory kf = KeyFactory.getInstance(algorithm);
return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Java did not support the algorithm:" + algorithm, e);
} catch (InvalidKeySpecException e) {
throw new RuntimeException("Invalid key format");
}
}
apple developer 에서 받아온 값들을 통해 JWT를 구현하는 모습이다. 보면 privateFilePath가 SubscriptionKey 파일 위치인데 JWS 방식이라 sign에 사용하는 PrivateKey를 이 파일을 통해 직접 만들어줘야 한다. 이부분에서 좀 머리가 많이 아팠다...
다음은 이를 통한 api 요청 로직이다.
private IosTransactionInfoDto getSignedTransactionInfo(String token, String transactionId) {
IosTransactionInfoDto response = iosClient.getTransactionInfo(token, transactionId);
if(response.getSignedTransactionInfo() == null) throw new RuntimeException("signedTransactionInfo is null.");
return response;
}
본인은 FeignClient를 사용하여 api 요청을 구현하였다. iosClient가 그 부분이고 위에서 생성한 token과 앱 결제 후 서버로 검증 요청할 때 받은 transactionId를 통해 암호화된 response를 받는다. 이제 이 response를 복호화를 해야 한다. 다음은 복호화 로직이다.
private String getPayload(String signedData) {
String[] check = signedData.split("\\.");
Base64.Decoder decoder = Base64.getDecoder();
return new String(decoder.decode(check[1]));
}
private <T> void getTransaction(T transaction, String payload, Class<T> clazz) {
try {
transaction = objectMapper.readerForUpdating(transaction).readValue(payload, clazz);
} catch (Exception e) {
throw new RuntimeException("TransactionInfo is null.");
}
}
해당 signedData의 구조는 header.payload.signature 구조이기에 중간인 payload 값을 복호화하여 리턴해주고 이를 기존 transaction 객체에 업데이트를 해주고 있다. 다음은 검증이다.
private void verifyCheck(IosTransactionInfoDto transaction) {
LocalDateTime signedDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(transaction.getSignedDate()), ZoneId.systemDefault());
LocalDate signedDate = signedDateTime.toLocalDate();
if("PURCHASED".equals(transaction.getInAppOwnershipType()) && signedDate.equals(LocalDate.now())) return;
throw new RuntimeException("Transaction is invalid.");
}
검증은 간단하게 인앱 결제 타입이 PURCHASED 인지, 그리고 결제 날짜가 오늘인지 확인해주었다. 그 후 서비스를 구독을 해준 뒤 클라이언트에 true를 리턴해주었다.
해당 사용자의 인덱스를 알기 위해서는 앱단에서 인앱 결제 요청 시 유일하게 직접 값을 넣어 보낼 수 있는 appAccountToken 을 이용해주었다. 해당 값은 UUID 형태로 보내야하기에 '결제 대상 - 사용자 id - 결제 옵션 - 결제 시간' 형태로 값을 넣어 보내주었다. 이렇게 보내게 되면 서버 측에서도 리턴 받은 transaction 객체 안에 해당 값이 들어있어 식별이 가능해진다.
마지막으로 환불은 구독한 사용자가 환불을 할 경우, 서버에서 구현한 api를 apple developer webhook에 등록해 등록한 url로 웹훅이 올 경우if("REFUND".equals(dto.getNotificationType())) 일 때 서비스 구독 해지를 진행했다.
ios 결제 영수증 검증을 구현하는데 생각보다 어려웠는데 구글링을 해보면 대부분이 verifyReceipt을 사용하여 구현한 옛날 포스팅들이어서 정보를 찾는데 시간이 걸렸다. 구현하면서 apple developer를 엄청 찾아봤는데 디벨로퍼가 좀 많이 불친절하다고 느껴졌다.
가장 시간이 걸린 부분은 사실 앱 검증을 끝낸 후 서비스 구독을 하는 로직이었다. 애플에서 받은 transaction 객체가 당최 어느 사용자의 데이터인지 알 수 가 없었다... 클라이언트에서 직접 transactionId와 사용자 id를 같이 주는 방법도 있었지만 이렇게 되면 데이터 정합성이 지켜지지 않는다고 판단해 다른 방식을 찾았었다. 다행히 appAccountToken을 통해 클라이언트 측에서 결제 시 직접 데이터를 넣을 수 있는 필드가 존재해 이를 이용하여 사용자 식별이 가능했었다. 유레카....!
이렇게 ios 결제를 구현해보았다. 이전에 부트페이로도 결제를 구현해봤는데 애플이 단순 api 호출에도 보안에 좀 더 신경을 쓰는 느낌이 들었다.
끝
허거거거거걱거걱 너무 좋은 글 감사합니다
배우고 갑니다.. IOS결제라니. 넘 신기.방귀.