Keycloak 세션을 6시간으로 늘렸는데 20분마다 로그아웃된다
인수받은 사내 모니터링 콘솔의 대시보드가 20~30분마다 로그인 화면으로 튕겼다. SSO(Keycloak) 세션을 6시간으로 늘려도 그대로였다. 로그인을 붙잡고 있던 건 Keycloak이 아니라 Tomcat 세션이었고, 그 세션을 굶긴 건 얼마 전 들어온 실시간 스트리밍(SSE)이었다. 코드에서 원인을 찾고, 로컬에서 재현 → 수정 → 8분 실측까지 한 뒤 고쳤다.
알림 글보다 조금 앞선, 인수 첫 주의 일이다.
(사내 인프라 특성상 서버·도메인·사람 이름은 일반화했다. 시스템 이름은 “콘솔”로 부른다.)
켜 두는 대시보드가 튕긴다
콘솔에는 서버들의 CPU·메모리·디스크를 실시간으로 그리는 대시보드가 있다. 이걸 듀얼 모니터 한쪽에 하루 종일 띄워 두는 사람들이 있는데, 그 화면이 20~30분마다 로그인 화면으로 바뀌었다. 다시 로그인하면 또 20~30분 뒤에 튕긴다.
누가 봐도 “세션이 짧다”는 증상이다. 콘솔은 Keycloak으로 로그인하니, 먼저 떠오르는 건 Keycloak의 SSO Session 시간이다. 그런데 그걸 6시간으로 늘려 둔 상태에서도 증상은 그대로였다.
Keycloak은 로그인 순간에만 등장한다
다른 작업을 하다가 이미 알게 된 사실이 있었다. 콘솔의 로그인 유지는 Keycloak 토큰이 아니라 Tomcat 서블릿 세션(HttpSession)이다.
흐름은 이렇다. Keycloak 로그인을 마치고 돌아오면, 콘솔은 사용자 정보를 HttpSession에 넣는다(SessionUser.store()). 그 뒤로는 Keycloak에 다시 묻지 않는다. 화면이 살아 있는지는 Tomcat 세션이 살아 있는지로만 정해진다.
그러니 Keycloak 세션을 6시간으로 늘려도 효과가 없는 게 당연했다. 늘려야 할 건 다른 시계였다.
여기까지는 금방 왔다. 막힌 건 다음 질문이다 — 대시보드는 계속 움직이고 있는데, 왜 서버는 “아무도 없다”고 판단할까?
대시보드는 움직이는데 서버는 아무도 없다고 본다
사수가 “SSE 커넥션 쪽을 봐라”고 귀띔해 줬다. 대시보드는 원래 60초마다 폴링으로 데이터를 받았는데, 최근 SSE(Server-Sent Events) 실시간 스트리밍이 들어왔다. 서버가 연결 하나를 열어 두고 데이터를 계속 밀어 주는 방식이다.
프런트 코드를 읽어 보니 스트림이 live 상태가 되는 순간 폴링을 끈다.
대시보드 화면: startRealtime() → 스트림이 live 가 되면 → stopPolling()
(실시간 스트림이 붙었으니 폴링은 더 필요 없다고 보고 끈다)
데이터만 보면 맞는 판단이다. 스트림이 데이터를 주니 폴링은 중복이다. 그런데 폴링은 데이터 말고 한 가지 일을 더 하고 있었다. 60초마다 서버에 새 요청을 보내서 세션의 “마지막 활동 시각”을 갱신하는 일이다.
Tomcat 세션은 새 HTTP 요청이 들어올 때 활동 시각을 갱신한다. 이미 열려 있는 스트림으로 데이터가 흘러가는 건 새 요청이 아니다. 그러니까:
페이지 열림 폴링(60초)이 세션을 계속 갱신
스트림 live 폴링 꺼짐 ← 서버가 받은 마지막 "새 요청"
… 화면은 스트림으로 계속 갱신됨 (서버 입장에선 새 요청 없음)
유휴 타임아웃 세션 만료 (Tomcat 기본 설정은 30분)
스트림이 세션 종료를 감지 → stream-reset reason=session-ended → 로그인 화면
화면은 1초도 쉬지 않고 움직이는데, 서버 시계로는 마지막 요청 뒤로 아무도 오지 않은 셈이다. 컨테이너 기본 유휴 타임아웃에 걸려 세션이 조용히 죽고, 스트림은 세션이 끝난 걸 알아채고 스스로 끊으면서 화면을 로그인으로 돌려보낸다. 20~30분이라는 증상과도 대략 맞는다.
로컬에서 세 번 확인
콘솔은 SVN에 커밋하면 곧바로 운영에 배포되고, 스테이징은 없다. 그래서 고치기 전에 내 PC에 JDK 8 + Tomcat 9 + MariaDB로 전체 스택을 올렸다. 30분을 기다릴 수는 없으니 시간을 줄여서 확인했다.
수정 전 — 1분짜리 세션으로 재현
컨테이너 세션 시간을 1분으로 줄이고, 대시보드를 열어 스트림이 붙은 상태로 90초를 기다렸다. 세션 상태를 물어보는 API가 login required를 돌려줬다. 화면은 계속 움직이고 있었는데도. 가설대로 재현됐다.
수정 후 — 내 설정이 컨테이너 기본값을 이기는지
수정본(아래)에 세션 시간을 180초로 넣고, 컨테이너 기본값은 1분 그대로 둔 채 130초를 기다렸다. authenticated: true. 코드에서 지정한 값이 컨테이너 기본값보다 우선하는 걸 확인했다.
축소 모형으로 8분 — 재연결이 세션을 살리는지
운영에서 진짜 기대는 수치는 따로 있다. 이 SSE 스트림은 70분마다 연결을 닫고 다시 붙는다(비동기 요청 타임아웃). 재연결은 새 요청이다. 그러니 세션 시간이 70분보다 길기만 하면, 대시보드를 켜 둔 동안에는 재연결이 세션을 계속 갱신해서 만료에 닿지 않아야 한다.
이것도 이론으로 두지 않고 비율만 유지한 채 줄여서 쟀다.
| 운영 | 축소 모형 | |
|---|---|---|
| 스트림 재연결 주기 | 70분 | 60초 |
| 세션 유휴 시간 | 6시간 | 90초 |
세션 확인 요청도 보내지 않고 8분을 그냥 뒀다. 스트림이 60~61초 간격으로 8번 연속 다시 붙었고, 8분 뒤에도 세션은 살아 있었다. “재연결이 세션을 반복해서 갱신한다”는 걸 눈으로 확인했다.
고친 것 — 두 파일, 몇 줄
// ServerSecrets 에 새 getter: sessionMaxInactiveSeconds()
// 기본 21600초(6시간), -Dsmon.session.maxInactiveSeconds 로 조정
// SessionUser.store() — 로그인 정보를 세션에 넣을 때 유휴 시간을 명시
session.setMaxInactiveInterval(ServerSecrets.sessionMaxInactiveSeconds());
6시간이라는 숫자보다 중요한 건 관계다. 세션 유휴 시간은 스트림 재연결 주기(70분)보다 반드시 길어야 한다. 거꾸로 되면 재연결이 오기 전에 세션이 먼저 죽어서 같은 증상이 돌아온다. 나중에 누가 숫자를 줄이지 않도록 이 조건을 운영 문서에 적어 뒀다.
세션을 늘리면 보안은?
세션을 기본값에서 6시간으로 늘리는 건, 자리를 비운 브라우저가 그만큼 오래 로그인 상태로 남는다는 뜻이기도 하다. 그래서 가장 위험한 기능부터 확인했다.
마침 커밋 직전 svn update를 하자 그동안 쌓인 42개 리비전이 한꺼번에 들어왔다. 그중에 System Reset이라는 파괴적인 기능이 있었고, 이 기능의 재인증 콜백이 SessionUser.store()를 새로 부르고 있었다. 세션 저장을 고친 내 변경과 정확히 겹치는 자리다.
코드를 따라가 보니:
- 재인증은
SessionUser.store()로 로그인한 사람이 누군지만 갱신한다. - 실제 리셋 권한은 별도 저장소가 자체 5분 TTL로 따로 관리한다(자기 타임스탬프로 계산).
session.getMaxInactiveInterval()을 읽는 코드는 전체 소스에 하나도 없다.
세션이 6시간이 되어도 System Reset의 “5분 안에 다시 인증” 경계는 그대로라는 뜻이다. 정합성 문제는 없다고 판단하고 커밋으로 넘어갔다.
커밋이 서버에서 막혔다
error: package javax.servlet.http does not exist
커밋이 거부됐다. 로컬에서는 정상 컴파일되고, SVN 명령줄과 IDE 둘 다 똑같이 막혔다. 클라이언트 문제가 아니다.
이 콘솔은 서버 쪽 pre-commit 훅이 바뀐 .java를 컴파일해 보고 통과해야 커밋을 받는다. 그 훅의 컴파일 classpath에 servlet-api.jar가 들어 있는 lib_build/가 빠져 있었던 것이다. 이 훅은 서버에서 돌기 때문에 클라이언트에서 우회할 방법이 없다. 두 달 전 Keycloak 작업 때도 같은 모양으로 막힌 적이 있다.
서버 권한이 없으니 전임자에게 “lib_build에 servlet-api 있는 거 맞죠?”라고 확인을 받아 범위를 좁혀 전달했다. 전임자가 훅을 고쳤고, 그날 커밋이 통과했다. 커밋 메시지는 이렇게 남겼다 — Extend session idle timeout past SSE reconnect interval. 커밋과 동시에 배포됐다.
8일 관찰, 종결
배포 뒤 8일 동안 듀얼 모니터의 상시 대시보드가 한 번도 로그인 화면으로 튕기지 않았다. 대시보드를 켜 둔 동안에는 70분마다 재연결이 세션을 갱신하니 6시간 유휴 시간에 닿지 않는다. 의도한 대로다. 이 확인을 마치고 이슈를 닫았다.
마치며
고친 코드는 두 파일, 몇 줄이다. 시간이 든 건 “어느 시계를 늘려야 하나”를 찾는 데였다.
진짜로 배운 것
- “로그인이 풀린다”면 먼저 로그인을 누가 들고 있는지 본다. SSO 세션과 앱 세션은 다른 시계다. 설정을 바꾸기 전에, 그 설정이 정말 그 동작을 지배하는지부터 확인한다.
- 기능을 끌 때는 그 기능이 덤으로 하던 일을 찾는다. 폴링은 데이터를 받으면서 세션도 살려 두고 있었다. 실시간 스트림(SSE, WebSocket)으로 바꾸면 그 덤이 소리 없이 사라진다.
- 숫자 하나가 아니라 숫자 사이의 관계를 고정한다. 세션 유휴 시간 > 재연결 주기. 운영 수치를 그대로 기다릴 수 없으면 비율을 유지한 축소 모형으로 잰다.
- 수명을 늘릴 땐 가장 위험한 기능의 경계부터 확인한다. 이번엔 System Reset이 세션과 무관한 5분 재인증을 따로 갖고 있어서 안전했다. 그게 없었다면 6시간은 다시 따져 봐야 했다.
다음 글은 알림 글에서 예고한 대로, 두 달 전의 Keycloak realm 사건이다. 이번 글에서 “같은 모양으로 막힌 적이 있다”고 한 그 일이다.