JWT access token 전역으로 저장하지 않고 다른 요청 헤더로 보내기 (문제 해결)

Devinix·2024년 3월 7일
0

[문제 해결]

목록 보기
17/29
post-thumbnail

개요

Next.Js를 활용해 로그인 기능을 개발하는 과정에서, 사용자가 입력한 이메일과 비밀번호를 서버에 전송하고, 서버로부터 access token을 받아오는 기능을 성공적으로 구현했다. 이 글에서는 access token을 처리하는 기존 방식과 이를 더욱 안전하게 다룰 수 있는 방법에 대해 다루어 보고자 한다. 기존의 코드를 보자.

useSignInForm 훅

interface IProps {
  inputValues: ISignInValues;
  setInputValues: Dispatch<SetStateAction<ISignInValues>>;
}

interface IUseSignInForm {
  handleSignIn: (e: React.FormEvent<HTMLFormElement>) => void;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

function useSignInForm({
  inputValues,
  setInputValues,
}: IProps): IUseSignInForm {
  const router = useRouter();
  const { setLoggedInTrue } = useIsLoggedinStore();
  const { setUserId } = useUserIdStore();
  const { setUserNickName } = useUserNickNameStore();

  const { onChange } = useOnChange<ISignInValues>({ setInputValues });

  const handleSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      const userData = {
        email: inputValues.email,
        password: inputValues.password,
      };

      const signInApiUrl = "/user/login";

      const { response, data } = await authApi<ISignInValues>(
        userData,
        signInApiUrl
      );

 	  // ...생략

      if (response.ok) {
        setInputValues(() => ({
          email: "",
          password: "",
        }));
        const jsonData = JSON.parse(data);
        const { accessToken } = jsonData;

        // 로컬스토리지에 access token 저장
        localStorage.setItem("accessToken", accessToken);
        
        setUserId(jsonData.userId);
        setUserNickName(jsonData.userInfoResponse.nickName);
        setLoggedInTrue();
        router.push("/");
        return data;
      } else {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
    } catch (error) {
      console.error("There was an error!", error);
    }
  };

  return {
    handleSignIn,
    onChange,
  };
}

export default useSignInForm;

문제 상황

기존 코드에서는 access token을 localStorage에 직접 저장해 관리하고 있었다. 이 방식은 access token을 손쉽게 저장하고, 필요할 때마다 localStorage에서 access token을 가져와 요청 헤더에 추가하는 방식으로 간단히 사용할 수 있지만, XSS 공격에 취약하다는 이유로 인해 권장되지 않는다고 했던 것 같다. axios를 사용하면 access token을 전역적으로 관리할 필요 없이 다른 요청의 헤더에 쉽게 추가할 수 있지만, Next.Js에서는 확장된 fetch 함수 사용이 권장되기에, fetch 함수를 활용해 서버 통신을 진행하면서 access token을 어떻게 좀 더 안전하게 관리할 수 있을지에 대해 고민 중이었다.

해결 과정

클로저

클로저는 함수와 그 함수가 선언될 때의 렉시컬 환경과의 조합이다. 렉시컬 환경은 함수가 선언된 시점의 변수 및 스코프에 대한 정보를 담고 있다. 클로저를 통해 함수는 자신이 생성될 때의 환경을 "기억"하고, 이 환경 밖에서 호출되어도 해당 환경에 접근할 수 있는 특성을 가진다.

클로저 사용 용도

  1. 데이터를 안전하게 숨기고 관리하기 위해
  2. 콜백 함수와 비동기 작업에서 외부 변수를 사용하기 위해
  3. 고차 함수에서 파라미터나 반환값으로 함수를 사용할 때

클로저 패턴 도입

우선 access token을 관리할 파일을 하나 만들어주었다.

/utils/tokenManager.ts

// 클로저 패턴 
function createTokenManager() {
  let accessToken: string | null = null;

  return {
    setToken: (token: string) => {
      accessToken = token;
    },
    getToken: () => accessToken,
  };
}

const tokenManager = createTokenManager();

export default tokenManager;

변수 선언과 초기화

createTokenManager 함수 내부에서 accessToken 변수가 선언되고 null로 초기화된다. 이 변수는 createTokenManager 함수의 지역 변수이며, 함수 외부에서는 접근할 수 없다.

클로저 생성

createTokenManager 함수는 객체를 반환하는데, 이 객체는 setToken과 getToken 두 메소드를 포함한다. 이 두 메소드는 accessToken 변수를 사용한다. 여기서 중요한 점은, 이 두 메소드가 createTokenManager 함수의 지역 변수 accessToken에 접근할 수 있다는 것이다. 함수가 실행되어 반환될 때, 반환되는 객체의 메소드들은 그들이 선언된 시점의 렉시컬 환경에 접근할 수 있기 때문에, accessToken 변수를 "기억"하게 된다.

이러한 현상이 가능한 이유는 JavaScript의 함수가 호출될 때 그 함수의 스코프 내의 변수뿐만 아니라, 해당 함수가 선언된 시점의 스코프에 있는 모든 변수에 대한 참조를 유지하기 때문이다. 이것이 바로 클로저의 핵심 원리이다.

변경된 useSignInForm 훅

interface IProps {
  inputValues: ISignInValues;
  setInputValues: Dispatch<SetStateAction<ISignInValues>>;
}

interface IUseSignInForm {
  handleSignIn: (e: React.FormEvent<HTMLFormElement>) => void;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

function useSignInForm({
  inputValues,
  setInputValues,
}: IProps): IUseSignInForm {
  const router = useRouter();
  const { setLoggedInTrue } = useIsLoggedinStore();
  const { setUserId } = useUserIdStore();
  const { setUserNickName } = useUserNickNameStore();

  const { onChange } = useOnChange<ISignInValues>({ setInputValues });

  const handleSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      const userData = {
        email: inputValues.email,
        password: inputValues.password,
      };

      const signInApiUrl = "/user/login";

      const { response, data } = await authApi<ISignInValues>(
        userData,
        signInApiUrl
      );

 	  // ...생략

      if (response.ok) {
        setInputValues(() => ({
          email: "",
          password: "",
        }));
        const jsonData = JSON.parse(data);
        const { accessToken } = jsonData;

        // tokenManager.setToken으로 access token 전달
        tokenManager.setToken(accessToken);
        
        setUserId(jsonData.userId);
        setUserNickName(jsonData.userInfoResponse.nickName);
        setLoggedInTrue();
        router.push("/");
        return data;
      } else {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
    } catch (error) {
      console.error("There was an error!", error);
    }
  };

  return {
    handleSignIn,
    onChange,
  };
}

export default useSignInForm;

이제 access token을 필요로 하는 요청들에서

tokenManager.getToken();

을 이용하여 보다 안전하게 access token을 가져올 수 있다.

결론

클로저를 이용하면 데이터를 안전하게 관리할 수 있는 이유는, 클로저가 외부에서 직접 접근할 수 없는 "은닉화된" 환경을 제공하기 때문이다. 이는 변수나 함수가 외부 스코프로부터 은닉되며, 오직 정의된 특정 함수를 통해서만 접근할 수 있다.

access token과 같은 중요한 정보를 클로저 내에 저장함으로써, 외부의 무단 접근으로부터 보호할 수 있다는 걸 깨달았다. 코드는 간단하지만 개념적으로는 조금 어려웠다. 앞으로 공부를 더 열심히 해야겠다는 생각이 들었던 시간이었다...

profile
프론트엔드 개발

4개의 댓글

comment-user-thumbnail
2024년 3월 7일

ㅋㅋㅋㅋㅋㅋ

1개의 답글
comment-user-thumbnail
2024년 3월 7일

ㅋㅋㅋㅋㅋㅋ

답글 달기
comment-user-thumbnail
2024년 3월 7일

잘 봤습니다~!

답글 달기