우당탕탕 Next.js 개발기 - ① 추상화 수준을 맞춘 클린한 Modal 개발하기 (2)

김동현·2024년 7월 17일
10

지난 포스팅을 꼭 보고 오셔야 이번 포스팅이 이해가 됩니다.
우당탕탕 Next.js 개발기 - ① 추상화 수준을 맞춘 Modal 개발하기 (1)를 꼭 보고 와주세요.
저와 비슷한 고민을 하는 프론트엔드 개발자 분들에게 조금이라도 도움이 되기를 소망합니다.

일반적인 Modal 구현 방식의 문제점 및 원인 요약

문제점은 다음과 같았습니다.

  1. 상위 컴포넌트에서 추상화 수준이 맞지 않게 코드를 작성해야 했습니다.
    • 이에 대해 toss 팀이 만든 overlay-kit 에서도 문제점이 잘 소개가 되어 있어서 인용해 봅니다.

      먼저 보일러플레이트 코드가 많고, React의 Hook 규칙때문에 코드의 흐름이 잘 보이지 않아요. (...중략) 코드의 응집도가 떨어지는 것이죠.

    • 토스 팀은 이를 "선언적"으로 코드를 작성하여 해결했고 이를 라이브러리로 배포했습니다. 제가 제시하는 해결책도 역시 선언적으로 코드를 작성합니다.
  2. 여러 개의 모달이 필요하거나 하위 모달이 필요한 경우
    • 파편화된 state와 props drilling 문제가 발생했습니다.
    • state 를 관리하지 않고, props 를 넘겨주지 않는 방안은 로 수정하면 될 것 같습니다.
  3. 리스트 컴포넌트에서 개별적인 모달이 필요한 경우
    • 사이드 이펙트를 통해서만 정상적으로 개별적인 모달을 열고 닫을 수 있었습니다.
    • 코드의 직관성과 유지보수성의 매우 큰 저하를 일으키는 문제점이었습니다.

모든 문제점에는 하나의 공통된 원인이 있습니다. 바로
상위 컴포넌트에서 열고 닫힘에 관한 상태관리를 책임지고 있다.
라는 것입니다. 이를 이제부터 어떻게 해결하는지를 살펴보겠습니다.

해결의 종착지

제가 구현 하고 싶었던 요구사항은 2가지 입니다.
1. 모달을 선언적으로 열고 닫고 싶다.
2. 여러번 모달을 사용할 수 있으니 모달 렌더링은 한곳에서만 이루어졌으면 좋겠다

1번 부터 한번 해결해볼까요? 선언적으로 한다는 것은 무엇을 의미할까요?
선언형 프로그래밍이란 원하는 결과를 묘사하는 방식으로 코드를 작성하는 프로그래밍 패러다임입니다. 자세한 내용은 선언형 프로그래밍으로 이해하기 쉬운 코드 작성하기 를 참고해주세요.

다시 우리의 주제로 돌아와서 우리는 "선언적으로" 아래와 같은 코드를 작성하고자 합니다.

const LandingPage = () =>{
	const {openModal, closeModal} = useModal('ExampleModal');
  
  {/* 중략 */}
  return (
  	<div>
    	{/* 중략 */}
    	<Button
    		label="열기 버튼"
    		onClick={()=>openModal()}
    	/>
   		<Button
			label="닫기 버튼"
    		onClick={()=>closeModal()}
    	/>
    </div>
  )
}

어떤것 같은가요? 확연히 이전 코드에 비해 추상화 수준이 올라갔고, 더이상 파편화된 state 관리를 하지 않아도 됩니다.

2번을 살펴보겠습니다. 모달 자체의 선언은 최상단 컴포넌트에서 한번만 선언하고 이를 여러번 재사용하고 싶은 것이 제 생각입니다. 위의 LandingPage 를 살펴보면 모달 선언은 되어있지 않습니다.

사실 해결책의 종착지는 react-native-modalfy 의 아이디어를 많이 차용했습니다.

해결책 ① - forwardRef 를 이용해봅시다.

이를 구현하기 위한 첫번째 단계는 modal 에 forwardRef 를 씌우는 것입니다.

forwardRef 는 상위 컴포넌트에서 자식 컴포넌트로 ref를 전달할 때 사용해야 하는 것이 바로 forwardRef 라고 할 수 있습니다. 우리는 자식 컴포넌트인 Modal 의 ref 에 접근해야 합니다. 그래야 선언적으로 open 과 close 를 할 수 있습니다. 코드로 살펴보겠습니다. useImperativeHandle 와 함께 커스터마이징도 같이 진행합니다.

const ExampleModal = forwardRef<ModalRef,Props>(({},ref) => {
	const [isOpen, setIsOpen] = useState(false);
    
   	useImperativeHandle(ref, () => ({
    	open: () => {
      		setIsDialogOpen(true);
			console.log("open!")
    	},
    	close: () => {
      		setIsDialogOpen(false);
    	},
    	isOpen: isDialogOpen,
  	}));
    
    
    return (
    	<div>
        	{/*중략*/}
        </div>
    )

    
});

이렇게 작성된 코드는 상위 LandingPage 에서 다음과 같이 작성할 수 있습니다.

const LandingPage = () =>{
	const ref = useRef<ModalRef>(null);
  
	{/* 중략 */}
  	return (
  		<div>
    		{/* 중략 */}
    		<Button
    			label="열기 버튼"
    			onClick={()=> ref.current?.open()}
    		/>
   			<Button
				label="닫기 버튼"
    			onClick={()=> ref.current?.close()}
    		/>
			<Modal
            	ref={ref}
            />
    	</div>
  )
}

이제 많은 문제가 해결됐습니다.
1. 우선 더이상 상위 컴포넌트에서 state 관리를 하지 않습니다. 따라서 불필요한 리렌더링이 발생하지 않게 됩니다.
2. 하위 컴포넌트로 props drilling 을 하지 않아도 됩니다. 원한다면 해당 컴포넌트에서 ref 를 선언하여 modal 을 컨트롤 하면 됩니다.
3. 선언적이다. open / close 을 하고 난 다음의 상황에 대해서는 일절 상관하지 않아도 됩니다. 가령 위의 코드에서 open 이후 console.log() 를 하게 되었는데, 이는 상위 컴포넌트인 LandingPage 에서 관리할 필요가 없는 사항입니다.
4. 선언적이기에 추상화 수준이 일치합니다.

하지만 아직은 많은 문제가 남았습니다.
1. 여전히 많은 모달을 선언해야 한다면, 그만큼 모달을 선언해야 합니다.
2. List 의 Item 별로 Modal 이 생성되야 합니다.

다음과 같이는 쓸 수 있지만, 모든 아이템마다 Modal 이 생성되어 성능 저하가 발생합니다.

{data.map(v=>{
	const ref = useRef();
    
    return (
    <>
    	<Item/>
    	<Modal ref={ref}/>
    </>
    )
})}

그러면 이런 문제를 어떻게 해결해야 할까요? 2번째 해결책으로 넘어가보도록 하겠습니다

중요한 것은 첫번째 해결책에 두번째 해결책을 같이 적용해야 한다는 것 입니다.

해결책 ② - Context 를 이용해봅시다.

두번째 해결책은 앞선 해결책 보다는 살짝 복잡한 내용을 다룹니다. Context 를 이용하여 전역적으로 Modal 을 선언하고, 필요할때마다 선언적으로 여는 방식을 채택하고 싶습니다. 이때, typescript 를 적극적으로 활용할 것이라는 것을 말해두겠습니다.

[1] 첫번째로 만들 파일은 modal.type.ts 입니다. modal이 무슨 종류가 있는지, 각각 props 로 무엇을 받아야 하는지를 정의합니다

// modal.type.ts
import {ExampleModalProps, DrawerProps, DialogProps} from './components';

export type ModalTypes = 
	| 'ExampleModal'
    | 'Drawer'
    | 'Dialog'

export type ModalPropsMap = {
	ExampleModal : 	ExampleModalProps,
    Drawer: DrawerProps,
    Dialog: DialogProps,
};

export type ModalProps<T extends ModalTypes> = T extends keyof ModalPropsMap
	? ModalPropsMap[T]
    : never;

[2] 3가지 모달이 무엇인지, props가 무엇인지를 선언했습니다. 두번째는 Context 를 만들 차례입니다. reducer 를 적극적으로 이용하여 open 과 close 라는 dispatch 를 선언적으로 관리해보고자 합니다.

// modal-context.provider.tsx
type ModalState ={
	isOpen : boolean;
  	modalType : string | null;
  	props : {},
}

type ModalAction =
	| { type: 'OPEN_MODAL'; modalType: string; props: any }
	| { type: 'CLOSE_MODAL' };

export const ModalContext = createContext<{
  state: ModalState;
  dispatch: Dispatch<ModalAction>;
}>({
  state: initialState,
  dispatch: () => undefined,
});

const modalReducer = (state: ModalState, action: ModalAction): ModalState => {
  switch (action.type) {
    case 'OPEN_MODAL':
      return {
        isOpen: true,
        modalType: action.modalType,
        props: action.props,
      };
    case 'CLOSE_MODAL':
      return initialState;
    default:
      return state;
  }
};

export const ModalProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(modalReducer, initialState);

  return (
    <ModalContext.Provider value={{ state, dispatch }}>
      {children}
      <ModalRenderer />
    </ModalContext.Provider>
  );
};

export const useModalContext = () => {
  return useContext(ModalContext);
};

코드가 조금 길어서 복잡해 보일지라도 간단합니다. ModalProvider
1. modalType : 어떤 모달이
2. props : 어떤 props 를 가지며
3. isOpen : 열려 있는가 닫혀 있는가
를 관리합니다. 그리고 reducer 는 단순히 선언적으로 이를 관리하기 위해 OPEN_MODALCLOSE_MODAL 을 dispatch 로 관리하는 것 뿐입니다. 마지막에는 Context 를 사용하기 편하게 custom Hook 으로 만들어주었습니다. 당연히 이 컴포넌트는 provider 로 앱 최상단에서 선언되야 합니다.

const App =() => {
  	// 중략
	return (
    	<>
      		{/* 중략 */}
      		<ModalProvider/>
      	</>
    )
}

[3] 이제 세번째 파일을 보겠습니다. 모달을 전역적으로 선언하여 렌더링하는 부분입니다. 당장 위의 파일에 <ModalRenderer/> 라고 되어 있는 부분입니다.

// modal-renderer.tsx

const MODAL_COMPONENTS: Record<ModalTypes, FC<any>> = {
  CBDCChargeDialog,
  TokenExchangeDialog,
  MenuDrawer,
};

export const ModalRenderer: FC = () => {
  const { state } = useModalContext();
  const modalRef = useRef<ModalRef>(null);

  const ModalComponent = MODAL_COMPONENTS[state.modalType as ModalTypes];

  useEffect(() => {
    if (!state.isOpen || !state.modalType) {
      modalRef.current?.close();
    } else {
      modalRef?.current?.open();
    }
  }, [state.isOpen, state.modalType, state.props]);

  if (!state.isOpen || !state.modalType) {
    return null;
  }

  return <ModalComponent ref={modalRef} {...state.props} />;
};

코드가 복잡해 보이니 동작방식을 차근차근 설명해보겠습니다.
1. ModalContext 로 부터 어떤 모달의 상태를 받아옵니다.
2. 현재 열려라라고 주문한 모달 중에 어떤 모달인지 MODAL_COMPONENTS 에서 mapping 을 통해 가져옵니다.
3. 해당 컴포넌트에 ref 를 연결하여 렌더링을 합니다.
4. 실제로 여는 작업은 useEffect 내에서 진행합니다. useEffect 는 브라우저가 렌더링을 끝마치고 작동합니다. 따라서 확실하게 ref 가 연결됐음이 보장됩니다 (아주 중요한 부분입니다)
5. state 가 열린 상태라면 open 을 해줍니다.

이를 sequence diagram 으로 표현하면 다음과 같습니다.

[4] 이제 마지막 파일입니다. 실질적으로 컴포넌트들이 사용할 Custom Hook 인 useModal 을 제작해보겠습니다.

// modal.hook.ts
import { ModalTypes, useModalContext, ModalProps } from '@/provider';

export const useModal = <T extends ModalTypes>(modalType: T) => {
  const { state, dispatch } = useModalContext();

  const openModal = useCallback(
    (props: ModalProps<T>) => {
      dispatch({ type: 'OPEN_MODAL', modalType, props });
    },
    [dispatch, modalType],
  );

  const closeModal = useCallback(() => {
    dispatch({ type: 'CLOSE_MODAL' });
  }, [dispatch]);

  const isOpen = state.isOpen && state.modalType === modalType;

  return {
    isOpen,
    openModal,
    closeModal,
  };
};

Context 로 부터 받아온 dispatch 를 이용하여 실질적으로 모달을 키고 닫을 수 있는 custom hook 을 제작했습니다. 또한 modalType 은 종전에 만들어두었던 ModalTypes 에 종속되고, 해당 Modal 이 받아야하는 props 를 받도록 제네릭 메개변수를 이용하여 작성해두었습니다.

해결책을 어떻게 사용해야할까요? 사용법에 대해 알아봅시다.

다 만들었으니 이제 사용해볼 차례입니다. 총 3가지 절차를 거치면 됩니다.

[1] state 관리와 ref 관리를 하는 modal 을 제작하기
[2] 해당 모달을 modal.type.ts 에 등록하기
[3] 모달을 사용할 곳에서 선언적으로 불러서 사용하기

각각의 절차를 예시를 들어 따라가보겠습니다. 이제 CTAModal 이라는 것을 추가적으로 만들고 싶습니다. props 로는 titlestring 으로 받는다고 가정을 해보겠습니다 1번 절차에 따라 다음과 같이 코드를 작성해야합니다.

// CTA-modal.tsx
type CTAModalProps ={
	title: string
}
const CTAModal = forwardRef<ModalRef,CTAModalProps>(({title},ref) => {
	const [isOpen, setIsOpen] = useState(false);
    
   	useImperativeHandle(ref, () => ({
    	open: () => {
      		setIsDialogOpen(true);
			console.log("open!")
    	},
    	close: () => {
      		setIsDialogOpen(false);
    	},
    	isOpen: isDialogOpen,
  	}));
    
    
    return (
    	<div>
        	<Typography>
            	{title}
            </Typography>
        	{/*중략*/}
        </div>
    )
});

두번째 절차입니다. 해당 모달의 컴포넌트와 Props 를 modal.type.ts 에 등록해봅시다. 이렇게요

// modal.type.ts
export type ModalTypes = 
	| 'ExampleModal'
    | 'Drawer'
    | 'Dialog'
    | 'CTAModal'

export type ModalPropsMap = {
	ExampleModal : 	ExampleModalProps,
    Drawer: DrawerProps,
    Dialog: DialogProps,
    CTAModal: CTAModalProps,
};
// 생략

이제 마지막 절차입니다. 이번에는 각각의 List Item 에서 독자적으로 modal 을 호출하고 싶다고 합니다. 어떻게 해야할까요?

// landing.page.tsx
const LandingPage = () => {
	// 심지어 대부분의 경우 closeModal 은 필요가 없습니다.
    // 모달을 끄는 기능은 모달 안쪽 세부 구현사항이기 때문입니다.
	const {openModal} = useModal('CTAModal');

	return (
    	<div>
        	{data.map((v)=> {
            	return (
                	<Item
                    	key={v.id}
                    	onPressButton={()=>openModal({title: v.name})}
					/>
                )
            })}
        </div>
    )
}

처음에 해결하고자 했던 모든 목표가 완료됐습니다. 이제 더이상 List Component 에서 모달을 호출하라고 해도 두렵지 않네요!

마치면서

모달은 프론트엔드 개발자들에게 많은 골치덩어리로 여겨집니다. createPortal 을 이용한 해결방법도 분명히 좋으나, 저는 Custom Hook 의 패턴을 좋아하기에 위의 방식으로 구현을 해봤습니다. 더 나은 방식과 열린 토론을 지향합니다. 로직상 아쉬운 부분이나 개선할 부분이 있다면 가감없이 댓글을 달아주면 좋겠습니다.

References

profile
Frontend Developer

0개의 댓글