02
제약
상담원이 회원 목록을 띄운 채로 계약을 확인하고 매물을 검수한다. 브라우저 탭이 아니라 앱 안의 탭이어야 했고, 각 탭이 필터와 스크롤 위치를 자기 것으로 기억해야 했다.
포기한 것
탭 상태를 한 저장소에 담는 단순함을 버렸다. localStorage와 메모리로 쪼개지 않으면 비동기 응답이 도착할 때 남의 탭 슬롯에 기록된다.
남은 것
재마운트 키 3줄로 탭 전환 시 조회 API 재호출을 없앴고, 진입 판정을 순수 함수로 분리해 진리표로 검증 가능하게 만들었다.
조직 식별 정보, 호스트·계정, 회원 데이터, 보안 점검 결과는 제외했다. 구조와 코드 패턴은 실제 그대로다.
내부 운영 어드민. Next 15 App Router + Turbopack. 라우트 62개, 뷰 슬라이스 41개, 파일 721개, 75,402행.
운영팀은 화면을 하나씩 쓰지 않는다
이 앱의 성격을 결정한 요구사항 하나. 상담원이 회원 목록을 띄운 채로 계약을 확인하고, 그 사이에 매물을 검수한다. 브라우저 탭을 여러 개 여는 게 아니라 앱 안에서 탭을 쓴다. 각 탭은 필터와 스크롤 위치를 자기 것으로 기억해야 한다.
SPA 안에 탭 브라우저를 만든다는 뜻이고, App Router 에서 이건 간단하지 않다.
MainLayout.tsx 의 이 주석이 그 어려움을 그대로 담고 있다.
/**
* children 재마운트 키 — **라우트가 실제로 바뀐 뒤에만** 활성 탭으로 넘어간다.
*
* 키가 필요한 이유: 같은 URL 로 탭을 여러 개 열 수 있으므로 라우트만으로는 탭을
* 구분할 수 없고, 키가 없으면 두 탭이 컴포넌트 state 를 공유한다.
*
* 그런데 `key={activeTabId}` 를 그대로 쓰면 탭을 누른 **즉시** 서브트리가 교체되는데,
* 그 시점의 children 은 아직 **떠나는 화면**의 트리다(router.replace 는 effect 에서
* startTransition 으로 나중에 커밋된다). 그래서 떠나는 화면이 도착 탭의 id 로 한 번
* 다시 마운트됐다가 버려졌다 — 마운트 effect 가 전부 재실행되어 조회 API 가 다시
* 나가고, 탭 세션 캐시는 탭별로 갈라져 있어 그 순간엔 항상 miss 였다.
*/
const routeSettled = !activeTab || tabBasePath(activeTab.pathname) === tabBasePath(pathname);
const renderKeyRef = useRef<string | null>(activeTabId);
if (routeSettled) renderKeyRef.current = activeTabId;
증상은 "탭을 바꿀 때 스켈레톤이 한 번 스친다"였다. 원인은 React 키와 라우터 커밋 타이밍의 불일치였다. key 는 즉시 바뀌고 pathname 은 transition 뒤에 바뀐다. 그 사이 한 프레임에서 떠나는 화면이 도착 탭의 정체성을 입고 재마운트된다.
해결은 키를 라우트가 따라잡을 때까지 붙잡아두는 것. useRef 로 이전 키를 유지하고 routeSettled 일 때만 갱신한다.
이 3줄이 API 재호출을 없앴다. 그리고 이런 건 주석이 없으면 다음 사람이 "왜 ref 를 쓰지?" 하고 지운다.
탭 스코프 저장소를 두 개로 나눴다
처음엔 탭별 상태를 한 곳에 담았다. 곧 두 종류가 섞이면 안 된다는 게 드러났다.
tabStates (localStorage) — 필터처럼 새로고침 후에도 남아야 하는 값
tabSession (메모리) — 조회 결과·열린 팝업처럼 새로고침하면 초기화되어야 하는 값
경계를 잘못 그으면 조용히 깨진다. API 응답을 localStorage 쪽에 담으면 안 된다 — 비동기 응답이 도착할 때 활성 탭이 이미 바뀌어 있으면 다른 탭 슬롯에 결과가 기록된다. 화면에는 남의 탭 데이터가 뜨고, 재현이 어렵다.
메모리 쪽은 atomFamily 로 만들었는데 거기서 두 번째 함정이 나왔다.
/** atomFamily param — 객체를 쓰면 참조 동등성 때문에 호출마다 새 atom 이 생기므로 문자열로 직렬화한다. */
const paramOf = (tabId: TabId, key: string) => `${tabId}::${key}`;
/**
* 탭별로 실제 사용된 key 장부.
*
* atomFamily 는 내부 Map 에 param→atom 을 **영구 보관**한다. `remove()` 를 명시
* 호출하지 않으면 탭을 닫아도 계약 목록·개인정보가 힙에 남는다.
*/
atomFamily 는 만든 atom 을 내부 Map 에 계속 들고 있다. 탭을 닫아도 그 탭의 조회 결과가 메모리에 남는다. 일반 앱이면 누수 수준이지만 여기 담기는 건 회원 개인정보다. 그래서 어떤 key 를 지워야 하는지 아는 장부를 따로 두고, 탭을 닫을 때 명시적으로 remove() 한다.
장부는 렌더에 쓰이지 않으므로 반응형 상태로 두지 않고 모듈 Map 으로 뒀다. 상태로 두면 장부가 바뀔 때마다 전체가 재렌더된다.
탭 스토어는 atom 12개다 — tabsAtom, activeTabIdAtom, tabStatesAtom 과 addTab/openTab/closeTab/activateTab/reorderTabs 같은 액션 atom. 액션을 atom 으로 두면 컴포넌트가 스토어 내부 구조를 몰라도 된다.
권한은 축이 3개다
어드민이라 권한이 화면의 절반이다. 축이 하나가 아니었다.
| 축 | 판정 대상 | 읽는 곳 |
|---|---|---|
| 메뉴 권한 | menu_code + READ/CREATE/UPDATE/DELETE/EXPORT | usePermission(code) |
| 일반 권한 | 여러 메뉴에 걸치는 기능 (계약 액션 등) | useGeneralPermission(code) |
| 슈퍼관리자 | 관리 화면 진입 | useSuperAdminOnly() |
일반 권한 축이 필요한 이유가 있다. 같은 기능이 여러 메뉴에서 노출되면 메뉴 권한으로는 표현이 안 된다. 계약 승인은 중개업체 화면에도 있고 전체 계약 관리 화면에도 있다. 메뉴별로 권한을 따로 주면 한쪽만 열려 있는 상태가 만들어진다.
게이트는 4곳에 나뉘어 있다 — 라우트 진입, 사이드바 노출, 화면 안 CRUD, 부서장 판정. 넷이 같은 규칙을 봐야 한다. 사이드바에는 안 보이는데 URL 로는 열리면 권한 체계를 읽을 수 없게 된다.
진입 판정은 순수 함수로 분리했다.
export function decideAccess({ pathname, menuCode, menusEmpty, isSuperAdmin, canAccess }) {
if (isAdminPath(pathname) || (menuCode && isAdminMenuCode(menuCode))) {
return isSuperAdmin ? 'allow' : 'deny';
}
if (!menuCode) return menusEmpty ? 'allow' : 'deny-unregistered';
return canAccess ? 'allow' : 'deny';
}
JSX 분기에 흩어 두면 실행으로 확인할 수 없다. 순수 함수면 진리표를 만들어 돌릴 수 있고, 조건을 하나 지워서 어떤 케이스가 뒤집히는지 볼 수 있다.
반복해서 나온 결함은 한 형태였다
권한 관련 결함을 모아보니 "판정 근거를 못 구하면 통과" 한 가지의 변형이었다.
- 값을 못 구하면 허용 —
canUpdate?: boolean+= true기본값. 호출부가 빠뜨려도 컴파일러가 못 잡는다. 화면은 정상으로 보이니 리뷰에서도 안 드러난다. 그래서 권한 prop 은 이름과 무관하게 required 로 둔다.= false기본값도 금지다 — 배선 누락이 '정상 거부'로 보여 발견이 더 늦어진다 - 진입만 막고 쓰기 지점은 안 막음 — 다이얼로그 여는 버튼만 게이트하면 오늘은 맞다. 같은 상태를 세우는 경로가 하나 더 생기는 순간 열린다. 실제로 한 화면은 등록 Enter 는 권한을 보고 수정 Enter 는 안 봤다
- 코드를 못 구하면 통과 — 메뉴 트리 조회가 실패하면 코드가
undefined가 되어 판정이 건너뛰어졌다. 지금은 경로 기준 판정을 함께 쓴다
그리고 문서 맨 앞에 못박아뒀다: 이 게이트는 UX 이지 보안이 아니다. 브라우저에서 전부 우회 가능하고 실제 방어선은 서버다. 이 한 줄이 없으면 프런트 게이트를 방어선으로 착각한 설계가 들어온다.
규약을 앱 문서에 적어둔다
앱마다 CLAUDE.md 를 두고 이 앱의 규약을 적었다. 사람과 코딩 에이전트가 같은 파일을 읽는다.
적어둔 것들:
- 엔드포인트 단일 출처 —
shared/config/endPoints.ts의API.<group>.<key>. 48개 그룹, 753행. 경로 문자열을 화면에 흩어 두면 서버가 경로를 바꿀 때 grep 으로 다 찾지 못한다 - 페이지네이션 쿼리 표준 —
offset / limit / order / sort. 화면마다 다르면 목록 훅을 공유할 수 없다 - 응답 검사 —
assertCrmSuccess(res.data)를 호출 후 항상. HTTP 200 이면서success: false인 응답이 있다 - 카드 패널 + sort select 표준 — 목록 화면의 컨테이너 구조를 마크업 수준으로 고정. 41개 슬라이스가 조금씩 다르게 생기는 것을 막는다
- 다이얼로그 표준 — outside 클릭·ESC 모두 차단. 폼 작성 중 실수로 닫히면 입력이 날아간다
- FormData 업로드 시
Content-Type헤더 명시 금지 — 브라우저가 boundary 를 붙여야 한다. 직접 지정하면 서버가 파싱을 못 한다
문서화 기준은 하나다. 한 번 물려본 것만 적는다. 일반론을 적으면 분량만 늘고 읽히지 않는다.
이 앱에서만 나온 함정들
41개 슬라이스를 만들면서 같은 데 두 번 물린 것들. 전부 재현·확인 후 기록했다.
알림 팝업이 목록 API 를 무한 재호출한다. 공용 useConfirm() 의 alert() 이 Provider 상태를 바꿔 모든 소비자를 재렌더한다. 인라인 객체를 prop 으로 넘기고 그걸 effect 의존성에 넣어둔 화면에서, 조회가 실패하면 → alert → 재렌더 → 새 객체 → effect 재실행 → 조회 실패 → alert. 루프가 닫힌다.
팝업 안에서 알림을 띄우면 부모 팝업까지 닫힌다. radix layer 사본이 2개 생기는 상황에서 알림이 최상위 layer 를 닫으면 그 아래 다이얼로그도 함께 내려간다. 저장 실패를 알리려다 사용자가 입력한 폼을 날린다.
overflow-x-auto 래퍼가 sticky 를 조용히 무효화한다. 가로 스크롤이 필요한 넓은 표에 헤더 고정을 넣으면 동작하지 않는다. 에러도 경고도 없다.
shadcn 기본 클래스가 반응형 변형이면 안 덮인다. 컴포넌트 기본값이 md:text-sm 인데 text-[13px] 로 덮으려 하면 768px 이상에서 무효다. tailwind-merge 는 서로 다른 breakpoint 를 충돌로 보지 않는다. md:text-[13px] 를 같이 붙여야 한다.
className 의 폭 지정이 내부 w-full 에 밀린다. 공용 select 컴포넌트에 w-32 를 줘도 안 먹는다. min-w/max-w 를 병기해야 고정된다.
공통점이 있다. 전부 에러 없이 조용히 틀린다. 컴파일도 되고 콘솔도 깨끗하다. 그래서 물려본 사람의 기록이 유일한 방어다.
지금 시점의 정직한 평가
잘 된 것
- 탭 시스템. 요구사항 자체가 까다로웠는데 재마운트 키와 두 저장소 분리로 정리됐다. 특히
atomFamily누수를 개인정보 관점에서 처리한 것 - 진입 판정을 순수 함수로 뽑은 것. 보안 판정을 JSX 분기에 두면 검증할 수 없다
- 권한 prop 을 required 로 강제한 것. 컴파일러가 배선 누락을 잡는다
- 함정을 재현 후에만 기록한 것. 41개 슬라이스가 같은 실수를 반복하지 않았다
아직 안 된 것
- FSD 레이어가
views/entities/shared뿐이다.widgets/features가 없다. 이 앱은 목록–상세–다이얼로그가 반복되는 어드민이라 지금은 충분하지만, 복합 UI 블록이 늘면views가 비대해진다 - 스토어의 실패 처리가 침묵한다. 조회 실패 시
catch가 에러를 삼키고 "조회 완료" 상태로 못박아서, 장애와 권한 없음이 화면에서 구분되지 않고 새로고침 전까지 복구되지 않는다 - 조회 전용 화면 일부에 CREATE/UPDATE/DELETE 권한이 과다 등록되어 있다. 권한 부여 화면에서 있지도 않은 기능을 줄 수 있다
다시 한다면
탭 시스템을 먼저 만들고 화면을 얹었는데, 순서가 맞았다. 나중에 얹으려면 41개 슬라이스의 상태 관리를 전부 고쳐야 했다.
반대로 스토어의 에러 상태를 처음부터 3분기로 뒀어야 했다. 성공 / 실패 / 미조회 를 구분하지 않고 boolean 두 개로 시작한 게 지금 남은 부채다. 실패를 성공처럼 저장하면 그 뒤 모든 판정이 잘못된 전제로 돌아간다.
남겨둘 규칙 3개
- 조용히 틀리는 것은 기록으로만 막는다. 컴파일과 콘솔이 잡아주는 건 이미 문제가 아니다
- 보안·권한 판정은 실행 가능한 단위로 분리한다. JSX 안에 있으면 진리표를 만들 수 없다
- 실패를 성공처럼 저장하지 않는다.
fetched: true로 못박기 전에 그게 성공인지 확인한다