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>
18 KiB
이 실험을 이해하기 위한 선수 지식
실험 결과를 먼저 들이밀었더니 맥락이 사라졌다. 이 문서는 왜 이런 걸 측정하고 있는지를 바닥부터 세운다.
읽는 순서가 곧 의존 관계다. 아는 절은 건너뛰어도 되지만, 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 프로세스(파드) 이름 |