문제 배경
DT 진단을 할 수 있는 제품의 MVP를 만들고 있었다. MVP를 개발할 당시까지만 해도(합의된) 프로덕트의 도메인은 dtlab.co.kr 하나였다.
하지만, B2B 서비스여서 그런지 기업들의 요구사항은 제각각 이었다. 많은 기업에서 커스텀 도메인이 가능하도록 해달라는 요구사항이 있었다. MVP를 설계할 당시는 도메인에 대한 고민이 없었기에, 도메인에 대한 기업의 요구사항이 생기면 프로젝트를 새로 생성했다. 그렇게 기업의 개수는 늘어났고 5개가 넘어가자, 고통받는 것은 결국 나였다.
우리 서비스를 사용하는 고객사가 추가될 때마다 새로운 프로젝트를 생성해야헸고 동일한 코드의 프로젝트가 동일하게 늘어났다. 기능이 추가되거나 수정되면 모든 프로젝트에 복제 해야했으며, 배포시 각 프로젝트별 빌드가 되어야하기 때문에 배포 시간이 늘어났다. 현재 상황만 봐도 복잡한데 새로운 문제가 발생했다. 그것은 바로 고객사의 커스텀 기능이었다. 이 때부터는 감당이 되지 않는 수준으로 관리포인트가 생겼다.
문제 정의
- 문제 상황 1. 고객사별 커스텀 기능
- 문제 상황 2. 고객사별 커스텀 도메인
문제 해결 방법
문제 상황 1. 고객사별 커스텀 기능
- 커스텀이 가능한 영역과 불가능한 영역을 나누기
- 커스텀이 가능한 영역을 클라이언트가 아닌 서버에서 관리하기
- 서버에서 기관 코드 값을 내려주어, 클라이언트에서 사용
문제 상황 2. 고객사별 커스텀 도메인
- 프로젝트를 하나로 통합
- 와일드카드 도메인 도입
- middleware에서 path redirect 및 rewrite
자 하나씩 해결해보자. 먼저, 프로젝트를 하나로 통합해보자. 프로젝트의 구조는 이러하다. 기존에 각 기관별로 존재했던 프로젝트가, 하나의 프로젝트 안에 통합이 되었다. 기관의 고유 코드 값을 기준으로 static하게 만들어준고, 기관별 페이지는 빌드시 생성이 되도록 했다.
기관별로 페이지를 생성했으니, 기관별 도메인이 필요하다. 와일드카드 도메인 등록하는 방법은 생각보다 어렵지 않은 작업이었다.
Vercel 공식 문서에 작성 되어있는 대로 도메인에 와일드 카드로 추가 하면 된다.
이제 각 페이지마다, 와일드 카드에 맞는 path로 이동하기 위해서는 Next.js의 middleware에서의 redirect와 rewrite가 필요하다.
-
rewrite
Rewrites allow you to send users to different URLs without modifying the visible URL. They allow you to change the URL path, query parameters, and headers of the request before it reaches your server.
- 리라이트(Rewrites)는 사용자가 보게 되는 URL을 변경하지 않고, 다른 URL로 요청을 보내는 것을 뜻한다.
-
redirect
- 리다이렉트(Redirect)는 사용자가 처음 요청한 URL이 아닌, 다른 URL로 보내는 것을 뜻한다.
먼저, 현재 주소로부터 어떤 기관인지 식별해주는 api를 호출한다.
export const middleware = async (request: NextRequest) => {
const url = request.nextUrl.clone();
const { pathname } = request.nextUrl;
const host = request.headers.get('host') || '';
const organization = await getOrganizationCodeByUrl(host);
if (!organization) {
return NextResponse.next();
}
const { code } = organization;
......
};
코드가 식별된 후에, 최상위 path인 /organizationCode 와 일치하는지 비교한다. path와 일치하면, path를 제거한 주소로 redirect한다.
const createRedirectResponse = (request: NextRequest, code: string) => {
const url = request.nextUrl.clone();
url.pathname = request.nextUrl.pathname.replace(`/${code}`, '');
return NextResponse.redirect(url);
}; 예를들어, 현재 org1.dtlab.co.kr/org1에 접속하면 내부적으로 코드를 뺀 org1.dtlab.co.kr로 리다이렉트 합니다. 즉, /org1와같이 path를 노출하지 않기 위해서, path를 제거하는 redirect 코드를 삽입하였습니다.
반대로, 코드가 일치하지 않으면 코드를 추가하여 rewrite한다.
const createRewriteResponse = (request: NextRequest, code: string) => {
const url = request.nextUrl.clone();
url.pathname = `/${code}${request.nextUrl.pathname}`;
return NextResponse.rewrite(url);
};예를들어, org1.dtlab.co.kr로 리다이렉트 후에 원래 정상적으로 동작하는 주소인 org1.dtlab.co.kr/org1로 rewrite합니다. 결과적으로 path에서는 기관 코드가 숨겨지는 효과를 얻을 수 있다.
최종 시나리오
org1.dtlab.co.kr/sign-up → (rewrite) → org1.dtlab.co.kr/org1/sign-up
org1.dtlab.co.kr/org1/sign-up → (redirect) → org1.dtlab.co.kr/sign-up → (rewrite) → org1.dtlab.co.kr/org1/sign-up
최종 코드 결과물
root > src > middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getOrganizationCodeByUrl } from './apollo/getOrganizationCodeByUrl';
const EXCLUDED_PATHS = new Set(['/b2b/graphql', '/mockServiceWorker.js']);
const STATIC_FILE_REGEX = /\.(png|jpg|jpeg|gif|webp|svg|ico)$/i;
/**
* 미들웨어 처리를 건너뛰어야 하는 경로인지 확인합니다.
* (정적 파일, 이미지, 특정 예외 경로)
*/
const isStaticOrIgnoredPath = (pathname: string): boolean => {
if (EXCLUDED_PATHS.has(pathname)) {
return true;
}
return STATIC_FILE_REGEX.test(pathname);
};
/**
* 호스트를 통해 유효한 조직 코드를 조회합니다.
* 조직 정보가 없거나 코드가 유효하지 않으면 undefined를 반환합니다.
*/
const fetchValidOrganizationCode = async (
host: string,
): Promise<string | undefined> => {
const organization = await getOrganizationCodeByUrl(host);
return organization?.code;
};
/**
* URL 경로가 이미 조직 코드로 시작하는지 확인합니다.
*/
const hasOrganizationCodePrefix = (pathname: string, code: string): boolean =>
pathname.startsWith(`/${code}`);
/**
* 조직 코드를 제거한 URL로 리다이렉트 응답을 생성합니다.
* (Case: /CODE/dashboard -> /dashboard)
*/
const createRedirectResponse = (request: NextRequest, code: string) => {
const url = request.nextUrl.clone();
url.pathname = request.nextUrl.pathname.replace(`/${code}`, '');
return NextResponse.redirect(url);
};
/**
* 조직 코드를 포함한 내부 경로로 Rewrite 응답을 생성합니다.
* (Case: /dashboard -> /CODE/dashboard)
*/
const createRewriteResponse = (request: NextRequest, code: string) => {
const url = request.nextUrl.clone();
url.pathname = `/${code}${request.nextUrl.pathname}`;
return NextResponse.rewrite(url);
};
export const middleware = async (request: NextRequest) => {
const { pathname } = request.nextUrl;
const host = request.headers.get('host') || '';
if (isStaticOrIgnoredPath(pathname)) {
return NextResponse.next();
}
const code = await fetchValidOrganizationCode(host);
if (!code) {
return NextResponse.next();
}
if (hasOrganizationCodePrefix(pathname, code)) {
return createRedirectResponse(request, code);
}
return createRewriteResponse(request, code);
};
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
최종 결과 화면
돌아보기: 현재 코드의 문제점
이 섹션은 글을 작성한 이후, 위 구현을 다시 검토하면서 발견한 문제점과 개선 방향을 추가로 정리한 것이다.
위 미들웨어는 매 요청마다 getOrganizationCodeByUrl(host)로 외부 API를 호출한다. 이 말은 곧 모든 페이지의 TTFB가 이 API의 latency(보통 수십~수백 ms)에 그대로 종속된다는 뜻이다.
정확히 짚으면, 미들웨어 자체는 문제가 아니다. 문제는 그 안의 blocking 네트워크 I/O가 세 가지 나쁜 속성을 동시에 갖는다는 점이다.
- Critical path 위에 있다 — 라우팅 결정(rewrite할지 말지)이 API 응답에 의존하므로, 이 호출이 끝나기 전까지는 렌더링을 시작조차 못 한다.
- 매 요청마다 발생한다 — 미들웨어를 거치는 모든 요청이 이 비용을 지불한다.
- 지리적으로 멀 수 있다 — 미들웨어는 사용자와 가까운 엣지에서 도는데 API 서버는 특정 리전 한 곳에 있어서, 엣지에서 실행하는 이점이 상쇄된다.
해결 방법
해결책들의 공통 원리는 하나다. "조회를 없애는 게 아니라, 조회에서 네트워크 왕복을 제거한다."
방법 A. Vercel Edge Config
매핑 데이터를 엣지에 미리 복제해두는 방식이다. Edge Config는 빠르고 저렴한 읽기 + 드문 쓰기에 최적화된 저장소(대부분의 읽기가 1ms 미만)라서, "host → organization code 매핑"처럼 읽기 빈도는 높고 변경 빈도는 낮은 데이터에 정확히 들어맞는다.
import { get } from '@vercel/edge-config';
export const middleware = async (request: NextRequest) => {
const host = request.headers.get('host') ?? '';
// 외부 API 왕복 대신 엣지에 복제된 데이터를 읽는다
const organizationMap = await get<Record<string, string>>('organizations');
const code = organizationMap?.[host];
if (!code) {
return NextResponse.next();
}
return NextResponse.rewrite(
new URL(`/${code}${request.nextUrl.pathname}`, request.url),
);
};외부 API 호출이 엣지에 복제된 데이터 읽기로 바뀌면서, 미들웨어 병목이 사라진다. 다만 업데이트가 전 세계에 전파되는 데 최대 10초가 걸릴 수 있어서, 등록 직후 즉시 반영이 필요한 데이터에는 이 지연이 허용되는지 확인이 필요하다.
방법 B. 빌드 타임 JSON 내장
런타임에 하던 조회를 빌드 타임으로 옮기는 방식이다. 빌드 직전에 API에서 매핑을 한 번만 가져와 JSON 파일로 떨어뜨리고, 미들웨어가 그걸 import하면 번들러가 JSON을 미들웨어 번들 안에 인라인한다. 런타임 조회는 메모리 객체 접근이 되므로 사실상 0ms다.
먼저 매핑 JSON을 생성하는 스크립트를 만들고, package.json의 prebuild에 연결해 빌드 전에 자동 실행되도록 한다.
// scripts/generate-org-map.mjs
const res = await fetch(`${process.env.INTERNAL_API_URL}/organizations`);
const orgs = await res.json();
const map = Object.fromEntries(orgs.map((o) => [o.host, o.code]));
await writeFile('src/generated/organization-map.json', JSON.stringify(map));미들웨어는 생성된 JSON을 정적 import한다.
import organizationMap from './generated/organization-map.json';
export const middleware = (request: NextRequest) => {
const host = request.headers.get('host') ?? '';
const code = (organizationMap as Record<string, string>)[host]; // 네트워크 I/O 없음
if (!code) return NextResponse.next();
return NextResponse.rewrite(
new URL(`/${code}${request.nextUrl.pathname}`, request.url),
);
};트레이드오프는 신선도다. 매핑이 번들에 박제되므로 신규 organization은 재배포 전까지 인식되지 않는다. BE의 organization 생성/변경 핸들러에서 Vercel Deploy Hook을 호출해 재배포를 트리거하면 되는데, 전파 시간이 **빌드 소요 시간(수 분)**이 된다는 게 대가다.
// BE: organization 생성/변경 완료 후
await fetch(process.env.VERCEL_DEPLOY_HOOK_URL, { method: 'POST' });두 가지 방법 비교
| 조회 비용 | 변경 전파 | 저장소 | |
|---|---|---|---|
| Edge Config | <1ms 읽기 | ~10초 | 필요 (Pro 64KB) |
| 빌드 타임 JSON | 0 (메모리) | 빌드 시간 (수 분) | 불필요 |
선택 기준을 요구사항별로 정리하면 이렇다.
- 등록 즉시 반영이 필요하다 → Edge Config
- 변경이 드물다 → 빌드 타임 JSON 내장