모노레포를 공유가 아니라 분해에 쓰고 있었다
토스는 흩어진 것을 모으려고 모노레포를 지켰다.
나는 하나를 쪼개려고 쓰고 있었다. 5개월 동안 앱이 둘에서 다섯이 됐다.
토스에서 모노레포 글이 올라왔다. 100명이 넘는 프론트엔드 엔지니어가 한 저장소에서 일하는데 서비스마다 React 버전이 제각각이었고, 그걸 카탈로그로 통일하는 데 1년이 걸렸다는 내용이다.
읽으면서 내 것도 그런가 싶었다. 마침 5개월 전에 모노레포 CI/CD 구축기를 써둔 게 있어서 비교할 기준이 있다.
열어보니 버전은 안 갈라져 있었다. 대신 다른 게 보였다. 토스와 나는 같은 도구를 반대 방향으로 쓰고 있었다.
그때의 구조
3월 글을 쓸 때 이 워크스페이스에는 앱이 둘이었다. Next.js로 만든 웹과 NestJS로 만든 API. 공유 라이브러리 넷은 저장소를 만든 날 Nx 제너레이터가 찍어준 그대로였고, CI의 변경 감지 표도 web, api, libs 세 줄이면 끝났다.
당시 설계 결정에 이렇게 적었다.
공유 라이브러리가 바뀌면 양쪽 모두 빌드한다.
앱이 둘이니 “양쪽 모두”가 정확한 표현이었다.
앱이 둘에서 다섯이 되는 동안
지금은 다섯이다. 5월에 릴리스 자동화용 앱이 붙었고, 이번 달에 둘이 더 붙었다.
소켓 서버를 뗀 건 배포 때문이다. 이 제품은 기능 대부분이 채팅에서 일어나는데, 처음에 웹과 API 둘로만 짜면서 소켓을 API 안에 같이 뒀다. 그러니 API 하나가 HTTP 포트와 소켓 포트를 같이 들고, 요청 부하와 연결 부하를 같이 받았다. 결정적인 건 배포였다. API를 새로 올릴 때마다 붙어 있던 연결이 전부 끊겼다. 채팅이 주 기능인데 배포할 때마다 대화가 끊기는 구조였다. 떼어내면서 Redis를 넣고 pub/sub으로 붙였다. 소켓 서버를 여러 벌 띄워도 메시지가 한쪽으로만 가지 않게 하려면 그게 필요했다.
LLM 런타임을 뗀 건 무게 때문이다. API 안에 LLM 기능까지 다 들어 있으니 langgraph와 langchain 모듈을 API가 통째로 들고 있었다. 정작 API가 할 일은 인증 관리와 DB를 다루는 쪽이었다. 게다가 LLM 쪽은 메모리를 많이 쓴다. 떼어내고 나니 API가 가벼워졌다.
셋을 놓고 보면 기준이 하나다. 역할이 달라서 쪼갠 게 아니라 운영 특성이 달라서 쪼갰다. 소켓은 연결이 배포보다 오래 살고, LLM 런타임은 메모리와 의존성 무게가 API와 다르다. 한 프로세스에 같이 두면 둘 중 하나가 항상 손해를 본다.
코드가 늘면서 이 경계가 눈에 보이기 시작한 게 5개월이다. 처음부터 다섯으로 짤 수 있었냐면 그건 아니었다. 무엇이 무엇과 수명이 다른지는 굴려봐야 안다.
쪼갰는데 안 흩어진 이유
프로세스를 쪼개면 보통 두 가지가 따라온다. 각자 의존성을 갖게 되면서 버전이 갈라지고, 서로 주고받는 계약이 깨진다.
둘 다 안 생겼다. 이유가 둘이다.
의존성이 루트 package.json 한 곳에 선언돼 있다. 앱과 라이브러리는 workspace:*로 서로를 참조하고, 외부 패키지 버전은 위에서 한 번만 정한다. 앱을 하나 더 만들어도 버전을 새로 고를 일이 없다. 토스가 카탈로그와 월간 정례로 1년 걸려 만든 상태를, 여기서는 그냥 갖고 시작했다.
그리고 타입 정의 하나가 다섯 프로세스의 계약을 컴파일 타임에 묶는다. 소켓 서버를 API에서 꺼낼 때 타입이 안 맞으면 빌드가 먼저 터졌다. 런타임에서 메시지 모양이 어긋나는 걸 나중에 발견하는 대신, 옮기는 중에 컴파일러가 잡았다.
여기서 처음 질문으로 돌아간다. 공유 라이브러리는 넷에서 여섯이 됐는데, 다섯 앱이 전부 쓰는 건 그 타입 정의 하나뿐이다. 나머지는 두 개씩만 쓰고, 처음 만든 넷 중 둘은 아직도 파일이 하나씩인 빈 껍데기다. 마지막으로 손댄 게 3월 31일이다.
앱이 두 배 반이 되는 동안 공유 코드는 늘지 않았다.
그동안 나는 “공유할 게 많아서 모노레포”라고 말해왔는데, 실제로 하고 있던 일은 그게 아니었다. 여기서 모노레포는 코드를 나눠 쓰는 장소가 아니라 쪼갤 때 드는 비용을 흡수하는 장치다. 토스가 흩어진 것을 모으려고 모노레포를 지켰다면, 나는 하나를 쪼개면서 안 흩어지려고 쓰고 있었다.
대신 CI가 넓어졌다
공짜는 아니다. 청구서는 CI로 온다.
변경 감지는 dorny/paths-filter로 한다. 앱별 필터에 각각 libs/**가 들어 있어서, 라이브러리가 바뀌면 필터가 전부 켜진다. 3월에 “공유 라이브러리가 바뀌면 양쪽 모두 빌드한다”고 썼을 때 양쪽은 둘이었다. 지금은 다섯이라 트리거되는 빌드가 2.5배가 됐다. 두 앱만 쓰는 라이브러리를 고쳐도 다섯이 다 빌드되고, 아무도 안 쓰는 빈 껍데기를 건드려도 다섯이 다 빌드된다.
3월 글의 설계 결정 표에는 “paths-filter와 Nx affected의 2단 필터링”이라고 적혀 있다. 지금 워크플로를 열어보면 Nx affected는 lint와 test에만 쓰고, 빌드 게이팅은 paths-filter 단독이다. Nx는 정확한 의존 그래프를 이미 갖고 있는데 CI는 그걸 안 보고 손으로 쓴 근사치를 본다.
손으로 관리하는 그래프는 갈라지기 마련이다. 실제로 앱 하나의 필터에만 워크플로 파일과 매니페스트 경로가 더 들어가 있고 나머지 넷에는 없다. 필요해서 넣었을 텐데 왜 하나에만 필요했는지는 지금 기억이 안 난다.
그래서 고쳤냐면, 안 고쳤다. 최근 100개 커밋 중 라이브러리를 건드린 게 4건이고, 그중 제일 잦은 게 타입 정의인데 얘는 어차피 다섯 앱이 다 쓴다. 초과 트리거가 실제로 발생하는 건 100 커밋당 세 번쯤이라 아직 손댈 자리가 아니다.
다만 이 비용은 쪼갤수록 커진다. 앱을 여섯, 일곱으로 늘릴 생각이면 그전에 빌드 게이팅을 Nx 그래프로 옮겨야 한다. 분해의 안전망을 쓰는 값이 여기서 나간다.
카탈로그는 필요했나
원래 질문으로 돌아가면, 토스가 카탈로그로 푼 문제는 여기 없다. 버전을 각자 소유하는 서비스가 없으니 회수할 것도 없다.
공급망 대응도 다른 게 하고 있었다. pnpm.overrides에 80개 항목이 쌓여 있다. 대부분 이런 모양이다.
"tar@<7.5.21": "7.5.21",
"undici@>=7.0.0 <7.29.0": "7.29.0"Dependabot 경고가 뜰 때마다 한 줄씩 늘어난 것들이다. 카탈로그는 직접 의존성을 다루지만 overrides는 전이 의존성까지 강제하니 커버 범위로는 더 넓다.
대신 대가가 있다. 이 80줄 중에 무엇을 언제 지워도 되는지 판단할 근거가 없다. 상위 패키지가 올라가서 이미 해결된 게 섞여 있을 텐데, 지워보기 전에는 모른다. 토스는 매달 모여서 카탈로그를 발행하고 파괴적 변경을 어디에 넣을지 정한다. 그런 정례가 있으면 목록이 자란 이유가 기록으로 남는데, 여기는 그게 없다.
찾다 보니 하나 걸리긴 했다. 웹 앱 package.json에 react, react-dom, next를 포함해 여덟 개가 루트와 중복으로 선언돼 있다. 버전 범위는 전부 일치한다. 지금은. 일치를 강제하는 장치는 없고, 두 곳을 같이 고쳐온 사람이 있었을 뿐이다. 앱이 하나 더 붙거나 다른 사람이 웹만 만지기 시작하면 갈라진다. 여덟 줄뿐이니 갈라지기 전에 지워두면 된다.
5개월 뒤에 남은 것
같은 도구를 쓰는데 얻는 게 다르다. 토스는 여러 팀의 서비스를 한 곳에 모아두고 버전 정책을 전파하려고 모노레포를 지킨다. 나는 한 제품을 운영 특성에 따라 여러 프로세스로 쪼개면서, 쪼갤 때 드는 비용을 모노레포에 떠넘기고 있다.
그래서 공유 코드가 안 늘어난 게 이상한 일이 아니었다. 애초에 그 용도로 쓰고 있지 않았다.
대신 값은 CI에서 치른다. 지금은 100 커밋당 세 번이라 참을 만하고, 앱이 더 늘면 못 참게 된다. 그 선을 넘기 전에 빌드 게이팅을 Nx 그래프로 옮기는 게 다음 숙제다.
3월에 남긴 문장 하나는 채워졌다. “Smoke Test는 아직 빈 칸이다”라고 써둔 걸 6월 11일에 ArgoCD PostSync Hook으로 메웠다. 과제로 남긴 걸 3개월 뒤에 처리한 셈인데, 글에 빈 칸이라고 적어둔 게 도움이 됐다. 공개된 문장은 잊기가 어렵다.
참고자료
- 모노리포 희망편, 절망의 리포가 희망의 리포로 부활하기까지 걸린 1년, 토스가 카탈로그로 의존성 파편화를 정리한 1년의 기록
- pnpm catalogs, 워크스페이스 단위로 버전을 한 곳에서 정의하는 공식 문서
- pnpm overrides, 전이 의존성까지 버전을 강제하는 설정