Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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

신규 프로젝트에서 가장 흔한 실수: **로그인을 직접 구현하는 것.** 비밀번호 해싱, 세션, 토큰 발급, 비밀번호 재설정, 소셜 로그인, 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을 새로 만든다.

콘솔에서 만들기

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-frontend` — **Public Client** (Next.js SPA). 시크릿 없음, PKCE 사용.

- `demo-backend` — **Confidential Client** (API 서버). 시크릿 있음.

Public Client — 프런트엔드용

좌측 **Clients** → **Create 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 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을 쓴다.

1. **Groups** → **Create group** → `administrators`

2. 그 group의 **Role mapping** → `admin` 할당

3. **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-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

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`로 직접 검증하고 싶다면

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

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

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

export const { GET, POST } = handlers

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

서버 컴포넌트에서 사용:

// app/page.tsx

export default async function Home() {

const session = await auth()

if (!session) {

return <form action={async () => { 'use server'; await signIn('keycloak') }}>

}

return (

)

}

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

// app/dashboard/page.tsx

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`)이어야 한다.

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`가 글자까지 정확히 일치하지 않음. 단골은 **`localhost` vs 컨테이너 호스트명** 불일치.

- 예: 백엔드가 Docker 안에서 `http://keycloak:8080/realms/demo`로 검증하는데, 토큰의 `iss`는 `http://localhost:8080/realms/demo`.

- **해결**: `KC_HOSTNAME`을 고정하고, 모든 곳에서 **같은 issuer 문자열**을 쓴다. 토큰을 디코드해 `iss`를 직접 확인(5장).

`aud` (audience) 검증 실패

- **원인**: 백엔드가 특정 `audience`를 요구하는데 Keycloak 기본 토큰의 `aud`는 `account`.

- **해결**: 둘 중 하나 —

1. 백엔드의 audience 검증을 `account`로 맞추거나,

2. Keycloak에서 **Audience Mapper**를 만들어 `demo-backend`를 `aud`에 넣는다 (Client scopes → 전용 scope → Mappers → Audience).

토큰에 role이 없다

- **원인**: Client에 `roles` Client 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가지

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 연동 실습, 끝.

현재 단락 (1/480)

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

작성 글자: 0원문 글자: 19,244작성 단락: 0/480