Study/오브젝트

[오브젝트01] 객체, 설계

voider 2021. 3. 30. 01:49

프로그래밍 패러다임

패러다임이란, 한 시대의 사회 전체가 공유하는 이론이나 방법, 문제 의식 등의 체계를 말한다. 프로그래밍에 패러다임이 필요한 이유는, 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 하므로 불필요한 의견 충돌을 방지하기 때문이다.

티켓 판매 애플리케이션

추첨을 통해 관객들에게 무료 초대장을 발송했다. 당첨되지 않은 관객과 당첨된 관객을 구분해서 당첨되지 않은 관객은 티켓을 구매해야만 입장할 수 있는 프로그램을 만들 것이다.

첫 번째는 초대장을 구현하는 것이다.

 public class Invatation{
     private LocalDateTime when; //초대 일자
 }

그리고 티켓 클래스

 public class Ticket {
     private Long fee;

     public Long getFee() {
         return fee;
     }
 }

이제 관객이 초대장 또는 티켓을 살 돈을 가지고 온 Bag 클래스를 만들 것이다. Bag 클래스는 티켓(ticket)과 초대장(invitation) 그리고 현금(amount)를 인스턴스 변수로 포함한다. 또한 초대장의 보유 여부를 판단하는 hasInvitation(), 티켓 소유 여부를 판단하는 hasTicket() 현금을 증가/감소시키는 plusAmount(), minusAmount()를 포함한다.

 public class Bag {
     private Long amount;
     private Invitation invitation;
     private Ticket ticket;

     /*
     * Bag인스턴스는 초대장과 현금을 함께 보유하거나,
     * 초대장 없이 현금만 보관하는 두 가지 상태만 존재한다.
     * 따라서 생성자를 통해 이를 강제한다.
     **/
     public Bag(long amount) {
         this(null, amount);
     }

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

     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;
     }
 }

이제 관객을 표현하는 Audience클래스. 관객(Audience)는 가방(Bag)을 보유한다.

 public class Audience {
     private Bag bag;

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

     public Bag getBag() {
         return bag;
     }
 }

이제 매표소(TicketOffice)를 만든다. 티켓오피스는 판매하거나 교환해 줄 tickets와 판매 금액(amount)을 인스턴스 변수로 포함한다.

 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() {
         return tickets.remove(0);
     }

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

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

티켓 오피스를 운영하려면 셀러가 있어야 한다.

 public class TicketSeller {
     private TicketOffice ticketOffice;

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

     public TicketOffice getTicketOffice() {
         return ticketOffice;
     }
 }

지금까지 만든 클래스를 도식화하면 아래 표와 같다.

조영호, 오브젝트(위키북스) 13p

이제 극장을 만들자.

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

enter()메서드는 간단하다.

관객의 가방을 열어서 초대장이 있는 지 검사한다.
있다면 초대장을 받은 관객의 가방을 열고 티켓을 넣어준다.

초대장이 없는 관객이라면 티켓 셀러가 티켓 오피스에서 티켓을 하나 가져온 다음, 관객의 가방을 열고 티켓을 넣어주고 티켓 가격을 빼온 뒤, 티켓 오피스에 티켓 가격을 채워넣는다. 이 모든 일을 소극장의 명령에 의해 이루어진다.

변경

이 코드는 제대로 작동하지만 문제가 있다. 모든 모듈(프로그램을 구성하는 요소)은 잘 실행되어야 하고 변경에 용이해야 하며 이해하기 쉬워야 한다. 하지만 위 코드는 잘 실행되기만 한다.
이 모듈들의 문제는 Theater의 통제를 받아 수동적으로 동작한다는 점이다.

이해 가능한 코드란 동작이 우리의 예상을 크게 벗어나지 않는 코드다. 소극장이 티켓셀러를 이용해서 티켓오피스의 티켓을 가져오고, 관객의 가방을 멋대로 열어서 돈을 더하거나 뺀다. 실제로 이런 소극장은 존재하지 않고 위에서 설명한 내용은 예상하기도 힘든 범위다.

그리고 또 한 가지 이해하기 힘든 이유는 세부사항을 너무 많이 알아야 한다. 관객이 가방을 가지고 있고 가방에는 현금과 티켓이 들어있으며 TicketSeller가 TicketOffice에서 티켓을 판매하고 돈을 관리한다는 사실을 모두 알고 있어야만 이런 코드를 쓸 수 있다. 이건 작성한 사람은 쉽게 알겠지만 이 코드를 읽는 다른 사람 뿐만 아니라 몇 주 뒤에 나 자신까지도 코드를 이해하기 힘들게 만든다.

제일 심각한 이유는 변경이다. Audience 또는 TicketSeller를 변경할 경우 Theater도 함께 변경이 일어난다.

만약에 관객이 주머니(Pocket)에도 돈과 초대권을 챙겨올 수 있도록 요구사항을 추가한다면 어떨까? 돈(amount)이 아니라 신용카드(CreditCard) 같은 결제 수단을 추가한다면? 기존 티켓셀러에 큰 문제가 생겨 다른 셀러로 변경해야 한다면?

Theater는 Audience나 TicketSeller의 세부사항을 지나치게 많이 알고 있다. 자신 외에 다른 클래스에 대해 많이 알면 알수록 변경이 어려워진다.

이건 의존성 문제다. 의존성은 한 쪽의 변경이 다른 한 쪽에 영향을 끼칠 것이라는 암시다. 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거해야 한다.

조영호, 오브젝트(위키북스) 17p

객체 사이의 의존성이 과한 경우를 가리켜 결합도(coupling)가 높다고 말한다. 결합도가 높다는 것은 변경에 취약하다는 뜻이다. 위 다이어그램에서 볼 수 있듯이 Theater는 결합도가 높다. 이 결합도를 낮춰 변경에 유연한 설계를 해야 한다.

개선

이해하기 쉬운 코드란, 우리의 예상을 (크게)벗어나지 않는 범위 안에서 동작하는 코드라고 했다. (많은 경우) 우리는 Theater가 관객의 가방을 열고 티켓이나 돈에 직접 접근하는 것을 예상하지 않는다. Theater가 직접하지 않고 관객에게 하라고 시키기만 한다면? 지금보다 결합도를 낮출 수 있을 것이다. 다시 말해 관객과 판매원을 자율적인 객체로 만들면 된다.

Audience/TicketSeller가 직접 Bag/TicketOffice에 접근하도록 변경해야 한다.

첫 번째 단계
Theater가 하던 일을 모두 TicketSeller에게 위임한다.

enter()가 하던 모든 일을 ticketSeller가 하도록 위임했다.

TicketSeller class

public class TicketSeller {
    private 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);
        }
    }
}

인스턴스 변수 ticketOffice의 접근 제어를 private으로 변경했다. 이제 TicketOffice에 직접 접근할 수 있는 것은 TicketSeller뿐이다. TicketOffice에 대해 어떤 작업을 처리하려면 TicketSeller가 처리해야 한다. 더 이상 Theater가 그 일을 대신할 수 없다.

이처럼 객체 내부의 세부사항을 감추는 것을 캡슐화(encapsulation)라고 한다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것이다.

변경된 Theater class

public void enter(Audience audience) {
    ticketSeller.sellTo(audience);
}

이제 TheatersellTo()라고 하는 TicketSeller가 제공하는 인터페이스에만 의존한다. TicketSellerTicketOffice를 포함하고 있는 지 Theater는 모르고, 몰라야 한다. 세부사항은 감추고 인터페이스만 공개하는 것은 객체 사이의 결합도를 낮춰서 변경하기 쉬운 코드를 작성하기 위한 가장 기본적인 설계 원칙이다.

조영호, 오브젝트(위키북스) 21p

 

Theater의 결합도는 줄었지만 여전히 Audience는 수동적이다. 이제는 TicketSellerAudience를 제어하고 있다. 동일한 방법으로 Audience도 자율적인 객체로 만들어야 한다.

변경된 Audience class

public class Audience {
    private 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는 스스로 자신이 초대장이 있는 지 없는 지 확인하고,
직접 티켓을 가방에 넣는다. 외부에서는 AudienceBag을 가지고 있는 지 Pocket을 가지고 있는 지 모른다. 따라서 더 이상 getBag()을 제공할 필요가 없다.

TicketSeller class

public class TicketSeller {
    private TicketOffice ticketOffice;

    ...

    public void sellTo(Audience audience) {
        ticketOffice.plusAmount(
            audience.buy(ticketOffice.getTicket()));
    }
}

조영호, 오브젝트(위키북스) 24p

 

이제 TicketSellerAudience사이의 결합도도 낮아졌다. TicketSeller, Audience에 캡슐화를 적용한 결과, 각 객체가 내부구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결할 수 있게 되었다. 자율적인 객체가 된 것이다.

캡슐화, 응집도

자신의 문제를 스스로 해결하도록 설계를 수정해서 위에서 말한 1.제대로 동작하는 코드 2.이해하기 쉬운 코드 3.변경에 유연한 코드를 만들 수 있었다. 이 설계의 핵심은 캡슐화였다. 내부구현은 숨기고, 인터페이스만 공개해서 결합도를 줄였다. 밀접하게 연관된 작업만 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도(cohesion)가 높다고 말한다. 따라서 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도가 낮으면서 응집도가 높은 객체가 만들어진다.

절차지향, 객체지향

수정하기 전 enter()메서드를 돌아보자.

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

모든 것을 Theater가 주관한다. 이 관점에서 enter()메서드는 프로세스이며, Audience, TicketSeller, Bag, TicketOffice는 데이터다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 한다. 앞서 봤듯이 절차지향적 방식은 번경에 취약하다. 이 문제를 해결하기 위해 나온 것이 객체지향 방식이다. 객체지향 프로그래밍에서는 데이터와 프로세스가 동일한 모듈 안에 있다. 즉, 객체지향적인 코드는 자신의 문제를 스스로 해결한다.

절차지향과 객체지향의 근본적인 차이는 책임의 이동이다. 여기서 책임은 기능을 가리키는 OOP용어다.

조영호, 오브젝트(위키북스) 28p

두 다이어그램에서 확인할 수 있듯이 절차지향적 방식은 책임이 한 군 데 집중 되어 있는 방면, 객체지향 방식에서는 제어 흐름이 각 객체에 적절하게 분산되어 있다. 절차지향 설계에서 enter()메서드가 혼자서 다 처리하던 일을, 객체지향 설계에서는 여러 객체에 걸쳐 분산해서 처리하는 것이다. 이것이 책임의 이동이다. 모든 책임을 enter()가 지지 않고 TicketSeller, Audience가 나눠서 진다. 따라서 객체는 자신의 책임만을 수행할 수 있으면 된다. 객체지향 설계에서 중요한 것은 어떤 데이터를 가지느냐가 아니라 객체에 어떤 책임을 할당한 것인가 결정하는 일이다.

설계를 어렵게 만드는 것은 의존성이다. 캡슐화를 통해 결합도를 낮추고, 응집도와 자율성을 높이고, 꼭 필요한 의존성만 남기는 것이 좋은 객체지향 설계다.

개선2

Audience를 보면 Bag은 여전히 수동적이다. Bag이 해야 할 일을 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();
    }
}

개선한 Bag class

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;
    }
}

*변경된 Audience class *

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

원래 public으로 공개하던 메서드들은 모두 private으로 변경하여 캡슐화했다. 기존에 Audience가 처리해주던 일을 이제 Bag 스스로 하도록 개선했다.

TicketSellerTicketOffice의 의존성도 제거해야 한다.

변경된 TicketOffice class

public class TicketOffice {
    public void sellTicketTo(Audience audience) {
        plusAmount(audience.buy(getTicket()));
    }

    private Ticket getTicket() {
        return tickets.revmoe(0);
    }

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

*변경된 TicketSeller class *

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

그런데 여기엔 문제가 있다. TicketOfficeAudience 사이에 이전에 없던 의존성이 생겼다.

오브젝트 33p

TicketOffice의 자율성을 보장하면서 Audience와 의존성을 없앨 방법이 없다면 트레이드 오프 해야 한다. 자율성을 얻고 의존성을 추가할 것인지, 좀 수동적이지만 의존성을 제거할 것인지. 자율성보다 의존성을 없애는 것이 더 중요하다는 합의가 있었다면 다시 전으로 돌려야 한다. 이처럼 모든 것을 만족시킬 순 없다. 적절히 트레이트 오프해서 균형있는 설계를 해야 한다.

흔히 객체지향은 현실 세계를 옮겨온 프로그래밍 패러다임이라는 얘기를 많이 한다. 하지만 현실세계에서 가방이 자율적으로 돈을 꺼내거나, 티켓 판매소가 스스로 돈을 계산하는 일은 벌어지지 않는다. 하지만 객체지향 패러다임 속에서 그것들은 생명을 가진다. 지갑이든 연필이든 객체는 스스로 행동한다.

 

설계는 왜 필요한가

설계가 중요한 이유는 요구사항이 항상 변경되기 때문이다. 좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계다. 그리고 훌륭한 객체지향 설계는 협력하는 객체 사이의 의존성을 적절하게 관리해야 한다.