교내 개발 동아리 디자인 시스템의 Snackbar 컴포넌트 개발을 진행하면서 고민하고 개선해나간 과정에 대해 정리하고자 한다.
처음에 작성했던 Snackbar 코드다.
처음 코드를 작성했을 때 정말.. 잘못 설계했다는 것을 깨달았다.
이유는 다음과 같다.
import { useCallback, useEffect, useRef, useState } from 'react';
import { IcAlertTriangleFilled, IcCloseFilled } from '@/style';
import { StyledErrorIc, StyledIcMessage, StyledMessage, StyledSnackbar } from './Snackbar.style';
import { SnackbarHeightType, SnackbarProps } from './Snackbar.type';
export const Snackbar = ({
id,
type = 'info',
width,
margin,
message,
onClose,
duration = 5000,
position = 'center',
}: SnackbarProps) => {
const closeToast = useCallback(onClose, [onClose]);
const messageRef = useRef<HTMLSpanElement>(null);
const [heightType, setHeightType] = useState<SnackbarHeightType>(1);
useEffect(() => {
setTimeout(() => {
closeToast();
}, duration);
}, [duration, closeToast]);
useEffect(() => {
if (messageRef.current) {
const messageHeight = messageRef.current.clientHeight;
const lineHeight = parseInt(window.getComputedStyle(messageRef.current).lineHeight, 10);
const isMultiLine = messageHeight > lineHeight;
setHeightType(isMultiLine ? 2 : 1);
}
}, [message, position]);
return (
<StyledSnackbar
id={id}
ref={type === 'info' ? snackbarRef : undefined}
$type={type}
$width={width}
$margin={margin}
position={position}
$heightType={heightType}
>
<StyledIcMessage>
{type === 'error' && <IcAlertTriangleFilled size="20px" />}
<StyledMessage ref={messageRef}>{message}</StyledMessage>
{type === 'error' && (
<StyledErrorIc>
<IcCloseFilled size="20px" onClick={() => onClose()} />
</StyledErrorIc>
)}
</StyledIcMessage>
</StyledSnackbar>
);
};
초기 코드에서의 주요 문제점은 다음과 같다.
- Snackbar의 상태를 각 컴포넌트에서 직접 관리해야 하는 문제(전역적으로 호출할 수 없음)
- Snackbar가 특정 컴포넌트의 생명주기에 종속되는 문제
- 자동 닫힘(setTimeout)과 수동 닫힘(onClick)이 동시에 존재하면서 중복 실행되는 문제
각 문제점과 함께 어떻게 해결했는지 정리해보겠다.
🚨 Snackbar의 상태를 각 컴포넌트에서 직접 관리해야 하는 문제(전역적으로 호출할 수 없음)
Snackbar가 필요한 곳마다 상태(useState)를 선언 후, 직접 관리해서 사용해야 했다.
이는 명령형 방식으로, Snackbar를 띄울 때마다 직접 상태를 변경하고 조건문을 사용해서 렌더링을 제어해야 한다.
여러 곳에서 Snackbar를 띄운다면 이는 중복 코드를 계속해서 작성해야 하는 상황이 발생한다.(재사용성 ⬇️)
function App() {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false);
return (
<>
<button onClick={() => setIsSnackbarOpen(true)}>Show Snackbar</button>
{isSnackbarOpen && (
<Snackbar
type="info"
message="정보성 메시지입니다."
onClose={() => setIsSnackbarOpen(false)}
/>
)}
</>
);
}
🚨 Snackbar가 특정 컴포넌트의 생명주기에 종속되는 문제
Snackbar가 특정 페이지나 컴포넌트에 종속되었기 때문에, 사용자가 페이지를 이동하면 Snackbar가 강제로 사라지는 문제가 발생한다.
첫번째 문제의 연장선으로, 전역적으로 Snackbar 상태가 관리되지 않기 때문에 상위 컴포넌트 생명주기에 종속되어 원치 않는 동작이 일어나는 것이다. 이는 명백히 잘못된 설계인 것이다.(왜 이렇게 짰던거니....)
💚 SnackbarProvider를 통해 전역적으로 Snackbar 관리하기(훅으로 Snackbar 로직 분리)
다음처럼 Context API를 활용하여 SnackbarProvider를 도입했고 커스텀 훅을 통해 선언적으로 Snackbar를 호출할 수 있는 구조로 개선했다. 이를 통해 Snackbar 상태를 전역적으로 관리할 수 있게 되었고 여러 컴포넌트에서 중복 코드 없이 Snackbar를 선언적으로 띄울 수 있게 되었다.👏🏻
사용처에서는 showSnackbar(props) 코드만 작성하면 Snackbar를 띄울 수 있는 것이다.
추가로 특정 컴포넌트에서가 아닌 전역적으로 상태를 관리하기 때문에 특정 컴포넌트의 생명주기에 종속되는 문제도 해결된다.
더욱 직관적이고 재사용성이 높은 Snackbar 컴포넌트로 업그레이드 됐다✨✨
type SnackbarContextType = {
showSnackbar: (props: SnackbarWithoutClosingProps) => void;
};
const SnackbarContext = createContext<SnackbarContextType>({ showSnackbar: () => {} });
export const SnackbarProvider = ({ children }: PropsWithChildren) => {
const [snackbar, setSnackbar] = useState<SnackbarWithoutClosingProps | null>(null);
const [isClosing, setIsClosing] = useState<boolean>(false);
const showSnackbar = useCallback((props: SnackbarWithoutClosingProps) => {
setSnackbar(props);
setIsClosing(false);
}, []);
const removeSnackbar = useCallback(() => {
setIsClosing(true);
setTimeout(() => setSnackbar(null), 300);
}, []);
return (
<SnackbarContext.Provider value={{ showSnackbar }}>
{children}
{snackbar &&
ReactDOM.createPortal(
<StyledSnackbarContainer
$width={snackbar.width}
$position={snackbar.position || 'center'}
>
<Snackbar {...snackbar} onClose={removeSnackbar} isClosing={isClosing} />
</StyledSnackbarContainer>,
document.body
)}
</SnackbarContext.Provider>
);
};
export const useSnackbarContext = () => {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within a SnackbarProvider');
}
return context;
};
import { SnackbarWithoutClosingProps } from '../Snackbar.type';
import { useSnackbarContext } from '../SnackbarProvider';
export const useSnackbar = () => {
const { showSnackbar } = useSnackbarContext();
const snackbar = (props: SnackbarWithoutClosingProps) => {
showSnackbar(props);
};
return { snackbar };
};
🚨 자동 닫힘(setTimeout)과 수동 닫힘(onClick)이 동시에 존재하면서 중복 실행되는 문제
setTimeout이 실행되는 도중에 사용자가 onClick으로 먼저 닫았을 경우, 이미 닫힌 Snackbar를 또 닫으려고 하는 문제가 발생한다.
onClose가 중복 실행되어 Snackbar의 상태가 꼬이게 되는 문제 발생(초기 Snackbar 생성 후 onClick으로 닫고난 후, 다시 Snackbar를 열려고 할 때 중복된 onClose(setTimeout)이 실행되어 의도치 않게 닫히는 문제)하게 되어 최악의 상황이 발생하게 된다...
useEffect(() => {
setTimeout(() => {
closeToast(); // 일정 시간이 지나면 자동으로 닫힘
}, duration);
}, [duration, closeToast]);
return (
<StyledSnackbar>
<IcCloseFilled onClick={() => onClose()} /> {/* 수동으로 닫기 버튼 제공 */}
</StyledSnackbar>
);
💚 clearTimeout을 이용한 닫기 충돌 문제 해결(+ 애니메이션 추가)
clearTimeout을 활용하여 Snackbar 컴포넌트가 언마운트되면 타이머가 같이 해제하여 불필요한 실행을 방지하였다.
추가적으로 디자이너 팀의 요구사항에 맞게 isClosing 상태 관리를 활용하여 부드러운 애니메이션을 추가하였다.
애니메이션이 진행되고 난 후 Snackbar가 없어지도록 구현을 하였다.
const [isClosing, setIsClosing] = useState(false);
const closeToast = useCallback(() => {
if (!isClosing) {
setIsClosing(true); // 닫힘 애니메이션 실행
setTimeout(() => {
onClose(); // 애니메이션이 끝난 후 실제로 제거
}, 300); // 애니메이션 지속 시간(300ms)
}
}, [isClosing, onClose]);
useEffect(() => {
const timeout = setTimeout(() => {
closeToast(); // 자동으로 닫힘
}, duration);
return () => clearTimeout(timeout); // 컴포넌트 언마운트 시 타이머 해제
}, [duration, closeToast]);
return (
<StyledSnackbar $isClosing={isClosing} onAnimationEnd={onClose}>
<IcCloseFilled onClick={closeToast} /> {/* 닫기 버튼 클릭 */}
</StyledSnackbar>
);
💚 드래그 인터랙션을 활용한 닫기 기능 추가
Snackbar를 상하로 드래그(스와이프)하면 닫히도록 구현하여 사용자가 더 직관적으로 제어할 수 있도록 개선하였다.
setTimeout을 사용해 정해진 duration 시간이 지난 후 자동으로 닫히는 방식 & X 버튼 클릭 후 닫히는 방식을 구현했었는데
초기 코드에서는 디자인 팀에서 요구한 닫기 인터랙션이 구현되어 있지 않았다.
UX 개선을 위해 사용자가 편하게 닫을 수 있도록 드래그 인터랙션 닫기 기능을 추가해야 했다.
import { useState, useRef, useEffect } from 'react';
export const useTouchMouseDrag = (onDismiss: () => void, threshold: number = 10) => {
const [startY, setStartY] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const elementRef = useRef<HTMLDivElement | null>(null);
const handleStart = (event: TouchEvent | MouseEvent) => {
const y = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY;
setStartY(y);
setIsDragging(true);
};
const handleMove = (event: TouchEvent | MouseEvent) => {
if (!isDragging || startY === null) return;
const currentY = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY;
const diffY = currentY - startY;
if (diffY > threshold) {
onDismiss();
setIsDragging(false);
setStartY(null);
}
};
const handleEnd = () => {
setIsDragging(false);
setStartY(null);
};
useEffect(() => {
const element = elementRef.current;
if (element) {
element.addEventListener('mousedown', handleStart);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleEnd);
element.addEventListener('touchstart', handleStart);
element.addEventListener('touchmove', handleMove);
element.addEventListener('touchend', handleEnd);
return () => {
element.removeEventListener('mousedown', handleStart);
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
element.removeEventListener('touchstart', handleStart);
element.removeEventListener('touchmove', handleMove);
element.removeEventListener('touchend', handleEnd);
};
}
}, [isDragging, startY]);
return elementRef;
};
useTouchMouseDrag 커스텀 훅을 활용하여 닫기 기능 추가하였다.
- 마우스 또는 터치 이벤트 감지
- 사용자가 특정 거리(threshold)보다 많은 거리로 내릴 때 Snackbar가 닫히도록
⭐️선언적인 Snackbar 활용법⭐️
snackbar({ ... }) 방식의 간결한 사용으로 선언적으로 호출이 가능해졌다.
function App() {
const { snackbar } = useSnackbar();
const handleShowSnackbar = () => {
snackbar({
type: 'info',
message: '정보성 메시지입니다.',
duration: 3000,
position: 'center',
});
};
return (
<>
<button onClick={handleShowSnackbar}>Show Snackbar</button>
</>
);
}
성공적으로 원하는대로 Snackbar가 작동되는 것을 확인할 수 있다☺️
'프로젝트' 카테고리의 다른 글
[Vite/디자인시스템] React / NextJS 빌드 테스트 삽질 여정기.. (0) | 2025.04.12 |
---|