thenullpage.com
결제 기능을 붙이는 작업을 예로 든다. 처음에는 카드 결제 하나뿐이라, 결제 회사가 준 클래스를 필요한 자리에서 바로 만들어 쓰는 방식이 가장 빠르다. 이 구조가 무너지는 시점은 대체로 정해져 있다. 계좌 이체가 붙고, 몇 달 뒤 간편결제가 붙을 때다.
코드로 보면 이런 모양으로 시작한다.
class CardPayment {
pay(amount: number) { console.log("카드로 " + amount + "원 결제"); }
}
class BankTransfer {
pay(amount: number) { console.log("계좌 이체로 " + amount + "원 결제"); }
}
function checkout(method: string, amount: number) {
if (method === "card") new CardPayment().pay(amount);
else if (method === "bank") new BankTransfer().pay(amount);
else throw new Error("지원하지 않는 결제 수단");
}
checkout("card", 30000); // 카드로 30000원 결제
결제 수단이 늘 때마다 열어야 하는 함수
checkout 함수는 결제 수단의 이름과 그 이름에 대응하는 클래스를 전부 알고 있다. 간편결제를 추가하려면 이 함수를 열어 분기를 하나 더 넣어야 한다. 실제 서비스에서는 같은 분기가 환불, 영수증 발행, 결제 내역 조회에도 복사돼 있어, 수단 하나를 추가하는 작업이 네다섯 군데를 뒤지는 작업이 된다. 한 곳을 빠뜨리면 결제는 되는데 환불은 안 되는 상태로 배포된다.
원인은 checkout이 무엇을 할지에 더해 어떻게 할지까지 알고 있다는 데 있다. 결제를 진행한다는 목적만 놓고 보면, 그 결제가 카드로 이뤄지는지 계좌 이체로 이뤄지는지는 checkout이 알 필요가 없는 정보다.
인터페이스는 무엇만 적어 둔 약속이다
인터페이스(interface, 어떤 메서드를 갖춰야 하는지만 적고 그 내용은 적지 않는 타입)를 쓰면 이 둘을 갈라놓을 수 있다. 아래 Payment에는 pay라는 메서드가 있어야 한다는 사실만 적혀 있고, 결제가 실제로 어떻게 이뤄지는지는 한 줄도 없다.
interface Payment {
pay(amount: number): void;
}
class CardPayment implements Payment {
pay(amount: number) { console.log("카드로 " + amount + "원 결제"); }
}
class BankTransfer implements Payment {
pay(amount: number) { console.log("계좌 이체로 " + amount + "원 결제"); }
}
function checkout(payment: Payment, amount: number) {
payment.pay(amount);
}
checkout(new CardPayment(), 30000); // 카드로 30000원 결제
checkout(new BankTransfer(), 30000); // 계좌 이체로 30000원 결제
implements Payment는 이 클래스가 Payment에서 요구하는 메서드를 전부 갖췄다는 선언이다. 빠뜨린 메서드가 있으면 실행해 보기 전에 컴파일 단계에서 잡힌다. checkout은 이제 카드라는 단어도 계좌 이체라는 단어도 모른다. 받은 객체에게 결제해 달라고 요청할 뿐이다.
간편결제를 추가하는 작업은 Payment를 구현하는 클래스를 하나 더 만드는 일로 끝난다. 어떤 구현을 넣을지 고르는 자리 한 곳만 바뀐다. 고칠 곳이 다섯 군데에서 한 군데로 줄었다는 사실이 인터페이스로 얻는 실질적인 이득이다.
추상화가 새는 순간
인터페이스 파일을 만들었다고 분리가 끝나지는 않는다. 흔한 실패는 인터페이스 안에 특정 구현의 사정이 섞여 들어가는 것이다.
interface Payment {
pay(amount: number): void;
getCardNumber(): string;
openBankApp(): void;
}
계좌 이체 클래스는 getCardNumber에 돌려줄 값이 없다. 빈 문자열을 돌려주거나 예외를 던지게 되고, 호출하는 쪽은 결국 이 객체가 카드인지 확인하는 분기를 다시 넣는다. 인터페이스를 만들기 전과 똑같은 자리로 돌아온다. 이렇게 감춰야 할 구현 세부가 인터페이스 밖으로 비어져 나오는 현상을 새는 추상화(leaky abstraction)라 부른다.
인터페이스에 넣을 메서드를 고르는 기준은 단순하다. 모든 구현이 자연스럽게 답할 수 있는 것만 넣는다. 카드 번호도 은행 앱도 수단마다 다른 세부 사항이므로 인터페이스에는 결과만 남긴다.
interface PaymentResult {
approved: boolean;
receiptId: string;
}
interface Payment {
pay(amount: number): PaymentResult;
}
호출하는 쪽이 알아야 할 것은 승인이 났는지와 영수증 번호뿐이다. 그 값을 얻기까지 무슨 통신을 거쳤는지는 각 구현이 알아서 처리한다.
가짜 구현으로 갈아 끼우기
인터페이스로 받도록 바꾸면 따라오는 이득이 하나 더 있다. 테스트를 돌릴 때 실제 결제사 서버를 부를 수는 없다. Payment를 구현한 가짜 클래스를 만들어 대신 넣으면 된다.
class FakePayment implements Payment {
paid: number[] = [];
pay(amount: number) {
this.paid.push(amount);
return { approved: true, receiptId: "TEST-1" };
}
}
const fake = new FakePayment();
checkout(fake, 30000);
console.log(fake.paid); // [30000]
FakePayment는 결제하는 시늉만 하고 요청받은 금액을 배열에 적어 둔다. checkout이 안에서 CardPayment를 직접 만들어 쓰는 구조였다면 이런 교체는 불가능하다. 테스트를 돌릴 때마다 실제 결제가 일어난다. 인자로 받도록 바꾼 순간 checkout의 로직만 따로 떼어 확인할 수 있게 된다.
인터페이스를 언제 만들지
반대 방향으로 과하게 가는 경우도 흔하다. 클래스를 만들 때마다 짝이 되는 인터페이스를 하나씩 뽑아 두는 습관이다. 구현이 하나뿐인데 인터페이스가 앞에 서 있으면, 코드를 읽는 사람은 메서드를 따라갈 때마다 인터페이스를 거쳐 구현을 다시 찾아가야 한다. 파일 수는 두 배가 되고 갈아 끼울 대상은 없다.
뽑을 만한 상황은 대체로 셋 중 하나다. 구현이 실제로 둘 이상 있거나, 결제사나 메일 발송이나 파일 저장처럼 외부 시스템과 맞닿아 테스트에서 갈아 끼울 필요가 있거나, 호출하는 쪽이 구현 세부를 알면 곤란한 경계에 놓인 경우다. 셋 다 아니라면 클래스 하나로 두고, 두 번째 구현이 실제로 필요해지는 날 뽑아도 늦지 않다.
이름에서도 같은 판단이 드러난다. IPayment나 PaymentImpl 같은 이름은 구현이 하나뿐이라 이름을 나눌 재료가 없다는 사정을 그대로 노출한다. 쓰는 쪽이 부르는 이름은 Payment로 두고, 구현에는 CardPayment처럼 그 방식이 드러나는 이름을 붙이는 편이 낫다.
추상화는 코드를 감추는 기술이 아니다. 무엇을 할지와 어떻게 할지를 갈라 두어, 어떻게가 바뀔 때 무엇을 쓰는 코드가 흔들리지 않게 만드는 작업이다. 지금 짜는 함수가 무슨 일을 하는지 설명하려는데 특정 회사 이름이나 특정 기술 이름이 자꾸 끼어든다면, 그 자리에 갈라 둘 경계가 하나 숨어 있는 것이다.