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