디자인 시스템 스낵바 구현기

한칙촉·2026년 7월 18일

스낵바 너 뭐하는 놈이야...

현재 동아리의 디자인 시스템을 emotion 기반에서 vanilla-extract 기반으로 마이그레이션하는 과정에 있다. Toast에 이어 Snackbar 컴포넌트의 마이그레이션을 맡게 되었는데... 이전 Toast 컴포넌트 개선 중 놓쳤던 부분도 발견했고, Snackbar는 사용자와의 인터랙션이 가능하다는 차이점이 있어 더 고려할 부분이 많았다. 내가 고민했던 부분들을 잊지 않기 위해 글로 남기려 한다~~

큐 관리 훅 리팩토링

기존에 emotion 기반으로 구현이 되어있어서 기본적인 바탕은 있었지만, 마이그레이션하는 김에 Toast와 Snackbar 컴포넌트가 공동으로 사용하던 useLimitedQueueProvider 훅을 리팩토링했다.

최신 상태 보장을 위한 이중 상태 동기화

우선 렌더링 상태와 동시성 제어 상태를 분리했다.

// 화면을 그리기 위한 상태
const [items, setItems] = useState<T[]>([]);
// 비동기 로직이 즉시 읽는 최신 상태
const itemsRef = useRef<T[]>([]);

const next = typeof updater === "function" ? updater(itemsRef.current) : updater;
itemsRef.current = next;
setItems(next);
  • items: 화면을 다시 렌더링하기 위한 React 상태
  • itemsRef: 현재 대기 아이템을 즉시 확인하기 위한 값

React의 상태 업데이트는 배치 처리될 수 있어서 setItems를 호출한 직후에는 최신 값을 바로 읽을 수 없다. 하지만 아이템(스낵바)을 추가하는 addItem은 비동기로 동작하며, 여러 번 연속 호출될 수도 있다. 따라서 itemsRef.current를 먼저 갱신하면 다음 addItem이 실행될 때까지 최신 대기자 수를 바로 확인할 수 있다.

두 값의 불일치를 막기 위해 모든 변경은 syncSetItems 함수를 거치도록 했다.

스낵바의 퇴장 대기열 관리

removeResolvers 함수는 특정 아이템이 실제로 제거되기를 기다리는 Promise의 resolve 함수들을 저장한다.

const removeResolvers = useRef<Map<string, Array<() => void>>>(new Map());

새 스낵바가 추가되려 하는데 이미 최대 개수만큼 스낵바가 띄워져 있는 상태이면 기존 스낵바가 사라지기까지 기다려야 한다. 언제 기존 스낵바가 사라지는 지 알기 위해 진동벨을 하나씩 준다고 생각하면 된다. 🔔

A가 나갈 때까지 기다리는 D → 진동벨 D
A가 나갈 때까지 기다리는 E → 진동벨 E

// removeResolvers는 아래와 같이 저장
Map {
  "A" => [진동벨D, 진동벨E]
}

key는 제거를 기다리는 기존 아이템의 id이며, value는 해당 아이템이 제거되면 재개되어야 하는 addItem 흐름들이다. A가 나가기를 기다리는 대기자들의 진동벨인 것이다. A가 제거되면 배열에 들어있는 모든 진동벨을 울린다.

배열로 관리하는 이유는 여러 addItem 호출이 동시에 같은 아이템의 제거를 기다릴 수 있기 때문이다.

const resolvers = removeResolvers.current.get(id);
if (resolvers) {
  resolvers.forEach(resolve => resolve());
  removeResolvers.current.delete(id);
}

syncSetItems(prev => prev.filter(item => item.id !== id));

removeItem 함수를 통해 진동벨을 모두 울린 뒤에는 A에 대한 기록도 Map에서 삭제한다. A는 이미 제거되었고, A를 기다리던 요청들도 모두 통보받았으므로 더 이상 기록을 유지할 이유가 없기 때문이다. 이는 단지 A가 제거되었으니 멈춰 있던 작업을 다시 진행해도 된다는 신호이며, D와 E의 addItem은 기다리던 지점 이후부터 다시 실행된다.

큐에 안전하게 새 스낵바 추가

addItem은 새로운 아이템을 추가하되, 아이템 수가 limit을 넘지 않도록 자리가 날 때까지 기다린다.

const id = crypto.randomUUID();
const newItem = { ...item, id } as T;

우선 새 대기자에게 고유 번호를 발급한다.

// 연속 호출 중에도 실제 렌더링되는 아이템 수가 limit을 넘지 않도록 추가 전까지 대기
while (itemsRef.current.length >= normalizedLimit) {
  // 여전히 자리가 없다면 다시 기다림
  
  //...
}

진동벨이 울려 대기에서 깨어나더라도 여러 addItem이 동시에 기다리고 있었다면 다른 호출이 먼저 차지할 수 있으므로 깨어날 때마다 자리가 생겼는지 다시 확인한다.

const firstClosingItem =
  itemsRef.current.find(item => item.isClosing);

if (firstClosingItem) {
  await waitForItemRemoval(firstClosingItem.id);
  continue;
}

이미 나가는 대기자가 있다면 다른 대기자를 추가로 내보내지 않는다. 퇴장 중인 아이템이 완전히 제거될 때까지 진동벨을 받고 기다린 다음, 다시 while 조건으로 돌아가 자리를 확인한다.

waitForItemRemoval 함수는 A가 제거되었다는 연락이 올 때까지 기다리라는 알림과 같다. 퇴장 애니메이션 콜백이 누락되면 Promise가 영원히 기다릴 수 있으므로, 큐가 영원히 멈추는 참사를 방지하기 위한 setTimeout 로직이 포함되어있다.

const firstActiveItem = itemsRef.current[0];

closeItem(firstActiveItem.id);
await waitForItemRemoval(firstActiveItem.id);

퇴장 중인 아이템이 없다면 가장 오래된 아이템을 선택하여 closeItem으로 퇴장 상태를 만든 후, 실제로 제거될 때까지 기다린다.

syncSetItems(prev => [...prev, newItem]);
return id;

while 문을 빠져나왔다는 것은 현재 아이템 수가 제한 수보다 작다는 뜻이다. 이때 새로운 아이템을 명단에 추가하고, 발급한 id를 반환한다.

전체 흐름을 간단히 축약해보면 아래와 같다.

초기 큐
[A, B, C]

DE 추가 요청
→ 큐가 가득 참
→ A에게 퇴장 요청
→ DEA 제거를 함께 기다림

Map {
  A => [D, E]
}

A 제거
→ DE의 기다림 종료
→ 현재 큐 [B, C]

D가 먼저 재개
→ 빈자리에 D 추가
→ 현재 큐 [B, C, D]

E가 재개
→ 큐가 다시 가득 찬 것을 확인
→ B에게 퇴장 요청
→ B 제거를 기다림

Map {
  B => [E]
}

B 제거
→ E의 기다림 종료
→ E 추가
→ 최종 큐 [C, D, E]

자동 닫힘 일시정지

type SnackbarPhase = "enter" | "static" | "exit";
const [phase, setPhase] = useState<SnackbarPhase>("enter");

기존 스낵바는 phase로 흐름이 관리되는 상태였고, static 단계가 되면 단순히 duration만큼 기다렸다가 exit 단계로 넘어갔다. 스낵바는 토스트와 다르게 인터랙션이 가능한 컴포넌트이기 때문에 사용자의 hover, focus의 상태에 따라 자동 닫힘을 멈추는 일시정지 기능을 추가하게 되었다.

타이머 상태 관리

타이머 id, 종료 예정 시각, 남은 시간은 화면에 직접 표시되는 값이 아니며, 값이 바뀔 때마다 다시 렌더링할 필요가 없기 때문에 useState 대신 useRef를 사용했다.

// 현재 실행 중인 자동 닫힘 타이머의 id 저장
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// 스낵바가 자동으로 닫힐 예정인 절대 시각 저장
const exitDeadlineRef = useRef(0);

// 타이머가 일시정지된 시점에 얼마나 시간이 남았는지 저장
const remainingDurationRef = useRef(duration);

// 현재 타이머를 멈춰야 하는 이유 저장
const pauseReasonsRef = useRef<Set<AutoDismissPauseReason>>(new Set());

// 스낵바의 exit 타이머를 안정적으로 관리하기 위한 ref
const onRemoveRef = useRef(onRemove);

타이머 시작과 종료 예정 시각 계산

스낵바가 static 상태에 진입하면 duration 만큼의 자동 닫힘 타이머를 시작한다.

if (phase === "static") {
  if (duration === Infinity) return;

  remainingDurationRef.current = duration;

  if (pauseReasonsRef.current.size === 0) {
    startExitTimer(duration);
  }

  return clearExitTimer;
}

clearExitTimer();

startExitTimer 함수는 먼저 기존 타이머가 있는지 확인하고 제거한 뒤, 현재 시각과 노출 시간, 닫힐 예정 시각을 저장한다.

현재 시각: 1000
노출 시간: 4000

닫힐 예정 시각: 1000 + 4000 = 5000

// 타이머에 설정된 시간
remainingDurationRef.current = 4000;
// 타이머가 닫힐 예정인 시각
exitDeadlineRef.current = 5000;
// 현재 실행 중인 자동 닫힘 타이머
exitTimerRef.current = 자동_닫힘_타이머_ID;

만약 현재 시각이 1000ms이고 노출 시간(duration)이 4초라면 위와 같이 저장된다!

hover 처리

이때 사용자가 스낵바에 마우스를 올리면 루트 요소의 onMouseEnter 함수가 실행된다.

const handleMouseEnter = () => pauseAutoDismiss("hover");
const handleMouseLeave = () => resumeAutoDismiss("hover");

<div
  onMouseEnter={handleMouseEnter}
  onMouseLeave={handleMouseLeave}
  // ...

pauseAutoDismiss에는 타이머를 멈춘 이유로 hover가 전달된다. 기존 값이 빈 Set Set()였다면 Set(["hover"])와 같이 변경된다.

if (duration === Infinity || phase !== "static" || !exitTimerRef.current) return;
remainingDurationRef.current = Math.max(0, exitDeadlineRef.current - Date.now());

자동 닫힘을 사용하는 스낵바인지, 현재 static 상태인지, 실행 중인 타이머가 있는지 확인한 후, 모든 조건을 만족하면 예정된 종료 시각에서 현재 시각을 빼 남은 시간을 계산한다.

닫힐 예정 시각: 5000
hover한 현재 시각: 2500

남은 시간: 5000 - 2500 = 2500
remainingDurationRef.current = 2500;

사용자가 2500ms 시점에 마우스를 올렸다고 가정하면 위와 같이 값을 저장한다. 남은 시간 계산을 완료했으므로 기존 자동 닫힘 타이머를 취소한다.

focus 처리

const handleFocus = () => pauseAutoDismiss("focus");
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
  const nextTarget = event.relatedTarget;
  // 내부 버튼 사이에서 포커스가 이동하는 경우 자동 닫힘을 재개하지 않는다.
  if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
  resumeAutoDismiss("focus");
};

<div
  onFocus={handleFocus}
  onBlur={handleBlur}
>
  <LabelButton />
  <IconButton />
</div>

사용자가 hover 중 내부 버튼에 포커스를 올렸다면 onFocus를 통해 이를 감지하고, Set는 Set(["hover", "focus"]) 로 변경된다. 포커스가 스낵바 내부의 버튼 안에서 이동되었다면 relatedTarget으로 포커스가 현재 스낵바 안에 존재하는지 검사하여 focusSet에서 제거하지 않는다.

타이머 재개

모든 일시정지 이유가 사라지면 이전에 저장해 둔 남은 시간으로 타이머를 시작한다.

현재 시각: 8000
남은 시간: 2500

새로운 종료 예정 시각: 8000 + 2500 = 10500

remainingDurationRef.current = 2500;
exitDeadlineRef.current = 10500;

exitTimerRef.current = setTimeout(
  () => setPhase("exit"),
  2500,
);

hover가 시작되었을 때 저장한 남은 시간이 2500ms였으므로, 현재 시각이 8000ms라고 가정하면 새로운 종료 예정 시각을 위와 같이 계산한다.

if (phase === "exit") {
  const timer = setTimeout(() => {
    onRemoveRef.current?.();
  }, SNACKBAR_ANIMATION_TIMER.EXIT);

  return () => clearTimeout(timer);
}

시간이 지나 exit 단계가 되면 애니메이션 시간만큼 기다린 후 onRemove를 실행한다.

스크린 리더 낭독 및 접근성 처리

가장 많이 고민하고 어려웠던 부분...
스낵바가 연속으로 추가되고 제거되는 상황에서 VoiceOver가 각 알림을 한 번씩, 빠짐없이 읽도록 만드는 일이었다.

{snackbars.map(snackbar => (
  <Snackbar
    key={snackbar.id}
    role='status'
    aria-live='polite'
    {...snackbar}
  />
))}

처음에는 개별 스낵바가 생성될 때 스스로 낭독되도록 각 요소에 role="status"를 부여하는 방식을 시도했다. 하지만 live region과 읽어야 할 텍스트가 같은 순간에 DOM에 추가되면서, VoiceOver가 해당 요소를 기존 live region에 발생한 변경으로 안정적으로 감지하지 못하는 경우가 있었다... 스크린리더가 변화를 감지하려면 live region이 먼저 존재하고, 이후 내부 텍스트가 변경되는 구조가 더 안정적이었다.

<div
  role='status'
  aria-live='polite'
>
  {snackbars.map(snackbar => (
    <Snackbar
      key={snackbar.id}
      {...snackbar}
    />
  ))}
</div>

다음으로 개별 스낵바가 아닌 스택 컨테이너에 live region 역할을 부여했다. live region 자체는 계속 유지되고 내부 스낵바 배열만 변경되기 때문에 새 요소의 추가를 잘 감지할 것으로 예상했다. 하지만 VoiceOver는 스낵바 배열이 변경될 때마다 컨테이너의 전체 텍스트가 바뀐 것으로 처리하여 이전 스낵바까지 누적 낭독하는 문제가 발생했다... 니네 뭐가 문젠건데

렌더링과 낭독 책임 분리

관련 사례를 조사하던 중 Radix UIToast시각적 Toast스크린 리더용 ToastAnnounce를 분리하고 있다는 점을 알게 되었다. 화면에 표시되는 Toast를 별도로 렌더링하면서, 낭독할 텍스트는 ToastAnnounce라는 시각적으로 숨겨진 영역에 전달한다. (Radix UI - Toast)

<>
  <ToastAnnounce
    role='status'
    aria-live='polite'
  >
    {announceText}
  </ToastAnnounce>

  {createPortal(
    <VisualToast />,
    viewport,
  )}
</>

해당 구조를 단순화하면 위와 같다. 낭독용 영역은 토스트가 추가되기 전부터 DOM에 존재한다. 새 토스트가 들어오면 live region을 새로 생성하는 대신 내부 announceText 문자열만 변경한다. 이 구조를 참고하여 SnackbarProvider에도 스크린리더 전용 live region을 만들었다.

const latestSnackbar = snackbars.length > 0 ? snackbars[snackbars.length - 1] : null;
// ...

// 스크린 리더 전용 live region 영역
<div
  className={visuallyHidden}
  role='status'
  aria-live='polite'
  aria-atomic='true'
>
  {announcement}
</div>

// 시각용 영역
{isMounted &&
  createPortal(
    <div className={stackContainer}>
      {snackbars.map(snackbar => (
        <Snackbar
          key={snackbar.id}
          {...snackbar}
        />
      ))}
    </div>,
    document.body,
  )}

낭독용 스택에는 전체 스낵바 배열을 넣지 않고 가장 최근에 추가된 스낵바 하나만 선택하여 넣는다. aria-atomic="true"여도 낭독 영역 안에는 최신 스낵바 하나의 텍스트만 존재하므로 누적 낭독이 문제가 되지 않는다.

같은 문구 연속 발생 시 낭독을 누락하는 문제

렌더링 영역과 낭독 영역을 분리하자 서로 다른 스낵바는 안정적으로 읽을 수 있었지만, 같은 텍스트를 가진 스낵바가 연속으로 발생하면 새로운 문제가 생겼다.

{
  id: "snackbar-A",
  title: "스낵바 메세지"
}

{
  id: "snackbar-B",
  title: "스낵바 메세지"
}

// 낭독용 문자열 값
announcement = "스낵바 메세지";

setAnnouncement("스낵바 메세지");
setAnnouncement("스낵바 메세지");

위의 두 스낵바는 서로 다른 id를 가진 새로운 알림이지만, 낭독용 문자열만 보면 이전 값과 다음 값이 같다. 두 번째 값이 이전 값과 동일하면 실제 DOM 텍스트 변경이 발생하지 않을 수 있다. DOM에 변화가 없으므로 VoiceOver도 새로운 알림으로 인식하지 않는다.

이를 해결하기 위해 화면에도 보이지 않고 스크린 리더도 발음하지 않는 zero-width space를 사용했다.

const announcementSpaceToggleRef = useRef(false);

announcementSpaceToggleRef.current = !announcementSpaceToggleRef.current;
const invisibleSpace = announcementSpaceToggleRef.current ? "\u200B" : "\u200B\u200B";

새로운 스낵바가 들어올 때마다 boolean 값을 반전한다. 그리고 값에 따라 zero-width space를 한 개 또는 두 개 붙인다.

setAnnouncement(`${baseText}${invisibleSpace}`);

최종 announcement는 위와 같이 만들어진다. 사용자에게 들리는 문구는 모두 같지만 DOM 문자열은 서로 다르다. 연속된 두 값이 항상 달라지므로 React DOM 텍스트는 업데이트하고 VoiceOver도 새로운 live region으로 인식할 수 있다! 🙉

최신 스낵바 먼저 제거 시 이전 스낵바를 다시 낭독하는 문제

동일한 텍스트 문제를 해결한 뒤에 스낵바의 제거 순서에 따른 문제가 발견되었다. 그만해
현재 낭독 대상은 배열의 마지막 스낵바이다. 스낵바 A가 먼저 추가되고 B가 나중에 추가되었다고 가정해보자..

A 최초 낭독
“A\u200B”

B 낭독
“B\u200B\u200B”

B 제거 후 A가 다시 latest
“A\u200B”
→ 직전 값 B와 다르므로 DOM 변경
→ A 중복 낭독

사용자가 B를 먼저 닫거나 B의 duration이 더 짧다면 최신 스낵바인 B가 먼저 제거될 수 있다. 그렇다면 위의 흐름을 거쳐 A가 latest가 된다. 하지만 A는 새로운 스낵바가 아니다. 이미 B가 추가되기 전에 한 번 낭독된 스낵바이지만 latest가 B에서 A로 변경되었기 때문에 effect가 다시 실행될 수 있다.

이는 현재 latest인가? 만으로 낭독 여부를 결정해서 발생한 문제이다. 여기서 아직 한 번도 낭독하지 않은 스낵바인지를 추가로 확인하기 위해 스낵바의 id를 Set으로 관리하도록 수정했다.

const announcedSnackbarIdsRef = useRef<Set<string>>(new Set());

if (announcedSnackbarIdsRef.current.has(latestSnackbarId)) return;
announcedSnackbarIdsRef.current.add(latestSnackbarId);

새로운 latest 스낵바를 발견하면 먼저 해당 id가 이미 낭독되었는지 확인한다. 처음 발견한 id라면 Set에 추가하고 announcement를 변경한다. A, B 사례에 적용하면 아래와 같이 동작한다.

A 추가
Set {}A가 없으므로 낭독
→ Set {A}

B 추가
Set {A}B가 없으므로 낭독
→ Set {A, B}

B 제거
latest가 다시 A
Set {A, B}A가 이미 존재함
→ announcement 변경하지 않음
→ A 재낭독 방지

제거된 스낵바 id 정리

낭독한 모든 id를 Set에 계속 보관하면 Provider가 유지되는 동안 값이 계속 누적된다. 따라서 현재 스낵바 큐에 존재하지 않는 id는 Set에서도 제거한다.

useEffect(() => {
  // 큐에서 제거된 스낵바 id를 정리해 낭독 완료 목록이 계속 누적되지 않도록 한다.
  const activeSnackbarIds = new Set(snackbars.map(snackbar => snackbar.id));

  announcedSnackbarIdsRef.current.forEach(id => {
    if (!activeSnackbarIds.has(id)) announcedSnackbarIdsRef.current.delete(id);
  });
}, [snackbars]);

위 코드를 통해 중복 낭독 방지에 필요한 현재 큐의 id만 유지하면서 Set이 무한히 커지는 것을 막도록 했다!

느낀 점과 마무리..

이번에 스낵바를 구현하면서 팀원들의 수많은 피드백, 리뷰를 통해 많은 걸 고민하고 또 배운 것 같다. 덕분에 현재 상황에서는 최선의 구현이 도출되었다고 생각한다... 하지만 모든 개발이 그렇듯 완벽한 건 없고 늘 트레이드 오프가 존재한다. 지금 상황에서는 연속된 스낵바의 낭독 누락 문제인 것 같다.

스낵바는 화면에 최대 3개까지 동시에 노출될 수 있는데, 만약 3개의 스낵바가 거의 동시에 연속으로 트리거된다면 스크린 리더는 앞선 두 개의 문구를 읽기도 전에 제일 마지막에 들어온 최신 스낵바의 문구로 덮어씌워 낭독하게 될 것이다. 두 개의 스낵바는 시각적으로 화면에 떠 있지만, 음성으로는 온전히 전달되지 못하는 상태인 것... 하지만 이를 해결하기 위해 모든 문구를 순차적으로 낭독하도록 열어둔다면 앞서 해결했던 누적 낭독 문제가 다시 생긴다. 결국 현재는 정보의 누락 없는 전달지연 없는 인터랙션 중 후자를 택한 것이다.

추후에 서비스의 요구사항이 바뀌거나 더 완벽한 접근성 해결책이 생긴다면 미래의 내가 열심히 수정할거고 미래의 내가 열심히 글로 남길 거다. 신경써야 할 디테일이 많아 머리는 좀 아팠지만 UI 뿐만 아니라 보이지 않는 곳의 접근성까지 고려하며 컴포넌트를 만들어간 과정 자체가 정말 값진 경험이었다고 생각한다! 2주만에 무사히 pr을 머지한 나에게 박수를...

profile
빙글빙글돌아가는..

1개의 댓글

comment-user-thumbnail
2026년 8월 21일

잘 읽었습니다!

답글 달기