객체지향 변경에 유연한 코드 리팩토링

7 분 소요

리팩토링 전 코드는 크게 두 가지 기능으로 영화관에서 관람객이 티켓을 구매하거나 초대권을 사용하여 입장, 카페에서 고객이 캐셔에게 음료 주문을 하여 바리스타가 주문을 바탕으로 음료를 만들어주는 기능을 하는 코드였다. 하지만 기능이 동작하는 것과 별개로 각 객체들이 너무 많은 범위의 책임을 가지고 있었고, 클래스 간의 관계가 강하게 결합되어있어 확장이 어렵고 코드를 읽는 사람이 객체의 상태와 행동을 예측할 수 없는 문제가 있었다.


리팩토링 전 코드

Theater Domain

기존 코드의 문제점

@Component
@RequiredArgsConstructor
public class Theater {

    public void enter(Audience audience, TicketSeller ticketSeller){
        if(audience.getBag().hasInvitation()){
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        }else {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

Theater(극장)은 현실 세계에서 절대적으로 혼자 생각하고 판단하고 행동하지 못한다. 인간이라는 행동주체가 있어야 극장의 문이 열리고 인간이 티켓을 확인 후, 극장의 입장을 허가 해준다. 하지만, 코드에서는 Theater가 TicketOffice 객체에 직접 접근하여 입장을 허가해주고 있다. 또한 Theater 클래스에서 입장을 할 때 audience.getBag().hasInvitation() 이렇게 관람객의 가방에서 초대권이 있는지 확인하고, audience.getBag().setTicket(ticket); 이렇게 관람객의 가방에 구매한 티켓을 저장하고 있다.

public class Audience {
    private final Bag bag;

    public Audience(Bag bag){
        this.bag = bag;
    }

    public Bag getBag(){ return bag;}
}

Audience 클래스의 Bag 의 접근제어자는 분명 private 이다. 그러나 Audience가 자체적으로 내부의 메서드를 호출하여 가방에 티켓이 있는지 확인하거나 넣는 것이 아니고 외부에서 직접 AudienceBag에 접근하여 작업을 수행하고 있다.

해결 방법

  • 내부와 외부를 구분 짓고 어떤 것을 외부에 노출하고 어떤 것을 내부로 숨길지 경계를 잘 결정한다.
  • 캡슐화를 통한 세부적인 내부 구현 숨기고, 외부와의 협력은 Public 인터페이스를 통해서만 한다.
@RequiredArgsConstructor
public class Theater {

    private final TicketOffice ticketOffice;

    public String enter(Audience audience) {
        handleEnter(audience);
        return "Have a good time " + audience.getName()+ ".";
    }

    public String refund(Audience audience) {
        handleRefund(audience);
        return audience.getName() + "'s refund success";
    }

    private void handleEnter(Audience audience) {
        if (audience.hasInvitation()) {
            handleInvitation(audience);
            return;
        }
        this.ticketOffice.sellTicketTo(audience);
    }

    private void handleRefund(Audience audience) {
        if (audience.hasInvitation() && audience.isValidInvitation()) {
            Ticket refundTicket = audience.refundTicket();
            this.ticketOffice.setTicket(refundTicket);
            return;
        }
        this.ticketOffice.refundTicketTo(audience);
    }

    // 관객이 초대권이 있다면, 초대권과 티켓을 교환하여 관객에게 지급.
    private void handleInvitation(Audience audience) {
        Invitation invitation = audience.getInvitation();
        invitation.verifyInvitation();

        Ticket freeTicket = this.ticketOffice.getAvailableTicket();
        audience.takeTicket(freeTicket);
    }
}

리팩토링을 진행한 Theater 클래스에 public interface로 만들어진 메서드는 enter()refund() 뿐이다. 그 이외에 private 메서드는 해당 public interface 메서드 내부에서 호출한다. 관람객은 영화관에 입장하거나, 환불을 요청할 뿐이고 관람객이 티켓을 소지하고 있는지? 티켓을 구매할 돈이 있는지? 등의 요청에 따른 비즈니스 로직은 각 객체의 역할과 책임의 범위에 따라 수행하도록 정의한다.

@RequiredArgsConstructor
public class TicketOffice {

    private final List<TicketSeller> ticketSellers;
    private final List<Ticket> tickets;
    private long amount;

    public TicketOffice(List<TicketSeller> ticketSellers, List<Ticket> tickets, long amount) {
        this.ticketSellers = ticketSellers;
        this.tickets = tickets;
        this.amount = amount;
    }

    ...

    protected TicketSeller findAvailableTicketSeller() {
        return this.ticketSellers.stream()
                .filter(ticketSeller -> ticketSeller.getStatus() == Status.WAITING)
                .findAny()
                .orElseThrow(() -> new TheaterException(TheaterErrorCode.ALL_TICKET_SELLER_IN_WORK));
    }

    // 기존에 관람객이 Theater 클래스에서 바로 티켓을 구매했지만 TicketOffice에서 구매.
    // TicketOffice는 업무 가능한 TicketSeller와 Ticket을 찾아주고
    // TicketSeller에게 관람객에게 해당 티켓을 파는 업무를 하라고 명령한다.
    public void sellTicketTo(Audience audience) {
        TicketSeller ticketSeller = findAvailableTicketSeller();
        Ticket ticket = getAvailableTicket();
        ticketSeller.sellTicketTo(audience, ticket);
    }

    // 환불에 관한 메서드도 Theater에서 TicketOffice로 이동하였다.
    // 환불 로직 또한 적절한 TicketSeller를 찾아서 관람객의 환불 업무를 하도록 명령한다.
    public void refundTicketTo(Audience audience) {
        TicketSeller ticketSeller = findAvailableTicketSeller();
        ticketSeller.refundTicketTo(audience);
    }

    ...
}
@Getter
@AllArgsConstructor
public class TicketSeller {

    private TicketOffice ticketOffice;

    private Status status;

    public void startWork() {
        this.status = Status.WORKING;
    }

    public void finishWork() {
        this.status = Status.WAITING;
    }

    // TicketOffice에게서 티켓 판매 업무를 넘겨받아 수행한다.
    public void sellTicketTo(Audience audience, Ticket ticket) {
        this.startWork();
        try {
            audience.buyTicket(ticket);
            ticketOffice.plusAmount(ticket.getFee());
            ticketOffice.removedSoldTicket(ticket);
            audience.takeTicket(ticket);
        } finally {
            this.finishWork();
        }
    }

    // TicketOffice에게서 티켓 환불 업무를 넘겨받아 수행한다.
    public void refundTicketTo(Audience audience) {
        this.startWork();
        try {
            Ticket refundTicket = audience.refundTicket();
            Long ticketFee = refundTicket.getFee();
            ticketOffice.setTicket(refundTicket);
            ticketOffice.minusAmount(ticketFee);
            audience.takeRefundMoney(ticketFee);
        } finally {
            this.finishWork();
        }
    }
}

이렇게 Theater 클래스에서 객체에 직접 접근하여 로직을 수행할 뿐더러 너무 많은 책임과 역할을 가지고 있던 코드에서 각 객체의 역할과 범위에 맞게 책임을 나눠가지게 분리하여 의존성을 낮추고 코드의 수정 전파 범위를 줄일 수 있었다.

핵심은 객체 내부의 상태를 캡슐화하고, 객체 간에 오직 메세지(public method)를 통해서만 상호작용하도록 만드는 것이다. 스스로 데이터를 처리하는 자율적인 객체로 만들면 결합도를 낮출 뿐만 아니라 응집도도 높아진다.


Cafe Domain

기존 코드의 문제점

public class Cashier {
    private Orderbook orderbook;
    
    public boolean takeOrders(String menuName, int quantity){
        Coffee makedCoffee = makeCoffee(menuName, quantity);
        deliveryOrder(orderbook.getUserName(), makedCoffee);
    }
    
    public long calculatePrice(String menuName, int quantity){
    
    }
    
    public Coffee makeCoffee(String menuName, int quantity){
    
    }
    
    public void deliveryOrder(Strint toCustomer, Coffee coffee){
    
    }
}

Caffe는 최초 설계 단계에서 주문 받기, 커피 제조하기, 준비된 커피 전달하기 라는 총 3개의 큰 기능이 필요하다고 판단했고, 이 모든 책임을 Cashier라는 객체에게 할당하는 설계로 만들어졌다.

처음에는 문제가 없지만, 미래에 “메뉴 추가” 또는 “가격 계산방식 변경”이라는 변화가 발생한다면 Cashier 클래스는 변경되어야 한다. 즉, Cashier 클래스를 변경 시키는 요소를 2개 이상 가지고 있다는 의미이다. 이렇게 하나의 객체에 다수의 책임이 할당 되어 있는 상태에서 많은 시간이 흐르고 변화의 폭격을 맞은 코드에는 아래와 같은 문제점을 가지고 있게 된다.

  1. 각기 다른 책임의 기능들이 강하게 결합되고, 변화가 발생했을 때 변경 포인트가 전파된다.
  2. 적절한 관심사 분리가 되어 있지 않아서 코드의 가독성이 많이 떨어진다.
  3. 재사용성이 떨어진다.

리팩토링 후 코드

@RequiredArgsConstructor
@AllArgsConstructor
public class Cashier {

    private final Cafe cafe;
    private Status status;

    private void startWork() {
        this.status = Status.WORKING;
    }

    private void finishWork() {
        this.status = Status.WAITING;
    }

    protected Status getStatus() {
        return this.status;
    }

    public String takeOrder(Customer customer) {
        startWork();
        Orders orders = customer.submitOrders();
        long totalPrice = orders.calculateTotalPrice(customer.getPayment());
        Long discountedTotalPrice = customer.payPrice(totalPrice);
        this.cafe.plusSales(discountedTotalPrice);
        Barista availableBarista = cafe.findAvailableBarista();
        finishWork();
        return availableBarista.makeCoffeeTo(orders) + ", Discounted total price: " + discountedTotalPrice;
    }
}

Cashier는 고객의 주문을 받는 takeOrder() 메서드만 가지고 있다. 고객이 submitOrders() 메서드로 자신이 주문하려고 하는 음료가 담긴 Orders를 만들면 Orders 클래스에서 calculateTotalPrice() 메서드로 해당 주문에 대한 총 합을 반환한다. 그 후 고객이 총 가격만큼 돈을 지불하고, 카페의 매상을 올리고 커피 제조를 할 수 있는 상태의 바리스타를 찾아서 음료 제조 업무를 수행하게 한다. 이렇게 기존 코드를 리팩토링 함으로써 Cashier 클래스는 주문을 받는 책임의 변경이 일어날 때만 수정을 하도록 하였다.

public record Orders(Map<Menu, Integer> orderItems) {

    public Orders() {
        this(new HashMap<>());
    }

    public Orders addOrder(Menu menu, int quantity) {
        Map<Menu, Integer> newOrderItems = new HashMap<>(orderItems);
        newOrderItems.put(menu, quantity);
        return new Orders(newOrderItems);
    }

    public Map<Menu, Integer> getOrderItems() {
        return orderItems;
    }

    public long calculateTotalPrice(Payment payment) {
        long totalPrice = 0L;
        Map<Menu, Integer> orderItems = this.getOrderItems();
        for (Map.Entry<Menu, Integer> entry : orderItems.entrySet()) {
            Menu menu = entry.getKey();
            Integer quantity = entry.getValue();
            totalPrice += (long) menu.getPrice() * quantity;
        }
        return payment.discount(totalPrice);
    }
}

Orders 는 고객이 주문한 음료의 가격을 계산하는 것 만 책임진다.

@RestController
@RequestMapping("/cafe")
@RequiredArgsConstructor
public class CafeController {

    private final CafeService cafeService;

    @GetMapping("/hello")
    public Response<String> welcomeMessage() {
        return Response.success("Welcome to The Wanted coding cafe!!");
    }

    @PostMapping("/orders")
    public Response<String> orderFromMenu(@RequestBody CustomerDto customerDto) {
        Customer customer = customerDto.toEntity();
        DiscountPolicy discountPolicy = DiscountPolicyFactory.createDiscountPolicyBy(customerDto.payment());
        Payment payment = PaymentFactory.createPayment(customerDto.payment(), customerDto.balance(), discountPolicy);
        customer.setPayment(payment);

        return Response.success(cafeService.orderFrom(customer));
    }
}

public class DiscountPolicyFactory {
    public static DiscountPolicy createDiscountPolicyBy(String payment) {
        return switch (payment.toLowerCase()) {
            case "card" -> new CardDiscountPolicy();
            case "cash" -> new CashDiscountPolicy();
            default -> null;
        };
    }
}

public class PaymentFactory {
    public static Payment createPayment(String payment, long balance, DiscountPolicy discountPolicy) {
        return switch (payment.toLowerCase()) {
            case "card" -> new Card(balance, discountPolicy);
            case "cash" -> new Cash(balance, discountPolicy);
            default -> null;
        };
    }
}

CafeController 에서는 CustomerDto 로 고객의 결제수단과 음료 주문을 Customer 클래스로 변환하여 CafeService 로 넘기는 역할을 한다. Customer 클래스로 변환할 때 DiscountPolicyFactoryPaymentFactory 클래스가 팩토리 메서드 패턴으로 고객의 결제수단과 그에 맞는 할인 정책을 생성하여 반환하고, PaymentFactory 클래스에 결제수단과 잔고, 할인정책을 매개변수로 넣어서 Customer 클래스에 넣어준다. 이렇게 팩토리 메서드 패턴을 사용함으로써 할인정책과 결제수단이 추가되거나 변경되어도 CafeController 에서는 변경에 의한 전파가 없고, 각 결제수단, 할인정책만 수정하면 된다.

public interface DiscountPolicy {

    Long discount(long totalPrice);
}

public class CardDiscountPolicy implements DiscountPolicy {

    @Override
    public Long discount(long totalPrice) {
        System.out.println("Card Discount 10%");
        return totalPrice / 10 * 9;
    }
}

public class CashDiscountPolicy implements DiscountPolicy {
    @Override
    public Long discount(long totalPrice) {
        System.out.println("Cash Discount 20%");
        return totalPrice / 10 * 8;
    }
}

DiscountPolicy 인터페이스에는 discount() 메서드만 존재하고 결제수단에 따른 할인정책을 추가할 때는 DiscountPolicy 인터페이스를 구현하는 구체 클래스를 생성하여 관리하면 된다. 이렇게 인터페이스를 통해 OCP 원칙을 지켜 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있다.

public interface Payment {
     void pay(Long price);
}

public class Card implements Payment {

    private long balance;
    private final DiscountPolicy discountPolicy;

    public Card(long balance, DiscountPolicy discountPolicy) {
        this.balance = balance;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Long pay(Long price) {
        Long discountedPrice = discountPolicy.discount(price);
        if (balance < discountedPrice) {
            throw new CafeException(CafeErrorCode.NOT_ENOUGH_MONEY);
        }
        this.balance -= discountedPrice;
        return discountedPrice;
    }
}

public class Cash implements Payment {

    private Long balance;
    private final DiscountPolicy discountPolicy;

    public Cash(Long balance, DiscountPolicy discountPolicy) {
        this.balance = balance;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Long pay(Long price) {
        Long discountedPrice = discountPolicy.discount(price);
        if (balance < discountedPrice) {
            throw new CafeException(CafeErrorCode.NOT_ENOUGH_MONEY);
        }
        balance -= discountedPrice;
        return discountedPrice;
    }
}

Payment 인터페이스를 구현한 CardCash 클래스는 pay() 라는 주문금액 결제 메서드만을 가지고 Orders 클래스에서 계산한 총 금액을 DiscountPolicydiscount() 메서드를 호출하여 할인이 적용된 금액을 지불하게 한다. 이로써 단일책임 원칙을 지키며 변경에 유연한 코드가 되었다.

댓글남기기