Ch.1 객체, 설계

로버트 L. 글래스는 <소프트웨어 크리에이티비티 2.0>에서 '이론 대 실무'에 대한 견해를 내놓았다. 소프트웨어 분야는 여느 다른 공학 분야에 비해 상대적으로 짧은 역사를 갖고 있다. 소프트웨어 분야는 아직 걸음마 단계에 머물러 있으므로, 이론보다 실무가 더 앞서 있으며 실무가 더 중요하다. 소프트웨어 개발에서 이론보다 실무가 앞서 있는 대표적인 분야로 '소프트웨어 설계', '소프트웨어 유지보수'가 있다.

이 책도 객체지향 패러다임을 설명하기 위해 우리가 가장 능숙하게 다룰 수 있는 '코드'를 이용해 다양한 측면을 설명하려고 할 것이다.

01. 티켓 판매 애플리케이션 구현하기

연극/음악회를 공연할 수 있는 작은 소극장을 경영하고 있다고 상상해 보자. 소극장의 관람객의 발길이 이어지도록 작은 이벤트를 기획하는데, 추첨을 통해 공연을 무료로 관람할 수 있는 초대장을 발송하는 것이다.

염두에 둬야 할 점은, 이벤트에 당첨된 관람객과 그렇지 않은 관람객은 다른 방식으로 입장시켜야 한다.

  • 이벤트에 당첨된 관람객은 초대장을 티켓으로 교환한 후에 입장할 수 있다.

  • 이벤트에 당첨되지 않은 관람객은 티켓을 구매해야만 입장할 수 있다.

따라서 관람객을 입장시키기 전에 이벤트 당첨 여부를 확인하고 당첨자가 아니라면 티켓을 판매한 후 입장시켜야 한다.

먼저 이벤트 당첨자에게 발송되는 초대장을 구현해보자. 초대일자(when)를 포함하고 있다.

Invitation.java
public class Invitation {
    private LocalDateTime when;
}

공연을 관람하기 원하는 모든 사람들은 티켓을 소지하고 있어야 한다.

Ticket.java
public class Ticket {
    private Long fee;

    public Long getFee() {
        return fee;
    }
}

이벤트 당첨자는 티켓으로 교환할 초대장을 갖고 있을 것이고, 그렇지 않은 관람객은 티켓을 구매할 현금을 보유하고 있을 것이다. 따라서 관람객이 갖고 올 수 있는 소지품은 1. 초대장, 2. 현금, 3. 티켓 세 가지 뿐이다. 관람객이 가방을 들고 올 수 있다고 가정하자.

Bag.java
public class Bag {
    // 관람객의 소지품을 인스턴스 변수로 포함한다.
    private Long amount;
    private Invitation invitation;
    private Ticket ticket;

    public boolean hasInvitation() { // 초대장의 보유 여부를 판단한다.
        return invitation != null;
    }

    public boolean hasTicket() { // 티켓의 소유 여부를 판단한다.
        return ticket != null;
    }

    public void setTicket(Ticket ticket) { // 초대장을 티켓으로 교환한다.
        this.ticket = ticket;
    }

    // 현금을 증가시키거나 감소시킨다.
    public void minusAmount(Long amount) {
        this.amount -= amount;
    }

    public void plusAmount(Long amount) {
        this.amount += amount;
    }
}

이벤트에 당첨된 관람객의 가방 안에는 현금, 초대장이 들어있지만 당첨되지 않은 관람객의 가방 안에는 초대장이 없을 것이다.

따라서 Bag 인스턴스의 상태는 1. 현금과 초대장을 함께 보관하거나, 2. 초대장 없이 현금만 보관하는 두 가지 중 하나일 것이다. Bag 인스턴스를 생성하는 시점에 이 제약을 강제해보자.

Bag.java
public Bag(Invitation invitation, long amount) {
    this.invitation = invitation;
    this.amount = amount;
}

public boolean hasInvitation() {
    return invitation != null;
}

다음은 관람객 개념을 구현해보자. 관람객은 소지품을 보관하기 위해 가방을 소지할 수 있다.

Audience.java
public class Audience {
    private Bag bag;

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

    public Bag getBag() {
        return bag;
    }
}

관람객이 소극장에 입장하기 위해서는 매표소에서 초대장을 티켓으로 교환하거나 구매해야 한다. 따라서 매표소에는 관람객에게 판매할 티켓티켓의 판매 금액이 보관돼 있어야 한다. 매표소를 구현하기 위해 TicketOffice 클래스를 추가한다.

TicketOffice.java
public class TicketOffice { // 매표소
    private Long amount; // 판매 금액
    private List<Ticket> tickets = new ArrayList<>(); // 판매하거나 교환해 줄 티켓의 목록

    public TicketOffice(Long amount, Ticket... tickets) {
        this.amount = amount;
        this.tickets.addAll(Arrays.asList(tickets));
    }

    public Ticket getTicket() { // 편의를 위해 컬렉션의 맨 첫 번째 위치에 저장된 ticket을 반환
        return tickets.remove(0);
    }

    // 판매 금액을 더하거나 차
    public void minusAmount(Long amount) {
        this.amount -= amount;
    }

    public void plusAmount(Long amount) {
        this.amount += amount;
    }
}

판매원은 매표소에서 초대장을 티켓으로 교환해 주거나, 티켓을 판매하는 역할을 수행한다. TicketSeller는 자신이 일하는 매표소(ticketOffice)를 알고 있어야 한다.

TicketSeller.java
public class TicketSeller {
    private TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }

    public TicketOffice getTicketOffice() {
        return ticketOffice;
    }
}

이제 모든 준비가 끝났으니 클래스를 조합해 관람객을 소극장에 입장시켜본다.

소극장을 구현하는 클래스는 Theater이다. Theater 클래스가 관람객을 맞이할 수 있도록 enter 메서드를 추가한다.

Theater.java
public class Theater {

    private TicketSeller ticketSeller;

    public Theater(TicketSeller ticketSeller) {
        this.ticketSeller = ticketSeller;
    }

    public void enter(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);
        }
    }
}

절차는 다음과 같다.

  1. 소극장은 먼저 관람객의 가방 안에 초대장이 들어 있는지 확인한다.

  2. 초대장이 들어 있다면 이벤트에 당첨된 관람객이므로 판매원에게서 받은 티켓을 관람객의 가방에 넣어준다.

  3. 가방 안에 초대장이 없다면 티켓을 판매해야 한다. 이 경우 관람객의 가방에서 티켓 금액만큼을 차감한 후 매표소에 금액을 증가시킨다.

  4. 마지막으로 소극장은 관람객의 가방 안에 티켓을 넣어줌으로써 관람객의 입장 절차를 끝낸다.

안타깝지만 (강조를 했으므로 눈치 챘을지도 모르지만) 이 프로그램은 몇 가지 문제점을 가지고 있다.

02. 무엇이 문제인가

"밥아저씨" 로버트 마틴(Robert. C. Martin)은 저서 <클린 소프트웨어: 애자일 원칙과 패턴, 그리고 실천 방법>에서 소프트웨어 모듈이 가져야 하는 세 가지 기능에 대해서 설명한다.

여기서 모듈이란, 크기와 상관 없이 클래스나 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소를 의미한다.

모든 소프트웨어 모듈에는 세 가지 목적이 있다. 첫 번째 목적은 실행 중에 제대로 동작하는 것이다. 이것은 모든 모듈의 존재 이유라고 할 수 있다.

두 번째 목적은 변경을 위해 존재하는 것이다. 대부분의 모듈은 생명주기 동안 변경되기 때문에 간단한 작업만으로도 변경이 가능해야 한다. 변경하기 어려운 모듈은 제대로 동작하더라도 개선해야 한다.

모듈의 세 번째 목적은 코드를 읽는 사람과 의사소통하는 것이다. 모듈은 특별한 훈련 없이도 개발자가 쉽게 읽고 이해할 수 있어야 한다. 읽는 사람과 의사소통할 수 없는 모듈은 개선해야 한다.

마틴에 의하면 모든 모듈은 제대로 실행돼야 하고, 변경에 용이해야 하고, 이해하기 쉬워야 한다.

지금 우리가 작성한 프로그램은 관람객을 입장시키는 데 필요한 기능을 오류 없이 정확하게 수행하고 있다. 따라서 제대로 동작해야 한다는 첫 번째 목적을 만족시킨다.

하지만 변경 용이성과 읽는 사람의 의사소통이라는 목적은 만족시키지 못한다. 그 이유를 알아보자.

예상을 빗나가는 코드

Theater 클래스의 enter 메서드가 수행하는 작업의 절차를 다시 한 번 살펴보자.

  • 소극장은 먼저 관람객의 가방 안에 초대장이 들어 있는지 확인한다.

  • 초대장이 들어 있다면 이벤트에 당첨된 관람객이므로 판매원에게서 받은 티켓을 관람객의 가방에 넣어준다.

  • 가방 안에 초대장이 없다면 티켓을 판매해야 한다. 이 경우 관람객의 가방에서 티켓 금액만큼을 차감한 후 매표소에 금액을 증가시킨다.

  • 마지막으로 소극장은 관람객의 가방 안에 티켓을 넣어줌으로써 관람객의 입장 절차를 끝낸다.

문제는 관람객과 판매원이 소극장의 통제를 받는 수동적인 존재라는 점이다.

  • 관람객의 입장에서 살펴보자. 소극장이라는 제3자가 초대장을 확인하기 위해 관람객의 가방을 마음대로 열어본다.

  • 판매원의 입장에서도 마찬가지이다. 소극장이 허락도 없이 매표소에 보관 중인 티켓과 현금에 마음대로 접근한다. 게다가 티켓을 꺼내 관람객의 가방에 집어넣고 관람객에게서 받은 돈을 매표소에 적립한다. 이것을 판매원이 아닌 소극장이 수행한다.

이해 가능한 코드란, 동작이 우리의 예상에서 크게 벗어나지 않는 코드이다. 위에서 살펴본 코드는 우리의 예상을 벗어난다.

현실에서는:

  • 관람객이 직접 가방에서 초대장을 꺼내 판매원에게 건네고,

  • 티켓을 구매하는 관람객은 가방에서 돈을 직접 꺼내 판매원에게 지불한다.

  • 판매원은 매표소에 있는 티켓을 직접 꺼내 관람객에게 건네고 관람객에게서 직접 돈을 받아 매표소에 보관한다.

코드를 이해하기 어려운 이유가 또 있다. 이 코드를 이해하기 위해서는 여러 가지 세부적인 내용을 한꺼번에 기억하고 있어야 한다는 점이다. Theaterenter()를 이해하려면 AudienceBag을 가지고 있고, Bag 안에는 현금과 티켓이 있으며, TicketSellerTicketOffice에서 티켓을 판매하고, TicketOffice 안에 돈과 티켓이 보관돼 있어야 한다는 사실을 동시에 기억하고 있어야 한다. 이 코드는 하나의 클래스나 메서드에서 너무 많은 세부사항을 다루고 있기 때문에, 코드를 작성하는 사람과 읽는 사람 모두 큰 부담을 준다.

제일 큰 문제는 따로 있다. AudienceTicketSeller를 변경할 경우 Theater도 함께 변경해야 한다는 사실이다.

변경에 취약한 코드

가장 큰 문제는 변경에 취약하다는 점이다. 이 코드는:

  • 관람객이 현금, 초대장을 보관하기 위해 항상 가방을 들고 다닌다고 가정한다.

    • 관람객이 가방을 들고 다니지 않는다면 어떻게 해야 할까?

    • 관람객이 현금이 아니라 신용카드를 이용해서 결제한다면 어떻게 해야 할까?

  • 판매원이 매표소에서만 티켓을 판매한다고 가정한다.

    • 판매원이 매표소 밖에서 티켓을 판매해야 한다면 어떻게 해야 할까?

이러한 가정이 깨지는 순간, 모든 코드가 일시에 흔들리게 된다.

관람객이 가방을 들고 있다는 가정이 바뀐다고 가정하면, Audience 클래스에서 1. Bag을 제거해야 할 뿐만 아니라, AudienceBag에 직접 접근하는 2. Theaterenter() 메서드도 수정해야 한다. Theater은 관람객이 가방을 들고 있고, 판매원이 매표소에서만 티켓을 판매한다는 지나치게 세부적인 사실에 의존하고 있다. 이러한 세부 사실 중 하나라도 바뀌면 해당 클래스뿐 아니라 이 클래스에 의존하는 Theater도 변경해야 한다. 이처럼 다른 클래스가 Audience의 내부에 대해 더 많이 알면 알수록 Audience를 변경하기 어려워진다.

이것은 객체 사이의 의존성(dependency)과 관련된 문제다. 문제는 의존성이 변경과 관련돼 있다는 점이다. 의존성은 변경에 대한 영향을 암시하고, 의존성이라는 말 속에는 어떤 객체가 변경될 때 그 객체에 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포돼 있다.

그렇다고 해서 객체 사이의 의존성을 완전히 없애는 것이 정답은 아니다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구성하는 것이다.

따라서 우리의 목표는 애플리케이션을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것이다.

객체 사이의 의존성이 과한 경우를 가리켜 결합도(coupling)가 높다고 말한다. 반대로 객체들이 합리적인 수준으로 의존할 때는 결합도가 낮다고 한다. 결합도는 의존성과 관련돼 있으므로 결합도도 변경과 관련이 있다. 두 객체의 결합도가 높을수록 함께 변경 확률이 높아지므로, 변경하기 어려워진다.

따라서 설계의 목표는 결합도를 낮춰 변경이 용이한 설계를 만드는 것이어야 한다.

이번 챕터를 정리해보자. 우리는 이번에 관람객, 판매원, 매표소 등이 있는 Theater 애플리케이션을 만들었다.

현재의 코드는 다음 이유 때문에 문제가 있다.

  • 우리의 상식과는 너무나도 다르게 동작하기 때문에 코드를 읽는 사람과 제대로 의사소통하지 못한다.

  • 코드에 대한 여러 가지 세부 사항을 기억해야 코드를 작성하고 이해할 수 있다.

  • 변경에 취약하다. (의존성 문제)

"변경에 취약하다"는 것은, 프로그램이 지나치게 세부적인 사실에 의존하고 있기 때문에, 가정이 깨지면 모든 코드가 깨진다는 것을 뜻한다.

현재 우리의 애플리케이션은 객체 간의 "의존성"이 높다. 의존성이 높으면 어떤 객체가 변경될 때 다른 객체도 변경될 수 있어 변경이 어렵다.

의존성이 높으면 "결합도"가 높다고 하는데, 두 객체의 결합도가 높으면 함께 변경될 확률이 높아지므로 변경하기 어려워진다.

따라서 우리의 목표는 애플리케이션을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하고, 그 결과 결합도를 낮춰 변경에 용이한 설계를 해야 한다.

03. 설계 개선하기

예제 코드는 로버트 마틴이 이야기한 세 가지 목적 중에 제대로 동작한다는 한 가지만 만족시키고 있다.

여기서 변경과 의사소통이라는 문제는 서로 엮여 있다. 코드를 이해하기 어려운 이유는 Theater가 관람객의 가방과 판매원의 매표소에 직접 접근하고 있기 때문에 우리의 직관에서 벗어나기 때문이다.

해결 방법은 간단하다. TheaterAudienceTicketSeller에 관해 너무 세세한 부분까지 알지 못하도록 정보를 차단하면 된다. Theater가 원하는 것은 관람객이 소극장에 입장하는 것 뿐이다. 따라서 관람객이 스스로 가방 안의 현금과 초대장을 처리하고, 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 한다면 이 문제를 해결할 수 있다.

다시 말해, 관람객과 판매원을 자율적인 존재로 만들면 되는 것이다.

자율성을 높이자

기존 Theaterenter()의 로직을 TicketSellersellTo()로 옮긴다.

Theater.java
public class Theater {
    // ... 수정된 Theater 클래스 어디에서도 ticketOffice에 접근하지 않는다는 사실에 주목.
    public void enter(Audience audience) {
        ticketSeller.sellTo(audience); // enter 메서드는 훨씬 간단해졌다.
    }
}
TicketSeller.java
public class TicketSeller {

    private TicketOffice ticketOffice;
    // ... getTicketOffice()는 더 이상 필요 없다.
    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);
        }
    }
}

TicketSeller에서 더 이상 getTicketOffice() 메서드가 호출되지 않는다. TicketSeller의 외부에서는 privateticketOffice에 직접 접근할 수 없다. 결과적으로 ticketOffice에 대한 접근은 오직 TicketSeller 안에만 존재하게 된다. 따라서 TicketSellerticketOffice에서 티켓을 꺼내거나 판매 요금을 적립하는 일을 스스로 수행할 수밖에 없다.

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

즉... 캡슐화를 통해 세부사항을 감추면 결합도를 낮출 수 있어 변경하기 쉬운 설계가 된다.

Theater는 오직 TicketSeller인터페이스(interface)에만 의존한다. TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현(implementation)의 영역에 속한다. 객체를 인터페이스와 구현으로 나누고 인터페이스만 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작서앟기 위해 따라야 하는 가장 기본적인 설계 원칙이다.

다음 개선해볼 것은 Audience의 캡슐화이다. TicketSellerAudiencegetBag() 메서드를 호출해 Audience 내부의 Bag 인스턴스에 직접 접근한다. 지금까지는 Bag 인스턴스에 접근하는 객체가 Theater에서 TicketSeller로 바뀌었을 뿐 여전히 Audience는 자율적인 존재가 아니다.

TicketSeller.java
public class TicketSeller {
    // ...
    public void sellTo(Audience audience) {
        // 기존 sellTo()에서 티켓을 구입하는 로직이 buy()로 옮겨졌다.
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }
}
Audience.java
public class Audience {

    private Bag bag;
    // ... getBag()이 없어졌다.
    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는 자신의 가방 안에 초대장이 들어있는지 스스로 확인한다. AudienceBag을 직접 처리하므로 외부에서는 더 이상 AudienceBag을 소유하고 있다는 사실을 알 필요가 없다. 따라서 getBag() 메서드를 제거할 수 있고, Bag의 존재를 내부로 캡슐화할 수 있다.

또한 TicketSellerAudience의 인터페이스에만 의존하도록 수정하였다. TicketSellerbuy() 메서드를 호출한다.

결과적으로 TicketSellerAudience 사이의 결합도가 낮아졌다. 또한 내부 구현이 캡슐화되어 Audience의 구현을 수정하더라도 TicketSeller에는 영향을 미치지 않는다.

모든 수정을 거쳐 가장 크게 달라진 점은 AudienceTicketSeller가 내부 구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결한다는 것이다.

무엇이 개선됐는가

개선된 코드는 로버트 마틴의 목적을 모두 만족시킬까?

  • 동작을 정확히 수행한다.

  • AudienceTicketSeller는 소지품을 스스로 관리한다. 코드를 읽는 사람과의 의사소통이라는 관점에서 이 코드는 확실히 개선되었다.

  • 변경을 하더라도 AudienceTicketSeller 내부로 제한되므로 변경 용이성 측면에서도 개선되었다.

어떻게 한 것인가

판매자가 티켓을 판매하기 위해 TicketSeller를 사용하는 모든 부분을 TicketSeller 내부로 옮기고, 관람객이 티켓을 구매하기 위해 Bag을 사용하는 모든 부분을 Audience 내부로 옮긴 것이다.

우리의 직관에 따라 코드를 작성하고 이해하기 위해서는 자기 자신의 문제를 스스로 해결하도록 하는 것이 좋겠다! 덕분에 코드는 변경이 용이하고 이해가 가능하도록 수정됐다.

우리는 객체의 자율성을 높이는 방향으로 설계를 개선했다. 그 결과, 이해하기 쉽고 유연한 설계를 얻을 수 있었다.

캡슐화와 응집도

핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다. Theater는 TicketSeller 내부에 대해선 전혀 알 필요가 없고 TicketSllersellTo() 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐이다. TicketSeller 역시 Audience 내부에 대해서 알 필요가 없으며 Audiencebuy() 메시지에 응답할 수 있고 원하는 결과를 반환할 것이라는 사실만 알고 있을 뿐이다.

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

절차지향과 객체지향

수정하기 전의 코드에서는 enter() 메서드에서 관람객을 입장시키는 절차를 구현했다. 다른 클래스는 필요한 정보를 제공하고 모든 처리는 Theaterenter() 메서드에 존재했다는 점에 주목하라. 이 관점에서 Theaterenter() 메서드는 프로세스(Process)이며 Audience, TicketSeller, Bag, TicketOffice데이터(Data)다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식절차적 프로그래밍(Procedural Programming)이라고 부른다.

절차적 프로그래밍은 우리의 직관에 위배된다. 절차적 프로그래밍의 세상은 우리의 예상을 쉽게 벗어나기 때문에 코드를 읽는 사람과 원활하게 의사소통하지 못한다. 또한, 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다. 한 클래스의 내부 구현의 변경이 다른 구현에 영향을 미친다. 변경은 버그를 부르고 버그에 대한 두려움은 코드를 변경하기 어렵게 만든다. 따라서 절차적 프로그래밍의 세상은 변경하기 어려운 코드를 양산하는 경향이 있다.

변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 해결 방법은 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 AudienceTicketSeller로 이동하는 것이다. 수정 후에는 데이터를 사용하는 프로세스가 데이터를 소유하고 있는 AudienceTicketSeller로 옮겨졌다. 이처럼 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식객체지향 프로그래밍(Object-Oriented Programming)이라고 부른다.

훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다. 일반적으로 객체지향이 절차지향에 비해 변경에 좀 더 유연하다고 하는 이유가 바로 이것이다.

책임의 이동

두 방식 사이에 근본적 차이를 만드는 것은 책임의 이동(Shift of responsibility)이다.

  • '책임'이란 기능을 가리키는 객체지향 세계의 용어로 생각해도 무방하다.

절차적 프로그래밍에서는 책임이 Theater에 집중돼 있다. 반면 객체지향 프로그래밍에서는 하나의 기능을 완성하는 데 필요한 책임이 여러 객체에 걸쳐 분산돼 있다.

우리의 코드에서 데이터와 데이터를 사용하는 프로세스가 별도의 객체에 위치하고 있다면 절차적 프로그래밍 방식을 따르고 있을 확률이 높다.

데이터와 데이터를 사용하는 프로세스가 동일한 객체에 있다면 객체지향 프로그래밍 방식을 따르고 있을 확률이 높다.

더 개선할 수 있다

사실 더 개선의 여지가 있다. Audience 클래스를 보자.

Audience.java
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는 자율적인 존재이지만, Bag은 어떤가? Bag을 자율적인 존재로 바꿔보자.

Bag.java
public class Bag {

    private Long amount;
    private Invitation invitation;
    private Ticket ticket;

    public Long hold(Ticket ticket) {
        if (hasInvitation()) {
            setTicket(ticket);
            return 0L;
        } else {
            setTicket(ticket);
            minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }

    private boolean hasInvitation() {
        return invitation != null;
    }

    private void setTicket(Ticket ticket) {
        this.ticket = ticket;
    }

    private void minusAmount(Long amount) {
        this.amount -= amount;
    }
}

이제 Bag은 관련된 상태와 행위를 함께 가지는 응집도 높은 클래스가 되었다. hasInvitation(), setTicket(), minusAmount() 메서드는 내부에서만 사용되므로 private으로 가시성을 변경한다. 그대로 냅두는 이유는 중복을 제거하고 표현력을 높이기 위해서다.

Audience.java
public class Audience {

    private Bag bag;
    // ...
    public Long buy(Ticket ticket) {
        return bag.hold(ticket); // Audience도 Bag의 구현이 아닌 인터페이스에 의존하도록 한다.
    }
}
TicketOffice.java
public class TicketOffice {
    // ...
    public void sellTicketTo(Audience audience) { // sellTicketTo 메서드 생성
        plusAmount(audience.buy(getTicket()));
    }

    private Ticket getTicket() { // private으로 변경
        return tickets.remove(0);
    }

    private void plusAmount(Long amount) { // private으로 변경
        this.amount += amount;
    }
}
TicketSeller.java
public class TicketSeller {
    // ...
    public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience); // TicketOffice의 인터페이스에 의존하도록 함
    }
}

그런데 이 변경은 아까만큼 만족스럽진 않다. TicketOfficeAudience 사이에 의존성이 추가됐다. 변경 전에는 TickeOfficeAudience에 대해 알지 못했지만 이제는 직접 판매하게 되기 때문에 알아야 한다.

변경 전에는 존재하지 않았던 새로운 의존성이 추가된 것이다. TicketOffice의 자율성은 높였지만 전체 설계의 관점에서는 결합도가 상승해, 변경하기 어려운 설계가 되었다는 뜻이다.

현재로서는 Audience에 대한 결합도와 TicketOffice의 자율성 모두 만족시키는 방법이 잘 떠오르지 않는다. 트레이드오프의 시점이다. 토론 끝에 개발팀은 TicketOffice의 자율성보다는 Audience에 대한 결합도를 낮추는 것이 더 중요하다는 결론에 도달했다.

두 가지 사실을 알 수 있다.

  1. 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다.

  2. 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 설계는 트레이드오프의 산물이다.

어떤 경우에도 모든 사람들을 만족시킬 수 있는 설계를 만들 수는 없다.

그래, 거짓말이다!

사실 Theater, Bag, TicketOffice는 실세계에서 자율적인 존재가 아니다. 비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다. 레베카 워프스브록(Rebecca Wirfs-Brock)은 이처럼 능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 가리켜 의인화(anthromorphism)이라고 부른다.

04. 객체지향 설계

설계가 왜 필요한가

좋은 설계는 무엇인가? 우리가 짜는 프로그램은 두 가지 요구사항을 만족시켜야 한다.

오늘 완성해야 하는 기능을 구현하는 코드를 짜는 동시에 내일 쉽게 변경할 수 있는 코드를 짜야 한다.

변경을 수용할 수 있는 설계가 중요한 이유는 다음과 같다.

  1. 개발이 진행되는 동안 요구사항은 항상 변경된다.

  2. 코드를 변경할 때 버그가 추가될 가능성이 높다.

객체지향 설계

우리가 진정으로 원하는 것은 변경에 유연하게 대응할 수 있는 코드다. 객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 수월하게 대처할 수 있도록 한다.

  • 변경 가능한 코드는 이해하기 쉬운 코드다. 우리가 예상하는 방식대로 객체가 동작하면 코드는 이해하기 쉽다.

  • 데이터와 프로세스를 단순히 객체 안에 밀어 넣는다고 해서 변경하기 쉬운 설계가 되는 것은 아니다.

  • 훌륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절히 관리하는 설계다.

1장 끝

정리를 너무 열심히 해서 손가락이 아프다... 다음 장부턴 필요한 정보를 일일히 옮겨 적는 대신, 머릿속에서 정리를 거쳐서 적고, 그 정성을 코드에 많이 할당하려고 한다.

Last updated