부록 B. Refresh Token 클라이언트 구현 예제#
1. 목적#
DataAPI의 JWT Bearer 인증을 사용하는 클라이언트가 Access 토큰 만료 시 Refresh 토큰으로 새 토큰을 발급받는 구현 예제를 제공한다. DataAPI 서버는 /auth/refresh 엔드포인트를 제공하지만, 기존 API 요청을 자동으로 갱신하거나 재시도하지 않는다. 따라서 토큰 저장, refresh 호출, 새 토큰 교체, 원래 요청 재시도는 클라이언트가 구현해야 한다.
2. DataAPI Refresh 토큰 동작 방식#
- 로그인은
POST /dataapi/auth/token으로 수행하고, 응답의accessToken과refreshToken을 함께 저장한다. - SQL API 호출 시
Authorization: Bearer <accessToken>헤더를 사용한다. - Access 토큰이 만료되면 보호 API는 보통
401과AUTH_003을 반환한다. - 클라이언트는
POST /dataapi/auth/refresh에 현재 Refresh 토큰을 보내 새accessToken과 새refreshToken을 받는다. - Refresh 성공 시 기존 Access 토큰뿐 아니라 기존 Refresh 토큰도 반드시 새 값으로 교체한다.
- rotation 이후 이전 Refresh 토큰은 다시 사용할 수 없다. 이미 사용되었거나 교체된 Refresh 토큰은
AUTH_010으로 거부될 수 있다. - Refresh 호출도 현재 서버 인스턴스의 사용자 세션 풀과 최신 Refresh 상태가 있어야 성공한다. 서버 재시작, 세션 reset, logout, pool 만료 이후에는 재로그인이 필요할 수 있다.
3. 공통 구현 흐름#
- 로그인 성공 응답에서
accessToken,refreshToken을 저장한다. - 보호 API 요청마다
Authorization: Bearer <accessToken>을 붙인다. - 보호 API가
401 AUTH_003을 반환하면 refresh를 시도한다. - Refresh 성공 응답의 새
accessToken, 새refreshToken을 같은 세션 저장소에 함께 교체한다. - 원래 API 요청을 새 Access 토큰으로 한 번만 재시도한다.
- 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 토큰이 거부되므로 다시 로그인한다.