2장에서 객체지향답게 짠 영화 예매 시스템을, 이 장에서는 일부러 데이터 중심으로 다시 짠다. 목적은 나쁜 코드를 감상하는 게 아니라, 왜 나쁜지를 이름 붙여 진단하는 것이다. 조영호는 응집도와 결합도라는 잣대로 같은 문제의 두 설계를 저울에 올린다. 여기서 만들어지는 파탄한 코드가 5장에서 책임 주도 설계로 되살아나는 출발점이 된다 — 그러니 이 장의 마지막 상태를 잘 기억해 둘 값어치가 있다.

데이터부터 정하면 벌어지는 일

데이터 중심 설계는 “이 객체가 어떤 데이터를 가질까”에서 시작한다. 이 출발점은 자연스러워 보인다 — 객체를 만들라고 하면 대부분 “이 객체엔 어떤 필드가 있어야 하지?”부터 떠올리기 때문이다. 그리고 필드를 정하면 거의 반사적으로 그 필드마다 getter와 setter를 단다. IDE가 한 번의 단축키로 만들어 주기까지 하니, 이 관행은 의심 없이 몸에 밴다.

문제는 이 반사가 3장이 세운 순서를 정반대로 뒤집는다는 데 있다. 책임 주도 설계는 협력 → 책임 → 역할 → 객체(데이터)로 흐르는데, 데이터 중심 설계는 데이터에서 출발해 그 데이터를 꺼내 쓸 로직을 나중에 어딘가에 욱여넣는다. 협력을 고려하지 않고 객체를 홀로 정의하니, 그 객체는 협력에 쓸모 있는 행동 대신 데이터를 내놓는 창구(getter)만 갖게 된다.

Movie는 요금·할인 종류·할인 금액·할인 비율을 필드로 갖고, 그걸 꺼내 쓰라고 getter를 연다. DiscountCondition은 기간이냐 순번이냐를 나타내는 타입과 요일·시간·순번을 담는다. 로직은? 데이터를 꺼내다 쓸 누군가 — ReservationAgency — 에게 몰아준다.

Refactoring Step 모든 것을 getter로 노출하는 데이터 클래스
1/3
public enum MovieType { AMOUNT_DISCOUNT, PERCENT_DISCOUNT, NONE_DISCOUNT }public class Movie {    private Money fee;    private MovieType movieType;    private Money discountAmount;    private double discountPercent;    private List<DiscountCondition> discountConditions;    public Money getFee() { return fee; }    public MovieType getMovieType() { return movieType; }    public Money getDiscountAmount() { return discountAmount; }    public double getDiscountPercent() { return discountPercent; }    public List<DiscountCondition> getDiscountConditions() { return discountConditions; }}public class DiscountCondition {    private DiscountConditionType type;    private int sequence;    private DayOfWeek dayOfWeek;    private LocalTime startTime;    private LocalTime endTime;    // ... 모든 필드에 getter. 아는 것도, 하는 것도 없이 데이터만 담는다.}// 데이터는 있는데 행동이 없다. 이 데이터를 다룰 책임은 어디로 갔나?

진단 — 세 가지 냄새

동작은 한다. 그러나 세 가지가 무너졌다.

캡슐화 붕괴. MovieDiscountCondition도 자기 데이터를 getter로 죄다 열어 놨다. 데이터를 숨기고 행동만 노출하는 것이 캡슐화인데, 이 클래스들은 행동이 없고 데이터만 있다. 필드 하나를 startTime에서 beginTime으로 바꾸면 그걸 꺼내 쓰던 ReservationAgency가 깨진다 — 내부 표현의 변경이 바깥으로 새어 나간다.

높은 결합도. ReservationAgency.reserve 하나가 Movie, Screening, DiscountCondition의 내부 구조에 전부 묶여 있다. 세 클래스 중 어느 하나의 데이터 표현이 바뀌어도 Agency를 고쳐야 하고, 반대로 할인 정책이 하나 늘면 switch를 찾아 열어야 한다. 변경의 파급이 사방으로 번진다.

낮은 응집도. reserve 한 메서드 안에 “할인 조건 판별”과 “요금 계산”이라는 서로 다른 관심사가 뒤엉켜 있다. 조건 판별 방식이 바뀌어도, 할인 계산 방식이 바뀌어도, 똑같은 이 메서드를 열어야 한다. 하나의 모듈이 여러 이유로 바뀐다면 응집도가 낮은 것이다.

판단 기준: “이 클래스가 데이터만 있고 행동이 없다”면 데이터 중심 설계의 징후다. 로직은 어딘가 다른 곳에서 그 데이터를 캐물으며 돌고 있다. 함정: getter/setter를 기계적으로 다는 관행은 캡슐화를 흉내만 낸다 — 필드를 private으로 두고 getX()로 다 열면, 사실상 public 필드와 다를 게 없다.

응집도와 결합도를 재는 감각

앞의 진단에서 쓴 두 잣대를 좀 더 또렷이 정의하자. 이 둘은 설계 품질을 저울에 올릴 때 늘 함께 쓰는 짝이다.

결합도는 한 모듈이 다른 모듈에 얼마나 많이 알고 의존하는가다. 재는 감각은 이렇다 — “이 모듈을 이해하거나 변경하려 할 때, 함께 열어 봐야 하는 다른 모듈이 몇 개인가.” ReservationAgency.reserve를 이해하려면 Movie, Screening, DiscountCondition의 내부 필드 구조를 전부 알아야 한다. 함께 열어야 하는 모듈이 셋이니 결합도가 높다. 결합도가 높으면 어느 하나가 바뀔 때 이 모듈도 바뀔 확률이 그만큼 커진다.

응집도는 한 모듈 안의 요소들이 하나의 목적을 향해 얼마나 밀접하게 관련되어 있는가다. 재는 감각은 “이 모듈이 바뀌는 이유가 몇 가지인가”다. reserve는 할인 조건 판별 방식이 바뀌어도, 할인 계산 방식이 바뀌어도, 새 할인 종류가 생겨도 바뀐다. 바뀌는 이유가 여럿이니 응집도가 낮다. 응집도가 높은 모듈은 한 가지 이유로만 바뀐다.

두 잣대는 대개 함께 움직인다. 데이터와 그것을 다루는 책임을 한 객체에 모으면 응집도가 오르고, 그 결과 다른 객체의 내부를 캐물을 일이 줄어 결합도가 내린다. 높은 응집도와 낮은 결합도가 좋은 설계의 방향이며, 이 장의 파탄한 코드는 정확히 그 반대편에 서 있다.

판단 기준: 변경 시나리오를 몇 개 던져 “이 변경에 몇 개의 모듈을 열어야 하나(결합도), 이 모듈은 몇 가지 이유로 열리나(응집도)“를 세어 보면 두 값을 체감할 수 있다. 함정: 결합도와 응집도를 코드 줄 수나 클래스 개수 같은 정적 지표로 재려 하면 헛다리다 — 둘 다 “변경”이라는 동적 상황에서만 드러난다.

캡슐화의 진짜 의미 — 변할 수 있는 모든 것을 감춘다

여기서 흔한 오해를 하나 걷어내야 한다. 캡슐화를 “필드를 private으로 감추는 것”으로만 이해하면 절반만 안 것이다. 캡슐화의 진짜 의미는 변경될 수 있는 어떤 것이라도 감추는 것이다. 그 대상은 데이터(필드)에 국한되지 않는다. 구현 방식, 타입, 클래스의 존재, 객체 생성 방법 — 바뀔 여지가 있는 것이라면 무엇이든 캡슐화의 대상이다.

이 관점에서 보면 MovieType이라는 열거형이 바깥에 노출되는 것 자체가 캡슐화 위반이다. MovieType은 “할인 방식이 세 종류다”라는, 언제든 바뀔 수 있는 구현상의 결정이다. 그런데 ReservationAgencymovie.getMovieType()으로 그 타입을 꺼내 switch를 돌리는 순간, 할인 종류라는 변경 가능한 결정이 바깥으로 새어 나가 Agency에 못박힌다. 필드를 감췄는지 여부와 무관하게, 변할 수 있는 결정이 노출된 것이다.

판단 기준: “이것이 바뀌면 바깥 코드가 함께 바뀌는가”를 물어, 그렇다면 그것은 감춰야 할 대상이다. 필드든 타입이든 분기 조건이든 마찬가지다. 함정: private 필드 뒤에 숨겼다고 안심하지 말 것 — getter로 그 값을 그대로 내보내거나, 타입 상수를 노출하면 감춘 것이 도로 새어 나온다.

캡슐화 위반은 어떻게 번지는가

추상적으로 “결합도가 높다”고 말하는 대신, 실제 변경 하나를 던져 파급을 따라가 보자. 요구가 바뀌어 “중복 할인” — 금액 할인과 비율 할인을 동시에 적용하는 네 번째 정책 — 을 추가한다고 하자. 데이터 중심 코드에서 손대야 할 곳을 세어 본다.

  1. MovieTypeOVERLAP_DISCOUNT 상수를 추가한다.
  2. Movie에 중복 할인 계산에 필요한 새 필드(들)를 추가하고, 그 getter를 연다.
  3. ReservationAgency.reserveswitchOVERLAP_DISCOUNT 케이스를 추가한다.
  4. 그 케이스의 계산식이 기존 Movie의 필드 조합에 의존하므로, Movie의 다른 getter들까지 다시 확인해야 한다.

하나의 개념적 변경(“정책 하나 추가”)이 서로 다른 여러 파일의 여러 지점으로 흩어져 번진다 — 이것이 산탄총 수술이다. 게다가 이 지점들 중 하나라도 빠뜨리면 컴파일은 되지만 런타임에 틀린 요금이 나온다. 변경 지점이 흩어졌다는 것은, 변경할 때마다 그 흩어진 지점을 빠짐없이 찾아내야 하는 부담을 개발자에게 떠넘긴다는 뜻이다. 2장의 설계였다면 DiscountPolicy를 상속한 클래스 하나를 추가하는 것으로 끝났을 일이다.

판단 기준: 하나의 요구 변경이 몇 개의 파일, 몇 개의 지점을 건드리는지 세어 보면 캡슐화의 건강 상태가 드러난다. 한 곳이면 건강하고, 사방으로 번지면 캡슐화가 뚫린 것이다. 함정: “그냥 case 하나 추가인데” 하고 가볍게 여기기 쉽지만, 그 case가 요구하는 데이터·getter·계산식까지 따라 번지는 연쇄가 진짜 비용이다.

두 번째 시도도 무너진다

날카로운 독자라면 이렇게 반박할 것이다 — “그럼 데이터를 가진 객체가 스스로 판단하게 고치면 되잖아.” 조영호는 이 반론을 예상하고, 데이터 중심 설계의 두 번째 시도를 보여 준다. ReservationAgency가 조건을 판별하는 대신, DiscountCondition이 스스로 판별하도록 책임을 돌려주는 것이다.

public class DiscountCondition {
    private DiscountConditionType type;   // 여전히 타입이 필드로 있다
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
 
    public DiscountConditionType getType() { return type; }
 
    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
        if (type != DiscountConditionType.PERIOD) {
            throw new IllegalArgumentException();
        }
        return this.dayOfWeek.equals(dayOfWeek)
            && this.startTime.compareTo(time) <= 0
            && this.endTime.compareTo(time) >= 0;
    }
 
    public boolean isDiscountable(int sequence) {
        if (type != DiscountConditionType.SEQUENCE) {
            throw new IllegalArgumentException();
        }
        return this.sequence == sequence;
    }
}

이제 판별 로직이 DiscountCondition 안으로 들어왔으니 나아진 듯 보인다. 그러나 파탄은 형태만 바꿔 그대로 남는다. 문제는 isDiscountable이 두 벌로 갈려 있고, 어느 것을 불러야 할지 정하려면 호출자가 여전히 getType()으로 타입을 확인해야 한다는 데 있다.

// 사용하는 쪽은 결국 이렇게 된다 — 타입을 되묻어 분기한다.
if (condition.getType() == DiscountConditionType.PERIOD) {
    condition.isDiscountable(dayOfWeek, time);
} else {
    condition.isDiscountable(sequence);
}

DiscountConditionType이라는 내부 구현이 여전히 퍼블릭 인터페이스(getType, 그리고 시그니처가 다른 두 isDiscountable)를 통해 바깥으로 새어 나온다. 조건의 종류가 하나 늘면 isDiscountable이 또 갈라지고, 이 분기하는 호출부가 전부 깨진다. 책임을 객체 안으로 옮겼는데도 캡슐화가 여전히 뚫려 있는 것이다. 진단하자면, 인터페이스가 내부의 타입 구분(구현)에 의존하도록 설계된 것이 병의 뿌리다.

판단 기준: 메서드를 객체 안으로 옮겼더라도, 그것을 호출하려고 바깥이 객체의 내부 타입·상태를 되물어야 한다면 캡슐화는 여전히 깨져 있다. 함정: “로직을 객체 안으로 넣었으니 객체지향이다”라고 안심하는 것 — 위치만 옮겼을 뿐 인터페이스가 구현을 노출하면 데이터 중심의 병폐는 그대로다.

캡슐화를 흉내 내도 소용없는 이유

getter를 없애고 필드를 진짜 숨기면 해결될까. 아니다. 문제의 뿌리는 접근 제어자가 아니라 책임이 데이터에서 떨어져 있다는 것이다. 할인 여부를 판별할 정보(할인 조건)는 DiscountCondition이 가졌는데, 그 판별 책임은 ReservationAgency가 가로챘다. 3장의 언어로 말하면, 정보 전문가가 아닌 자에게 책임이 갔다. 그러니 아무리 필드를 숨겨도 Agency가 그 데이터를 필요로 하는 한, 결국 getter를 다시 열게 된다.

되살리는 길은 방향을 뒤집는 것이다 — 데이터를 가진 객체에게 그 데이터를 다루는 책임까지 돌려주기. DiscountCondition이 “나는 이 상영에 해당하는가”를 스스로 답하고, Movie가 “내 요금은 얼마인가”를 스스로 계산하게 하면, Agency는 조율만 하면 된다. 그것이 5장의 책임 주도 설계다.

데이터 중심 설계가 변경에 취약한 두 가지 이유

이 장을 관통하는 실패를 조영호는 두 가지 근본 원인으로 정리한다. 증상(캡슐화 붕괴·높은 결합도·낮은 응집도)은 이 두 원인에서 파생된 것이다.

첫째, 객체의 상태(구현)를 인터페이스에 노출한다. 데이터 중심 설계는 데이터를 먼저 정하고 그 데이터를 꺼내는 창구를 인터페이스로 삼는다. 그래서 인터페이스가 곧 내부 상태의 지도가 된다 — getFee, getMovieType, getDiscountConditions. 인터페이스가 구현(상태)에 묶여 있으니, 상태가 바뀌면 인터페이스가 바뀌고, 인터페이스에 의존하던 모든 코드가 함께 무너진다. 두 번째 시도에서 getType과 두 벌의 isDiscountable이 그대로 이 함정에 빠졌다. 반대로 좋은 인터페이스는 “무엇을 할 수 있는가”(행동)를 드러낼 뿐 “무엇을 담고 있는가”(상태)를 감춘다.

둘째, 협력을 고려하지 않고 객체를 고립시켜 정의한다. 데이터 중심 설계는 “이 객체가 어떤 데이터를 가질까”를 그 객체 하나만 놓고 결정한다. 협력의 맥락 없이 홀로 정의된 객체는 어떤 행동이 필요한지 알 길이 없어, 결국 데이터를 내놓는 것 말고 할 수 있는 게 없다. 그러면 실제 로직은 그 데이터를 그러모으는 제3의 객체(ReservationAgency)로 몰리고, 데이터를 가진 객체들은 수동적 창고로 전락한다. 3장이 “협력이 먼저”라고 못박은 이유가 여기서 증명된다 — 협력을 먼저 그렸다면 각 객체가 협력에서 맡을 행동(책임)이 정해졌을 것이고, 데이터는 그 행동에 필요한 만큼만 딸려 왔을 것이다.

판단 기준: 인터페이스가 객체의 상태가 아니라 행동을 말하고 있는가, 그리고 그 객체가 협력의 맥락에서 정의되었는가 — 이 둘을 물으면 설계가 데이터 중심인지 책임 중심인지 갈린다. 함정: 두 원인은 한 뿌리다 — 협력을 무시하고 데이터부터 정하면(둘째), 그 객체가 내놓을 것은 상태밖에 없어 인터페이스가 상태를 노출하게 된다(첫째). 하나만 고쳐서는 벗어날 수 없다.

  • 데이터 중심 설계는 “어떤 데이터를 가질까”에서 시작해, 데이터는 수동적 덩어리가 되고 로직은 한 곳(ReservationAgency)으로 몰린다.
  • 증상은 세 가지 — 캡슐화 붕괴(getter 남발), 높은 결합도(한 메서드가 여러 객체 내부에 묶임), 낮은 응집도(한 메서드에 여러 관심사).
  • 근본 원인은 두 가지 — 상태(구현)를 인터페이스에 노출한 것, 협력을 무시하고 객체를 고립시켜 정의한 것. 접근 제어자를 조인다고 풀리지 않는다.
  • 캡슐화는 필드만이 아니라 변할 수 있는 모든 것(타입·구현·분기 조건)을 감추는 것이다. 책임을 정보 전문가에게 돌려주는 5장이 해법이다.

다음장으로 5장