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

- Name
- Youngju Kim
- @fjvbn20031
프롤로그 — 인증을 직접 만들지 마라
신규 프로젝트에서 가장 흔한 실수: 로그인을 직접 구현하는 것. 비밀번호 해싱, 세션, 토큰 발급, 비밀번호 재설정, 소셜 로그인, 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 | 격리 경계. 사용자·클라이언트·역할의 독립된 네임스페이스. 보통 서비스/환경당 하나 |
| Client | Keycloak에 등록된 애플리케이션. 프런트엔드 SPA, 백엔드 API 각각이 Client |
| User | 최종 사용자 계정. Realm에 소속 |
| Role | 권한 묶음. Realm Role(전역)과 Client Role(앱별)이 있음 |
| Group | User의 묶음. 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-dev | start | |
|---|---|---|
| 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을 새로 만든다.
콘솔에서 만들기
- 좌상단 realm 드롭다운 → Create realm
- Realm name:
demo - 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-frontend— Public Client (Next.js SPA). 시크릿 없음, PKCE 사용.demo-backend— Confidential Client (API 서버). 시크릿 있음.
Public Client — 프런트엔드용
좌측 Clients → Create client:
- General settings
- Client type:
OpenID Connect - Client ID:
demo-frontend
- Client type:
- Capability config
Client authentication: Off (← 이게 Public으로 만드는 핵심)Standard flow: On (Authorization Code Flow)Direct access grants: Off (프로덕션에서는 끈다)
- 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:
- Client ID:
demo-backend - Capability config
Client authentication: On (← Confidential)Standard flow: OnService accounts roles: On (서버 대 서버 통신용 — Client Credentials Grant)
- 생성 후 Credentials 탭에서
Client Secret을 복사해 둔다.
백엔드가 순수 "Resource Server"(토큰 검증만)라면 사실 시크릿이 필요 없다. JWKS로 검증만 하면 되기 때문이다. 시크릿은 백엔드가 직접 토큰을 발급받아야 할 때(Service Account, Token Exchange) 쓴다.
중요 Client 설정 요약
| 설정 | 의미 | 자주 하는 실수 |
|---|---|---|
| Valid redirect URIs | 로그인 후 돌아올 수 있는 URL 화이트리스트 | 너무 넓게 * → open redirect 위험 |
| Web origins | CORS 허용 오리진 | 비워두면 브라우저 호출이 CORS로 막힘 |
| Standard flow | Authorization Code Flow 활성화 | SPA·웹앱은 필수 |
| Direct access grants | username/password 직접 교환 | 프로덕션에서는 꺼라 (테스트 전용) |
| Service accounts | Client Credentials Grant | 서버 대 서버에만 |
4장 · User·Role·Group 만들기
User 만들기
Users → Create new user:
- Username:
alice - Email:
alice@demo.test,Email verified: On - Create → Credentials 탭 → Set password →
alice123,Temporary: Off
(Temporary: On이면 첫 로그인 시 비밀번호 변경을 강제한다. 실습에서는 Off.)
Realm Role 만들기
Realm roles → Create role:
admin하나,user하나 만든다.
Role를 User에 매핑
Users → alice → Role mapping → Assign role → admin, user 선택.
Group으로 묶기 (선택이지만 권장)
사용자가 많아지면 User마다 Role을 거는 건 지옥이다. Group을 쓴다.
- Groups → Create group →
administrators - 그 group의 Role mapping →
admin할당 - Users →
alice→ Groups → Join group →administrators
이제 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) | 역할 |
|---|---|---|
| issuer | http://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-backendClient의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_token | API 호출 인가 | 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-js는 keycloak.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) — 단일 장애점 제거.
- TLS —
auth.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 origins에http://localhost:3000추가. (+를 넣으면 redirect URI에서 자동 추론.)
Invalid token issuer / 백엔드가 토큰 거부
- 원인: 토큰의
iss와 백엔드의issuer-uri가 글자까지 정확히 일치하지 않음. 단골은localhostvs 컨테이너 호스트명 불일치. - 예: 백엔드가 Docker 안에서
http://keycloak:8080/realms/demo로 검증하는데, 토큰의iss는http://localhost:8080/realms/demo. - 해결:
KC_HOSTNAME을 고정하고, 모든 곳에서 같은 issuer 문자열을 쓴다. 토큰을 디코드해iss를 직접 확인(5장).
aud (audience) 검증 실패
- 원인: 백엔드가 특정
audience를 요구하는데 Keycloak 기본 토큰의aud는account. - 해결: 둘 중 하나 —
- 백엔드의 audience 검증을
account로 맞추거나, - Keycloak에서 Audience Mapper를 만들어
demo-backend를aud에 넣는다 (Client scopes → 전용 scope → Mappers → Audience).
- 백엔드의 audience 검증을
토큰에 role이 없다
- 원인: Client에
rolesClient Scope가 안 붙었거나, role을 User에 매핑 안 함. - 해결: Client → Client scopes 탭에
roles가Default로 있는지 확인. 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.io나cut | base64 -d | jq로iss·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가지
- 인증을 직접 구현 — Keycloak이 있는데 비밀번호 해싱부터 짠다.
masterrealm에 앱 Client·User를 만든다.start-dev로 프로덕션 운영 (H2 DB, HTTPS 없음).- Redirect URI를
*로 활짝 열어둠 — open redirect. Web origins안 채워서 CORS로 계속 막힘.- 백엔드에
id_token을 보냄 (보내야 할 건access_token). - 프런트엔드 Public Client에 PKCE 미사용.
Direct access grants(Password Grant)를 프로덕션에서 켜둠.KC_HOSTNAME안 잡아서 토큰iss불일치로 검증 실패.- 콘솔 클릭으로만 설정 — export/import 없이 재현 불가능한 환경.
에필로그 — Keycloak은 "끝"이 아니라 "시작점"
이 글을 따라 했다면 이제 갖춘 것:
- Docker로 띄운 Keycloak 26 (+ Postgres)
demorealm, 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개 항목 체크리스트
- Keycloak을
start-dev가 아닌start모드로 띄울 준비가 됐는가? - 앱용 realm을
master와 분리했는가? - 프런트는 Public + PKCE, 백엔드는 적절한 Client 유형인가?
- Redirect URI와 Web origins를 정확히(좁게) 설정했는가?
- 백엔드가 JWKS로 로컬 검증하는가 (매 요청 introspection 아님)?
iss·aud·exp· 서명을 모두 검증하는가?- role이 토큰에 들어가고 백엔드 권한으로 변환되는가?
- 백엔드에
access_token을, 신원 확인엔id_token을 쓰는가? - RP-Initiated Logout으로 Keycloak 세션까지 끊는가?
- Realm 설정을 export/import(또는 IaC)로 재현 가능한가?
다음 글 예고
다음 글 후보: Keycloak Identity Provider Brokering — Google·GitHub 소셜 로그인과 사내 LDAP 연동, Keycloak 커스텀 테마·확장(SPI) 개발, Keycloak Authorization Services — UMA 기반 fine-grained 권한 실습.
"좋은 인증은 사용자가 의식하지 못하는 인증이다. 그리고 좋은 인증 인프라는 개발자가 직접 만들지 않은 인프라다."
— Keycloak 연동 실습, 끝.