퇴사 전 내 코드들을 지켜보는데, 기존 코드들은 UI 컴포넌트와 Hooks가 분리되지 않고, 컴포넌트 내부에 얽혀 있는 상태로 작성되어 있었다. 이는 가독성을 떨어뜨릴 뿐 아니라, 유지보수 과정에서 잦은 수정과 오류를 유발했다. 또한 테스트코드를 작성하려해도 로직이 분리되어있지 않아 해당 Hooks만 따로 분리하기 난감한 상황이었다.
그래서 회사 코드를 리팩토링 하는 작업을 진행하려한다.
💡 코드 예시들은 실제 코드가 아닌 약간의 모방을 한 예시코드임을 알려드립니다.
리팩토링 하기에 앞서 일단 나 자신이 지켜야할 리팩토링 규칙을 세웠다.
위 규칙은 ChatGPT와 함께 정리한 것이며, 이를 최대한 지키며 가독성과 유지보수성을 높이는 방향으로 리팩토링을 진행하고자 한다.
일단 리팩토링에 앞서 가장 먼저 진행한 작업은 Private folders
구조를 분해하는 것 이였다.
Nextjs - Project-structure#private-folders
처음 도입 당시에는 nextjs에서 UI 로직과 라우팅 로직은 분리할때나 editor에서 파일 정렬 및 그룹화 하고싶을때 도입해보라는 내용을 참고하여 작성하였는데, 막상 사용하고보니 작성자 외에는 _components
와 @/components
의 구분이 잘 가지 않는다는 문제점이 있었다.
src/
├── app/
│ ├── device/
│ │ ├── _components/
│ │ └── [deviceSn]/
│ ├── .../
│ │ ├── _components/
│ │ └── [...]/
│ └── .../
│ ├── _components/
│ └── [...]/
└── components/
├── device/
│ ├── components.tsx
│ ├── components.tsx
│ └── *.tsx
└── domain
├── components.tsx
├── components.tsx
└── *.tsx
또한 import 환경에서도 다소 복잡함을 유발하는 경우도 생겨났다.
import DeviceContainer from './_components/deviceContainer'
import DeviceSearchForm from '@/components/device/deviceSearchForm'
작성자는 이해할 수 있다 하더라도, 처음 이 코드를 접한사람은 다음과 같은 의문을 가지게 될 가능성이 있다.
"이게 무엇을 기준으로 나눈거지?"
"이렇게 나눔으로써 생기는 이점이 뭐지?"
"이 코드는 왜 여기에 작성되었지?"
그리고 나 조차도 장시간 해당 코드를 보지 않게 된다면 나중에 이러한 생각을 할 가능성이 높았고, 실제로 몇몇 코드는 위와 같은 생각이 들었다. 이는 유지보수하기 좋은 코드라고 생각이 들지 않았고, 이에 대해선 분명히 개선해야한다고 느꼈다.
폴더 구조 개선은 우리에게 익숙한 파일 중심의 구조로 변경하였다.
src/
├── app/
│ ├── device/
│ │ └── [deviceSn]/
│ ├── .../
│ │ └── [...]/
│ └── .../
│ └── [...]/
├── components/
├── device/
│ ├── components.tsx
│ ├── components.tsx
│ ├── _components의 컴포넌트.tsx
│ ├── _components의 컴포넌트.tsx
│ └── *.tsx
└── ...domain
├── components.tsx
├── components.tsx
├── _components의 컴포넌트.tsx
├── _components의 컴포넌트.tsx
└── *.tsx
누구나 가장 자주 접하는 폴더 구조인 만큼 명확하게 구분짓기 좋다고 판단하여 위와 같이 개선하였다.
처음에는 항해 플러스 프론트엔드 스터디에서 알게 된 FSD(Feature-Sliced Design)
를 도입해볼까 고민했다. 확실히 관리자 사이트는 사이트 내에서 여러 도메인으로 갈라지기 때문에 FSD 아키텍쳐로 명확히 구분지어 폴더구조를 나눌수 있다고 생각이 들어서다.
막상 도입하려고 features, entities, widgets, shared
등등... 코드를 Layer
단위로 쪼개는 작업에서부터 막혔다.
위와 같은 이슈들은 결국 도입하려는 나 조차도 FSD에 아직 완벽하게 이해못함을 의미했고, 또 단순히 도메인으로 나누기 쉽다고 무작정 도입할 수 있는 아키텍쳐가 아니라는 생각이 들었다.
이런 이슈도 있었다.
nextjs는 v13.4 이후부터 정식 도입된 app router를 사용중인데 이는 FSD아키텍쳐의 app layer와도 충돌이 일어나는 부분이 있어 구조 변경을 해야한다는 문제점도 있었다.
FSD 공식문서 - with nextjs
FSD는 결국 더 나은 개발 환경을 위한 아키텍처다. 하지만 내가 제대로 이해하지 못한 상태에서 도입하고, 함께 협업하는 사람이 이를 불편하게 느낀다면 굳이 적용할 필요가 있을까 하는 생각이 들었다.
기존 코드들은 UI Components내에 상태관리, ReactQuery 호출 등 비즈니스 로직이 섞인 코드였다.
const DeviceSearchForm = () => {
const [deviceInfo, setDeviceInfo] = useState<IDeviceSearch | null>();
const getDeviceInfo = (data: IDeviceSearch | null) => {
setDeviceInfo(data);
};
const formSchema = z.object({
search: string()
});
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm({
resolver: zodResolver(formSchema),
});
const mutateDeviceSearch = useGetDeviceSearch(setError, getDeviceInfo);
const handleSubmitDeviceAdd = handleSubmit(async (data) => {
mutateDeviceSearch.mutate({
...
});
});
return (
<div className="w-full">
<form className="my-5" onSubmit={handleSubmitDeviceAdd}>
<div className="flex items-start justify-center gap-4">
<Label
className="text-sm font-medium sr-only"
>
기기 검색 폼
</Label>
<div className="w-full">
<Input
{...register("search")}
/>
{errors && (
<p className="text-sm text-red-500 mt-1">
{typeof errors.message === "string" &&
errors.message}
</p>
)}
</div>
<Button type="submit">기기 검색하기</Button>
</div>
</form>
<div className="mt-4">
<h2 className="text-lg">기기 정보</h2>
{deviceInfo && (
<>
<Separator className="my-4" />
<DeviceAddContainer deviceInfo={deviceInfo} />
</>
)}
</div>
</div>
);
};
deviceInfo의 상태
와 form과 관련된 로직들
그리고 form UI
등.. 여러 코드가 섞여있어 로직에 대한 테스트도 힘들고, 한눈에 코드가 들어오지않아 다소 복잡하다. 또한 특정 로직을 수정하려하면 컴포넌트 전체의 수정이 이루어져야 하는 경우도 있어 개선이 필요하였다.
먼저 UI와 상태 관리 로직을 분리하기위해 useDeviceSearchForm
이라는 Custom Hooks
를 생성했다. 이후 1차적으로 상태값과 API로직 등을 옮겨주어 form에 필요한 상태나 액션들을 리턴해주었다.
export const useDeviceSearchForm = () => {
const [deviceInfo, setDeviceInfo] = useState<IDeviceSearch | null>();
const getDeviceInfo = (data: IDeviceSearch | null) => {
setDeviceInfo(data);
};
const formSchema = z.object({
search: string()
});
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm({
resolver: zodResolver(formSchema),
});
const mutateDeviceSearch = useGetDeviceSearch(setError, getDeviceInfo);
const handleSubmitDeviceAdd = handleSubmit(async (data) => {
mutateDeviceSearch.mutate({
...
});
});
return {
deviceInfo,
register,
handleSubmit,
setError,
errors,
handleSubmitDeviceAdd
}
}
이렇게 구조를 정리하고 보니, zod
와 react-hook-form
도 methods
로 묶어서 리턴하는 방식이 더 간결하겠다고 판단했다.
// hooks/device/useDeviceSearchForm.ts
export const useDeviceSearchForm = () => {
const [deviceInfo, setDeviceInfo] = useState<IDeviceSearch | null>();
const getDeviceInfo = (data: IDeviceSearch | null) => {
setDeviceInfo(data);
};
const formSchema = z.object({
search: string()
});
const methods = useForm({
resolver: zodResolver(formSchema),
});
const mutateDeviceSearch = useGetDeviceSearch(methods.setError, getDeviceInfo);
const handleSubmitDeviceAdd = methods.handleSubmit(async (data) => {
mutateDeviceSearch.mutate({
...
});
});
return {
deviceInfo,
methods,
handleSubmitDeviceAdd
}
}
이후 DeviceSearchForm
도 Container
와 Form
으로 분리해주었다.
// components/device/DeviceSearchContainer.tsx
const DeviceSearchContainer = () => {
const {deviceInfo, methods, handleSubmitDeviceAdd} = useDeviceSearchForm();
return (
<div className="w-full">
<DeviceSearchForm
register={methods.register}
handleSubmitDeviceAdd={handleSubmitDeviceAdd}
errors={methods.formState.errors}
/>
<div className="mt-4">
<h2 className="text-lg">기기 정보</h2>
{deviceInfo && (
<>
<Separator className="my-4" />
<DeviceAddContainer deviceInfo={deviceInfo} />
</>
)}
</div>
</div>
);
};
// components/device/DeviceSearchForm.tsx
export const DeviceSearchForm = ({
register,
handleSubmitDeviceAdd,
errors,
}: {
register: UseFormReturn<IDeviceSearchForm>["register"];
handleSubmitDeviceAdd: () => void;
errors: UseFormReturn<IDeviceSearchForm>["formState"]["errors"];
}) => {
return (
<form className="my-5" onSubmit={handleSubmitDeviceAdd}>
<div className="flex items-start justify-center gap-4">
<Label
className="text-sm font-medium sr-only"
>
기기 검색 폼
</Label>
<div className="w-full">
<Input
{...register("search")}
/>
{errors && (
<p className="text-sm text-red-500 mt-1">
{typeof errors.message === "string" &&
errors.message}
</p>
)}
</div>
<Button type="submit">기기 검색하기</Button>
</div>
</form>
);
};
이와 같이 개선함으로써 기존의 복잡하고 얽힌 코드들을 보다 간결하고 한눈에 들어오게 개선하였으며, 비즈니스 로직
과 UI
가 분리됨으로써 비즈니스 로직에 대한 수정이 이루어져도 컴포넌트에서의 로직에 대한 의존성을 다소 덜어낼 수 있었다.
기존 코드들중 몇몇 코드는 fetch를 이용해 직접 api를 호출
하는식으로 api hooks가 작성된 코드들이 있었다.
export const useGetData = () => {
return useQuery({
queryKey: ["datakey"],
queryFn: async () => await fetch("...api")
})
}
위와 같은 코드가 존재할 때 fetch에서 axios로 변경시 useGetData 까지 수정해야하는 번거로움이 생기게 되고, 코드 자체가 API에 의존적이게 되어버린다.
이를 개선하고자 API를 service로 분리
하고, 해당 service코드를 import함으로써 의존성을 제거하고, 테스트시에도 해당부분만 mock으로 바꿔주면 됨으로써 테스트에도 용이해졌다.
// hooks/useGetData.ts
export const useGetData = () => {
return useQuery({
queryKey: ["datakey"],
queryFn: async () => await getData()
})
}
// service/getData.ts
export const getData = async () => {
const response = await axios.get("...api");
return response.data;
}
기존에는 hooks/*.ts
형태로 훅들을 관리했는데, API 훅과 커스텀 훅이 섞여 있어 구조가 다소 지저분했다. 이를 개선하고자 hooks의 관심사
를 분리하였다.
hooks/
├── usePostUserApi.ts
├── useGetUserApi.ts
├── useDeleteUserApi.ts
├── usePatchUserApi.ts
├── useSearchDeviceForm.ts
├── useGetDeviceListApi.ts
├── usePostDeviceApi.ts
├── useGetDeviceDetail.ts
├── useSearchUserForm.ts
└── ...*.ts
우선 API를 위한 Hooks
인지 state를 관리하기위한 custom Hooks
인지 구분해주었다. 이후 해당 API Hooks를 도메인별로 한번 더 구분하여 Hooks의 관심사를 한눈에 볼 수 있도록 수정하였다.
hooks/
│ └── api/
│ ├── device/
│ │ ├── useGetDeviceListApi.ts
│ │ ├── usePostDeviceApi.ts
│ │ └── useGetDeviceDetail.ts
│ └── user/
│ ├── usePostUserApi.ts
│ ├── useGetUserApi.ts
│ ├── usePatchUserApi.ts
│ └── useDeleteUserApi.ts
├── useSearchDeviceForm.ts
├── useSearchUserForm.ts
└── ...*.ts
이번 리팩토링은 코드의 구조를 재정비하고 역할과 책임을 명확히 나눔으로써 가독성과 유지보수성
을 크게 향상시킬 수 있었던 작업이었다.
특히 UI와 로직, API 계층을 분리하고 의존성
을 낮추는 것만으로도 테스트가 쉬워지고, 추후 확장 시에도 부담이 줄어들었다.
비록 완전한 FSD 구조를 도입하진 않았지만, 현재 팀 상황과 프로젝트의 규모를 고려해 현실적인 선에서 개선 가능한 부분들을 명확히 분리하고 정리한 것이 더 큰 의미가 있었다고 생각한다.