인디버디 리팩토링하기 (2)
위 부분이다. 크게 보면 2개의 부분으로 나눌 수 있다. 인기, 신규 탭을 보여주는 네비게이션 바과 게임 카드와 페이지네이션이 보여지는 부분이다. 또 게임 카드들과 페이지네이션도 분리할 수 있다.
Home 리팩토링 - 2
이전 포스트에서 Home컴포넌트를 1차적으로 리팩토링을 진행했다. 하지만 그 때 한 부분은 리팩토링을 하는 부분이 적었다고 볼 수 있다. 오늘부터 진행할 부분은 손 댈 곳이 정말 많다고 생각한다. 이번 프로젝트를 하면서도 느꼈고 하고 나서도 느낀 것인데 시간에 쫓겨 급하게 코드를 작성하게 되면 재사용은 물론이고 의미를 한 번에 알기 어려운 코드가 짜여지게 된다. 내 실력을 너무 믿지 않는다. 자신감이 없는 것이 아니라 누구나 한 번에 좋은 코드를 짜기 어렵고 심지어 나는 개발 경력이 오래되지 않았기 때문에 더 그럴 것이다. 그래서 코드를 짜면서 많은 고민을 하는 습관을 들였다. 이제는 그 고민을 시간 안에 녹여내는 것을 연습해야 한다.
Home 하위 컴포넌트
GameContainer
오늘 리팩토링 할 부분은 GameContainer 컴포넌트다. 컴포넌트 명만 보면 어떤 컴포넌트인지 알 수 없다.(여기서 벌써 컴포넌트 명이 불분명하다는 문제가 있는 것일까..)

위 부분이다. 크게 보면 2개의 부분으로 나눌 수 있다. 인기, 신규 탭을 보여주는 네비게이션 바과 게임 카드와 페이지네이션이 보여지는 부분이다. 또 게임 카드들과 페이지네이션도 분리할 수 있다.
위 작업을 위해 내가 짠 코드를 봐보자. 코드가 굉장히 지저분하고 길 것이니 미리 주의하자.
리팩토링 전
const GameContainer = () => {
const user = useSelector((s: RootState) => s.user);
const [games, setGames] = useState<GameType[]>([]);
const [curPage, setCurPage] = useState(1);
const [pageInfo, setPageInfo] = useState<PageInfoType>({
page: 1,
size: 14,
totalPage: 1,
totalSize: 1
});
const [tabInd, setTabInd] = useState<number>(0);
const apiRef = [
'/api/games/?filter=POPULAR',
'/api/games/?filter=NEW',
`/api/members/${user.memberId}/creategame`,
`/api/members/${user.memberId}/mygame`
];
const onClickHandler = (i: number) => () => {
setTabInd(i);
};
const onPageClickHandler = (page: number) => {
setCurPage(page);
};
useEffect(() => {
if (tabInd === 0 || tabInd === 1) {
(async () => {
try {
const res = await axios.get(
`${process.env.REACT_APP_API_URL}${apiRef[tabInd]}&page=${
curPage !== pageInfo.page ? curPage : 1
}&size=${pageInfo.size}`
);
setGames(res.data.data);
setPageInfo(res.data.pageInfo);
// isTabIndChanged() && setCurPage(1);
curPage === pageInfo.page && setCurPage(1);
} catch (err) {
console.error(err);
}
})();
} else {
(async () => {
try {
const res = await axios.get(
`${process.env.REACT_APP_API_URL}${apiRef[tabInd]}`
);
const newPageInfo = {
...pageInfo,
page: curPage !== pageInfo.page ? curPage : 1,
totalSize: res.data.data.length,
totalPage: Math.ceil(res.data.data.length / 14)
};
const startInd = (newPageInfo.page - 1) * newPageInfo.size;
const endInd = startInd + newPageInfo.size;
setGames(res.data.data.slice(startInd, endInd));
setPageInfo(newPageInfo);
// isTabIndChanged() && setCurPage(1);
curPage === pageInfo.page && setCurPage(1);
} catch (err) {
console.error(err);
}
})();
}
}, [tabInd, curPage]);
return (
<StyledWrapper>
<StyledContainer>
<FilterBar onClickHandler={onClickHandler} tabInd={tabInd} />
<StyledCardContainer>
{games.length === 0 && (
<StyledEmptyItem>등록된 게임 채널이 없습니다.</StyledEmptyItem>
)}
{games.map((game) => (
<HomeGameCard key={game.gameId} {...game} />
))}
</StyledCardContainer>
{games.length > 0 && (
<StyledPagination>
<Pagination
activePage={curPage}
itemsCountPerPage={pageInfo.size}
totalItemsCount={pageInfo.totalSize}
pageRangeDisplayed={5}
prevPageText={'‹'}
nextPageText={'›'}
onChange={onPageClickHandler}
/>
</StyledPagination>
)}
</StyledContainer>
</StyledWrapper>
);
};
이렇게 보면 짧아 보이지만 실제 모듈 import 부분과 스타일드 컴포넌트 정의 부분을 합치면 더 보기 싫어진다.
문제라고 생각되는 부분
이제 위 코드에서 문제라고 생각되는 부분을 봐보자.
제일 먼저 보이는 것은 useEffect에서 데이터를 패칭하는 부분이다. 이건 백엔드에서 넘겨주는 데이터가 페이지네이션으로 구현되는 부분이 있고 그냥 한 번에 전체 데이터를 넘겨주는 것이 있어서 분기 처리를 했는데, 그래도 내부에 중복되는 코드가 많이 발생한다는 문제가 있다.
(async () => {
try {
const res = await axios.get(
`${process.env.REACT_APP_API_URL}${apiRef[tabInd]}&page=${
curPage !== pageInfo.page ? curPage : 1
}&size=${pageInfo.size}`
);
setGames(res.data.data);
setPageInfo(res.data.pageInfo);
// isTabIndChanged() && setCurPage(1);
curPage === pageInfo.page && setCurPage(1);
} catch (err) {
console.error(err);
}
})();
위 코드와 아래 코드를 비교해보자.
(async () => {
try {
const res = await axios.get(
`${process.env.REACT_APP_API_URL}${apiRef[tabInd]}`
);
const newPageInfo = {
...pageInfo,
page: curPage !== pageInfo.page ? curPage : 1,
totalSize: res.data.data.length,
totalPage: Math.ceil(res.data.data.length / 14)
};
const startInd = (newPageInfo.page - 1) * newPageInfo.size;
const endInd = startInd + newPageInfo.size;
setGames(res.data.data.slice(startInd, endInd));
setPageInfo(newPageInfo);
// isTabIndChanged() && setCurPage(1);
curPage === pageInfo.page && setCurPage(1);
} catch (err) {
console.error(err);
}
})();
api 요청 주소와 페이지네이션을 직접 구현하는 부분을 제외하면 동일하다는 것을 알 수 있다. 동일한 부분은 당연히 함수로 뺄 수 있다는 것이다.
또한 현재 인기, 신규, 생성, 팔로우 필터링을 하는 방식에도 문제가 있다.
const [tabInd, setTabInd] = useState<number>(0);
const apiRef = [
'/api/games/?filter=POPULAR',
'/api/games/?filter=NEW',
`/api/members/${user.memberId}/creategame`,
`/api/members/${user.memberId}/mygame`
];
위와 같은 방식으로 필터링 탭 상태를 인덱스로 관리하고, 그에 맞는 주소를 인덱스로 접근하는 데 이는 어떤 탭인지 명확하게 알 수 없고 각 탭마다 요청 주소가 어떻게 연결되는 지 알기 어렵다고 생각한다.
문제는 요청 주소가 각각 다르고 생성, 팔로우 요청 주소의 경우 user.memberId가 필요해 외부로 빼기가 어려워 보인다.
다음은 렌더링 부분이다.
return (
<StyledWrapper>
<StyledContainer>
<FilterBar onClickHandler={onClickHandler} tabInd={tabInd} />
<StyledCardContainer>
{games.length === 0 && (
<StyledEmptyItem>등록된 게임 채널이 없습니다.</StyledEmptyItem>
)}
{games.map((game) => (
<HomeGameCard key={game.gameId} {...game} />
))}
</StyledCardContainer>
{games.length > 0 && (
<StyledPagination>
<Pagination
activePage={curPage}
itemsCountPerPage={pageInfo.size}
totalItemsCount={pageInfo.totalSize}
pageRangeDisplayed={5}
prevPageText={'‹'}
nextPageText={'›'}
onChange={onPageClickHandler}
/>
</StyledPagination>
)}
</StyledContainer>
</StyledWrapper>
);
이 역시 컴포넌트의 의미 레벨이 맞지 않는다고 생각했다. 위 코드를 보면 필터바는 컴포넌트로 분리가 되었지만, 게임 카드들을 보여주는 부분은 분리되어 있지 않다. 또한 페이지네이션도 게임 카드를 보여주는 부분에 합쳐줄 수 있을 것 같다. 단, 이때의 문제점은 프롭스로 넘겨받을 상태가 많아진다는 것이다.
리팩토링
탭 상태 관리
먼저 탭의 상태를 인덱스가 아닌 글자를 가지도록 변경한다.
const [tabInd, setTabInd] = useState<string>('인기');
이후 API 주소를 외부로 추출한다.
// filterTabList.ts
export const getFilterPath = (memberId: number): { [key: string]: string } =>
memberId === -1
? {
인기: '/api/games/?filter=POPULAR',
신규: '/api/games/?filter=NEW'
}
: {
인기: '/api/games/?filter=POPULAR',
신규: '/api/games/?filter=NEW',
생성: `/api/members/${memberId}/creategame`,
팔로우: `/api/members/${memberId}/mygame`
};
주소가 memeberId를 필요로하기 때문에 함수로 만들었다. key를 인자로 받아 요청 주소를 리턴하게 만들지 않은 이유는 이 함수가 반환하는 객체의 키값 전체를 FilterBar 컴포넌트에서 사용하기 때문이다.
const FilterBar = ({
onClickHandler,
tabInd
}: {
onClickHandler: (i: number) => () => void;
tabInd: number;
}) => {
const user = useSelector((s: RootState) => s.user);
return (
<StyledContainer>
<StyledFilterTabContainer>
<StyledItem onClick={onClickHandler(0)} selected={tabInd === 0}>
인기
</StyledItem>
<StyledItem onClick={onClickHandler(1)} selected={tabInd === 1}>
신규
</StyledItem>
{user.memberId !== -1 && (
<>
<StyledItem onClick={onClickHandler(2)} selected={tabInd === 2}>
생성
</StyledItem>
<StyledItem onClick={onClickHandler(3)} selected={tabInd === 3}>
팔로우
</StyledItem>
</>
)}
</StyledFilterTabContainer>
</StyledContainer>
);
};
이 컴포넌트도 조금 리팩토링 해줄 것이다.
const FilterBar = ({
onClickHandler,
tab
}: {
onClickHandler: (i: string) => () => void;
tab: string;
}) => {
const user = useSelector((s: RootState) => s.user);
const tabs = Object.keys(getFilterPath(user.memberId));
return (
<StyledContainer>
<StyledFilterTabContainer>
{tabs.map((tabName) => (
<StyledItem
key={tabName}
onClick={onClickHandler(tabName)}
selected={tab === tabName}
>
{tabName}
</StyledItem>
))}
</StyledFilterTabContainer>
</StyledContainer>
);
};
const apiPath = useMemo(() => getFilterPath(user.memberId), [user]);
(async () => {
try {
const res = await axios.get(
`${process.env.REACT_APP_API_URL}${apiPath[tab]}&page=${
curPage !== pageInfo.page ? curPage : 1
}&size=${pageInfo.size}`
);
setGames(res.data.data);
setPageInfo(res.data.pageInfo);
// isTabIndChanged() && setCurPage(1);
curPage === pageInfo.page && setCurPage(1);
} catch (err) {
console.error(err);
}
})();
위와 같이 주소를 받는 부분이 변경되었다.
API 요청 부분
필터 탭의 상태에 따라 useEffect에서 데이터를 패칭하는 부분을 수정했다.
export const makeNewPageInfo = (
data: GameType[],
pageInfo: PageInfoType,
curPage: number
): [number, number, PageInfoType] => {
const newPageInfo = {
...pageInfo,
page: curPage !== pageInfo.page ? curPage : 1,
totalSize: data.length,
totalPage: Math.ceil(data.length / 14)
};
const startInd = (newPageInfo.page - 1) * newPageInfo.size;
const endInd = startInd + newPageInfo.size;
return [startInd, endInd, newPageInfo];
};
우선 페이지네이션 기능을 직접 만들어야 하는 경우를 위한 함수를 추출했다.
const onSuccess = ({
data
}: { data: { data: GameType[]; pageInfo?: PageInfoType }; }) => {
if (data.pageInfo) {
setGames(data.data);
setPageInfo(data.pageInfo);
} else {
const [startInd, endInd, newPageInfo] = makeNewPageInfo(
data.data,
pageInfo,
curPage
);
setGames(data.data.slice(startInd, endInd));
setPageInfo(newPageInfo);
}
curPage === pageInfo.page && setCurPage(1);
};
이후 데이터 패칭 요청이 성공했을 때 실행할 함수를 분리했다. 여기서 넘어오는 데이터의 data.pageInfo가 있는지 여부에 따라 상태 변경 함수에 들어갈 값을 다르게 주었다.
useEffect(() => {
let url = '';
if (tab == '인기' || tab == '신규') {
url = `${process.env.REACT_APP_API_URL}${apiPath[tab]}&page=${
curPage !== pageInfo.page ? curPage : 1
}&size=${pageInfo.size}`;
} else {
url = `${process.env.REACT_APP_API_URL}${apiPath[tab]}`;
}
getData(url, onSuccess, (error) => {
console.error(error);
});
}, [tab, curPage]);
마지막으로 useEffect의 내용을 다음과 같이 수정했다. getData 함수를 이용해서 데이터 패칭 부분을 숨겼고, 주소는 따로 만들어 넘겨주었다.
랜더링 부분
마지막으로 랜더링 부분을 리팩토링 했다.
// GaemCardList.tsx
const GameCardList = ({ games }: { games: GameType[] }) => {
return (
<StyledCardContainer>
{games.length === 0 && (
<StyledEmptyItem>등록된 게임 채널이 없습니다.</StyledEmptyItem>
)}
{games.map((game) => (
<GameItem key={game.gameId} {...game} />
))}
</StyledCardContainer>
);
};
게임 카드를 랜더링 하는 부분을 컴포넌트로 분리했다.
// GameCardPagination.tsx
const GameCardPagination = ({
curPage,
gameLength,
pageInfo,
onPageClickHandler
}: {
curPage: number;
gameLength: number;
pageInfo: PageInfoType;
onPageClickHandler: (page: number) => void;
}) => {
if (gameLength <= 0) return <></>;
return (
<StyledPagination>
<Pagination
activePage={curPage}
itemsCountPerPage={pageInfo.size}
totalItemsCount={pageInfo.totalSize}
pageRangeDisplayed={5}
prevPageText={'‹'}
nextPageText={'›'}
onChange={onPageClickHandler}
/>
</StyledPagination>
);
};
페이지네이션 부분도 분리했다. 이제 완성된 모습을 확인하면 아래와 같다.
return (
<StyledWrapper>
<StyledContainer>
<FilterBar onClickHandler={onTabClickHandler} tab={tab} />
<GameCardList games={games} />
<GameCardPagination
gameLength={games.length}
curPage={curPage}
pageInfo={pageInfo}
onPageClickHandler={onPageClickHandler}
/>
</StyledContainer>
</StyledWrapper>
);
랜더링부분이 많이 줄었고 각 컴포넌트에 기능을 위임하여 분리하였다.