프로젝트/Next+TypeScript

[Coworkers] 리팩토링

dev-hpk 2025. 2. 7. 19:12

오늘은 1차 배포 테스트가 얼마 남지 않아서 리팩토링을 진행해 보겠습니다.

 

계획 없이 리팩토링을 진행하면 결과물의 퀄리티가 떨어질 수 있을 것 같아, 작업을 시작하기 전에 어떤 방향으로 개선할 것인지 계획을 세워보겠습니다.

리팩토링 중점 사항

1️⃣ 가독성

  • 변수와 함수를 이름만 보고도 어떤 역할을 하는지 알 수 있도록 의미 있게 수정하겠습니다.
  • 불필요한 로직이나 변수를 제거해 코드를 간결하게 유지하겠습니다.
  • 코드를 간결하게 유지하기 위해 컴포넌트를 기능별로 적절히 분리하겠습니다.

2️⃣ 재사용성

  • 특정 UI가 반복적으로 사용되는 경우 컴포넌트화해 재사용하겠습니다.
  • 자주 사용되는 로직이나 기능은 커스텀 훅, 유틸 함수로 분리해 재사용하겠습니다. 

3️⃣ 유지보수성

  • 컴포넌트와 함수가 하나의 책임만 갖도록 수정하겠습니다.
    SOLID 원칙의 단일 책임원칙(Singe Responsibility Principle) 고려
  • 다른 팀원들이 코드를 읽을 때를 고려해 복잡한 로직은 주석을 달아두겠습니다.
  • 간단한 로직의 경우는 코드를 간결하게 유지하기 위해 함수의 이름을 의미있게 작성하고 주석을 삭제하겠습니다. 

4️⃣ 사용자 경험(UX) 개선

  •  사용자 입장에서 서비스를 테스트해 보며 불편사항을 최대한 줄일 수 있도록 수정하겠습니다.

5️⃣ 성능 개선

  • React Query를 이용해 불필요한 리렌더링을 방지해 성능을 개선하겠습니다.

 

위에서 세운 계획을 최대한 지키면서 리팩토링을 시작해 보겠습니다.

 

유저 로그인 정보 확인 - 커스텀 훅 분리

Coworkers 서비스는 자유게시판을 제외하면 거의 모든 페이지가 로그인을 필요로 합니다.

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

useEffect(() => {
  if (!accessToken) {
    alert('로그인 후 이용할 수 있습니다.');
    router.push('/login');
  }
}, [accessToken, router]);

 

기존 작업에서는 페이지마다 Redux에서 전역으로 관리하고 있는 accessToken을 확인했습니다.

토큰이 없다면 로그인이 필요하다는 문구를 alert하고 로그인(/login) 페이지로 이동하도록 동작합니다.

 

이 로직을 매번 작성하는 것은 계획했던 유지보수와 재사용성을 고려했을 때 좋지 않은 것 같아 커스텀 훅으로 분리하도록 하겠습니다. 우선 커스텀 훅을 만들기에 앞서 훅의 이름을 정해야 합니다.

 

의미 있는 이름을 정하기가 어려워 GPT에게 도움을 요청했는데 아래와 같은 이름을 작성해 줬습니다.

  • useRequireLoginRedirect
  • useRedirectToLoginIfNotAuthenticated
  • useLoginRedirect
  • useAuthRedirect

가장 직관적인 이름은 useRedirectToLoginIfNotAuthenticated이지만 너무 길어서 가독성이 떨어지는 것 같아, 간결하게 인증 후 리다이렉트 한다는 의미에서 useAuthRedirect를 사용하도록 하겠습니다.

 

useAuthRedirect.tsx

import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () => {
  const router = useRouter();
  const { accessToken } = useSelector((store: RootState) => store.auth);

  useEffect(() => {
    if (!accessToken) {
      /* alert : 알림 방법 정해지기 전 임시 사용 */
      alert('로그인 후 이용할 수 있습니다.');
      router.push('/login');
    }
  }, [accessToken, router]);
};

export default useAuthRedirect;

useSelector 변수명 수정 - 프로젝트 일관성 유지

  const { user } = useSelector((store: RootState) => store.auth);

 

useSelector에서 사용하고 있는 변수명을 팀원마다 다르게 사용하고 있어 state로 통일했습니다.

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

 

사실 코드를 보면 storestate 어떤 이름으로 사용해도 실제 코드에서 사용되는 일이 없어서 큰 차이는 없을 것 같지만, 유지보수 측면에서 일관성을 유지하기 위해 state로 수정했습니다.

중복 변환 코드 제거

const { teamid } = useParams();

queryKey: ['group', Number(teamid)],
queryFn: () => getGroupById(Number(teamid)),
enabled: !!Number(teamid),

await patchGroup(Number(teamid), teamData);

queryClient.invalidateQueries({ queryKey: ['group', Number(teamid)] });
router.push(`/${Number(teamid)}`);

 

기존에 작업했던 코드에서는 타입을 변환하는 Number(teamid)가 반복되고 있습니다. 가독성과 유지보수 측면에서 좋지 않은 코드인 것 같습니다.

const { teamid } = useParams();
const groupId = Number(teamid);

queryKey: ['group', groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,

await patchGroup(groupId, teamData);

queryClient.invalidateQueries({ queryKey: ['group', groupId] });
router.push(`/${groupId}`);

 

코드의 가독성과 유지보수성을 개선하기 위해 groupId를 선언해 타입 변환의 중복을 제거했습니다.

이미지 업로드 로직 - 유틸 함수 분리

const mutation = useMutation({
    mutationFn: async ({ profile, name }: FieldValues) => {
      let imageUrl: string | null = null;

      if (profile && profile[0] instanceof File) {
        const formData = new FormData();
        formData.append('image', profile[0]);

        const { url } = await postImage(formData);
        imageUrl = url;
      }

      const teamData: GroupData = { name };
      if (imageUrl) teamData.image = imageUrl;

      await patchGroup(groupId, teamData);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['group', groupId] });
      router.push(`/${groupId}`);
    },
    onError: () => {
      alert('팀 수정에 실패했습니다.');
    },
  });

 

기존 코드에서는 mutationFn에서 이미지 업로드 로직을 작성하고 사용하고 있습니다.

mutation 로직의 길이가 길어져 가독성도 떨어지고 코드를 읽어보기 전에는 어떤 동작을 하는지 알 수도 없습니다.

 

uploadImage.ts

import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: FileList) => {
  if (!profile || !(profile[0] instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile[0]);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;

 

이미지를 업로드하는 기능만 담당하는 uploadImage 함수를 만들고 재사용을 위해 /utils에 분리했습니다.

const mutation = useMutation({
  mutationFn: async ({ profile, name }: FieldValues) => {
    const imageUrl = await uploadImage(profile);

    const teamData: GroupData = { name };
    if (imageUrl) teamData.image = imageUrl;

    await patchGroup(groupId, teamData);
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['group', groupId] });
    router.push(`/${groupId}`);
  },
  onError: () => {
    alert('팀 수정에 실패했습니다.');
  },
});

 

기존 코드보다 간결해져 가독성도 좋아졌고, 유틸 함수로 분리해 유지보수성도 개선되었습니다.

 

React Query를 이용한 성능 개선

const onSubmit = async ({ profile, name }: FieldValues) => {
    let imageUrl: string | null = null;

    if (profile && profile[0] instanceof File) {
      try {
        const formData = new FormData();

        formData.append('image', profile[0]);

        const { url } = await postImage(formData);

        imageUrl = url;
      } catch (error) {
        alert('이미지 업로드에 실패했습니다.');
      }
    }

    try {
      const teamData: GroupData = {
        name,
      };

      if (imageUrl) {
        teamData.image = imageUrl;
      }

      const { id } = await postGroup(teamData);

      router.push(`/${id}`);
    } catch (error) {
      alert('팀 생성에 실패했습니다.');
    }
  };

 

기존 팀 생성 로직의 경우 onSubmit 핸들러로 처리했습니다. 함수 내부를 보니 이미지 업로드 로직을 함수 내에서 작성해서 사용했었네요.

const mutation = useMutation({
  mutationFn: async ({ profile, name }: FieldValues) => {
    const imageUrl = await uploadImage(profile);
    const teamData: GroupData = {
      name,
    };

    if (imageUrl) {
      teamData.image = imageUrl;
    }

    return postGroup(teamData);
  },
  onSuccess: ({ id }) => {
    router.push(`/${id}`);
  },
  onError: () => {
    alert('팀 생성에 실패했습니다.');
  },
});

 

onSubmit을 React Query의 mutation으로 리팩토링하면서 많은 장점을 얻었습니다.

  • onSuccess, onError 상태를 활용해 try/catch 보다 직관적으로 상태를 관리
  • onSuccess 발생 시 팀 데이터를 리패치(invalidateQueries)해 자동으로 팀 목록을 업데이트
  • React Query는 전역적인 상태 관리 및 데이터 캐싱을 지원하기 때문에 다른 곳에서도 쉽게 재사용 가능
  • mutation.isLoading을 활용해 간단하게 중복 데이터 요청을 방지
    React Query를 사용 안 했을 때는 const [isLoading, setIsLoading] = useState(false);로 loading 상태를 별도로 관리해야 했음

사용자 경험(UX) 개선 - 로딩 상태 추가

위에서 작업했던 useAuthRedirect 커스텀 훅을 적용했을 때, 접근할 수 없는 화면이 렌더링 된 후 로그인(/login) 페이지로 이동하는 불편함이 있었습니다.

리다이렉트 이슈

 

위 영상은 로그인을 하지 않은 사용자가 팀 생성(/addteam) 페이지로 접근하는 경우입니다. 팀 생성 페이지가 보인 후 로그인 페이지로 이동하도록 동작하는 것이 저에게는 불편하다고 느껴졌습니다.

저의 일방적인 의견일 수 있어, 주변 사람들에게 피드백을 요청했고 대부분이 화면이 깜빡거리는 것처럼 느껴진다고 답했습니다.

개선 방향에 대해서는 로그인(/login) 페이지로 바로 이동하는 것이 아니라, 로그인 정보를 확인하고 있다는 알림이나 로딩 화면이 있으면 좋겠다는 의견이 많아서 로딩 화면을 추가해 보겠습니다.

 

useAuthRedirect 훅 수정 - 로딩 state 추가

 

import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () => {
  const router = useRouter();
  const { accessToken } = useSelector((state: RootState) => state.auth);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!accessToken) {
      setTimeout(() => {
        alert('로그인 후 이용할 수 있습니다.');
        router.push('/login');
      }, 300);
    } else {
      setIsLoading(false);
    }
  }, [accessToken, router]);

  return { isLoading };
};

export default useAuthRedirect;

 

 

로딩 화면을 추가하기 위해 기존 커스텀 훅에 isLoading state를 추가했습니다.

 

리다이렉트 이슈 해결
function Page() {
  const { isLoading } = useAuthRedirect();
  
  if (isLoading)
    return (
      <div className="flex h-screen items-center justify-center bg-black text-white opacity-50">
        로그인 정보 확인중...
      </div>
    );
}

 

로딩 상태를 보여주는 부분도 여러 페이지에서 사용하니 컴포넌트로 분리하겠습니다.

 

AuthCheckLoading.tsx - 로그인 정보 확인 로딩 화면

function AuthCheckLoading() {
  return (
    <div className="flex h-screen items-center justify-center bg-black text-white opacity-50">
      로그인 정보 확인중...
    </div>
  );
}

export default AuthCheckLoading;
function Page() {
  const { isLoading } = useAuthRedirect();
  
  if (isLoading) return <AuthCheckLoading />;
}

 

컴포넌트로 분리함으로써 추후 디자인 수정을 고려했을 때 유지보수성과 재사용성 모두 개선되었습니다.

 

사용자 경험(UX) 개선 - 뒤로 가기 이슈 수정

로그인 페이지로 리다이렉트 이후 뒤로 가기 버튼을 누르게 되면, 다시 로그인이 필요한 페이지로 이동하게 되어 로그인 페이지로 이동하도록 동작하고 있습니다.

 

간단하게 순서도로 정리해 보겠습니다. 내용은 로그인 안 한 사용자 기준입니다.

  1. 팀 생성(/addteam) 페이지 접근
  2. 로딩 페이지 렌더링 후 로그인(/login) 페이지로 리다이렉트
  3. 뒤로 가기 버튼 클릭
  4. 팀 생성(/addteam) 페이지로 이동
  5. 로딩 페이지 렌더링 후 로그인(/login) 페이지로 리다이렉트

검색을 해본 결과로 위 내용을 해결하기 위해서는 router.push와 router.replace의 차이를 알아야 할 것 같습니다.

  • router.push : 새로운 페이지로 이동하며 브라우저 히스토리에 기록됨 (이전 페이지로 돌아가기 가능)
  • router.replace : 현재 페이지를 새로운 페이지로 대체하며 브라우저 히스토리를 덮어씀 (이전 페이지로 돌아가기 불가능)
import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () => {
  const router = useRouter();
  const { accessToken } = useSelector((state: RootState) => state.auth);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!accessToken) {
      setTimeout(() => {
        alert('로그인 후 이용할 수 있습니다.');
        router.replace('/login');
      }, 300);
    } else {
      setIsLoading(false);
    }
  }, [accessToken, router]);

  return { isLoading };
};

export default useAuthRedirect;

 

 

리팩토링을 진행하면서 유지보수하기 쉬운 코드가 좋은 코드라는 점을 다시 한번 느꼈습니다. 처음부터 작업을 진행할 때 계획이나 설계 없이 코드부터 짜다 보니, 리팩토링 할 때 변경해야 할 부분이 너무 많다는 것을 실감했습니다.

앞으로는 꾸준히 리팩토링 하며 더 읽기 쉽고, 유지보수하기 쉬운 코드를 작성하는 개발자로 성장하도록 노력해야겠습니다!