NDC Oslo 2025 - 로그아웃 버튼이 이렇게 어려웠나? OpenID Connect 심화 패턴 탐구
여기 아주 좋은 유튜브 강연이 있는데요, OpenID Connect의 기본을 넘어 실제 애플리케이션에서 마주하게 되는 다양한 아키텍처 패턴들을 다루고 있어서 이 강연의 핵심만 전체적으로 살펴볼까 합니다.
발표자인 Anders는 스웨덴에서 컨설팅 회사를 운영하며 OpenID Connect 관련 오픈소스를 개발하고 있다고 하는데요, 그의 풍부한 경험을 바탕으로 로그인 버튼 너머의 세계, 특히 까다롭기로 소문난 '로그아웃 버튼'의 세계로 함께 떠나보시죠.
OpenID Connect 기본 흐름 복습
먼저 아주 빠르게 기본 흐름부터 짚고 넘어가 볼게요.
사용자가 클라이언트 애플리케이션에 접근하면, 애플리케이션은 사용자를 OpenID Provider(OP)로 리디렉션시킵니다.
그러면 OP는 자신만의 방식으로 사용자를 인증하는데요, 이게 바로 OpenID Connect의 핵심이죠.
클라이언트 앱은 복잡한 인증 과정을 OP에게 위임하고, OP가 어떻게 사용자를 인증하는지는 신경 쓸 필요가 없어지는 겁니다.
인증이 성공하면 OP는 자신의 도메인에 세션 쿠키를 생성하고, 사용자를 다시 클라이언트 앱으로 리디렉션시켜 줍니다.
이때 ID 토큰 같은 정보를 함께 전달하고, 클라이언트 앱은 이 정보를 검증한 뒤 자신의 세션 쿠키를 생성해서 로그인을 완료하는 구조입니다.
패턴 1: 싱글 사인온 (Single Sign-On, SSO)
자, 이제 클라이언트 앱이 하나 더 생겼다고 해봅시다.
사용자가 두 번째 앱에 접근하면, 역시 OP로 리디렉션되는데요.
그런데 OP에는 이미 첫 번째 앱에서 로그인할 때 만들어 둔 세션 쿠키가 있잖아요?
그래서 OP는 사용자에게 다시 로그인하라고 요구할 필요 없이, "아, 이 사용자 이미 로그인했구나" 하고 바로 인증을 완료한 뒤 두 번째 앱으로 돌려보내 줍니다.
이게 바로 우리가 흔히 아는 싱글 사인온(SSO)이죠.
사용자 입장에서는 한 번만 로그인하면 여러 서비스를 비밀번호 입력 없이 넘나들 수 있으니 정말 편리한 경험입니다.
로그아웃의 함정: 싱글 로그아웃 (Single Logout)
SSO가 편리한 만큼, 로그아웃은 훨씬 더 복잡해지는데요.
만약 첫 번째 앱에서 로그아웃 버튼을 눌렀다고 해봅시다.
단순히 첫 번째 앱의 세션 쿠키만 지우면 어떻게 될까요?
사용자가 다시 보안 페이지에 접근하는 순간, OP에 남아있는 세션 쿠키 때문에 자기도 모르게 바로 다시 로그인이 되어버립니다.
이건 사용자가 의도한 동작이 아니죠.
그래서 SSO 환경에서는 반드시 '싱글 로그아웃(Single Logout)'을 구현해야 합니다.
즉, 한 앱에서 로그아웃하면 연결된 모든 앱과 OP에서 동시에 로그아웃 처리가 되어야 하는 거죠.
프론트 채널 로그아웃 (Front-Channel Logout)
클라이언트 앱에서 OP의 쿠키를 직접 지울 수는 없으니, 사용자의 브라우저를 OP의 'end session' 엔드포인트로 리디렉션시켜야 하는데요.
그러면 OP는 자신의 쿠키를 지울 수 있게 됩니다.
하지만 그냥 GET 요청으로 이 엔드포인트를 호출하면, OP 입장에서는 이게 진짜 로그아웃 요청인지 CSRF 공격인지 알 수가 없거든요.
그래서 보통 로그인할 때 받았던 'ID 토큰'을 id_token_hint
라는 파라미터에 담아서 함께 보내줍니다.
OP는 이 힌트를 보고 "아, 이 요청은 유효한 세션에 참여했던 클라이언트가 보낸 거구나"라고 판단하고, 추가 확인 절차 없이 로그아웃을 진행할 수 있죠.
여기에 post_logout_redirect_uri
를 추가하면 로그아웃 후 사용자를 원래 클라이언트 앱으로 다시 돌려보내 줄 수도 있습니다.
// 클라이언트 앱의 로그아웃 로직 예시
public async Task<IActionResult> Logout()
{
// 로컬 세션에서 로그아웃
await HttpContext.SignOutAsync();
// ID 토큰 가져오기
var idToken = await HttpContext.GetTokenAsync("id_token");
// OP의 end_session 엔드포인트로 리디렉션
var logoutUrl = $"https://op.example.com/connect/endsession?id_token_hint={idToken}&post_logout_redirect_uri=https://app1.example.com";
return Redirect(logoutUrl);
}
이제 한 앱에서 로그아웃하면 OP에서도 로그아웃이 되는데요.
문제는 아직 두 번째 앱에는 세션이 그대로 남아있다는 겁니다.
이 문제를 해결하기 위해 OP의 로그아웃 완료 페이지에는 비밀이 숨겨져 있는데요.
바로 보이지 않는 <iframe>
을 사용해서 세션에 참여한 다른 모든 클라이언트 앱들의 로그아웃 엔드포인트를 호출하는 겁니다.
브라우저가 이 <iframe>
들을 로드하면서 각 클라이언트 앱에 요청을 보내고, 해당 요청을 받은 클라이언트 앱들은 자신의 세션을 파기하게 되죠.
이처럼 브라우저를 매개로 통신하는 방식을 '프론트 채널 로그아웃'이라고 부릅니다.
하지만 이 방식에는 치명적인 약점이 있는데요.
사파리처럼 서드파티 쿠키를 차단하는 브라우저에서는 <iframe>
을 통한 요청에 쿠키가 포함되지 않아 정상적으로 로그아웃이 처리되지 않을 수 있습니다.
로그아웃 실패는 로그인 실패보다 훨씬 더 심각한 보안 위협이 될 수 있기 때문에, 더 안정적인 방법이 필요하죠.
백 채널 로그아웃 (Back-Channel Logout)
그래서 등장한 것이 바로 '백 채널 로그아웃'입니다.
이 방식은 OP가 브라우저를 거치지 않고, 서버 대 서버 통신으로 직접 다른 클라이언트 앱들에게 "이 세션 ID를 가진 사용자를 로그아웃시켜라"라는 알림을 보내는 건데요.
알림을 받은 클라이언트 앱은 해당 세션 ID를 '로그아웃된 세션 목록'에 기록해 둡니다.
그리고 이후에 해당 세션 ID를 가진 쿠키로 요청이 들어오면, "아, 이 세션은 이미 로그아웃되었구나" 하고 요청을 거부하고 세션을 파기하는 거죠.
이 방식은 브라우저의 쿠키 정책에 영향을 받지 않기 때문에 훨씬 더 안정적입니다.
패턴 2: 연합 게이트웨이 (Federation Gateway)
이제 우리 서비스가 점점 커져서 Entra ID(구 Azure AD), 구글, 심지어는 파트너사의 자체 OP까지 여러 외부 ID Provider와 연동해야 하는 상황이 왔다고 해봅시다.
클라이언트 앱이 이 모든 OP와 각각 연동하는 건 끔찍한 일이죠.
설정 정보도 맞춰야 하고, 시크릿 키도 주기적으로 갱신해야 하고, 관리 포인트가 너무 많아집니다.
이럴 때 사용하는 것이 바로 '연합 게이트웨이' 패턴입니다.
우리 회사 전용의 중앙 OP를 하나 두고, 모든 클라이언트 앱은 이 게이트웨이하고만 통신하는 거죠.
그리고 외부 OP들과의 복잡한 연동은 이 게이트웨이가 전담해서 처리합니다.
이렇게 하면 클라이언트 앱은 아주 단순하게 우리 내부 게이트웨이만 바라보면 되니 개발과 관리가 훨씬 쉬워지고요.
외부에서 들어오는 사용자 신원 정보를 우리 시스템 표준에 맞게 정제하거나 검증하는 로직을 게이트웨이 한 곳에 집중시킬 수 있다는 장점도 있습니다.
연합 로그아웃의 딜레마
그런데 이 연합 게이트웨이 환경에서 로그아웃은 더 큰 딜레마를 안겨주는데요.
사용자가 우리 회사 경비 처리 앱에서 로그아웃을 눌렀을 때, 연합된 Entra ID까지 모두 로그아웃시켜서 SharePoint에서도 로그아웃되게 만들어야 할까요?
아마 대부분의 사용자는 경비 처리 앱에서만 로그아웃되기를 원할 겁니다.
그래서 보통은 외부 OP까지 연쇄적으로 로그아웃시키지는 않는데요.
하지만 만약 사용자가 잠시 빌린 공용 컴퓨터에서 경비 처리를 하고 로그아웃했다면 어떨까요?
우리 시스템에서는 로그아웃되었지만, Entra ID에는 세션이 그대로 남아있기 때문에 다른 사람이 그 컴퓨터에서 메일이나 문서에 접근할 수 있는 심각한 보안 문제가 발생할 수 있습니다.
이건 정말 정답이 없는 문제라서, 서비스의 성격과 사용자 환경을 고려해서 신중하게 정책을 결정해야 합니다.
사용자에게 "모든 곳에서 로그아웃하시겠습니까?"라고 물어보는 것도 하나의 방법이 될 수 있겠네요.
패턴 3: 멀티테넌시 (Multi-tenancy)
마지막으로 여러 고객사(테넌트)를 동시에 서비스하는 SaaS 환경에서의 인증 패턴입니다.
가장 간단한 방법은 사용자 DB에 tenant_id
같은 컬럼을 추가해서 사용자가 어느 테넌트 소속인지 구분하는 건데요.
로그인 시 ID 토큰에 tenant
클레임을 담아주면, 클라이언트 앱은 이 클레임을 보고 해당 테넌트의 데이터에만 접근하도록 제어할 수 있습니다.
이 방식은 구현이 간단하고, 여러 테넌트의 데이터를 동시에 봐야 하는 관리자나 파트너 같은 역할을 지원하기에 용이하다는 장점이 있죠.
하지만 데이터 격리 수준이 높지는 않은데요.
만약 고객사가 데이터의 물리적 격리를 요구하거나, 특정 테넌트의 데이터를 완전히 삭제하고 증명해야 하는 요구사항이 있다면 다른 접근이 필요합니다.
바로 각 테넌트마다 별도의 OP 인스턴스(혹은 논리적 인스턴스)와 데이터베이스를 할당하는 방식입니다.
이 구조에서는 테넌트 정보가 ID 토큰의 tenant
클레임이 아닌, 토큰을 발급한 '발급자(issuer)' 자체에 의해 구분됩니다.
https://tenant-a.idp.com
에서 발급된 토큰과 https://tenant-b.idp.com
에서 발급된 토큰은 완전히 다른 것으로 취급되는 거죠.
이 방식은 완벽한 데이터 격리를 제공하고, 테넌트별로 다른 디자인(화이트 라벨링)을 적용하기에도 유리합니다.
물론 어떤 방식이 절대적으로 좋은 것은 아니고, 서비스의 요구사항에 따라 적절한 패턴을 선택하는 것이 중요합니다.
정리하며
OpenID Connect는 단순히 로그인 버튼을 만드는 기술을 넘어, 복잡하고 다양한 인증 시나리오를 해결할 수 있는 강력한 프레임워크라는 걸 알 수 있었네요.
특히 강연 내내 발표자의 데모가 계속 말썽을 부렸던 것처럼, '로그아웃'은 정말 사소한 설정 하나로도 쉽게 망가질 수 있는 아주 까다로운 기능입니다.
오늘 살펴본 여러 패턴들을 통해, 여러분의 서비스에 더 안전하고 견고한 인증 아키텍처를 구축하는 데 도움이 되었으면 좋겠습니다.