[객체지향] 책임과 협력(역할, 책임 주도 설계)

개요

객체지향 설계를 처음 배울 때 흔히 클래스에 어떤 데이터를 담을지부터 정한다. 영화 예매 시스템을 만든다면 Movie 클래스에 제목, 상영 시간, 가격을 넣고, Screening 클래스에 상영 순번과 시각을 넣는 식이다. 이렇게 데이터를 먼저 정하는 방식을 데이터 중심 설계(data-driven design)라 한다. 직관적이고 빠르게 시작할 수 있지만, 요구사항이 늘어날수록 변경에 취약해진다.

데이터 중심 설계의 근본 문제는 데이터와 행동을 분리한다는 데 있다. Movie가 데이터만 들고 있으면 그 데이터를 다루는 로직은 어딘가 다른 객체로 빠진다. 영화 예매 예제에서는 ReservationAgency 라는 관리자 객체가 Movie, Screening, DiscountCondition의 내부 데이터를 전부 꺼내와 직접 계산한다. 결과적으로 모든 객체가 서로의 내부 구현에 강하게 의존하고, 한 객체의 데이터 구조가 바뀌면 그것을 꺼내 쓰던 모든 객체가 함께 무너진다.

객체지향 설계는 개별 객체를 잘 만드는 것이 아니라 객체 사이의 협력(collaboration)을 설계하는 것이다. 협력 안에서 각 객체는 자신만의 책임(responsibility)을 수행하고, 비슷한 책임의 집합을 추상화해 역할(role)로 묶는다. 책임을 먼저 정하고 그 책임에 가장 적합한 객체를 찾아가는 방식을 책임 주도 설계(Responsibility-Driven Design)라 부른다.

키워드설명
협력(Collaboration)여러 객체가 메시지를 주고받으며 공동의 목표를 달성하는 상호작용
책임(Responsibility)객체가 알고 있는 것과 할 수 있는 것
역할(Role)여러 객체가 공유하는 책임의 집합을 추상화한 것
책임 주도 설계(RDD)행동(메시지)을 먼저 정의하고 적합한 객체에 책임을 할당하는 설계 방식
메시지(Message)객체가 다른 객체에게 보내는 오퍼레이션명과 인자의 조합
정보 전문가(Information Expert)책임 수행에 필요한 정보를 가장 많이 아는 객체에 책임을 할당하는 패턴
기능 분해(Procedural Abstraction)최상위 기능을 하위 프로시저로 쪼개는 하향식 추상화
데이터 추상화(Data Abstraction)데이터와 그 데이터를 다루는 행동을 한 타입으로 묶는 추상화
추상 데이터 타입(ADT)상태와 오퍼레이션을 하나로 묶은 타입
Protected Variations변할 부분을 안정된 인터페이스 뒤에 감춰 변경 영향을 가두는 패턴
단일 책임 원칙(SRP)한 클래스는 하나의 변경 이유만 가져야 한다는 원칙

추상화의 두 갈래: 기능 분해와 데이터 추상화

큰 문제를 작은 단위로 나누는 추상화에는 두 갈래가 있다. 무엇을 하는지로 나누는 기능 분해(procedural abstraction)와, 무엇을 알아야 하는지로 나누는 데이터 추상화(data abstraction)다.

기능 분해는 최상위 기능을 정의하고 이를 하위 프로시저로 쪼개는 하향식 접근이다. 직원 급여 계산을 예로 들면, 급여를 계산한다는 최상위 기능을 세율 입력, 급여 계산, 결과 출력으로 분해한다. main 함수가 모든 하위 프로시저를 순서대로 제어한다.

// 기능 분해: main 이 모든 로직을 제어하고, 기능 추가 시 직접 수정된다
fun main(operation: String) {
    when (operation) {
        "calculatePay" -> {
            val taxRate = getTaxRate()                 // UI 로직
            val pay = calculatePayFor(name, taxRate)   // 비즈니스 로직
            println(describeResult(name, pay))         // UI 로직
        }
        "sumBasePays" -> println("기본급 총합: ${sumOfBasePays()}") // 기능 추가로 main 수정
        else -> println("알 수 없는 작업입니다.")
    }
}

이 방식은 네 가지 문제가 존재한다. 첫째 경직성이다. 기능의 실행 순서가 이른 시점에 고정돼 유연성이 떨어진다. 둘째 변경 취약성이다. 기본급 총합 구하기 같은 새 기능이 추가되면 최상위 main에 분기가 늘어난다. 셋째 높은 결합도다. 비즈니스 로직(급여 계산)과 사용자 인터페이스(출력)가 main에 강하게 얽힌다. 넷째 데이터 파급이다. 모든 프로시저가 employees, basePays같은 전역 데이터에 의존하므로, 데이터 형식이 바뀌면 그 데이터를 쓰는 함수를 전부 찾아 고쳐야 한다. 근본 원인은 데이터와 행동이 분리돼 있다는 점이다.

데이터 추상화는 이 분리를 없앤다. 무엇을 하는지가 아니라 무엇을 알아야 하는지를 기준으로, 데이터와 그 데이터를 다루는 행동을 하나로 묶는다. 두 가지 장치가 쓰인다. 정보 은닉(information hiding)은 자주 바뀌는 구현을 안정적인 인터페이스 뒤로 감춘다. 추상 데이터 타입(ADT, Abstract Data Type)은 데이터(상태)와 오퍼레이션(행동)을 한 타입으로 묶는다. 외부 클라이언트는 ADT 의 인터페이스만 사용할 뿐 내부 구현은 알 필요가 없다. 급여 계산 로직을 Employee 안으로 캡슐화하면, 외부에선 employee.calculatePay() 를 호출하기만 하면 된다.

// 데이터 추상화: Employee 가 자신의 데이터와 행동을 함께 책임진다
class Employee(
    private val name: String,
    private val basePay: Money,
    private val hourly: Boolean,
) {
    fun calculatePay(taxRate: Double): Money =
        if (hourly) calculateHourlyPay(taxRate) else calculateSalaryPay(taxRate)
}

추상 데이터 타입은 오퍼레이션을 기준으로 타입을 묶지만, 객체지향은 타입을 기준으로 오퍼레이션을 묶는다. 위 Employee에는 아직 if (hourly) 분기가 남아 있다. 직원 유형이 늘 때마다 이 분기를 고쳐야 하므로 여전히 변경에 취약하다. 다형성이 이 분기를 없앤다. Employee를 인터페이스로 두고 SalariedEmployee(정규직)와 HourlyEmployee(아르바이트)가 각자 calculatePay를 구현하면, 클라이언트의 호출은 그대로 두고 객체의 실제 타입이 알맞은 구현을 실행한다.

// 객체지향: 타입 분기를 다형성으로 대체한다
interface Employee {
    fun calculatePay(taxRate: Double): Money
}
 
class SalariedEmployee(/* ... */) : Employee {
    override fun calculatePay(taxRate: Double): Money = /* 정규직 계산 */ Money.ZERO
}
 
class HourlyEmployee(/* ... */) : Employee {
    override fun calculatePay(taxRate: Double): Money = /* 아르바이트 계산 */ Money.ZERO
}

새로운 직원 유형(예: CommissionedEmployee)이 추가돼도 기존 calculatePay 호출 코드는 손대지 않는다. 이것이 개방-폐쇄 원칙(OCP)이다.

협력: 자율적인 객체들의 상호작용

객체는 고립된 존재가 아니라 다른 객체와 메시지를 주고받으며 공동의 목표를 달성하는 존재다. 객체는 다른 객체의 메서드를 호출(메시지 전송)함으로써 협력한다. 성공적인 협력의 전제는 각 객체가 자신의 상태를 잘 알고 스스로 행동을 결정할 수 있는 자율적인 존재여야 한다는 점이다. 자율성은 캡슐화에서 나온다.

티켓 판매 예제가 자율성의 의미를 잘 보여준다. 초기 설계에서 소극장(Theater) 객체는 관객의 가방을 직접 열어 초대장을 확인하고, 판매원을 거쳐 매표소의 티켓과 돈에 직접 접근한다. Theater가 모든 것을 통제하는 절차적 구조다.

// 잘못된 설계: Theater 가 모든 객체의 내부를 통제한다
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience) {
        if (audience.bag.hasInvitation()) {
            val ticket = ticketSeller.ticketOffice.getTicket() // 2단계 참조
            audience.bag.setTicket(ticket)                     // 외부에서 내부 상태 조작
        } else {
            val ticket = ticketSeller.ticketOffice.getTicket()
            audience.bag.minusAmount(ticket.fee)
            ticketSeller.ticketOffice.plusAmount(ticket.fee)
            audience.bag.setTicket(ticket)
        }
    }
}

이 코드는 ticketSeller.ticketOffice.getTicket() 처럼 두 단계 이상의 참조로 다른 객체의 내부를 파고든다. Theater는 TicketSeller, TicketOffice, Audience, Bag의 내부 구현에 모두 결합된다. TicketOffice나 Bag의 구조가 바뀌면 Theater까지 수정해야 한다.

개선의 방향은 각 객체에게 자신의 일을 돌려주는 것이다. 판매 업무는 판매원에게, 가방 관리는 관객에게 위임한다.

// 개선 설계: 각 객체가 자신의 책임만 수행한다
class Theater(private val ticketSeller: TicketSeller) {
    fun enter(audience: Audience) {
        ticketSeller.sellTo(audience) // 판매원에게 위임, TicketOffice 는 모른다
    }
}
 
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 // 초대권 교환이므로 지불 금액 0
        }
        bag.minusAmount(ticket.fee)
        bag.setTicket(ticket)
        return ticket.fee // 지불 금액 반환
    }
}

이제 Theater는 TicketSeller가 판매를 수행한다는 사실만 알고 TicketOffice의 존재는 모른다. TicketSeller는 관객의 가방을 들여다보지 않고 관객에게 구매를 시킨 뒤 대가를 받는다. 각 객체가 자신의 일만 책임지므로 응집도가 높아지고 자율성이 보장된다.

다만 자율성을 높인다고 시스템 전체의 의존성이 사라지는 것은 아니다. 중앙 집중적이던 의존성(Theater가 모든 객체에 의존)이 객체 간 협력 관계(Theater → TicketSeller → Audience)로 분산될 뿐, 새로운 의존이 생겨나기도 한다. 좋은 설계의 목표는 의존성을 없애는 것이 아니라 의미 있고 관리 가능한 의존성을 만드는 것이다.

책임: 무엇을 알고 무엇을 할 수 있는가

책임은 객체가 알고 있는 것과 할 수 있는 것으로 구성된다. 올바른 객체에 올바른 책임을 할당하는 것이 설계의 핵심이다. 데이터 중심 설계는 이 데이터를 가진 객체는 누구인가를 묻지만, 책임 주도 설계는 이 행동을 가장 잘 수행할 객체는 누구인가를 먼저 묻는다.

책임 주도 설계의 프로세스는 다음과 같다.

  1. 시스템에 필요한 메시지(행동)를 먼저 정의한다. 예: 예매하라, 요금을 계산하라.
  2. 그 메시지를 처리하는 데 가장 많은 정보를 가진 객체에게 책임을 할당한다.

이 두 번째 단계가 GRASP(General Responsibility Assignment Software Pattern)의 정보 전문가(Information Expert) 패턴이다. 책임 수행에 필요한 정보를 가장 많이 아는 객체에게 책임을 주라는 원칙이다. 영화 예매에서 예매하라는 상영 정보를 아는 Screening에게, 요금을 계산하라는 요금과 할인 정책을 아는 Movie에게 할당한다.

정보 전문가 패턴을 처음 들으면 데이터를 먼저 만들라는 말처럼 들려 책임이 우선이라는 객체지향 원칙과 모순돼 보인다. 이 모순은 도메인 모델링이 해소한다. 시스템을 설계하고 도메인을 모델링하는 단계에서 어떤 개념이 어떤 정보와 책임을 갖는지 이미 조사한다. 그래서 코드를 쓸 때는 사전에 파악한 정보를 근거로 어느 객체에 책임을 할당할지 결정할 수 있다. 데이터를 먼저 정하는 것이 아니라, 책임을 정의한 뒤 그 책임에 필요한 정보를 가진 객체를 찾는 순서다.

책임을 받은 객체가 모든 일을 혼자 처리할 필요는 없다. Screening은 예매 책임을 받았지만 영화 요금 계산은 Movie가 더 잘 안다. Screening은 자신이 처리할 수 없는 부분을 Movie에게 위임한다. 이때 Screening은 Movie의 내부 구현(if 문, when 문)을 전혀 몰라도 되고, calculateMovieFee라는 메시지만 결정해 호출한다. 위임을 통해 두 객체 사이의 캡슐화가 지켜지고 결합도가 느슨하게 유지된다.

역할: 책임의 집합을 추상화하기

역할은 여러 객체가 공유할 수 있는 책임의 집합을 추상화한 것이다. 영화 예매에서 AmountDiscountPolicy(금액 할인)와 PercentDiscountPolicy(비율 할인)는 할인 요금을 계산한다는 책임을 동일하게 수행한다. 이 둘을 개별적으로 다루면 중복이 생기고 유연성이 떨어진다.

해결책은 할인 요금을 계산한다는 책임을 뽑아 DiscountPolicy라는 역할(추상 클래스 또는 인터페이스)로 추상화하는 것이다. Movie는 구체적인 할인 정책이 아니라 DiscountPolicy라는 상위 수준의 역할에만 의존한다.

// Movie 는 구체 정책이 아니라 DiscountPolicy 역할에 의존한다
class Movie(
    private val fee: Money,
    private val discountPolicy: DiscountPolicy,
) {
    fun calculateMovieFee(screening: Screening): Money {
        // 할인 금액 계산을 역할에게 위임한다
        return fee.minus(discountPolicy.calculateDiscountAmount(screening))
    }
}
 
abstract class DiscountPolicy(
    private val conditions: List<DiscountCondition>,
) {
    // 공통 로직: 조건을 만족하면 할인 금액을 계산한다 (템플릿 메서드)
    fun calculateDiscountAmount(screening: Screening): Money {
        for (condition in conditions) {
            if (condition.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening)
            }
        }
        return Money.ZERO
    }
 
    // 자식이 구체화하는 부분
    protected abstract fun getDiscountAmount(screening: Screening): Money
}
 
class AmountDiscountPolicy(
    private val discountAmount: Money,
    conditions: List<DiscountCondition>,
) : DiscountPolicy(conditions) {
    override fun getDiscountAmount(screening: Screening): Money = discountAmount
}
 
class PercentDiscountPolicy(
    private val percent: Double,
    conditions: List<DiscountCondition>,
) : DiscountPolicy(conditions) {
    override fun getDiscountAmount(screening: Screening): Money =
        screening.movieFee.times(percent)
}

역할로 추상화하면 두 가지 이득이 생긴다. 첫째, 단순화다. Movie는 구체적인 할인 정책의 종류를 신경 쓰지 않고 DiscountPolicy라는 하나의 역할만 알면 된다. 둘째, 유연성이다. Movie 코드를 바꾸지 않고도 런타임에 다양한 DiscountPolicy 구현체를 교체할 수 있다. 새로운 쿠폰 할인 정책을 추가해도 Movie는 영향받지 않는다.

할인 조건도 같은 방식으로 추상화한다. 순번 조건과 기간 조건은 할인 조건을 만족하는가라는 책임을 공유하므로 DiscountCondition 인터페이스로 묶는다. DiscountPolicy는 DiscountCondition의 리스트를 부품처럼 가지는데, 이것이 합성(composition)이다. 주중 3회차 상영 같은 복합 조건이 생겨도 DiscountPolicy 코드를 바꾸지 않고 새 DiscountCondition 구현체만 추가하면 된다.

이 리팩토링에는 이름이 있다. Protected Variations(변경 보호) 패턴이다. 변할 가능성이 높은 지점(구체적인 할인 조건, 할인 금액 계산법)을 안정된 인터페이스(DiscountCondition, DiscountPolicy) 뒤에 감춰 변경의 영향을 그 뒤로 가둔다. 반대로 Movie나 DiscountPolicy가 분기문으로 MovieType이나 DiscountConditionType을 직접 분기하면, 새 타입이 추가될 때마다 그 클래스의 내부 코드를 고쳐야 한다. 이는 한 클래스가 여러 변경 이유를 갖는다는 뜻으로, 단일 책임 원칙(SRP, Single Responsibility Principle) 위반의 신호다. 다형성으로 분기를 갈라내면 각 구현체가 자신의 계산 하나만 책임지므로 SRP를 회복하고, 각 책임의 변경이 다른 구현체로 번지지 않는다.

데이터 중심 vs 책임 중심: 영화 예매로 보는 차이

협력, 책임, 역할을 영화 예매 예제에 모아 두 설계 방식을 정면으로 비교한다. 데이터 중심 설계는 객체가 가져야 할 상태를 먼저 정하고, 책임 중심 설계는 객체가 수행할 책임(메시지)을 먼저 정한다.

데이터 중심으로 시작하면 Movie는 상태 덩어리가 되고 모든 상태를 getter로 노출한다.

// 잘못된 설계: 데이터 중심 Movie 와 외부 관리자 ReservationAgency
class Movie(
    private val title: String,
    private val fee: Money,
    private val movieType: MovieType,
    private val discountAmount: Money,
    private val discountPercent: Double,
) {
    // 모든 내부 상태를 외부로 노출한다
    fun getFee(): Money = fee
    fun getMovieType(): MovieType = movieType
    fun getDiscountAmount(): Money = discountAmount
    fun getDiscountPercent(): Double = discountPercent
}
 
class ReservationAgency {
    fun calculateFee(movie: Movie): Money {
        // Movie 의 내부 데이터를 모두 꺼내 직접 분기한다
        return when (movie.getMovieType()) {
            MovieType.AMOUNT_DISCOUNT -> movie.getFee().minus(movie.getDiscountAmount())
            MovieType.PERCENT_DISCOUNT ->
                movie.getFee().minus(movie.getFee().times(movie.getDiscountPercent()))
            MovieType.NONE_DISCOUNT -> movie.getFee()
        }
    }
}

이 설계의 문제는 세 가지로 정리된다. 캡슐화 위반이다. getFee, getMovieType은 내부 구현을 그대로 외부에 드러낸다. 낮은 응집도다. 할인 로직은 ReservationAgency에 있고 관련 데이터는 Movie 에 있어, 데이터와 행동이 분리됐다. Movie 의 요금 할인 방식이 바뀌면(예: movieType 이 사라지면) Movie뿐 아니라 ReservationAgency도 함께 수정해야 한다. 게다가 새 MovieType이 추가될 때마다 when 분기를 직접 고쳐야 하므로 개방-폐쇄 원칙(OCP)을 위반한다.

행동을 부여하더라도 여전히 데이터 중심적으로 생각하는 함정이 있다. DiscountCondition에 할인 여부 판단 메서드를 추가해보자.

// 함정: 메서드를 추가했지만 시그니처가 내부 상태를 광고한다
class DiscountCondition(
    private val sequence: Int,
    private val dayOfWeek: DayOfWeek,
) {
    fun isDiscountable(sequence: Int): Boolean { /* ... */ }
    fun isDiscountable(dayOfWeek: DayOfWeek): Boolean { /* ... */ }
}

isDiscountable은 파라미터로 Int와 DayOfWeek를 요구한다. 이는 DiscountCondition이 sequence와 dayOfWeek를 내부에 가지고 있다고 외부에 광고하는 것과 같다. 이 메서드를 호출하는 객체는 DiscountCondition의 내부 상태를 알아야 하므로 결합도는 여전히 높다.

책임 중심으로 다시 설계하면 데이터와 행동이 한 객체에 모인다. Screening은 Movie에게, Movie는 DiscountPolicy에게 메시지만 보낸다.

// 개선 설계: 책임 중심. 각 객체가 스스로 처리하고 메시지로 위임한다
class Screening(
    private val movie: Movie,
    private val sequence: Int,
    private val whenScreened: ZonedDateTime,
) {
    fun calculate(audienceCount: Int): Money {
        // Movie 에게 요금 계산을 시킨다. 계산 방법은 모른다
        return movie.calculateMovieFee(this).times(audienceCount)
    }
}
 
class Movie(
    private val fee: Money,
    private val discountPolicy: DiscountPolicy,
) {
    fun calculateMovieFee(screening: Screening): Money {
        // DiscountPolicy 에게 할인 계산을 위임한다
        return fee.minus(discountPolicy.calculateDiscountAmount(screening))
    }
}

데이터 구조가 바뀌어도 그 데이터를 다루는 로직이 같은 객체 안에 있으므로 변경의 파급이 객체 경계를 넘지 않는다. 새 할인 정책은 DiscountPolicy구현체를, 새 할인 조건은 DiscountCondition 구현체를 추가하면 되며 기존 객체는 수정하지 않는다. 데이터 중심 설계가 변경을 시스템 전체로 퍼뜨리는 반면, 책임 중심 설계는 변경을 한 객체 안에 가둔다.

다이어그램

영화 예매 협력에서 Client가 Screening에게, Screening이 Movie에게, Movie가 DiscountPolicy 역할에게 메시지로 책임을 위임하는 관계를 보여준다. 각 객체는 자신이 가장 잘 아는 책임만 수행하고, 모르는 일은 다음 객체에게 메시지로 넘긴다.

다음 도식은 같은 문제를 데이터 중심과 책임 중심으로 설계했을 때의 차이를 비교한다. 왼쪽은 ReservationAgency가 Movie의 상태를 getter로 꺼내 직접 처리하는 구조이고, 오른쪽은 Screening이 Movie에게 메시지를 보내 스스로 처리하게 하는 구조다.

주의사항과 베스트프랙티스

상황흔한 실수권장 방향
설계 시작점클래스의 데이터(필드)부터 정한다시스템에 필요한 메시지(행동)부터 정의하고 책임을 할당한다
책임 할당만들기 편한 객체에 책임을 몰아준다정보 전문가 패턴으로 정보를 가장 많이 아는 객체에 책임을 둔다
협력 방식getter 로 상태를 꺼내 외부에서 계산한다메시지를 보내 객체가 스스로 처리하게 한다
타입 분기if/when 으로 타입을 확인해 분기한다다형성으로 변하는 부분을 인터페이스 뒤에 캡슐화한다

책임을 먼저, 데이터는 나중에 둔다. 데이터를 먼저 정하면 객체가 단순한 데이터 집합으로 전락하고 로직이 외부 관리자로 빠져나간다. 객체에게 무엇을 할 것인지를 먼저 묻고, 그 행동에 필요한 데이터는 객체 내부에 감추도록 한다.

캡슐화는 getter를 막는 것이 아니라 구현을 감추는 것이다. getFee()처럼 내부 상태를 그대로 반환하는 메서드는 캡슐화가 아니다. 캡슐화는 불안정한 구현을 안정적인 인터페이스 뒤에 감추는 것이다. 상태를 노출하는 대신, 그 상태로 무엇을 할지를 메시지로 제공해야 한다.

상속보다 합성을 우선 고려한다. 합성(주로 전략 패턴)은 런타임에 부품 객체를 교체할 수 있어 상속보다 유연하다. 영화 예매에서 DiscountPolicy가 DiscountCondition리스트를 합성으로 가졌기 때문에, 새 조건이 생겨도 정책 코드를 바꾸지 않고 조건만 갈아 끼울 수 있었다. 명확한 타입 계층이 필요하면 상속을, 행위의 다양한 조합이 필요하면 합성을 선택한다.