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

[객체지향과 스프링의 이해] 2. 카페 코드를 객체지향적으로 개선하기

by 민휘 2023. 5. 30.

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

 


 

앞에서 구현한 카페 코드가 이해하기 어렵고 변경에도 취약하다는 단점이 있었습니다. 우리가 클래스를 만들 때 어떤 데이터가 필요했는지부터 생각했기 때문에, 변경되기 쉬운 데이터가 외부에서 접근 가능하게 되었고 강한 결합이 생겼습니다. 사실 이러한 접근은 전형적인 절차지향적 방법으로, 데이터를 담고 있는 자료구조 클래스와 데이터를 조작하는 프로시저 클래스의 구조를 띄고 있습니다. 프로시저 클래스는 자료구조 안의 데이터를 직접 꺼내서 사용하기 때문에 여러 자료구조의 클래스와 강한 결합이 생깁니다. 그래서 데이터가 바뀌었을 때 Cafe가 아주 쉽고 빈번하게 변경된 것입니다.

 

 

프로시저가 데이터를 직접 꺼낼 수 없도록 내부로 숨기고, 메시지를 통해 상태 변경을 요청하도록 만들겠습니다. 불안정한 데이터를 알고 변경할 수 있는 존재는 오직 그 데이터를 가지고 있는 객체 뿐입니다.

 

 

손님부터 손을 봅시다. 손님은 money에 대한 게터와 세터를 허용하고 있어 카페가 돈을 꺼내고 넣을 수 있었습니다. 게터와 세터를 삭제하고, 지불해야하는 금액 만큼 돈을 차감하는 메소드를 추가합니다.

// 게터 세터 삭제
public class Customer {
    private Coffee coffee;
    private 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;
    }
}

 

 

이제 Cafe는 Customer의 money에 접근하지 못하므로, 대신 order을 사용해야합니다.

@AllArgsConstructor
public class Cafe {

    private Barista barista;
    private Menu menu;

    public void enter(Customer customer){
        MenuItem selectedMenuItem = customer.selectRandomMenuItem(menu);

//        customer.setMoney(customer.getMoney() - selectedMenuItem.getPrice());
				customer.order(selectedMenuItem, barista);

        barista.setAmount(barista.getAmount() + selectedMenuItem.getPrice());
        Coffee coffee = barista.makeCoffee(selectedMenuItem);
        customer.setCoffee(coffee);
    }

}

 

 

Barista도 동일하게 게터와 세터를 제거하고, 수익을 증가시키는 메소드 receive를 추가합니다. enter에서 사용하던 세터 대신 receive를 사용합니다.

// 게터 세터 삭제
@AllArgsConstructor
public class Barista {

    private Integer amount;

    public Coffee makeCoffee(MenuItem menuItem) {
        return new Coffee(
                menuItem.getName(),
                menuItem.getPrice());
    }

    // 추가
    public void receive(Integer value) {
        this.amount += value;
    }

}

@AllArgsConstructor
public class Cafe {

    private Barista barista;
    private Menu menu;

    public void enter(Customer customer){
        MenuItem selectedMenuItem = customer.selectRandomMenuItem(menu);
        customer.pay(selectedMenuItem.getPrice());

				// barista.setAmount(barista.getAmount() + selectedMenuItem.getPrice());
        barista.receive(selectedMenuItem.getPrice());

        Coffee coffee = barista.makeCoffee(selectedMenuItem);
        customer.setCoffee(coffee);
    }

}

 

 

Customer 코드를 다시보니, Cafe가 Customer 데이터를 헤집었던 것과 마찬가지로 Customer도 Menu의 데이터를 접근하고 있는 것을 볼 수 있습니다. 메뉴의 랜덤 선택 로직을 메뉴로 옮기고, recommend로 이름을 붙이겠습니다. Cafe에서 사용하던 Customer의 selectRandomMenuItem 대신 Menu의 recommend를 호출합니다.

@AllArgsConstructor
@Getter @Setter
public class Menu {
    private List<MenuItem> items;

    public MenuItem recommend() {
        Random random = new Random();
        int randomIndex = random.nextInt(items.size());
        return items.get(randomIndex);
    }
}

@AllArgsConstructor
public class Cafe {

    private Barista barista;
    private Menu menu;

    public void enter(Customer customer){
//        MenuItem selectedMenuItem = customer.selectRandomMenuItem(menu);
        MenuItem selectedMenuItem = menu.recommend();
        
        customer.pay(selectedMenuItem.getPrice());
        barista.receive(selectedMenuItem.getPrice());
        Coffee coffee = barista.makeCoffee(selectedMenuItem);
        customer.setCoffee(coffee);
    }

}

 

 

이제 다시 Cafe의 enter를 보겠습니다. 아직 카페가 바리스타에게 커피 제조를 명령하고, 손님의 손에 커피를 들려주는 부분이 남았습니다. 그런데 이 부분은 지금까지 했던 것처럼 단순히 데이터를 숨기고 코드를 옮기는 것만으론 구현하기가 어려울 것 같습니다. 왜냐하면 바리스타가 커피를 만드는 작업은 손님에 요청에 의해 이루어져야 하고, 손님이 커피를 손에 들고 가려면 바리스타가 건내준 커피를 받아야 하기 때문입니다. 결국 바리스타의 커피 제조를 요청하는 barista.makeCoffee는 손님 안에 위치해야 합니다.

public class Customer {

    private Integer money;
    private Coffee coffee;

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

		// 추가
    public void orderCoffee(Barista barista, MenuItem menuItem) {
        Coffee coffee = barista.makeCoffee(menuItem);
        this.coffee = coffee;
    }

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

}

@AllArgsConstructor
public class Cafe {

    private Barista barista;
    private Menu menu;

    public void enter(Customer customer){
        MenuItem selectedMenuItem = menu.recommend();
        customer.pay(selectedMenuItem.getPrice());
        barista.receive(selectedMenuItem.getPrice());

//        Coffee coffee = barista.makeCoffee(selectedMenuItem);
//        customer.setCoffee(coffee);
        customer.orderCoffee(barista, selectedMenuItem);
    }
}

 

 

그런데 Customer의 orderCoffee 메소드의 매개변수를 다시 한번 봅시다. Barista와 MenuItem가 추가되었습니다. Customer는 이 행동을 갖기 전과 비교했을 때 Barista와 MenuItem을 추가로 알게되었고, 이 객체에 대한 의존성이 추가되었습니다. orderCoffee 안에서 객체에게 메시지를 요청할 수 있게 되었으므로, 커피 주문과 관련되지 않은 메시지에 대한 정보도 알게 되었습니다. 물론 Cafe의 enter를 생각해봤을 때, 이렇게 하는 편이 낫다는 생각이 듭니다. 일단 이렇게 놔두겠습니다.

public class Customer {

    // 결합도의 증가!!
    public void orderCoffee(Barista barista, MenuItem menuItem) {
        Coffee coffee = barista.makeCoffee(menuItem);
        this.coffee = coffee;
    }

}

 

 

다시 Cafe의 enter를 보겠습니다. 여전히 부자연스러운 부분이 있습니다. 메뉴를 추천해달라는 요청은 커스토머 쪽에서 하는 것이 맞는 것 같습니다. 그리고 손님이 돈을 내고 바리스타가 돈을 받는 작업은 서로의 상호작용으로 이루어지는게 더 자연스러울 것 같고, 이 결제 흐름은 커피를 주문하는 흐름 안에 포함되는 것이 더 자연스러운 맥락일 것 같습니다. 이렇게 개선해봅시다.

@AllArgsConstructor
public class Cafe {

    private Barista barista;
    private Menu menu;

    public void enter(Customer customer){
        MenuItem selectedMenuItem = menu.recommend(); // 손님이
        customer.pay(selectedMenuItem.getPrice()); // 아래 메소드와 상호작용
        barista.receive(selectedMenuItem.getPrice()); // 위의 메소드와 상호작용
        customer.orderCoffee(barista, selectedMenuItem); // 결제를 주문에 포함
    }
}

 

 

손님 클래스에 menu.recommend 메시지를 호출하는 메소드를 추가합니다. 매개변수로 Menu 의존성이 추가되었습니다.

public class Customer {

    // 결합도 증가!!
    public MenuItem selectRandomMenuItem(Menu menu) {
        return menu.recommend();
    }

}

 

 

barista에 결제를 요청하는 receive을 orderCoffee 안으로 옮기고, pay도 Customer 안에서 호출하도록 변경합니다. pay 메소드는 Customer 안에서만 사용되니 private으로 감추도록 합니다.

public class Customer {

    private Integer money;
    private Coffee coffee;

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

    // 결합도 증가!!
    public MenuItem selectRandomMenuItem(Menu menu) {
        return menu.recommend();
    }

    // 결제 흐름 포함
    public void orderCoffee(Barista barista, MenuItem menuItem) {
        pay(menuItem.getPrice());
        barista.receive(menuItem.getPrice());

        Coffee coffee = barista.makeCoffee(menuItem);
        this.coffee = coffee;
    }

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

}

 

 

그런데 잘 보니 orderCoffee와 selectRandomMenuItem를 하나로 합칠 수 있을 것 같습니다. selectRandomMenuItem가 반환하는 MenuItem을 orderCoffee가 사용하면 되겠네요. 둘다 손님의 행동이니 하나로 합쳐도 괜찮을 것 같습니다. MenuItem 대신 Menu를 받도록 하고, selectRandomMenuItem를 제거하니 MenuItem 의존성이 사라졌습니다. 이전에 비하면 결합도가 비교적 낮아졌습니다.

public class Customer {

    private Integer money;
    private Coffee coffee;

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

		// selectRandomMenuItem 제거

    // MenuItem의 의존성이 사라짐
    public void orderCoffee(Barista barista, Menu menu) {
        MenuItem menuItem = menu.recommend();

        pay(menuItem.getPrice());
        barista.receive(menuItem.getPrice());
        Coffee coffee = barista.makeCoffee(menuItem);

        this.coffee = coffee;
    }

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

}

 

 

orderCoffee 안에서 pay 행동과 receive, makeCoffee 요청이 함께 일어나고 있습니다. 그런데 생각해보면 카페에 손님이 들어와 주문을 하려면 바리스타가 이 주문을 받아줘야하지 않을까요? 위의 세 행동을 takeOrder 메시지로 묶어서 바리스타로 이동하겠습니다. 바리스타 안에서 더이상 외부에서 호출되지 않는 메소드는 private으로 변경합니다.

public class Customer {

    private Integer money;
    private Coffee coffee;

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

    // 바리스타야 주문 좀 받아줘!
    public void orderCoffee(Barista barista, Menu menu) {
        MenuItem menuItem = menu.recommend();
        Coffee coffee = barista.takeOrder(menuItem, this);
        this.coffee = coffee;
    }

}

@AllArgsConstructor
public class Barista {

    private Integer amount;

    // private으로 변경
    private Coffee makeCoffee(MenuItem menuItem) {
        return new Coffee(
                menuItem.getName(),
                menuItem.getPrice());
    }

    // private으로 변경
    private void receive(Integer value) {
        this.amount += value;
    }

    // 추가
    public Coffee takeOrder(MenuItem menuItem, Customer customer) {
        customer.pay(menuItem.getPrice());
        receive(menuItem.getPrice());
        return makeCoffee(menuItem);
    }
}

 

 

이제 테스트를 해보러 main 코드를 보니 아.. 여기도 코드가 좀 더럽습니다. 우선 menu를 초기화하는 로직은 MenuItem List를 가지고 있는 menu 안으로 옮기겠습니다.

@AllArgsConstructor
public class Menu {
    // 불변 객체로 변경
    private final List<MenuItem> items;

    public Menu() {
        items = new ArrayList<>();
        items.add(new MenuItem("Americano", 1_500));
        items.add(new MenuItem("Cappuccino", 2_000));
        items.add(new MenuItem("Caramel Macchiato", 2_500));
        items.add(new MenuItem("Espresso", 2_500));
    }

    public MenuItem recommend() {
        Random random = new Random();
        int randomIndex = random.nextInt(items.size());
        return items.get(randomIndex);
    }
}

 

 

한결 이해하기 쉬운 main이 만들어졌습니다.

public class Cafe {

    public static void main(String[] args) {

        Menu menu = new Menu();
        Barista bob = new Barista(100_000);
        Customer cathy = new Customer(50_000);
        Cafe cafe = new Cafe(bob, menu);

        cafe.enter(cathy);

    }

}

 

 

지금까지 한 작업을 정리해보겠습니다. 자신의 상태를 외부로부터 숨기고, 자신의 상태는 자신이 변경하도록 했습니다. 만약 애플리케이션 기능을 수행하기 위해 다른 객체의 상태 변경을 기대한다면 다른 객체에게 메시지를 보내서 요청합니다. 이 작업을 캡슐화라고 부릅니다. 손님은 money를 숨기고 pay 메시지를 제공하고, 바리스타는 amount를 숨기고 recieve 메시지를, 메뉴는 menuItem list를 숨기고 recomment 메시지를 제공합니다.

 

 

캡슐화 외에도, 기능이 더 자연스러운 맥락을 가지도록 메시지 요청의 주체를 고민하고 코드를 이동했습니다. 손님은 메뉴를 고르고, 바리스타에게 주문을 요청합니다. 바리스타는 주문을 받아 계산을 하고, 커피를 만들고, 커피를 제공합니다. 메뉴판은 스스로 정보를 초기화하고, 랜덤한 메뉴를 추천합니다.

 

 

지금까지 한 작업은 모두 코드를 객체 내부로 이동한 것입니다. 객체가 자신의 상태를 변경하도록 코드를 이동했고, 요청 주체가 자연스러운 맥락을 가지도록 메시지 요청 코드를 이동했습니다. 그 결과 객체에 코드가 추가되었고, 애플리케이션에서 객체가 존재감을 드러내기 시작했습니다. 객체지향의 제시한 애플리케이션의 주인공은 객체입니다. 점점 객체지향적인 코드에 가까워지고 있습니다.

 

 

그렇다면 개선한 코드가 정말 변경에 더 유연한지 생각해보겠습니다. 만약 손님이 현금 대신 카드나 애플페이 등으로 결제하는 요구사항을 추가한다면 어디에 코드가 추가되어야할까요? Customer에 결제 수단의 필드를 추가하고 pay 메소드에 처리 로직이 들어가야합니다. 하지만 Customer에게 요청을 보내는 Barista의 takeOrder에는 전혀 변화가 없습니다. Barista는 손님이 현금으로 계산하는지 카드로 계산하는지는 관심이 없고, 오직 커피에 대한 대가를 바랄 뿐입니다. 손님의 구현 변경이 외부에 영향을 미치지 않고, 그 파급 효과가 자신으로 제한되었습니다. 바람직합니다.

 

public class Customer {

    // MenuItem의 의존성이 사라짐
    public void orderCoffee(Barista barista, Menu menu) {
        MenuItem menuItem = menu.recommend();
        Coffee coffee = barista.takeOrder(menuItem, this);
        this.coffee = coffee;
    }

}

public class Barista {

    public Coffee takeOrder(MenuItem menuItem, Customer customer) {
        customer.pay(menuItem.getPrice()); // 손님의 결제 수단에 상관없이 결제만 요청
        receive(menuItem.getPrice());
        return makeCoffee(menuItem);
    }

}

 

 

다음 게시글에 이어집니다.