시작하며;
최근 사내 보안 취약점 대응의 일환으로 '토큰 관리 체계 개선' 업무를 살펴 보고 있다. 단순히 설정 몇 개 바꾸고 끝날 일이 아니다. 키 하나 잘못 건드렸다가 서비스 전체가 멈추거나 사용자가 튕겨 나가는 대참사를 막기 위해, '어떻게 하면 사고 없이 안전하게 되돌릴지(롤백)' 와 '우리 유저들이 로그아웃되는 일은 없을지' 를 끝까지 고민해야 하는 꽤나 까다로운 작업이었다.
본격적인 작업에 앞서, 관련 로직을 작은 스케일로 축소하여 사이드 프로젝트에 선제적으로 적용하며 메커니즘을 손에 익히는 과정을 거쳤다. 마침, 사이드 프로젝트도 마찬가지로 JWT 시크릿 키를 환경 변수에 하드코딩하여 관리하고 있었다.
작업 시작 전, 관련 업무에 대해 전달받은 참고 자료와 피드백에 이러한 리스크를 방지하기 위한 전략이 포함되어 있었다. 그 중 하나인 AWS Secrets Manager 를 활용한 무중단 키 로테이션 구조를 설계 및 도입했다.
기존 방식;
- 키 교체 시 즉시 무효화
- 시크릿 키 변경과 동시에 모든 기존 JWT 검증 실패 → 사용자 강제 로그아웃
- 다운타임 발생
- 키 변경을 위해 서비스 재시작 및 배포 파이프라인 필요
- 보안 취약점
- 환경변수에 평문으로 저장된 키가 로그, 설정 파일 등을 통해 노출될 위험
- 감사 추적 불가
- 누가 언제 키를 변경했는지 이력 관리가 어려움
개선된 아키텍처: 듀얼 키 검증 방식;
핵심 아이디어는 Secrets Manager에 두 개의 키를 저장하고, CURRENT → PREVIOUS 순서로 폴백 검증하는 구조다.

Secrets Manager 에는 다음과 같은 형태로 값을 저장한다.
{
"CURRENT": "신규 시크릿 키 (새로 발급되는 토큰에 사용)",
"PREVIOUS": "이전 시크릿 키 (기존 토큰 검증용)",
"EXPIRES_IN": "3600"
}
폴백 검증 전략;
실제 운영 환경에서는 아주 짧은 찰나의 불일치 구간이 반드시 발생한다. 키 로테이션은 단일 프로세스 내부에서만 일어나는 작업이 아니다. Secrets Manager 의 값이 변경되고, 각 서버 인스턴스의 캐시가 갱신된다. 그리고 로드밸런서를 통해 트래픽이 분산되는 과정에서 모든 인스턴스가 동일한 시점에 동일한 키를 인지하는 것은 불가능하다.
특히 다음과 같은 찰나의 순간이 발생할 수 있다.
- Secrets Manager 에는 이미 새로운 CURRENT 키가 반영되었지만
- 특정 서버 인스턴스는 아직 이전 캐시(CURRENT = old) 를 사용 중인 상태
- 이 시점에 새 키로 발급된 JWT 가 유입되면
- 1, 2단계 캐시 검증은 모두 실패하게 된다
이때 즉시 인증 실패를 반환하면, 실제로는 유효한 토큰임에도 불구하고 사용자는 오류를 경험하게 된다.
이를 대비한 4단계 설계;
이러한 순간적인 불일치를 흡수하기 위해 다음과 같은 전략을 사용한다.
- 캐시된 CURRENT 키로 1차 검증
- 캐시된 PREVIOUS 키로 2차 검증
- 두 검증 모두 실패한 경우에만
- 캐시 미스 또는 로테이션 직후 상황으로 판단
- Secrets Manager 를 강제 조회하여 최신 키를 가져온다
- 최신 CURRENT / PREVIOUS 키로 재검증
검증 코드 예시
async verify(token: string): Promise<AuthTokenPayload | null> {
const secrets = await this.secretService.getSecrets();
// 1단계: 캐시된 CURRENT 키로 검증
const currentResult = await this.verifyWithKey(token, secrets.current);
if (currentResult) return currentResult;
// 2단계: 캐시된 PREVIOUS 키로 검증
if (secrets.previous) {
const previousResult = await this.verifyWithKey(token, secrets.previous);
if (previousResult) return previousResult;
}
// 3단계: 캐시 미스 가능성 → 최신 시크릿 fetch
const freshSecrets = await this.secretService.getSecrets(true);
// 4단계: 새 키로 재검증
const freshCurrentResult = await this.verifyWithKey(token, freshSecrets.current);
if (freshCurrentResult) return freshCurrentResult;
if (freshSecrets.previous) {
return await this.verifyWithKey(token, freshSecrets.previous);
}
return null;
}
마무리하며;
회사에서 다루는 대규모 시스템은 이보다 훨씬 복잡하다. 자동 키 로테이션, 키 버저닝, 장애 상황에서의 롤백 전략 등 추가로 고려해야 할 요소들이 많다. 이번 구현은 그런 복잡한 요구사항을 모두 포괄하기보다는, 핵심 개념을 작은 단위로 직접 구현해보는 데 초점을 맞췄다.
실제로도 현재 단계에서는 AWS Secrets Manager 의 자동 로테이션 기능을 사용하지 않고, 콘솔이나 CLI 를 통해 시크릿 값을 수동으로 교체하는 방식으로 대체했다. 아직 이어서 해야 할 작업들이 많지만, 처음부터 모든 것을 완성하려 하기보다는 어떻게 설계해야 하는지에 대한 감을 익히는 수준으로 범위를 의도적으로 제한했다.
'AWS' 카테고리의 다른 글
| FSR vs Warm Pool (0) | 2025.11.20 |
|---|---|
| [AWS] VPC 실습 #1 (0) | 2021.06.26 |
| [AWS] VPC 를 구성하는 요소 (0) | 2021.06.26 |