• oop
  • design
  • java
  • book

오브젝트 Ch.6 — 메시지와 인터페이스

메시지·오퍼레이션·메서드의 구분, 디미터 법칙과 그 한계, 명령-쿼리 분리 원칙을 예제 코드와 함께 정리한다.

시리즈 · BackEnd 서적13 / 17
  1. SQL 레벨업 Ch.1 — DBMS 아키텍처
  2. SQL 레벨업 Ch.3 — SQL의 조건 분기
  3. SQL 레벨업 Ch.4~5 — 집약과 자르기, 반복문
  4. SQL 레벨업 Ch.6 — 결합
  5. SQL 레벨업 Ch.7 — 서브쿼리
  6. SQL 레벨업 Ch.8 — SQL의 순서
  7. SQL 레벨업 Ch.9 — 갱신과 데이터 모델
  8. SQL 레벨업 Ch.10 — 인덱스 사용
  9. 오브젝트 Ch.1 — 객체, 설계
  10. 오브젝트 Ch.2 — 객체지향 프로그래밍
  11. 오브젝트 Ch.3~4 — 역할, 책임, 협력 / 설계 품질과 트레이드오프
  12. 오브젝트 Ch.5 — 책임 할당하기
  13. 오브젝트 Ch.6 — 메시지와 인터페이스
  14. 오브젝트 Ch.8 — 의존성 관리하기
  15. 오브젝트 Ch.9 — 유연한 설계
  16. 오브젝트 Ch.10, 12 — 상속과 코드 재사용, 다형성
  17. 오브젝트 Ch.13 — 서브클래싱과 서브타이핑

클래스는 도구에 불과하다. 애플리케이션은 클래스의 집합이 아니라 메시지를 통해 정의되며, 객체지향 애플리케이션의 가장 중요한 재료는 객체들이 주고받는 메시지, 즉 객체가 수행하는 책임이다.

메시지, 오퍼레이션, 메서드

  • 메시지: 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단. 오퍼레이션명과 인자로 구성된다.
  • 메시지 전송(패싱): 한 객체가 다른 객체에게 도움을 요청하는 것. 수신자와 메시지로 구성된다.
condition.isSatisfiedBy(screening)

condition이 수신자, isSatisfiedBy가 오퍼레이션명, screening이 인자다. condition은 DiscountCondition 인터페이스 타입으로 선언돼 있지만 실제로 실행되는 코드는 인스턴스의 클래스에 따라 달라진다. PeriodCondition의 인스턴스라면 PeriodCondition에 구현된 isSatisfiedBy 메서드가 실행된다.

오퍼레이션 하나에 메서드가 하나뿐이라면 둘을 구분할 필요가 없다. 하지만 오퍼레이션의 관점에서 다형성이란 동일한 오퍼레이션 호출에 대해 서로 다른 메서드들이 실행되는 것이므로, 이 구분이 다형성을 이해하는 바탕이 된다.

인터페이스와 설계 품질

좋은 인터페이스는 최소한의 인터페이스이면서 추상적인 인터페이스다.

  • 최소한의 인터페이스: 꼭 필요한 오퍼레이션만 포함한다.
  • 추상적인 인터페이스: 어떻게 수행하는지가 아니라 무엇을 하는지를 표현한다.

책임 주도 설계에서는 메시지를 먼저 선택하므로 협력과 무관한 오퍼레이션이 인터페이스에 스며드는 것을 막을 수 있다.

디미터 법칙

객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 원칙이다. “낯선 자에게 말하지 말라”, “오직 인접한 이웃하고만 말하라”로 요약된다. 메서드는 다음 대상에게만 메시지를 전송해야 한다.

  • this 객체
  • 메서드의 매개변수
  • this의 속성
  • this의 속성인 컬렉션의 요소
  • 메서드 내에서 생성된 지역 객체

캡슐화 원칙이 클래스 내부의 구현을 감춰야 한다는 점을 강조한다면, 디미터 법칙은 협력하는 클래스의 캡슐화를 지키기 위해 접근할 수 있는 요소를 제한한다.

// 디미터 법칙을 위반하는 코드: 기차 충돌(train wreck)
screening.getMovie().getDiscountConditions();

// 수정된 코드
screening.calculateFee(audienceCount);

위반 코드는 수신자의 내부 구조를 물어보고, 반환받은 요소에 연쇄적으로 메시지를 전송한다.

원칙의 함정

디미터 법칙과 “묻지 말고 시켜라”는 훌륭한 설계 원칙이지만 절대적인 법칙은 아니다. 설계는 트레이드오프의 산물이다. 원칙을 아는 것보다 언제 그 원칙이 유용하고 언제 유용하지 않은지 판단하는 능력이 더 중요하다.

그렇다면 아래 코드는 디미터 법칙 위반일까?

IntStream.of(1, 15, 20, 3, 9).filter(x -> x > 10).distinct().count();

그렇지 않다. of, filter, distinct는 모두 IntStream이라는 동일한 클래스의 인스턴스를 반환한다. IntStream을 다른 IntStream으로 변환할 뿐, 객체를 둘러싼 캡슐은 그대로 유지된다. 점의 개수가 아니라 내부 구조가 노출되는지가 기준이다.

명령-쿼리 분리 원칙

  • 명령(프로시저): 객체의 상태를 수정하는 오퍼레이션. 부수효과를 발생시키지만 값을 반환하지 않는다.
  • 쿼리(함수): 객체와 관련된 정보를 반환하는 오퍼레이션. 값을 반환하지만 상태를 변경하지 않는다.

다음은 이 원칙을 어긴 코드다.

public class Event {
    private String subject;
    private LocalDateTime from;
    private Duration duration;

    public boolean isSatisfied(RecurringSchedule schedule) {
        if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
                !from.toLocalTime().equals(schedule.getFrom()) ||
                !duration.equals(schedule.getDuration())) {
            reschedule(schedule); // 쿼리 안에서 상태를 변경한다
            return false;
        }

        return true;
    }
}

isSatisfied는 값을 반환하면서 Event의 상태도 수정한다. 명령과 쿼리 두 역할을 동시에 수행하는 것이다. 같은 인자로 두 번 호출하면 처음에는 false, 다음에는 true가 나오므로 결과를 예측하기 어렵고 디버깅도 힘들다.

해결책은 둘을 분리하고, 상태 변경 여부를 호출하는 쪽이 결정하게 하는 것이다.

public boolean isSatisfied(RecurringSchedule schedule) {
    if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
            !from.toLocalTime().equals(schedule.getFrom()) ||
            !duration.equals(schedule.getDuration())) {
        return false;
    }

    return true;
}

// Event를 사용하는 쪽
if (!event.isSatisfied(schedule)) {
    event.reschedule(schedule);
}

명령과 쿼리를 분리하면 코드는 예측 가능하고 이해하기 쉬워지며, 디버깅과 유지보수가 수월해진다.

명령형 프로그래밍과 함수형 프로그래밍

  • 명령형 프로그래밍: 상태를 변경시키는 연산들을 적절한 순서대로 나열해 프로그램을 작성한다.
  • 함수형 프로그래밍: 부수효과가 없는 수학적인 함수에 기반한다. 참조 투명성의 장점을 극대화할 수 있어 실행 결과를 이해하고 예측하기가 더 쉽다.