고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

나의 JWT 이야기

고윤태 2024. 1. 8. 09:28
안녕하세요 이번에 작성하게 될 내용은 제가 그동안 개발을 하면서 JWT를 사용하면서 느꼈던 경험과 모자란 지식을 채운 경험에 대해 공유하고자 글을 작성하게 되었습니다. (당연히 지금도 모자다라고 생각합니다.)

 

🤔 JWT가 뭔데?

JWT란?

JWT(Json Web Token)는 말 그대로 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격인데요. 주로 사용자의 인증(authentication) 또는 인가(authorization) 정보를 서버와 클라이언트 간에 안전하게 주고받기 위해서 사용됩니다.

JWT 토큰 웹에서 보통 Authorization HTTP 헤더를 Bearer <토큰>의 형태로 설정하여 클라이언트에서 서버로 전송되며, 서버에서는 토큰에 포함되어 있는 서명(signature) 정보를 통해서 위변조 여부를 빠르게 검증할 수 있게 됩니다.

JWT 토큰은 Base64로 인코딩이 되어 있어서 육안으로 보면 eyJ로 시작하는 아주 긴 문자열인데요. 온라인 디버거를 통해서 어렵지 않게 실제로 저장되어 있는 내용을 JSON 형태로 디코딩하여 확인해 볼 수 있습니다.

JWT 구조

하나의 JWT 토큰은 헤더(header)와 페이로드(payload), 서명(signature) 이렇게 세 부분으로 이루어지며 각 구역이. 기호로 구분됩니다.

<헤더>.<페이로드>.<서명>

 

첫 번째 부분인 헤더(header)에는 토큰의 유형과 서명 알고리즘에 명시되고, 중간 부분인 페이로드(payload)에는 소위 claim이라고도 불리는 사용자의 인증/인가 정보가 담기는데요. 마지막 부분인 서명(signature)에는 헤더와 페이로드가 비밀키로 서명되어 저장됩니다.


저보다는 훨씬 더 퀄리티가 좋은 내용으로 잘 작성된 글이 있어서 출처를 남기겠습니다. JWT에 대해 더 알고 싶다면 출처 링크의 글을 참고하시는 게 더 좋을 것 같다는 생각이 드네요

 

JWT 설명 출처(https://www.daleseo.com/jwt/)

❓JWT를 왜 썼는데?

JWT의 사용 이유에 대해 설명하기 위해 미리 알면 좋은 것들을 설명해 드리겠습니다.


인증과 인가

인증

  • Authentication
  • 로그인
  • 놀이공원 입장

인가

  • Authorization
  • 사용자의 로그인 이후의 활동에 대한 서버의 허가
  • 티켓을 보여주면 놀이기구를 탈 수 있음

인증과 인가의 방법

  • 쿠키
  • 세션
  • 토큰

JWT 통신 방식


JWT 사용 이유

  • 로그인
    • 사용자 로그인 => 서버가 해당 유저의 토큰을 유저에게 전달 (JWT)
      • 유저가 요청할 때 토큰을 포함해서 전달
      • 서버는 해당 토큰일 권한이 있는지 유효하고 인증이 되었는지 확인하고 유저의 요청 작업을 진행
    • 서버는 유저의 세션을 유지할 필요가 없다.
      • 유저가 보낸 토큰만 확인하면 된다.
      • 서버의 자원을 아낄 수 있다.
  • 정보교류
    • JWT는 두 개체 사이에서 안정성 있게 정보를 교환하기에 좋은 방법이다.
      그 이유는, 정보가 sign 이 되어있기 때문에 정보를 보낸 이 가 바뀌진 않았는지,
      또 정보가 도중에 조작되지는 않았는지 검증할 수 있다.

이러한 장점들 덕에 JWT를 다른 유저 분들도 많이 선호하고 있지 않나 싶습니다.

 

📝 JWT를 사용일지

JWT에 대해 첫걸음

이때도 평소의 일상과 다름없이 공부를 진행하고 있었습니다. 지금은 유료강의로 인프런에서 판매가 되고 있지만 제로초님이 강의 판매를 위하여 유튜브에 라이브 방송을 통하여 ReactNative에 대한 강의를 진행하고 계셨습니다.

 

 

시간이 지나면 당연히 유로강의로 전환이 될 것을 알고 있었고 평소에 React Native 개발을 한 번쯤은 진행해보고 싶었던 욕구도 있었던 시기에 마침 제로초님이 ReactNative 강의를 라이브로 무료로 진행하여 시청했었습니다.

이 강의 내용 중에 감사하게도 제로초님이 그동안 제가 JWT라는 키워드와 기본적인 개념만을 인지하고 있고 제대로 아는 것도 없고 어떤 식으로 사용해야 하는지도 모르는 상태에서 JWT에 대한 친절한 설명과 함께 코드를 제공해 주셨습니다.

아래 코드는 제로초님이 작성하신 JWT 사용을 위한 코드입니다.

  useEffect(() => {
    axios.interceptors.response.use(
      response => {
        return response;
      },
      async error => {
        const {
          config,
          response: {status},
        } = error;
        if (status === 419) {
          if (error.response.data.code === 'expired') {
            const originalRequest = config;
            const refreshToken = await EncryptedStorage.getItem('refreshToken');
            // token refresh 요청
            const {data} = await axios.post(
              `${Config.API_URL}/refreshToken`, // token refresh api
              {},
              {headers: {authorization: `Bearer ${refreshToken}`}},
            );
            // 새로운 토큰 저장
            dispatch(userSlice.actions.setAccessToken(data.data.accessToken));
            originalRequest.headers.authorization = `Bearer ${data.data.accessToken}`;
            // 419로 요청 실패했던 요청 새로운 토큰으로 재요청
            return axios(originalRequest);
          }
        }
        return Promise.reject(error);
      },
    );
  }, [dispatch]);

 

코드 살펴보기

 

useEffect 훅을 사용하여 axios의 응답 인터셉터를 설정하는 부분입니다. 응답 인터셉터는 axios를 사용하여 서버로부터 응답을 받을 때 실행되는 함수입니다.

 

여기서 사용된 응답 인터셉터는 두 개의 콜백 함수를 인자로 받습니다. 첫 번째 콜백 함수는 정상적인 응답을 처리하고 반환하는 역할을 합니다. 두 번째 콜백 함수는 요청이 실패했을 때 실행되며, 에러를 처리하고 처리된 에러를 반환합니다.

두 번째 콜백 함수에서는 에러 객체에서 요청 정보(config)와 응답 상태(status)를 추출합니다. 상태(status)가 419인 경우에만 처리가 진행됩니다.

419 상태는 "인증 시간 초과"를 나타내며, 이 경우에는 access token의 유효기간이 만료되었음을 의미합니다. 만약 에러 응답 데이터의 코드(code)가 'expired'인 경우에는 토큰을 갱신하는 로직이 수행됩니다.

갱신된 토큰은 EncryptedStorage에서 가져오고, refresh token을 사용하여 서버에 토큰 갱신 요청을 보냅니다. 새로운 토큰을 받은 후에는 받은 토큰을 저장하고, 원래 요청의 헤더에 새로운 토큰을 설정합니다. 그리고 419로 실패한 원래 요청을 새로운 토큰을 사용하여 다시 요청합니다.

 

Async Storage vs Encrypted Storage

(출처 : https://github.com/react-native-async-storage/async-storage)
(출처 : https://github.com/emeraldsanto/react-native-encrypted-storage)

 

앱이 꺼졌다 켜진 후에도 데이터 유지가 가능한 것이 바로 Async-storage이며, 웹으로 치면 로컬스토리지와 유사하다. 그렇다면 Encrypted-storage는 왜 필요할까? 그 이유는 Async-storage 공식 문서에서 찾아볼 수 있다. 공식 문서 한 줄 소개에서는 Async-storage를 'An asynchronous, unencrypted, persistent, key-value storage system for RN'이라고 정의되어 있다. 그대로 해석하면, '비동기적 - 암호화되지 않으며 - '키-값'으로 저장되는 시스템'이라고 한다. 데이터가 암호화되지 않는 스토리지이기 때문에 누구든지 값을 열어볼 수 있어 귀중한 토큰을 보관할 시에는 적합하지 않다.

 

이를 보안한 것이 바로 Encrypted-storage이다. '비동기적 - '키-값'으로 저장'은 동일하며 실제로 구현 방식도 async-storage와 동일하다. 공식 문서의 한 줄 소개만 보아도 Async storage의 약점인 보안을 강화시키기 위한 라이브러리임을 알 수 있다. 따라서, 보안이 철저히 필요한 토큰들은 Async storage가 아닌 Encrypted storage에 넣어주면 된다.


어떻게 사용했는데?

위에 설명한 일을 토대로 JWT를 어떤 식으로 사용해야 하는지 아주 아주 조금은 알게 된 상태에서 지인들과 프로젝트를 진행하게 되었습니다.

팀원들과 얘기를 통해 프로젝트의 주제를 결정하였고 팀원들과 소통 끝에 가장 좋은 플랫폼은 Web이라고 결정을 내렸습니다.

해당 프로젝트에서 저는 팀 리드 및 프론트엔드 개발을 맡았고 같이 진행하는 친구의 기술 선택을 조율하여 React를 사용하고 전역 상태 관리는 redux를 사용하기로 결정했습니다.

저는 팀원들에게 제가 알고 있는 지식과 정리된 글을 토대로 JWT를 적용하자고 팀원을 설득하였고 팀원들도 호의적인 반응이었습니다.

백엔드도 Node JS를 사용하여 제가 JWT를 적용하기 위해 middleware 작업을 먼저 진행했습니다.

middleware가 모르시는 분들을 위해 간단하게 비유를 통해 설명을 하자면

middleware?

미들웨어는 마치 긴 여정 중에 들르는 휴게소와 같습니다. 여행자가 목적지로 가는 길에 휴게소에 들르듯이, 데이터나 요청도 서버의 최종 목적지(특정 API 엔드포인트)에 도달하기 전에 미들웨어를 거칩니다.

 

이러한 역할을 해주는 middleware 작업을 끝 마친 뒤 프론트엔드 작업을 들어갔습니다.

제로초님 코드를 참고해서 작업했었습니다.

 

프론트엔드 코드

  //api 요청 후 accessToekn이 유효하지 않다면 재발급 후 다시 요청, refreshToken이 유효하지 않다면 사용자에게 다시 로그인 요청
  useEffect(() => {
    axios.interceptors.response.use(
      (response) => {
        return response;
      },
      async (error) => {
        const {
          config,
          response: { status },
        } = error;

        if (status === 419) {
          if (error.response.data.code === 'expired') {
            const type = error.response.data.type;

            if (type === 'refresh') {
              dispatch(userSlice.actions.initUser());
              alert('다시 로그인해주세요.');
              return;
            } else if (type === 'access') {
              const originalRequest = config;
              const refreshToken = localStorage.getItem('refreshToken');

              const {
                data: { data },
              } = await axios.post(
                'api/token/refresh-token',
                {},
                {
                  headers: {
                    authorization: `${process.env.REACT_APP_JWT_KEY} ${refreshToken}`,
                  },
                }
              );

              //새로운 토큰 저장
              dispatch(userSlice.actions.setAccessToken(data.accessToken));
              //419 요청에 실패했던 요청 새로운 토큰으로 재요청
              originalRequest.headers.authorization = `${process.env.REACT_APP_JWT_KEY} ${data.accessToken}`;

              return axios(originalRequest);
            }
          }
        }

        return Promise.reject(error);
      }
    );
  }, [dispatch]);

 

코드 살펴보기

 

주석에 작성해 놨지만 axios를 사용하여 API 요청 후 accessToken이 유효하지 않다면 localStorage에 보관한 refreshToken을 사용하여 accessToken을 재발급받은 후 이전에 요청했던 API 요청을 다시 보내기와 redux를 사용하여 관리하고 있는 user state에 accessToken 값을 변경해 주는 코드입니다.


면접 이야기

운이 좋아서 면접의 기회를 얻었을 때 하나의 질문을 받았습니다.

"백엔드와 소통은 어떤 식으로 했으며, 보안 문제는 어떻게 해결하셨나요?"라는 느낌의 질문을 받았습니다.

저는 "Rest API를 이용하여 백엔드와 데이터를 주고받았으며, 보안 문제의 경우 JWT를 사용했습니다"라고 답 했습니다.

그 후 면접관님은 accessToken과 refreshToken에 대한 차이를 질문하셨었고 저는 여기까지는 자신 있게 답했었습니다.

 

 

하지만 바로 또 다른 질문이 들어왔습니다. "accessToken과 refreshToken을 각각 어디에 보관하셨나요?"라는 질문에 저는

"accessToken은 redux를 이용한 전역 상태에 보관하였고, refreshToken은 localstorage에 보관하였습니다."이 대답에 면접관님은 의아하시면서 "JWT를 사용하는 이유가 뭐였죠?"라고 다시 물어보셨습니다.

저는 아차 싶은 감정이 온몸을 지배했고 몸이 얼어붙기 시작했습니다. 면접관님은 저 스스로도 이상함을 감지한 것을 눈치채셨는지 한 번의 기회를 더 주시는 듯한 의미로 "그러면 어디에 보관하면 좋을까요?"라고 다시 물어봐주셨습니다.

저는 잘 대답해야 한다는 압박감을 가지고 "잠깐 생각할 시간을 주실 수 있을까요?"라고 답을 하였고 면접관님은 흔쾌히 생각의 시간을 허락해 주셨습니다.

 

 

 

아쉽게도 정답을 대답하지 못하였고 면접은 그 후 몇 가지의 추가 질문 후에 종료가 됐습니다.

그 후 면접관님께서 "지원자님께서 저희한테 여쭤보고 싶은 게 있으신가요?"라는 말씀을 해주셨고

저는 기회다 싶어서 아까 답하지 못했던 "refreshToken은 어디에 보관하는 것이 정답인가요?"라고 여쭤봤고

면접관님께서 "Cookie에 httpOnly를 이용하여 저장하는 것입니다."라고 정답을 알려주셔서 듣자마자

"아~" 소리가 나왔습니다. 비록 면접에서는 좋은 결과를 얻지 못했지만 지식적으로 얻은 것이 있는 의미 있는 면접이었습니다.


새로운 프로젝트에 적용해 보자

현재 진행하고 있는 토이 프로젝트 "Code-Koi"에도 당연히 JWT를 적용하기로 결정했었습니다.

Code-Koi 프로젝트는 Web 환경에서 React + TypeScript로 진행하고 있는 프로젝트입니다.

지난번 프로젝트와 코드에서의 차이가 있다면

  const { accessToken } = useSelector((state) => state.user);

  const getPostList = useCallback(async () => {
    try {
      const {
        data
      } = await axios.get(
        `api/`,
        {
          ...(!!accessToken && {
            headers: {
              authorization: `${process.env.REACT_APP_JWT_KEY} ${accessToken}`,
            },
          }),
        }
      );

	  // something ...
    } catch (error) {
      alert(error.message);
    }
  }, [accessToken]);

 

지난 프로젝트에서는 accessToken이 필요한 API를 요청할 때 이런 식으로 코드를 구현했습니다.

너무나도 바보짓이었죠

 

 

왜 바보짓인지 다른 분들은 바로 느끼셨을 거 같습니다.

개발자는 반복을 지양해야 합니다.

 

이전에 제가 작성한 코드 중에 반복을 지양하기 위해 했던 것이 뭐가 있었을까요?

바로 API에서 response로 status 419를 전달해 준다면 token 에러에 대한 처리를 하는 것이었습니다.

그렇다면 API를 요청할 때 accessToken이 있다면 headers에 담아서 보내주는 것도 axios의 interceptors의 request를 이용하여 자동으로 처리할 수 있었다는 말이 됩니다.

 

코드

  const [user, setUser] = useRecoilState(UserState);

  useEffect(() => {
    const requestInterceptor = axios.interceptors.request.use((config) => {
      if (user.accessToken) {
        config.headers.Authorization = `${user.accessToken}`;
      }
      return config;
    });

    return () => {
      axios.interceptors.request.eject(requestInterceptor);
    };
  }, [user]);

 

refreshToken을 이용하여 accessToken을 재발급받는 경우에는 refreshToken의 경우 HttpOnly, Secure 등의 옵션을 이용하여 Cookie에 보관했으니 accessToken을 재발급받는 API 요청 시 Cookie는 자동으로 넘어가기 때문에 API 요청만 해주면 됩니다.


재발급이 왜 안돼?

Web이 아닌 React Native 환경에서 JWT를 처리하다 또 문제를 만났습니다.

원인을 찾고 나니 너무나 당연한 것이면서 허무했던 일입니다.

 

 

axios의 interceptors request를 이용하여 accessToken이 존재하면 headers의 Authorization에 accessToken을 담아서 보내도록 로직을 구현했었습니다.

하지만 accessToken이 만료되었을 때 refreshToken을 사용하여 재발급받는 로직에서 API를 사용하여 재발급받도록 구현을 했습니다.

 

코드

              const originalRequest = config;
              const refreshToken = await EncryptedStorage.getItem(
                'refreshToken'
              );

              const {
                data: { data },
              } = await axios.post(
                '/token/refresh-token',
                {},
                {
                  headers: {
                    authorization: `${refreshToken}`,
                  },
                }
              );
              setUser((prev) => ({ ...prev, accessToken: data.accessToken }));
              //419 요청에 실패했던 요청 새로운 토큰으로 재요청
              originalRequest.headers.authorization = `${data.accessToken}`;
              return axios(originalRequest);

 

하지만 재발급받는 API 호출도 axios를 사용하다 보니 axios의 interceptors request에서 authorization에 담아놨던 refreshToken을 만료된 accessToken으로 교체하여 보내게 되어서 config를 통해 url을 검사하여 refreshToken을 사용하여 accessToken을 재발급받는 API는 headers의 authorization에 accessToken을 담는 로직을 제외하도록 구현했습니다.

 

코드

    const requestInterceptor = axios.interceptors.request.use((config) => {
      if (user.accessToken && config.url !== '/token/refresh-token') {
        config.headers.Authorization = `${user.accessToken}`;
      }
      return config;
    });

 

이로서 accessToken을 재발급받는 로직에서 문제가 발생하지 않도록 되었습니다.

😁 글 작성 후기

이 후기를 작성하기 전부터 아마 지금까지 작성했던 글 작성 후기 중에 가장 길지 않을까 벌써 예상이 갑니다.

왜냐하면 글 작성에 가장 오랜 기간을 사용하기도 했고 적당한 정보 전달과 저의 경험을 잘 풀어내는 것을 동시에 해야 하는 글이기에 작성하는데 어려움도 많이 있었고 전부터 말했지만 이런 히스토리 형식을 잘 쓰고 싶다는 욕심이 과해 글이 오히려 처음에 작성을 목표했던 방향성을 잃지는 않을까 걱정도 있습니다. (양해해 주시면 감사합니다)

 

 

이번에 JWT를 사용하다 글을 작성해야겠다는 생각이 들자마자 주제로 잡고 글을 쓰게 됐습니다.

이유는 글에 작성했지만 JWT를 사용하면서 발생한 문제 해결을 하면서 제가 그동안 JWT를 사용하면서 겪은 일, 배웠던 과정 등을 글로 작성하고 싶다는 생각이 들었습니다.

JWT는 많은 사람들이 사용하기도 하고 많이 듣기도 하는 키워드라고 생각해서 JWT의 본질 또는 사용법에 대한 것은 이미 너무나도 저보다 작성을 잘해주신 분들이 많기 때문에 오히려 제가 JWT를 알게 된 이유, 사용했던 경험, 모자랐던 지식을 채운 방법에 대해 글을 작성하는 것이 굳이 JWT가 아니라도 다른 것을 타깃으로 삼았을 때도 이 사람은 이런 식으로 학습을 했구나에 대한 경험을 공유하는 것이 더 의미 있는 글이 될 것이라 생각하고 이런 글을 작성하게 됐습니다. 

제 목적이 전달이 잘 되어서 다른 분들께도 꼭 도움이 됐으면 하는 글입니다.

 

읽어주셔서 감사합니다.