OAuth 소셜 로그인
웹 서비스에 회원가입을 할 때마다 이메일, 비밀번호를 새로 만들어야 한다면 사용자 입장에서 귀찮다. 비밀번호를 서비스마다 다르게 관리하기도 어렵고, 서비스 운영자 입장에서는 비밀번호를 안전하게 저장하고 관리하는 것 자체가 부담이다. 보안 사고가 나면 책임도 따른다.
OAuth 소셜 로그인은 이 문제를 해결한다. 사용자는 이미 가지고 있는 Google이나 Apple 계정으로 로그인하고, 서비스는 비밀번호를 직접 다루지 않는다. 인증의 책임을 신뢰할 수 있는 제3자(Google, Apple 등)에게 위임하는 것이다.
OAuth 2.0이 뭔지
OAuth 2.0은 권한 위임 프로토콜이다. 핵심 아이디어는 간단하다: 사용자의 비밀번호를 직접 받는 대신, 사용자가 Google 같은 Provider에게 "이 서비스가 내 정보에 접근해도 괜찮아"라고 허락하면, Provider가 그 허락의 증거로 토큰을 발급해주는 것이다.
이걸 이해하려면 등장하는 역할을 먼저 알아야 한다:
| 역할 | 설명 | 예시 |
|---|---|---|
| Resource Owner | 자원의 소유자, 즉 사용자 | Google 계정을 가진 사용자 |
| Client | 사용자의 자원에 접근하려는 애플리케이션 | 우리가 만드는 웹 서비스 |
| Authorization Server | 인증을 처리하고 토큰을 발급하는 서버 | Google OAuth 서버 |
| Resource Server | 사용자의 자원(프로필 등)을 가진 서버 | Google API 서버 |
실제로 Authorization Server와 Resource Server는 같은 회사(Google)에 있지만, 역할이 다르기 때문에 개념적으로 분리한다.
Authorization Code Flow
OAuth 2.0에는 여러 가지 인증 흐름(Grant Type)이 있는데, 웹 애플리케이션에서 소셜 로그인에 사용하는 건 거의 Authorization Code Flow다. 이 방식이 가장 안전한 이유는 access token이 브라우저에 직접 노출되지 않기 때문이다.
전체 흐름을 순서대로 보자:
1. 사용자가 "Google로 로그인" 버튼 클릭
2. 브라우저가 Google 인증 페이지로 리다이렉트
3. 사용자가 Google에서 로그인 + 권한 동의
4. Google이 우리 서비스의 callback URL로 리다이렉트 (authorization code 포함)
5. 우리 서버가 이 code를 Google에 보내서 access token으로 교환
6. access token으로 Google API에서 사용자 정보 조회
7. 사용자 정보로 우리 서비스의 계정 생성/로그인 처리
왜 code를 한 번 더 교환하는가?
"그냥 바로 access token을 주면 안 되나?" 라는 의문이 들 수 있다. 사실 그런 방식도 있었다(Implicit Flow). 하지만 access token이 URL에 직접 노출되면 브라우저 히스토리, 리퍼러 헤더, 로그 등을 통해 유출될 위험이 크다.
Authorization Code는 일회용이고 짧은 수명을 가진다. 이 code를 access token으로 교환하는 요청은 서버 간에 이루어지기 때문에(client_secret 포함) 브라우저에 token이 노출되지 않는다.
[브라우저] --code--> [우리 서버] --code + client_secret--> [Google]
[우리 서버] <--access_token-- [Google]
구현: Google OAuth
1단계: Google Cloud Console 설정
Google OAuth를 쓰려면 먼저 Google Cloud Console에서 프로젝트를 만들고 OAuth 2.0 클라이언트를 등록해야 한다. 이때 Authorized redirect URI를 설정하는데, 이게 Google이 인증 후 code를 보내줄 주소다.
https://yourdomain.com/auth/provider.google/continue
등록하면 client_id와 client_secret을 발급받는다. client_id는 공개되어도 괜찮지만, client_secret은 절대 클라이언트에 노출하면 안 된다. 서버 환경변수에 저장한다.
2단계: 로그인 URL 생성
사용자가 "Google로 로그인" 버튼을 클릭하면, Google의 인증 페이지 URL로 리다이렉트한다. 이 URL에는 여러 파라미터가 포함된다:
https://accounts.google.com/o/oauth2/v2/auth?
client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourdomain.com/auth/provider.google/continue
&response_type=code
&scope=openid email profile
&state=RANDOM_STATE_VALUE
&access_type=offline
각 파라미터의 역할:
- client_id: Google에 등록한 애플리케이션 식별자
- redirect_uri: 인증 후 돌아올 주소 (Console에 등록한 것과 정확히 일치해야 함)
- response_type=code: Authorization Code Flow를 사용하겠다는 의미
- scope: 요청하는 권한 범위.
openid는 OIDC 사용,email과profile은 이메일과 프로필 정보 접근 - state: CSRF 공격 방지용 랜덤 값. 콜백에서 이 값을 검증한다
- access_type=offline: refresh token도 함께 발급받을 때 사용
실제 구현에서는 이 URL을 서버에서 생성해서 클라이언트에 내려주는 게 좋다. client_id를 클라이언트에 하드코딩하지 않아도 되고, state 값 관리도 서버에서 할 수 있다:
// API 호출로 로그인 URL 받기
const { refetch: getGoogleUrl } = useQuery({
queryKey: ['auth', 'google-url'],
queryFn: authApi.getGoogleLoginUrl,
enabled: false, // 버튼 클릭 시에만 호출
});
const handleGoogleLogin = async () => {
const { data } = await getGoogleUrl();
window.location.href = data.url; // Google 인증 페이지로 이동
};
enabled: false로 설정해서 컴포넌트 마운트 시 자동으로 호출되지 않도록 하고, refetch()로 필요할 때만 호출한다.
3단계: 콜백 처리
사용자가 Google에서 로그인을 마치면, Google은 설정한 redirect URI로 리다이렉트하면서 URL에 code를 붙여준다:
https://yourdomain.com/auth/provider.google/continue?code=4/0AX4XfWh...&state=RANDOM_STATE_VALUE
클라이언트에서 이 code를 추출해서 서버로 보내고, 서버가 Google과 token 교환을 한다:
// 콜백 페이지 (클라이언트)
function useGoogleOAuthCallback() {
const searchParams = useSearchParams();
const { mutate: continueGoogleLogin } = useContinueGoogleLogin();
useEffect(() => {
const code = searchParams.get('code');
const error = searchParams.get('error');
if (error) {
// 사용자가 취소했거나 에러 발생
redirect('/sign-in');
return;
}
if (code) {
// 서버에 code 전송 → 서버가 token 교환 처리
continueGoogleLogin({ code });
}
}, []);
}
서버 측에서는 이 code를 받아서 Google에 access token을 요청한다:
// 서버 측 (개념적 코드)
async function handleGoogleCallback(code: string) {
// 1. code → access token 교환
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://yourdomain.com/auth/provider.google/continue',
grant_type: 'authorization_code',
}),
});
const { access_token } = await tokenResponse.json();
// 2. access token으로 사용자 정보 조회
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
});
const { email, name, picture } = await userInfo.json();
// 3. 우리 서비스의 계정 생성 또는 기존 계정 연결
const user = await findOrCreateUser({ email, name, picture, provider: 'google' });
// 4. 우리 서비스의 JWT 토큰 발급
return { accessToken: generateJWT(user) };
}
구현: Apple Sign-In
Apple 로그인은 Google과 기본 흐름은 같지만, 한 가지 결정적인 차이가 있다. Apple은 콜백에서 form_post 방식을 사용한다.
form_post vs query 방식
Google은 콜백 시 code를 URL 쿼리 파라미터로 보낸다:
GET /callback?code=xxx&state=yyy
Apple은 code를 HTTP POST body로 보낸다:
POST /callback
Content-Type: application/x-www-form-urlencoded
code=xxx&state=yyy
왜 이런 차이가 있을까? Apple은 보안을 이유로 form_post를 채택했다. URL에 민감한 데이터를 넣으면 서버 로그, 브라우저 히스토리, 리퍼러 헤더 등에 노출될 수 있는데, POST body는 이런 경로로 유출되지 않는다.
Next.js에서 form_post 처리
문제는 브라우저에서 POST 요청을 클라이언트 사이드 라우터가 직접 받을 수 없다는 것이다. Next.js의 페이지 컴포넌트는 GET 요청만 처리하기 때문에, Apple이 보내는 POST 요청을 먼저 Route Handler에서 받아서 GET으로 변환해줘야 한다:
// app/auth/provider.apple/callback/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData();
const code = formData.get('code') as string | null;
const error = formData.get('error') as string | null;
const origin = getOrigin(request);
if (error) {
return NextResponse.redirect(
new URL(`/auth/provider.apple/continue?error=${encodeURIComponent(error)}`, origin),
303,
);
}
if (!code) {
return NextResponse.redirect(
new URL('/auth/provider.apple/continue?error=missing_code', origin),
303,
);
}
// 303 See Other: POST → GET 자동 변환
return NextResponse.redirect(
new URL(`/auth/provider.apple/continue?code=${encodeURIComponent(code)}`, origin),
303,
);
}
여기서 HTTP 상태 코드 303이 중요하다. 303 See Other는 "이 POST 요청의 결과를 GET으로 확인하라"는 의미다. 브라우저가 303 응답을 받으면 자동으로 GET 요청으로 리다이렉트한다. 302를 쓸 수도 있지만, 303은 명시적으로 POST→GET 변환을 의미하므로 더 정확하다.
이후 /auth/provider.apple/continue 페이지에서는 Google과 동일하게 URL의 code를 추출해서 서버에 보내면 된다:
function useAppleOAuthCallback() {
const searchParams = useSearchParams();
const { mutate: continueAppleLogin } = useContinueAppleLogin();
useEffect(() => {
const code = searchParams.get('code');
const error = searchParams.get('error');
if (error) {
redirect('/sign-in');
return;
}
if (code) {
continueAppleLogin({ code });
}
}, []);
}
state 파라미터와 CSRF 방지
OAuth 흐름에서 state 파라미터는 선택 사항이지만 사실상 필수다. 이걸 빼면 CSRF(Cross-Site Request Forgery) 공격에 취약해진다.
공격 시나리오를 보자:
1. 공격자가 자신의 Google 계정으로 인증해서 code를 받음
2. 이 code가 포함된 콜백 URL을 피해자에게 보냄
3. 피해자가 링크를 클릭하면, 피해자의 계정이 공격자의 Google 계정과 연결됨
state 파라미터가 있으면 이 공격을 막을 수 있다:
// 1. 로그인 시작 시 랜덤 state 생성 후 세션에 저장
const state = crypto.randomUUID();
session.set('oauth_state', state);
// state를 포함한 URL로 리다이렉트
// 2. 콜백에서 state 검증
const returnedState = searchParams.get('state');
const savedState = session.get('oauth_state');
if (returnedState !== savedState) {
throw new Error('Invalid state parameter');
}
로그인 후 처리
OAuth 인증이 성공하면 우리 서비스의 자체 JWT 토큰을 발급받고, 이후에는 이 토큰으로 인증한다. Google의 access token은 사용자 정보 조회에만 사용하고, 우리 서비스의 세션 관리는 자체 토큰으로 한다.
const { mutate: continueGoogleLogin } = useMutation({
mutationFn: (data: { code: string }) => authApi.continueGoogleLogin(data),
onSuccess: (response) => {
// 서버가 발급한 우리 서비스의 JWT 저장
setToken(response.accessToken);
// 사용자 정보 캐시 업데이트
authApi.getCurrentUser().then((user) => {
queryClient.setQueryData(['auth', 'user'], user);
});
// 워크스페이스로 리다이렉트
redirectToWorkspace();
},
});
로그인 전에 접근하려던 페이지가 있었다면, 그 URL을 localStorage에 저장해뒀다가 로그인 후 복원하는 것도 좋은 UX다:
// 로그인 전: 접근하려던 URL 저장
localStorage.setItem('auth_redirect_url', currentUrl);
// 로그인 후: 저장된 URL로 리다이렉트
const redirectUrl = localStorage.getItem('auth_redirect_url');
if (redirectUrl) {
localStorage.removeItem('auth_redirect_url');
router.push(redirectUrl);
}
OAuth vs 자체 인증 비교
| 항목 | OAuth 소셜 로그인 | 자체 이메일/비밀번호 |
|---|---|---|
| 사용자 경험 | 기존 계정으로 원클릭 | 새 비밀번호 생성 필요 |
| 비밀번호 관리 | Provider가 담당 | 서비스가 직접 해싱/저장 |
| 보안 책임 | Provider에게 위임 | 서비스가 전적으로 부담 |
| 구현 복잡도 | Provider별 흐름 학습 필요 | 비교적 단순 |
| 의존성 | Provider 장애 시 로그인 불가 | 독립적 운영 |
| 사용자 정보 | Provider가 제공하는 것만 | 원하는 정보 자유롭게 수집 |
실제 서비스에서는 둘 다 제공하는 경우가 많다. 소셜 로그인으로 진입 장벽을 낮추면서, 비밀번호 로그인도 옵션으로 두는 것이다.
관련 개념
- OIDC (OpenID Connect): OAuth 2.0 위에 인증 레이어를 추가한 프로토콜.
scope에openid를 넣으면 access token과 함께 ID Token(JWT)을 발급받을 수 있다. 이 ID Token에 사용자 정보가 직접 담겨 있어서 별도 API 호출 없이도 이메일, 이름 등을 확인할 수 있다. - PKCE (Proof Key for Code Exchange): SPA처럼 client_secret을 안전하게 보관할 수 없는 환경에서 Authorization Code Flow를 안전하게 사용하기 위한 확장. 랜덤 code_verifier를 생성하고, 그 해시값(code_challenge)을 인증 요청에 포함시킨다. token 교환 시 원본 code_verifier를 보내서 요청의 출처를 검증한다.
- Magic Link: OAuth와는 다른 접근. 이메일로 일회용 로그인 링크를 보내서, 링크 클릭만으로 인증하는 방식이다. 비밀번호도 필요 없고 소셜 계정도 필요 없지만, 이메일 접근이 필수다.
정리
- Authorization Code Flow는 code→token 2단계 교환으로 access token이 브라우저에 노출되지 않는다. state 파라미터로 CSRF를 방지하고, PKCE로 SPA 환경까지 커버한다.
- Google은 query 방식, Apple은 form_post 방식으로 콜백을 처리한다. Apple의 POST 응답은 Route Handler에서 303 리다이렉트로 GET 변환해야 클라이언트 라우터가 받을 수 있다.
- OAuth로 받은 access token은 사용자 정보 조회에만 쓰고, 이후 인증은 자체 JWT로 관리한다. Provider 의존성을 줄이려면 자체 인증과 병행하는 것이 일반적이다.