위클리 미션에서 리팩토링을 하고 싶은 부분이 아주 많다.
그 중에서 한 두가지 정도를 뽑아서 매주 리팩토링을 해보자는 다짐을 했었는데, 이번주에는 모달을 리팩토링 해보기로 했다.
그래서, 멘토링 시간 때 모달 리팩토링에 대해서 이야기를 해봤고 context API만으로도 구현이 가능해보인다는 결론이 나서 이번 주차에 해보기로 마음을 먹었다..!
이전부터 상태관리를 위한 다양한 방법들을 공부하고 싶었기 때문에 바로 리팩토링에 들어갔다.
그럼 바로, ContextAPI를 이용해서 어떻게 모달 리팩토링을 진행했는지 확인해보자~ ! 😊
Modal 컴포넌트 리팩토링이 필요하다고 생각한 이유
우선, 왜 모달 컴포넌트를 리팩토링 하고 싶었는지에 대해 짚고 넘어가야할 것 같다.
현재 모달 컴포넌트 구현을 위한 로직은 아래와 같이 작성되고 있다.
* 각 각의 모달 컴포넌트에 대한 코드는 생략하도록 하겠다 *
export function CategoryNavButtons({ selectFolder }: { selectFolder: string }) {
// 각 각의 모달을 열어주는 state를 생성해서 관리하고 있음
const [sharedModal, setSharedModal] = useState(false);
const [renameModal, setRenameModal] = useState(false);
const [deleteFolderModal, setDeleteFolderModal] = useState(false);
// 각 각의 state를 변경해주는 handler 함수를 정의하고 있다.
const handleSharedModal = () => {
setSharedModal(true);
};
const handleRenameModal = () => {
setRenameModal(true);
};
const handleDeleteFolderModal = () => {
setDeleteFolderModal(true);
};
// 모달 바깥을 클릭했을 때, x 버튼을 눌렀을 때
// state를 false로 바꿔줘야하기 때문에 handleCloseModal을 프롭으로 내려주고 있다.
const handleCloseModal = () => {
setSharedModal(false);
setRenameModal(false);
setDeleteFolderModal(false);
};
return (
// state에 따라서 조건부 렌더링 되도록 하고 있음
<>
{sharedModal && (
<SharedFolder
selectFolder={selectFolder}
isOpenModal={sharedModal}
handleModalClose={handleCloseModal}
/>
)}
{renameModal && (
<RenameModal
isOpenModal={renameModal}
handleModalClose={handleCloseModal}
/>
)}
{deleteFolderModal && (
<DeleteFolder
isOpenModal={deleteFolderModal}
handleModalClose={handleCloseModal}
selectFolder={selectFolder}
/>
)}
<S.CategoryNavButton onClick={handleSharedModal}>
<p>공유</p>
</S.CategoryNavButton>
<S.CategoryNavButton onClick={handleRenameModal}>
<p>이름 변경</p>
</S.CategoryNavButton>
<S.CategoryNavButton onClick={handleDeleteFolderModal}>
<p>삭제</p>
</S.CategoryNavButton>
</>
);
}
위와 같이 작성해도 모달은 정상적으로 로드되고, 잘 닫히지만
동일한 로직이 모달의 개수만큼 반복된다는 점이 맘에 들지 않았다.
또한, 모달에서 사용하는 data들을 넘겨주기 위해서 깊어지는 propDriling도 하나의 이유였다.
사실 이 부분에 대해서는 아직 고민이 되긴 한다
이러한 이유로 리팩토링을 하기로 결정했고, 그렇다면 Context API를 어떻게 사용할 것인지 생각해봐야했다.
그런데, Context API 넌 누구냐!?
Context API
리액트 공식문서의 Context API
ContextAPI가 전역 데이터를 다루기 위한 방법 중 하나라는 것은 알고 있었지만, 구체적인 사용방법에 대해서는 한 번도 알아본 적이 없었기 때문에 공식문서 정독에 들어갔다.
언제 Context API를 써야 할까
React 컴포넌트 트리 안에서 전역 데이터를 공유하기 위한 방법으로 사용된다.
전역 데이터의 예시는 현재 로그인한 유저, 다크모드 / 라이트모드, 지원 언어 등이 있다.
Context API의 사용법
React.createContext( )
const MyContext = React.createContext(defaultValue);
createContext를 통해 컨텍스트를 생성하고, 매개변수로 기본값을 넣어주면 된다.
이를 통해 데이터를 담을 Context (store의 역할)를 생성한다.
매개변수로 들어가는 defaultValue는, 트리 안에서 적절한 Provider을 찾지 못했을 때만 쓰이는 값이다.
그렇기 때문에 꼭 지정해주지 않아도 되지만, 컨텍스트 안에 어떤 형식의 데이터가 들어갈 지 유추할 수 있도록 도와주기 때문에 지정해주는 것이 좋다.
생성한 컨텍스트에 값 넣어주기
ContextName.Provider value={ data }
//...
<MyContext.Provider value={/*defaultValue와 형식이 같은 데이터*/}>
{children}
</ MyContext.Provider>
Provider
Context를 구독하는 컴포넌트들에게 context의 변화를 알리는 역할을 한다.
value prop을 받아서, 이 값을 하위에 있는 컴포넌트에게 전달한다.
값을 전달 받을 수 있는 컴포넌트의 수에는 제한이 없고, Provider이 중첩되어 있는 경우 하위 Provider의 값이 우선시 된다.
Provider 하위에서 Context를 구독하는 모든 컴포넌트들은 value prop이 바뀔 때마다 다시 렌더링 된다.
이 외에도 공식 문서에 관련 내용이 더 많지만,
모달 리팩토링을 위한 주요한 기능은 이 두가지를 잘 사용하면 되기 때문에 생략하도록 하겠다.
이때까지만 해도 나는
" ContextAPI 쉬운데? 금방 하겠는데? "
라고 생각하고 있었다....다가올 미래를 알지 못한 채...
우선, 내가 이해한 것을 바탕으로 여러 개의 모달을 한 모달 컴포넌트에 정리한 후 로직을 작성해보았다.
나의 의도는 모달들을 한 컴포넌트에서 모아서 어떤 모달 타입인지에 따라 조건부 렌더링 되도록 하는 것이었다.
// 모달들을 모아둔 컴포넌트
export function RefactorModal() {
// context에서 가져올 데이터
const { modalType, isOpenModal, selectURL, selectFolder, data } = useContext(ModalContext);
let modalContent;
let modalTitle = "";
// context로 받아오는 prop 중 하나로 modalType을 받아와서, 그 값에 따라 조건부 렌더링
switch (modalType) {
case "deleteLink":
modalContent = <DeleteLink deleteURL={selectURL} />;
modalTitle = "링크 삭제";
break;
case "addToFolder":
modalContent = <AddToFolder linkURL={selectURL} data={data} />;
modalTitle = "폴더에 추가";
break;
case "addFolderContent":
modalContent = <AddFolderContent />;
modalTitle = "폴더 추가";
break;
case "sharedFolder":
modalContent = <SharedFolder selectFolder={selectFolder} />;
modalTitle = "폴더 공유";
break;
case "renameModal":
modalContent = <RenameModal />;
modalTitle = "폴더 이름 변경";
break;
case "deleteFolder":
modalContent = <DeleteFolder selectFolder={selectFolder} />;
modalTitle = "폴더 삭제";
break;
default:
modalContent = <></>;
}
return isOpenModal ? <Modal title={modalTitle}>{modalContent}</Modal> : <></>;
}
모달 컴포넌트를 위와 같이 만들고, 사용하는 곳에서 버튼을 누를 때마다 value를 바꿔주려고 했다.
문제는 여기서 발생했다.
위의 공식문서를 읽고 난 후 나는 Provider이 값을 바꿔주는 방법이라고 이해했기 때문이다.
그래서 모달을 사용하는 컴포넌트마다 Provider을 남발했고, 모달이 열리는 버튼과 닫히는 버튼에서 각각 다른 state value를 전달하고 있었다.
// 모달이 닫히는 곳
export default function Modal({ children, title }: ModalProps) {
const contextModalValue = useContext(ModalContext);
const [showModalState, setShowModalState ] = useState(contextModalValue);
function handleCloseModal() {
setShowModalState({
...showModalState,
isOpenModal: false,
});
}
return (
<ModalContext.Provider value={showModalState}>
<ModalPortal>
<ModalDim onClick={handleCloseModal} />
<ModalContainer>
<ModalCloseButton handleModalClose={handleCloseModal} />
<ModalTitle>{title}</ModalTitle>
{children}
</ModalContainer>
</ModalPortal>
</ModalContext.Provider>
);
}
// 모달을 사용하는 곳
export function KebabMenu({ selectURL, data }: Props) {
const modalContextValue = useContext(ModalContext);
const [ modalState, setModalState ] = useState(modalContextValue);
const handleShowModal = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
switch (e.currentTarget.id) {
case "deleteLink":
setModalState({
...modalState,
isOpenModal: true,
selectURL: selectURL,
modalType: "deleteLink",
});
break;
case "addToFolder":
setModalState({
...modalState,
isOpenModal: true,
selectURL: selectURL,
data: data,
modalType: "addToFolder",
});
}
};
return (
<ModalContext.Provider value={modalState} >
<RefactorModal />
<S.CardContentKebabMenu>
<S.CardContentKebabMenuDelete
type="button"
onClick={handleShowModal}
id="deleteLink"
>
삭제하기
</S.CardContentKebabMenuDelete>
<S.CardContentKebabMenuDelete
type="button"
onClick={handleShowModal}
id="addToFolder"
>
폴더에 추가
</S.CardContentKebabMenuDelete>
</S.CardContentKebabMenu>
</ModalContext.Provider>
);
}
위와 같이 코드를 작성하자, 모달이 열리지만 닫히지 않는 문제가 발생했다.
그 이유는 ContextAPI가 어떤 흐름으로 데이터를 전달하고 사용하는지 이해하지 못했기 때문이었다.
그래서 다시 ContextAPI의 공식문서를 읽어보았고, 이제서야 조금 흐름이 눈에 들어오기 시작했다.
모달이 열렸지만, 닫히지 않는 것은 Provider의 사용법을 잘못 알고 있었기 때문이었다.
잘못 이해한 부분 짚고 넘어가기
나는 contextAPI를 어떻게 쓸 지를 고민하지 않고, 어떤 것을 전역 데이터로 받아서 사용할 것인지에 너무 치중되어 있었다.
그래서 익숙한 useState를 전역에서 사용하듯이 데이터를 업데이트 해주려고 했다.
Recoil을 사용하듯 사용하려고 했다
ContextAPI에서 전역으로 다루는 데이터를 어디서 어디로 전달하는 지를 다시 보자.
최상단인 컴포넌트를 Provider으로 감싸주면, 하위 컴포넌트에서는 context를 구독하고 데이터를 사용할 수 있다.
여기서 내가 놓친 부분이 바로 저 "최상단인 컴포넌트를 Provider으로 감싸" 이다.
한 Context에서는 하나의 데이터를 보관하고, 그 곳에 있는 데이터를 공유한다.
나는 여러 Provider에 value prop으로 데이터를 업데이트해주려고 했는데, 이는 적절한 contextAPI의 사용법이 아니었다.
전역 상태로 공유되는 context는 하나이기 때문에 모달을 사용하는 곳에서만 데이터가 업데이트되고,
모달을 닫는 곳에서는 데이터가 업데이트 되지 않은 것이다.
그래서 내가 원하는 대로 동작하지 않았다.
그럼 이제 원인도 알았으니, 완성을 향해 달려볼 차례이다!
로직 작성 계획
저 위의 로직을 작성하면서 ModalProvider이라는 컴포넌트를 만들어서 value를 업데이트하기 쉽게 state와 handlerEvent를 context로 관리하려고도 했었다.
하지만, Provider을 여러 곳에서 쓰겠다는 마음으로 작성했던 터라 제대로 동작하지 않았었는데 이제는 이 코드를 제대로 쓸 수 있을 것 같다.
//ModalContext.ts
export type ModalContextType = {
modalStateProperty: {
isOpenModal: boolean;
selectURL: string;
data: FolderListDataForm[] | undefined;
selectFolder: string;
modalType: string;
};
handleModalState: (
newState: Partial<ModalContextType["modalStateProperty"]>
) => void;
};
export const ModalContextInitial: ModalContextType = {
modalStateProperty: {
isOpenModal: false,
selectURL: "",
data: [],
selectFolder: "",
modalType: "",
},
handleModalState: () => {},
};
export const ModalContext = React.createContext(ModalContextInitial);
//ModalProvider.tsx
export function ModalProvider({ children }: { children: ReactNode }) {
const [modalState, setModalState] = useState<
ModalContextType["modalStateProperty"]
>(ModalContextInitial["modalStateProperty"]);
function handleModalState(
newState: Partial<ModalContextType["modalStateProperty"]>
) {
setModalState((prevState) => ({
...prevState,
...newState,
}));
}
const modalStateValue: ModalContextType = {
modalStateProperty: modalState,
handleModalState,
};
return (
<ModalContext.Provider value={modalStateValue}>
{children}
</ModalContext.Provider>
);
}
위 코드에 있는 handleModalState 함수를 정의할 때, 타입 지정에 굉장히 어려움을 겪었다.
매개변수로 들어갈 수 있는 값들은 ModalContextType안에 있는 modalStateProperty 객체의 프로퍼티들 중 하나가 될 '수도' 있는 값들이었다. (어떤 모달인지에 따라 들어갈 수 있는 프로퍼티가 다름)
그래서 어떤 타입으로 지정해줘야 Vs Code의 자동완성 기능을 사용할 수 있을까 고민을 많이 했고,
(그래야 사용할 때 편리해서)
Partial이라는 타입스크립트의 유틸리티 타입으로 타입을 지정했다.
TypeScript의 Partial
타입스크립트 공식문서 - 유틸리티 타입
타입 스크립트의 Utility Types는, 일반적인 타입 변환을 쉽게 하기 위해서 제공하는 타입이다.
Partial<Type>
위와 같은 형태로 사용이 가능하며, Type 집합의 모든 프로퍼티 안에서 선택적으로 타입을 생성한다.
주어진 타입의 모든 하위 타입 집합을 나타내는 타입을 반환한다.
이외에도 정말 다양한 유틸리티 타입이 있어서 공식문서 한 번 꼭 읽어보시길 ! (재밌어요)
어쨌든, 나의 경우에는
handleModalState: (
newState: Partial<ModalContextType["modalStateProperty"]>
) => void;
위와 같이 타입을 지정해주었는데, ModalContextType안에 있는 modalStateProperty의 하위 타입 집합이 필요해서 사용하게 되었다.
ModalContext와 ModalProvider의 코드가 마무리되었다면 이제는 동작하도록 적용할 시간이다 !
우선 모달을 사용할 최상위 컴포넌트를 감싸주고,
모달을 사용하는 곳에서 handleModalState를 통해 데이터를 업데이트해주면 된다
/// 최상위 컴포넌트 Folder.tsx
function Folder() {
//...
return (
<ModalProvider>
<FolderHeader />
<S.ItemsContainer>
<SearchBar />
<FolderContent />
</S.ItemsContainer>
<Footer />
</ModalProvider>
);
}
// 사용하는 컴포넌트 Kebab.tsx
export function KebabMenu({ selectURL, data }: Props) {
// context의 setState 함수를 가져온다.
const { handleModalState } = useContext(ModalContext);
// isOpenModal 및 모달 생성에 필요한 데이터를 모달 버튼의 id에 따라서 다르게 전달한다.
const handleShowModal = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
switch (e.currentTarget.id) {
case "deleteLink":
handleModalState({
isOpenModal: true,
selectURL: selectURL,
modalType: "deleteLink",
});
break;
case "addToFolder":
handleModalState({
isOpenModal: true,
selectURL: selectURL,
data: data,
modalType: "addToFolder",
});
}
};
return (
<>
<RefactorModal />
<S.CardContentKebabMenu>
<S.CardContentKebabMenuDelete
type="button"
onClick={handleShowModal}
id="deleteLink"
>
삭제하기
</S.CardContentKebabMenuDelete>
<S.CardContentKebabMenuDelete
type="button"
onClick={handleShowModal}
id="addToFolder"
>
폴더에 추가
</S.CardContentKebabMenuDelete>
</S.CardContentKebabMenu>
</>
);
}
위와 같은 방식으로 기존의 문제점이었던 모달 개수에 따라 늘어나는 state및 handler함수가 개선이 되었다.
마찬가지로 모달 개수에 따라 늘어나는 조건부 렌더링의 태그들도 RefactorModal 컴포넌트 하나로 줄어들어 코드가 훨씬 간결해졌다.
모달 컴포넌트 리팩토링 끝~!
이제 잘 열리고 잘 닫힌다 (진짜 성취감 미뗬고)
하지만 문제를 해결하고 나니, 또다른 고민이 생겨났다.
위의 코드로 이야기를 하자면, kebabButton은 프롭으로 selectURL과 data를 받아오고 있는데,
이는 단순히 모달에 데이터를 전달해주기 위함이다.
그렇다면 어처피 전역적으로 data와 selectURL을 사용하고 있으니, prop으로 깊게 내려주지 않고 전역으로 업데이트 해주면 되지 않을까?
이를 고민하는 이유는, 그렇게 되면 이 모달에서 어떤 데이터를 사용하고 있는지를 사용하는 곳에서는 알 수 가 없기 때문에 직관적이지 않을 것 같다는 생각이 들었기 때문이다.
(현재도 data라는 명칭으로 어떤 데이터인지 명확하지 않긴 하다만)
이 부분에 대해서는 다른 사람들과 이야기 나눠보고 결정하고 싶다.
그리고 이번에는 욕심이지만, 한 가지가 더 있다. (포스팅을 하며 든 생각)
위의 코드에 있는 handleShowModal 함수 내부의 switch case 문을 분리해서 사용하면 좋을 것 같다는 생각이다.
그렇게 된다면 사용하는 모달이 늘었을 때마다 각 페이지에서 case를 추가하지 않고도 한 함수만 수정한 후, 호출하면 되기 때문에 사용하기에 더욱 편리해지지 않을까 싶다.
모달 사용을 위한 함수, 충분히 분리해볼만 하다고 생각한다.
이번 리팩토링을 통해 얻은 것
ContextAPI의 사용법을 직접 익힐 수 있었고,
어떻게 하면 더욱 간결하고 직관적으로 코드를 짤 수 있을지 고민해보는 것만으로도 큰 도움이 되었던 것 같다.
부가적으로 Typescript의 새로운 유틸리티 타입도 알게 되었고,
setState를 변경할 수 있는 함수 자체를 전달할 수 있다는 것도 알게 되었다. (나는 떠올리기 쉽지 않았다..!)
새로운 것을 써보는 것에 약간의 막연함, 두려움이 있었지만,
이렇게 직접 부딪혀보고 완전히 잘못 파악해서 뒤엎어보기도 하면서 이해해나가는 과정이 너무너무 재밌는 것 같다.
내가 생각했던 것보다 모달 리팩토링이 오래 걸렸지만, 아직 리팩토링 할 부분이 넘쳐나기 때문에 (맛난 거 먹고)
꾸준하게 고민하고 개선해봐야겠다.
몇 달 뒤에 다시 읽어봤습니다.
11월이 되서 다시 이 게시글을 읽어봤다.
솔직히 개선이 아닌 것 같다. 열심히 고민하고 로직을 변경 했지만, 사용할 때 사용성이 더 나빠졌다는 점이 너무 치명적이다 악악!!!! 과거의 나 왜 이렇게 짰니..!?
지금 이 로직을 개선한다면 지금 같은 방법보다는 모달의 열리고 닫히는 상태를 전역으로 관리할 것 같다. 그렇게 로직을 짜면 컴포넌트 간에 모달 상태를 쉽게 공유할 수 있게 되고, 모달 상태를 특정 컴포넌트나 계층 구조 안에서 고정하지 않아도 되서 여러모로 개선이 될 것 같다.
그래도 이때 Context API를 사용해봐서 이후에 정말 필요한 곳에서 훨씬 쉽게 적용할 수 있었기 때문에 불필요했다고 말할 수는 없을 것 같다.
그래 ! 뭘 몰라도 일단 도전해보는 거야....후회는 미래의 내가 하겠지...
그때 더 나은 로직을 생각해낼 수 있다면 성장한 거 아니겠니.... 화이팅 해보자
'💡뚝딱뚝딱 만들어보자 ~! :) > Linkbrary' 카테고리의 다른 글
[Refactoring] 모듈화 및 추상화를 통한 API함수 개선해보기 (0) | 2024.05.21 |
---|---|
react-hook-form으로 로그인, 회원가입 기능 구현하기 (1) | 2024.04.06 |
커스텀 훅 도전기 (feat.Intersection Observer) (0) | 2024.04.01 |
40% 부족한 검색 기능 완성 시키기 (0) | 2024.03.24 |