<Observer pattern with React 전역 Toast, Modal(2부 모달)>

강민수·2024년 3월 31일
2
post-thumbnail

참고 사항
해당 글은 지난 시리즈(<Observer pattern with React 전역 Toast, Modal(1부 토스트)>)를 바탕으로 기술되오니, 안 보신 분들께서는 참조 후, 읽어 주시기 바랍니다.

1. intro

지난 시간에, 1부에서는 observer pattern을 활용해 리랜더링을 방지하면서도 전역 상태관리를 공유하는 전역 토스트 메시지를 만들었다.

그러면서 문득 이런 의문이 하나 들었다.

"modal도 가능하지 않을까?"

그래서 이번에는 기존에 지역적으로 스테이트 방식으로 구현했던 모달 방식을 observer 패턴을 활용해서 전역 모달로 변경해 보겠다.

기존 방식과 어떤 차이가 있고, 또 어떤 성능적인 측면이 존재할 지 살펴봐 주시면 좋겠다.

2. 기존 모달 구현법

1) 기존 모달 구현 코드

interface ModalProps {
    isOpenQuizModal: boolean;
    isOpenLoginModal: boolean;
}

const Index = () => {
    const [modalProps, setModalProps] = useState<ModalProps>({
        isOpenLoginModal: false,
        isOpenQuizModal: false,
    });

    const handleQuizModal = () => {
        setModalProps((prev) => ({ ...prev, isOpenQuizModal: !prev.isOpenQuizModal }));
    };
    const handleLoginModal = () => {
        setModalProps((prev) => ({ ...prev, isOpenLoginModal: !prev.isOpenLoginModal }));
    };

    return (
        <div className="w-screen">
            <HomeHeader onClickLoginButton={handleLoginModal} />
            <HomeBody />
            <HomeFooter />
            <HomeFooterButton onClick={handleQuizModal} />
            {modalProps.isOpenQuizModal && (
                <Modal onClose={handleQuizModal}>
                    <HomeQuizSelectScreen testId="quiz-modal" onClickButton={handleQuizModal} />
                </Modal>
            )}
            {modalProps.isOpenLoginModal && (
                <Modal onClose={handleLoginModal}>
                    <HomeLoginModalScreen />
                </Modal>
            )}
        </div>
    );
};

누구나 알 듯이, 그냥 스테이트에 담아서 상태 관리에 따라 그려주는 기본적인 코드다.

물론 이렇게 구현해도 구현 자체는 문제가 없다.

다만, 문제는 아래와 같다.

2) 문제 1. 재 랜더링 이슈.

역시나, 이렇게 되면, 아무리 최적화를 한다고 해도, 재 랜더링이 발생한다.

위의 녹화 내용처럼 실상은 전체 부모에서 선언된 스테이트는 변화가 있을 때마다, 아래 자식 컴포넌트의 재 랜더링을 유발한다.

3) 문제 3. 코드 중복

    const [modalProps, setModalProps] = useState<ModalProps>({
        isOpenLoginModal: false,
        isOpenQuizModal: false,
    });

    const handleQuizModal = () => {
        setModalProps((prev) => ({ ...prev, isOpenQuizModal: !prev.isOpenQuizModal }));
    };
    const handleLoginModal = () => {
        setModalProps((prev) => ({ ...prev, isOpenLoginModal: !prev.isOpenLoginModal }));
    };

    {modalProps.isOpenLoginModal && (
                <Modal onClose={handleLoginModal}>
                    <HomeLoginModalScreen />
                </Modal>
            )}

위의 코드처럼 지역 스테이트로 사용하게 되면, 항상 사용처에서 스테이트를 만들어 줘야 하며,

그에 따라 조건부 랜더링을 거는 것 역시 마찬가지로 구현해 줘야 한다.

즉, 불가피 한 코드 중복이 발생되는 것 이다.

3. Observer 드가자.

그래서, 이런 문제를 해결할 수 있으면서, 전역 모달로 만들기 위해서 observer-pattern을 모달로 구현했다.

사실 앞선 시리즈에서 구현한 토스트와 구조적으로 다른 점은 많이는 없다.

1) ModalService.

모달 역시 옵저버 패턴에 맞게 모달 서비스를 하나 만든다.

import { ReactNode } from 'react';

export interface ModalState {
    isOpen: boolean;
    contents: Array<{ content: ReactNode; backGroundColor?: string }>;
}

export class ModalService {
    static instance: ModalService;

    private currentState: ModalState = { isOpen: false, contents: [] };

    subscribers: ((state: ModalState) => void)[] = [];

    static getInstance(): ModalService {
        if (!ModalService.instance) {
            ModalService.instance = new ModalService();
        }
        return ModalService.instance;
    }

    subscribe(callback: (state: ModalState) => void) {
        this.subscribers.push(callback);
    }

    unsubscribe(callback: (state: ModalState) => void) {
        this.subscribers = this.subscribers.filter((sub) => sub !== callback);
    }

    openModal(modalContent: ReactNode, backGroundColor?: string) {
        const newContents = [
            ...this.currentState.contents,
            { content: modalContent, backGroundColor },
        ];
        const newState = { isOpen: true, contents: newContents };
        this.currentState = newState;
        this.subscribers.forEach((callback) => callback(newState));
    }

    closeModal() {
        const newContents = [...this.currentState.contents];
        newContents.pop();
        const newState = { isOpen: newContents.length > 0, contents: newContents };
        this.currentState = newState;
        this.subscribers.forEach((callback) => callback(newState));
    }

    // 현재 모달의 열림 상태 확인
    isModalOpen(): boolean {
        return this.currentState.isOpen;
    }
}

기존의 ToastService 구조와 상당히 유사하다.
물론, observer pattern이기 때문에... 대동소이하다.

약간 다른 점 위주로 살펴보면,
ModalState는 열고 닫는 상태를 가지고 있다.

추가로, contents는 모달이 children으로 받는 콘텐츠를 의미하는 리액트 노드와 모달 내부 배경 색을 설정해 주는 프롭스로 구성해 봤다.

한 가지 더 주의해서 볼 지점은 contents는 array로 구성한 점이다.

이유는 이따 코드 상으로 설명을 다시 하겠지만, 만약 모달 위의 모달이 뜨는 경우를 고려해야 하기 때문이다.

이를 토대로, openModal과 closeModal을 구현해 놓았다.

openModal은 기존 모달 스테이트 배열을 복사하고, 새로운 모달 스테이트 값을 이어붙인다.
이후, subscribers의 modalstate도 forEach로 돌려서 최신화 된 값으로 업데이트 시키는 구조다.

closeModal은 반대로 현재 값 중에서 가장 먼저 올라온 최신의 모달을 pop으로 제거한다.

현재 열려 있는 것이 있는 지 점검하고 있다면 살려두고, 없다면 isOpen이 false가 되는 식으로 newState를 만든다.

역시, subscribers의 modalstate도 forEach로 돌려서 최신화 된 값으로 업데이트 시키는 구조다.

그리고, 필요에 의해서 사용자가 현재 instance의 currentState를 확인할 수 있도록,

 // 현재 모달의 열림 상태 확인
    isModalOpen(): boolean {
        return this.currentState.isOpen;
    }

코드도 추가해 놓았다.

2) GlobalModal

다음으로 실질적으로 전역에서 사용할 모달인 globalModal 컴포넌트다.

'use client';

import React, { ReactNode, useEffect, useState } from 'react';
import Modal from './Modal';
import { ModalService, ModalState } from './ModalService';

const GlobalModal: React.FC = () => {
    const [isOpen, setIsOpen] = useState(false);
    const [contents, setContents] = useState<
        Array<{ content: ReactNode; backGroundColor?: string }>
    >([]);

    useEffect(() => {
        const modalService = ModalService.getInstance();
        const handleModalChange = ({
            isOpen: isModalOpen,
            contents: modalContents,
        }: ModalState) => {
            setIsOpen(isModalOpen);
            setContents(modalContents);
        };
        modalService.subscribe(handleModalChange);
        return () => {
            modalService.unsubscribe(handleModalChange);
        };
    }, []);

    if (!isOpen) {
        return null;
    }

    return (
        <>
            {contents.map(({ content, backGroundColor }) => (
                <Modal
                    key={crypto.randomUUID()}
                    onClose={() => ModalService.getInstance().closeModal()}
                    backGroundColor={backGroundColor}
                >
                    {content}
                </Modal>
            ))}
        </>
    );
};

export default React.memo(GlobalModal);

기본적으로 모달의 열고 닫는 것은 리액트 랜더링을 위해, 스테이트로 관리하지만, 해당 스테이트는 모달이 마운트 되는 시점에 바로 modalService에 등록시켜서 작동하도록 만든 구조다.

그에 따라, modal의 콘텐츠와 열림의 여부가 결정되고 렌더링 되는 구조라고 생각하면 되겠다.

4. Observer 사용해 보자.

1) app/layout.tsx

토스트처럼, 그냥 layout 최상단에 GlobalModal을 딱 임포트 해두면 사용할 준비는 끝났다.

import { FC } from 'react';
import GlobalModal from '@src/components/common/modal/GlobalModal';
import Toaster from '@src/components/common/toast/Toast';
import './globals.css';

interface LocaleLayoutProps {
    children: React.ReactNode;
}

const LocaleLayout: FC<LocaleLayoutProps> = ({ children }) => (
    <html lang="ko">
        <body>
            {children}
            <Toaster />
            <GlobalModal />
        </body>
    </html>
);

export default LocaleLayout;

2) 전체 페이지에서 사용 시.

const HomePage = () => {
    // TODO 추후 토스트 사용 시 추가.
    // const toastService = ToastService.getInstance();
    const modalService = ModalService.getInstance();

    const handleQuizModal = useCallback(() => {
        // TODO 추후 토스트 사용 시 추가.
        // toastService.addToast('토스트!');
        modalService.openModal(
            <HomeQuizSelectScreen
                testId="quiz-modal"
                onClickButton={() => {
                    modalService.closeModal();
                }}
            />,
        );
    }, [modalService]);
    const handleLoginModal = () => {
        modalService.openModal(<HomeLoginModalScreen />);
    };

    return (
        <div className="w-screen">
            <HomeHeader onClickLoginButton={handleLoginModal} />
            <HomeBody />
            <HomeFooter />
            <HomeFooterButton onClick={handleQuizModal} />
        </div>
    );
};

위의 코드에서 보는 것처럼 굳이 불 필요한 조건부 랜더링 코드는 없다.

선언적으로 openModal 시에 사용할 react node 컴포넌트를 넘겨주기만 하면된다.

또한, props로 넘겨줄 부분이 있다면 기존처럼 그냥 넘겨주면 끝이다.

닫을 때는 modalService.closeModal을 호출하면 끝이다.

3) 특정 컴포넌트 내에서 중첩 모달 사용시.

interface HomeBasicLoginSectionProps {}

const HomeBasicLoginSection: FC<HomeBasicLoginSectionProps> = () => {
    const modalService = ModalService.getInstance();

    const onCloseModal = () => {
        modalService.closeModal();
    };
    const onClickPasswordFind = () => {
        modalService.openModal(
            <Modal onClose={onCloseModal}>
                <HomePasswordFindScreen />
            </Modal>,
        );
    };

    const onClickSignup = () => {
        modalService.openModal(
            <Modal onClose={onCloseModal}>
                <HomeSignupScreen />
            </Modal>,
        );
    };

    return (
        <form className="container mt-4">
                <Button variant="primary-unselect" onClick={onClickSignup}>
                    이메일 회원가입
                </Button>
                <Button variant="primary">로그인</Button>
                <span
                    className="flex w-full cursor-pointer justify-end text-sm font-medium text-gray-500"
                    onClick={onClickPasswordFind}
                >
                    비밀번호를 잊으셨나요?
                </span>
            </div>
        </form>
    );
};

export default HomeBasicLoginSection;

위의 예시는 특정 컴포넌트에서 중첩된 모달을 사용할 때 구조다.

위처럼, 선언적으로 각 모달 스크린을 선언해 주는 식으로 작성하면 된다.

추가로, closeModal을 할 때는 그냥 공통되게 하나의 modalservice.closeModal 처리하면 끝이다.

더는 어떤 게 닫히고, 어떤 게 닫히는 지 등의 불 필요한 스테이트를 만들 필요가 없다!!!

딱 모달이 필요한 열고 닫히는 것만 사용처에서 선언적으로 알려주고, 그 외에 나머지 역할은 service에 위임하는 구조다.

5 비교해 보기.

이제 본격적으로 기존 지역적인 모달 코드와 비교해 볼 텐데, 바로 위에서 언급한 특정 컴포넌트 내에서 중첩 모달 사용시를 기준으로 비교해 본다.

1) 코드 비교해 보기.

기존 코드
 const [modalState, setModalState] = useState<OpenModalState>({
        isOpenPasswordModal: false,
        isOpenSignupModal: false,
    });

    const onClickPasswordFind = () => {
        setModalState((prev) => ({ ...prev, isOpenPasswordModal: !prev.isOpenPasswordModal }));
    };

    const onCloseModal = () => {
        setModalState(() => ({
            isOpenSignupModal: false,
            isOpenPasswordModal: false,
        }));
    };

    const onClickSignup = () => {
        setModalState((prev) => ({ ...prev, isOpenSignupModal: !prev.isOpenSignupModal }));
    };

    {modalState.isOpenSignupModal && (
                <Modal onClose={onCloseModal}>
                    <HomeSignupScreen />
                </Modal>
            )}
     {modalState.isOpenPasswordModal && (
                <Modal onClose={onCloseModal}>
                    <HomePasswordFindScreen />
                </Modal>
            )}

기존 코드는 상태 관리로 인해서, 하나의 스테이트로 합쳐도 이정도로 더덕 붙어서 가독성이나 유지 보수 관점에서 별로다.

const modalService = ModalService.getInstance();

const onCloseModal = () => {
        modalService.closeModal();
    };

const onClickPasswordFind = () => {
        modalService.openModal(
            <Modal onClose={onCloseModal}>
                <HomePasswordFindScreen />
            </Modal>,
        );
    };

const onClickSignup = () => {
        modalService.openModal(
            <Modal onClose={onCloseModal}>
                <HomeSignupScreen />
            </Modal>,
        );
 	};

 <div className="mt-6 flex flex-col gap-3">
                <Button variant="primary-unselect" onClick={onClickSignup}>
                    이메일 회원가입
                </Button>
                <Button variant="primary">로그인</Button>
                <span
                    className="flex w-full cursor-pointer justify-end text-sm font-medium text-gray-500"
                    onClick={onClickPasswordFind}
                >
                    비밀번호를 잊으셨나요?
                </span>
            </div>

코드량도 적어졌지만, 훨씬 한 눈에 파악하기 쉽다. 그리고 유지 보수 측면에서도 modalService만 보면 되기 때문에 좋다.

2) 렌더링 show time~

1. 기존 중첩 모달.

2. observer 전역 모달.

토스트 때랑 마찬가지로, 확연히 비교가 되지 않은가?

특히나, 이렇게 중첩으로 된 컴포넌트 구성일 때, 더욱 옵저버 패턴의 구조는 랜더링에서 확연한 차이가 난다.

확실히, observer pattern의 전역 모달 구조가 훨씬 합리적인 랜더링 구조라는 것을 누가 봐도 느낄 수 있다.

6 마무리 결론 및 소감.

어? 이거 이렇게 하면 더 좋지 않을까? 라는 시작으로 시작된 의문.

사실 이 작은 의문이 모든 발전의 시작이라고 생각한다.

이전 역사를 살펴봐도,

아르키메데스가 목욕을 하다가 우연히 밀도를 측정하는 법칙을 발견한 것처럼..

물론 그에 비단할 수는 없지만, 일전에 나 역시 단순히 코드를 굴러가게 만들고 거기서 끝내고 말았던 거 같다.

그런데, 지금은 이렇게 하나의 디자인 패턴을 보고, 그걸 바탕으로 내 코드에 적용해 볼 수 있는 안목.

그게 조금이나마 이번 경험을 토대로 생겨난 거 같다.

앞으로 그래서 이런 안목을 키우기 위해 디자인 패턴과 다양한 밑바탕의 근본을 채우고 그걸 또 적용하는 식으로 발전시키는 개발자가 되어야 되지 않을까... 생각해 본다.

profile
개발도 예능처럼 재미지게~

0개의 댓글