React로 캠핑 웹 만들기2 - 미들웨어 구현

이지영·2025년 1월 29일

🔒Protected Route & 미들웨어

📝 구현 목표

  • 로그인 상태: 회원가입, 로그인 페이지 접근 불가
  • 로그아웃 상태: 마이페이지 접근 불가

🚀 구현 과정

  1. AppRoute.jsx로 라우터 분리
  2. Protected Router, Public Router 설정
  3. 설정한 Route 적용

💡 Protected Router, public Router설정

  const {isAuthenticated, isLoading} = useContext(AuthContext)

  const ProtectedRoute = () => {
    if(isLoading) {
      return <div>Loading</div>
    }
    if(!isAuthenticated) {
      return <Navigate to="/login"replace />
    }
    return children
  }
  
  const PublicRoute = ({children}) => {
    if(isLoading) {
      return <div>Loading</div>
    }
    if(isAuthenticated) {
      return <Navigate to="/" replace/>
    }
    return children
  }

이렇게 구현했고
여기서 useNavigate hook을 쓰지 않고 <Navigate />을 사용한 이유는

1. useNavigate hook: 특정한 코드의 실행이 끝나고 나서 페이지를 이동하고 싶을 때 사용

함수이기 때문에 어떠한 이벤트나 조건을 만족하면 해당 페이지로 이동할 수 있도록 조절할 수 있다.
예시

  • 쇼핑몰에서 장바구니에 담기 버튼을 눌렀을 때 request를 보낸 후 장바구니 페이지로 이동,
  • 로그인 페이지에서 로그인을 완료한 후 메인페이지로 이동 등

2. Navigate컴포넌트: 특정 경로에서 렌더링 시점에 다른 페이지로 이동시키고 싶을 때 사용

  • 쇼핑몰의 회원전용 페이지에 로그인 없이 들어올 경우, 로그인 페이지로 리다이렉트,
  • 쇼핑 상세 페이지에서 제품이 품절되었거나 삭제되어서 다른 페이지로 리다이렉트 시키는 경우

이러한 차이점이 있다.
따라서 내가 사용할 용도는 2번 경우이기 때문에 Navigate컴포넌트를 사용했다.

replace

replace를 쓴 이유는 뒤로가기 했을 때 이전 보호된 페이지로 돌아가지 않는다.
주로 인증/인가 관련 리다이렉트에서 사용된다.

만약 로그인이 성공하여 마이페이지로 이동했는데
다시 로그인 페이지로 이동하는것을 막기 위해 사용했다.

라우트 적용

라우트를 적용하는 방법도 두 가지가 있다.

1. 각 Route에 개별적으로 적용

<Routes>
  <Route path="/" element={<PublicRoute><Home /></PublicRoute>} />
  <Route path="/signin" element={<PublicRoute><SignIn /></PublicRoute>} />
  <Route path="/signup" element={<PublicRoute><SignUp /></PublicRoute>} />
</Routes>

2. Routes그룹화 사용

<Routes>
  // Public Routes
  <Route element={<PublicRoute />}>
    <Route path="/" element={<Home />} />
    <Route path="/signin" element={<SignIn />} />
    <Route path="/signup" element={<SignUp />} />
  </Route>
  
  // Protected Routes
  <Route element={<ProtectedRoute />}>
    <Route path="/mypage" element={<MyPage />} />
  </Route>
</Routes>

첫 번째 방식의 장점은

  • 각 라우트마다 독립적인 제어 가능
    두 번째 방식의 장점은
  • 라우트 그룹 관리가 용이함
  • 중첩 라우팅이 필요할 때 더 유연함

나는 두 번째 방법이 더 깔끔한 것 같아 두 번째 방법을 사용했다.
하지만 두 번째 방법을 사용하려면 위에서 children으로 구현했던것을 Outlet으로 수정해야한다.

이유는 React Router의 동작 방식 때문이다.
Outlet은 중첩된 라우트의 컴포넌트를 렌더링하기위한 특별한 컴포넌트이다.

라우트가 정상적으로 작동하는것을 확인했다.

🚨 트러블 슈팅

트러블1. 적용한 라우터 동작 제대로 안됨

[원인]

  1. AuthContext에서 isAuthenticated상태가 동기화 되지 않음
  2. 로그인 시에만 setIsAuthenticated(true)로 설정되어있어 페이지를 새로고침 하거나 재방문할 때 인증 상태가 초기화됨

[해결]

  1. useEffect를 추가하여 초기 인증 상태 확인
  2. auth상태 변경을 구독하여 실시간으로 상태 업데이트
useEffect(() => {
    // 초기 인증 상태 확인
    const checkUser = async () => {
      try {
        const {
          data: { session },
        } = await supabase.auth.getSession();
        setIsAuthenticated(!!session);
      } catch (error) {
        console.error("session체크 오류", error);
      } finally {
        setIsLoading(false);
      }
    };
	// auth 상태 변경 구독
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      setIsAuthenticated(!!session);
    });

    checkUser();
	// 클린업
    return () => {
      subscription?.unsubscribe();
    };
  }, []);

트러블2. 위를 적용하여도 정상 동작 안함

[원인]

const [isLoading, setIsLoading] = useState(false);
이렇게 초기 세션 체크가 완료되기 전에 isLoading이 false이고 insAuthenticated도 false인 상태가 되어 리다이렉트가 발생했다.

[해결]

const [isLoading, setIsLoading] = useState(true);
변경하니 실제 인증 상태에 따라 적절한 라우팅이 이루어졌다.

트러블3. context에 user정보를 얻어오는 로직 누락

생각해보니 로그인 여부만 확인하고있지, 유저 정보를 가져오는 로직이 없다.

useEffect(() => {
    const checkUser = async () => {
      try {
        const {
          data: { session },
        } = await supabase.auth.getSession();
        setIsAuthenticated(!!session);
        setUser(session?.user ?? null); // 추가
      } catch (error) {
        console.error("session체크 오류", error);
      } finally {
        setIsLoading(false);
      }
    };

    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      setIsAuthenticated(!!session);
      setUser(session?.user ?? null); // 추가
      if (event === "SIGNED_OUT") { // 추가
        setUser(null);
      }
    });

    checkUser();

    return () => {
      subscription?.unsubscribe();
    };
  }, []);

추가해주어 추후 마이페이지나 헤더에서 사용할 수 있도록 했다.

트러블 4. user정보를 테이블에서 가져와야함

[문제]

바로위 코드를 보면 user정보를 session에서 가져오고있는데
이러면 유저 정보를 수정했을 때
마이페이지에서 정보를 정상적으로 뿌려줄 수 없다.

[해결]

session에서 가져오던 user정보를 user테이블에서 가져오는 로직으로 수정했다.

const checkSession = async (session) => {
    try {
      if (session) {
        const { data } = await supabase
          .from("users")
          .select("*")
          .eq("id", session.user.id)
          .single();
        setUser(data);
      } else {
        setUser(null);
      }
    } catch (error) {
      console.error("session체크 오류", error);
    } finally {
      setIsLoading(false);
    }
  };

	useEffect(() => {
    // 초기 세션 체크
    supabase.auth.getSession().then((res) => {
      const session = res.data.session;
      setIsAuthenticated(!!session);
      checkSession(session);
    });

    // 인증 상태 변경 감지
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      setIsAuthenticated(!!session);
      checkSession(session);
    });

    return () => {
      subscription?.unsubscribe();
    };
  }, []);

인증 상태 변경 감지에
setIsAuthenticated 와 checkSession을 추가하여
로그인/로그아웃 시 실시간으로 사용자 정보가 업데이트 되도록 하였다.

추가로 여기서 eq('id'userId)
의 id는 supabase의 테이블 컬럼명이고, userId는 session.user.id에서 가져온 값이다.
실제 SQL로 변환하면
SELECT * FROM users WHERE id = "userId"

single()은 supabase쿼리에서 단일 레코드만을 반환하도록 지정하는 메서드이다.
1. 결과가 없으면 null을 반환
2. 결과가 여러 개면 에러를 발생
3. 결과가 정확히 하나면 그 객체를 반환


우당탕탕 로그인 및 미들웨어 구현을 완료했다.
처음부터 설계를 잘 했다면, 같은 코드를 여러번 짜는 경우는 없었을텐데..
아직 미숙하다보니 짰던 코드를 다시 짜고 다시 짜게되는 것 같다.

다음 로그인 및 미들웨어 구현 시
같은 실수는 반복하지 않도록 이번 기회에 확실히 익혀야겠다.
또 이런식으로 짜는게 맞는지 여러번 확인하고 체크해서
다음에는 더 나은 방식으로 구현해보도록 하겠다.

0개의 댓글