Skip to main content

[오브젝트] Chapter06. 메시지와 인터페이스

· 19 min read

클래스는 객체지향 프로그래밍을 위한 도구로서 많은 개발자들이 객체지향에 대해 생각하면 떠오르는 키워드일 것이다. 그러나 클래스는 프로그래밍 도구일 뿐 설계를 클래스에 초점을 두어선 안된다.
정말 중요한 것은 객체들이 주고 받는 메시지이며 이 메시지가 객체의 퍼블릭 인터페이스를 결정하고, 클래스를 결정한다.

협력과 메시지

클라이언트 - 서버 모델

협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다. [위프스브록(Wirfs-Brock03)]

메시지를 매개로 하는 요청과 응답의 조합이 두 객체 사이의 협력을 결정한다. 두 객체 사이의 협력 관계를 설명하기 위한 전형적인 메타포는 클라이언트 - 서버(Client - Server) 모델 이다.
클라이언트가 서버의 서비스를 요청하는 단방향 상호작용을 협력으로써 활용한다. 객체지향 관점에서 클라이언트는 메시지를 보내는 주체가 되는 객체이며 서버는 메시지를 수신받은 협력 객체이다.

객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적이다. 객체 외부로 전송하는 메시지의 집합과 외부의 객체로부터 수신하는 메시지의 집합의 두 가지의 메시지 집합으로 구성된다.

메시지와 메시지 전송

메시지 (Message)

  • 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다.
  • 다른 객체에게 도움을 요청하는 것을 메시지 전송(message sending) 또는 메시지 패싱(message passing)이라 한다.
  • 메시지를 전송하는 객체를 메시지 전송자(message sender), 수신하는 객체를 메시지 수신자(message receiver) 라 한다.
  • 클라이언트 - 서버 모델에선 메시지 전송자를 클라이언트, 메시지 수신자를 서버라 할 수 있다.
  • 오퍼레이션 명 (operation name) + 인자 (argument)로 구성.
  • 메시지 전송 = [메시지 수신자] + [오퍼레이션 명] + [인자].

메시지와 메서드

메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇이가에 달려있다. 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라 한다.
메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨하게 결합될 수 있게 한다. 전송자는 전송할 메시지만 알면 되고 수신자는 받은 메시지를 자율적으로 처리하면 된다.
이렇게 실행 시점에 메시지와 메서드를 바인등하는 메커니즘은 객체간의 결합도를 낮춰 확장 가능한 코드를 작성할 수 있게 한다.

퍼블릭 인터페이스와 오퍼레이션

객체의 내부는 해당 객체만이 알고있다. 외부에선 장막으로 가린 듯이 명확하게 구분되어있다. 이러한 외부와 단절된 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 메인터페이스라 한다.
프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(operation)이라고 부른다. 런타임에서 메시지 전송자가 메시지를 보내면 메시지 수신자는 실제 타입을 기반으로 적절한 메서드를 찾아 실행한다.
퍼블릭 인터페이스와 메시지의 관점에서 보면 '메서드 호출'보다는 '오퍼레이션 호출'이라는 용어가 더 적절한 표현이다.

시그니처

오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합쳐 시그니처 (signature)라고 한다. 오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것이다.

  • 메서드 = 시그니처 + 구현
  • 시그니처 = 오퍼레이션 명 + 인자

인터페이스와 품질

퍼블릭 인터페이스의 품질에 영향을 미치는 4가지 원칙

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스
  • 명령 - 쿼리 분리

디미터 법칙(Law of Demeter)

객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하는 원칙이다.

낮선 자에게 말하지 마라 [Larman04] 오직 인접한 이웃하고만 말하라 [Metz12] 오직 하나의 도트만 사용하라 [Metz12]

디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 제한해야 한다.

모든 클래스 C와 C에 구현된 모든 메서드 M에 대해

  • M의 인자로 전달된 클래스
  • C의 인스턴스 변수의 클래스

이를 좀더 구체화 해보면 아래와 같이 나열할 수 있다.

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

디미터의 법칙을 따르면 부끄럼타는 코드(shy code) 를 작성할 수 있다. 즉, 불필요한 어떤 것도 외부에 공개하지 않으며 다른 객체의 구현에 의존하지 않는 코드를 작성 할 수 있다.
이로 인해 클라이언트와 서버 사이에 낮은 결합도를 유지할 수 있다.

디미터 법칙을 위반하는 코드의 경우 아래와 같은 모습일 것이다.

a.getB().getC().doSometing();

각 메서드의 결과 반환 타입이 A -> B -> C로 변화하는 것을 알 수 있다. 이 로직을 사용하는 부분은 A, B, C 모두에게 결합되어 유연하지 못한 코드가 된다.

묻지말고 시켜라

훌륭한 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시키는 것이다. 메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안된다.
모든 구현되는 로직은 메시지 수자가 담당해야할 책임인 것이다.

묻지 말고 시켜라 원칙을 따르도록 메시지를 결정하면 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다.
객체 내부의 상태를 이용해 어떤 결정을 내리는 로직이 객체 외부에 존재한다면 해당 객체가 책임져야하는 어떤 행동이 객체 외부로 누수된 것이다.

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

메서드의 이름을 짓는 방법에는 두 가지가 있다.

  • 메서드의 내부 구현 방법을 드러내는 이름 (어떻게 동작하는가?)
  • 메서드가 짊어진 책임이 무엇인지를 드러내는 이름 (무엇을 하는가?)

첫 번째의 경우는 나쁘지는 않지만 다형성과 같은 기법을 사용하기 어려워진다. 같은 역할의 다른 구현인 객체는 모두 다른 메서드 이름을 갖게 될 것이기 때문이다. 더 큰 문제는 메서드 수준에서 이미 캡슐화를 위반한다는 것이다.
이런 이름의 메서드들은 협력하는 객체의 종류를 알도록 강요한다. 두 번째의 경우를 따르게 되면 프로그래머는 메서드가 하는 일에 대한 충분한 정보를 보여주며 역할과 책임에 기반한 코드를 작성할 수 있다.
이 원칙에서 중요한 것은 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현한다는 것이다.

원칙의 함정

디미터 원칙과 묻지말고 시켜라 스타일은 객체지향에서 좋은 원칙임에는 분명하다. 그러나 그 어떤 원칙도 절대적인 것은 아니다.
설계는 트레이드오프의 결과물이라는 사실을 잊지 말아야 한다. 적용하려는 원칙이 충돌하는 상황에서도 원칙에 너무 얽메여 억지로 끼워맞추는 상황이 발생하고 결과적으로 설계는 일관성을 잃고 코드는 무질서 속에 파묻히게 된다.

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

앞서 말한 디미터 법칙에서 단 하나의 도트를 사용하라[Metz12]고 했었다. 이를 보고 대부분의 사람들이 IntStream이 디비터 법칙을 위반했다고 생각할 것이다.
그러나 IntStream의 메서드는 다른 어떤 것을 반환하지 않고 또다른 IntStream의 인스턴스를 반환한다. A 가 B 가 되는 것이 아닌 A 가 A'이 되는 것일 뿐이다.
디미터 법칙은 결합도와 관련된 것이며 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.

결합도와 응집도의 충돌

묻지말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍정적인 결과로만 귀결되는 것은 아니다. 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 필요없는 책임을 지게 되어 응집도가 낮아진다.

가끔씩은 객체의 상태를 묻는것 외에 다른 방법이 없는 경우도 있다. 컬렉션에 포함된 객체들을 처리하는 유일한 방법은 객체에게 물어보는 것 뿐이다. 객체가 정말로 데이터인 경우에는 묻지 않고서는 처리항 방법이 없다.
객체에게 시키는 것이 항상 가능한 것은 아니다. 늘 "경우에 따라 다르다"라는 사실을 명심해야한다.

명령 쿼리 분리 원칙

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

  • 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴(routine) 이라 한다.
  • 루틴은 프로시저와 함수로 구분할 수 있다.
  • 프로시저는 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다.
  • 함수는 값을 반환할 수 있지만 부수 효과를 발생시킬 수 없다.

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

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

어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안된다. 명령-쿼리 분리 원칙을 한 문장으로 표현하면 "질문이 답변을 수정해서는 안된다." 는 것이다.

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

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

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

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

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

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

책임에 초점을 맞춰라

앞서 소개된 원칙들의 장점을 정리하면 다음과 같다.

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

훌륭한 메시지를 얻기 위해서는 책임 주도 설계 원칙을 따라야 한다. 객체가 메시지를 선택하는 것이 아니라 메시지가 객체를 선택하는 것이기 때문에 적합한 메시지를 결정할 수 있을 확률이 높아진다.