1. OAuth2 소셜 로그인은 어떻게 동작할까?
소셜 로그인은 사용자가 서비스에 직접 아이디와 비밀번호를 입력하지 않는다. 대신 Google, Kakao, GitHub 같은 외부 인증 제공자(OAuth Provider)를 통해 사용자를 인증하고, 우리 서비스는 그 결과를 바탕으로 자체 로그인 처리를 수행한다.
1.1 일반 로그인 흐름


일반적인 자체 로그인은 프론트엔드와 백엔드 간의 직접적인 검증으로 이루어진다.
- 사용자가 아이디와 비밀번호를 입력한다.
- 백엔드가 데이터베이스(DB)의 계정 정보를 검증한다.
- 검증이 성공하면 자체 accessToken / refreshToken을 발급하고 로그인을 완료한다.
1.2 OAuth2 소셜 로그인 흐름 (Authorization Code 방식)
반면, OAuth2 소셜 로그인은 인증 제공자(Provider)라는 제3자가 개입하며 다음과 같은 순서로 진행된다.
sequenceDiagram
autonumber
actor User as 사용자
participant FE as 프론트엔드
participant BE as 백엔드
participant OP as OAuth Provider<br>(Google 등)
User->>FE: 소셜 로그인 버튼 클릭
FE->>BE: OAuth 시작 요청
BE-->>FE: OAuth Authorization URL로 Redirect 응답
FE->>OP: 인증 페이지 이동 요청
User->>OP: 로그인 및 권한 동의
OP-->>BE: Authorization Code 전달 (Redirect)
BE->>OP: Authorization Code로 Access Token 요청
OP-->>BE: Access Token 발급
BE->>OP: Access Token으로 사용자 정보 요청
OP-->>BE: 사용자 정보(이메일, 이름 등) 반환
BE->>BE: 회원 조회 및 신규 가입 처리
BE->>FE: 우리 서비스 JWT (Access/Refresh Token) 발급
FE->>User: 로그인 완료 및 서비스 이용 가능
⚠️ 주의 및 핵심 포인트
Google이 우리 서비스의 `accessToken`을 발급해 주는 것이 아니다. Google은 오직 사용자 인증 결과와 사용자 정보만 제공할 뿐이며, 실제 우리 서비스에서 사용할 JWT는 우리 서비스의 백엔드가 직접 발급한다.
1.3 구성 요소별 역할 분담
소셜 로그인 과정에서 프론트엔드, 백엔드, 인증 제공자의 역할은 엄격히 분리된다.
| 주체 | 주요 역할 |
| Frontend | • 소셜 로그인 버튼을 제공한다. • 백엔드의 OAuth 시작 URL로 사용자를 이동시킨다. • 로그인 성공 후 백엔드가 발급한 accessToken을 저장한다. • 이후 인증이 필요한 API 요청 시 Authorization 헤더에 토큰을 첨부한다. |
| Backend | • 사용자를 OAuth Provider의 로그인 페이지로 Redirect시킨다. • 인증 제공자가 전달한 authorization code를 수신하고 처리한다. • 이 코드를 사용해 OAuth Provider에서 사용자 정보를 조회한다. • 조회한 정보로 서비스 내 회원 존재 여부를 확인하고, 없으면 신규 가입을 진행한다. • 최종적으로 우리 서비스 전용 accessToken / refreshToken을 발급한다. |
| OAuth Provider | • 사용자 로그인 및 인증 처리를 담당한다. • 인증 성공 시 백엔드에 authorization code를 발급한다. • 백엔드의 요청에 따라 안전하게 사용자 정보를 제공한다. |
1.4 Authorization Code 방식을 사용하는 이유
OAuth2 프로토콜에는 실행 환경(프론트엔드 중심, 서버 중심, 모바일 등)과 보안 요구사항에 따라 권한을 부여하는 여러 가지 방식(Grant Type)이 존재한다. 그중 우리 서비스가 채택한 Authorization Code(인증 코드) 방식은 현재 웹 소셜 로그인 구현의 글로벌 표준(Best Practice)으로 자리 잡고 있다. 이 방식의 핵심 철학은 "프론트엔드(브라우저)라는 탈취당하기 쉬운 환경에 소셜 서비스의 진짜 열쇠(Access Token)를 직접 쥐여주지 않는다"에 있다.
1) 왜 Authorization Code 방식을 사용해야 할까?
- 보안성 향상: 프론트엔드가 소셜 서비스의 Provider Access Token을 직접 만지거나 저장하지 않기 때문에, 스크립트 탈취(XSS) 공격으로부터 원천적으로 보호받는다.
- 안전한 토큰 교환: 백엔드가 외부로 노출되지 않는 고유 키인 Client Secret을 사용해, 중간 징검다리 역할인 일회성 Authorization Code를 소셜 서비스의 진짜 토큰과 교환하므로 탈취 위험이 현저히 낮아진다.
- 안전한 정보 조회 (Server-to-Server): 사용자의 이메일이나 프로필 데이터를 긁어오는 민감한 통신이 프론트엔드가 아닌, 백엔드와 소셜 Provider 간의 안전한 서버 내부망 통신으로만 처리된다.
2) 핵심 메커니즘: 코드와 비밀 통신
- 안전한 중간 다리 (Authorization Code): 유저가 소셜 로그인을 마치면 프론트엔드는 토큰이 아니라, 일회성 인증 티켓인 코드(`?code=...`)만 받아서 백엔드에 토큰 교환을 요청한다.
- 서버 간의 비밀 통신: 백엔드는 전달받은 코드를 들고 소셜 Provider(Google, Kakao 등)를 직접 찾아가 사용자 정보와 소셜 토큰을 교환해 온다.
3) 짚고 넘어가야 할 핵심 공식 (소셜 로그인 != 로그인 완료)
이 아키텍처를 이해할 때 가장 중요한 공식은 다음과 같다.
- 소셜 로그인 성공 = OAuth Provider 인증 성공 + 우리 서비스 회원 식별 + 우리 서비스 JWT 발급
단순히 Google이나 Kakao 창에서 로그인이 성공했다고 해서 우리 서비스의 로그인이 끝나는 것이 아니다. Google이 신원을 보증해 주면, 백엔드는 그 정보를 기준으로 우리 DB에서 기존 회원을 조회하거나 신규 회원을 자동 생성(식별)해야 한다. 그 후, 우리 서비스 API에 접근할 수 있는 자체 JWT(Access/Refresh Token)를 최종 발급해야 비로소 전체 로그인 처리가 완료된다. 소셜 플랫폼은 유저가 '누구인지' 인증만 대신해 줄 뿐, 서비스 내 권한 통제는 온전히 우리 시스템의 몫이다.
💡 한 줄 요약
자격 증명이 노출되기 쉬운 브라우저 환경을 보호하기 위해, 일회성 인가 코드(Code)와 백엔드 서버망(Server-to-Server)을 활용하여 소셜 인증 결과를 우리 서비스의 자체 JWT 체계로 안전하게 편입시키는 가장 견고한 인증 방식이다. (※ 향후 모바일 앱 환경으로 확장할 때는 이 구조에 PKCE 보안 프로토콜을 결합하여 가로채기 공격을 원천 차단해야 한다.)
2. 전체 인증 흐름
이번 구현의 전체 아키텍처 흐름은 프론트엔드, 백엔드, 그리고 OAuth Provider가 긴밀하게 통신하며 진행된다.
전체적인 흐름을 시퀀스 다이어그램으로 표현하면 다음과 같다.
sequenceDiagram
autonumber
actor User as 사용자
participant Browser as 브라우저
participant FE as Frontend<br>(localhost:5173)
participant BE as Backend<br>(localhost:8080)
participant OP as OAuth Provider
User->>Browser: /login 접속
Browser->>FE: GET /login
FE-->>Browser: LoginPage 응답
User->>Browser: 소셜 로그인 버튼 클릭
Browser->>BE: GET /api/v1/oauth/{provider}/login
BE->>BE: provider 확인 및<br>Authorization URL 생성
BE-->>Browser: 302 Redirect to OAuth Provider
Browser->>OP: OAuth 로그인 페이지 요청
OP-->>Browser: 로그인/동의 화면 응답
User->>Browser: 로그인 및 권한 동의
Browser->>OP: 로그인 정보 제출
OP-->>Browser: 302 Redirect with authorization code
Browser->>BE: GET /api/v1/oauth/{provider}/callback?code=...
BE->>OP: authorization code로 provider access token 요청
OP-->>BE: provider access token 응답
BE->>OP: provider access token으로 사용자 정보 요청
OP-->>BE: 사용자 이메일, 프로필 정보 응답
BE->>BE: 사용자 식별 및<br>회원 조회/신규 가입
BE->>BE: 서비스 accessToken 생성 및<br>refreshToken 저장
BE-->>Browser: Set-Cookie refreshToken (HttpOnly)
BE-->>Browser: 302 Redirect to /oauth/callback?accessToken=...
Browser->>FE: GET /oauth/callback?accessToken=...
FE-->>Browser: OAuthCallbackPage 응답
Browser->>FE: accessToken 추출 및 authStore 저장
Browser->>FE: /me로 navigate
Browser->>FE: GET /me
FE-->>Browser: MyPage 렌더링
Browser->>BE: GET /api/v1/members/me<br>(Authorization: Bearer accessToken)
BE->>BE: accessToken 검증 및 회원 조회
BE-->>Browser: 내 정보 응답
Browser-->>User: /me 페이지 표시
1) 흐름 단계별 상세 분석
위 다이어그램의 흐름을 단계별로 나누어 보면 다음과 같다.
- 로그인 시작: 사용자가 프론트엔드의 `/login` 페이지에 접속하여 소셜 로그인 버튼을 클릭한다.
- 인증 페이지 이동: 브라우저는 백엔드의 `/api/v1/oauth/{provider}/login`으로 요청을 보낸다. 백엔드는 해당하는 OAuth Provider의 로그인 페이지 URL을 생성하여 302 Redirect 응답을 내린다.
- 소셜 인증 진행: 사용자는 Provider(Google 등)가 제공하는 화면에서 로그인과 권한 동의를 완료한다.
- 인증 코드 전달: 인증이 완료되면 Provider는 백엔드의 Callback URL로 authorization code를 포함하여 302 Redirect 시킨다. (`/api/v1/oauth/{provider}/callback?code=...`)
- 사용자 정보 조회: 백엔드는 전달받은 code를 사용해 Provider에게 access token을 요청 및 수신한다. 이 토큰을 활용해 다시 Provider로부터 사용자의 이메일, 프로필 등 식별 정보를 조회한다.
- 회원 처리 및 토큰 생성: 조회한 사용자 정보를 기준으로 DB를 확인하여 기존 회원이면 조회, 신규 회원이면 자동 가입 처리를 진행한다. 이후 우리 서비스 전용 accessToken과 refreshToken을 생성한다.
- 토큰 전달 및 리다이렉트: 보안을 위해 refreshToken은 HttpOnly Cookie로 설정하고, accessToken은 프론트엔드의 콜백 주소 쿼리 스트링에 담아 302 Redirect 시킨다. (`/oauth/callback?accessToken=...`)
- 프론트엔드 토큰 저장: 프론트엔드는 URL에서 accessToken을 추출하여 전역 상태(예: authStore)에 저장한 뒤, 마이페이지(/me)로 사용자를 이동시킨다.
- 인증된 API 요청: 이후 인증이 필요한 모든 API를 요청할 때는 HTTP 요청 헤더(Authorization: Bearer accessToken)에 발급받은 토큰을 담아 보낸다.
2) 핵심 정리: 두 가지 Access Token의 차이점
이 구현에서 가장 혼동하기 쉽고 중요한 부분은 OAuth Provider가 준 토큰과 우리 서비스가 만든 토큰을 명확히 구분하는 것이다.
| 구분 | Provider Access Token | Service Access Token |
| 발급 주체 | OAuth Provider (Google, Kakao 등) | 우리 서비스 Backend |
| 사용 목적 | Provider API(이메일, 프로필 조회 등) 호출 | 우리 서비스의 보호된 API 인증 |
| 사용 위치 | 백엔드 내부에서만 사용 후 폐기 | 프론트엔드가 저장 후 헤더에 담아 사용 |
| 보안 특징 | 프론트엔드에 노출되지 않음 | JWT 형태로 구현되어 자체 검증 가능 |
💡 결론
프론트엔드가 최종적으로 저장하고 관리하는 토큰은 Google의 access token이 아니다. 우리 백엔드가 소셜 인증 결과를 기반으로 자체 발급한 JWT accessToken이다. 이 구조를 이해하는 것이 소셜 로그인 구현의 첫걸음이다.
3. Backend 구현
소셜 로그인 아키텍처에서 백엔드는 전체 흐름을 제어하는 오케스트레이터 역할을 담당한다.
구현된 주요 API 진입점은 다음과 같이 Path Variable을 활용해 다형성을 가질 수 있도록 단 두 개로 압축된다.
- `GET /api/v1/oauth/{provider}/login` : 소셜 로그인 시작 및 인가(Authorization) URL 리다이렉트
- `GET /api/v1/oauth/{provider}/callback` : 인가 코드(code) 수신 후 서비스 자체 토큰 발급 및 프론트엔드 리다이렉트
1) OAuth 로그인 시작 및 인가 URL 생성
사용자가 프론트엔드에서 소셜 로그인 버튼을 클릭하면 백엔드의 로그인 시작 API가 호출된다. 컨트롤러는 직접 URL을 조립하지 않고 `OAuthRedirectService`에 위임하여 책임을 분리한다.
@GetMapping("/{provider}/login")
public void login(@PathVariable String provider, HttpServletResponse response) throws IOException {
OAuthProvider oauthProvider = OAuthProvider.valueOf(provider.toUpperCase());
response.sendRedirect(oauthRedirectService.getAuthorizationUrl(oauthProvider));
}
OAuthRedirectService의 역할
OAuthRedirectService는 각 Provider별 프로퍼티(clientId, redirectUri, scope 등)를 조합해 사용자가 인증을 진행할 외부 폼 주소를 동적으로 생성한다.
- 각 Provider에 맞는 규격화된 Authorization URL을 생성한다.
- switch 표현식을 통해 GOOGLE, KAKAO, GITHUB 등의 전략을 깔끔하게 분기 처리한다.
2) OAuth Callback 처리 및 프론트엔드 리다이렉트
사용자가 외부 플랫폼에서 로그인을 완료하면, OAuth Provider는 백엔드가 사전에 등록한 Callback URL로 인가 코드(code)를 던진다. 백엔드는 이 코드를 받아 최종적인 서비스 가입/로그인 처리를 완성한다.
@GetMapping("/{provider}/callback")
public void callback(
@PathVariable String provider,
@RequestParam String code,
HttpServletResponse response
) throws IOException {
OAuthProvider oauthProvider = OAuthProvider.valueOf(provider.toUpperCase());
// 1. 핵심 비즈니스 로직 수행 (가입/회원조회 및 자체 토큰 생성)
var result = oauthLoginService.login(new OAuthLoginCommand(oauthProvider, code));
// 2. Refresh Token을 HttpOnly 쿠키에 저장
refreshTokenCookieProvider.addRefreshTokenCookie(
response, result.refreshToken(), result.refreshTokenMaxAgeSeconds()
);
// 3. Access Token을 쿼리 파라미터에 담아 프론트엔드로 리다이렉트
response.sendRedirect(oauthRedirectService.getFrontendCallbackUrl(result.accessToken()));
}
💡 설계 팁: Callback API가 JSON을 반환하지 않는 이유
초기 설계 시 일반 API처럼 JSON 결과(예: `{"accessToken": "..."}`)를 반환하도록 실수하기 쉽다. 하지만 OAuth 로그인은 브라우저의 전체 페이지 리다이렉트 흐름으로 진행된다. 만약 JSON을 그대로 반환하면 사용자는 프론트엔드 앱으로 돌아가지 못하고 흰 화면에 텍스트만 보게 된다. 반드시 프론트엔드의 콜백 주소(`http://localhost:5173/oauth/callback?accessToken=...`)로 다시 리다이렉트시켜 흐름을 넘겨주어야 한다.
3) 핵심 비즈니스 계층: OAuthLoginService
OAuthLoginService는 외부 OAuth Provider의 인증 결과를 우리 서비스의 자체 회원 및 인증 체계로 변환하는 가장 핵심적인 도메인 로직을 수행한다.
graph TD
A[인가 코드 수신] --> B[OAuthClient를 통한 UserInfo 조회]
B --> C{소셜 계정 존재 여부 확인}
C -- 존재함 --> D[기존 회원 정보 조회]
C -- 존재하지 않음 --> E[신규 가입 처리 registerSocialMember]
E --> F[Member 생성 및 SocialAccount 연관 관계 저장]
D --> G[자체 JWT Access/Refresh Token 생성]
F --> G
G --> H[해시 처리된 RefreshToken 저장 및 LoginResult 반환]
4) 세부 수행 책임 및 로직 정리
- 소셜 정보 조회: OAuthClientResolver가 적절한 클라이언트를 찾아 인가 코드로 Provider 토큰을 얻고, 유저 정보(OAuthUserInfo)를 가져온다.
- 소셜 계정 및 이메일 검증:
- 외부 채널로부터 이메일 정보가 반드시 제공되었는지(hasEmail), 인증된 이메일인지(emailVerified) 체크한다.
- 이미 다른 방식으로 등록된 이메일인지 검증(validateEmailNotRegistered)하여 계정 충돌을 방지한다.
- 가입 및 식별:
- 이미 연결된 소셜 계정이 있다면 곧바로 회원을 조회한다.
- 처음 방문한 유저라면 Member 테이블과 SocialAccount 테이블에 각각 데이터를 영속화하며 자동 가입 프로세스를 밟는다. 닉네임이 없을 경우 google_12345 형태로 자동 생성한다.
- 자체 JWT 발급: 서비스 전용 accessToken과 refreshToken을 생성한다. 보안을 강화하기 위해 refreshToken은 SHA-256으로 해시화하여 refreshTokenRepository에 만료 시간과 함께 안전하게 보관한다.
5) 토큰 관리 전략 및 쿠키 보안
로그인이 완료되면 두 가지 토큰은 각각 최적의 보안 저장소로 나누어 전송된다.
| 토큰 유형 | 저장 위치 | 전송 및 수신 방식 | 보안 특징 및 주 용도 |
| Access Token | 프론트엔드 메모리 (authStore) |
URL 쿼리 파라미터 리다이렉트 수신 | 짧은 만료 시간 유지, API 요청 시 Authorization: Bearer 헤더에 첨부 |
| Refresh Token | 브라우저 쿠키 (HttpOnly) |
백엔드 Set-Cookie 응답 헤더 생성 | JavaScript에서 접근 불가(XSS 방지), Access Token 재발급용으로만 사용 |
6) RefreshTokenCookieProvider 구현 상세
ResponseCookie 객체를 빌더 패턴으로 생성하여 안전하게 헤더에 주입한다.
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true) // JavaScript (document.cookie) 노출 차단 (XSS 방지)
.secure(false) // 로컬 개발 환경(http) 편의를 위해 false (운영은 true 권장)
.sameSite("Lax") // CSRF 공격 방어를 위한 기본 설정
.path("/") // 모든 경로에서 쿠키 전송 허용
.maxAge(maxAgeSeconds)
.build();
7) 환경별 유연한 인프라 관리
프론트엔드 콜백 URL은 코드 내에 하드코딩하지 않고 스프링 부트 환경 설정(application.yml)으로 격리하여 관리한다.
app:
oauth:
frontend-callback-url: ${FRONTEND_OAUTH_CALLBACK_URL:http://localhost:5173/oauth/callback}
- 로컬 개발 환경: 별도의 설정 없이 `http://localhost:5173/oauth/callback` 기본값을 사용한다.
- 배포/운영 환경: 시스템 환경 변수(`FRONTEND_OAUTH_CALLBACK_URL`)를 주입하여 백엔드 코드를 수정하거나 다시 빌드하지 않고 운영 도메인 주소로 동적 전환할 수 있도록 유연성을 다진다.
4. Frontend 구현
프론트엔드는 백엔드가 열어준 소셜 로그인 진입점으로 사용자를 안내하고, 인증 성공 후 반환된 JWT 토큰을 안전하게 가로채어 애플리케이션 전역의 인증 상태를 유지 및 복구하는 역할을 담당한다.
1) 로그인 페이지 (LoginPage)와 외부 링크 이동
소셜 로그인은 일반 비동기 API 호출(`axios.get`)로 처리하지 않는다. 브라우저 자체가 OAuth Provider의 로그인 페이지로 대이동해야 하므로 `window.location.href`를 활용하여 백엔드가 제공하는 인가 URL 시작점으로 핸들링한다.
const handleOAuthLogin = (provider: OAuthProvider) => {
window.location.href = `http://localhost:8080/api/v1/oauth/${provider}/login`;
};
- 동작 방식: 사용자가 소셜 버튼을 클릭하면 브라우저 주소 자체가 백엔드 API로 튀며, 백엔드가 즉시 302 Redirect를 응답하여 외부 인증창(Google, Kakao 등)으로 부드럽게 이어진다.
2) OAuth Callback 처리 페이지 (OAuthCallbackPage)
백엔드에서 소셜 가입/로그인 처리가 완료되면 프론트엔드의 약속된 주소(`/oauth/callback?accessToken=...`)로 리다이렉트된다. 이 페이지는 UI 렌더링보다 토큰 추출 및 상태 저장이라는 단일 책임을 가진다.
export default function OAuthCallbackPage() {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const clearAuth = useAuthStore((state) => state.clearAuth);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('accessToken');
if (!accessToken) {
clearAuth();
setErrorMessage('OAuth 로그인에 실패했습니다.');
return;
}
setAccessToken(accessToken); // Zustand 전역 스토어에 저장
navigate('/me', { replace: true }); // 뒤로가기 기록 방지를 위해 replace 사용
}, [clearAuth, navigate, setAccessToken]);
if (errorMessage) return <div>{errorMessage}</div>;
return <div>로그인 처리 중...</div>;
}
3) 전역 인증 상태 관리 (useAuthStore)
발급받은 accessToken은 보안을 위해 JavaScript 메모리 내부(Zustand 스토어)에 보관한다.
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
isLoggedIn: false,
isLoggingOut: false,
isInitialized: false, // 새로고침 시 세션 복구 여부를 판별하는 핵심 플래그
setAccessToken: (accessToken) => set({
accessToken,
isLoggedIn: true,
isLoggingOut: false,
isInitialized: true,
}),
clearAuth: () => set({
accessToken: null,
isLoggedIn: false,
isLoggingOut: false,
isInitialized: true,
}),
// ... 생략
}));
💡 isInitialized 플래그가 중요한 이유 (새로고침 이슈 해결)
accessToken은 메모리 기반이므로 브라우저를 새로고침하면 완전히 증발하여 `null`이 된다. 이때 앱이 켜지자마자 "토큰이 없네?" 하고 사용자를 `/login`으로 튕겨버리면 안 된다. 브라우저 쿠키에 refreshToken이 살아있을 수 있기 때문에, 초기 1회 자동 재발급(reissue) 프로세스를 시도하기 전까지는 `isInitialized`를 `false`로 두어 판단을 유보해야 한다.
4) 인가 복구 및 라우트 보호 (ProtectedRoute)
인증이 필요한 접근 제한 페이지(예: `/me`)를 보호하고, 새로고침 시 세션을 자동으로 복구하는 가드 컴포넌트다.
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const isInitialized = useAuthStore((state) => state.isInitialized);
const accessToken = useAuthStore((state) => state.accessToken);
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const clearAuth = useAuthStore((state) => state.clearAuth);
const isLoggingOut = useAuthStore((state) => state.isLoggingOut);
useEffect(() => {
// 이미 초기화되었거나, 로그아웃 중이거나, 토큰이 이미 존재하면 재발급 생략
if (isInitialized || isLoggingOut || accessToken) return;
let isMounted = true;
const restoreSession = async () => {
try {
const result = await reissue(); // HttpOnly 쿠키 기반 토큰 재발급 API
if (!isMounted) return;
setAccessToken(result.accessToken);
} catch {
if (!isMounted) return;
clearAuth(); // 실패 시 인증 정보 청소 (isInitialized 가 true 로 변경됨)
}
};
void restoreSession();
return () => { isMounted = false; };
}, [accessToken, clearAuth, isInitialized, isLoggingOut, setAccessToken]);
// 1. 아직 복구 프로세스가 진행 중이라면 로딩 화면을 보여준다 (팅김 방지)
if (!isInitialized) return <div>로그인 상태 확인 중...</div>;
// 2. 확인 결과 토큰이 없다면 비로그인 상태이므로 로그인 페이지로 강제 이동
if (isLoggingOut || !accessToken) return <Navigate to="/login" replace />;
return <>{children}</>;
}
5) Axios Interceptor: 자동 헤더 첨부 및 401 Unauthorized 격파
인증이 필요한 매 API 요청마다 헤더를 손수 넣는 중복을 제거하고, 만료된 accessToken을 알아채어 백그라운드에서 자동으로 갱신(Silent Reissue)하는 네트워크 핵심 계층이다.
6) Request Interceptor: 토큰 자동 적재
api.interceptors.request.use((config: AuthRequestConfig) => {
const accessToken = useAuthStore.getState().accessToken;
// 특정 API(로그인, 회원가입, 재발급 등)에서 토큰 첨부를 원치 않을 때 skip 옵션 활용
if (accessToken && !config.skipAuthHeader) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
7) Response Interceptor & Concurrency 제어: 401 자동 복구
토큰이 만료되어 401 에러가 떨어지면 사용자 몰래 `/auth/reissue`를 실행하고 원래 하려던 API 요청을 재시도(`_retry`)한다. 이때 여러 API가 동시에 401을 뱉을 때 발생하는 중복 재발급 요청 문제를 refreshPromise 변수로 원자적으로 묶어 해결하기 위해 지금처럼 구현했다.
let refreshPromise: Promise<string | null> | null = null;
const refreshAccessToken = async (): Promise<string | null> => {
if (!refreshPromise) { // 하나의 Promise 동기화 채널 생성 (중복 호출 차단)
refreshPromise = (async () => {
try {
const response = await api.post<CommonResponse<ReissueResponse>>(
'/auth/reissue', undefined, { skipAuthRefresh: true, skipAuthHeader: true }
);
const accessToken = response.data.data.accessToken;
useAuthStore.getState().setAccessToken(accessToken);
return accessToken;
} catch {
useAuthStore.getState().clearAuth();
return null;
} finally {
refreshPromise = null; // 초기화
}
})();
}
return refreshPromise;
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as AuthRequestConfig | undefined;
if (!originalRequest || originalRequest.skipAuthRefresh) return Promise.reject(error);
// 401 에러이고, 재시도(_retry) 이력이 없는 최초 만료 상황일 때만 실행
if (error.response?.status !== 401 || originalRequest._retry) return Promise.reject(error);
if (useAuthStore.getState().isLoggingOut) return Promise.reject(error);
originalRequest._retry = true; // 무한 루프 방지용 플래그 On
const newAccessToken = await refreshAccessToken();
if (!newAccessToken) return Promise.reject(error);
// 새 토큰으로 헤더를 교체하고 기존 실패했던 요청을 다시 전송하여 완벽히 복구
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
}
);
8) 최종 요약: 프론트엔드 유기적 협력 구조
프론트엔드 아키텍처의 유기적 흐름을 정리하면 다음과 같은 유즈케이스로 압축된다.
- 최초 진입 (LoginPage): `window.location.href`로 백엔드 소셜 라우트를 흔들어 로그인을 시작한다.
- 인증 콜백 (OAuthCallbackPage): 주소창 파라미터에서 accessToken만 명석하게 발라내어 스토어에 보관 후 메인 홈으로 돌린다.
- 인증 가드 (ProtectedRoute): 새로고침 시 스토어가 깨져도 쿠키를 이용해 `reissue()`를 먼저 때려보고 판단하여 무분별한 로그인 튕김을 차단한다.
- 네트워크 수문장 (Axios Interceptor): 매 API마다 Bearer 토큰을 부착하고, 활동 중 토큰이 만료되어 발생하는 401 상태를 refreshPromise 기법으로 우아하게 동시성 제어하며 자동 갱신한다.
5. 로그인 유지와 토큰 재발급
소셜 로그인에 성공하면 시스템 보안과 편의성을 절충하기 위해 성격이 다른 두 종류의 토큰을 쌍으로 관리한다.
- accessToken: 프론트엔드 메모리에 저장되며 짧은 만료 시간을 가진다. 매 API 요청 인증에 사용된다.
- refreshToken: 브라우저의 HttpOnly Cookie에 저장되어 스크립트 접근이 차단된다. 오직 accessToken 재발급에만 사용된다.
메모리 기반의 accessToken이 증발하는 두 가지 시점(새로고침, 토큰 만료)에서 인증 상태를 유기적으로 복구하는 백엔드와 프론트엔드의 메커니즘은 다음과 같다.
1) 새로고침 시 로그인 복구 흐름
사용자가 브라우저를 새로고침하면 프론트엔드 전역 상태(Zustand)가 초기화되어 accessToken이 유실된다. 이때 `ProtectedRoute` 가드가 개입하여 세션 복구를 시도한다.
sequenceDiagram
autonumber
actor User as 사용자
participant FE as Frontend
participant BE as Backend
User->>FE: /me 새로고침
FE->>FE: accessToken 초기화 (null)
FE->>FE: ProtectedRoute 실행 (인증 유보)
FE->>BE: POST /api/v1/auth/reissue<br>(with refreshToken Cookie)
BE->>BE: refreshToken 검증 (Redis & JWT)
alt refreshToken 유효 및 일치
BE->>BE: 새 accessToken / refreshToken 생성 (RTR)
BE-->>FE: 새 accessToken 응답 (JSON)
BE-->>FE: Set-Cookie new refreshToken (HttpOnly)
FE->>FE: 스토어에 새 accessToken 저장
FE-->>User: /me 페이지 렌더링 유지
else refreshToken 만료 또는 변조/탈취
BE-->>FE: 401 Unauthorized
FE->>FE: 스토어 청소 (clearAuth)
FE-->>User: /login 페이지로 강제 리다이렉트
end
2) API 호출 중 만료 시 자동 복구 흐름
활동 중에 accessToken의 수명이 다하면 백엔드는 401 Unauthorized를 반환한다. 프론트엔드의 Axios Interceptor는 사용자에게 에러를 노출하지 않고 백그라운드에서 토큰을 갱신(Silent Reissue)한 뒤 원래 요청을 재시도한다.
sequenceDiagram
autonumber
participant FE as Frontend Axios
participant BE as Backend
FE->>BE: 특정 비즈니스 API 요청 (expired accessToken)
BE-->>FE: 401 Unauthorized (EXPIRED_ACCESS_TOKEN)
FE->>BE: POST /api/v1/auth/reissue<br>(with refreshToken Cookie)
BE->>BE: RTR 토큰 교환 알고리즘 작동
alt 토큰 재발급 성공
BE-->>FE: 새 accessToken 응답
BE-->>FE: Set-Cookie new refreshToken (HttpOnly)
FE->>FE: 스토어 갱신
FE->>BE: 기존 비즈니스 API 재요청 (new accessToken)
BE-->>FE: 정상 데이터 응답
else 토큰 재발급 실패
BE-->>FE: 401 Unauthorized
FE->>FE: 인증 상태 초기화 (clearAuth)
FE-->>FE: /login 이동
end
3) 백엔드 핵심 설계: RTR(Refresh Token Rotation)과 Redis 검증
보안을 더욱 강화하기 위해 백엔드 계층에서는 ReissueTokenService를 도입하고 RTR(Refresh Token Rotation) 전략을 사용한다. RTR은 accessToken을 재발급할 때 refreshToken도 함께 새것으로 교체하여 쿠키에 내려주는 방식이다.
4) Redis 인프라와 Lua 스크립트를 통한 원자적 회전
리프레시 토큰의 데이터베이스 저장소로 인메모리 NoSQL인 Redis를 채택했다. 유저 아이디별 키(`auth:rt:{memberId}`)로 매핑하여 만료 시간(TTL) 관리를 최적화한다. 특히 토큰 교환 시 발생할 수 있는 동시성 이슈 및 변조 검증을 위해 Lua 스크립트를 작성하여 `rotateIfMatches` 연산을 원자적(Atomic)으로 수행한다.
// RedisRefreshTokenRepository 일부분
String script = """
local current = redis.call('GET', KEYS[1])
if not current then
return 0 -- 저장소에 토큰 없음 (만료)
end
if current ~= ARGV[1] then
return 2 -- 저장소의 토큰과 제출된 토큰 불일치 (재사용/탈취 감지)
end
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]) -- 새 토큰 교체 및 TTL 설정
return 1 -- 성공
""";
5) 토큰 재사용(Reuse) 감지와 무력화 처리
보안상 가장 위협적인 요소는 공격자가 refreshToken을 탈취하여 먼저 재발급을 받아버리는 경우다.
백엔드는 중복 사용이 감지되면 단호하게 대응하도록 설계했다.
// ReissueTokenService 로직 일부분
String hashedOldRefreshToken = jwtHashUtil.sha256(refreshToken);
String hashedNewRefreshToken = jwtHashUtil.sha256(newRefreshToken);
RefreshTokenRotationResult rotationResult = refreshTokenRepository.rotateIfMatches(
memberId, hashedOldRefreshToken, hashedNewRefreshToken, refreshTokenTtl
);
return switch (rotationResult) {
case SUCCESS -> new ReissueTokenResult(newAccessToken, newRefreshToken, ...);
case NOT_FOUND -> throw new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN);
case MISMATCH -> {
// [위험 상황 감지] 기만적인 토큰 재사용 요청 발생 시
log.warn("Refresh token reuse detected. memberId={}", memberId);
refreshTokenRepository.deleteByMemberId(memberId); // 해당 사용자의 Redis 내 토큰 전체 강제 삭제
throw new AuthException(AuthErrorCode.REUSED_REFRESH_TOKEN); // 세션 만료 및 재로그인 유도
}
};
- 동작 원리: 클라이언트가 전송한 리프레시 토큰의 해시값(`ARGV[1]`)이 Redis에 보관된 현재 최신 값(`current`)과 다르면 `MISMATCH(2)`를 반환한다. 이는 누군가 이미 한 번 사용한 구형 토큰으로 접근했음을 의미하므로, 백엔드는 즉시 저장소의 토큰을 폭파하여 정상 유저와 탈취 유저를 모두 로그아웃시키고 재인증을 요구한다.
6) 토큰 라이프사이클 처리 구조 정리
최종적으로 구현된 로그인 유지 시스템의 결정 구조를 요약하면 다음과 같다.
1. accessToken 정상 소지 시
└─ 그대로 API 서버 통신 진행 (최상의 상태)
2. 브라우저 새로고침 발생 시
└─ Zustand 초기화 ➔ ProtectedRoute가 가로챔 ➔ /reissue API 자동 요청 (Cookie 첨부)
3. 활동 중 accessToken 만료 시 (401 수신)
└─ Interceptor 감지 ➔ refreshPromise로 동시성 제어 ➔ /reissue 자동 요청 ➔ 기존 요청 복구
4. /reissue 요청 시 검증 단계 (Backend)
├─ 수신된 토큰 해시 일치 ➔ 새로운 JWT 쌍 발급 (RTR 정상 회전)
├─ Redis 데이터 소멸 ➔ 만료 에러 반환 ➔ 프론트엔드 로그인 페이지 추방
└─ 구형 토큰 중복 사용 포착 ➔ Redis 세션 강제 파괴 ➔ 탈취 방지 비상조치
결과적으로 accessToken은 메모리에서 짧고 가볍게 유지하고, refreshToken은 DB(Redis)와 브라우저 쿠키(HttpOnly) 사이에서 안전하게 보호받으며, 재발급은 프론트와 백의 핸드셰이킹을 통해 완벽하게 자동화되는 견고한 인증 아키텍처가 완성된다.
6. 구현하며 겪은 문제와 해결 (Troubleshooting)
프로젝트를 진행하며 직면했던 시행착오들과 이를 극복하기 위해 적용한 아키텍처적 고민들을 정리한다.
가장 크게 깨달은 점은 외부 플랫폼의 인증 성공이 우리 서비스의 로그인 완료를 의미하지 않는다는 사실이다.
문제 1. OAuth Callback API에서 JSON만 응답되는 현상
- 상황: 백엔드 Callback 컨트롤러가 일반 비즈니스 API처럼 성공 메시지와 accessToken이 담긴 JSON 객체를 반환하도록 설계했다.
- 원인: OAuth 소셜 로그인은 프론트엔드가 비동기(Axios)로 요청을 보내는 구조가 아니라, 브라우저의 주소창이 직접 이동하는 리다이렉트 흐름이다. 따라서 백엔드가 JSON을 반환하면 브라우저 화면에는 텍스트 데이터만 덜렁 노출되고, 프론트엔드 앱은 이 토큰을 받아서 상태 스토어에 담을 방법이 없다.
- 해결: 백엔드가 JSON을 뱉는 대신, 프론트엔드가 준비한 전용 콜백 페이지 URL 쿼리 스트링에 토큰을 실어서 강제로 리다이렉트(302)시키도록 수정했다.
// JSON 반환 대신 프론트엔드 콜백 페이지로 토큰과 함께 리다이렉트
response.sendRedirect("http://localhost:5173/oauth/callback?accessToken=" + accessToken);
프론트엔드는 이 `/oauth/callback` 라우트에서 쿼리 파라미터를 읽어 안전하게 메모리에 안착시킨다.
문제 2. 브라우저를 새로고침하면 로그인 상태가 증발하는 현상
- 상황: 보안을 위해 accessToken을 Zustand 전역 메모리 스토어에만 보관했더니, 사용자가 새로고침을 할 때마다 로그아웃되는 현상이 발생했다.
- 원인: SPA(Single Page Application) 특성상 새로고침은 React 앱의 상태 맵을 완전히 청소하고 재시작한다. 당연히 메모리에 있던 토큰은 null로 초기화된다.
- 해결: 브라우저 쿠키에 여전히 유효한 refreshToken이 남아있을 수 있으므로, 라우터를 방어하는 ProtectedRoute에서 앱이 켜질 때 백엔드에 토큰 재발급(`/auth/reissue`)을 선제적으로 요청하도록 방어막을 쳤다.
- 또한, 재발급 API의 응답이 오기도 전에 비로그인 상태로 오판하여 사용자를 `/login` 페이지로 튕겨버리는 릴레이 버그를 막기 위해 스토어에 `isInitialized`라는 초기화 완료 플래그 상태를 도입하여 동기화를 맞추었다.
문제 3. Access Token 만료 시 발생하는 401 Unauthorized 에러
- 상황: 보안을 위해 accessToken의 생명 주기를 짧게 가져갔더니, 서비스 이용 도중 토큰이 만료될 때마다 사용자의 모든 API 요청이 실패하며 강제 로그아웃된 것처럼 튕기는 현상이 발생했다.
- 원인: 유효기간이 끝난 토큰을 헤더에 실어 보냈으니 백엔드 인가 필터가 401 에러를 반환하는 것은 당연했으나, 프론트엔드단에서 이를 유연하게 받아치고 처리하는 자동 복구 인터셉터가 부재했다.
- 해결: Axios Response Interceptor를 구축했다. 모든 API 응답을 감시하다가 401 에러가 감지되면, 현재 진행 중이던 비즈니스 요청을 잠시 대기 큐에 묶어둔다. 그사이 백그라운드에서 `/auth/reissue`를 조용히 호출(Silent Reissue)하여 새로운 토큰을 발급받아 스토어를 교체한 뒤, 기존에 실패했던 요청의 헤더만 새 토큰으로 갈아 끼워 재시도(`_retry`)하는 복구 메커니즘을 완성했다.
문제 4. 인증 제공자(Provider) 증가에 따른 컨트롤러 매핑 중복
- 상황: 최초 설계 시 구글, 카카오, 깃허브 등 플랫폼마다 로그인 매핑 메서드를 별도로 개설했다. (`/google/login, /kakao/login ...`)
- 원인: 당장은 동작하지만, 새로운 소셜 로그인 수단이 추가될 때마다 컨트롤러 코드 라인이 기하급수적으로 늘어나며 코드 중복과 유지보수 효율 저하를 야기하는 구조였다.
- 해결: 컨트롤러 레벨에서는 `@PathVariable {provider}` 속성을 사용해 진입점을 단 하나로 통합했다.
@GetMapping("/{provider}/login")
public void login(@PathVariable String provider, HttpServletResponse response) throws IOException {
OAuthProvider oauthProvider = OAuthProvider.valueOf(provider.toUpperCase());
response.sendRedirect(oauthRedirectService.getAuthorizationUrl(oauthProvider));
}
컨트롤러는 유입된 문자열을 OAuthProvider 열거형(Enum)으로 바인딩하여 요청/응답 흐름만 제어하고, 실제 플랫폼별 인가 URL 생성 책임은 switch 표현식을 품은 OAuthRedirectService로 완벽히 격리하여 확장성을 챙겼다.
문제를 해결하면서 후기
이번 Full-Stack 소셜 로그인 아키텍처 구현 과정을 통해 배운 핵심 가치는 다음과 같이 세 가지로 압축된다.
- 인증 체계의 국산화: OAuth Provider의 인증 결과를 무비판적으로 사용하는 것이 아니라, 우리 서비스 도메인의 회원 DB 체계와 유기적으로 맵핑하고 엮어내는 계층 설계가 핵심이다.
- 자체 토큰 발행의 독립성: 소셜 플랫폼은 유저가 '누구인지' 신원만 보증해 줄 뿐이며, 우리 서비스 안에서 돌아다닐 권한 표식(JWT Access/Refresh)은 우리 백엔드가 온전히 책임지고 직접 통제해야 한다.
- 사용자 경험(UX)과 보안의 트레이드 오프: 토큰 탈취 위험성을 줄이기 위해 만료 시간을 타이트하게 쥐는 만큼, 새로고침 세션 복구 플래그(isInitialized)와 동시성 제어가 가미된 Axios Interceptor 같은 프론트엔드 인프라가 정교하게 받쳐주어야 완벽한 로그인이 완성된다.
7. 향후 고도화 과제 (Next Step)
현재 구축한 로컬 환경 기반의 베이스 프로토타입을 넘어, 프로덕션 환경으로 나아가기 위해 보완해야 할 고도화 체크리스트다.
- 운영 환경 보안 적용: 가동 서버에 HTTPS 프로토콜을 전면 이식하고, 브라우저 쿠키의 Secure 플래그 활성화 및 크로스 도메인 이슈 방지를 위한 SameSite 설정을 정교하게 셋업한다.
- Redis 분리 보완: 현재 연동된 Redis 기반 리프레시 토큰 저장소의 클러스터 예외 처리 및 장애 백업 전략을 보강한다.
- 보안 프로토콜 강화: CSRF(사이트 간 요청 위조) 공격을 원천 차단하기 위한 OAuth2 state 파라미터 검증 로직을 추가하고, 더 나아가 모바일 및 SPA 환경에서 인가 코드 탈취를 원천 차단하는 PKCE(Proof Key for Code Exchange) 방식 도입을 검토한다.
이번 구현은 로컬 환경(`http://localhost`)에서 OAuth2 소셜 로그인과 JWT 발급 흐름의 유기적 연동을 완성하는 데 초점을 맞췄다. 하지만 이를 실제 상용(Production) 운영 환경에 그대로 올리기에는 보안 및 인프라 측면에서 몇 가지 위험 요소가 존재한다. 실제 서비스를 배포하고 운영하려면 다음 세 가지 축의 고도화 작업이 반드시 수반되어야 한다.
1) 운영 환경 인프라 보안 및 크로스 도메인(CORS) 설정 정리
로컬 환경과 달리 운영 환경에서는 데이터 패킷 탈취를 막기 위해 반드시 HTTPS 프로토콜이 전면 적용되어야 한다. 특히 refreshToken을 브라우저 쿠키로 내려주는 구조에서는 보안 옵션 조율이 핵심이다.
상용 환경에서의 쿠키 옵션 표준
Set-Cookie: refreshToken=...
HttpOnly;
Secure;
SameSite=None;
Path=/;
Max-Age=604800;
- Secure: HTTPS 연결 환경에서만 쿠키가 전송되도록 강제한다.
- SameSite=None: 대부분의 모던 웹 서비스 아키텍처는 프론트엔드 도메인(`https://frontend.example.com`)과 백엔드 API 도메인(`https://api.example.com`)이 분리되어 운영된다. 크로스 도메인 간의 안정적인 쿠키 전송을 위해서는 `SameSite=None`과 `Secure` 쌍이 필수로 결합되어야 한다.
2) 트레이드 오프: CORS 설정 연동
크로스 오리진 상황에서 웹 브라우저가 자격 증명(Cookie 등)을 안전하게 주고받으려면, 백엔드와 프론트엔드가 아래 사양을 동시에 만족해야 한다.
- Backend CORS Configuration: allowedOrigins에 와일드카드(`*`) 대신 명확한 프론트엔드 실 도메인을 명시하고, 자격 증명을 허용한다.
configuration.setAllowCredentials(true); configuration.setAllowedOrigins(List.of("https://frontend.example.com")); - Frontend Axios Instance: HTTP 요청 시 헤더에 쿠키를 강제로 실어 나르도록 컨피그를 활성화한다.
withCredentials: true
3) Redis 기반 RefreshToken 저장소 고도화 및 장애 대응 (HA)
현재 가동 중인 Redis 기반 토큰 저장소 역시 프로덕션 레벨의 가용성(High Availability)과 데이터 모델 아키텍처를 보강해야 한다.
다중 기기 로그인 대응 키 설계
현재는 회원당 단 하나의 토큰 세션만 인정하는 구조(`auth:rt:{memberId}`)다. 만약 유저가 PC 브라우저와 모바일 앱에서 동시에 로그인 상태를 유지하는 다중 디바이스 환경을 지원하려면 디바이스 식별자(deviceId 또는 UUID)를 조합한 복합 키 설계로 전환해야 한다.
- 단일 세션 제한: `refreshToken:{memberId}` ➔ hashedToken
- 다중 기기 허용: `refreshToken:{memberId}:{deviceId}` ➔ hashedToken
4) 분산 환경 구축 및 Fail-Safe 전략
- 인프라 이중화: 단일 Redis 노드 장애(SPOF)로 전체 인증 시스템이 마비되는 것을 방지하기 위해 상용 환경에서는 Redis Sentinel 또는 Redis Cluster 환경을 도입하여 마스터-슬레이브 복제 및 자동 페일오버를 보장한다.
- 보안 우선 장애 처리(Fail-Safe): 인프라 순간 단절 등으로 인해 Redis 서버 조회 실패(RedisConnectionFailureException)가 발생했을 때, 시스템 편의를 위해 인증을 임의로 통과시키는 것은 극히 위험하다. 인증 저장소 조회 실패는 즉시 검증 실패 처리(401 Unauthorized)로 간주하여 유저를 안전하게 재로그인 화면으로 안내하는 하드-캐칭 전략이 안전하다.
5) OAuth2 사양 보안 프로토콜 강화 (state & PKCE)
인가 코드(Authorization Code) 탈취 및 가로채기 공격을 프론트엔드와 백엔드 양단에서 원천 차단하기 위해 표준 스펙의 고급 보안 파라미터를 결합한다.
CSRF 방어를 위한 state 파라미터 적용
state는 소셜 로그인 요청을 시작한 브라우저와 최종 Callback을 수신한 브라우저의 흐름이 일치하는지 검증하는 고유 난수 값이다. 공격자가 위조된 인가 코드를 가로채어 정상 사용자의 세션에 강제 결합하는 CSRF(사이트 간 요청 위조)를 차단한다.
sequenceDiagram
autonumber
participant FE as Frontend Browser
participant BE as Backend Server
participant OP as OAuth Provider
FE->>BE: 1. 소셜 로그인 시작 (/login)
BE->>BE: 2. 일회성 무작위 난수 state 생성 및 세션/Redis 임시 저장
BE-->>FE: 3. Google URL 리다이렉트 (state=XYZ 포함)
FE->>OP: 4. 인증 진행 및 권한 동의
OP-->>FE: 5. 백엔드 콜백 리다이렉트 (code=...&state=XYZ)
FE->>BE: 6. Callback API 진입
BE->>BE: 7. 전달된 state(XYZ)와 2번에서 저장한 원본 state 대조
Note over BE: 일치 시 토큰 교환 진행<br>불일치 시 위조 요청으로 판단하여 즉시 거부(Drop)
인가 코드 탈취 원천 봉쇄: PKCE (Proof Key for Code Exchange)
클라이언트 사이드 환경(SPA 웹앱, 모바일 네이티브 앱)처럼 백엔드 내부망에 비해 client_secret을 온전히 은닉하기 어려운 퍼블릭 클라이언트 환경에서는 인가 코드가 가로채어질 위험이 존재한다. 이를 방어하는 프로토콜이 PKCE 방식이다.
- 로그인 요청 시: 프론트엔드 혹은 서버가 무작위 문자열인 code_verifier를 생성하고, 이를 암호화 해시(SHA-256)한 변환값인 code_challenge를 구한다.
- Provider 이동: OAuth 인가 요청 URL에 `code_challenge`와 해시 방법(`code_challenge_method=S256`)을 파라미터로 실어 보낸다. OAuth Provider는 이 챌린지 값을 메모리에 기록해 둔다.
- 토큰 교환 시: 인가 코드를 받아 최종 토큰 교환 API를 Provider에게 요청할 때, 해시 처리 전 원본 문자열인 `code_verifier`를 함께 전달한다.
- Provider 검증: OAuth Provider는 들고 있던 code_challenge와 수신한 `code_verifier`를 직접 다시 SHA-256 알고리즘으로 돌려 일치 여부를 대조한다.
- 이 연산이 통과되어야만 소셜 토큰을 정상 발급하므로, 중간 통신 과정에서 설령 `authorization code`만 탈취한 공격자가 있더라도 원본 암호 키(`code_verifier`)를 알지 못해 무용지물이 된다.
전체 요약 및 결론
이번 구현에서는 로컬 환경에서 OAuth2 로그인 메커니즘과 자체 JWT 인증 체계를 매끄럽게 가로채고 바인딩하는 연동 흐름을 완성하는 데 집중했다. 운영 환경으로 전개할 때는 단순히 코드의 무결성을 넘어 HTTPS 인프라 정착, Secure 및 SameSite 기반 쿠키 보안, 다중 디바이스 처리를 위한 분산 크로스오버 CORS 정책이 명확히 선행 정리되어야 한다. 아울러 고가용성 분산 환경을 위한 Redis 장애 대응 핵심 전략을 세우고, OAuth2 state 파라미터 및 PKCE 프로토콜을 적극 상용 도입함으로써 CSRF와 자격 증명 탈취 공격에 대한 보안 신뢰 수준을 완벽한 방어선으로 끌어올릴 수 있다. 소셜 로그인의 완성은 외부 플랫폼 버튼의 단순 결합이 아니라, 외부 신원 보증을 우리 시스템만의 견고한 보안 경계선 내부로 안착시키는 일련의 보안 인프라 엔지니어링 작업이다.
'🍃SpringBoot' 카테고리의 다른 글
| JUnit [2/2]: Service, Repository, Controller, 동시성 테스트 (0) | 2026.06.22 |
|---|---|
| JUnit [1/2]: 테스트 개념과 JUnit, Mockito 사용법 (0) | 2026.06.22 |
| Java Time API: JWT와 Audit으로 배우는 실전 시간 정책 (0) | 2026.06.16 |
| 카카오 로그인 구현(이론): OAuth 2.0부터 JWT 인증 설계(Google, Github) (0) | 2026.06.12 |
| DDD, Hexagonal: 동시성, 중복 요청, 이벤트 유실과 싸우며 배운 '적정 아키텍처'의 가치(Idempotency, Outbox, Port & Adapter) (0) | 2026.06.10 |
