인터페이스와 상속, 중복 코드를 없애는 원리

결제 수수료 정책이 바뀐 다음 날 새벽, 정산 배치가 경고를 띄웠다. 계좌이체 건만 수수료가 옛 요율로 계산돼 있었다. 수수료 계산 로직이 결제 수단별로 네 군데에 복사돼 있었고, 나는 그중 세 군데만 고쳤다. 코드 리뷰도 통과했고 테스트도 초록불이었다. 틀린 건 한 줄이 아니라 같은 코드가 네 벌 있다는 구조 자체였다.

이 글은 그 장애 이후 일주일 동안 결제 코드를 정리하면서 인터페이스와 상속을 어떻게 썼는지에 대한 기록이다. 교과서처럼 “인터페이스는 약속, 상속은 물려주기”라고 정리하면 한 줄이면 끝난다. 하지만 실제로는 상속으로 먼저 묶었다가 다시 깨졌고, 그 과정에서 둘의 차이를 몸으로 배웠다. 잘못 고른 선택까지 그대로 적는다.

새벽 3시, 정산 금액이 맞지 않았다

당시 결제 모듈에는 카드, 간편결제, 계좌이체, 포인트 네 가지 결제 서비스가 있었다. 처음 한 명이 카드 결제를 만들었고, 나머지는 필요할 때마다 그 클래스를 복사해서 이름만 바꾼 형태였다. 수수료 계산, 결제 로그, 금액 검증이 클래스마다 거의 똑같이 들어 있었다.

public class CardPaymentService {
    public PaymentResult pay(Order order) {
        validateAmount(order.getAmount());
        BigDecimal fee = order.getAmount()
                .multiply(new BigDecimal("0.021"))
                .setScale(0, RoundingMode.DOWN);
        log.info("[PAY] card orderId={} fee={}", order.getId(), fee);
        return cardGateway.approve(order, fee);
    }
}

public class TransferPaymentService {
    public PaymentResult pay(Order order) {
        validateAmount(order.getAmount());
        BigDecimal fee = order.getAmount()
                .multiply(new BigDecimal("0.021"))   // 여기를 놓쳤다
                .setScale(0, RoundingMode.DOWN);
        log.info("[PAY] transfer orderId={} fee={}", order.getId(), fee);
        return bankGateway.transfer(order, fee);
    }
}

요율이 바뀌던 날 나는 IDE에서 “CardPaymentService”를 열어 고치고, 비슷하게 생긴 간편결제와 포인트 쪽을 찾아 고쳤다. 계좌이체 서비스는 패키지가 달랐다. 검색어로 클래스 이름을 쳤지 숫자 0.021을 치지 않았기 때문에 목록에 뜨지 않았다. 배포 후 여섯 시간 동안 들어온 계좌이체 결제 수백 건이 옛 요율로 계산됐고, 정산팀이 수작업으로 차액을 맞춰야 했다.

회고 자리에서 “다음부터 꼼꼼히 보자”는 말이 나왔지만 나는 그게 답이 아니라고 생각했다. 처음 이 프로젝트를 맡은 사람은 수수료 로직이 네 군데 있다는 사실을 알 방법이 없다. 사람이 기억해야 안전한 구조라면, 사람이 바뀌는 순간 다시 터진다.

첫째 날 — 급한 불은 결국 복붙으로 껐다

장애 당일에는 구조를 고칠 여유가 없었다. 먼저 0.021과 0.019(이전 간편결제 요율)를 전체 검색했다. 결과는 결제 서비스 네 곳이 아니라 다섯 곳이었다. 관리자 화면에서 예상 수수료를 보여주는 컨트롤러에도 같은 숫자가 하드코딩돼 있었다. 아무도 몰랐던 다섯 번째 복사본이었다.

핫픽스는 다섯 군데 숫자를 모두 바꾸는 것으로 끝냈다. 부끄럽지만 중복을 없애기 위한 작업의 첫 단계가 또 한 번의 복붙이었다. 대신 그날 저녁 “수수료율이 한 곳에서만 정의되도록 바꾼다”는 작업 티켓을 만들고, 정산 금액을 검증하는 테스트 하나를 먼저 추가했다. 주문 금액 10만 원에 대해 결제 수단별 수수료가 기대값과 같은지 확인하는 단순한 테스트였다. 이 테스트가 뒤에서 나를 두 번 살렸다.

둘째 날 — 첫 시도: 부모 클래스 하나에 전부 몰아넣었다

가장 먼저 떠오른 방법은 상속이었다. 네 서비스의 공통 코드를 추상 부모 클래스로 올리고, 결제 수단마다 다른 부분만 자식이 구현하게 했다. 템플릿 메서드 패턴 모양이다.

public abstract class AbstractPaymentService {

    public PaymentResult pay(Order order) {
        validateAmount(order.getAmount());
        BigDecimal fee = calculateFee(order);
        log.info("[PAY] {} orderId={} fee={}", type(), order.getId(), fee);
        return doPay(order, fee);
    }

    protected BigDecimal calculateFee(Order order) {
        return order.getAmount()
                .multiply(FEE_RATE)
                .setScale(0, RoundingMode.DOWN);
    }

    protected abstract String type();
    protected abstract PaymentResult doPay(Order order, BigDecimal fee);
}

반나절 만에 네 클래스가 절반 길이로 줄었고 수수료 계산은 한 곳에 모였다. 그런데 금방 예외가 쏟아졌다. 포인트 결제는 수수료가 없어서 calculateFee()를 오버라이드해 0을 돌려줘야 했다. 간편결제는 요율이 달라서 또 오버라이드했다. 해외 카드는 환율 적용 뒤에 수수료를 매겨야 해서 부모 메서드에 boolean isForeign 파라미터가 생겼다.

저녁이 되자 부모 클래스에는 skipFee, isForeign, needsExtraLog 같은 플래그가 세 개 붙어 있었다. 공통 코드를 모으려고 만든 부모가, 자식마다 다른 사정을 처리하는 분기문 모음으로 변하고 있었다. 이때 이미 뭔가 잘못됐다는 느낌이 있었지만 테스트가 통과했으니 일단 넘어갔다.

셋째 날 — 부모를 한 줄 고쳤더니 자식 둘이 깨졌다

문제는 다음 날 드러났다. 금액 검증을 수수료 계산 뒤로 옮겨 달라는 요청이 와서(수수료 포함 금액으로 한도를 검사해야 했다) 부모의 pay() 안에서 두 줄의 순서를 바꿨다. 부모 클래스 한 곳만 고쳤으니 깔끔하다고 생각했다.

첫째 날 만든 정산 테스트가 빨간불을 켰다. 간편결제 서비스는 calculateFee()를 오버라이드하면서 내부에서 “검증을 통과한 금액”을 전제로 할인 쿠폰을 먼저 차감하고 있었다. 순서가 바뀌자 쿠폰 차감 전 금액으로 수수료가 계산됐다. 포인트 서비스도 비슷한 이유로 깨졌다. 자식 클래스들이 부모 메서드의 호출 순서라는 내부 구현에 몰래 기대고 있었던 것이다.

이게 흔히 말하는 “깨지기 쉬운 부모 클래스(fragile base class)” 문제다. 상속은 공통 코드만 물려주는 게 아니라 부모의 내부 동작 순서까지 자식에게 묶어 버린다. 부모를 고치는 사람은 자식 코드를 전부 읽지 않는 한 무엇이 깨질지 알 수 없다. 첫째 날 겪은 “네 군데를 다 찾아다녀야 한다”는 문제가 모양만 바꿔서 돌아온 셈이었다. 복붙일 때는 고칠 곳을 찾아다녀야 했고, 상속일 때는 영향받을 곳을 찾아다녀야 했다.

넷째~다섯째 날 — 인터페이스로 약속을 나누고, 공통 코드는 조합으로

상속 구조를 되돌리고 다시 설계했다. 이번에는 질문을 바꿨다. “무엇이 공통인가”가 아니라 “무엇이 결제 수단마다 달라지는가”를 먼저 적었다. 달라지는 것은 두 가지였다. 실제 결제를 요청하는 방법, 그리고 수수료를 매기는 방법이다. 각각을 인터페이스로 뽑았다.

public interface PaymentMethod {
    PaymentType type();
    PaymentResult pay(Order order, BigDecimal fee);
}

public interface FeePolicy {
    BigDecimal calculate(Money amount);
}

public class RateFeePolicy implements FeePolicy {
    private final BigDecimal rate;
    public RateFeePolicy(BigDecimal rate) { this.rate = rate; }

    public BigDecimal calculate(Money amount) {
        return amount.value().multiply(rate).setScale(0, RoundingMode.DOWN);
    }
}

public class NoFeePolicy implements FeePolicy {
    public BigDecimal calculate(Money amount) { return BigDecimal.ZERO; }
}

그리고 검증, 수수료, 로그라는 공통 흐름은 부모 클래스가 아니라 PaymentProcessor라는 별도 클래스 하나가 맡았다. 이 클래스는 결제 수단과 수수료 정책을 상속받지 않고 필드로 가지고 있다(조합, composition). 스프링이 PaymentMethod 구현체를 전부 찾아 주입해 주므로, 결제 타입으로 골라 쓰기만 하면 된다.

@Component
public class PaymentProcessor {
    private final Map<PaymentType, PaymentMethod> methods;
    private final FeePolicyRegistry feePolicies;

    public PaymentProcessor(List<PaymentMethod> methods, FeePolicyRegistry feePolicies) {
        this.methods = methods.stream()
                .collect(Collectors.toMap(PaymentMethod::type, m -> m));
        this.feePolicies = feePolicies;
    }

    public PaymentResult pay(Order order) {
        BigDecimal fee = feePolicies.of(order.getPaymentType()).calculate(order.getAmount());
        limitValidator.validate(order.getAmount(), fee);
        log.info("[PAY] {} orderId={} fee={}", order.getPaymentType(), order.getId(), fee);
        return methods.get(order.getPaymentType()).pay(order, fee);
    }
}

이제 결제 수단 클래스는 “결제 요청을 보내는 법”만 안다. 수수료가 어떻게 계산되는지, 검증이 언제 도는지 모른다. 모르니까 흐름 순서가 바뀌어도 깨질 이유가 없다. 셋째 날 겪은 검증 순서 변경을 다시 해 보니 PaymentProcessor 한 파일만 바뀌었고, 결제 수단 네 클래스는 한 줄도 바뀌지 않았다. 정산 테스트도 그대로 통과했다.

수수료율은 코드에서 아예 뺐다. application.yml에 결제 수단별 요율을 두고 FeePolicyRegistry가 읽어서 정책 객체를 만든다. 관리자 화면의 예상 수수료도 같은 레지스트리를 쓰게 바꿨다. 첫째 날 찾은 다섯 번째 복사본이 사라졌다. 설정값이 어느 파일에서 덮어써지는지 헷갈린 적이 있다면 Spring Boot 설정 우선순위 정리도 함께 보면 좋다. 운영 서버의 외부 yml이 요율을 덮어쓰는 줄 모르고 반나절을 날린 적이 있다.

상속을 남긴 곳, 인터페이스로 바꾼 곳

그렇다고 상속을 전부 버린 것은 아니다. 정리가 끝난 뒤 남은 선택을 표로 적어 두었다. 기준은 하나였다. “A는 B다”라는 문장이 어색하지 않고, 부모가 바뀔 때 자식도 같이 바뀌는 게 자연스러운가.

대상선택이유
결제 수단(카드·간편결제·계좌이체·포인트)인터페이스결제를 요청하는 방법이 서로 완전히 다르다. 공유할 구현이 거의 없다.
수수료 계산인터페이스 + 정책 객체결제 수단과 독립적으로 바뀐다. 같은 수단도 이벤트 기간에 요율이 달라진다.
검증·로그·수수료 흐름조합(PaymentProcessor)흐름 순서는 한 곳에서만 알아야 한다. 자식이 순서에 기대면 안 된다.
결제 예외 클래스상속 유지“한도 초과 예외는 결제 예외다”가 자연스럽고, 부모가 바뀌면 같이 바뀌는 게 맞다.
PG사 응답 DTO 공통 필드상속 유지응답 코드·메시지·거래번호가 모든 PG에 동일하고 동작이 없는 데이터라 깨질 일이 적다.

써 놓고 보니 상속을 유지한 곳은 동작이 거의 없거나, 계층이 분명한 곳뿐이었다. 동작이 많고 사정이 제각각인 곳은 전부 인터페이스로 갔다.

두 달 뒤, 새 결제 수단이 들어왔을 때

이 구조가 제값을 한 건 두 달 뒤였다. 해외 간편결제를 추가해 달라는 요청이 왔다. 예전 구조였다면 카드 결제 클래스를 복사해 이름을 바꾸고, 부모 클래스에 isForeign 분기를 하나 더 넣었을 것이다.

실제로 한 일은 PaymentMethod 구현 클래스 하나를 새로 만들고, yml에 요율 한 줄을 추가한 것뿐이다. 기존 파일은 한 개도 열지 않았다. 코드 리뷰어도 “기존 결제 쪽 영향 없음”을 파일 목록만 보고 확인할 수 있었다. 변경에는 닫혀 있고 확장에는 열려 있다는 개방-폐쇄 원칙을, 설명이 아니라 PR 화면에서 처음 실감했다.

같은 달 카드 수수료율이 한 번 더 바뀌었다. 이번에는 yml 한 줄이었고, 정산 테스트가 새 요율로 계산된 값을 확인해 주었다. 새벽 알람은 울리지 않았다.

다시 한다면

일주일을 돌아보면 둘째 날의 상속 시도는 하지 않아도 됐던 우회였다. 다시 같은 코드를 정리한다면 이렇게 하겠다.

  • 돈 계산은 두 번째 복사에서 바로 묶는다. 보통은 같은 코드가 세 번 반복될 때 묶으라고 하지만, 금액·수수료·세금처럼 틀리면 바로 사고가 나는 로직은 두 번째에 묶는다.
  • 부모 클래스에 boolean 플래그가 생기면 멈춘다. skipFee 같은 플래그는 “자식마다 사정이 다르다”는 신호다. 그 사정은 상속이 아니라 인터페이스로 분리해야 한다.
  • 상속 전에 “A는 B다”를 소리 내어 읽어 본다. “포인트 결제는 수수료를 계산하는 결제 서비스다”는 어색했다. 어색하면 상속이 아니다.
  • 숫자는 코드 밖으로 뺀다. 요율 같은 값이 코드에 있으면 결국 복사된다. 설정이나 DB 한 곳에 두고 모두가 그곳을 읽게 한다.
  • 구조를 바꾸기 전에 결과를 검증하는 테스트부터 만든다. 첫째 날 만든 정산 테스트가 셋째 날의 상속 문제와 다섯째 날의 리팩터링을 모두 잡아 주었다.

나중에 토비의 스프링을 다시 읽었을 때 1장이 거의 같은 이야기라는 걸 알았다. DAO의 중복을 상속으로 먼저 풀었다가, 그 한계 때문에 인터페이스와 조합으로 넘어가는 흐름이다. 책으로 읽을 때는 당연해 보였는데, 직접 장애를 겪고 나서야 왜 그 순서로 설명하는지 이해했다.

정리하면 인터페이스는 “무엇을 할 수 있어야 하는가”를 약속하고, 상속은 “어떻게 하는가”까지 물려준다. 중복을 없애는 게 목표라면 대부분의 경우 약속을 먼저 나누고, 공통 흐름은 조합으로 한 곳에 모으는 편이 안전했다. 상속은 계층이 분명하고 동작이 적은 곳에만 남겼다. 그렇게 해 두니 새 요구사항이 “수정”이 아니라 “추가”로 끝났고, 건드리지 않은 코드는 깨지지 않았다.

참고: Refactoring Guru — 디자인 패턴

“인터페이스와 상속, 중복 코드를 없애는 원리”에 대한 3개의 생각

댓글 남기기