Please enable JavaScript to view the comments powered by Disqus.객체지향에서 메세지와 인터페이스 - 설계
Search
💡

객체지향에서 메세지와 인터페이스 - 설계

태그
Object
OOP
Functional Interface
공개여부
작성일자
2021/01/14
객체지향에서 가장 많이 하는 오류는 class 에 대한 집착이다. 사실 객체지향이라 말하며 class 를 어떻게 구성해야 하는지 정말 많이 고민 했었고, 이것이 객체지향의 전부라고 생각했었다. 하지만, 훌륭한 객체지향 코드를 얻기위해 class가 아닌 obejct 를 지향해야 한다. → 내 생각을 깨어버린 문장이라 그대로 가져왔다.
오브젝트 책의 6장 메세지와 인터페이스를 읽고 정리해둔 내용 입니다. 너무 좋은 책이고 특히 스프링 개발자라면 코드를 작성하는 패러다임이 상당히 변경될 훌륭한 책이니 꼭 읽어보시길 추천합니다!

이 챕터에서 가장 의미있는 내용

협력 안에서 객체가 수행하는 책임에 초점을 맞출것
책임은 객체가 수신할 수 있는 message 의 기반이 된다는 것
이유는 객체지향 app 에서 가장 중요한 재료는 class 가 아닌 객체들이 주고받는 메세지 이기 때문이다.
app 은 클래스로 구성되지만, 메세지를 통해 정의된다는 사실을 기억해야 한다.
객체가 수신하는 message 들이 객체의 public interface 를 구성한다.

협력과 메세지

client-server model

협력은 다른 객체에게 무언가 요청할 때 시작된다.
message 를 전송하는 객체를 client 라 하고, 수신하는 쪽을 server 라 한다.
1.
Screening 은 가격을 계산하라는 메세지를 Movie 에게 전송한다.
Screening : client, Movie : server
2.
Movie 는 할인 정책에 대한 정보를 얻기 위해 DiscountPolicy 로 메세지를 전송한다.
Movie : client, DiscountPolicy: server
이렇게 상대적으로 client 와 server 의 역할이 할당된다.

메세지와 메세지 전송

message: 오퍼레이션 명(operation name) + 인자 (argument) 로 구성되었다.
condition.isSatisfiedBy(screen) 과 같이 구성되어 있다.
condition 수신자
isSatisfiedBy operation name
screen argument
메세지 전송(message sending, message passing): 객체들이 협력을 하기 위해 사용하는 유일한 의사소통 수단
message 에 receiver 를 더하면 메세지 전송이다.
메세지 전송자(message sender): 메세지를 전송하는 객체 (client)
메세지 수신자(message recevier): 메세지를 수신하는 객체 (server)
message: operation + argument 로 구성되어 있다.

message 와 method (aka. 다형성)

condition.isSatisfiedBy(screen) 에서 condition 은 interface 이기 때문에 이 interface 를 구현한 코드가 실행된다. 따라서 SequenceConditionPeriodCondition 중에서 어떤 instance 의 isSatisfiedBy 를 실행 시키느냐에 따라 실행되는 코드가 달라진다.
따라서 compile 시점의 코드와 runtime 시점의 실행되는 코드가 다르다
그리고 이것을 '느슨한 결합' 이라 한다.

왜 이런 짓이 가능하지?

message sender 는 자신이 어떤 message 를 전송하는지만 알면 된다.
수신자를 고려할 필요가 없다.
message receiver 는 자신이 어떤 message 를 수신 하는지, 그래서 무엇을 하는지만 알면 된다.
누가 전송자인지 고려할 필요가 없다.
객체 지향에서 sender 와 receiver 는 서로를 자세히 알지 못한 채로 message 라는 얇고 가는 끈을 통해 연결하여 결합도를 낮춰 유연하고 확장 가능한 코드를 작성할 수 있게 만들어야 한다.

public interface 와 operation

객체는 캡슐화를 통해 장막에 모든것을 감추고 미지의 공간에서 소통을 위해 공개하는 메세지를 통해서만 의사소통을 해야한다.
public interface: 객체가 의사소통을 위해 외부에 공개하는 메세지의 집합
operation: 수행 가능한 어떤 행동에 대한 추상화, 내부 구현 코드는 제외하고 단순히 메세지와 signature 를 가리킨다.
method: 실제로 실행되는 코드
객체가 다른 객체에게 message 를 전송하면 runtime system 은 operation 호출로 해석하고, 메세지를 수신한 객체의 실제 타입을 기반으로 적절한 method 를 찾아 실행한다.

시그니처

operation(혹은 method)의 이름과 parameter 목록을 합쳐 시그니처라 부른다.

인터페이스의 설계 품질

최소한의 interface: 꼭 필요한 operation 만을 interface 에 포함한다.
추상적인 interface: 무엇을 하는지 표현한다.
최소한의 interface 와 추상적인 interface 를 만족하는데 필요한 것이 책임 주도 설계이다.
책임 주도? message 를 먼저 선택함으로써 객체를 선택하게 한다. client 의 의도가 message 에 표현되어야 한다.
interface 품질에 영향을 미치는 원칙을 알아본다.

1. 디미터 법칙 (결합도)

객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라.
오직 인접한 이웃하고만 말하라
오직 하나의 도트만 사용하라
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) { // 이런 코드를 기차 충돌(train wreek) 코드라 한다. dicountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek() && condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 && condition.getEndTime.compareTo(screening.getWhenScreend().toLocalTime()) >= 0; } else { discountable = condition.getSequence() == screening.getSequence(); } } } }
Java
복사
이 코드의 문제는 Screening 과 결합도가 너무 높다.
class 내부의 method 가 아래 조건을 만족하는 instance 에만 메세지를 전송하도록 해야한다.
this 객체
method의 매개변수
this 의 속성
this 의 속성인 collection
method 내에서 생성된 지역 객체
public class ReservationAgency { public Reservation reserve(Screening screening, Customer customer, int audienceCount) { Money fee = screening.calculateFee(audienceCount); return new Reservation(customer, screening, fee, audienceCount); } }
Java
복사
method 의 인자로 전달된 screening instance 에게멘 메세지를 전송한다.
ReservationAgencyScreening 내부 정보를 알지 못한다.
다른 객체의 구현에 의존하지 않는 코드를 작성할 수 있다.
디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조한다.
하지만, 무비판적으로 디미터 법칙을 수용하면 객체의 응집도가 낮아질 수 있다.(원칙의 함정)

3. 묻지 말고 시켜라

객체의 상태에 관해 묻지말고 원하는 것을 시켜야 한다
// 객체의 상태를 묻고 있다. dicountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek() && condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 && condition.getEndTime.compareTo(screening.getWhenScreend().toLocalTime()) >= 0;
Java
복사
요금 계산은 요금에 대해 가장 잘 아는 객체(Screening)한테 시켜라 ReserverAgency 에 끌고 나와 계산할게 아니다.

객체의 외부에서 해당 객체의 상태를 기반으로 결정을 내리는 것은 캡슐화를 위반한다.

객체의 정보를 이용하는 행동을 객체 내부로 돌려야 한다.
따라서 내부의 상태를 묻는 operation 을 interface 에 포함시키고 있다면, 다른 방법을 고민해 봐야 한다.
상태를 묻는 operation 을 행동하는 operation 으로 바꿔라
협력을 설계하고 객체가 수신할 메세지를 결정할 때 묻지 말고 시켜라 (호출 되는 객체가 스스로 결정한다)

의도를 드러내는 interface (의도를 드러내는 선택자)

하지만, 묻지 않고 시킨다고 능사가 아니다. interface 는 객체가 어떻게가 아니라 무엇을 하는지 서술해야 한다.
public class PeriodCondition { public boolean isSatisfiedByPeriod(Screening screening) {...} } public class SequenceCondition { public boolean isSatisfiedBySequence(Screening screening) {...} }
Java
복사
위 method 는 어떻게 를 설명하는 네이밍이다. 이러한 코드는 두가지 측면에서 좋지 않다.
1.
내부 구현을 정확하게 이해하지 못하면, 동일한 작업을 수행함을 알 수 없다.
2.
캡슐화를 위반한다: 정책이 변경되어 기간으로 할인을 결정하던 상영이 순서로 변경되면 client 코드까지 수정해야 한다.
반면 무엇을 하는지 드러내는 네이밍은 객체가 협력 안에서 수행해야 하는 책임에 관한 고민이 필요하다.
// 무엇: 할인 조건을 만족하는지 public class PeriodCondition { public boolean isSatisfiedBy(Screening screening) {...} } public class SequenceCondition { public boolean isSatisfiedBy(Screening screening) {...} }
Java
복사
정책이 변경되어 할인 방법이 변경될 경우를 대비해 두 method 가 동일한 타입으로 간주할 수 있도록 동일한 계층으로 묶어야 한다
public interface DiscountCondition { boolean isSatisfiedBy(Screening screening); }
Java
복사
그래서 interface 로 정의하고 각 객체가 책임에 맞춰 구현을 담당한다.
무엇을 하느냐에 중점을 맞춰 이름을 짓게 되면 설계가 유연해진다. 그러면 같은 계층으로 묶을 수 있기 때문이다.
이처럼 무엇을 하느냐에 따라 method 이름을 짓는 패턴을 의도를 드러내는 선택자 (Intention Revealing Selector) 라 한다.
훈련방법
켄트백은 type 이름, method 이름, parameter 이름을 모두 결합하여 의도를 드러내는 interface 를 형성한다
이렇게 하면 개발자가 내부를 이해해야 할 필요성이 줄어든다. (문제를 내라, 방정식 푸는 방법을 제시하지 말고 공식으로 표현하라)

4. 함께 모으기

디미터, 묻지 말고 시켜라, 의도를 드러내는 interface 를 이해하는 가장 좋은 방법은 이런 원칙을 위반하는 코드를 살펴보는 것이다.

디미터 원칙 위배

public class Theater { private TicketSeller ticketSeller; public Theater(TicketSeller ticketSeller) { this.ticketSeller = ticketSeller; } public void enter(Audience audience) { if (audience.getBag().hasInvitation()) { Ticket ticket = ticketSeller.getTicketOffice().getTicket(); audience.getBag().setTicket(ticket); } else { Ticket ticket = ticketSeller.getTicketOffice().getTicket(); // 기차 충돌, dot 은 하나만 사용하라 위배 audience.getBag().minusAmount(ticket.getFee()); ticketSeller.getTicketOffice().plusAmount(ticket.getFee()); audience.getBag().setTicket(ticket); } ticketSeller.setTicket(audience); } }
Java
복사
audienceticketSeller 에게 메세지를 보내는 것은 전혀 문제가 없다.
하지만 audience 내부의 bag 에 메세지를 보내는 것은 문제이다.
bagaudience 의 내부 구현이다. 이것이 public 에 포함 된 것은 내부 구현을 노출시키는 것이며, 작은 요구사항 변경에도 무너지는 불안정한 코드가 된다.
AudienceTicketSeller 의 내부 구조를 묻는 대신 각각 직접 책임을 수행하도록 시키는 것이다.

묻지 말고 시켜라 위배

TheaterTicketSellerAudience 의 내부 구조에 관해 묻지 말고 원하는 작업을 시켜야 한다.
묻지 않고 시킬 수 있는 interface 가 필요하다.
1.
Theater 가 TicketSeller 에게 시키고 싶은 일은 Audience 가 Ticket 을 가지도록 하는 것이다. (setTicket 을 추가한다)
public class TicketSeller { @Getter private TicketOffice ticketOffice; public TicketSeller(TicketOffice ticketOffice) { this.ticketOffice = ticketOffice; } public void setTicket(Audience audience) { if (audience.getBag().hasInvitation()) { Ticket ticket = ticketOffice.getTicket(); audience.getBag().setTicket(ticket); } else { Ticket ticket = ticketOffice.getTicket(); audience.getBag().minusAmount(ticket.getFee()); ticketOffice.plusAmount(ticket.getFee()); audience.getBag().setTicket(ticket); } }
Java
복사
public class Theater { private TicketSeller ticketSeller; public Theater(TicketSeller ticketSeller) { this.ticketSeller = ticketSeller; } public void enter(Audience audience) { ticketSeller.setTicket(audience); } }
Java
복사
자신을 속성으로 포함하고 있는 TicketSeller 의 인스턴스에만 메세지를 전송한다.
이제 Audience 가 티켓을 보유하도록 만든다
class Audience { public Audience(Bag bag) { this.bag = bag; } public long setTicket(Ticket ticket) { if (bag.hasInvitation()) { bag.setTicket(ticket); return 0L; } else { bag.setTicket(ticket); bag.minusAmount(ticket.getFee()); return ticket.getFee(); } } }
Java
복사
이에 맞춰 TicketSeller 도 수정하는데 TicketOffice 의 instance 와 인자로 전달된 Audience 에게만 메세지를 전달한다
class TicketSeller { @Getter private TicketOffice ticketOffice; public TicketSeller(TicketOffice ticketOffice) { this.ticketOffice = ticketOffice; } public void setTicket(Audience audience) { ticketOffice.plusAmount( audience.setTicket(ticketOffice.getTicket()) ); } }
Java
복사

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

TheaterTicketSeller 에게 setTicket 메세지를 전송해서 얻고 싶은 결과는 무엇인가?
Audience 에게 티켓을 판매하는 것이다.
public class TicketSeller { public void sellTo(Audience audience) {...} } public class Audience { public Long buy(Ticket ticket) {...} } public class Bag { public Long hold(Ticket ticket) {...} }
Java
복사
operation 의 이름은 협력 이라는 문맥을 반영해야 한다. sellTo, buy, hold 와 같은 이름은 client 에 의도를 분명히 드러낸다.

원칙의 함정 (원칙을 맹신하지 마라)

설계는 trade off 의 산물이다. 원칙이 현재 상황에서 부적합 하다 판단되면 과감하게 원칙을 무시하라. 원칙을 아는 것 보다 원칙이 유용하고 언제 유용하지 않은지 판단할 수 있는 능력을 기르는 것이 중요하다.

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

IntStream.of(1, 5, 20, 3, 9).filter(x -> x>10).disticnt().count();
Java
복사
이것을 보고 기차 충돌을 생각할 수 있지만, 이들은 IntStream 의 또 다른 instance 를 변환하는 것이다. 객체 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수하는 것이다.

결합도와 응집도의 충돌

public class PeriodCondition implements DiscountCondition { // 생략 @Override public boolean isSatisfiedBy(Screening screening) { return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) && startTime.isBefore(screening.getWhenScreened().toLocalTime()) && endTime.isAfter(screening.getWhenScreened().toLocalTime()); } }
Java
복사
이 코드를 보면 Screening 안으로 체크하는 로직이 들어가야 디미터 법칙을 준수하는 것이 아닐까 하는 의문이 든다. 하지만, Screening 안으로 상태를 확인하는 로직이 들어가면 할인을 계산하는 주체는 Screening 이 된다. 그것은 역할과 책임 관점에서 봤을 때 적합하지 않다.
for (Movie each : movies) { total += each.getFeed(); }
Java
복사
또한 collection 역시 전체 영화 가격을 계산하는 것을 직접 묻는것 밖에 방법이 없다.
객체는 내부 구조를 숨겨야 하지만, 자료구조는 내부를 노출해야 하므로 디미터 법칙을 적용할 이유가 없다.
원칙을 맹신해서는 안된다.
software 에 법칙은 없다. 법칙은 예외가 없지만, 원칙에는 예외가 넘쳐난다.

명령 - 쿼리 분리 원칙

필요에 따라 물어야 하는 사실이 동의가 된다면 command-query separation 을 알아두면 도움이 된다.
명령: 객체의 상태를 변경시킬 수 있지만, 값을 반환할 수 없다.
쿼리: 객체의 정보를 반환할 수 있지만, 부수효과(객채의 상태 변화)를 발생시킬 수 없다.
데이터를 반환 받는 함수는 몇번을 호출하더라도 동일한 결과가 나와야 투명성이 보장된다. 호출할 때 마다 내부적인 연산으로 인해 객체의 상태가 변경된다면 사용자 입장에서 그 method 의 내부 구현을 정확히 알아야 사용할 수 있다.

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

수학과 컴퓨터 세계의 가장 큰 차이점은 부수효과(side effect)의 유무이다.
수학에서 function 은 그냥 그 기능 그대로 작동하지만, 컴퓨터에서는 function 의 결과를 받으며 내부적으로 상태가 변경될 수 있다.
명령과 쿼리로 구분하여 반환 값이 없는 명령 함수를 만들면 호출할 때 주의할 수 있게 된다.
f에 1을 넣으면 반드시 3을 반환하는 함수가 있다고 가정하자
f(1) + f(1) = 6 f(1) * 2 = 6 f(1) - 1 = 2
Java
복사
위와 같이 성립할 때 다음을 보면
3 + 3 = 6 3 * 2 = 6 3 - 1 = 2
Java
복사
f(1) 을 3으로 대체하여 계산해도 동일한 결과가 나온다.
이것이 참조 투명성이다.
이러한 수식이 성립하는 이유는 f(1) 이 3이고 이 결과가 절대로 변하지 않기 때문이다. 이처럼 어떤 값이 변하지 않는 성질을 불변성(immutable) 이라 하는데, 어떤 값이 변하지 않는다는 말은 부수효과가 발생하지 않는다는 말과 동일하다.
객체 지향 패러타임에서 객체 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다.
하지만 명령-쿼리 분리 원칙을 사용하면 이 균열을 줄일 수 있으며 제한적으로 나마 참조 투명성의 혜택을 누릴 수 있다.