최근 <소프트웨어 아키텍처: The Basics> 라는 책을 읽으며, 흥미로운 표현을 접했습니다. "모듈성은 받아들이되, 세분도를 조심하라"는 말입니다. 여기서 세분도란 시스템을 얼마나 잘게 쪼갤 것인가, 즉 나눈 조각 하나하나의 크기를 말합니다. 모듈성(modularity)은 시스템을 독립적인 단위로 나누는 것 자체를 가리키고, 세분도(granularity)는 그 단위를 어느 정도 크기로 잡을 것인가를 가리킵니다. 두 단어는 붙어 다니지만 같은 말이 아닙니다.
이 문장이 흥미로운 이유는, 모듈로 나누는 것은 좋은 일이니 받아들이라고 해놓고, 곧바로 얼마나 나눌지는 조심하라고 하기 때문입니다. 제가 이해한 바는 ‘나누는 것은 미덕인데, 많이 나누는 것은 미덕이 아니라는 이야기’입니다. 이 글은 이러한 과정을 프론트 개발 과정에서 겪은 경험을 바탕으로 풀어씁니다.
관심사를 분리하고, 각 부분을 따로 이해하고 수정하고 테스트할 수 있게 만드는 것. 재사용성을 높이는 것. 이것들은 설계의 기본 미덕이고, 여기에 반대하는 사람은 없습니다. 문제는 이 미덕이 종종 "그러니까 작을수록 좋다"로 미끄러진다는 데 있습니다.
작은 것은 단순합니다. component 하나, 함수 하나만 놓고 보면 잘게 쪼갤수록 각 조각은 명료해집니다. 그런데 조각을 작게 만들수록 조각의 개수는 늘어나고, 조각을 이어붙이는 연결의 개수는 그보다 빠르게 늘어납니다. 조각 하나의 복잡도는 내려가지만 전체를 조립하는 복잡도는 올라갑니다. 어느 지점을 넘어가면, 내려간 것보다 올라간 것이 더 커집니다.
작다는 것은 단순함의 증거가 아니라, 단순함을 조각 개수만큼 나눠 가진 상태일 뿐입니다. 나눈 복잡도는 사라지지 않고 조각과 조각 사이로 옮겨 갑니다. 그리고 그 사이 공간은 파일 트리에도, 코드 리뷰 화면에도 잘 보이지 않습니다.
이 미끄러짐이 가장 선명하게 드러나는 사례가 Atomic Design입니다. 여기서 Atomic Design이란 UI를 원자(atoms), 분자(molecules), 유기체(organisms), 템플릿(templates), 페이지(pages)라는 다섯 단계로 나눠 component를 조직하는 방법론을 말합니다. 화학의 원자 비유를 그대로 가져와, 가장 작은 button이나 input을 atom으로 두고 그것들을 조합해 위로 쌓아 올립니다. Atomic Design Pattern 을 팀의 표준으로서 채택하고 난 뒤에는, 프론트 개발자들이 모여, Atomic Design Pattern을 실체화하기 위해 규칙을 정의하고, 이를 문서화합니다.
방법론 자체는 명료합니다. 문제는 이 명료함이 코드를 쓰는 시점이 아니라 코드를 나누는 시점에 매번 비용을 청구한다는 데 있습니다. 새 component를 하나 만들 때마다 개발자는 질문에 답해야 합니다. 이것은 atom인가 molecule인가. 검색창은 input이라는 atom과 button이라는 atom을 합쳤으니 molecule인가, 아니면 그 자체로 하나의 atom으로 봐도 되는가. label을 포함하면 등급이 올라가는가.
이 질문에는 객관적인 정답이 없습니다. 경계가 팀의 합의에 달려 있기 때문입니다. 그래서 결정은 코드 리뷰로 넘어갑니다. 리뷰어는 로직의 정합성을 보며, 동시에 "이 파일이 molecules 폴더에 있는 게 맞는가"를 두고 시간을 씁니다. 분류를 두고 오가는 대화가 review마다 반복되고, 그 대화가 유의미한지 고민해봅니다. 솔직히 잘 모르겠습니다(제 식견이 짧은 탓일 수도 있습니다.) 이러한 대화는 사실상 불필요한 약속들로밖에 보이지 않고, 제품을 한 발짝도 앞으로 밀지는 못합니다. 결국, 의사결정의 총량이 감당할 수 없이 커지는 순간, 팀은 암묵적으로 타협하기 시작합니다. 애매하면 그냥 molecules에 넣는 식입니다.
그렇게 분류 규칙은 문서에는 남아 있지만 코드에서는 지켜지지 않는 상태가 됩니다. 세분도를 잘게 잡은 대가는 성능이나 번들 크기가 아니라, 사람이 매번 치러야 하는 판단 비용으로 돌아옵니다.
component 이야기만은 아닙니다. 함수 단위에서도 같은 일이 벌어집니다.
순수 함수(pure function)를 선호하는 것은 옳은 습관입니다. 여기서 순수 함수란 같은 입력에 항상 같은 출력을 내고, 바깥 상태를 건드리지 않는 함수를 말합니다. 테스트하기 쉽고, 어디에 놓아도 예측 가능합니다. 그래서 로직을 잘게 순수 함수로 쪼개는 것은 대체로 권장됩니다.
문제는 잘게 쪼갠 순수 함수들이 저절로 하나의 기능이 되지는 않는다는 데 있습니다. 결국 누군가는 그 함수들을 순서대로 이어 붙여야 합니다.
pipe로 흐름을 만들고, 중간 결과를 다음 함수로 넘기고, 그 조합이 원래 의도한 동작을 하는지 다시 확인해야 합니다. 여기서 pipe란 여러 함수를 왼쪽에서 오른쪽으로 연결해, 앞 함수의 결과를 뒤 함수의 입력으로 넘기는 조립 방식을 말합니다.다음 두 코드는 같은 일을 합니다. 위는 잘게 나눈 뒤 조립한 형태이고, 아래는 나누지 않은 형태입니다.
// 잘게 나눈 뒤 pipe로 다시 조립하는 형태 const trim = (s: string) => s.trim(); const toLower = (s: string) => s.toLowerCase(); const collapseSpaces = (s: string) => s.replace(/\s+/g, " "); const normalize = (s: string) => pipe(s, trim, toLower, collapseSpaces);
// 하나의 함수 안에 그대로 둔 형태 const normalize = (s: string) => s.trim().toLowerCase().replace(/\s+/g, " ");
trim, toLower, collapseSpaces를 따로 두면 각각은 재사용 가능하고 테스트도 쉽습니다. 그런데 이 셋이 오직 normalize 안에서만 쓰인다면, 우리가 얻은 것은 세 개의 이름과 한 번의 조립 단계이고, 잃은 것은 "이 함수가 무슨 일을 하는가"를 한눈에 읽는 능력입니다. 아래 코드는 위에서 아래로 읽으면 끝이지만, 위 코드는 세 함수를 각각 찾아 읽고 나서 pipe의 순서로 머릿속에서 다시 조립해야 합니다.순수 함수로 나누는 것 자체가 목적이 될 수는 없습니다. 나눈 조각을 다시 조립하는 비용까지 계산에 넣어야, 나누는 것이 이득인지 손해인지 판단할 수 있습니다.
잘게 쪼갠 것이 손해로 바뀌는 가장 구체적인 순간은 코드를 고칠 때 찾아옵니다. 흔히 SOLID 법칙으로 불리는 것에 대해서 들어본 적이 있을 겁니다. 그 중, 가장 첫 글자는 S입니다. 이는 Single Responsibility Principle, 줄여서 SRP라고 부르는 원칙이 있습니다. 흔히 "함수는 한 가지 일만 해야 한다"로 오해되지만, 그 한 가지 일의 단위를 묻는다면, 어떻게 답해야할까요? 이 원칙을 제안한 Robert Martin은 뒤에 정의를 분명히 했습니다.
하나의 모듈은 오직 하나의, 단 하나의 변경 이유만을 가져야 한다. Gather together the things that change for the same reasons. Separate those things that change for different reasons.
핵심은 "한 가지 일"이 아니라 "한 가지 변경 이유"입니다. 같은 이유로 함께 바뀌는 것은 함께 두고, 다른 이유로 바뀌는 것은 떼어 놓으라는 뜻입니다. 이 기준으로 보면, 잘게 쪼개는 것이 원칙을 어기는 경우가 생깁니다.
어떤 기능을 세 개의 파일로 나눴다고 해봅니다. 그런데 이 셋이 언제나 같은 이유로 함께 바뀐다면, 요구사항 하나가 바뀔 때마다 개발자는 세 파일을 동시에 열어 고쳐야 합니다. 더 어려운 부분은 고치는 수고 자체가 아니라, 하나를 고칠 때 나머지 둘도 고쳐야 한다는 사실을 기억해내는 일입니다. 파일이 하나였다면 눈앞에 함께 있었을 로직이, 나뉘는 순간 "이것도 고쳐야 한다"를 개발자의 기억에 의존하게 만듭니다. 그리고 기억은 종종 실패합니다. 세 파일 중 둘만 고친 채 배포되는 production 버그가 이렇게 생깁니다.
수정할 이유가 하나인데 고쳐야 할 파일이 둘이라면, 그 분리는 내적 만족은 챙기되, 응집도를 떨어뜨린 것입니다. 여기서 응집도(cohesion)란 하나의 모듈 안에 있는 요소들이 서로 얼마나 밀접하게 관련되어 있는가를 말합니다. 함께 바뀌어야 할 것을 서로 다른 파일로 흩어 놓으면, 모듈의 경계는 늘었지만 응집도는 내려갑니다.
그러면 무엇을 기준으로 나눠야 하는가. 크기가 아니라 경계입니다.
오래된 두 지표가 여전히 가장 쓸모 있습니다. 응집도(cohesion)와 결합도(coupling)입니다. 응집도는 앞서 설명했으며, 추가로, 결합도란 서로 다른 모듈이 얼마나 강하게 얽혀 있는가를 말합니다. 좋은 설계는 응집도가 높고 결합도가 낮은 상태, 즉 함께 바뀌는 것은 한곳에 모여 있고 서로 관계없는 것은 떨어져 있는 상태입니다. 이 기준은 조각의 크기를 묻지 않습니다. 큰 덩어리 하나가 응집도 높은 경계일 수도 있고, 잘게 나뉜 조각들이 응집도 낮은 분리일 수도 있습니다. 즉, 그것이 코드의 줄 수가 되면 안됩니다.
매우 거대한 God object, 즉 너무 많은 책임을 한 덩어리에 욱여넣은 component나 모듈이 나쁘다는 것은 누구나 압니다. 그런데 그 반대편, 너무 잘게 부순 것도 그 나름의 복잡성을 만듭니다. 한쪽 극단만 경계하고 다른 쪽 극단은 미덕으로 착각하면, God object를 피하려다 조각의 바다에 빠집니다.
Atomic Design이 지켜지지 않는 것도, 순수 함수를 나눈 뒤 다시 조립해야 하는 것도, 파일 셋을 함께 고쳐야 하는 것도 결국 한 가지 질문으로 돌아갑니다. 이 경계가 기능적으로 의미 있는 단위인가, 아니면 그저 작다는 이유로 그은 선인가. 경계가 "함께 바뀌는 것"과 일치할 때 세분도는 적절하고, "작으니까 좋다"는 감각으로 그은 선은 대개 잘못된 세분도입니다.
모듈로 나누는 것은 받아들여야 합니다. 그 과정에서 관심사 분리가 자연스레 이루어지기 때문입니다. 그러나 나누는 것이 미덕이라는 사실이, 많이 나누는 것도 미덕이라는 뜻은 아닙니다. 잘게 쪼갤수록 각 조각은 단순해지지만, 그 단순함의 대가로 조립 비용과 판단 비용과 기억 비용이 생겨납니다. 좋은 세분도의 기준은 조각의 크기가 아니라, 함께 바뀌는 것을 함께 두었는가입니다.
kyu-log