'가장 티가 나지는 않지만 가장 도움이 되는' 기능 그 두번째 이야기.
회원의 인증기능은 어떤 웹페이지에도 필수적으로 필요하고 가장 중요하지만,
유저가 로그인 화면에 머무르는 시간은 가장 적다.
그래서 이번 프로젝트의 로그인 관련 기능은 내 담당이 되었다.
이전의 프로젝트에서 로그인 기능은 내 담당이 아니었지만
결국 소셜 로그인 부분은 내가 맡아서 했다.
당시에는 추가적인 로직이 필요하지 않은,
순수하게 소셜 로그인만 진행하면 되는 부분이었기 때문에
크게 어렵지 않다고 생각했고, 하루 정도면 구현이 끝날거라고 생각했다.
정말 큰 착각이 아닐 수 없다.

우리의 로그인 플로우는 조금 많이 특이했다.
우선 일반적으로 정보를 입력해서 할 수 있는 회원가입은 없앴다.
출결관리의 핵심인 알림 서비스가 카카오톡 연동으로 이루어지기 때문에
반드시 사용자의 카카오톡 계정 정보가 필요했기 때문이다.
또한 유저의 권한에 따라서 보여줘야 할 정보나 사용할 수 있는 기능이 달랐기 때문에
분기를 나누어야 했다.
첫번째 분기는 이 유저가 관리자인가, 일반유저인가.
관리자인 원장선생님의 가입에는 유치원을 생성하는 로직이 추가되어 있다.
두번째 분기는 일반유저가 선생님인가, 학부모인가.
권한에 따른 정보도 다르지만 가입시 입력해야하는 정보도 다르기 때문에
서로 다른 페이지에서 작업을 해야했다.
추가적으로 아무나 특정 유치원에 접속해서 정보를 열람할 수 없도록
학부모와 선생님은 원장선생님의 가입승인 절차가 필요하도록 설정했다.
가입승인 절차가 진행중일때는 메인 페이지를 볼 수 없어야 하기 때문에
새로 로그인 했을 때 로그인 완료 페이지로 이동할 수 있도록 해야한다.
이전의 방식
useEffect(() => {
const code = new URLSearchParams(location.search).get("code");
if (!code) return;
// ...
}, [location]);
프로젝트에 적용한 방식
const [code, setCode] = useState("");
useEffect(() => {
const code = new URLSearchParams(location.search).get("code");
setCode(code);
}, [location]);
useEffect(() => {
if (!code) return;
// ...
}, [code]);
두 코드를 비교하면서 보면 조금 더 명확하게 볼 수 있다.
이전의 방식은 location, 주소값이 변경될 때마다 useEffect가 실행된다.
이 과정에서 중복으로 서버에 요청을 하는 경우가 있었다.
useEffect는 location 이 변경될 때마다 실행되지만,
location이 변경된 이후에도 인증코드 상수(code) 값이 변하지 않은 상황에서
useEffect가 실행되어 서버에 중복으로 요청이 발생하는 상황.
그 반면에 두번째 방식은 code의 상태값에 의존성을 가지게 되므로
인증코드가 변경됐을때만 서버에 요청하는 useEffect가 실행된다.
code가 정상적으로 상태값에 저장된 시점. 즉, 필요한 경우에만 서버에 요청한다.
코드가 조금 길어지고 복잡해보이지만,
하나의 useEffect가 각자 다른 서로의 기능을 수행하고 있다는 점에서
코드의 가독성이 크게 저하되지 않으면서 로직의 안정성을 높일 수 있는 방식이었다고 생각한다.
추가적으로는 아예 useKakaoAuth 같은 이름의 커스텀 훅으로
코드를 받아오는 로직을 따로 관리해도 괜찮을 것 같다.
개발 당시에는 로그인 페이지에서만 사용하는 특수한 로직이라서 재사용 하지 않으므로,
커스텀 훅으로 관리해야 하지 않으려고 했으나,
로직을 분리하여 커스텀 훅으로 관리하는 쪽이 유지보수 측면에서 유리하다는 것을 최근에 알게됐다.
개선한다면 이렇게 할 수 있겠다.
const useKakaoAuth = (onSuccess, onError) => {
const location = useLocation();
const [code, setCode] = useState("");
useEffect(() => {
const code = new URLSearchParams(location.search).get("code");
setCode(code);
}, [location]);
useEffect(() => {
if (!code) return;
const source = axios.CancelToken.source();
const request = SignAPI.kakaoAuth(code, source.token);
request
.then((res) => {
onSuccess && onSuccess(res);
})
.catch((error) => {
onError && onError(error);
});
return () => {
source.cancel("인증 요청이 취소되었습니다.");
};
}, [code]);
};
export default useKakaoAuth;
const KakaoLogin = () => {
const navigate = useNavigate();
const onSuccess = (res) => {
tokenCookie.set(res.headers.authorization);
session.set("user", res.data.data);
switch (res.data.statusCode) {
case 200:
// 아직 추가정보를 입력하지 않은 상태
navigate("/signup");
break;
case 202:
// 정보입력 후 미승인 상태
navigate("/signup/success");
break;
case 203:
// 원장 선생님이 정보를 입력했지만 유치원은 생성하지 않은 상태
navigate("/signup/registration/info");
break;
default:
// 승인까지 완료
navigate("/host");
break;
}
};
const onError = (error) => {
if (axios.isCancel(error)) {
alert("요청이 취소되었습니다. 확인 후 다시 로그인을 시도해주세요.");
navigate("/login");
} else {
navigate("/login");
}
};
useKakaoAuth(onSuccess, onError);
return <></>;
};
export default KakaoLogin;
아예 카카오 소셜 로그인을 담당하는 커스텀 훅을 따로 관리하고
React query 를 사용할 때 처럼 성공했을때, 실패했을때의 콜백함수를
정의하여 사용하는 식의 로직이다.
이러면 훨씬 코드의 가독성도 좋고 직관적이며 로직을 분리하여 관리하기 쉽다.
처음에 우리의 회원가입 로직에 라우트 가드를 도입하려고 했던 이유는 아주 단순하다.
"이거 뒤로가기 했을 때는 어떻게 되요?"
우리의 회원가입 로직에는 정말 많은 페이지 컴포넌트가 있는데,
뒤로가기로 회원가입이 끝난 이후에도 정보를 입력할 수 있는 폼을 다시 볼 수 있다던지,
카카오 로그인을 끝낸 이후에 뒤로가기를 했을 때 리다이렉트 URL 로 이동해서
에러가 발생해 하얀 화면을 본다던지 한다면 사용자 경험 측면에서 좋지않다.
이런 에러들을 효과적으로 처리할 수 있으면서,
예를들어 원장선생님이 정보를 입력한 이후에 유치원 검색 페이지를 방문한다던지 하는
주소창으로 해당 페이지를 접근할 수 없도록 하려면
라우트 가드를 설정해 놓는것이 가장 효과적이라고 판단했다.
라우트 가드는 특정 조건을 충족하지 않는 사용자가
허용되지 않는 페이지에 접근하는 것을 방지하는 컴포넌트 기법이다.
라우트 가드를 설정하는 방법은 굉장히 여러가지가 있는데,
그중에서 우리가 선택한 방법은
'특정 분기를 끝냈을 때 서버에서 받은 응답(유저의 정보)를 세션에 저장하고
해당 정보가 세션에 있어야만 다음 페이지를 볼 수 있는' 식의 접근이었다.
그리고 레이아웃 컴포넌트에서
const requiredKeys = {
"/signup": ["name", "profileImageUrl"],
"/signup/search": ["name", "profileImageUrl", "role"],
"/signup/teacher": ["name", "profileImageUrl", "role"],
"/signup/parent": ["name", "profileImageUrl", "role"],
"/signup/principal": ["name", "profileImageUrl", "role"],
"/signup/registration": ["name", "profileImageUrl", "role"],
"/signup/success": [
"name",
"profileImageUrl",
"kindergartenName",
"logoImageUrl",
"role",
],
};
이런식의 도메인 requiredKeys 를 객체로 관리하고
const SignupRouteGuard = ({ requiredKeys }) => {
const location = useLocation();
const user = session.get("user");
const keysForCurrentPath = requiredKeys[location.pathname] ?? [];
const hasAllRequiredKeys =
user &&
keysForCurrentPath.every((key) => {
return user[key] !== null && user[key] !== undefined;
});
return hasAllRequiredKeys ? <Outlet /> : <Navigate to="/login" />;
};
export default SignupRouteGuard;
requiredKeys 객체에서 주어지며 현재 라우트 경로에 대한 필수 키 배열을 가져오기 위해
const keysForCurrentPath = requiredKeys[location.pathname] ?? [];
객체에서 키값에 맞는 배열을 필터링 한다.
그 후에, hasAllRequiredKeys를 계산할 때, 먼저 user 객체가 존재하는지 확인한다.
user 객체가 없으면, 필수 키가 충족되지 않은 것으로 간주하고, false를 반환하고,
user 객체가 있는 경우, Array.every() 메소드를 사용하여
user 객체가 필수 키를 모두 포함하고 있는지 체크.
every() 메서드는 배열의 모든 요소가 주어진 함수에 대해 참을 반환하는 경우에만 true를 반환한다.
따라서 user 객체의 해당 키 값이 null 또는 undefined가 아닌지 확인할 수 있다.
최종적으로 user 객체가 모든 필수 키를 포함하고 그 값이 null 또는 undefined가 아니라면,
hasAllRequiredKeys는 true를 반환하고, 해당 라우트로 이동.
그렇지 않은 경우, 로그인 페이지로 리다이렉트 하는 로직이다.
이 로직의 한계는 세션 스토리지는 개발자 도구로의 접근이 굉장히 쉽고 간단하다는 점이다.
악의적인 목적을 가지고 세션 스토리지 값을 조작하면 간단하게 뚫을 수 있다.
이러한 한계점은 클라이언트에서 해결할 수 있는 방법이 제한적이다.
생각했던 방법은 서버에서도 라우트 가드를 서포트 하는 방식이다.
라우트 가드 컴포넌트에 서버에 API 요청을 보내
특정 응답을 받고, 그 응답에 따른 상태값으로 라우트 가드 로직을 수행하는 방법이다.
간단하게 예를들자면,
const SignupRouteGuard = () => {
const { data } = useQuery('checkAuth', checkAuth)
return data.isAuth ? <Outlet/> : <Navigate to="/login">
}
이런식의 로직이 되지 않을까 싶다.
차후에 백엔드와의 협의를 통해 구현을 해보고 싶긴 하지만,
다들 바쁘셔서 힘들 것 같다. 이 부분은 조금 아쉽다.
선생님은 이름, 연락처, 생년월일을 필수로 받고 추가적으로 메일주소, 자기소개, 프로필사진을,
학부모는 이름, 연락처를 필수로 받고 추가적으로 비상연락망, 프로필 사진을 받는다.
원장선생님은 선생님과 입력해야 하는 정보는 동일하지만 자기소개를 입력하지 않고
유치원을 생성해야 하는 로직이 추가된다.
처음 회의 당시에는 선생님, 학부모 이렇게 두개의 권한만 가입을 받았다.
'원장선생님' 이라는 admin 권한을 가진 유저를 어떻게 가입을 받을 것인지,
어떤 역할을 담당하게 될 것인지 논의가 되지 않았기 때문이다.
그래서 재사용을 크게 고려하지 않았다.
그 당시 스스로의 원칙 아닌 원칙이 있었다면
재사용을 하지 않는 컴포넌트는 굳이 분리하지 않는다 였다.
여기에는 나름의 근거가 있었는데,
컴포넌트를 잘게 분리하고 쪼갤수록 오히려 수정할 때 불편하다는 점을 매번 느껴왔기 때문이다.
그게 기준없이 잘게 쪼개기만 해서 라는 사실을 비교적 최근에 깨달았다.
컴포넌트가 재사용되지 않는다. 라는 하나의 기준을 가지고 컴포넌트를 나누는 것이 아니라
이런 여러가지 측면에서 고려해봐야 한다.
회원가입 로직이 사실 필요 이상으로 복잡하다는 느낌을 많이 받았다.
비슷한 서비스인 키즈노트는 4 Step 으로 회원가입 절차를 진행하고 있다.
아마 우리의 서비스도 소셜 로그인이 아니라 일반 회원가입 이라면
하나의 회원가입 폼을 사용해 정보를 받고 선택한 권한에 따른
추가 절차를 진행하는 식으로 설계했을 것 같다.
아마 우리의 서비스도 소셜 로그인이 아니라 일반 회원가입이라면,
하나의 회원가입 폼을 사용해 정보를 받고 선택한 권한에 따른 추가 절차를 진행하는 식으로
설계했을 것이다. 그러나 우리의 서비스는 사업자 허가를 받을 수 없어 카카오톡 알림 서비스를
이용하기 위해 카카오 소셜 로그인이 불가피했다.
서비스 설계 과정에서 회원가입 단계나 과정을 조금 더 간소화 하고
사용자 입장에서 더 쉽게 회원가입을 진행할 수 있도록 설계했으면 어땠을까 ?
권한에 따른 가입절차를 통합하거나 했다면 컴포넌트를 통합해서 쉽게 관리할 수 있지 않았을까 ?
결국 소셜로그인은 접근성이 가장 큰 목적인데 오히려 접근성을 제한하게 되어버리지 않았을까 ?
이런 문제들은 조금 더 깊게 고민해봐야 할 것 같다.
이것도 프론트엔드 개발자가 해야 할 고민이라고 생각하니까.