OOP — 객체의 품질을 높이기 위한 좋은 퍼블릭 인터페이스

Jiyun Park
13 min readMay 31, 2021

--

Object 코드로 이해하는 객체지향 설계 책 리뷰

4장, 5장 : 객체가 저장하는 데이터 대신, 객체가 외부에 제공하는 책임을 기준으로 객체를 분해하자

6장 서론..

“ 객체지향 어플리케이션의 가장 중요한 재료는 클래스가 아니라 객체들이 주고받는 메세지이다. 객체가 수신하는 메세지들이 객체의 퍼블릭 인터페이스를 구성하고, 훌륭한 퍼블릭 인터페이스를 얻기 위해서는 책임 주도 설계 방법을 따르는 것만으로는 부족하다. 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 데 도움이 되는 설계 원칙과 기법을 익히고 적용해야한다.”

협력과 메세지

객체가 다른 객체에 접근할 수 있는 유일한 방법은 메세지를 전송하는 것. 메세지를 매개로 하는 요청과 응답의 조합이 두 객체 사이의 협력을 구성한다.

인터페이스와 설계 품질

퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법.

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

> 디미터 법칙 (Law of Demeter)

객체 내부의 구조에 강하게 결합되지 않도록 협력 경로를 제한하라.

클래스가 특정한 조건을 만족하는 대상에게만 메세지를 전송하도록 프로그래밍 해야한다.

❌ 디미터의 법칙을 위반하는 코드

screening.getMovie().getDiscountConditions();

메세지 전공자(reservation agency)가 수신자(screening)의 내부 구조에 대해 물어보고 반환받은 요소(movie)에 대해 연쇄적으로 메세지를 전송한다.

getMovie()를 통해 객체의 내부 상태에 접근

⭕ 디미터의 법칙 적용

screening.calculateFee(audienceCount);

Screening 인스턴스에게만 메세지를 전송한다.

calculateFee 를 통해 필요한 것을 수행하도록 시킴 → 할인 가격을 계산하는 주체를 ReservationAgency에서 실제 데이터를 포함하고 있는객체 Screening으로 이동시킴

객체는 자신이 어떤 데이터를 갖고 있는지 내부에 캡슐화하고 외부에 공개해서는 안된다. 객체는 단순한 데이터 제공자가 아니다. 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.

객체에게 의미있는 메소드는 객체가 책임쳐야 하는 무언가를 수행하는 메서드이다.

전체 코드

데이터 중심 설계 — 영화 예매 시스템 코드 (수정 전)

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

/*
discountable 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산
*/
Money fee;
if(discountalbe){
Money discountAmount = Money.ZERO;
switch(movie.getMovieType()){
case AMOUNT_DISCOUNT:
discountAmount = movie.getDiscountAmount();
brek;
case PERCENT_DISCOUNT:
discountAmount = movie.getFee().times(movie.getDiscountPercent());
break;
case NONE_DISCOUNT:
discountAmount = Money.ZERO;
break;
}
fee = movie.getFee().minus(discountAmount);
}else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
  • ReservationAgency 가 모든 데이터 객체에 의존
  • DiscountCondition의 데이터가 변경되거나 Screening 의 데이터가 변경되면 ReservationAgency도 함께 수정해야함
  • 높은 결합도

할인 가능 여부를 확인하는 코드 수정 후

public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
public class Screening {
public Money calculateFee(int audienceCount){
switch(movie..getMovieType()){
case AMOUNT_DISCOUNT:
...
}
...
}
}
public class Movie {
public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
for(DiscountCondition condition : discountConditions){
...
}
return false;
}
}

ReservationAgency는 Screening 내부의 Movie에 접근하는 대신 Screening에게 직접 요금을 계산하도록 요청한다. 요금을 계산하는 데 필요한 정보를 잘 알고 있는 Screening에게 요금을 계산할 책임을 할당한 것이다.

> 묻지 말고 시켜라

내부 상태를 묻는 오퍼레이션을 인터페이스에 포함시키고 있다면 더 나은 방법이 없는지 고민하라. 상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체함으로써 인터페이스를 향상시켜라.

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

IntStream.of(1, 15, 20, 3, 9)
.filter(x -> x > 10)
.distinct()
.count();
  • of, filter, distinct 메서드는 모두 IntStream 이라는 동일한 클래스의 인스턴스를 반환
  • 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않음

> 의도를 드러내는 인터페이스 (Intention Revealing Interface)

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

❌ 좋지 않은 메서드 명명

public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening){ ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening){ ... }
}
  • PeriodCondition을 사용하는 코드를 SequenceCondition을 사용하도록 변경하려면 참조하는 객체를 변경할 뿐아니라 호출하는 메서드도 변경해야한다.
  • isSatisfiedByPeriod, isSatisfiedBySequence 가 두 메서드의 내부 구현을 알지 못하는 한 동일한 작업을 수행한다는 것을 알아채기 어렵다.

⭕ 나은 메서드 명명

public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening){ ... }
}
public class SequenceCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening){ ... }
}
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
  • PeriodCondition 과 SequenceCondition의 isSatisfiedBy 메서드가 동일한 목적을 가지는 것을 명시한다.
  • 두 메서드를 가진 객체를 동일한 타입으로 간주할 수 있도록 동일한 타입 계층으로 묶어주는 interface 정의

> 명령-쿼리 분리 원칙 (Command-Query Separation)

퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침.“어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안 된다.”

  • 명령과 쿼리를 분리해서 얻게 되는 장점

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

/* 
event 특정 일자에 발생하는 사건
2019년 5월 8일 수요일 10시 30분부터 11시까지 열리는 회의
*/
public class Event {
...
public boolean isSatisfied(RecurringSchedule schedule){
...
}
}
/*
recurring schedule 일주일 단위로 돌아오는 사건 전체
매주 수요일 10시 30분부터 30분 동안 열리는 회의
*/
public class RecurringSchedule {
...
}

Event 클래스는 현재 이벤트가 RecurringSchedule이 정의한 반복 일정 조건을 만족하는지를 검사하는 isSatisfied 메서드를 제공한다. 이 메서드는 RecurringSchedule의 인스턴스를 인자로 받아 해당 이벤트가 일정 조건을 만족하면 true를, 만족하지 않으면 false를 반환한다.

🧨 문제 발생 : 동일한 event 와 동일한 recurringschedule을 이용해 isSatisfied를 2번 호출했을 때 각 결과가 (true, false)로 다르게 나타남.

public class Event {
...
public boolean isSatisfied(RecurringSchedule schedule){
if (schedule의 요일, 시작 시간, 소요 시간이 현재 Event의 값과 하나라도 동일하지 않는다면){
reschedule(schedule); // 문제 원인
return false;
}
return true;
}

문제 원인 : false 를 반환하기 전에 reschedule 메서드를 호출해 event 객체의 상태를 변경하고 있음 → isSatisfied 가 명령과 쿼리의 2가지 역할을 동시에 수행하고 있음.

💬 명령과 쿼리를 분리하여 메서드를 구현하자. 라고 생각한다면, 위와 같은 실수가 그렇게 많을까 싶었지만, isSatisfied가 처음 구현됐을 때는 그 안에서 reschedule 메서드를 호출하는 부분이 빠져 있었고, 기능을 추가하는 과정에서 또다른 프로그래머가 추후에 ‘조건에 맞지 않을 경우 event의 상태를 수정한다’라는 요구사항을 추가하여 결과적으로 위와 같은 코드가 만들어지는 케이스는 발생할 수도 있겠다..라는 생각이 들었습니다.

해결책 : 퍼블릭 인터페이스를 설계할 때 ‘객체 내부 상태를 변경하되, 값을 반환하지 않는 오퍼레이션’ 과 ‘객체와 관련된 정보를 반환하되, 상태를 변경시키지 않는 오퍼레이션’ 을 분리한다!

public class Event {
public boolean isSatisfied(RecurringSchedule schedule){ ... }
public void reschedule(RecurringSchedule schedule){ ... }
}

3장에서의 메세지와 인터페이스

객체의 책임은 객체가 ‘무엇을 알고 있는가’와 ‘무엇을 할 수 있는가’로 구성된다. … 객체는 자신이 맡은 책임을 수행하는 데 필요한 정보를 알고 있을 책임이 있다. 또한 객체는 자신이 할 수 없는 작업을 도와줄 객체를 알고 있을 책임이 있다. 어떤 책임을 수행하기 위해서는 그 책임을 수행하는 데 필요한 정보도 함께 알아야 할 책임이 있는 것이다. p.79

객체는 자신의 상태를 스스로 결정하고 관리하는 자율적인 존재이기 때문에 객체가 수행하는 행동에 필요한 상태도 함께 가지고 있어야 한다. p.77

객체들이 어플리케이션의 기능을 구현하기 위해 수행하는 상호작용을 협력이라고 한다. 객체가 협력에 참여하기 위해 수행하는 로직은 책임이다. 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구상한다. … 메세지 전송은 객체 사이의 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단이다. 객체는 다른 객체의 상세한 내부 구현에 직접 접근할 수 없기 때문에 오직 메세지 전송을 통해서만 자신의 요청을 전달할 수 있다. 메세지를 수신한 객체는 메서드를 실행해 요청에 응답한다. p.75

4장에서의 메세지와 인터페이스

시스템을 분할하기 위해 데이터와 책임 중 어떤 것을 선택해야할까? 결론부터 말하자면 훌륭한 객체지향 설계는 데이터가 아니라 책임에 초점을 맞춰야한다. 이유는 변경과 관련이 있다. p.98

객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다. p.99

변경될 가능성이 높은 부분을 구현이라고 부르고 상대적으로 안정적인 부분을 인터페이스라고 부른다는 사실을 기억하라. 객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것이다. p.109

--

--

No responses yet