들어가며
웹/모바일 서비스에서 "Google로 로그인", "카카오로 로그인" 같은 소셜 로그인은 이제 필수 기능입니다. 이 뒤에서 작동하는 프로토콜이 바로 OAuth 2.0과 OpenID Connect(OIDC)입니다. 하지만 많은 개발자가 OAuth2와 OIDC의 차이를 혼동하거나, Grant Type을 잘못 사용하는 경우가 많습니다.
이 글에서는 OAuth 2.0의 핵심 Grant Type부터, OIDC가 추가하는 인증 레이어, Access/Refresh Token 전략, PKCE를 이용한 보안 강화, 그리고 소셜 로그인 구현 흐름까지 체계적으로 다루겠습니다.
OAuth 2.0 기본 개념
OAuth 2.0은 인가(Authorization) 프로토콜입니다. 사용자의 리소스에 대한 접근 권한을 제3자 애플리케이션에 위임하는 것이 핵심입니다. 중요한 점은 OAuth 2.0 자체는 인증(Authentication)을 다루지 않는다는 것입니다.
핵심 용어
| 용어 | 설명 | 예시 |
|---|---|---|
| Resource Owner | 리소스 소유자 (사용자) | Google 계정 소유자 |
| Client | 리소스에 접근하려는 애플리케이션 | 우리 서비스 (백엔드) |
| Authorization Server | 인가 서버, 토큰 발급 | Google OAuth 서버 |
| Resource Server | 보호된 리소스를 제공하는 서버 | Google API 서버 |
| Access Token | 리소스 접근을 위한 토큰 | JWT 또는 Opaque Token |
| Refresh Token | Access Token 갱신용 토큰 | 장기 토큰 |
| Scope | 접근 권한의 범위 | email, profile, openid |
| Redirect URI | 인가 코드를 전달받을 URI | https://myapp.com/callback |
OAuth 2.0 Grant Types
1. Authorization Code Grant
가장 일반적이고 안전한 방식입니다. 서버 사이드 웹 애플리케이션에 권장됩니다.
┌──────────┐ ┌──────────────────┐
│ 사용자 │ │ Authorization │
│ (Browser)│ │ Server │
└────┬─────┘ └────────┬─────────┘
│ │
│ 1. 로그인 버튼 클릭 │
│─────────────────────────┐ │
│ │ │
│ 2. Authorization Server로 리다이렉트 │
│─────────────────────────────────────────────▶│
│ GET /authorize? │
│ response_type=code& │
│ client_id=xxx& │
│ redirect_uri=https://myapp/callback& │
│ scope=openid email profile& │
│ state=random_state_value │
│ │
│ 3. 사용자 로그인 + 동의 화면 │
│◀────────────────────────────────────────────│
│ │
│ 4. 동의 후 redirect_uri로 코드 전달 │
│◀─ 302 Redirect ─────────────────────────────│
│ GET /callback?code=AUTH_CODE&state=xxx │
│ │
┌────▼─────┐ │
│ Client │ 5. code로 토큰 교환 (서버 간 통신) │
│ (Backend)│──────────────────────────────────────▶│
│ │ POST /token │
│ │ grant_type=authorization_code& │
│ │ code=AUTH_CODE& │
│ │ client_id=xxx& │
│ │ client_secret=xxx& │
│ │ redirect_uri=https://myapp/callback │
│ │ │
│ │ 6. 토큰 응답 │
│ │◀──────────────────────────────────────│
│ │ { access_token, refresh_token, │
│ │ id_token (OIDC), expires_in } │
└──────────┘ │
Spring Boot 구현: Authorization Code Flow
// application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, email, profile
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
scope: profile_nickname, account_email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
// Security 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo ->
userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler))
.build();
}
}
// OAuth2 사용자 정보 처리 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService
extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest request)
throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request);
String registrationId = request.getClientRegistration()
.getRegistrationId(); // "google", "kakao"
OAuthAttributes attributes = OAuthAttributes.of(
registrationId, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
return new CustomOAuth2User(
Collections.singleton(
new SimpleGrantedAuthority(user.getRole().getKey())),
oAuth2User.getAttributes(),
attributes.getNameAttributeKey(),
user.getId(),
user.getEmail());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository
.findByProviderAndProviderId(
attributes.getProvider(),
attributes.getProviderId())
.map(existing -> existing.update(
attributes.getName(), attributes.getEmail()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
// OAuth 속성 매핑
public class OAuthAttributes {
private String provider;
private String providerId;
private String name;
private String email;
private String nameAttributeKey;
private Map<String, Object> attributes;
public static OAuthAttributes of(String registrationId,
Map<String, Object> attributes) {
return switch (registrationId) {
case "google" -> ofGoogle(attributes);
case "kakao" -> ofKakao(attributes);
default -> throw new IllegalArgumentException(
"Unsupported provider: " + registrationId);
};
}
private static OAuthAttributes ofGoogle(
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.provider("google")
.providerId((String) attributes.get("sub"))
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.nameAttributeKey("sub")
.attributes(attributes)
.build();
}
private static OAuthAttributes ofKakao(
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount =
(Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile =
(Map<String, Object>) kakaoAccount.get("profile");
return OAuthAttributes.builder()
.provider("kakao")
.providerId(String.valueOf(attributes.get("id")))
.name((String) profile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.nameAttributeKey("id")
.attributes(attributes)
.build();
}
}
2. Authorization Code + PKCE
PKCE(Proof Key for Code Exchange)는 Authorization Code Grant의 보안 강화 버전입니다. SPA나 모바일 앱처럼 client_secret을 안전하게 보관할 수 없는 Public Client에서 필수입니다. OAuth 2.1에서는 모든 클라이언트에 PKCE가 권장됩니다.
// PKCE 흐름
// 1. 클라이언트가 code_verifier 생성 (랜덤 문자열)
String codeVerifier = generateRandomString(43, 128);
// 2. code_challenge 생성
// code_challenge = BASE64URL(SHA256(code_verifier))
String codeChallenge = Base64.getUrlEncoder().withoutPadding()
.encodeToString(
MessageDigest.getInstance("SHA-256")
.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)));
// 3. Authorization 요청에 code_challenge 포함
// GET /authorize?
// response_type=code&
// client_id=xxx&
// redirect_uri=xxx&
// code_challenge=CHALLENGE&
// code_challenge_method=S256&
// scope=openid email
// 4. Token 요청에 code_verifier 포함
// POST /token
// grant_type=authorization_code&
// code=AUTH_CODE&
// redirect_uri=xxx&
// client_id=xxx&
// code_verifier=VERIFIER ← 원본 전송
Authorization Server는 code_verifier에 SHA256을 적용하여 저장된 code_challenge와 비교합니다. Authorization Code를 탈취해도 code_verifier 없이는 토큰을 받을 수 없습니다.
3. Client Credentials Grant
사용자 개입 없이 서비스 간(Machine-to-Machine) 통신에 사용됩니다.
// Client Credentials: 서비스 간 인증
@Service
@RequiredArgsConstructor
public class ExternalApiClient {
private final WebClient webClient;
// Spring Security OAuth2 Client가 자동으로 토큰 관리
public Mono<ProductResponse> getProduct(Long productId) {
return webClient.get()
.uri("/api/v1/products/{id}", productId)
.attributes(
ServerOAuth2AuthorizedClientExchangeFilterFunction
.clientRegistrationId("internal-api"))
.retrieve()
.bodyToMono(ProductResponse.class);
}
}
// application.yml
spring:
security:
oauth2:
client:
registration:
internal-api:
client-id: ${INTERNAL_CLIENT_ID}
client-secret: ${INTERNAL_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: internal.read, internal.write
provider:
internal-api:
token-uri: https://auth.internal.com/oauth2/token
OpenID Connect (OIDC)
OIDC는 OAuth 2.0 위에 구축된 인증(Authentication) 레이어입니다. OAuth 2.0이 "이 앱이 당신의 데이터에 접근해도 되나요?"라면, OIDC는 "당신이 누구인지 확인합니다"입니다.
OAuth 2.0 vs OIDC
| 구분 | OAuth 2.0 | OIDC |
|---|---|---|
| 목적 | 인가 (Authorization) | 인증 (Authentication) + 인가 |
| 핵심 토큰 | Access Token | ID Token + Access Token |
| 사용자 정보 | 표준 없음 | UserInfo Endpoint 표준 |
| 토큰 형식 | 제한 없음 (Opaque 가능) | ID Token은 반드시 JWT |
| scope | 자유 정의 | openid (필수), profile, email 등 표준 |
| Discovery | 없음 | .well-known/openid-configuration |
ID Token
OIDC의 핵심은 ID Token입니다. JWT 형식으로 사용자의 인증 정보를 포함합니다.
// ID Token의 구조 (JWT Payload)
{
"iss": "https://accounts.google.com", // 발급자
"sub": "1234567890", // 사용자 고유 ID
"aud": "your-client-id", // 대상 클라이언트
"exp": 1712035200, // 만료 시간
"iat": 1712031600, // 발급 시간
"auth_time": 1712031500, // 인증 시간
"nonce": "abc123", // Replay 공격 방지
"email": "user@gmail.com", // 이메일
"email_verified": true,
"name": "홍길동",
"picture": "https://photo.url/photo.jpg",
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q" // Access Token 해시
}
ID Token 검증 과정
@Component
@RequiredArgsConstructor
public class IdTokenValidator {
private final JwkProvider jwkProvider; // 공개키 제공자
public IdTokenClaims validate(String idToken, String expectedNonce) {
try {
// 1. JWT 디코딩 (헤더에서 kid 추출)
DecodedJWT decoded = JWT.decode(idToken);
String kid = decoded.getKeyId();
// 2. JWK Set에서 공개키 조회
Jwk jwk = jwkProvider.get(kid);
RSAPublicKey publicKey = (RSAPublicKey) jwk.getPublicKey();
// 3. 서명 검증
Algorithm algorithm = Algorithm.RSA256(publicKey, null);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("https://accounts.google.com")
.withAudience(clientId)
.build();
DecodedJWT verified = verifier.verify(idToken);
// 4. nonce 검증 (Replay 공격 방지)
String nonce = verified.getClaim("nonce").asString();
if (!expectedNonce.equals(nonce)) {
throw new InvalidTokenException("Nonce mismatch");
}
// 5. 만료 시간 검증
if (verified.getExpiresAt().before(new Date())) {
throw new InvalidTokenException("Token expired");
}
return new IdTokenClaims(
verified.getSubject(),
verified.getClaim("email").asString(),
verified.getClaim("name").asString()
);
} catch (JWTVerificationException e) {
throw new InvalidTokenException(
"ID Token 검증 실패: " + e.getMessage());
}
}
}
UserInfo Endpoint
// OIDC UserInfo 엔드포인트 호출
public UserInfo getUserInfo(String accessToken) {
return webClient.get()
.uri("https://openidconnect.googleapis.com/v1/userinfo")
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(UserInfo.class)
.block();
}
// UserInfo 응답
// {
// "sub": "1234567890",
// "name": "홍길동",
// "email": "user@gmail.com",
// "email_verified": true,
// "picture": "https://photo.url"
// }
Access Token / Refresh Token 전략
토큰 수명 설계
| 토큰 | 수명 | 저장소 | 용도 |
|---|---|---|---|
| Access Token | 15분 ~ 1시간 | 메모리 / 헤더 | API 접근 |
| Refresh Token | 7일 ~ 30일 | HttpOnly Cookie / DB | Access Token 갱신 |
| ID Token | Access Token과 동일 | 메모리 | 사용자 인증 정보 |
Refresh Token Rotation
@Service
@RequiredArgsConstructor
public class TokenService {
private final JwtTokenProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
public TokenPair refreshAccessToken(String refreshToken) {
// 1. Refresh Token 유효성 검증
RefreshTokenEntity stored = refreshTokenRepository
.findByToken(refreshToken)
.orElseThrow(() -> new InvalidTokenException(
"Invalid refresh token"));
// 2. 사용 여부 확인 (Replay 공격 방지)
if (stored.isUsed()) {
// Refresh Token이 재사용됨 = 탈취 의심
// 해당 사용자의 모든 Refresh Token 무효화
refreshTokenRepository.revokeAllByUserId(stored.getUserId());
throw new TokenTheftDetectedException(
"Refresh token reuse detected. All sessions revoked.");
}
// 3. 만료 확인
if (stored.isExpired()) {
throw new InvalidTokenException("Refresh token expired");
}
// 4. 기존 Refresh Token을 '사용됨'으로 표시
stored.markAsUsed();
refreshTokenRepository.save(stored);
// 5. 새로운 Access Token + Refresh Token 발급 (Rotation)
String newAccessToken = jwtProvider.generateAccessToken(
stored.getUserId(), stored.getRoles());
String newRefreshToken = jwtProvider.generateRefreshToken();
// 6. 새 Refresh Token 저장
refreshTokenRepository.save(RefreshTokenEntity.builder()
.token(newRefreshToken)
.userId(stored.getUserId())
.expiresAt(LocalDateTime.now().plusDays(14))
.used(false)
.build());
return new TokenPair(newAccessToken, newRefreshToken);
}
}
Scope 설계
// OAuth2 Scope 정의
public enum OAuthScope {
// OIDC 표준 scope
OPENID("openid", "사용자 식별 정보"),
PROFILE("profile", "이름, 프로필 사진"),
EMAIL("email", "이메일 주소"),
// 커스텀 scope
ORDER_READ("order:read", "주문 조회"),
ORDER_WRITE("order:write", "주문 생성/수정"),
ADMIN("admin", "관리자 전체 권한");
private final String value;
private final String description;
}
// Resource Server에서 scope 기반 접근 제어
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthConverter())))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/orders/**")
.hasAuthority("SCOPE_order:read")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**")
.hasAuthority("SCOPE_order:write")
.requestMatchers("/api/v1/admin/**")
.hasAuthority("SCOPE_admin")
.anyRequest().authenticated())
.build();
}
private JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter converter =
new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix("SCOPE_");
converter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter jwtConverter =
new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
소셜 로그인 구현 전체 흐름
// 전체 흐름 요약
// 1. 프론트엔드: 소셜 로그인 버튼 클릭
// 2. 프론트엔드 → Authorization Server: 인가 요청
// 3. Authorization Server: 사용자 로그인 + 동의
// 4. Authorization Server → 백엔드: Authorization Code 전달
// 5. 백엔드 → Authorization Server: Code로 Token 교환
// 6. 백엔드: ID Token 검증 + 사용자 정보 추출
// 7. 백엔드: 회원가입/로그인 처리
// 8. 백엔드 → 프론트엔드: 자체 JWT 발급
// 성공 핸들러: 소셜 로그인 완료 후 처리
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
private final TokenService tokenService;
private final UserService userService;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
CustomOAuth2User oAuth2User =
(CustomOAuth2User) authentication.getPrincipal();
// 자체 JWT 발급
TokenPair tokenPair = tokenService.createTokenPair(
oAuth2User.getUserId(),
oAuth2User.getAuthorities());
// Refresh Token을 HttpOnly Cookie로 설정
ResponseCookie refreshCookie = ResponseCookie
.from("refresh_token", tokenPair.refreshToken())
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.path("/api/v1/auth/refresh")
.maxAge(Duration.ofDays(14))
.build();
response.addHeader(HttpHeaders.SET_COOKIE,
refreshCookie.toString());
// 프론트엔드로 리다이렉트 (Access Token은 URL fragment로 전달)
String redirectUrl = String.format(
"https://myapp.com/oauth/callback#access_token=%s",
tokenPair.accessToken());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
보안 체크리스트
- state 파라미터 필수 사용: CSRF 공격 방지. 요청 시 생성하고, 콜백에서 검증
- PKCE 적용: Public Client뿐 아니라 Confidential Client에도 권장 (OAuth 2.1)
- Redirect URI 정확히 매칭: 와일드카드 사용 금지, 정확한 URI 등록
- Access Token은 짧게: 15분~1시간. 탈취 시 피해 최소화
- Refresh Token Rotation: 갱신 시 기존 토큰 폐기, 재사용 탐지
- HTTPS 필수: 토큰이 평문으로 전송되면 안 됨
- Token 저장 주의: localStorage 대신 HttpOnly Cookie (XSS 방지)
- scope 최소화: 필요한 최소 권한만 요청
Discovery Endpoint
// OIDC Discovery: 서버 설정 자동 검색
// GET https://accounts.google.com/.well-known/openid-configuration
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
// Spring Security는 이 endpoint를 자동으로 조회하여
// 토큰 검증에 필요한 공개키, 엔드포인트 등을 설정합니다.
마치며
OAuth 2.0과 OIDC는 현대 웹 서비스의 인증/인가 기반입니다. 핵심을 정리하면 다음과 같습니다:
- OAuth 2.0은 인가(Authorization) 프로토콜이고, OIDC는 인증(Authentication)을 추가한 상위 프로토콜입니다.
- Authorization Code + PKCE가 거의 모든 시나리오에서 권장되는 Grant Type입니다.
- Client Credentials는 서비스 간 통신에, Device Authorization은 입력이 제한된 기기에 사용합니다.
- ID Token은 "누구인지"를, Access Token은 "무엇을 할 수 있는지"를 나타냅니다.
- 보안은 state, nonce, PKCE, Refresh Token Rotation, HTTPS 등 여러 겹의 방어가 필요합니다.
Spring Security OAuth2 Client와 Resource Server는 이러한 복잡한 프로토콜을 추상화하여 비교적 간단하게 구현할 수 있게 해줍니다. 하지만 프로토콜의 동작 원리를 이해하고 있어야 문제가 발생했을 때 올바르게 디버깅할 수 있으므로, 스펙 문서(RFC 6749, RFC 7636, OpenID Connect Core)를 한 번쯤 읽어보시길 권장합니다.
'CS' 카테고리의 다른 글
| 백엔드 개발자 기술 면접 완벽 대비 - Spring/JPA/DB/인프라 핵심 질문 50선 (0) | 2026.04.14 |
|---|---|
| DNS와 CDN 동작 원리 - 웹 성능 최적화의 기초 (0) | 2026.04.08 |
| 실시간 통신 완벽 가이드 - WebSocket, SSE, gRPC 비교 분석 (0) | 2026.04.08 |
| REST API 설계 원칙 - 실무에서 바로 쓰는 베스트 프랙티스 (0) | 2026.03.31 |
| HTTP 완벽 가이드 - 백엔드 개발자가 알아야 할 모든 것 (0) | 2026.03.31 |