usePortal (Modal, Alert, Dialog, Toast 컴포넌트를 위한 커스텀 훅) 설계 과정
1. 문제 인식
기존에는 모달, 토스트, 알림, 다이얼로그와 같은 다양한 UI 컴포넌트들이 각각의 포탈을 통해 개별적으로 관리되고 있었다. 이러한 방식은 특별히 막 불편하지는 않았지만, 더 효율적인 방법을 찾을 수 있을 것 같았다. 일단, 네개의 컴포넌트 모두 각각을 위한 Provider을 통해 화면의 최상단에 렌더링되는 컴포넌트 들이었기에, 해당 포털을 하나로 통일하고 하나의 훅에서 관리하면 좋겠다는 생각이 들었다.
특히, 다양한 포탈 유형을 각각 별도로 관리함으로써 발생한 사소하지만 구체적인 불편함들은 다음과 같다:
// 기존 예시 코드
const { modal } = useModal();
const { toast } = useToast();
const openNotifyAction = async () => {
await createRequest({ id });
toast({
description: "신청 완료되었습니다.",
size: "fit",
});
// ... 생략
};
const confirmModal = () => {
modal(
notifyModal({
action,
serviceAlerts
}),
)
}
1.1. 코드 중복
각 포탈 유형마다 별도의 관리 로직과 상태 관리가 필요했기 때문에, 각각의 컴포넌트를 위한 포털(화면의 최상단에 컴포넌트를 그리기 위한 프로바이더)코드가 중복되고있었다.
1.2. 관리 복잡성 증가
다양한 포탈 유형을 개별적으로 관리하다 보니, 애플리케이션의 상태 관리가 복잡해졌다. 특히, 포탈의 열림/닫힘 상태를 추적하고 이를 각 컴포넌트에 반영하는 과정에서 혼란이 발생했다. 각 포탈마다 별도의 상태 관리 로직이 존재했기 때문에, 전체 포탈 상태를 한눈에 파악하기 어려웠다. 이는 버그 발생 가능성을 높였으며, 포탈 간의 상태 충돌이나 예기치 않은 동작을 초래할 수 있었다.
1.3. 확장성 부족
새로운 포탈 유형을 추가하거나 기존 포탈을 수정할 때, 각 포탈마다 별도의 로직을 추가해야 했기 때문에 유지보수에 드는 비용이 높아진다고 생각했다.
1.4. 성능 저하
여러 개의 포탈이 동시에 열릴 경우, 각각의 포탈 컴포넌트가 개별적으로 렌더링되고 상태를 관리해야 하기 때문에, 유의미한 정도는 아니지만, 성능에 부정적인 영향을 미칠 수도 있다고 생각했다.
1.5. 코드 유지보수의 어려움
포탈 관리 로직이 분산되어 있어, 코드의 유지보수가 어려웠다. 새로운 기능을 추가하거나 기존 기능을 수정할 때, 관련된 모든 포탈을 일일이 확인하고 수정해야 하는 번거로움이 있다.
2. 해결 방향
해결을 위해 중앙 집중식 포탈 관리 시스템을 도입하여, 모든 포탈을 일관되게 관리하고 코드의 중복을 줄이며, 유지보수와 확장성을 향상시키고자 했다. 포탈 관리의 효율성을 높이고, 개발 프로세스의 효율성을 높이는 것을 목표로 했다.
2.1. 전역 상태 관리 개념으로 설계
여러 포탈 컴포넌트를 하나의 공통된 포탈로 관리하려면, 전역 상태 관리 개념을 도입하는 것이 필수적이다. 전역 상태 관리는 애플리케이션 전반에서 포탈의 상태와 동작을 중앙에서 일관되게 관리할 수 있게 해준다. 이를 통해, 개별 컴포넌트들이 포탈의 상태를 직접 관리하지 않고도 포탈을 열거나 닫을 수 있으며, 이는 코드의 일관성을 유지하고 중복을 제거하는 데 크게 기여한다. 특히, 여러 위치에서 포탈을 트리거해야 하는 경우, 전역 상태 관리 없이 이를 구현하려면 복잡한 prop 전달이나 이벤트 핸들링이 필요하게 되는데, 이는 유지보수성을 저하시킬 수 있다. 따라서, 포탈 관리 로직을 중앙화하여, 애플리케이션 내 모든 포탈이 일관된 방식으로 동작하도록 보장할 수 있으며, 향후 포탈 유형의 추가나 변경이 용이하게 한다.
2.2. 유지보수 및 확장의 용이성 개선
포탈의 상태 관리와 트리거 로직을 중앙화함으로써, 포탈의 추가, 수정, 삭제가 용이하게 해야한다. 새로운 포탈 유형을 추가하거나 기존 포탈을 수정할 때, 기존 구조를 크게 변경하지 않고, 별도의 관리 로직을 추가할 필요 없이 중앙 시스템에서 간단히 처리할 수 있도록 해야한다.
2.3. 타입 안전성 개선
TypeScript의 강력한 타입 시스템을 활용하여 포탈 유형별로 엄격한 타입 관리를 도입이 필요하다. 각 포탈 유형에 맞는 인터페이스를 정의하고, 이를 통해 런타임 오류를 사전에 방지하기 위함이다. 타입 안전성을 강화함으로써, 포탈 트리거 시 올바른 props가 전달되도록 보장해야, 코드의 안정성과 예측 가능성을 높일 수 있기 때문이다.
3. 해결 방법
중앙 집중식 포탈 매니저와 usePortal 훅 도입
포탈 관리의 일관성과 효율성을 높이기 위해, 모든 포탈을 하나의 중앙 매니저(PortalManager)를 통해 관리하고, 포탈 관리를 더욱 간편하고 일관되게 하기 위해, usePortal이라는 하나의 커스텀 훅을 통해 모든 포탈 유형을 관리하는 방안을 고려했다.
PortalManager 클래스:
- 중앙 집중식 관리: 포탈의 상태를 중앙에서 관리하여, 모든 포탈의 열림/닫힘 상태를 일관되게 추적하고 관리할 수 있다.
- 리스너 패턴: 포탈 상태 변화 시, 등록된 리스너들에게 이를 통보하여, 포탈의 상태를 실시간으로 업데이트할 수 있도록 한다.
- 유연한 포탈 관리: 다양한 포탈 유형(모달, 토스트, 알림, 다이얼로그)을 지원하며, 새로운 포탈 유형을 쉽게 추가할 수 있는 구조를 제공한다.
usePortal 커스텀 훅:
- 추상화된 인터페이스: 컴포넌트에서 포탈을 쉽게 트리거하고 관리할 수 있도록 추상화된 인터페이스를 제공한다.
- 포탈 트리거 및 상태 조회: 포탈을 열고 닫는 기능뿐만 아니라, 특정 포탈의 상태를 조회할 수 있는 메서드를 제공하여, 컴포넌트에서 포탈의 상태를 쉽게 관리할 수 있게 한다.
- 타입 안전성: TypeScript를 활용하여 포탈 유형별로 엄격한 타입 관리를 도입함으로써, 런타임 오류를 사전에 방지하고 코드의 안정성을 높인다.
이 방법으로 구현했을 때의 장단점을 판단해 보았을 때,
장점:
- 코드 중복 감소: 모든 포탈을 하나의 매니저에서 관리함으로써, 중복 코드를 줄일 수 있다.
- 일관된 상태 관리: 모든 포탈의 상태가 중앙 매니저에서 관리되므로, 상태의 일관성을 유지할 수 있다.
- 확장성 향상: 새로운 포탈 유형을 추가할 때, 중앙 매니저의 로직만 수정하면 되어 확장성이 높인다.
- 간편한 사용성: usePortal 훅을 통해 중앙 매니저의 장점을 살리면서도, 사용이 간편해진다.
단점:
- 초기 구현 복잡성: 모든 포탈을 하나의 시스템에서 관리하려면, 초기 설계가 복잡할 수 있다.
- 매니저의 역할 복잡성 증가: 포탈 유형이 많아질수록 매니저의 역할이 복잡해질 수 있다.
4. 구현 과정
일단 핵심 목표는 Modal, Alert, Dialog, Toast와 같은 UI 컴포넌트를 효율적으로 관리하고 재사용할 수 있는 커스텀 훅(usePortal)을 만드는 것이다. 이러한 포탈 컴포넌트들은 사용자와의 상호작용에서 중요한 역할을 하며, 일관된 사용자 경험을 제공하기 위해 중앙 집중식으로 관리될 필요가 있다. 이를 통해 코드의 중복을 줄이고, 유지보수성을 높이며, 새로운 포탈 유형을 쉽게 추가할 수 있도록 하는 것이 목표다.
1. 초기 접근 방식
시작하면서, 먼저 어떤 방식으로 포탈 컴포넌트를 관리할지에 대한 전반적인 설계가 필요했다. 단순히 usePortal 훅을 구현하는 것에서 출발했지만, 다양한 포탈 유형을 지원하고, 상태 관리를 효율적으로 처리하기 위해서는 더 복잡한 구조가 필요하다는 것을 깨달았다.
1. 포탈의 상태는 어떻게 관리할까?
- 포탈의 열림/닫힘 상태를 효율적으로 관리하고, 여러 포탈이 동시에 열릴 수 있도록 해야 한다.
2. 포탈의 유형별로 어떻게 분리할까?
- Modal, Alert, Dialog, Toast 등 다양한 포탈 유형을 지원하면서도, 공통된 로직을 재사용할 수 있는 구조가 필요하다.
3. 포탈의 라이프사이클을 어떻게 관리할까?
- 포탈이 생성되고 소멸되는 과정에서 발생할 수 있는 메모리 누수나 불필요한 렌더링을 방지해야한다.
4. 파일 구조는 어떻게 구성할까?
- 프로젝트의 확장성과 유지보수성을 고려하여 모듈을 어떻게 나눌지 결정해야한다.
이러한 질문들을 바탕으로, 포탈 관리 시스템을 설계하기 위한 기본 틀을 잡기 시작했다.
2. 요구사항 분석 및 파일 구조 설계
커스텀 훅 usePortal을 구현하기 위해서는 다음과 같은 기능들이 필요했다:
포탈 생성 및 관리:
- Modal, Alert, Dialog, Toast 등의 다양한 포탈을 생성하고 관리할 수 있어야 함.
상태 관리:
- 각 포탈의 열림/닫힘 상태를 추적하고 관리할 수 있어야 함.
재사용성:
- 포탈의 공통된 로직을 재사용하여 코드의 중복을 최소화해야 힘.
확장성:
- 새로운 포탈 유형이 추가되더라도 기존 구조를 크게 변경하지 않고 쉽게 확장할 수 있어야 함.
타입 안전성:
- 타입스크립트로 각 포탈의 속성을 명확하게 정의하고, 컴파일 타임에 오류를 방지해야함.
이러한 요구사항들을 바탕으로 다음과 같은 파일 구조를 설계했다
use-portal.tsx:
usePortal 커스텀 훅을 구현하여, 포탈을 쉽게 생성하고 관리할 수 있는 인터페이스를 제공.
portalManager.ts:
포탈의 상태와 이벤트를 중앙에서 관리하는 매니저 클래스를 구현.
portal.ts:
포탈 관련 타입과 인터페이스를 정의하여 타입 안전성을 보장.
PortalContainer.tsx:
실제 DOM에 포탈 컴포넌트를 렌더링하는 컨테이너를 구현.
3. 포탈 타입 정의 (portal.ts)
설계 과정
- 포탈 유형 : 프로젝트에서 지원할 포탈 유형을 식별한다. 일단 기존에 구현되어있는 Modal, Toast, Alert, Dialog 네 가지 유형을 가지고 설계한다.
- 공통 속성 정의: 모든 포탈이 공통으로 가지는 속성(예: id, type)을 정의한다.
- 포탈별 고유 속성 정의: 각 포탈 유형에 특화된 속성을 정의한다. 예를 들어, Modal은 title, description, onConfirm 등의 속성을 가질 수 있다.
- 디스크리미네이티드 유니언 타입 적용: 포탈의 유형에 따라 다른 속성을 가지도록 하기 위해, 디스크리미네이티드 유니언 타입을 적용한다.
구현
// portal.ts
import {
ToastActionElement,
ToastProps,
ToastViewportProps,
} from "@/components/ui/toast";
// 포탈 유형 정의
export type PortalType = "modal" | "toast" | "alert" | "dialog";
// 각 포탈 유형에 맞는 Props 인터페이스 정의
export interface ModalPortalOptions {
id: string;
type: "modal";
props: ModalProps;
}
export interface ToastPortalOptions {
id: string;
type: "toast";
props: ToasterProps;
}
export interface AlertPortalOptions {
id: string;
type: "alert";
props: AlertProps;
}
export interface DialogPortalOptions {
id: string;
type: "dialog";
props: DialogProps;
}
// 포탈 옵션의 디스크리미네이티드 유니언 타입
export type PortalOptions =
| ModalPortalOptions
| ToastPortalOptions
| AlertPortalOptions
| DialogPortalOptions;
// 각 포탈 컴포넌트의 Props 타입 정의
export interface ModalProps {
id?: string;
title: string;
description?: string | React.ReactNode;
subDescription?: string | React.ReactNode;
size?: "sm" | "md" | "lg" | "fit";
confirmText?: string;
onConfirm?: () => void;
cancelText?: string;
onClose: () => void; // 포탈 제거를 위한 콜백
contents?: React.ReactNode;
className?: string;
}
export type ToasterProps = ToastProps &
ToastViewportProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
closeButton?: boolean;
onClose?: () => void;
};
export interface AlertProps {
id?: string;
title: string;
description?: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
size?: "sm" | "md" | "lg" | "fit";
onClose?: () => void; // 포탈 제거를 위한 콜백
}
export interface DialogProps {
id?: string;
title: string;
content: string | React.ReactNode;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void; // 포탈 제거를 위한 콜백
}
고려 사항
- 타입 안전성: 각 포탈 유형별로 정확한 속성을 정의함으로써, 컴파일 타임에 오류를 방지하고, 코드의 안정성을 높인다.
- 유연성: 포탈의 속성을 유연하게 정의하여, 다양한 상황에 맞게 쉽게 확장할 수 있도록 한다.
- 재사용성: 공통된 속성을 PortalOptions 타입으로 묶어, 포탈 매니저와 커스텀 훅에서 재사용할 수 있도록 한다.
4. 포탈 매니저 구현 (portalManager.ts)
포탈의 상태를 중앙에서 관리하고, 포탈의 생성, 제거, 상태 변경을 효율적으로 처리하는 매니저 클래스를 구현하는 것이다. 이를 통해 포탈 관련 로직을 일원화하고, 재사용성과 유지보수성을 고려해서 설계해야한다.
설계 과정
- 리스너 패턴 적용: 포탈의 상태 변경을 구독하고 있는 컴포넌트들에게 알리기 위해 리스너 패턴을 적용한다. 이를 통해 매니저와 컴포넌트 간의 결합도를 낮출 수 있다.
- 포탈 상태 관리: 각 포탈의 상태(열림/닫힘)와 속성을 Map을 사용하여 효율적으로 관리한다.
- 작업 큐 관리: 포탈의 상태 변경 작업이 비동기적으로 처리될 때, 작업의 순서를 보장하기 위해 큐를 관리한다.
- 중복 포탈 방지: 동일한 ID를 가진 포탈이 여러 번 열리는 것을 방지하여, 예기치 않은 동작이나 UI 오류를 방지한다.
- 메서드 정의: 포탈을 표시(showPortal), 상태 설정(setPortalOpen), 상태 조회(getIsPortalOpen), 열려 있는 포탈 목록 조회(getOpenPortals), 리스너 구독(subscribe) 등의 메서드를 정의한다.
구현
// portalManager.ts
import {
PortalType,
PortalOptions,
ModalProps,
ToasterProps,
AlertProps,
DialogProps,
} from "@/types/ui/portal";
// 리스너 타입 정의
type Listener = (options: PortalOptions, isOpen: boolean) => void;
// PortalManager 클래스
class PortalManager {
private listeners: Listener[] = [];
private portals: Map<string, { isOpen: boolean; options: PortalOptions }> =
new Map();
private queue: (() => void)[] = [];
private isProcessing: boolean = false;
// 큐를 처리하는 메서드
private processQueue() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const task = this.queue.shift();
task && task();
this.isProcessing = false;
this.processQueue();
}
// 포탈이 열려 있는지 확인하는 메서드
private isPortalOpen(id: string): boolean | undefined {
return this.portals.has(id) && this.portals.get(id)?.isOpen;
}
// 리스너를 구독하는 메서드
subscribe(listener: Listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
// 포탈을 표시하는 메서드
showPortal(type: PortalType, props: any): string | null {
const id = props.id || this.generateId(type);
// 중복 포탈 방지
if (this.isPortalOpen(id)) {
console.warn(`Portal with id "${id}" is already open.`);
return id;
}
let portal: PortalOptions | null = null;
switch (type) {
case "modal":
portal = { id, type, props: props as ModalProps };
break;
case "toast":
portal = { id, type, props: props as ToasterProps };
break;
case "alert":
portal = { id, type, props: props as AlertProps };
break;
case "dialog":
portal = { id, type, props: props as DialogProps };
break;
default:
break;
}
if (portal) {
this.portals.set(id, { isOpen: true, options: portal });
this.queue.push(() => {
this.listeners.forEach((listener) => listener(portal!, true));
});
this.processQueue();
}
return id;
}
// 포탈의 열림 상태를 설정하는 메서드
setPortalOpen(id: string, isOpen: boolean) {
if (this.portals.has(id)) {
const portalEntry = this.portals.get(id)!;
portalEntry.isOpen = isOpen;
this.portals.set(id, { ...portalEntry });
this.queue.push(() => {
this.listeners.forEach((listener) =>
listener(portalEntry.options, isOpen),
);
});
this.processQueue();
}
}
// 포탈의 열림 상태를 조회하는 메서드
getIsPortalOpen(id: string): boolean | undefined {
return this.portals.get(id)?.isOpen;
}
// 현재 열려 있는 포탈들의 ID를 반환하는 메서드
getOpenPortals(): string[] {
return Array.from(this.portals.entries())
.filter(([_, value]) => value.isOpen)
.map(([key, _]) => key);
}
// 포탈 ID를 생성하는 메서드
private generateId(type: PortalType): string {
return `${type}_${Date.now()}`;
}
}
const portalManager = new PortalManager();
export default portalManager;
export type { PortalType, PortalOptions };
고려 사항
- 리스너 패턴: 포탈의 상태가 변경될 때마다 등록된 모든 리스너에게 알림을 보내기 위해 리스너 패턴을 적용했다. 이를 통해 포탈의 상태 변경을 구독하고 있는 컴포넌트들이 실시간으로 업데이트를 받을 수 있다.
- 큐 관리: 포탈의 상태 변경 작업이 연속적으로 발생할 경우, 큐를 사용하여 작업의 순서를 보장하고, 동시에 여러 작업이 처리되지 않도록 했다. 이는 포탈의 일관성을 유지하는 데 중요한 역할을 한다.
- 중복 포탈 방지: 동일한 ID를 가진 포탈이 여러 번 열리는 것을 방지하여, 예기치 않은 동작이나 UI 오류를 방지한다.
- 유연성: 다양한 포탈 유형을 지원하기 위해 switch 문을 사용하여 포탈 유형에 따라 다른 속성을 설정할 수 있도록 했다.
- 타입 안전성: 포탈의 유형에 따라 다른 속성을 가지도록 PortalOptions 타입을 사용하여 타입 안전성을 보장했다.
5. 커스텀 훅 구현 (use-portal.tsx)
usePortal 커스텀 훅을 통해 컴포넌트에서 포탈을 손쉽게 생성하고 관리할 수 있도록 하는 것이다. 이 훅은 포탈 매니저와 상호작용하여 포탈의 상태를 관리하고, 필요한 메서드를 제공해야한다.
설계 과정
- 상태 관리: 현재 열려 있는 포탈들의 ID를 상태로 관리하여, 컴포넌트가 이를 활용할 수 있도록 한다.
- 포탈 매니저와의 연동: 포탈 매니저의 리스너에 구독하여 포탈의 상태 변화에 실시간으로 반응한다.
- 포탈 생성 메서드 제공: Modal, Toast, Alert, Dialog 등 각 포탈 유형에 따라 포탈을 생성할 수 있는 메서드를 제공한다.
- 포탈 상태 조회 메서드 제공: 특정 포탈의 열림 상태를 조회할 수 있는 메서드를 제공한다.
- 클린업: 컴포넌트가 언마운트될 때, 포탈 매니저의 리스너를 적절히 해제하여 메모리 누수를 방지한다.
구현
// use-portal.tsx
import { useEffect, useState } from "react";
import {
ModalProps,
ToasterProps,
AlertProps,
DialogProps,
PortalOptions,
} from "@/types/ui/portal";
import portalManager from "../utils/portalManager";
const usePortal = () => {
const [openPortals, setOpenPortals] = useState<string[]>([]);
useEffect(() => {
// 초기 openPortals 설정
setOpenPortals(portalManager.getOpenPortals());
// 리스너 정의
const listener = (options: PortalOptions, isOpen: boolean) => {
if (isOpen) {
setOpenPortals((prev) => [...prev, options.id]);
} else {
setOpenPortals((prev) => prev.filter((id) => id !== options.id));
}
};
// PortalManager의 리스너 등록
const unsubscribe = portalManager.subscribe(listener);
return () => {
unsubscribe();
};
}, []);
// 포탈 유형별 트리거 함수
const modal = (props: ModalProps): string | null => {
return portalManager.showPortal("modal", props);
};
const toast = (props: ToasterProps): string | null => {
return portalManager.showPortal("toast", props);
};
const alert = (props: AlertProps): string | null => {
return portalManager.showPortal("alert", props);
};
const dialog = (props: DialogProps): string | null => {
return portalManager.showPortal("dialog", props);
};
// 포탈의 isOpen 상태 조회 함수
const getIsOpen = (id: string): boolean | undefined => {
return portalManager.getIsPortalOpen(id);
};
return { modal, toast, alert, dialog, getIsOpen, openPortals };
};
export default usePortal;
고려 사항
- 초기 상태 설정: 컴포넌트가 마운트될 때, 현재 열려 있는 포탈들의 ID를 상태로 설정하여 초기 UI 상태를 동기화.
- 실시간 상태 업데이트: 포탈 매니저의 리스너를 통해 포탈의 열림/닫힘 상태가 변경될 때마다 상태를 업데이트하여 UI가 실시간으로 반영되도록 한다.
- 포탈 생성 메서드 추상화: 각 포탈 유형별로 별도의 메서드를 제공하여, 컴포넌트에서 포탈을 쉽게 생성할 수 있도록 한다. 이는 코드의 가독성을 높이고, 사용성을 향상시킨다.
- 포탈 상태 조회: 특정 포탈의 상태를 조회할 수 있는 메서드를 제공하여, 컴포넌트에서 포탈의 상태를 기반으로 추가적인 로직을 구현할 수 있도록 한다.
- 클린업: 컴포넌트가 언마운트될 때, 포탈 매니저의 리스너를 해제하여 메모리 누수나 불필요한 상태 업데이트를 방지한다.
6. 포탈 컨테이너 구현 (PortalContainer.tsx)
포탈 컴포넌트를 실제 DOM에 렌더링하기 위한 컨테이너를 구현하는 것이다. 이 컨테이너는 포탈 매니저가 관리하는 포탈들을 중앙에서 렌더링한다.
- 포탈 루트 노드 생성: 포탈을 렌더링할 DOM 노드를 생성하거나, 기존에 존재하는 포탈 컨테이너를 찾는다.
- React Portal 사용: ReactDOM.createPortal을 사용하여 포탈 컴포넌트를 지정된 DOM 노드에 렌더링한다.
- 퍼포먼스 최적화: 포탈 컨테이너가 클라이언트에서만 렌더링되도록 설정하여, 불필요한 렌더링을 방지한다.
구현
// PortalContainer.tsx
"use client";
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import portalManager from "../utils/portalManager";
import { PortalOptions } from "@/types/ui/portal";
import Modal from "@/components/ui/Modal";
import Toast from "@/components/ui/Toast";
import Alert from "@/components/ui/Alert";
import Dialog from "@/components/ui/Dialog";
type Props = {
children: React.ReactNode;
};
const PortalContainer = () => {
const [portals, setPortals] = useState<PortalOptions[]>([]);
useEffect(() => {
// 리스너 정의
const listener = (options: PortalOptions, isOpen: boolean) => {
if (isOpen) {
setPortals((prev) => [...prev, options]);
} else {
setPortals((prev) => prev.filter((portal) => portal.id !== options.id));
}
};
// PortalManager의 리스너 등록
const unsubscribe = portalManager.subscribe(listener);
return () => {
unsubscribe();
};
}, []);
// 포탈 렌더링 함수
const renderPortal = (portal: PortalOptions) => {
switch (portal.type) {
case "modal":
return <Modal key={portal.id} {...portal.props} />;
case "toast":
return <Toast key={portal.id} {...portal.props} />;
case "alert":
return <Alert key={portal.id} {...portal.props} />;
case "dialog":
return <Dialog key={portal.id} {...portal.props} />;
default:
return null;
}
};
// 클라이언트 사이드에서만 렌더링
if (typeof window === "undefined") return null;
let portalDiv = document.getElementById("portal");
if (!portalDiv) {
portalDiv = document.createElement("div");
portalDiv.id = "portal";
document.body.appendChild(portalDiv);
}
return ReactDOM.createPortal(
<>
{portals.map((portal) => renderPortal(portal))}
</>,
portalDiv
);
};
export default PortalContainer;
고려 사항
- 포탈 루트 노드 관리: 포탈을 렌더링할 루트 노드(#portal)를 찾고, 존재하지 않을 경우 새로 생성하여 document.body에 추가한다. 이를 통해 포탈이 어디에 렌더링될지 명확히 지정한다.
- React Portal 활용: ReactDOM.createPortal을 사용하여 자식 컴포넌트를 지정된 DOM 노드에 렌더링함으로써, 포탈 컴포넌트가 실제 DOM 계층과 분리되어 렌더링되도록 한다.
- 퍼포먼스 최적화: 포탈 컨테이너가 클라이언트에서만 렌더링되도록 설정함으로써, 서버 사이드 렌더링 시 불필요한 렌더링을 방지하고, 초기 로드 시의 퍼포먼스를 최적화힌다.
- 안전한 DOM 접근: useEffect 훅을 사용하여 컴포넌트가 마운트된 후에만 DOM에 접근하도록 하여, 서버 사이드 렌더링 시 발생할 수 있는 오류를 방지한다.
- 포탈 컴포넌트 렌더링: 포탈 유형에 따라 적절한 컴포넌트를 렌더링하도록 renderPortal 함수를 구현했다. 이는 포탈의 유형별로 다른 UI를 제공할 수 있게 한다.
5. 구현 중 마주한 문제점과 한계 (트러블 슈팅)
포탈 관리 시스템을 구현하는 과정에서 다양한 문제점이 생겼다. 이를 해결하기 위해 여러 접근 방식을 시도하고, 최적화 작업을 했다. 아래는 시스템을 구현하면서 마주친 주요 문제점들과 그에 대해 해결했던 과정들이다.
5. 1. 적절히 포탈 제거되지 않아 포탈이 중복되는 문제
toast 포탈을 처리할 때, 토스트가 표시된 후 해당 포탈을 목록에서 제거하는 과정에서 문제가 발생했다. handleClose 함수가 제대로 호출되지 않아 포탈이 제거되지 않았고, 이는 동일한 토스트가 반복적으로 표시되거나, 포탈 목록에 불필요한 포탈이 남아 있는 문제로 이어졌다.
- onClose 콜백의 올바른 전달:toast 함수를 호출할 때, onClose 콜백을 정확히 전달하여, 토스트가 닫힐 때 포탈을 정상적으로 제거할 수 있도록 했다. PortalProvider의 useEffect에서 toast 포탈을 감지하고, onClose를 통해 handleClose를 호출하도록 설정했다.
- 포탈 상태 업데이트:handleClose 함수가 포탈의 isOpen 상태를 false로 업데이트하도록 했으며, 이는 PortalManager를 통해 setPortalOpen을 호출하여 구현했다. 이를 통해 포탈의 닫힘 상태를 정확히 반영할 수 있었다.
- 렌더링 최적화:toast 포탈은 portals.map 내에서 렌더링하지 않고, useEffect에서 별도로 처리하도록 설정하여, 불필요한 렌더링을 방지했다. 이는 toast 포탈이 별도의 로직으로 관리되도록 하여, 포탈 목록과 렌더링 로직을 명확히 분리했다.
5. 2. 렌더링 최적화
toast 포탈은 modal이나 dialog와 달리, 여러 개가 동시에 표시될 수 있기 때문에 렌더링을 별도로 처리해야 했다. modal과 dialog는 보통 한 번에 하나씩만 표시되므로, 렌더링 로직이 단순했으나, toast는 여러 개가 동시에 나타날 수 있어 이를 적절히 관리해야 했다.
- useEffect를 통한 별도 처리:PortalProvider의 useEffect에서 toast 포탈을 감지하고, toast 함수를 호출하여 토스트를 표시하도록 했다. 이를 통해, portals.map 내에서는 toast 포탈을 렌더링하지 않고, 별도로 처리함으로써 렌더링 최적화를 달성했다.
- 포탈 목록에서 toast 포탈 제거:toast 포탈이 처리된 후, 즉시 handleClose를 호출하여 포탈을 목록에서 제거하도록 설정했다. 이는 포탈 목록에 불필요한 toast 포탈이 남아 있지 않도록 했다.
- 렌더링 조건 설정:toast 포탈은 portals.map 내에서 null을 반환하도록 설정하여, 렌더링되지 않도록 했다. 이는 useEffect를 통해 별도로 처리되므로, 불필요한 렌더링을 방지했다.
- 포탈 배열 관리: 여러 개의 toast 포탈이 동시에 열릴 수 있도록, portals 상태를 배열로 관리하고, 각 toast 포탈을 별도로 처리했다. 이는 toast의 특성상 여러 개가 동시에 표시될 수 있기 때문에, 이를 고려하여 설계된 것이다.
5.3. 포탈 타입 관리 및 타입 안전성
다양한 포탈 유형이 존재함에 따라, 각 포탈의 Props 타입을 엄격히 관리하는 것이 복잡해졌다. TypeScript를 활용하여 타입 안전성을 보장하려 했으나, 포탈 유형별로 Props 타입을 관리하는 과정에서 복잡성이 증가했다. 특히, 포탈 유형이 늘어날수록, 각 포탈의 Props 타입을 명확히 정의하고, 이를 활용하는 로직을 유지하는 것이 어려워졌다.
- 디스크리미네이티드 유니언 타입 도입:PortalOptions를 디스크리미네이티드 유니언 타입으로 정의하여, 각 포탈 유형에 따라 Props 타입을 명확히 분리했다. 이를 통해 TypeScript의 타입 체크 기능을 활용하여 포탈 유형별 Props를 엄격히 관리할 수 있었다.
디스크리미네이티드 유니언 타입(Discriminated Union Type)이란?
TypeScript에서 유니언 타입(union type)을 보다 안전하고 효율적으로 활용하기 위해 사용하는 패턴이다. 이 패턴은 각 유니언 멤버가 공통의 디스크리미네이터(discriminator) 속성을 가지면서, 그 속성의 값을 통해 각 멤버를 구별할 수 있도록 설계된다. 이를 통해 TypeScript의 타입 검사 기능을 최대한 활용하여 코드의 안전성을 높이고, 타입 오류를 사전에 방지할 수 있다.
- 포탈 Props 인터페이스 분리: 각 포탈 유형별로 ModalProps, ToastProps, AlertProps, DialogProps 인터페이스를 분리하여, 포탈 유형에 맞는 Props를 명확히 정의했다. 이는 포탈 관리 시스템에서 포탈 유형별 Props를 엄격히 관리할 수 있도록 했다.
- 타입 매핑 활용:PortalPropsMap을 통해 포탈 유형과 Props 타입을 매핑하여, usePortal 훅에서 포탈 트리거 함수의 인자 타입을 명확히 했다. 이는 포탈 트리거 시 올바른 Props 타입을 전달하도록 강제했다.
- TypeScript 제네릭 활용:usePortal 훅에서 제네릭 <T extends PortalType>을 사용하여, 포탈 유형에 따라 Props 타입이 다르게 적용되도록 했다. 이는 TypeScript의 강력한 타입 추론 기능을 활용하여, 포탈 트리거 시 Props 타입의 오류를 방지했다.
// use-portal.tsx {* 생략 *} type PortalPropsMap = { modal: ModalProps; toast: ToasterProps; alert: AlertProps; dialog: DialogProps; }; const usePortal = () => { const [openPortals, setOpenPortals] = useState<string[]>([]); useEffect(() => { // 초기 openPortals 설정 setOpenPortals(portalManager.getOpenPortals()); // 리스너 정의 const listener = (options: PortalOptions, isOpen: boolean) => { if (isOpen) { setOpenPortals((prev) => [...prev, options.id]); } else { setOpenPortals((prev) => prev.filter((id) => id !== options.id)); } }; // PortalManager의 리스너 등록 const unsubscribe = portalManager.subscribe(listener); return () => { unsubscribe(); }; }, []); // 포탈 트리거 함수 **const portal = <T extends PortalType>( type: T, props: PortalPropsMap[T], ): string | null => { return portalManager.showPortal(type, props); };** // 포탈의 isOpen 상태 조회 함수 const getIsOpen = (id: string): boolean | undefined => { return portalManager.getIsPortalOpen(id); }; return { portal, getIsOpen, openPortals }; }; export default usePortal; {* ----------------------------------------------- *} // use-portal.tsx {* 생략 *} type PortalPropsMap = { modal: ModalProps; toast: ToasterProps; alert: AlertProps; dialog: DialogProps; }; const usePortal = () => { const [openPortals, setOpenPortals] = useState<string[]>([]); useEffect(() => { // 초기 openPortals 설정 setOpenPortals(portalManager.getOpenPortals()); // 리스너 정의 const listener = (options: PortalOptions, isOpen: boolean) => { if (isOpen) { setOpenPortals((prev) => [...prev, options.id]); } else { setOpenPortals((prev) => prev.filter((id) => id !== options.id)); } }; // PortalManager의 리스너 등록 const unsubscribe = portalManager.subscribe(listener); return () => { unsubscribe(); }; }, []); // 포탈 트리거 함수 const portal = <T extends PortalType>( type: T, props: PortalPropsMap[T], ): string | null => { return portalManager.showPortal(type, props); }; // 포탈의 isOpen 상태 조회 함수 const getIsOpen = (id: string): boolean | undefined => { return portalManager.getIsPortalOpen(id); }; return { portal, getIsOpen, openPortals }; }; export default usePortal;
- 이러한 방식으로 타입 관리를 체계화함으로써, 포탈 관리 시스템의 타입 안전성을 향상시킬 수 있었다.
5. 4. 포탈 렌더링 및 생명주기 관리
포탈의 렌더링과 생명주기 관리가 분리되어 있지 않아, 포탈의 상태 변화에 따라 렌더링이 적절히 이루어지지 않는 문제가 발생했다. 특히, toast 포탈의 경우 렌더링과 별도로 상태를 관리해야 했기 때문에, 포탈의 생명주기 관리가 복잡해졌다.
- 포탈 렌더링 조건 분리:toast 포탈을 portals.map 내에서 렌더링하지 않고, useEffect에서 별도로 처리함으로써, 포탈의 렌더링과 생명주기를 분리했다. 이는 toast 포탈이 별도의 로직으로 관리되도록 하여, 포탈 목록과 렌더링 로직을 명확히 분리했다.
- 포탈 상태 변화 감지:PortalManager와 useEffect를 활용하여 포탈의 상태 변화를 감지하고, 이에 따라 적절한 동작을 수행하도록 했다. 예를 들어, toast 포탈의 경우 포탈이 열릴 때 toast 함수를 호출하여 토스트를 표시하고, 포탈을 닫는 로직을 적용했다.
- 포탈 생명주기 관리: 포탈이 닫혔을 때, 일정 시간 후 포탈 목록에서 제거하는 로직을 추가하여, 포탈의 생명주기를 적절히 관리했다. 이는 포탈의 닫힘 상태를 감지하고, 이를 반영하여 포탈을 목록에서 제거하도록 했다.이러한 방식으로 포탈의 렌더링과 생명주기 관리를 분리하고, useEffect를 통해 포탈의 상태 변화를 감지함으로써, 포탈 관리 시스템의 유연성과 안정성을 높일 수 있었다.
// portalManager.ts 내 로깅 추가 예시
showPortal(type: PortalType, props: any): string | null {
const id = props.id || this.generateId(type);
console.log(`Attempting to show portal: ${type} with id: ${id}`);
// 중복 포탈 방지 로직
if (this.portals.has(id) && this.portals.get(id)!.isOpen) {
console.warn(`Portal with id "${id}" is already open.`);
return id;
}
// 포탈 생성 로직...
console.log(`Portal opened: ${type} with id: ${id}`);
return id;
}
setPortalOpen(id: string, isOpen: boolean) {
console.log(`Setting portal "${id}" to ${isOpen ? 'open' : 'closed'}`);
// 상태 업데이트 로직...
console.log(`Portal "${id}" is now ${isOpen ? 'open' : 'closed'}`);
}
5. 5. 버그 발생 및 디버깅 어려움
포탈 관리 시스템이 중앙 집중식으로 동작하면서, 특정 포탈에서 발생하는 버그를 추적하고 수정하는 과정이 복잡해졌다. 포탈의 상태 변화가 다양한 컴포넌트에 영향을 미치기 때문에, 버그의 원인을 신속하게 파악하기 어려웠다.
- 로깅 및 모니터링 도입: 포탈의 상태 변화와 트리거 이벤트에 대한 로깅을 추가하여, 문제 발생 시 신속하게 원인을 파악할 수 있도록 했다. 이를 통해, 어떤 포탈이 언제 열리고 닫혔는지 추적할 수 있었다.
// portalManager.ts 내 로깅 추가 예시
showPortal(type: PortalType, props: any): string | null {
const id = props.id || this.generateId(type);
console.log(`Attempting to show portal: ${type} with id: ${id}`);
// 중복 포탈 방지 로직
if (this.portals.has(id) && this.portals.get(id)!.isOpen) {
console.warn(`Portal with id "${id}" is already open.`);
return id;
}
// 포탈 생성 로직...
console.log(`Portal opened: ${type} with id: ${id}`);
return id;
}
setPortalOpen(id: string, isOpen: boolean) {
console.log(`Setting portal "${id}" to ${isOpen ? 'open' : 'closed'}`);
// 상태 업데이트 로직...
console.log(`Portal "${id}" is now ${isOpen ? 'open' : 'closed'}`);
}
5. 6. 다수의 포탈 관리 시 성능 저하
다수의 포탈이 동시에 열려 있을 경우, 포탈 관리 시스템이 성능 저하를 초래할 수 있었다. 특히, 많은 수의 포탈이 렌더링되고 상태가 빈번하게 업데이트될 때, 애플리케이션의 반응 속도가 느려질 수 있었다.
- 포탈의 우선순위 관리: 포탈의 유형에 따라 우선순위를 설정하여, 중요한 포탈이 우선적으로 렌더링되도록 했다. 예를 들어, 모달은 항상 최상단에 표시되도록 설정하고, 토스트는 제한된 수만 동시에 표시되도록 했다.
- 포탈의 렌더링 조건 최적화: 불필요한 포탈 렌더링을 방지하기 위해, 포탈의 isOpen 상태를 기준으로 렌더링 여부를 결정했다. 이는 포탈이 열려 있을 때만 렌더링되도록 하여, 렌더링 비용을 최소화했다.
- 가상화 기법 도입: 매우 많은 수의 포탈이 열리는 경우, 가상화 기법을 도입하여 렌더링 성능을 최적화했다. 이는 실제로 화면에 표시되는 포탈만 렌더링하도록 하여, DOM 노드의 수를 줄이는 방법이다.이러한 최적화 방안을 통해, 다수의 포탈이 동시에 열려 있을 때 발생할 수 있는 성능 저하 문제를 효과적으로 해결할 수 있었다.
// portalProvider.tsx 내 포탈 렌더링 최적화 예시
return (
<PortalContainer>
<ToastManager />
{portals.map((portal) => {
if (!portal.isOpen) return null;
switch (portal.type) {
case "modal":
return (
<Modal
key={portal.id}
{...portal.props}
onClose={() => {
portal.props.onClose && portal.props.onClose();
handleClose(portal.id);
}}
description={portal.props.description?.toString()}
/>
);
case "alert":
return (
<Alert
key={portal.id}
{...portal.props}
onClose={() => {
portal.props.onClose && portal.props.onClose();
handleClose(portal.id);
}}
/>
);
case "dialog":
return (
<Dialog
key={portal.id}
{...portal.props}
onClose={() => {
portal.props.onClose && portal.props.onClose();
handleClose(portal.id);
}}
/>
);
case "toast":
return null; // 'toast' 포탈은 useEffect에서 처리하므로 렌더링하지 않음
default:
return null;
}
})}
</PortalContainer>
);
5. 7. 상태 동기화 문제
포탈 관리 시스템에서 PortalManager 클래스와 usePortal 훅 간의 상태 동기화가 완벽하지 않아, 포탈의 상태가 예상과 다르게 동작하는 문제가 발생했다. 특히, 포탈의 상태가 비동기적으로 업데이트될 때, 컴포넌트에서 이를 적절히 반영하지 못하는 경우가 있었다.
해결 방안:
상태 업데이트의 일관성 유지:PortalManager 클래스에서 포탈의 상태를 업데이트할 때, 항상 새로운 상태 객체를 생성하여 상태 변화를 트리거하도록 했다. 이는 상태 업데이트의 일관성을 유지하고, React의 상태 관리 메커니즘과 호환되도록 했다.
// portalManager.ts 내 상태 업데이트 로직
setPortalOpen(id: string, isOpen: boolean) {
if (this.portals.has(id)) {
const portalEntry = this.portals.get(id)!;
portalEntry.isOpen = isOpen;
this.portals.set(id, { ...portalEntry }); // 새로운 객체로 설정
this.listeners.forEach((listener) =>
listener(portalEntry.options, isOpen),
); // 포탈 상태 변경 알림
}
}
포탈 상태 변화의 실시간 반영:PortalProvider 컴포넌트에서 PortalManager의 리스너를 통해 포탈 상태 변화를 실시간으로 반영하도록 했다. 이는 포탈 상태가 변경될 때마다 컴포넌트의 상태가 즉시 업데이트되도록 보장했다.이러한 방식을 통해, 포탈 관리 시스템에서 상태 동기화 문제를 해결하고, 포탈의 상태가 예상대로 동작하도록 했다.
// portalProvider.tsx 내 포탈 상태 반영 로직
useEffect(() => {
// PortalManager의 이벤트를 구독
const unsubscribe = portalManager.subscribe(
(options: PortalOptions, isOpen: boolean) => {
setPortals((prev) => {
if (isOpen) {
// 포탈이 열릴 때
if (!prev.find((p) => p.id === options.id)) {
return [...prev, { ...options, isOpen }];
}
} else {
// 포탈이 닫힐 때
return prev.map((p) =>
p.id === options.id ? { ...p, isOpen } : p,
);
}
return prev;
});
},
);
return () => {
unsubscribe();
};
}, []);
5. 8. 포탈 관리 시스템의 복잡성 증가
포탈 관리 시스템이 중앙 집중식으로 동작하면서, 시스템 자체의 복잡성이 증가했다. 특히, 다양한 포탈 유형과 그에 따른 관리 로직이 추가될수록, 시스템의 이해도와 유지보수성이 낮아지는 문제가 발생했다.
해결 방안:
- 모듈화 및 코드 분할: 포탈 관리 시스템의 각 구성 요소(예: PortalManager, usePortal 훅, 포탈 컴포넌트 등)를 모듈화하고, 명확히 분리하여 코드의 가독성과 유지보수성을 높였다. 각 포탈 유형별로 관련된 로직을 별도의 파일로 분리함으로써, 시스템의 복잡성을 줄였다.
- 문서화 및 주석 추가: 포탈 관리 시스템의 각 부분에 대한 문서화와 주석을 추가하여, 팀 내 개발자들이 시스템을 쉽게 이해하고 유지보수할 수 있도록 했다. 이는 새로운 개발자가 시스템에 쉽게 적응할 수 있도록 돕는다.
- 코드 리뷰 및 리팩토링: 정기적인 코드 리뷰와 리팩토링을 통해, 포탈 관리 시스템의 구조를 지속적으로 개선하고, 불필요한 복잡성을 제거했다. 이는 코드의 품질을 높이고, 시스템의 안정성을 유지하는 데 기여했다.
- 이러한 방안을 통해, 포탈 관리 시스템의 복잡성 증가 문제를 효과적으로 해결할 수 있었다.
5. 9. 동시성 및 경쟁 조건 문제
여러 포탈이 동시에 열리고 닫히는 상황에서, 상태 업데이트가 비동기적으로 이루어져 경쟁 조건(Race Condition)이 발생할 수 있었다. 이는 포탈의 상태가 예상과 다르게 동작하거나, 포탈이 올바르게 열리거나 닫히지 않는 문제로 이어졌다.
해결 방안:
- 상태 업데이트의 원자성 보장:PortalManager 클래스 내에서 상태 업데이트를 원자적으로 처리하여, 동시에 여러 포탈의 상태가 변경될 때도 일관성을 유지하도록 했다. 이는 setPortalOpen 메서드 내에서 포탈 상태를 동기적으로 업데이트함으로써 달성했다.
- 비동기 작업의 큐 비동기 작업이 포탈 상태와 관련된 경우, 작업을 큐에 저장하고 순차적으로 처리하여 경쟁 조건을 방지했다. 이는 비동기 작업이 포탈의 상태에 영향을 미칠 때, 상태 변경이 순차적으로 이루어지도록 보장했다.
// portalManager.ts 내 비동기 작업 큐 관리 예시
class PortalManager {
private listeners: Listener[] = [];
private portals: Map<string, { isOpen: boolean; options: PortalOptions }> =
new Map();
private queue: (() => void)[] = [];
private isProcessing: boolean = false;
private processQueue() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const task = this.queue.shift();
task && task();
this.isProcessing = false;
this.processQueue();
}
subscribe(listener: Listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
showPortal(type: PortalType, props: any): string | null {
const id = props.id || this.generateId(type);
// 중복 포탈 방지
if (this.portals.has(id) && this.portals.get(id)!.isOpen) {
console.warn(`Portal with id "${id}" is already open.`);
return id;
}
let portal: PortalOptions | null = null;
switch (type) {
case "modal":
portal = { id, type, props: props as ModalProps };
break;
case "toast":
portal = { id, type, props: props as ToastProps };
break;
case "alert":
portal = { id, type, props: props as AlertProps };
break;
case "dialog":
portal = { id, type, props: props as DialogProps };
break;
default:
break;
}
if (portal) {
this.portals.set(id, { isOpen: true, options: portal });
this.queue.push(() => {
this.listeners.forEach((listener) => listener(portal!, true));
});
this.processQueue();
}
return id;
}
setPortalOpen(id: string, isOpen: boolean) {
if (this.portals.has(id)) {
const portalEntry = this.portals.get(id)!;
portalEntry.isOpen = isOpen;
this.portals.set(id, { ...portalEntry });
this.queue.push(() => {
this.listeners.forEach((listener) =>
listener(portalEntry.options, isOpen),
);
});
this.processQueue();
}
}
getIsPortalOpen(id: string): boolean | undefined {
return this.portals.get(id)?.isOpen;
}
getOpenPortals(): string[] {
return Array.from(this.portals.entries())
.filter(([_, value]) => value.isOpen)
.map(([key, _]) => key);
}
}
6. 완성된 컴포넌트 코드 적용 예시
6.1. 모달 트리거 및 관리
모달을 열고 닫는 과정은 매우 직관적이다. usePortal 훅을 사용하여 모달을 트리거할 수 있으며, 필요한 props를 전달하면 된다.
import React from "react";
import usePortal from "@/hooks/use-portal";
const MyComponent = () => {
const { modal } = usePortal();
const openModal = () => {
modal({
title: "모달",
description: "이것은 메인 카피입니다.",
subDescription: "이것은 추가 설명입니다.",
onConfirm: () => alert("확인 버튼 클릭됨")
});
};
return (
<div>
<button onClick={openModal}>모달 열기</button>
</div>
);
};
export default MyComponent;
위 코드를 실행하면 "모달 열기" 버튼을 클릭할 때 모달이 열리고, portal 함수가 모달의 props를 전달하여 포탈 트리거가 작동한다. 모달의 열림/닫힘 상태는 portalManager를 통해 관리되며, 모달이 닫힐 때는 onClose 콜백을 통해 처리할 수 있다.
6.2. 토스트 트리거 및 관리
토스트 알림은 사용자가 어떤 액션을 했을 때 빠르게 피드백을 주기 위해 사용할 수 있다. usePortal 훅을 통해 간단하게 트리거할 수 있다.
const ToastComponent = () => {
const { toast } = usePortal();
const showSuccessToast = () => {
toast({
title: "white 토스트",
description: "작업이 성공적으로 완료되었습니다.",
variant: "success"
});
};
const showErrorToast = () => {
toast({
description: "작업 중 오류가 발생했습니다.",
variant: "error"
});
};
return (
<div>
<button variant={"outline"} theme={"primary"} onClick={showSuccessToast}>White 토스트 열기</button>
<button onClick={showErrorToast}>Black 토스트 열기</button>
</div>
);
};
export default ToastComponent;
7. 구현 결과
1. 중앙 집중식 관리의 효율성이 높아졌다.
PortalManager 클래스를 통해 모든 포탈의 상태를 중앙에서 관리함으로써, 상태 관리의 일관성과 효율성을 높일 수 있었다. 이는 포탈의 열림/닫힘 상태를 일관되게 추적하고, 이를 컴포넌트에 반영하는 데 큰 도움이 되었다. 중앙 매니저는 포탈의 상태 변화를 한 곳에서 처리하므로, 상태 관리 로직이 분산되지 않아 유지보수가 용이했다.
2. 단일 진입점으로 인해 코드가 일관되었다.
usePortal 훅을 통해 모든 포탈 유형을 일괄적으로 관리할 수 있어, 코드의 일관성을 유지하고 중복을 줄일 수 있었다. 포탈을 트리거하는 로직을 한 곳에 모아두어, 유지보수가 용이하게 만들었다. 이는 개발자가 새로운 포탈을 추가하거나 기존 포탈을 수정할 때, 일관된 방식으로 접근할 수 있게 해주었다.
3. 유지보수와 확장성이 향상되었다.
새로운 포탈 유형을 추가할 때, PortalManager 클래스와 usePortal 훅을 통해 쉽게 통합할 수 있었다. 포탈 유형이 추가되더라도, 기존 구조를 크게 변경하지 않고 확장할 수 있어 유지보수가 용이했다. 중앙 매니저는 포탈의 유형에 상관없이 일관된 관리 방식을 제공하므로, 포탈 관리 로직의 확장성이 높아졌다.
4. 타입 안전성 확보되었다.
TypeScript를 활용하여, 각 포탈 유형에 맞는 Props 타입을 엄격히 관리할 수 있었다. 이를 통해, 런타임 오류를 사전에 방지하고, 코드의 안정성을 높일 수 있었다. 포탈 유형별로 명확한 타입 정의를 통해, 개발 과정에서 발생할 수 있는 오류를 줄이고, 코드의 가독성과 유지보수성을 향상시켰다.
5. 재사용성이 좋아지고, 코드가 간결해졌다.
usePortal 훅은 포탈 트리거와 상태 조회 기능을 단일 인터페이스로 제공함으로써, 컴포넌트 내에서 포탈을 간편하게 사용할 수 있게 했다. 이는 코드의 재사용성을 높이고, 포탈 관리 로직을 간결하게 유지할 수 있게 해주었다. 개발자는 usePortal 훅을 통해 다양한 포탈을 손쉽게 관리할 수 있어, 생산성이 향상되었다.
6. 유연한 상태 관리가 가능해졌다.
PortalManager 클래스는 포탈의 상태를 효과적으로 관리하며, usePortal 훅을 통해 포탈을 트리거하고 상태를 조회할 수 있도록 했다. 이로 인해 포탈 관리 로직이 명확해지고, 상태 관리가 간편해졌다.