7장 메시지와 인터페이스

협력과 메시지

클라이언트-서버 모델

객체가 다른 객체에게 접근할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이다.

객체는 자신의 희망을 메시지로 전송하고, 이를 수신한 객체는 요청을 적절하게 처리하여 응답한다.

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

객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는것이 일반적이다.

협력의 관점에서 객체는 두 가지 종류의 메시지 집합으로 구성된다.

1) 객체가 수신하는 메시지의 집합

2) 외부의 객체에게 전송하는 메시지의 집합

핵심은 객체가 독립적으로 수행하는 것 보다 더 큰 책임을 수행하기 위해서는 다른 객체와 협력해야 한다는 점 이다.

이때 사용하는 매게체가 바로 메시지 이다!

메시지와 메시지 전송

메시지는 객체들이 협력할때 사용하는 의사소통 수단이다.

한 객체가 다른 객체에게 도움을 요청하는 것 을 메시지 전송(message sending)이라 부른다.

메시지를 전송하는 객체를 메시지 전송자(message sender), 수신하는 객체를 메시지 수신자(message receiver)라 부른다.

메시지는 오퍼레이션명 과 인자 로 구성되며, 메시지 전송은 여기에 메시지 수신자를 추가한 것 이다.

Java에서의 다음 코드를 살펴보자.

condition.isSatisfiedBy(screening)

condition은 메시지를 받는 수신자 이고, isSatisfiedBy는 오퍼레이션명, screening은 인자에 해당된다.

메시지와 메서드

메시지를 수신했을때 실제로 수행되는 코드는 수신자의 실제 타입이 무엇인가? 에 달려 있다.

condition.isSatisfiedBy(screening)에서 메시지 수신자인 condition은 DiscountCondition 이라는 인터페이스 타입으로 정의돼 있지만, 실제로 실제 실행되는 코드는 인터페이스를 실체화 한 클래스의 종류에 따라 달라진다.

이처럼 메시지를 수신했을때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다.

동일한 이름의 변수(condition)에게 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라진다.

=> 객체는 메시지와 메서드라는 서로다른 개념을 실행 시점에 연결해야 하기 때문에 컴파일 시점과 Runtime때의 의미가 다를수 있다.

메시지를 코드상에 적는 순간에는 어떤 코드가 실행될지 알수가 없는 것 이다.

실행 시점에 수신하는 객체의 타입에 따라서 달라지기 때문이다.

우리는 그저 메시지에 응답할 수 있는 객체가 존재하고, 그 객체가 적절한 메서드를 선택해서 응답할 것 이라고 믿을 수밖에 없다.

메시지 전송자와 수신자는 서로에 대한 상세한 정보를 알지 못한체, 단지 메시지라는 얇고 가는 끈을 통해서 연결된다.

실행시점에 메시지와 메서드를 바인딩하는 메커니즘은 두 객체사이의 결합도를 낮추고, 유연한 확장을 가능하게 해준다.

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

객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 인터페이스 라고 부른다.

프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션 이라고 부른다.

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

흔히 오퍼레이션이라 부를때는 내부의 구현 코드는 제외하고, 단순히 메시지와 관련된 시그니처를 가리키는 경우가 대부분이다.

앞에서 예로 든 DiscountCondtion의 isSatisfiedBy() 가 오퍼레이션에 해당한다.

그에 비해서 실제로 실행되는 코드는 메서드 라고 부른다.

인터페이스를 구체화한 SequenceCondition과 PercentConditoin에 정의된 isSatisfiedBy()는 실제 구현을 포함하기 때문에 메서드라고 부른다.

다음 그림을 살펴보자.

객체가 다른 객체에게 메시지를 전송하면 런타임 시스템은 메시지 전송을 오퍼레이션 호출로 해석하고 메시지를 수신한 객체의 실제 타입을 기반으로 적잘한 메서드를 찾아 실행한다.

=> 퍼블릭 인터페이스와 메시지의 관점에서 '메서드 호출' 보다는 '오퍼레이션 호출'이라는 용어가 더 적절하다.

시그니처

오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합쳐 시그니처(signature)라고 부른다.

오퍼레이션은 실행코드 없이 시그니처만을 정의한 것 입니다. 메서드는 이 시그니처에 구현을 더한 것 이다.

다형성을 사용하기 위해 하나의 오퍼레이션에 대해 다양항 메서드를 구현해야만 한다.

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

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

좋은 인터페이스 란

1) 최소한의 인터페이스 (꼭 필요한 오퍼레이션만을 인터페이스에 포함한다)

2) 추상적인 인터페이스 (어떻게 수행하는지? 가 아닌, 무엇을 수행하는지)

이러한 좋은 인터페이스를 얻기 위해 책임 주도 방법으로 메시지를 선택함으로써 협력과 무관한 오퍼레이션이 인터페이스에 스며드는 것을 방지한다.

퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법에 관하여 알아보자.

  • 디미터 법칙

  • 묻지 말고 시켜라

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

  • 명령-쿼리 분리

디미터 법칙

다음은 ReservationAgency 의 코드중 일부 이다.

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Movie movie = screening.getMovie();

        boolean discountable = false;
        for(DiscountCondition condition : movie.getDiscountConditions()) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                        condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                        condition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
            } else {
                discountable = condition.getSequence() == screening.getSequence();
            }

            if (discountable) {
                break;
            }
        }
        ...
    }
}

위 코드는 ReservationAgency와 Screening의 결합도가 높기 때문에, Screening의 내부를 변경할 때 마다 ReservationAgency 또한 함께 변경된다는 것 이다.

원인은 ReservationAgency가 Screening 뿐만 아니라, Screening의 내부 구현인 Movie 와 DiscountCondition에도 직접 접근하기 때문이다.(getter를 활용하여 직접적으로 접근하고 있다)

이러한 문제를 해결하기 위해 제안된 원칙이 바로 디미터 법칙(Law of Demeter)이다.

디미터 법칙은 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것 이다.

디미터의 법칙은 _"오직 인접한 이웃하고만 말하라"_로 요약할수가 있다.

디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 인스턴스에만 메시지를 전송하도록 프로그래밍 해야 한다.

클래스 내부의 메서드가 아래 조건을 만족하는 인터스턴스에만 메시지를 전송하도록 프로그래밍 해야 한다.

핵심은 자기 자신의 메서드, 로컬 생성 객체 에게만 메시지를 전달해야 한다.

  • this 객체 (객체 자신에 대한 접근)

  • 메서드의 매개변수 (생명주기가 해당 메서드 안에서만 유지)

  • this의 속성 (객체 자신에 대한 접근)

  • this의 속성인 컬렉션의 요소 (객체 자신에 대한 접근)

  • 메서드 내에서 생성된 지역 객체 (생명주기가 해당 메서드 안에서만 유지)

수정된 ReservationAgency는 다음과 같다. 메서드의 매겨변수로 Screening을 받고있다.

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

위 코드에서 ReservationAgency는 메서드의 인자로 전달된 Screening 인스턴스에게만 메시지를 전달한다.

ReservationAgency가 Screening의 내부 구조에 결합되어 있지 않기 때문에 Screening의 내부 구현을 변경할 때 ReservationAgency를 함께 변경할 필요가 없다.

디미터 법칙을 따르는 코드는 메시지 수신자의 내부 구조가 전송자에게 노출되지 않으며, 메시지 전송자는 수신자의 내부 구현에 결합되지 않는다.

다음은 디미터 법칙을 위반하는 코드의 전형적인 모습이다.

Screening.getMovie().getDiscountConditions();

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

이를 기차 충돌(train wreck)이라고 부른다.

디미터 법칙을 따르도록 수정하면, 더이상 전송자는 메시지 수신자의 내부 구조에 관해 묻지 않게 된다.

Screening.calculateFee(audienceCount);

디미터의 법칙은 정보를 처리하는데 필요한 책임을 정보가 알고 있는 객체에게 할당하기 때문에 응집도가 높은 객체가 만들어 진다.

메시지를 보낼때는 내부 구조를 묻는 것이 아니라, 무언가를 시키는 메시지가 더 좋은 메시지 이다.

하지만 또 이러한 디미터 법칙을 무분별 하게 사용하다보면, 퍼블릭 인터페이스 관점에서 객체의 응집도가 낮아질수도 있다.

묻지 말고 시켜라

앞서 ReservationAgency는 Screening 내부의 Movie에 접근하는 대신, Screening에게 직접 요금을 계산하도록 요청했다.

디미터 법칙은 훌륭한 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다는 사실을 강조한다.

_묻지 말고 시켜라(Tell, Don't Ask)_는 이런 스타일의 메시지 작성을 장려하는 원칙을 가리키는 용어다.

객체의 외부에서 해당 객체의 상태를 기반으로 결정을 내리면 안된다!!! => 자연스럽게 정보 전문가에게 책임을 할당

협력을 설계하고 객체가 수신할 메시지를 결정하는 매 순간 묻지 말고 시켜라 원칙과 디미터 법칙을 상기하자.

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

다음은 메서드의 이름이 내부의 구현 방법을 드러내는 방식의 코드이다.

public class PeriodCondition {
    public boolean isSatisfiedByPeriod(Screening screening) { ... }
}

public class SequenceCondition {
    public boolean isSatisfiedBySequence(Screening screening) { ... }
}

위 스타일이 좋지 못한 이유는 크게 2가지가 있다.

  • 두 메서드 모두 할인 조건을 판단하는 동일한 작업을 수행하지만, 메서드의 이름이 다르기 때문에 두 메서드의 내부 구현을 정확하게 이해하지 못한다면 두 메서드가 동일한 작업을 수행한다는 사실을 알아채기 어렵다.

  • 메서드 수준에서 캡슐화를 위반한다. 메서드를 사용하는 클라이언트는 PeriodCondition을 사용하던 코드가 SequenceCondition을 사용하도록 변경하려면 단순히 참조하는 객체를 변경하는 것 뿐만 아니라, 호출하는 메서드를 변경해야 한다. 따라서 클라이언트 코드 또한 변경해야 한다.

메서드의 이름을 지을때는 "어떻게" 가 아니라, "무엇"을 하는지를 드러내는 것 이다.

무엇을 하는지를 드러내도록 메서드의 이름을 짓기 위해서는 객체가 협력 안에서 수행해야 하는 책임에 관해 고민해야 한다.

이는 외부의 객체가 메시지를 전송하는 목적을 생각하도록 한다. 이른 시기부터 클래스의 구현을 고민하는것이 아니다!

위 2가지 메서드 모두 클라이언트의 관점에서는 할인 여부를 판단하기 위한 작업을 수행한다.

따라서 두 메서드 모두 클라이언트의 의도를 담을수 있도록 isSatisfiedBy로 변경하자.

추가적으로 클라이언트가 두 메서드를 가진 객체를 동일한 타입으로 간주할 수 있도록 타입 계층을 묶어야 한다.

간단하게 DiscountCondition이라는 인터페이스를 정의하고, 이 인터페이스 안에 isSatisfiedBy 오퍼레이션을 정의하는 것 이다.

public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}

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

에릭 에반스는 이러한 켄트백의 의도를 드러내는 선택자를 인터페이스 레벨로 확장한 의도를 드러내는 인터페이스를 제시한다.

의도를 드러내는 인터페이스를 한 마디로 요약하면 구현과 관련된 모든 정보를 캡슐화 하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 한다.

함께 모으기

디미터의 법칙을 위반하는 티켓 판메 도메인을 살펴보자.

public class Theater {
	// ... 생략
    
	public void enter(Audience audience){
        if(audience.getBag().hadInvitation()){
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        }else{
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

디미터의 원칙에 따르면 Theater의 enter 메서드가 인자로 전달받은 audience와 인스턴스 변수인 ticketSeller에게 메시지를 전달하는것은 문제되지 않는다.

문제는 Theater가 audience 와 ticketSeller 내부에 포함된 객체에게도 직접적으로 접근하고 있다는 점 이다.

다음 코드 부분이 기차 충돌의 전형적인 스타일 이다.

audience.getBag().minusAmount(ticket.getFee());

근본적으로 디미터 법칙을 위반하는 설계는 _인터페이스와 구현의 분리 원칙_을 위반한다.

객체의 내부 구조는 구현에 해당되는 부분이다.

Audience가 Bag을 포함한다는 사실은 내부 구현에 해당되며, Audience는 자신 스스로 내부 구현을 자유롭게 변경할수 있어야 한다.

그러나!, public interface에 getBag을 포함시키는 순간 객체의 구현이 public interface를 통해 외부로 노출되어 나가버린다.

이렇게 되면 코드를 사용하기도 어렵다.

클라이언트 입장에서는 Audience의 public interface 뿐만 아니라, Audience의 내부 구조까지 알고 있어야 한다.

위 코드에서는 ticketSeller가 그 정도가 심하다.

Client인 Theater는 TicketSeller가 getTicketOffice 메시지를 수신할 수 있다는 사실 뿐만 아니라, 내부에 TicketOffice를 포함하고 있다는 사실도 알고 있어야 한다.

더 나아가 반환된 ticketOffice가 getTicket 메시지를 수신할 수 있으며, 이 메서드가 반환하는 Ticket 인스턴스가 getFee 메시지를 이해할 수 있다는 사실도 알아야 한다.

디미터 법칙을 위반하는 코드를 수정하는 일반적인 방법

=> Audience와 TicketSeller의 내부 구조를 묻는 대신, Audience와 TicketSeller가 직접 자신의 책임을 수행하도록 시키는 것.

// 코드 생략

인터페이스에 의도를 드러내자

Audience, TicketSeller, Bag의 setTicket 메서드는 모두 이름이 같다. 이 메서드 들은 모두 동일한 의도를 드러내는가?

클라이언트의 의도가 분명하게 드러나도록 객체의 퍼블릭 인터페이스를 개선해야 한다.

Theater가 TicketSeller에게 setTicket 메시지를 전달하여 얻고 싶었던 결과는 무엇일까?

=> Audience에게 티켓 판매하기이다. 따라서 sellTo가 의도를 좀 더 명확하게 전달하는 메시지라 할 수 있다.

TicketSeller가 Audience에게 setTicket 메시지를 전달하여 얻고 싶었던 결과는 무엇일까?

=> Audience가 티켓을 사도록 만드는것. 클라이언트가 원하는 것은 buy라는 메시지 이다.

Audience가 Bag에게 setTicket 메시지를 전달하여 얻고 싶었던 결과는 무엇일까?

=> 티켓을 보관하도록 만드는 것이 목적이다. Bag이 hold 라는 메시지를 수신할수 있도록 하면 좋을것 같다.

public class TicketSeller {
	public void sellTo(Audience audience) { ... }
}

public class Audience {
	public Long buy(Ticket ticket) { ... }
}

public class Bag {
	public Long hold(Ticket ticket) { ... }
}

오퍼레이션은 클라이언트가 객체에게 무엇을 원하는지를 표현해야 한다.

위 변경된 오퍼레이션의 이름은 클라이언트의 의도를 명확하게 표현한다. 하지만 sellTicket은 그렇지 않다.

3. 원칙의 함정

설계는 트레이드오프의 산물이다.

초보자 일수록 맹목적으로 원칙을 추종하려 한다. 적용하려는 원칙들이 서로 충돌 나도 스스로 정당성을 부여하면서 억지로 끼워 맞춘다.

원칙이 현 상황에서 부적합 하다면 과감하게 원칙을 무시하라.

언제 원칙이 유용하고, 유용하지 않은지를 판딴할 수 있는 능력을 기르는 것이다.

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

다음 코드를 하나 살펴보자.

IntStream.of(1, 2, 15, 22, 7).filter(x -> x > 10).distinct().count();

위 코드가 디미터의 법칙을 어긴다고 생각했으면 디미터의 법칙을 제대로 이해하지 못한것 이다.

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

즉, 이들은 IntStream을 다른 IntStream으로 변환한다.

위 코드는 디미터의 법칙을 위반하지 않는다.

이 결합도가 문제 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.

IntSream의 내부 구조가 외부로 노출되었는가? 아니다, 객체를 둘러싸고 있는 캡슐은 그대로 유지된다.

아무리 dot(.)를 여러번 사용하여 기차 충돌처럼 보이는 코드라도 객체의 내부 구현을 외부에 노출하지 않는다면 그것은 디미터의 법칙을 준수한 것 이다.

결합도와 응집도의 충돌

일반적으로 어떤 객체에게 상태를 물어본 후 반환된 값을 기반으로 결정을 내리는 코드는 "묻지 말고 시켜라" 스타일로 변경해야 한다.

// 일부 생략...

로버트 마틴은 <<클린코드>> 에서 디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료구조 인지에 달려있다고 설명한다.

객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는 것이 좋지만, 자료 구조라면 당연히 내부 구조를 노출해야 하므로 디미터 법칙을 적용할 필요가 없다.

소프트웨어 설계에 법칙이란 존재하지 않는다. 원칙을 맹신하지 마라.

4. 명령-쿼리 분리 원칙

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

우선 몇가지 용어를 정리하자.

어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴(routine)이라 부른다.

루틴은 다시 프로시저(procedure) 와 함수(function)으로 구분할 수 있다.

프로시저와 함수는 부수효과와 반환값의 유무라는 측면에서 명확하게 구분된다.

프로시저는 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류이다. 값을 반환할수는 없다.

이에 반해 함수는 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류다. 부수효과를 발생시킬수는 없다.

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

객체의 상태를 수정하는 오퍼레이션을 명령 이라 부르고, 객체와 관련된 정보를 반환하는 오퍼레이션을 쿼리 라고 부른다.

따라서 개념적으로 명령은 프로시저와 동일하고, 쿼리는 함수와 동일하다.

명령-쿼리 분리 원칙의 핵심은 오퍼레이션은 명령, 쿼리 중 하나여야 한다는 것 이다.

어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안된다.

마틴 파울러(Martin Fowler)는 명령-쿼리 분리 원칙에 따라 작성된 객체의 인터페이스를 명령-쿼리 인터페이스(Command-Query Interface)라고 부른다.

그럼 이렇게 명령과 쿼리를 분리해서 얻게 되는 장점은 무엇인가?

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

명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성(referential transparency)의 장점을 제한적이나마 누릴 수 있다.

그럼 참조 투명성이란 무엇일까?

컴퓨터의 세계와 수학의 세계를 나누는 가장 큰 특징은 부수효과 이다.

프로그램에서는 대입문과 함수를 통해 부수효과를 발생시킬수가 있다.

수학의 경우 x의 값을 초기화 한 후에는 변경이 불가능 하지만, 프로그램에서는 대입문을 이용해 다른 값으로 변경이 가능하다.

함수는 내부에 부수효과를 포함할 경우 동일한 인자를 전달하더라고 결과값이 매번 다를 수 있다.

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

예를 들어 f(1)이 3이라고 가정하자.

f(1) + f(1) = 6

f(1) * 2 = 6

f(1) - 1 = 2

f(1)의 결과값을 3으로 바꾸면 식의 결과는 변하지 않는다. 이것이 바로 참조 투명성 이다.

위 식에서 표현식 e는 f(1) 이고, 값은 3이다. 모든 표현식을 3이라는 값으로 바꿔도 결과는 달라지지 않는다.

참조 투명성을 만족한다.

참조 투명성은

  • 모든 함수를 이미 알고있는 하나으 값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다.

  • 모든 곳에서 함수의 결과값이 동일하기 때문에 식의 순서를 변경하더라도 결과는 동일하다.

명령 쿼리 분리 원칙을 적용하면 제한적으로 나마 이러한 참조 투명성의 혜택을 누릴수 있게 된다.

Last updated