[오브젝트] 6장 메시지와 인터페이스

ppparkta·2025년 6월 7일
0

오브젝트

목록 보기
8/14

협력과 메시지

클라이언트 - 서버 모델

여태까지 다뤘던 객체 간의 협력을 클라이언트-서버 모델로 표현할 수 있다.

  • 협력
    • 객체가 독립적으로 수행할 수 있는 것보다 더 큰 책임을 수행할 때 다른 객체에 메시지를 보내 수행한다
  • 메시지
    • 두 객체 사이의 협력 매개체

메시지와 메시지 전송

  • 메시지
    • 객체 간의 유일한 의사소통 수단

  • 메시지 구성요소
    • 오퍼레이션의 이름
    • 인자
  • 메시지 전송
    • 메시지 구성요소
    • 메시지 수신자

메시지와 메서드

  • 메서드
    • 메시지를 수신했을 때 실제로 받는 실행 함수 or 프로시저
    • 객체에 따라 실행 메서드 변경 가능

*즉, 메시지는 추상적이지만 메서드는 명확한 실체가 존재한다.

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

  • 퍼블릭 인터페이스
    • 객체를 외부와 연결해주는 메시지의 집합
    • 객체의 외부 관점에서 내부는 새까맣게 보임. 오직 퍼블릭 인터페이스만을 통해 외부와 객체가 통신할 수 있음
  • 오퍼레이션
    • 추상화된 메시지
  • 메서드
    • 실해 실행되는 코드 (구현)

시그니처

  • 시그니처
    • 오퍼레이션(or 메서드) 이름 + 파라미터 목록
  • 메서드
    • 시그니처 + 구현

용어 정리

  • 메시지
    • 객체와 협력하기 위한 매개체
  • 오퍼레이션
    • 객체와 협력하기 위한 추상적 서비스
  • 메서드
    • 오퍼레이션 구현체. 실제 실행되는 코드
    • 메서드가 하나라면 오퍼레이션과 메서드가 같을 수 있지만 오퍼레이션 == 메서드는 아니다.
    • 다형성의 축복을 이용하기 위해서 메서드와 오퍼레이션을 구분해서 생각하자
  • 퍼블릭 인터페이스
    • 외부에서 수신할 수 있는 메시지 묶음
  • 시그니처
    • 오퍼레이션이나 메서드의 명세
    • 이름 + 인자

인터페이스와 설계 품질

  • 좋은 인터페이스
    • 최소한의 인터페이스 + 추상적인 인터페이스
    • RDD를 따르면 좋은 인터페이스 설계가 가능함

이외에도 몇가지 내용을 지키면 퍼블릭 인터페이스 품질을 올릴 수 있음

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

디미터 법칙

  • 디미터 법칙
    • 객체 내부에 강하게 결합되지 않도록 협력 경로를 제한하는 것
    • 오직 인접한 이웃과만 말하라
    • 하나의 .(dot)만 사용하라
  • 디미터 법칙에 의하면 아래의 경우에만 메시지를 전달해야 한다
    • 메서드에 의해 생성된 객체
    • 메서드가 호출하는 메서드에 의해 생성된 객체
    • 전역 변수로 선언된 객체

이 설명이 이해하기 어려우면 아래의 조건을 만족할 때만 인스턴스에 메시지를 전송하도록 프로그래밍해야 한다.

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

디미터 법칙을 따르면 부끄럼타는 코드(shy code) 작성 가능.

단, 과하게 지키게 되면 결합도가 낮아지지만 응집도도 낮아질 가능성이 있음

묻지 말고 시켜라

  • 디미터 법칙
    • 훌륭한 메시지는 객체의 상태를 묻지 않고 원하는걸 시켜야 한다
  • 묻지 말고 시켜라
    • 메시지 전송자는 메시지 수신자의 상태를 기반으로 결정내리고 수신자 상태를 바꿔서는 안 된다
    • 객체 외부에서 객체 상태를 기반으로 결정내리는 것은 캡슐화를 위반한다
    • 정보와 행동을 동일한 클래스에 둬라
  • 디미터 법칙 + 묻지 말고 시켜라
    • 훌륭한 인터페이스를 제공하기 위한 오퍼레이션 힌트를 제공함
    • 해당 스타일을 무시하면 기차 충돌 코드가 만들어짐

인터페이스는 객체가 어떻게 하는지가 아니라 무엇을 하는지 서술한다.

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

  • 메서드를 명명하는 두가지 방법(켄트 벡)
    1. 메서드가 어떻게 작업하는지 나타낸다
    2. 어떻게가 아니라 무엇을 하는지 드러낸다
  1. 메서드가 어떻게 작업하는지 나타낸다
    • 이 방식을 사용하면 객체가 제대로 커뮤니케이션하지 못한다
    • 메서드 수준에서 캡슐화를 위반하게 된다
  2. 어떻게가 아니라 무엇을 하는지 드러낸다
    • 유연한 코드를 작성할 수 있다
    • 클라이언트 관점에서 바라본 코드를 작성하게 된다
    • 무엇을 하는지 드러내는 메서드를 두고 의도를 드러내는 선택자라고 한다
  • 의도를 드러내는 선택자 작성 방법 (켄트 벡)
    1. 매우 다른 두번째 구현을 상상하라
    2. 해당 메서드에 동일한 이름을 붙여라
    3. 그럼 가장 추상적인 이름을 설정할 수 있다
  • 의도를 드러내는 인터페이스 작성 방법 (에릭 에반스)
    1. 구현과 관련된 모든 정보를 캡슐화하라
    2. 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만 표현하라

⇒ 결론은 객체에게 묻지 말고 시키되 구현 방법이 아닌 클라이언트의 의도를 드러내야 한다. 그래야 이해하기 쉽고 유연하며 협력적인 객체를 작성할 수 있다.

함께 모으기

앞서 본 세개의 원칙을 이해하기 위해서 셋 다 안 지킨 코드를 확인했다

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스
  • 디미터 법칙 위반 사례
    • 디미터 법칙을 위반한 설계는 인터페이스와 구현의 분리 원칙을 위반한다
    • 내부 구조를 묻는 것도 위반에 해당한다. 내부 구조는 구현이다
    • 디미터 법칙을 어긴 코드를 수정하기 위해서 내부 구조를 묻는 대신 직접 객체가 자신의 책임을 수행하도록 한다.
    public void enter(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            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);
        }
    }
  • 묻지 말고 시켜라

    • 묻지 말고 시켜라 스타일을 따르는 퍼블릭 인터페이스를 만들어야 한다
    • 디미터 법칙과 묻지 말고 시켜라 스타일로 리팩토링하면 객체가 자신의 상태를 스스로 제어하게 된다 → 자율적인 객체를 만든다
  • 인터페이스에 의도를 드러내자

    • 퍼블릭 인터페이스가 무엇을 하는지 드러내라
    • 이를 위해서 클라이언트가 서버에게서 얻고 싶은 정보에 대해 고민하라
    • TicketSeller.setTicket()
      • 원하는 정보: 티켓 판매 sellTo()
    • Audience.setTicket()
      • 원하는 정보: 티켓 구매 buy()
    • Bag.setTicket()
      • 원하는 정보: 티켓 보관 hold()

⇒ 오퍼레이션 이름은 협력이라는 문맥을 반영해야 한다.

정리

  • 디미터 법칙
    • 객체간 협력을 설계할 때 캡슐화를 위반하는 메시지가 인터페이스에 포함되지 않도록 한다
  • 묻지 말고 시켜라 원칙
    • 디미터 법칙을 준수하는 협력을 만들기 위한 스타일을 제시한다
  • 의도를 드러내는 인터페이스
    • 퍼블릭 인터페이스에 어떤 이름이 드러나야 하는지 지침을 제공한다
    • 명확한 커뮤니케이션이 가능하도록 한다

원칙의 함정

앞서 소개한 스타일은 절대적이지 않다. 설계는 트레이드오프의 산물이기 때문이다.

몇 가지 이슈를 보며 트레이드 오프에 대해 고민해보자

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

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

다음 IntStream 코드의 경우 여러 개의 도트를 찍었기 때문에 얼핏 보면 디미터 법칙을 위반한 것처럼 보일 수 있다.

그러나 디미터 법칙은 결합도에 관한 것이다.

결합도는 내부 구조가 외부로 노출될 때에 한정한다. IntStream은 내부적으로 IntStream을 반환하므로 자기 자신을 사용하고 있을 뿐이다. 따라서 외부에 객체에 대한 정보가 노출되지 않는다.

⇒ 즉, 객체 내부 구현에 대해 무엇도 외부로 노출하지 않으면 디미터 법칙을 준수한 것이다.

결합도와 응집도 충돌

일반적으로 어떤 객체 상태를 물어보고 상태 기반으로 결정내리고 결정에 따라 객체 상태를 변경하는 코드는 묻지 말고 시켜라 스타일로 변경해야 한다.

대표적인 예시가 1장의 Theater.enter()이다.

이 예시는 결합도는 낮추면서 응집도도 높아진다.

그러나 예외 상황도 있다. 모든 상황에 맹목적으로 위임 메서드를 추가하면 퍼블릭 메서드 답지 않은 불필요한 책임을 떠안게 된다.

PeriodCondition.isSatisfiedBy() 를 보면 디미터 법칙을 준수하기 위해서 상영 할인 판단을 Screening으로 위임 시 Screening이 할인 판단에 대한 책임을 떠안게 된다.

Screening은 영화를 예매하기 위한 책임을 갖고 있다.

할인 판단이 Screening의 본질적인 책임이 맞는가?

때에 따라 캡슐화보다 응집도를 선택하는 것이 방법일 수 있다. 판단 기준은 전체를 보았을 때 이득이 되는 것으로 고르면 된다.

일급 컬렉션의 경우도 살펴보자.

for(Movie movie : movies) {
		total += movie.getFee();
}

다음과 같이 내부에 속한 객체에 대해 연산할 때 물어보는 방법밖에는 존재하지 않는다.

디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료구조인지에 달려있다.

위의 예시와 같이 Movie가 단순 자료구조일 때에는 물어보는 행위가 디미터 법칙을 위반하는 것이 아니다.

명령-쿼리 분리 원칙

가끔씩 필요에 따라 물어야 할 때도 있다는 사실을 납득했으면 명령-쿼리 원칙을 알아보면 좋다.

  • 루틴
    • 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈
    • 종류) 프로시저, 함수
    • 프로시저
      • 정해진 절차에 따라 내부 상태를 변경하는 루틴의 한 종류
      • 부수효과 o, 값 반환 x
    • 함수
      • 어떤 절차에 따라 필요한 값을 계산해 반환하는 루틴의 한 종류
      • 부수효과 x, 값 반환 o
  • 명령-쿼리
    • 명령
      • 객체 상태를 수정하는 오퍼레이션
      • 프로시저와 동일함
    • 쿼리
      • 객체 관련 정보를 반환하는 오퍼레이션
      • 함수와 동일함

오퍼레이션은 부수효과를 발생하는 명령 혹은 부수 효과를 발생시키지 않는 쿼리 둘 중 하나로 이루어져 있다. (명령이자 쿼리인 것은 존재하지 않는다)

  • 객체 상태 변경 시 명령은 반환값이 없다
  • 객체 정보 반환 시 쿼리는 상태를 변경하지 않는다

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

  • 이벤트
    • 특정한 일자에 실제로 발생하는 사건
  • 반복 일정
    • 일주일 단위로 돌아오는 특정 시간 간격에 발생하는 사건

이벤트와 반복 일정을 코드로 구현하면 다음과 같다.

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

    public Event(String subject, LocalDateTime from, Duration duration) {
        this.subject = subject;
        this.from = from;
        this.duration = duration;
    }
}
public class RecurringSchedule {
    private String subject;
    private DayOfWeek dayOfWeek;
    private LocalTime from;
    private Duration duration;

    public RecurringSchedule(String subject, DayOfWeek dayOfWeek, LocalTime from, Duration duration) {
        this.subject = subject;
        this.dayOfWeek = dayOfWeek;
        this.from = from;
        this.duration = duration;
    }

    public DayOfWeek getDayOfWeek() {
        return dayOfWeek;
    }

    public LocalTime getFrom() {
        return from;
    }

    public Duration getDuration() {
        return duration;
    }
}
Event meeting = new Event("회의",
        LocalDateTime.of(2025, 4, 24, 19, 0, 0),
        Duration.ofMinutes(30));

RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.THURSDAY,
        LocalTime.of(10, 30), Duration.ofMinutes(30));

반복 일정에 이벤트를 발생시킬 수 있다.

Event 클래스는 이벤트가 RecurringSchedule 클래스가 정의한 반복 일정 조건을 만족하는지 검사하는 isSatisfied() 메서드를 제공한다.

그런데 다음과 같은 버그가 발생했다.

RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.THURSDAY,
        LocalTime.of(10, 30), Duration.ofMinutes(30));

Event meeting2 = new Event("회의",
        LocalDateTime.of(2025, 4, 25, 10, 30, 0),
        Duration.ofMinutes(30));

assert meeting2.isSatisfied(schedule) == false;
assert meeting2.isSatisfied(schedule) == true;

처음에는 요일 조건을 만족하지 않아서 false를 반환하는데, 이후에 한번 더 같은 코드를 실행했을 때 true를 반환하는 것이다.

isSatisfied()의 내부 구조를 보면 원인을 알 수 있다.

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

reschedule(schedule) 구문을 통해 내부 상태가 수정되고 있었기 때문이다.

버그를 찾기 어려웠던 이유는 isSatisfied()가 명령과 쿼리 두 가지 역할을 동시에 수행하고 있었기 때문이다.

명령과 쿼리를 뒤섞으면 실행 결과를 예측하기 어려워질 수 있다. isSatisfied()처럼 겉으로 보기에 쿼리처럼 보이지만 내부적으로 부수효과를 가지는 메서드는

  • 이해하기 어렵고
  • 잘못 사용하기 쉽고
  • 버그를 양산하는 경향이 있다

가장 깔끔한 해결책은 명령과 쿼리를 명확히 분리하는 것이다.

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

public void reschedule(RecurringSchedule schedule) {
    from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistance(schedule)), schedule.getFrom());
    duration = schedule.getDuration();
}
if (!meeting2.isSatisfied(schedule)) {
    meeting2.reschedule(schedule);
}

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

  • 쿼리
    • 상태를 변경하지 않기 때문에 몇 번이고 호출해도 상관없다
  • 명령
    • 상태를 변경하므로 부수효과에 주의해야 한다
  • 참조 투명성
    • 어떤 표현식 e가 있을 때 e의 값으로 e가 나타내는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성
    • 만약 다음과 같이 f(1)이 있고 이 값이 3이라고 가정했을 때 표현식을 값으로 변경해도 결과는 달라지지 않는다.
      f(1) + f(1) = 6
      f(1) * 2 = 6
      f(1) - 1 = 2
      
      3 + 3 = 6
      3 * 2 = 6
      3 - 1 = 2
    • 장점
      • 모든 함수를 이미 알고 있는 하나의 결괏값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다
      • 모든 곳에서 함수의 결괏값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다
  • 불변성
    • 어떠한 값이 변하지 않는 성질
    • 부수효과 발생하지 않는다는 말과 같은 의미다

실제 객체지향 패러다임은 객체 상태 변경이라는 부수효과를 기반으로 하므로 이러한 참조 투명성은 예외에 가깝다.

하지만 명령-쿼리 분리 원칙을 사용하면 이 균열을 조금이나마 줄일 수 있다. 그래서 제한적으로나마 참조 투명성의 혜택을 누릴 수 있게 된다.

앞선 예제에서 Event 인스턴스의 reschedule()을 호출하지 않는다면 isSatisfied()를 어떤 순서로 몇 번이나 호출하던 결과는 항상 동일하다.

책임에 초점을 맞춰라

앞서 소개한 디미터 법칙, 묻지 말고 시켜라, 인터페이스에 의도를 드러내라 … 를 만족시키면서도 쉬운 방법이 있다.

메시지 먼저 선택하고 그 후에 메시지를 처리할 객체를 선택하는 것이다.

  • 명령과 쿼리를 분리하고 계약에 의한 설계 개념을 통해 객체의 협력 방식을 명시적으로 드러낼 수 있는 방법
    • 객체 구현 이전에 객체 사이의 협력에 초점을 맞추고 협력 방식을 단순하고 유연하게 만드는 것
    • 이 중심에는 객체가 수행할 책임이 위치한다.

결국 훌륭한 메시지를 얻기 위한 출발은 책임 주도 설계 원칙(RDD)를 따르는 것이다.

  • 계약에 의한 설계
    • 협력을 위해 클라이언트와 서버가 준수해야 하는 제약을 코드 상에 명시적으로 표현하고 강제할 수 있는 방법
profile
겉촉속촉

0개의 댓글