코드에 박힌 고객사 이름 지우기

코드에 박힌 고객사 이름 지우기

August 5, 2026

설계와 구조

고객사마다 다른 메뉴를 보여줘야 했습니다

A사는 우리가 만든 기능을 거의 다 씁니다.
B사는 그중 두 개만 씁니다.
같은 코드베이스에서 나간 같은 제품인데, 고객사마다 계약한 기능이 다르거든요.

제가 만드는 건 온프레미스로 설치되는 B2B 제품이고,
저는 여기서 프론트엔드를 맡고 있습니다.
그래서 고객사 서버에 설치하러 들어가기 전에
“이번 고객사는 어떤 기능까지 들어간다”를 먼저 정하고 갑니다.

🤔 온프레미스(on-premise)란?
제품을 우리 서버가 아니라 고객사 내부 서버에 직접 설치해서 쓰는 방식입니다. 인터넷과 분리된 망에 들어가는 경우도 많아서, 고객사마다 설치된 버전도 계약한 기능도 다를 수 있습니다.

그 “어떤 기능까지”를 코드에서 어떻게 표현할지가 문제입니다.
저는 이걸 두 번 다르게 풀어봤고, 이 글은 세 번째 이야기예요.

처음엔 고객사마다 레포를 나눴습니다

이전에 있던 솔루션 프로젝트가 그랬습니다.
B2B에서 흔한 모양이에요.
고객사 요구가 다르니 포크를 떠서 거기서 고칩니다.
사이드바 메뉴 목록도 코드에 상수로 뒀는데,
레포마다 그 상수를 고객사에 맞게 손보면 그만이었습니다.

처음엔 요구사항을 빨리 반영할 수 있어서 좋았는데,
레포가 서너 개로 늘어나고부터 같은 일을 여러 번 하게 됐습니다.

  • 같은 기능을 만들어도 레포 수만큼 반영해야 합니다
  • 버그 하나 고치면 그 수만큼 다시 고쳐야 합니다
  • 어느 레포에 무엇이 들어갔는지도 시간이 지나면 아무도 모릅니다

그다음엔 한 레포에서 .env로 갈랐습니다

그래서 지금 제품은 레포를 하나로 뒀습니다.
대신 고객사별 차이를 어딘가엔 둬야 하니,
계약에 없는 기능은 진입점을 .env 값으로 막았어요.

export const isCustomerAService = () =>
  import.meta.env.VITE_API_SERVICE_TYPE === 'customer-a';

원래는 저 자리에 실제 고객사 이름이 들어가 있었고,
여기서는 익명으로 바꿔 적었습니다.
고객사 이름이 코드에 박혀 있는 함수입니다.
고객사가 늘어날 때마다 키를 하나 추가하고 if를 하나 더 써야 했고,
if가 계속 늘었습니다.

그리고 사이드바는 이 분기와 상관없이 그대로 그려졌습니다.
레포가 따로일 땐 상수를 고객사에 맞게 손보면 됐는데,
한 레포로 합치면서 그 상수를 모든 고객사가 같이 쓰게 됐거든요.

// entities/menu/config/managerNavigation.ts
export const MANAGER_NAVIGATION_CONFIG = [
  { title: 'dashboard', type: 'link', url: ROUTE_PATH.DASHBOARD },
  { title: 'report', type: 'link', url: ROUTE_PATH.REPORT },
  { title: 'data', type: 'collapsible', items: [ /* 데이터 소스, 알림 설정 */ ] },
  // ... 총 12개
];

이 배열이 그대로 화면이 됩니다.
.env로 안쪽 화면을 막아둬도 사이드바에는 메뉴 12개가 그대로 떠 있었어요.
두 개만 계약한 B사도 나머지 열 개를 메뉴에서 다 봤습니다.
눌러보면 빈 화면이 나오거나 서버가 권한 없음(403)을 돌려줬습니다.

급하지 않은 문제를 지금 하기로 했습니다

사실 이건 그냥 둬도 되는 문제였습니다.
안 쓰면 그만이고 당장 급한 것도 아니니까요.
그런데 걸리는 게 두 가지 있었습니다.

하나는 화면이 제품 사양을 잘못 말하고 있다는 점이었습니다.
어떤 기능이 계약에 들어갔는지는 우리만 압니다.
화면을 쓰는 사람은 메뉴가 보이면 되는 기능이라고 생각해요.
그래서 눌러서 안 되면 계약에 없는 기능이라고 생각하지 않고 고장 났다고 받아들입니다.
그러면 문의가 들어오는데,
답이 “그건 계약에 없습니다”가 되는 순간 저희가 코드로 풀 수 있는 문제가 아니게 됩니다.

다른 하나는 이걸 막는 방법 쪽이었습니다.
급하게 막는 방법은 확실히 있고, 쉬운 방법이기도 합니다.
.env에 키를 하나 더 두고 사이드바 상수에도 if를 하나 더 쓰면 되니까요.

그런데 그렇게 하면
레포를 나눴을 때 겪은 문제가 파일 하나 안에서 그대로 반복됩니다.
레포는 하나인데 그 안에서 고객사별로 갈라지니까요.
이런 분기가 열 개쯤 되면 어느 게 아직 유효한지 알기 어려워지고,
그때 가서 되돌리려면 훨씬 오래 걸립니다.
그러면 레포를 합친 이유가 없어집니다.

그래서 지금은 고객사 요구가 들어와도 그대로 코드에 넣지 않으려고 합니다.
“이게 제품 기능으로 말이 되는가”를 한 번 검토하고, 되면 기능으로 만듭니다.
쓰고 보니 너무 당연한 원칙인데, 회사에서 이게 마음처럼 잘 되지는 않습니다.
납기가 급하면 “일단 이 고객만” 하고 분기를 하나 더 만드는 쪽이 늘 더 간단해 보이거든요.

마침 새 고객사 설치 일정이 잡히면서 이게 실제 요구사항이 됐습니다.

  • 계약한 메뉴만 보일 것
  • 일반 사용자용 화면은 아예 없을 것
  • 로그인하면 계약한 메뉴 중 하나가 먼저 뜰 것

방법은 두 가지를 놓고 봤어요.

방법판단
.env 키를 늘리기지금 쓰던 방식이라 제일 빠르지만, 고객사가 늘 때마다 분기가 같이 늘어납니다
서버가 켜진 메뉴를 내려주기백엔드와 스펙을 맞춰야 하고 응답이 없을 때를 처리해야 하지만, 고객사가 늘어도 프론트는 안 바뀝니다

두 번째를 골랐습니다.
고객사마다 다른 부분을 코드에 두지 않고,
서버가 내려주는 데이터로 옮기는 쪽입니다.

서버가 켜진 메뉴를 내려주게 했습니다

메뉴로 들어가는 입구가 사이드바 말고 하나 더 있어서,
⌘K로 여는 검색창까지 둘 다 같은 데이터를 쓰게 했습니다.

고객사별 차이를 어디에 뒀는지 세 번을 나란히 놓으면 이렇습니다.

flowchart TB
  subgraph S1["1차 · 레포가 결정"]
    R1["고객사별 레포"] --> R2["레포마다 상수를 손봄"]
  end
  subgraph S2["2차 · 코드가 결정"]
    E1["레포 하나"] --> E2[".env 키 + if"]
    E2 --> E3["진입점만 차단<br/>사이드바는 12개 그대로"]
  end
  subgraph S3["3차 · 데이터가 결정"]
    A1["서버 메뉴 응답"] --> A2["enabledKeys"]
    A2 --> A3["사이드바 필터"]
    A2 --> A4["⌘K 검색 팔레트 필터"]
  end

응답은 이런 트리로 옵니다.
이 제품은 관리자가 쓰는 화면과 일반 사용자가 쓰는 채팅 화면이 나뉘어 있어서,
트리도 menususerMenus로 따로 옵니다.

{
  "menus": [
    { "menuKey": "dashboard", "enabled": true,  "children": [] },
    { "menuKey": "report",    "enabled": false, "children": [] },
    { "menuKey": "data",      "enabled": false,
      "children": [{ "menuKey": "alert", "enabled": false, "children": [] }] }
  ],
  "userMenus": [ /* 사용자 화면용 별도 트리 */ ]
}

프론트가 할 일은 켜진 키를 모아 enabledKeys라는 Set으로 만들어두고,
앞에서 본 MANAGER_NAVIGATION_CONFIG를 그 키로 거르는 것입니다.

export function collectEnabledMenuKeys(menus: MenuNode[], acc = new Set<string>()) {
  for (const menu of menus) {
    if (!menu.enabled) continue; // 부모가 꺼져 있으면 자식은 순회하지 않습니다
    acc.add(menu.menuKey);
    collectEnabledMenuKeys(menu.children ?? [], acc);
  }
  return acc;
}

config를 거르는 것 자체는 몇 줄이었는데,
실제로 시간을 쓴 건 그 주변 결정들이었습니다.

표시용 이름은 서버에서 받지 않았습니다

응답에는 menuName도 같이 옵니다.
“대시보드”, “리포트” 같은 화면에 그대로 쓸 수 있는 이름이에요.
안 썼습니다.
한글로 고정돼 있어서 영어로 바꿔도 메뉴 이름만 한글로 남기 때문입니다.

메뉴 응답 스펙은 백엔드와 맞춰서 나온 거라,
menuName이 한글로 고정된 건 제가 정할 수 있는 부분이 아니었어요.
마침 menuKey가 기존 i18n 키와 같은 문자열이라,
키만 받고 이름은 로컬 i18n에서 가져왔습니다.
서버가 나중에 번역 키를 내려주게 되면 그때 옮기기로 하고 TODO를 남겨뒀습니다.

부모만 남는 빈 아코디언을 없앴습니다

“데이터”는 켜져 있는데,
그 아래 데이터 소스와 알림 설정이 전부 꺼지는 조합이 나옵니다.
그러면 눌러도 아무것도 안 열리는 아코디언이 사이드바에 남습니다.

// 자식을 먼저 거른 뒤
.filter(item => item.type !== 'collapsible' || item.items.length > 0)

자식이 하나도 안 남은 그룹은 부모까지 지웁니다.
서버가 부모를 꺼주면 좋겠지만,
부모와 자식의 enabled 값을 따로 관리하는 이상 둘이 어긋날 거라고 봤어요.

로그인 후 첫 화면을 고정하지 않았습니다

로그인하고 나서 어디로 보낼지가 남았습니다.
/dashboard로 고정하는 게 제일 간단한데,
그러면 대시보드를 끈 고객사에서는 사이드바에 없는 화면이 먼저 뜹니다.

return firstLinkUrl(filterMenuByEnabled(MANAGER_NAVIGATION_CONFIG, enabledKeys));

그래서 “필터를 통과한 첫 메뉴”로 보냈습니다.
config에서 빼든 서버가 끄든 첫 화면도 같이 바뀝니다.
기본값을 상수로 고정해두는 자리를 볼 때마다 한 번씩 의심하는 편인데,
이 자리가 딱 그랬어요.

구버전 서버와의 하위 호환을 챙겼습니다

가장 조심한 건 서버가 이 필드를 안 내려줄 때입니다.

온프레미스라 프론트와 서버가 늘 같은 버전은 아닙니다.
고객사 서버는 그 고객사 일정에 맞춰 업데이트되니까요.
사용자 화면 메뉴(userMenus)는 이번에 새로 생긴 필드라,
아직 업데이트되지 않은 고객사에서는 응답에 이 필드가 없습니다.
새 프론트가 구버전 서버에서도 그대로 돌아야 하는, 하위 호환 문제입니다.

이때 필드가 없는 것을 “켜진 메뉴가 하나도 없음”으로 처리하면,
프론트를 배포하는 순간 잘 쓰고 있던 고객사의 채팅 메뉴가 전부 없어집니다.
서버가 필드를 안 내려준 상황과 빈 목록을 내려준 상황은 다릅니다.

그래서 응답 상태를 세 가지로 나눴습니다.
필드가 없으면 필터를 쓰지 않고 전부 보여줍니다.

type UserMenuState =
  | { status: 'loading' }      // 조회 중이라 아직 판단할 수 없음
  | { status: 'unsupported' }  // 서버가 필드를 안 줌, 필터 없이 전부 노출
  | { status: 'ready'; keys: Set<string> };

코드에서 고객사 이름이 사라졌습니다

이제 서버 응답에 따라 사이드바가 달라집니다.
두 개만 계약한 B사에는 사이드바에 그 두 개만 뜹니다.
코드에는 고객사 이름이 없고, 새 고객사가 늘어도 프론트는 안 바뀝니다.
앞에서 본 isCustomerAService()도 쓸 데가 없어져서 마지막에 지웠습니다.

그런데 URL로는 그대로 들어가집니다

여기까지 해도 완전히 막히지는 않았습니다.
주소창에 /reports를 직접 치면 그 화면이 그대로 열립니다.
사이드바와 검색에서 지웠을 뿐, 화면 자체는 하나도 막지 않은 상태였습니다.
링크가 없으면 아무도 안 들어갈 거라고 생각했는데,
그 가정이 맞는지는 확인해보지 않았어요.

남겨둔 것도 있습니다.

  • 메뉴 이름은 아직 로컬 i18n에 있습니다. 서버가 번역 키를 내려주게 되면 그때 옮겨야 합니다
  • 부모와 자식의 enabled 값이 따로 관리되는 것도 그대로입니다

그래서 다음으로는 라우터에서도 같은 판정을 쓰는 작업을 했습니다.
메뉴에서 지우는 것과 실제로 못 들어가게 하는 것은 서로 다른 일이었습니다.

  • 온프레미스 멀티테넌시
  • 피처 플래그
  • 고객사별 분기
  • 서버 주도 메뉴
  • 사이드바 메뉴 권한
  • B2B 프론트엔드
  • 메뉴 필터링
  • 환경변수 분기
  • API 하위 호환