[객체지향] 객체, 설계(책임 이동과 캡슐화)

개요

객체는 실제 세계의 사물, 개념 등을 코드 수준에서 구체화하는 것이다. 이 객체들을 이용해서 우리는 요구사항에 맞는 애플리케이션을 만들고, 유지보수하고, 확장해나갈 수 있다.

객체를 이용한 설계는 요구사항에 부합될 수 있게 어느 시점, 어느 위치에 객체를 배치하는 것이다. 이 설계는 메소드, 클래스, 패키지, 모듈, 애플리케이션 등 다양한 곳에서 사용된다. 객체지향은 설계를 하고, 설계도에 따라 객체를 구현해나가는 과정이다.

이 글은 초대장을 티켓으로 바꿔주는 티켓 판매 애플리케이션 하나를 두 단계에 걸쳐 고친다. 등장하는 객체는 일곱 개이고, 각자 맡은 역할은 아래와 같다.

객체역할
Invitation이벤트에 당첨된 청중이 받은 초대장이다
Ticket관람에 필요한 티켓이고 요금을 가진다
Bag청중이 들고 다니는 가방이고 현금과 초대장과 티켓을 담는다
Audience극장에 입장하는 청중이고 가방을 가진다
TicketOffice매표소이고 판매할 티켓과 판매 대금을 가진다
TicketSeller매표소에서 티켓을 파는 판매자다
Theater청중을 입장시키는 극장이다

이 표는 우리가 코드를 읽기 전에 이미 알고 있는 역할이다. 설계가 어긋난 자리는 표에 적힌 역할과 코드가 하는 일이 갈라지는 곳에서 드러난다.

티켓 판매 애플리케이션

  • 청중은 이벤트 당첨으로 영화 초대장을 받았다.
  • 청중은 영화를 관람할 수 있다.
  • 영화관에 입장할 때 이벤트에 당첨된 청중과 티켓을 구매하는 청중으로 나뉜다.

초대장과 티켓과 가방, 그리고 가방을 든 청중을 먼저 만든다. 초대장을 가졌는지는 가방이 답한다.

class Invitation(private val invitedAt: LocalDateTime)
 
class Ticket(private val fee: Long) {
    fun getFee(): Long = fee
}
 
// 청중은 가방 클래스를 가지고 있고, 여기에 초대장이 있냐 없냐로 입장 시 청중을 구분한다.
class Bag(
    private var amount: Long,
    private val invitation: Invitation?,
    private var ticket: Ticket?,
) {
    fun hasInvitation(): Boolean = invitation != null
 
    fun hasTicket(): Boolean = ticket != null
 
    fun setTicket(ticket: Ticket) {
        this.ticket = ticket
    }
 
    fun minusAmount(amount: Long) {
        this.amount -= amount
    }
 
    fun plusAmount(amount: Long) {
        this.amount += amount
    }
}
 
class Audience(private val bag: Bag) {
    fun getBag(): Bag = bag
}

청중이 극장에 입장하기 위해선 매표소에서 초대장을 티켓으로 교환하거나 구매를 해야 한다.

class TicketOffice(
    private var amount: Long,
    private val tickets: MutableList<Ticket>,
) {
    fun getTicket(): Ticket = tickets.removeAt(0)
 
    fun minusAmount(amount: Long) {
        this.amount -= amount
    }
 
    fun plusAmount(amount: Long) {
        this.amount += amount
    }
}
 
class TicketSeller(private val ticketOffice: TicketOffice) {
    fun getTicketOffice(): TicketOffice = ticketOffice
}
 
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience) {
        if (audience.getBag().hasInvitation()) {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            audience.getBag().setTicket(ticket)
        } else {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            audience.getBag().minusAmount(ticket.getFee())
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee())
            audience.getBag().setTicket(ticket)
        }
    }
}

이 코드는 요구사항을 전부 만족한다. 요구사항을 만족하는 코드와 이해 가능한 코드는 같지 않다. 극장 하나가 나머지 다섯 클래스의 멤버를 부르고 있다.

통념에서 어긋나는 코드

객체는 실세계의 사물, 개념을 코드로 옮겨놓은 것이다. 즉, 코드로 옮기기 전에 이 사물 혹은 개념이 어떤 행동을 할지, 어떤 것인지 우리는 추측하거나 이미 알고 있는 지식들이 존재한다. 만약 추측이 빗나가거나 알고 있는 지식에 반하면 어떻게 될까?

가령 강아지가 야옹이라고 짖는다던지, 사과가 파란색이라던지 통념에서 어긋난다면 이해가 어려워지고, 어떤 이유 때문에 이렇게 된건지 컨텍스트를 계속해서 알아야 하고 이해해가야 한다. 코드 또한 마찬가지이다. Theater 클래스의 enter 메소드는 다음의 수순에 따라 동작한다.

  1. 청중을 시그니처로 받는다.
  2. 청중의 가방을 확인하여 초대장의 유무를 검사한다.
  3. 초대장이 있다면, 티켓 판매자로부터 매표소를 찍고 티켓을 얻어와서, 청중의 가방에 넣어준다.
  4. 초대장이 없다면, 같은 방법으로 티켓을 얻어와서, 청중의 가방에서 요금을 차감하고, 다시 티켓 판매자로부터 매표소를 찍어 대금을 늘리고, 청중의 가방에 티켓을 넣어준다.

이 수순을 두 줄로 좁히면 극장이 한 식에서 지나가는 객체가 드러난다. 극장이 부르는 메서드는 전부 다른 객체의 것이다.

// 극장이 한 식에서 점을 두 번 찍으며 지나가는 객체는 셋이다
val ticket = ticketSeller.getTicketOffice().getTicket()   // 판매자에서 매표소로, 매표소에서 티켓으로
audience.getBag().minusAmount(ticket.getFee())            // 청중에서 가방으로, 가방에서 요금 차감으로

사실 극장으로 청중이 입장한다 까지는 일반적인 통념이다. 그 안에서 극장은 청중의 가방을 확인하고, 티켓 판매자를 타고 매표소를 찍는 행위까지 한다. 이 모든 행위는 과연 극장 혼자서 행동할 수 있는 것일까? 청중의 가방을 확인하고, 티켓을 넣어주는 행위가 일반적인 이해인가?

이해 가능한 코드는 ~객체는 ~행동을 할 수 있겠고, 내부 구현도 ~객체가 가진 데이터만으로 행동하는 것까지이다. 이는 예상에서 벗어나지 않고, 어느 정도까지만의 정보로 행동하는 것이다.

Theater 는 enter 라는 메소드 이름까지는 이해가 갔지만 내부 행동에서 이해가 벗어났고, 너무 많은 정보를 알고 있다. 이는 여러 객체들에게 의존이 생기는 것이고 결과적으로 결합도(Coupling)가 높아지는 행위이다.

의존 클래스 수

결합도가 높다는 말은 형용사여서 개선이 되었는지 판정하지 못한다. 판정에 쓰려면 코드에서 세어야 한다. 한 클래스가 멤버를 부르는 클래스의 개수, 한 식에서 점을 이어 찍는 횟수, 정책이 바뀔 때 다시 읽어야 하는 갈래의 개수를 센다.

원본 코드에서 Theater 는 TicketSeller, TicketOffice, Ticket, Audience, Bag 다섯 클래스의 멤버를 부른다. ticketSeller.getTicketOffice().getTicket() 은 점을 두 번 찍어 판매자와 매표소와 티켓을 차례로 지나간다. 요금 정책이 바뀌면 enter 하나를 열지만 그 안의 네 갈래를 전부 다시 읽어야 한다.

결합도는 형용사가 아니라 세어지는 값이다. 이 세 값이 줄지 않으면 그 변경은 개선이 아니다.

1차 개선: 판매 위임

개선의 목적은 변경을 용이하게 하는 것이다. 즉, 객체가 알고 있는 정보(의존성)를 줄이고, 의존하고 있는 객체가 자율적으로 자신의 책임을 처리하도록 하는 것이다.

첫 단계는 극장이 매표소까지 타고 들어가던 구현을 티켓 판매자에게 통째로 넘기는 것이다.

// as-is: 극장이 판매자에게서 매표소를 꺼내 직접 판다
class TicketSeller(private val ticketOffice: TicketOffice) {
    fun getTicketOffice(): TicketOffice = ticketOffice
}
 
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience) {
        if (audience.getBag().hasInvitation()) {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            audience.getBag().setTicket(ticket)
        } else {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            audience.getBag().minusAmount(ticket.getFee())
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee())
            audience.getBag().setTicket(ticket)
        }
    }
}
 
// to-be: 판매자가 sellTo 로 판매를 맡고, 매표소는 판매자 안에 숨는다
class TicketSeller(private val ticketOffice: TicketOffice) {
    fun sellTo(audience: Audience) {
        if (audience.getBag().hasInvitation()) {
            val ticket = ticketOffice.getTicket()
            audience.getBag().setTicket(ticket)
        } else {
            val ticket = ticketOffice.getTicket()
            audience.getBag().minusAmount(ticket.getFee())
            ticketOffice.plusAmount(ticket.getFee())
            audience.getBag().setTicket(ticket)
        }
    }
}
 
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience) {
        ticketSeller.sellTo(audience)
    }
}

기존과 달리 변경점에서 영화관은 티켓 판매자에게 청중을 입장시켰으니 판매하라는 메시지를 보내도록 변경되었다. 티켓 판매자는 sellTo 라는 인터페이스(Interface)를 만들고, 기존 영화관이 가지고 있던 구현을 가져가게 되었는데, 영화관에서의 의존성은 크게 줄었고, 이해 가능한 코드로 변했다.

다시 세어보면 Theater 가 멤버를 부르는 클래스는 다섯에서 하나로 줄고, 점은 두 번에서 한 번으로 줄었다. getTicketOffice 가 사라져 매표소는 판매자 밖으로 나오지 않는다.

극장은 가벼워졌지만 판매자가 청중의 가방을 여는 구현을 그대로 물려받았으므로 개선은 한 단계 남았다.

2차 개선: 구매 위임

이제 판매자가 청중의 가방을 직접 연다. 티켓 판매자가 손님 가방에 손을 넣어 요금을 꺼내는 장면은 통념에서 다시 어긋난다. 가방을 열고 요금을 꺼내는 일은 청중이 스스로 하는 행동이다.

// as-is: 판매자가 청중의 가방을 열어 요금을 꺼낸다
class TicketSeller(private val ticketOffice: TicketOffice) {
    fun sellTo(audience: Audience) {
        if (audience.getBag().hasInvitation()) {
            val ticket = ticketOffice.getTicket()
            audience.getBag().setTicket(ticket)
        } else {
            val ticket = ticketOffice.getTicket()
            audience.getBag().minusAmount(ticket.getFee())
            ticketOffice.plusAmount(ticket.getFee())
            audience.getBag().setTicket(ticket)
        }
    }
}
 
class Audience(private val bag: Bag) {
    fun getBag(): Bag = bag
}
 
// to-be: 판매자는 티켓을 건네고 대금만 돌려받는다
class TicketSeller(private val ticketOffice: TicketOffice) {
    fun sellTo(audience: Audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()))
    }
}
 
class Audience(private val bag: Bag) {
    fun buy(ticket: Ticket): Long {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket)
            return 0L
        } else {
            bag.setTicket(ticket)
            bag.minusAmount(ticket.getFee())
            return ticket.getFee()
        }
    }
}

청중, 티켓 판매자 또한 마찬가지이다. 우리는 이런 식으로 각 객체에게 책임을 이동시킬 수 있고, 변하지 않는 인터페이스에 의존시킴으로써 기존에 갖고 있던 의존성을 크게 줄일 수 있다. 이로써 내부 구현을 외부에 노출하지 않고, 객체 스스로 문제를 해결하게 된다.

Audience 에서 getBag 이 사라지면서 가방은 청중 밖으로 나오지 않는다. 판매자는 티켓을 건네고 대금을 돌려받을 뿐, 청중이 가방을 들었는지도 알지 못한다.

두 단계를 값으로 옮기면 아래와 같다. 멤버를 부르는 클래스만 세고, 타입 이름만 적힌 경우는 세지 않는다.

단계TheaterTicketSellerAudience한 식의 최대 점 개수
원본5개(TicketSeller, TicketOffice, Ticket, Audience, Bag)0개0개2회
1차 개선1개(TicketSeller)4개(Audience, Bag, TicketOffice, Ticket)0개2회
2차 개선1개(TicketSeller)2개(Audience, TicketOffice)2개(Bag, Ticket)1회

세 단계 모두 합은 다섯으로 같다. 1차 개선은 Theater 가 내려놓은 넷을 TicketSeller 가 그대로 받은 재배치였고, 2차 개선에 와서야 한 클래스가 지는 최대치가 다섯에서 둘로 내려간다.

캡슐화와 응집도

객체 내부의 상태를 캡슐화(Encapsulation)하고, 객체 간은 메시지를 통해서만 상호작용한다는 것은 해당 객체와는 연관없는 작업을 위임한다는 의미이다. 즉, 위임된 객체는 그 작업과 연관이 크다는 의미이고, 이런 식으로 응집도(Cohesion)를 높여나갈 수 있다.

캡슐화는 필드를 private으로 가리는 것이 아니라, 그 필드를 쓰는 판단까지 같은 객체 안에 두는 것이다. 원본의 getBag은 필드를 private으로 두고도 가방을 그대로 내주므로 캡슐화가 아니다.

// 잘못된 패턴: getter 로 내부를 꺼내 호출하는 쪽이 판단한다
if (audience.getBag().hasInvitation()) {
    audience.getBag().setTicket(ticket)
} else {
    audience.getBag().minusAmount(ticket.getFee())
    audience.getBag().setTicket(ticket)
}
 
// 올바른 패턴: 메시지 하나를 보내고 결과만 받는다
val fee = audience.buy(ticket)

캡슐화가 깨졌는지는 호출하는 쪽 코드에서 먼저 드러난다. 아래 네 신호는 전부 판단이 데이터에서 떨어져 나온 흔적이다.

신호드러난 내부옮겨야 할 자리
a.getB().doSomething() 처럼 점이 두 번 찍힌다a 가 b 를 가진다는 사실doSomething 을 부르는 판단을 a 안으로
getter 가 컬렉션이나 다른 객체를 그대로 돌려준다내부 구조 전체컬렉션을 훑는 계산을 소유자 안으로
호출하는 쪽에 그 객체의 필드로 거는 if 가 있다필드가 가진 의미조건 판단을 필드 소유자 안으로
여러 곳에서 같은 getter 를 꺼내 같은 계산을 한다계산 규칙 자체계산을 메서드 하나로 소유자 안에

2차 개선을 마친 코드에서 객체들이 주고받는 메시지는 아래와 같다. 초대장을 가진 청중과 티켓을 사는 청중이 갈리는 자리는 이제 Audience 안에만 있다.

sequenceDiagram
    autonumber
    participant T as Theater
    participant S as TicketSeller
    participant O as TicketOffice
    participant A as Audience
    participant B as Bag

    T->>S: sellTo(audience)
    S->>O: getTicket()
    O-->>S: ticket
    S->>A: buy(ticket)
    A->>B: hasInvitation()
    B-->>A: 초대장 보유 여부
    alt 초대장을 가진 청중
        A->>B: setTicket(ticket)
        A-->>S: 0
    else 티켓을 구매하는 청중
        A->>B: setTicket(ticket)
        A->>B: minusAmount(fee)
        A-->>S: fee
    end
    S->>O: plusAmount(fee)

Bag의 멤버를 부르는 객체는 Audience 하나뿐이다. 가방을 다루는 코드가 한곳에 모였다는 것이 응집도가 높아졌다는 말이고, 가방이 현금을 어떻게 담든 판매자와 극장의 코드는 그대로다.

절차지향과 객체지향

가장 처음 Theater의 enter 메소드는 모든 객체를 의존하고, 모든 객체에서 데이터를 뽑아와 행동을 수행했다. 이를 절차지향이라 하는데, enter 메소드는 프로세스이고, 나머지 객체들은 데이터이다.

정책이 하나 늘어나면 두 방식의 차이가 드러난다. 조조 할인을 넣는다고 하면 절차지향에서는 enter 의 시그니처와 분기가 함께 추가된다.

// 절차지향: 정책이 늘어날수록 enter 한 메소드가 계속 자란다
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience, isEarlyBird: Boolean) {
        if (audience.getBag().hasInvitation()) {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            audience.getBag().setTicket(ticket)
        } else {
            val ticket = ticketSeller.getTicketOffice().getTicket()
            val fee = if (isEarlyBird) ticket.getFee() / 2 else ticket.getFee()  // 추가된 정책
            audience.getBag().minusAmount(fee)
            ticketSeller.getTicketOffice().plusAmount(fee)
            audience.getBag().setTicket(ticket)
        }
    }
}

객체지향에서는 요금을 내는 주체가 청중이므로 같은 정책이 buy 안에서만 추가된다.

// 객체지향: 요금을 내는 주체가 청중이므로 buy 안에서만 바뀐다
class Audience(
    private val bag: Bag,
    private val isEarlyBird: Boolean,
) {
    fun buy(ticket: Ticket): Long {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket)
            return 0L
        } else {
            val fee = if (isEarlyBird) ticket.getFee() / 2 else ticket.getFee()  // 추가된 정책
            bag.setTicket(ticket)
            bag.minusAmount(fee)
            return fee
        }
    }
}

절차지향에서 정책이 추가되거나 정책이 바뀌면 enter 메소드의 모든 라인들을 전수조사하고 변경해야 한다. 이 과정에서 누락이 발생하기도 하고, 어디서부터 어디까지 변경하고 추가해야 할지 찾기 어려워진다.

객체지향은 이와 달리 각 객체에게 맞는 책임을 위임함으로써 유지보수에 강점을 가진다. 특정 정책이 추가된다면 관련된 정책 책임을 가지고 있는 객체만 변경하면 되기 때문에 조사해야 할 코드, 추가 지점들을 쉽게 찾을 수 있다.

항목절차지향(enter 가 전부 처리)객체지향(각 객체가 처리)
요금 정책이 바뀔 때 여는 메소드Theater.enterAudience.buy
그 메소드에서 다시 읽는 갈래초대장, 구매, 차감, 대금까지 네 갈래요금을 계산하는 한 갈래
정책이 하나 더 늘 때enter 의 시그니처와 분기가 함께 늘어난다Audience 가 필드로 받고 buy 안에서만 늘어난다
판매 창구가 하나 더 생길 때새 창구가 enter 의 구현을 복사한다새 창구도 sellTo 한 줄만 부른다

이런 의미에서 객체지향이 절차지향보다 유연하다.

객체지향 설계

소프트웨어는 항상 요구사항이 들어오고, 변화하기 때문에 언제든지 쉽게 바뀔 수 있어야 한다. 이를 위해서 설계를 어떻게 할 것인지가 중요하다. 이 지점에선 변경 혹은 확장이 많을 것으로 예상되니 인터페이스를 두고, 어느 지점은 순수하게 구현만 있어도 충분하겠다 라는 배치를 항상 염두에 두어야 한다.

판매 창구가 창구 판매와 무인 발권기로 갈릴 것으로 예상된다면 그 자리에 인터페이스를 둔다. 가방처럼 구현이 하나뿐인 자리는 클래스 하나로 남긴다.

// 변경이 예상되는 자리에만 인터페이스를 둔다
interface TicketSeller {
    fun sellTo(audience: Audience)
}
 
class WindowTicketSeller(private val ticketOffice: TicketOffice) : TicketSeller {
    override fun sellTo(audience: Audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()))
    }
}
 
// 구현이 하나뿐이고 바뀔 이유가 없는 가방은 인터페이스 없이 그대로 둔다
class Bag(private var amount: Long, private val invitation: Invitation?, private var ticket: Ticket?)

어디를 고칠지는 아래 다섯 상황으로 판정한다.

상황선택근거
한 식에서 점이 두 번 이상 찍힌다마지막 점 뒤의 호출을 그 객체 안으로 옮긴다점의 개수가 곧 아는 클래스의 개수이고, 그만큼 함께 열어야 하는 파일이 늘어난다
getter 로 꺼낸 값으로 호출하는 쪽이 판단한다판단을 값의 소유자에게 넘긴다같은 판단이 여러 곳에 복제되면 정책이 바뀔 때 전수조사가 된다
방식이 여러 개로 갈릴 것으로 예상된다인터페이스를 두고 구현을 갈아 끼운다부르는 쪽이 변하지 않는 이름에만 의존해 구현 교체가 한 파일로 끝난다
구현이 하나뿐이고 바뀔 이유가 없다클래스 하나로 둔다인터페이스를 미리 두면 읽는 사람이 구현이 없는 갈래를 찾아 헤맨다
한 번에 전부 고치고 싶다한 단계만 옮기고 세 값을 다시 센다1차 개선처럼 총합이 그대로인 이동이 있어서, 세어보지 않으면 개선을 확인하지 못한다

객체지향 설계는 객체를 자율적인 존재로 만들고, 자신의 데이터를 스스로 책임지게 하여 행동하도록 해야 한다. 그래야 의존성을 줄이고, 유지보수와 확장에 강점을 가질 수 있다.