목록으로
코어/커스텀 분리기

코어/커스텀 분리기 (1) - 왜 분리해야 하는가

2026년 04월 03일19 min read
#Architecture#Monorepo#TypeScript#B2B

목차 보기
시리즈 목차 보기1 / 3

문제의 시작

B2B 솔루션을 개발하는 팀에서 일하고 있다. 우리가 만드는 제품은 하나지만, 고객사는 여럿이다. 고객사마다 요구사항이 다르고, 화면 구성이 다르고, 업무 흐름이 다르다. 같은 뼈대 위에 살을 다르게 붙여야 하는 구조다.

처음에 우리 팀이 선택한 방법은 단순했다. 프로젝트 폴더를 통째로 복사해서 고객사별로 관리하는 것이다. A사 프로젝트를 복사해서 B사 프로젝트를 만들고, 거기서 B사에 맞게 수정한다. C사가 들어오면 또 복사한다. 빠르고 직관적이다. 각 프로젝트가 독립적이니 A사를 건드려도 B사에 영향이 없다. 개발자 입장에서 심리적 안정감도 있다.

문제는 시간이 지나면서 드러난다.

코어 로직에 버그가 하나 발견됐다고 하자. 데이터를 조회하는 공통 유틸리티에서 날짜 포맷 처리가 잘못되어 있었다. A사 프로젝트에서 발견해서 수정했다. 그런데 B사, C사, D사 프로젝트에도 같은 코드가 있다. 같은 버그가 살아있다. 누군가 기억하고 있으면 다행이지만, 프로젝트가 다섯 개, 열 개로 늘어나면 기억만으로는 불가능하다.

실제로 내가 겪은 상황이다. A사에서 발견한 버그를 수정하고 배포까지 마쳤는데, 두 달 뒤에 C사에서 같은 증상이 보고됐다. 원인을 추적해보니 똑같은 버그였다. 이미 고친 버그를 또 고치고 있는 자신을 발견하면, 이건 구조의 문제라는 걸 인정하게 된다.

복사-붙여넣기 구조의 진짜 비용은 버그 수정만이 아니다. 코어에 새로운 기능을 추가할 때도 N개 프로젝트에 동일한 작업을 반복해야 한다. 성능 개선, 보안 패치, 라이브러리 업데이트 — 모든 공통 작업의 비용이 프로젝트 수에 비례해서 늘어난다. 프로젝트가 세 개일 때는 참을 만하지만, 열 개를 넘어가면 팀의 생산성이 눈에 띄게 떨어진다.

문서로 금지하면 되지 않나?

이 문제를 인식한 뒤 처음 시도한 건 문서화였다. README에 이렇게 적었다.

이 폴더 안의 파일은 코어 로직입니다. 수정하지 마세요. 커스터마이징이 필요하면 별도 파일을 만들어서 확장하세요.

한동안은 잘 지켜졌다. 팀원들이 README를 읽고 "아, 이건 건드리면 안 되는구나" 이해했다. 코드 리뷰에서도 코어 파일을 수정한 PR이 올라오면 리뷰어가 "이건 코어 영역이니 원복해주세요"라고 코멘트를 남겼다.

그런데 사람이 바뀌면 규칙도 흔들린다.

운영 단계에 들어가면 개발팀 구성이 달라진다. 초기 개발에 참여했던 사람이 빠지고 새로운 사람이 들어온다. 외부 인력이 투입되기도 한다. 이 사람들에게 "저 README 읽어보셨죠?"라고 물어볼 수는 있지만, 바쁜 일정 속에서 모든 문서를 꼼꼼히 읽는 사람은 드물다. 특히 긴급 이슈 대응 중에는 "일단 고치고 보자"가 되기 쉽다.

코드 리뷰로 잡겠다는 전략도 한계가 있다. 리뷰어 자체가 바뀐다. 초기에 코어와 커스텀의 경계를 설계했던 사람이 리뷰어일 때는 잘 잡히지만, 그 사람이 빠지고 나면 경계가 흐려진다. "이 파일이 코어인지 커스텀인지 어떻게 구분하죠?"라는 질문이 나오기 시작하면 이미 컨벤션은 반쯤 무너진 상태다.

사람에게 의존하는 규칙의 본질적 한계

결국 깨달은 건 이거다. 사람에게 의존하는 규칙은 사람이 바뀌면 깨진다. 이건 그 사람의 역량이나 성실함의 문제가 아니다. 조직은 본질적으로 유동적이고, 모든 맥락을 완벽하게 인수인계하는 건 불가능하다. 구두 전달도, 문서도, 위키도 시간이 지나면 현실과 괴리가 생긴다.

규칙이 지켜지려면 규칙을 어겼을 때 즉각적인 피드백이 있어야 한다. 문서는 피드백을 주지 않는다. "이 파일을 수정하지 마세요"라고 적어놔도, 수정했을 때 아무 일도 일어나지 않는다. 빌드가 깨지지 않고, 테스트가 실패하지 않고, 에디터가 경고를 띄우지 않는다. 규칙 위반이 눈에 보이지 않으니, 규칙은 점점 잊혀진다.

코드로 강제한다는 것

그래서 방향을 바꿨다. 규칙을 사람이 아닌 도구가 강제하도록 만들자.

타입 시스템으로 옵션을 잠그다

TypeScript의 타입 시스템은 단순한 자동완성 도구가 아니다. "이 값은 이 형태여야 한다"는 제약을 컴파일 타임에 강제하는 도구다. 이걸 활용하면 "이 설정은 코어가 정한 값만 허용한다"를 코드 레벨에서 표현할 수 있다.

예를 들어 코어가 API 경로의 규격을 정의하고, 프로젝트에서는 그 규격 안에서만 값을 지정할 수 있게 타입을 설계한다. 프로젝트 개발자가 규격을 벗어나는 값을 넣으면 에디터에서 빨간 줄이 뜨고, 빌드가 실패한다. README에 "이 값은 바꾸지 마세요"라고 쓰는 것보다 훨씬 강력하다. 규칙을 어기는 순간 즉각적인 피드백이 온다.

패키지 경계로 수정을 차단하다

타입 시스템만으로는 부족한 부분이 있다. 타입은 "어떤 값을 넣을 수 있는가"를 제어하지만, "이 파일의 코드를 수정하는 것" 자체를 막지는 못한다. 코어 파일이 프로젝트 안에 있으면, 아무리 타입을 잘 설계해도 누군가 파일을 열어서 코드를 직접 고칠 수 있다.

그래서 코어 코드를 아예 프로젝트 밖으로 빼야 한다. 별도의 패키지로 만들어서 npm이나 사내 레지스트리를 통해 배포하면, 프로젝트에서는 node_modules 안에 설치된 코어 코드를 사용하게 된다. node_modules 안의 코드를 직접 수정하는 사람은 거의 없다. 수정해도 install을 다시 하면 원래대로 돌아간다. 이게 물리적 차단이다.

배포로 동기화를 해결하다

패키지로 분리하면 버그 수정의 전파 문제도 자연스럽게 풀린다. 코어에서 버그를 고치고 새 버전을 배포하면, 각 프로젝트에서는 패키지 버전만 올리면 된다. N개 프로젝트의 동일한 코드를 하나하나 수정하는 게 아니라, 코어 한 곳에서 수정하고 버전을 올리는 것이다. 프로젝트 수가 아무리 늘어나도 코어 수정 비용은 일정하다.

정리하면 이런 구도다. 사람이 지켜야 하는 규칙을 도구가 강제하는 규칙으로 전환한다. 타입 시스템은 컴파일러가, 패키지 경계는 패키지 매니저가, 버전 관리는 배포 파이프라인이 담당한다. 사람이 바뀌어도 도구는 바뀌지 않는다.

분리의 그림

구체적으로 코어와 커스텀을 분리하면 어떤 구조가 되는가.

코어는 하나의 저장소(또는 모노레포 안의 패키지)에서 개발한다. 빌드하면 npm 패키지 형태로 결과물이 나온다. 이 패키지에는 공통 유틸리티, 타입 정의, 기본 설정, 프레임워크 어댑터 같은 것들이 들어간다.

커스텀 프로젝트는 코어 패키지를 의존성으로 설치한다. 코어가 제공하는 함수와 타입을 가져다 쓰되, 확장이 허용된 부분만 프로젝트에 맞게 변경한다. 코어가 "이건 고정이다"라고 정한 부분은 타입 에러로 수정이 차단되고, "이건 프로젝트가 결정해라"라고 열어둔 부분은 자유롭게 설정할 수 있다.

시나리오로 이해하기

국제화(i18n) 라이브러리를 예로 들어보자. 코어가 담당하는 영역과 프로젝트가 담당하는 영역을 나눠볼 수 있다.

코어가 결정하는 것은 이런 것들이다. 번역 키를 관리하는 방식, 언어 전환 함수의 인터페이스, 번역 파일을 불러오는 API의 경로 규격. 이런 것들은 모든 프로젝트에서 동일해야 한다. 프로젝트마다 번역 키 포맷이 다르면 코어 도구가 작동하지 않고, API 경로 규격이 다르면 백엔드와의 계약이 깨진다.

프로젝트가 결정하는 것은 이런 것들이다. 지원하는 언어 목록, 기본 언어, 프로젝트 고유 번역 키. 한국과 일본에 납품하는 프로젝트는 한국어와 일본어를, 동남아에 납품하는 프로젝트는 영어와 태국어를 지원할 것이다. 이건 프로젝트마다 다를 수밖에 없다.

이렇게 나누면 코어가 업그레이드되어도 프로젝트의 언어 설정은 영향을 받지 않는다. 반대로, 프로젝트에서 언어를 추가해도 코어의 내부 구현을 건드릴 필요가 없다. 변경의 영향 범위가 명확하게 격리된다.

모노레포라는 선택

코어를 패키지로 분리하기로 했다면, 그 코어를 어디서 개발할지 정해야 한다. 코어가 단일 패키지라면 별도의 저장소 하나면 충분하다. 하지만 현실에서 코어는 대개 여러 개의 패키지로 나뉜다. 유틸리티 패키지, 타입 정의 패키지, 프레임워크 어댑터 패키지, 설정 패키지 등. 이 패키지들은 서로 의존 관계가 있다.

멀티레포로 관리하면 각 패키지가 독립된 저장소에 있게 된다. 하나를 수정하고 배포한 뒤 다른 패키지에서 버전을 올려야 변경이 반영된다. 패키지 A를 수정했는데 A에 의존하는 B와 C도 함께 수정해야 한다면, 세 개의 저장소에서 순서를 맞춰 배포해야 한다. 가능은 하지만 번거롭고, 실수의 여지가 있다.

모노레포를 사용하면 이 문제가 상당 부분 해결된다. 하나의 저장소 안에 여러 패키지를 두고, workspace protocol로 로컬 의존성을 연결한다. 패키지 A를 수정하면 B와 C에서 즉시 변경이 반영된다. 별도의 배포 과정 없이 로컬에서 통합 테스트까지 할 수 있다. 코어 개발의 속도가 붙는다.

외부 프로젝트(커스텀 프로젝트)와의 연결도 해결해야 한다. 모노레포에서 빌드한 패키지를 레지스트리에 배포하고 프로젝트에서 설치하는 게 정식 루트지만, 개발 중에는 로컬에서 바로 연결하고 싶을 때가 있다. 이때 패키지 매니저의 portal이나 link 프로토콜을 사용하면 로컬 모노레포의 패키지를 외부 프로젝트에서 직접 참조할 수 있다. 수정하고 저장하면 외부 프로젝트에서 바로 반영되니 개발 사이클이 빠르다.

트레이드오프

모노레포가 만능은 아니다. 저장소 하나가 커지면 클론 시간이 느려지고, CI/CD 파이프라인 설정이 복잡해진다. 패키지 간 의존성 그래프가 복잡해지면 빌드 순서를 관리하는 도구가 필요하다. 모노레포 전용 도구 — 빌드 오케스트레이션, 변경 감지, 선택적 배포 등 — 을 도입하고 유지하는 비용도 있다.

반면 멀티레포는 구조가 단순하다. 각 패키지가 독립된 저장소이므로 CI/CD가 간단하고, 저장소 크기도 작다. 대신 앞서 말한 동기화 비용이 지속적으로 발생한다.

내가 내린 결론은 이렇다. 코어 패키지가 서로 긴밀하게 의존하고 함께 수정되는 경우가 잦다면 모노레포가 유리하다. 패키지가 완전히 독립적이고 각자 다른 주기로 배포된다면 멀티레포도 합리적이다. 우리 상황은 전자에 가까웠다. 코어의 타입 정의를 바꾸면 유틸리티와 어댑터가 함께 바뀌어야 했으니까.

다음 편에서는

여기까지가 "왜 분리해야 하는가"에 대한 이야기다. 문제를 인식하고, 사람이 아닌 도구로 규칙을 강제해야 한다는 방향까지 잡았다. 모노레포로 코어를 관리하고, 패키지로 배포해서 커스텀 프로젝트에서 설치하는 큰 그림도 그렸다.

하지만 아직 구체적인 구현은 다루지 않았다. 타입으로 옵션을 잠근다고 했는데, 실제로 어떤 패턴을 쓰는가? 코어 패키지가 특정 프레임워크에 종속되지 않으려면 어떻게 설계해야 하는가? 프레임워크 어댑터는 어떤 역할을 하는가?

2편에서는 이 질문들에 답한다. TypeScript의 Readonly, 제네릭, 조건부 타입을 활용해서 "코어가 허용한 범위 안에서만 커스터마이징이 가능한" 타입 패턴을 설계하는 과정을 다룬다.

검색...

검색...