엔지니어링JWT·쿠키·프런트엔드 로그인 만료 정책 통합
· 알고수
- #session
- #jwt
- #cookie
- #debugging
- #nestjs
"세션이 너무 짧아요"
사용자 피드백이 하나 들어왔습니다.
"다른 거 하다가 돌아오면 또 로그인해야 하는 게 귀찮아요."
기획한 로그인 토큰(JWT)의 수명은 2시간이었습니다. 그리고 sliding session, 즉 사용자가 활동하는 동안에는 토큰을 자동으로 새로 발급해 만료 시점을 계속 뒤로 미는 방식이 의도된 동작이었습니다. 2시간이면 충분한데, 왜 자꾸 끊길까요?
파고들어 보니 운영 서버의 환경변수에는 기획값 2시간이 아니라 초기값 7일이 그대로 남아 있었습니다. 토큰 수명이 오히려 길었는데도 세션은 1시간 만에 끊겼습니다.
결론부터 말하면, 4개 레이어가 각자 다른 시계를 쓰고 있었습니다. AI 에이전트 12개가 레이어를 나눠 맡아 작업하는 구조였습니다. 그래서 이런 불일치는 누군가 전체를 한눈에 보지 않으면 잡기 어려운 버그였습니다.
문제
기획한 JWT 수명(TTL)은 2시간이고 운영 환경변수에는 초기값 7일이 남아 있었는데, 정작 1시간 만에 로그아웃됐다. 4개 레이어(서버 환경변수·쿠키·sliding 갱신·프론트 타이머)가 각자의 시계를 갖고 있었다.
결정
흩어진 4개의 시계를 하나의 기준(서버 환경변수)으로 통일했다. 상수만 바꾸지 않고, SessionPolicyModule이라는 모듈로 구조를 바꿨다.
결과
세션 수명을 정하는 곳이 4곳에서 1곳으로 줄었다. 환경변수 하나를 바꾸면 쿠키·갱신·프론트까지 자동으로 따라오고, 테스트 24건을 더했다.
4개의 시계, 4개의 진실
문제를 정리하면 이렇습니다.
7일
JWT TTL
운영 환경변수 (기획: 2h)
1시간
Cookie maxAge
하드코딩 상수
5분
Sliding 임계값
만료 5분 전에만 갱신
65분
프론트 타이머
강제 로그아웃 판정
어느 한 곳을 고쳐도 다른 레이어가 세션을 끊는 구조였습니다. 하나씩 보겠습니다.
Layer 1: 운영 환경변수에 남아 있던 옛 값
토큰 수명은 JWT_EXPIRES_IN이라는 환경변수로 정합니다. 운영 서버에서는 이 값이 Sealed Secret에 들어 있었습니다. Sealed Secret은 Kubernetes의 비밀값을 암호화해서 Git에 올려 둘 수 있게 해 주는 도구입니다.
// aether-gitops의 Sealed Secret에 암호화 저장된 값
JWT_EXPIRES_IN=7d // 기획값은 2h인데?여기에 초기 설정값 7d가 그대로 남아 있었습니다. 토큰 수명은 비밀이 아닌데 비밀 저장소에 갇혀 있었던 겁니다. 그래서 값 하나를 고치려 해도 암호화 도구(kubeseal)를 돌리고 원본 평문 .env를 되살리는 절차가 필요했습니다. 바꾸기가 쓸데없이 어려웠습니다.
Layer 2: 쿠키 수명(maxAge) 하드코딩
토큰은 브라우저 쿠키에 담겨 오갑니다. 그런데 쿠키의 수명은 코드에 숫자로 박혀 있었습니다.
// services/gateway/src/auth/cookie.util.ts (수정 전)
const COOKIE_MAX_AGE_SECONDS = 60 * 60; // 1시간 고정JWT 수명이 아무리 길어도 브라우저는 정확히 1시간 뒤 쿠키를 버립니다. JWT와 쿠키가 같은 "세션 수명"을 서로 다른 곳에서 따로 정하고 있었던 거죠. JWT 수명을 2h로 잡든 7d로 잡든 1시간 만에 끊기던 원인이 바로 이것이었습니다.
Layer 3: Sliding 갱신이 사실상 꺼져 있었음
요청이 들어올 때 토큰을 새로 발급하는 코드(모든 요청을 받는 관문 서비스 Gateway의 인터셉터)는 "만료까지 얼마 남았을 때부터 갱신할지"를 기준값으로 갖고 있습니다. 이 값이 5분이었습니다.
// services/gateway/src/auth/token-refresh.interceptor.ts (수정 전)
const REFRESH_THRESHOLD_SECONDS = 5 * 60; // 만료 5분 전에만 갱신2시간짜리 토큰이라면 앞 1시간 55분 동안은 요청이 와도 갱신이 일어나지 않습니다. 갱신이 거의 일어나지 않으니, 세션은 결국 1시간짜리 쿠키(Layer 2)나 65분 프론트 타이머(Layer 4)에 먼저 걸려 끊겼습니다. "활동 중에는 세션이 유지된다"는 기획 의도가 사실상 동작하지 않았습니다.
Layer 4: 프론트엔드가 서버보다 먼저 세션을 끊음
// frontend/src/hooks/useSessionKeepAlive.ts (수정 전)
const SESSION_TIMEOUT_MS = 65 * 60 * 1000; // 65분JWT는 2시간인데 프론트엔드는 65분이 지나면 로그아웃시킵니다. 서버에서는 아직 유효한 세션을 클라이언트가 먼저 끊어버리는 거죠.
버그의 핵심: 기준 4개
문제는 개별 값이 아니라 구조였습니다.
수정 전: 4개의 독립적인 시계
4곳이 "세션 수명"이라는 같은 의미의 값을 각자 관리하고 있었습니다. 환경변수 하나를 바꿔도 나머지 3곳은 따라오지 않습니다. 기준값을 한 곳에서만 관리해야 한다는 원칙, 단일 원천(SSoT)이 깨져 있었던 겁니다. 이게 이번 버그의 뿌리였습니다.
해결: 하나의 시계로 통일
처음에는 하드코딩된 값을 하나씩 올바른 값으로 바꾸려 했습니다. 그런데 총괄 에이전트가 이렇게 피드백했습니다.
"세션을 하드코딩하지 말고 모듈화해."
맞는 말이었습니다. 숫자만 바꾸면, 다음에 정책이 바뀔 때 또 4곳을 찾아다녀야 합니다. 근본 치료가 필요했습니다.
SessionPolicyModule: 단일 원천
서버 환경변수 하나만 바꾸면 서버와 클라이언트가 모두 자동으로 따라오는 구조를 설계했습니다.
수정 후: 서버 env가 유일한 원천
핵심은 환경변수 5개가 모든 것을 정한다는 점입니다.
| 환경변수 | 값 | 설명 |
|---|---|---|
| JWT_EXPIRES_IN | 2h | Access Token 수명 |
| SESSION_REFRESH_THRESHOLD | 1h | Sliding 갱신 임계값 |
| SESSION_HEARTBEAT_INTERVAL | 10m | 프론트가 세션 상태를 확인하는 주기(heartbeat) |
| SESSION_TIMEOUT_BUFFER | 5m | 토큰 수명에 더하는 여유 시간 (프론트 세션 타임아웃 = 수명 + 버퍼) |
| JWT_DEMO_EXPIRES_IN | 2h | 체험용 데모 계정 전용 토큰 수명 |
데이터 흐름
- 서버 환경변수: JWT_EXPIRES_IN=2h 등 5종
- SessionPolicyService: 환경변수를 밀리초로 바꿔 한 곳에서 보관
- 서버 쪽 사용처에 주입: JwtModule · Interceptor · OAuthService(소셜 로그인 처리)
- 공개 API: GET /auth/session-policy
- 프론트가 가져감: 앱 시작 시 1회 호출, 실패하면 기본값(DEFAULT) 사용
레이어별 수정
Layer 1 해결: Deployment env로 덮어쓰기
Sealed Secret을 다시 암호화하는 대신 Kubernetes 규칙을 활용했습니다. 서버 실행 설정(Deployment)에 직접 적는 env: 블록은, Secret에서 한꺼번에 불러오는 envFrom:보다 우선합니다. 아래는 배포 설정만 따로 모아 둔 저장소(aether-gitops)의 Gateway 설정입니다.
# aether-gitops: algosu/base/gateway.yaml
env:
- name: JWT_EXPIRES_IN
value: "2h"
- name: SESSION_REFRESH_THRESHOLD
value: "1h"
- name: SESSION_HEARTBEAT_INTERVAL
value: "10m"
- name: SESSION_TIMEOUT_BUFFER
value: "5m"토큰 수명은 비밀이 아닙니다. 비밀 저장소에 가둘 이유가 없습니다. 매니페스트에 평문으로 두는 게 구조적으로 맞습니다.
Layer 2 해결: 토큰의 만료 시각을 기준으로
쿠키 수명을 상수 대신 JWT 토큰 안에 적힌 만료 시각(exp claim)에서 계산합니다.
// services/gateway/src/auth/cookie.util.ts (수정 후)
const decoded = jwt.decode(token) as { exp?: number } | null;
if (decoded?.exp) {
const remainingMs = decoded.exp * 1000 - Date.now();
if (remainingMs > 0) {
maxAge = Math.floor(remainingMs / 1000);
}
}이제 JWT 수명이 바뀌면 쿠키 수명도 자동으로 맞춰집니다. Sliding 갱신으로 새 토큰이 나올 때마다 쿠키도 함께 갱신되고요. 토큰을 읽지 못하면 안전하게 1시간을 쓰고, 그 사실을 구조화된 로그로 남깁니다.
Layer 3 해결: Sliding 임계값 5분 → 1시간
// services/gateway/src/auth/token-refresh.interceptor.ts (수정 후)
constructor(
private readonly sessionPolicy: SessionPolicyService,
// ...
) {}
// 하드코딩 상수 대신 정책 서비스에서 주입
const thresholdMs = this.sessionPolicy.getRefreshThresholdMs();이제 2시간 수명의 절반(1시간)이 지나면 요청마다 토큰을 갱신합니다. 활동하는 사용자는 사실상 끊기지 않습니다.
"요청마다 항상 갱신"은 기각했습니다. 매번 JWT를 다시 서명하는 CPU 비용과, 모든 응답에 Set-Cookie 헤더를 붙이는 비용 때문입니다. 50% 임계값이 성능과 사용자 경험 사이의 타협점입니다.
Layer 4 해결: 서버 정책을 가져오기
아래 기본값은 서버에서 정책을 받아 오지 못했을 때만 쓰는 값입니다.
// frontend/src/lib/session-policy.ts
export const DEFAULT_SESSION_POLICY: ClientSessionPolicy = {
accessTokenTtlMs: 60 * 60 * 1000, // 1h (서버 2h보다 짧게)
heartbeatIntervalMs: 10 * 60 * 1000, // 10m
sessionTimeoutMs: 65 * 60 * 1000, // 1h + 5m
refreshThresholdMs: 30 * 60 * 1000, // 30m
};
export async function fetchSessionPolicy(): Promise<ClientSessionPolicy> {
const res = await fetch('/api/auth/session-policy');
// 실패 시 DEFAULT_SESSION_POLICY fallback
}프론트엔드는 앱이 시작될 때 GET /auth/session-policy를 한 번 호출합니다. 하드코딩이 사라졌습니다. 서버 환경변수가 바뀌면, 프론트엔드는 다음 시작 때 새 정책을 자동으로 받아옵니다.
기본값의 세션 타임아웃 65분은 토큰 수명 1시간에 여유 버퍼 5분을 더한 값입니다(1h + 5m). 호출이 실패할 때 쓰는 이 기본값(DEFAULT)은 서버 기본값(2h)보다 일부러 짧게 잡았습니다. 서버보다 먼저 만료 처리해서, 갑작스러운 인증 실패(401)를 막는 안전장치입니다.
Before / After
| 항목 | 수정 후 | 변화 |
|---|---|---|
| JWT TTL | 2h | 7d → 2h |
| Cookie maxAge | 동적 | 1h 고정 → JWT exp 연동 |
| Sliding 임계값 | 1h | 5분 → 1시간 (TTL 50%) |
| 프론트 타이머 | 정책 fetch | 65분 고정 → 서버 정책 연동 |
| 항목 | 수정 전 | 수정 후 |
|---|---|---|
| 기준값을 정하는 곳 | 4곳 (각자 하드코딩) | 1곳 (서버 환경변수) |
| 환경변수 변경 시 | 4곳 수동 동기화 필요 | 자동 전파 |
| 신규 외부 의존 | 없음 | 없음 (자체 duration 파서) |
| 테스트 추가 | 없음 | Gateway 8건 + Frontend 16건 |
자체 Duration 파서
SessionPolicyService에는 2h, 30m, 500ms 같은 문자열을 밀리초로 바꾸는 파서가 들어 있습니다. 이 일을 하는 ms 패키지를 직접 의존하지 않은 데는 이유가 있습니다.
ms는 지금 다른 패키지가 함께 끌고 온 간접 의존성(transitive dependency)입니다. 우리 package.json에는 없는데, 패키지 매니저가 최상위로 끌어올려(hoisting) 둔 덕분에 쓸 수 있을 뿐입니다. 이런 상태에 기대면, 패키지 매니저가 의존성 트리를 다시 짤 때 파서가 갑자기 사라질 수 있습니다. 그래서 파서를 직접 내장해 이 의존 자체를 없앴습니다. 덕분에 파서가 언제든 문제를 바로 드러내는 fail-fast 동작을 우리가 보장할 수 있습니다. 지원 형식은 Nd, Nh, Nm, Ns, Nms와 순수 숫자(초 단위)입니다.
교훈 체크리스트
이 버그에서 얻은 교훈을 체크리스트로 정리했습니다.
상수 교체가 아닌 구조 전환
이 버그의 처음 계획은 "하드코딩된 숫자 4개를 올바른 숫자로 바꾸자"였습니다. 그걸로도 당장은 고쳐졌을 겁니다.
하지만 다음에 세션 정책이 바뀌면? 또 4곳을 찾아다녀야 합니다. 그리고 누군가는 한 곳을 빼먹을 겁니다. 상수 교체는 버그 수정이고, 구조 전환은 버그 예방입니다.
SessionPolicyModule을 도입하며 바뀐 파일은 19개, 추가한 테스트는 24건이었습니다. 적지 않은 작업이었지만, 이제 세션 정책을 바꾸려면 Deployment yaml의 env 한 줄만 고치면 됩니다. 서버도, 클라이언트도, 쿠키도 자동으로 따라옵니다.
환경변수로 정하는 정책 값 근처에 "우연히 같은 의미"를 가진 상수를 두지 마세요. 의미가 같으면 반드시 같은 원천을 참조해야 합니다. 환경변수 하나를 바꿨을 때 연관 상태가 모두 따라오지 않는다면, 그 지점이 다음 버그의 씨앗입니다.