본문 바로가기
OOP/<객체지향과 스프링의 이해>, by me

[객체지향과 스프링의 이해] 4. 카페 코드에 다형적인 결제 협력 추가하기

by 민휘 2023. 5. 30.

이 글은 2023 GDSC Sookmyung Core Member Session을 텍스트로 정리한 것입니다. (사실 대본 작성하는 김에 쓴거예요-!!) 영상으로 보고 싶으신 분들은 이 링크를 참고해주세요. 대부분의 내용은 조영호 님의 <객체지향의 사실과 오해>, <오브젝트>, 이일민 님의 <토비의 스프링 vol1>을 참고했습니다.

 


 

저번 포스팅에서 역할을 이용해 책임들을 추상화하여 다형적인 협력을 만들 수 있다고 하였습니다. 이번 포스팅에선 결제 역할을 도입해서 현금 결제, 카드 결제 등의 다형적인 협력을 만들어보겠습니다.

 

 

역할은 인터페이스와 추상 클래스로 구현할 수 있는데, 결제 역할에서는 아직 어떤 코드를 공유해야할지 알 수 없으므로 인터페이스를 사용합니다. Payable 인터페이스가 결제 메시지를 이해할 수 있도록 pay 메소드를 추가했습니다. 그리고 결제 방법은 enum으로 표현합니다. enum은 일반 상수에 비해서 type safety의 장점이 있습니다.

 

 

public interface Payable {
    void pay(Integer price);
}

public enum PayWay {
    CARD, CASH;
}

 

 

결제 메시지를 각기 다른 방식으로 구현하는 PayWithCard와 PayWithCash 객체를 작성합니다. pay 행동을 구현하기 위한 메소드를 구현합니다. 결제를 하기 위해 필요한 데이터도 추가합니다. 처음 작성했던 코드에선 데이터를 먼저 결정했는데, 이젠 행동을 먼저 정하고 행동에 필요한 데이터를 추가하고 있습니다.

public class PayWithCash implements Payable {
    private Integer money;

    public PayWithCash(Integer money) {
        this.money = money;
    }

    @Override
    public void pay(Integer cost) {
        if (money >= cost){
            this.money -= cost;
        } else {
            throw new RuntimeException("잔액이 부족합니다!");
        }
    }

}

public class PayWithCard implements Payable {
    private Card card;

    public PayWithCard(Integer money) {
        this.card = new Card(money);
    }

    public void pay(Integer cost) {
        card.pay(cost);
    }

    private class Card {
        private String cardNum;

        private Integer money;

        public Card(Integer money) {
            this.money = money;
        }

        private void pay(Integer cost) {
            if (money >= cost){
                this.money -= cost;
            } else {
                throw new RuntimeException("잔액이 부족합니다!");
            }
        }
    }
}

 

 

이렇게 만든 결제 기능은 Customer가 수행합니다. Customer에 Payable을 필드로 추가합니다. 이렇게 두 객체의 관계를 맺을 때 필드 참조를 사용하는 방식을 합성이라고 합니다. 흔히 has-a 관계를 만든다고 표현합니다. 이렇게 참조하는 필드는 생성자를 통해서 외부에서 주입 받도록 하겠습니다. 이렇게 만들면 Customer는 자신이 어떤 결제 방법으로 결제하는지 구분하지 않아도 되므로 코드가 깔끔하게 유지됩니다. 하지만 프로그래밍이 정상적으로 동작하려면 어딘가에서는 Customer을 생성할 때 Payable의 구현체를 결정해서 넘겨줘야합니다.

public class Customer {
    private Coffee coffee;

    // 손님은 결제할 수 있다
    private Payable payable;

    // 실제로 어떤 Payable 클래스가 들어올지는 외부에서 결정한다
    public Customer(Payable payable) {
        this.payable = payable;
    }

    public void order(Menu menu, Barista barista) {
        MenuItem menuItem = menu.choose(menu.recommend());
        barista.takeOrder(menuItem, payable);
        this.coffee = coffee;
    }
}

Barista의 takeOrder는 결제 메시지를 요청하기 위해 Customer을 매개변수로 받고 있었습니다. Barista의 관심사는 결제 메시지를 요청하는 것 뿐이므로, Customer 대신 Payable을 받아서 결제를 요청합니다. Customer를 사용하던 것과 비교해서, Barista에 노출하는 퍼블릭 인터페이스가 줄어들었습니다.

public class Barista {

    @Getter
    private Integer amount;

    public Barista(Integer amount) {
        this.amount = amount;
    }

    public Coffee makeCoffee(MenuItem menuItem) {
        return new Coffee(menuItem);
    }

		// 매개변수에 Customer 대신 Payable
    public Coffee takeOrder(MenuItem menuItem, Payable payable) {
        payable.pay(menuItem.cost());
        receive(menuItem.cost());
        Coffee coffee = makeCoffee(menuItem);
        return coffee;
    }

    public void receive(Integer price) {
        this.amount += price;
    }
}

 

 

이제 main에서 다시 테스트를 수행합니다. 어딘가에서는 Customer을 생성할 때 Payable의 구현체를 결정해서 넘겨줘야한다고 했는데, main에서 Customer을 생성하므로 Payable 구현체도 여기서 선택해서 넘겨줍니다. 현금 결제를 하도록 하고 싶다면 PayWithCash을, 카드 결제를 하고 싶다면 PayWithCash를 선택해서 넘겨줍니다.

public class Cafe {

    private final Barista barista;
    private final Menu menu = new Menu();

    public Cafe(Barista barista) {
        this.barista = barista;
    }

    public void enter(Customer customer) {
        customer.order(menu, barista);
    }

    public static void main(String[] args) {
        Barista barista = new Barista(100_000);
        Cafe cafe = new Cafe(barista);

        Payable payableCash = new PayWithCash(10_000);
//        Payable payableCard = new PayWithCash(20_000);
        Customer customer = new Customer(payableCash);

        cafe.enter(customer);
    }

}

 

 

Customer의 Payable 의존성을 프로그램이 뜰 때 처음 실행되는 main 메소드에서 결정합니다. 이를 DI, 의존성 주입이라고 부릅니다. 보통 일반적인 프로그램에서 필드에 어떤 클래스를 넣을지 결정하는 것은 필드를 가진 객체가 하는데, 이 의존성을 외부에서 주입한다고 해서 IoC, 제어 흐름의 역전 기법이라고 봅니다. DI를 사용하면 의존성을 주입받는 객체에서는 의존성의 하위 타입을 모르고도 이 의존성에 메시지를 보내는 것으로 기능을 동작할 수 있습니다. 그래서 하위 타입이 추가된다고 해도 클라이언트에서는 변경의 영향이 없습니다. 앞에서 했던 개선 작업에서도 적용되었던 결합도를 낮추려는 시도로 DI를 적용했습니다. DI를 살펴보면 결합도를 낮추면서 다형적인 협력을 만들 수 있습니다. 이 DI는 나중에 살펴볼 스프링 컨테이너의 존재 이유이기 때문에 중요한 개념입니다.

 

 

그런데 Payable에 어떤 타입을 주입할지 자주 변경된다면 어떻게 해야할까요? 지금 main 메소드에서는 new로 클래스를 직접 결정하고 있기 때문에 주입되는 타입을 변경하려면 클래스 이름을 직접 바꿔야 합니다. 이번엔 클라리이언트에게 어떤 결제 방식을 이용할건지 선택하게 하고 그에 따라 적절한 Payable의 하위 클래스를 생성해서 반환하려고 합니다. 그러려면 입력으로 타입 enum을 받고 적절한 Payable 타입을 반환하는 메소드를 만들어야 합니다. Payable의 하위 클래스를 초기화하려면 money 정보도 함께 필요하므로 매개변수로 넘겨줍니다. 이렇게 타입을 받아 하위 타입을 결정하는 메소드를 팩토리 메소드라고 부릅니다. 상태를 가지지 않으므로 static으로 선언하면 좋습니다.

public class PayableFactory {

    public static Payable with(PayWay payWay, Integer money) {
        if (payWay.equals(PayWay.CASH)) return new PayWithCash(money);
        else if (payWay.equals(PayWay.CARD)) return new PayWithCard(money);
        throw new IllegalArgumentException("지원하지 않는 결제 방식입니다.");
    }

}

 

 

main에서 클라이언트에게 PayWay를 입력 받고, PayableFactory을 사용해 Payable을 받아오면 main에도 Payable의 하위 타입이 드러나지 않습니다. 훨씬 깔끔하고, main과의 Payable 하위 타입의 의존성도 없어졌습니다.

public class Cafe {

    public static void main(String[] args) {
        Barista barista = new Barista(100_000);
        Cafe cafe = new Cafe(barista);

        PayWay clientChoice = PayWay.CASH; // 사용자 입력
        Payable payable = PayableFactory.with(clientChoice, 10_000);
        Customer customer = new Customer(payable);

        cafe.enter(customer);
    }

}

 

 

사실 Barista 부분에서 대충 넘어간 부분이 있습니다. menuItem.cost()이 두번 반복되었는데요, 만약 할인 로직이 추가된다면 이 부분도 같이 수정될 것입니다. 이 부분은 어떻게 해결해야할까요? 아까 결제 인터페이스를 도입했던 것처럼, 할인을 담당하는 인터페이스를 도입해 바리스타가 이 인터페이스로 메시지를 보내도록 개선하면 됩니다. 이 부분은 여러분이 자유롭게 상상해보세요.

public class Barista {

    public Coffee takeOrder(MenuItem menuItem, Payable payable) {
        // 얼렁뚱땅 넘어간 코드 : menuItem.cost() 사용이 반복됨
        // 만약 할인 가격을 계산해야한다면 무엇이 바뀔까요?
        // 두개의 관심사 혼재 : 계산해야하는 가격 얻기, 결제하기
        // 어떻게 해결해야할까요?
        payable.pay(menuItem.cost());
        receive(menuItem.cost());
        Coffee coffee = makeCoffee(menuItem);
        return coffee;
    }
}

 

 

이렇게 해서 코드로 이해하는 객체지향 파트를 마치겠습니다. 이 파트에서는 객체지향적인 코드는 어떻게 만들 수 있고, 객체지향적으로 개선함으로써 얻을 수 있는 확장 가능한 설계에 대해 이해했습니다. 핵심적인 기법은 자신의 상태를 직접 변경하도록 캡슐화하고, 반복되거나 달라질 수 있는 부분은 추상화하고 코드를 객체 내부로 이동함으로써 객체들이 서로 상호작용하여 애플리케이션 기능을 이루도록 만들었습니다. 그 과정에서 객체는 단순한 자료구조 클래스에서 스스로 행동하는 자율적인 책임을 가지게 되었습니다. 하지만 객체지향적인 개선 작업이 언제나 결합도를 낮추는 것은 아니며, 유연한 시스템을 만들수록 시스템 내부의 상호 작용은 점점 복잡해지기 때문에 현재의 요구사항에 맞게 적절한 수준으로 도입해야합니다.

 

 

다음 포스팅에서는 객체지향이라는 가치를 매우 잘 활용하고 있는 애플리케이션 프레임워크인 스프링에 대해서 알아보겠습니다.