OAuth 2.0과 OIDC의 차이점, 그리고 왜 이렇게 설계되었는지 은행 보안 시스템에 빗대어 알아봅니다.
OAuth 2.0과 OIDC의 차이점, 그리고 왜 이렇게 설계되었는지 은행 보안 시스템에 빗대어 알아봅니다.
Sangwoo Yang
@IGhost-P
여러분이 은행의 보안 시스템을 담당한다면, 고객이 거래를 하려고 할 때 어떤 절차를 거쳐야 할까요?
그 고객이 정말 계좌 소유자가 맞는지, 그리고 요청한 거래를 실행해도 되는지 확인해야 할 겁니다.
모든 고객의 얼굴과 거래 패턴을 기억할 수 있다면 좋겠지만, 현실적으로는 체계적인 인증 시스템이 필요하죠.
저는 현재 다니는 회사에서 '통합 인증-인가 서버'를 구축하면서 은행의 보안 시스템과 비슷한 고민을 하게 되었습니다.
시스템에 접근하려는 사용자가 누구인지, 그리고 접근해도 되는지에 대한 서비스를 구현해야 하는데 이를 위해서는 OAuth와 OIDC를 알아야 합니다.
"이 고객이 누구인가?"와 "이 고객이 무엇을 할 수 있는가?"
이 두 질문의 차이를 이해하는 것이 OAuth 2.0과 OIDC(OpenID Connect)를 제대로 이해하는 출발점입니다. 저를 포함한 몇몇 사람들은 OAuth 2.0을 인증(Authentication) 시스템으로 오해하지만, 실제로는 인가(Authorization) 프레임워크입니다.
OAuth가 인가 서비스인 이유는 OAuth 2.0에 대한 설계 철학을 먼저 살펴보면 이해가 됩니다.
OAuth 2.0의 명확한 정체성 (RFC 6749):
"The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service"
실제로 OAuth가 하는 역할은 "리소스에 대한 접근 권한을 안전하게 위임"하는 것입니다.
하지만 RFC에서 명시적으로 나오듯 OAuth 2.0은 사용자 신원 정보를 제공하지 않으며, 사용자 인증에 대한 정보도 제공할 수 없습니다.
// OAuth 2.0 거래승인토큰을 받았다고 가정
const accessToken = "abcd1234...";
// 이제 이 질문들에 답할 수 있을까?
// 1. 이 토큰의 고객이 누구인가? ❌
// 2. 이 고객이 언제 인증했는가? ❌
// 3. 이 고객의 연락처는 무엇인가? ❌
// 4. 이 고객이 실제로 본인인증했는가? ❌
즉 OAuth는 아래와 같이 관심사를 분리할 수 있습니다.
// OAuth 2.0의 핵심 관심사
const oauthPrinciples = {
목적: "거래 권한 위임",
질문: "이 앱이 이 고객의 계좌에 접근할 수 있는가?",
결과: "access_token (거래승인토큰)",
범위: "scope로 정의된 특정 권한들 (잔고조회, 송금 등)"
};
// OAuth 2.0이 관심 없는 것들
const notOAuthConcern = {
고객신원: "이 사람이 누구인지는 중요하지 않아",
인증방법: "어떻게 로그인했는지도 중요하지 않아",
고객정보: "개인정보는 내 관심사가 아니야"
};
어떻게 보면 조금 이상하게 보일지도 모릅니다. 고객의 신원을 굳이 파악하지 않는 모습은 마치, 은행 직원이 고객을 알아보지도 못하면서 "어? 누군지 모르겠지만 일단 거래 진행하세요~" 하고 있는 것 같거든요.
하지만 잠깐, 생각해보세요. 실제 은행에서는 어떻게 할까요?
고객: "잔고 조회하고 싶어요"
직원: "신분증과 통장 좀 보여주세요"
고객: "여기 제 신분증이요"
직원: "아 네, 김철수님이시네요. 어떤 거래하실 건가요?"
고객: "적금 잔고 확인할게요"
직원: "확인해보니 적금 조회 권한 있으시네요. 여기 거래승인토큰이요"
실제 은행처럼 본점 인증시스템(인증 서버)은 고객 신원 확인을 하고, 거래승인토큰(access_token)을 발급해주는데, 정작 각 부서(리소스 서버)는 그냥 "거래승인토큰 있나? 있으면 처리해드릴게요~" 이런 식이거든요.
// 전통적인 방식 (비효율적)
const traditionalWay = {
문제: "매번 각 부서에서 김철수가 맞는지 신원확인 해야함",
비용: "모든 부서마다 신원확인 시스템 필요",
보안위험: "여러 곳에 고객정보 노출"
};
// OAuth 방식 (효율적)
const oauthWay = {
해결: "한 번 신원확인 받고 거래승인토큰만 들고 다니면 됨",
비용: "중앙 인증 시스템 하나만 있으면 됨",
보안강화: "고객정보는 본점에만 있고, 각 부서는 토큰만 확인"
};
하지만 여전히 문제가 있어요. 만약 해커가 김철수의 거래 과정을 지켜보다가 중간에 가로채면 어떻게 될까요?

김철수가 모바일앱에서: "적금 잔고 조회해주세요"
앱이 본점에 요청: "김철수 고객 인증해주세요"
해커가 중간에 개입: "저 김철수예요! 토큰 주세요!"
본점이 해커에게 거래승인토큰 발급...
이런 '중간자 공격'을 막기 위해 OAuth 2.0에서는 PKCE(Proof Key for Code Exchange)라는 방식을 사용합니다. 이는 Daniel Fett, Ralf Küsters, Guido Schmitz의 OAuth 2.0 보안 분석에서 확인된 Mix-Up Attack 등의 문제점을 해결하기 위해 도입되었습니다.
은행: "임시 거래번호 말씀해주세요"
김철수: "TXN-김영숙19851225파랑-001이요!"
은행: "힌트 'TXN-ㄱㅇㅅ19851225파랑-001'와 맞네요! 거래승인토큰 드릴게요"
해커가 요청서를 훔쳐도:
해커: "음... 'TXN-ㄱㅇㅅ19851225파랑-001'? 김옥순? 김윤서? 김유신?" 😵
→ 원본을 모르니까 거래승인토큰을 받을 수 없다!
이것이 바로 PKCE의 작동 원리입니다!
// PKCE의 암호학적 설계
class PKCEImplementation {
constructor() {
// 128-bit 엔트로피의 암호학적으로 안전한 무작위 값
this.codeVerifier = this.generateSecureRandom(128);
// SHA256 해시: 일방향 함수의 수학적 특성 이용
this.codeChallenge = sha256(this.codeVerifier);
this.codeChallengeMethod = 'S256';
}
// 인가 요청 시: 해시된 값만 전송
buildAuthURL() {
return `${AUTH_URL}?` +
`client_id=${CLIENT_ID}&` +
`code_challenge=${this.codeChallenge}&` + // 해시만!
`code_challenge_method=S256`;
}
// 토큰 교환 시: 원본 값으로 증명
exchangeToken(authCode) {
return fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({
code: authCode,
code_verifier: this.codeVerifier, // 원본 공개로 소유 증명!
client_id: CLIENT_ID
})
});
}
}
왜 이게 안전할까요?
Preimage Resistance: 해시에서 원본을 역추적할 수 없음
Second Preimage Resistance: 동일한 해시를 만드는 다른 값을 찾을 수 없음
Collision Resistance: 서로 다른 두 입력이 같은 해시를 만들 수 없음
즉, 공격자가 code_challenge (거래번호 힌트)를 가로채도 code_verifier (원본 거래번호)를 알아낼 수 없어서 토큰을 탈취할 수 없습니다.
김철수씨는 이렇게 안전한 거래승인토큰을 받아서 각 부서에서 거래할 수 있게 되었습니다.
하지만 이런 문제가 생겼습니다. 각 부서에서는 이 고객이 누구인지 모르겠다는 겁니다. 거래승인토큰에는 아무것도 적혀 있지 않고 본점이 대충 쓴 "앞에 있는 고객"이라고만 적혀있다면… 이 토큰을 가지고 있는 고객이 누군지, 어떤 등급인지 어떻게 알 수 있을까요?
매번 다시 본점에 물어볼 수도 없고, 또 어떤 담당자에게 물어봐야 하는지 어떻게 알 수 있을까요? 그 절차는 부서마다 다 다를 겁니다.
개발자들도 똑같은 고민이었습니다.
// 개발자 A의 방식
app.get('/customer', async (req, res) => {
const customerInfo = await fetch('https://api.bank.com/customer', {
headers: { Authorization: `Bearer ${accessToken}` }
});
// 하지만 이 엔드포인트가 표준인가? 🤔
});
// 개발자 B의 방식
app.get('/profile', async (req, res) => {
const profile = await fetch('https://bank.com/v1/user-profile', {
headers: { Authorization: `Bearer ${accessToken}` }
});
// 또 다른 엔드포인트... 🤔
});
그렇다면 여러분들은 이 상황에서 어떻게 할 것인가요?
바로 해결책은 거래승인토큰의 정보를 표준화하는 것입니다. 마치 "거래승인토큰에 고객 이름과 등급을 적어주세요"처럼 말이죠.
이러한 요청이 바로 OpenID Connect, OIDC의 탄생입니다.
OpenID Foundation이 공식적으로 정의하는 OIDC의 역할:
"They define mechanisms to obtain and use Access Tokens to access resources but do not define standard methods to provide identity information. Notably, without profiling OAuth 2.0, it is incapable of providing information about the authentication of an End-User."
"identity layer on top of" — OAuth 2.0을 대체하는 것이 아니라 그 위에 추가된 레이어
"verify the identity" — OAuth 2.0이 제공하지 않았던 신원 확인 기능
"interoperable" — 각자 다른 방식으로 구현하던 것을 표준화
즉 OIDC는 OAuth 2.0 위에 표준화된 인증 레이어를 추가하는 것입니다. 대체가 아닌 확장입니다.
// OIDC의 핵심 질문
const oidcPrinciples = {
목적: "고객 인증 + OAuth 2.0 인가",
질문: "이 고객이 누구인가?" + "무엇을 할 수 있는가?",
결과: "id_token (고객신원증명) + access_token (거래승인토큰)",
표준화: "모든 은행이 같은 방식으로 구현"
};
이러한 OIDC를 이용한다면:
// 모든 OIDC 지원 은행에서 동일하게 작동
const oidcClient = new OIDCClient({
issuer: 'https://any-bank.com',
client_id: 'your-app-id'
});
// 표준화된 방식
const customerInfo = await oidcClient.getUserInfo(accessToken);
// 항상 동일한 형식: { sub, name, email, phone, grade, ... }
// 표준화된 검증
const idToken = await oidcClient.validateIdToken(idToken);
// JWT 형식으로 표준화됨
// 표준화된 엔드포인트
const config = await fetch('https://any-bank.com/.well-known/openid_configuration');
// 모든 OIDC 지원 은행이 이 경로 지원
또한 OpenID Connect는 Claims-based Identity 모델을 도입하여, JWT Token을 통해서 일관된 고객 정보를 payload로 받을 수 있게 됩니다.
// OIDC ID Token (JWT 형태)
const idToken = {
header: {
alg: "RS256",
typ: "JWT"
},
payload: {
sub: "customer123", // 고객 고유 ID
name: "김철수",
email: "[email protected]",
phone: "010-1234-5678",
grade: "VIP", // 고객 등급
aud: "your-app-id", // 토큰 수신자
iss: "https://bank.example.com", // 토큰 발급자
iat: 1234567890, // 발급 시간
exp: 1234571490, // 만료 시간
auth_time: 1234567880 // 실제 인증 시간
}
};
이렇게 하면 매번 중앙 고객정보시스템에 "김철수가 누구인지" 물어보지 않아도, 거래승인토큰 자체에 적힌 정보만으로 고객을 확인할 수 있게 되었고, 특히 분산된 여러 은행 부서에서의 정보 공유 문제를 해결해 줄 수 있습니다.
이로써 각 부서에서도 김철수씨가 누구인지, 어떤 등급의 고객인지 확인할 수 있게 되었고, 은행의 보안도 안전하게 유지될 수 있었습니다.

이전에 저는 그저 다른 서드파티 서비스(카카오, 구글)에서 제공하는 SSO 로그인, 또는 사설 DB에 사용자의 id, pw를 저장하며 클라이언트-서버 간 협약된 관계만으로도 인증-인가가 완료되는 게 아닌가? 라는 막연한 생각을 가지고 개발했었습니다.
이번 서비스 구현에도 그냥, 인증이면 인증 관련 서버, 인가면 인가 관련 서버를 구축해서 대충 만들면 되는 게 아닐까 싶었는데… 설계 철학을 이해하지 않고 개발하려 하니 어떤 게 정답인지 알 수가 없었습니다.
이번 기회에 OAuth와 OIDC를 공부하면서 "아, 그래서 이렇게 설계된 거구나!"라는 걸 많이 느꼈습니다. 단순히 기능만 구현하는 게 아니라, 왜 이런 방식으로 만들어졌는지 이해하니까 개발할 때도 훨씬 확신을 가지고 할 수 있었습니다.
물론 여기서 다 다루지 못한 내용들이 정말 많습니다. (특히 보안 취약점들은…) 더 자세한 내용은 아래 참고문서들을 한번씩 보시는 것도 추천드립니다.