
iOS 환경에서 소셜 로그인을 구현할 때 Apple 로그인(Sign in with Apple) 은 선택지가 아니라 필수에 가깝습니다.
앱에서 다른 소셜 로그인을 제공하는 경우, Apple은 Apple 로그인을 반드시 함께 제공하도록 요구하기 때문입니다.
이 글에서는 '스쿼드'라는 앱을 구현하면서 Spring Boot 기반 백엔드에서 Apple Identity Token(JWT)을 검증하고 자체 인증 토큰을 발급하는 과정을 단계별로 정리했습니다. 실제 서비스에 적용해 운영 중인 구조를 토대로 작성했습니다.
Apple 로그인은 아래 순서로 진행됩니다.
이 중 핵심은 4번, JWT 검증 단계입니다.
여기서 검증이 제대로 이뤄지지 않으면 로그인 전체가 실패합니다.
Apple이 발급한 JWT는 Nimbus JOSE + JWT로 검증합니다.
기존에 JWT를 사용하여 토큰을 만들고 있어서 재사용 하려 하였으나, Apple 공개 키(JWKS)를 읽어 RSA 서명을 검증하는 부분은 Nimbus가 표준이라고 하여 추가하였습니다.
dependencies {
implementation "com.nimbusds:nimbus-jose-jwt:9.37.3"
}
Apple Identity Token의 aud 검증을 위해 Bundle ID가 필요합니다.
# application.yml
apple:
bundle-id: com.myapp.squad
환경별로 Bundle ID가 다를 수 있기 때문에 application-*.yml 분리를 추천합니다.
Apple 로그인 시 필요한 필드는 아래와 같이 정리했습니다.
public record AppleLoginReq(
@NotBlank(message = "Identity token은 필수입니다")
String identityToken,
String authorizationCode,
String fullName // 첫 로그인 시에만 제공됨
) {}
Apple은 첫 로그인 시에만 이메일/이름 정보를 내려줍니다.
따라서 최초 로그인에서 해당 데이터를 반드시 저장하는 것이 좋습니다.
Apple의 공개 키를 조회해 서명을 검증하고, 클레임(aud, iss, exp) 검증까지 포함한 실제 검증 로직입니다.
@Slf4j
@Component
public class AppleJwtValidator {
private static final String APPLE_PUBLIC_KEYS_URL = "https://appleid.apple.com/auth/keys";
@Value("${apple.bundle-id}")
private String appleBundleId;
private final WebClient webClient;
public AppleJwtValidator(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
}
public Map<String, Object> validateAndGetClaims(String identityToken) throws Exception {
SignedJWT signedJWT = SignedJWT.parse(identityToken);
if (!verifySignature(signedJWT)) {
throw new IllegalArgumentException("Invalid JWT signature");
}
Map<String, Object> claims = signedJWT.getJWTClaimsSet().getClaims();
validateAudience(claims);
validateExpiration(signedJWT);
validateIssuer(claims);
return claims;
}
private void validateAudience(Map<String, Object> claims) {
Object audClaim = claims.get("aud");
boolean valid = false;
if (audClaim instanceof String) {
valid = appleBundleId.equals(audClaim);
} else if (audClaim instanceof List) {
valid = ((List<?>) audClaim).contains(appleBundleId);
}
if (!valid) {
throw new IllegalArgumentException("Invalid audience: " + audClaim);
}
}
private void validateExpiration(SignedJWT signedJWT) throws Exception {
Date exp = signedJWT.getJWTClaimsSet().getExpirationTime();
if (exp == null || exp.before(new Date())) {
throw new IllegalArgumentException("Token expired");
}
}
private void validateIssuer(Map<String, Object> claims) {
String issuer = (String) claims.get("iss");
if (!"https://appleid.apple.com".equals(issuer)) {
throw new IllegalArgumentException("Invalid issuer: " + issuer);
}
}
private boolean verifySignature(SignedJWT signedJWT) throws Exception {
String jwksJson = webClient.get()
.uri(APPLE_PUBLIC_KEYS_URL)
.retrieve()
.bodyToMono(String.class)
.block();
if (jwksJson == null) {
throw new IllegalStateException("Failed to fetch Apple public keys");
}
JWKSet jwkSet = JWKSet.parse(jwksJson);
JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());
if (jwk == null) {
throw new IllegalArgumentException("Public key not found for kid");
}
RSAKey rsaKey = jwk.toRSAKey();
JWSVerifier verifier = new RSASSAVerifier(rsaKey);
return signedJWT.verify(verifier);
}
public String extractAppleUserId(Map<String, Object> claims) {
return (String) claims.get("sub");
}
public String extractEmail(Map<String, Object> claims) {
return (String) claims.get("email");
}
}
JWT 검증이 끝나면, 사용자 조회/생성 후 자체 액세스 토큰을 발급합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final AppleJwtValidator appleJwtValidator;
private final JwtProvider jwtProvider;
private final AppUserRepository appUserRepository;
@Transactional
public AuthRes loginWithApple(AppleLoginReq req) {
try {
Map<String, Object> claims = appleJwtValidator.validateAndGetClaims(req.identityToken());
String appleUserId = appleJwtValidator.extractAppleUserId(claims);
String email = appleJwtValidator.extractEmail(claims);
AppUserEntity user = appUserRepository
.findByProviderUserIdAndSignupMethod(appleUserId, SignupMethod.APPLE)
.orElseGet(() -> createAppleUser(appleUserId, email, req.fullName()));
String jwt = jwtProvider.createAccessToken(
user.getId(),
user.getProviderUserId(),
user.getDisplayName()
);
return new AuthRes(jwt, UserDto.fromEntity(user));
} catch (Exception e) {
log.error("Apple login failed", e);
throw new IllegalArgumentException("Apple 로그인 실패: " + e.getMessage());
}
}
private AppUserEntity createAppleUser(String appleUserId, String email, String fullName) {
AppUserEntity user = new AppUserEntity();
user.setProviderUserId(appleUserId);
String name = (fullName != null && !fullName.isBlank())
? fullName
: "이름을 입력해주세요";
user.setDisplayName(name);
user.setProfileImage(null);
user.setSignupMethod(SignupMethod.APPLE);
return appUserRepository.save(user);
}
}
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthApiController {
private final AuthService authService;
@PostMapping("/apple")
public ResponseEntity<AuthRes> loginWithApple(@RequestBody @Valid AppleLoginReq req) {
return ResponseEntity.ok(authService.loginWithApple(req));
}
}
개발 과정에서 아래 오류가 발생했습니다.
java.lang.ClassCastException: class java.util.ArrayList cannot be cast to class java.lang.String
Apple의 aud 클레임은 문자열 또는 배열(List) 로 내려올 수 있습니다.
String으로 단정해 캐스팅하면 예외가 발생합니다.
타입에 따라 분기하면 됩니다.
Object audClaim = claims.get("aud");
boolean isValidAudience = false;
if (audClaim instanceof String) {
isValidAudience = appleBundleId.equals(audClaim);
} else if (audClaim instanceof List) {
isValidAudience = ((List<String>) audClaim).contains(appleBundleId);
}
curl -X POST http://localhost:8080/auth/apple \
-H "Content-Type: application/json" \
-d '{
"identityToken": "eyJraWQiOiJIdl...",
"fullName": "홍길동"
}'
이름/이메일은 첫 로그인에서만 제공됩니다.
로컬/개발/운영에서 각각 다를 수 있습니다.
Apple의 JWKS는 자주 바뀌지 않기 때문에 Redis로 캐싱하는 것도 고려할 수 있습니다.
운영 환경에서는 더 세분화된 예외 코드와 로깅이 필요합니다.
Apple 로그인을 Spring Boot에서 구현할 때 가장 중요한 부분은 Identity Token 검증입니다.
이 단계만 안정적으로 구축하면 이후 인증 흐름은 비교적 단순합니다.
Apple 로그인은 처음 접하면 다소 복잡해 보이지만, 구조를 한 번 잡아두면 안정적으로 운영할 수 있습니다.