메시지와 인터페이스
객체 지향 애플리케이션의 가장 중요한 재료는 클래스가 아닌 객체들이 주고받는 메시지입니다.
객체를 수신하는 메시지들이 객체의 퍼블릭 인테페이스를 구성합니다.
책임 주도 설계 방법 + 좋은 퍼블릭 인터페이스를 얻기 위한 설계 원칙과 기법을 익히고 적용해야 합니다.
협력과 메시지
클라이언트 - 서버 모델
두 객체 사이의 협력 관계를 설명할 때 전통적으로 클라이언트 - 서버(Client - Server) 모델 을 사용합니다. 메시지를 전송하는 객체가 클라이언트, 수신하는 객체가 서버입니다. 클라이언트가 서버의 서비스를 요청하는 단방향 상호작용이죠
위 그림에서 클라이언트 Screening 이 가격을 계산하라 메시지를 전송하여 도움을 요청하고 서버 Movie 는 가격을 계산하는 서비스를 제공하여 메시지에 응답합니다.
객체는 일반적으로 위처럼 협력에서 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적입니다.
메시지와 메시지 전송
메시지(message) 는 객체들이 협력하기 위한 의사소통 수단입니다.
메시지는 오퍼레이션명(operation name), 인자(argument) 그리고, 전송 시 메시지 수신자(인자) 로 구성됩니다.
일반적으로 아래처럼 메시지 전송의 표기법을 따릅니다. 언어별로 크게 다르지 않습니다.
isSatisfiedBy(screening) 이 '메시지', condition.isSatisfiedBy(screening) 이 '메시지 전송' 입니다.
메시지와 메서드
메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입에 달려있습니다.
condition.isSatisfiedBy(screening) 에서 condition 은 DiscountCondition 이라는 인터페이스 타입이지만 실제로 실해오디는 코드는 인터페이스를 구현한 클래스의 종류에 따라 다릅니다. condition 이 PeriodCondition 의 인스턴스이면 PeriodCondition 클래스의 isSatisfiedBy 메서드가 , SequenceCondition 의 인스턴스이면 SequenceCondition 클래스의 isSatisfiedBy 메서드가 실행될 것입
니다.
이렇게 메시지를 수신했을 때 실제로 실행되는 함수 or 프로시저를 메서드 라고 합니다.
객체에서는 메시지와 메서드가 서로 다른 개념이며, 메시지는 수신자의 타입에 따라 호출되는 메소드 코드가 달라집니다.
메시지와 메서드의 구분으로 메시지 전송자와 메시지 수신자의 결합이 느슨해집니다. 전송자는 수신자가 어떤 클래스의 인스턴스인지 등을 몰라도 오직 어떤 메시지를 전송해야 하는지만 아라면 되며, 메시지 수신자도 정확히 누가 메시지를 전송하는 지를 몰라도 됩니다.
퍼블릭 인터페이스와 오퍼레이션
'객체가 의사소통을 위해서 외부에 공개하는 메시지의 집합' 을 퍼블릭 인터페이스 라고 부릅니다.
'퍼블릭 인터페이스에 포함된 메시지' 를 오퍼레이션(operation) 이라고 합니다. 보통 오퍼레이션은 내부의 구현 코드는 제외하고 단순히 메시지와 관련된 시그니처를 가리키는 경우가 대부분입니다. 반면에 실제로 실행되는 코드는 메서드라고 합니다.
UML 에서는 인터페이스의 각 요소는 오퍼레이션이며, 오퍼레이션은 구현이 아닌 추상화입니다. UML 의 메서드가 오퍼레이션을 구현한 것입니다.
시그니처
오퍼레이션(또는 메서드)의 이름과 파라미터의 목록을 합쳐서 시그니처(signature) 라고 합니다.
하나의 오퍼레이션에 오직 하나의 메서드만 있으면 굳이 오퍼레이션과 메서드를 구분할 필요 없지만 그렇지 않다면 다형성의 이점을 누릴 수 있습니다.
- 메시지: 객체가 다른 객체와 협력하기 위해 사용하는 의사소통 매커니즘. (전송 + 수신)
- 오퍼레이션: 객체가 다른 객체에게 제공하는 추상적인 서비스. 메시지는 전송/수신자 사이의 협력을 강조하고, 오퍼레이션은 메시지를 수신하는 객체의 인터페이스를 강조함(수신자의 관점). 메시지 수신은 메시지에 대응되는 객체의 오퍼레이션 호출을 의미.
- 메서드: 메시지에 응답하기 위해 실행되는 코드 블록. 오퍼레이션의 구현임.
- 퍼블릭 인터페이스: 객체가 협력에 참여하기 위해 외부에서 수신할 수 있는 메시지의 묶음. 클래스의 퍼블릭 메서드들의 집합 or 메시지의 집합을 가리킴.
- 시그니처: 오퍼레이션이나 메서드로, 이름과 인자들을 포함함.
인터페이스와 설계 품질
좋은 인터페이스는 1. 최소한의 인터페이스 여야 하며 2. 추상적인 인터페이스 여야 합니다.
1. 꼭 필요한 오퍼레이션만을 인터페이스에 포함.
2. 어떻게 수행하는지가 아닌 무엇을 하는지를 표현.
위를 만족하는 가장 좋은 방법은 책임 주도 설계 방법을 따르는 것입니다.
1. 메시지를 먼저 선택
2. 메시지가 객체를 선택
퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법은 크게 4가지가 있습니다.
* 디미터 법칙 * 묻지 말고 시켜라 * 의도를 드러내는 인터페이스 * 명령-쿼리 분리
디미터 법칙
https://sh1mj1-log.tistory.com/137 이 글에서 데이터 중심(절차적인 방식)의 영화 예매 시스템 코드 중 할인 가능 여부를 확인하는 코드의 일부입니다.
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 , Movie, DiscountCondition 에도 직접 접근하고 있어 결합도가 너무 높다는 단점이 발생합니다. Screening 의 내부 구현이 변경되면 ReservationAgency 가 함께 변경됩니다.
협력하는 객체의 내부 구조에 대현 결합으로 발생하는 설계 문제를 해결하기 위한 원칙이 디미터 법칙(Law of Demeter) 입니다. 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것입니다. 자바나 C# 처럼 'dot(.)' 을 이용해서 메시지 전송을 표현하는 언어에서는 "오직 하나의 dot 만 사용하라" 라고도 볼 수 있습니다. (엄밀히 말하면 아님. )
클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송해야 합니다.
모든 클래스 C 와 C 에 구현된 모든 메서드 M 에 대해서 M 이 메시지를 전송할 수 있는 모든 객체는 아래 클래스의 인스턴스여야 합니다.
▪️ M 의 인자로 전달된 클래스 (C 자체를 포함)
▪️ C 의 인스턴스 변수의 클래스
즉, 클래스의 내부 메서드가 아래 조건을 만족하는 인스턴스에만 메시지를 전송하도록 해야 합니다.
▪️ this 객체
▪️ 메서드의 매개변수
▪️ this 의 속성
▪️ this 의 속성인 컬렉션의 요소
▪️ 메서드 내에서 생성된 지역 객체
결합도가 매우 컸던 코드는 아래처럼 변경됩니다.
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 내부에 대한 어떤 정보도 알지 못합니다.
ReservationAgency 가 Screnning 의 내부 구조에 결합되어 있지 않기 때문에 내부 구현을 변경해도 ReservationAgency 는 변경하지 않아도 됩니다.
이렇게 디미터 법칙을 따르면 shy code 을 만들 수 있습니다. shy code 는 불필요한 어떤 것도 다른 객체에게 보여주지 않으면서 다른 객체의 구현에 의존하지 않는 코드입니다. 클라이언트와 서버 사이 결합도가 낮게 유지할 수 있습니다.
디미터 법칙을 위반하는 코드의 전형적인 모습은 아래와 같습니다.
screening.getMovie().getDiscountConditions();
이런 코드를 기차 충돌(train wreck) 이라고 합니다. 여러 대의 기차가 한 줄로 늘어서 충돌한 것처럼 보이기 때문입니다.
디미터 법칙은 클래스를 캡슐화하기 위해 따라야 하는 구체적인 지침을 제공해줍니다! 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조합니다. 정보를 처리하는데 필요한 책임을 정보를 알고 있는 객체에게 할당하기 때문에 응집도가 높은 객체가 만들어집니다. (무비판적인 디미터 법칙 적용은 객체의 응집도가 낮아질 수 있습니다.)
묻지 말고 시켜라
좋은 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다 라는 것을 강조합니다. 묻지 말고 시켜라 원칙 은 이런 스타일의 메시지 작성 원칙입니다.
이 원칙을 따르면 밀접한 정보, 행동을 하나로 캡슐화하는 객체를 가지게 됩니다. 정보 전문가가 책임을 갖게 되고 응집도가 높은 클래스를 갖게 됩니다.
상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체해서 인터페이스를 향상시켜야 합니다.
하지만 단순히 객체에게 묻지 않고 시킨다고 해서 모든 문제가 해결되지는 않습니다. 훌륭한 인터페이스는 추상적인 인터페이스라고 했었죠?
의도를 드러내는 인터페이스
객체지향에서 메서드의 이름을 짓는 방법은 '어떻게' 가 아닌 '무엇을' 하는지를 드러내는 것입니다.
'어떻게' 수행하는지를 드러내는 메서드는 내부 구현을 설명하는 메서드이기 때문에 협력 설계 시기부터 내부 구현에 대해 고민하게 됩니다. 반면에 무엇을 하는지를 드러내는 메서드는 객체가 협력에서의 책임에 대해 고민하게 만듭니다.
즉, '어떻게' 를 드러내는 메서드가 아래와 같다면
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}
'무엇을' 드러내는 메서드로 아래처럼 만들 수 있게 됩니다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}
public class SequenceCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}
이렇게 '무엇을' 하느냐에 따라 메서드의 이름을 짓는 패턴을 의도를 드러내는 선택자(Intention Revealing Selector) 라고 합니다. 이것을 인터페이스 레벨로 확장하면 의도를 드러내는 인터페이스(Intention Revealing Interface) , 즉, 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 한다 는 것이 됩니다.
객체에게 묻지 말고 시키되 구현 방법이 아닌 클라이언트의 의도를 드러내야 합니다.
디미터 법칙을 위반하는 설계는 인터페이스와 구현의 분리 원칙 을 위반합니다. 즉, 클라이언트에게 구현을 노출한다는 것을 의미하며, 그 결과 작은 요구사항 변경에도 쉽게 무너지는 불안정한 코드를 얻게 됩니다. 디미터 법칙과 묻지말고 시켜라 스타일을 따르는 인터페이스를 얻은 후에 인터페이스가 클라이언트의 의도를 올바르게 반영했는지 확인해야 합니다.
▪️ 디미터 법칙은 객체 간의 협력을 설계할 때 캡슐화를 위반하는 메시지가 인터페이스에 포함되지 않도록 제한합니다.
▪️ 묻지말고 시켜라 원칙은 디미터 법칙을 준수하는 협력을 만들기 위한 스타일을 제시합니다.
▪️ 의도를 드러내는 인터페이스 원칙은 객체의 퍼블릭 인터페이스에 어떤 이름이 드러나야 하는지에 대한 지침을 제공하여 코드의 목적을 명확하게 커뮤니케이션하게 합니다.
원칙의 함정
언제 원칙이 유용하고 유용하지 않은지를 판단할 수 있는 능력이 중요합니다.
엄밀히 말하면 디미터 법칙은 하나의 도트(.)을 강제하는 규칙이 아님.
자바의
IntStream.of(1, 15, 20, 3, 9).filter(x -> x > 10).distinct().count();
이 코드는 디미터 법칙을 위반한 것이 아닙니다. of, filter,distinct 메서드는 모두 IntStream 이라는 동일한 클래스의 인스턴스를 반환합니다. IntStream 의 인스턴스를 또 다른 IntStream 의 인스턴스로 변환하는 것입니다. 즉, IntStream 의 내부 구조가 외부로 노출되고 있지 않으므로 객체를 둘러싸고 있는 캡슐은 그대로 유지됩니다.
결합도와 응집도의 충돌
public class Theater {
public void enter(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = tickerSeller.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 은 Audience 내부에 포함된 Bag 에 대해 질문한 후 반환된 결과를 이용해서 Bag 의 상태를 변경합니다. 이는 Audience 의 캡슐화를 위반합니다. Audience 에게 위임 메서드를 추가하면,
public class Audience {
public Long buy(Ticket ticket) {
if (bag.hasInvitation()) {
bag.setTicket(ticket);
return 0L;
} else {
bag.setTicket(ticket);
bag.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
}
위처럼 위임 메서드를 통해 객체의 내부 구조를 감추는 것이 결합도를 낮추면서 응집도를 높입니다.
하지만 묻지 말고 시켜라, 디미터 원칙에 따라 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션이 공존하게 됩니다. 결국 객체가 상관없는 책임을 모두 떠안게 되어 응집도가 낮아집니다. 클래스는 하나의 변경 원인만을 가져야 합니다.
아래 PeriodCondition 은 얼핏 보아 Screening 의 내부 상태를 가져와서 사용하므로 캡슐화를 위반한 것으로 보일 수 있습니다.
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
return screening.gstStartTime().getDayofWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
할인 여부 판단 로직을 Screening 의 isDiscountable 메서드로 옮기고 PeriodCondition 이 메서드를 호출하도록 변경하면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다고 생각할 수 있습니다.
public class Screening {
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
return whenScreenend.getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(whenScreened.toLocalTime()) <= 0 &&
endTime.compareTo(whenScreened.toLocalTime()) >= 0;
}
}
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
return screening.isDiscountable(dayOfWeek, startTime, endTime);
}
}
하지만 이렇게 하면 Screening 이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 됩니다.
기간에 따른 할인 조건을 판단하는 것이 Screening 의 본질적인 책임이 아닙니다. 이 경우 응집도가 낮아집니다.
게다가 Screening 의 isDiscountable 메서드는 PeriodCondition 의 인스턴스 변수 dayOfWeek, startTime, endTime 을 인자로 받기 때문에 결합도가 높아집니다. 따라서 이 경우 Screening 의 캡슐화를 향상시키는 것보다 Screening 의 응집도를 높이고 Screening 과 PeriodCondition 사이의 결합도를 낮추는 것이 더 좋습니다.
가끔은 묻는 것 외에는 다른 방법이 존재하지 않은 경우도 존재합니다. 객체에게 시키는 것이 항상 가능한 것은 아닙니다. 결국 뻔한 이야기지만 '경우에 따라 다릅니다.' 원칙 적용이 적절한지, 부적절한지를 판단하는 안목을 기르는 수 밖에 없습니다.
명령-쿼리 분리 원칙
명령-쿼리 분리(Command-Query Seperation) 원칙 은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있습니다.
루틴(routine): 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈. 루틴은 프로시저(procedure) 와 함수(function) 로 구분됩니다. 프로시저는 부수효과(side effect) O, 값 반환 X. 함수는 값 반환 O, 부수효과 X.
명령(Command) 과 쿼리(Query) 는 객체의 인터페이스 측면에서 각각 순서대로 객체의 상태를 수정하는 오퍼레이션, 객체와 관련된 정보를 반환하는 오퍼레이션입니다.
■ 오퍼레이션은 부수효과를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야 합니다.
■ 객체의 상태를 변경하는 명령은 반환값을 가질 수 없음. 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없음.
즉, '질문이 답변을 수정해서는 안 됩니다.'
명령과 쿼리를 뒤섞으면 실행결과를 예측하기 어려워집니다. 겉으로 보기에는 쿼리처럼 보이지만 내부적으로 부수효과를 가지는 메서드는 이해하기 어렵고 잘못 사용하기 쉬우며 버그를 양산합니다.
명령-쿼리 분리와 참조 투명성
명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성(referential transparency) 의 장점을 제한적이나마 누릴 수 있게 됩니다. 참조 투명성은 "어떤 표현식 e 가 있을 때 모든 e 를 e 의 값으로 바꾸더라도 결과가 달라지지 않는 특성" 입니다.
f(1) = 3 일 때 f(1) + f(1) = 6 인 것이 3 + 3 = 6 과 같다는 것입니다. f(1) 은 불변성(immutability) 을 가집니다. 어떤 값이 불변한다는 말은 부수효과가 발생하지 않는다는 말과 같습니다.
참조 투명성을 만족하는 식은 두 가지 장점을 가집니다.
▶ 모든 함수를 이미 알고 있는 하나의 결과값으로 대체할 수 있기 때문에 식을 쉽게 계산 가능.
▶ 모든 곳에서 함수의 결과값이 동일하기 때문에 식의 순서를 변경하더라도 식의 결과는 달라지지 않음.
명령형 프로그래밍(imperative programming): 부수효과를 기반으로 하는 프로그래밍 방식
함수형 프로그래밍(functional programming): 부수효과가 존재하지 않는 수학적인 함수에 기반. → 참조 투명성의 장점을 극대화, 프로그램의 실행결과 예측이 쉬움. 병렬 처리가 중요해진 최근에는 함수형 프로그래밍이 중요해지고 있음.
책임에 초점을 맞춰라
메시지를 먼저 선택하고 그 후에 메시지를 처리하는 객체를 선택하도록 인터페이스를 설계하면 디미터 법칙, 묻지 말고 시켜라, 의도를 드러내는 인터페이스, 명령-쿼리 분리 원칙 을 지킬 수 있게 됩니다.
훌륭한 메시지를 얻기 위한 출발점은 책임 주도 설계 원칙을 따르는 것입니다. 더 중요한 것은 협력에 적합한 객체가 아닌 협력에 적합한 메시지입니다. 책임 주도 설계 방법에 따라 메시지가 객체를 결정하게 합시다.
지금까지의 원칙에서는 구현과 부수효과를 캡슐화하고, 높은 응집도와 낮은 결합도를 가진 인터페이스를 만들 수 있는 지침을 제공하지만 실제로 실행 시점에 필요한 구체적인 제약이나 조건을 명확하게 표현하지는 못합니다. 즉, 협력을 위해 두 객체가 보장해야 하는 실행 시점의 제약을 인터페이스에 명시할 수 있는 방법이 존재하지 않는다는 것입니다.
계약에 의한 설계(Design By Contract) 는 협력을 위해 클라이언트와 서버가 준수해야 하는 제약을 코드 상에 명시적으로 표현하고 강제할 수 있는 방법입니다. 이는 테스트 코드와도 관련이 있다고도 볼 수 있습니다. 나중에 구체적으로 알아봅시다.
이렇게 객체의 퍼블릭 인터페이스가 객체의 품질에 어떤 영향을 미치는지 알아보았습니다. 다음 글에서는 객체지향 개념들이 탄생하게 된 배경을 알아봅니다.
'Computer Science > 객체지향' 카테고리의 다른 글
의존성 관리 - 코드로 이해하는 객체지향 (0) | 2023.08.23 |
---|---|
객체 분해 - 코드로 이해하는 객체지향 (0) | 2023.08.21 |
책임 할당 - 코드로 이해하는 객체지향 프로그래밍 (0) | 2023.07.28 |
설계 품질과 트레이드오프 - 코드로 이해하는 객체지향 프로그래밍 (0) | 2023.07.25 |
역할, 책임, 협력 - 코드로 이해하는 객체지향 프로그래밍 (0) | 2023.07.21 |