회사 프런트엔드 모노레포에는 현재 서로 닮은 앱이 세 개 있고, 앞으로 앱은 계속 추가될 예정이다. 다루는 데이터와 화면이 상당 부분 같아서 도메인 로직은 core에, 공통 컴포넌트는 UI kit에 두고 함께 사용한다. 같은 버그를 앱 수만큼 반복해서 고치지 않아도 된다는 점만으로도 코드를 공유할 이유는 충분했다.
그렇다고 모든 앱이 같지는 않았다. 사용할 수 있는 화면과 URL이 다르고, API 오류를 처리하는 방식과 관측 도구도 달랐다. 지금까지 같은 프레임워크를 사용했다는 이유로 앞으로도 그래야 하는 것은 아니었다. 일부 앱에서는 Next.js 대신 TanStack Router를 사용해 보고 싶었다.
여기서 질문이 생겼다.
공유 코드는 그대로 두면서 앱마다 다른 구현을 어디에서 선택해야 할까?
처음부터 DI 라이브러리를 만들 생각은 없었다. React에서 구현을 바꾸고 싶을 때 가장 먼저 떠오르는 방법부터 사용했다. prop이다.
차이가 적을 때는 optional prop으로 충분했다
공유 컴포넌트에 기본 동작을 두고 특정 앱만 prop으로 교체하면 된다.
type SearchFilterProps = {
renderExtraFilter?: () => ReactNode;
};
export function SearchFilter({
renderExtraFilter = () => null,
}: SearchFilterProps) {
return (
<div>
<DefaultFilter />
{renderExtraFilter()}
</div>
);
}차이가 한두 군데라면 이 코드는 별 문제가 없다. 컴포넌트의 변형을 prop으로 표현하는 것은 React에서 자연스럽다. 별도 도구도 필요하지 않다.
문제는 앱마다 달라지는 값이 늘어나면서 시작됐다. 실제 관리 화면에서는 최상위 페이지가 받은 render prop을 테이블에 넘기고, 테이블은 다시 필터에 넘겼다.
Page
-> DataTable
-> SearchFilter
-> BusinessExtensionDataTable은 확장 UI를 사용하지 않는다. 그런데도 하위 컴포넌트에 전달하려고 자신의 props에 같은 항목을 추가해야 했다. 앱별 고정 URL과 선택적인 오류 처리 콜백도 비슷한 경로를 지났다.
새로운 앱별 정책이 생길 때마다 실제 사용 지점뿐 아니라 전달 경로까지 수정했다. 중간 컴포넌트의 API가 자신이 하는 일보다 하위 컴포넌트의 사정에 따라 바뀌기 시작했다.
여기서 prop을 두 종류로 나눠 볼 필요가 있었다.
- 현재 선택한 행처럼 호출할 때마다 달라지는 값
- 앱을 빌드하는 시점에 이미 정해져 있는 값
첫 번째 값은 prop으로 전달하는 편이 맞다. 두 번째 값까지 같은 방법으로 운반하자 컴포넌트 트리가 앱 설정의 전달 경로가 되었다.
optional prop은 틀린 해법이 아니었다. 다만 앱 하나의 고정된 결정을 여러 컴포넌트가 대신 운반하는 순간부터 맞지 않았다.
UI 밖에서는 전역 초기화가 같은 역할을 맡았다
HTTP 클라이언트처럼 React 컴포넌트 밖에서 사용하는 의존성은 prop으로 전달하기도 어려웠다. 당시에는 앱이 시작할 때 전역 설정 함수를 호출했다.
configureHttpClient({
baseUrl,
onError,
});호출하는 쪽은 편했다. 어디서든 같은 클라이언트를 import하면 됐다. 대신 보이지 않는 전제가 하나 생겼다. 클라이언트를 사용하기 전에 configureHttpClient()가 반드시 실행되어야 했다.
이 전제는 타입에 나타나지 않는다. 초기화를 빠뜨려도 빌드는 성공한다. 테스트에서는 전역 설정을 준비하고, 끝난 뒤 원래 상태로 되돌려야 했다. 실행 순서가 사실상 의존성으로 작동하지만 코드에는 그 관계가 드러나지 않았다.
optional prop과 전역 초기화는 모양은 달라도 같은 문제를 피하려다 나온 해법이었다. 앱마다 다른 결정을 공유 코드 안의 조건문으로 만들고 싶지 않았다. 하나는 그 결정을 컴포넌트 트리 전체로 운반했고, 다른 하나는 모듈의 전역 상태에 숨겼다.
그렇다면 앱별 결정을 한곳에서 명시적으로 조립하면 어떨까?
앱별 결정을 한 파일에서 조립해 봤다
앱이 하나라면 직접 composition root를 작성하는 것으로 충분하다.
// app/composition-root.ts
const apiConfig = {
baseUrl: "/api",
};
const errorPolicy = createErrorPolicy();
export const httpClient = createHttpClient({
config: apiConfig,
errorPolicy,
});이 파일에는 HTTP 클라이언트를 만들기 위해 무엇이 필요한지 그대로 적혀 있다. 초기화 순서를 따로 기억하지 않아도 되고, 앱의 설정을 찾으려고 컴포넌트 트리를 거슬러 올라갈 필요도 없다.
이 정도면 이미 꽤 많은 문제가 사라진다. 실제로 앱이 하나이고 환경도 하나라면 여기서 멈추는 편이 낫다.
우리 모노레포에서는 같은 포트를 앱마다 다르게 조립해야 했다. 앞으로 앱이 늘어날수록 관리할 root도 함께 늘어난다. live와 test처럼 환경에 따라서도 구현이 바뀌었다. 수동 root를 앱과 환경마다 복사하면 어느 파일은 새 의존성을 추가하고 어느 파일은 빠뜨리기 쉽다. 구현이 두 개 선택되거나 순환 의존성이 생겨도 root를 실제로 빌드하기 전에는 알 수 없다.
필요한 것은 새로운 DI 개념이 아니었다. 수동 composition root에서 이미 잘 동작하던 방식을 여러 앱과 환경에 맞게 반복하고 검사해 줄 도구였다.
런타임 컨테이너보다 빌드 시점의 코드 생성이 맞았다
어떤 앱을 빌드하는지, 어떤 환경의 구현을 쓸지는 실행 전에 이미 정해져 있다. 그렇다면 브라우저에서 런타임 컨테이너가 토큰을 조회할 이유가 없었다.
원하는 동작을 적어 보니 다음과 같았다.
- 공유 패키지는 필요한 기능을 선언한다.
- 각 앱은 그 기능을 제공할 구현체를 작성한다.
- 도구가 앱과 환경에 맞는 구현을 골라 composition root를 만든다.
- 구현이 빠지거나 충돌하면 사용자 실행 전에 빌드를 실패시킨다.
- 생성된 root가 import하지 않는 구현은 번들에도 들어가지 않는다.
이 요구에 맞춰 prewire를 만들었다. TypeScript 소스에서 binding 선언을 찾고, 의존성 그래프를 검사한 뒤, 앱과 환경별 composition root를 평범한 TypeScript 파일로 생성한다.
GitHubGitHub - clroot/prewire: Compile-time DI for TypeScript frontends — wired at build time, zero runtime containerCompile-time DI for TypeScript frontends — wired at build time, zero runtime container - clroot/prewirehttps://github.com/clroot/prewire런타임 컨테이너도 없고 데코레이터나 reflect-metadata도 필요하지 않다. 결과물은 내가 직접 쓰려던 composition root와 같은 코드다.
공유 패키지는 필요한 기능을 선언한다
앞에서 사용한 HTTP 설정을 prewire로 옮겨 보자. 먼저 공유 패키지에서 앱이 제공해야 할 타입을 만들고 InjectionToken을 선언한다.
// packages/core/src/api-config.ts
import { injectable, InjectionToken } from "@prewire/core";
export interface ApiConfig {
baseUrl: string;
}
export const API_CONFIG = new InjectionToken<ApiConfig>("api config");모든 앱에서 쓸 수 있는 기본 구현이 있다면 injectable()로 함께 선언할 수 있다.
export const defaultApiConfig = injectable(
{},
(): ApiConfig => ({ baseUrl: "/api" }),
{ provides: API_CONFIG, default: true },
);injectable()의 첫 번째 인자는 이 구현에 필요한 의존성이고, 두 번째는 실제 값을 만드는 factory다. 세 번째 인자의 provides는 이 factory가 어떤 토큰의 구현인지 알려 준다.
default: true는 다른 앱이 이 binding을 교체해도 된다는 명시적인 허용이다. 이 표시가 없는 구현을 같은 토큰에 두 개 선언하면 prewire는 충돌로 판단한다.
앱은 같은 토큰에 자신의 구현을 연결한다
기본 API 경로와 다른 값을 쓰는 앱은 core를 수정하지 않고 자신의 binding을 추가한다.
// apps/admin/src/bindings/api-config.ts
import { injectable } from "@prewire/core";
import { API_CONFIG, type ApiConfig } from "@shared/core";
export const adminApiConfig = injectable(
{},
(): ApiConfig => ({ baseUrl: "/admin-api" }),
{ provides: API_CONFIG },
);HTTP 클라이언트는 API_CONFIG 토큰에 의존한다고 선언한다.
// packages/core/src/http-client.ts
export const HTTP_CLIENT = new InjectionToken<HttpClient>("http client");
export const httpClientBinding = injectable(
{ config: API_CONFIG },
({ config }) => createHttpClient({ config }),
{ provides: HTTP_CLIENT },
);여기까지 작성한 코드는 객체를 직접 생성하지 않는다. prewire가 읽을 그래프의 노드를 선언한다.
API_CONFIG -> HTTP_CLIENTadmin 앱을 빌드하면 API_CONFIG 자리에는 adminApiConfig가 선택된다. 기본 구현은 이 앱의 그래프에서 밀려난다.
생성된 파일에는 특별한 마법이 없다
prewire는 injectable() 선언을 정적으로 읽고 의존성 순서대로 factory를 호출하는 코드를 만든다. import와 내부 별칭을 생략하면 다음과 비슷하다.
// apps/admin/src/prewire/container.live.gen.ts
export const apiConfig = adminApiConfig.factory({});
export const httpClient = httpClientBinding.factory({
config: apiConfig,
});공유 코드는 생성 파일의 위치나 admin 앱의 존재를 모른다. 항상 같은 경로만 import한다.
import { httpClient } from "#prewire";앱의 빌드 설정이 #prewire를 자신의 생성된 root로 연결한다. 같은 공유 코드가 다른 앱에서 빌드되면 그 앱의 root를 가져온다.
그래프를 만들 수 없으면 파일을 생성하지 않는다. 필요한 binding이 없거나, 하나만 있어야 할 구현이 충돌하거나, 순환 의존성이 생기면 빌드가 실패한다. 전역 초기화에서 실행 중에야 발견하던 문제를 빌드 단계로 옮긴 셈이다.
실제 제품에서는 무엇이 달라졌나
가장 먼저 HTTP 설정과 오류 처리 정책을 옮겼다. 전역 초기화 함수와 변경 가능한 singleton을 제거하고, 앱마다 설정과 오류 정책의 binding을 두었다.
이 작업은 68개 파일을 건드렸다. 숫자만 보면 꽤 큰 변경이다. 그러나 기존 HTTP 클라이언트를 사용하던 49개 파일은 수정하지 않았다. 소비자는 계속 httpClient를 사용했고, 그 값이 만들어지는 위치만 composition root로 옮겼다.
테스트도 전역 클라이언트를 초기화하고 복구할 필요가 없어졌다. 필요한 factory에 가짜 의존성을 직접 넘기면 됐다.
관리 화면의 확장 UI도 port와 binding으로 옮겼다. 페이지에서 테이블과 필터를 거쳐 내려가던 render prop이 사라졌다. 중간 컴포넌트는 더 이상 자신이 사용하지 않는 앱 정책을 전달하지 않았다.
모든 URL을 binding으로 옮긴 것은 아니다. 현재 행의 값으로 만드는 URL은 여전히 prop으로 남겼다. 앱마다 고정된 경로만 composition root로 보냈다. 이 구분을 하면서 한 앱이 존재하지 않는 두 페이지의 링크를 표시하고 있다는 사실도 발견했다. 새 port에서는 null을 반환하면 그 앱이 기능을 지원하지 않는다는 뜻으로 계약을 바꿨다.
두 앱에 복제되어 있던 관측 도구 코드도 하나의 port로 합쳤다. 관측 기능이 필요 없는 앱은 no-op binding을 사용했다. 생성된 root는 선택한 구현만 import하므로, 실제 SDK도 그 앱의 번들에서 빠졌다.
도입 직후에는 관련 패키지 여섯 개의 타입 검사, 전체 테스트 851개와 세 앱의 production build를 통과시켰다. lint에서도 오류가 없음을 확인했다.
더 믿을 만한 검증은 그다음 요구사항이었다. 새로운 관리 화면 필터와 앱별 검색 정책이 추가됐을 때 core에 앱 이름을 확인하는 조건문을 넣지 않았다. 기존 composition 계층에 binding을 추가하는 방식으로 처리했다. 처음 만든 구조가 이후의 차이도 같은 방식으로 받아들였다.
빌드 타임 코드 생성이 새로 만든 문제도 있었다
prewire를 적용했다고 복잡성이 사라진 것은 아니다. 복잡성이 모이는 장소가 바뀌었다.
생성 파일이 있는 상태와 없는 상태에서 lint의 import 정렬 결과가 달랐다. 개발자 컴퓨터에는 이전 실행에서 만든 파일이 남아 있어서 통과했지만, 깨끗한 CI에서는 경고가 발생했다. 결국 lint 앞에도 codegen을 실행하도록 순서를 고정했다.
TypeScript 설정에도 걸리는 부분이 있었다. tsconfig의 paths는 상위 설정과 깊게 병합되지 않는다. 앱이 자체 경로 별칭을 사용하면 #prewire 경로도 같은 설정에 직접 적어야 했다.
기본 binding도 조심해야 한다. 모든 port에 no-op 기본값을 두면 앱이 구현을 빠뜨려도 빌드가 성공한다. 어느 앱에서나 정말 같은 의미로 쓸 수 있는 구현만 기본값으로 열어야 한다. 앱이 반드시 결정해야 하는 정책에는 기본값을 두지 않는 편이 낫다.
코드 생성기는 이런 판단을 대신해 주지 않는다. 잘못된 그래프는 잡아 주지만, 잘못 고른 기본값까지 알아서 판단하지는 못한다.
언제 수동 composition root로 충분한가
prewire의 사용법보다 이 질문이 더 중요하다. 앱이 하나이고 환경별 구현도 몇 개 없다면 수동 composition root가 더 단순하다. 앞에서 작성한 파일 하나면 앱의 결정을 충분히 모을 수 있다.
다음 질문에 대부분 그렇다고 답할 때 prewire의 비용을 감수할 만했다.
- 같은 port를 앱이나 환경마다 다르게 연결하는가?
- 새 의존성을 추가할 때 여러 composition root를 함께 고쳐야 하는가?
- binding 누락과 충돌을 빌드에서 검사할 필요가 있는가?
- 선택하지 않은 구현이 번들에 들어가지 않아야 하는가?
반대로 앱 하나의 root를 한 화면 안에서 읽을 수 있다면 굳이 생성기를 둘 이유가 없다. prewire가 하는 일은 수동으로 관리하기 버거워진 root를 생성하고 검사하는 것이다.
이제 앱마다 다른 요구사항이 생겼을 때 보는 곳이 달라졌다
처음에는 일부 앱에서 다른 라우터를 사용해 보고 싶었을 뿐이었다. 그 차이를 분리하려고 보니 optional prop과 전역 초기화 곳곳에 앱의 결정이 숨어 있었다.
지금은 새로운 요구사항이 들어오면 먼저 값이 언제 달라지는지 본다. 화면의 현재 상태에 따라 달라지면 prop으로 전달한다. 앱을 빌드할 때 이미 정해지는 값이면 composition root에서 선택한다. 앱과 환경에 따라 같은 port의 조합이 많아져 수동 관리가 어려울 때 prewire를 사용한다.
객체를 어디서 가져올지는 그다음 문제다. 먼저 정해야 할 것은 앱마다 다른 결정을 어디에 둘지였다.
나는 이 문제를 의존성 역전과 composition root라는 백엔드 쪽 어휘로 바라봤다. 프런트엔드 제품군을 오래 운영한 사람들은 같은 문제를 어떤 구조로 풀고 있는지 여전히 궁금하다.