회사에서 여러 솔루션을 운영하고 있었는데,
각 시스템마다 별도로 로그인해야 하는 불편함을 해소하기 위해서
그리고 개발팀 입장에서도 각 시스템마다 인증 로직을 중복 관리하는 게 비효율적이었다.
이에 여러 솔루션을 한번의 로그인으로 진행해야하는 작업이 내게 주어졌고 ,
JWT는 이전에 토이 프로젝트를 진행하면서 한번 아주 그냥 맛보기 처럼 해본 기억이 있긴 한데,,
정확히 어떤 방식인지 잘 모르기도 했었고 생소한 기술이었다.
아무튼 그래서 이런 저런 이유로 JWT 기반 SSO(Single Sign-On) 시스템을 구축하게 되었다.
처음에는 "JWT로 SSO? 그냥 토큰 발급하고 검증하면 되는 거 아냐?"라고 생각했는데
막상 구현하려니 생각보다 고려할 게 정말 많았다.
이 글에서는 내가 직접 겪으면서 배운 것들,
그리고 최종적으로 어떻게 안정적인 SSO 시스템을 만들 수 있었는지를 공유하려고 한다.
세션 vs JWT, 뭘 써야 하나?
기존에는 세션 기반 인증을 사용하고 있었다. 각 서버마다 독립적인 세션을 관리하고 있었는데,
이걸 SSO로 통합하려니 몇 가지 선택지가 있었다:
- 공유 세션 저장소 (Redis): 모든 서버가 하나의 Redis를 바라보게
- 중앙 인증 서버 (CAS 같은): 매번 인증 서버에 확인
- JWT: 토큰 자체에 정보를 담아서 분산 처리
결국 JWT + DB 저장소를 선택했다.
이유인 즉슨,
- Redis 인프라를 새로 구축할 여력이 없었다. (고객사 개발 환경이나 솔루션 환경이 적합하지 않다..?)
- 매번 인증 서버에 요청하는 건 부하가 걱정됨.
- JWT는 각 서버에서 독립적으로 검증 가능하고,
- DB에 메타데이터를 저장하면 즉시 폐기도 가능하니
JWT의 장점(분산 검증)과 세션의 장점(즉시 폐기)을 둘 다 가져가는 방식이라 생각이 되었다.
"JWT를 Access Token 형태로 발급하되,
서버 측 DB에 JTI·Fingerprint·만료정보·폐기상태를 저장하여
토큰 도난 방지(IP/UA 바인딩), 즉시 폐기, 재사용 방지 등이
가능한 Stateful JWT 구조로 SSO를 구축했다."
사실 참 구현은 어찌저찌 했는데 , 누군가한테 내가 생각하는 방식을 설명하는 것이 참 어려웠다.
왜 Refresh Token은 사용하지 않았는가?
좀 찾아보니 Access Token과 Refresh Token? 이 있었고 , 보통 두개를 전부 활용하는것이 정배? 인데,
구글이나 네이버 같은 엔터프라이즈 OAuth 구조에서는
Access Token + Refresh Token 조합이 거의 정석이다.
나는 이것에 대해 오히려 불필요하지 않은가 라고 느꼈다.. 굳이 이렇게까지?
"애초에 세션이 30분 만료이기도 하고 , 세션은 세션대로 연장 시키고 ,
access token expired가 되면 그냥 로그인을 다시 시키면 되는게 아닌가?
굳이 refresh token까지 만들고 세션은 또 세션대로 연장시키고 , 테이블은 많아지고,
엔터프라이즈한 엄청난걸 만들지 않는데 그런 공수가 필요할까?"
이 문제로 동기와 많이 설왕설래도 있었고 참 이래저래 많은 일이 있었는데 ,
결국 우리 환경·우리 시스템에 맞는 현실적 선택을 하는 것이 더 중요하다고 생각했다.
여기에 쓰긴 너무 긴 얘기니 언젠가 말할 기회가 생기면 좋겠다.
그래서 최종적으로 다음과 같은 구조를 만들었다
┌──────────────────┐
│ 인증 허브 서버 │ ← JWT 발급만 담당
│ (Auth Hub) │ - 로그인 처리
└────────┬─────────┘ - 토큰 생성
│ - DB 저장
│ JWT Token
├────────────┬────────────┐
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ 서비스A │ │ 서비스B │ │ 서비스C │ ← 검증만
└────────┘ └────────┘ └────────┘ - 서명 확인
- DB 조회
↓ ↓ ↓ - 세션 생성
┌──────────────────────────────┐
│ 공유 DB (MariaDB) │
│ SSO_JWT_TOKEN 테이블 │
└──────────────────────────────┘
핵심 원칙은
- 발급은 오직 인증 허브만: 다른 서비스는 절대 토큰을 만들지 않는다
- 검증은 모든 서비스: 각자 독립적으로 검증하고 세션 생성
- 상태는 DB에: 즉시 폐기가 필요하면 DB 플래그만 바꾸면 됨
구현 과정
JWT 이해하기
JWT는 세 부분으로 나뉜다:
eyJhbGci... . eyJzdWIi... . SflKxwRJ...
↑ Header ↑ Payload ↑ Signature
Header 어떤 알고리즘으로 서명했는지
{
"alg": "HS256",
"typ": "JWT"
}
Payload 실제 데이터
{
"jti": "550e8400-e29b-41d4-a716-446655440000", // 토큰 ID
"sub": "user123", // 사용자 ID
"iat": 1699200000, // 발급 시간
"exp": 1699203600, // 만료 시간
"fingerprint": "a1b2c3d4e5f6..." // 보안 지문
}
Signature 위변조 방지
HMACSHA256(
base64(header) + "." + base64(payload),
secret_key
)
이 서명 부분이 핵심이다. 서명이 있어야 누군가 payload를 마음대로 바꿀 수 없다.
토큰 발급 구현
인증 허브 서버에서만 토큰을 발급하도록 했다. 처음에는 간단하게 생각했는데, 막상 코드를 짜보니 신경 쓸 게 많았다.
public JwtTokenIssueResult issueToken(User user, HttpServletRequest request) {
// 1. 고유 토큰 ID 생성 (UUID)
String tokenId = UUID.randomUUID().toString();
// 2. 시간 정보 설정
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiresAt = now.plusMinutes(60); // 60분 후 만료
// 3. 핵심: Fingerprint 생성
String clientIp = getClientIp(request);
String userAgent = request.getHeader("User-Agent");
String fingerprint = DigestUtils.sha256Hex(clientIp + "|" + userAgent);
// 4. Payload 구성
JwtTokenPayload payload = JwtTokenPayload.builder()
.tokenId(tokenId)
.userId(user.getUserId())
.issuedAt(now)
.expiresAt(expiresAt)
.fingerprint(fingerprint) // ← 이게 핵심!
.build();
// 5. JJWT 라이브러리로 서명된 토큰 생성
String token = Jwts.builder()
.setId(tokenId)
.setSubject(user.getUserId())
.setIssuedAt(toDate(now))
.setExpiration(toDate(expiresAt))
.claim("fingerprint", fingerprint)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
// 6. DB에 메타데이터 저장
tokenMapper.insertToken(TokenEntity.builder()
.tokenId(tokenId)
.userId(user.getUserId())
.tokenHash(DigestUtils.sha256Hex(token)) // 토큰 전체 해시
.issuedAt(now)
.expiresAt(expiresAt)
.clientIp(clientIp)
.clientAgent(userAgent)
.revokedYn("N")
.build());
return new JwtTokenIssueResult(token, expiresAt);
}
- Fingerprint가 생명이다: 단순히 토큰만 발급하면 누가 가져가도 쓸 수 있다. IP와 User-Agent를 조합해서 해시를 만들고, 이걸 토큰에 박아넣었다. 나중에 검증할 때 이게 다르면 탈취로 판단한다.
- UUID를 jti로 쓴 이유: JWT의 jti (JWT ID) 클레임에 UUID를 넣었다. 이게 DB와 연결되는 기본키 역할을 한다. 나중에 "이 토큰 무효화해!"라고 할 때 이 ID로 찾는다.
- 토큰 전체를 해시로 저장: 토큰 자체를 DB에 저장하면 유출 위험이 있어서, SHA-256 해시만 저장했다. 검증할 때 들어온 토큰을 해시해서 DB와 비교한다.
토큰 검증
이제 각 서비스에서 토큰을 검증해야 했다.
public JwtTokenPayload validateToken(String token, HttpServletRequest request) {
// 1단계: JWT 파싱 및 서명 검증
Claims claims;
try {
claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.setAllowedClockSkewSeconds(60) // 시계 오차 허용
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new JwtValidationException("토큰이 만료되었습니다");
} catch (SignatureException e) {
throw new JwtValidationException("서명이 유효하지 않습니다");
}
String tokenId = claims.getId();
// 2단계: DB에서 토큰 정보 조회
TokenEntity entity = tokenMapper.selectByTokenId(tokenId);
if (entity == null) {
throw new JwtValidationException("등록되지 않은 토큰입니다");
}
// 3단계: 폐기 여부 확인
if ("Y".equals(entity.getRevokedYn())) {
throw new JwtValidationException("폐기된 토큰입니다");
}
// 4단계: 만료 확인 (이중 체크)
if (entity.getExpiresAt().isBefore(LocalDateTime.now())) {
throw new JwtValidationException("만료된 토큰입니다");
}
// 5단계: 핵심! Fingerprint 검증
String currentIp = getClientIp(request);
String currentAgent = request.getHeader("User-Agent");
String currentFingerprint = DigestUtils.sha256Hex(currentIp + "|" + currentAgent);
String tokenFingerprint = claims.get("fingerprint", String.class);
if (!currentFingerprint.equals(tokenFingerprint)) {
// 토큰 탈취 의심! 즉시 폐기
revokeToken(tokenId, "Fingerprint 불일치 - 토큰 탈취 의심");
log.warn("⚠️ 토큰 탈취 감지! TokenID: {}, 발급IP: {}, 사용IP: {}",
tokenId, entity.getClientIp(), currentIp);
throw new JwtValidationException("토큰 바인딩 검증 실패");
}
// 6단계: 사용 이력 업데이트
tokenMapper.updateLastUsed(tokenId, currentIp, currentAgent, LocalDateTime.now());
// 모든 검증 통과!
return JwtTokenPayload.fromClaims(claims);
}
DB 테이블 설계
토큰 메타데이터를 저장할 테이블이 필요했다. 처음엔 간단하게 만들었다가, 운영하면서 컬럼이 계속 추가되었다.
CREATE TABLE SSO_JWT_TOKEN (
-- 기본 정보
TOKEN_ID VARCHAR(64) NOT NULL COMMENT 'JWT의 jti 클레임',
USER_ID VARCHAR(50) NOT NULL COMMENT '사용자 ID',
TOKEN_HASH CHAR(64) NOT NULL COMMENT '토큰 SHA-256 해시',
-- 시간 관련
ISSUED_AT DATETIME NOT NULL COMMENT '발급 시간',
EXPIRES_AT DATETIME NOT NULL COMMENT '만료 시간',
LAST_USED_AT DATETIME COMMENT '마지막 사용 시간',
-- 보안 추적 (이게 중요!)
CLIENT_IP VARCHAR(64) COMMENT '발급 당시 IP',
CLIENT_AGENT VARCHAR(255) COMMENT '발급 당시 User-Agent',
LAST_USED_IP VARCHAR(64) COMMENT '최근 사용 IP',
LAST_USED_AGENT VARCHAR(255) COMMENT '최근 사용 Agent',
-- 폐기 관리
REVOKED_YN CHAR(1) NOT NULL DEFAULT 'N',
REVOKED_AT DATETIME COMMENT '폐기 시간',
REVOCATION_REASON VARCHAR(200) COMMENT '폐기 사유',
CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (TOKEN_ID),
UNIQUE KEY UK_TOKEN_HASH (TOKEN_HASH),
KEY IDX_USER_ID (USER_ID),
KEY IDX_EXPIRES_AT (EXPIRES_AT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- TOKEN_HASH에 UNIQUE 제약: 같은 토큰이 중복 저장되는 걸 막기 위해
- CLIENT_IP와 LAST_USED_IP 분리: 발급 장소와 사용 장소를 추적해야 이상 징후를 발견할 수 있다
- REVOCATION_REASON: 나중에 감사할 때 "왜 폐기됐지?" 알 수 있어야 한다
인터셉터로 자동 로그인
매번 컨트롤러에서 검증하는 건 비효율적이어서, Spring Interceptor로 만들었다.
@Component
public class JwtAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1. 세션이 이미 있으면 통과
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("user") != null) {
return true;
}
// 2. JWT 토큰 찾기 (우선순위별로)
String token = extractToken(request);
if (token == null) {
// 토큰 없음 → 로그인 페이지로
redirectToLogin(request, response);
return false;
}
try {
// 3. 토큰 검증
JwtTokenPayload payload = tokenValidator.validateToken(token, request);
// 4. DB에서 사용자 정보 조회
User user = userService.getUserById(payload.getUserId());
// 5. 세션 자동 생성! (핵심)
HttpSession newSession = request.getSession(true);
newSession.setAttribute("user", user);
newSession.setAttribute("loginTime", LocalDateTime.now());
// 6. 쿠키 갱신 (보안 설정 포함)
Cookie cookie = new Cookie("ssoJwtToken", token);
cookie.setHttpOnly(true); // JavaScript 접근 차단
cookie.setSecure(true); // HTTPS만 전송
cookie.setPath("/");
cookie.setMaxAge(3600); // 1시간
response.addCookie(cookie);
return true;
} catch (JwtValidationException e) {
// 검증 실패 → 로그인 페이지로
log.warn("JWT 검증 실패: {}", e.getMessage());
redirectToLogin(request, response);
return false;
}
}
private String extractToken(HttpServletRequest request) {
// 우선순위 1: Authorization 헤더
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
// 우선순위 2: 커스텀 헤더
String header = request.getHeader("X-SSO-Token");
if (header != null) {
return header;
}
// 우선순위 3: URL 파라미터 (콜백용)
String param = request.getParameter("jwtToken");
if (param != null) {
return param;
}
// 우선순위 4: 쿠키
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("ssoJwtToken".equals(c.getName())) {
return c.getValue();
}
}
}
return null;
}
}
- 세션 만료되어도 JWT만 살아있으면 자동으로 재로그인된다
- 사용자는 토큰이 있는지 없는지 신경 쓸 필요가 없다
- 각 컨트롤러는 세션만 확인하면 됨 → 기존 코드 변경 최소화
HttpOnly, Secure 쿠키
Cookie cookie = new Cookie("ssoJwtToken", token);
cookie.setHttpOnly(true); // ← JavaScript로 접근 불가
cookie.setSecure(true); // ← HTTPS에서만 전송
cookie.setSameSite("Lax"); // ← CSRF 방어
리다이렉트 화이트리스트
private static final Set ALLOWED_DOMAINS = Set.of(
"https://service-a.example.com",
"https://service-b.example.com"
);
public void validateReturnUrl(String returnUrl) {
boolean isAllowed = ALLOWED_DOMAINS.stream()
.anyMatch(returnUrl::startsWith);
if (!isAllowed) {
throw new SecurityException("허용되지 않은 리다이렉트: " + returnUrl);
}
}
* 트러블슈팅 및 확장 기능
로그아웃했는데 여전히 접근됨
증상:로그아웃 → 세션 삭제이나, 뒤로가기 하면 또 접근됨
원인: 쿠키에 JWT가 남아있어서 인터셉터가 자동으로 세션 재생성
해결:
// 로그아웃 시 토큰 폐기 + 쿠키 삭제
revokeToken(tokenId, "사용자 로그아웃");
Cookie cookie = new Cookie("ssoJwtToken", "");
cookie.setMaxAge(0); // 즉시 삭제
response.addCookie(cookie);
만료된 토큰 자동 정리
DB에 토큰이 계속 쌓이면 안 되니까, 배치 작업 추가:
@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
public void cleanupExpiredTokens() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
int deleted = tokenMapper.deleteExpiredTokens(cutoff);
log.info("만료 토큰 {}개 삭제 완료", deleted);
}
만료된 지 7일 지난 건 삭제. 감사 로그는 따로 아카이빙 해줘야 할듯
의심스러운 접근 모니터링
// Fingerprint 불일치 발생 시
if (!fingerprintMatches) {
alertService.sendSecurityAlert(
"토큰 탈취 의심",
"TokenID: " + tokenId,
"발급IP: " + entity.getClientIp(),
"사용IP: " + currentIp
);
}
Slack/Discord으로 실시간 알림 보내는 방식도 구현 가능해보임.
-- 가장 많이 사용된 시간대
SELECT HOUR(LAST_USED_AT) as hour, COUNT(*) as cnt
FROM SSO_JWT_TOKEN
WHERE DATE(LAST_USED_AT) = CURDATE()
GROUP BY hour;
-- 평균 토큰 수명
SELECT AVG(TIMESTAMPDIFF(MINUTE, ISSUED_AT, LAST_USED_AT)) as avg_minutes
FROM SSO_JWT_TOKEN
WHERE REVOKED_YN = 'N';
Stateless vs Stateful JWT
JWT의 가장 큰 장점은 "Stateless"라는 점인데, 우리는 DB에 상태를 저장하는 "Stateful" 방식을 선택했다. 이것이 JWT의 본질을 훼손하는 것 아닌가?
완전한 Stateless JWT는 즉시 폐기가 불가능하다. 보안 사고 발생 시 대응이 어렵다.
우리는 JWT의 분산 검증 장점과 세션의 제어 가능성을 모두 가져가기로 했다.
트레이드오프를 인정하고 현실적인 선택을 한 것이다.
토큰 만료 시간?
처음에는 30분으로 설정했다가, 리뷰 후 1시간이 적당하다고 생각했다.
고려 사항:
- 너무 짧으면: 사용자 경험 저하 (자주 재로그인)
- 너무 길면: 보안 위험 증가 (탈취 시 피해 기간)
-> Access Token = 60분 + 세션은 세션 따로, 자동 연장
- 활동 중인 사용자는 세션이 계속 유지됨
- 비활성 사용자는 자연스럽게 만료
- 필요시 재로그인하면 새 토큰 발급
보안은 레이어드 디펜스(Layered Defense)
단일 보안 메커니즘은 완벽하지 않다. 우리는 여러 겹의 방어선을 구축했다:
- 서명 검증 (위조 방지)
- 만료 시간 (시간 제한)
- Token Binding (탈취 방지)
- DB 상태 관리 (즉시 폐기)
- 쿠키 보안 옵션 (XSS/CSRF 방어)
- 화이트리스트 (Open Redirect 방지)
하나가 뚫려도 다른 방어선이 있다.
로깅과 모니터링은 선택이 아닌 필수
개발할 때는 기능 구현에만 집중하기 쉽지만, 운영 단계에서는 로깅이 생명이다.
- 모든 발급/검증/폐기 이벤트 로깅
- 실패 케이스 상세 기록
- 이상 패턴 실시간 알림
- 정기적인 사용 패턴 분석
이것들이 있어야 문제 발생 시 빠르게 대응할 수 있다.
Server Health check에 대해서도 그렇고 이런 단순 로깅 체크에 대해서도,,
좀 더 파악이 빠르게 되는 방법이 없을까? 늘 고민하고 고민하는데..
Jenkins같은 CI/CD 기술과 쉘 스크립트를 잘 활용해서 Trigger 처럼 작동시킨다면.. 음음음.. 생각에만 그치는 게 아쉽다.
향후 개선 계획
1. Redis 캐싱 도입
현재는 매 검증마다 DB를 조회하지만, Redis를 도입하면:
- 토큰 메타데이터 캐싱
- 폐기된 토큰 블랙리스트 캐싱
- 성능 향상
단, Redis 장애 시 Fallback 전략도 함께 고려해야 한다.
일단 Redis를 배울 기회가 생기길.. ㅠㅠㅠ
2. Rate Limiting
동일 IP나 사용자로부터 과도한 검증 시도가 발생하면 제한하는 기능
if (failedAttempts > threshold) {
blockTemporarily(identifier, duration);
alertService.sendBruteForceAlert(...);
}
3. 토큰 갱신 정책
현재는 만료되면 재로그인하지만, UX 개선을 위해:
- 만료 임박 시 자동 갱신 (Silent Refresh)
- 또는 Refresh Token 도입 검토
단, 복잡도와 편의성 사이의 균형을 찾아야 한다.
4. 감사 로그 아카이빙
장기 보관이 필요한 감사 로그를 위해:
- 삭제된 토큰 정보를 별도 아카이빙 테이블로 이동
- 규정 준수를 위한 로그 보관 정책 수립
JWT 기반 SSO를 구축하면서 많은 것을 배웠다.
막상 프로덕션 레벨의 시스템을 만들려니 고려할 것이 정말 많았다..
- 보안은 어디까지 해야 하는가?
- 성능과 보안의 균형점은?
- 표준을 따를 것인가, 우리 환경에 맞출 것인가?
- 복잡도를 감수하고 Refresh Token을 도입할 것인가?
한도 끝도 없이 보안을 생각하면 너무나 많은 경우의 수가 있고,
그렇다고 모든걸 충족할 수 없다고 생각한다.
가장 중요한 것은 왜 이렇게 만들었는가를 명확히 하는 것이다.
모든 설계 결정에는 이유가 있어야 하고, 그 이유를 설명할 수 있어야 한다.
'🗄️ Backend > Spring' 카테고리의 다른 글
| [트러블슈팅] Spring Tx 이중 Ajax 업로드 트랜잭션 처리 로직 해결 (0) | 2025.07.10 |
|---|---|
| Spring과 Spring Boot의 차이점 (1) | 2025.02.08 |
| Spring Boot 아키텍처 : Entity, DTO, Repository, Service, Controller 흐름과 활용 (1) | 2025.01.28 |
| SpringBoot 비동기(Async) 처리 (6) | 2025.01.17 |
| Spring Boot 기초: 웹 애플리케이션 설정 (0) | 2024.11.10 |
