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

[객체지향과 스프링의 이해] 1. 절차지향적인 카페 코드

by 민휘 2023. 5. 30.

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

 

 


 

일단 코드를 보자!

 

객체지향이 어떤 것인지 논하기 전에, 우선 코드부터 짜봅시다. 이번 세션에서 사용할 예시 요구사항은 카페에서의 커피 주문입니다. (객체지향의 사실과 오해 마지막 부분에 나오는 그 예제를 살짝 변형했습니다) 카페라는 세상 안에서 커피 주문을 하는 시나리오를 코드로 작성해봅시다.

 

요구사항은 다음과 같습니다.

 

우선 의식의 흐름대로, 손이 가는대로 코드를 작성해보겠습니다. 어떤 클래스가 필요할지 생각해보겠습니다. Cafe, Coffee, Customer, Menu, MenuItem, Barista 클래스 정도가 필요할 것 같네요. 클래스를 정했으니 각 클래스에 어떤 데이터가 필요한지 생각하면서 클래스에 코드를 채우겠습니다. (참고로 더 많은 코드를 싣기 위해 게터, 세터, 생성자 등의 보일러 플레이트 코드는 Lombok로 생성합니다)

메뉴판은 각 메뉴 아이템을 목록으로 가지고 있어야 하고, 메뉴 아이템은 커피의 이름과 가격을 가지고 있어야 합니다.

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

@AllArgsConstructor
@Getter @Setter
public class MenuItem {
    private String name;
    private Integer price;
}

 

다음은 손님 클래스입니다. 돈을 가지고 있어야할 것이고, 커피도 손에 들고 있어야할 것입니다. 손님은 메뉴판을 보고 메뉴를 하나 랜덤으로 고르니 이 메소드도 필요할 것 같습니다.

@Getter @Setter
public class Customer {

    private Integer money;
    private Coffee coffee;

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

    public MenuItem selectRandomMenuItem(Menu menu) {
        List<MenuItem> menuItems = menu.getItems();
        Random random = new Random();
        int randomIndex = random.nextInt(menuItems.size());
        return menuItems.get(randomIndex);
    }

}

 

바리스타 클래스입니다. 바리스타가 손님에게 돈을 받고 결제하니 현재 수익을 알고 있어야 합니다. 그리고 커피를 만들어야 하니 커피 제조 메소드를 추가하겠습니다.

@Getter @Setter
@AllArgsConstructor
public class Barista {

    private Integer amount;

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

}

 

마지막으로 카페 클래스입니다. 카페에서 일하는 바리스타가 필요하고, 카페의 벽에 붙어있는 메뉴판도 필요합니다. 그리고 손님이 들어오면 주문을 해야하니 이 메소드도 추가합니다.

@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());
        barista.setAmount(barista.getAmount() + selectedMenuItem.getPrice());
        Coffee coffee = barista.makeCoffee(selectedMenuItem);
        customer.setCoffee(coffee);
    }

}

 

프로그램의 엔트리 포인트인 main 메소드를 작성합니다. 전 그냥 Cafe에 작성했습니다. 메뉴판에 값을 넣고, 바리스타와 손님, 카페를 생성해 Cafe의 enter 메소드를 호출합니다. syso로 찍어보면 문제 없이 동작합니다.

@AllArgsConstructor
public class Cafe {

		// ..

    public static void main(String[] args) {

        // init
        List<MenuItem> menuItemList = new ArrayList<>();
        menuItemList.add(new MenuItem("Americano", 1_500));
        menuItemList.add(new MenuItem("Cappuccino", 2_000));
        menuItemList.add(new MenuItem("Caramel Macchiato", 2_500));
        menuItemList.add(new MenuItem("Espresso", 2_500));
        Menu menu = new Menu(menuItemList);

        Barista bob = new Barista(100_000);
        Cafe cafe = new Cafe(bob, menu);

        Customer cathy = new Customer(50_000);

        // run
        cafe.enter(cathy);

    }

}

 

이 코드가 만들어지는 과정을 보면서 어떤 생각이 드셨나요? 전 일단 돌아가면 장땡이지라는 심정으로 빠르게 만들었습니다. 이런 방법으로 코드의 초안을 작성하는 것은 효율적인 방법입니다. 하지만 이렇게 만든 코드는 문제가 있습니다.

Cafe의 enter 메소드를 다시 보겠습니다. 우리가 한 것은 카페 주문 과정을 코드로 작성한 것 뿐이에요. 뭔가 이상한 점이 보이시나요?

@AllArgsConstructor
public class Cafe {

    public void enter(Customer customer){
        MenuItem selectedMenuItem = customer.selectRandomMenuItem(menu);
        customer.setMoney(customer.getMoney() - selectedMenuItem.getPrice());
        barista.setAmount(barista.getAmount() + selectedMenuItem.getPrice());
        Coffee coffee = barista.makeCoffee(selectedMenuItem);
        customer.setCoffee(coffee);
    }

}

 

Cafe이 손님의 지갑 사정을 확인하고 돈을 뺏어서 바리스타에게 주고 있습니다. 바리스타에게 커피를 만들게 시키고, 손님의 손에 커피를 들려줍니다. 카페라는 상황을 상상해봤을 때 뭔가 우리의 직관과 어긋나는 부분이 있습니다. 손님은 스스로 돈을 내야합니다. 바리스타는 손님이 낸 돈을 받아서 주머니에 넣어야합니다. 바리스타는 손님의 요청으로 커피를 만들어야 합니다. 손님은 나온 커피를 받아 손으로 들고 나가야합니다. Cafe 코드를 읽었을 때 뭔가 이상하고 이해가 안 가는 부분은 코드가 우리의 직관을 벗어나기 때문입니다. 하지만 이것보다 더 큰 문제가 있습니다.

 

 

새로운 요구사항으로 카드 결제를 추가해달라는 요청이 들어왔습니다. 이 요구사항을 구현하려면 카드 클래스를 만들고, 손님 클래스에 필드로 추가하고, enter 메소드에서 카드 결제 여부를 확인하고 현금 결제와 카드 결제 로직을 작성해야합니다. 작은 요구사항이 추가됐는데 3개의 클래스가 수정됩니다. 아직은 아주 작은 애플리케이션이니 문제가 없어보이지만, 만약 이러한 상황이 오백개의 클래스를 가진 애플리케이션이라면 어떤가요? 요구사항을 구현하기 위해 시스템의 절반을 수정해야오는 상황이 온다면요? 모든 코드를 뒤져서 참조 관계를 확인하고 코드를 복붙하고, 그러다 몇개를 빼먹어서 버그가 숨어들고, 운영 중인 애플리케이션에서 장애가 나고… 아주 끔찍한 상황입니다.

이 코드의 문제는 이해하기 어렵고, 변경에 취약하다는 점입니다. 왜 이런 문제가 발생했을까요? 우리가 처음 코드를 작성했을 때 어떤 생각으로 클래스를 채우기 시작했는지 복기해봅시다. 메뉴판은 개별 메뉴를 가지고 있어야하고, 손님은 돈과 커피를 들고 있어야 하고, 바리스타는 현재 수익을 알고 있어야 하고, 카페에는 바리스타와 메뉴판이 있어야 한다고 먼저 생각했습니다. 어떤 데이터가 필요한지 먼저 정한 후 클래스를 채웠습니다. 문제는 이런 데이터는 요구사항에 따라 아주 쉽게 변경된다는 점입니다. 손님이 항상 현금으로만 결제를 할까요? 언제나 바리스타가 결제와 커피 제조를 함께 할까요? 만약 할인 쿠폰을 나누어준다면, 할인 가격은 어떻게 계산할까요? 클래스에 넣은 데이터는 요구사항에 변동이 생기면 아주 쉽게 바뀝니다.

 

 

우리가 클래스를 만들 때 어떤 데이터가 필요한지부터 생각하고 코드를 작성했기 때문에, 이렇게 불안정한 데이터들을 외부에서 접근할 수 있게 됩니다. 카페 코드에서는 @Getter와 @Setter가 데이터에 접근할 수 있는 인터페이스를 제공했죠. 이제 불안정한 데이터를 사용하는 외부의 메소드에도 불안정성이 전염됩니다. 데이터가 변경되면 이 데이터를 사용하는 외부의 메소드도 함께 변경되어야 합니다. 데이터와 이 데이터를 사용하는 클라이언트 사이에 강한 결합이 생기게 되고, 요구사항 구현을 위해 데이터를 건드리면 이 데이터에 의존하고 있는 클라이언트도 함께 수정해야해서 작업량이 늘어나고 버그가 숨어드는 원인이 됩니다. 지금은 애플리케이션이 작기 때문에 금방 수정하면 되겠지 생각하지만, 규모가 커지면 이러한 수정 작업은 거의 불가능에 가까워집니다.

 

 

이 문제를 해결하려면 어떻게 해야할까요? 우선 강한 결합을 유발하는 데이터를 외부에서 접근하지 못하도록 숨겨야합니다. 어디에 숨겨야할까요? 객체 안으로 숨깁니다. 그러면 이제 데이터를 변경할 수 있는 주체는 객체 뿐입니다. 기존에 이 데이터를 변경하던 외부의 메소드에서는 객체 안으로 숨긴 데이터를 더이상 볼 수도 없고, 변경할 수도 없습니다. 기존의 기능을 유지하려면 외부의 메소드는 객체의 데이터 변경이 필요합니다. 그러므로 해당 데이터를 변경할 수 있는 객체에게 상태를 변경해달라는 메시지를 요청합니다.

 

 

상태를 객체 내부로 숨기고, 상태 변경을 하는 주체는 오직 이 상태를 관리하는 객체 뿐이어야 합니다. 애플리케이션의 기능을 정상적으로 구현하기 위해서 외부에서 상태 변경이 필요한 경우, 메시지를 통해 상태 변경을 기대합니다. 이제 객체는 자신의 상태를 자신만이 변경하는, 자율적인 책임을 가집니다. 더이상 카페에서 손님의 돈을 빼앗지 않고, 바리스타에게 커피 제조를 명령하지도 않습니다. 손님은 스스로 돈을 내고, 바리스타는 스스로 커피를 만들어 제공합니다. 커피 가격을 결제할 때 바리스타는 손님이 알아서 커피 값을 지불하기를 요청합니다. 그래서 바리스타는 손님이 돈을 현금으로 내든, 카드로 내든, 애플페이로 내든 그 방법은 신경쓰지 않고, 가게의 수익 숫자가 올라가는 것만 신경쓰면 됩니다. 새로운 결제 방법이 추가되어도 돈을 내는 손님만 바뀌면 되겠죠.

 

 

이제 이 코드를 어떻게 개선해야할지 감이 잡힙니다. Cafe가 조작하고 있던 클래스들의 데이터를 숨기고, 해당 데이터의 변경은 클래스 안에서만 할 수 있도록 메소드를 추가합니다. 그리고 Cafe는 그 데이터를 변경할 수 있는 클래스에 메시지를 요청하도록 변경하면 될 것 같습니다.

 

 

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