devseop08 님의 블로그

[오브젝트] 객체, 설계 본문

Architecture/객체지향설계

[오브젝트] 객체, 설계

devseop08 2025. 6. 6. 16:41

티켓 판매 애플리케이션 구현

실무가 우선

  • 실무적으로 기능이 돌아가도록 구현시킨 후 문제점을 찾아 해결하는 과정을 거쳐보자
  • 이론이 먼저 앞서기보단 실무에서의 코드를 개선해나가면서 좋은 설계에 대해 생각하고 알아갈 수 있다.
  • 설계 이론과 개념에 앞서 간단한 티켓 판매 프로그램을 구현해보는 것으로 시작하자

티켓 판매 시나리오

  • 소극장의 입장 방식은 두 가지로 나뉜다.
    • 이벤트에 당첨되어 공연 무료 관람 티켓을 가진 경우
    • 공연 무료 관람 티켓이 없는 경우
  • 이벤트애 당첨되어 무료 관람 티켓을 가진 사람은 초대장을 티켓으로 교환한 후 입장
  • 이벤트에 당첨되지 않아 초대장이 없는 사람은 티켓을 현금을 주고 구매해야 입장 가능
  • 관람객을 입장시키기 전에 이벤트 당첨 여부를 확인해야 하고
    이벤트 당첨자가 아닌 경우엔 티켓을 판매한 후 입장시켜야 한다.
  • 먼저 이벤트 당첨자에게 발송되는 초대장을 코드로 구현하는 것으로 시작
public class Invitation {
    private LocalDateTime when;
}
  • 공연을 관람하기 원하는 모든 사람들은 티켓을 소지하고 있어야만 한다.
public class Ticket {
    private Long fee;

    public Long getFee() {
        return fee;
    }
}
  • 관람객이 가져올 수 있는 소지품은 초대장, 현금, 티켓 세가지뿐이다.
  • 관람객은 소지품을 보관할 용도로 가방을 들고 올 수 있다.
  • 관람객이 소지품을 보관할 Bag 클래스
    • 초대장, 티켓, 현금을 인스턴스 변수로 포함한다.
    • 초대장의 보유 여부를 판단하는 hasInvitaion 메서드와 티켓의 소유 여부를 판단하는 hasTicket 메서드, 현금을 증가시키거나 감소시키는 plusAmount와 minusAmount 메서드, 초대장을 티켓으로 교환하는 setTicket 메서드를 구현한다.
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 인스턴스의 상태는 현금과 초대장을 함께 보관하거, 초대장 없이 현금만 보관하는 두 가지 중 하나일 것이다. Bag 인스턴스를 생성하는 시점에 제약을 강제할 수 있도록 생성자를 추가하자
public class Bag {
    public Bag(long amount){
        this(null,amount);
    }

    public Bag(Invitation invitation, long amount){
        this.invitation = invitation;
        this.amount = amount;    
    }
}
  • 관람객은 소지품을 보관하기 위해 가방을 소지할 수 있다.
public class Audience {
    private Bag bag;

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

    public Bag getBag(){
        return bag;
    }
}
  • 관람객이 소극장에 입장하기 위해서는 매표소에서 초대장을 티켓으로 교환하거나 구매해야 한다.
  • 매표소에는 관람객에 판매할 티켓과 티켓의 판매 금액이 보관돼 있어야 한다.
  • 매표소 구현을 위한 TicketOffice 클래스는 판매하거나 교환해 줄 티켓의 목록(tickets)과 판매 금액(amount)을 인스턴스 변수로 포함
  • 티켓을 판매하는 getTicket 메서드는 편의상 tickets 컬렉션에서 맨 첫 번째 위치에 저장된 Ticket을 반환하는 것으로 구현
  • 판매 금액을 더하거나 차감하는 plusAmount와 minusAmount 메서드도 구현
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;
    }
}
  • 판매원은 매표소에서 초대장을 티켓으로 교환해 주거나 티켓을 판매하는 역할을 수행
  • 판매원을 구현한 TicketSeller 클래스는 자신이 일하는 매표소(ticketOffice)를 알고 있어야 한다.
public class TicketSeller {
    private TicketOffice ticketOffice;

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

    public TicketOffice getTicketOffice(){
        return ticketOffice;
    }
}

  • 클래스들을 조합해서 관람객을 소극장에 입장시키는 로직을 구현해보자
  • 소극장을 구현하는 클래스 Theater에 관람객을 맞이하는 enter 메서드를 구현하자
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);
        }
    }
}
  • 작성된 프로그램의 로직은 간단하고 예상대로 동작한다. 하지만 이 작은 프로그램에는 몇 가지 문제점을 가지고 있다.

무엇이 문제인가

  • 로버트 마틴은 소프트웨어 모듈이 가져야 하는 세 가직 기능에 관해 설명했다.
  • 모든 모듈은 제대로 실행돼야 하고, 변경이 용이해야 하며, 이해하기가 쉬워야 한다.
  • 현재 티켓 판매 프로그램은 제대로 동작은 하지만 변경 용이성과 읽는 사람과의 의사소통이라는 목적은 만족시키지 못한다.

예상을 빗나가는 코드

  • 현재 티켓 판매 프로그램에선 소극장이 관람객의 가방을 열어 그 안에 초대장이 들어있는지 확인하고, 초대장이 들어있으면 판매원이 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮기고 있다
  • 가방 안에 초대장이 들어 있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내 매표소에 적립한 후에 매표소에 보관돼있는 티켓을 관람객의 가방 안으로 옮기고 있는 형국이다.
  • 문제는 관람객과 판매원이 소극자의 통제를 받는 수동적인 존재라는 것이다.
  • 가방의 주인인 관람객이 아닌 소극장이라는 제 3자가 관람객의 가방을 열어본다는 것은 상식적이지 않다.
  • 판매원이 아닌 소극장이 매표소의 티켓과 현금을 마음대로 건들일 수 있다는 거도 비상식적이다.
  • 이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다.
  • 현재 코드는 우리의 상식과는 너무나도 다르게 동작하기 때문에 코드를 읽는 사람과 의사소통하기 어렵다.
  • 더 문제가 되는 것은 하나의 클래스에서 너무 많은 세부사항을 다루기 때문에 코드를 작성하는 사람뿐만 아니라 코드를 읽고 이해해야 하는 사람 모두에게 큰 부담을 준다

변경에 취약한 코드

  • 더 큰 문제는 변경에 취약하다는 것이다.
  • 현재 코드는 관람객이 현금과 초대장을 보관하기 위해 항상 가방을 들고 다닌다고 가정한다.
  • 또한 판매원이 매표소에서만 티켓을 판매한다고 가정한다.
  • 관람객이 가방을 들고 있다는 가정이 바뀌었다고 상상해보자.
  • Audience 클래스에서 Bag을 제거해야 할 뿐만 아니라 Audience의 Bag에 직접 접근하는 Theater의 enter 메서드 역시 수정해야 한다.
  • 현재 Theater 클래스는 지나치게 세부적인 사실에 의존해서 동작하고 있다. 그렇기 때문에 세부적인 사실 중 한 가지라도 변경되면 해당 클래스뿐만 아니라 이 클래스에 의존하는 Theater도 함께 변경해야 한다.
  • 이것은 객체 사이의 의존성(dependency)과 관련된 문제다.
  • 문제는 의존성이 변경과 관련돼 있다는 것이다.
  • 의존성은 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포된다.
  • 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거해야 한다.

  • Theater 클래스가 너무 많은 클래스에 의존하고 있다.
  • 객체 사이의 의존성이 과한 경우를 가리켜 결합도(coupling)가 높다고 말한다.
  • 설계 개선하기
  • 현재 코드는 기능은 제대로 수행하지만 이해하기 어렵고 변경하기가 쉽지 않다.
  • 변경과 의사 소통이라는 문제가 서로 엮여 있다는 점에 주목하자
  • Theater가 Audience와 TicketSeller에 관해 너무 세세한 부분까지 알지 못하도록 정보를 차단하면된다.
  • 결국엔 관람객과 판매원을 자율적인 존재로 만들면 되는 것이다.

자율성을 높이자

  • Theater의 enter 메서드에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 클래스 내부로 숨긴다.
  • TicketSeller에 sellTo 메서드를 추가하고 Theater에 있던 로직을 이 메서드에 옮기자.
public class TicketSeller {
    private TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice){
        athis.ticketOffice = 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이고 접근 가능한 퍼블릭 메서드가 더 이상 존재하지 않기 때문에 외부에서는 ticketOffice에 직접 접근할 수 없다.
  • 결과적으로 ticketOffice에 대한 접근은 오직 TicketSeller 안에만 존재하게 되기 때문에 TicketSeller 객체는 ticketOffice에서 티켓을 꺼내거나 판매 요금을 적립하는 일을 스스로 수행할 수 밖에 없다.
  • 이처럼 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화라고 부른다.
  • 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것이다.
  • 캡슐화를 통해 객체 내부로의 접근 제한 -> 객체와 객체 간의 결합도 낮춤 -> 설계 변경 용이
  • 변경된 Theater 클래스
public class Theater {
    private TicketSeller ticketSeller;

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

    public void enter(Audience audience) {
        ticketSeller.sellTo(audience);
    }
}
  • Theater는 ticketOffice가 TicketSeller 내부에 존재한다는 사실을 알지 못한다.
  • 단지 ticketSeller가 sellTo 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐이다.
  • Theater는 오직 TicketSeller의 인터페이스에만 의존하게 된다.
  • TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현의 영역에 속한다.
  • 객체를 인터페이스와 구현으로 나누고, 인터페이스만을 공개 -> 객체 사이의 결함도를 낮추고 변경하기 쉬운 코드를 작성하기 위한 가장 기본적인 설계 원칙

  • Theater에서 TicketOffice로의 의존성이 제거됨을 확인할 수 있다.
  • 동일한 방법으로 TickSeller에서 Audience의 getBag 메서드를 호출해서 Audience 내부의 Bag 인스턴스에 직접 접근하는 문제를 캡슐화로 개선하자.
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는 자신의 가방 안에 초대장이 들어있는지를 가방의 주인인 자신이 스스로 확인한다.
  • 외부에서는 더 이상 Audience가 Bag을 소유하고 있다는 사실을 알 필요가 없다.
  • Bag의 존재를 내부로 캡슐화한 것이다.
  • TicketSeller가 Audience의 인터페이스에만 의존하도록 수정
public class TicketSeller {
    private TicketOffice ticketOffice;

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

    public void sellTo(Audience audience){
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()))
    }
}
  • TicketSeller와 Audience 사이의 결합도가 낮아졌다.
  • 자율적인 Audience와 TicketSeller

무엇이 개선됐는가

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

어떻게 한 것인가

  • 객체의 자율성을 높이는 방향으로 설계를 개선했다.

캡슐화와 응집도

  • 핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
  • 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도가 높다고 말한다.
  • 객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다.
  • 외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길인 것이다.

절차지향과 객체지향

  • 제일 처음 만든 프로그램에서 Theater 클래스의 enter 메서드는 프로세스이며 Audience, TicketSeller, Bag, TicketOffice는 데이터다.
  • 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부른다.
  • 일반적으로 절차적 프로그래밍은 우리의 직관에 위배된다.
  • 더 큰 문제는 절차적 프로그래밍에서는 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것이다.
  • 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다.
  • 해결 방법은 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 적절한 객체로 이동시키는 것이다.
  • 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부른다.
  • 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.
  • 책임의 이동
  • 절차지향 프로그래밍 방식과 객체지향 프로그래밍 방식 간의 근본적인 차이를 만드는 것은 책임의 이동이다.
  • 책임은 기능을 가리키는 객체지향 세계의 용어
  • 책임이 중앙집중된 절차적 프로그래밍

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

  • 변경 전엔 Theater에 몰려있던 책임이 개별 객체로 이동: 책임의 이동
  • 객체지향 설계에서는 독재자 존재하지 않는다
  • 각 객체에 책임이 적절하게 분배된다. 각 객체는 자신을 스스로 책임진다.
  • 객체지향 어플리케이션은 스스로 책임을 수행하는 자율적인 객체들의 공동체를 구성함으로써 완성된다.
  • 객체지향 설계의 핵심은 적절한 객체에 적절한 책임을 할당하는 것이다.
  • 객체가 어떤 데이터를 가지느냐보다는 객체에 어떤 책임을 할당할 것이냐에 초점을 맞춰야 한다.
  • 설계를 어렵게 만드는 것은 의존성
  • 불필요한 의존성을 제거하여 객체 사이의 결합도를 낮춰야 한다.
  • 결합도를 낮추기 위해 캡슐화를 수행
  • 캡술화하는 것은 객체의 자율성을 높이고 응집도 높은 객체들의 공동체를 창조할 수 있게 된다.
  • 불필요한 세부사항을 캡슐화하는 자율적인 객체들이 낮은 결합도높은 응집도 를 가지고 협력하도록 최소한의 의존성만을 남기는 것이 훌륭한 객체지향 설계다.
  • 더 개선할 수 있다
  • Audience는 자율적이지만 Bag은 자율적이지 못하다.
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();
        }
    }
}
  • Bag을 자율적인 존재로 바꿔보자
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 void setTicket(Ticket ticket){
        this.ticket = ticket;
    }

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

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

}
  • Audience를 Bag의 구현이 아닌 인터페이스에만 의존하도록 수정하자.
public class Audience {
    private Bag bag;

    public Long buy(Ticket ticket){
        return bag.but(ticket);
    }
}
  • TicketSeller에서 TicketOffice에게도 자율성을 부여해주자
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 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의 인터페이스에만 의존하게 됐다
public class TicketSeller {
    private TicketOffice ticketOffice;

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

    public void sellTo(Audience audience){
        ticketOffice.sellTicketTo(audience);
    }
}
  • 하지만 변경 전엔 없었던 TicketOffice와 Audience 사이의 의존성이 새로 추가되어 전체 설계의 결합도가 높아졌다.
  • TicketOffice의 자율성은 높일지 Audience에 대한 결합도를 낮출지는 트레이드 오프의 문제이다.
  • 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드 오프의 산물이다.
  • 어떤 경우에도 모두를 만족시킬 수 있는 설계를 만들 수는 없다.
  • 설계는 균형의 예술이다.
  • 그래, 거짓말이다!
  • 비록 현실에서는 수동적인 존재라고 하더라도 객체지향의 세계에서는 모든 것이 능동적이고 자율적인 존재로 변한다.
  • 이처럼 능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 가리켜 의인화라고 부른다.
  • 실세계에서는 생명이 없는 수동적인 존재라고 하더라도 객체지향의 세계에선 생명과 지능을 가진 싱싱한 존재로 다시 태어난다.

설계가 왜 필요한가

설계가 왜 필요한가

"설계란 코드를 배치하는 것이다."

  • 설계를 구현과 떨어뜨려서 이야기하는 것은 불가능하다.
  • 설계는 코드 작성의 일부이며 코드를 작성하지 않고서는 검증할 수 없다.
  • 변경을 수용할 수 있는 설계가 중요한 이유는 요구사항이 항상 변경되고 코드를 변경할 때 버그가 추가될 가능성이 높기 때문이다.
  • 객체지향 설계
  • 객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 높여준다.
  • 훌륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계다.
  • 협력하는 객체들 사이의 의존성을 적절하게 조절함으로써 변경에 용이한 설계를 만들어야 한다.