Apple 로그인 with spring boot(feat. Sign in with Apple)

J_Eddy·2025년 11월 20일
post-thumbnail

Spring Boot에서 Apple 로그인(Sign in with Apple) 구현하기

iOS 환경에서 소셜 로그인을 구현할 때 Apple 로그인(Sign in with Apple) 은 선택지가 아니라 필수에 가깝습니다.
앱에서 다른 소셜 로그인을 제공하는 경우, Apple은 Apple 로그인을 반드시 함께 제공하도록 요구하기 때문입니다.

이 글에서는 '스쿼드'라는 앱을 구현하면서 Spring Boot 기반 백엔드에서 Apple Identity Token(JWT)을 검증하고 자체 인증 토큰을 발급하는 과정을 단계별로 정리했습니다. 실제 서비스에 적용해 운영 중인 구조를 토대로 작성했습니다.


기술 스택

  • Spring Boot 3.5.6
  • Java 21
  • Nimbus JOSE + JWT
  • PostgreSQL

Apple 로그인 전체 흐름

Apple 로그인은 아래 순서로 진행됩니다.

  1. iOS 클라이언트에서 "Sign in with Apple" 실행
  2. Apple이 사용자를 인증하고 Identity Token(JWT)을 발급
  3. 클라이언트 → 서버로 Identity Token 전달
  4. 백엔드에서 Identity Token 검증 및 사용자 정보 추출
  5. 백엔드에서 자체 액세스 토큰(JWT) 발급 후 반환

이 중 핵심은 4번, JWT 검증 단계입니다.
여기서 검증이 제대로 이뤄지지 않으면 로그인 전체가 실패합니다.


1. Dependency 추가

Apple이 발급한 JWT는 Nimbus JOSE + JWT로 검증합니다.
기존에 JWT를 사용하여 토큰을 만들고 있어서 재사용 하려 하였으나, Apple 공개 키(JWKS)를 읽어 RSA 서명을 검증하는 부분은 Nimbus가 표준이라고 하여 추가하였습니다.

dependencies {
    implementation "com.nimbusds:nimbus-jose-jwt:9.37.3"
}

2. 설정 파일 구성

Apple Identity Token의 aud 검증을 위해 Bundle ID가 필요합니다.

# application.yml
apple:
  bundle-id: com.myapp.squad

환경별로 Bundle ID가 다를 수 있기 때문에 application-*.yml 분리를 추천합니다.


3. Request DTO 정의

Apple 로그인 시 필요한 필드는 아래와 같이 정리했습니다.

public record AppleLoginReq(
        @NotBlank(message = "Identity token은 필수입니다")
        String identityToken,
        String authorizationCode,
        String fullName // 첫 로그인 시에만 제공됨
) {}

Apple은 첫 로그인 시에만 이메일/이름 정보를 내려줍니다.
따라서 최초 로그인에서 해당 데이터를 반드시 저장하는 것이 좋습니다.


4. Apple JWT Validator 구현

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");
    }
}

5. AuthService 구현

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);
    }
}

6. Controller API

@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));
    }
}

트러블슈팅: ClassCastException

개발 과정에서 아래 오류가 발생했습니다.

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": "홍길동"
  }'

주의할 점

1) 첫 로그인 시 데이터 저장

이름/이메일은 첫 로그인에서만 제공됩니다.

2) Bundle ID 환경별 분리

로컬/개발/운영에서 각각 다를 수 있습니다.

3) Apple 공개 키 캐싱

Apple의 JWKS는 자주 바뀌지 않기 때문에 Redis로 캐싱하는 것도 고려할 수 있습니다.

4) 에러 처리

운영 환경에서는 더 세분화된 예외 코드와 로깅이 필요합니다.


마치며

Apple 로그인을 Spring Boot에서 구현할 때 가장 중요한 부분은 Identity Token 검증입니다.
이 단계만 안정적으로 구축하면 이후 인증 흐름은 비교적 단순합니다.

Apple 로그인은 처음 접하면 다소 복잡해 보이지만, 구조를 한 번 잡아두면 안정적으로 운영할 수 있습니다.


참고 자료

profile
논리적으로 사고하고 해결하는 것을 좋아하는 개발자입니다.

0개의 댓글