뱁새 다리찢기
article thumbnail

배경

vscode의 탭 바처럼 동작하는 대시보드를 만들고 있었어요. 파일을 열면 탭이 생기고, 클릭하면 전환되고, X로 닫는 그 구조요.

대시보드에는 몇 가지 종류의 화면이 있어요.

  • home — 홈 화면. 항상 존재하는 기본 탭이에요.
  • new — 새 인사이트를 작성하는 페이지. 사이드바에서 "새 페이지 만들기"를 누르면 탭이 열립니다.
  • insight-{id} — 작성이 완료된 개별 인사이트 페이지. new에서 글을 저장하면 insight-42 같은 탭으로 교체돼요.
  • tag-{id} — 특정 태그에 속한 인사이트 목록.
  • trash — 휴지통.

이 탭 상태를 어디에 저장할까요? React state에 넣으면 간단하지만, 새로고침하면 날아가요. 그래서 공유 기능도 넣을 겸 URL query string을 single source of truth로 사용합니다.

/dashboard?tab=insight-3&tabs=new|insight-3|tag-1
  • tab — 지금 보고 있는 탭 (active tab)
  • tabs — 열려 있는 탭 목록, |로 구분

이 URL 하나가 탭 상태의 전부예요. 새로고침해도 상태가 유지되고, 뒤로가기도 자연스럽게 동작합니다.

각 화면의 id는 타입이 다르기 때문에 (태그도 숫자 id, 인사이트도 숫자 id) URL에는 tag-1, insight-3처럼 접두사를 붙여서 구분하고, 코드에서는 discriminated union으로 파싱해서 사용해요.

type TabId =
  | { type: "home" }
  | { type: "new" }
  | { type: "trash" }
  | { type: "tag"; id: number }
  | { type: "insight"; id: number };

이제 문제는 이 URL 상태를 어떻게 변경하느냐예요. 탭을 열고, 닫고, 교체하는 동작마다 URL을 다시 만들어서 router.push해야 하거든요.

처음에는 이런 API를 만들었어요.

const { navigate, openTab, closeTab, replaceTab } = useDashboardTabs();

각 함수가 URL을 조립해서 router.push하는 방식이었는데, 여기서 문제가 생겼습니다.


복합 동작을 훅이 제공하면 이름만으로 예측할 수 없다

이름만 보겠습니다. navigateopenTab의 차이가 뭘까요?

// navigate: 포커스만 이동. 탭 목록은 건드리지 않음.
navigate({ type: "home" });

// openTab: 탭 목록에 추가 + 포커스 이동. 두 가지를 동시에 함.
openTab({ type: "new" });

openTab이라는 이름에서 "포커스도 같이 이동한다"를 읽어낼 수 있나요? 협업자 입장에선 코드를 열어봐야 알수있어요.

더 큰 문제는 확장할 때 드러나요. "탭을 열되 포커스는 옮기지 않는" 요구사항이 생기면 어떻게 될까요? openTab에 옵션을 추가하게 됩니다.

// 이런 식으로 흘러가게 돼요
openTab({ type: "new" }, { activate: false });

옵션이 하나 추가될 때마다 함수의 동작 경우의 수가 배로 늘어나요. 이름만으로 동작을 예측하는 건 점점 불가능해집니다.

action의 단일책임이 깨져서 생기는 일이에요.


원자적 action만 제공하고, 조합은 소비자에게 맡기기

원자적(atomic)이란 "더 이상 쪼갤 수 없는"이라는 뜻이에요. openTab처럼 여러 동작을 하나로 묶는 대신, 쪼갤 수 없는 최소 단위의 action만 제공하고 조합은 소비자에게 맡기는 거예요.

 

reducer 패턴을 빌려와서, 각 action이 정확히 하나의 일만 하도록 바꿨어요.

interface TabState {
  activeKey: string;
  tabKeys: string[];
}

type TabAction =
  | { type: "activate"; tab: TabId }
  | { type: "add"; tab: TabId }
  | { type: "remove"; tab: TabId }
  | { type: "replace"; oldTab: TabId; newTab: TabId };

function reduceTab(state: TabState, action: TabAction): TabState {
  switch (action.type) {
    case "activate":
      return { ...state, activeKey: serializeTab(action.tab) };
    case "add": { /* ... */ }
    case "remove": { /* ... */ }
    case "replace": { /* ... */ }
  }
}
function useDashboardTabs() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const state: TabState = useMemo(() => ({
    activeKey: searchParams.get("tab") ?? "home",
    tabKeys: searchParams.get("tabs")?.split("|").filter(Boolean) ?? [],
  }), [searchParams]);

  const dispatch = useCallback(
    (...actions: TabAction[]) =>
      router.push(buildUrl(actions.reduce(reduceTab, state))),
    [router, state],
  );

  return { activeTab, openTabs, dispatch } as const;
}

각 action이 정확히 하나의 일만 합니다.

action 하는 일
activate 어떤 탭을 보고 있는지 변경
add 탭 목록에 추가
remove 탭 목록에서 제거
replace 탭 목록에서 위치를 보존하며 교체

dispatch가 여러 action을 받을 수 있으므로, 소비자가 의도를 직접 조합합니다.

// 사이드바: "새 탭 열고 거기로 이동"
dispatch(
  { type: "add", tab: { type: "new" } },
  { type: "activate", tab: { type: "new" } },
);

// 탭바: "이 탭으로 이동" — activate만
dispatch({ type: "activate", tab });

// 탭바: "이 탭 닫기" — remove만
dispatch({ type: "remove", tab });

// 인사이트 생성 후: "new 탭을 insight 탭으로 교체"
dispatch({
  type: "replace",
  oldTab: { type: "new" },
  newTab: { type: "insight", id: 42 },
});

호출부만 보면 무슨 일이 일어나는지 전부 드러나요. 훅 내부를 열어볼 필요가 없습니다.

"탭을 열되 포커스는 옮기지 않는" 요구사항이 생기면요? add만 dispatch하면 돼요. 새 API를 만들 필요가 없습니다.

// 백그라운드에서 탭 열기
dispatch({ type: "add", tab: { type: "insight", id: 99 } });

+ replace는 원자적인가?

replaceremove + add로 분해되는 거 아닌가 하는 의문이 들 수 있어요. 한번 살펴볼게요.

replace는 "목록에서의 위치를 보존하며 교체"라는 하나의 의미 단위예요. remove(old) + add(new)로 쪼개면 위치가 보존되지 않습니다.

Before: [new, insight-3, tag-1]

              ↓ replace(new → insight-42)
After:  [insight-42, insight-3, tag-1]  ← 같은 자리

              ↓ remove(new) + add(insight-42)
After:  [insight-3, tag-1, insight-42]  ← 끝으로 밀림

remove + add로 분해하면 동작이 달라지니까, replace는 더 이상 쪼갤 수 없는 원자적 action이 맞습니다.


후기

트레이드오프로 소비자 코드가 늘어나긴 합니다. 이 경우엔 중간레이어를 추가하는 방식(openTab 함수 추가)으로 해결이 가능할 것 같아요

++마침 더 복잡한걸 회사에서 만들다 보니 소비자 코드가 너무 복잡해져서 메인 훅은 openTab처럼 두단계 추상화로 내보내되 훅 내부에선 dispatch처럼 아토믹하게 함수를 쪼갰습니다. 이게 더 나은듯여 ㅎ

profile

뱁새 다리찢기

@donghyk2-eric

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!