알스솔루션
BI 서비스 신규 구축
약품 판매 실적 데이터를 제약사 운영자·사내 운영자에게 시각화·분석 형태로 제공하는 사내 BI 서비스. 제약사용 서비스 앱과 사내 어드민 콘솔 두 프로덕트를 pnpm 모노레포 한 저장소에서 공유 패키지 기반으로 구축
주요 성과
- 제약사 사용자용 서비스 앱과 사내 운영자용 어드민 콘솔 두 프로덕트를 pnpm 모노레포로 구축 — 인증·차트·도메인 레이어는 공유 패키지로 묶고 앱 셸(라우터·가드·경로)은 각 앱이 독립 보유해 교차 의존 제거
- 서비스 앱: 대시보드 · 통합분석 5축(출고·채널·지역·시장·제품) · 데이터 활용 · 계정관리 · 고객센터 / 어드민 앱: 계약사·어드민 계정 관리 · 운영 콘텐츠 · 데이터 전처리 · 보안 접근 로그
- 직전 사내 백오피스 리팩토링에서 사전 검증한 Antd 디자인 시스템을 본격 적용. 차트도 라이브러리 직접 사용을 막고 공유 래퍼 6종만 공개해 디자인 시스템에 편입
- 라이브러리의 이중 축 컴포넌트가 자식별 y 스케일을 독립 강제해 라인이 소실되는 문제를, 두 마크가 스케일을 공유하도록 차트 뷰를 직접 조립한 컴포넌트로 해결 — 래퍼 층 덕분에 대응이 공유 패키지 안에서 끝나, 명령형 차트 뷰로 내려간 사실이 화면으로 번지지 않음
- 아키텍처 규약을 문서가 아니라 도구로 강제: 단방향 import·feature 간 직접 참조 금지는 lint 에러로, SDK 호출을 데이터 레이어로 제한하는 경계는 테스트로 고정
- 동시성·세션 경계 설계: 조회 함수 65개에 취소 신호를 관통 전달해 실제 HTTP 취소 적용, 세션 세대로 늦게 도착한 401을 분류. 세션 흔적 정리는 스토어 한 경로로만 내려 다계정 복원 힌트가 남아 다른 계정으로 세션이 되살아나는 경로를 차단
- 권한 설계: 알스마스터·알스어드민·계약사마스터·계약사일반 4종 역할에 대해 ①버튼·메뉴 미렌더 ②라우트 가드 ③페이지 가드 세 지점의 조건을 일치시켜 "버튼은 보이는데 누르면 튕기는" 불일치를 제거. 역할 헬퍼 단위 테스트 · 실제 route object 통합 테스트 · 브라우저 E2E를 각각 다른 책임으로 분리
- CS 고객 화면 보기: 어드민이 계약사 화면을 그대로 보는 세션을 창 간 postMessage로 인계. 양쪽 모두 상대 origin을 명시(`'*'` 금지)하고 `event.origin`·`event.source`를 검증하며, 토큰은 메모리에만 두고 결과는 토큰 없는 응답으로 회신. CS 탭은 분석 스크립트 미로드 · 쿠키 세션 복원 생략 · 토큰 없는 요청 차단 · 읽기 전용 가드로 격리하고, 세션 종료도 일반 로그아웃과 분리해 같은 브라우저의 본인 세션을 끊지 않도록 설계
- 분석 도구(GA4 · Microsoft Clarity)를 계약사 데이터 유출 없이 도입: 개인정보 parameter 제거와 `page_location` 정제를 공통 client에 두어 deep link 토큰까지 자동 차단, 사용자 식별은 인증 스토어 구독 한 곳으로 모아 모든 로그아웃·만료 경로에서 자동 해제. Clarity 마스킹은 대시보드 설정이 아니라 `<body>` 속성으로 코드에서 강제 — Antd Modal·Drawer가 `#root` 밖으로 portal되기 때문
- 실시간 알림센터: SSE 스트림으로 보정 반영 알림을 수신하고 토스트·데스크톱 알림·인박스를 공유 패키지 모듈로 통합
- 테스트 233개 파일·1,389개 케이스(Vitest) + 역할별 인증 E2E 31개(Playwright, 8 spec). 리렌더 횟수가 계약인 화면은 렌더 카운팅 스텁으로 memo 무력화 회귀를 검출
- 배포: 정적 산출물에 API 호스트를 굽지 않고 동일 origin 상대경로만 사용해 dev·prod가 같은 이미지를 공유. 운영 네임스페이스 보안 정책에 맞춰 비루트 nginx 이미지로 전환
- 전처리 결과의 생산 측은 BI 데이터 전처리 백오피스 — 이 서비스의 어드민 '데이터 전처리' 화면과 수집 현황 요약을 공유
관련 블로그 글
BI 서비스의 상태 관리 구조 — React Query는 서버 상태 게이트웨이, Zustand는 캐시 + 영속화
als-bi 프로젝트에서 서버 상태(React Query)와 클라이언트 전역 상태(Zustand)를 어떻게 나누고 어디서 연결하는지, 그리고 SessionBootstrap이 풀어주는 쿠키 ↔ store 정합성 문제까지 정리.
Query Key는 캐시 주소이자 계정 경계다 — BI 서비스의 React Query 캐시 규칙
Query Key 설계 규칙, mutation 이후 cancel → invalidate 순서, 삭제 시 remove가 필요한 이유, 그리고 계정 전환·늦은 401을 다루는 세션 세대 스코프까지. BI 서비스 두 앱이 공유하는 캐시 정책을 정리했다.
설계 자세히 보기
두 프로덕트를 한 저장소에서 굴리기 위해 어떤 경계를 세웠고, 그 경계를 말이 아니라 도구로 어떻게 강제했는지. 라이브러리 한계에 부딪혔을 때 화면 코드를 지키기 위해 어디를 갈아끼웠는지. 그리고 남의 세션으로 들어가는 기능과 외부 분석 도구처럼 “편의와 데이터 경계가 정면으로 부딪히는” 요구를 어떤 선으로 나눴는지.
2프로덕트
서비스 앱 · 어드민 콘솔
5축
통합분석 (출고·채널·지역·시장·제품)
65개
취소 신호를 관통시킨 조회 함수
1,389케이스
Vitest 233개 파일 + E2E 31개
1. 두 프로덕트, 한 저장소
제약사 사용자가 쓰는 서비스 앱과 사내 운영자가 쓰는 어드민 콘솔은 사용자도 권한도 다르지만, 인증·차트·계약사/계정 도메인은 같은 것을 쓴다. 그래서 공통은 공유 패키지로 올리되 앱 셸은 각 앱이 독립 보유하도록 나눴다. 라우터·가드·경로를 공유하면 한쪽의 라우팅 변경이 다른 쪽으로 새는데, 두 앱은 서로의 화면으로 이동할 일이 아예 없다.
2. 규약을 문서가 아니라 도구에 둔다
경계는 지켜야 지켜진다. “이렇게 하기로 했다”는 문서는 바쁜 날 가장 먼저 깨진다. 그래서 지켜야 할 규칙을 전부 lint 에러나 실패하는 테스트로 바꿨다.
app → features → shared 단방향
eslint 경계 규칙shared가 앱 코드를 참조하면 두 앱이 서로에게 묶인다
feature 간 직접 import 금지
eslint 경계 규칙 (lint 에러)공유가 필요하면 승격 — 한 앱 안이면 앱 로컬 티어, 두 앱이면 shared
SDK 호출은 데이터 레이어에서만
경계 테스트SDK 교체·이름 변경의 파급을 파일 한 줄로 막는다
차트 라이브러리 직접 import 금지
eslint + 단일 진입점라이브러리 교체·자체 구현이 화면으로 번지지 않는다
3. 차트를 디자인 시스템 안으로
BI 서비스에서 차트는 부속이 아니라 제품 그 자체다. 화면마다 라이브러리를 직접 부르면 팔레트와 축 설정이 화면 수만큼 갈라진다. 그래서 화면은 공유 래퍼 6종만 쓰고, 래퍼만 단일 동적 진입점을 통해 라이브러리를 로드한다.
4. 이 층이 실제로 값을 한 순간
막대와 라인을 하나의 y축에 겹쳐 그려야 하는 화면에서, 라이브러리의 이중 축 컴포넌트가 자식마다 y 스케일을 독립으로 강제해 라인이 아예 사라졌다. 옵션으로 우회할 수 없는 구조적 제약이었다.
그래서 라이브러리가 주는 고수준 컴포넌트를 버리고, 두 마크가 y·color 스케일을 공유하도록 차트 뷰를 직접 조립한 컴포넌트를 만들었다. 화면에 라이브러리 직접 사용을 막아둔 덕분에 이 대응은 공유 패키지 안에서 끝났다. 화면은 여전히 래퍼 하나에 props를 넘길 뿐, 그 안이 선언형 컴포넌트인지 명령형 차트 뷰인지 모른다.
→ 이 층이 없었다면 같은 수정을 차트를 쓰는 화면 수만큼 반복해야 했다. 되살아나지 않도록 “이중 축 컴포넌트를 다시 쓰지 말 것”과 그 이유를 저장소 규약에 남겼다.
5. 사람 리뷰가 없는 환경의 안전망
조회 함수 65개에 취소 신호 관통. 화면 이탈·필터 재조회 때 요청이 실제로 끊긴다. 한 번 고치는 것보다 66번째가 빠지는 걸 막는 게 어려워서, 신호를 넘기지 않은 조회 함수가 새로 생기면 실패하는 구조 검사 테스트를 함께 뒀다.
렌더 카운팅 회귀 테스트. 리렌더 횟수가 사실상 계약인 화면은 차트·표를 카운팅 스텁으로 바꿔 상호작용별 렌더 횟수를 고정했다. 카운트가 늘면 props identity가 갈렸다는 뜻이라 memo 무력화가 리뷰 없이도 잡힌다.
계단식 검증 게이트. 바꾼 범위에 따라 타입체크·린트만, 해당 앱 테스트까지, 전체까지를 나눠 돌린다. 첫 실패에서 멈추고 그 단계 로그만 보여준다.
6. 운영자가 고객 화면을 그대로 보는 길
고객센터 문의는 “제 화면에 이게 안 나와요”로 온다. 운영자가 어드민에서 보는 집계와 계약사가 서비스에서 보는 화면은 다르기 때문에, 운영자가 그 계약사의 화면을 그대로 열어볼 수 있어야 했다. 문제는 이게 남의 세션으로 들어가는 기능이라는 점이다.
두 앱은 서로 다른 origin에서 돌고 라우팅도 공유하지 않는다. 그래서 토큰을 URL이나 저장소에 흘리지 않고 창 사이 메시지로 한 번만 넘기도록 했다.
열린 탭을 네 가지로 격리했다
분석 스크립트 미로드
운영자가 대신 보는 화면의 행동이 고객 사용 지표로 섞이지 않는다
쿠키 세션 복원 생략
같은 브라우저에 남아 있는 본인 세션으로 되살아나지 않는다
토큰 없는 요청 차단
CS 토큰이 실리지 않은 요청이 일반 세션 자격으로 나가는 경로를 끊는다
쓰기 전부 읽기 전용
문의 등록·내 정보 수정·계정 추가를 막고, 직접 URL은 가드가 목록으로 되돌린다
→ 가장 까다로웠던 건 종료다. CS 탭에서 로그아웃하거나 세션이 만료됐을 때 일반 로그아웃 경로를 타면 같은 브라우저에 있는 운영자 본인 세션까지 끊긴다. 그래서 CS 탭의 세션 종료는 CS 세션만 지우는 별도 경로로 분리하고, 사용자가 직접 끝낸 경우에만 창을 닫는다. 서버가 만료와 “다른 CS 로그인에 의한 교체”를 같은 오류로 돌려주므로 안내 문구에는 두 사유를 함께 적었다 — 추측해서 하나로 쓰면 틀린 안내가 된다.
7. 분석 도구를 붙이면서 계약사 데이터는 내보내지 않기
어떤 화면이 실제로 쓰이는지 알아야 다음 개선을 고를 수 있어서 GA4와 세션 리플레이 도구를 붙였다. 그런데 이 서비스 화면에는 제약사의 판매 실적이 그려져 있다. 도구를 기본 설정대로 켜면 그게 전부 외부로 올라간다.
마스킹을 대시보드 설정이 아니라 코드로. 리플레이 도구의 마스킹 수준은 관리 화면에서 누구나 되돌릴 수 있다. 그래서 문서 속성으로 전체 마스킹을 강제해 대시보드 설정을 덮도록 했다. 또 #root가 아니라 body에 걸었다 — 디자인 시스템의 모달·드로어·셀렉트는 루트 밖으로 portal되기 때문에 루트에만 걸면 모달 안의 숫자가 그대로 올라간다. 레이아웃·클릭·스크롤은 그대로 기록돼서 UX 분석 목적은 유지된다.
개인정보 제거를 이벤트마다가 아니라 공통 client에. 이름·이메일·검색어 같은 자유 텍스트를 보내지 않는 것은 규칙으로 두면 새 이벤트에서 깨진다. 공통 client가 알려진 개인정보 파라미터를 제거하고 모든 이벤트의 주소값을 origin + 경로만 남기도록 정제한다. 덕분에 비밀번호 재설정 링크의 토큰 같은 것도 자동으로 빠진다.
사용자 식별은 인증 스토어 구독 한 곳에서만. 로그인 버튼마다 식별값을 설정하면 로그아웃·만료·다른 탭 교체 중 하나가 반드시 빠진다. 그래서 인증 스토어를 구독해 user가 비는 모든 전이에서 식별을 해제하도록 한 곳에 모았다. 인증 경로를 새로 추가해도 스토어를 거치면 자동으로 따라온다.
→ 운영 쪽에도 함정이 있었다. 앱이 화면 전환을 직접 추적하므로 도구의 자동 추적 옵션을 끄지 않으면 같은 전환이 두 번 집계된다. 코드만 맞춰두고 끝낼 수 없어서 관리 화면에서 꺼야 하는 항목까지 저장소 규약에 남겼다.