오늘은 웹에서 Apple 소셜 로그인을 할 수 있도록 REST API 문서를 보고 구현했습니다.
애플 로그인은 카카오 로그인보다 훨씬 까다롭더군요...
https://developer.apple.com/documentation/signinwithapplerestapi
Sign in with Apple REST API | Apple Developer Documentation
Communicate between your app servers and Apple’s authentication servers.
developer.apple.com
웹 Apple 로그인 구현 가이드
TechFork 프로젝트의 Spring Security OAuth2 기반 Apple 소셜 로그인 구현
⚠️ Apple vs Kakao 핵심 차이점 (먼저 읽어야 할 것!)
Apple 로그인은 Kakao와 달리 몇 가지 특수한 처리가 필요합니다. 이걸 모르면 삽질합니다.
1️⃣ Authorization Code를 POST로 받음 (response_mode=form_post)
| 구분 | Kakao | Apple |
| 콜백 방식 | GET (URL 쿼리 파라미터) | POST (form data) |
| response_mode | 기본값 (query) | form_post 필수 |
# application.yml
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
# → 콜백: GET /login/oauth2/code/kakao?code=xxx&state=yyy
apple:
authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post
# → 콜백: POST /login/oauth2/code/apple (body: code=xxx&state=yyy)
🚨 이로 인해 필요한 설정: CORS에 Apple 도메인 추가
Apple이 우리 서버로 POST 요청을 보내기 때문에, CORS 허용 Origin에 Apple 도메인을 추가해야 합니다.
// SecurityConfig.java
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:5173",
"https://techfork.shop",
"https://api.techfork.shop",
"https://appleid.apple.com" // ⚠️ Apple Sign In form_post 필수!
));
// ...
}
빠뜨리면? Apple 콜백 시 CORS 에러로 로그인 실패
2️⃣ client-secret 동적 생성 (JWT 서명)
| 구분 | Kakao | Apple |
| client-secret | 고정값 (콘솔에서 발급) | JWT로 동적 생성 |
| 서명 알고리즘 | 없음 | ES256 (ECDSA) |
| 필요한 키 | 없음 | Private Key (.p8 파일) |
| 만료 시간 | 없음 | 최대 6개월 |
# application.yml
registration:
kakao:
client-secret: ${KAKAO_CLIENT_SECRET} # ✅ 고정값 그대로 사용
apple:
client-secret: dummy-will-be-replaced-at-runtime # ⚠️ 런타임에 교체됨
Apple client-secret JWT 구조
Header: { "kid": "{KEY_ID}", "alg": "ES256" }
Payload: {
"iss": "{TEAM_ID}", // Apple Developer Team ID
"iat": 1234567890, // 발급 시간
"exp": 1234567890, // 만료 시간 (최대 6개월)
"aud": "https://appleid.apple.com",
"sub": "{CLIENT_ID}" // Apple Services ID
}
Signature: ES256(Header + Payload, PrivateKey)
필요한 Apple Developer 설정
| 항목 | 설명 | 환경변수 |
| Team ID | Apple Developer 계정 ID | APPLE_TEAM_ID |
| Key ID | Sign in with Apple Key ID | APPLE_KEY_ID |
| Client ID | Services ID (Identifier) | APPLE_CLIENT_ID |
| Private Key | .p8 파일 경로 | APPLE_PRIVATE_KEY_PATH |
3️⃣ 프로필 이미지 미제공
| 구분 | Kakao | Apple |
| 프로필 이미지 | picture 제공 | ❌ 항상 null |
| 이름 | nickname 제공 | 최초 로그인 시에만 (user 파라미터) |
// CustomOidcUserService.java
String profileImage = oidcUser.getAttribute("picture");
// Kakao: "http://k.kakaocdn.net/..."
// Apple: null (항상)
주의: Apple은 사용자가 "Hide My Email"을 선택하면 랜덤 이메일(xxx@privaterelay.appleid.com)을 제공합니다.
4️⃣ 사용자 정보 최초 1회만 제공
| 구분 | Kakao | Apple |
| 사용자 정보 | 매 로그인마다 조회 가능 | 최초 로그인 시에만 |
| 이름/이메일 | userinfo endpoint로 조회 | 콜백의 user 파라미터로만 |
# Apple 최초 로그인 콜백 (POST body)
code=xxx
&state=yyy
&user={"name":{"firstName":"길동","lastName":"홍"},"email":"user@example.com"}
# Apple 재로그인 콜백 (POST body) - user 파라미터 없음!
code=xxx
&state=yyy
중요: 최초 로그인 시 받은 사용자 정보를 반드시 DB에 저장해야 합니다. 이후에는 다시 받을 수 없습니다.
5️⃣ HTTPS 필수 (로컬 테스트 시 ngrok 사용)
| 구분 | Kakao | Apple |
| HTTP 허용 | ✅ 로컬 테스트 가능 | ❌ HTTPS 필수 |
| 로컬 테스트 | http://localhost:8080 | ngrok 등 터널링 필요 |
Apple은 redirect_uri에 HTTPS만 허용합니다. 로컬에서 테스트하려면 ngrok으로 HTTPS 터널을 만들어야 해요.
ngrok 설치 및 실행
# 1. ngrok 설치 (macOS)
brew install ngrok
# 2. ngrok 계정 연동 (https://ngrok.com 에서 authtoken 발급)
ngrok config add-authtoken {YOUR_AUTH_TOKEN}
# 3. 터널 실행 (Spring Boot 8080 포트)
ngrok http 8080
ngrok 실행 결과
Session Status online
Forwarding https://abc123.ngrok-free.app -> http://localhost:8080
주의: ngrok 무료 플랜은 실행할 때마다 URL이 바뀝니다. 고정 도메인이 필요하면 유료 플랜을 사용하세요.
Apple Developer에 redirect URI 등록
ngrok URL이 바뀔 때마다 Apple Developer Console에서도 업데이트해야 합니다:
- Apple Developer Console 접속
- Certificates, Identifiers & Profiles → Identifiers
- Services IDs 선택 → Sign in with Apple 설정
- Return URLs에 ngrok URL 추가: https://abc123.ngrok-free.app/login/oauth2/code/apple
📋 체크리스트: Apple 로그인 구현 시 확인사항
- [ ] authorization-uri에 ?response_mode=form_post 추가했는가?
- [ ] CORS 허용 Origin에 https://appleid.apple.com 추가했는가?
- [ ] AppleClientSecretGenerator로 JWT 동적 생성 구현했는가?
- [ ] Apple Developer에서 Team ID, Key ID, Client ID, Private Key 발급받았는가?
- [ ] Private Key (.p8) 파일을 안전한 경로에 배치했는가?
- [ ] 프로필 이미지가 null일 수 있음을 고려했는가?
- [ ] 최초 로그인 시 사용자 정보를 DB에 저장하는가?
- [ ] (로컬 테스트) ngrok으로 HTTPS 터널 설정했는가?
- [ ] (로컬 테스트) Apple Developer Console에 ngrok URL 등록했는가?
- [ ] (로컬 테스트) yml 파일에 ngrok HTTPS 경로로 리다이렉트 URI를 등록했는가?
🔄 Kakao 로그인과의 코드 공유
TechFork는 Kakao 로그인을 OIDC 방식으로 구현하여 Apple 로그인과 동일한 CustomOidcUserService와 핸들러를 공유합니다.
// AppleOAuth2Config.java - Apple만 특수 처리
if ("apple".equals(grantRequest.getClientRegistration().getRegistrationId())) {
// Apple: JWT로 client-secret 동적 생성
String clientSecret = appleClientSecretGenerator.generateClientSecret();
parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientSecret);
} else {
// Kakao 등: yml 설정의 고정값 사용
parameters.add(OAuth2ParameterNames.CLIENT_SECRET,
grantRequest.getClientRegistration().getClientSecret());
}
# application.yml - 설정 비교
registration:
kakao:
client-id: ${KAKAO_REST_API_KEY}
client-secret: ${KAKAO_CLIENT_SECRET} # 고정값
scope: [openid, account_email, profile_image]
apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: dummy-will-be-replaced-at-runtime # 동적 생성
scope: [openid, email] # name scope 없음 (어차피 최초 1회만)
📖 전체 플로우 상세 설명
위의 핵심 차이점을 이해했다면, 아래 전체 플로우를 읽어보세요.
1️⃣ 사용자가 로그인 시작
사용자 → 프론트엔드: "Sign in with Apple" 버튼 클릭
프론트엔드 → 서버: GET /oauth2/authorization/apple
2️⃣ Authorization Request (서버 → Apple)
// SecurityConfig의 oauth2Login이 자동 처리
// application.yml의 provider.apple.authorization-uri 사용
서버가 Apple authorization URL 생성:
https://appleid.apple.com/auth/authorize?
client_id={APPLE_CLIENT_ID}
&redirect_uri={baseUrl}/login/oauth2/code/apple
&response_type=code
&scope=openid email
&response_mode=form_post
&nonce={랜덤생성}
&state={랜덤생성}
→ 사용자를 Apple 로그인 페이지로 리다이렉트
🔑 STATELESS 환경에서의 state 관리
// HttpCookieOAuth2AuthorizationRequestRepository
// JWT 기반 STATELESS 환경에서 OAuth2 state를 쿠키로 관리
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
private static final int COOKIE_EXPIRE_SECONDS = 180; // 3분
// OAuth2AuthorizationRequest를 직렬화하여 쿠키에 저장
// → 콜백 시 쿠키에서 복원하여 state 검증
}
3️⃣ 사용자 인증 (Apple)
사용자 → Apple: Apple ID/비밀번호 입력 또는 Face ID/Touch ID
Apple: 사용자 인증 및 동의 확인
4️⃣ Authorization Code 수신
Apple → 서버: POST /login/oauth2/code/apple
- code: {authorization_code}
- state: {검증용}
- user: {최초 로그인 시에만, 이름/이메일 정보}
5️⃣ Token 교환 (핵심! 커스터마이징한 부분)
5-1. client-secret 동적 생성
// AppleClientSecretGenerator.generateClientSecret() 호출
@Component
public class AppleClientSecretGenerator {
@Value("${apple.team-id}")
private String teamId;
@Value("${apple.key-id}")
private String keyId;
@Value("${apple.private-key-path}")
private String privateKeyPath;
@Value("${spring.security.oauth2.client.registration.apple.client-id}")
private String clientId;
public String generateClientSecret() {
Date expirationDate = Date.from(
LocalDateTime.now().plusDays(180)
.atZone(ZoneId.systemDefault())
.toInstant()
);
return Jwts.builder()
.setHeaderParam("kid", keyId)
.setHeaderParam("alg", "ES256")
.setIssuer(teamId) // Apple Team ID
.setIssuedAt(new Date())
.setExpiration(expirationDate) // 180일 후
.setAudience("https://appleid.apple.com")
.setSubject(clientId) // Apple Client ID
.signWith(getPrivateKey(), SignatureAlgorithm.ES256)
.compact();
}
}
https://developer.apple.com/documentation/AccountOrganizationalDataSharing/creating-a-client-secret
Creating a client secret | Apple Developer Documentation
Generate a signed token to identify your client application.
developer.apple.com
5-2. AppleOAuth2Config - Token Client 커스터마이징
@Configuration
@RequiredArgsConstructor
public class AppleOAuth2Config {
private final AppleClientSecretGenerator appleClientSecretGenerator;
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
RestClientAuthorizationCodeTokenResponseClient client =
new RestClientAuthorizationCodeTokenResponseClient();
client.setParametersConverter(grantRequest -> {
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
// 기본 필수 파라미터
parameters.add(OAuth2ParameterNames.GRANT_TYPE, grantRequest.getGrantType().getValue());
parameters.add(OAuth2ParameterNames.CODE,
grantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode());
// Authorization Request에서 실제 사용된 redirect_uri 가져오기 (템플릿이 아닌 실제 값)
String redirectUri = grantRequest.getAuthorizationExchange()
.getAuthorizationRequest()
.getRedirectUri();
parameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
parameters.add(OAuth2ParameterNames.CLIENT_ID,
grantRequest.getClientRegistration().getClientId());
// Apple일 때만 client-secret 동적 생성
if ("apple".equals(grantRequest.getClientRegistration().getRegistrationId())) {
String clientSecret = appleClientSecretGenerator.generateClientSecret();
parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientSecret);
} else {
// 다른 소셜 로그인(Kakao 등)은 yml 설정 사용
parameters.add(OAuth2ParameterNames.CLIENT_SECRET,
grantRequest.getClientRegistration().getClientSecret());
}
return parameters;
});
return client;
}
}
Authorization Grant Support :: Spring Security
This section describes Spring Security’s support for authorization grants.
docs.spring.io
5-3. Apple Token API 호출
서버 → Apple: POST https://appleid.apple.com/auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={authorization_code}
&redirect_uri={baseUrl}/login/oauth2/code/apple
&client_id={APPLE_CLIENT_ID}
&client_secret={동적생성된JWT} ← 여기가 포인트!
5-4. Apple 응답
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"id_token": "eyJraWQ..." // OIDC ID Token (JWT)
}
6️⃣ ID Token 검증 및 파싱
// Spring Security가 자동 처리
// - jwk-set-uri(https://appleid.apple.com/auth/keys)에서 Apple 공개키 가져옴
// - id_token의 서명 검증 (ES256)
// - aud, iss, exp, nonce 검증
// - JWT 파싱하여 사용자 정보 추출
https://developer.apple.com/documentation/signinwithapple/verifying-a-user
Verifying a user | Apple Developer Documentation
Check the validity and integrity of a user’s identity token.
developer.apple.com
ID Token 내용 예시
{
"iss": "https://appleid.apple.com",
"aud": "{APPLE_CLIENT_ID}",
"sub": "001234.abc123def456...", // Apple User ID (socialId)
"email": "user@example.com",
"email_verified": true,
"is_private_email": false,
"nonce": "...",
"exp": 1234567890,
"iat": 1234567890
}
7️⃣ 사용자 정보 처리
// CustomOidcUserService.loadUser() 실행
@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {
private final UserRepository userRepository;
@Override
@Transactional
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
SocialType socialType = SocialType.fromRegistrationId(registrationId);
String socialId = oidcUser.getAttribute("sub"); // "001234.abc..."
String email = oidcUser.getAttribute("email"); // "user@example.com"
String profileImage = oidcUser.getAttribute("picture"); // Apple은 항상 null
User user = getOrCreateUser(socialType, socialId, email, profileImage);
return UserPrincipal.buildUserPrincipal(user);
}
}
8️⃣ DB 조회/생성
// CustomOidcUserService.getOrCreateUser()
private User getOrCreateUser(SocialType socialType, String socialId,
String email, String profileImage) {
return userRepository.findBySocialTypeAndSocialId(socialType, socialId)
.map(user -> {
// 탈퇴 사용자 재가입 처리
if (user.isWithdrawn()) {
user.reactivate(email, profileImage);
return user;
}
return user;
})
.orElseGet(() -> {
// 신규 사용자 생성
User newUser = User.createSocialUser(socialType, socialId, email, profileImage);
return userRepository.save(newUser);
});
}
User 엔티티 생성
public static User createSocialUser(SocialType socialType, String socialId,
String email, String profileImage) {
return User.builder()
.socialType(socialType)
.socialId(socialId)
.email(email)
.profileImage(profileImage)
.role(Role.USER)
.status(UserStatus.PENDING) // 온보딩 전 상태
.build();
}
9️⃣ JWT 토큰 발급
// OAuth2AuthenticationSuccessHandler.onAuthenticationSuccess() 실행
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
// JWT 토큰 생성
JwtDTO tokens = jwtUtil.generateTokens(userPrincipal.getId(), userPrincipal.getRole());
// - accessToken: 15분 (900000ms)
// - refreshToken: 14일 (1209600000ms)
// RefreshToken 저장 및 쿠키 설정
long expiration = jwtProperties.getRefreshTokenExpiration();
refreshTokenService.saveRefreshToken(userId, tokens.refreshToken(), expiration);
CookieUtil.addRefreshTokenCookie(response, domain, tokens.refreshToken(), expiration);
}
🔟 프론트엔드로 리다이렉트
// jwt.redirect-uri 사용 (환경변수로 설정 가능)
// 기본값: http://localhost:5173/auth/callback?registered=%s&token=%s&email=%s
boolean isRegistered = userPrincipal.getStatus() == UserStatus.ACTIVE;
String email = userPrincipal.getEmail() != null ?
UriUtils.encode(userPrincipal.getEmail(), StandardCharsets.UTF_8) : "";
String targetUrl = String.format(jwtProperties.getRedirectUri(),
isRegistered, // 온보딩 완료 여부
tokens.accessToken(), // Access Token
email // 이메일 (URL 인코딩)
);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
1️⃣1️⃣ 로그인 완료
서버 → 프론트엔드: 302 Redirect
프론트엔드: accessToken 저장, refreshToken은 HttpOnly 쿠키에 자동 저장됨
사용자: 로그인 완료! 🎉
📋 application.yml 설정
spring:
security:
oauth2:
client:
registration:
apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: dummy-will-be-replaced-at-runtime # 동적 생성됨
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
scope:
- openid
- email
client-name: Apple
provider:
apple:
authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post
token-uri: https://appleid.apple.com/auth/token
jwk-set-uri: https://appleid.apple.com/auth/keys
user-name-attribute: sub
jwt:
secret: ${JWT_SECRET}
access-token-expiration: 900000 # 15분 (밀리초)
refresh-token-expiration: 1209600000 # 14일 (밀리초)
redirect-uri: ${JWT_REDIRECT_URI:http://localhost:5173/auth/callback?registered=%s&token=%s&email=%s}
login-failure-redirect-uri: ${JWT_LOGIN_FAILURE_REDIRECT_URI:http://localhost:5173/login?error=true}
apple:
team-id: ${APPLE_TEAM_ID}
key-id: ${APPLE_KEY_ID}
private-key-path: ${APPLE_PRIVATE_KEY_PATH:keys/AppleAuthKey.p8}
🔑 핵심 포인트 요약
| 구분 | 설명 |
| client-secret 동적 생성 | Apple은 고정된 secret이 아닌 private key로 서명한 JWT 사용 |
| OIDC 사용 | id_token으로 사용자 정보를 안전하게 전달 |
| STATELESS OAuth2 | HttpCookieOAuth2AuthorizationRequestRepository로 쿠키 기반 state 관리 |
| 탈퇴 사용자 처리 | user.reactivate()로 재가입 지원 |
| Spring Security 자동 처리 | 대부분의 검증과 플로우는 프레임워크가 처리 |
🛠 커스터마이징한 컴포넌트
| 컴포넌트 | 역할 |
| AppleOAuth2Config | client-secret 동적 생성 로직 |
| AppleClientSecretGenerator | ES256 JWT 생성 |
| CustomOidcUserService | 사용자 DB 저장/조회 |
| OAuth2AuthenticationSuccessHandler | JWT 발급 및 리다이렉트 |
| OAuth2AuthenticationFailureHandler | 로그인 실패 시 에러 리다이렉트 |
| HttpCookieOAuth2AuthorizationRequestRepository | STATELESS 환경 OAuth2 state 관리 |