경계를 빌드로 못 박으면, 경계를 옮기는 일도 빌드가 붙잡습니다

기술 블로그

지키는 힘과 붙잡는 힘은 같은 힘이다

계층 경계를 물리적인 Gradle 모듈로 쪼개서 컴파일러에게 지키게 하는 방식은, 저희 팀이 오래 써 온 방법입니다. 도메인 코어는 인프라를 몰라야 한다는 규칙이 리뷰어의 눈이 아니라 클래스패스에서 강제되고, 잘못된 방향의 의존은 코드를 쓰는 순간 컴파일 에러로 막힙니다. 이 방식의 이점은 이 블로그의 다른 글들에서 이미 충분히 이야기했습니다. 모듈을 어떻게 자르는지, 타입 한 줄이 빌드 설정을 어떻게 구성하는지, 어떤 플러그인이 무슨 규칙을 강제하는지 까지요.

이 글은 그 이점의 반대편을 이야기합니다. 저희가 이 구조를 몇 년 굴리면서 배운 건, 경계를 빌드로 못 박아 두면 그 경계를 지키는 힘과 그 경계를 옮기는 걸 방해하는 힘이 사실 같은 힘이라는 것입니다. 컴파일러가 잘못된 의존을 막아 주는 그 정확한 기제가, 나중에 경계를 다시 긋고 싶어질 때 그대로 마찰로 돌아옵니다. 규칙을 사람에게 부탁하면 규칙이 새어 나가는 대신 바꾸기는 쉽습니다. 규칙을 컴파일러에게 넘기면 규칙은 새지 않는 대신 바꾸는 데 값을 치릅니다.

그러니까 물리 모듈 분리가 공짜가 아니라는 흔한 말, 모듈이 많아지고 CRUD 하나에도 파일이 여러 벌 생긴다는 그 첫 번째 청구서 이야기가 아닙니다. 그건 시작하는 날 한 번 내면 끝나는 비용입니다. 저희가 정작 오래 앓은 건 그다음 청구서였습니다. 경계가 한 번 굳은 뒤에, 그 경계가 틀렸다는 걸 알게 됐을 때 청구되는 비용입니다.


처음 그은 경계는 대체로 틀린다

솔직히 인정할 게 있습니다. 프로젝트 초기에 그은 모듈 경계는 대체로 틀립니다. 도메인을 충분히 알기 전에 경계를 정해야 하는데, 그때 아는 것으로 그은 선은 나중에 도메인을 더 알게 되면 어긋나 있기 마련입니다.

구체적인 장면으로 보겠습니다. 어느 도메인을 처음 설계할 때, 겉으로 보면 두 개의 개념이 각자 독립적으로 보였습니다. 그래서 각각을 독립된 모듈 묶음으로 뒀습니다. 그런데 기능을 붙여 나가다 보니 두 개념이 늘 함께 바뀌었습니다. 한쪽 규칙이 바뀌면 다른 쪽도 반드시 손봐야 했고, 한쪽만 배포하는 일은 실제로는 한 번도 없었습니다. 처음에 그은 경계선이 실은 아무것도 나누고 있지 않았던 것입니다. 두 모듈은 이름만 둘 일뿐 사실상 한 덩어리였습니다.

이런 어긋남 자체는 어느 아키텍처에서나 생깁니다. 문제는 그다음입니다. 두 개념을 하나로 합치려면, 논리적으로는 그저 클래스 몇 개를 한 패키지로 옮기는 일입니다. 그런데 저희 구조에서는 그게 두 벌의 모듈 묶음을 통째로 병합하는 일이 됩니다. 두 개의 model, 두 개의 service, 두 개의 저장소 어댑터를 하나로 합치고, 그 모듈들을 참조하던 모든 의존 선언을 바꾸고, 사라진 모듈을 조립 지점에서 걷어내야 합니다. 클래스를 옮기는 손쉬운 리팩토링이, 모듈 지형 전체를 다시 그리는 공사가 되는 것입니다.

여기서 핵심은 이 마찰이 부작용이 아니라는 점입니다. 이건 저희가 원했던 그 견고함의 뒷면입니다. 경계를 함부로 넘지 못하게 만든 바로 그 힘이, 경계를 함부로 옮기지도 못하게 만듭니다. 잘못 그은 선을 지우는 데 드는 비용이, 그 선을 처음 긋는 데 든 비용보다 큽니다.


그래서 사람은 잘못된 경계와 동거하기 시작한다

여기서 조용하지만 더 위험한 일이 벌어집니다. 경계를 옮기는 비용이 높다는 걸 몸으로 알게 된 개발자는, 경계를 옮기는 대신 우회하는 길을 찾습니다.

앞의 예로 돌아가 보겠습니다. 두 개념이 늘 함께 바뀐다는 걸 알았지만, 두 모듈을 합치는 공사가 부담스럽습니다. 그러면 어떻게 될까요. 한쪽 모듈이 다른 쪽 모듈을 의존하도록 선언을 하나 추가하는 것으로 급한 불을 끕니다. 원래대로라면 두 개념 사이에 있어서는 안 될 방향의 의존일 수도 있는데, 컴파일러는 이 의존을 막지 않습니다. 왜냐하면 개발자가 의존 선언에 그 모듈을 명시적으로 적어 넣었기 때문입니다.

이 지점이 미묘합니다. 컴파일러는 선언하지 않은 의존만 막습니다. 선언된 의존이 아키텍처적으로 옳은지 그른지는 판단하지 못합니다. 그러니 "이번만 이렇게 연결하자"는 선언이 하나 들어오고, 그게 굳고, 다음 사람이 그걸 선례로 삼아 또 하나를 연결합니다. 물리 모듈로 막았다고 믿었던 규칙이, 정작 경계 조정 비용을 회피하는 과정에서 조금씩 우회당합니다.

빌드가 잘못된 의존을 막아 준다는 안심이, 역설적으로 잘못된 경계를 방치하게 만드는 것입니다. 컴파일이 되니까 괜찮다고 여기고, 정작 경계 자체가 틀렸다는 신호는 못 본 채 지나갑니다. 저희가 뒤늦게 깨달은 건, 컴파일러가 지켜 주는 건 의존의 방향이지 경계의 위치가 아니라는 사실이었습니다. 방향은 자동으로 검사되지만, 경계가 옳은 자리에 있는지는 여전히 사람이 판단해야 합니다. 그리고 그 판단을 사람이 미루게 만드는 유인이, 바로 그 자동 검사의 편안함 안에 숨어 있었습니다.


경계는 코드 모양이 아니라 함께 바뀌는 리듬에서 온다

몇 번 이런 일을 겪고 나서 저희가 경계를 긋는 기준 자체를 바꿨습니다. 처음에는 헥사고날 다이어그램의 모양을 그대로 모듈에 옮기려 했습니다. 안쪽에 도메인, 바깥에 어댑터, 그 사이에 유스케이스. 그림이 그렇게 생겼으니 모듈도 그렇게 나누면 된다고 여긴 것입니다. 계층 방향으로 나누는 이 축은 여전히 유효합니다. 도메인이 인프라를 몰라야 한다는 규칙은 그대로 지킬 가치가 있습니다.

문제는 그 안에서 도메인을 다시 여러 조각으로 쪼갤 때였습니다. 무엇을 한 덩어리로 묶고 무엇을 따로 뗄지, 이 판단을 코드의 겉모습으로 하면 자주 틀립니다. 지금 두 개념이 서로 달라 보인다는 이유로 나누는 것은, 지금의 스냅샷에 경계를 맞추는 일입니다. 정작 봐야 할 것은 이 둘이 앞으로 함께 바뀔 것인가 입니다.

기준을 이렇게 바꿨습니다. 늘 함께 바뀌는 것은 한 모듈에 두고, 서로 다른 이유로 다른 시점에 바뀌는 것만 경계로 가른다. 겉모습이 아무리 달라도 늘 함께 손대야 하는 두 개념이라면 한 덩어리로 두는 편이 낫습니다. 반대로 겉모습이 비슷해도 바뀌는 이유가 완전히 다르다면, 그 사이는 모듈로 갈라 둘 값어치가 있습니다. 경계는 코드가 어떻게 생겼는지가 아니라, 코드가 어떤 리듬으로 바뀌는 지를 따라가야 한다는 것입니다.

이 기준이 아름다워서 택한 게 아닙니다. 앞 절에서 본 마찰 때문에 택했습니다. 함께 바뀌는 것을 갈라 두면, 매번 두 모듈을 동시에 열어 고치는 마찰을 평생 냅니다. 반대로 따로 바뀌는 것을 한 모듈에 뭉쳐 두면, 한쪽 때문에 무관한 다른 쪽까지 다시 빌드하고 다시 테스트하는 마찰을 냅니다. 경계를 옮기는 비용이 큰 구조일수록, 처음에 경계를 변경 리듬에 맞춰 긋는 일이 더 중요해집니다. 되돌리기 어려운 결정일수록 그 결정의 기준을 더 신중하게 골라야 하니까요.


되돌리기 어렵다는 성질을 설계에 반영하기

그렇다고 처음에 완벽한 경계를 그을 수 있는 건 아닙니다. 함께 바뀔지 따로 바뀔지는 미래의 일이고, 미래는 지금 다 알 수 없습니다. 그래서 저희가 실용적으로 택한 태도는 두 가지입니다.

첫째, 확신이 없으면 덜 쪼갭니다. 나중에 하나를 둘로 쪼개는 건 상대적으로 쉽습니다. 이미 한 모듈 안에 있던 것들의 관계는 자유롭게 조정할 수 있고, 쪼갤 선이 분명해졌을 때 그 선을 따라 가르면 됩니다. 반대로 둘을 하나로 합치는 건 앞에서 본 것처럼 훨씬 비쌉니다. 두 방향의 비용이 대칭이 아니니, 애매할 때는 비용이 싼 쪽, 즉 덜 쪼갠 쪽으로 기울입니다. 경계는 필요가 분명해진 다음에 긋는 편이 낫습니다.

둘째, 경계를 옮기는 일 자체를 정상적인 작업으로 취급합니다. 한번 그은 경계를 신성불가침으로 두면, 사람들은 앞 절처럼 우회로 도망칩니다. 그래서 저희는 "이 두 모듈은 늘 함께 바뀌니 합치자"거나 "이 모듈은 두 가지 이유로 바뀌니 가르자"는 제안을, 기능 개발과 똑같은 무게의 정당한 작업으로 다룹니다. 경계 조정을 별나고 위험한 일이 아니라 주기적으로 하는 정비로 여기면, 잘못된 경계와 동거하는 시간이 짧아집니다.

여기서 골격 도구가 이 두 번째 태도를 실제로 가능하게 해 줍니다. 새 모듈을 만드는 일이 디렉토리 하나 만들고 설정 파일에 타입 한 줄 적는 정도로 끝나기 때문에, 쪼개는 방향의 조정은 가볍습니다. 무거운 건 합치는 방향입니다. 그래서 앞의 첫째 원칙, 애매하면 덜 쪼갠다가 더 중요해집니다. 도구가 싸게 만들어 준 방향으로 나중에 움직일 수 있게, 처음에는 뭉쳐 두는 것입니다.


이 비용을 알고도 우리는 왜 이 방식을 유지하는가

여기까지 읽으면 물리 모듈 분리가 손해처럼 들릴 수 있습니다. 그래서 균형을 맞춰 두겠습니다. 저희는 이 비용을 다 겪고도 이 방식을 유지하고 있습니다. 이유는 이 마찰이 결국 좋은 방향의 마찰이기 때문입니다.

경계를 옮기기 어렵다는 사실은, 경계를 아무렇게나 옮기지 못하게도 만듭니다. 물리 모듈이 아니라 그냥 패키지였다면, 두 개념을 붙였다 뗐다 하는 일이 아무 마찰 없이 일어납니다. 그건 편해 보이지만, 실은 경계가 있는지 없는지도 모르는 상태로 미끄러진다는 뜻입니다. 마찰이 없으면 구조가 슬그머니 무너져도 아무도 눈치채지 못합니다. 저희 구조에서는 경계를 옮기는 순간 여러 모듈의 의존 선언을 손대야 하니, 그 변경이 리뷰에 또렷하게 드러납니다. 경계를 옮긴다는 사실 자체가 눈에 보이는 사건이 됩니다.

정리하면 이렇습니다. 물리 모듈 분리의 진짜 트레이드오프는 흔히 말하는 모듈 개수나 초기 설정 비용이 아닙니다. 그건 하루치 비용입니다. 진짜 트레이드오프는 경계의 견고함과 경계의 유연함을 맞바꾼다는 데 있습니다. 저희는 견고함을 골랐고, 그 대가로 유연함을 잃었습니다. 그리고 잃은 유연함을 메우기 위해, 처음부터 변경 리듬으로 경계를 긋고, 애매하면 덜 쪼개고, 경계 조정을 정상 작업으로 다루는 습관을 들였습니다.

이 선택이 모든 팀의 정답은 아닙니다. 도메인이 아직 요동치는 초기 제품이라면, 경계를 자주 옮겨야 할 텐데 옮기기 어려운 구조는 오히려 발목을 잡습니다. 반대로 도메인이 어느 정도 안정되고 여러 팀이 한 코드베이스를 오래 함께 다뤄야 하는 상황이라면, 경계가 함부로 무너지지 않는 견고함이 값을 합니다. 저희가 이 방식을 유지하는 건 후자의 상황에 있기 때문이지, 이 방식이 언제나 옳아서가 아닙니다.

경계를 컴파일러에게 지키게 하는 순간, 경계를 옮기는 일도 컴파일러의 일이 됩니다. 지키는 힘과 붙잡는 힘이 같은 힘이라는 걸 받아들이고 나면, 남는 질문은 하나입니다. 지금 이 경계는, 붙잡아 둘 만큼 옳은 자리에 있는가.


🚀플렉스팀 채용페이지 바로가기

☕flex Private Talk 신청하기

글이 마음에 드셨나요?
공유하기