junyeokk
Blog
Auth·2025. 09. 05

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이 브라우저에 직접 노출되지 않기 때문이다.

전체 흐름을 순서대로 보자:

text
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이 노출되지 않는다.

text
[브라우저]  --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를 보내줄 주소다.

text
https://yourdomain.com/auth/provider.google/continue

등록하면 client_idclient_secret을 발급받는다. client_id는 공개되어도 괜찮지만, client_secret은 절대 클라이언트에 노출하면 안 된다. 서버 환경변수에 저장한다.

2단계: 로그인 URL 생성

사용자가 "Google로 로그인" 버튼을 클릭하면, Google의 인증 페이지 URL로 리다이렉트한다. 이 URL에는 여러 파라미터가 포함된다:

text
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 사용, emailprofile은 이메일과 프로필 정보 접근
  • state: CSRF 공격 방지용 랜덤 값. 콜백에서 이 값을 검증한다
  • access_type=offline: refresh token도 함께 발급받을 때 사용

실제 구현에서는 이 URL을 서버에서 생성해서 클라이언트에 내려주는 게 좋다. client_id를 클라이언트에 하드코딩하지 않아도 되고, state 값 관리도 서버에서 할 수 있다:

typescript
// 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를 붙여준다:

text
https://yourdomain.com/auth/provider.google/continue?code=4/0AX4XfWh...&state=RANDOM_STATE_VALUE

클라이언트에서 이 code를 추출해서 서버로 보내고, 서버가 Google과 token 교환을 한다:

typescript
// 콜백 페이지 (클라이언트)
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을 요청한다:

typescript
// 서버 측 (개념적 코드)
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 쿼리 파라미터로 보낸다:

text
GET /callback?code=xxx&state=yyy

Apple은 code를 HTTP POST body로 보낸다:

text
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으로 변환해줘야 한다:

typescript
// 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를 추출해서 서버에 보내면 된다:

typescript
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) 공격에 취약해진다.

공격 시나리오를 보자:

text
1. 공격자가 자신의 Google 계정으로 인증해서 code를 받음
2. 이 code가 포함된 콜백 URL을 피해자에게 보냄
3. 피해자가 링크를 클릭하면, 피해자의 계정이 공격자의 Google 계정과 연결됨

state 파라미터가 있으면 이 공격을 막을 수 있다:

typescript
// 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은 사용자 정보 조회에만 사용하고, 우리 서비스의 세션 관리는 자체 토큰으로 한다.

typescript
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다:

typescript
// 로그인 전: 접근하려던 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 위에 인증 레이어를 추가한 프로토콜. scopeopenid를 넣으면 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 의존성을 줄이려면 자체 인증과 병행하는 것이 일반적이다.

관련 문서