thenullpage.com

청구서를 다루는 클래스를 예로 든다. 금액을 합치고, 표 형식 파일로 내보내고, 메일로 보낸다. 셋 다 청구서에 관한 일이니 한 클래스에 담는 것이 자연스러워 보인다.


interface Line { name: string; price: number; qty: number; }
interface Mailer { send(to: string, title: string, body: string): void; }
declare const mailer: Mailer;

class Invoice {
constructor(private lines: Line[]) {}

total() {
let sum = 0;
for (const l of this.lines) sum += l.price * l.qty;
return sum + Math.round(sum * 0.1);
}

toCsv() {
return this.lines.map(l => l.name + "," + l.price * l.qty).join("\n");
}

send(to: string) {
mailer.send(to, "청구서", this.toCsv());
}
}

const inv = new Invoice([{ name: "키보드", price: 30000, qty: 2 }]);
console.log(inv.total()); // 66000


6만 원에 부가세 10퍼센트를 더한 66000이 찍힌다. 필요한 기능이 한 자리에 모여 있고 쓰는 쪽도 짧다. 이 코드가 몇 달 뒤 어떻게 망가지는지는 기능이 아니라 요청이 오는 방향을 세어 보면 보인다.

클래스가 바뀌는 이유를 세는 방법

이 파일을 고쳐 달라는 요청이 실제로 누구에게서 오는지 적어 본다. 부가세율이 8퍼센트로 바뀌었다는 요청은 회계 담당이 한다. 내보낸 파일이 엑셀에서 한글이 깨진다는 요청은 그 파일을 받아 쓰는 쪽에서 한다. 메일 서버를 교체했으니 맞춰 달라는 요청은 인프라 담당이 한다. 세 사람은 서로 상의하지 않고 각자 필요할 때 요청한다.


클래스가 바뀔 이유는 하나여야 한다는 원칙을 단일 책임 원칙(Single Responsibility Principle, SRP)이라 한다. 여기서 말하는 책임은 기능 하나가 아니라 변경을 요청하는 사람 한 부류다. 클래스는 한 가지 일만 해야 한다는 문장으로 외우면 기준이 사라진다. 한 가지가 어디까지인지는 아무도 정해 주지 않기 때문이다. 요청하는 사람을 세면 답이 하나로 정해진다.


세 방향의 요청이 한 파일로 몰리면 두 가지가 따라온다. 첫째, 메일 형식만 손보러 연 파일에서 합계 계산 코드를 같이 지나가게 된다. 관계없는 줄을 잘못 건드릴 기회가 매번 생긴다. 둘째, 테스트가 엉킨다. 세율 계산이 맞는지 확인하려는데 같은 클래스에 메일 전송이 붙어 있으면 그 객체를 만드는 순간부터 메일 설정이 필요해진다.


사고는 대개 이 지점에서 난다. 세율을 고치러 파일을 연 김에 눈에 걸리는 출력 코드를 조금 정리하면, 파일을 받아 쓰는 쪽 시스템이 다음 날 멈춘다. 회계 요청 하나를 처리했을 뿐인데 아무도 부탁하지 않은 변경이 같이 나간 것이다. 사람이 조심하면 될 일처럼 보이지만, 서로 다른 요청이 같은 파일에 계속 몰리는 구조라면 언젠가는 일어난다.

같이 바뀌는 것끼리 모으기

나누는 기준은 요청이 오는 방향이다. 계산은 계산끼리, 형식은 형식끼리, 전송은 전송끼리 모은다.


class Invoice {
constructor(readonly lines: Line[]) {}
subtotal() {
let sum = 0;
for (const l of this.lines) sum += l.price * l.qty;
return sum;
}
tax() { return Math.round(this.subtotal() * 0.1); }
total() { return this.subtotal() + this.tax(); }
}

class CsvWriter {
write(inv: Invoice) {
return inv.lines.map(l => l.name + "," + l.price * l.qty).join("\n");
}
}

class InvoiceMailer {
constructor(private mailer: Mailer, private writer: CsvWriter) {}
send(to: string, inv: Invoice) {
this.mailer.send(to, "청구서", this.writer.write(inv));
}
}

console.log(new CsvWriter().write(inv)); // 키보드,60000


세율이 바뀌면 Invoice만 연다. 파일에서 글자가 깨지면 CsvWriter만 연다. 메일 서버가 바뀌면 InvoiceMailer만 연다. 고칠 자리를 찾는 데 드는 시간이 줄고, 고치다 다른 것을 부술 여지도 줄어든다. 합계 계산 테스트는 Invoice 하나만 만들어서 돌리면 되니 메일 설정도 필요 없다.


세 조각을 이어 붙이는 자리는 밖으로 나왔다. 이 조립을 누가 하느냐는 문제는 남지만, 적어도 서로 다른 이유로 바뀌는 코드가 같은 파일에서 부딪히는 일은 없어졌다.

메서드 개수로 나누지 않는다

고친 뒤에도 Invoice에는 메서드가 셋이다. subtotal, tax, total. 한 가지 일만 하라는 문장으로 외운 사람은 여기서 또 쪼개려 든다. 그럴 이유가 없다. 셋은 전부 회계 담당 한 사람의 요청으로 함께 바뀐다. 세율이 8퍼센트가 되면 tax가 바뀌고 total의 결과도 따라 바뀐다. 같은 이유로 같이 바뀌는 코드는 붙여 두는 것이 맞다.


반대로 줄 수가 적어도 나눠야 하는 경우가 있다. Invoice에 저장을 담당하는 save 메서드를 넣었다고 하자. 세 줄짜리 짧은 메서드지만 데이터베이스 담당이 테이블을 바꿀 때마다 회계 규칙이 든 파일이 열린다. 길이는 상관이 없다. 요청이 어디서 오는지만 본다.


클래스 안에 코드가 몇 줄인지, 메서드가 몇 개인지는 판단 기준이 아니다. 메서드 열 개가 전부 한 사람의 요청으로 바뀐다면 그 클래스는 책임이 하나다. 메서드가 둘뿐이어도 요청이 두 부서에서 온다면 둘로 나뉘어야 한다.


판단이 애매할 때 도움이 되는 신호가 이름이다. 클래스에 이름을 붙이려는데 Manager, Helper, Util, Processor 같은 단어부터 떠오른다면 그 안에 서로 다른 이유로 바뀌는 코드가 섞여 있을 가능성이 높다. 무엇을 책임지는지 한 문장으로 말할 수 있으면 이름은 대개 저절로 나온다.

지나치게 쪼갠 쪽의 대가

원칙을 막 배운 사람이 흔히 반대쪽으로 넘어간다. 메서드 하나마다 클래스를 만들고 이름 뒤에 Service를 붙이는 식이다. 파일 수는 늘지만 얻는 것은 없다. 할인 규칙 하나를 바꾸려고 파일 다섯 개를 열어야 한다면 그것은 원칙을 지킨 결과가 아니라 어긴 결과다. 같은 이유로 함께 바뀌는 코드를 흩어 놓았다는 뜻이기 때문이다.


그래서 나누기 전에 확인할 것은 하나다. 지금 실제로 요청이 두 방향에서 오는가. 온다면 나눈다. 한 방향이면 메서드가 몇 개든 그대로 둔다. 언젠가 회계 규칙과 출력 형식이 따로 놀 것 같다는 짐작만으로 미리 쪼개면, 아직 오지도 않은 변경에 대비하느라 지금 읽는 사람만 고생한다.


기준을 한 문장으로 줄이면 이렇다. 코드를 고칠 때 파일 하나만 열게 되는가. 하나로 끝나면 경계가 제대로 그어진 것이고, 매번 여러 파일을 오간다면 잘못 묶였거나 잘못 흩어진 것이다. 클래스 목록을 볼 때 기능이 아니라 그 클래스를 고쳐 달라고 말할 사람을 떠올리는 습관이 붙으면 이 판단은 어렵지 않다.