2026년 4월 4일

OAuth 2.0과 OpenID Connect: 현대 웹 애플리케이션의 인증 및 인가 마스터하기

70
OAuth 2.0과 OpenID Connect: 현대 웹 애플리케이션의 인증 및 인가 마스터하기

OAuth 2.0과 OpenID Connect: 현대 웹 애플리케이션의 인증 및 인가 마스터하기

OAuth 2.0과 OpenID Connect: 현대 웹 애플리케이션의 인증 및 인가 마스터하기

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘 우리는 현대 웹 애플리케이션 개발에서 필수적인 개념인 OAuth 2.0과 OpenID Connect(OIDC)에 대해 깊이 있게 알아보려 합니다. 이 두 기술은 단순히 "소셜 로그인" 기능 구현을 넘어, 분산 시스템에서 사용자 신원을 확인하고 리소스 접근 권한을 안전하게 위임하는 데 핵심적인 역할을 합니다. 초중급 개발자라면 이 원리를 정확히 이해하고 실무에 적용할 수 있어야 합니다.

1. 개념 소개

1. 개념 소개

정의: OAuth 2.0과 OpenID Connect

  • OAuth 2.0 (Open Authorization 2.0): 사용자의 **권한 위임(Delegated Authorization)**을 위한 프레임워크입니다. 쉽게 말해, 사용자가 자신의 ID와 비밀번호를 서드파티 애플리케이션에 직접 제공하지 않고도, 특정 리소스(예: Google Drive 파일, GitHub 저장소)에 접근할 수 있는 권한을 안전하게 부여하도록 돕는 표준 방식입니다. OAuth 2.0은 "당신이 누구인지"를 묻는 **인증(Authentication)**이 아니라, "당신이 무엇을 할 수 있는지"를 묻는 **인가(Authorization)**에 초점을 맞춥니다.
  • OpenID Connect (OIDC): OAuth 2.0 프로토콜 위에 구축된 인증(Authentication) 레이어입니다. OAuth 2.0이 인가에 중점을 둔 반면, OIDC는 사용자의 신원(Identity) 정보를 안전하게 제공하는 것을 목표로 합니다. OIDC는 OAuth 2.0을 사용하여 클라이언트가 사용자의 ID 정보를 ID 토큰(ID Token) 형태로 얻을 수 있게 하며, 이 ID 토큰은 주로 JSON Web Token (JWT) 형식을 따릅니다.

탄생 배경

인터넷 초창기, 사용자가 A 서비스의 데이터를 B 서비스에서 사용하고 싶을 때, B 서비스에 A 서비스의 사용자 ID와 비밀번호를 직접 입력해야 하는 경우가 많았습니다. 이는 다음과 같은 심각한 보안 문제를 야기했습니다.

  1. 비밀번호 노출 위험: B 서비스가 해킹당하면 A 서비스의 비밀번호까지 유출될 수 있습니다.
  2. 과도한 권한 부여: B 서비스는 사용자의 ID/PW를 알게 되므로 A 서비스의 모든 데이터에 접근할 수 있게 됩니다. 사용자는 특정 기능만 허용하고 싶어도 제어할 수 없습니다.

이러한 문제를 해결하기 위해 2007년 Twitter와 Google을 중심으로 OAuth 프로토콜이 개발되기 시작했고, 2012년 OAuth 2.0이 표준화되었습니다. 이후 사용자 신원 확인의 필요성이 커지면서 2014년 OAuth 2.0 위에 OIDC가 추가되어 오늘날 우리가 아는 안전하고 편리한 로그인 및 권한 부여 시스템의 기반을 마련했습니다.

왜 중요한가?

  • 보안 강화: 사용자 계정 정보(ID/PW)를 서드파티 앱과 직접 공유할 필요 없이, 필요한 최소한의 권한만을 위임할 수 있습니다.
  • 사용자 편의성 증대: 'Google로 로그인', '카카오 로그인' 등과 같이 익숙하고 간편한 방식으로 여러 서비스에 로그인할 수 있게 합니다. 이는 사용자의 서비스 진입 장벽을 낮춥니다.
  • 표준화된 연동: 다양한 서비스와 애플리케이션 간에 인증 및 인가 메커니즘을 표준화하여 상호 운용성을 높입니다. 개발자는 각 서비스마다 다른 방식으로 인증을 구현할 필요 없이, OAuth/OIDC 표준을 따르면 됩니다.
  • 마이크로서비스 아키텍처: 분산 시스템 환경에서 서비스 간의 안전한 API 호출 및 사용자 신원 확인에 필수적으로 사용됩니다.

2. 핵심 원리 설명 (비유와 다이어그램 활용)

2. 핵심 원리 설명 (비유와 다이어그램 활용)

OAuth 2.0과 OIDC의 핵심 원리를 이해하기 위해 흔히 사용되는 "호텔 비유"를 들어 설명하겠습니다.

비유: 당신은 호텔(리소스 서버)에 짐(사용자 데이터)을 맡겨두었습니다. 당신의 친구(클라이언트 애플리케이션)가 당신의 짐 중 특정 물건(예: 신분증)을 찾아야 합니다. 당신은 친구에게 호텔 방 열쇠(ID/PW)를 통째로 줄 위험을 감수하고 싶지 않습니다.

OAuth 2.0의 역할:

  1. 리소스 소유자 (Resource Owner, 사용자): 당신입니다. 짐의 주인이며, 친구에게 짐을 찾을 권한을 주고 싶습니다.
  2. 클라이언트 애플리케이션 (Client Application): 당신의 친구입니다. 당신의 짐에 접근하고 싶어 합니다.
  3. 인가 서버 (Authorization Server): 호텔 프런트 데스크입니다. 누가 누구에게 어떤 권한을 줄지 관리하고, 그 권한을 증명하는 토큰을 발급합니다.
  4. 리소스 서버 (Resource Server): 호텔 짐 보관소입니다. 실제 짐(사용자 데이터)이 보관되어 있으며, 유효한 토큰이 있어야만 접근을 허용합니다.

OAuth 2.0 (Authorization Code Grant Flow) 과정:

  1. 권한 요청 (친구가 당신에게 요청): 친구(클라이언트 앱)는 당신(사용자)에게 "호텔에서 신분증만 찾아도 될까?"라고 묻습니다.
  2. 인가 서버로 리디렉션 (프런트 데스크로 안내): 당신은 친구에게 직접 권한을 주지 않고, "프런트 데스크(인가 서버)에 가서 내가 허락했다고 말해봐"라고 안내합니다. 클라이언트 앱은 사용자 브라우저를 인가 서버로 리디렉션하며, 이때 클라이언트 ID와 요청하는 권한(Scope), 콜백 주소(Redirect URI)를 함께 보냅니다.
  3. 사용자 동의 (당신이 프런트 데스크에 허락): 당신은 프런트 데스크(인가 서버)에서 "이 친구에게 내 신분증을 가져갈 권한을 줍니다"라고 직접 허락합니다. (웹 페이지에서 "허용" 버튼 클릭).
  4. 인가 코드 발급 및 리디렉션 (프런트 데스크가 메모를 친구에게 전달): 프런트 데스크는 당신의 허락을 확인하고, "신분증을 가져가도 좋다는 메모(인가 코드)"를 작성해 친구에게 전달합니다. (인가 서버는 redirect_uri로 인가 코드를 포함하여 클라이언트 앱으로 리디렉션).
  5. 토큰 요청 (친구가 프런트 데스크에 메모와 신분증 제시): 친구는 당신이 준 메모(인가 코드)와 함께 자신의 신분증(클라이언트 시크릿)을 프런트 데스크에 제시하며 "이 메모를 내가 받았다. 이제 정말로 신분증을 가져갈 수 있는 토큰을 달라"고 요청합니다. 이 요청은 브라우저를 거치지 않고 클라이언트 앱의 백엔드에서 인가 서버로 직접 이루어집니다.
  6. 액세스 토큰 발급 (프런트 데스크가 토큰 발행): 프런트 데스크는 메모와 친구의 신분을 확인한 후, "신분증을 가져갈 수 있는 임시 출입증(액세스 토큰)"을 발행해 친구에게 줍니다. (인가 서버가 access_tokenrefresh_token을 클라이언트 앱에 발급).
  7. 리소스 접근 (친구가 임시 출입증으로 짐 보관소에서 신분증 획득): 친구는 임시 출입증(액세스 토큰)을 가지고 짐 보관소(리소스 서버)에 가서 "이 토큰으로 내 신분증을 주세요"라고 요청하고, 신분증을 받습니다. (클라이언트 앱이 액세스 토큰으로 리소스 서버 API를 호출하여 사용자 데이터 획득).

다이어그램:

sequenceDiagram
    participant User as Resource Owner (사용자)
    participant ClientApp as Client Application (클라이언트 앱)
    participant AuthServer as Authorization Server (인가 서버)
    participant ResourceServer as Resource Server (리소스 서버)

    User->>ClientApp: 1. 서비스 이용 (예: "Google로 로그인")
    ClientApp->>User: 2. 인가 서버로 리디렉션 요청 (클라이언트 ID, 스코프, 리디렉션 URI 포함)
    User->>AuthServer: 3. 인가 서버에 권한 부여 요청
    AuthServer->>User: 4. 사용자에게 권한 부여 동의 요청 (로그인, 권한 범위 확인)
    User->>AuthServer: 5. 권한 부여 동의
    AuthServer->>ClientApp: 6. 인가 코드 발급 및 리디렉션 (redirect_uri?code=...)
    ClientApp->>AuthServer: 7. 인가 코드와 클라이언트 시크릿으로 액세스 토큰 요청 (백엔드에서 직접)
    AuthServer->>ClientApp: 8. 액세스 토큰, (리프레시 토큰), (ID 토큰) 발급
    ClientApp->>ResourceServer: 9. 액세스 토큰으로 리소스 요청
    ResourceServer->>ClientApp: 10. 리소스 응답 (사용자 데이터)

OpenID Connect의 추가:

OIDC는 위 과정에서 8단계에 ID 토큰이 추가로 발급된다는 점이 다릅니다. 이 ID 토큰은 JWT 형식으로, 사용자의 sub (고유 ID), name, email 등 신원 정보가 담겨 있습니다. 클라이언트 앱은 이 ID 토큰을 검증하여 사용자가 누구인지 안전하게 확인할 수 있습니다.

OAuth 2.0과 JWT의 관계:

  • OAuth 2.0: 인가 프레임워크 (어떻게 권한을 주고받을 것인가)
  • JWT (JSON Web Token): 토큰 형식 (권한 정보나 신원 정보를 어떤 형태로 담을 것인가)

액세스 토큰이나 OIDC의 ID 토큰은 종종 JWT 형식으로 구현됩니다. JWT는 토큰 자체에 정보를 담고 서명하여 위변조를 방지하는 방식으로, 서버에 별도로 토큰 정보를 저장할 필요 없이 유효성을 검증할 수 있어 분산 시스템에 유리합니다.

3. 코드 예제 2개

여기서는 Python을 사용하여 OAuth 2.0 (Google 기준) 클라이언트 앱의 핵심 로직을 간략하게 보여드리겠습니다. 실제 프로덕션에서는 requests-oauthlib 같은 라이브러리를 사용하거나, Flask-OAuthlib, Authlib 같은 프레임워크 통합 라이브러리를 사용하는 것이 일반적입니다.

예제 1: Python (Flask)를 이용한 OAuth 2.0 인가 코드 플로우

이 예제는 Flask 웹 애플리케이션에서 Google OAuth 2.0을 통해 사용자에게 권한을 요청하고, 액세스 토큰을 받아오는 과정을 보여줍니다.

# app.py
from flask import Flask, redirect, url_for, session, request, jsonify
import requests
import os

app = Flask(__name__)
# 세션 관리를 위한 시크릿 키 (실제 서비스에서는 복잡하고 안전한 값 사용)
app.secret_key = os.urandom(24) 

# Google Cloud Platform에서 발급받은 클라이언트 정보
# 실제 서비스에서는 환경 변수나 설정 파일에서 로드
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "YOUR_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "YOUR_CLIENT_SECRET")
GOOGLE_AUTHORIZATION_BASE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
# 개발 환경에서는 http://localhost:5000/callback 과 같이 설정
# Google Cloud Console에서 '승인된 리디렉션 URI'에 반드시 등록해야 함
REDIRECT_URI = "http://localhost:5000/callback" 
# 요청할 권한 범위 (사용자 프로필 정보와 이메일 접근)
SCOPES = ["openid", "email", "profile"] 

@app.route('/')
def index():
    if 'access_token' in session:
        return f"안녕하세요, {session.get('user_email')}님! <a href='/logout'>로그아웃</a>"
    return '<a href="/login/google">Google로 로그인</a>'

@app.route('/login/google')
def login_google():
    # 1. 인가 코드 요청을 위한 URL 생성
    # state 파라미터는 CSRF 공격 방지를 위해 사용하며, 세션에 저장하여 콜백 시 검증
    session['oauth_state'] = os.urandom(16).hex()
    
    auth_url = (
        f"{GOOGLE_AUTHORIZATION_BASE_URL}?"
        f"client_id={GOOGLE_CLIENT_ID}&"
        f"redirect_uri={REDIRECT_URI}&"
        f"scope={'%20'.join(SCOPES)}&" # 스코프는 공백으로 구분
        f"response_type=code&"
        f"state={session['oauth_state']}"
    )
    return redirect(auth_url)

@app.route('/callback')
def callback():
    # 2. 인가 서버로부터 리디렉션된 후 인가 코드 및 state 검증
    code = request.args.get('code')
    state = request.args.get('state')

    if not code:
        return "인가 코드가 없습니다.", 400
    
    # CSRF 방지를 위해 state 검증
    if state != session.get('oauth_state'):
        return "잘못된 state 파라미터입니다.", 400
    
    # 3. 인가 코드를 이용해 액세스 토큰 요청 (백엔드에서 직접)
    token_request_payload = {
        "code": code,
        "client_id": GOOGLE_CLIENT_ID,
        "client_secret": GOOGLE_CLIENT_SECRET,
        "redirect_uri": REDIRECT_URI,
        "grant_type": "authorization_code",
    }
    
    token_response = requests.post(GOOGLE_TOKEN_URL, data=token_request_payload)
    token_response.raise_for_status() # HTTP 에러 발생 시 예외 처리
    token_data = token_response.json()
    
    session['access_token'] = token_data.get('access_token')
    session['refresh_token'] = token_data.get('refresh_token') # 리프레시 토큰은 만료된 액세스 토큰 갱신에 사용
    session['id_token'] = token_data.get('id_token') # OIDC에서 사용자 신원 정보 포함

    # 4. 액세스 토큰을 사용하여 사용자 정보 요청 (리소스 서버 접근)
    userinfo_response = requests.get(
        GOOGLE_USERINFO_URL,
        headers={'Authorization': f"Bearer {session['access_token']}"}
    )
    userinfo_response.raise_for_status()
    user_info = userinfo_response.json()

    session['user_email'] = user_info.get('email')
    session['user_name'] = user_info.get('name')

    return redirect(url_for('index'))

@app.route('/logout')
def logout():
    session.pop('access_token', None)
    session.pop('refresh_token', None)
    session.pop('id_token', None)
    session.pop('user_email', None)
    session.pop('user_name', None)
    session.pop('oauth_state', None)
    return redirect(url_for('index'))

if __name__ == '__main__':
    # 환경 변수 설정 예시 (실제로는 .env 파일 등을 사용)
    # export GOOGLE_CLIENT_ID="your_client_id_from_google_cloud_console"
    # export GOOGLE_CLIENT_SECRET="your_client_secret_from_google_cloud_console"
    app.run(debug=True)

설명:

  1. login_google 라우트: 사용자가 "Google로 로그인" 링크를 클릭하면, Google 인가 서버로 리디렉션할 URL을 생성합니다. 이때 client_id, redirect_uri, scope, response_type=code, state 파라미터를 포함합니다. state는 CSRF 공격 방지를 위해 사용하며, 콜백 시에 서버에서 받은 state 값과 세션에 저장된 값이 일치하는지 확인해야 합니다.
  2. callback 라우트: Google 인가 서버로부터 인가 코드(code)와 state를 받아옵니다. 먼저 state를 검증하고, 문제가 없으면 code를 사용하여 Google 토큰 엔드포인트에 POST 요청을 보냅니다. 이때 client_idclient_secret을 함께 전송하여 클라이언트 앱의 신원을 확인받습니다.
  3. 토큰 응답: Google 인가 서버는 요청이 유효하면 access_token, refresh_token, id_token 등을 포함하는 JSON 응답을 보냅니다. access_token은 리소스 서버에 접근할 때 사용되며, id_token은 OIDC 표준에 따라 사용자 신원 정보를 포함합니다.
  4. 사용자 정보 요청: 발급받은 access_tokenAuthorization: Bearer <access_token> 헤더에 담아 Google UserInfo 엔드포인트(리소스 서버)에 요청하여 사용자 프로필 정보를 가져옵니다.
  5. 세션 저장: 획득한 토큰과 사용자 정보를 Flask 세션에 저장하여 사용자가 로그인 상태를 유지하도록 합니다.

예제 2: OIDC ID 토큰 검증 (개념 설명 위주)

실제 프로덕션 환경에서는 OIDC 클라이언트 라이브러리(예: Authlib for Python, oidc-client-js for JavaScript)를 사용하여 ID 토큰 검증을 자동화합니다. 여기서는 ID 토큰(JWT)의 구조와 검증의 개념을 간략히 설명합니다.

ID 토큰은 JWT 형식이며, 세 부분으로 구성됩니다: Header.Payload.Signature.

  • Header: 토큰의 타입(typ: JWT)과 서명 알고리즘(alg: RS256 등)을 포함합니다.
  • Payload: 클레임(Claim)이라고 불리는 사용자 신원 정보를 포함합니다. OIDC 표준에 따라 iss(발급자), sub(주체), aud(수신자), exp(만료 시간), iat(발급 시간) 등의 필수 클레임과 name, email, picture 등 선택적 클레임이 포함됩니다.
  • Signature: Header와 Payload를 Base64Url 인코딩한 값을 인가 서버의 개인 키로 서명한 값입니다.

클라이언트 앱은 이 ID 토큰을 받아 다음과 같은 과정을 거쳐 검증합니다.

  1. 서명 검증: 인가 서버가 공개적으로 제공하는 공개 키(JWKS 엔드포인트에서 획득)를 사용하여 토큰의 서명을 검증합니다. 서명이 유효하다면 토큰이 위변조되지 않았음을 의미합니다.
  2. 클레임 검증:
    • iss (Issuer): 토큰 발급자가 예상하는 인가 서버와 일치하는지 확인.
    • aud (Audience): 토큰 수신자(클라이언트 앱의 client_id)가 일치하는지 확인.
    • exp (Expiration Time): 토큰이 만료되지 않았는지 확인.
    • iat (Issued At Time): 토큰 발급 시간이 너무 오래되지 않았는지 확인 (리플레이 공격 방지).
    • nonce (옵션): 인가 요청 시 보낸 nonce와 ID 토큰의 nonce가 일치하는지 확인 (리플레이 공격 방지).

이러한 검증 과정을 통과하면 클라이언트 앱은 ID 토큰에 담긴 사용자 신원 정보를 신뢰할 수 있습니다.

# ID 토큰 검증의 개념적 예시 (실제로는 라이브러리를 사용합니다)
import jwt
import requests
import json
from datetime import datetime, timezone

# 가상의 Google OIDC 설정 정보 (실제 Google의 discovery 문서에서 가져옴)
GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"
GOOGLE_CLIENT_ID = "YOUR_CLIENT_ID"

def validate_id_token_conceptual(id_token, expected_client_id):
    try:
        # 1. JWKS (JSON Web Key Set) 엔드포인트에서 공개 키 로드
        # 실제로는 캐싱하여 사용하며, 만료 시 갱신
        discovery_doc = requests.get(GOOGLE_DISCOVERY_URL).json()
        jwks_uri = discovery_doc['jwks_uri']
        jwks_response = requests.get(jwks_uri).json()
        
        # 2. JWT 헤더에서 서명 알고리즘과 키 ID 추출
        unverified_header = jwt.get_unverified_header(id_token)
        kid = unverified_header['kid']
        
        # 3. JWKS에서 해당 kid에 맞는 공개 키 찾기
        public_key = None
        for key in jwks_response['keys']:
            if key['kid'] == kid:
                public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
                break
        
        if not public_key:
            raise ValueError("Matching public key not found in JWKS")

        # 4. 서명 검증 및 페이로드 디코딩
        # audience, issuer, expiration 등 클레임 검증은 jwt.decode에서 처리
        decoded_payload = jwt.decode(
            id_token,