요즘, 개발하며 가장 많이 드는 생각 중 하나는
과연 이게 최선인가?
라는 물음이다.
회사에서도 마찬가지고, 사이드 프로젝트 등을 하면서도 느끼는 지점인 거 같다.
그런데 이런 내 의문과 궁금증이 결국은 요즘은 코드에 대한 고도화로 이뤄지고 있는 거 같다.
이번에 하게 될 얘기 역시, 이전과는 다른 시각에서 시작된 물음이 여기까지 이어진 결과다.
기존에도 react-hook-form을 사용하면서 과연 어떻게 훅폼은 react의 스테이트를 쓰지 않아도 사용자의 입력값을 감지하고 그걸로 어떻게 랜더링 시키는 구조인지 의문을 품어왔다.
다만, 그 의문이 글을 읽으면서 조금씩 하나, 둘 풀리기 시작했다.
아래 글은 필자가
위의 글의 원본 글 중 옵저버 패턴만 일부 발췌했다.
글에서도 설명하듯, 옵저버 패턴은 훅폼 뿐만 아니라, 다양한 유명 라이브러리에서도 이미 사용중인 디자인 패턴이다.
코드로 나타내면 대략 이러했다.
function createSubject() {
const listeners = [];
const subscribe = (listener) => {
// Add the listener
listeners.push(listener);
// Return an unsubscribe method
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const update = (value) => {
for (const listener of listeners) {
listener(value);
}
};
return {
subscribe,
update,
};
}
간략하게 살펴 보면, listeners라는 일종의 store 변수가 있다.
이 변수에 저장을 해두고, subscribe, update 등의 method를 통해서 업데이트 하는 구조라고 보면 간략하게 이해할 수 있을 것이다.
다만, 구조적으로는 간단한데, 그러면 왜 굳이 이런 패턴을 쓸까?
그게 사실 핵심 포인트!
결국 리액트 훅 폼에서, 핵심은 값이 변화되는 것을 감지하면서도 리액트에게 불 필요한 재 랜더링을 방지시키기 위해서다.
물론 여기에는 단순히 재랜더링외에도 성능적인 이슈 등의 다양한 요소들도 결합은 되어 있을 것이다. 다만, 그것은 이 글의 범위를 넘어서므로 여기서는 재랜더링을 최소화 해서 불 필요한 성능이나 리소스 낭비를 줄이겠다라고 정도만 이해하고 넘어가자.(참고로 훅폼에 관한 원글에서는 proxy나 useEvent등을 다룸)
보다 자세한 내용은 해당 글의 원본 링크(https://www.romaintrotard.com/posts/react-hook-form-unique-implem#proxies--defineproperty)에서 더 살펴볼 수 있다.
이렇게 기존에 궁금했던, hook-form의 구조를 조금이나마 돞아본 걸로 일단 넘어갔다.
그런데, 곧 이걸 응용할 계기가 생기고야 말았다.
앞서 옵저버 패턴을 살짝 일러둔 이유는 바로 이번 글의 전역 모달과 토스트를 구현하기 위한 핵심 요체라는 것은 아마 다들 느끼 셨을 것이다.
필자는 항상 개발을 할 때, react-dev-tools를 켜고 심지어는 재 랜더링을 위해서 아래 기능을 꼭 켠다.
물론 많이 아시는 기능이겠지만, 이 도구를 키면 대게 얼마나 화면에서 재랜더링이 걸리고 있는 지를 실시간으로 파악할 수 있고, 그에 따라 내가 얼마나 컴포넌트의 구조나 설계를 강결합으로 해 놓았는 지 파악할 수 있기 때문이다.
각설하고, 필자가 사이드 프로젝트를 하던 중, 갑자기 toast가 필요할 거 같다는 생각이 들었다.
평소대로, 만들고 그냥 바로 만들어서 테스트를 해 봤다. 그랬더니, 상당히 재 랜더링이 많이 걸리는 화면을 보고 말았다...
예전 초심 개발자 시절이었다면,
그냥 넘어갔겠지만, 지금은 그냥 도저히 넘어갈 수 없었다.
그리고, 이놈의 why 병이 하나 도졌다. 또...
그래서 곰곰이 생각도 해보고,
내가 생각하는 토스트의 구현 방식에 대해 다시 돞아봤다.
1. 간단한 경우라면,
context를 통해, 전역 ToastProvider와 useToast라는 훅을 만들어서 사용한다.
2. 재랜더링을 신경 쓴다면,
context 대신에, 여타의 전역 상태 관리 툴(recoil, redux 등)을 사용해서 useToast를 만든다.
물론 이게 안 좋다는 것이 아니다.
다만, 필자가 생각했을 땐, 과연 이게 최선인가?라는 물음이 계속 머리를 울렸다.
그래서 구글링도 해보고, 다른 개발자가 구현한 결과물도 계속 봤다. 하지만, 선뜻 맘에 드는 게 있지 않았다.
일단 위의 방법에서 필자가 마음에 안 드는 점을 나열해 보면,
-> 아무리 memo이제이션 등을 잘 한다고 해도 일일이 그걸 개발자가 다 처리하기에 여간 버거운 게 아니다. 그리고 전역으로 쓴다는 가정하에서는 더욱 useCase가 다양해 지는데, 과연 그때 마다 계속 신경쓰고 해야 하는게 맞나?(-> 물론 신경을 안 쓰라고 하는 것은 아니다!)
-> 물론 기존에 전역 상태를 써야 하는 케이스며, 잘 쓰고 있다면 그걸 써서 구현해도 상관은 없다.
하지만, 필자는 현재 그렇게 굳이 복잡한 서비스도 아니었고, 그에 따라 전역 상태를 쓰지 않았다.
토스트 전역 상태 관리를 위해 스토어 만들기 등등 셋팅하고 싶지 않았다. 이런 의존성을 최대한 줄이고 싶었다.
그때 였다!
혹시.... 혹시나
이 생각을 실행에 옮겼다.
개발자에게 생각을 실행에 옮기는 것은 결국 코드이기에...
코드를 짜 봤다.
먼저 토스트는 크게 2가지 부류로 나눴다. 토스트를 관리할 observer이제 subject 클래스, 그리고 그걸 사용하는 전역 토스트 메시지.
참고로 필자는 Nextjs 14에 App-directory 구조로 프로젝트를 구축했다.
아래는 해당 기준이니, 만약 잘 모르겠다면 nextjs 공식 문서 참조를 바란다.
전체적인 구조는 일전에 잠시 보았던 observer pattern과 다르지 않다.
type ToastObserver = (message: string) => void;
export class ToastService {
private static instance: ToastService;
private observers: ToastObserver[] = [];
private messages: string[] = []; // 메시지 목록 관리
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
public static getInstance(): ToastService {
if (!ToastService.instance) {
ToastService.instance = new ToastService();
}
return ToastService.instance;
}
subscribe(observer: ToastObserver) {
this.observers.push(observer);
}
unsubscribe(observer: ToastObserver) {
this.observers = this.observers.filter((obs) => obs !== observer);
}
notify() {
this.observers.forEach((observer) => observer(this.messages[this.messages.length - 1]));
}
addToast(message: string) {
if (this.messages.length >= 3) {
// 최대 메시지 수를 초과할 경우 가장 오래된 메시지 제거
this.messages.shift();
}
this.messages.push(message);
this.notify();
}
}
코드를 간략하게 설명해 보자면, 다음과 같이 요약해 볼 수 있다.
이 코드는 ToastService라는 싱글턴 패턴을 사용한 토스트 알림 서비스 클래스다. 싱글턴 패턴을 통해 이 클래스의 인스턴스는 어플리케이션 내에서 단 하나만 생성되어 사용되는 구조다.
클래스 내부에는 메시지를 관리하는 messages 배열과 옵저버(구독자)를 관리하는 observers 배열이 존재한다.
이 클래스는 토스트 메시지를 전역적으로 관리하고, 필요한 곳에서 메시지를 받아 처리할 수 있도록 하는 기능을 제공한다.
이런 식의 구조를 토대로, 굳이 context 없이도 심지어 재 랜더링이 없이도 토스트가 딱 해당 컴포넌트만 랜더링을 걸은 채로 구현할 수 있다.
왜냐하면, 이 구조에서는 기본적으로 생성을 단 한번만하는 싱글턴 패턴과 옵저버 패턴의 절묘한 조화가 이뤄졌기 때문이다.
다음으로 이렇게 생성한 인스턴스 클래스를 직접 사용해야 한 전역 토스트 컴포넌트를 만들 차례다.
'use client';
import React, { useEffect, useState } from 'react';
import ToastMessage from './ToastMessage';
import { ToastService } from './ToastService';
interface IndividualToastType {
id: number;
message: string;
}
const Toast = () => {
const [messages, setMessages] = useState<IndividualToastType[]>([]);
useEffect(() => {
const toastService = ToastService.getInstance();
const handleNewMessage = (message: string) => {
if (messages.length > 3) {
return;
}
setMessages((prev) => [...prev, { id: Date.now(), message }]);
// 메시지를 일정 시간 후에 제거
setTimeout(() => {
setMessages((prev) => prev.slice(1));
}, 2000);
};
toastService.subscribe(handleNewMessage);
return () => {
toastService.unsubscribe(handleNewMessage);
};
}, [messages.length]);
return (
<div className="fixed bottom-10 left-1/2 flex -translate-x-1/2 transform flex-col gap-3">
{messages.map((toast) => (
<ToastMessage message={toast.message} key={toast.id} />
))}
</div>
);
};
export default React.memo(Toast);
구조적으로 본다면, 어? 왜 여기서 messages를 state로 처리하지? 할 수도 있다.
필자가 state를 아예 안 쓴다고 한적은 없다. 일전에 본 react-hook-form도 내부적으로는 state를 쓰고 있다.
다만, 그걸 관리하는 구조가 옵저버였던 것이다.
필자 역시 마찬가지다. 내부적으로는 messages라는 메세지 배열을 state로 관리해서 해당 컴포넌트안에서 랜더링은 이뤄지게 뒀다.
다만, 핵심은 useEffect 안의 구조다.
잘 살펴보면, 첫 마운트시에 toastService라는 인스턴스를 생성하고, 이후에 토스트 서비스에 구독형태로 토스트 처리 콜백 함수를 넘겨준다.
이를 토대로, 나머지 처리는 toastService안에서 알아서 처리하게 될 것이다.
그리고 추가로, unmount 시에는 구독 자체를 해제해 줌으로서 불 필요한 리소스를 줄여줬다.
next13부터 나온 app-directory에서는 _app.tsx가 사라졌다.
그에 따라, 만약 전역적으로 작용하려면, 이렇게 사실상 최상단의 layout.tsx에 설정해 줘야 한다.
다만, 보이는 것처럼 해당 컴포넌트는 서버 컴포넌트다.
따라서, 만약 클라이언트 단에서 작용해야 하는 컴포넌트는 use-client를 선언해 줘야 한다.
물론, 필자는 현재 context나 별다른, 전역 라이브러리를 사용하지 않기에...
그냥 Toast를 적어주면 된다.
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}
<Toast />
</body>
</html>
);
export default LocaleLayout;
이후에는 해당 사용처에서 자유롭게 전역적으로 사용하면 된다.
const HomePage = () => {
const toastService = ToastService.getInstance();
const onClick= () => {
toastService.addToast("토스트 입니다.");
};
return (
<div className="w-screen">
<button onClick={onClick} />
</div>
);
};
이렇게만 사용하면 끝이다. 간단해서 사실 훅을 만들까 싶다가도 안 만들었다. ㅎㅎ
자 이제 만들었으니, 테스트를 해봐야지!
비교는 재 랜더링 기준으로 녹화한 영상을 첨부한다.
먼저, 그 전에 파일 구조부터 전역 설정에 대한 비교부터.
테스트로 만든 Context-api 토스트는 이렇게 총 3개의 관리 포인트가 존재한다.
provider, hooks, toastContainer.
물론 여기서 토스트를 분리하면 더 많아질 수 있다.
아래는 옵저버 패턴의 구조다.
크게는 toast컴포넌트와 ToastService로 2개다.
필자가 재랜더링 관점에서 분리시킨 거 뿐이지.
확실히 차이가 나는 부분은 아무래도 전역 설정 부분이다.
기존 토스트 구조는 저렇게 wrapping 된 구조가 아니면 사용하지 못하는 반면, 옵저버 패턴의 toast는 그런 제약이 없다.
보이시는가? 이렇게 꽤나 많은 랜더링 차이를 보인다. context로 하면, 예상했듯이, 아무리 memo 처리를 해도 전체 굳이 불 필요한 부모까지 전부 재랜더링이 걸리게 된다.
그런데 observer 패턴의 토스트는 그렇지 않다. 왜냐하면, 구조적으로 싱글톤을 함께 적용한 옵저버는 해당 객체 인스턴스를 저 사용처인 컴포넌트 안에서 딱 한번만 만들고 더는 만들지 않는다. 그 이후에 랜더링은 내부 컴포넌트의 스테이트에 위임하고 처리하기 때문에, 재 랜더링은 해당 컴포넌트에서만 발생하는 것이다.
일단, 1부로 토스트는 이 정도로 마치겠다. 뒤이어 2부는 모달을 준비했다.
모달도 구조는 거의 비슷한데, 얼마나 또 차이가 있는 지 볼 수 있으니, 뒤이어 곧 작성해 보겠다.
이번에 글을 작성하면서도 다시 느낀 것이지만,
요즘은 정말 이런 생각이 많이 든다. 내가 정말 최선의 구조로 짜고 있는 게 맞는가?
결국 그 의구심이 어떤 포인트의 개념과 연결되고, 또 직접 해보고 결과를 비교해 보는 식으로 이어지고 있다.
이게 좋은 과정이라고 현재는 생각이 들고, 나도 그래도 조금씩은 발전해 가는 개발자라는 생각이 든다.
앞으로는 더욱 이런 디자인 패턴에 대해서 더 알아가고 내가 만드는 서비스에 조금씩 코드로 실현해 볼 예정이다.
긴 글 읽어주셔서 감사하다.