냉비서 프로젝트 - Refresh Token 재설계

박정민·2026년 3월 2일

냉비서

목록 보기
1/2
post-thumbnail

"모바일 앱은 웹이 아니다" — Refresh Token 시스템을 다시 설계한 이야기

전통적인 웹 API 개발 방식으로 JWT를 설계한 뒤, 출시 전 테스트에서 모바일 앱의 사용 패턴이 웹과 근본적으로 다르다는 것을 인식하고, Refresh Token 시스템을 디바이스 인식 세션 관리로 재설계한 과정을 정리합니다.


문제 인식: 출시 전 테스트에서 발견한 것

저는 전통적인 웹 백엔드 개발자입니다. PG사에서 API를 오픈하는 업무를 해왔고, JWT 인증도 웹 서비스 기준으로 설계했습니다. 처음 냉비서의 인증을 만들 때도 그 경험대로 구현했습니다.

출시 전 테스트를 하면서 한 가지를 깨달았습니다.

"Refresh Token이 만료되면 재로그인이 필요한데, 내가 평소에 쓰는 모바일 앱들은 꾸준히 사용하면 별도의 로그인을 다시 하지 않는다."

웹에서는 Refresh Token이 만료되면 로그인 페이지로 돌아가는 게 자연스럽습니다. 하지만 모바일 앱에서는 지속적인 로그인 상태 유지가 기본 기대값입니다. 이 차이를 인식하면서, 기존 설계를 모바일 앱에 맞게 재설계하기로 했습니다.

정리하면 이런 시나리오가 문제였습니다:

  • 앱을 2주 이상 안 쓰다 돌아오면 토큰 만료로 재로그인 필요
  • 다른 기기에서 로그인하면 기존 기기 세션에 영향
  • 토큰이 탈취되어도 탐지할 수 없는 구조

1. 기존 시스템: 무엇이 문제였는가

웹 API 개발 경험을 기반으로 JWT 인증을 구현했습니다. 토큰은 당연히 인코딩 + 암호화 후 해시 매칭 방식으로 저장했고, 기본적인 보안은 갖추고 있었습니다. 하지만 모바일 앱의 UX 요구사항을 반영하지 못한 부분이 있었습니다.

기존 Refresh Token 구조

// 기존 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
);

문제 1: 디바이스 구분 없음

토큰에 디바이스 정보가 없었습니다. 모든 기기의 세션이 하나의 테이블에서 구분 없이 관리되었고, "이 토큰이 어떤 기기에서 발급된 것인지" 알 수 없었습니다.

사용자 A의 토큰들:
┌────────────────────────────────────────┐
│ token: abc123  (어떤 기기?)             │
│ token: def456  (어떤 기기?)             │
│ token: ghi789  (어떤 기기?)             │
└────────────────────────────────────────┘
→ 로그아웃 시 "이 기기만" 로그아웃하는 것이 불가능

문제 2: 짧은 토큰 수명 (웹 기준 설계)

Refresh Token TTL이 14일이었습니다. 웹 서비스에서는 합리적인 수치이지만, 식품 관리 앱은 매일 쓰는 앱이 아닙니다. 장을 보고 등록한 뒤 2주 뒤에 다시 열면 이미 토큰이 만료되어 재로그인이 필요했습니다. 평소에 쓰는 모바일 앱들은 이런 경험을 주지 않습니다.

문제 3: 토큰 재사용 탐지 부재

기존 시스템은 폐기된 토큰이 다시 사용되었을 때, 단순히 "유효하지 않은 토큰"으로 처리했습니다.

// 기존 토큰 갱신
public RefreshToken rotate(String token) {
    RefreshToken stored = refreshTokenQueryPort.findByToken(token)
        .orElseThrow(() -> new AuthException(REFRESH_TOKEN_INVALID));
    // 폐기된 토큰? → 그냥 에러
    // 누가 재사용한 건지? → 모름
    // 해당 기기의 다른 세션? → 관리 불가
}

이는 보안 문제입니다. 토큰이 탈취된 후 공격자가 먼저 갱신하면, 정상 사용자의 토큰이 폐기되어 로그아웃됩니다. 그리고 이 상황이 탐지되지 않습니다.

정리: 기존 시스템의 한계

문제사용자 영향
디바이스 구분 없음다른 기기 로그인 시 기존 세션 영향
TTL 14일 (웹 기준)2주 미사용 시 재로그인 필요 (모바일 UX와 불일치)
재사용 탐지 없음토큰 탈취 시 정상 사용자가 로그아웃
세션 목록 없음어디서 로그인되어 있는지 확인 불가
로그 부재보안 사고 추적 불가

2. 재설계: 디바이스 인식 세션 관리

새로운 RefreshToken 모델

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.

변경 1: 토큰 저장 스키마 정리 + 타이밍 공격 방지

기존부터 HMAC-SHA256으로 토큰을 해싱해서 저장하고 있었습니다. 재설계에서는 스키마를 정리하고(tokentoken_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() (타이밍 공격 방지)

변경 2: 디바이스 기반 세션 격리

로그인, 토큰 갱신, 로그아웃 모든 과정에서 디바이스 정보를 추적합니다.

// 로그인 시: 같은 디바이스의 이전 세션을 폐기하고 새 세션 발급
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     │ ← 영향 없음
└─────────────────────────────────────────────────────────────┘

변경 3: 토큰 재사용 탐지

폐기된 토큰이 다시 사용되면, 토큰 탈취로 간주하고 해당 디바이스의 모든 세션을 폐기합니다.

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 포함)
     → 공격자의 세션도 무효화됨

변경 4: TTL 조정

항목기존개선 후이유
Refresh Token TTL14일60일비활성 사용자의 불필요한 재로그인 방지
Access Token TTL30분15분짧은 Access Token으로 노출 시간 최소화

Refresh Token 수명을 늘리되, Access Token 수명을 줄여서 편의성과 보안의 균형을 맞췄습니다. Access Token이 탈취되어도 15분 후 만료되고, Refresh Token은 해시로 보호됩니다.

변경 5: 새로운 API 엔드포인트

활성 세션 조회 — 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

관리자가 특정 사용자의 세션을 강제로 폐기할 수 있습니다. 계정 탈취 대응이나 보안 사고 시 사용합니다.


3. 보안 감사 로그

모든 세션 관련 활동에 구조화된 로그를 추가했습니다.

// 토큰 갱신 성공
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());

이 로그를 통해 보안 사고 발생 시 언제, 어디서, 어떤 디바이스에서 비정상 접근이 있었는지 추적할 수 있습니다.


4. 테스트

새 시스템의 핵심 시나리오를 검증하는 테스트를 작성했습니다.

@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 TTL14일60일
Access Token TTL30분15분
로그아웃 옵션단일단일 / 전체 기기
세션 조회불가GET /sessions로 확인 가능
관리자 제어없음특정 사용자 세션 강제 폐기
보안 로그없음구조화된 감사 로그
타이밍 공격 방어없음MessageDigest.isEqual()

사용자 체감 변화

시나리오기존개선 후
2주 뒤 앱 재접속재로그인 필요자동 로그인 유지 (60일)
다른 기기에서 로그인기존 기기 영향 가능기존 기기 세션 유지
네트워크 불안정 시 토큰 갱신세션 끊김 위험재시도 시 정상 처리
토큰 탈취 시감지 불가, 정상 사용자 로그아웃탈취 감지, 공격자 세션 폐기
"어디서 로그인했지?"확인 불가세션 목록 조회 가능
기기 분실 시대응 불가전체 기기 로그아웃

배운 것

웹의 상식이 모바일의 상식은 아니다

웹 개발 경험으로 JWT를 설계하면 "14일이면 충분하다"고 생각하게 됩니다. 하지만 모바일 앱 사용자의 기대는 다릅니다. 출시 전 테스트에서 이 차이를 인식한 것이 재설계의 출발점이었습니다. 디바이스 컨텍스트를 붙이는 것만으로 "다른 기기 로그인 시 기존 기기 로그아웃" 문제가 해결됩니다.

보안은 "한 번 하고 끝"이 아니다

처음부터 토큰을 인코딩 + 암호화 + 해시 매칭으로 저장했지만, 재설계를 하면서 HMAC-SHA256 + 타이밍 공격 방지(MessageDigest.isEqual())로 강화했습니다. 보안은 초기 구현에서 끝나는 게 아니라, 시스템이 발전할 때마다 함께 점검하고 강화해야 합니다. 현재는 기존 JWT 서명 키를 HMAC 키로 재사용하여 별도 키 관리 부담을 줄였는데, 프로덕션 환경에서는 용도별 키 분리가 바람직합니다. 이 부분은 향후 키 관리 체계를 정비할 때 함께 개선할 예정입니다.


기술 스택: Spring Boot 3.5 / Java 17 / PostgreSQL / Spring Security

profile
Backend Developer

0개의 댓글