React: 싱글톤은 생각만큼 악하지 않다
Source: Dev.to
위에 제공된 내용만으로는 번역할 텍스트가 없습니다. 번역을 원하는 본문을 알려주시면 한국어로 번역해 드리겠습니다.
싱글톤 회의론
역사적으로 React에서 싱글톤의 데이터를 가져오려면 다른 이유로 앱이 다시 렌더링될 때까지 기다려야 했습니다. 수동 동기화 버튼이나 폴링을 사용해 간극을 메우는 경우를 보셨을 수도 있지만, 그 방법은 거의 보기 흉했습니다.
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some';
export function ReactElement() {
const [singletonData, setSingletonData] = useState(SomeSingleton.data || null);
/**
* Sync singleton and state
*/
const handleRefresh = useCallback(() => {
setSingletonData(SomeSingleton.data || null);
}, []);
// Trigger refresh every 5 seconds
useEffect(() => {
const interval = window.setInterval(handleRefresh, 5000);
return () => window.clearInterval(interval);
}, [handleRefresh]);
return (
<>
{singletonData || 'N/A'}
<button onClick={handleRefresh}>Manual Refresh</button>
</>
);
}
제가 “싱글톤은 구현하기 쉽다”고 말했을 때 여러분이 떠올리는 것이 바로 이 모습이라면, 혼란스러울 수 있다는 점을 이해합니다. 이것은 싱글톤을 구현하는 좋은 방법이 아니며, 과거에도 그랬습니다. 이제 훨씬 더 깔끔한 접근 방식을 보여드리겠습니다.
import { useState, useEffect } from 'react';
import SomeSingleton from '@/singletons/some';
export function ReactElement() {
const [singletonData, setSingletonData] = useState(SomeSingleton.data || null);
// Update the state as soon as things change in the singleton
useEffect(() => {
const ac = new AbortController();
SomeSingleton.addEventListener(
'change',
({ detail }) => {
setSingletonData(detail);
},
{ signal: ac.signal }
);
return () => ac.abort();
}, []);
return (
<>
{singletonData || 'N/A'}
</>
);
}
이 버전은 이미 훨씬 깔끔하고 가독성이 좋으며, 뒤처질 위험도 적습니다. 다만 이벤트를 발생시키기 위해 싱글톤 쪽에 약간의 작업이 필요합니다 – 곧 그 부분을 살펴보겠습니다.
이제 이 방법은 완전히 사용 가능하지만, 네이티브 React 상태처럼 느껴지도록 몇 가지 추가 팁과 트릭을 적용할 수 있습니다.
클래스의 단순함
클래스는 JavaScript에 기본으로 제공되므로 추가 패키지 때문에 무게가 늘어나는 일은 없습니다. 클래스를 정의하고 초기화하면 바로 사용할 수 있습니다.
믿기 어렵겠지만, 여러분이 매일 사용하고 있는 언어의 많은 부분이 Error, Array, Map, 혹은 HTMLElement와 같이 확장 가능한 클래스입니다. 이렇게 미리 만들어진 클래스를 많이 활용할 수 있기 때문에, 우리가 사용하고 싶은 동작을 발견했을 때 이를 다시 구현하거나 라이브러리를 만들 필요가 없습니다 – 그냥 네이티브 클래스를 확장하면 브라우저 안에 이미 존재하는 기능을 바로 사용할 수 있습니다.
이것이 핵심입니다: 클래스를 확장하여 네이티브 동작을 활용할 수 있기 때문에 강력하고, 엔진에 내장돼 있어 가볍고, 문서는 웹 사양 자체이므로 구현이 쉽습니다.
타입이 지정된 Target 이벤트
앞서 나는 우리 싱글톤이 이벤트를 발생시킬 수 있기를 원한다고 언급했습니다. 이를 위해 EventTarget을 확장하면 정확히 원하는 동작을 구현할 수 있습니다. 하지만 TypeScript를 사용하고 있다면(그리고 사용하고 있기를 바랍니다) 기본 구현이 다소 느슨하게 느껴질 수 있습니다. 나는 이전에 EventTarget을 좀 더 타입‑안전하게 만들어 메시징이 견고하도록 하는 방법에 대해 글을 쓴 적이 있습니다.
해결할 문제
우리가 반드시 해결해야 할 필요는 없지만, 우리가 할 수 있는 일을 보여주기 위해 문제를 하나 생각해 봅시다. 제가 선택한 문제는 토스트 매니저입니다. sonner와 react‑toastify가 이미 존재하고 훌륭하기 때문에 처음부터 직접 만들 필요는 없지만, 실제 데모가 있으면 훨씬 원활하게 진행될 것입니다.
알림 시스템을 구축하는 것은 우리 싱글톤 아키텍처를 테스트하기에 완벽한 사례입니다. 애플리케이션의 어느 부분에서든 접근 가능해야 하고, 자체 타이머를 관리해야 하며, 특정 컴포넌트 트리와 결합되지 않은 상태로 UI 업데이트를 트리거할 수 있어야 합니다.
Source: …
싱글톤
앞서 논의한 바와 같이, 우리는 네이티브 EventTarget을 기반으로 만든 TypedEventTarget 클래스를 확장할 것입니다. 우리는 다음이 필요합니다:
- 토스트 목록,
- 토스트를 추가하는 메서드,
- 토스트를 조기에 제거하는 메서드,
- 충분한 시간이 지나면 토스트를 제거하는 타이머,
- 토스트 목록이 변경될 때마다 발생하는 이벤트.
타입 (TypeScript)
export interface Toast {
id: string;
message: string;
type: "info" | "success" | "loading" | "error";
action?: {
label: string;
callback: () => void;
};
}
type ToastEvents = {
changed: void;
};
이제 타입을 정의했으니 토스트 객체가 어떻게 생겼는지, 어떤 이벤트가 발생하는지 알 수 있습니다. 다음으로 클래스를 설정해 보겠습니다.
클래스 스켈레톤
class ToastManager extends TypedEventTarget {
private _toasts: Toast[] = [];
private _timers = new Map<string, number>();
}
시작은 좋지만 _toasts가 private이라 클래스 외부에서 접근할 수 없습니다. 읽기 전용 getter를 제공하고, 나머지 메서드를 다음 섹션에서 구현하겠습니다.
Getter와 Setter로 구조 개선
get toasts() {
return this._toasts;
}
private set toasts(value: Toast[]) {
this._toasts = [...value];
this.dispatchEvent("changed");
}
이제 toasts 프로퍼티를 읽을 수 있고 내부적으로는 업데이트할 수 있지만, 외부에서는 여전히 제어할 수 없습니다. 몇 가지 메서드를 추가해야 합니다.
// add or update a toast item
add = (
toast: Omit<Toast, "id"> & { id?: string },
duration = 3000
) => {
const id = toast.id ?? Math.random().toString(36).substring(2, 9);
this.clearTimer(id);
const newToast = { ...toast, id };
const exists = this.toasts.some((t) => t.id === id);
if (exists) {
this.toasts = this.toasts.map((t) =>
t.id === id ? newToast : t
);
} else {
this.toasts = [...this.toasts, newToast];
}
if (duration > 0) {
const timer = window.setTimeout(() => this.remove(id), duration);
this._timers.set(id, timer);
}
return id;
};
// remove a toast and its timer
remove = (id: string) => {
this.clearTimer(id);
const index = this.toasts.findIndex(
({ id: _id }) => _id === id
);
if (index >= 0) {
this.toasts = this.toasts.filter(
({ id: _id }) => _id !== id
);
}
};
// remove a timer
private clearTimer(id: string) {
if (this._timers.has(id)) {
clearTimeout(this._timers.get(id));
this._timers.delete(id);
}
}
마지막으로 클래스를 인스턴스화하고 내보냅니다.
export const toastManager = new ToastManager();
여러분은 어떨지 모르겠지만, 이 정도 코드량은 많지 않은 것 같습니다. TypeScript를 사용함으로써 무거운 라이브러리를 도입하지 않고도 자동 완성과 타입 검사의 안전망을 얻을 수 있습니다.
The Connection
When I showed you how to connect to a singleton with a useEffect earlier, I mentioned that it did not quite feel like a natural part of React. This is where useSyncExternalStore comes in. It allows us to define a subscription to an external source and a function to retrieve a snapshot of that state, handling the synchronisation for us.
First, we need to create the functions to pass to the hook.
import { toastManager } from '@/singletons/toastManager';
// Add an event listener
const subscribe = (callback: () => void) => {
const ac = new AbortController();
toastManager.addEventListener('changed', callback, {
signal: ac.signal,
});
return () => ac.abort();
};
// Get the state
const getSnapshot = () => toastManager.toasts;
Now we can put it all together inside a component.
import { useSyncExternalStore } from 'react';
export default function ToastContainer() {
const toastList = useSyncExternalStore(subscribe, getSnapshot);
return (
<ul>
{toastList.map(({ id, message }) => (
<li key={id}>- {message}</li>
))}
</ul>
);
}
This is a somewhat simplistic implementation, but it demonstrates the core principle. We have full access to the data inside the singleton and it triggers a React render cycle whenever the internal state updates. By using useSyncExternalStore, we ensure that our UI is always in sync with our source of truth, without having to manually manage state variables or worry about stale closures.
데모
자, 여기 있습니다: 필요할 때마다 React에 연결되는 토스트 매니저 싱글톤으로, 애플리케이션 어디서든 토스트를 제어하고 모니터링할 수 있게 합니다. 기능이 완전한 제품까지는 만들지 않았고, 외관으로는 어떤 상도 받을 수 없겠지만, 데모를 즐겨 주세요.
마무리 말씀
제가 여러분을 설득했나요, 아니면 아직도 싱글톤에 반대하시나요? 어쩌면 이미 팬이셨을 수도 있겠네요. 댓글에서 토론을 계속하게 되어 기쁩니다. 작성 시점의 최신 TanStack Hotkeys도 실제로 비슷한 방식으로 동작한다는 사실에 놀라실 수도 있습니다. 즉, React 혹은 다른 어떤 라이브러리와 연결된 싱글톤 컨트롤러를 사용합니다.
읽어 주셔서 감사합니다! 연결하고 싶으시면 제 BlueSky와 LinkedIn 프로필을 확인해 주세요. 인사하러 오세요 😊