React와 Keycloak 연동 인증 가이드

이번 글에서는 React 앱과 Keycloak을 연동하여 인증을 구현합니다. Keycloak은 OAuth2/OpenID Connect 기반의 인증·인가 솔루션으로, SSO 로그인과 토큰 관리, API 접근 제어 등과 같은 기능 제공합니다.


1. 설치 환경 구성

React와 Keycloak 연동 개발을 위해 구성한 설치 환경 정보입니다.

  • Kubernetes v1.30
  • Keycloak Server 25.0.4
  • Docker Desktop 4.41.2 (macOS)
  • Node.js v22.16.0

Keycloak 설치 및 React 프로젝트 구성방식은 아래 블로그를 참고하세요.

이 글을 작성하면서 사용한 코드는 GitHub의 react-keycloak-demo 저장소를 내려받아 확인할 수 있습니다.


2. React 프로젝트 구성

프로젝트 구성은 Vite + React + TailwindCSS 프로젝트 구성 가이드와 동일하게 진행합니다. 다만, 여기서는 public/env.js 대신에 .env를 사용하여 환경변수를 구성하면서 index.html을 변경하지 않습니다.

Vite를 사용하여 프로젝트 생성 후, Keycloak 연동하기 위해 변경하고 추가한 파일 구성은 다음과 같습니다.

.
├── .env               
├── public                      // 정적 파일 폴더
│   └── silent-check-sso.html   // Keycloak SSO용 silent check HTML
└── src                         // 소스 코드 폴더
    ├── App.jsx                 // React 루트 컴포넌트
    ├── authManager.js          // Keycloak 인증 관리 모듈
    ├── axiosConfig.js          // Axios 공통 설정
    ├── index.css               // 전역 CSS 스타일
    ├── main.tsx                // React 진입점
    └── vite-env.d.ts           // Vite TypeScript 환경 타입 정의

Keycloak 클라이언트 생성

Keycloak Admin Console에서 cnap realm으로 이동하여 react 클라이언트를 생성합니다.

  • Client ID: react
  • Authentication Flow:
    Standard Flow ✔︎
  • Home URL: http://localhost:5173
  • Redirect URI: *
  • Web Origins: http://localhost:5173

React 프로젝트 추가 설정

Keycloak관 연동하여 인증을 구성하기 위해 다음과 같이 추가 설정을 진행합니다.

프로젝트 루트에 “.env” 파일을 생성하고 Keycloack의 react 클라이언트 정보를 추가합니다.

VITE_KEYCLOAK_REALM=cnap
VITE_KEYCLOAK_AUTH_SERVER_URL=https://keycloak.cnap.dev
VITE_KEYCLOAK_CLIENT_ID=react

Silent SSO 체크를 위해 public 디렉토리에 “silent-check-sso.html” 파일을 추가합니다.

<html><body><script>parent.postMessage(location.href, location.origin)</script></body></html>

keycloak과 연동를 하기 “package.json” 파일의 dependencies에 다음 패키지를 추가합니다.

"dependencies": {
  "@react-keycloak/web": "^3.4.0",
  "axios": "^1.4.0",
  "keycloak-js": "^24.0.0"
}

3. Keycloak 인증 매니저

소스 디렉토리에 “src/authManager.js” 스크립트 파일을 생성합니다. Keycloak 인증 매니저는 React 앱과 Keycloak을 연결하는 핵심 인증 로직을 담당하고 있으며 로그인, 로그아웃, 토큰 갱신 등과 같은 기능을 제공합니다.

Keycloak과 연동하기 위해 keycloak-js 패키지에서 Keycloak 모듈을 임포트하고, 환경변수에서 Realm, 인증 서버 URL, Client ID 값을 읽어 Keycloak 설정 객체를 생성합니다.

import Keycloak from "keycloak-js";

const keycloakConfig = {
  realm: import.meta.env.VITE_KEYCLOAK_REALM,
  url: import.meta.env.VITE_KEYCLOAK_AUTH_SERVER_URL,
  clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
};

Keycloak 인스턴스를 생성한 뒤, 초기화 과정에서 SSO(Single Sign-On) 인증 여부를 확인합니다.

const _kc = new Keycloak(keycloakConfig);

const initKeycloak = (onAuthenticatedCallback) => {
  _kc.init({
    onLoad: "check-sso",
    silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
  }).then(authenticated => {
    if (!authenticated) console.log("User is not authenticated!");
    onAuthenticatedCallback();
  }).catch(console.error);
};

authService는 로그인, 로그아웃, 토큰 갱신 기능을 제공합니다. 토큰은 만료 5초 전에 자동 갱신하며, 갱신에 실패하면 로그인 페이지로 리다이렉트합니다.

const authService = {
  login: () => _kc.login(),
  logout: () => _kc.logout(),
  updateToken: (successCallback) => {
    _kc.updateToken(5).then(refreshed => {
      if (refreshed) console.log("Token refreshed");
      successCallback?.(_kc.token);
    }).catch(() => _kc.login());
  },
};

마지막으로 authService와 authInfo를 통합하고, Keycloak 초기화 함수를 포함한 authManager 객체를 정의합니다.

const authInfo = {
  isLoggedIn: () => !!_kc.token,
  getUsername: () => _kc.tokenParsed?.preferred_username,
  hasRole: (roles) => roles.some(role => _kc.hasRealmRole(role)),
};

마지막으로, 인증 서비스와 인증 정보를 하나로 통합하고 Keycloak 초기화 함수를 포함한 인증 관리자 객체입니다.

const authManager = {
  ...authService,
  ...authInfo,
  initKeycloak,
};

export default authManager;

4. Axios 인증 토큰 설정

React 앱의 소스 디렉토리에 “axiosConfig.js"를 파일을 생성합니다.

apiClient는 Axios를 사용하여 API 호출 시 authManager를 사용하여 자동으로 인증 토큰을 갱신하고 Authorization 헤더에 삽입합니다.

import axios from "axios";
import authManager from "./authManager";

const apiClient = axios.create({
  baseURL: "/",
});

apiClient.interceptors.request.use((config) => {
  authManager.updateToken();
  const token = authManager.getToken();
  if (token) config.headers.authorization = `Bearer ${token}`;
  return config;
});

5. React 앱 진입점에서 Keycloak 초기화

소스 디렉토리의 “src/main.tsx” 파일을 변경합니다.

React 앱을 렌더링하기 전에 Keycloak과 연동하여 인증 상태를 확인합니다.

import { createRoot } from 'react-dom/client'
import App from './App'
import AuthManager from "./authManager";

const renderApp = () => {
  const container = document.getElementById("root");
  if (!container) throw new Error('Root container "root" not found');
  createRoot(container).render(<App />);
};

AuthManager.initKeycloak(renderApp);

6. 인증 상태 관리 UI 구성

React 앱의 소스 디렉토리에 있는 메인 컴포넌트 “src/App.jsx"를 변경합니다. 여기서는 상태를 이용해 인증 상태를 관리하고, UI에서 로그인/로그아웃/토큰 갱신 등 동작을 제공합니다.

Keycloack 연동하여 인증을 구성하기 위해 authManager와 apiClient를 임포트합니다.

import React from "react";
import authManager from "./authManager";
import apiClient from "./axiosConfig";

인증 정보를 제공하는 페이지를 구성할 변수를 정의하고 초기값을 설정합니다.

const [isExpired, setIsExpired] = React.useState(false);
const [protectedData, setProtectedData] = React.useState(null);
const [token, setToken] = React.useState(authManager.getToken());
const [username, setUsername] = React.useState(authManager.getUsername());

axiosConfig를 사용하여 백엔드 API 호출하면, 인증 토큰을 포함해 요청이 수행하는 함수를 정의합니다.

const fetchProtectedData = async () => {
  try {
    const response = await apiClient.get("/api/ip");
    setProtectedData(response.data);
  } catch (error) {
    setProtectedData(`Error: ${error.message}`);
  }
};

authManager를 사용하여 토근 업데이트, 로그인, 로그아웃, 토근 만료 검사하는 함수를 정의합니다.

  const handleUpdateToken = () => {
    authManager.updateToken(() => {
      console.log("Token updated");
      setToken(authManager.getToken());
      setUsername(authManager.getUsername());
      setIsExpired(false);
    });
  };

  const handleLogin = () => {
    authManager.login();
  };

  const handleLogout = () => {
    authManager.logout();
    setToken(null);
    setUsername(null);
    setProtectedData(null);
    setIsExpired(null);
  };

  const handleCheckExpired = () => {
    setIsExpired(authManager.isTokenExpired());
  };

로그인 상태가 아닐 경우 로그인 버튼만 표시합니다. 로그인 상태에서는 사용자 정보와 토큰을 보여주고, 토큰 만료 검사, 토큰 갱신, 보호된 데이터 요청, 로그아웃 기능을 제공합니다.

return (
  <div>
    {!isLoggedIn ? <button onClick={authManager.login}>Login</button> :
    <>
      <p>Username: {username}</p>
      <p>token: {token}</p>
      <p>Token expired: {String(isExpired)}</p>
      <button onClick={handleUpdateToken}>Is Token Expired?</button>
      <button onClick={handleUpdateToken}>Update Token</button>
      <button onClick={fetchProtectedData}>Fetch Protected Data</button>
      <button onClick={authManager.logout}>Logout</button>
    </>}
  </div>
)

7. 앱 실행 및 테스트

GitHub에서 React와 Keycloak을 연동하여 개발한 react-keycloak-demo를 클론합니다.

git clone https://github.com/cnapcloud/react-keycloak-demo.git

앱을 실행한 후, 브라우저에서 http://localhost:5173에 접속합니다. 이 때, 자체 서명된 인증서를 사용하는 Keycloak을 사용하는 경우는 이 인증서가 시스템 인증 저장소에 설치되어 있어야 정상적으로 동작합니다.

cd react-keycloak-demo
npm i
npm run dev

초기 화면에서 로그인을 하면, 다음같이 인증 정보를 구성한 페이지가 보입니다.

dnsmasq web ui

8. CORS 문제 해결

현재 구성은 React App을 실행하고 “Fetch Protected Data” 버튼을 누르면 Network 에러가 발생합니다.

Kong gateway 소개 블로그에서 구성한 HttpBin 서비스에서 IP를 가져오는 API를 호출하도록 코드를 변경하면 CORS 문제가 발생하는데, 이를 우회하는 3가지 방법에 대해 알아보겠습니다.

dnsmasq web ui

이 설정을 시작하기 전에 Kong gateway의 HttpBin 서비스는 OIDC 플로그인이 Bearer Only로 설정되어 있는지 확인을 합니다.

React 개발 환경에서 프록시 구성

개발 중에는 React 개발 서버의 프록시 기능을 활용하여 브라우저가 같은 출처(origin)로 요청하는 것처럼 API를 전달할 수 있습니다. Vite를 사용하는 경우 vite.config.ts 예시는 다음과 같습니다.

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import type { ProxyOptions } from 'vite';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'https://kong-httpbin.cnap.dev', // 백엔드 서버 주소
        changeOrigin: true,
        secure: false,
        rewrite: (path: string) => path.replace(/^\/api/, ''),
      } as ProxyOptions,
    },
  },  
})

Nginx를 통한 서버 라우팅

서버에 직접 배포할 경우, Nginx를 리버스 프록시로 구성하여 API 요청을 라우팅하고 CORS 헤더를 추가할 수 있습니다. /api 경로로 들어오는 요청을 백엔드 서버로 전달하고, OPTIONS 요청에는 인증 없이 204 응답과 CORS 헤더를 반환하도록 설정합니다.

server {
    listen 80;

    location /api/ {
        proxy_pass https://kong-httpbin.cnap.dev;
        proxy_set_header Host $host;

        add_header 'Access-Control-Allow-Origin' 'http://localhost:5174';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Credentials' 'true';

        if ($request_method = 'OPTIONS') {
            add_header 'Content-Length' 0;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            return 204;
        }
    }
}

Kong Gateway 사용

먼저 App.jsx에서 fetchProtectedData 함수를 찾아 기존에 ‘/api’를 호출하던 코드를 다음과 같이 변경합니다.

const response = await apiClient.get("https://kong-httpbin.cnap.dev/ip");

Kong Gateway의 HttpBin 서비스에 다음과 같이 cors 플러그인을 적용합니다.

curl -X POST http://kong-admin.cnap.dev/services/httpbin/plugins \
    --data "name=cors" \
    --data "config.origins=http://localhost:5174" \
    --data "config.methods=GET,POST,PUT,DELETE,OPTIONS" \
    --data "config.credentials=true"
  • config.origins: 허용할 프론트엔드 주소
  • config.methods: 허용할 HTTP 메서드 (OPTIONS 포함)
  • config.credentials: 쿠키/토큰 전송 허용

이 설정을 적용하면 브라우저가 preflight 요청을 보내더라도 Kong이 200 OK와 함께 적절한 CORS 헤더를 반환하여 React 애플리케이션에서 API 호출이 정상적으로 수행됩니다.


8. 전체 인증 흐름 및 동작 원리

React 앱과 Keycloak 연동 시 사용자 인증의 전체 흐름을 다시 정리하면 다음과 같습니다.

  1. 앱 실행 시 Keycloak 초기화 (initKeycloak)
  2. 로그인 상태 확인 (Silent SSO)
  3. 로그인 버튼 클릭 → Keycloak 로그인 페이지 리다이렉트
  4. 로그인 성공 후 앱 상태에 토큰과 사용자 정보 저장
  5. API 호출 시 Axios가 토큰 갱신 및 Authorization 헤더 추가
  6. 토큰 만료 여부 확인, 갱신, 로그아웃 등 UI 버튼 제공

8. 마무리

이번 글에서는 React 앱에서 Keycloak 인증을 완전히 구현하고, Silent SSO와 토큰 갱신 기능을 포함했습니다. 환경변수 관리와 Axios 인터셉터를 통해 인증 상태를 일관되게 유지할 수 있습니다. 향후 권한 기반 라우팅, Role 관리, 다중 Realm 지원 등을 구현할 수 있으며, 배포 시 HTTPS, CORS, 보안 정책 강화를 고려해야 합니다.