🌼 해당 글은 다음 스펙을 기준으로 작성되었습니다.
NextJS v14.1.0
pages router
typescript
🎈 이전 포스팅
NextJS에서 JWT 관리하기(+API Routes 활용)
Axios Interceptors로 then
또는 catch
로 처리 되기 전 요청과 응답을 가로챌 수 있다. request
인터셉터로는 요청이 전달되기 전 수행될 작업이나 요청 오류가 있을 때의 수행될 작업을 추가할 수 있고 response
인터셉터로는 서버에서 받은 응답이 return 되기 전 (then
과 catch
로 넘어가기 전) 수행될 작업이나 응답 오류가 있을 때의 수행될 작업을 추가할 수 있다.
// 요청 인터셉터
axios.interceptors.request.use(function (config) {
// 요청이 전달되기 전에 작업 수행
return config;
}, function (error) {
// 요청 오류가 있는 작업 수행
return Promise.reject(error);
});
// 응답 인터셉터 추가하기
axios.interceptors.response.use(function (response) {
// 2xx 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
// 응답 데이터가 있는 작업 수행
return response;
}, function (error) {
// 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
// 응답 오류가 있는 작업 수행
return Promise.reject(error);
});
또한 필요 시에 인터셉터를 제거할 수 있고, 커스텀 인스턴스에서도 인터셉터를 추가할 수 있다.
이번 프로젝트에서는 커스텀 인스턴스를 만들어 사용했다.
import axios, {
AxiosInstance,
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { getCookie } from 'cookies-next';
const axiosInstance: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
});
true
로 설정하면 요청에 사용자 인증 정보(ex. 쿠키)를 포함시킬 수 있다. (설정은 해놓긴 했지만 API 요청 시 항상 헤더에 따로 쿠키의 토큰 값을 전달했기 때문에 나에겐 무의미했다.)이외에도 다양한 옵션들이 존재하므로, 본인에게 필요한 설정을 해놓으면 된다. 커스텀 인스턴스에 모든 API 요청 시 공통되는 설정을 해놓으면, 요청을 할 때마다 이 설정을 반복할 필요가 없어 코드의 중복을 줄이고 효율성을 높일 수 있다.
config
객체는 Axios를 사용하여 HTTP 요청을 수행할 때 넘겨주는 설정 값들을 포함하고 있다. 자주 사용되는 속성들은 url
, method
, headers
, params
, data
등이 있다.
요청 인터셉터는 서버로 요청을 보내기 전 요청 config
를 가로채서 추가 설정을 적용하는 역할을 한다.
const onRequest = (
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const { method, url } = config;
if (process.env.NODE_ENV !== 'production') {
console.log(`🛫 [API - REQUEST] ${method?.toUpperCase()} ${url}`);
}
const accessToken = getCookie('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
onRequest
함수를 만들어 config
객체에서 method
, url
을 구조 분해 할당을 하여 꺼내고, 콘솔 로그에서 확인하도록 설정하였다. 어떤 API 요청이 언제 발생하는지 추적하기 쉬워진다. 다만 배포환경에선 보이지 않게 하고, 개발 환경에서만 볼 수 있도록 NODE_ENV
가 productino
이 아닐 경우에만 기록하게 조건문 처리를 하였다.
또한 이번 프로젝트에선 cookies-next
라는 라이브러리를 사용하여 쿠키를 관리하였기 때문에 해당 라이브러리의 getCookie
를 사용하여 accessToken 값을 가져왔다. 해당 토큰이 존재하면 요청 헤더에 Authorization
키를 추가하고, Bearer
와 함께 accessToken을 설정하였다.
onRequest
함수는 이렇게 최종적으로 수정된 config
객체를 반환한다.
응답 인터셉터도 요청 인터셉터와 비슷하게, 서버로부터 받는 응답을 처리하기 전에 가로채어 추가적인 로직을 적용하였다. 응답 객체 res
를 매개변수로 받고, 응답을 처리한 후 다시 반환을 하였다.
const onResponse = (res: AxiosResponse): AxiosResponse => {
const { method, url } = res.config;
if (process.env.NODE_ENV !== 'production') {
console.log(
`🛫 [API - RESPONSE] ${method?.toUpperCase()} ${url} | ${res.data.message ? res.data.message : res.data}`,
);
}
return res;
};
응답 객체 res
에서 config
객체를 추출하고, 이를 통해 해당 요청의 HTTP 메소드와 URL을 가져왔다. 개발 환경에서만 콘솔에 응답에 대한 정보를 출력하였다. 만약 응답 데이터 안에 메세지가 있다면 해당 메세지를, 없다면 전체 데이터를 출력하였다. (응답 데이터는 JSON 타입이지만, 데이터가 많아질 경우를 생각해서 일부러 별도의 파싱은 하지 않았다.) 해당 작업이 끝나면 원본 응답 객체 res
를 그대로 반환하였다.
axiosInstance.interceptors.request.use(onRequest);
axiosInstance.interceptors.response.use(onResponse);
onRequest
함수는 요청을 가로채어, 요청 객체에 새로운 설정을 적용하거나 콘솔 로직에 기록하는 작업을 추가하였다. onResponse
는 서버로부터 응답을 받은 후 실행될 함수로, 응답 객체를 가로채어 응답 로그를 출력하는 작업을 수행하도록 하였다.
const onRequestError = (err: AxiosError | Error): Promise<AxiosError> => {
// 요청 전 발생할 수 있는 에러를 처리하는 로직 추가
return Promise.reject(err);
};
onRequestError
는 Axios 요청 인터셉터에서 오류가 발생했을 때 실행되는 에러 핸들링 함수이다. err
매개변수는 AxiosError
또는 일반 Error
타입이다. AxiosError
는 Axios에서 발생하는 특정 에러 정보를 포함하며, HTTP 응답 상태 코드, 응답 헤더, 요청 구성 등의 정보를 포함할 수 있다.
이 함수에는 Axios 요청을 보내기 전 발생할 수 있는 오류(네트워크, 잘못된 요청 파라미터)를 처리하는 작업을 추가하면 된다. (로그 기록, 사용자에게 에러 알림 등)
Promise.reject
를 사용하면, 요청 인터셉터에서 발생한 오류를 다른 곳으로 전달할 수 있다. Promise.reject
는 인자로 받은 값을 사용하여 새로 rejected
된 Promise를 만들고, err
객체는 rejected
된 이유로 사용된다. rejected
된 Promise는 이 함수(여기서 onRequestError
함수)를 호출한 곳으로 반환된다. .catch
메소드를 사용하여 이 에러를 잡아내고, 적절한 로직을 수행하도록 처리할 수 있다.
axiosInstance.interceptors.request.use(onRequest, onRequestError);
해당 에러 핸들링 함수는 위의 인터셉터 함수와 같은 방식으로 커스텀 인스턴스에 적용할 수 있다.
Request Interceptors
참고)401(Unautorized)
에러 코드로 응답한다.401
상태코드와 에러 메세지를 통해 accessToken의 유효 기간이 만료되었음을 알 수 있다.Axios 응답 인터셉터에서 에러를 캐치했을 때의 로직은 onResponseError
함수를 만들어 따로 작성하였다. 이 함수에서 AccessToken 만료 시 재발급을 하는 로직을 추가하였다.
const onResponseError = async (error: AxiosError) => {
if (
error.response &&
error.response.status === 401 &&
error.response.data === '만료된 JWT 토큰입니다.' &&
error.config
) {
try {
// reissue API 라우트 생성을 먼저 하였다.
// '/api/auth/reissue' 엔드포인트로 POST 요청을 보내
// 새로운 액세스 토큰을 요청한다.
const { data } = await axios.post<IAuthResponse>(
'/api/auth/reissue',
{},
{
headers: {
'Content-Type': 'application/json',
},
},
);
// 새로 발급 받은 accessToken을 에러가 발생한 요청의 헤더에 설정
error.config.headers.Authorization = `Bearer ${data.accessToken}`;
// 같은 요청을 다시 시도
return await axiosInstance(error.config);
} catch (reissueError) {
console.error('액세스 토큰 재발급 실패', reissueError);
return Promise.reject(reissueError);
}
}
// 토큰 재발급 조건에 맞지 않는 에러는 그대로 반환
return Promise.reject(error);
};
API 요청을 할 때마다 AccessToken을 헤더에 담아서 보내는데, 이때 AccessToken이 만료 됐으면 서버에서는 401
상태 코드를 보내준다. Axios 응답 인터셉터에서 에러를 캐치하고, 만약 에러가 401
상태 코드이고 "만료된 JWT 토큰입니다."라는 메세지를 반환하면 토큰 재발급 로직이 수행되도록 하였다.
/api/auth/reissue
API 라우트로 POST 요청을 보내면 새로 발급된 accessToken을 응답으로 받게 된다. 그 다음 새로운 accessToken을 에러가 발생한 요청의 헤더에 설정하고 같은 요청이 다시 시도되게 하였다. 이렇게 하면, 새로운 토큰으로 업데이트 된 상태에서 원래의 요청을 재시도할 수 있게 되어 사용자 경험을 중단하지 않고 자동으로 토큰 재발급 및 재시도가 이뤄지게 된다.
NextJS API Routes에 대한 설명은 위 포스팅을 참고하면 된다. API Routes로 NextJS에서 JWT를 관리하는 방법에 대해선 이전 포스팅에서 설명했으므로 추가 설명은 하지 않겠다.
위의 프로세스에서 언급했듯이, accessToken이 만료되면 refreshToken을 가지고 서버에 재발급 요청을 보내야 한다. 다만 우리 프로젝트에서 refreshToken은 HttpOnly 쿠키에 저장되어 있기 때문에 클라이언트 사이드에선 접근이 불가능하다. 서버 사이드에서 접근을 하기 위해 해당 로직을 수행하는 reissue API 라우트를 만들었다.
import { NextApiRequest, NextApiResponse } from 'next';
import axios, { AxiosError } from 'axios';
import { deleteCookie, getCookie, setCookie } from 'cookies-next';
export default async function Reissue(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === 'POST') {
try {
// getCookie를 사용하여 쿠키에 있는 refreshToken 값 가져오기
const refreshToken = getCookie('refreshToken', { req, res });
// 로그인 시 쿠키에 refreshToken을 저장하면서 만료 기간을 24시간으로 설정하였다.
// 그렇기 때문에 refreshToken이 없는 경우는 로그인한 지 1일이 지났다는 것이기 때문에, 에러를 일부러 발생시켜 해당 에러를 처리하는 곳에서 로그아웃 로직을 추가하였다.
if (!refreshToken) {
deleteCookie('accessToken', {
req,
res,
path: '/',
});
return res
.status(401)
.json({ message: '인증 정보가 만료되어 로그아웃 되었습니다.' });
}
// refreshToken을 가지고 새로운 accessToken 발급 요청
const response = await axios.post<string>(
`${process.env.NEXT_PUBLIC_BASE_URL}/members/reissue`,
{},
{
headers: { Authorization: `Bearer ${refreshToken}` },
},
);
// 위의 요청에 대한 응답으로 새로운 accessToken을 받고
// 해당 값으로 쿠키에 있는 accessToken 값 업데이트
if (response.data) {
const accessToken = response.data;
setCookie('accessToken', accessToken, {
req,
res,
path: '/',
maxAge: 60 * 60 * 24,
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
res.status(200).json({
message: '액세스 토큰 재발급 성공',
accessToken,
});
} else {
res.status(401).json({ message: '액세스 토큰 재발급 실패' });
}
} catch (error) {
const axiosError = error as AxiosError;
const axiosErrorData = axiosError.response?.data;
console.error('Error refreshing tokens:', axiosError);
// 중복 로그인인 상태로 토큰 재발급을 요청하면 아래와 같은 에러 메시지가 반환된다.
// 해당 에러 핸들링 코드 (본문 내용과 관련 x)
if (
axiosErrorData ===
'다른 위치에서 로그인하여 현재 세션이 로그아웃되었습니다.'
) {
res
.status(401)
.json('다른 위치에서 로그인하여 현재 세션이 로그아웃되었습니다.');
} else {
res.status(500).json({ message: 'Failed to refresh token' });
}
}
} else {
// POST 메서드가 아닌 다른 요청이 들어왔을 때 `405` 에러를 반환한다.
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
POST 메서드로 요청이 들어오면, refreshToken을 쿠키에서 가져와 /members/reissue
엔드포인트로 새 accessToken을 요청한다. (만약 refreshToken이 없으면 401
에러를 반환하여 사용자를 로그아웃 시켰다.) 응답으로 새로운 토큰을 받으면 쿠키에 업데이트한 뒤 성공 메세지와 함께 클라이언트로 반환한다.
재발급 로직이 포함된 응답 에러 핸들링 함수 역시 적용될 수 있도록 설정한다.
axiosInstance.interceptors.response.use(
(response) => response,
onResponseError,
);
import axios, {
AxiosInstance,
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { getCookie } from 'cookies-next';
export interface IAuthResponse {
message: string;
accessToken: string;
}
const axiosInstance: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
});
const onRequest = (
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const { method, url } = config;
if (process.env.NODE_ENV !== 'production') {
console.log(`🛫 [API - REQUEST] ${method?.toUpperCase()} ${url}`);
}
const accessToken = getCookie('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
const onResponse = (res: AxiosResponse): AxiosResponse => {
const { method, url } = res.config;
if (process.env.NODE_ENV !== 'production') {
console.log(
`🛫 [API - RESPONSE] ${method?.toUpperCase()} ${url} | ${res.data.message ? res.data.message : res.data}`,
);
}
return res;
};
const onRequestError = (err: AxiosError | Error): Promise<AxiosError> => {
return Promise.reject(err);
};
const onResponseError = async (error: AxiosError) => {
if (
error.response &&
error.response.status === 401 &&
error.response.data === '만료된 JWT 토큰입니다.' &&
error.config
) {
try {
const { data } = await axios.post<IAuthResponse>(
'/api/auth/reissue',
{},
{
headers: {
'Content-Type': 'application/json',
},
},
);
error.config.headers.Authorization = `Bearer ${data.accessToken}`;
return await axiosInstance(error.config);
} catch (reissueError) {
console.error('액세스 토큰 재발급 실패', reissueError);
return Promise.reject(reissueError);
}
}
return Promise.reject(error);
};
axiosInstance.interceptors.request.use(onRequest, onRequestError);
axiosInstance.interceptors.response.use(onResponse);
axiosInstance.interceptors.response.use(
(response) => response,
onResponseError,
);
export default axiosInstance;
서버 사이드에서 API 요청 시 토큰이 만료 됐을 때의 재발급 코드는 따로 작성해야 했다. (로직은 같으나, 서버사이드에서 처리하기 위해 일부 달라지는 코드가 있었다.) 해당 내용도 블로그에 적을까 하다가 로직에서의 차이는 없어서,, 우선 클라이언트 사이드에서의 재발급 기능 구현만 작성하기로!
전체 코드는 여기에서 확인할 수 있습니다.