
전통적인 웹 API 개발 방식으로 JWT를 설계한 뒤, 출시 전 테스트에서 모바일 앱의 사용 패턴이 웹과 근본적으로 다르다는 것을 인식하고, Refresh Token 시스템을 디바이스 인식 세션 관리로 재설계한 과정을 정리합니다.
저는 전통적인 웹 백엔드 개발자입니다. PG사에서 API를 오픈하는 업무를 해왔고, JWT 인증도 웹 서비스 기준으로 설계했습니다. 처음 냉비서의 인증을 만들 때도 그 경험대로 구현했습니다.
출시 전 테스트를 하면서 한 가지를 깨달았습니다.
"Refresh Token이 만료되면 재로그인이 필요한데, 내가 평소에 쓰는 모바일 앱들은 꾸준히 사용하면 별도의 로그인을 다시 하지 않는다."
웹에서는 Refresh Token이 만료되면 로그인 페이지로 돌아가는 게 자연스럽습니다. 하지만 모바일 앱에서는 지속적인 로그인 상태 유지가 기본 기대값입니다. 이 차이를 인식하면서, 기존 설계를 모바일 앱에 맞게 재설계하기로 했습니다.
정리하면 이런 시나리오가 문제였습니다:
웹 API 개발 경험을 기반으로 JWT 인증을 구현했습니다. 토큰은 당연히 인코딩 + 암호화 후 해시 매칭 방식으로 저장했고, 기본적인 보안은 갖추고 있었습니다. 하지만 모바일 앱의 UX 요구사항을 반영하지 못한 부분이 있었습니다.
// 기존 RefreshToken 도메인 모델
public class RefreshToken {
private Long id;
private Long userSeq;
private String tokenHash; // 해시 저장 (평문 아님)
private LocalDateTime expiresAt;
private LocalDateTime revokedAt;
private LocalDateTime createdAt;
}
-- 기존 DB 스키마
CREATE TABLE refresh_token (
id BIGINT PRIMARY KEY,
user_seq BIGINT NOT NULL,
token_hash VARCHAR(200) NOT NULL UNIQUE, -- 해시 저장
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
);
토큰에 디바이스 정보가 없었습니다. 모든 기기의 세션이 하나의 테이블에서 구분 없이 관리되었고, "이 토큰이 어떤 기기에서 발급된 것인지" 알 수 없었습니다.
사용자 A의 토큰들:
┌────────────────────────────────────────┐
│ token: abc123 (어떤 기기?) │
│ token: def456 (어떤 기기?) │
│ token: ghi789 (어떤 기기?) │
└────────────────────────────────────────┘
→ 로그아웃 시 "이 기기만" 로그아웃하는 것이 불가능
Refresh Token TTL이 14일이었습니다. 웹 서비스에서는 합리적인 수치이지만, 식품 관리 앱은 매일 쓰는 앱이 아닙니다. 장을 보고 등록한 뒤 2주 뒤에 다시 열면 이미 토큰이 만료되어 재로그인이 필요했습니다. 평소에 쓰는 모바일 앱들은 이런 경험을 주지 않습니다.
기존 시스템은 폐기된 토큰이 다시 사용되었을 때, 단순히 "유효하지 않은 토큰"으로 처리했습니다.
// 기존 토큰 갱신
public RefreshToken rotate(String token) {
RefreshToken stored = refreshTokenQueryPort.findByToken(token)
.orElseThrow(() -> new AuthException(REFRESH_TOKEN_INVALID));
// 폐기된 토큰? → 그냥 에러
// 누가 재사용한 건지? → 모름
// 해당 기기의 다른 세션? → 관리 불가
}
이는 보안 문제입니다. 토큰이 탈취된 후 공격자가 먼저 갱신하면, 정상 사용자의 토큰이 폐기되어 로그아웃됩니다. 그리고 이 상황이 탐지되지 않습니다.
| 문제 | 사용자 영향 |
|---|---|
| 디바이스 구분 없음 | 다른 기기 로그인 시 기존 세션 영향 |
| TTL 14일 (웹 기준) | 2주 미사용 시 재로그인 필요 (모바일 UX와 불일치) |
| 재사용 탐지 없음 | 토큰 탈취 시 정상 사용자가 로그아웃 |
| 세션 목록 없음 | 어디서 로그인되어 있는지 확인 불가 |
| 로그 부재 | 보안 사고 추적 불가 |
public class RefreshToken {
private Long id;
private Long userSeq;
private String token; // 클라이언트 응답용 (DB에는 미저장)
private String tokenHash; // HMAC-SHA256 해시 (DB 저장)
private String deviceId; // 디바이스 식별자
private String ipAddress; // 접속 IP
private String userAgent; // 브라우저/앱 정보
private LocalDateTime expiresAt;
private LocalDateTime revokedAt;
private LocalDateTime lastSeenAt; // 마지막 활동 시각
private LocalDateTime createdAt;
}
변경의 핵심은 4개 필드 추가입니다: tokenHash, deviceId, ipAddress/userAgent, lastSeenAt.
기존부터 HMAC-SHA256으로 토큰을 해싱해서 저장하고 있었습니다. 재설계에서는 스키마를 정리하고(token → token_hash 컬럼 분리), 타이밍 공격 방지를 위한 상수 시간 비교를 추가했습니다.
@Service
public class TokenHashService {
private final byte[] secretKey; // JWT 서명 키와 동일
public String hash(String rawToken) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] hashBytes = mac.doFinal(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashBytes);
}
public boolean matches(String rawToken, String storedHash) {
String computed = hash(rawToken);
// 타이밍 공격 방지를 위한 상수 시간 비교
return MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
storedHash.getBytes(StandardCharsets.UTF_8));
}
}
토큰 발급 시 원문은 클라이언트에게만 전달하고, DB에는 기존과 동일하게 HMAC-SHA256 해시만 저장합니다. 변경된 점은 token_hash 컬럼을 분리하고, 비교 시 MessageDigest.isEqual()로 상수 시간 비교를 적용한 것입니다.
public RefreshToken issue(Long userSeq, String deviceId, String ipAddress, String userAgent) {
String rawToken = UUID.randomUUID().toString().replace("-", "");
String tokenHash = tokenHashService.hash(rawToken);
RefreshToken saved = refreshTokenCommandPort.save(
RefreshToken.builder()
.userSeq(userSeq)
.token(null) // DB에 평문 저장하지 않음
.tokenHash(tokenHash) // 해시만 저장
.deviceId(deviceId)
.ipAddress(ipAddress)
.userAgent(userAgent)
.expiresAt(LocalDateTime.now().plusDays(60)) // 14일 → 60일
.build()
);
// 클라이언트 응답에만 평문 포함
return RefreshToken.builder()
.id(saved.getId())
.token(rawToken) // 이 값은 DB에 없음
.tokenHash(saved.getTokenHash())
.build();
}
| 기존 | 개선 후 | |
|---|---|---|
| 해싱 방식 | HMAC-SHA256 (동일) | HMAC-SHA256 (동일) |
| 스키마 | token 컬럼에 해시 저장 | token_hash 컬럼 분리 (명확한 의미) |
| 토큰 비교 | 일반 비교 | MessageDigest.isEqual() (타이밍 공격 방지) |
로그인, 토큰 갱신, 로그아웃 모든 과정에서 디바이스 정보를 추적합니다.
// 로그인 시: 같은 디바이스의 이전 세션을 폐기하고 새 세션 발급
public RefreshToken issue(Long userSeq, String deviceId, ...) {
// 1. 이 디바이스의 기존 세션 폐기
refreshTokenCommandPort.revokeByUserAndDevice(userSeq, deviceId);
// 2. 새 세션 발급
// ...
}
-- 디바이스 기반 세션 격리를 위한 인덱스 추가
CREATE INDEX idx_refresh_token_user_device ON refresh_token (user_seq, device_id);
이로써 기기 A에서 로그인해도 기기 B의 세션은 유지됩니다.
기존: 사용자 A가 iPhone에서 로그인 → iPad 세션에 영향 (같은 공간에서 관리)
개선: 사용자 A가 iPhone에서 로그인 → iPhone 세션만 갱신, iPad 세션 유지
사용자 A의 세션:
┌─────────────────────────────────────────────────────────────┐
│ device_id: iPhone-1234 │ token_hash: 7f3a... │ active │
│ device_id: iPad-5678 │ token_hash: b2c1... │ active │ ← 영향 없음
└─────────────────────────────────────────────────────────────┘
폐기된 토큰이 다시 사용되면, 토큰 탈취로 간주하고 해당 디바이스의 모든 세션을 폐기합니다.
public RefreshToken rotate(String rawToken, String deviceId, String ipAddress, String userAgent) {
String tokenHash = tokenHashService.hash(rawToken);
RefreshToken stored = refreshTokenQueryPort.findByTokenHash(tokenHash)
.orElse(null);
// 레거시 해시 폴백 (마이그레이션 호환)
if (stored == null) {
stored = refreshTokenQueryPort.findByToken(rawToken).orElse(null);
}
if (stored == null) {
throw new AuthException(REFRESH_TOKEN_INVALID);
}
// 폐기된 토큰 재사용 → 탈취 탐지
if (stored.isRevoked()) {
log.warn("[SECURITY] action=REFRESH_TOKEN_REUSE_DETECTED userId={} deviceId={} ip={}",
stored.getUserSeq(), stored.getDeviceId(), ipAddress);
// 해당 디바이스의 모든 세션 폐기 (공격자 + 정상 사용자 모두)
refreshTokenCommandPort.revokeByUserAndDevice(
stored.getUserSeq(), stored.getDeviceId());
throw new AuthException(REFRESH_TOKEN_REUSED);
}
// 정상 갱신
revokeStored(stored);
return issue(stored.getUserSeq(), deviceId, ipAddress, userAgent);
}
재사용 탐지 시나리오:
정상 흐름:
사용자 → Token A로 갱신 → Token A 폐기, Token B 발급 → Token B로 갱신 → ...
탈취 시나리오:
1. 사용자가 Token A 보유
2. 공격자가 Token A 탈취
3. 공격자가 먼저 Token A로 갱신 → Token A 폐기, Token C 발급 (공격자가 받음)
4. 사용자가 Token A로 갱신 시도 → Token A는 이미 폐기됨
→ ★ REFRESH_TOKEN_REUSE_DETECTED
→ 해당 디바이스의 모든 세션 폐기 (Token C 포함)
→ 공격자의 세션도 무효화됨
| 항목 | 기존 | 개선 후 | 이유 |
|---|---|---|---|
| Refresh Token TTL | 14일 | 60일 | 비활성 사용자의 불필요한 재로그인 방지 |
| Access Token TTL | 30분 | 15분 | 짧은 Access Token으로 노출 시간 최소화 |
Refresh Token 수명을 늘리되, Access Token 수명을 줄여서 편의성과 보안의 균형을 맞췄습니다. Access Token이 탈취되어도 15분 후 만료되고, Refresh Token은 해시로 보호됩니다.
GET /api/v1/auth/sessions사용자가 어떤 기기에서 로그인되어 있는지 확인할 수 있습니다.
@GetMapping("/sessions")
public ResponseEntity<ApiResponse<List<SessionResponse>>> getSessions(
@AuthenticationPrincipal UserPrincipal principal) {
List<RefreshToken> activeSessions =
refreshTokenQueryPort.findAllActiveByUser(principal.getUserSeq());
List<SessionResponse> sessions = activeSessions.stream()
.map(s -> new SessionResponse(
s.getDeviceId(),
s.getIpAddress(),
s.getUserAgent(),
s.getLastSeenAt(),
s.getCreatedAt()))
.toList();
return ResponseEntity.ok(ApiResponse.success(sessions));
}
// 응답 예시
{
"success": true,
"data": [
{
"deviceId": "iPhone-A1B2",
"ipAddress": "192.168.1.100",
"userAgent": "naengbisuh/1.0 (iOS 17.0)",
"lastSeenAt": "2026-02-14T18:00:00",
"createdAt": "2026-02-01T10:30:00"
},
{
"deviceId": "iPad-C3D4",
"ipAddress": "10.0.0.50",
"userAgent": "naengbisuh/1.0 (iPadOS 17.0)",
"lastSeenAt": "2026-02-10T22:15:00",
"createdAt": "2026-02-10T20:00:00"
}
]
}
POST /api/v1/auth/logout-all모든 기기에서 한 번에 로그아웃할 수 있습니다.
@PostMapping("/logout-all")
public ResponseEntity<ApiResponse<LogoutResponse>> logoutAll(
@Valid @RequestBody LogoutRequest request) {
Long userSeq = logoutUseCase.executeAllByRefreshToken(request.refreshToken());
return ResponseEntity.ok(ApiResponse.success(
new LogoutResponse("모든 기기에서 로그아웃 되었습니다.")));
}
내부적으로는 단일 UPDATE 쿼리로 처리됩니다.
UPDATE refresh_token
SET revoked_at = NOW()
WHERE user_seq = ? AND revoked_at IS NULL;
POST /api/v1/admin/sessions/revoke관리자가 특정 사용자의 세션을 강제로 폐기할 수 있습니다. 계정 탈취 대응이나 보안 사고 시 사용합니다.
모든 세션 관련 활동에 구조화된 로그를 추가했습니다.
// 토큰 갱신 성공
log.info("[USER_ACTION] action=TOKEN_REFRESH status=SUCCESS userId={} deviceId={} elapsedMs={}",
user.getId(), deviceId, System.currentTimeMillis() - start);
// 토큰 재사용 탐지 (보안 경고)
log.warn("[SECURITY] action=REFRESH_TOKEN_REUSE_DETECTED userId={} deviceId={} ip={} sessionId={}",
stored.getUserSeq(), stored.getDeviceId(), ipAddress, stored.getId());
// 관리자 세션 폐기
log.info("[ADMIN_ACTION] action=SESSION_REVOKE userId={} deviceId={}",
request.userSeq(), request.deviceId());
이 로그를 통해 보안 사고 발생 시 언제, 어디서, 어떤 디바이스에서 비정상 접근이 있었는지 추적할 수 있습니다.
새 시스템의 핵심 시나리오를 검증하는 테스트를 작성했습니다.
@Test void rotateWithValidToken_returnsNewToken() // 정상 갱신
@Test void rotateWithRevokedToken_throwsReuseDetected() // 재사용 탐지
@Test void rotateWithExpiredToken_throwsExpired() // 만료 처리
@Test void rotateWithUnknownToken_throwsInvalid() // 존재하지 않는 토큰
@Test void issueForDeviceA_doesNotAffectDeviceB() // 디바이스 격리
@Test void issueStoresHmacHash() // HMAC-SHA256 저장 확인
@Test void revokeAllByUser_delegatesToCommandPort() // 전체 로그아웃
특히 issueForDeviceA_doesNotAffectDeviceB 테스트는 디바이스 격리의 핵심을 검증합니다. 기기 A에서 새 세션을 발급해도 기기 B의 세션이 유지되는지 확인합니다.
| 항목 | 기존 | 개선 후 |
|---|---|---|
| 토큰 비교 | HMAC-SHA256 해시 매칭 | + MessageDigest.isEqual() 타이밍 공격 방지 |
| 디바이스 인식 | 없음 | 디바이스별 세션 격리 |
| 재사용 탐지 | 없음 | 탐지 + 해당 디바이스 전체 폐기 |
| Refresh Token TTL | 14일 | 60일 |
| Access Token TTL | 30분 | 15분 |
| 로그아웃 옵션 | 단일 | 단일 / 전체 기기 |
| 세션 조회 | 불가 | GET /sessions로 확인 가능 |
| 관리자 제어 | 없음 | 특정 사용자 세션 강제 폐기 |
| 보안 로그 | 없음 | 구조화된 감사 로그 |
| 타이밍 공격 방어 | 없음 | MessageDigest.isEqual() |
| 시나리오 | 기존 | 개선 후 |
|---|---|---|
| 2주 뒤 앱 재접속 | 재로그인 필요 | 자동 로그인 유지 (60일) |
| 다른 기기에서 로그인 | 기존 기기 영향 가능 | 기존 기기 세션 유지 |
| 네트워크 불안정 시 토큰 갱신 | 세션 끊김 위험 | 재시도 시 정상 처리 |
| 토큰 탈취 시 | 감지 불가, 정상 사용자 로그아웃 | 탈취 감지, 공격자 세션 폐기 |
| "어디서 로그인했지?" | 확인 불가 | 세션 목록 조회 가능 |
| 기기 분실 시 | 대응 불가 | 전체 기기 로그아웃 |
웹 개발 경험으로 JWT를 설계하면 "14일이면 충분하다"고 생각하게 됩니다. 하지만 모바일 앱 사용자의 기대는 다릅니다. 출시 전 테스트에서 이 차이를 인식한 것이 재설계의 출발점이었습니다. 디바이스 컨텍스트를 붙이는 것만으로 "다른 기기 로그인 시 기존 기기 로그아웃" 문제가 해결됩니다.
처음부터 토큰을 인코딩 + 암호화 + 해시 매칭으로 저장했지만, 재설계를 하면서 HMAC-SHA256 + 타이밍 공격 방지(MessageDigest.isEqual())로 강화했습니다. 보안은 초기 구현에서 끝나는 게 아니라, 시스템이 발전할 때마다 함께 점검하고 강화해야 합니다. 현재는 기존 JWT 서명 키를 HMAC 키로 재사용하여 별도 키 관리 부담을 줄였는데, 프로덕션 환경에서는 용도별 키 분리가 바람직합니다. 이 부분은 향후 키 관리 체계를 정비할 때 함께 개선할 예정입니다.
기술 스택: Spring Boot 3.5 / Java 17 / PostgreSQL / Spring Security