🎯 목표
- OAuth2 인증 로직에 대한 기본 개념과 동작 흐름 정리
- OAuth2 핵심 요소(Authorization Server, Resource Server, Client, Token 등) 이해
- Authorization Code Grant 방식의 인증 흐름 단계별 정리
- Google OAuth2를 활용한 프론트엔드-백엔드 연동 실습
- 프론트엔드에서 Authorization 요청 → Redirect 응답 처리 → 백엔드로 Authorization Code 전달
- 백엔드(Spring Boot WebFlux)에서 Authorization Code로 Access Token과 ID Token 교환
- ID Token 검증 및 사용자 인증 처리
- 보안 고려사항 및 실무 적용 팁 제공
- CSRF 방지를 위한 state 파라미터 사용
- ID Token 서명 검증 및 안전한 토큰 저장 방식 안내
- HTTPS 강제 사용 및 흔한 오류 대응 전략 정리
✅ 핵심 개념 및 정의
| 용어 |
설명 |
| OAuth2 |
권한 위임(Authorization) 표준 프로토콜 |
| Authorization Server |
클라이언트에게 접근 토큰을 발급하는 서버 |
| Resource Server |
보호된 자원을 제공하는 API 서버 |
| Client |
보호된 자원에 접근하려는 애플리케이션 |
| Access Token |
자원 서버 접근을 위한 단기 사용 토큰 |
| Refresh Token |
만료된 Access Token을 재발급하기 위한 토큰 |
| ID Token |
사용자 인증 정보를 담은 토큰 (OIDC 확장) |
🔍 동작 흐름 및 구조
- 클라이언트 등록
Google Cloud Console에서 OAuth 2.0 클라이언트 ID/Secret 발급
- 사용자 인증 요청 (Authorization Code Grant)
GET https://accounts.google.com/o/oauth2/v2/auth ?client_id={CLIENT_ID} &redirect_uri={REDIRECT_URI} &response_type=code &scope=openid%20email%20profile &state={STATE}
- 사용자 인증 및 승인 후 리디렉션
GET {REDIRECT_URI}?code={AUTH_CODE}&state={STATE}
- 프론트엔드: code + state 검증 후 백엔드로 전달
- 백엔드: 토큰 교환 요청
POST https://oauth2.googleapis.com/token Content-Type: application/x-www-form-urlencoded client_id={CLIENT_ID} &client_secret={CLIENT_SECRET} &code={AUTH_CODE} &grant_type=authorization_code &redirect_uri={REDIRECT_URI}
- Access Token + ID Token 수신
- 백엔드: ID Token 검증 및 사용자 인증
- ID Token은 JWT 포맷
- RS256 서명 검증 필요 (Google public key 이용)
- (선택) UserInfo 엔드포인트 추가 조회
- JWT 발급 또는 세션 관리 후 프론트엔드에 전달
🔗 프론트엔드 → 백엔드 연동 로직
(React 예시)
import React, { useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
const CLIENT_ID = 'YOUR_CLIENT_ID';
const REDIRECT_URI = 'https://yourapp.com/oauth/callback';
const STATE_KEY = 'oauth_state';
export function LoginButton() {
const state = uuidv4();
sessionStorage.setItem(STATE_KEY, state);
const authUrl = \`https://accounts.google.com/o/oauth2/v2/auth?client_id=\${CLIENT_ID}&redirect_uri=\${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=openid%20email%20profile&state=\${state}\`;
return <a href={authUrl}>Google 로그인</a>;
}
export function OAuthCallback() {
const { search } = useLocation();
const history = useHistory();
useEffect(() => {
const params = new URLSearchParams(search);
const code = params.get('code');
const state = params.get('state');
const savedState = sessionStorage.getItem(STATE_KEY);
if (state !== savedState) {
alert('State mismatch detected! Possible CSRF attack.');
return;
}
if (code) {
fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code }),
})
.then(res => res.json())
.then(() => history.replace('/'));
}
}, [search, history]);
return <div>로그인 중...</div>;
}
🛠 백엔드 구현 예시 (Spring Boot WebFlux)
@RestController
@RequestMapping("/api/auth")
public class OAuthController {
@Value("${oauth.clientId}")
private String clientId;
@Value("${oauth.clientSecret}")
private String clientSecret;
@Value("${oauth.redirectUri}")
private String redirectUri;
private final WebClient webClient;
public OAuthController(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
}
@PostMapping("/google")
public Mono<ResponseEntity<?>> googleAuth(@RequestBody Map<String, String> body) {
String code = body.get("code");
return webClient.post()
.uri("https://oauth2.googleapis.com/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData("code", code)
.with("client_id", clientId)
.with("client_secret", clientSecret)
.with("redirect_uri", redirectUri)
.with("grant_type", "authorization_code"))
.retrieve()
.bodyToMono(Map.class)
.flatMap(tokenResponse -> {
String idToken = (String) tokenResponse.get("id_token");
Map<String, Object> userInfo = JwtUtil.parseAndValidateIdToken(idToken);
String jwt = JwtUtil.createToken(userInfo);
Map<String, Object> result = new HashMap<>();
result.put("accessToken", jwt);
result.put("profile", userInfo);
return Mono.just(ResponseEntity.ok(result));
});
}
}
# application.yml
oauth:
clientId: YOUR_CLIENT_ID
clientSecret: YOUR_CLIENT_SECRET
redirectUri: https://yourapp.com/oauth/callback
spring:
webflux:
base-path: /api
🚧 Security Tip
state 파라미터 검증 필수 (CSRF 방지)
- HTTPS 적용 필수
- ID Token 서명 검증 필수
- Access Token은 가능하면 HttpOnly Secure 쿠키에 저장
⚠️ 흔한 이슈 및 해결 전략
| 이슈 |
원인 |
해결 방법 |
| redirect_uri_mismatch |
등록된 URI와 요청 URI 불일치 |
Google Console 등록 URI 정확히 설정 |
| invalid_grant |
Authorization Code 재사용 또는 만료 |
코드 1회용 사용, 즉시 교환 |
| invalid_client |
Client ID/Secret 오류 |
환경변수로 안전하게 관리 |
💡 학습 정리
- OAuth2 = 권한 위임 프로토콜
- Authorization Code Grant 권장
- 프론트→백엔드→Google 인증 흐름 구현
- state 검증, ID Token 검증, HTTPS 적용 필수