[Uket] 중복 회원가입 사용자 패턴 방지 feat. 회원가입 페이지 진입 여부 개선

coggiee
10 min readJul 21, 2024

--

사용자는 예측 불가능하다

사용자가 예측할 수 없게 서비스를 사용한다면?

웹 서비스는 웹 이라는 특성 때문에 사용자는 URL에 직접적으로 접근할 수 있다.
또한, 개발자 도구로 서비스를 뜯어보면? 로컬 스토리지, 쿠키에 접근해서 중요한 값(e.g. 토큰)을 바꾼다면 어떻게 대처해야 할까?

Uket에서는 특히 회원가입 페이지에 대한 접근 여부를 정확하게 결정해야 한다. (재학생과 일반인으로 사용자가 구분되고 재학생은 입금 과정없이 티켓팅이 가능하기 때문에 중복 또는 비정상적인 회원가입을 방지하기 위함이다)

이러한 고민들로 인해, 기존의 방식을 개선해야 할 필요성을 느꼈다.

토큰으로 진입 여부를 결정

기존의 회원가입 페이지를 포함하여 인증이 필요한 특정 페이지들은 토큰으로만 진입 여부를 판별하고 있다.

즉, 가장 일반적이고 단순한 방식인 accessToken, refreshToken이 존재하는지 만을 판단하여 인증 유무를 판별한다. (각 토큰은 localstorage, cookie로 관리한다.)

const isAuthenticated = !!getAccessToken() && !!getRefreshToken("refreshToken");

충분하지만 충분하지 않다

하지만 내가 사용자라고 생각해 봤을 때, 토큰 방식은 2가지 문제가 있다.

  1. 사용자는 토큰을 변조할 수 있다.
  2. 회원가입은 토큰이 있는 경우 예외처리가 불가능하다.

1번 방식은, 인증하지 않은 사용자가 localStorage와 cookie에 특정 key를 추가하여 비정상적으로 인증을 수행함을 의미한다.

그런데 사실 토큰을 실어서 비동기 API 요청을 보내는 인증이 필요한 특정 페이지는 토큰 방식으로만 진입 여부를 판별해도 크게 무리가 없다.
결국, 비정상적인 토큰은 API 응답에서 오류를 반환하기 때문이다.

그래서 기존에는 토큰 방식을 아래와 같이 활용하고 있다.

const PRIVATE: Path[] = ["/buy-ticket", "/ticket-list", "/signup", "/myinfo"]; // 인증이 된 사용자만 접근할 수 있는 페이지(라우트) 

const Redirects = ({ children }) => {
const isAuthenticated = !!getAccessToken() && !!getRefreshToken("refreshToken"); // 인증 유무
const unAuthedOnPrivatepath =
!isAuthenticated && PRIVATE.includes(location.pathname as Path); // 인증되지 않은 사용자가 인증이 필요한 페이지에 접근할 경우

if (unAuthedOnPrivatepath) return <Navigate to='/login' replace />; // 로그인 페이지로 리다이렉트

return children;
}

토큰 유무(로그인 유무)를 판별하여, 토큰이 없는데 인증이 필요한 페이지로 진입하려는 사용자를 로그인 페이지로 진입시킨다.

그러나, 위 코드는 인증된 사용자(계정이 이미 있는 사용자)가 회원가입 페이지에 접근할 수 있다. (인증된 사용자는 isAuthenticated=true이기 때문에, 최하단의 return children이 실행되기 때문이다.)

즉, 2번 (이미 가입한 사용자가 회원가입 페이지에 다시 접근할 수 있다) 문제를 방지하지 못한다. 이는 결국 중복 회원가입을 초래할 수 있다.

그렇다면…
인증된 사용자가 회원가입 페이지로 진입하는 경우 진입하지 못하게 특정 페이지로 리다이렉트 시키면 되는 거 아닌가? 라고 생각할 수 있다.

바로 아래의 코드처럼 말이다.

const PRIVATE: Path[] = ["/buy-ticket", "/ticket-list", "/signup", "/myinfo"]; // 인증이 된 사용자만 접근할 수 있는 페이지(라우트) 

const Redirects = ({ children }) => {
// …
if (isAuthenticated && location.pathname === '/signup') return <Navigate to='/' replace />;
return children;
}

코드의 결과를 보기 전에, 먼저 Uket의 회원가입 과정을 살펴보자.
Uket의 회원가입은 로그인 페이지를 필수적으로 거쳐야만 한다.

아래와 같은 플로우를 거친다.

  1. 회원가입을 한 사용자는, 로그인 요청 > 토큰 발급받음 > 메인 페이지(리다이렉트)
  2. 회원가입을 하지 않은 사용자는, 로그인 요청 > 토큰 발급받음 > 회원가입 페이지(리다이렉트)
    (+ 로그인 과정에서 accessToken을 발급 받게되고 이를 회원가입 API 요청에 실어서 보내야 한다.)

즉, 회원가입에는 accessToken이 필수로 필요하다.

하지만, 위 코드는 회원가입을 하지 않았음에도 토큰을 발급받았기 때문에 isAuthenticated=true 로 취급받아서 회원가입 페이지로 진입하지 못하는 문제가 발생한다.

결국, 기존 토큰 방식으로 만은 2번 문제를 해결할 수 없어서 개선이 필요했다.

개선 시도

먼저, Uket의 로그인 과정은 살펴보자.

  1. 로그인 페이지로 진입한다.
  2. 카카오 로그인 또는 구글 로그인을 선택한다.
  3. 카카오 또는 구글 소셜 로그인 서버로 이동된다.
  4. 소셜 로그인 서버로 부터 code를 query parameter로 전달받는다.
  5. code를 전달하여 로그인 API를 요청한다.
  6. 로그인 API 응답 값의 isRegistered필드가 false인 경우에 회원가입 페이지로 리다이렉트된다.

방법 #1. useMutationState

const [loginState] = useMutationState({ 
filters: { mutationKey: ["login"] },
select: mutation => mutation.state.data,
});

로그인 API 요청이 전제되어야 하므로, useMutationState 를 사용하여 로그인을 시도했는지에 대한 데이터를 가져오고자 했다.

(useMutationStateMutationCache 에 저장된 모든 mutation 데이터를 가져온다. 이때 filtersmutationKey 를 전달하면 해당 키를 가지는 mutation 의 캐시만 가져온다.)

위 코드로 가져온 데이터는 아래의 구조를 갖는다.

{ 
"id": "number",
"name": "string",
"accessToken": "string",
"refreshToken": "string",
"isRegistered": "boolean",
}

여기서 isRegeistered 필드를 가져와서 진입 여부를 판별하려고 했다.

그러나 이는 치명적인 단점이 있다.

새로고침을 할 경우, mutation캐시가 초기화되어 데이터가 null 이 된다. 그래서 정상적인 판별이 불가능하다.

방법 #2. getQueryData

로그인한 사용자의 정보를 활용하고자 getQueryData 를 사용했다.

(user-info 키는 Profile 컴포넌트에서 useQuery로 사용자 정보를 불러오는데 사용되고 있다.)

const queryClient = useQueryClient(); 
const data = queryClient.getQueryData(['user-info']);

if (data.isRegistered) {
// ... 회원가입 페이지로의 이동을 차단
}

로그인한 사용자의 정보(data)에서 isRegistered 필드를 가져온 후, 임의로 회원가입 페이지로 이동하려고 하는 경우 이동하지 못하도록 처리하고자 했다.

그런데 2가지 문제가 있다.

  1. Profile 컴포넌트를 사용하지 않는 페이지에서는 데이터가 null 이다.
  2. 로그인한 사용자의 정보는 로그인이 전제되므로, 로그인하지 않은 사용자의 경우 사용자 정보가 없어서 회원가입이 필요한 사용자인지 판별이 불가능하다.

방법 #3. useNavigate의 state 옵션

위에서 설명한 로그인 과정을 더 자세히 뜯어보면, 아래와 같다.

  1. 로그인 버튼을 클릭하면 소셜 로그인 서버로 이동한 후 통신의 결과로 code를 query parameter로 얻는다.
    (예를 들어, /login에서 로그인 버튼을 클릭하면 최종적으로 /login/[provider]?code=… 경로로 이동하게 된다.)
  2. 해당 경로에서 code 를 추출하여 실제 로그인 API 요청을 보낼 때 함께 전달한다.
  3. 로그인 API의 응답으로 받은 JSON에서 isRegistered 필드를 추출하여 값에 따라 리다이렉트될 페이지를 결정한다.

아래는 2, 3번 과정을 나타내는 커스텀 mutation 훅이다.

// useMutationLogin.tsx 

const mutation = useMutation({
mutationFn: ({ code, provider }: LoginRequestParams) =>
login({ code, provider }),
onSuccess: ({ accessToken, refreshToken, isRegistered }: AuthResponse) => {
setAccessToken(accessToken);
setRefreshToken("refreshToken", refreshToken);

if (isRegistered) {
navigate("/", { replace: true });
} else {
navigate("/signup", { replace: true });
}
},
});

키 포인트를 다시 짚어보면, 회원가입을 수행하려면 반드시 로그인 페이지를 거쳐야 한다는 점이다. (정상적인 흐름: 로그인 페이지 > 회원가입 페이지)

결국 로그인 페이지에서 회원가입 페이지로 이동하는 그 사이에 회원가입이 필요한 사람인지 아닌지를 판별할 수 있는 특정 정보를 전달하면 된다.

즉, 페이지 간의 이동에 정보를 전달하면 된다는 것이고 이는 useNavigatestate 옵션을 사용하면 된다.

아래는 이를 적용한 코드이다.

// … 
if (isRegistered) {
navigate(“/”, {
replace: true,
});
} else {
navigate(“/signup”, {
state: { isUnregistered: true }
replace: true,
});
}
// …

이제 회원가입이 필요한 사람은 state 와 함께 회원가입 페이지로 이동하게 된다.

하지만, 여기서 끝이 아니다.

궁극적으로 개선하고자 하는 것은 회원가입을 이미 한 사용자가 URL을 임의로 조작하여 중복 회원가입을 수행하는 것을 방지하는 것이다.

이를 위해, 회원가입 페이지에서 state가 전달 되었는지를 확인한다.

const SignUpPage = () => { 
const { state: isUnregistered } = useLocation();

if (!isUnregistered) {
return <Navigate to={“/”} replace />;
}

return ( … );
}

(이때, 위 코드에서 isUnregistered는 전달받은 state 내부의 isUnregistered 필드를 의미하지 않는다. state 객체 자체를 의미한다. state 가 존재하는지만 판별하면 되기 때문에 굳이 필드를 가져와서 확인하지는 않았다.)

개선된 코드의 동작

이제 개선된 코드는 아래와 같이 동작하게 된다.

  • state가 전달되지 않았다면, 정상적인 흐름으로 회원가입 페이지에 진입한 것이 아니므로 메인 페이지로 리다이렉트 된다.(즉, URL 임의 조작으로 회원가입 페이지에 접근할 수 없다)
  • state가 전달되었다면, 정상적인 흐름으로 회원가입 페이지에 진입한 것이므로 회원가입을 이어서 진행한다.
  • 정상적인 흐름으로 회원가입 페이지에 진입 했더라도, 오랜 시간이 지나accessToken 이 도중에 만료된다면 로그인 페이지로 리다이렉트된다. (토큰 방식을 제거한 게 아니기 때문)

결과적으로, 기존의 토큰으로만 진입 여부를 판별하던 로직에 추가적으로 state를 얹음으로써 더 정확하고 확실하게 판별이 가능해졌다.

--

--

coggiee
coggiee

Written by coggiee

0 Followers

Just doing

No responses yet