객체지향 설계의 모든 결정은 하나의 질문에서 시작된다
책임 할당(GRASP)부터 메시지 원칙, 객체 분해, 의존성 관리까지 — 좋은 OOP 설계가 공유하는 하나의 철학을 추적한다.
- 01 객체는 데이터가 아니라 책임으로 정의된다
- 02 객체지향 설계의 모든 결정은 하나의 질문에서 시작된다
- 03 상속은 왜 코드 재사용의 도구가 아닌가
- 04 상속은 언제 올바른가 — 서브타이핑, LSP, 그리고 협력의 일관성
- 05 객체지향 설계의 세 가지 근본 질문
객체지향 설계를 배우다 보면 이런 말들을 듣게 된다. “책임 중심으로 설계하라”, “메시지가 객체를 선택해야 한다”, “추상화에 의존하라”. 각각 다른 챕터에서 다른 문제를 다루는 것처럼 보이지만, 실은 하나의 질문으로 수렴한다. “변경이 발생했을 때 얼마나 적은 곳을 고쳐야 하는가?” 이 글은 그 질문을 중심으로 네 개의 챕터를 하나의 흐름으로 읽는다.
데이터가 아니라 책임으로 시작하는 이유
대부분의 설계 실수는 클래스를 먼저 만들고 나서 메서드를 채우는 방식에서 시작된다. Movie 클래스가 필요할 것 같으니 만들고, 거기에 어떤 데이터가 필요할지 생각하고, 데이터를 꺼내는 get 메서드를 붙인다. 결과적으로 클래스는 데이터 홀더가 되고 비즈니스 로직은 Service로 모인다.
책임 주도 설계(RDD)는 순서를 뒤집는다. “영화 요금을 계산해야 한다”는 메시지를 먼저 정의하고, 그 메시지를 처리하기에 적합한 객체를 찾는다. GRASP의 Information Expert 패턴이 이 선택의 기준을 제공한다 — 책임을 수행하는 데 필요한 정보를 가진 객체에게 책임을 준다.
// 메시지를 먼저 정의하고, 정보를 가진 객체에게 할당
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
Money fee = movie.calculateMovieFee(this).times(audienceCount);
return new Reservation(customer, this, fee, audienceCount);
}
}
Screening이 Reservation을 만드는 이유는 GRASP의 Creator 패턴으로도 설명된다. Reservation 생성에 필요한 정보(상영 정보, 요금)를 가장 많이 아는 객체가 Screening이기 때문이다. 이렇게 결정을 내리면 새로운 결합도가 생기지 않는다.
Low Coupling과 High Cohesion 패턴은 Information Expert의 검증 도구다. 후보가 여럿일 때 “어떤 선택이 결합도를 낮추고 응집도를 높이는가?”를 확인해 최종 결정을 내린다. 항상 명확한 답이 있는 것은 아니며, 설계는 트레이드오프의 산물이다.
메시지가 인터페이스를 만드는 방식
책임 중심 설계의 자연스러운 귀결은 메시지 중심 인터페이스다. 객체를 먼저 만들고 메서드를 붙이면 인터페이스는 내부 구현을 따라간다. 메시지를 먼저 정의하고 객체를 선택하면 인터페이스는 클라이언트의 의도를 따라간다.
디미터 법칙(Law of Demeter)은 이 원칙의 구조적 표현이다. order.getCustomer().getAddress().getCity() 같은 기차 충돌은 Order의 내부 구조를 외부에 드러낸다. Customer나 Address의 구조가 바뀌면 이 호출 체인 전체가 깨진다. 올바른 방향은 order.isSeoulDelivery()처럼 의도를 드러내는 단일 메시지를 보내는 것이다.
“묻지 말고 시켜라(Tell, Don’t Ask)“는 같은 원칙의 행동 양식이다. 객체의 상태를 꺼내서 외부에서 판단하면, 그 판단 로직은 데이터를 가진 객체로부터 멀어진다. 결합도가 높아지고 응집도가 낮아진다.
명령-쿼리 분리(CQS)는 예측 가능성의 문제다. 상태를 변경하는 메서드(명령)와 값을 반환하는 메서드(쿼리)가 섞이면, 동일한 호출이 다른 결과를 낳는다. 아래 코드는 처음 호출에서 isSatisfied가 내부적으로 일정을 재조정하기 때문에 두 번째 호출에서 다른 값을 반환한다.
// 명령과 쿼리가 섞인 위험한 패턴
public boolean isSatisfied(RecurringSchedule schedule) {
if (/* 조건 불만족 */) {
reschedule(schedule); // ← 상태 변경!
return false;
}
return true;
}
분리하면 각 메서드는 예측 가능해지고, 쿼리 메서드는 몇 번을 호출해도 동일한 결과를 보장한다.
추상화 수준이 결합도를 결정한다
객체지향 설계에서 “무엇에 의존하는가”는 “얼마나 많은 것을 알아야 하는가”와 같다. Movie가 AmountDiscountPolicy에 직접 의존하면, Movie는 그 구체적인 구현까지 알아야 한다. 변경이 일어나면 Movie도 바뀐다.
인터페이스나 추상 클래스에 의존하면 Movie가 알아야 하는 것은 딱 하나다 — calculateDiscountAmount라는 메시지를 이해한다는 사실. 나머지는 런타임에 결정된다.
// 컴파일타임: 추상화에 의존
public class Movie {
private DiscountPolicy discountPolicy; // 인터페이스
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
// 런타임: 구체 인스턴스가 결정됨
Movie avatar = new Movie("아바타", ..., new AmountDiscountPolicy(...));
Movie titanic = new Movie("타이타닉", ..., new PercentDiscountPolicy(...));
new는 이 원칙을 파괴하는 주된 경로다. 내부에서 new AmountDiscountPolicy(...)를 호출하는 순간, Movie는 구체 클래스에 의존하게 되고 그 생성자 인자까지 알아야 한다. 해결책은 사용과 생성의 책임 분리다 — 객체 생성은 클라이언트가, 사용은 내부에서.
의존성이 명시적으로 드러나야 한다는 원칙도 같은 맥락이다. 생성자 시그니처에 DiscountPolicy discountPolicy가 있으면 코드를 읽는 것만으로 의존 관계를 파악할 수 있다. 내부에서 몰래 생성하면 파악하기 위해 구현을 열어야 한다.
다형성이 조건문을 대체하는 이유
급여 시스템을 절차적으로 작성하면 어떻게 되는가. 정규직과 아르바이트를 구분하는 if문이 calculatePay, sumOfBasePays, 그리고 새로 추가될 모든 메서드에 등장한다. 새로운 직원 유형이 생길 때마다 모든 메서드를 수정해야 한다.
# 추상 데이터 타입(ADT)의 한계 — 타입 체크가 퍼져나간다
def calculatePay(taxRate)
if (hourly) then calculateHourlyPay(taxRate)
else calculateSalariedPay(taxRate)
end
end
클래스 기반 다형성은 이 문제를 구조적으로 해결한다. SalariedEmployee와 HourlyEmployee가 각자 calculatePay를 구현하면, 호출하는 쪽은 타입을 알 필요가 없다. 새로운 유형을 추가할 때 기존 코드는 건드리지 않는다 — 개방-폐쇄 원칙(OCP)이 자연스럽게 달성된다.
GRASP의 Polymorphism 패턴은 이것을 원칙으로 표현한다. 타입에 따라 행동이 달라진다면, 조건문 대신 다형성을 사용하라. Protected Variations 패턴은 한 발 더 나간다. 변하는 부분(구체적인 할인 조건)을 인터페이스 뒤로 숨겨서, 변하지 않는 부분(Movie의 요금 계산 로직)이 영향받지 않도록 보호한다.
정리
네 챕터를 관통하는 철학은 하나다. 변경의 영향을 가능한 좁은 범위에 가두어라.
- 책임을 정보를 가진 객체에 할당하면, 변경이 필요한 곳이 명확해진다.
- 메시지 중심 인터페이스를 만들면, 내부 구현이 바뀌어도 협력은 유지된다.
- 추상화에 의존하면, 구체 구현이 교체되어도 의존하는 쪽은 영향을 받지 않는다.
- 다형성으로 타입을 캡슐화하면, 새로운 타입 추가가 기존 코드를 건드리지 않는다.
설계 원칙들은 서로 다른 관점에서 같은 목표를 향한다. 원칙을 외우는 것보다 “이 결정이 변경을 어디에 가두는가?”를 물어보는 습관이 더 중요하다.
다음 글에서는 의존성 역전 원칙(DIP)과 개방-폐쇄 원칙(OCP)이 구체적으로 어떻게 연결되는지, 그리고 Factory 패턴이 이 두 원칙을 동시에 어떻게 달성하는지 살펴본다.