오브젝트 - 06. 메시지와 인터페이스

1

오브젝트

목록 보기
6/7
post-thumbnail

객체지향 프로그래밍에 대한 가장 흔한 오해는 애플리케이션이 클래스의 집합으로 구성된다는 것이다.

훌륭한 객체지향 코드를 얻기 위해서는 클래스가 아니라 객체를 지향해야 한다.

01. 협력과 메시지

클라이언트-서버 모델

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

메시지와 메시지 전송

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

메시지 전송 또는 메시지 패싱: 한 객체가 다른 객체에게 도움을 요청하는 것
메시지를 전송하는 객체: 메시지 전송자
메시지 수신자: 메시지를 수신하는 객체

메시지는 오퍼레이션명 + 인자 구성
메시지 전송은 메시지에 메시지 수신자를 추가한 것

메시지와 메서드

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

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

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

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

프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(opertaion)이라고 부른다. 오퍼레이션은 어떤 행동에 대한 추상화다.

시그니처

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

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

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

디미터 법칙

ReservationAgency는 사소한 변경에도 흔들리는 의존성의 집결지다.

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

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

screnning.getMoive().getDiscountConditions();

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

디미터 법칙을 따르도록 코드를 개선하면 메시지 전송자는 더 이상 메시지 수신자의 내부 구조에 관해 묻지 않게 된다. 단지 자신이 원하는 것이 무엇인지를 명시하고 단순히 수행하도록 요청한다.

screening.calculdateFee(audienceCount)

묻지말고 시켜라

묻지 말고 시켜라 원칙을 따르면 밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들 수 있다.

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

켄트 백(Kent Beck)은 메서드를 명명하는 두 가지 방법을 설명했다. 첫 번째 방법은 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것이다. 이 경우 메서드의 이름은 내부의 구현 방법을 드라낸다. 다음은 ㅓㅅ 번째 방법에 따라 메서드를 명명한 것이다.

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

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

이런 스타일은 좋지 않은데 그 이유를 두 가지로 요악할 수 있다.

  • 메서드에 대해 제대로 커뮤니케이션하지 못한다. 클라이언트 관점에서 isSatisfiedByPeriod, isSatisfiedBySequence 모두 할인 조건을 판단하는 동일한 작업을 수행한다.
  • 더 큰 문제는 메서드 수준에서 캡슐화를 위반한다는 것이다. 이 메서드들은 클라이언트로 하여금 협력하는 객체의 종류를 알도록 강요한다.

두 번째 방법은 '어떻게'가 아니라 '무엇'을 하는지 드러내는 것이다.

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

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

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

클라이어느가 두 메서드를 가진 객체를 동일한 타입으로 간주할 수 있도록 동일한 타입 계층으로 묶어야 한다. 가장 간단한 방법은 인터페이스를 정의하고 오퍼레이션을 정의하는 것이다.

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

함께 모으기

묻지말고 시켜라

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

03. 원칙의 함정

디미터 법칙, 묻지 말고 시켜라 스타일은 인터페이스를 깔끔하고 유연하게 만들 수 있는 훌륭한 설계 원칙이다. 하지만 절대적인 법칙은 아니다.

사실 설계가 트레이드오프의 산물이라는 것이다.

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

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

하지만 이것은 디미터 법칙을 제대로 이해하지 못한 것이다. 위 코드에서 of, filter, distinct 메서드는 모두 IntStream 이라는 동일한 클래스의 인스턴스를 반환한다.

결합도와 응집도의 충돌

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

public class Theater {    
    public void legacyEnter(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        } else {
            ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticketSeller.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

TheaterAudience 내부에 포함된 Bag에 대해 질문한 후 반환된 결과를 이용해 Bag의 상태를 변경한다. 이 코드는 분명히 Audience의 캡슐화를 위반하기 때문에 TheaterAudience의 내부 구조에 강하게 결합된다. 이 문제를 해결 하기 위해 상태 변경 코드를 Audience로 옮겨야 한다.

public class Audience {
    public Long legacyBuy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

이제 Audience는 상태와 함께 상태를 조작하는 행동도 포함하기 때문에 응집도가 높아졌다.

isSatisfiedBy 메서드는 screening에게 질의한 후 할인 여부를 결정한다. 이 코드는 Screening 의 내부 상태를 가져와 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.

public class PeriodCondition implements DiscountCondition {

    @Override
    public boolean isSatisfiedBy(Screening screening) {
        return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
}

따라서 할인 여부를 판단하는 로직을 ScreeningisDiscountable 메서드로 옮기고 PeriodCondition이 이 메서드를 호출하도록 변경한다면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다고 생각할 것이다.

public class Screening {
    public boolean isSatisfiedByBad(Screening screening) {
        return screening.isDiscountableBad(dayOfWeek, startTime, endTime);
    }
}

public class PeriodCondition implements DiscountCondition {
    public boolean isSatisfiedByBad(Screening screening) {
        return screening.isDiscountableBad(dayOfWeek, startTime, endTime);
    }
}

하지만 이렇게 하면 Screening이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이것은 Screening이 담당해야 하는 본질적인 책임이 아니다. Screening의 책임은 영화를 예매하는 것이다.

04. 명령-쿼리 분리 원칙

가끔씩은 필요에 따라 물어야 한다는 사실에 납득했다면 명령-쿼리 분리(Command-Query Separation) 원칙을 알아두면 도움이 될 것이다. 명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.

  • 루틴(routine): 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈
    • 프로시저(procedure): 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류
    • 함수(function): 어떤 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류

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

명령과 쿼리를 분리하기 위해서는 다음의 두 가지 규칙을 준수해야 한다.

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

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

도메인의 중요한 두 가지 용어인 "이벤트(event)"와 "반복 일정(recurring schedule)"에 관해 살펴보자. "이벤트"는 특정 일자에 실제로 발생하는 사건을 의미한다.

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;
    }
}

"반복 일정"은 RecurringSchedule 클래스로 구현한다. 이 클래스는 주 단위로 반복되는 일정을 정의하기 위한 클래스다.

@Getter
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;
    }
}

isSatisfied 함수를 통해 검증을 하는데 버그가 발견된다.

RecurringSchedule schdule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY, LocalTime.of(10, 30), Duration.ofMinutes(30));
Event meeting = new Event("회의", LocalDateTime.of(2019, 5, 9, 10, 30), Duration.ofMinutes(30));

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

2019년 5월 9일은 목요일이므로 수요일이라는 반복 일정의 조건을 만족시키지 못한다. 따라서 isSatisfied 메서드는 false를 반환한다.

다시 한 번 isSatisfied 메서드를 호출하면 놀랍게도 true를 반환한다.

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

isSatisfied 메서드는 false를 반환하기 전에 reschedule 메서드를 호출하고 있다. 이 메서드는 Event 객체의 상태를 수정한다.

public class Event {    
    private void reschedule(RecurringSchedule schedule) {
        from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistance(schedule)),
                schedule.getFrom());
        duration = schedule.getDuration();
    }
    
    private long daysDistance(RecurringSchedule schedule) {
        return schedule.getDayOfWeek().getValue() - from.getDayOfWeek().getValue();
    }
}

reschedule 메서드는 Event의 일정을 인자로 전달된 RecurringSchedule의 조건에 맞게 변경한다. Event가 RecurringSchedule에 설정된 조건을 만족하지 못하는 경우 Event의 상태를 변경한다.

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

명령과 쿼리를 뒤섞으면 실행 결과를 예측하기가 어려워질 수 있다. 가장 깔끔한 해결은 명령과 쿼리를 명확하게 분리하는 것이다.

public class Event {
    public boolean isSatisfied(RecurringSchedule schedule) {
        if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
            !from.toLocalTime().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();
    }
}

수정 후의 isSatisfied 메서드는 부수효과를 가지지 않기 때문에 순수한 쿼리가 됐다.

reschedule 메서드도 public으로 변경되어 외부에서 접근할 수 있으므로 아래와 같이 사용할 수 있다.

if (!event.isSatisfied(schedule)) {
	event.reschedule(scheudle);
}

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

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

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

profile
서버 백엔드 개발자

0개의 댓글