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을 어떻게 좀 더 안전하게 관리할 수 있을지에 대해 고민 중이었다.
클로저는 함수와 그 함수가 선언될 때의 렉시컬 환경과의 조합이다. 렉시컬 환경은 함수가 선언된 시점의 변수 및 스코프에 대한 정보를 담고 있다. 클로저를 통해 함수는 자신이 생성될 때의 환경을 "기억"하고, 이 환경 밖에서 호출되어도 해당 환경에 접근할 수 있는 특성을 가진다.
우선 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의 함수가 호출될 때 그 함수의 스코프 내의 변수뿐만 아니라, 해당 함수가 선언된 시점의 스코프에 있는 모든 변수에 대한 참조를 유지하기 때문이다. 이것이 바로 클로저의 핵심 원리이다.
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과 같은 중요한 정보를 클로저 내에 저장함으로써, 외부의 무단 접근으로부터 보호할 수 있다는 걸 깨달았다. 코드는 간단하지만 개념적으로는 조금 어려웠다. 앞으로 공부를 더 열심히 해야겠다는 생각이 들었던 시간이었다...
ㅋㅋㅋㅋㅋㅋ