thenullpage.com

주문을 받으면 알림을 보내는 코드를 예로 든다. 주문 서비스가 메일 발송기를 직접 만들어 쓴다.


class EmailSender {
send(to: string, text: string) {
console.log("[메일] " + to + " : " + text);
}
}

class OrderService {
private sender = new EmailSender();
place(user: string, item: string) {
console.log(item + " 주문 완료");
this.sender.send(user, item + " 주문이 접수되었다");
}
}

new OrderService().place("[email protected]", "키보드");
// 키보드 주문 완료
// [메일] [email protected] : 키보드 주문이 접수되었다


두 줄이 순서대로 찍힌다. 클래스 두 개에 열 몇 줄이고 동작에 이상도 없다. 이 코드의 문제는 실행 결과가 아니라 OrderService라는 파일이 무엇을 알고 있느냐에 있다.

정책이 도구를 알고 있다

주문을 접수하고 알린다는 것은 이 서비스의 정책이다. 메일로 보내느냐 문자로 보내느냐는 그 정책을 실어 나르는 도구다. 지금 코드는 정책 쪽 파일이 도구 쪽 클래스 이름을 직접 적어 두고 있다.


결과는 셋이다. 문자 발송으로 바꾸라는 요구가 오면 OrderService를 연다. 알림 방식이 고객마다 달라지면 그 분기도 이 파일로 들어온다. 주문 로직만 확인하려 해도 테스트가 EmailSender를 같이 만든다. 바뀔 이유가 다른 두 가지가 한 파일에서 만나고 있다.


고수준 모듈이 저수준 모듈에 의존해서는 안 되고 둘 다 추상에 의존해야 한다는 원칙을 의존 역전 원칙(Dependency Inversion Principle, DIP)이라 한다. 고수준은 업무 규칙 쪽이고, 저수준은 메일이나 데이터베이스처럼 바깥과 닿는 쪽이다.


역전이라는 이름은 화살표에서 나왔다. 실행할 때는 주문 서비스가 메일 발송기를 부르니 흐름이 위에서 아래로 간다. 원칙을 지키면 메일 발송기 쪽이 주문 서비스가 정한 인터페이스를 구현하게 되므로, 코드가 서로를 아는 방향은 아래에서 위가 된다. 실행 흐름은 그대로 두고 참조 방향만 뒤집는 것이다.

만들지 말고 받는다

고치는 방법은 필요한 능력을 인터페이스로 적고, 그것을 생성자로 받는 것이다.


interface Notifier {
notify(to: string, text: string): void;
}

class EmailNotifier implements Notifier {
notify(to: string, text: string) { console.log("[메일] " + to + " : " + text); }
}

class SmsNotifier implements Notifier {
notify(to: string, text: string) { console.log("[문자] " + to + " : " + text); }
}

class OrderService {
constructor(private notifier: Notifier) {}
place(user: string, item: string) {
console.log(item + " 주문 완료");
this.notifier.notify(user, item + " 주문이 접수되었다");
}
}

new OrderService(new EmailNotifier()).place("[email protected]", "키보드");
new OrderService(new SmsNotifier()).place("010-0000-0000", "마우스");
// 키보드 주문 완료
// [메일] [email protected] : 키보드 주문이 접수되었다
// 마우스 주문 완료
// [문자] 010-0000-0000 : 마우스 주문이 접수되었다


OrderService 안에는 EmailNotifier도 SmsNotifier도 등장하지 않는다. 어떤 구현을 쓸지 정하는 코드는 프로그램을 조립하는 자리, 즉 맨 아래 두 줄로 올라갔다. 필요한 것을 스스로 만들지 않고 밖에서 받는 이 방식을 의존성 주입(Dependency Injection, DI)이라 한다. 프레임워크부터 떠올릴 필요는 없다. 생성자 인자 하나가 전부다.


DIP와 DI는 층이 다르다. 무엇에 의존할 것인가를 정하는 쪽이 원칙이고, 그것을 어떻게 건네줄 것인가가 기법이다. 인터페이스 없이 EmailSender를 생성자로 받기만 해도 주입은 주입이지만, 구체 클래스 이름이 여전히 시그니처에 박혀 있으니 역전은 일어나지 않았다.


덤으로 따라오는 것이 테스트다.


class FakeNotifier implements Notifier {
sent: string[] = [];
notify(to: string, text: string) { this.sent.push(to); }
}

const fake = new FakeNotifier();
new OrderService(fake).place("[email protected]", "키보드");
console.log(fake.sent.length); // 1


메일은 나가지 않고 호출된 사실만 남는다. 이런 대역을 끼워 넣을 수 있다는 것 자체가 의존이 제대로 뒤집혔다는 신호다. 반대로 테스트를 짜려는데 실제 발송을 막을 방법이 없다면 그 클래스는 도구에 직접 묶여 있는 것이다.

인터페이스는 쓰는 쪽 언어로 짓는다

여기서 흔한 실수가 나온다. 구현 클래스에서 메서드를 그대로 뽑아 인터페이스라고 부르는 것이다.


interface IEmailSender {
sendMail(to: string, subject: string, body: string, cc: string[]): void;
}


파일은 하나 늘었는데 뒤집힌 것은 없다. 제목과 참조 수신자는 메일에만 있는 개념이라, 문자 구현이 이 인터페이스를 따르려면 두 인자를 받아 놓고 버려야 한다. 추상이 저수준을 그대로 베낀 탓에 주문 서비스는 여전히 메일이라는 도구의 사정을 알고 있다.


Notifier는 다르다. 누구에게 무슨 내용을 알린다는, 주문 서비스가 쓰는 말로만 적혀 있다. 인터페이스는 구현하는 쪽이 아니라 호출하는 쪽이 소유한다. 이름도 메서드도 상위 쪽 언어로 짓고 구현이 거기에 맞춘다. 추출 작업이 아니라 설계다.


의존을 숨기는 방식도 피한다. 생성자를 비워 두고 안에서 전역 저장소를 꺼내 쓰면 바깥에서 바꿔 끼울 수는 있으니 주입처럼 보인다. 그러나 생성자만 봐서는 이 클래스가 무엇을 필요로 하는지 알 수 없고, 테스트할 때마다 전역 상태를 먼저 채워 놓아야 한다. 필요한 것은 감추지 말고 인자로 드러낸다.

뒤집을 자리를 고른다

추상 하나에는 값이 붙는다. 파일이 늘고, 코드를 따라가려면 인터페이스를 거쳐 구현을 찾아야 한다. 구현이 하나뿐이고 바뀔 이유도 없는 문자열 포맷 함수까지 인터페이스로 감싸면 간접층만 남는다.


기준은 경계다. 네트워크, 파일, 데이터베이스, 현재 시각, 난수처럼 프로세스 바깥과 닿는 것을 먼저 뒤집는다. 느리고, 실패하고, 테스트에서 통제하기 어려운 것들이다. 반대로 계산만 하는 코드는 그냥 부르면 된다.


주입하는 것이 늘 인터페이스일 필요도 없다. 현재 시각 하나만 있으면 되는 자리라면 new Date를 안에서 부르는 대신 날짜를 돌려주는 함수를 생성자로 받으면 된다. 실제 코드에서는 시각을 그대로 주고 테스트에서는 고정된 날짜를 주니 결과가 매일 달라지지 않는다. 클래스를 새로 만들지 않아도 역전은 성립한다.


마지막 판단 기준은 단순하다. 이 클래스만 떼어 놓고 테스트할 때 진짜로 무언가 바깥으로 나가는가. 나간다면 그 자리가 뒤집을 자리다. 나가지 않는다면 지금 구조로 충분하다.