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

[객체지향과 스프링의 이해] 6. 카페 코드를 스프링 웹 애플리케이션으로 만들기

by 민휘 2023. 5. 30.

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


 

이전 게시글에서 3 계층 애플리케이션과 도메인 중심 설계를 살펴보았습니다. 이번에는 카페 예제를 스프링의 웹 애플리케이션으로 확장해보겠습니다.

 

 

우선 스프링에 어떤 빈이 등록될 수 있을지 고민해봅니다. 싱글톤으로 관리되면 좋을 객체는 전역 객체로 사용되어도 괜찮은 Cafe와 Menu입니다. 나머지 객체는 상태를 가지기 때문에 싱글톤으로 등록하면 안됩니다.

 

 

싱글톤이어도 되는 객체

  • Menu
  • Cafe : 기존에 있던 바리스타 의존성은 매개변수로 주입

싱글톤이면 안되는 객체

  • Customer
  • Barista
  • Coffee
  • Payable 구현체

 

 

Menu부터 구현해봅니다. Menu는 불변 객체인 List MenuItem을 가지므로, 생성자가 호출될 때 메뉴 아이템을 초기화하도록 합니다. List MenuItem을 사용하는 비즈니스 로직을 추가하고, 빈으로 등록하기 위해 @Component를 붙입니다.

@Component
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);
    }
}

 

 

카페도 @Component를 붙입니다. 원래 카페는 바리스타를 참조로 가지고 있었는데, 싱글톤은 무상태로 설계해야하므로 바리스타는 메소드 안에서만 사용할 수 있도록 변경합니다.

@Component
public class Cafe {

    private final Menu menu;

    public Cafe(Menu menu) {
        this.menu = menu;
    }

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

}

 

 

바리스타는 수익 정보를 알고 있는데요, 우선 DB에서 가져온 값을 사용한다고 치고 하드코딩한 값을 박아두겠습니다. 나머지는 기존 코드와 동일합니다.

public class Barista {

    private Integer amount = 100_000; // db에서 조회

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

    public Coffee takeOrder(MenuItem menuItem, Payable payable) {
        payable.pay(menuItem.cost());
        receive(menuItem.cost());
        return makeCoffee(menuItem);
    }

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

 

 

손님의 돈도 DB에서 가져온다고 가정하고 하드코딩하겠습니다. 나머지 Payable 코드는 동일합니다.

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.recommend();
        Coffee coffee = barista.takeOrder(menuItem, payable);
        this.coffee = coffee;
    }
}

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

public class PayWithCard implements Payable {
    private Card card = new Card(10_000); // db에서 조회

    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(this.money >= cost) {
                this.money -= cost;
            } else {
                throw new RuntimeException("잔액이 부족합니다!");
            }
        }
    }
}

public class PayWithCash implements Payable {
    private Integer money = 10_000; // db에서 조회

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

}

 

 

이제 주문 요청을 담당하는 서비스 계층의 코드를 작성합니다. Cafe는 스프링 컨테이너에 의해 생성자로 주입됩니다. 그리고 Cafe에 포함된 Menu도 Cafe가 생성될 때 스프링 컨테이너에 의해 주입됩니다.

@RequiredArgsConstructor
@Service
public class OrderService {
    private final Cafe cafe;
}

 

 

서비스 계층의 order에서 딱히 구현해야할 비즈니스 로직은 없습니다. 도메인 안에 비즈니스 로직이 모두 들어있기 때문에, 협력에 참여하는 객체를 생성하고 enter를 호출해주면 동작합니다.

@RequiredArgsConstructor
@Service
public class OrderService {

    private final Cafe cafe;

    public void order(PayWay payway) {
        Customer customer = new Customer(PayableFactory.of(payway));
        Barista barista = new Barista();
        cafe.enter(customer, barista);
    }

}

 

 

서비스 로직을 작성하고 바로 컨트롤러를 만들어 수동 테스트를 하는 것보다는 단위 테스트를 작성하는 것이 계층간 종속성을 끊어내는데 도움이 됩니다. 예외 없이 동작하는지에 대해서만 우선 테스트를 진행합니다. 컨테이너를 사용해야하므로 @SpringBootTest를 붙입니다.

@SpringBootTest
class OrderServiceTest {

    @Autowired OrderService orderService;

    @Test
    public void test() {
        orderService.order(PayWay.CASH);
    }
}

 

 

HTTP 요청을 받고 응답을 반환하는 컨트롤러를 추가합니다. 참고로 스프링의 컨트롤러는 디스패처 서블릿으로부터 매핑된 요청을 처리합니다. 필요하다면 Payway의 검증 로직을 여기에 추가할 수 있습니다. 예외가 발생하지 않고 잘 처리되었다면 성공 메시지를 반환합니다.

@RequiredArgsConstructor
@RestController
public class CafeController {

    private final OrderService orderService;

    @PostMapping("/customers/{id}/order/{payWay}")
    public String recommendOrder(@PathVariable Integer id, @PathVariable String payWay) {
        // payway 검증 필요하다면 추가
        orderService.order(PayWay.of(payWay));
        return "커피 나왔습니다. 맛있게 드세요.";
    }

}

 

 

서버를 띄우고 포스트맨으로 수동 테스트해보면 잘 동작합니다.

 

 

백엔드 서비스의 데이터 관리 패턴으로 트랜잭션 스크립트 패턴과 도메인 모델 패턴이 있습니다. 트랜잭션 스크립트는 도메인에 필드만 넣고 게터 세터로 열어서 모든 비즈니스 로직을 서비스 계층에서 하는 방식이고, 도메인 모델 패턴은 도메인 내에 비즈니스 로직을 넣어서 풍성하게 만드는 방식입니다. 각각 절차지향과 객체지향 방식을 따릅니다. 재밌는 점은 백엔드 서비스를 개발할 때 두 패턴이 양립할 수 있다는 것입니다. 이 둘은 트레이드오프 관계에 있기 때문에, 유연성이 필요한 정도에 따라서 그 정도를 조절하면 됩니다. 카페 코드는 굉장히 유연하게 협력을 추가할 수 있도록 도메인에 코드를 많이 추가했습니다. 이렇게 기능을 만들면 디버깅하기가 어렵기 때문에 당장 유연함이 필요하지 않다면 어느정도 절차지향적인 서비스 로직을 작성하는 것이 더 나을 수도 있습니다.