Skip to content
Published on

Keycloak 연동 실습 — Docker로 띄우고 Realm·Client 설정, Spring Boot·Next.js 연동까지 (2025 핸즈온 가이드)

Authors

프롤로그 — 인증을 직접 만들지 마라

신규 프로젝트에서 가장 흔한 실수: 로그인을 직접 구현하는 것. 비밀번호 해싱, 세션, 토큰 발급, 비밀번호 재설정, 소셜 로그인, 2FA, brute-force 방어… 이걸 다 직접 만들면 보안 구멍을 직접 파는 것과 같다.

Keycloak은 Red Hat이 후원하는 오픈소스 IAM(Identity and Access Management)이다. OIDC·OAuth2·SAML을 표준으로 구현했고, 관리 콘솔·소셜 로그인·2FA·LDAP 연동·brute-force 방어가 다 들어 있다. 자체 호스팅이고, 라이선스는 Apache 2.0이다.

2025년 참고: Keycloak은 17 버전부터 Quarkus 기반으로 재작성됐다. 옛날 WildFly 배포판은 사라졌다. 이 글은 Keycloak 26.x 기준이다.

이 글은 이론서가 아니라 핸즈온이다. OAuth2·OIDC 프로토콜 자체의 깊은 설명은 별도 글(OAuth2/OIDC 심층 가이드)에 있고, 여기서는 실제로 띄우고 연동하는 것에 집중한다. 터미널을 열고 따라 하면 된다.

흐름은 이렇다: Docker로 띄우기 → Realm·Client·User 설정 → OIDC 엔드포인트 직접 찔러보기 → Spring Boot·Node.js 백엔드 연동 → Next.js 프런트 연동 → 토큰 흐름 정리 → 프로덕션 체크리스트 → 트러블슈팅.


0장 · 사전 준비물과 큰 그림

준비물

  • Docker (Keycloak 실행용)
  • JDK 21+ (Spring Boot 실습 시) 또는 Node.js 20+ (Node·Next.js 실습 시)
  • curl, jq (엔드포인트 찔러보기용)

큰 그림

   ┌─────────┐   1. 로그인 리다이렉트    ┌──────────────┐
   │ Browser │ ───────────────────────▶ │   Keycloak   │
   │         │ ◀─────────────────────── │  (IdP, 8080) │
   └────┬────┘   2. 인가 코드 + 토큰     └──────┬───────┘
        │                                      │
        │ 3. Access Token 으로 API 호출         │ JWKS 공개키
        ▼                                      ▼
   ┌─────────┐                          ┌──────────────┐
   │ Backend │ ─── 4. 토큰 서명 검증 ──▶ │  (JWKS 캐싱)  │
   │  (API)  │                          └──────────────┘
   └─────────┘

핵심: 백엔드는 Keycloak에 매 요청을 묻지 않는다. Keycloak의 공개키(JWKS)를 한 번 받아 캐싱하고, 그 키로 JWT 서명을 로컬에서 검증한다. Keycloak이 죽어도 이미 발급된 토큰은 검증된다.

Keycloak 핵심 개념 7가지

개념한 줄 설명
Realm격리 경계. 사용자·클라이언트·역할의 독립된 네임스페이스. 보통 서비스/환경당 하나
ClientKeycloak에 등록된 애플리케이션. 프런트엔드 SPA, 백엔드 API 각각이 Client
User최종 사용자 계정. Realm에 소속
Role권한 묶음. Realm Role(전역)과 Client Role(앱별)이 있음
GroupUser의 묶음. Group에 Role을 매핑하면 소속 User가 상속
Client Scope토큰에 어떤 claim·role을 넣을지 정하는 재사용 단위
Identity Provider외부 IdP(Google, GitHub, 또 다른 Keycloak)를 연결하는 브로커링

1장 · Keycloak 띄우기 (Docker)

30초 퀵스타트 (dev 모드)

docker run --name keycloak -p 8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0 start-dev

버전 주의: Keycloak 26부터 초기 관리자 계정 환경변수가 KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD로 바뀌었다. 그 이전 버전은 KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD를 쓴다.

브라우저로 http://localhost:8080 접속 → "Administration Console" → admin / admin 로그인. 끝.

start-dev는 인메모리 H2 DB를 쓰고 HTTPS를 강제하지 않는다. 실습·로컬 전용이다. 프로덕션은 10장에서 다룬다.

docker-compose — Postgres 붙인 현실적인 구성

실습이라도 데이터가 날아가면 짜증난다. Postgres를 붙이자.

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak"]
      interval: 5s
      timeout: 5s
      retries: 5

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: start-dev
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_HEALTH_ENABLED: "true"
      KC_METRICS_ENABLED: "true"
    ports:
      - "8080:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pg_data:
docker compose up -d
docker compose logs -f keycloak   # "Keycloak ... started" 가 뜨면 준비 완료

dev 모드 vs 프로덕션 모드

start-devstart
HTTPS선택 (강제 안 함)기본 필수
캐시로컬분산(Infinispan)
hostname자동 추론명시 필수 (KC_HOSTNAME)
용도로컬·실습운영

Health·Metrics 엔드포인트

KC_HEALTH_ENABLED=true로 켜면 — Keycloak은 관리용 포트(기본 9000)로 노출한다:

  • http://localhost:9000/health/ready — 준비 상태
  • http://localhost:9000/health/live — 생존 상태
  • http://localhost:9000/metrics — Prometheus 메트릭

2장 · Realm 만들기

Realm은 격리 경계다. master realm은 Keycloak 자체 관리용이므로 건드리지 말고, 우리 서비스용 realm을 새로 만든다.

콘솔에서 만들기

  1. 좌상단 realm 드롭다운 → Create realm
  2. Realm name: demo
  3. Create

이제 모든 작업은 demo realm 안에서 한다.

알아둘 Realm 설정 (Realm settings)

  • Tokens 탭
    • Access Token Lifespan — 기본 5분. 짧을수록 안전, 길수록 편함. 5~15분이 일반적.
    • SSO Session Idle / Max — 리프레시 토큰 수명을 좌우.
  • Login 탭
    • User registration — 셀프 회원가입 허용 여부
    • Forgot password, Remember me, Email as username
  • Sessions 탭 — 세션 수명 세부 조정
  • Security defenses 탭Brute Force Detection 켜기 (10장)

지금은 기본값으로 두고 진행한다.


3장 · Client 설정 — 두 가지 유형

Client는 "Keycloak에 등록된 앱"이다. 우리 실습은 두 개를 만든다:

  • demo-frontendPublic Client (Next.js SPA). 시크릿 없음, PKCE 사용.
  • demo-backendConfidential Client (API 서버). 시크릿 있음.

Public Client — 프런트엔드용

좌측 ClientsCreate client:

  1. General settings
    • Client type: OpenID Connect
    • Client ID: demo-frontend
  2. Capability config
    • Client authentication: Off (← 이게 Public으로 만드는 핵심)
    • Standard flow: On (Authorization Code Flow)
    • Direct access grants: Off (프로덕션에서는 끈다)
  3. Login settings
    • Valid redirect URIs: http://localhost:3000/*
    • Valid post logout redirect URIs: http://localhost:3000/*
    • Web origins: http://localhost:3000 (← CORS 허용 오리진)

Public Client는 시크릿을 안전하게 보관할 수 없으므로(브라우저 코드는 다 보임) PKCE가 필수다. Keycloak은 Standard flow에서 PKCE를 자동 지원한다.

Confidential Client — 백엔드용

다시 Create client:

  1. Client ID: demo-backend
  2. Capability config
    • Client authentication: On (← Confidential)
    • Standard flow: On
    • Service accounts roles: On (서버 대 서버 통신용 — Client Credentials Grant)
  3. 생성 후 Credentials 탭에서 Client Secret을 복사해 둔다.

백엔드가 순수 "Resource Server"(토큰 검증만)라면 사실 시크릿이 필요 없다. JWKS로 검증만 하면 되기 때문이다. 시크릿은 백엔드가 직접 토큰을 발급받아야 할 때(Service Account, Token Exchange) 쓴다.

중요 Client 설정 요약

설정의미자주 하는 실수
Valid redirect URIs로그인 후 돌아올 수 있는 URL 화이트리스트너무 넓게 * → open redirect 위험
Web originsCORS 허용 오리진비워두면 브라우저 호출이 CORS로 막힘
Standard flowAuthorization Code Flow 활성화SPA·웹앱은 필수
Direct access grantsusername/password 직접 교환프로덕션에서는 꺼라 (테스트 전용)
Service accountsClient Credentials Grant서버 대 서버에만

4장 · User·Role·Group 만들기

User 만들기

UsersCreate new user:

  • Username: alice
  • Email: alice@demo.test, Email verified: On
  • CreateCredentials 탭Set passwordalice123, Temporary: Off

(Temporary: On이면 첫 로그인 시 비밀번호 변경을 강제한다. 실습에서는 Off.)

Realm Role 만들기

Realm rolesCreate role:

  • admin 하나, user 하나 만든다.

Role를 User에 매핑

UsersaliceRole mappingAssign roleadmin, user 선택.

Group으로 묶기 (선택이지만 권장)

사용자가 많아지면 User마다 Role을 거는 건 지옥이다. Group을 쓴다.

  1. GroupsCreate groupadministrators
  2. 그 group의 Role mappingadmin 할당
  3. UsersaliceGroupsJoin groupadministrators

이제 administrators group에 들어간 사람은 자동으로 admin role을 갖는다.

Role가 토큰에 들어가는 원리

Realm Role은 기본적으로 Access Token의 realm_access.roles 배열에 들어간다. Client Role은 resource_access 객체 아래 클라이언트별 roles 배열에 들어간다. 이건 roles Client Scope의 기본 Mapper가 처리한다.

만약 토큰에 role이 안 보인다면 — Client의 Client scopes 탭에서 roles scope가 Default로 붙어 있는지 확인하라 (11장 트러블슈팅 참고).


5장 · OIDC 엔드포인트 이해 — curl로 직접 찔러보기

연동 코드를 짜기 전에, Keycloak이 뭘 주는지 직접 보자. 이게 핸즈온의 핵심이다.

Discovery 문서 — 모든 것의 지도

curl -s http://localhost:8080/realms/demo/.well-known/openid-configuration | jq

여기서 핵심 엔드포인트들이 다 나온다:

엔드포인트URL (realm=demo)역할
issuerhttp://localhost:8080/realms/demo토큰 발급자 식별자
authorization_endpoint.../protocol/openid-connect/auth로그인 화면으로 보내는 곳
token_endpoint.../protocol/openid-connect/token코드를 토큰으로 교환
userinfo_endpoint.../protocol/openid-connect/userinfo사용자 정보 조회
jwks_uri.../protocol/openid-connect/certs서명 검증용 공개키
end_session_endpoint.../protocol/openid-connect/logout로그아웃

테스트용 토큰 받기 (Password Grant)

경고: Password Grant(Direct Access Grants)는 테스트 전용이다. 실제 앱은 Authorization Code Flow를 쓴다. 여기서는 토큰 내용을 빨리 보려고 임시로 켠다 — demo-backend Client의 Direct access grants를 잠깐 On으로.

curl -s -X POST \
  http://localhost:8080/realms/demo/protocol/openid-connect/token \
  -d "client_id=demo-backend" \
  -d "client_secret=PASTE_YOUR_SECRET" \
  -d "grant_type=password" \
  -d "username=alice" \
  -d "password=alice123" | jq

응답:

{
  "access_token": "eyJhbGciOiJSUzI1Ni...",
  "expires_in": 300,
  "refresh_token": "eyJhbGciOiJIUzI1Ni...",
  "token_type": "Bearer",
  "scope": "profile email"
}

JWT 안에 뭐가 들었나

access_token의 가운데 부분(payload)을 디코드해 보자:

TOKEN="eyJhbGci..."   # 위에서 받은 access_token
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq

핵심 claim들:

{
  "iss": "http://localhost:8080/realms/demo",
  "sub": "f7e3...-user-uuid",
  "aud": "account",
  "exp": 1763400000,
  "iat": 1763399700,
  "azp": "demo-backend",
  "realm_access": { "roles": ["admin", "user", "default-roles-demo"] },
  "resource_access": { "account": { "roles": ["view-profile"] } },
  "scope": "profile email",
  "email": "alice@demo.test",
  "preferred_username": "alice"
}

백엔드가 검증할 것: iss(우리 issuer인가), exp(안 만료됐나), aud(나를 향한 토큰인가), 그리고 서명(JWKS 공개키로). 권한 판단은 realm_access.roles로 한다.

이제 코드로 옮기자.


6장 · 백엔드 연동 (1) — Spring Boot Resource Server

Spring Boot에서 Keycloak 전용 어댑터는 더 이상 필요 없다. (구버전 keycloak-spring-boot-adapter는 deprecated.) 표준 Spring Security OAuth2 Resource Server를 쓴다.

의존성

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
}

application.yml — 이게 거의 전부다

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/demo

issuer-uri 하나만 주면 Spring이 자동으로 .well-known/openid-configuration을 읽고 jwks_uri를 찾아 공개키를 캐싱한다. 토큰 서명·iss·exp 검증이 자동으로 켜진다.

SecurityConfig — Keycloak role을 Spring 권한으로 변환

기본 상태로는 realm_access.roles를 Spring이 모른다. 변환기를 끼운다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // @PreAuthorize 사용
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("admin")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(converter())))
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());   // 무상태 API라 CSRF 불필요
        return http.build();
    }

    // realm_access.roles -> ROLE_* 권한으로 매핑
    private JwtAuthenticationConverter converter() {
        JwtAuthenticationConverter c = new JwtAuthenticationConverter();
        c.setJwtGrantedAuthoritiesConverter(jwt -> {
            Map<String, Object> realm = jwt.getClaim("realm_access");
            if (realm == null || realm.get("roles") == null) {
                return List.of();
            }
            Collection<String> roles = (Collection<String>) realm.get("roles");
            return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                .collect(Collectors.toList());
        });
        return c;
    }
}

hasRole("admin")은 내부적으로 ROLE_admin 권한을 찾는다. 그래서 변환기에서 ROLE_ 접두사를 붙였다.

보호된 엔드포인트

@RestController
@RequestMapping("/api")
public class DemoController {

    @GetMapping("/public/ping")
    public String publicPing() {
        return "anyone can see this";
    }

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "sub", jwt.getSubject(),
            "username", jwt.getClaimAsString("preferred_username"));
    }

    @GetMapping("/admin/secret")
    @PreAuthorize("hasRole('admin')")
    public String adminSecret() {
        return "only admins";
    }
}

테스트

# 5장에서 받은 토큰으로
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/me
# 토큰 없이 → 401
curl -i http://localhost:8081/api/me

7장 · 백엔드 연동 (2) — Node.js / Express

Node 진영도 똑같다 — JWKS로 로컬 검증. Auth0이 만든 express-oauth2-jwt-bearer가 가장 간단하다.

설치 & 미들웨어

npm install express express-oauth2-jwt-bearer
import express from 'express'
import { auth } from 'express-oauth2-jwt-bearer'

const app = express()

// JWKS 자동 조회·캐싱, iss·exp·서명 검증
const checkJwt = auth({
  issuerBaseURL: 'http://localhost:8080/realms/demo',
  audience: 'account', // Keycloak 기본 aud. 전용 aud는 11장 참고
})

// realm_access.roles 기반 역할 가드
function requireRealmRole(role) {
  return (req, res, next) => {
    const roles = req.auth?.payload?.realm_access?.roles ?? []
    if (!roles.includes(role)) {
      return res.status(403).json({ error: 'forbidden' })
    }
    next()
  }
}

app.get('/api/public/ping', (req, res) => {
  res.json({ msg: 'anyone' })
})

app.get('/api/me', checkJwt, (req, res) => {
  res.json({
    sub: req.auth.payload.sub,
    username: req.auth.payload.preferred_username,
  })
})

app.get('/api/admin/secret', checkJwt, requireRealmRole('admin'), (req, res) => {
  res.json({ msg: 'only admins' })
})

app.listen(8082, () => console.log('API on :8082'))

어댑터 없이 jose로 직접 검증하고 싶다면

라이브러리를 줄이고 원리를 보고 싶을 때:

import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('http://localhost:8080/realms/demo/protocol/openid-connect/certs')
)

async function verify(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'http://localhost:8080/realms/demo',
    // audience: 'demo-backend',  // 전용 aud를 쓸 때
  })
  return payload // { sub, realm_access, ... }
}

createRemoteJWKSet이 공개키를 가져와 캐싱하고, 키 회전(rotation)도 자동 처리한다.


8장 · 프런트엔드 연동 — Next.js

프런트엔드의 역할: 사용자를 Keycloak 로그인으로 보내고, 받은 Access Token으로 백엔드를 호출한다. 두 가지 방법.

방법 A — Auth.js (NextAuth v5), Next.js에 권장

Next.js App Router라면 Auth.js가 가장 매끄럽다. 토큰 교환·세션·리프레시를 서버 사이드에서 처리해 토큰이 브라우저 JS에 노출되지 않는다.

npm install next-auth@beta
// auth.ts
import NextAuth from 'next-auth'
import Keycloak from 'next-auth/providers/keycloak'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Keycloak({
      clientId: process.env.KEYCLOAK_CLIENT_ID,        // demo-frontend
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, // Confidential일 때
      issuer: process.env.KEYCLOAK_ISSUER,             // http://localhost:8080/realms/demo
    }),
  ],
  callbacks: {
    // 최초 로그인 시 Keycloak 토큰을 세션 토큰에 보관
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
        token.expiresAt = account.expires_at
      }
      return token
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken
      return session
    },
  },
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers

위 라우트 경로의 대괄호 부분은 Next.js의 catch-all 라우트 문법이다 (auth 다음에 ...nextauth가 대괄호로 감싸진 폴더).

서버 컴포넌트에서 사용:

// app/page.tsx
import { auth, signIn, signOut } from '@/auth'

export default async function Home() {
  const session = await auth()
  if (!session) {
    return <form action={async () => { 'use server'; await signIn('keycloak') }}>
      <button>Keycloak으로 로그인</button>
    </form>
  }
  return (
    <div>
      <p>{session.user?.email} 님 환영합니다</p>
      <form action={async () => { 'use server'; await signOut() }}>
        <button>로그아웃</button>
      </form>
    </div>
  )
}

백엔드 호출 — 서버에서 Access Token을 붙인다:

// app/dashboard/page.tsx
import { auth } from '@/auth'

export default async function Dashboard() {
  const session = await auth()
  const res = await fetch('http://localhost:8082/api/me', {
    headers: { Authorization: `Bearer ${session?.accessToken}` },
  })
  const me = await res.json()
  return <pre>{JSON.stringify(me, null, 2)}</pre>
}

방법 B — keycloak-js 어댑터 (순수 SPA)

Next.js가 아니라 순수 React SPA거나, 클라이언트 사이드에서 직접 토큰을 다루고 싶을 때. Client는 Public(Client authentication: Off)이어야 한다.

import Keycloak from 'keycloak-js'

export const keycloak = new Keycloak({
  url: 'http://localhost:8080',
  realm: 'demo',
  clientId: 'demo-frontend',
})

await keycloak.init({
  onLoad: 'check-sso',
  pkceMethod: 'S256',   // PKCE 필수
})

if (keycloak.authenticated) {
  // 백엔드 호출
  await fetch('http://localhost:8082/api/me', {
    headers: { Authorization: `Bearer ${keycloak.token}` },
  })
}

// 토큰 만료 임박 시 갱신
setInterval(() => keycloak.updateToken(30), 20_000)

어느 쪽을 쓸까

Auth.js (방법 A)keycloak-js (방법 B)
토큰 노출서버 사이드 보관, 더 안전브라우저 메모리
Next.js 적합도매우 높음보통
순수 SPA부적합적합
권장Next.js면 이것SPA·레거시 React

9장 · 토큰 흐름 정리 — Authorization Code + PKCE 전체 그림

8장의 코드가 내부적으로 하는 일을 한 장으로 정리한다. SPA·웹앱의 표준 흐름이다.

1. 사용자가 "로그인" 클릭
2. 프런트 → Keycloak authorization_endpoint 로 리다이렉트
     ?response_type=code
     &client_id=demo-frontend
     &redirect_uri=http://localhost:3000/...
     &scope=openid profile email
     &code_challenge=...        (PKCE: code_verifier의 SHA-256)
     &code_challenge_method=S256
3. Keycloak 로그인 화면 → 사용자 인증
4. Keycloak → redirect_uri 로 돌려보냄, ?code=AUTH_CODE
5. 프런트(또는 Auth.js 서버) → token_endpoint 로 교환
     grant_type=authorization_code
     &code=AUTH_CODE
     &code_verifier=...         (PKCE: 원본 verifier — 코드 가로채기 방어)
6. Keycloak → { access_token, id_token, refresh_token }
7. access_token 으로 백엔드 API 호출
8. 만료되면 refresh_token 으로 token_endpoint 재요청 → 새 토큰

세 가지 토큰의 역할

토큰용도받는 쪽
access_tokenAPI 호출 인가Resource Server (백엔드)
id_token"누가 로그인했나" 신원 증명Client (프런트엔드)
refresh_token새 access_token 발급Authorization Server (Keycloak)

흔한 실수: id_token을 백엔드 API에 보내는 것. 백엔드는 access_token을 받아야 한다. id_token은 프런트가 "로그인됨"을 아는 용도다.

로그아웃 — 세션까지 끊기

프런트에서 토큰만 버리는 건 진짜 로그아웃이 아니다. Keycloak SSO 세션이 살아 있어서 다시 로그인하면 화면도 안 뜨고 통과된다. RP-Initiated Logout으로 Keycloak 세션까지 끊어야 한다:

http://localhost:8080/realms/demo/protocol/openid-connect/logout
  ?id_token_hint=ID_TOKEN
  &post_logout_redirect_uri=http://localhost:3000

Auth.js의 signOut()은 이걸 처리해 준다 (provider 설정에 따라). keycloak-jskeycloak.logout().


10장 · 프로덕션 체크리스트

start-dev로 운영하면 안 된다. 운영 전환 시 필수 항목.

프로덕션 모드로 전환

# start-dev → start, hostname·HTTPS 명시
docker run ... quay.io/keycloak/keycloak:26.0 start \
  --hostname https://auth.example.com \
  --proxy-headers xforwarded     # 리버스 프록시 뒤에 있을 때
  • start 모드 — HTTPS를 기본 강제, 분산 캐시.
  • KC_HOSTNAME — 명시 필수. 안 그러면 발급되는 토큰의 iss가 엉뚱해진다 (11장 단골 버그).
  • --proxy-headers — Nginx·ALB 뒤에 있으면 X-Forwarded-*를 신뢰하도록.

DB·인프라

  • Postgres/MySQL 외부 DB — H2 절대 금지. 백업·HA 설정.
  • 여러 인스턴스 + 분산 캐시(Infinispan) — 단일 장애점 제거.
  • TLSauth.example.com은 반드시 HTTPS.

보안 설정

  • Brute Force Detection 켜기 — Realm settings → Security defenses. 비밀번호 무차별 대입 방어.
  • Password policy — 최소 길이, 복잡도, 재사용 금지.
  • Token lifespan 조정 — Access Token 5~15분, Refresh Token은 서비스 특성에 맞게.
  • Direct access grants 끄기 — 모든 프로덕션 Client에서.
  • Redirect URI 좁히기* 금지. 정확한 경로로.

설정을 코드로 — Realm Export/Import

콘솔에서 클릭으로 만든 설정은 재현 불가능하다. Realm을 JSON으로 export 해서 Git에 넣고, 부팅 시 import 한다 (IaC).

# export
docker exec keycloak /opt/keycloak/bin/kc.sh export \
  --dir /tmp/export --realm demo

# import (컨테이너 시작 시)
docker run ... quay.io/keycloak/keycloak:26.0 \
  start-dev --import-realm   # /opt/keycloak/data/import/ 의 JSON을 읽음

더 정교하게는 keycloak-config-cli 같은 도구로 선언적 설정을 관리하거나, Terraform Keycloak provider를 쓴다.

관측성

  • KC_HEALTH_ENABLED, KC_METRICS_ENABLED 켜고 Prometheus·k8s probe 연결.
  • 로그인 실패·관리 작업은 Events로 남긴다 (Realm settings → Events). 감사 로그.

11장 · 트러블슈팅 — 자주 막히는 곳

핸즈온에서 100% 만나는 에러들. 미리 알아두면 시간을 아낀다.

Invalid redirect_uri

  • 원인: Client의 Valid redirect URIs에 등록 안 된 URL로 돌아오려 함.
  • 해결: Client 설정에 정확한 URL 등록. 포트·경로·trailing slash까지 정확히. 와일드카드는 http://localhost:3000/*처럼.

CORS 에러 (브라우저 콘솔)

  • 원인: Client의 Web origins가 비어 있거나 프런트 오리진이 없음.
  • 해결: Web originshttp://localhost:3000 추가. (+를 넣으면 redirect URI에서 자동 추론.)

Invalid token issuer / 백엔드가 토큰 거부

  • 원인: 토큰의 iss와 백엔드의 issuer-uri가 글자까지 정확히 일치하지 않음. 단골은 localhost vs 컨테이너 호스트명 불일치.
  • 예: 백엔드가 Docker 안에서 http://keycloak:8080/realms/demo로 검증하는데, 토큰의 isshttp://localhost:8080/realms/demo.
  • 해결: KC_HOSTNAME을 고정하고, 모든 곳에서 같은 issuer 문자열을 쓴다. 토큰을 디코드해 iss를 직접 확인(5장).

aud (audience) 검증 실패

  • 원인: 백엔드가 특정 audience를 요구하는데 Keycloak 기본 토큰의 audaccount.
  • 해결: 둘 중 하나 —
    1. 백엔드의 audience 검증을 account로 맞추거나,
    2. Keycloak에서 Audience Mapper를 만들어 demo-backendaud에 넣는다 (Client scopes → 전용 scope → Mappers → Audience).

토큰에 role이 없다

  • 원인: Client에 roles Client Scope가 안 붙었거나, role을 User에 매핑 안 함.
  • 해결: Client → Client scopes 탭에 rolesDefault로 있는지 확인. User → Role mapping 확인. 토큰 디코드해서 realm_access.roles 직접 확인.

Clock skew — Token is not active / expired

  • 원인: Keycloak 컨테이너와 백엔드 서버의 시계가 어긋남.
  • 해결: NTP 동기화. 검증 라이브러리의 clockTolerance(보통 몇 초~1분)를 약간 허용.

start 모드인데 안 뜸 — hostname 에러

  • 원인: 프로덕션 모드는 KC_HOSTNAME이 필수.
  • 해결: --hostname 또는 KC_HOSTNAME 환경변수 지정. 리버스 프록시 뒤면 --proxy-headers도.

12장 · 팁 & 안티패턴

실전 팁

  • Realm은 환경/서비스 단위로demo-dev, demo-prod를 분리. master는 절대 앱용으로 쓰지 마라.
  • Client는 앱 단위로 — 프런트와 백엔드는 별개 Client. 프런트는 Public, 백엔드는 필요 시 Confidential.
  • role은 Group으로 관리 — User에 직접 role 거는 건 소규모만. 커지면 Group + role mapping.
  • 토큰을 항상 디코드해 보는 습관 — 막히면 jwt.iocut | base64 -d | jqiss·aud·exp·roles를 눈으로 확인. 추측하지 마라.
  • Discovery 문서를 신뢰 — 엔드포인트 URL을 하드코딩하지 말고 .well-known/openid-configuration에서 읽어라.
  • 로컬은 docker-compose + Postgres + --import-realm — realm JSON을 Git에 넣으면 팀원이 docker compose up 한 번으로 동일 환경.
  • Access Token은 짧게, Refresh Token으로 갱신 — 5~15분. 유출돼도 수명이 짧다.

안티패턴 10가지

  1. 인증을 직접 구현 — Keycloak이 있는데 비밀번호 해싱부터 짠다.
  2. master realm에 앱 Client·User를 만든다.
  3. start-dev로 프로덕션 운영 (H2 DB, HTTPS 없음).
  4. Redirect URI를 *로 활짝 열어둠 — open redirect.
  5. Web origins 안 채워서 CORS로 계속 막힘.
  6. 백엔드에 id_token을 보냄 (보내야 할 건 access_token).
  7. 프런트엔드 Public Client에 PKCE 미사용.
  8. Direct access grants(Password Grant)를 프로덕션에서 켜둠.
  9. KC_HOSTNAME 안 잡아서 토큰 iss 불일치로 검증 실패.
  10. 콘솔 클릭으로만 설정 — export/import 없이 재현 불가능한 환경.

에필로그 — Keycloak은 "끝"이 아니라 "시작점"

이 글을 따라 했다면 이제 갖춘 것:

  • Docker로 띄운 Keycloak 26 (+ Postgres)
  • demo realm, Public·Confidential Client, User·Role·Group
  • OIDC 엔드포인트를 직접 찔러본 경험 — Discovery, token, JWKS
  • Spring Boot Resource Server, Node.js Express 백엔드 연동
  • Next.js(Auth.js) + keycloak-js 프런트 연동
  • Authorization Code + PKCE 전체 토큰 흐름의 이해
  • 프로덕션 체크리스트와 트러블슈팅 감각

여기서 더 나아갈 길: Identity Provider Brokering(Google·GitHub 소셜 로그인 연결), LDAP/AD User Federation(사내 디렉터리 연동), 커스텀 테마(로그인 화면 브랜딩), Authorization Services(fine-grained 권한 — UMA), Token Exchange(서비스 간 토큰 변환), Organizations(B2B 멀티테넌시, Keycloak 26 신기능).

핵심 교훈은 하나다. 인증·인가는 직접 만들 영역이 아니다. 표준(OIDC)을 구현한 검증된 솔루션 위에 올라타고, 당신은 비즈니스 로직에 집중하라. Keycloak은 그 표준 위의 가장 보편적인 자체 호스팅 선택지다.

10개 항목 체크리스트

  1. Keycloak을 start-dev가 아닌 start 모드로 띄울 준비가 됐는가?
  2. 앱용 realm을 master와 분리했는가?
  3. 프런트는 Public + PKCE, 백엔드는 적절한 Client 유형인가?
  4. Redirect URI와 Web origins를 정확히(좁게) 설정했는가?
  5. 백엔드가 JWKS로 로컬 검증하는가 (매 요청 introspection 아님)?
  6. iss · aud · exp · 서명을 모두 검증하는가?
  7. role이 토큰에 들어가고 백엔드 권한으로 변환되는가?
  8. 백엔드에 access_token을, 신원 확인엔 id_token을 쓰는가?
  9. RP-Initiated Logout으로 Keycloak 세션까지 끊는가?
  10. Realm 설정을 export/import(또는 IaC)로 재현 가능한가?

다음 글 예고

다음 글 후보: Keycloak Identity Provider Brokering — Google·GitHub 소셜 로그인과 사내 LDAP 연동, Keycloak 커스텀 테마·확장(SPI) 개발, Keycloak Authorization Services — UMA 기반 fine-grained 권한 실습.

"좋은 인증은 사용자가 의식하지 못하는 인증이다. 그리고 좋은 인증 인프라는 개발자가 직접 만들지 않은 인프라다."

— Keycloak 연동 실습, 끝.