ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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 로그인 및 해당 포스터에 없는 클래스 내용은 👉 이전 글을 참고해주세요 🙌)


    📑 목차

    1. Apple 로그인 흐름 이해
    2. 프로젝트 환경
    3. 아키텍처 개요
    4. 구현 단계
      1. Controller & Service
      2. OidcProvider 인터페이스 재사용
      3. AppleOidcProvider
      4. AppleOidcClient
      5. AppleKeyGenerator (client_secret 생성)
      6. PublicKeyProvider
      7. 관련 VO/DTO 클래스
    5. 예외 처리 전략
    6. 흐름 요약 (시퀀스 다이어그램)
    7. 마무리

    1. Apple 로그인 흐름 이해

    ✅ 특징

    1. Authorization Code Flow 사용
      • 클라이언트 → 서버로 authorization_code를 전달합니다.
      • 서버는 이 코드로 access_token, id_token, refresh_token을 교환합니다.
    2. 최초 로그인만 email 제공
      • Apple은 개인정보 보호를 위해 최초 로그인 때만 email, 이름 등을 내려줍니다.
      • 따라서 DB에 임시 유저를 저장하고 이후 회원가입 시, 해당 정보로 업데이트해야 합니다.
    3. refresh_token 관리 필요
      • 회원 탈퇴 시 서비스 탈퇴뿐 아니라 refresh_token을 Apple 서버에 전달해 revoke 해야 합니다.

    2. 프로젝트 환경

    • Spring Boot: 3.4.0
    • Java: 21
    • Redis: OIDC 공개키 캐싱

    3. 아키텍처 개요

    1. 클라이언트 → 서버 요청
      • 클라이언트는 Apple 로그인 성공 후 발급받은 authorization code를 서버에 전달합니다.
      • 요청 DTO: SocialLoginRequest(authorizationCode, providerType=APPLE)
    2. 서버 → Apple OAuth
      • 서버(AppleOidcProvider)는 전달받은 authorization code를 Apple OAuth 서버에 교환 요청.
      • Access Token, ID Token, Refresh Token을 응답으로 받습니다.
      • Refresh Token은 DB에 보관 (회원 탈퇴 시 필요).
    3. ID Token 파싱 & 검증
      • Apple Public Key(AppleOidcClient.getApplePublicKeys())를 가져와 JWT 서명 검증.
      • 검증에 성공하면 sub(=고유 Apple User ID) 값을 추출.
      • 추출된 값은 우리 서비스에서 providerId로 사용.
    4. 임시 유저 생성 / 업데이트
      • 최초 로그인 시: Apple에서 제공하는 이메일/이름 정보를 임시 저장.
      • 이후 회원가입 시: Apple은 프로필을 다시 주지 않으므로, 기존 저장된 값을 업데이트.
    5. JWT 발급
      • 내부 JwtProvider를 통해 우리 서비스 AccessToken / RefreshToken 생성 후 응답.
    6. 회원 탈퇴 시
      • 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 로그인 플로우를 모두 담당하는 클래스입니다.

    1. authorization_code → 토큰 교환
    2. id_token 검증 → sub 추출
    3. refresh_token 저장
    4. 회원탈퇴 시 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();
        }
    }
     

    🔎 동작 단계

    1. 토큰 교환
      • authorization_code → Apple OAuth 서버로 전달
      • id_token, refresh_token 획득
    2. id_token 검증
      • JWKS로 서명 검증
      • iss, aud 값 확인
      • sub 값(애플 고유 유저 ID) 추출
    3. refresh_token 저장
      • 최초 로그인 시 DB에 저장 (회원탈퇴 때 사용)
    4. 회원탈퇴
      • 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
    • revokeUri
    • issuerUri
    • 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;
    }

     

    🔎 동작 단계

    1. 기존 사용자 조회
      • provider + providerId로 DB 조회
      • 가입 완료 상태라면 로그인 성공
      • 임시 계정이면 SocialUserNotFoundException 발생 → 추가 회원가입 필요
    2. 사용자가 없는 경우
      • 신규 로그인 시 User.createTemporaryUser()로 임시 계정 생성
      • 생성 직후 예외 발생 → 프론트엔드에서 회원가입 페이지로 유도
    3. 마지막 로그인 일시 갱신
      • 정상 로그인한 경우 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와 상당히 유사한 흐름으로 동작합니다.
    인터페이스 기반으로 구현하면 여러 소셜 로그인을 일관성 있게 확장할 수 있습니다.

    반응형

    댓글

Designed by Tistory.