4장은 일부러 잘못 설계했다. 데이터를 먼저 정하고, 그 데이터를 꺼내 쓰는 절차를 한곳에 몰았다. 결과가 ReservationAgency다 — 모든 객체의 게터를 빨아들여 혼자 계산하는 트랜잭션 스크립트. 이 장은 그 코드를 지우지 않고 책임을 재배치해서 되살린다. 기법의 이름은 GRASP(General Responsibility Assignment Software Patterns), 책임을 어느 객체에게 줄지 결정하는 원칙들의 모음이다.

핵심 질문은 하나다. 이 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 객체가 누구인가. 그 객체에게 책임을 준다. 이것이 정보 전문가(Information Expert) 패턴이고, 나머지 원칙들은 이 결정이 낳는 결합도와 응집도를 점검하는 장치다.

데이터에서 시작하면 왜 망가지는가

ReservationAgency는 아무 잘못된 문법도 쓰지 않았다. 문제는 모든 결정을 자기가 내린다는 것이다. 할인 조건을 판단하려고 Screening의 상영 시간과 순번을 꺼내고, 요금을 계산하려고 Movie의 타입·금액·비율을 꺼낸다. 데이터의 주인은 따로 있는데 판단은 여기서 한다.

이 구조의 대가는 변경에서 나타난다. 할인 조건 타입을 하나 추가하면 ReservationAgencyif-else를 고쳐야 하고, 요금 정책을 바꿔도 이 클래스를 열어야 한다. 서로 무관한 변경 이유들이 한 클래스에 모여 있다 — 낮은 응집도다. 그리고 이 클래스는 Movie·Screening·DiscountCondition의 내부 구조를 전부 알고 있다 — 높은 결합도다.

판단 기준: “데이터를 어떻게 나눌까”가 아니라 “어떤 행동이 필요하고, 그 행동에 필요한 데이터를 누가 가졌나”를 먼저 묻는다. 함정: 게터가 많아지는 클래스는 대개 자기 책임을 남에게 빼앗긴 클래스다. 게터의 개수는 캡슐화가 새는 구멍의 개수다.

책임 할당의 출발점 — 메시지가 객체를 결정한다

책임 주도 설계에는 순서가 있다. 먼저 시스템이 수행해야 하는 책임을 큰 단위로 파악하고, 그 시스템 책임을 더 작은 책임으로 분할한다. 그다음 분할된 책임을 수행할 적절한 객체 또는 역할을 찾아 책임을 할당한다. 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요하면, 이 과정을 반복하며 도움을 요청할 협력 객체를 찾는다.

여기서 반드시 지켜야 할 순서가 있다. 객체를 먼저 결정하고 메시지를 나중에 정하는 것이 아니라, 메시지를 먼저 결정하고 그 메시지를 처리할 객체를 나중에 선택한다. 객체가 있어야 메시지를 보낸다고 생각하기 쉽지만, 설계의 출발점은 반대다. “어떤 메시지가 필요한가”를 먼저 묻고, 그 메시지를 처리하기에 가장 적합한 객체를 찾는다.

// 설계 순서 1: 어떤 메시지가 필요한가 (메시지가 먼저)
//   "예매하라" → reserve(customer, audienceCount)
//   "요금을 계산하라" → calculateFee(screening)
//   "할인 조건을 만족하는가" → isSatisfiedBy(screening)
 
// 설계 순서 2: 이 메시지를 처리할 객체는 누구인가 (객체는 나중)
//   reserve → Screening    (예매에 필요한 정보를 가장 많이 안다)
//   calculateFee → Movie   (요금 정보를 가졌다)
//   isSatisfiedBy → 각 DiscountCondition

이 순서가 중요한 이유는, 메시지를 먼저 결정하면 메시지를 수신할 객체가 무엇을 알아야 하는지가 자연스럽게 정해지기 때문이다. 메시지가 객체의 인터페이스를 결정하고, 인터페이스가 결정되고 나서야 그 인터페이스를 구현할 내부 구조를 고민한다. 객체를 먼저 정하면 “이 객체가 가진 데이터로 무엇을 할까”를 묻게 되어 다시 데이터 중심 설계로 미끄러진다.

판단 기준: 설계가 막히면 “지금 어떤 메시지가 필요한가”로 돌아온다. 메시지가 명확해지면 수신자는 대개 자명하다. 함정: 객체의 구현(속성, 내부 자료구조)을 먼저 상상하면 메시지가 그 구현에 오염된다. 구현은 인터페이스가 정해진 후에 고민한다.

책임을 집으로 돌려보낸다

아래 스텝 플레이어가 이 장의 전부다. 게터를 빨아들이는 절차 덩어리에서 시작해, 각 책임을 그 정보를 가진 객체에게 하나씩 돌려준다. 마지막에는 조건 타입 분기가 다형성으로 사라진다.

Refactoring Step 파탄 — ReservationAgency가 혼자 다 안다
1/4
public class ReservationAgency {    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {        Movie movie = screening.getMovie();        Money discountAmount = switch (movie.getMovieType()) {            case AMOUNT_DISCOUNT -> movie.getDiscountAmount();            case PERCENT_DISCOUNT -> movie.getFee().times(movie.getDiscountPercent());            case NONE_DISCOUNT -> Money.ZERO;        };        boolean discountable = false;        for (DiscountCondition condition : movie.getDiscountConditions()) {            if (condition.getType() == DiscountConditionType.PERIOD) {                discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek())                    && condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0                    && condition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime()) >= 0;            } else {                discountable = condition.getSequence() == screening.getSequence();            }            if (discountable) break;        }        Money fee = discountable ? movie.getFee().minus(discountAmount) : movie.getFee();        return new Reservation(customer, screening, fee, audienceCount);    }}

네 걸음을 GRASP로 다시 읽기

창조자(Creator)Reservation을 누가 만드는가. GRASP은 “생성될 객체를 긴밀히 사용하거나, 그 초기값을 가진 객체”에게 생성 책임을 준다. ReservationScreening·Customer·요금으로 만들어지고, 그 대부분을 아는 건 Screening이다. 그래서 reserveScreening의 메서드가 된다.

정보 전문가(Information Expert) — 요금 계산에 필요한 정보(기본 요금, 할인 정책)는 Movie가 가졌다. 그래서 Screening은 요금을 직접 계산하지 않고 movie.calculateMovieFee(this)시킨다. 마찬가지로 개별 할인 조건의 만족 여부는 그 조건 객체가 가장 잘 안다 — condition.isSatisfiedBy(screening). 게터로 데이터를 꺼내 밖에서 판단하던 흐름이, 데이터를 가진 쪽에게 판단을 맡기는 흐름으로 뒤집혔다.

낮은 결합도(Low Coupling)ReservationAgency는 세 클래스의 내부를 전부 알았다. 재배치 후 ScreeningMoviecalculateMovieFee 하나만 알고, MovieDiscountConditionisSatisfiedBy 하나만 안다. 각자 이웃의 인터페이스만 알지 내부 필드는 모른다. 결합의 폭이 게터 다발에서 메시지 하나로 줄었다.

높은 응집도(High Cohesion) — 할인 조건이 바뀌면 조건 클래스만, 요금 정책이 바뀌면 Movie만 열면 된다. 변경의 이유가 클래스마다 하나씩으로 정렬됐다. 한 클래스에 모여 있던 서로 무관한 변경 이유들이 각자의 집을 찾았다.

판단 기준: 책임을 줄 후보가 여럿이면 정보 전문가를 1순위로 놓되, 그 배치가 결합도를 높이거나 응집도를 깨면 후보를 재검토한다. GRASP은 규칙이 아니라 서로를 견제하는 힘의 균형이다. 함정: 정보 전문가를 기계적으로 적용하면 데이터를 가진 클래스가 만능이 되어 다시 God 클래스로 돌아간다. “정보를 가진 객체”와 “그 정보로 하는 일이 그 객체의 본분인가”를 함께 본다.

GRASP 패턴의 정식 정의

앞의 네 걸음은 GRASP의 대표 패턴들을 적용한 결과다. 각 패턴은 “문제 상황 — 해결책”의 형태로 정리된 이름 붙은 원칙이다. 이름을 붙여 두면 설계 대화에서 “여기는 정보 전문가로 가자”, “그건 낮은 결합도를 깬다” 같은 말로 의사소통이 빨라진다.

INFORMATION EXPERT(정보 전문가) — 문제: 객체에게 책임을 할당하는 일반 원칙이 필요하다. 해결: 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 객체에게 그 책임을 할당한다. 여기서 “정보를 안다”는 것이 반드시 “데이터를 필드로 가진다”는 뜻은 아니다. 스스로 데이터를 가지지 않더라도, 필요한 정보를 알고 있는 다른 객체를 알고 있어 그에게 물어볼 수 있다면 그 객체 역시 정보 전문가다. Screening은 요금 데이터를 직접 갖지 않지만 Movie를 알기에, 요금 계산을 시작할 정보 전문가가 된다.

CREATOR(창조자) — 문제: 객체 A의 인스턴스를 누가 생성할 책임을 져야 하는가. 해결: 아래 조건 중 하나 이상을 만족하는 객체 B에게 A의 생성 책임을 준다 — B가 A를 포함하거나 집약한다, B가 A를 기록한다, B가 A를 긴밀하게 사용한다, B가 A를 초기화하는 데 필요한 데이터를 가졌다. 여러 조건을 만족할수록 강한 후보다. ScreeningReservation이 필요로 하는 정보 대부분을 가졌고 그것을 긴밀히 사용하므로 창조자가 된다. 창조자 패턴의 근거는 “이미 결합된 객체를 재사용하라”다 — 어차피 알고 있는 객체에게 생성을 맡기면 새로운 결합이 생기지 않는다.

LOW COUPLING(낮은 결합도) — 문제: 의존성을 낮추고 변경의 영향을 줄이며 재사용성을 높이려면 어떻게 책임을 할당해야 하는가. 해결: 결합도가 낮게 유지되도록 책임을 할당한다. 결합도는 한 요소가 다른 요소에 대해 알고 있는 지식의 양이다. 결합도가 높으면 한 곳의 변경이 결합된 다른 곳으로 전파된다. 낮은 결합도는 그 자체로 목적이라기보다, 다른 패턴을 적용할 때 여러 후보 중 하나를 고르는 평가 원칙으로 쓰인다.

HIGH COHESION(높은 응집도) — 문제: 객체를 이해하기 쉽고 관리하기 쉽게 유지하려면 어떻게 책임을 할당해야 하는가. 해결: 높은 응집도를 유지할 수 있도록 책임을 할당한다. 응집도는 한 요소가 얼마나 밀접하게 관련된 책임만 모으고 있는가다. 서로 관련 없는 책임이 한 객체에 모이면 응집도가 낮아지고, 그 객체는 여러 이유로 바뀐다. 낮은 결합도와 마찬가지로 여러 대안을 평가하는 원칙이다 — 두 패턴은 종종 함께 저울에 올라 균형점을 찾는다.

POLYMORPHISM(다형성) — 문제: 객체의 타입에 따라 행동이 달라질 때, 그 행동을 어떻게 처리할 것인가. 해결: 타입을 명시적으로 검사하는 조건문 대신, 다형적 메시지를 사용해 각 타입이 자신의 행동을 책임지게 한다. 조건 분기를 타입 계층으로 대체하면 새 타입이 생겨도 기존 코드를 건드리지 않는다.

PROTECTED VARIATIONS(변경 보호) — 문제: 예측 가능한 변경 지점이 있을 때, 그 변경이 다른 요소로 번지지 않게 하려면 어떻게 설계할 것인가. 해결: 변경이 예상되는 불안정한 지점을 안정된 인터페이스로 감싼다. 인터페이스 뒤에서 무엇이 바뀌든 그것을 사용하는 쪽은 영향받지 않는다. 다형성과 변경 보호는 짝을 이룬다 — 다형성이 구현 기법이라면 변경 보호는 그 기법이 지키려는 목적이다.

판단 기준: GRASP은 암기할 규칙이 아니라 설계 결정을 정당화하는 어휘다. “왜 이 배치를 골랐는가”를 이 이름들로 설명할 수 있으면 설계 근거가 명확해진다. 함정: 패턴 이름을 앞세워 코드를 억지로 맞추면 본말이 전도된다. 문제가 먼저고 패턴은 그 문제의 해결책일 때만 유효하다.

DiscountCondition의 응집도를 진단한다

조건 판단을 DiscountCondition 한 클래스에 몰아넣으면, 겉보기엔 정보 전문가를 잘 적용한 것 같지만 실제로는 응집도가 낮다. 이 클래스는 순번 조건일 때 쓰는 sequence와 기간 조건일 때 쓰는 dayOfWeek/startTime/endTime모두 가지고, isSatisfiedBy 안에서 타입으로 분기한다.

// 낮은 응집도 — 한 클래스가 두 종류의 조건을 다 안다
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;                 // 순번 조건일 때만 쓴다
    private DayOfWeek dayOfWeek;           // 기간 조건일 때만 쓴다
    private LocalTime startTime;           // 기간 조건일 때만 쓴다
    private LocalTime endTime;             // 기간 조건일 때만 쓴다
 
    public boolean isSatisfiedBy(Screening screening) {
        if (type == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(screening);      // startTime/endTime/dayOfWeek만 사용
        }
        return isSatisfiedBySequence(screening);        // sequence만 사용
    }
}

이 클래스가 응집도가 낮다는 것을 어떻게 진단하는가. 책은 두 가지 판단법을 준다.

첫째, 인스턴스 변수가 초기화되는 시점을 본다. 응집도가 높은 클래스는 모든 인스턴스 변수가 함께 초기화된다. 그런데 DiscountCondition은 기간 조건으로 만들어지면 dayOfWeek/startTime/endTime만 초기화되고 sequence는 의미 없는 기본값으로 남는다. 순번 조건이면 그 반대다. 일부 인스턴스 변수만 초기화되는 클래스는 여러 개의 클래스가 억지로 뭉쳐 있다는 신호다.

둘째, 메서드들이 인스턴스 변수를 사용하는 방식을 본다. 응집도가 높은 클래스는 모든 메서드가 모든 인스턴스 변수를 골고루 사용한다. DiscountConditionisSatisfiedByPeriodsequence를 전혀 안 쓰고, isSatisfiedBySequence가 기간 관련 변수를 전혀 안 쓴다. 메서드들이 변수 그룹별로 갈라져 서로 다른 변수만 사용하면, 그 변수 그룹과 메서드 그룹을 각각 별도 클래스로 분리하라는 신호다.

이 두 진단은 “이 클래스는 변경의 이유가 하나 이상이다”라는 같은 사실을 다른 각도에서 본 것이다. 순번 조건 계산 방식이 바뀌어도, 기간 조건 계산 방식이 바뀌어도 이 한 클래스를 열어야 한다. 변경의 이유가 둘이면 클래스도 둘로 나눈다.

판단 기준: 초기화되지 않는 인스턴스 변수가 있거나, 특정 메서드 그룹만 쓰는 변수 그룹이 있으면 응집도가 낮다. 함정: 응집도 진단은 “이름이 그럴듯한가”가 아니라 “변수와 메서드의 사용 패턴이 갈라지는가”로 한다. 이름이 하나여도 속은 둘일 수 있다.

분리가 낳은 결합, 그리고 그 해소

DiscountConditionPeriodConditionSequenceCondition으로 나누면 각 조건의 응집도는 올라간다. 그런데 이 분리가 새로운 문제를 만든다. 조건이 두 개의 구체 클래스가 되면서, 이제 이 조건들을 사용하는 Movie두 클래스를 모두 알아야 할 위험이 생긴다.

// 나이브한 분리 — Movie가 구체 조건 클래스마다 분기하게 되면 결합이 늘어난다
public class Movie {
    public boolean isDiscountable(Screening screening) {
        for (Object condition : conditions) {
            if (condition instanceof PeriodCondition) {         // 구체 타입을 안다
                if (((PeriodCondition) condition).isSatisfiedByPeriod(screening)) return true;
            } else if (condition instanceof SequenceCondition) { // 또 다른 구체 타입을 안다
                if (((SequenceCondition) condition).isSatisfiedBySequence(screening)) return true;
            }
        }
        return false;
    }
}

응집도를 얻으려다 결합도를 잃은 셈이다. 새 조건 타입을 추가하면 Movieinstanceof 분기를 또 고쳐야 한다. 이 딜레마를 푸는 것이 POLYMORPHISM과 PROTECTED VARIATIONS다. 두 구체 클래스를 DiscountCondition이라는 안정된 인터페이스로 감싸고(변경 보호), 조건 판단을 다형적 메시지 isSatisfiedBy 하나로 통일한다(다형성).

// POLYMORPHISM + PROTECTED VARIATIONS — Movie는 인터페이스 하나만 안다
public class Movie {
    private List<DiscountCondition> conditions;   // 구체 타입을 모른다
 
    public boolean isDiscountable(Screening screening) {
        return conditions.stream()
                .anyMatch(condition -> condition.isSatisfiedBy(screening)); // 다형적 메시지
    }
}

Movie는 이제 PeriodConditionSequenceCondition도 모른다. 오직 DiscountCondition 인터페이스만 안다. 조건의 응집도(각 조건 클래스)와 Movie의 낮은 결합도(인터페이스 의존)를 동시에 얻었다. 이것이 GRASP 패턴들이 서로를 견제하며 함께 작동하는 방식이다.

판단 기준: 응집도를 높이려 클래스를 쪼갠 뒤 결합도가 올라갔다면, 쪼갠 조각들을 안정된 인터페이스로 묶어 사용하는 쪽을 그 인터페이스에만 의존시킨다. 함정: 인터페이스 없이 구체 클래스를 그냥 여러 개로 나누면, 응집도는 얻지만 사용처가 모든 구체 타입을 알게 되어 결합이 폭발한다. 분리와 추상화는 한 쌍으로 움직인다.

다형성으로 타입 분기를 흩는다

마지막 걸음이 가장 중요하다. if (condition.getType() == PERIOD) 같은 타입 검사 분기는 “새 타입이 생기면 여기도 고쳐야 한다”는 신호다. 조건 타입을 인터페이스와 구현 클래스로 나누면, 각 조건이 자기 판단(isSatisfiedBy)을 스스로 안다. 분기문이 사라진 자리에 다형성이 들어선다.

이것이 POLYMORPHISM 패턴이다 — 타입에 따라 달라지는 행동을 조건문이 아니라 타입 계층으로 표현한다. 순번 할인 조건(SequenceCondition)을 새로 추가해도 기존 코드는 한 줄도 건드리지 않는다. List<DiscountCondition>에 인스턴스를 하나 더 넣으면 끝이다 — 확장에는 열리고 변경에는 닫힌다.

판단 기준: switch/if-else같은 기준(여기선 조건 타입)으로 여러 곳에서 반복되면 다형성으로 흩을 후보다. 한 곳에서 한 번만 분기한다면 굳이 클래스로 쪼갤 필요는 없다. 함정: MoviecalculateDiscountAmount에는 아직 movieType 스위치가 남아 있다. 이것도 같은 냄새다 — 이 분기는 상속으로 흩는 게 자연스럽고, 10장에서 AmountDiscountMovie·PercentDiscountMovie로 갈라진다. 한 장에서 모든 분기를 없애려 들지 않는다.

책임 주도 설계와 리팩터링의 관계

책임 주도 설계는 처음부터 완벽한 구조를 뽑아내는 방법이 아니다. 오히려 이 장의 흐름 자체가 보여주듯, 먼저 동작하는 코드를 만들고 그것을 다듬어 나가는 과정과 자연스럽게 맞물린다. 4장의 ReservationAgency는 잘못됐지만 동작은 했다. 이 장은 그 동작하는 코드에서 출발해 책임을 재배치했다. 만약 책임 주도 설계가 어렵게 느껴진다면, 익숙한 절차적 코드로 먼저 짜고 나서 냄새를 찾아 리팩터링하는 것도 훌륭한 대안이다.

리팩터링으로 다듬을 때 눈여겨볼 두 가지 냄새가 있다. 하나는 몬스터 메서드다. 하나의 메서드가 너무 길고 많은 일을 하면, 그 메서드는 여러 책임이 뭉친 것이다. 긴 메서드는 이해하기 어렵고, 변경하기 어렵고, 재사용할 수 없다.

// 몬스터 메서드 — 하나의 메서드가 여러 수준의 일을 한꺼번에 한다
public Reservation reserve(Screening screening, Customer customer, int count) {
    // (1) 할인 여부 판단 ... 수십 줄
    // (2) 할인 금액 계산 ... 수십 줄
    // (3) 최종 요금 계산 ... 수십 줄
    // (4) 예매 생성 ...
}
 
// 분해 — 각 문단을 의도가 드러나는 이름의 메서드로 추출한다
public Reservation reserve(Screening screening, Customer customer, int count) {
    boolean discountable = isDiscountable(screening);
    Money fee = calculateFee(screening, discountable, count);
    return new Reservation(customer, screening, fee, count);
}

메서드를 잘게 나누면 각 조각의 응집도가 드러난다. 어떤 조각은 Screening이 하는 게 맞고, 어떤 조각은 Movie가 하는 게 맞다는 것이 보인다. 즉 몬스터 메서드를 응집도 있는 작은 메서드로 분해하는 것이, 책임을 올바른 객체로 옮기는 첫걸음이 된다. 메서드 수준의 응집도를 높이면 클래스 수준의 책임 재배치가 뒤따라 보인다.

판단 기준: 메서드가 여러 문단으로 나뉘어 각 문단에 주석을 달게 된다면, 그 문단들은 각각 별도 메서드로 추출할 후보다. 추출한 뒤 각 메서드가 어느 객체의 데이터를 주로 쓰는지 보면 옮길 곳이 보인다. 함정: 처음부터 완벽한 책임 분배를 하려고 설계를 붙들고 있지 않는다. 동작하는 코드를 만들고, 냄새를 따라 다듬는 반복이 책임 주도 설계의 실제 모습이다.

정리

  • 데이터를 먼저 나누면 절차가 한곳에 몰린다. 행동을 먼저 묻고, 그 행동에 필요한 정보를 가진 객체에게 책임을 준다 — 정보 전문가.
  • 게터의 개수는 캡슐화가 새는 구멍의 개수다. 데이터를 꺼내 밖에서 판단하는 대신, 데이터를 가진 객체에게 시킨다.
  • GRASP의 네 원칙(창조자·정보 전문가·낮은 결합·높은 응집)은 규칙이 아니라 서로를 견제하는 힘이다. 정보 전문가를 기계적으로 밀면 다시 God 클래스가 된다.
  • 같은 기준으로 반복되는 타입 분기는 다형성으로 흩는다. 확장은 새 클래스 추가로, 기존 코드 수정 없이.
  • movieType 분기는 아직 남았다. 이 실은 10장의 상속에서 마저 풀린다.

다음장으로 6장