http란?
웹은 클라이언트 PC와 서버 PC의 통신으로 구성된다. 사용자는 흔히 '서버'라고 불리는 컴퓨터에게 필요한 문서-데이터 등을 요청하고, 경우에 따라선 서버에 저장된 데이터를 '수정' 또는 '삭제'해달라는 명령을 보낸다. 서버는 클라이언트의 요청 내용에 따라 작업을 수행한 후 요청에 대한 응답을 전달한다. 웹이 동작하는 가장 기본적인 메커니즘이다.
해당 과정에서 클라이언트와 서버가 서로 통신하는 데 사용되는 규약(프로토콜; Protocol)을 http라고 한다. 흔히 브라우저에서 특정 페이지에 접속하기 위해 사용하는 URL을 보면 경로 앞에 http://
또는 https://
라는 Prefix가 붙는데, 이는 해당 URL 경로로 접근한 클라이언트와 경로의 반대쪽 끝에 있어 정보를 제공해 주는 서버가 http 통신 규약에 맞춰서 서로 통신하고 있음을 의미한다(https의 s는 Security를 의미한다. 일반 http보다 조금 더 보안에 신경 쓴 통신 규약을 의미한다).
클라이언트와 서버는 효율적이면서도 강력한 보안을 갖추기 위해 정해진 규약에 맞게 요청과 응답을 주고받게 된다. 그 과정에서 지켜야 할 수많은 규칙들이 http로 정의되어 있는데, 사실 이 부분에 대해 정확하게 알려면 매우 깊은 공부를 긴 시간 해야만 한다(그만큼 내용이 방대하다). 기회가 된다면 http 통신 규약에 대해서도 더 알아보기로 하고, 일단 이번 포스트에선 http 통신을 도와주는 추상화된 도구에 대해 살펴보려고 한다.
HTTP 클라이언트와 axios
아무리 개발자라고 해도 클라이언트와 서버의 통신을 위한 모든 http 규약을 다 아는 것은 어렵고, 또 규약을 안다고 해도 규약을 지켜서 요청과 응답을 주고 받는 과정으로 코드로 구현하는 것은 굉장히 힘든 일이다. 굉장히 높은 수준의 네트워크 지식이 필요하다.
이런 과정을 추상화해서 개발자로 하여금 클라이언트-서버 간의 통신을 조금 더 안전하고 효과적으로 처리할 수 있게 도와주는 도구를 HTTP 클라이언트라고 한다. HTTP 클라이언트는 시간의 경과에 따라 다양한 기술들이 등장했으며, 현재도 개발하는 환경이나 선호에 따라 사용할 수 있는 다양한 선택지가 존재한다.
사실상 최초의 비동기 데이터 통신(AJAX; Asynchronous Javascript And XML)을 가능하게 해줬던 XMLHttpRequest부터 시작해서, 한창 jQuery가 유행하던 시기에는 자체에서 제공해 주던 라이브러리가 있었고, 브라우저 API에서 내장 함수로 제공하는 fetch
메서드도 있다. 각각은 시간의 흐름에 따라 HTTP 요청을 조금 더 수월하게 해주기 위한 다양한 기능과 특징들을 가지게 됐으며, 이러한 과정에서 등장하게 된 http 클라이언트 중 하나가 axios이다.
axios는 http 통신을 위한 직관적인 문법을 제공하면서, 동시에 통신에 필요한 여러 추가적인 기능까지 제공해 준다. 사용 방법이 굉장히 쉽고, 인증/인가를 위한 토큰 처리나 Refresh Token 요청 등을 관리하는 데 유용한 기능 또한 제공하면서, 최근 큰 유행을 끌고 있다.
axios 사용해보기
axios는 Javascript 언어나 브라우저 API 등에서 기본 제공되는 기능이 아닌 별도의 라이브러리이다. 때문에, 사용을 위해선 별도 설치와 import
가 필요하다.
npm i axios
import axios from 'axios'
axios는 서버로 보내는 요청이 어떤 메서드인지를 axios 객체의 메서드(get
, post
, put
, delete
등)로 나타낸다.
import axios from 'axios'
axios.get(URL); // URL엔 API 엔드포인트 경로를 추가, URL만 인자로 넘기면 자동으로 GET 요청
axios.post(URL, data) // 두 번째 인자엔 URL 경로로 post 요청을 보낼 데이터(fetch의 body에 담길 데이터)를 추가
axios.put(URL + '/1', data) // '/1' 처럼 URL의 특정 경로로 접근하면 해당 경로로 접근 가능한 데이터를 수정 가능
axios.delete(URL + './1') // 삭제할 땐 어떤걸 삭제할지만 경로로 나타내고, 두 번째 인자로 별도 데이터는 전달하지 않음
fetch
메서드에선 URL과 함께 두 번째 config 정보를 넘기는 위치에서 method
, body
등의 정보를 객체 형태로 넘겨줘야 했는데, axios
는 http 메서드도 axios의 메서드로 표현하고, body
에 담아야 하는 데이터도 두 번째 인자로 바로 넘기면 돼서 직관적이다.
특히, axios
객체로 생성한 axios
객체는 서버에 전달할 데이터를 자동으로 직렬화(Serialize) 해준다. 직렬화란, 서버와 클라이언트의 통신 시 서로의 데이터 타입이 달라 발생할 수 있는 문제를 해결하기 위해, 클라이언트에서 서버로 데이터를 보낼 때 문자열로 변환해 주는 것을 의미한다. fetch
로 http 요청을 보낼 땐 body
에 담길 데이터를 JSON.stringify(data)
와 같이 JSON 객체의 정적 메서드로 직렬화를 직접 해줘야 했지만, axios
객체는 이를 자동으로 수행해 준다(직렬화를 깜빡해서 에러를 겪은 적이 몇 번 있는데, 굉장히 편리하다).
axios
로 요청을 보낼 때 헤더에 정보를 담아주고 싶다면 axios.메서드
의 두 번째 매개 변수(post
와 put
은 세 번째 매개 변수)에 헤더 객체를 추가해 주면 된다.
import axios from 'axios'
axios.get(URL, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
}
axios.post(URL, data, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
}
headers
이외에도, axios
객체의 메서드에는 다양한 config 정보를 담아서 요청에 포함할 수 있다. 해당 내용은 axios 공식 문서: 요청 config 부분을 참고하기 바란다.
axios 인스턴스
axios
는 인스턴스라는 강력한 기능을 제공한다. 인스턴스는 axios.create
라는 메서드로 생성할 수 있으며, http 요청을 필요한 여러 기본 값을 미리 설정해 줄 수 있다.
const instance = axios.create({
baseURL: URL, // 필요한 도메인의 URL 기본 설정
timeout: 1000,
headers: {'X-Custom-Header': 'foobar'} // headers 설정을 미리 넣어둘 수 있다
});
생성한 인스턴스 객체를 사용해서 http 요청을 다음과 같이 보내면 미리 정의해 둔 config 정보를 기본 반영해서 요청이 보내진다.
instance.get('/data');
instance.post('./data', data);
instance.put('./data/1', data);
instance.delete('./data/1');
예시 코드를 보면 알겠지만, baseURL
은 http 요청을 보낼 기본 도메인을 의미한다. 기본 도메인을 미리 URL로 설정해 뒀기 때문에, instance
로 요청을 보낼 땐 상대 경로만 작성해 주면 된다. 위의 예시에선 ${URL}/data
와 ${URL}/data/1
로 요청이 보내진다.
timeout
은 요청이 시간 초과되기 전의 시간(밀리초)을 의미한다. 예시에선 timeout
이 1000ms로 설정되어 있기 때문에, 요청에 대한 응답이 오는 데 시간이 1초 이상 걸리면 에러를 뱉어준다.
이렇게 인스턴스를 생성하면 불필요한 반복 코드(e.g. baseURL 반복 입력)를 줄일 수 있어 사용에 편리하다. 뿐만 아니라, 토큰 등 인증/인가 시 클라이언트에서 관리해야 하는 토큰 정보를 인스턴스의 헤더에 미리 박아두면 매번 http 요청을 생성할 때마다 인가 정보를 작성해주지 않아도 돼서 편리하다.
const instance = axios.create({
baseURL: URL, // API의 기본 URL
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '' // 토큰이 있으면 추가
}
});
instance.get('/my-page') // data라는 경로가 인가가 필요한 곳이더라도 인스턴스에 박아뒀기 떄문에 요청이 잘 된다
아래 코드를 사용해서 특정 인스턴스가 아니라 axios
객체에 전역적으로 토큰 키를 박아두는 것도 가능하다.
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
이렇게 하면 모든 api 요청에서 손쉽게 토큰 정보에 접근이 가능하다는 장점이 있지만, axios
를 사용하는 모든 곳에서 접근이 된다는 건 단점이 될 수도 있다. 인가가 불필요한 곳에서는 토큰 정보에 접근이 아예 안되도록 하는 것이 상대적으로 안전하고 효율적인 방식일 것이다.
그래서, 가능하면 토큰 정보를 넣어둘 인스턴스를 별도로 생성하고, 권한 인가가 필요한 페이지에 접근할 때에만 해당 인스턴스를 사용하는 것이 조금 더 권장된다. 해당 방식에 대해서도 잘 기억해 두자.
axios 인터셉터
axios
객체에는 '인터셉터'라는 기능도 있다. 의미는 말 그대로 http 요청을 보낼 때와 응답을 받을 때 클라이언트에서 미리 요청-응답을 각각 가로챈다는 것이며, 요청-응답이 양쪽 엔드포인트에 도달하기 전에 미리 추가적인 처리를 해줄 수 있다는 점에서 굉장히 유용하다.
사실, 처음엔 이게 왜 유용한지 잘 와닿지 않았는데, 토큰 기반 인증/인가 상황을 예로 들어보니 필요성에 크게 공감됐다. 토큰 기반 인증/인가란 로그인 정보가 확인되면 서버에서 클라이언트로 이후 권한이 필요한 페이지로 접근할 때 확인할 고유한 토큰 정보를 보내주고, 해당 토큰을 통해 서버로부터 권한을 인가받은 방식을 의미한다.
일반적으론 보안을 위해 인가를 위한 핵심 정보를 담고 있는 Access Token과, Access Token이 만료될 시 토큰을 재발급받기 위한 인가에 사용되는 Refresh Token 두 가지가 함께 사용되며, Refresh Token은 탈취되더라도 상대적으로 위험도가 덜한 정보만 담고 있는 대신 만료 기간을 길게 해서 보안과 유저 경험을 좋게 만든다(인증-인가에 대해선 아직 공부 중이라, 나중에 따로 정리해 봐야겠다).
해당 과정에서 중요한 것은 '토큰을 어디에서 어떻게 관리하냐'라는 것과, '토큰의 만료 여부를 언제 어떻게 확인할 것이냐', 그리고 '토큰이 만료됐을 때 어떤 절차로 재발급을 받을 것이냐'라는 부분이다. 그리고, 해당 문제를 다루는 데 있어 axios
의 인터셉터는 굉장히 유용하다.
코드로 살펴보자. 먼저, axios
객체로 인스턴스를 생성한 후 header
의 Authorization
에 토큰 정보를 아래와 같이 기본 설정해 줬다.
const instance = axios.create({
baseURL: URL, // API의 기본 URL
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '' // 토큰이 있으면 추가
}
});
이제 instance
로 http 요청을 보내면 인가가 잘 될 것이다. 다만, 아무런 절차 없이 그냥 요청을 보내면 headers
에 미리 등록해 둔 Access Token이 사용기간 만료된 것은 아닌지 확인하기 어렵다.
해당 상황에서 axios
의 인터셉터를 사용하면 http 요청이 날아가기 전에 해당 요청에 포함된 Access Token이 아직 만료되지 않았는지 확인한 다음, 만료가 됐을 시 Refresh Token으로 토큰을 갱신해서 요청이 가게 만들 수 있다.
instance.interceptors.request.use(
// config는 인터셉트 한 요청의 config를 의미
async (config) => {
const tokenExpiry = localStorage.getItem('tokenExpiry'); // 보통은 서버에서 내려주는 값이고 서버에서 값이 내려온 시간 + 토큰 만료까지 남은 ms 시간이라고 가정
const currentTime = Math.floor(Date.now()); // 현재 시간 (초 단위)
// 토큰 만료 확인, tokenExpiry가 없거나 만료 기간이 지났을 때 블록 내 본문 실행
if (tokenExpiry && (currentTime > tokenExpiry)) {
try {
const newAccessToken = await refreshAccessToken(); // Refresh Token으로 Access Token 다시 받아오는 util 함수, 인증 인가에 대한 포스트가 아니라서, 관련 내용은 일단 생략
instance.defaults.headers['Authorization'] = `Bearer ${newAccessToken}`; // 새 토큰을 axios 인스턴스 헤더에 추가
config.headers['Authorization'] = `Bearer ${newAccessToken}`; // 새 토큰으로 헤더 갱신
} catch (error) {
console.error('토큰 갱신 실패:', error);
throw error;
}
}
return config;
},
(error) => Promise.reject(error) // 에러가 나면 요청 종료하고 결과로 에러 내려보내기
);
인터셉트는 서버에서 오는 응답에 대해서도 할 수 있다. 응답에 대한 인터셉트는 Instance.interceptor.response.use()
를 사용하며, 점점 더 인증-인가에 대한 맥락이 많이 들어가는 것 같아 일단 생략한다(위 예시 코드도 GPT의 도움을 받아 만들어냈다...).
아무튼, 중요한 것은 axios
객체(또는 인스턴스)에 인터셉터를 미리 설정해 주면 http 요청을 보낼 때마다 반복 코드를 추가적으로 작성할 필요 없이 미리 정의된 설정을 적용시킬 수 있다는 것이다(인터셉터 구문을 http 요청 함수 내부에서 실행하는 게 아니라, 인스턴스의 config 설정 값처럼 미리 정의해 두고, 이후부턴 자동으로 해당 로직이 돌도록 하는 것이다).
마찬가지로 axios
객체에 직접 인터셉터를 설정하면 axios
객체를 사용하는 모든 곳에서 인터셉터가 적용되기 때문에, 가급적 필요한 로직 처리가 있는 경우 해당 인터셉터를 등록할 인스턴스를 별도로 생성해서 사용하는 게 권장된다.
결론
그동안 공부하면서는 http 클라이언트로 fetch
메서드만 사용했었는데, 현업에서 axios
를 훨씬 더 많이 사용하는 것 같기도 하고, 실제로 사용 방법이 굉장히 간단하며, 인증-인가가 들어간 서비스를 만들어보는 과정에서 '토큰을 어디에 저장할 것인가'라는 고민을 하던 끝에, 결국 axios를 프로젝트에 도입해 보기로 결정했다.
아직 문법도 살짝 어색하고, 인스턴스나 인터셉터를 잘 사용하는 방법도 생소하지만, 계속 공부하고 써먹어보면서 경험치를 잘 쌓아가야겠다. 끝.
- 레퍼런스
'Web' 카테고리의 다른 글
바닐라 자바스크립트로 SPA 라우팅 구현하기 (1) | 2024.11.09 |
---|