← all posts
DEV 2026.05.05 · 16 min read Intermediate

객체지향 설계의 세 가지 근본 질문

계약에 의한 설계로 협력을 명시하고, 타입 계층으로 행동을 분류하고, 동적 협력으로 정적 코드를 주도하는 세 원칙이 하나의 철학으로 수렴하는 과정을 추적한다.


《오브젝트》 부록 세 편은 제각각 다른 주제를 다루는 것처럼 보인다 — 계약, 타입 계층, 동적 모델. 그러나 읽고 나면 세 편이 동일한 질문의 다른 각도임을 알게 된다. “객체는 무엇을 약속하고, 어떻게 분류되며, 무엇이 코드 구조를 결정하는가?” 이 세 질문에 동시에 답하지 못하면 객체지향 설계는 반쪽이다.

계약: 인터페이스가 말하지 못하는 것

인터페이스는 메서드 이름, 파라미터 타입, 반환 타입을 알려준다. 그러나 호출 순서, 파라미터 제약, 실행 후 객체 상태는 알려주지 않는다. 이 간극을 메우는 것이 계약에 의한 설계(Design By Contract)다.

계약은 세 요소로 구성된다. **사전조건(Precondition)**은 클라이언트의 의무다 — 메서드를 호출하기 전에 만족시켜야 할 조건. **사후조건(Postcondition)**은 서버의 의무다 — 메서드 실행 후 클라이언트에게 보장해야 할 상태. **불변식(Invariant)**은 생성자 실행 후부터 객체가 소멸할 때까지 항상 참이어야 하는 조건이다.

public class Screening {
    public Reservation reserve(Customer customer, int audienceCount) {
        // 사전조건 — 클라이언트 의무
        assert customer != null;
        assert audienceCount >= 1;

        Reservation result = new Reservation(customer, this,
            calculateFee(audienceCount), audienceCount);

        // 사후조건 — 서버 의무
        assert result != null;
        return result;
    }
}

주석보다 assert가 낫고, assert보다 Code Contracts가 낫다. 핵심은 제약 조건을 코드와 함께 진화시키는 것이다. 주석은 구현과 동기화가 보장되지 않지만, 실행 가능한 계약은 위반 즉시 알린다 — Fail Fast.

계약과 캡슐화

불변식을 유지하려면 인스턴스 변수를 private으로 보호하고 protected 메서드로만 접근을 허용해야 한다. protected 변수를 그대로 노출하면 서브클래스가 불변식을 우회할 수 있다.

타입 계층: LSP는 계약의 다른 이름이다

타입을 구현하는 방법은 여러 가지다 — 클래스 상속, 인터페이스 구현, 추상 클래스, 덕 타이핑, 믹스인. 그러나 어떤 방법을 쓰든 리스코프 치환 원칙(LSP)을 지키지 않으면 타입 계층이 아니라 타입 흉내에 불과하다.

LSP는 결국 계약 준수다.

명제 1 · 서브타입 계약 규칙

서브타입은 슈퍼타입의 사전조건을 강화할 수 없고(완화만 가능), 사후조건을 완화할 수 없으며(강화만 가능), 슈퍼타입의 불변식을 반드시 유지해야 한다.

▷ 증명

사전조건을 강화하면 슈퍼타입 기준으로 작성된 클라이언트 코드가 서브타입에서 실패한다. 사후조건을 완화하면 클라이언트가 기대한 보장이 사라진다. 불변식이 깨지면 객체의 일관성이 무너진다. 세 경우 모두 서브타입을 슈퍼타입 자리에 치환할 수 없으므로 LSP 위반이다.

가변성 규칙도 같은 맥락이다. 서브타입의 리턴 타입은 공변성 — 슈퍼타입의 리턴 타입의 서브타입을 반환할 수 있다(더 구체적이므로 클라이언트에 손해가 없다). 파라미터 타입은 이론적으로 반공변성 — 슈퍼타입의 파라미터의 슈퍼타입을 받을 수 있다(더 일반적이므로 사전조건이 완화된다). Java는 반공변성을 언어 차원에서 지원하지 않지만, 원칙 자체는 여전히 유효하다.

타입 계층을 구현할 때 “인터페이스 + 골격 구현 추상 클래스” 조합이 강력한 이유도 여기에 있다. 인터페이스는 계약(타입)을 정의하고, 추상 클래스는 공통 구현을 제공한다. 덕 타이핑은 명시적 계약 없이 행동만으로 타입을 판단하므로 유연하지만, LSP 위반을 컴파일 타임에 잡아낼 수 없다.

동적 협력이 정적 코드를 결정한다

세 편 중 가장 근본적인 통찰은 Appendix C에 있다. 정적 모델(코드 구조)은 동적 모델(런타임 협력)의 그림자여야 한다.

흔한 실수는 타입부터 설계하는 것이다.

❌ 클래스 우선: "어떤 클래스가 필요할까?" → Movie, Actor, Director ...
✅ 협력 우선: "무엇을 해야 하는가?" → 가격 계산, 할인 적용, 예매 생성

클래스 우선 설계는 데이터 중심 설계로 흘러가고, 책임이 불명확한 빈 껍데기 객체들이 생긴다. 협력 우선 설계는 “누가 이 메시지를 처리해야 하는가”를 묻고, 행동에서 책임이, 책임에서 타입이, 타입에서 클래스가 자연스럽게 나온다.

도메인 모델은 출발점이지 목적지가 아니다. 협력을 설계하다 보면 도메인 모델에 없던 타입이 필요해지고(예: DiscountCondition), 도메인 모델에 있던 개념이 코드에서 다른 형태로 나타난다. 코드에 맞춰 도메인 모델을 진화시키는 것이 올바른 방향이다.

TYPE OBJECT 패턴은 이 원칙의 극단적 사례다. 몬스터 종류마다 클래스를 만드는 대신 Breed 객체로 타입을 표현하면, 새 종류를 추가할 때 코드가 아니라 데이터만 바꾼다.

// 상속 기반: 새 몬스터마다 새 클래스 → 컴파일 → 배포
class Dragon extends Monster { ... }

// TYPE OBJECT: 새 몬스터는 데이터로
Breed dragonBreed = new Breed("", 230, "용은 불을 내뿜는다");
Monster dragon = new Monster(dragonBreed);

트레이드오프

세 원칙은 각각 비용이 따른다.

계약에 의한 설계는 런타임 오버헤드와 작성 비용이 있다. Java의 assert-ea 플래그 없이는 비활성화되고, Code Contracts(C#)는 별도 툴체인이 필요하다. 실용적 타협안은 “퍼블릭 API의 사전조건은 항상 검사, 내부 메서드는 선택적”이다.

타입 계층은 추상화 수준이 올라갈수록 이해하기 어렵다. “인터페이스 + 추상 클래스” 조합은 강력하지만 클래스 수가 늘어난다. 덕 타이핑은 간결하지만 타입 안전성을 포기한다. Java에서 디폴트 메서드로 믹스인을 구현하면 캡슐화가 약해진다 — 내부에서만 써야 할 메서드가 public으로 노출된다.

트레이드오프

동적 협력 우선 설계는 초기에 “코드가 너무 일찍 바뀐다”는 불편함을 동반한다. 도메인 모델에 없는 타입이 생기고, 있던 타입이 합쳐진다. 이 불안을 견디는 것이 협력 중심 설계의 실천이다. 안정적인 것은 타입이 아니라 협력의 목적이다.

정리

세 편의 부록은 결국 하나의 명제로 수렴한다 — 객체는 계약으로 협력하고, 타입으로 분류되며, 협력이 코드 구조를 결정한다.

  • 계약(사전조건·사후조건·불변식)은 인터페이스가 표현하지 못하는 협력의 제약을 코드 안에 새긴다.
  • LSP는 계약 준수의 다른 이름이다. 사전조건을 강화하거나 사후조건을 완화하는 서브타입은 타입이 아니다.
  • 정적 모델(클래스)은 동적 모델(협력)을 따라가야 한다. 타입부터 설계하면 협력 없는 껍데기가 된다.
  • 도메인 모델은 출발점이다. 협력이 요구하는 방향으로 코드와 함께 진화한다.

객체지향에서 “어떻게 구현할까”보다 “무엇을 약속하고 어떻게 협력할까”가 먼저인 이유가 여기에 있다.