05. 메세지와 인터페이스

jrun
Research Team — DAWN
9 min readJun 1, 2022

--

객체지향에서 가장 중요한 것은 객체들이 주고받는 메시지입니다. 그리고 이 메시지들이 객체의 퍼블릭 인터페이스를 구성하는데, 유연하고 재사용가능한 인터페이스는 어떻게 만들 수 있을까요?

1. 협력과 메시지

협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작됩니다. 그리고 이런 요청, 응답에 대한 전통적인 메타포는 클라이언트-서버 모델입니다. 또한 여기에 참여하는 객체는 클라이언트임과 동시에 서버가 될 수 있습니다.

앞선 예시에서 Screening → Movie → DiscountPolicy의 요청 구조를 가진 것처럼 말이죠.

메시지와 메시지 전송

메시지는 객체의 협력에 사용되는 유일한 의사소통 수단입니다.
메시지는 오페레이션명 + 인자로 구성되며
메시지 전송은 여기에 메시지 수신자를 더한 것입니다.

Java : condition.isSatisfied(screening) == 수신자.오퍼레이션명(인자)

메시지와 메서드

메시지를 수신했을 때 실제 어떤 코드가 실행되는가, 이는 메시지 수신자의 실제 타입이 무엇인가에 달려있습니다. 이렇게 실제 실행되는 함수 또는 프로시저를 메서드라고 부릅니다.

메시지와 메서드의 구분은 객체들의 느슨한 결합을 가능하게 합니다. 송신자와 수신자는 서로를 알 필요가 없습니다.

2. 인터페이스와 설계 품질

좋은 인터페이스에 대해 다시 말해보면 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 만족해야 합니다. 이를 위한 가장 좋은 방법은 책임 주도 설계를 따르는 것입니다.
메시지를 먼저 선택해 협력과 무관한 오퍼레이션이 인터페이스에 스며드는 것을 방지할 수 있습니다.

책임 주도 설계가 훌륭한 인터페이스에 대한 지침을 제공하긴 하지만 좋은 인터페이스가 가지는 공통된 특징을 알아둬서 나쁠 것은 없습니다. 공통된 특징은 다음과 같습니다.

  • 디미터 법칙
  • 묻지말고 시킬 것
  • 의도를 보이는 인터페이스
  • 명령-쿼리 분리

디미터 법칙

보기만 해도 어지럽습니다…

위 코드는 ReservationAgency가 Screening 뿐 아니라 Movie, DiscountCondition에도 직접 접근하고 있습니다. 다른 클래스가 변경될 때마다 영향을 받는 의존성 덩어리가 되어버렸네요.

이처럼 객체 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한해야하는 것이 디미터 법칙입니다.
보통 도트를 사용하는 경우 하나의 도트만 사용할 것 이라고 요악되기도 합니다. (물론 절대는 아님.)

이 법칙을 위해서는 특정 조건을 만족하는 대상에게만 메시지를 전송하도록 해야 합니다. 클래스 C와 메시지 M에 대해

  • M의 인자로 전달된 클래스 (C 포함)
  • C의 인스턴스 변수로 존재하는 클래스

다음은 결합도 문제를 해결하기 위해 수정된 코드입니다.

기존엔 Movie movie = screening.getMovie() 처럼 Movie를 직접 생성하고 필요한 내용을 요청하는 등 아주 난잡했으나 Screening에게 요청하고 Movie와는 결합하지 않게 되었습니다.

이렇게 디미터 법칙을 따르면 수신자의 내부 구조가 전송자에게 노출되지 않고 전송자는 수신자의 내부 구현에 결합되지 않습니다.
결국 캡슐화를 표현한 것입니다. 클래스를 캡슐화하기 위한 구체적인 지침을 제공하기에 가치있다고 할 수 있어요.

디미터 법칙의 전형적인 위반

screening.getMovie().getDiscountCondition();

수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메시지를 전송합니다. 이런 코드를 기차 충돌(train wreck) 이라고 부릅니다. 송신자가 수신자의 내부 구조에 대해 과하게 알게 된다.

결국 디미터 법칙은 내부 구조를 묻는게 아니라 수신자에게 무언가를 시키는 메시지 구조가 더 좋다는 것을 말하고 있습니다. 하지만 디미터 법칙을 무비판적으로 수용하면 퍼블릭 인터페이스 관점에서 객체의 응집도가 낮아질 수 있습니다. 자세한 내용은 3. 원칙의 함정으로!

묻지 말고 시켜라

디미터 법칙은 객체의 상태에 묻지 말고 원하는 것을 시켜야 한다고 말합니다. “묻지 말고 시켜라”는 이런 스타일의 메시지 작성을 장려하는 원칙입니다.

메시지 전송자가 수신자의 상태를 기반으로 결정을 내린 후 수신자의 상태를 바꿔서는 안됩니다. 그 로직은 수신자가 담당해야 한다. 객체 외부에서 상태를 확인하고 결정을 내리는 것은 캡슐화를 위반하는 행위입니다.

의도를 보이는 인터페이스

켄트 백(Kent Back)은 “Smalltalk Best Practice Patterns에서 메서드를 명명하는 두 가지 방법을 설명했습니다.

  1. 메서드가 어떻게 수행하는지 보이는 것

좋지 않은 두 이유가 있습니다.

  • 두 메서드가 동일한 작업을 수행한다는 사실을 알기 어렵다. 조건에 따라 수행 방식이 바뀔 수도 있지 않은가?
  • 캡슐화를 위반한다. 클라이언트가 협력하는 객체의 종류를 알아야한 한다. Period에서 Sequence로 변경되면 객체 뿐 아니라 메서드까지 변경해야 한다.

2. 메서드가 무엇을 수행하는지 보이는 것

무엇을 하는지 드러내는 이름은 코드를 읽고 이해하기 쉽게 할 뿐 아니라 유연한 코드를 낳는 지름길입니다. 쉽게 이해가 되지 않을테니 “어떻게”부터 살펴볼까요?

어떻게 수행하는지 드러내는 이름이란 결국 메서드의 내부 구현을 설명하는 이름입니다. 협력을 설계하는 이른 시점부터 클래스의 내부 구현에 관해 고민할 수 밖에 없습니다. 반면 무엇을 하는지 보이는 것은 책임만 고민하면 됩니다.

위 코드처럼 어떻게를 배제하면 두 메서드가 동일한 목적을 가진다는 것을 알 수 있습니다. 같은 메시지를 다른 방법으로 처리하기 때문에 서로 대체가능하고 유연한 변경이 가능합니다.

매우 다른 두 번째 구현을 상상하고 두 메서드에 동일한 이름을 붙인다고 상상해보면 할수 있는 가장 추상적인 이름을 메서드에 붙일 수 있습니다.
메서드에는 결과와 목적만을 포함해야 합니다!

3. 원칙의 함정

디미터 법칙와 묻지 말고 시켜라는 훌륭한 설계 원칙이지만 절대적인 법칙은 아닙니다. 법칙에는 예외가 없지만 원칙에는 예외가 넘쳐납니다. 원칙이 현재 상황에 부적합하다고 판단되면 과감하게 원칙을 무시해야합니다.

디미터는 하나의 도트를 강제하는 규칙이 아니다.

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

도트가 많으니 디미터 법칙을 위반한 것인가요? 아니죠? of, filter, distinct 메서드는 모두 IntStream이라는 동일한 인스턴스를 반환합니다. 디미터 법칙은 결합도에 관한 것이며 위 코드는 결합도와 아무 관련이 없습니다. IntStream의 내부 구조를 보이고 있지 않습니다. 객체를 둘러싼 캡슐은 그대로 유지되고 있어요.

결합도와 응집도의 충돌

일반적으로는 어떤 객체의 상태를 물어본 후 반환된 상태를 기반으로 결정을 내리고 그 결정에 따라 객체의 상태를 변경하는 코드는 묻지 말고 시키는 스타일로 바꿔야 합니다.

하지만 이 행동들이 항상 긍정적인 결과로만 귀결되는 것은 아닙니다. 모든 상황에 맹목적으로 위임 메서드를 추가하면 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존할 수 있어 응집도가 낮아지는 경우가 생깁니다.

위임할 때 클래스는 하나의 변경 원인만을 가져야 한다 는 사실을 명심해야 합니다.
영화 예매의 PeriodCondition 클래스를 보면 얼핏 보기에 Screening의 내부 상태를 사용하기 때문에 캡슐화를 위반한 것으로 보입니다.

할인 여부는 판단하는 로직은 Screening안의 isDiscountable로 옮기면 묻지 말고 시키는 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있습니다.

하지만 Screening이 할인 조건을 판단하는 책임을 지니는게 맞을까요? 이게 Screening의 본질적인 책임은 아닌 것 같습니다. 본질적 책임은 예매하는 것이고 직접 할인 조건을 판단하게 되면 객체의 응집도가 낮아지게 됩니다. 또한 Screening에게 위임할 경우 PeriodCondition의 인스턴스 변수가 필요하기 때문에 둘 사이의 결합도도 높아지게 됩니다.

따라서 Screening의 캡슐화를 향상시키는 것 보다는 응집도를 높이고 Screening과 PeriodCondition사이의 결합도를 낮추는 것이 더 좋은 것으로 보입니다.

이처럼 가끔은 묻지 않고는 해결할 수 없는 경우도 있습니다. 모든 것은 경우에 따라 다릅니다.

4. 명령-쿼리 분리 원칙

가끔은 필요에 따라 물어야 한다는 사실에 납득했다면 명령-쿼리 분리원칙에 대해 알아봅니다.

우선 용어에 대해 설명합니다.
어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴이라고 하며 이 루틴은 다시 프로시저와 함수로 구분됩니다.

  • 프로시저 : 부수효과가 있지만 값을 반환할 수 없다.
  • 함수 : 값을 반환할 수 있지만 부수효과가 없다.

명령과 쿼리는 객체의 인터페이스 측면에서 프로시저와 함수를 부르는 다른 이름입니다. 어떠한 오퍼레이션도 명령인 동시에 쿼리여서는 안된다.라는 것이 명령-쿼리 분리 원칙입니다.

  • 명령은 객체의 상태를 변경하고 반환값을 가질 수 없다.
  • 쿼리는 객체의 정보를 반환하고 상태를 변경할 수 없다.

반복 일정의 명령과 쿼리 분리하기

명령 쿼리 분리 원칙의 장점에 대해 알아보기 위한 예시입니다.

일정 관리 소프트웨어를 제작하는데 “이벤트”와 “반복 일정” 이라는 개념이 있습니다. 이벤트는 특정 일자에 발생하는 사건이고 반복 일정은 일주일 단위로 돌아오는 사건을 지칭합니다.

  • 이벤트 클래스는 현재 이벤트가 반복 일정 조건을 만족하는지 검사하는 isSatisfied 메서드를 제공하는데 이 메서드는 RecurringSchedule의 인스턴스를 인자로 받아 해당 이벤트가 일정 조건을 만족하면 true, 아니면 false를 반환합니다.

우선 이 코드에는 치명적인 오류가 있습니다. 첫 번째 assert에서는 false인데 두 번째부턴 true가 되어버립니다.

이유가 무엇일까요? 이 메서드 내부에는 Event의 상태를 변경하는 메서드가 포함되어 있습니다. isSatisfied가 명령과 쿼리 두 가지 역할을 동시에 수행하고 있었기 때문에 부수효과가 있는지 없는지 겉만 보고는 판단할 수가 없습니다.
이렇게 쿼리처럼 보이지만 내부적으로 부수효과를 가지는 메서드는 이해하기 어렵고, 잘못사용하기 쉬우며, 버그를 양산하는 경향이 있습니다.

고로 부수효과를 가지는 명령과 가지지 않는 쿼리로 분리해야 합니다.

결론 : 책임에 초점을 맞출 것

디미터 법칙을 준수하고, 묻지 말고 시키는 스타일을 따르며 의도를 드러내는 인터페이스를 설계하는 가장 쉬운 방법은 메시지를 먼저 선택하고 이후에 메시지를 처리할 객체를 선택하는 것입니다. 명령과 쿼리의 분리에도 이와 같은 방법이 통합니다.
모든 방식의 중심에는 객체가 수행할 책임이 위치합니다.

--

--