잘 되면 부러지는 구조

멀티테넌트 SaaS는 아이러니한 특성이 있습니다. 제품이 잘 되면 구조가 부러집니다.

고객이 늘고, 기능이 추가되고, 팀이 커지면서 초기에 잘 설계했던 구조가 하나둘 무너지기 시작합니다. 이건 설계를 못해서가 아니라, 성장 자체가 만드는 압력 때문입니다.

크로노젠은 200개 이상의 테이블, 7개 버티컬, 134개 이상의 API를 운영하면서 이 문제들을 직접 경험했습니다. 아래 4가지 함정은 규모가 커지는 멀티테넌트 SaaS라면 거의 반드시 만나게 되는 것들입니다.


함정 1: SSOT Drift — 상수 체계가 다시 분열한다

문제

"Single Source of Truth"를 잡았음에도 시간이 지나면 다시 분열합니다.

초기에는 상수를 한 곳에서 관리합니다.

core/constants/
  ├ service-categories.ts
  ├ matching-status.ts
  ├ roles.ts
  └ tenant-types.ts

하지만 기능이 늘어나면 이런 일이 벌어집니다.

  • 새 기능 담당자가 feature 폴더에 enum을 새로 만듦
  • API 응답에서 string literal을 직접 사용
  • 데이터베이스에 별도의 enum이 생김
  • 모바일 앱에서 라벨을 별도 관리

6개월 후 상수 체계는 이렇게 됩니다.

core/constants (공식 SSOT)
features/matching/constants (기능별 상수)
API response literals (문자열 하드코딩)
DB enums (마이그레이션에서 생성)
mobile labels (앱 내 별도 관리)

그리고 이것들을 연결하는 매핑 코드가 폭발합니다.

실제로 겪은 사례

크로노젠에서는 9개의 SSOT 축을 관리합니다.

서비스 카테고리, 매칭 상태, 테넌트 유형, 역할,
센터 유형, 워크스페이스 프리픽스, 바우처 유형,
포인트, 루틴 카테고리

이 체계가 흔들리기 시작한 지점은 새 버티컬을 추가할 때였습니다. 교육 버티컬에서만 쓰는 상태값, 재활 버티컬에서만 쓰는 카테고리가 생기면서 "이건 core에 넣어야 하나, feature에 넣어야 하나"라는 판단이 매번 필요했습니다.

해결 원칙

규칙을 하나만 추가하면 80%를 막을 수 있습니다.

새 enum / 상수는 반드시 core/constants에서 시작한다.

feature 폴더에 상수가 생기면 즉시 질문합니다.

  • 이 상수가 다른 버티컬에서도 쓰일 가능성이 있는가?
  • 이 상수가 API 응답이나 DB와 동기화되어야 하는가?

하나라도 "예"라면 core로 올립니다.


함정 2: Vertical Leakage — 버티컬 경계가 붕괴한다

문제

멀티버티컬 SaaS에서 가장 위험한 것은 버티컬 간 의존성입니다.

초기 구조는 깔끔합니다.

verticals/
  ├ rehab/    (재활)
  ├ edu/      (교육)
  ├ market/   (상권)
  └ interior/ (인테리어)

하지만 기능이 복잡해지면 이런 코드가 슬금슬금 생깁니다.

// rehab 서비스에서 market 데이터 참조
import { getMarketAnalysis } from '@/verticals/market/services';

// admin 페이지에서 모든 버티컬 로직 직접 호출
import { getRehabSchedules } from '@/verticals/rehab';
import { getEduCourses } from '@/verticals/edu';
import { getMarketStores } from '@/verticals/market';

이것이 Vertical Leakage입니다.

왜 위험한가

  1. 배포 영향 범위 확대: rehab 코드를 고쳤는데 market이 깨짐
  2. 테넌트 격리 위험: 버티컬 간 데이터 접근 경로가 생기면 멀티테넌트 격리가 약해짐
  3. 독립 배포 불가능: 버티컬을 별도 서비스로 분리하고 싶어도 의존성 때문에 불가능

해결 원칙

의존성 방향을 한 방향으로 강제합니다.

vertical → platform(core)  ✅ 허용
platform → vertical         ❌ 금지
vertical → vertical         ❌ 금지

즉, rehab은 core의 매칭 엔진을 사용할 수 있지만, market의 분석 모듈을 직접 가져올 수 없습니다.

버티컬 간 데이터가 필요하면 반드시 core의 공통 인터페이스를 통해야 합니다.

// ❌ 잘못된 방법: 직접 참조
import { getMarketData } from '@/verticals/market';

// ✅ 올바른 방법: core 인터페이스 경유
import { getCrossTenantData } from '@/core/data-access';

크로노젠에서는 이 원칙을 보안 감사로 검증합니다. basePrisma 사용처 213개를 전수 조사하여 cross-vertical 접근이 없는지 확인했고, DANGER 0건을 달성했습니다.


함정 3: Workflow Duplication — 같은 로직이 플랫폼마다 복제된다

문제

모바일과 웹을 동시에 운영하면 거의 반드시 만나는 문제입니다.

초기에는 역할이 명확합니다.

보호자(guardian) → 모바일 앱
관리자(admin)   → 웹 콘솔
강사(instructor) → 웹 + 모바일

하지만 제품이 커지면 이런 요구가 쏟아집니다.

  • "이 기능 모바일에서도 되게 해주세요"
  • "이 화면 웹에서도 볼 수 있어야 합니다"
  • "관리자도 모바일에서 승인할 수 있어야 합니다"

결과적으로 같은 비즈니스 로직이 여러 곳에 복제됩니다.

모바일: 매칭 신청 로직 v1
웹: 매칭 신청 로직 v1.1 (약간 다른 검증)
관리자 웹: 매칭 신청 로직 v2 (관리자 전용 규칙 추가)

6개월 후에는 "어느 버전이 정확한 로직인가"를 아무도 모릅니다.

특히 위험한 영역

  • 매칭 승인: 승인 조건이 플랫폼마다 다르면 감사에서 문제
  • 정산 계산: 금액 계산 로직이 다르면 재무 오류
  • 일정 관리: 충돌 검사가 다르면 이중 예약

해결 원칙

워크플로우를 두 층으로 분리합니다.

Domain Workflow (비즈니스 로직)
  → 매칭 조건 검증
  → 정산 금액 계산
  → 일정 충돌 검사

UI Workflow (화면 로직)
  → 폼 상태 관리
  → 네비게이션
  → 로딩/에러 처리

Domain Workflow는 한 곳에만 존재해야 합니다.

// core/workflows/matching.ts — 유일한 진실
export function validateMatchingRequest(request: MatchingRequest): ValidationResult {
  // 이 로직은 모바일, 웹, API 어디서 호출하든 동일
}

모바일과 웹은 이 함수를 호출만 합니다. 각자의 UI 로직만 따로 관리합니다.


함정 4: Console Explosion — 관리 콘솔이 제품보다 커진다

문제

이것은 모든 B2B SaaS에서 100% 발생합니다.

관리 콘솔은 이렇게 시작합니다.

Admin Console
  ├ 대시보드
  ├ 회원 관리
  ├ 센터 관리
  └ 매칭 관리

1년 후에는 이렇게 됩니다.

Admin Console
  ├ 대시보드 (3종류)
  ├ 회원 관리
  ├ 센터 관리
  ├ 매칭 관리
  ├ 일정 관리
  ├ 바우처 관리
  ├ 정산 관리
  ├ 청구 관리
  ├ 분석/통계 (7개 화면)
  ├ 결제 관리
  ├ 알림 설정
  ├ 실험 (A/B 테스트)
  ├ 권한 관리
  ├ 감사 로그
  ├ 시스템 설정
  ├ API 키 관리
  ├ 이메일 템플릿
  ├ 마케팅 도구
  └ ... (계속 증가)

어느 순간 관리 콘솔의 복잡도가 실제 서비스보다 커집니다.

왜 위험한가

  1. 신규 관리자 온보딩 비용 폭발: 기능을 찾는 데만 시간 소모
  2. 권한 관리 복잡도 증가: 어떤 관리자에게 어떤 메뉴를 보여줄지
  3. 개발 리소스 분산: 사용자 기능 대신 관리 기능에 시간 투입
  4. UX 품질 저하: 메뉴가 많아질수록 각 화면의 품질 관리가 어려움

해결 원칙

관리 콘솔을 두 계층으로 분리합니다.

Operator Console — 일상 운영:

매칭 관리
회원 관리
일정 관리
세션 관리
보고서

Platform Console — 시스템 관리:

결제/정산
감사 로그
실험/피처 플래그
시스템 설정
API 키

이 분리의 핵심은 역할 기반입니다. 센터 운영자는 Operator Console만 보고, 플랫폼 관리자만 Platform Console에 접근합니다.

크로노젠에서는 이 구조를 라우트 그룹으로 분리합니다.

/(private)/admin/       → Operator Console
/(private)/platform/    → Platform Console

이렇게 하면 콘솔이 아무리 커져도 각 역할에게는 필요한 메뉴만 보입니다.


4가지 함정의 공통점

이 4가지 문제는 모두 같은 뿌리에서 나옵니다.

"경계(boundary)가 무너지는 것"

  • SSOT Drift: 상수의 경계가 무너짐
  • Vertical Leakage: 버티컬의 경계가 무너짐
  • Workflow Duplication: 도메인과 UI의 경계가 무너짐
  • Console Explosion: 운영과 시스템 관리의 경계가 무너짐

멀티테넌트 SaaS에서 경계를 유지하는 것은 기능을 추가하는 것보다 어렵지만, 훨씬 중요합니다. 기능은 나중에 추가할 수 있지만, 무너진 경계를 다시 세우는 것은 리팩토링의 지옥입니다.

크로노젠의 접근

크로노젠은 이 4가지 문제를 다음과 같이 관리합니다.

  1. SSOT 9개 축: 모든 상수는 core/constants에서 출발
  2. 보안 감사 전수조사: basePrisma 213개, cross-vertical 접근 0건
  3. Domain Workflow 분리: createApiHandler로 비즈니스 로직을 API 레이어에서 통합
  4. 역할 기반 콘솔: 라우트 그룹 + 멤버십 역할로 접근 제어

완벽한 해결은 아닙니다. 지속적인 관리가 필요합니다. 하지만 문제를 알고 대비하는 것문제를 만나고 나서 수습하는 것은 비용이 10배 이상 차이납니다.


체크리스트

지금 멀티테넌트 SaaS를 만들고 있다면, 아래 질문으로 자가 점검해보세요.

  • 새 상수/enum이 core가 아닌 곳에 생기고 있지 않은가?
  • 버티컬 간 import가 발생하고 있지 않은가?
  • 같은 비즈니스 로직이 모바일/웹에 각각 구현되어 있지 않은가?
  • 관리 콘솔의 메뉴가 20개를 넘었는가?

하나라도 "예"라면, 지금이 경계를 다시 세울 때입니다.