메뉴에서 지웠는데 URL로는 들어가집니다

메뉴에서 지웠는데 URL로는 들어가집니다

August 7, 2026

설계와 구조
고객사마다 다른 화면, 코드는 하나
  1. 1. 코드에 박힌 고객사 이름 지우기
  2. 2. 메뉴에서 지웠는데 URL로는 들어가집니다

사이드바에서 지운 화면이 URL로는 열렸습니다

제가 만드는 건 고객사 서버에 직접 설치되는 B2B 제품이에요.
같은 코드베이스가 여러 고객사에 나가는데, 고객사마다 계약한 기능이 다릅니다.

1편에서는 서버가 “이 고객사에 켜진 메뉴” 목록을 내려주게 하고,
그 키를 모아둔 enabledKeys로 사이드바와 ⌘K 검색창을 걸렀습니다.
고객사와 계약하지 않은 기능은 화면에서 사라졌어요.

그런데 주소창에 경로를 직접 치면 들어가집니다.

사이드바:  대시보드 · 알림 설정      ← 리포트 없음
주소창:    /reports  →  리포트 목록 화면이 그대로 열림

라우터에 아무 가드도 없던 건 아닙니다.
이 제품은 계정마다 역할이 정해져 있어요.
관리자로 로그인하면 관리 화면 전체를 쓰고,
일반 사용자로 로그인하면 채팅 화면만 씁니다.
라우터가 그 역할에 맞는 경로만 열어주고 있었습니다.
제가 만든 코드는 아니고 원래 있던 거예요.

const allowedRolePathPatterns = {
  SUPERADMIN: '.*',
  ADMIN: '.*', // 관리자는 모든 경로 통과
  USER: '/chat/.*',
};

그런데 이 가드는 역할만 봅니다.
“이 사람이 관리자인가”는 묻지만 “이 고객사가 이 기능을 계약했는가”는 아무도 묻지 않아요.
관리자면 무조건 .*이니,
리포트를 계약하지 않은 고객사의 관리자도 주소만 알면 그대로 들어갑니다.

아무도 안 들어올 거라고 봤습니다

사이드바에 링크가 없으면 아무도 안 들어갈 거라고 생각했습니다.
주소를 외워서 치는 사람이 어디 있나 싶었고,
진짜 데이터는 어차피 서버 권한이 막고 있으니까요.
그런데 두 군데서 이 가정이 안 맞았습니다.

링크를 지워도 URL은 남습니다

  • 북마크에 남습니다
  • 메신저로 공유한 링크에 남습니다
  • 사용자 매뉴얼 문서에 적혀 있습니다
  • 브라우저 주소창 자동완성에 뜹니다

특히 한 번이라도 그 기능을 켰다가 끈 고객사라면,
사내 여기저기에 링크가 이미 흩어져 있습니다.
한번 나간 링크는 회수할 방법이 없어요.

열리는데 안 되는 화면은 고장으로 보입니다

그리고 실제로 그 상태가 어떻게 보이는지가 더 문제였습니다.
화면은 멀쩡히 열리는데 목록은 비어 있고,
버튼은 눌리는데 저장하면 서버가 권한 없음(403)을 돌려줍니다.

사용자는 이걸 계약에 없는 기능이라고 생각하지 않고 고장 난 화면으로 받아들입니다.
1편에서 사이드바를 거른 이유가 정확히 이거였는데, 절반만 한 상태였어요.

라우트에 메뉴 키를 붙였습니다

숨기는 판정과 막는 판정이 같은 데이터를 쓰게 하고 싶었습니다.
둘이 어긋나면 사이드바에는 없는데 URL로는 들어가지거나,
반대로 사이드바에는 있는데 막히는 일이 생깁니다.

새 매핑 파일을 만드는 대신 react-router의 handle을 썼습니다.
이미 padding, scroll 같은 레이아웃 옵션을 여기 달고 있어서, 새로 만들 게 없었어요.

🤔 react-router의 handle이란?
라우트 정의에 임의의 메타데이터를 달아두는 필드입니다. useMatches()로 현재 매칭된 라우트 체인을 훑으면 각 라우트의 handle 값을 읽을 수 있습니다.

{
  children: [
    { element: <ReportListPage />,   path: '/reports' },
    { element: <ReportDetailPage />, path: '/reports/:id' },
    { element: <ReportCreatePage />, path: '/reports/new' },
  ],
  handle: { menuKey: 'report' },   // 그룹에 한 번만
}

키는 화면 단위가 아니라 도메인 그룹에 한 번만 붙입니다.
훅이 매칭된 라우트 체인을 훑어서, 자식 화면도 부모에 붙은 menuKey를 그대로 쓰게 했어요.

flowchart TB
  G["라우트 그룹<br/>handle: menuKey = report"] --> P1["/reports"]
  G --> P2["/reports/:id"]
  G --> P3["/reports/new"]
  P1 --> K["매칭된 체인을 거슬러<br/>menuKey 하나를 찾음"]
  P2 --> K
  P3 --> K

리포트 화면이 5개든 10개든 붙이는 키는 하나입니다.

버린 안이 셋 있습니다

방법왜 안 골랐나
URL 앞자리로 매핑하는 표라우트를 안 건드려도 돼서 제일 끌렸는데, 대시보드 경로가 /라 앞자리 매칭이 성립하지 않았습니다. 무엇보다 라우트 정의와 그 표가 서로 떨어져 있어서, 나중에 화면을 추가한 사람이 표의 존재를 모르면 에러 없이 그냥 안 막힙니다
기존 역할 가드에 조건 추가이미 경로를 검사하는 함수가 있으니 조건만 하나 더 추가하면 됩니다. 그런데 “이 사람이 볼 권한이 있는가”와 “이 고객사가 계약했는가”는 답이 다른 질문이고, 실패했을 때 보여줄 화면도 다릅니다(권한 없음 vs 미제공). 한 함수에서 섞으면 나중에 둘 중 하나만 바꾸기 어려워집니다
라우터 loader에서 리다이렉트렌더 전에 막을 수 있어 깔끔하지만, 원하는 건 다른 곳으로 보내는 게 아니라 “제자리에 안내를 띄우고 사이드바는 남기기”였습니다. loader로 하면 그 화면을 만들려고 일이 오히려 늘어납니다

판단이 애매하면 통과시킵니다

판정은 순수 함수로 빼고, 컴포넌트는 결과만 렌더링하게 했습니다.

상황판정
menuKey가 없는 라우트✅ 통과 (로그인·404처럼 가드 대상이 아님)
메뉴 응답이 아직 안 도착⏳ 대기 (로딩만 보여줌)
메뉴 응답이 실패✅ 통과
켜진 키가 하나도 없음✅ 통과
enabledKeys에 있음✅ 통과
enabledKeys에 없음⛔ 차단

여섯 줄 중 판단이 안 서는 두 줄(응답 실패, 켜진 키 없음)을 통과로 뒀습니다.

🤔 fail-open이란?
판단이 안 설 때 막는 쪽(fail-closed)으로 가지 않고 통과시키는 쪽으로 기울이는 설계입니다. 보안 장치는 보통 반대로 갑니다.

이 가드는 계약에 없는 기능이라는 걸 알려주는 용도입니다.
메뉴 응답이 잠깐 안 온다고 멀쩡한 고객사의 화면까지 막으면 손해가 더 큽니다.

막을 때는 사이드바를 남겨두고, 콘텐츠 영역만 안내 화면으로 바꿨습니다.
화면 전체를 안내로 바꾸면 다른 메뉴로 이동할 방법이 없어요.

규칙을 사람 손에 맡기지 않고 테스트로 검사했습니다

라우트 옆에 붙여두면 눈에는 보이니까 괜찮을 줄 알았는데,
보이는 것과 빠뜨리지 않는 건 또 달랐습니다.
menuKey 하나가 네 군데에 흩어져 있거든요.

어디무엇
서버 응답menuKeyenabled
사이드바 config코드에 둔 메뉴 목록 배열, 어떤 메뉴를 보여줄지
라우트 handle어떤 화면이 어떤 메뉴에 속하는지
i18n 리소스그 메뉴를 뭐라고 표시할지

넷이 같은 문자열이어야 동작하는데, 이걸 맞추는 건 순전히 사람 손입니다.
새 화면을 만든 사람이 handle 붙이는 걸 잊으면 그 화면은 아무한테나 열립니다.
오타가 나면 그 화면은 아무한테도 안 열리고요.
둘 다 에러가 안 나서 알아채기 어렵습니다.

그래서 마지막에 사람이 빠뜨리기 쉬운 것들을 테스트로 검사하게 했습니다.

  • 라우트에 붙인 키가 사이드바 config에 실제로 있는가
  • 사이드바에 있는 메뉴가 라우트에도 붙어 있는가
  • 관리자 라우트에 사용자 전용 키를 붙이지 않았는가
  • 새로 만든 화면에 키가 빠지지 않았는가 (미리 적어둔 예외 목록에 있는 것만 허용)

넷을 사람이 맞추고 있으니 언젠가 하나는 어긋날 거라고 봤습니다.
어긋나도 에러가 안 나서, 화면이 잘못 동작하는 걸 한참 모를 수 있거든요.

막긴 했는데 보안 장치는 아닙니다

이제 /reports를 직접 쳐도 리포트 목록은 안 열립니다.
사이드바는 그대로 남고 오른쪽만 안내 화면으로 바뀌어서, 다른 메뉴로 이동할 수 있어요.
역할만 검사하던 라우터가 계약 여부도 같이 검사하게 됐습니다.

그래도 이건 여전히 안내 화면입니다.
개발자 도구를 열어 메뉴 응답을 바꿔치면 화면은 그대로 열립니다.
실제 데이터는 서버 권한이 막고 있고, 프론트 가드는 알려주는 역할입니다.
도입에서는 “어차피 서버가 막는다”를 안 해도 되는 이유로 봤는데,
지금은 서버가 뭘 막고 프론트가 뭘 알려줄지 나누는 데 쓰고 있어요.

남은 문제도 하나 있습니다.
로그인 직후 첫 화면을 정하는 로직이 이 메뉴 가드와 별도로 돌아갑니다.
사용자 화면을 끈 고객사에서 사용자 계정으로 로그인하면,
첫 화면이 곧바로 안내 화면이 되는 경우가 생겨요.
같은 데이터를 써야 어긋나지 않는다고 글 내내 말해놓고,
정작 한 군데가 아직 안 맞춰져 있습니다.

두 편을 쓰고 보니 같은 일이 계속 반복됐습니다.
고객사별 차이를 코드에서 서버 데이터로 옮긴 건 1편에서 끝났는데,
그 데이터를 쓰는 곳은 그 뒤로도 계속 늘었어요.
사이드바, ⌘K 검색창, 라우터, 그리고 아직 안 고친 첫 화면까지요.
데이터를 옮기는 것보다 그 데이터를 쓸 곳을 빠짐없이 찾는 데 시간이 더 들었습니다.

  • react-router handle
  • 라우트 가드
  • 프론트엔드 권한 처리
  • fail-open
  • 고객사별 기능 차이
  • URL 직접 접근
  • 라우트 메타데이터
  • useMatches
  • 메뉴 키 정합성 테스트