next13 앱 라우터에서 토큰 리프레시 전략
next13의 앱 라우터와 서버 컴포넌트를 사용해보면서 가장 크게 헤맸던 부분입니다. 이번에 사용한 솔루션을 정리해봅니다.
이전 페이지 라우터 방식에서 사용했던 토큰 리프레시 전략
axios.interceptors.response.use(
async (res) => res,
async (err) => {
if (isClient() && err?.response?.status === 401) {
const result = await axios.post('/api/auth/refresh', null, {});
// 새 토큰을 받아온다면 쿠키 저장 후 재요청
if (result.data) {
setCookie(result.data);
return axios(err.config);
}
}
deleteCookie('access_token');
deleteCookie('refresh_token');
return Promise.reject(err);
},
);
리프레시 전략으로 여러가지 방법이 있겠지만, 저는 주로 패칭 라이브러리인 axios의 interceptors 기능을 활용했습니다.
매 요청마다 응답 상태를 확인하여 401에러의 경우 토큰 인증 오류로 판단하고 토큰을 갱신 요청, 새 토큰을 받은 경우 이전의 요청을 재요청합니다.
next13 에서 마주친 문제점
우선 axios 라이브러리는 캐시 기능 미지원으로 기본 fetch 함수를 사용하여 api 요청을 해야합니다. 기본 fetch함수에서는 axios 에서와 같은 interceptor 기능이 없어 fetch를 래핑한 라이브러리인 ofetch 를 사용했습니다.
ofetch.create({
baseURL: `${process.env.SITE_URL}/api`,
onResponse: (ctx) => {
if (ctx.response.status === 401) {
// 토큰 처리 로직
}
},
});
ofetch 라이브러리에서도 onResponse 라는 axios의 interceptor 유사한 기능이 있어서 동일하게 구현할 수 있을 줄 알았는데요, 문제는 서버 컴포넌트에서 쿠키를 설정할 수 없기 때문에 서버 컴포넌트에서 api를 요청하면 리프레시 로직이 동작하지 않았습니다.
(서버컴포넌트는 직렬화된 json 번들을 클라이언트로 전달할 뿐이므로 클라이언트 영역인 쿠키를 직접 제어할 수 없음. 쿠키 설정은 미들웨어나 라우트 핸들러에서만 가능)
적용한 솔루션
export async function middleware(request: NextRequest) {
const accessTokenInCookie = request.cookies.get('access_token')?.value;
const refreshTokenInCookie = request.cookies.get('refresh_token')?.value;
if (!refreshTokenInCookie) {
return deleteTokenResponse();
}
if (!checkValidAccessToken(accessTokenInCookie)) {
const { newAccessToken, newRefreshToken } =
await getTokens(refreshTokenInCookie);
if (!newAccessToken || !newRefreshToken) {
return deleteTokenResponse();
}
return setTokenAndRefresh({
nextUrl: request.nextUrl.href,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
}
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};
미들웨어를 활용하여 페이지에 진입하는 시점에 액세스토큰이 유효한지 확인하고 만료 시에는 새 토큰을 받아 설정하도록 했습니다. (config에서 페이지 path에서만 실행하도록 /api 또는 next에서 생성되는 번들 prefix주소는 제외합니다.)
만약 리프레시 토큰이 없거나 토큰 갱신에 실패할 경우 쿠키에 토큰을 삭제하고 페이지에 진입합니다. 이러한 경우 페이지에서 인증이 필요한 api를 요청하는 경우 401에러를 받을텐데요, 이는 ofetch 인스턴스에 설정한 onResponse에서 로그인페이지로 이동합니다. 뒤에서 설명하겠습니다.
function checkValidAccessToken(accessToken?: string) {
if (!accessToken) return false;
const payload = decode(accessToken) as { exp: number };
if (!payload) return false;
const expireDay = dayjs(payload.exp * 1000);
if (!expireDay.isValid()) return false;
const isExpired = expireDay.subtract(6, 'hours').isBefore(dayjs());
return !isExpired;
}
액세스토큰의 유효성을 검사하는 함수입니다. 토큰을 직접 검증하는 대신 paylaod의 exp 값을 확인하여 현재 시간과 비교하여 만료여부만 판별했습니다. 이유는 jwt verify의 경우 별도 백엔드 서버에서 수행하는데요, 매 페이지 이동마다 jwt 검사를 위한 api를 요청하기엔 서버에 부하가 클 것으로 생각되었습니다.
물론 위와 같이 페이로드의 만료시간만 검사한다면, 만료시간이 유효하나 jwt의 시크릿이 틀린 경우의 케이스를 잡을 수 없습니다. 해당 케이스는 페이지로 진입 후 401에러로 위에서 언급한 ofetch 인스턴스에 의해 로그인페이지로 튕겨버리는 플로우를 탑니다. 다른 시크릿이 설정된 케이스는 일반적인 경우가 아니기 때문에 api 요청 부하를 줄이는게 낫다고 생각하여 느슨하게 검사했습니다.
서버에서 설정한 액세스 토큰의 만료시간은 1일입니다. 해당 미들웨어에서는 만료 6시간전에 미리 토큰을 갱신하도록 설정했습니다.
function deleteTokenResponse() {
const response = NextResponse.next();
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
}
// 컴포넌트에서 사용하는 패칭 라이브러리 인스턴스
ofetch.create({
baseURL: `${process.env.SITE_URL}/api`,
credentials: 'include',
onResponse: (ctx) => {
if (ctx.response.status === 401) {
redirect('/login');
}
},
});
토큰 갱신에 실패했을 때 호출하는 토큰 삭제 후 next 함수를 실행하는 함수입니다. 액세스 토큰이 유효하지 않기 때문에 페이지에서 인증이 필요한 패칭을 실행한다면 위의 ofetch 인스턴스의 onResponse 동작에 의해 로그인 페이지로 이동하게 됩니다.
function setTokenAndRefresh({
nextUrl,
accessToken,
refreshToken,
}: {
nextUrl: string;
accessToken: string;
refreshToken: string;
}) {
const response = NextResponse.redirect(nextUrl);
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
return response;
}
토큰 갱신에 성공하여 새 토큰을 받은 경우에 실행하는 함수입니다. 응답에 토큰을 설정하고 nextUrl 로 다시 리다이렉트합니다. 여기서 토큰 설정은 응답을 보내면서 설정이 완료되기 때문에 redirect대신 next를 사용하면 이전 토큰을 가진채로 페이지를 생성됩니다. 때문에 redirect를 사용해 응답을 받고 완전히 쿠키를 설정한 후 다시 페이지를 생성하도록 redirect를 사용했습니다. (새로고침과 유사하게 동작하더라구요)
한계
위 방식의 한계는 동적으로 인증 에러가 발생하면 그 시점에 토큰 갱신을 하는 것이 아닌, 페이지에 진입하는 시점에만 토큰을 갱신하기 때문에 페이지 진입 후에 발생하는 인증 오류에 대한 대처가 불가능합니다.
예를 들어 페이지 진입 후 사용자가 토큰을 수정하거나, 또는 토큰의 만료시간이 다 되어버리는 경우 토큰 갱신이 아닌 ofetch 인스턴스에 의해 로그인페이지로 리다이렉트 될 겁니다.
이러한 문제를 최대한 방지하고자 만료 시간을 1일, 그리고 만료 6시간 전에 토큰을 미리 갱신합니다. 일반적으로 한 페이지에 6시간이나 머무르는 경우는 거의 없을거라 페이지 진입 이후 토큰이 만료되는 이슈는 거의 생기지 않을거라 생각합니다. 그러나 이런 문제점 때문에 액세스 토큰의 만료 시간을 길게 잡은 것은 좋은 방식은 아닌 듯 합니다. 이후에 더 좋은 방식을 찾게 된다면 대체하는게 좋을 듯 싶네요.