React SPA에서 Keycloak 인증 구현하기
keycloak-js를 사용한 SSO 인증과 토큰 갱신 전략
최근 모노레포 프로젝트를 진행하면서 여러 앱에서 공통으로 사용할 수 있는 인증 시스템이 필요했습니다.
Keycloak을 적용하면서 겪었던 시행착오와 최종적으로 정리한 구현 방법을 공유하려고 합니다.
특히 페이지 깜빡임 문제와 토큰 갱신 타이밍을 잡는 게 까다로웠는데요,
이 부분에 대해서도 자세히 다뤄보겠습니다.
Keycloak이란
Keycloak은 Red Hat에서 개발한 오픈소스 Identity and Access Management(IAM) 솔루션입니다.
SSO(Single Sign-On), OAuth 2.0, OpenID Connect 등을 지원하며, 기업 환경에서 많이 사용됩니다.
Keycloak을 선택한 이유는 크게 두 가지였습니다.
- SSO 지원: 한 번 로그인하면 모든 앱에서 인증 유지
- 중앙 집중 관리: 모노레포 환경에서 인증 로직을 한 곳에서 관리할 수 있음
주요 용어
처음에 문서를 읽을 때 용어가 헷갈려서 정리해봤습니다.
- Realm: 사용자, 역할, 클라이언트 등을 관리하는 독립된 공간. 쉽게 말해 "프로젝트" 같은 개념입니다.
- Client: 인증을 요청하는 애플리케이션 (React 앱)
- Role: 사용자에게 부여되는 권한. ADMIN, USER 같은 것들입니다.
인증 플로우: Authorization Code Flow + PKCE
SPA에서는 Authorization Code Flow with PKCE 방식을 사용합니다.
PKCE(Proof Key for Code Exchange)는 Authorization Code가 탈취되더라도
토큰을 발급받을 수 없도록 보호하는 보안 확장 기능입니다.
과거에는 Implicit Flow를 많이 사용했지만, 토큰이 URL에 노출되는 보안 문제가 있어
현재는 PKCE를 적용한 Authorization Code Flow가 표준으로 자리잡았습니다.
keycloak-js 설치
React에서 Keycloak을 연동할 때는 공식 어댑터를 사용합니다.
npm install keycloak-jsKeycloak 인스턴스 관리
싱글톤 패턴으로 인스턴스를 관리했습니다.
여러 곳에서 같은 인스턴스를 참조해야 하기 때문입니다.
import Keycloak from 'keycloak-js'
let keycloakInstance: Keycloak | null = null
export function createKeycloak(config: KeycloakConfig): Keycloak {
keycloakInstance = new Keycloak({
url: config.url,
realm: config.realm,
clientId: config.clientId,
})
return keycloakInstance
}
export function getKeycloak(): Keycloak {
if (!keycloakInstance) {
throw new Error('Keycloak이 초기화되지 않았습니다')
}
return keycloakInstance
}초기화 전략: check-sso vs login-required
keycloak-js는 두 가지 초기화 모드를 제공합니다.
여기서부터가 꽤 고민했던 부분입니다.
login-required
await keycloak.init({ onLoad: 'login-required' })- 앱 시작 시 무조건 로그인 페이지로 이동
- 단점: 페이지 리다이렉션으로 인한 깜빡임 발생
사용자 입장에서 앱에 들어갔는데 갑자기 다른 페이지로 갔다가 돌아오니까
UX가 좋지 않았습니다.
check-sso
await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
})- iframe을 통해 백그라운드에서 SSO 세션 확인
- 이미 로그인된 경우 깜빡임 없이 바로 앱 진입
- 로그인되지 않은 경우에만 로그인 페이지로 이동
깜빡임 없는 UX를 위해 check-sso 방식을 선택했습니다.
check-sso를 사용하려면 public 폴더에 silent-check-sso.html 파일이 필요합니다.
<!doctype html>
<html>
<head>
<title>Silent SSO Check</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin)
</script>
</body>
</html>파일 내용은 이게 전부입니다.
iframe 내에서 Keycloak 서버와 통신하고, 결과를 부모 창에 전달하는 역할을 합니다.
구현 코드
초기화 함수
export async function initKeycloak(config: KeycloakConfig): Promise<boolean> {
const keycloak = createKeycloak(config)
try {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: config.silentCheckSsoRedirectUri,
checkLoginIframe: false,
pkceMethod: 'S256',
})
if (authenticated) {
setupTokenRefresh(keycloak)
}
return authenticated
} catch (error) {
console.error('Keycloak 초기화 실패:', error)
throw error
}
}
export function login(redirectUri?: string): void {
const keycloak = getKeycloak()
keycloak.login({
redirectUri: redirectUri || window.location.href,
})
}
export function logout(redirectUri?: string): void {
const keycloak = getKeycloak()
keycloak.logout({
redirectUri: redirectUri || window.location.origin,
})
}checkLoginIframe: false를 설정하면 주기적인 세션 체크 iframe이 비활성화됩니다.
true로 두면 네트워크 탭이 지저분해지는 문제가 있었습니다.
main.tsx에서 사용
import { initKeycloak, login, syncAuthState } from '@repo/libs/auth'
import { createRoot } from 'react-dom/client'
import App from './App'
async function bootstrap() {
try {
const authenticated = await initKeycloak({
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
})
if (!authenticated) {
login()
return
}
syncAuthState()
createRoot(document.getElementById('root')!).render(<App />)
} catch (error) {
console.error('앱 초기화 실패:', error)
}
}
bootstrap()포인트는 인증이 완료된 후에만 React를 렌더링한다는 것입니다.
이렇게 하면 로그인되지 않은 상태에서 앱이 잠깐 보이는 문제를 방지할 수 있습니다.
토큰 갱신 전략
Access Token은 보안상 짧은 유효 시간을 가집니다.
4분 30초로 설정하고, 만료 30초 전에 갱신을 시도합니다.
setInterval 방식의 문제점
처음에는 setInterval로 주기적으로 갱신하려고 했습니다.
// 권장하지 않음
setInterval(() => {
keycloak.updateToken(30)
}, 60000)그런데 이 방식은 문제가 있습니다.
- 사용자가 앱을 사용하지 않아도 계속 갱신 요청 발생
- 탭이 백그라운드에 있으면 타이머가 정확하지 않음
On-demand 방식
그래서 API 요청 시점에 토큰 유효성을 체크하는 방식으로 변경했습니다.
const MIN_TOKEN_VALIDITY = 30
let refreshPromise: Promise<boolean> | null = null
export async function ensureValidToken(): Promise<void> {
const keycloak = getKeycloak()
if (keycloak.isTokenExpired(MIN_TOKEN_VALIDITY)) {
await refreshToken(keycloak)
}
}
export async function refreshToken(keycloak: Keycloak): Promise<boolean> {
if (refreshPromise) {
return refreshPromise
}
refreshPromise = (async () => {
try {
const refreshed = await keycloak.updateToken(MIN_TOKEN_VALIDITY)
if (refreshed) {
syncAuthState()
}
return true
} catch {
console.error('[Auth] 토큰 갱신 실패, 재로그인 필요')
keycloak.login()
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}동시 요청 처리
여기서 refreshPromise 변수가 핵심입니다.
만약 API 요청이 동시에 여러 개 들어오면, 토큰 갱신도 여러 번 호출될 수 있습니다.
그래서 갱신이 진행 중이면 같은 Promise를 반환해서 중복 요청을 막았습니다.
export const apiClient = createApiClient({
baseUrl: import.meta.env.VITE_API_BASE_URL,
getToken: getAccessToken,
ensureValidToken,
onUnauthorized: () => logout(),
})Zustand로 상태 관리
keycloak-js 인스턴스는 모듈 싱글톤으로 관리하고,
UI에서 필요한 상태는 Zustand 스토어로 관리합니다.
import { create } from 'zustand'
export const useAuthStore = create<AuthState>(() => ({
isAuthenticated: false,
isInitialized: false,
user: null,
token: null,
}))
export function syncAuthState(): void {
const keycloak = getKeycloak()
useAuthStore.setState({
isAuthenticated: keycloak.authenticated ?? false,
isInitialized: true,
user: keycloak.tokenParsed ? parseTokenToUser(keycloak.tokenParsed) : null,
token: keycloak.token ?? null,
})
}컴포넌트에서 사용
import { useAuth } from '@repo/libs/auth'
function ProfilePage() {
const { user, logout, hasRole } = useAuth()
return (
<div>
<p>{user?.name}님 환영합니다</p>
{hasRole('ADMIN') && <button>관리자 메뉴</button>}
<button onClick={() => logout()}>로그아웃</button>
</div>
)
}토큰 저장 위치
keycloak-js는 토큰을 메모리에 저장합니다.
- localStorage에 저장하지 않음 → XSS 공격에 안전
- 새로고침 시 토큰이 사라짐 → Silent SSO로 자동 복구
처음에는 새로고침하면 토큰이 없어지는데 괜찮을지 걱정했는데,
check-sso가 자동으로 처리해주기 때문에 사용자는 인지하지 못합니다.
Keycloak 서버 설정
| 설정 | 값 | 설명 |
|---|---|---|
| Access Token Lifespan | 270초 (4분 30초) | 토큰 유효 시간 |
| SSO Session Idle | 1800초 (30분) | 비활성 시 세션 만료 |
| SSO Session Max | 36000초 (10시간) | 최대 세션 유지 |
Access Token 유효 시간이 너무 길면 보안에 취약하고,
너무 짧으면 잦은 갱신으로 UX가 나빠집니다.
4~5분 정도가 일반적인 권장 값입니다.
정리
Keycloak을 React SPA에 연동하면서 중요하게 생각한 포인트입니다.
- 깜빡임 없는 인증: check-sso + Silent SSO 사용
- 보안: PKCE 적용, 메모리 토큰 저장
- 효율적인 토큰 갱신: On-demand 방식 + 중복 요청 방지
- SSO 지원: 모노레포 여러 앱에서 통합 인증
감사합니다.