Objects Study - Chapter6. 메세지와 인터페이스

협력과 메세지

  • 협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다.
  • 메세지는 객체 사이의 협력을 가능하게 하는 매개체다
  • 객체가 다른 객체에게 접근할 수 있는 유일한 방법은 메세지를 전송하는 것뿐이다.
  • 메세지는 매개로 하는 요청과 응답의 조합이 두 객체 사이의 협력을 구성한다.

클라이언트 서버 모델

두 객체 사이의 협력관계를 설명하기 위해 사용하는 전통적인 메타포는 클라이언트-서버(Client-Server)모델이다.

  • 클라이언트 : 협력 안에서 메세지를 전송하는 객체
  • 서버: 협력 안에서 메세지를 수신하는 객체

client-server

객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적이다.
협력의 관점에서 객체는 두 가지 종류의 메세지 집합으로 구성된다.

  1. 객체가 수신하는 메세지의 집합
  2. 외부의 객체에게 전송하는 메세지의 집합

client-server2

메세지와 메세지 전송

메세지(Message) 는 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다.

  • 한 객체가 다른 객체에게 도움을 요청하는 것을 메세지 전송(message sending) 또는 메세지 패싱(message passing) 이라 부른다.
  • 메세지를 전송하는 객체를 메세지 전송자(message sender) 혹은 클라이언트라고 부른다.
  • 메세지를 수신하는 객체를 메세지 수신자(message receiver) 혹은 서버라고 부른다.

message

  • 메세지는 오퍼레이션 + 인자로 구성
  • 메세지 전송은 수신자 + 오퍼레이션 + 인자로 구성

메세지와 메서드

메세지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라 부른다.
중요한 점은 코드 상에서 동일한 이름의 변수(condition)에게 동일한 메세지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라질 수가 있다는 것이다.

  • 전통적인 방식 - 컴파일 레벨에서 어떤 코드가 실행될지 정확하게 알고 있는 상황에서 함수 호출이나 프로시저 호출 구문을 작성한다.
  • 객체지향 방식 - 메세지와 메서드라는 두 가지 서로 다른 개념을 실행 시점에 연결해야 하기 때문에 컴파일 시점과 실행 시점의 의미가 달라질 수 있다.
    • 메세지와 메서드의 구분은 메세지 전송자와 메세지 수신자가 느슨하게 결합되도록 한다.
    • 메세지 전송자는 자신이 어떤 메세지를 객체에 전송할 지만 알면된다.
    • 메세지 수신자는 누가 전송하는지는 알 필요가 없고 전송받은 메세지를 처리하기 위해 필요한 메서드를 스스로 결정할 수 있는 자율권을 누린다.

실행 시점에 메세지와 메서드를 바인딩하는 메커니즘은 두 객체 사이의 결합도를 낮춤으로써 유연하고 확장 가능한 코드를 작성할 수 있게 만든다.

Public 인터페이스와 오퍼레이션

  • 객체가 의사소통을 위해 외부에 공개하는 메세지의 집합을 퍼블릭 인터페이스(public interface) 라고 한다.

  • 퍼블릭 인터페이스에 포함된 메세지를 오퍼레이션(operation) 이라고 부른다.

  • 오퍼레이션은 수행 가능한 어떤 행동에 대한 추상화다

프로그래밍 언어의 관점에서 객체가 다른 객체에게 메세지를 전송하면 런타임 시스템은 메세지 전송을 오퍼레이션 호출로 해석한다.
메세지를 수신한 객체의 실제 타입을 기반으로 적잘한 메서드를 찾아 실행한다.
따라서 퍼블릭 인터페이스와 메세지의 관점에서 보면 메서드 호출보다는 오퍼레이션 호출이라는 용어를 사용하는 것이 더 적절하다.

message-operation

메서드 시그니처

오퍼레이션(또는 메서드)와 파라미터 목록을 합쳐 메서드 시그니처 (method signature)라고 부른다.
오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것이다.
메서드는 이 시그니처에 구현을 더한 것이다.
이반적으로 메세지를 수신하면 오퍼레이션의 시그니처와 동일한 메서드가 실행된다.

오퍼레이션의 관점에서 다형성이란 동일한 오퍼레이션 호출에 대해 서로 다른 메서드들이 실행되는 것이라고 정의할 수 있다.

인터페이스와 설계 품질

좋은 인터페이스는 최소한의 인터페이스추상적인 인터페이스라는 조건을 만족해야 한다.

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

책임 주도 설계 방법은 메세지를 먼저 선택함으로써 협력과는 무관한 오퍼레이션이 인터페이스에 스며드는 것을 방지한다.
따라서 인터페이스는 최소의 오퍼레이션만 포함하게 된다.

디미터 법칙

디미터 법칙(Law of Demeter) 는 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙이다.
요약하자면, 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것이다.

  • 낯선 자에게 말하지 말라 (don`t talk to strangers)
  • 오직 인접한 이웃하고만 말하라 (only talk to your immediate neighbors)
  • 자바와 같이 도트(.)를 이용해 메세지를 전송을 표현하는 언어에서는 “오직 하나의 도트만 사용하라(use only one dot)”

디미터 프로젝트를 진행하던 사람들은 디미터 법칙을 이용하여 객체들간의 협력 경로를 제한하면 결합도를 효과적으로 낮출 수 있다는 사실을 발견하였다.

디미터 법칙을 따르면 부끄럼타는 코드 (shy code) 를 작성할 수 있다.
부끄럽타는 코드란 불필요한 어떤 것도 다른 객체에게 보여주지 않으며, 다른 객체의 구현에 의존하지 않는 코드를 말한다.
디미터 법칙을 따르는 코드는 메세지 수신자의 내부 구조가 전송자에게 노출되지 않으며, 메세지 전송자는 수신자의 내부 구현에 결합되지 않는다.
따라서 클라이언트와 서버 사이에 낮은 결합도를 유지할 수 있다.

디미터 법칙을 어기는 코드

1
screening.getMovie().getDiscountConditions();

메세지 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메세지를 전송한다.
흔히 이와 같은 코드를 기차 충돌(train wreck) 이라고 부른다.

이와 같은 코드는 Movie의 내부 구현을 외부로 노출 시키게 되므로 메세지 수신자의 캡슐화는 무너지고
메세지 전송자가 메세지 수신자의 내부 구현에 강하게 결합된다.

디미터 법칙은 객체의 내부 구조를 묻는 메세지가 아니라 수신자에게 무언가를 시키는 메세지가 더 좋다고 얘기한다.

묻지 말고 시켜라

1
screening.getMovie().getDiscountConditions();

위와 같은 코드는 screening에게 직접 요금을 계산할 책임을 할당한 것이다.
디미터 법칙은 훌륭한 메세지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다는 사실을 강조한다.
메세지 전송자는 메세지 수신자의 상태를 기반으로 결정을 내린 후 메세지 수신자의 상태를 바꿔서는 안된다.
객체의 외부에서 해당 객체의 상태를 기반으로 결정을 내리는 것은 객체의 캡슐화를 위반한다.

묻지말고 시켜라 원칙으로 얻는 이점

  • 밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들 수 있다.
    • 객체지향의 기본은 함께 변경될 확률이 높은 정보와 행동을 하나의 단위로 통합하는 것이다.
  • 객체의 정보를 이용하는 행동을 객체의 외부가 아닌 내부에 위치시키기 때문에 자연스럽게 정보와 행동을 동일한 클래스 안에 두게 된다.
  • 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다.

의도를 드러내는 인터페이스

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

  1. 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는다.
  2. “어떻게”가 아니라 “무엇”을 하는지 드러낸다.

메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는다.

1
2
3
4
5
6
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}

위와 같은 스타일은 좋지 않다.

  • 메서드에 대해 제대로 커뮤니케이션 하지 못한다. 두 메서드 모두 할인 조건을 판단하는 메서드이다.
  • 하지만 메서드의 이름이 다르기 때문에 두 메서드의 내부 구현을 정확하게 이해하지 못한다면 두 메서드가 동일한 작업을 수행하는 사실을 알아채기 어렵다.
  • 메서드 수준에서 캡슐화를 위반한다.
  • 위의 메서드들은 클라이언트로 하여금 객체의 종류를 알도록 강요한다.
  • 만약에 할인여부가 변경된다면 메세지를 전송하는 클라이언트 코드의 메서드의 이름또한 변경되어야 한다.
  • 따라서 책임을 수행하는 방법을 드러내는 메서드를 사용한 설계는 변경에 취약할 수밖에 없다.

“어떻게”가 아니라 “무엇”을 하는지 드러낸다.

어떻게 수행하는지를 드러내는 이름이란 메서드의 내부 구현을 설명하는 이름이다.
협력을 설계하기 시작하는 이른 시기부터 클래스의 내부 구현에 관해 고민할 수 밖에 없다.
반면 무엇을 하는지 드러내도록 메서드의 이름을 짓기 위해서는 객체가 협력 안에서 수행해야 하는 책임에 관해 고민해야 한다.
이것은 외부의 객체가 메세지를 전송하는 목적을 먼저 생각하도록 만들며, 결과적으로 협력하는 클라이언트의 의도에 부합하도록 메서드의 이름을 짓게 된다.

1
2
3
4
5
6
public class PeriodCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}

클라이언트 관점에서 두 메서드 모두 다 할인 여부를 판단하기 위한 작업을 수행한다.
따라서 두 메서드 모두 클라이언트들의 의도를 담을 수 있도록 isSatisfiedBy로 변경하는 것이 적절하다

자바와 같은 정적 타이핑 언어에서는 단순히 메서드 이름이 같다고 해서 동일한 메세지를 처리할 수 있는 것은 아니다.
클라이언트가 동인할 타입으로 간주할 수 있도록 동일한 타입 계층으로 묶어야 한다.

1
2
3
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening) { ... }
}

가장 간단한 방법은 DiscountCondition이라는 인터페이스를 정의하고 이 인터페이스에 isSatisfiedBy 오퍼레이션을 정의하는 것이다.

의도를 드러내는 선택자 (Intention Revealing Selector)

메서드가 어떻게 수행하느냐가 아니라 무엇을 하느냐에 초점을 맞추면 클라이언트의 관점에서 동일한 작업을 수행하는 메서드를 하나의 타입 계층으로 묶을 수 있는 가능성이 커진다.
그 결과, 다양한 타입의 개규체가 참여할 수 있는 유연한 협력을 얻게 된다.

이처럼 어떻게 수행하느냐가 아니라 무엇을 하느냐에 따라 메서드의 이름을 짓는 패턴을 의도를 드러내는 선택자 (Intention Revealing Selector) 라고 부른다.

하나의 구현을 가진 메세지 이름을 일반화 하도록 도와주는 간단한 방법

매우 다른 두 번째 구현을 상상하라. 그러고는 메서드에 동일한 이름을 붙인다고 생각해보라
그렇게 하면 아마도 그 순간 가장 추상적인 이름을 메서드에 붙일 수 있을 것이다.

의도를 드러내는 인터페이스 (Intention Revealing Interface)

<도메인 주도 설계>에서 에릭에반스는 의도를 드러내는 선택자를 인터페이스 레벨로 확장한 의도를 드러내는 인터페이스를 제시했다.
의도를 드러내는 인터페이스란, 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 한다는 것이다.

  • 수행 방법에 관해서는 언급하지 말고 결과와 목적만을 포함하도록 클래스와 오퍼레이션의 이름을 부여하라
    이렇게 하면 클라이언트 개발자가 내부를 이해해야 할 필요성이 줄어들게 된다.

  • 방법이 아닌 의도를 표현하는 추상적인 인터페이스 뒤로 모든 까다로운 메커니즘을 캡슐화해야 한다.
    도메인의 퍼블릭 인터페이스에서는 관계와 규칙을 시행하는 방법이 아닌 이벤트와 규칙 그 자체만 명시한다.

원칙의 함정

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

디미터 법칙은 “오직 하나의 도트만을 사용하라”라는 말로 요약되기도 한다.
그렇다면 builder패턴이나 lambda 식에서 주로 볼 수 있는 method chaining은 디미터 법칙을 위반하는 것일까?

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

위의 코드에서 of, filter, distinct 메서드는 모두 IntStream이라는 동일한 클래스의 인스턴스를 반환한다.

따라서 이 코드는 디미터 법칙을 위반하지 않는다.
디미터 법칙은 결합도와 관련된 것이며, 이 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.
위의 코드에서는 IntStream의 내부 구조가 외부로 노출되지 않았다.
단지 IntStream을 다른 IntStream으로 변환할 뿐, 객체를 둘러싸고 있는 캡슐은 그대로 유지된다.

따라서 하나 이상의 도트(.)를 사용하는 모든 케이스가 디미터 법칙 위반인 것은 아니다.
기차 충돌처럼 보이는 코드라도 객체 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것이다.

또한 이 메서드들은 객체의 내부에 대한 어떤 내용도 묻지 않는다.
그저 객체를 다른 객체로 변환하는 작업을 수행하라고 시킬 뿐이다. 따라서 묻지말고 시켜라 원칙을 위반하지 않는다.

로버트 마틴은 <클린코드>에서 디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료구조 인지에 따라 다르다고 한다.
객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는게 좋다.
자료 구조는 당연히 내부를 노출해야 하므로 디미터 법칙을 적용할 필요가 없다.

명령-쿼리 분리 원칙 (CQRS)

명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.

  • Routine: 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈. 루틴은 프로시져와 함수로 나뉜다.
    • Procedure: 정해진 절차에 따라 내부의 상태를 변경. 부수효과(side effect)를 발생시킬 수 있지만 값을 반환할 수 없다.
    • Function: 함수는 값을 반환할 수 있지만, 부수효과(side effect)를 발생시킬 수 없다.

명령(Command)와 쿼리(Query)는 객체의 인터페이스 측면에서 프로시저와 함수를 부르는 또 다른 이름이다.

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

기계로서의 객체 메타포

metaphor_machine

버트란드 마이어(Bertrand Meyer)는 에서 명령-쿼리 분리 원칙을 설명할 때 기계 메타포를 이용한다.
이 관점에서 객체는 블랙박스이며 객체의 인터페이스는 객체의 관찰 가능한 상태를 보기 위한 일련의 디스플레이와 객체의 상태를 변경하기 위해 누를 수 있는 버튼의 집합이다.

이런 스타일의 인터페이스를 사용함으로써 객체의 캡슐화와 다양한 문맥에서의 재사용성을 보장할 수 있다.
마틴 파울러는 명령-쿼리 분리 원칙에 따라 작성된 인터페이스를 명령-쿼리 인터페이스(Command-Query Interface) 라고 부른다.

  • insert, delete, merge, search는 명령(Command) 버튼으로써 기계의 상태가 변경된다.
  • 명령(Command) 버튼은 실행 결과를 제공하지 않기 때문에 명령 버튼을 누른 직후에는 기계 내부의 상태를 직접 확인할 수 없다.
  • empty, current, first, last는 쿼리(Query) 버튼으로서 기계의 상태를 확인할 수 있다.
  • 명령(Command) 버튼을 누르지 않고 쿼리(Query) 버튼을 계속 누르게 되면 항상 똑같은 값이 패널에 표시될 것이다.

명령-쿼리 분리와 참조 투명성

  • 명령과 쿼리를 엄격하게 분류하면 객체의 부수효과(side effect)를 제어하기 수월해진다.
  • 쿼리는 객체의 상태를 변경하지 않기 때문에 몇 번이고 반복적으로 호출하더라도 상관이 없다. (항상 같은 값을 리턴한다.)
  • 명령이 개입하지 않는 한 쿼리의 값은 변경되지 않기 때문에 쿼리의 결과를 예측하기 쉬워진다.
  • 또한 쿼리의 순서를 자유롭게 변경할 수도 있다.

명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성(referential transparency) 의 장점을 제한적이나마 누릴 수 있게 된다.
참조 투명성을 잘 활용하면 버그가 적고, 디버깅이 용이하고, 쿼리의 순서에 따라 실행 결과가 변하지 않는 코드를 작성할 수 있다.

하지만 명령-쿼리 분리를 하지 않은 함수에서는 내부에 부수효과를 포함할 경우 동일한 인자를 전달하더라도 부수효과에 의해 그 결괏값이 매번 달라질 수 있다.

수학으로 보는 참조 투명성

참조 투명성이란 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라고 결과가 달라지지 않는 특성을 의미한다.

어떤 함수 f(n)이 존재 할 때 n의 값으로 1을 대입하면 그 결과가 3이라고 가정하자 (f(1) = 3)

  • f(1) + f(1) = 6
  • f(1) * 2 = 6
  • f(1) - 1 = 2

위에서 f(1) = 3 이라고 했기 때문에 대입하여 결과를 얻어내기 쉬웠다.
f(1) 자리에 3으로 바꿔보자

  • 3 + 3 = 6
  • 3 * 2 = 6
  • 3 - 1 = 2

이것이 바로 참조 투명성이다.
수학에서의 함수는 항상 동일한 입력에 대해 동일한 값을 반환하기 때문에 수학의 함수는 참조 투명성을 만족 시키는 이상적인 예이다.

따라서 참조 투명성은 식을 값으로 치환하는 방법을 통해 결과를 쉽게 계산할 수 있게 해준다.
여기서 f(1)의 값을 항상 3이라고 말할 수 있는 이유는 f(1)의 값이 변하지 않기 때문이다.
이처럼 어떤 값이 변하지 않는 성질을 불변성(immutability)이라고 부른다.
어떤 값이 불변한다는 말은 부수효과(side effect)가 발생하지 않았다는 말과 동일하다.

참조 투명성을 만족하는 식은 두 가지 장점을 제공한다.

  • 모든 함수를 이미 알고 있는 하나의 결괏값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다.
  • 모든 곳에서 함수의 결괏값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다.

객체지향 패러다임이 객체의 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다.
하지만 명령-쿼리 분리 원칙을 사용하면 조금이나마 예외를 줄일 수 있다.
명령-쿼리 분리 원칙은 부수효과를 가지는 명령으로부터 부수효과를 가지지 않는 쿼리를 명백하게 분리함으로써 제한적이나마 참조 투명성의 혜택을 누릴 수 있다.

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

  • 명령형 프로그래밍 : 부수효과를 기반으로 하는 프로그래밍 방식
    • 상태를 변경시키는 연산들을 적절한 순서대로 나열하여 프로그램을 작성한다.
    • 대부분의 객체지향 프로그래밍 언어는 메세지에 의한 객체의 상태 변경에 집중하기 때문에 명령형 프로그래밍 언어로 본다.
  • 함수형 프로그래밍 : 부수효과가 존재하지 않는 수학적인 함수에 기반한다.
    • 참조 투명성의 장점을 극대화할 수 있다.
    • 명령형 프로그래밍에 비해 프로그램의 실행 결과를 이해하고 예측하기가 쉽다.

요약

  • 디미터 법칙: 협력이라는 컨텍스트 안에서 객체보다 메세지를 먼저 결정하면 두 객체 사이의 구조적인 결합도를 낮출 수 있다.
    수신할 객체를 알지 못한 상태에서 메세지를 먼저 선택하기 때문에 객체의 내부 구조에 대해 고민할 필요가 없어진다.
    따라서 메세지가 객체를 선택하게 함으로써 의도적으로 디미터 법칙을 위반할 위험을 최소화할 수 있다.
  • 묻지 말고 시켜라: 메세지를 먼저 선택하면 묻지 말고 시켜라 스타일에 따라 협력을 구조화하게 된다.
    클라이언트의 관점에서 메세지를 선택하기 때문에 필요한 정보를 물을 필요 없이 원하는 것을 표현한 메세지를 전송하면 된다.
  • 의도를 드러내는 인터페이스: 메세지를 먼저 선택한다는 것은 메세지를 전송하는 클라이엍느의 관점에서 메세지의 이름을 정한다는 것이다.
    당연히 그 이름에는 클라이언트가 무엇을 원하는지, 그 의도가 분명하게 드러날 수 밖에 없다.
  • 명령-쿼리 분리 원칙: 메세지를 먼저 선택한다는 것은 협력이라는 문맥 안에서 객체의 인터페이스에 관해 고민한다는 것을 의미한다.
    객체가 단순히 어떤 일을 해야 하는지뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 쉽게 만들기 위한 방법에 관해 고민하게 된다. 따라서 예측 가능한 협력을 만들기 위해 명령과 쿼리를 분리하게 될 것이다.

참고

  • Objects(코드로 이해하는 객체지향 설계) - chapter6. 메세지와 인터페이스