콘텐츠로 이동

부록 B. Refresh Token 클라이언트 구현 예제#

1. 목적#

DataAPI의 JWT Bearer 인증을 사용하는 클라이언트가 Access 토큰 만료 시 Refresh 토큰으로 새 토큰을 발급받는 구현 예제를 제공한다. DataAPI 서버는 /auth/refresh 엔드포인트를 제공하지만, 기존 API 요청을 자동으로 갱신하거나 재시도하지 않는다. 따라서 토큰 저장, refresh 호출, 새 토큰 교체, 원래 요청 재시도는 클라이언트가 구현해야 한다.

2. DataAPI Refresh 토큰 동작 방식#

  • 로그인은 POST /dataapi/auth/token으로 수행하고, 응답의 accessTokenrefreshToken을 함께 저장한다.
  • SQL API 호출 시 Authorization: Bearer <accessToken> 헤더를 사용한다.
  • Access 토큰이 만료되면 보호 API는 보통 401AUTH_003을 반환한다.
  • 클라이언트는 POST /dataapi/auth/refresh에 현재 Refresh 토큰을 보내 새 accessToken과 새 refreshToken을 받는다.
  • Refresh 성공 시 기존 Access 토큰뿐 아니라 기존 Refresh 토큰도 반드시 새 값으로 교체한다.
  • rotation 이후 이전 Refresh 토큰은 다시 사용할 수 없다. 이미 사용되었거나 교체된 Refresh 토큰은 AUTH_010으로 거부될 수 있다.
  • Refresh 호출도 현재 서버 인스턴스의 사용자 세션 풀과 최신 Refresh 상태가 있어야 성공한다. 서버 재시작, 세션 reset, logout, pool 만료 이후에는 재로그인이 필요할 수 있다.

3. 공통 구현 흐름#

  1. 로그인 성공 응답에서 accessToken, refreshToken을 저장한다.
  2. 보호 API 요청마다 Authorization: Bearer <accessToken>을 붙인다.
  3. 보호 API가 401 AUTH_003을 반환하면 refresh를 시도한다.
  4. Refresh 성공 응답의 새 accessToken, 새 refreshToken을 같은 세션 저장소에 함께 교체한다.
  5. 원래 API 요청을 새 Access 토큰으로 한 번만 재시도한다.
  6. Refresh가 실패하면 저장된 토큰을 폐기하고 사용자를 다시 로그인 흐름으로 보낸다.

Refresh token rotation을 사용하므로 한 사용자 세션에서 refresh 요청은 한 번에 하나만 수행해야 한다. 동시에 여러 요청이 같은 Refresh 토큰으로 /auth/refresh를 호출하면, 먼저 성공한 요청이 Refresh 토큰을 교체하고 뒤따른 요청은 이전 Refresh 토큰 재사용으로 판단될 수 있다. 이런 경쟁을 피하려면 클라이언트에서 refresh in-flight 요청을 공유하거나 잠금으로 직렬화한다.

4. Java Spring RestClient 샘플#

아래 예시는 Spring Boot 3.x의 RestClient를 사용하는 서버 사이드 클라이언트 예시이다. 실제 서비스에서는 토큰 저장소를 사용자 세션, tenant, 또는 연동 계정 단위로 분리하고, Refresh 토큰은 로그에 남기지 않는다.

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

public final class DataApiClient {
    private static final String ERROR_TOKEN_INVALID_OR_EXPIRED = "AUTH_003";

    private final RestClient restClient;
    private final ObjectMapper objectMapper;
    private final Object refreshLock = new Object();
    private volatile TokenPair tokens;

    public DataApiClient(String baseUrl, ObjectMapper objectMapper) {
        this.restClient = RestClient.builder().baseUrl(baseUrl).build();
        this.objectMapper = objectMapper;
    }

    public void login(String username, String password) {
        this.tokens =
                restClient
                        .post()
                        .uri("/auth/token")
                        .body(new AuthRequest(username, password))
                        .retrieve()
                        .body(TokenPair.class);
    }

    public Map<?, ?> queryRecords(String sql) {
        QueryRequest request = new QueryRequest(sql, List.of());
        return postWithBearer("/query/records", request, Map.class);
    }

    private <T> T postWithBearer(String uri, Object request, Class<T> responseType) {
        return postWithBearer(uri, request, responseType, true);
    }

    private <T> T postWithBearer(
            String uri, Object request, Class<T> responseType, boolean allowRefresh) {
        TokenPair snapshot = requireTokens();
        try {
            return restClient
                    .post()
                    .uri(uri)
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + snapshot.accessToken())
                    .body(request)
                    .retrieve()
                    .body(responseType);
        } catch (RestClientResponseException exception) {
            if (!allowRefresh || !isAccessTokenExpired(exception)) {
                throw exception;
            }
            refreshOnce(snapshot);
            return postWithBearer(uri, request, responseType, false);
        }
    }

    private void refreshOnce(TokenPair observedTokens) {
        synchronized (refreshLock) {
            if (tokens != observedTokens) {
                return;
            }

            try {
                tokens =
                        restClient
                                .post()
                                .uri("/auth/refresh")
                                .body(new RefreshRequest(observedTokens.refreshToken()))
                                .retrieve()
                                .body(TokenPair.class);
            } catch (RestClientResponseException exception) {
                tokens = null;
                throw exception;
            }
        }
    }

    private TokenPair requireTokens() {
        TokenPair snapshot = tokens;
        if (snapshot == null) {
            throw new IllegalStateException("DataAPI login is required.");
        }
        return snapshot;
    }

    private boolean isAccessTokenExpired(RestClientResponseException exception) {
        return exception.getStatusCode().value() == HttpStatus.UNAUTHORIZED.value()
                && hasErrorCode(exception, ERROR_TOKEN_INVALID_OR_EXPIRED);
    }

    private boolean hasErrorCode(RestClientResponseException exception, String expectedCode) {
        try {
            ErrorBody errorBody =
                    objectMapper.readValue(exception.getResponseBodyAsByteArray(), ErrorBody.class);
            return expectedCode.equals(errorBody.code());
        } catch (IOException ignored) {
            return false;
        }
    }

    record AuthRequest(String username, String password) {}

    record RefreshRequest(String refreshToken) {}

    record TokenPair(String accessToken, String refreshToken) {}

    record QueryRequest(String sql, List<Object> args) {}

    record ErrorBody(String code, String message, String path) {}
}

이 샘플의 핵심은 refreshOnce()가 같은 TokenPair에 대해 refresh를 한 번만 수행한다는 점이다. 첫 번째 요청이 refresh를 성공해 tokens를 새 값으로 교체하면, 뒤늦게 들어온 요청은 새 Access 토큰으로 원래 요청을 재시도한다.

5. JavaScript Axios 샘플#

아래 예시는 브라우저 또는 Node.js에서 Axios 인스턴스를 구성하는 예시이다. 브라우저 환경에서는 토큰 저장 위치를 서비스 보안 정책에 맞게 정해야 한다. localStorage는 구현이 쉽지만 XSS에 취약할 수 있으므로, 운영 환경에서는 애플리케이션의 위협 모델에 맞는 저장 방식을 선택한다.

import axios from "axios";

const api = axios.create({
  baseURL: "http://localhost:8080/dataapi",
  headers: {
    "Content-Type": "application/json",
  },
});

let tokens = null;
let refreshPromise = null;

export async function login(username, password) {
  const response = await api.post("/auth/token", { username, password }, { skipAuth: true });
  tokens = response.data;
  return tokens;
}

api.interceptors.request.use((config) => {
  if (!config.skipAuth && tokens?.accessToken) {
    config.headers = config.headers ?? {};
    config.headers.Authorization = `Bearer ${tokens.accessToken}`;
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const response = error.response;
    const originalRequest = error.config;

    if (
      !response ||
      originalRequest?.skipAuth ||
      originalRequest?._retry ||
      response.status !== 401 ||
      response.data?.code !== "AUTH_003"
    ) {
      return Promise.reject(error);
    }

    originalRequest._retry = true;

    try {
      const refreshedTokens = await refreshTokensOnce();
      originalRequest.headers = originalRequest.headers ?? {};
      originalRequest.headers.Authorization = `Bearer ${refreshedTokens.accessToken}`;
      return api(originalRequest);
    } catch (refreshError) {
      tokens = null;
      return Promise.reject(refreshError);
    }
  }
);

async function refreshTokensOnce() {
  if (!tokens?.refreshToken) {
    throw new Error("DataAPI login is required.");
  }

  if (!refreshPromise) {
    refreshPromise = api
      .post("/auth/refresh", { refreshToken: tokens.refreshToken }, { skipAuth: true })
      .then((response) => {
        tokens = response.data;
        return tokens;
      })
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

export async function queryRecords(sql) {
  const response = await api.post("/query/records", { sql, args: [] });
  return response.data;
}

export async function logout() {
  if (!tokens) {
    return;
  }

  const refreshToken = tokens.refreshToken;
  try {
    await api.post("/auth/logout", { refreshToken });
  } finally {
    tokens = null;
  }
}

이 샘플의 핵심은 refreshPromise이다. 여러 API 요청이 동시에 AUTH_003을 받아도 실제 /auth/refresh 호출은 하나만 발생하고, 나머지 요청은 같은 Promise를 기다린 뒤 새 Access 토큰으로 재시도한다.

6. 오류 처리 권장 기준#

상황 권장 처리
보호 API가 401 AUTH_003 반환 refresh 1회 시도 후 원래 요청 1회 재시도
refresh가 AUTH_003, AUTH_007, AUTH_010 등으로 실패 저장된 토큰 폐기 후 재로그인 유도
refresh가 네트워크 오류로 실패 짧은 재시도 정책을 적용하거나 사용자에게 일시 장애 안내
원래 요청 재시도도 실패 무한 재시도하지 않고 호출자에게 오류 반환
AUTH_014로 로그인 실패 기존 세션이 남아 있을 수 있으므로 /auth/session/reset 또는 로그아웃 절차 안내

7. 구현 시 주의사항#

  • Refresh 토큰은 /auth/refresh 요청 본문과 /auth/logout 요청 본문에만 사용한다.
  • Refresh 성공 응답은 Access 토큰과 Refresh 토큰을 모두 포함하므로 두 값을 원자적으로 교체한다.
  • Access 토큰 만료를 이유로 username/password를 반복 전송하지 않는다.
  • 로그, 브라우저 콘솔, 오류 메시지에 Access 토큰이나 Refresh 토큰을 남기지 않는다.
  • JWT 세션 상태는 서버 메모리에도 존재하므로, 로드밸런서 환경에서는 Bearer 트래픽에 sticky routing이 필요할 수 있다.
  • 수동 session reset 이후에는 cutoff 이전에 발급된 Access/Refresh 토큰이 거부되므로 다시 로그인한다.