2026년 3월 20일

OAuth 2.0과 OpenID Connect: 안전한 인증과 권한 부여의 표준

140
OAuth 2.0과 OpenID Connect: 안전한 인증과 권한 부여의 표준

OAuth 2.0과 OpenID Connect: 안전한 인증과 권한 부여의 표준

OAuth 2.0과 OpenID Connect: 안전한 인증과 권한 부여의 표준

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 현대 소프트웨어 개발에서 빼놓을 수 없는 중요한 두 가지 개념, 바로 OAuth 2.0과 **OpenID Connect(OIDC)**에 대해 이야기하려 합니다. 많은 개발자분들이 이 두 기술의 이름은 자주 듣지만, 정확히 무엇이고 어떻게 동작하며 서로 어떤 관계인지 혼동하는 경우가 많습니다. 이 글을 통해 여러분의 궁금증을 해소하고, 실무에서 이들을 효과적으로 활용할 수 있는 기반을 다지는 데 도움을 드리고자 합니다.

1. 개념 소개: 왜 OAuth 2.0과 OpenID Connect가 중요할까요?

1. 개념 소개: 왜 OAuth 2.0과 OpenID Connect가 중요할까요?

정의

  • OAuth 2.0 (Open Authorization 2.0): 사용자의 ID/비밀번호를 직접 공유하지 않고도, 특정 애플리케이션(클라이언트)이 사용자의 특정 자원(예: 사진, 연락처, 프로필 정보)에 접근할 수 있도록 **권한을 위임(Delegated Authorization)**하는 표준 프레임워크입니다. 여기서 핵심은 '권한 부여'이지 '인증'이 아니라는 점입니다.
  • OpenID Connect (OIDC): OAuth 2.0 위에 구축된 신원 확인(Authentication) 계층입니다. OAuth 2.0의 권한 부여 기능을 활용하여 사용자의 신원을 확인하고, 기본적인 프로필 정보를 안전하게 제공하는 표준 방식을 정의합니다. 즉, OIDC는 OAuth 2.0을 '인증' 목적으로 사용할 수 있게 확장한 것입니다.

탄생 배경

초기 웹 서비스들은 사용자가 자신의 데이터를 다른 서비스와 공유하려면, 해당 서비스에 자신의 ID와 비밀번호를 직접 입력해야 했습니다. 이는 다음과 같은 심각한 문제들을 야기했습니다.

  1. 보안 취약점: 사용자의 민감한 인증 정보를 제3의 서비스에 넘겨주는 것은 보안상 매우 위험했습니다. 해당 서비스가 정보를 오용하거나 유출될 경우 사용자 전체 계정이 위험해질 수 있었습니다.
  2. 권한 제어의 어려움: 특정 기능(예: 사진 업로드)만을 허용하고 싶어도, ID/비밀번호를 넘겨주면 모든 기능에 대한 접근 권한을 주게 되어 세밀한 제어가 불가능했습니다.
  3. 비밀번호 변경의 번거로움: 한 서비스에서 비밀번호를 변경하면, 연동된 모든 서비스에서 다시 비밀번호를 업데이트해야 하는 번거로움이 있었습니다.

이러한 문제들을 해결하기 위해 **OAuth 1.0 (2007년)**이 등장했으나, 복잡한 구현 때문에 널리 채택되지 못했습니다. 이후 **OAuth 2.0 (2012년)**은 더 간단하고 유연한 프로토콜로 개선되어, 웹, 모바일, 데스크톱 등 다양한 클라이언트를 지원하며 사실상의 표준으로 자리 잡았습니다.

하지만 OAuth 2.0은 '권한 부여'에 초점을 맞추었기에, '사용자 인증'이라는 중요한 요구사항을 직접적으로 다루지는 않았습니다. 이 간극을 메우기 위해 **OpenID Connect (2014년)**가 등장했습니다. OIDC는 OAuth 2.0의 토큰 교환 메커니즘을 활용하여, 사용자 신원 정보가 담긴 ID Token을 추가함으로써 인증 기능까지 포괄하게 되었습니다.

왜 중요한가요?

  • 보안 강화: 사용자의 ID/비밀번호를 직접 공유하지 않고, 제한된 범위의 권한만 위임함으로써 보안 위험을 최소화합니다. Access Token이 유출되더라도, 해당 토큰이 허용하는 범위 내에서만 자원 접근이 가능하며, 쉽게 폐기할 수 있습니다.
  • 편의성 증대 (SSO 및 소셜 로그인): 구글, 페이스북, 카카오 등 소셜 계정을 이용한 로그인은 OIDC를 통해 구현됩니다. 사용자는 여러 서비스에 일일이 가입하고 로그인할 필요 없이, 익숙한 계정으로 편리하게 접근할 수 있습니다. 이는 Single Sign-On (SSO) 환경 구축의 핵심입니다.
  • 서비스 확장성: 다양한 외부 서비스 및 API와 안전하게 연동할 수 있는 표준 방식을 제공합니다. 이를 통해 애플리케이션은 사용자 경험을 풍부하게 하고, 새로운 기능을 쉽게 통합할 수 있습니다.
  • API 경제 활성화: 수많은 서비스들이 자신들의 API를 외부에 개방하고, 다른 애플리케이션들이 이 API를 활용하여 새로운 가치를 창출하는 'API 경제'의 기반이 됩니다.

2. 핵심 원리 설명: 호텔 투숙객과 벨보이 비유

2. 핵심 원리 설명: 호텔 투숙객과 벨보이 비유

OAuth 2.0과 OIDC의 작동 방식을 이해하기 위해, 호텔 투숙객과 벨보이의 비유를 들어 설명해 보겠습니다.

등장인물 (Roles)

  • Resource Owner (투숙객/사용자): 호텔 방(자원)의 주인이며, 방에 대한 모든 권한(데이터)을 가지고 있습니다.
  • Client (벨보이/클라이언트 애플리케이션): 투숙객의 방에 접근하여 특정 업무(예: 짐 옮기기, 룸서비스 주문)를 수행하려는 앱 또는 서비스입니다. 직접 방 키를 가질 수 없습니다.
  • Authorization Server (프론트 데스크/권한 서버): 투숙객의 신원을 확인하고, 벨보이에게 특정 업무를 수행할 수 있는 '일회용 지시서'를 발급해주는 곳입니다.
  • Resource Server (객실 관리부/자원 서버): 투숙객의 방(보호된 자원)을 관리하며, 벨보이가 가져온 '일회용 지시서'를 확인하여 요청된 업무를 수행해 줍니다.

OAuth 2.0 흐름 (Authorization Code Grant)

가장 일반적이고 안전한 OAuth 2.0 흐름인 'Authorization Code Grant'를 비유에 맞춰 설명해 보겠습니다.

  1. 벨보이의 요청: 벨보이(클라이언트)가 투숙객(사용자)에게 "짐을 방으로 옮겨드릴까요?"라고 묻습니다. 이때 벨보이는 "몇 호실의 어떤 짐을 옮길지"에 대한 요청 정보와 본인(벨보이)의 신분증(Client ID)을 함께 전달하며, "동의하면 프론트 데스크로 가서 허락받아 오세요"라고 안내합니다. (클라이언트가 권한 서버로 사용자 리다이렉션)
  2. 투숙객의 동의: 투숙객은 프론트 데스크(권한 서버)에 가서 자신의 신분(ID/PW)을 확인받고, "이 벨보이에게 내 짐을 옮기는 것을 허락합니다"라고 동의합니다. (사용자가 권한 서버에서 인증 및 권한 부여)
  3. 프론트 데스크의 지시서 발급: 프론트 데스크는 투숙객의 동의를 확인한 후, 벨보이에게 "이 투숙객의 짐을 옮기는 것을 허락한다"는 내용의 **임시 지시서 (Authorization Code)**를 발급해 줍니다. 이 지시서는 벨보이에게 직접 전달되지 않고, 투숙객을 통해 간접적으로 전달됩니다. (권한 서버가 Authorization Code를 클라이언트로 리다이렉션)
  4. 벨보이의 정식 요청: 벨보이는 이 임시 지시서를 들고 다시 프론트 데스크로 찾아갑니다. 이때 벨보이는 자신의 신분증(Client ID)과 더불어, 벨보이만 아는 비밀번호(Client Secret)를 함께 제시하며 "이 임시 지시서에 따라, 저에게 짐을 옮길 수 있는 **정식 카드키 (Access Token)**를 발급해 주세요"라고 요청합니다. (클라이언트가 Authorization Code와 Client Secret으로 권한 서버에 Access Token 요청)
  5. 프론트 데스크의 카드키 발급: 프론트 데스크는 임시 지시서와 벨보이의 신분, 비밀번호를 모두 확인한 후, 벨보이에게 "투숙객의 짐을 옮길 수 있는" **정식 카드키 (Access Token)**를 발급해 줍니다. 이 카드키는 특정 기간 동안만 유효하며, 특정 기능(짐 옮기기)만 가능합니다. 또한, 나중에 유효 기간이 만료될 경우 새 카드키를 받을 수 있는 **갱신용 카드키 (Refresh Token)**도 함께 발급해 줄 수 있습니다. (권한 서버가 Access Token 및 Refresh Token 발급)
  6. 벨보이의 업무 수행: 벨보이는 이 정식 카드키를 들고 객실 관리부(자원 서버)에 가서 "이 카드키로 투숙객의 방에 들어가 짐을 옮기겠습니다"라고 말합니다. (클라이언트가 Access Token으로 자원 서버에 자원 요청)
  7. 업무 처리: 객실 관리부는 벨보이의 카드키를 확인하고, 유효한 카드키이며 요청된 작업(짐 옮기기)이 허용된 범위 내에 있는지 확인한 후, 벨보이가 방에 접근하여 짐을 옮길 수 있도록 허락합니다. (자원 서버가 Access Token 검증 후 자원 제공)

이 비유에서 중요한 점은 투숙객이 자신의 방 키(ID/PW)를 벨보이에게 직접 주지 않았다는 것입니다. 오직 프론트 데스크를 통해서만 제한된 권한을 위임했습니다.

OpenID Connect의 추가

OIDC는 이 OAuth 2.0 흐름에 '인증' 정보를 추가합니다. 프론트 데스크(권한 서버)가 벨보이(클라이언트)에게 정식 카드키(Access Token)를 발급할 때, 투숙객의 신원 정보가 담긴 **신분증 (ID Token)**도 함께 발급해 줍니다. 이 신분증은 JWT (JSON Web Token) 형식으로 되어 있으며, 벨보이는 이 신분증을 통해 투숙객이 누구인지, 정확히 인증되었는지 확인할 수 있습니다.

핵심 다이어그램 (텍스트 설명):

Resource Owner (사용자)
      |
      | 1. 권한 요청 (클라이언트 앱에서 시작)
      V
Client (클라이언트 앱) --(Redirect)--> Authorization Server (권한 서버)
      ^                           |
      | 3. Authorization Code     | 2. 사용자 인증 및 동의
      | (Redirect)                V
      |<--(Back-channel)----------| 4. Authorization Code, Client Secret
      |                           |
      | 5. Access Token, ID Token |
      V                           V
Client (클라이언트 앱) <---------- Authorization Server (권한 서버)
      |
      | 6. Access Token으로 자원 요청
      V
Resource Server (자원 서버)
      |
      | 7. 보호된 자원 제공
      V
Client (클라이언트 앱)

3. 코드 예제: 실제 구현 맛보기

예제 1: Python으로 OAuth 2.0 클라이언트 구현 (Google OAuth 2.0)

requests_oauthlib 라이브러리를 사용하여 Google OAuth 2.0 흐름을 통해 Access Token을 얻는 기본적인 과정을 보여줍니다. 이 예제는 실제 웹 애플리케이션에서 사용하려면 웹 프레임워크와 결합해야 합니다.

import os
from requests_oauthlib import OAuth2Session
import json

# 환경 변수에서 클라이언트 정보 로드 (실제 서비스에서는 보안상 더 안전한 방법 사용)
# Google Cloud Console에서 OAuth 2.0 클라이언트 ID 생성 후 정보 입력
# REDIRECT_URI는 Google Cloud Console에 등록된 리다이렉트 URI와 일치해야 함
CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "YOUR_GOOGLE_CLIENT_ID")
CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "YOUR_GOOGLE_CLIENT_SECRET")
REDIRECT_URI = "http://localhost:5000/callback" # 개발 환경 예시

# Google OAuth 2.0 엔드포인트
AUTHORIZATION_BASE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"

# 요청할 스코프 (권한 범위) - OpenID Connect를 위한 'openid'와 이메일, 프로필 정보 요청
SCOPE = ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "openid"]

def get_authorization_url():
    """사용자 동의를 위한 인증 URL 생성"""
    google = OAuth2Session(CLIENT_ID, scope=SCOPE, redirect_uri=REDIRECT_URI)
    authorization_url, state = google.authorization_url(AUTHORIZATION_BASE_URL, access_type="offline", prompt="select_account")
    # 'state'는 CSRF 방지를 위해 사용되며, 콜백 시 동일한 값인지 검증해야 합니다.
    # 실제 앱에서는 세션 등에 state 값을 저장해두고 검증합니다.
    print(f"Please go to this URL and authorize: {authorization_url}")
    return authorization_url, state

def get_token_and_user_info(authorization_response_url, state):
    """Authorization Code를 사용하여 Access Token을 얻고 사용자 정보를 가져옵니다."""
    # 콜백 시 받은 state 값이 이전에 저장된 state와 일치하는지 검증 (CSRF 방지)
    # 실제 앱에서는 get_authorization_url에서 반환된 state를 세션에 저장해두고 비교합니다.
    # 여기서는 예시를 위해 간단히 처리합니다.
    
    google = OAuth2Session(CLIENT_ID, redirect_uri=REDIRECT_URI, state=state)
    
    try:
        # Authorization Code를 사용하여 Access Token 및 Refresh Token 교환
        # client_secret은 서버에서만 사용되어야 합니다.
        token = google.fetch_token(TOKEN_URL, client_secret=CLIENT_SECRET,
                                   authorization_response=authorization_response_url)
        
        print("\n--- Token Information ---")
        print(json.dumps(token, indent=2))

        # Access Token을 사용하여 사용자 정보 요청
        user_info_response = google.get(USERINFO_URL)
        user_info = user_info_response.json()
        
        print("\n--- User Information ---")
        print(json.dumps(user_info, indent=2, ensure_ascii=False))

        # OIDC ID Token 검증 (여기서는 단순히 디코딩만, 실제 검증은 다음 예제 참고)
        if 'id_token' in token:
            print(f"\nID Token (JWT): {token['id_token']}")
            # PyJWT 등으로 ID Token의 유효성을 검증해야 합니다.
            # (발급자, 대상, 만료 시간, 서명 등)
            
    except Exception as e:
        print(f"Error during token exchange or user info fetch: {e}")

if __name__ == "__main__":
    # 1. 사용자 동의 URL 생성
    auth_url, initial_state = get_authorization_url()
    
    # 개발자는 이 URL을 브라우저에 붙여넣어 구글 로그인 및 동의를 진행합니다.
    # 동의 후, 브라우저는 REDIRECT_URI로 이동하며, URL에 'code'와 'state' 파라미터가 포함됩니다.
    # 예: http://localhost:5000/callback?state=xxx&code=yyy
    
    # 2. 사용자가 동의 후 리다이렉트된 URL을 입력
    print("\nAfter authorizing, paste the full redirect URL here:")
    redirected_url = input("> ")
    
    # 3. 토큰 및 사용자 정보 가져오기
    # 실제 앱에서는 initial_state를 세션 등에서 가져와 redirected_url의 state와 비교해야 합니다.
    get_token_and_user_info(redirected_url, initial_state)

실행 방법:

  1. Google Cloud Console에서 새 프로젝트를 만들고, "OAuth 동의 화면"을 구성합니다.
  2. "사용자 인증 정보"에서 "OAuth 2.0 클라이언트 ID"를 생성합니다. 애플리케이션 유형을 "웹 애플리케이션"으로 설정하고, "승인된 리디렉션 URI"에 http://localhost:5000/callback을 추가합니다.
  3. 생성된 클라이언트 ID와 클라이언트 보안 비밀을 위의 CLIENT_ID, CLIENT_SECRET 변수에 직접 입력하거나 환경 변수로 설정합니다.
  4. 스크립트를 실행하고, 출력된 URL을 웹 브라우저에 붙여넣어 Google 로그인 및 권한 동의를 진행합니다.
  5. 동의 후 리다이렉트된 http://localhost:5000/callback?... 형태의 전체 URL을 스크립트의 input("> ") 프롬프트에 붙여넣고 엔터를 누르면 토큰 정보와 사용자 프로필이 출력됩니다.

예제 2: Python으로 OIDC ID Token 검증 (PyJWT)

OIDC의 id_token은 JWT 형식으로, 클라이언트 애플리케이션은 이 토큰을 받아 자체적으로 유효성을 검증해야 합니다.

import jwt
import requests
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

# 예시 ID Token (실제 ID Token으로 교체하여 테스트)
# 이 토큰은 실제 Google OAuth 2.0 흐름을 통해 얻은 ID Token이어야 합니다.
SAMPLE_ID_TOKEN = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFhMjM0NWY2Nzg5MDEyMzQ1Njc4OTAifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiJZT1VSX0dPT0dMRV9DTElFTlRfSUQiLCJhdWQiOiJZT1VSX0dPT0dMRV9DTElFTlRfSUQiLCJzdWIiOiIxMjM0NTY3ODkwMTIzNDU2Nzg5MDEiLCJoZCI6ImV4YW1wbGUuY29tIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF0X2hhc2giOiJwZzQxMjM0NTY3ODkwIiwiZXhwIjoxNzA0MDY3MjAwLCJpYXQiOjE3MDQwNjM2MDB9.EXAMPLE_SIGNATURE"

# Google의 JWKS (JSON Web Key Set) 엔드포인트 URL
# OIDC Provider마다 이 URL은 다릅니다.
GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"

# 클라이언트 ID (예제 1과 동일)
CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID" # 예제 1에서 사용한 클라이언트 ID와 일치해야 함

def get_public_key_from_jwks(kid):
    """JWKS 엔드포인트에서 특정 kid에 해당하는 공개 키를 가져옵니다."""
    response = requests.get(GOOGLE_JWKS_URL)
    response.raise_for_status()
    jwks = response.json()

    for key_data in jwks.get('keys', []):
        if key_data.get('kid') == kid:
            # JWK (JSON Web Key)를 PEM 형식의 공개 키로 변환
            # PyJWT는 JWK를 직접 처리할 수 있지만, 명시적인 이해를 위해 변환 과정을 보여줍니다.
            # 실제로는 jwt.decode 함수에 jwks를 직접 전달할 수 있습니다.
            
            # rsa_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_data))
            # return rsa_key
            
            # 보다 일반적인 PEM 형식으로 변환 (직접 파싱하는 경우)
            from jwcrypto.jwk import JWK
            jwk_obj = JWK.from_json(json.dumps(key_data))
            public_pem = jwk_obj.export_to_pem(private=False, password=None)
            
            return public_pem
    
    raise ValueError(f"Public key for kid '{kid}' not found in JWKS.")

def verify_id_token(id_token, client_id):
    """ID Token (JWT