Notion이 내 Google 캘린더를 읽으려면 내 비밀번호를 줘야 할까? 당연히 아니다. 비밀번호 없이 어떻게 권한을 넘기는 걸까?

소셜 로그인을 구현할 때 Spring Security 설정만 따라하면 돌아가긴 합니다. 하지만 한 발짝 물러서서 보면, 용어가 많고 Flow가 여러 개라서 복잡해 보이지만 핵심 아이디어는 단순해요.

OAuth 2.0이 바로 이 문제를 푸는 프레임워크입니다. 용어가 많고 Flow가 여러 개라서 복잡해 보이지만, 핵심 아이디어는 단순합니다. 하나씩 정리해볼게요.


OAuth 2.0이란

OAuth 2.0은 인가(Authorization) 프레임워크 입니다. 인증(Authentication)이 아니에요. 이 구분이 꽤 중요합니다.

핵심은 위임 인가(Delegated Authorization) 예요. 내가 Google에 있는 내 정보를, 어떤 서드파티 앱에게 직접 비밀번호를 알려주지 않고도 접근할 수 있게 허가하는 메커니즘입니다.

예를 들어 "Notion에서 Google 캘린더를 연동하고 싶다"고 하면:

  1. 사용자가 Google에 로그인합니다
  2. "Notion이 내 캘린더를 읽어도 됩니다"라고 허락합니다
  3. Google이 Notion에게 토큰 을 발급합니다
  4. Notion은 이 토큰으로 Google 캘린더 API를 호출합니다

Notion은 내 Google 비밀번호를 몰라요. 단지 "캘린더 읽기" 권한만 위임받은 것입니다.


역할 4가지

OAuth 2.0에는 명확하게 역할이 나뉘어 있습니다.

역할설명예시
Resource Owner리소스의 주인. 보통 사용자(end user)나 (Google 계정 주인)
Client리소스에 접근하려는 애플리케이션Notion, 내가 만든 앱
Authorization Server인가를 처리하고 토큰을 발급하는 서버Google OAuth 서버
Resource Server보호된 리소스를 제공하는 서버Google Calendar API

Authorization Server와 Resource Server가 물리적으로 같은 서버일 수도 있고, 분리돼 있을 수도 있어요. Google의 경우 OAuth 서버(accounts.google.com)와 API 서버(www.googleapis.com)가 분리돼 있습니다.


Grant Types

OAuth 2.0은 토큰을 발급받는 방식을 Grant Type 이라 부릅니다. RFC 6749에 4가지가 정의돼 있어요.

1. Authorization Code Grant

가장 표준적이고, 가장 안전한 방식입니다. 서버 사이드 애플리케이션에서 사용해요.

PLAINTEXT
사용자 → Client → Authorization Server (로그인 + 동의)

          redirect로 Authorization Code 전달

Client 서버가 Code + Client Secret으로 토큰 교환

Authorization Code가 브라우저를 거쳐 전달되긴 하지만, 이 코드만으로는 아무것도 못 합니다. 토큰 교환은 Client의 백엔드에서 Client Secret과 함께 이뤄지니까, 토큰이 브라우저에 노출되지 않아요.

2. Implicit Grant (Deprecated)

SPA에서 쓰던 방식인데, **더 이상 권장되지 않습니다 **. Authorization Code 없이 바로 Access Token을 redirect URL의 fragment(#)에 담아 내려줬어요.

PLAINTEXT
https://app.com/callback#access_token=abc123&token_type=bearer

문제는 토큰이 브라우저 히스토리에 남고, Referer 헤더로 유출될 수 있다는 점이에요. 지금은 PKCE를 쓰는 Authorization Code Flow가 이걸 대체했습니다.

3. Client Credentials Grant

사용자 개입 없이, ** 애플리케이션 자체가 인증 **해서 토큰을 받는 방식입니다. 서버 간 통신(M2M)에서 써요.

PLAINTEXT
POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=my-service
&client_secret=secret123
&scope=read:metrics

사용자가 없으니까 Resource Owner가 없어요. 모니터링 시스템이 다른 서비스의 메트릭을 가져올 때 같은 상황에서 사용합니다.

4. Resource Owner Password Credentials Grant (Deprecated)

사용자가 Client에 ** 직접 ID/PW를 입력 **하고, Client가 이걸로 토큰을 받는 방식입니다.

PLAINTEXT
POST /token

grant_type=password
&username=user@example.com
&password=1234
&client_id=my-app

OAuth 2.0이 해결하려는 "비밀번호를 제3자에게 주지 말자"를 정면으로 위반합니다. 자사 앱(first-party)에서만 쓰이라고 만든 건데, 지금은 이것도 deprecated예요.


Authorization Code Flow 상세

"OAuth 로그인 흐름을 설명해보세요"라는 질문에는, 이 Flow를 설명하면 됩니다.

PLAINTEXT
┌────────┐     ┌────────┐     ┌──────────────────┐     ┌──────────────┐
│ 사용자  │     │ Client │     │ Authorization    │     │ Resource     │
│(브라우저)│     │ (서버)  │     │ Server           │     │ Server       │
└───┬────┘     └───┬────┘     └────────┬─────────┘     └──────┬───────┘
    │   1. 로그인 버튼 클릭  │                │                      │
    │──────────────→│                │                      │
    │               │  2. redirect   │                      │
    │←──────────────│                │                      │
    │   3. 로그인 + 동의 화면        │                      │
    │──────────────────────────────→│                      │
    │   4. redirect + code + state  │                      │
    │←──────────────────────────────│                      │
    │   5. code 전달 │                │                      │
    │──────────────→│                │                      │
    │               │  6. code + client_secret → token     │
    │               │───────────────→│                      │
    │               │  7. access_token + refresh_token     │
    │               │←───────────────│                      │
    │               │  8. API 호출 (Bearer token)           │
    │               │──────────────────────────────────────→│
    │               │  9. 리소스 응답  │                      │
    │               │←──────────────────────────────────────│
    │  10. 결과 반환  │                │                      │
    │←──────────────│                │                      │

step 2: Authorization Request

Client가 사용자를 Authorization Server로 redirect시킵니다.

PLAINTEXT
https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code
  &client_id=my-client-id
  &redirect_uri=https://myapp.com/callback
  &scope=openid profile email
  &state=xyzRandom123

state 파라미터 — CSRF 방지

state는 Client가 생성한 랜덤 문자열입니다. Authorization Server가 redirect할 때 그대로 돌려주는데, Client는 이 값을 세션에 저장해둔 것과 비교해요.

공격자가 자신의 Authorization Code를 피해자에게 전달하는 CSRF 공격 을 막기 위한 것입니다. state가 없으면, 공격자의 계정으로 피해자가 로그인되는 상황이 발생할 수 있어요.

step 6: Token Exchange

PLAINTEXT
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=4/0AX4XfWg...
&redirect_uri=https://myapp.com/callback
&client_id=my-client-id
&client_secret=my-client-secret

이 요청은 Client의 백엔드에서 직접 보냅니다. client_secret이 포함되니까, 절대 프론트엔드에서 하면 안 돼요.


PKCE (Proof Key for Code Exchange)

SPA나 모바일 앱은 client_secret을 안전하게 저장할 수 없습니다. 코드를 뜯어보면 다 보이니까요. 그래서 Implicit Flow를 쓰던 건데, 그것도 deprecated됐으니 대안이 필요해요.

PKCE(피키시라고 읽습니다)는 client_secret 없이도 Authorization Code를 안전하게 교환하는 방법입니다.

PLAINTEXT
1. Client가 랜덤 문자열 code_verifier를 생성
2. code_verifier를 SHA256으로 해시 → code_challenge
3. Authorization Request에 code_challenge를 포함
4. Token Exchange 시 code_verifier를 포함
5. Authorization Server가 code_verifier를 해시해서 code_challenge와 비교

공격자가 Authorization Code를 탈취해도, code_verifier를 모르면 토큰을 교환할 수 없습니다.

PLAINTEXT
// Authorization Request
https://auth.server.com/authorize?
  response_type=code
  &client_id=spa-client
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

// Token Exchange
POST /token
grant_type=authorization_code
&code=abc123
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

OAuth 2.1(아직 드래프트)에서는 PKCE가 ** 모든 Client에 필수 **로 바뀔 예정입니다.


Access Token과 Refresh Token

Authorization Server가 발급하는 토큰은 두 종류입니다.

토큰용도수명
Access TokenAPI 호출 시 인증 수단짧음 (15분 ~ 1시간)
Refresh TokenAccess Token 재발급용김 (7일 ~ 90일)

왜 두 개로 나눌까?

Access Token이 탈취되면 피해가 큽니다. 그래서 수명을 짧게 가져가요. 대신 매번 로그인하라고 하면 UX가 엉망이 되니까, Refresh Token으로 조용히 재발급받습니다.

PLAINTEXT
POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
&client_id=my-client-id
&client_secret=my-client-secret

Refresh Token Rotation

Refresh Token을 쓸 때마다 새 Refresh Token을 발급하고, 기존 건 폐기하는 전략입니다. 탈취된 Refresh Token이 사용되면, 정상 사용자의 Refresh Token도 무효화되어 탈취를 감지할 수 있어요.


Scope — 권한 범위 지정

Scope는 Client가 요청하는 ** 권한의 범위 **를 정의합니다. Access Token에 어떤 기능까지 허용할지를 정하는 거예요.

PLAINTEXT
scope=openid profile email
scope=repo user:email        // GitHub
scope=profile_nickname profile_image account_email  // Kakao

사용자 동의 화면에서 "이 앱이 다음 권한을 요청합니다: 이메일 읽기, 프로필 조회"라고 뜨는 게 Scope를 기반으로 렌더링된 것입니다.

** 최소 권한 원칙 **을 지켜야 해요. 캘린더만 필요한데 Google Drive 접근 권한까지 요청하면 사용자가 거부할 확률이 높아지고, 보안적으로도 좋지 않습니다.


OpenID Connect (OIDC)

OAuth 2.0은 ** 인가 **만 담당합니다. "이 사용자가 누구인지"는 알려주지 않아요. Access Token으로 API를 호출해서 프로필 정보를 가져올 수는 있지만, 그건 OAuth 2.0의 표준이 아니라 각 Provider마다 다른 API를 호출해야 합니다.

OpenID Connect는 OAuth 2.0 ** 위에 얹힌 인증 레이어 **입니다. "이 사용자가 누구인지"를 표준화된 방식으로 알려줘요.

ID Token

OIDC의 핵심은 ID Token 입니다. JWT 형식으로 발급되고, 사용자 정보가 들어 있어요.

JSON
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "my-client-id",
  "exp": 1711040400,
  "iat": 1711036800,
  "email": "user@gmail.com",
  "name": "김정훈",
  "picture": "https://lh3.googleusercontent.com/..."
}
  • iss: 발급자 (Google, GitHub 등)
  • sub: 사용자 고유 식별자 (Provider별 유니크)
  • aud: 이 토큰이 의도된 Client
  • email, name, picture: 사용자 정보 (scope에 따라 달라짐)

Access Token과 달리, ID Token은 Client가 직접 파싱해서 사용하는 용도 입니다. API 호출에 쓰면 안 돼요.

UserInfo Endpoint

ID Token에 담기지 않은 추가 정보가 필요하면, Access Token으로 UserInfo Endpoint를 호출합니다.

PLAINTEXT
GET https://openidconnect.googleapis.com/v1/userinfo
Authorization: Bearer ya29.access-token-here

이 Endpoint는 OIDC에서 표준화돼 있어서, Provider에 상관없이 동일한 구조의 응답을 기대할 수 있습니다.

OAuth 2.0 vs OIDC 정리

구분OAuth 2.0OpenID Connect
목적인가 (Authorization)인증 (Authentication)
발급 토큰Access Token, Refresh Token+ ID Token
사용자 정보Provider별 API 호출표준화된 Claims
scope리소스 접근 범위openid, profile, email

소셜 로그인 구현 흐름

실제 소셜 로그인을 구현할 때 어떤 흐름으로 동작하는지 정리해볼게요. Google, GitHub, Kakao 전부 기본적으로 같은 패턴입니다.

1단계: OAuth 앱 등록

각 Provider의 Developer Console에서 앱을 등록하고, client_idclient_secret을 발급받습니다. redirect_uri도 여기서 설정해요.

Provider등록 위치
GoogleGoogle Cloud Console
GitHubSettings → Developer settings → OAuth Apps
KakaoKakao Developers

2단계: 로그인 버튼 → Authorization Server로 redirect

PLAINTEXT
// Google
https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code&client_id=...&redirect_uri=...&scope=openid profile email&state=...

// GitHub
https://github.com/login/oauth/authorize?
  client_id=...&redirect_uri=...&scope=user:email&state=...

// Kakao
https://kauth.kakao.com/oauth/authorize?
  response_type=code&client_id=...&redirect_uri=...&scope=profile_nickname&state=...

3단계: Callback에서 Code 수신 → Token 교환

사용자가 동의하면, Authorization Server가 redirect_uri로 code를 보내줍니다. 백엔드에서 이 code를 가지고 토큰을 교환해요.

4단계: ID Token 또는 UserInfo로 사용자 식별

받은 ID Token의 sub 클레임이 해당 Provider에서의 고유 ID입니다. 이걸로 우리 DB에서 사용자를 찾거나, 없으면 회원가입 처리를 해요.

JAVA
// 간단한 로직 예시
String providerId = idToken.getSubject();       // "110169484..."
String email = idToken.getClaim("email");

User user = userRepository.findByProviderAndProviderId("google", providerId)
    .orElseGet(() -> userRepository.save(
        User.builder()
            .provider("google")
            .providerId(providerId)
            .email(email)
            .build()
    ));

// 우리 서비스의 JWT 발급
String jwt = jwtProvider.createToken(user.getId());

핵심은 Provider의 Access Token으로 우리 서비스의 API를 호출하는 게 아니라는 점입니다. Provider Token은 사용자 식별에만 쓰고, 이후엔 우리 서비스 자체의 JWT 를 발급해서 사용해요.


심화 개념

"OAuth 2.0은 인증인가요, 인가인가요?"

인가(Authorization) 입니다. OAuth 2.0 자체는 "이 토큰으로 어떤 리소스에 접근할 수 있는가"만 다뤄요. "이 사용자가 누구인가"는 관심사가 아닙니다.

소셜 로그인에서 인증처럼 보이는 이유는, Access Token으로 Profile API를 호출해서 사용자 정보를 가져오기 때문이에요. 하지만 이건 OAuth 2.0 표준의 범위 밖이고, 제대로 하려면 OIDC를 써야 합니다.

"JWT를 Access Token으로 쓰는 이유는?"

Access Token은 원래 포맷이 정해져 있지 않습니다. 그냥 opaque string(랜덤 문자열)이어도 돼요.

**Opaque Token 방식 **: Resource Server가 토큰을 받으면 Authorization Server에 ** 매번 검증 요청 **을 보내야 합니다. 이게 Token Introspection이에요.

**JWT 방식 **: 토큰 자체에 정보(claims)가 들어 있고, 서명으로 위변조를 검증합니다. Resource Server가 ** 자체적으로 검증 **할 수 있으니까 Authorization Server에 네트워크 요청을 보내지 않아도 돼요.

MSA 환경에서 서비스가 수십 개인데, 매 API 호출마다 Authorization Server에 검증 요청을 보내면 병목이 됩니다. JWT를 쓰면 각 서비스가 공개키만 가지고 독립적으로 검증할 수 있어요.

"Token Introspection이 뭔가요?"

RFC 7662에 정의된 엔드포인트입니다. Resource Server가 Authorization Server에 "이 토큰 아직 유효한가요?"를 물어보는 거예요.

PLAINTEXT
POST /introspect
Content-Type: application/x-www-form-urlencoded

token=2YotnFZFEjr1zCsicMWpAA

→ { "active": true, "scope": "read write", "client_id": "my-app", "exp": 1711040400 }

JWT를 쓰면 대부분의 경우 불필요하지만, 토큰을 ** 즉시 무효화 **해야 하는 상황(로그아웃, 계정 정지)에서는 여전히 필요합니다. JWT는 만료 전까지 자체적으로 유효하니까요.

"Authorization Code가 탈취되면?"

Authorization Code는 일회용이고, 보통 10분 이내에 만료됩니다. 그리고 코드를 토큰으로 교환하려면 client_secret이 필요해요 (서버 사이드 앱의 경우). PKCE를 쓰면 code_verifier까지 필요합니다. 코드 하나만으로는 토큰을 얻을 수 없어요.


파생되는 개념들

  • HTTP/HTTPS — OAuth 2.0은 반드시 HTTPS 위에서 동작해야 합니다
  • Spring Securityspring-boot-starter-oauth2-client로 소셜 로그인 구현
  • JWT — ID Token의 포맷이자, Access Token으로도 자주 사용되는 토큰 형식
댓글 로딩 중...