Files
keycloak-pattern/docs/session-lab-prerequisites.md
T
DongHyeonkaandClaude Opus 5 1de6108157 docs: add the prerequisite knowledge this lab assumes
Builds up from HTTP statelessness to why session storage location determines the operational response, so the measurements have context to land in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:24:21 +09:00

401 lines
18 KiB
Markdown

# 이 실험을 이해하기 위한 선수 지식
실험 결과를 먼저 들이밀었더니 맥락이 사라졌다. 이 문서는 **왜 이런 걸
측정하고 있는지**를 바닥부터 세운다.
읽는 순서가 곧 의존 관계다. 아는 절은 건너뛰어도 되지만, 3장까지는
"세션"이라는 말의 뜻이 계속 바뀌므로 훑고 가는 편이 낫다.
---
## 0. 출발점 — 당신이 원래 물은 것
> Keycloak이 여러 개일 경우, 세션 저장소를 Redis나 별도 저장소로 쓸 경우,
> Redis와 DB에 분리해서 세션과 토큰을 관리할 때 어떻게 달라지는지.
> SSO를 추가하면 어떻게 달라지는지. Redis 또는 DB가 죽으면 어떻게 복구하는지.
이 질문에 답하려면 **"세션이 어디에 있는가"** 를 정확히 알아야 한다.
지금 하고 있는 실험은 전부 그 한 문장을 쪼갠 것이다.
---
## 1. HTTP는 기억이 없다
모든 것의 출발점.
```
요청 1: GET /login → 서버
요청 2: GET /mypage → 서버 ← 서버는 요청 1을 기억하지 못한다
```
HTTP 요청은 **하나하나가 완전히 독립적**이다. 서버 입장에서 두 번째 요청은
생판 처음 보는 사람이 보낸 것과 구별되지 않는다.
그래서 "로그인했다"는 사실을 **어딘가 저장**해야 한다.
```
브라우저 서버
┌──────────────┐ ┌────────────────────────┐
│ 쿠키 │ │ 세션 저장소 │
│ SESSIONID= │ ──── 매 요청 ────▶ │ abc123 → { │
│ abc123 │ 이 값만 보냄 │ user: "홍길동", │
└──────────────┘ │ 로그인시각: ... │
│ } │
작은 표만 들고 다닌다 └────────────────────────┘
실제 내용은 여기 있다
```
| 용어 | 뜻 |
|---|---|
| **쿠키** | 브라우저가 들고 다니는 **작은 표(번호표)**. 보통 세션 ID만 들어 있다 |
| **세션** | 서버가 그 번호에 대해 기억하는 **실제 내용** |
**서버가 1대면 여기서 이야기가 끝난다.** 문제는 2대부터다.
---
## 2. 서버가 2대가 되는 순간 — 이 실험의 진짜 출발점
```
로그인 요청 ──▶ 서버 A A의 메모리에 "abc123 = 홍길동" 기록
다음 요청 ──▶ 서버 B B: "abc123? 그런 거 모르는데" → 로그아웃 화면
```
**이게 전부다.** 분산 세션이라는 주제 전체가 이 한 장면에서 나온다.
푸는 방법은 셋뿐이다.
| 방법 | 어떻게 | 대가 |
|---|---|---|
| **1. 고정 배정** (sticky session) | 같은 사람은 항상 같은 서버로 보낸다 | **그 서버가 죽으면 그 사람 세션은 사라진다.** 부하도 안 고르게 퍼진다 |
| **2. 복제** | 서버끼리 메모리 내용을 서로 보낸다 | 서버가 N대면 트래픽이 N² 로 는다. 어긋남(불일치)이 생긴다 |
| **3. 공유 저장소** | 세션을 바깥(DB·Redis)에 두고 모두가 본다 | **그게 죽으면 전체가 멈춘다.** 매 요청마다 네트워크 왕복 |
> **당신이 원래 물은 "Redis나 별도 저장소를 쓰면"이 바로 3번**이다.
> 그리고 "Redis나 DB가 죽으면 어떻게 복구하나"는 3번의 대가를 묻는 것이다.
**Keycloak도 예외가 아니다.** Keycloak을 2대 띄우면 정확히 이 문제가 생긴다.
Keycloak이 이걸 어떻게 풀었는지가 실험 0의 주제다.
---
## 3. Keycloak은 무엇이고, 왜 세션을 갖는가
### 3-1. 하는 일
Keycloak은 **로그인을 대신 해주는 서버**다.
```
[사용자] [내 앱] [Keycloak]
│ │ │
│─ 접속 ──────▶│ │
│◀─ "Keycloak 가서 로그인하고 와" ────│
│──────────────────── 로그인 ───────▶│
│◀─────────────── 토큰 발급 ─────────│
│─ 토큰 들고 ──▶│ │
│ │─ 이 토큰 유효해? ──▶│
```
내 앱은 비밀번호를 저장하지도, 검증하지도 않는다. 그 일을 Keycloak이 한다.
### 3-2. 그래서 **세션이 두 겹**이 된다
여기가 헷갈리는 지점이다. "세션"이라는 말이 두 가지를 가리킨다.
```
┌─────────────────────────────────────────────────────┐
│ Keycloak 의 SSO 세션 │
│ "이 브라우저는 홍길동으로 로그인되어 있다" │
│ 쿠키 이름: KEYCLOAK_IDENTITY │
└─────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 앱1 의 세션 │ │ 앱2 의 세션 │
│ (또는 토큰) │ │ (또는 토큰) │
└──────────────────┘ └──────────────────┘
```
| | 누가 갖는가 | 사라지면 |
|---|---|---|
| **SSO 세션** | **Keycloak** | 모든 앱에서 다시 로그인해야 한다 |
| 앱 세션 | 각 애플리케이션 | 그 앱만 다시 들어가면 된다 |
**SSO가 되는 원리가 이것이다.** 앱1에서 로그인하면 Keycloak에 SSO 세션이
생긴다. 앱2로 가면 Keycloak이 "이 브라우저 이미 로그인했네" 하고 **로그인
화면 없이** 바로 토큰을 준다.
> **그래서 Keycloak의 세션이 사라지면 SSO 전체가 깨진다.**
> 당신 질문의 "SSO를 추가하면 어떻게 달라지는지"가 여기 걸린다.
> 앱이 하나일 때는 그 앱만 재로그인이지만, SSO에서는 **전 앱이 동시에** 터진다.
---
## 4. 토큰이 있는데 왜 세션이 필요한가
가장 흔한 오해다. "JWT는 stateless라서 서버가 기억할 게 없다"는 말은
**반만 맞다.**
### 4-1. 토큰이 두 종류다
```
로그인 성공
├──▶ access token 수명 짧음 (이 실험대: 60초)
│ JWT. 서명이 붙어 있어 서버가 아무것도 기억 안 해도 검증된다
│ → 진짜 stateless
└──▶ refresh token 수명 김 (이 실험대: 1800초 = 30분)
access token 이 만료되면 이걸로 새로 받는다
→ 서버가 세션을 기억하고 있어야 한다
```
| | access token | refresh token |
|---|---|---|
| 검증 방식 | **서명만 보면 됨** | **서버 세션 조회 필요** |
| 취소 | **불가능** (만료를 기다려야) | 가능 |
| 수명 | 짧게 (분 단위) | 길게 (시간~일) |
### 4-2. 그래서 이렇게 된다
```
0초 로그인 세션 생성
0초 access token 발급 이후 60초간은 서버에 안 물어봐도 됨
60초 access token 만료
60초 refresh 요청 ─────▶ 서버: "이 세션 살아 있나?" ◀── 여기서 세션 필요
60초 새 access token
120초 또 만료 → 또 refresh → 또 세션 조회
```
**60초마다 세션 저장소를 친다.** access token 수명이 짧을수록 세션 저장소
부하가 커진다 — 보안과 성능의 맞바꿈이 여기서 일어난다.
### 4-3. 로그아웃도 세션이 있어야 한다
**로그아웃 = 세션 삭제**다. 세션이 없으면 로그아웃이라는 개념 자체가 없다.
이미 발급된 access token은 서명이 유효하므로 만료 전까지 계속 통과한다.
> 그래서 access token 수명을 60초로 짧게 잡는다. 로그아웃해도 최대 60초는
> 살아 있다는 뜻이고, 그 이상은 refresh 가 막히므로 끝난다.
---
## 5. Keycloak은 세션을 어디에 두는가 — **버전에 따라 답이 다르다**
이 실험 전체가 여기에 걸려 있다.
### 5-1. 두 개의 후보
| | 무엇 | 성질 |
|---|---|---|
| **Infinispan** | Keycloak **안에 내장된** 분산 캐시. Java 라이브러리 | 메모리. 빠름. 프로세스가 죽으면 사라짐 |
| **데이터베이스** | PostgreSQL 등 바깥의 DB | 디스크. 느림. 재시작해도 남음 |
**Infinispan은 별도로 설치하는 물건이 아니다.** Keycloak 프로세스 안에서 도는
라이브러리다. Redis처럼 따로 띄우는 게 아니다 — 이걸 헷갈리면 전체가 안 맞는다.
### 5-2. 버전별로 이렇게 바뀌었다
| 버전 | 진실의 원천 | 전체 재시작하면 |
|---|---|---|
| ~24 | **Infinispan (메모리)** | **세션 전부 소멸** |
| 25 | 선택 (`persistent-user-sessions` 옵션) | 설정에 따라 |
| **26 (지금 이 실험대)** | **데이터베이스** | **세션 살아남음** |
**이게 결정적이다.** 인터넷에 있는 Keycloak 클러스터링 자료 대부분은
24 이전 기준이라 **"세션은 Infinispan이 노드끼리 복제한다"** 고 쓰여 있다.
26에서는 더 이상 사실이 아니다.
> 제가 처음에 개념 문서에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고
> 써둔 것도 이 옛 모델을 그대로 옮긴 것이었다. 실험 0에서 틀렸음이 드러났다.
---
## 6. Infinispan / JGroups / 7800 — 이름들의 정체
실험 로그에 계속 나오는 이름들이다.
```
Keycloak 프로세스
┌────────────────────────────────────────┐
│ Infinispan "세션을 어디 두고 어떻게 │ ← 캐시 계층
│ 나눌까" │
│ │ │
│ JGroups "누가 우리 멤버이고 │ ← 그룹 통신 계층
│ 어떻게 메시지를 주고받나" │
│ │ │
│ TCP 7800 실제 소켓 │ ← 네트워크
└────────────────────────────────────────┘
```
| 이름 | 정체 |
|---|---|
| **Infinispan** | Keycloak 내장 캐시. 세션·realm 설정·로그인 실패 횟수 등을 담는다 |
| **JGroups** | Infinispan이 노드끼리 대화할 때 쓰는 하부 라이브러리 |
| **TCP 7800** | JGroups가 쓰는 포트. **노드 간 통신 경로** |
| **jdbc-ping** | 서로를 **찾는** 방법. DB의 `JGROUPS_PING` 테이블을 게시판처럼 쓴다 |
| `ISPN000094` | "새 멤버 명단을 받았다"는 로그 코드 |
**찾는 것과 대화하는 것이 다른 경로다.**
```
디스커버리 (서로를 찾는다) → PostgreSQL JGROUPS_PING 테이블
트랜스포트 (실제 대화) → TCP 7800
```
---
## 7. 왜 쿠버네티스와 노드 2대가 나오는가
당신 질문은 "Keycloak이 여러 개일 경우"였다. 그걸 **진짜로** 재현하려면
Keycloak 프로세스 2개가 **서로 다른 기계**에 있어야 한다.
| 방식 | 노드 상실을 실험할 수 있나 |
|---|---|
| Docker 컨테이너 2개 (한 기계) | **못 한다.** 커널이 하나라 "기계가 죽는" 상황을 못 만든다 |
| **VM 2대 + k3s** | **된다.** 하나를 전원 차단할 수 있다 |
그래서 이 실험대는 VM 2대(`kc-lab-1`, `kc-lab-2`) 위에 k3s를 올렸다.
```
kc-lab-1 (k3s 서버) kc-lab-2 (k3s 에이전트)
├─ keycloak-1 ├─ keycloak-0
├─ traefik, coredns └─ postgres
└─ prometheus, grafana
```
**`keycloak-0` / `keycloak-1` 은 Keycloak 프로세스**이고,
**`kc-lab-1` / `kc-lab-2` 는 그것들이 올라간 기계**다. 이름이 비슷해서
헷갈리기 쉬운데 계층이 다르다.
---
## 8. 그래서 실험 0은 무엇을 알아내려 한 것인가
### 8-1. 답해야 할 실무 질문
> Keycloak을 2대로 늘렸다. **한 대가 죽으면 로그인한 사람들은 어떻게 되나?**
> **DB가 죽으면?** **노드 사이 네트워크가 끊기면?**
이 질문들에 답하려면 **정상일 때 무엇이 어디에 있는지**를 먼저 알아야 한다.
그게 없으면 장애를 일으켜도 무엇이 왜 깨졌는지 해석할 수 없다.
### 8-2. 그래서 실험 0의 질문은 두 개다
```
질문 A. 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가?
↓ 답: 그렇다
질문 B. 그 공유는 무엇 덕분인가?
(a) Infinispan 이 메모리를 복제해서
(b) 둘 다 같은 DB 를 봐서
```
### 8-3. **B를 구분해야 하는 이유** — 운영 대응이 정반대다
| 상황 | (a) 복제라면 | (b) DB라면 |
|---|---|---|
| 노드 간 7800 끊김 | **세션 공유 깨짐** | **멀쩡** |
| DB 죽음 | 한동안 버팀 | **즉시 전면 장애** |
| 노드 1대 죽음 | 세션 살아남음 | 세션 살아남음 |
| 성능 병목 | 노드 간 네트워크 | **DB, 커넥션 풀** |
| 튜닝할 곳 | JGroups 설정 | **DB 인덱스, 커넥션 수** |
| 노드를 10대로 늘리면 | **복제 트래픽 폭증** | DB 부하 증가 |
**같은 증상에 정반대 처방이 나온다.** 그래서 추측이 아니라 측정으로
확정해야 했다.
---
## 9. 왜 하필 "캐시 엔트리 개수"를 셌는가
질문 B를 가르는 가장 직접적인 방법이기 때문이다.
```
keycloak-0 에만 로그인을 보낸다
└──▶ 그리고 keycloak-1 의 메모리를 들여다본다
그 세션이 들어와 있으면 → (a) 복제한 것
비어 있으면 → (b) DB 로 공유한 것
```
Keycloak은 자기 캐시에 몇 개가 들었는지를 `/metrics` 로 알려준다.
```
vendor_statistics_approximate_entries_unique{cache="sessions"} 7.0
───────────── ───
세션 캐시 7개 들어 있다
```
**측정 결과: keycloak-1은 계속 0이었다.** keycloak-0이 14개를 들고 있는
동안에도 0. 그리고 keycloak-1에 직접 로그인을 보낸 순간에만 늘었다.
```
단계 k0 k1
시작 2 0
keycloak-1 에 로그인 5회 2 5 ← k0 안 늘어남
keycloak-0 에 로그인 5회 7 5 ← k1 안 늘어남
PostgreSQL 세션 수: 12 = 7 + 5 ← 캐시 합과 정확히 일치
```
**각 노드는 자기가 처리한 것만 캐시한다. 메모리는 건너가지 않는다.**
→ 답은 **(b)**.
그리고 마지막으로 PostgreSQL 로그를 켜서, keycloak-1이 **실제로 날리는
SELECT 문**을 잡았다. 추측이 아니라는 것을 못 박기 위해서다.
---
## 10. 이 사실이 당신 운영에 뜻하는 것
| 알게 된 것 | 실무적 의미 |
|---|---|
| 세션은 DB에 있다 | **DB가 단일 장애점이다.** HA·백업 계획이 Keycloak 대수보다 중요하다 |
| 메모리는 로컬 캐시일 뿐 | Keycloak을 몇 대로 늘려도 **노드 간 트래픽은 안 는다.** 대신 DB 부하가 는다 |
| 남의 세션은 캐시 안 함 | **sticky session 은 정확성이 아니라 성능 문제다.** 없어도 동작하지만 DB를 더 친다 |
| refresh 마다 DB 읽기+쓰기 | access token 수명을 줄이면 **DB 부하가 그만큼 는다** |
| `synchronous_commit OFF` | **DB가 강제 종료되면 직전 수백 ms 갱신이 사라진다** (의도된 설계) |
| 낙관적 락 (`VERSION`) | **동시에 refresh 하면 한쪽이 진다.** 클라이언트에 재시도가 필요하다 |
---
## 11. 앞으로 할 실험과 각각이 답하는 질문
| # | 실험 | 답하는 실무 질문 | 예측 |
|---|---|---|---|
| **A-0** | ✅ 세션 복제 확인 | 정상일 때 세션은 어디 있나 | — (완료) |
| **A-1** | TCP 7800 차단 | 노드 간 네트워크가 끊기면? | 세션 공유는 **안 깨짐**. 무효화 전파가 깨질 것 |
| **A-2** | DB 정지 | **DB가 죽으면?** | **즉시 전면 장애** |
| A-2' | DB 강제 종료 | 복구하면 뭘 잃나 | 직전 수백 ms 세션 갱신 소멸 |
| **A-3** | 노드 1대 전원 차단 | **Keycloak 한 대가 죽으면?** | 세션 살아남음 |
| **A-4** | volatile 모드 비교 | 옛 방식(24 이전)은 뭐가 다른가 | A-1이 **정반대로** 치명적이 됨 |
| B-5 | 동시 refresh 경쟁 | 토큰 갱신이 겹치면? | 한쪽이 낙관적 락에서 짐 |
| — | SSO 다중 앱 | **SSO를 붙이면 뭐가 달라지나** | Keycloak 세션 하나가 전 앱을 좌우 |
**A-1이 특히 중요하다.** 통념("클러스터 포트 막으면 세션 깨짐")과
이번 측정("세션은 7800으로 안 다님")이 정면으로 어긋나므로, **둘 중 하나는
틀렸다.** 실험이 판정한다.
---
## 12. 용어 빠른 참조
| 용어 | 한 줄 |
|---|---|
| **세션** | 서버가 "이 사람 로그인했음"을 기억하는 것 |
| **SSO 세션** | Keycloak이 가진 세션. 이게 죽으면 전 앱 재로그인 |
| **access token** | 짧게 사는 JWT. 서명만으로 검증. 취소 불가 |
| **refresh token** | access token을 새로 받는 표. **세션 조회가 필요** |
| **sid** | 세션 식별자. JWT·DB·관리 API에서 **같은 문자열** |
| **Infinispan** | Keycloak **내장** 캐시 (별도 설치 아님) |
| **JGroups** | Infinispan의 노드 간 통신 라이브러리 |
| **jdbc-ping** | DB 테이블로 서로를 찾는 방식 |
| **7800** | 노드 간 통신 포트 |
| **persistent-user-sessions** | 세션을 DB에 저장하는 기능. **KC 26 기본값** |
| **`OFFLINE_USER_SESSION`** | 이름과 달리 **온라인 세션도** 여기 있다 (`offline_flag='0'`) |
| **낙관적 락** | 읽을 때 버전과 같을 때만 쓰기. 충돌은 사후 검출 |
| **kc-lab-1/2** | VM(기계) 이름 |
| **keycloak-0/1** | Keycloak 프로세스(파드) 이름 |