React SPA에서 Keycloak 인증 구현하기

keycloak-js를 사용한 SSO 인증과 토큰 갱신 전략

Frontend
2026년 1월 18일

최근 모노레포 프로젝트를 진행하면서 여러 앱에서 공통으로 사용할 수 있는 인증 시스템이 필요했습니다.
Keycloak을 적용하면서 겪었던 시행착오와 최종적으로 정리한 구현 방법을 공유하려고 합니다.

특히 페이지 깜빡임 문제토큰 갱신 타이밍을 잡는 게 까다로웠는데요,
이 부분에 대해서도 자세히 다뤄보겠습니다.

Keycloak이란

Keycloak은 Red Hat에서 개발한 오픈소스 Identity and Access Management(IAM) 솔루션입니다.
SSO(Single Sign-On), OAuth 2.0, OpenID Connect 등을 지원하며, 기업 환경에서 많이 사용됩니다.

Keycloak을 선택한 이유는 크게 두 가지였습니다.

  1. SSO 지원: 한 번 로그인하면 모든 앱에서 인증 유지
  2. 중앙 집중 관리: 모노레포 환경에서 인증 로직을 한 곳에서 관리할 수 있음

주요 용어

처음에 문서를 읽을 때 용어가 헷갈려서 정리해봤습니다.

  • 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-js

Keycloak 인스턴스 관리

싱글톤 패턴으로 인스턴스를 관리했습니다.
여러 곳에서 같은 인스턴스를 참조해야 하기 때문입니다.

keycloak.ts
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 파일이 필요합니다.

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 서버와 통신하고, 결과를 부모 창에 전달하는 역할을 합니다.

구현 코드

초기화 함수

init.ts
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에서 사용

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 요청 시점에 토큰 유효성을 체크하는 방식으로 변경했습니다.

token-refresh.ts
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를 반환해서 중복 요청을 막았습니다.

http-client.ts
export const apiClient = createApiClient({
  baseUrl: import.meta.env.VITE_API_BASE_URL,
  getToken: getAccessToken,
  ensureValidToken,
  onUnauthorized: () => logout(),
})

Zustand로 상태 관리

keycloak-js 인스턴스는 모듈 싱글톤으로 관리하고,
UI에서 필요한 상태는 Zustand 스토어로 관리합니다.

auth-store.ts
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,
  })
}

컴포넌트에서 사용

ProfilePage.tsx
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 Lifespan270초 (4분 30초)토큰 유효 시간
SSO Session Idle1800초 (30분)비활성 시 세션 만료
SSO Session Max36000초 (10시간)최대 세션 유지

Access Token 유효 시간이 너무 길면 보안에 취약하고,
너무 짧으면 잦은 갱신으로 UX가 나빠집니다.
4~5분 정도가 일반적인 권장 값입니다.

정리

Keycloak을 React SPA에 연동하면서 중요하게 생각한 포인트입니다.

  1. 깜빡임 없는 인증: check-sso + Silent SSO 사용
  2. 보안: PKCE 적용, 메모리 토큰 저장
  3. 효율적인 토큰 갱신: On-demand 방식 + 중복 요청 방지
  4. SSO 지원: 모노레포 여러 앱에서 통합 인증

감사합니다.

참고 자료

Tags:
ReactAuthenticationKeycloak