오늘은 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);
사실 코드를 보면 store나 state 어떤 이름으로 사용해도 실제 코드에서 사용되는 일이 없어서 큰 차이는 없을 것 같지만, 유지보수 측면에서 일관성을 유지하기 위해 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) 개선 - 뒤로 가기 이슈 수정
로그인 페이지로 리다이렉트 이후 뒤로 가기 버튼을 누르게 되면, 다시 로그인이 필요한 페이지로 이동하게 되어 로그인 페이지로 이동하도록 동작하고 있습니다.
간단하게 순서도로 정리해 보겠습니다. 내용은 로그인 안 한 사용자 기준입니다.
- 팀 생성(/addteam) 페이지 접근
- 로딩 페이지 렌더링 후 로그인(/login) 페이지로 리다이렉트
- 뒤로 가기 버튼 클릭
- 팀 생성(/addteam) 페이지로 이동
- 로딩 페이지 렌더링 후 로그인(/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;
리팩토링을 진행하면서 유지보수하기 쉬운 코드가 좋은 코드라는 점을 다시 한번 느꼈습니다. 처음부터 작업을 진행할 때 계획이나 설계 없이 코드부터 짜다 보니, 리팩토링 할 때 변경해야 할 부분이 너무 많다는 것을 실감했습니다.
앞으로는 꾸준히 리팩토링 하며 더 읽기 쉽고, 유지보수하기 쉬운 코드를 작성하는 개발자로 성장하도록 노력해야겠습니다!
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[맛길] Naver Map API를 이용한 맛집 지도 추가 (0) | 2025.02.13 |
---|---|
[Coworkers] IOS 이미지 업로드 이슈 (0) | 2025.02.12 |
[맛길] 상세 페이지 - Youtube 영상 추가 (1) | 2025.02.06 |
[맛길] 동적 라우팅(Dynamic Routing) 적용 (1) | 2025.02.05 |
[Coworkers] 할 일 리스트 페이지 작업 (협업) (1) | 2025.02.04 |