-
[Spring] Apple OIDC 로그인 구현Spring 2025. 9. 25. 16:41반응형
이전 글에 이어서 Apple 로그인 구현 방법에 대해서도 포스팅 해보겠습니다.
카카오 로그인은 OIDC 기반으로 단순히 id_token을 검증하면 됐지만,
애플은 조금 더 복잡한 구조를 가지고 있습니다.특히, 아래 2개의 내용 때문에 좀 더 복잡하다고 생각합니다.
- 최초 로그인 시에만 email, 등 scope 정보를 제공한다는 점
- 회원 탈퇴 시 반드시 refresh_token을 사용해 revoke를 호출해야 한다는 점
이 두 가지 때문에 단순 OIDC 검증만으로는 부족합니다.
이번 글에서는 Spring Boot 3.4 + Java 21 환경에서 Apple OIDC 로그인을 구현 방법에 대해 포스팅 하겠습니다.
(Kakao OIDC 로그인 및 해당 포스터에 없는 클래스 내용은 👉 이전 글을 참고해주세요 🙌)
📑 목차
- Apple 로그인 흐름 이해
- 프로젝트 환경
- 아키텍처 개요
- 구현 단계
- Controller & Service
- OidcProvider 인터페이스 재사용
- AppleOidcProvider
- AppleOidcClient
- AppleKeyGenerator (client_secret 생성)
- PublicKeyProvider
- 관련 VO/DTO 클래스
- 예외 처리 전략
- 흐름 요약 (시퀀스 다이어그램)
- 마무리
1. Apple 로그인 흐름 이해
✅ 특징
- Authorization Code Flow 사용
- 클라이언트 → 서버로 authorization_code를 전달합니다.
- 서버는 이 코드로 access_token, id_token, refresh_token을 교환합니다.
- 최초 로그인만 email 제공
- Apple은 개인정보 보호를 위해 최초 로그인 때만 email, 이름 등을 내려줍니다.
- 따라서 DB에 임시 유저를 저장하고 이후 회원가입 시, 해당 정보로 업데이트해야 합니다.
- refresh_token 관리 필요
- 회원 탈퇴 시 서비스 탈퇴뿐 아니라 refresh_token을 Apple 서버에 전달해 revoke 해야 합니다.
2. 프로젝트 환경
- Spring Boot: 3.4.0
- Java: 21
- Redis: OIDC 공개키 캐싱
3. 아키텍처 개요
- 클라이언트 → 서버 요청
- 클라이언트는 Apple 로그인 성공 후 발급받은 authorization code를 서버에 전달합니다.
- 요청 DTO: SocialLoginRequest(authorizationCode, providerType=APPLE)
- 서버 → Apple OAuth
- 서버(AppleOidcProvider)는 전달받은 authorization code를 Apple OAuth 서버에 교환 요청.
- Access Token, ID Token, Refresh Token을 응답으로 받습니다.
- Refresh Token은 DB에 보관 (회원 탈퇴 시 필요).
- ID Token 파싱 & 검증
- Apple Public Key(AppleOidcClient.getApplePublicKeys())를 가져와 JWT 서명 검증.
- 검증에 성공하면 sub(=고유 Apple User ID) 값을 추출.
- 추출된 값은 우리 서비스에서 providerId로 사용.
- 임시 유저 생성 / 업데이트
- 최초 로그인 시: Apple에서 제공하는 이메일/이름 정보를 임시 저장.
- 이후 회원가입 시: Apple은 프로필을 다시 주지 않으므로, 기존 저장된 값을 업데이트.
- JWT 발급
- 내부 JwtProvider를 통해 우리 서비스 AccessToken / RefreshToken 생성 후 응답.
- 회원 탈퇴 시
- Apple에 refresh_token을 전달하여 토큰을 revoke.
- revoke 실패 시 APPLE_REVOKE_FAIL 처리.
4. 구현 단계
4.1 Controller & Service
카카오 버전과 동일하게 /login/social 엔드포인트를 사용합니다.
providerType=APPLE + authorizationCode를 요청해야 합니다.@PostMapping("/login/social") public ResponseEntity<ApiResult<LoginResponse>> socialLogin( @Valid @RequestBody SocialLoginRequest request ) { OidcUserInfo oidcUserInfo = authService.getOidcUserInfo(request); return ResponseEntity.ok(new ApiResult<>(authService.socialLogin(oidcUserInfo))); }
- /login/social 하나의 엔드포인트에서 카카오 + 애플을 모두 처리
- Service는 OidcProvider 인터페이스 기반으로 라우팅
- Apple은 authorizationCode, Kakao는 id_token을 사용
4.2 OidcProvider 인터페이스 재사용
public interface OidcProvider { OidcUserInfo getUserInfo(OidcProviderParams params); } public record OidcProviderParams(String token, String authorizationCode) { public static OidcProviderParams ofApple(String authorizationCode) { return new OidcProviderParams(null, authorizationCode); } }
- 공통 인터페이스로 Kakao와 Apple을 모두 추상화
- 카카오는 id_token, 애플은 authorization_code를 파라미터로 전달
4.3 AppleOidcProvider – 핵심 처리
Apple 로그인 플로우를 모두 담당하는 클래스입니다.
- authorization_code → 토큰 교환
- id_token 검증 → sub 추출
- refresh_token 저장
- 회원탈퇴 시 revoke 처리
@Slf4j @Component("apple") @RequiredArgsConstructor public class AppleOidcProvider implements OidcProvider { final AppleKeyGenerator appleKeyGenerator; final AppleOidcClient appleOidcClient; final PublicKeyProvider publicKeyProvider; final JwtProvider jwtProvider; final AppleProperties properties; final WebClient webClient; final CacheManager cacheManager; // 1. authorization_code → 토큰 교환 후 유저정보 생성 @Override public OidcUserInfo getUserInfo(OidcProviderParams params) { AppleTokenResponse tokenResponse = requestTokenFromApple(params.authorizationCode()); return parseAndValidateIdToken(tokenResponse.idToken(), tokenResponse.refreshToken()); } // 2. Apple 서버에 토큰 요청 private AppleTokenResponse requestTokenFromApple(String authorizationCode) { try { String clientId = properties.getIosClientId(); String clientSecret = appleKeyGenerator.generateClientSecret(clientId); MultiValueMap<String, String> formData = new LinkedMultiValueMap<>(); formData.add("client_id", clientId); formData.add("client_secret", clientSecret); formData.add("grant_type", "authorization_code"); formData.add("code", authorizationCode); return createWebClient(properties.getTokenUri()).post() .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(formData)) .retrieve() .bodyToMono(AppleTokenResponse.class) .blockOptional() .orElseThrow(() -> new ApiException(ErrorCode.APPLE_TOKEN_REQUEST_FAIL.getCode())); } catch (Exception e) { throw new ApiException(ErrorCode.APPLE_TOKEN_REQUEST_FAIL.getCode()); } } // 3. id_token 검증 및 유저정보 생성 private OidcUserInfo parseAndValidateIdToken(String idToken, String refreshToken) { Map<String, String> headers = jwtProvider.parseHeaders(idToken); try { OidcPublicKeyList keys = appleOidcClient.getApplePublicKeys(); PublicKey publicKey = publicKeyProvider.generatePublicKey(headers, keys); Claims claims = jwtProvider.parseOidcClaims(idToken, publicKey); jwtProvider.validateIdToken(claims, properties.getIssuerUri(), properties.getIosClientId()); // Apple 전용: refresh_token 보관 return OidcUserInfo.apple(claims.getSubject(), refreshToken); } catch (Exception e) { throw new ApiException(ErrorCode.SYSTEM_ERROR.getCode()); } } // 4. 회원탈퇴 시 refresh_token revoke public void revoke(String refreshToken) { String clientId = properties.getIosClientId(); try { String clientSecret = appleKeyGenerator.generateClientSecret(clientId); MultiValueMap<String, String> formData = new LinkedMultiValueMap<>(); formData.add("client_id", clientId); formData.add("client_secret", clientSecret); formData.add("token", refreshToken); formData.add("token_type_hint", "refresh_token"); createWebClient(properties.getRevokeUri()).post() .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(formData)) .retrieve() .toBodilessEntity() .block(); } catch (Exception e) { throw new ApiException(ErrorCode.APPLE_REVOKE_FAIL.getCode()); } } private WebClient createWebClient(String baseUrl) { return webClient.mutate().baseUrl(baseUrl).build(); } }
🔎 동작 단계
- 토큰 교환
- authorization_code → Apple OAuth 서버로 전달
- id_token, refresh_token 획득
- id_token 검증
- JWKS로 서명 검증
- iss, aud 값 확인
- sub 값(애플 고유 유저 ID) 추출
- refresh_token 저장
- 최초 로그인 시 DB에 저장 (회원탈퇴 때 사용)
- 회원탈퇴
- revoke(refreshToken) 메서드 호출 → Apple 서버와 연결 해제
4.4 AppleOidcClient - 공개키 조회
@FeignClient( name = "AppleOidcAuthClient", url = "https://appleid.apple.com", configuration = OidcClientConfig.class ) public interface AppleOidcClient { // JWKS는 변동 가능하므로 Redis 캐싱 @Cacheable(cacheNames = "AppleOIDC", key = "'publicKeys'", cacheManager = "oidcCacheManager") @GetMapping("/auth/keys") OidcPublicKeyList getApplePublicKeys(); }
- Apple의 JWKS는 가끔 갱신되므로 Redis 캐싱 적용
- 캐시가 만료되거나 검증 실패 시 → 다시 요청
4.5 AppleKeyGenerator – client_secret 생성
Apple OAuth 요청 시 필요한 client_secret은 JWT 형식으로 직접 만들어야 합니다.
@Slf4j @Component @RequiredArgsConstructor public class AppleKeyGenerator { AppleProperties properties; public String generateClientSecret(String clientId) { Instant now = Instant.now(); Date expiration = Date.from(now.plus(30, ChronoUnit.DAYS)); return Jwts.builder() .setHeaderParam("alg", "ES256") .setHeaderParam("kid", properties.getKeyId()) .setIssuer(properties.getTeamId()) .setIssuedAt(Date.from(now)) .setExpiration(expiration) .setAudience("https://appleid.apple.com") .setSubject(clientId) .signWith(getPrivateKey(), SignatureAlgorithm.ES256) .compact(); } private PrivateKey getPrivateKey() { String base64Key = properties.getPrivateKey() .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); byte[] keyBytes = Base64.getDecoder().decode(base64Key); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); try { return KeyFactory.getInstance("EC").generatePrivate(keySpec); } catch (Exception e) { throw new ApiException(ErrorCode.JWT_PARSE_ERROR.getCode()); } } }
- client_secret은 Apple이 아닌 서비스 서버에서 직접 JWT로 생성
- 필수 Claim: iss(teamId), sub(clientId), aud(https://appleid.apple.com)
4.6 PublicKeyProvider
카카오 로그인과 동일하게 JWKS로부터 공개키를 생성합니다.
@Component public class PublicKeyProvider { public PublicKey generatePublicKey(Map<String, String> tokenHeaders, OidcPublicKeyList publicKeys) { String kid = tokenHeaders.get("kid"); String alg = tokenHeaders.get("alg"); if (kid == null || alg == null) { throw new ApiException(ErrorCode.OIDC_PUBLIC_KEY_EMPTY.getCode()); } OidcPublicKey matchedKey = publicKeys.getMatchedKey(kid, alg); return buildPublicKey(matchedKey); } private PublicKey buildPublicKey(OidcPublicKey key) { try { byte[] nBytes = Base64.getUrlDecoder().decode(key.getN()); byte[] eBytes = Base64.getUrlDecoder().decode(key.getE()); RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(1, nBytes), new BigInteger(1, eBytes)); return KeyFactory.getInstance("RSA").generatePublic(spec); } catch (Exception e) { throw new ApiException(ErrorCode.OIDC_PUBLIC_KEY_BUILD_FAIL.getCode()); } } }
4.7 관련 VO/DTO 클래스
아래 공통 클래스들은 이전 카카오 OIDC 로그인 포스터에서 확인 부탁드립니다. (동일)
- OidcUserInfo.apple(providerId, refreshToken)
- SocialLoginRequest(authorizationCode)
- OidcPublicKey / OidcPublicKeyList
4.8 AppleProperties – 환경 설정 관리
애플 로그인은 단순히 id_token 검증만 하는 카카오와 달리,
토큰 교환 / client_secret 생성 / refresh_token revoke 등 여러 과정에서 다양한 설정 값이 필요합니다.이 설정 값들을 코드에 직접 초기화 해두는 대신,
application.yml에서 관리하고 @ConfigurationProperties로 주입받아 사용합니다.@Getter @Setter @Configuration @ConfigurationProperties(prefix = "oidc.apple") @FieldDefaults(level = AccessLevel.PRIVATE) public class AppleProperties { String tokenUri; String revokeUri; String issuerUri; String teamId; String keyId; String privateKey; String iosClientId; }
🔎 속성 설명
- tokenUri
- authorization_code를 전달해 id_token, refresh_token을 교환하는 Apple OAuth API 엔드포인트
- 기본값: https://appleid.apple.com/auth/token
- revokeUri
- 회원 탈퇴 시 refresh_token을 전달해 Apple 계정과의 연결을 해제하는 엔드포인트
- 기본값: https://appleid.apple.com/auth/revoke
- issuerUri
- Apple에서 발급하는 id_token의 iss Claim 값 검증에 사용
- 기본값: https://appleid.apple.com
- teamId
- Apple Developer 계정의 Team ID
- client_secret JWT 생성 시 iss(issuer) 값으로 사용됨
- keyId
- Apple Developer 콘솔에서 발급받은 Key ID
- client_secret JWT의 header.kid 값으로 사용
- privateKey
- Apple Developer 콘솔에서 다운로드한 .p8 키 파일 내용
- client_secret JWT 서명 시 사용
- iosClientId
- Apple Developer에서 설정한 App의 Bundle ID (client_id)
- id_token의 aud(audience) 검증에 사용됨
4.9 application.yml 설정 예시
oidc: apple: token-uri: https://appleid.apple.com/auth/token revoke-uri: https://appleid.apple.com/auth/revoke issuer-uri: https://appleid.apple.com ios-client-id: com.example.myapp # 앱 번들 ID team-id: TEAM1234567 # Apple Developer Team ID key-id: ABCDEFGHIJ # Apple Key ID private-key: |- -----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -----END PRIVATE KEY-----
⚠️ 주의할 점
- private-key 보안 관리
- .p8 키를 Git에 올리면 안 됩니다.
- 보통은 application.yml 대신 Secret Manager나 환경변수로 관리하는 것을 권장합니다.
- client_secret 유효기간
- Apple client_secret은 최대 6개월까지만 유효합니다.
- 위에서 구현한 AppleKeyGenerator에서는 30일 단위로 만료되도록 생성했습니다.
- 운영 환경에서는 주기적으로 재생성/갱신이 필요합니다.
4.10 socialLoginInternal – 임시 유저 저장 및 추가 회원가입 유도 처리
애플 로그인은 최초 로그인 시에만 email 같은 프로필 정보를 제공합니다.
따라서 sub(providerId)만으로는 회원가입을 완료할 수 없고,
추가 정보 입력을 유도하기 위해 임시 유저를 생성한 뒤 예외를 던지는 방식을 사용했습니다./** * 소셜 로그인 처리 내부 로직. * 1. provider + providerId로 기존 사용자 조회 * - 가입 완료된 경우: 활성 상태 확인 후 반환 * - 임시 계정인 경우: 예외 발생 → 추가 회원가입 유도 * 2. 일치하는 사용자가 없는 경우 * - 최초 로그인 대응용 임시 계정 생성 (애플 scope 제한 때문) * - 예외 발생 → 추가 회원가입 유도 * 3. 로그인 성공 시 마지막 로그인 일시 갱신 * * 트랜잭션 설정: * - noRollbackFor: 임시 계정 저장 후 예외 발생 시 롤백 방지 */ @Transactional(noRollbackFor = SocialUserNotFoundException.class) public User socialLoginInternal(OidcUserInfo info) { ProviderType provider = info.providerType(); String providerId = info.providerId(); User user = userRepository.findByProviderAndProviderIdAndRemovedFalse(provider, encProviderId) .map(u -> { // 기존 유저가 있지만 아직 가입을 완료하지 않은 경우 if (!u.isSignedUp()) { throw new SocialUserNotFoundException(Map.of("providerId", u.getProviderId())); } // 비활성 상태 확인 u.checkEnabled(); return u; }) .orElseGet(() -> { // 신규 유저 → 임시 계정 생성 후 SocialUserNotFoundException 발생 userRepository.save(User.createTemporaryUser( provider, providerId, info.appleRefreshToken(), // 애플 전용: refresh_token 저장 generateUniqueLoginId("temp"), generateUniqueNickname("temp"), generateTempCi(), generateUniqueReferralCode(true) )); throw new SocialUserNotFoundException(Map.of("providerId", providerId)); }); // 정상 로그인 시 마지막 로그인 시간 갱신 user.updateLastLoginAt(); return user; }
🔎 동작 단계
- 기존 사용자 조회
- provider + providerId로 DB 조회
- 가입 완료 상태라면 로그인 성공
- 임시 계정이면 SocialUserNotFoundException 발생 → 추가 회원가입 필요
- 사용자가 없는 경우
- 신규 로그인 시 User.createTemporaryUser()로 임시 계정 생성
- 생성 직후 예외 발생 → 프론트엔드에서 회원가입 페이지로 유도
- 마지막 로그인 일시 갱신
- 정상 로그인한 경우 user.updateLastLoginAt() 호출
⚙️ 트랜잭션 전략
- noRollbackFor = SocialUserNotFoundException.class
- 임시 계정을 만든 뒤 예외를 던져도 저장 내용이 롤백되지 않도록 설정
- 즉, 임시 유저는 DB에 남고, 이후 사용자가 추가 회원가입을 완료할 수 있습니다.
⚠️ 주의할 점
- 애플 로그인 전용 로직
- 카카오는 id_token만으로 충분하지만, 애플은 최초 로그인 시만 email을 제공하기 때문에 임시 계정이 필요합니다.
- refresh_token 저장
- Apple은 회원탈퇴 시 refresh_token이 필수이므로 DB에 저장해둡니다.
5. 예외 처리 전략
- APPLE_TOKEN_REQUEST_FAIL: 토큰 교환 실패
- APPLE_CLIENT_SECRET_FAIL: client_secret 생성 실패
- INVALID_ID_TOKEN: iss/aud 불일치
- APPLE_REVOKE_FAIL: 회원탈퇴(revoke) 실패
6. 흐름 요약 (시퀀스 다이어그램)
7. 마무리
애플 로그인은 카카오보다 한층 복잡했습니다.
- Authorization Code Flow를 직접 구현해야 하고,
- client_secret을 자체 서명해야 하며,
- refresh_token을 보관해두어야 회원탈퇴 처리가 가능합니다.
하지만 구조를 이해하고 나면, 카카오 OIDC와 상당히 유사한 흐름으로 동작합니다.
인터페이스 기반으로 구현하면 여러 소셜 로그인을 일관성 있게 확장할 수 있습니다.반응형'Spring' 카테고리의 다른 글
[Spring] Kakao OIDC 로그인 구현 (0) 2025.09.25 [Spring] Redis의 Redisson을 활용한 분산락 처리 (0) 2025.05.10 [Spring] Lock 종류 정리 (낙관적 락, 비관적 락, 분산락, 데드락, 등) 및 예시 (0) 2025.05.08 [Aws + Spring] SNS(Simple Notification Service) + Spring Boot 에러 메일 전송 기능 구축하기 (0) 2024.01.20 [Aws + Spring] Spring Boot + Aws Glue + S3를 활용한 방문자 통계 구축 (1) (0) 2024.01.02