1. 개요

Chois International 은 철강과 수산물 두 사업 부문을 가진 무역 회사다. 부문마다 브랜드 사이트가 필요했고, 바이어가 견적(RFQ)을 남길 문의 폼이 필요했고, 관리자가 두 사이트를 한 곳에서 편집할 CMS 가 필요했다. 언어는 한국어·영어·중국어 셋.

기획·개발·운영을 혼자 맡았다. 이 조건이 아키텍처를 결정했다. 이 시리즈는 그 결정들과, 운영하면서 그 결정이 어디서 삐끗했는지의 기록이다. 1편은 구조 이야기다.


2. 핵심 내용

2-1. 스택

계층선택
백엔드Spring Boot 3.5 / Java 21, JPA, Redis, AWS SDK v2(S3)
프론트Next.js 16 / React 19(React Compiler), Tailwind 4, TanStack Query, BlockNote 에디터
데이터PostgreSQL 16(JSONB 다수), Redis 7
스토리지SeaweedFS(S3 호환)
플랫폼홈랩 k3s + ArgoCD + Envoy Gateway + sealed-secrets

QueryDSL 도, Flyway 도 없다. 스키마는 JPA 가 만든다. 규모가 작고 혼자 개발하는 프로젝트라 그 편이 빨랐다. 이 선택의 대가는 다른 프로젝트(DoEatFit 3편)에서 이미 봤으니, 스키마가 자라면 그때 갈아탈 생각이다.

2-2. 인스턴스 하나, siteCode 컬럼 하나

두 사이트를 어떻게 나눌지 세 가지 선택지가 있었다.

  • 인스턴스 분리: 사이트마다 백엔드·프론트·DB 한 벌
  • 스키마 분리: DB 는 하나, 스키마를 사이트별로
  • 컬럼 분리: 모든 테넌트 데이터에 siteCode 컬럼

혼자 운영하면 인스턴스 두 벌은 배포·마이그레이션·모니터링이 두 배다. 사이트가 셋이 되면 세 배. 그래서 컬럼 분리를 택했다. 데이터는 siteCode 로, 화면은 서브도메인으로 가른다. 사이트가 늘어도 인스턴스는 늘지 않는다.

flowchart LR
    U[바이어 / 관리자] --> CF[Cloudflare]
    CF --> GW[Envoy Gateway<br/>Cloudflare 대역만 허용]
    subgraph NS[k3s namespace · default-deny ingress]
        GW -->|브랜드 서브도메인 ×2 · 관리자| FE[Next.js<br/>미들웨어가 Host 로 분기]
        GW -->|API 서브도메인| BE[Spring Boot]
        GW -->|리소스 서브도메인 GET/HEAD| S3[(SeaweedFS)]
        FE -->|BFF 프록시| BE
        BE --> PG[(PostgreSQL<br/>siteCode 컬럼)]
        BE --> RD[(Redis)]
        BE --> S3
    end

2-3. 필터도 ThreadLocal 도 없다

멀티테넌시를 구현하는 흔한 방법은 요청 필터에서 테넌트를 알아내 ThreadLocal 에 넣고, 리포지토리가 그걸 자동으로 WHERE 절에 붙이는 것이다. 편하지만 마법이다. 비동기 코드나 스케줄러에서 ThreadLocal 이 비면 조용히 전 테넌트를 훑는다.

여기서는 매 요청이 siteCode 를 명시 한다. 경로에 넣거나(/api/v1/{siteCode}/inquiries) 쿼리 파라미터로 받는다. 서비스 메서드 시그니처에 siteCode 가 없으면 컴파일이 안 된다. 마법이 없으니 잊을 수도 없다.

메뉴 트리 같은 사이트 단위 리소스는 (siteCode, slug) 유니크 인덱스로 격리한다. 다국어는 행을 늘리지 않고 JSONB 안의 키로 푼다. name: {"ko": "...", "en": "...", "zh": "..."}. 언어 축과 사이트 축이 직교한다.

2-4. 프론트: 미들웨어가 Host 를 읽는다

Next.js 미들웨어가 요청의 Host 헤더를 본다. 관리자 서브도메인이면 /admin 으로 내부 rewrite, 서비스 도메인이면 통과시키면서 x-site-code 응답 헤더를 붙인다. 페이지 컴포넌트는 그 값을 읽어 어느 사이트인지 안다.

여기에 함정이 있었다. 사이트 판정 로직이 서버와 클라이언트 두 벌 이다. 서버 컴포넌트용과 클라이언트 훅용. 둘 다 “seafood 가 아니면 STEEL” 로 폴백한다. 잘못된 호스트로 들어온 요청이 에러 대신 조용히 철강 사이트로 흐른다. 지금은 알고 있는 위험으로 두고 있는데, 판정을 한 곳으로 모으는 것이 남은 과제다.

2-5. 대소문자는 규약인가 습관인가

siteCode 를 대문자로 보내면 500 이 났던 적이 있다. DB 에는 소문자로 저장돼 있고, 캐시 키만 소문자로 정규화하고, 입력 검증은 없었다. 정규화가 규약이 아니라 호출 지점의 습관 이었던 것이다.

400 으로 고치면서 배운 것: 정규화는 검증과 저장 둘 다 에서 같은 함수를 타야 한다. 검증에만 넣으면 소문자로 저장된 옛 행이 영원히 안 보인다. 저장에만 넣으면 검증이 통과한 값이 저장 시점에 달라진다.

2-6. 테넌트별인 것과 전역인 것

콘텐츠·문의·푸터는 사이트별이다. 약관·업로드 파일·관리자 계정은 전역이다. 이 경계를 처음에 문서로 그리지 않았다면, 나중에 “약관을 사이트마다 다르게” 요청이 왔을 때 헤맸을 것이다. 경계는 코드보다 먼저 표로 적어 두는 게 낫다.


3. 마무리

요약

  • 혼자 운영하면 인스턴스 수가 비용이다. 데이터는 siteCode 컬럼, 화면은 서브도메인.
  • ThreadLocal 마법 대신 매 요청이 siteCode 를 명시한다. 잊을 수 없게.
  • 다국어는 JSONB 키로, 사이트 축과 직교.
  • 정규화는 검증과 저장이 같은 함수를 탄다.

다음 편은 관리자 CMS 를 지키는 세 겹이다.