팀 프로젝트에서 로그인 기능을 새롭게 도입하기로 결정했습니다. 다양한 로그인 방식이 있지만 저희는 액세스 토큰과 리프레시 토큰을 사용하는 JWT 로그인 방식을 택했습니다. 해당 방식의 경우 인증이 필요한 통신 요청을 하게되면 클라이언트에서는 다음과 같은 과정이 필요했습니다.
- 요청을 보낼 때, 토큰을 요청 헤더에 포함시켜서 서버에 전송한다.
- 토큰이 만료 되었다는 응답을 받을 경우, 토큰을 새로 갱신(refresh)한 뒤 요청을 다시 보낸다.
이러한 통신간 토큰 처리 레퍼런스를 보면 axios 라이브러리의 interceptor 기능을 활용하는 경우가 많았지만, 우리 팀의 경우 fetch API를 사용하고 있었습니다. interceptor 기능 하나만을 위해 새로운 라이브러리를 도입하는 것은 내키지 않았고, 차라리 interceptor 기능을 직접 구현하기로 결정했습니다.
우선, axios에서는 인터셉터를 어떤식으로 사용하는지 알아보겠습니다. 다음 코드는 axios의 공식문서에 나와있는 interceptor 사용법 코드를 그대로 가져온 것입니다.
// 요청 인터셉터 추가하기
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);
});
각 요청 인터셉터 객체와 응답 인터셉터 객체의 use()
메소드를 사용해서 새로운 인터셉터를 달아주는 형태입니다. use()
메소드의 첫 번째 인자로는 각각 요청 전, 응답 후를 가로채는 인터셉터를, 두 번째 인자로는 요청과 응답에 오류가 있을 경우의 인터셉터를 달아줘야합니다.
실제 인터셉터 객체(AxiosInterceptorManager)의 use 메소드 인터페이스를 보면 다음과 같습니다.
interface AxiosInterceptorManager<V> {
use(onFulfilled?: ((value: V) => V | Promise<V>) | null, onRejected?: ((error: any) => any) | null, options?: AxiosInterceptorOptions): number;
...
}
개인적으로 axios에서 인터셉터를 사용하는 방식이 불편하다고 생각합니다. 그 이유는 다음과 같습니다.
use()
메소드에 인터셉터 콜백을 전달하는 방식이 불편하다.
인터셉터 콜백 함수 인자를 나열해서 전달하는 방식은 명시적이지 않아 가독성이 떨어집니다. 코드를 한 번 보겠습니다.
axios.interceptors.request.use(function (config) {
return config;
}, function (error) {
return Promise.reject(error);
});
위 코드를 처음 보면 use()
메소드에 전달하는 첫 번째 콜백함수는 무슨 함수고, 두 번째 콜백함수는 무슨 함수인지 한 번에 이해가 되지 않습니다. 물론, 코드 내부를 보면 대략적으로 유추는 가능하겠지만, 코드 내부가 복잡하다면 이를 읽는데에도 시간이 많이 소요될 것입니다.
또한, 콜백의 순서를 보장해야 한다는 점도 사용성이 떨어진다고 생각합니다.
axios.interceptors.request.use(null, function (error) {
return Promise.reject(error);
});
만약 onFulfilled
없이 onRejected
인터셉터만 사용하고 싶다면, onFulfilled
인자가 들어가는 자리에는 억지로 null
을 채워넣어야 하는 불편함이 있습니다.
요청 인터셉터 객체와 응답 인터셉터 객체가 나뉘어져 있다.
요청과 응답 인터셉터를 둘 다 초기화해야 할 경우, use()
메소드를 두 번 사용해야 한다는 사소한 불편함이 있습니다.
이러한 점들을 고려하여 서버 통신 함수 with 인터셉터
를 구현해보도록 하겠습니다.
클로저를 사용해서 인터셉터가 달린 request 함수를 반환하는 createRequest()
를 만드려고 합니다. createRequest()
의 인터페이스는 다음과 같습니다.
createRequest: ({ onRequest?, onResponse?, onRequestError?, onResponseError? }: Interceptor) => (url: string, config?: RequestInit) => Promise<Response>
createRequest()
는 인터셉터들을 인자로 받고, fetch
와 같은 서버 통신 요청 함수를 반환하게 됩니다. 위에서 설명한 axios 인터셉터 사용 방식의 불편함을 인자 전달 방식의 변경으로 해소하고자 합니다.
인터셉터들을 나열하는 방식이 아니라 객체 인자로 묶어 전달하려고 하는데요. 명시적인 객체의 key 값을 통해 해당 함수가 정확히 어떤 함수인지 더 쉽게 알 수 있도록 했습니다.
const request = createRequest({
onRequest: function (config) {
return config;
},
onResponse: function (response) {
return response;
}
})
객체이기 때문에 인터셉터의 순서도 마음대로 바꿔도 되고, 필요한 인터셉터만 선언하면 되도록 했습니다. 또한, 코드를 보면 알수 있듯이 요청과 응답에 관한 인터셉터를 한 번에 처리할 수 있도록 해줬습니다.
그렇다면, createRequest()
의 인자로 들어가는 인터셉터들의 타입은 어떻게 될까요?
interface Interceptor {
onRequest?: (config: RequestInit) => RequestInit;
onResponse?: (response: Response) => Response | PromiseLike<Response>;
onRequestError?: (reason: unknown) => Promise<never>;
onResponseError?: (reason: unknown) => Promise<never>;
};
onRequest
는 fetch의 옵션 값들을 가로채는 함수이므로 config를 인자로 받고 config를 반환합니다.
onResponse
는 서버로부터 받은 응답 가로채는 함수이므로 response를 인자로 받고 response를 반환합니다.
onRequestError
와 onResponseError
의 경우 통신 중 에러가 발생한 상황이므로 Promise를 반환합니다.
인터셉터들을 활용하여 구현한 createRequest()
내부의 request()
함수는 다음과 같습니다.
const request = (url: string, config?: RequestInit) => {
try {
config = config && onRequest(config);
return fetch(url, config).then(onResponse).catch(onResponseError);
} catch (reason) {
return onRequestError(reason);
}
};
onRequest
를 통해 config를 가로채서 작업을 한 뒤, fetch
를 통해 서버로 통신하게 됩니다. fetch 과정이 성공(.then)한다면 onResponse
로 응답 값을 가로챈 뒤 응답을 반환합니다.
만약, fetch 과정의 응답에 에러가 있을 경우(.catch) onResponseError
를 실행하고, config 변환 과정 혹은 fetch 자체의 에러가 발생할 경우 onRequestError
를 실행합니다.
interface Interceptor {
onRequest?: (config: RequestInit) => RequestInit;
onResponse?: (response: Response) => Response | PromiseLike<Response>;
onRequestError?: (reason: unknown) => Promise<never>;
onResponseError?: (reason: unknown) => Promise<never>;
};
const createRequest = ({
onRequest = (config) => config,
onResponse = (response) => response,
onRequestError = (reason) => Promise.reject(reason),
onResponseError = (reason) => Promise.reject(reason),
}: Interceptor) => {
const request = (url: string, config?: RequestInit) => {
try {
config = config && onRequest(config);
return fetch(url, config).then(onResponse).catch(onResponseError);
} catch (reason) {
return onRequestError(reason);
}
};
return request;
};
앞서 설명한 interceptor와 request 함수를 조합하면 위와 같은 createRequest()
함수가 탄생합니다. 각 인터셉터 인자들은 옵셔널한 값이므로, 인자 전달이 안되었을 경우 기본 값을 지정하는 로직을 추가해줬습니다. 클로저 개념을 활용해 인자로 넘겨준 인터셉터를 활용하는 request()
함수를 리턴하고, 이 request()
함수를 fetch 처럼 활용하면 됩니다.
createRequest()
의 인터셉터는 다음과 같이 사용할 수 있습니다.
const request = createRequest({
onRequest: (config) => {
console.log("요청 가로채기");
return config;
},
onResponse: (response) => {
console.log("응답 가로채기");
return response;
},
});
request("/api/example")
인터셉터 기능은 완성되었지만, 이왕 http 객체를 만든 김에 기능을 조금 더 확장 해보려고 합니다. 인터셉터 외에도 fetch를 사용하며 반복되는 로직들을 추상화시켜 axios 부럽지 않은 HTTP 클라이언트 라이브러리를 만들어보려고 합니다.
다음과 같은 기능들을 새롭게 추가하려고 합니다.
- json() 자동으로 해주기
- base url, default config 설정하기
- http method 함수 추가하기
Rest API를 사용할 경우, 주고 받는 데이터 형식은 대부분 JSON 형식입니다. 이를 위해 response를 매번 json() 함수를 활용해서 파싱해줘야 하는 불편함이 있었습니다. 이 과정을 줄이고자 createRequest()
함수 내에서 JSON 파싱을 수행해주고, 파싱한 값을 응답 객체(Response)에 새로 추가해주려고 합니다. 추가로, 요청 config도 함께 응답 객체에 추가해주겠습니다.
반환할 응답 객체의 타입은 다음과 같습니다.
type HttpResponse<T extends object> = {
data: T;
config: RequestInit;
headers: Headers;
ok: boolean;
redirected: boolean;
status: number;
statusText: string;
type: ResponseType;
url: string;
};
기존 Response 객체 타입에 data와 config 필드만 추가한 타입인 HttpResponse 타입을 새로 만들어줬습니다. data 필드의 타입을 외부에서 지정할수 있도록 제네릭 타입으로 지정해줬습니다.
fetch 실행 후 받은 response를 HttpResponse로 바꿔주는 작업을 해줘야하는데요. 이를 수행하는 함수를 새로 만들겠습니다.
const processHttpResponse = async <T extends object>(response: Response, config: RequestInit) => {
const data = (await response.json().catch(() => ({}))) as T;
const { headers, ok, redirected, status, statusText, type, url } = response;
return { data, config, headers, ok, redirected, status, statusText, type, url };
};
response의 body 데이터를 json으로 파싱한 뒤 객체에 넣어 반환하는 함수입니다. 여기서 주의할 점은, response의 body가 JSON 형식이 아니라면 json() 함수 실행시 에러가 나기 때문에, 에러 발생시 빈 객체({}
)를 data에 넣어주는 코드도 작성해줍니다.
이를 적용한createRequest()
의 request()
함수는 다음과 같습니다.
const request = async <T extends object = object> (url: string, config?: RequestInit) => {
try {
config = config && onRequest(config);
const response = await fetch(url, config);
return processHttpResponse<T>(response, config).then(interceptor.onResponse).catch(interceptor.onResponseError);
} catch (reason) {
return interceptor.onRequestError(reason);
}
};
onResponse를 실행하기 전, processHttpResponse()
함수를 통해 response 값을 파싱해줍니다. 또한, request에서 제네릭 값을 지정할 수 있도록 해줬습니다.
이렇게 바꿔줌으로써, fetch 작업 후 귀찮았던 json() 파싱 과정과 타입 단언 과정을 생략할 수 있게되었습니다.
// before
request("/api/1")
.then((response) => response.json())
.then((data) => {
return data as ApiData;
});
// after
request<ApiData>("/api/1").then((response) => {
return response.data
});
기본 url과 기본 config를 설정할 수 있게하는 기능입니다. 이 기능은 createRequest()
의 인자로 base url과 default config를 받아서 request()
에서 붙여주기만 하면 됩니다.
const createRequest = (baseURL = "", defaultConfig: RequestInit = {}, {
// interceptors
}: Interceptor) => {
const request = (url: string, config?: RequestInit) => {
try {
url = `${baseURL}${url}`
config = { ...defaultConfig, ...interceptor.onRequest(config) };
config.headers = { ...defaultConfig.headers, ...config.headers };
const response = await fetch(url, config);
return processHttpResponse<T>(response, config).then(interceptor.onResponse).catch(interceptor.onResponseError);
} catch (reason) {
return onRequestError(reason);
}
};
return request;
};
axios에는 axios.get()
, axios.post()
등 http method에 따른 함수들이 있습니다. 소소하지만 이 함수들이 사용하기에도 편했고, 가독성 측면에서도 큰 장점으로 느껴졌었습니다. 이런 method alias 함수들을 createRequest()
함수에도 추가해주려고 합니다.
이를 적용하기 위해 createRequest()
함수의 반환 타입을 고민해야 했습니다. 기존에는 request
함수를 바로 넘겨줬었는데, 이를 하나의 객체로 넘겨주는 방식으로 변경하려고 합니다. 이에 따라 함수의 이름도 createRequest()
에서 createHttp()
로 변경해주었습니다.
// before
const request = createRequest(...);
request("/api");
// after
const http = createHttp(...);
http.request("/api");
http.get("/api");
http.post("/api");
http.put("/api");
이렇게 변경한 이유는 http(구 request)
의 확장성을 더 넓히고 싶기 때문입니다. 하나의 함수로 한정 짓기보단, http
라는 객체에 다양한 함수들을 추가해줄 여지를 남겨주려고 합니다. 이를 위해 createHttp()
의 반환문은 다음과 같습니다.
return {
request,
get: <T extends object>(url: string, config: RequestInit = {}) =>
request<T>(url, {
...config,
method: "GET",
}),
post: <T extends object>(url: string, config: RequestInit = {}) =>
request<T>(url, {
...config,
method: "POST",
}),
// 이하 method 생략
}
createRequest()
가 createHttp()
로 바뀜으로 인해 단순히 인터셉터를 등록하는 함수 역할에 그치지 않고, 더 범용성 있는 http 유틸 생성 함수의 역할이 되었는데요. 이로 인해 함수 호출 시점에 인터셉터를 등록하는 것보단, 인터셉터를 등록하는 함수를 따로 분리하는게 더 낫다고 생각 들었습니다.
const createHttp = (baseURL = "", defaultConfig: RequestInit = {}) => {
let interceptor: Interceptor = {
onRequest: (config) => config,
onResponse: (response) => response,
onRequestError: (reason) => Promise.reject(reason),
onResponseError: (reason) => Promise.reject(reason),
};
...
return {
...
registerInterceptor: (customInterceptor: Partial<Interceptor>) => {
interceptor = {
...interceptor,
...customInterceptor,
};
},
}
}
createHttp의 인자에서 interceptors를 빼고, 함수 내부 변수로 이동시켰습니다. 그리고 registerInterceptor()
함수를 활용해서 인터셉터를 등록할 수 있도록 해주었습니다.
const http = createHttp();
http.registerInterceptor({
onRequest: (config) => config,
...
})
이렇게 만든 createHttp()
함수를 활용해서 토큰 로직을 구현해보겠습니다.
프로젝트마다 토큰을 다루는 방식이 조금씩 다릅니다. 각 프로젝트별로 토큰을 저장하는 위치도 다를 것이고, 토큰 만료와 같이 서버에서 에러가 발생했을 때 이 에러를 처리하는 방식도 다를겁니다. 이번 글의 코드는 저희 하루스터디 프로젝트에서 다루는 방식 기준으로 설명드리지만, 어떤 프로젝트든 큰 틀은 다르지 않을 것이라고 생각합니다.
뒤에 나오는 코드들의 이해를 돕고자 저희 프로젝트에서 토큰을 다루는 방식에 대해 간략히 설명드리겠습니다.
먼저, 토큰 저장위치의 경우 액세스 토큰은 Session Storage에, 리프레시 토큰은 Cookie에 저장합니다. 그러나 각 저장소의 API를 직접 사용하지 않고, tokenStorage
라는 객체로 토큰 저장소를 추상화하여 사용합니다. tokenStorage
는 다음과 같이 사용합니다.
tokenStorage.accessToken: 엑세스 토큰 가져오기
tokenStorage.setAccessToken(): 액세스 토큰 저장하기
tokenStorage.clear(): 토큰 지우기
...
또한, 하루스터디 프로젝트에서는 서버에서 에러가 발생할 경우, 에러 코드와 에러 메시지를 응답에 담아 보내주게 됩니다. 액세스 토큰 만료 에러의 경우, 400 상태 코드와 함께 아래와 같은 데이터가 응답 body에 담겨서 넘겨주게 됩니다.
{
code: 1403,
message: "토큰이 만료되었습니다."
}
onRequest()
인터셉터를 활용해서 header에 토큰을 항상 넣도록 하겠습니다.
const http = createHttp();
http.registerInterceptor({
onRequest: (config) => {
if (!tokenStorage.accessToken) return config;
config.headers["Authorization"] = `Bearer ${tokenStorage.accessToken}`
return config;
}
})
엑세스 토큰이 없다면 early return 해주고, 토큰이 있다면 config의 Authorization 값을 토큰으로 설정해줍니다.
설정 후 네트워크 탭을 보니 Authorization 값이 잘 설정된 모습을 볼 수 있습니다.
서버로부터 토큰이 만료 되었다는 응답을 받을 경우, onResponse()
인터셉터를 활용해서 처리해줍니다.
const http = createHttp();
http.registerInterceptor({
onRequest: ...// 위 코드와 동일 코드이므로 생략
onResponse: async <T extends object>(response: HttpResponse<T>) => {
if (isApiErrorData(response.data)) {
const errorCode = response.data.code;
if (errorCode === 1403 || errorCode === 1404) {
return refreshAndRefetch(response);
}
}
return response;
},
});
response가 ApiErrorData(하루스터디의 에러 응답)
형식이고, 그 에러 응답의 코드가 1403(토큰 만료)
이거나 1404(토큰 없음)
일 때, 토큰 갱신 및 재요청을 하는 refreshAndRefetch()
함수를 실행하여 반환하도록 합니다.
여기서 refreshAndRefetch()
함수의 코드는 다음과 같습니다.
const refreshAndRefetch = async <T extends object>(response: HttpResponse<T>) => {
const {
data: { accessToken },
} = await http.post<{ accessToken: string }>('/auth/refresh');
tokenStorage.setAccessToken(accessToken);
return http.request<T>(response.url, response.config);
};
하루스터디의 토큰 갱신 api url(이 url은 프로젝트마다 다릅니다.)로 요청을해서 새로 갱신된 액세스 토큰을 가져오고, 이 액세스 토큰을 스토리지에 새로 저장합니다. 그 후에 이전 요청의 url과 config로 동일한 요청을 다시 날립니다.
브라우저의 네트워크 탭을 보면, 처음 me
요청이 토큰 만료로 인해 실패하고 refresh
요청 후 다시 me
요청을 보내는 모습을 볼 수 있습니다. 잘 작동하네요👍
createHttp()
가 만들어진 과정을 요약하면 다음과 같습니다.
createRequest()
함수 구현createHttp()
함수 구현이렇게 만든 createHttp()
를 사용해서 JWT 토큰 로직을 핸들링 해줄 수 있게되었고, 이후에도 axios 같은 라이브러리 도입 없이 잘 사용중입니다 :)
createHttp()
의 전체 코드가 궁금하다면 아래 깃허브 링크를 통해 확인 가능합니다.
또한, NPM에도 배포했으니 필요하신 분이 있다면 npm install http-request-fetch
명령어를 통해 사용 가능합니다. (axios 번들 사이즈의 1/30 밖에 안됨 👍)