프로젝트/Next+TypeScript

[Coworkers] 접근 권한 관련 이슈 해결

dev-hpk 2025. 2. 15. 23:53

멤버 관련 테스트 작업을 진행하던 중 이슈를 발견했습니다.

🚨 문제 상황

팀의 멤버가 아닌 유저가 팀 페이지(/[teamid])와 할 일 목록(/[teamid]/[tasklist])에 접근 및 추가/수정/삭제를 할 수 있습니다.

팀의 멤버가 아닌 사용자가 해당 페이지에 접근할 수 있는 유일한 방법은 URL을 직접 입력하는 것입니다. 하지만 URL을 직접 입력하여 접근한 후 수정/삭제 등의 작업을 시도할 가능성도 존재합니다. 이를 방지하기 위해 추가적인 보안 조치가 필요해 보입니다.

접근 권한 이슈 - 팀 페이지
권한 이슈 - 멤버 아닌 유저 할 일 생성 가능
권한 이슈 - 멤버 아닌 유저 할 일 수정 가능
권한 이슈 - 멤버 아닌 유저 할 일 삭제 가능

요청/추가/수정/삭제 동작이 모두 가능한 것을 보니, 서버에서 API 요청 시 팀 멤버 여부를 확인하는 로직이 없는 것 같습니다. 프론트엔드에서 처리할 수 있는 방법을 생각해 본 결과 아래 두 가지 정도가 있을 것 같습니다.

 

1️⃣ 멤버가 아닌 유저에게는 추가/수정/삭제가 보이지 않도록 수정

2️⃣ 페이지 접근 시 유저가 팀의 멤버인지 확인하도록 수정 (멤버가 아닌 경우: 접근 차단 알림 후 메인 페이지로 이동)

 

팀원들에게 이슈를 논의한 결과 만장일치로 2️⃣번이 선택되었습니다.

팀의 멤버인지 확인하는 로직을 추가하기 전에 차단 프로세스를 미리 계획해보겠습니다.

 

👀 해결 프로세스 - 멤버 아닌 경우 차단

1️⃣ 유저가 팀 페이지(/[teamid]), 할 일 목록 페이지(/[teamid]/[tasklist]) 페이지에 접근

2️⃣ 유저가 팀의 멤버인지 확인

3️⃣ 팀의 멤버가 아닐 경우 "접근 제한: 팀의 멤버가 아닙니다!" 등의 메시지 노출

4️⃣ 메인 페이지(/)로 리다이렉트

 

✨ 멤버 확인 및 차단 로직 추가

/app/[teamid]/page.tsx

const { user } = useSelector((state: RootState) => state.auth);
const router = useRouter();

const {
  data: groupData,
  isLoading,
  error,
} = useQuery({
  queryKey: ['group', groupId],
  queryFn: () =>
    groupId
      ? getGroup({ id: groupId })
      : Promise.reject(new Error('No ID provided')),
  enabled: !!groupId,
  staleTime: 0,
  refetchOnMount: 'always',
});

if (
  !isLoading &&
  groupData &&
  !groupData.members.some(({ userId }) => userId === Number(user?.id))
) {
  alert('접근제한: 팀의 멤버가 아닙니다!');
  router.replace('/');
}
  • useSelector((state: RootState) => state.auth) : Redux에서 전역으로 관리하는 유저 데이터
  • !isLoading : react-query의 loading 상태일 때 groupData가 undefined이기 때문에 로딩이 종료된 후 실행하기 위해 추가
  • !groupData.member.some : 팀 멤버 데이터 중 로그인 한 유저와 id가 같은 멤버가 없는 경우

로직을 간단하게 살펴보면 페이지에 접근했을 때 서버에서 받아온 팀의 멤버 배열에 로그인한 유저의 id(Redux 전역 데이터)가 없는 경우 차단하고 있습니다.

 

결과를 확인해 보겠습니다.

접근 제한 결과

 

접근 제한이 잘 동작하는데 에러가 발생하네요.. 확인해 보겠습니다.

에러

에러 메시지 Cannot updatea component (`Router`) while rendering a different component (`TeamPage`)에 대해 찾아보니 렌더링 중에 Route를 해서 그렇다고 합니다. useEffect를 이용해 컴포넌트가 렌더링 된 후 로직이 동작하도록 수정하겠습니다.

 

/app/[teamid]/page.tsx - 수정

useEffect(() => {
  if (
    !isLoading &&
    groupData &&
    !groupData.members.some(({ userId }) => userId === Number(user?.id))
  ) {
    alert('접근제한: 팀의 멤버가 아닙니다!');
    router.replace('/');
  }
}, [isLoading, groupData, user?.id, router]);

접근 제한 결과

 

팀의 멤버가 아닌 유저가 접근하면 해결 프로세스에서 정의한 것처럼 잘 동작하네요!

다른 페이지에서도 적용하기 위해 커스텀 훅으로 분리하는 작업을 해보겠습니다.

컴포넌트의 이름은 팀의 멤버가 아니면 리다이렉트 하는 로직에 맞게 useRedirectIfNotMember로 하겠습니다.

 

useRedirectIfNotMember.ts

import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { GroupResponse } from '../types/grouptask';

const useRedirectIfNotMember = ({
  isLoading,
  groupData,
}: {
  isLoading: boolean;
  groupData?: GroupResponse;
}) => {
  const router = useRouter();
  const { user } = useSelector((state: RootState) => state.auth);

  useEffect(() => {
    if (
      !isLoading &&
      groupData &&
      !groupData.members.some(({ userId }) => userId === Number(user?.id))
    ) {
      alert('접근제한: 팀의 멤버가 아닙니다!');
      router.replace('/');
    }
  }, [isLoading, groupData, router, user?.id]);
};

export default useRedirectIfNotMember;

 

훅은 props로 데이터 요청 상태인 isLoading과 members를 담고 있는 groupData를 받았습니다.

페이지에 적용해 보겠습니다.

useRedirectIfNotMember({
  isLoading,
  groupData,
});

 

커스텀 훅으로 분리하면서 코드가 짧아져 가독성도 좋아지고, 다른 페이지에서도 사용할 수 있게 되었습니다.

여기서 끝내면 조금 아쉬우니 성능적으로도 조금이나마 최적화를 진행해 보겠습니다.

 

useRedirectIfNotMember.ts - 최적화

import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { GroupResponse } from '../types/grouptask';

const useRedirectIfNotMember = ({
  isLoading,
  groupData,
}: {
  isLoading: boolean;
  groupData?: GroupResponse;
}) => {
  const router = useRouter();
  const userId = useSelector((state: RootState) => state.auth.user?.id);
  const [isRedirecting, setIsRedirecting] = useState(false);
  
  const redirect = useCallback(() => {
    setIsRedirecting(true);
    alert('접근제한: 팀의 멤버가 아닙니다!');
    router.replace('/');
  }, [router])

  useEffect(() => {
    if (
      !isLoading &&
      groupData &&
      !groupData.members.some(({ userId: id }) => id === Number(userId))
    ) {
      redirect();
    }
  }, [isLoading, groupData, userId, redirect]);
};

export default useRedirectIfNotMember;
  • const userId = useSelector((state: RootState) => state.auth.user?.id);
    기존 구조분해 할당으로 선언한 코드({user})는 컴포넌트가 렌더링 될 때 Redux의 auth 전체의 변화를 감지했습니다. user.id만 조회함으로써 불필요한 리렌더링을 방지했습니다.
  • redirect 함수 생성
    기존 코드는 컴포넌트가 렌더링 될 때마다 useEffect 내부의 로직을 새로 생성했습니다. 로직을 useCallback을 사용해 redirect 함수로 분리함으로써 렌더링 될 때마다 로직이 새로 생성되는 것을 방지했습니다.

 

멤버 접근 제한 이슈 수정하면서 느낀 점

 

이번 경험을 통해 리액트에서 성능 최적화의 중요성과 useCallback, useSelector와 같은 훅을 효율적으로 사용하는 방법을 배웠습니다. 작은 성능 최적화가 사용자 경험에 큰 영향을 미칠 수 있다는 점에서, 앞으로는 더욱 세심하게 코드 최적화를 고려해야겠다는 생각을 하게 되었습니다. 또한, 멤버 접근 제한 프로세스를 설계하면서 사용자 경험을 고려한 기능 구현의 중요성을 깨닫게 되었고, 단순히 기술적인 구현에 그치지 않고 사용자가 어떻게 느낄지에 대한 고민이 필요하다는 점을 깊이 인식하게 되었습니다😊