From 1de610815783668ed09110ff5241cda673afbc87 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 10:24:21 +0900 Subject: [PATCH] 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 --- docs/experiment-00-session-replication.md | 5 + docs/session-lab-prerequisites.md | 400 ++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 docs/session-lab-prerequisites.md diff --git a/docs/experiment-00-session-replication.md b/docs/experiment-00-session-replication.md index dc7502c..b091cd5 100644 --- a/docs/experiment-00-session-replication.md +++ b/docs/experiment-00-session-replication.md @@ -2,6 +2,11 @@ 로드맵 A-0. 이후 모든 장애 실험의 기준선이다. +> **맥락이 안 잡히면 먼저 읽을 것** — +> [`docs/session-lab-prerequisites.md`](session-lab-prerequisites.md). +> 왜 세션이 문제가 되는지, Keycloak이 세션을 어디에 두는지, 그래서 이 실험이 +> 무엇을 가르려는 것인지를 바닥부터 세워둔 문서다. + - 실행 스크립트 — [`deploy/lab/scripts/experiment-session-replication.sh`](../deploy/lab/scripts/experiment-session-replication.sh), [`experiment-cache-replication-delta.sh`](../deploy/lab/scripts/experiment-cache-replication-delta.sh), [`experiment-cache-ownership.sh`](../deploy/lab/scripts/experiment-cache-ownership.sh), diff --git a/docs/session-lab-prerequisites.md b/docs/session-lab-prerequisites.md new file mode 100644 index 0000000..42fa948 --- /dev/null +++ b/docs/session-lab-prerequisites.md @@ -0,0 +1,400 @@ +# 이 실험을 이해하기 위한 선수 지식 + +실험 결과를 먼저 들이밀었더니 맥락이 사라졌다. 이 문서는 **왜 이런 걸 +측정하고 있는지**를 바닥부터 세운다. + +읽는 순서가 곧 의존 관계다. 아는 절은 건너뛰어도 되지만, 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 프로세스(파드) 이름 |