배경
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하는 방식이었는데, 여기서 문제가 생겼습니다.
복합 동작을 훅이 제공하면 이름만으로 예측할 수 없다
이름만 보겠습니다. navigate와 openTab의 차이가 뭘까요?
// 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는 원자적인가?
replace가 remove + 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처럼 아토믹하게 함수를 쪼갰습니다. 이게 더 나은듯여 ㅎ
'<frontend>' 카테고리의 다른 글
| Branded Type String Key Factory 만들기 (0) | 2026.02.13 |
|---|---|
| Branded Type으로 유틸함수 강제하기 (0) | 2026.02.07 |
| 토스 Frontend Fundamentals 모의고사 후기 (15) | 2025.11.26 |
| 클라이언트 데이터 정규화 (15) | 2025.11.15 |
| 주석대신 순수함수로 TODO적기 (20) | 2025.10.19 |