운영 중인 사이드 프로젝트에서 결제 기능을 개발하게 되었습니다. 2024년 이후 Apple의 영수증 검증 방식이 변경되었는데, 관련 한글 자료가 많지 않아 정리해봅니다.
Apple 영수증 검증 방식의 변화
Apple은 2024년부터 기존 verifyReceipt API를 Deprecated 처리하고, JWS(JSON Web Signature) 기반 영수증 검증으로 전환했습니다.
기존 방식 (Deprecated)
클라이언트 → 서버 → Apple verifyReceipt API → 응답
Base64 인코딩된 영수증을 Apple 서버로 전송하고, Apple이 검증 후 JSON 응답을 반환하는 방식이었습니다.
문제점
- Apple 서버에 대한 의존성
- 네트워크 latency
- API 호출 제한
새로운 방식 (JWS)
클라이언트 → 서버 → JWS 파싱 & 서명 검증 → 완료
클라이언트가 JWS 형태의 영수증을 전달하면, 서버에서 Apple 공개키로 직접 서명을 검증합니다.
장점
- 빠른 응답 속도
- Apple 서버 의존성 제거
- 높은 확장성
JWS 영수증 구조
Apple JWS 영수증은 세 부분으로 구성됩니다.
[Header].[Payload].[Signature]
Header (x5c 포함)
{
"alg": "ES256",
"x5c": [
"MIIDFzCCApygAwIBAgI...", // Leaf Certificate
"MIICjjCCAhSgAwIBAgI...", // Intermediate Certificate
"MIICQzCCAcmgAwIBAgI..." // Root Certificate
],
"kid": "yWU4S0LdAk"
}
Payload (영수증 정보)
{
"transactionId": "0000123456789",
"originalTransactionId": "000123456789",
"bundleId": "com.test.test",
"productId": "com.test.test.test",
"purchaseDate": 169999,
"quantity": 1,
"type": "Consumable"
}
⚠️ 암호화 알고리즘은 ES256입니다. RSA256으로 오류난 기억이..
의존성 추가
dependencies {
api 'com.apple.itunes.storekit:app-store-server-library:3.6.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
결제 흐름

구현 로직
Apple Root CA 인증서 로드
@Configuration
public class AppStoreServerConfig {
@Value("${apple.app-store.bundle-id}")
private String bundleId;
@Value("${apple.app-store.environment:SANDBOX}")
private String environment;
@Bean
public SignedDataVerifier signedDataVerifier() throws IOException {
var rootCAStreams = loadAppleRootCertificates();
var env = Environment.valueOf(environment);
return new SignedDataVerifier(
rootCAStreams,
bundleId,
appAppleId,
env,
true
);
}
private Set<IntputStream> loadAppleRootCertificates() throws IOEexception {
var rootCAStreams = new HashSet<InputStream>();
String[] appleRootCAUrls = {
"https://www.apple.com", //AppleRootCA-G2.cer
"https://www.apple.com" //AppleRootCA-G3.cer
}
for (String certUrl : appleRootCAUrls) {
var certStream = URI.create(certUrl).toURL().openStream();
byte[] certBytes = certStream.readAllBytes();
ByteArrayInputStream(certBytes));
}
return rootCAStreams;
}
}Apple Root CA G2, G3 인증서를 미리 로드합니다. SignedDataVerifier는 환불 알림 검증용으로도 사용됩니다.
JWS 영수증 검증
@Component
public class AppStoreVerifier {
private final String bundleId;
private final ApplePublicKeyProvider publicKeyProvider;
public VerificationResult verify(String jws, String productId) {
try {
// JWS 파싱 및 서명 검증
var claims = parseAndVerify(jws);
// Payload 검증
return verifyPayload(claims, productId);
} catch (SignatureException e) {
// 공개키 갱신 가능성 → 캐시 삭제 후 재시도
publicKeyProvider.evictCache(jws);
var claims = parseAndVerify(jws);
return verifyPayload(claims, productId);
}
}
private Jws<Claims> parseAndVerify(String jws) {
var publicKey = publicKeyProvider.getPublicKey(jws);
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(jws);
}
private VerificationResult verifyPayload(Claims claims, String productId) {
// 1. bundleId 검증
String payloadBundleId = claims.get("bundleId", String.class);
if (!bundleId.equals(payloadBundleId)) {
throw new InvalidReceiptException("Invalid bundleId");
}
// 2. 만료 시간 검증
if (claims.getExpiration() != null &&
claims.getExpiration().before(new Date())) {
throw new ExpiredReceiptException("Receipt expired");
}
// 3. productId 검증
String payloadProductId = claims.get("productId", String.class);
if (!productId.equals(payloadProductId)) {
throw new InvalidProductException("Product mismatch");
}
// 4. transactionId 반환
String transactionId = claims.get("transactionId", String.class);
return VerificationResult.success(transactionId, productId);
}
}JWS 서명 검증 후 Payload의 bundleId, productId, expiration을 검증합니다. SignatureException 발생 시 캐시를 삭제하고 재시도합니다.
공개키 추출 및 캐싱
@Component
public class ApplePublicKeyProvider {
private final AppleCertificateChainVerifier certificateVerifier;
private final ObjectMapper objectMapper;
@Cacheable(value = "applePublicKeys", key = "#jws.substring(0, 100)")
public PublicKey getPublicKey(String jws) {
try {
// 1. JWT Header 추출
var header = extractHeader(jws);
// 2. x5c (인증서 체인) 추출
@SuppressWarnings("unchecked")
var x5c = (List<String>) header.get("x5c");
// 3. 인증서 체인 검증
certificateVerifier.verify(x5c);
// 4. Leaf Certificate에서 공개키 추출
var leafCert = parseCertificate(x5c.get(0));
return leafCert.getPublicKey();
} catch (Exception e) {
throw new PublicKeyExtractionException("Failed to extract public key", e);
}
}
private Map<String, Object> extractHeader(String jws) throws IOException {
int dotIndex = jws.indexOf('.');
String headerBase64 = jws.substring(0, dotIndex);
byte[] decoded = Base64.getUrlDecoder().decode(headerBase64);
String headerJson = new String(decoded, StandardCharsets.UTF_8);
return objectMapper.readValue(headerJson, new TypeReference<>() {});
}
private X509Certificate parseCertificate(String certBase64) throws CertificateException {
byte[] certBytes = Base64.getDecoder().decode(certBase64);
var factory = CertificateFactory.getInstance("X.509");
return (X509Certificate) factory.generateCertificate(
new ByteArrayInputStream(certBytes)
);
}
@CacheEvict(value = "applePublicKeys", key = "#jws.substring(0, 100)")
public void evictCache(String jws) {
log.info("Evicting cached public key");
}
}x5c 헤더에서 인증서 체인 추출하고 Spring Cache로 공개키 캐싱하되 검증 실패하면 캐시를 삭제합니다.
인증서 체인 검증
@Component
public class AppleCertificateChainVerifier {
private Set<TrustAnchor> trustAnchors;
@PostConstruct
public void init() throws Exception {
this.trustAnchors = loadAppleRootCertificates();
log.info("Loaded {} Apple Root CA certificates", trustAnchors.size());
}
public void verify(List<String> x5c) throws CertificateException {
try {
// 1. Base64 인증서를 X509Certificate로 변환
var certChain = x5c.stream()
.map(this::parseCertificate)
.toList();
// 2. CertPath 생성
var factory = CertificateFactory.getInstance("X.509");
var certPath = factory.generateCertPath(certChain);
// 3. PKIX 파라미터 설정
var params = new PKIXParameters(trustAnchors);
params.setRevocationEnabled(false);
// 4. 인증서 체인 검증
var validator = CertPathValidator.getInstance("PKIX");
validator.validate(certPath, params);
} catch (CertPathValidatorException e) {
throw new CertificateException("Invalid certificate chain", e);
} catch (Exception e) {
throw new CertificateException("Certificate validation error", e);
}
}
private Set<TrustAnchor> loadAppleRootCertificates()
throws CertificateException, IOException {
var anchors = new HashSet<TrustAnchor>();
String[] rootCAUrls = {
"https://www.apple.com", //AppleRootCA-G2.cer
"https://www.apple.com" //AppleRootCA-G3.cer
};
for (String url : rootCAUrls) {
var certStream = URI.create(url).toURL().openStream();
byte[] certBytes = certStream.readAllBytes();
certStream.close();
var factory = CertificateFactory.getInstance("X.509");
var cert = (X509Certificate)
factory.generateCertificate(
new ByteArrayInputStream(certBytes)
);
anchors.add(new TrustAnchor(cert, null));
}
return anchors;
}
private X509Certificate parseCertificate(String certBase64) {
try {
byte[] certBytes = Base64.getDecoder().decode(certBase64);
var factory = CertificateFactory.getInstance("X.509");
return (X509Certificate)
factory.generateCertificate(
new ByteArrayInputStream(certBytes)
);
} catch (CertificateException e) {
throw new RuntimeException("Failed to parse certificate", e);
}
}
}Apple Root CA G2, G3를 신뢰 앵커로 설정하고 PKIX 알고리즘으로 인증서 체인 유효성을 검증합니다.
이서어 서비스에 맞게 서비스 로직을 구현하면서 재화를 발급해주면 될 것 같아요. 개발 / 운영 환경에 따라 SANDBOX , PRODUCTION을 구분해서 테스트 진행하면 될 것 같구요. 주의사항은 아래와 같아요.
핵심 포인트 및 주의사항
인증서 체인 검증 필수
x5c가 Apple Root CA로 연결되는지 반드시 검증해야 합니다. 이를 통해 중간자 공격(MITM)을 방지할 수 있습니다.
bundleId, productId 검증
다른 앱의 영수증을 사용하는 공격을 방지합니다. Payload에서 추출한 값이 우리 앱의 설정과 일치하는지 확인하세요.
중복 결제 방지
transactionId를 DB에 UNIQUE 제약조건으로 설정하고, 저장 전 중복 검사를 수행합니다.
공개키 캐싱
매 요청마다 x5c를 파싱하면 성능이 저하됩니다. Spring Cache 등을 활용해 공개키를 캐싱하세요.
Apple 서버 호출 제거
JWS 검증은 서버에서 직접 처리하므로 Apple 서버에 대한 네트워크 호출이 없습니다. 이로 인해 빠른 응답 속도를 확보할 수 있습니다.
환경 분리
개발/운영 환경에 따라 SANDBOX 또는 PRODUCTION을 구분하여 설정합니다.
Apple Root CA 로드 실패 대비
서버 시작 시 Root CA 다운로드에 실패하면 앱이 시작되지 않을 수 있습니다. 로컬에 인증서 파일을 백업해두는 것을 권장합니다.
SignatureException 처리
Apple이 공개키를 갱신할 수 있으므로, 검증 실패 시 캐시를 삭제하고 재시도하는 로직이 필요합니다.
설정 파일
# application.yml
apple:
app-store:
bundle-id: com.test.test
environment: SANDBOX # or PRODUCTION
app-apple-id: 123456 # optional
마무리
Apple의 새로운 JWS 기반 영수증 검증 방식은 기존 verifyReceipt API 대비 여러 장점을 제공합니다.
- 빠른 응답 속도: Apple 서버를 거치지 않고 자체 서버에서 직접 검증
- 외부 의존성 제거: Apple 서버 장애나 네트워크 이슈에 영향받지 않음
- 높은 확장성: 트래픽 증가에도 안정적인 처리 가능
- 강화된 보안: 인증서 체인 검증으로 위변조 시도 차단
처음 구현할 때 ES256 알고리즘을 RSA256으로 착각해서 한참 헤맸던 기억이 있네요. 이 글이 저와 같은 시행착오를 겪는 분들께 도움이 되었으면 합니다. 실제 서비스에 적용할 때는 각자의 비즈니스 로직에 맞게 재화 지급, 트랜잭션 관리, 에러 처리 등을 추가로 구현하시면 됩니다. 다음 글에서는 App Store Server Notifications V2를 활용한 환불 처리와 구독 상태 관리에 대해 다뤄보겠습니다.
'Spring boot' 카테고리의 다른 글
| @Valid 로 DTO 검증 (0) | 2024.01.25 |
|---|---|
| [ JPA ] Eager, Lazy Fetching (0) | 2024.01.15 |
| [ Spring ] @Bean (1) | 2024.01.11 |
| 회원 가입 기능 구현하기 (0) | 2023.09.12 |
| 스프링 시큐리티(Spring Security) (0) | 2023.09.06 |