🎫 티켓 판매 애플리케이션

연극이나 음악회 등을 공연할 수 있는 소극장을 운영하고 있다고 생각해보자. 홍보를 위해 작은 이벤트를 준비하려고 하는데, 바로 추첨을 통해서 관람객들에게 공연을 무료로 관람할 수 있는 초대장을 뿌리는 것이다.

이때, 당연히 생각해야 하는 부분은 공연 시 이벤트에 당첨된 관람객과 당첨되지 않은 관람객을 분리해서 입장시켜야 한다는 것이다. 초대장을 소지하고 있는 관람객은 티켓으로 교환 후 입장하고, 그렇지 않은 관람객은 티켓을 구입하고 입장해야 한다.

 

먼저 초대장을 구현해보자.

import java.time.LocalDate;

public class Invitation {
    private LocalDate when;  // 초대 일자
}

초대장이라는 개념을 구현한 Invitation은 초대 일자를 인스턴스 변수로 가지고 있는 아주 간단한 클래스다.

 

그리고 공연을 관람하기 위해 입장하려면 티켓을 소지하고 있어야 한다.

public class Ticket {
    private Long fee;

    public Long getFee() {
        return fee;
    }
}

서비스 흐름 상 관람객이 가질 수 있는 소지품 정도로는 당첨 받은 초대장, 지불 수단(현금, 카드 등), 교환 받은 티켓 정도로 생각할 수 있을 것 같다.

 

따라서 이들을 보관할 용도로 작은 가방을 가지고 올 수 있다고 가정하자.

public class Bag {

    private Long amount;  // 지불 가능한 금액
    private Invitation invitation;  // 초대장
    private Ticket ticket;  // 티켓

    public Bag(Long amount) {
        this.amount = amount;
    }

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

    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 인스턴스를 생성할 수 있도록 생성자로 강제하도록 처리했다.

 

이제 관람객을 구현해보자.

public class Audience {
    
    private Bag bag;

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

    public Bag getBag() {
        return bag;
    }
}

 

이제 티켓을 판매할 매표소가 필요하다. 매표소에는 관람객에게 판매할 티켓과 티켓의 판매 금액이 보관되어 있을 것이다.

import java.util.ArrayList;
import java.util.List;

public class TicketOffice {

    private List<Ticket> tickets = new ArrayList<>();
    private Long amount;

    public TicketOffice(List<Ticket> tickets, Long amount) {
        this.tickets = tickets;
        this.amount = amount;
    }
    
    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;
    }
}

지금까지 구현한 애플리케이션의 핵심 클래스들의 구조를 나타낸 그림이다.

이제 이 클래스들을 잘 조합해서 관람객들을 맞이할 수 있는 소극장을 구현해보자.

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

        if (!audience.getBag().hasInvitation()) {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

언뜻 보기에는 일련의 과정을 아무 문제가 없이 잘 설계한 것처럼 보이지만, 지금 말도 안 되는 상황이 벌어지고 있다…


🤔 무엇이 문제인가?

모든 소프트웨어 모듈에는 3가지 목적이 있다고 한다.

  1. 실행 중에 제대로 동작하는 것
  2. 변경을 위해 존재하는 것
  3. 코드를 읽는 사람과 의사소통하는 것

하지만, 위의 코드는 제대로 동작하고는 있지만 변경에 취약하고, 코드를 읽는 사람이 혼란스러워 할 수 있다. 지금 enter() 메서드는 천천히 곱씹어 보면, “소극장”“관람객” 의 가방을 마음대로 들춰보고, “판매원” 을 조종해서 “매표소” 를 마음대로 뒤져보고 있다. 관람객과 판매원이 소극장에게 이리저리 휘둘리고 있다는 것이 문제라는 것이다.

이해 가능한 코드라는 것은 그 동작이 예상과 크게 벗어나지 않는 코드다. 하지만 위의 코드에 따르면 상상도 할 수 없는 충격적인 상황이 벌어지고 있는 것이다. 그렇기 때문에 해당 코드를 가지고는 다른 사람과 원활히 의사소통하는 것은 불가능하다.

추가로, 코드를 한 눈에 이해하기 어려운 이유가 또 존재하는데, 바로 전체 코드를 이해하기 위해서는 너무 많은 내용들을 한꺼번에 기억하고 있어야 한다는 점이다. 지금 enter() 메서드만 해도, 지금까지 만든 모든 클래스에 대한 정보나 연관 관계를 모두 기억하고 있어야 한다.

그리고 지금 무엇보다 치명적인 결점은 바로 변경에 취약하다는 것이다. 만일 AudienceTicketSeller 클래스에 변경이 일어나게 되면 Theater 클래스도 변경을 피해갈 수 없다.

 

😵‍💫 변경에 취약한 코드

관람객이 달랑 신용카드 하나만 들고 온다면 어떻게 될까? 만약 판매원이 매표소 밖에서 티켓을 판매한다면? Audience 클래스에서 Bag을 제거해야 하고, AudienceBag에 직접 접근하고 있는 Theater 클래스의 enter() 메서드까지 모두 수정해줘야 한다. 이처럼 Theater는 관람객이 가방을 들고 있고 판매원이 매표소에서만 티켓을 판매한다는 지나치게 세부적인 사실에 의존해서 동작하기 때문에 이런 사태가 발생하는 것이다. 이런 세부 사항 중 단 한 가지라도 바뀌면 해당 클래스뿐만 아니라 해당 클래스를 의존하고 있는 Theater 클래스도 함께 변경해야 하는 것이다. 이렇게 객체 사이의 의존성이 강한 경우에 결합도(Coupling)가 높다고 한다.

그렇다고 해서 이러한 객체 사이의 의존성을 아예 지워버리는 것이 정답은 아니다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것이므로, 최소한의 의존성은 유지하고 결합도를 최대한 낮춰서 변경이 용이하도록 설계하는 것이 목표다.

 

🗽 자율성 개선하기

위에서 말했다시피 AudienceTicketSellerTheater 객체가 과하게 관여하게 있는 것이 문제다. 그럼 해결 방법은? 간단하다. TheaterAudienceTicketSeller에 대해 세세하게 관여하지는 못하도록 막으면 된다. 상식적으로 생각해봐도 Theater의 임무는 그냥 관람객을 입장시킬 뿐, 다른 일에 관여할 이유가 없다. 다시 말해, AudienceTicketSeller를 자율적인 존재로 만들면 된다는 말이다.

 

기존에 설계했던 Theater 코드를 다시 살펴보자.

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

        if (!audience.getBag().hasInvitation()) {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

여기서 AudienceBag에 접근해서 초대장을 확인하는 행위와 TicketSeller로부터 Ticket을 뺏어오는 행위를 AudienceTicketSeller에게 각각 맡겨버리면 된다.

 

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

        if (!audience.getBag().hasInvitation()) {
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketOffice.plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

먼저 Theater 클래스에서 TicketOffice에 접근하는 코드를 모두 TicketSeller 클래스로 옮겼다. 여기서 getTicketOffice() 메서드가 제거됐다는 사실을 주목해야 하는데, 이제 TicketSeller를 제외한 외부에서 TicketOffice에 직접 접근할 수 있는 방법은 존재하지 않는다. 그렇기 때문에 자연스럽게 TicketOffice에서 티켓을 꺼내거나 티켓 가격만큼의 금액을 저장하는 일은 TicketSeller 밖에 할 수 없는 것이다.

 

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

public class Theater {

    private TicketSeller ticketSeller;

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

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

이제 Theater 객체는 TicketOfficeTicketSeller 내부에 존재한다는 사실도 알지 못한다. 단지 지금 TicketSellersellTo() 메서드를 통해 메시지에 대한 응답을 자신에게 줄 수 있다는 굳은 믿음만 간직하고 있는 것이다. 지금 상황을 좀 더 멋진 말로 표현하면, Theater 객체는 오직 TicketSeller 인터페이스에만 의존하고 있다고 할 수 있다. TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현(Implementation)의 영역에 해당한다. 이처럼 객체를 인터페이스와 구현으로 나누고, 인터페이스만 외부에 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 지침이다.

 

이제 Audience에도 캡슐화를 적용해보자. TicketSeller 클래스에서도 감히 관람객의 가방을 뒤지는 예의 없는 상황이 벌어지고 있다. 가방을 뒤지는 행위를 Audience 클래스 내부로 옮겨 캡슐화 해보자.

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.minusAmount(ticket.getFee());
            bag.setTicket(ticket);
            return ticket.getFee();
        }
    }
}

이제 Audience는 자신의 가방 안에 티켓이 들어있는지 아닌지를 스스로 확인한다. 더 이상 외부에서 자신의 가방을 들춰보는 것을 허락하지 않는다. 마찬가지로 이제 Audience가 아닌 그 누구도 Bag에 접근할 수도 없고, 이유도 없기 때문에 getBag() 클래스도 삭제해줬다. 이제 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()));
    }
}

TicketSellerAudience 간의 결합도를 낮추는데 성공했다. 이제 AudienceTicketSeller가 내부 구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결하는 자율적인 존재가 된 것이다.

그림을 보면 알 수 있듯이, 이제 TheaterAudienceTicketSeller의 내부에 직접적으로 접근하지 않는다. 이처럼 핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다. 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 응집도(Cohesion)가 높다고 한다. 이처럼 객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임지게 해야 한다.

 

🎏 절차지향과 객체지향

수정하기 전의 코드에서는 Theater 클래스의 enter() 메서드 안에서 AudienceTicketSeller로부터 Bag 안을 뒤지고, TicketOffice를 강제해서 관람객을 입장시키는 절차를 구현하고 있었다. 한마디로 관람객을 입장시키는 모든 처리는 Theaterenter() 메서드 안에 존재하고 있던 것이다.

이 관점에서 Theaterenter() 메서드는 프로세스(Process)이며, Audience, TicketSeller, Bag, TicketOffice데이터(Data)다. 이렇게 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍(Procedural Programming)이라고 부른다.

이렇게 절차적 프로그래밍의 세상에서는 관람객과 판매원은 수동적인 존재다. 해당 상황에 대해 다른 사람과의 의사소통도 어려울 뿐더러, 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다. 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수밖에 없다.

그래서 데이터를 사용하는 프로세스가 데이터를 소유하고 있는 AudienceTicketSeller 안으로 들어가도록 수정해준 것이다. 이렇게 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍(Object-Oriented Programming)이라고 부른다. 다른 말로, 두 방식의 차이점은 책임의 이동(Shift of Responsibility)에 있는 것이다. 코드를 개선하면서 느꼈듯이 Theater에 몰려 있던 책임이 개별 객체로 이동했다. 이게 바로 책임이 이동했다는 의미다.

다시 한번 강조하지만, 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다. 그런 차원에서 여전히 개선해야 하는 부분이 있다. Bag도 과거의 Audience처럼 Audience에게 휘둘리고 있다. 자율적인 객체라고 하기에는 분명 무리가 있어 보인다.

public class Bag {

    private Long amount;  // 지불 가능한 금액
    private Invitation invitation;  // 초대장
    private Ticket ticket;  // 티켓

    public Long hold() {
        if (hasInvitation()) {
            setTicket(ticket);
            return 0L;
        } else {
            minusAmount(ticket.getFee());
            setTicket(ticket);
            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;
    }
}

외부에 공개되었던 hasInvitation(), setTicket(), minusAmount() 이제 더 이상 외부에서 사용되지 않고 내부에서만 사용되기 때문에 접근 제어자를 private으로 바꿔줬다. 위와 같이 Bag의 구현을 캡슐화시켜줬으니, Audience도 이제는 Bag의 구현이 아닌 인터페이스에 의존하도록 수정했다.

public class Audience {

    private Bag bag;

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

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

 

근데 TicketSeller를 살펴보면, 얘도 TicketOffice의 구현에 의존하고 있다. TicketSellersellTo() 메서드의 내부 코드를 TicketOffice 내부로 옮겨줘야 한다.

public class TicketOffice {

    private List<Ticket> tickets = new ArrayList<>();
    private Long amount;

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

 

이제 TicketSellersellTicketTo() 메서드를 호출함으로써 내부 로직은 신경쓰지 않고, 요청에 해당하는 응답만 받을 수 있게 됐다.

public class TicketSeller {

    private TicketOffice ticketOffice;

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

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

하지만 이렇게 설계했을 때도 여전히 문제점이 남아 있다. 바로 TicketOfficeAudience와의 의존성이 추가된 것이다. 코드를 보면, TicketOfficeAudience에게 직접 티켓을 판매하고 있기 때문에 Audience의 내부에 대해 알고 있어야 한다. TicketOffice의 자율성은 높였지만, 전체 설계에 대해서는 결합도가 상승했다. 어떻게 해야 할까? 선택의 기로에 서 있는 것이다. Audience에 대한 결합도를 낮출 것인가, TicketOffice에게 자율성을 부여할 것인가?

이처럼 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다. 위의 고민처럼 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 Trade-Off의 산물이라고 할 수 있다. 그 어떤 경우에도 모두의 요구를 만족시킬 수 있는 설계를 할 수는 없는 것이다.

 

🍀 마치 생물처럼

지금까지 했던 설계를 살펴보면, 문제없이 직관적으로 흐름을 이해할 수 있었다. AudienceTicketSeller는 스스로 자신의 책임을 다 했다. 하지만 TheaterBag, TicketOffice를 생각해보면 어색한 구석이 있다. 얘네들은 실세계에서 자율적인 존재라고 하기에는 무리가 있다. 소극장이 갑자기 본인이 문을 열고, 가방이 자신의 내부를 뒤져보고, 매표소는 티켓을 건네주고 있다. 무생물도 스스로 행동하고 책임을 다 하는 자율적인 존재로 취급해준 것이다.

비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다. 훌륭한 객체지향 설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다.

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글