Observer Pattern을 이용한 Modal 구현하기

Derek Frontend History·2024년 12월 23일
0
post-thumbnail

react.js로 프론트엔드 개발을 하다 보면 필수적으로 개발을 해야할게 생깁니다.
바로 Modal UI 이죠.

항상 이 시점에서 모달을 ContextAPI로 개발을 해야하나 redux나 zustand와 같은 전연관리 툴을 써야하나 고민을 하게 됩니다.

어떻게 개발을 해야 좀 더 효율적이게 개발을 할 수 있을까요?

ContextAPI를 활용한 Modal UI 개발 장단점

  • 장점
    - 개발을 하려다 보면 외부 dependency에 대한 의존성을 끊을 수 있다.
    - 다른 외부 라이브러리를 설치하지 않아도 된다.
    - 다르 프로젝트에서도 해당 Provider를 재활용하기 용이하다.
  • 단점
    - 각기 다른 Provider와 의존성이 복잡하게 얽히게 될 수 있다.
    - 하위(자식) 컴포넌트들의 불필요한 리랜더링이 많아질 수 있다.
    - 커스텀 훅으로만 사용을 해야하기 때문에 컴포넌트 내 로직에서만 컨트롤이 가능하다.

전역 관리 툴(redux, zustand)을 활용한 Modal UI 개발 장단점

  • 장점
    - 다른 Provider의 영향을 받지 않아 Provider간 의존성을 끊어낼 수 있다.
    - 컴포넌트간 불필요한 리랜더를 줄일 수 있다.
  • 단점
    - 전역 관리 툴을 설치해야 하기 때문에 dependency에 대한 의존성이 걸린다.
    - 다른 프로젝트에서 해당 시스템을 사용하기 위해 같은 전역 관리 툴을 설치해야한다.
    - 커스텀 훅으로만 사용을 해야하기 때문에 컴포넌트 내 로직에서만 컨트롤이 가능하다.

ContextAPI와 전역관리 툴의 공통된 단점

위 두개의 방법에 공통된 단점은 아래와 같습니다.

  • dependency간 다른 Provider간 서로 의존성이 걸리게 된다.
  • 커스텀 훅으로만 사용을 해야하기 때문에 컴포넌트 내 로직에서만 컨트롤이 가능하다.

그렇다면 위와 같은 단점들을 보완하고 좀 더 유연하고 재사용에 용이한 Modal 시스템을 구현하려면 어떻게 할 수 있을까요?

Observer Pattern을 활용한 Modal 시스템 구현

의존성을 끊고 커스텀 훅에서만이 아닌 여러 서비스 및 비지니스 로직에서 Modal을 열고 닫으며 독립적으로 관리 할 수 있게끔 구현하려면 사실 Class를 이용한 Modal 모듈을 만들면 가능합니다.
하지만 Class로 구현한다면 react.js가 추구하는 불변성을 지키며 모달 정보가 달라지면 그에 맞게 UI를 리랜더링 해줘야 하는 문제가 생깁니다.

react.js는 useState를 활용한 상태 관리를 통해 불변성을 유지하고 리랜더링을 진행합니다.
저희는 Modal Class가 갖고 있는 modal 정보가 업데이트 될때마다 observe function을 호출하여 모달 UI를 리랜더링을 하는 방식의 Observing Pattern을 사용하여 Modal 시스템을 구현하고자 합니다.

1. Modal Class 구현하기

./modal/Modal.js

import { uniqueId } from 'lodash-es';

class Modal {
  /** 
  * 모달로 노출될 모달 정보를 담은 배열
  * 여러개의 모달을 연달아서 띄울 수도 있기 때문에 배열로 구현
  */
  private _modalList = [];
  /** 
  * _modalList가 업데이트 될때마다 실행할 observe 함수
  */
  public observeFunction = null;

  set modalList(value) {
    const newModalList = [...value];

    this._modalList = newModalList;

    // 업데이트할 새로운 모달 배열을 observe 함수에 전달인자로 전달하여 함수 호출
    this.observeFunction && this.observeFunction(newModalList);
  }

  get modalList() {
    return this._modalList;
  }

  /** 
  * 모달을 닫을때 호출하는 함수
  */
  closeModal(id) {
    // 닫고자 하는 모달이 현재 노출 되고 있는 모달 리스트에 존재하는지 확인
    const findModal = this.modalList.find((m) => m.id === id);

    if (!findModal) return;

    // 모달 리스트 업데이트
    this._modalList = this.modalList.filter((m) => m.id !== id);
  }

  /** 
  * 비동기 결과값을 반환하고 closeModal 함수를 호출하는 함수
  */
  resolveModal({ modalId, result }) {
    modal.resolve(result);
    this.closeModal(modal.id);
  }


  resetModal() {
    this.modalList = [];
  }

  openModal({ component, props = {}, options = {} }) {
    return new Promise((resolve, reject) => {
      // 유니크한 모달 id를 생성
      const modal = {
        id: options.id ?? uniqueId(), // options에 id로 modalId 직접 지정 가능
        component // 모달로 랜더링할 Component 함수
        props, // 모달로 랜더링할 Component의 props
        resolve, // 비동기로 모달의 결과값을 받을 수 있게 하는 resolve 함수
        reject
      };

      this.modalList = [...this.modalList, modal];
    });
  }
  
  observe(observeFunction) {
    this.observeFunction = observeFunction;
  }

  unobserve() {
    this.observeFunction = null;
  }
}

const modal = new Modal();

export default modal;

2. modal을 노출한 컴포넌트 구현하기

./modal/components/ModalContainer.jsx

import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';

import modal from '../Modal';

const ModalContainer = () => {
  const prevPathname = useRef('');

  const { pathname } = useLocation();

  const [modals, setModals] = useState(modal.modalList);

    /**
   * 1. Modal 클래스로 생성된 modal 객체의 modalList가 변경될때 호출된 옵저버 함수를 등록
   */
  useEffect(() => {
    modal.observe(setModals);

    return () => {
      modal.unobserve();
    };
  }, []);
  
  // pathname이 바뀔때 즉, 페이지가 변경될때 모든 모달을 reset
  useEffect(() => {
    if (prevPathname.current && prevPathname.current !== pathname) {
      modal.resetModal();
    }

    prevPathname.current = pathname;
  }, [pathname]);

  return (
    <>
      {modals.map((m) => createElement(m.component, { 
        key: m.id, 
        onCancel: () => modal.deleteModal(m.id),
        onOk: (result) => modal.resolve({ modalId: m.id, result }),
        ...m.props
      }))}
    </>
  );
};

export default ModalContainer;

3. 노출할 모달 컴포넌트 구현하기

./components/ModalTemplate.jsx

import React from 'react';
import styled from 'styled-components';

const ModalTemplateStyle = styled.div`
  position: fixed; top: 0; left:0; bottom: 0; right: 0; z-index: 1; display: flex; justify-content: center; align-items: center;
  .dim{ position: absolute; top: 0; left:0; bottom: 0; right: 0; z-index: 1; background-color: rgba(0,0,0,0.5); }
  .modal-cont{ position: relative; z-index: 2;  width: 500px; height: 500px;  padding: 20px; border-radius: 8px; background-color: white;  }
`;

const ModalTemplate = ({ className, children, onClose }) => {
  return (
    <ModalTemplateStyle>
      <div tabIndex={-1} role="button" aria-label="modal-dim" className="dim" onClick={onClose} />
      <div className="modal-cont">
        {children}
      </div>
    </ModalTemplateStyle>
  );
};

export default ModalTemplate;

./components/TestModal.jsx


const TestModal = ({ className, text, onCancel, onOK }) => {
	return (
    	<ModalTemplate className={className}>
        	TestModal {text}
            <div className="btn-cont">
              <button onClick={onCancel}>취소</button>
              <button onClick={() => onOK && onOK('확인을 눌렀네요!')}>확인</button>
            </div>
        </ModalTemplate>
    )
}

export default TestModal;

4. app.jsx에 ModalContainer 적용

App.jsx

import modal from './modal/Modal';
import ModalContainer from './modal/components/ModalContainer';
import TestModal from './components/TestModal';

const App = () => {
  	const openTestModal = async () => {
    	const result = await modal.openModal({
        	component: TestModal,
          	props: {
            	text: '모달 컨텐츠 입니다.'
            },
          	options: {
            	id: 'TestModal' // 직접 지정하지 않으면 랜덤하게 자동 생성 됩니다.
            }
        });
      
      	console.log(result); // '확인을 눌렀네요!'
    }
    
    // TestModal 컴포넌트를 강제로 모달에서 닫게 하도록 하는 함수
    const forceCloseTestModal = () => {
    	modal.closeModal('TestModal');
    }
  
	return (
    	<div>
        	<ModalContainer />
        	/** 모달을 테스트 하기 위한 button */
        	<button onClick={openTestModal}>Open Modal</button>
        	<button onClick={forceCloseTestModal}>Close Modal</button>
      	</div>
    )
}

개발 결과물

마치며..

지금까지 Observer Pattern을 이용하여 Modal 시스템을 간단하게 구현해보았습니다.
사실 실제로 제가 구현한 Modal 시스템은 위 내용에 모달 애니메이션, modalId 직접 지정, 동적 임포트한 컴포넌트 Suspense로 처리 등 여러 옵션을 넣어서 Modal 시스템을 확장하여 개발하였습니다.

또한 제네릭 타입을 이용하여 openModal 함수를 호출할때 component의 따른 props 타입 동적 지정 등 타입스크립트를 활용하여 개발자가 좀 더 직관적이고 유용하게 사용할 수 있도록 개발하였습니다.

그 모든 시스템을 녹인 소스 코드를 공유하기 보다는 Observer Pattern을 이용한 Modal 시스템 구현에 관련된 핵심적인 소스만을 간추려서 글을 작성하였습니다.

해당 아키텍처를 참고하여 여러분이 현재 개발하고 있는 프로젝트에 좀 더 잘 맞는 효율적이고 멋진 모달 시스템을 구현하는데에 조금이나마 도움이 되기를 바라겠습니다.

위 글에 대해 궁금점이 있거나 피드백을 주실게 있으시다면 언제든지 아래의 메일 주소로 문의 부탁드리겠습니다~!

contact me: dfd11233@gmail.com

profile
8년차 프론트엔드 개발자 Derek 이라고 합니다! 반갑습니다~

0개의 댓글