티스토리 뷰

책/오브젝트

오브젝트 1-3. 설계 개선하기

기내식은수박바 2022. 12. 22. 23:37
반응형

설계 개선하기

앞 글의 코드는 세 가지 목적 중 한 가지는 만족시키지만 다른 두 조건은 만족시키지 못한다.

  • 기능은 제대로 수행
  • 이해하기 어려움
  • 변경하기가 쉽지 않음

 

여기서 변경과 의사소통이라는 문제가 서로 엮여 있다는 점에 주목하라.

코드를 이해하기 어려운 이유는 Theater가 관람객의 가방과 판매원의 매표소에 직접 접근하기 때문이다. 이것은 관람객과 판매원이 자신의 일을 스스로 처리해야 한다는 우리의 직관을 벗어난다.

다시 말해서 의도를 정확하게 의사소통하지 못하기 때문에 코드가 이해하기 어려워진 것이다.

Theater가 관람객의 가방과 판매원의 매표소에 직접 접근한다는 것은 Theater가 Audience와 TicketSeller에 결합된다는 것을 의미한다.

따라서 Audience와 TicketSeller를 변경할 때 Theater도 함께 변경해야 하기 때문에 전체적으로 코드를 변경하기도 어려워진다.

해결방법

관람객이 가방을 가지고 있다는 사실과 판매원이 매표소에서 티켓을 판매한다는 사실을 Theater가 알아야 할 필요가 없다.

따라서 관람객이 스스로 가방 안의 현금과 초대장을 처리하고 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 한다면 이 모든 문제를 한 번에 해결할 수 있을 것이다.

 

자율성을 높이자

설계를 변경하기 어려운 이유는 Theater가 Audience와 TicketSeller 뿐만 아니라 Audience 소유의 Bag과 TicketSeller가 근무하는 TicketSellerOffice까지 마음대로 접근할 수 있기 때문이다.

해결 방법은 Audience와 TicketSeller가 직접 Bag과 TicketOffice를 처리하는 자율적인 존재가 되도록 설계를 변경하는 것이다.

1단계

Theater의 enter 메서드에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 내부로 숨기는 것이다.

  • TicketOffice에 접근하는 코드를 ticketOffice를 포함하는 TicketSeller로 이동
public class TicketSeller {
    private TicketOffice ticketOffice;
    
    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }
    
    /* public TicketOffice getTicketOffice() {
        return ticketOffice;
    } */
    
    public void sellTo(Audience audience) {
        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);
        }
    }
}

주목할 점

TicketSeller에서 getTicketOffice 메서드가 제거됐는데, ticketOffice의 가시성이 private이고 접근 가능한 public 메서드가 더 이상 존재하지 않기 때문에 외부에서는 ticketOffice에 직접 접근할 수 없다.

결과적으로 ticketOffice에 대한 접근은 오직 ticketSeller 안에만 존재하게 된다.

따라서 TicketSeller는 ticketOffice에서 티켓을 꺼내거나 판매 요금을 적립하는 일을 스스로 수행할 수밖에 없다.

 

캡슐화

  • 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것
  • 목적 : 변경하기 쉬운 객체를 만드는 것. (캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 좀 더 쉽게 변경할 수 있게 된다.)

이제 Theater의 enter 메서드는 sellTo 메서드를 호출하는 간단한 코드로 바뀐다.

public class Theater {
    private TicketSeller ticketSeller;
    
    public Theater(TicketSeller ticketSeller) {
        this.ticketSeller = ticketSeller;
    }
    
    public void enter(Audience audience) {
        ticketSeller.sellTo(audience);
    }
}

Theater는 ticketOffice가 TickerSeller 내부에 존재한다는 사실을 알지 못한다. 

Theater는 오직 TicketSeller의 인터페이스 (Interface) 에만 의존한다. TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현 (Implementation) 의 영역에 속한다.

  • 객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.

위 그림은 수정 후의 클래스 사이의 의존성을 나타낸 것이다. 

Theater의 로직을 TicketSeller로 이동시킨 결과, Theater에서 TicketOffice로의 의존성이 제거됐다는 사실을 알 수 있다. TicketOffice와 협력하는 TicketSeller의 내부 구현이 성공적으로 캡슐화된 것이다.

 

TicketSeller는 Audience의 getBag 메서드를 호출해서 Audience 내부의 Bag 인스턴스에 직접 접근한다. Bag 인스턴스에 접근하는 객체가 Theater에서 TicketSeller로 바뀌었을 뿐 Audience는 여전히 자율적인 존재가 아닌 것이다.

TicketSeller와 동일한 방법으로 Audience의 캡슐화를 개선할 수 있다. buy 메서드는 인자로 전달된 Ticket을 Bag에 넣은 후 지불된 금액을 반환한다.

public class TicketSeller {
    private TicketOffice ticketOffice;
    
    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }
    
    public void sellTo(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().setTicket(ticket);
        } else {
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketOffice.plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

public class Audience {
    private Bag bag;
    
    public Audience(Bag bag) {
        this.bag = bag;
    }
    
    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

이제 Audience 클래스에서 getBag 메서드를 제거할 수 있고 결과적으로 Bag의 존재를 내부로 캡슐화할 수 있게 됐다.

이제 TicketSeller가 Audience의 인터페이스에만 의존하도록 수정하자.

public class TicketSeller {
    private TicketOffice ticketOffice;
    
    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }
    
    public void sellTo(Audience audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }
}

 

캡슐화를 개선한 후에 가장 크게 달라진 점은 Audience와 TicketSeller가 내부 구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결한다는 것이다. 다시 말해 자율적인 존재가 된 것이다.

 

무엇이 개선됐는가

수정된 Audience와 TicketSeller는 자신이 가지고 있는 소지품을 스스로 관리한다. 따라서 코드를 읽는 사람과의 의사소통이라는 관점에서 이 코드는 확실히 개선된 것으로 보인다.

더 중요한 점은 Audience나 TicketSeller의 내부 구현을 변경하더라도 Theater를 함께 변경할 필요가 없어졌다는 점이다.

 

어떻게 한 것인가

자기 자신의 문제를 스스로 해결하도록 코드를 변경한 것이다.

수정하기 전에는 Theater가 Audience와 TicketSeller의 상세한 내부 구현까지 알고 있어야 했다. 따라서 Theater는 Audience와 TicketSeller에 강하게 결합돼 있었고, 그 결과 Audience와 TicketSeller의 사소한 변경에도 Theater가 영향을 받을 수밖에 없었다.

수정한 후의 Theater는 Audience나 TicketSeller의 내부에 직접 접근하지 않는다

 

캡슐화와 응집도

핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.

밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도 (cohesion) 가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.

 

절차지향과 객체지향

Audience, TicketSeller, Bag, TicketOffice는 관람객을 입장시키는 데 필요한 정보를 제공하고 모든 처리는 Theater의 enter 메서드 안에 존재했었다는 점에 주목하라.

이 관점에서 Theater의 enter 메서드는 프로세스이며 Audience, TicketSeller, Bag, TicketOffice는 데이터다.

이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부른다.

 

일반적으로 절차적 프로그래밍은 우리의 직관에 위배된다. 우리는 관람객과 판매원이 자신의 일을 스스로 처리할 것이라고 예상한다. 하지만 절차적 프로그래밍의 세계에서는 관람객과 판매원이 수동적인 존재일 뿐이다.

더 큰 문제는 절차적 프로그래밍의 세상에서는 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것이다. Audience와 TicketSeller의 내부 구현을 변경하려면 Theater의 enter 메서드를 함께 변경해야 한다.

해결 방법은 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 Audience와 TicketSeller로 이동시키는 것이다. 

이처럼 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부른다.

훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.

 

책임의 이동

두 방식의 차이점을 가장 쉽게 이해할 수 있는 방법은 기능을 처리하는 방법을 살펴보는 것이다.

작업 흐름이 주로 Theater에 의해 제어되는 것을 볼 수 있는데, 객체지향 세계의 용어를 사용해서 표현하면 책임이 Theater에 집중돼 있는 것이다.

책임이 중앙집중된 절차적 프로그래밍

아래 그림의 객체지향 설계에서는 제어 흐름이 각 객체에 적절하게 분산돼 있음을 알 수 있다.

다시 말해 하나의 기능을 완성하는 데 필요한 책임이 여러 객체에 걸쳐 분산돼 있는 것이다.

책임이 분산된 객체지향 프로그래밍

객체지향 설계에서는 독재자가 존재하지 않고 각 객체에 책임이 적절하게 분배된다. 따라서 각 객체는 자신을 스스로 책임진다.

변경 전의 코드에서는 모든 책임이 Theater에 몰려 있었기 때문에 Theater가 필요한 모든 객체에 의존해야 했다. 그 결과로 얻게 된 것은 변경에 취약한 설계다. 

개선된 코드에서는 Theater와 Audience, TicketSeller에 적절히 책임이 분배됐다. 그 결과 변경에 탄력적으로 대응할 수 있는 견고한 설계를 얻었다.

 

더 개선할 수 있다

public class Audience {
    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

Audience는 자율적인 존재다. 스스로 티켓을 구매하고 가방 안의 내용물을 직접 관리한다.

하지만 Bag은 어떤가? Bag은 과거의 Audience처럼 스스로 자기 자신을 책임지지 않고 Audience에 의해 끌려다니는 수동적인 존재다.

Bag을 자율적인 존재로 바꿔보자.

public class Bag {
    private Long amount;
    private Ticket ticket;
    private Invitation invitation;
    
    public Long hold(Ticket ticket) {
        if (hasInvitation()) {
            setTicket(ticket);
            return 0L;
        } else {
            setTicket(ticket);
            minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
    
    private void setTicket(Ticket ticket) {
        this.ticket = ticket;
    }
    
    private boolean hasInvitation() {
        return invitation != null;
    }
    
    private void minusAmount(Long amount) {
        this.amount -= amount;
    }
}

public 메서드였던 hasInvitation, minusAmount, setTicket 메서드들은 더 이상 외부에서 사용되지 않고 내부에서만 사용되기 때문에 가시성을 private으로 변경했다.

 

Bag의 구현을 캡슐화시켰으니 이제 Audience를 Bag의 구현이 아닌 인터페이스에만 의존하도록 수정하자.

public class Audience {
    public Long buy(Ticket ticket) {
        return bag.hold(ticket);
    }
}

 

TicketOffice에 sellTicketTo 메서드를 추가하고 TicketSeller의 sellTo 메서드의 내부 코드를 이 메서드로 옮기자.

public class TicketOffice {
    public void sellTicketTo(Audience audience) {
        plusAmount(audience.buy(getTicket()));
    }
    
    private Ticket getTicket() {
        return tickets.remove(0);
    }
    
    private void plusAmount(Long amount) {
        this.amount += amount;
    }
}

 

 

TicketSeller는 TicketOffice의 sellTicketTo 메서드를 호출함으로써 원하는 목적을 달성할 수 있다. TicketSeller가 TicketOffice의 구현이 아닌 인터페이스에만 의존하게 됐다.

public class TicketSeller {
    public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience);
    }
}

TicketOffice와 Audience 사이에 의존성이 추가됐다. TicketOffice의 자율성은 높였지만 전체 설계의 관점에서는 결합도가 상승했다. 어떻게 할 것인가?

TicketOffice에서 Audience로 향하는 의존성이 추가된다

이 작은 예제를 통해 여러분은 두 가지 사실을 알게 됐을 것이다.

  1. 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다.
  2. 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물이다. 어떤 경우에도 모든 사람들을 만족시킬 수 있는 설계를 만들 수는 없다.

 

그래, 거짓말이다!

비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다.

앞에서는 실세계에서의 생물처럼 스스로 생각하고 행동하도록 소프트웨어 객체를 설계하는 것이 이해하기 쉬운 코드를 작성하는 것이라고 설명했다. 하지만 이제 말을 조금 바꿔야겠다.

훌륭한 객체지향설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다. 

 

따라서 이해하기 쉽고 변경하기 쉬운 코드를 작성하고 싶다면 한 편의 애니메이션을 만든다고 생각하라. 

다른 사람의 코드를 읽고 이해하는 동안에는 애니메이션을 보고 있다고 여러분의 뇌를 속여라.

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함