OAuth 2.0과 OpenID Connect 마스터하기: 현대 웹 인증/인가의 핵심

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 현대 웹 서비스 개발에서 빼놓을 수 없는 중요한 개념인 OAuth 2.0과 OpenID Connect(OIDC)에 대해 이야기해보고자 합니다. 이 두 가지 기술은 우리가 매일 사용하는 '구글로 로그인', '카카오로 로그인'과 같은 소셜 로그인 기능의 기반이며, 안전하고 효율적인 API 연동의 핵심입니다. 많은 개발자들이 이 개념을 접하지만, 그 복잡성 때문에 종종 혼동하거나 잘못 적용하는 경우가 많습니다. 오늘 저와 함께 이 두 기술의 본질을 파헤쳐보고, 실제 프로젝트에 자신 있게 적용할 수 있는 지식을 얻어가시길 바랍니다.
1. 개념 소개: 왜 필요한가?

정의
- OAuth 2.0 (Open Authorization 2.0): 서드파티 애플리케이션이 사용자 이름과 비밀번호를 직접 알지 못하면서도, 리소스 소유자(사용자)의 승인 하에 특정 리소스(데이터)에 접근할 수 있도록 권한을 위임하는 개방형 표준 프레임워크입니다. 핵심은 **권한 위임(Delegated Authorization)**입니다.
- OpenID Connect (OIDC): OAuth 2.0 프레임워크 위에 구축된 단순한 **아이덴티티 레이어(Identity Layer)**입니다. 클라이언트가 Authorization Server(인증 서버)를 통해 최종 사용자의 아이덴티티를 확인할 수 있도록 해주며, 기본적인 프로필 정보를 얻을 수 있게 합니다. 핵심은 **사용자 인증(User Authentication)**입니다.
탄생 배경
과거에는 서드파티 애플리케이션(예: 사진 편집 앱)이 사용자의 구글 드라이브 사진에 접근하려면, 사용자로부터 구글 계정의 ID와 비밀번호를 직접 받아야 했습니다. 이는 심각한 보안 문제를 야기합니다.
- 보안 취약점: 서드파티 앱이 사용자의 모든 정보에 접근할 수 있게 되며, 앱이 해킹당하면 사용자 계정 전체가 위험해집니다.
- 사용자 불신: 민감한 정보를 알 수 없는 앱에 제공하기 꺼립니다.
- 권한 관리의 어려움: 특정 기능(사진 업로드)만을 허용하고 싶어도, 전체 계정 권한을 넘겨주어야 했습니다.
이러한 문제를 해결하기 위해 OAuth 1.0이 등장했고, 복잡성 및 확장성 문제를 개선한 OAuth 2.0이 2012년에 표준화되었습니다. OAuth 2.0은 사용자에게 "이 앱이 당신의 어떤 정보에 접근하는 것을 허용하시겠습니까?"라고 명확하게 물어볼 수 있게 함으로써, 특정 권한만 위임할 수 있는 길을 열었습니다.
하지만 OAuth 2.0은 인증 프로토콜이 아닌 인가 프레임워크였습니다. 즉, "누가 당신인지"를 확인하는 것이 아니라, "누구에게 어떤 권한을 줄지"를 다루는 것이었죠. 많은 개발자들이 OAuth 2.0을 인증 목적으로 오용하자, OAuth 2.0 위에 사용자 인증 기능을 추가한 OpenID Connect가 2014년에 등장하여 인증과 인가의 역할을 명확히 분리하고 표준화했습니다.
왜 중요한가?
- 보안 강화: 사용자 자격 증명(ID/비밀번호)을 서드파티 앱과 직접 공유할 필요가 없어 보안 위험을 크게 줄여줍니다.
- 사용자 경험 개선: '구글로 로그인', '카카오로 로그인' 등 한 번의 인증으로 여러 서비스에 접근(SSO: Single Sign-On)할 수 있어 사용자 편의성을 높입니다.
- API 생태계 활성화: 개발자들이 안전하게 다른 서비스의 API를 활용하여 혁신적인 애플리케이션을 만들 수 있도록 돕습니다.
- 표준화된 접근 방식: 다양한 서비스와 앱 간의 호환성을 보장하여 개발 비용을 절감하고 복잡성을 줄입니다.
2. 핵심 원리 설명: 발레파킹 비유와 동작 방식

OAuth 2.0과 OpenID Connect의 동작 원리를 이해하기 위해 '발레파킹' 비유를 들어보겠습니다.
등장인물:
- 리소스 소유자 (Resource Owner): 당신 (차 주인)
- 클라이언트 (Client): 발레파킹 앱 (주차를 요청하는 앱)
- 인증 서버 (Authorization Server): 주차 관리 데스크 (누가 차 주인인지 확인하고, 주차 요원에게 임시 키를 발급)
- 리소스 서버 (Resource Server): 당신의 차 (주차되어 있는 리소스)
OAuth 2.0 (인가) 동작 원리
- 클라이언트가 리소스 접근 요청 (주차 요청): 발레파킹 앱이 "당신 차를 주차하고 싶다"고 요청합니다.
- 리소스 소유자, 인증 서버로 리다이렉트: 발레파킹 앱은 당신을 주차 관리 데스크로 안내합니다.
- 리소스 소유자, 인증 서버에서 권한 부여: 당신은 주차 관리 데스크 직원에게 "이 발레파킹 앱이 내 차를 주차하고, 나중에 찾아갈 수 있도록 허락한다"고 말합니다 (동의). 이때, 당신은 차 키를 직접 앱에 주는 것이 아니라, 주차 관리 데스크에 맡기는 것입니다.
- 인증 서버, 클라이언트에 '인가 코드' 발급: 주차 관리 데스크는 발레파킹 앱에 "이 코드(인가 코드)를 가져가서 차 키(Access Token)로 교환해 오라"고 말합니다. 이 코드는 매우 짧은 시간 동안만 유효합니다.
- 클라이언트, 인가 코드로 'Access Token' 요청: 발레파킹 앱은 이 인가 코드를 가지고 다시 주차 관리 데스크로 돌아가 "이 코드로 차 키를 주세요"라고 요청합니다. 이때 앱은 자신의 신분(Client ID, Client Secret)도 함께 제시합니다.
- 인증 서버, 클라이언트에 'Access Token' 발급: 주차 관리 데스크는 앱의 신분을 확인하고, 차 키(Access Token)를 발급해줍니다. 이 차 키는 특정 기간 동안, 특정 작업(주차, 차 빼기)만을 위한 임시 키입니다. 트렁크를 열거나 다른 기능을 사용할 수는 없습니다.
- 클라이언트, Access Token으로 리소스 서버 접근: 발레파킹 앱은 이 Access Token(임시 차 키)을 가지고 당신의 차(리소스 서버)에 접근하여 주차하거나 찾아오는 작업을 수행합니다.
이 과정에서 당신의 실제 차 키(ID/비밀번호)는 발레파킹 앱에 노출되지 않습니다. 오직 주차 관리 데스크(인증 서버)만이 알고 있으며, 앱은 제한된 권한의 임시 키(Access Token)만 받습니다.
OpenID Connect (인증) 추가 원리
OIDC는 이 비유에 한 가지를 더합니다. 주차 관리 데스크(인증 서버)가 발레파킹 앱(클라이언트)에 Access Token과 함께 ID Token이라는 것을 추가로 발급합니다. 이 ID Token은 마치 "이 차 주인은 누구이고, 언제 주차를 허락했으며, 누구의 신분으로 확인되었는지"가 적힌 신분증과 같습니다. 발레파킹 앱은 이 신분증을 통해 "아, 이 사람이 진짜 차 주인이 맞구나" 하고 사용자를 인증할 수 있습니다. ID Token은 JWT(JSON Web Token) 형태로, 암호화되어 있어 위변조를 방지합니다.
핵심 다이어그램 (개념적)
+-----------------+ +------------------+
| Resource Owner | | Authorization |
| (사용자) | | Server (인증 서버) |
+-----------------+ +------------------+
^ ^
| (3. 권한 부여) | (4. 인가 코드 발급)
| |
v v
+-----------------+ <--- (2. 리다이렉트) --- +------------------+
| Client | | 웹 브라우저 |
| (클라이언트 앱) | --- (1. 접근 요청) ---> +------------------+
+-----------------+
^ ^
| (6. Access Token & ID Token 발급) | (5. 인가 코드로 토큰 요청)
| |
v v
+-----------------+ +------------------+
| Resource Server | <--- (7. Access Token으로 접근) --- | Client |
| (리소스 서버) | | (클라이언트 앱) |
+-----------------+ +------------------+
(다이어그램은 텍스트로 표현되었지만, 실제로는 화살표와 박스로 각 구성 요소와 흐름이 시각적으로 연결됩니다.)
구성 요소 요약:
- Resource Owner: 데이터의 실제 주인 (사용자).
- Client: 리소스 소유자의 데이터에 접근하고자 하는 애플리케이션.
- Authorization Server: Resource Owner를 인증하고, Client에게 Access Token을 발급하는 서버.
- Resource Server: Client가 접근하고자 하는 보호된 리소스를 호스팅하는 서버.
- Access Token: Client가 Resource Server에 접근할 때 사용하는 자격 증명. 제한된 권한과 유효 기간을 가집니다.
- ID Token: OpenID Connect에서 사용자의 신원을 증명하는 JWT.
3. 코드 예제: OAuth 2.0 클라이언트 구현 (Python & JavaScript)
여기서는 가장 일반적인 OAuth 2.0/OIDC 흐름인 "Authorization Code Grant"를 기반으로 클라이언트 앱이 어떻게 동작하는지 보여주는 예제입니다. 실제 서비스에서는 requests-oauthlib이나 oauthlib 같은 라이브러리를 사용하면 더 편리합니다.
예제 1: Python Flask 클라이언트 (백엔드)
이 Flask 앱은 OAuth 2.0 클라이언트 역할을 하며, 사용자를 인증 서버로 리다이렉트하고, 콜백을 받아 Access Token을 교환하는 과정을 시뮬레이션합니다.
# app.py
from flask import Flask, redirect, url_for, request, session, jsonify
import requests
import os
app = Flask(__name__)
app.secret_key = os.urandom(24) # 세션 관리를 위한 시크릿 키
# 설정 (실제 서비스에서는 환경 변수나 설정 파일에서 로드)
CLIENT_ID = "your_client_id" # 인증 서버에서 발급받은 클라이언트 ID
CLIENT_SECRET = "your_client_secret" # 인증 서버에서 발급받은 클라이언트 시크릿
AUTHORIZATION_BASE_URL = "https://authorization-server.com/oauth/authorize" # 인증 서버의 권한 부여 엔드포인트
TOKEN_URL = "https://authorization-server.com/oauth/token" # 인증 서버의 토큰 엔드포인트
RESOURCE_SERVER_URL = "https://resource-server.com/api/userinfo" # 리소스 서버의 API 엔드포인트
REDIRECT_URI = "http://localhost:5000/callback" # 인증 서버가 인가 코드를 보낼 클라이언트 콜백 URI
@app.route('/')
def index():
# 로그인 또는 OAuth 흐름 시작 버튼을 제공하는 페이지
return """
<h1>OAuth 2.0 / OIDC 데모 클라이언트</h1>
<p>이 예제는 Python Flask 앱이 어떻게 OAuth 2.0 클라이언트 역할을 하는지 보여줍니다.</p>
<a href="/login">인증 서버로 로그인</a>
"""
@app.route('/login')
def login():
# 1. 사용자를 인증 서버의 권한 부여 엔드포인트로 리다이렉트합니다.
# 'state' 파라미터는 CSRF 공격 방지를 위해 사용됩니다.
# 'scope'는 클라이언트가 요청하는 권한의 범위입니다 (예: email, profile, openid).
# 'openid' 스코프를 추가하면 OIDC 흐름이 활성화됩니다.
state = os.urandom(16).hex()
session['oauth_state'] = state # 세션에 state 저장
params = {
"client_id": CLIENT_ID,
"response_type": "code",
"scope": "openid email profile", # OIDC를 위한 openid 스코프 포함
"redirect_uri": REDIRECT_URI,
"state": state
}
auth_url = f"{AUTHORIZATION_BASE_URL}?" + "&".join([f"{k}={v}" for k, v in params.items()])
return redirect(auth_url)
@app.route('/callback')
def callback():
# 2. 인증 서버로부터 인가 코드와 state를 받습니다.
code = request.args.get('code')
state = request.args.get('state')
# CSRF 방지를 위해 세션에 저장된 state와 비교합니다.
if state != session.pop('oauth_state', None):
return "State mismatch. Possible CSRF attack.", 400
if not code:
return "Authorization code not received.", 400
# 3. 인가 코드를 사용하여 Access Token (및 ID Token)을 요청합니다.
token_data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET # Confidential Client이므로 secret 사용
}
try:
response = requests.post(TOKEN_URL, data=token_data)
response.raise_for_status() # HTTP 오류가 발생하면 예외 발생
tokens = response.json()
access_token = tokens.get('access_token')
id_token = tokens.get('id_token') # OIDC의 경우 ID Token도 함께 반환됩니다.
session['access_token'] = access_token
session['id_token'] = id_token
# Access Token을 사용하여 리소스 서버에서 사용자 정보를 가져옵니다.
user_info_headers = {"Authorization": f"Bearer {access_token}"}
user_info_response = requests.get(RESOURCE_SERVER_URL, headers=user_info_headers)
user_info_response.raise_for_status()
user_info = user_info_response.json()
return jsonify({
"message": "로그인 및 토큰 교환 성공!",
"user_info": user_info,
"access_token": access_token,
"id_token": id_token # ID Token 내용 디코딩은 별도 라이브러리 필요
})
except requests.exceptions.RequestException as e:
return f"토큰 교환 또는 사용자 정보 가져오기 실패: {e}", 500
if __name__ == '__main__':
app.run(debug=True)
실행 방법: python app.py 후 http://localhost:5000 접속
예제 2: JavaScript (프론트엔드) - 로그인 흐름 시작 및 콜백 처리
이 JavaScript 코드는 사용자가 "로그인" 버튼을 클릭했을 때 인증 서버로 리다이렉트하고, 인증 서버로부터 콜백을 받았을 때 URL에서 code와 state 파라미터를 추출하는 프론트엔드 로직을 보여줍니다. 실제 토큰 교환은 보안상 백엔드에서 처리하는 것이 일반적입니다 (Confidential Client).
// index.html (또는 React/Vue 컴포넌트 내부)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frontend OAuth Client</title>
</head>
<body>
<h1>프론트엔드 OAuth 클라이언트 데모</h1>
<button id="loginButton">구글로 로그인 (예시)</button>
<div id="result"></div>
<script>
// 설정 (실제 서비스에서는 환경 변수나 빌드 시 주입)
const CLIENT_ID = "your_frontend_client_id"; // 퍼블릭 클라이언트는 client_secret이 없습니다.
const AUTHORIZATION_BASE_URL = "https://accounts.google.com/o/oauth2/v2/auth"; // 구글 인증 서버 예시
const REDIRECT_URI = "http://localhost:3000/auth/callback"; // 프론트엔드 콜백 URI
const SCOPES = "openid email profile"; // 요청할 스코프
document.getElementById('loginButton').addEventListener('click', () => {
// 1. CSRF 방지를 위한 state 생성 및 저장 (실제 앱에서는 localStorage 등에 저장)
const state = generateRandomString(16);
localStorage.setItem('oauth_state', state);
// 2. 인증 서버의 권한 부여 엔드포인트로 리다이렉트
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
scope: SCOPES,
redirect_uri: REDIRECT_URI,
state: state
});
window.location.href = `${AUTHORIZATION_BASE_URL}?${params.toString()}`;
});
// 콜백 URL 처리
window.onload = () => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
if (code && state) {
const storedState = localStorage.getItem('oauth_state');
if (state !== storedState) {
document.getElementById('result').innerText = "State mismatch. Possible CSRF attack.";
return;
}
localStorage.removeItem('oauth_state'); // 사용 후 삭제
document.getElementById('result').innerText = `
인증 코드 수신 성공!
Code: ${code}
State: ${state}
(이 코드를 백엔드로 보내 토큰을 교환해야 합니다.)
`;
// 실제 앱에서는 이 'code'를 백엔드 서버로 전송하여 Access Token 및 ID Token을 교환합니다.
// fetch('/api/token-exchange', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ code: code, state: state, redirect_uri: REDIRECT_URI })
// })
// .then(response => response.json())
// .then(data => {
// console.log('Tokens received from backend:', data);
// // 로그인 성공 처리
// })
// .catch(error => console.error('Token exchange failed:', error));
} else if (urlParams.get('error')) {
document.getElementById('result').innerText = `인증 실패: ${urlParams.get('error_description') || urlParams.get('error')}`;
}
};
function generateRandomString(length) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
</script>
</body>
</html>
실행 방법: 이 HTML 파일을 브라우저에서 열고, http://localhost:3000/auth/callback으로 리다이렉트되도록 호스팅 환경을 설정합니다. (예: live-server 사용)
4. 실무 적용 사례
OAuth 2.0과 OpenID Connect는 현대 소프트웨어 개발에서 광범위하게 사용됩니다.
- 소셜 로그인 (Social Login): '구글로 로그인', '카카오로 로그인', '네이버로 로그인', 'Facebook으로 로그인' 등은 모두 OpenID Connect를 기반으로 합니다. 사용자는 기존 소셜 미디어 계정으로 새로운 서비스에 간편하게 가입하고 로그인할 수 있습니다.
- 외부 서비스 연동:
- Slack 봇이 Google Drive에 접근: Slack 앱이 Google Drive API를 사용하여 특정 폴더에 파일을 업로드하거나 검색하는 기능을 구현할 때, 사용자의 Google 계정 비밀번호를 직접 받는 대신 OAuth 2.0을 통해 Google Drive 접근 권한을 위임받습니다.
- Zapier와 같은 자동화 도구: 다양한 SaaS (Software as a Service) 서비스 간의 연동(예: Gmail의 새 이메일을 Trello 카드로 자동 생성)에 OAuth 2.0을 사용하여 각 서비스에 대한 제한된 접근 권한을 안전하게 획득합니다.
- API 보안: RESTful API를 제공하는 서비스에서 클라이언트 앱이 해당 API에 접근할 때, OAuth 2.0 Access Token을 사용하여 API 요청을 인증/인가합니다. 이를 통해 API 소비자는 사용자 인증 정보를 직접 관리할 필요 없이 안전하게 API를 사용할 수 있습니다.
- 마이크로서비스 아키텍처: 내부 마이크로서비스 간의 통신에서도 OAuth 2.0 토큰 기반의 인증/인가를 사용하여 각 서비스가 서로에게 접근할 수 있는 권한을 관리합니다. (물론 이 경우 Service Mesh나 API Gateway 등 다른 기술과 결합하여 더 효율적으로 관리되기도 합니다.)
5. 자주 하는 실수와 해결법
OAuth 2.0과 OIDC는 강력하지만, 잘못 사용하면 심각한 보안 문제를 야기할 수 있습니다.
-
Access Token과 ID Token 혼동:
- 실수: "둘 다 토큰이니 아무거나 써도 되겠지?" Access Token으로 사용자를 인증하거나, ID Token으로 리소스 서버에 접근하려고 합니다.
- 해결법:
- Access Token: **인가(Authorization)**의 목적입니다. 리소스 서버에 접근할 때 "이 클라이언트가 이 리소스에 접근할 권한이 있는가?"를 확인하는 데 사용됩니다.
- ID Token: **인증(Authentication)**의 목적입니다. 클라이언트가 "로그인한 사용자가 누구인가?"를 확인하고 사용자의 기본 프로필 정보를 얻는 데 사용됩니다. ID Token은 클라이언트가 직접 유효성을 검증(서명, 만료 시간 등)해야 합니다.
- 기억하세요: Access Token은 Resource Server를 위한 것이고, ID Token은 Client를 위한 것입니다.
-
Client Secret 노출:
- 실수: 클라이언트 시크릿(Client Secret)을 프론트엔드 코드나 모바일 앱에 하드코딩합니다.
- 해결법:
Client Secret은 기밀 클라이언트(Confidential Client), 즉 백엔드 서버처럼 안전하게 비밀을 저장할 수 있는 애플리케이션에서만 사용해야 합니다.- 프론트엔드 앱(SPA, 모바일 앱)은 **공개 클라이언트(Public Client)**이며,
Client Secret을 사용해서는 안 됩니다. 대신, **PKCE (Proof Key for Code Exchange)**라는 확장 기능을 사용하여 인가 코드 가로채기 공격을 방지해야 합니다.response_type=code와PKCE를 사용하면 공개 클라이언트도 안전하게Authorization Code Grant를 사용할 수 있습니다.
-
State 파라미터 미사용 또는 부적절한 사용:
