ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 오브젝트 4 - 메세지, 인터페이스, 의존성
    책책책 책을 읽읍시다/프로그래밍 2023. 2. 13. 23:21

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

    객체가 의사소통을 위해 외부에 공개하는 메세지의 집합을 퍼블릭 인터페이스라고 부른다. 프록래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메세지를 오퍼레이션(operation)이라고 부른다. 오퍼레이션은 수행 가능한 어떤 행동에 대한 추상화다. 흔히 오퍼레이션이라고 부를 때는 내부의 구현 코드는 제외하고 단순히 메세지와 관련된 시그니처를 가리키는 경우가 대부분이다. 영화 예매 시스템의 예로 DiscountCondition 인터페이스에 정의된 isSatisfiedBy가 오퍼레이션에 해당한다.

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

    그에 비해 메세지를 수신했을 때 실제로 실행되는 코드는 메서드라고 부른다. SequenceCondition과 PeriodCondition에 정의된 각각의 isSatisfiedBy는 실제 구현을 포함하기 때문에 메서드라고 부른다. SequenceCondition과 PeriodCondition의 두 메서드는 DiscountCondition 인터페이스에 정의된 isSatisfiedBy 오퍼레이션의 여러 가능한 구현 중 하나이다.

    public class SequenceCondition implements DiscountCondition {
        private int sequence;
    
        public SequenceCondition(int sequence) {
            this.sequence = sequence;
        }
    
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.isSequence(sequence);
        }
    }
    
    public class PeriodCondition implements DiscountCondition {
        private DayOfWeek dayOfWeek;
        private LocalTime startTime;
        private LocalTime endTime;
    
        public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
            this.dayOfWeek = dayOfWeek;
            this.startTime = startTime;
            this.endTime = endTime;
        }
    
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                    startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                    endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
        }
    }

    디미터 법칙(Law of Demeter), 묻지 말고 시켜라(Tell, Don't Ask)와 원칙의 함정

    객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 지침이 디미터 법칙이다. "낯선 자에게 말하지 말라(don't talk to stragers)[Lerman04]" 또는 "오직 인접한 이웃하고만 말하라(only talk to your immediate neighbors)[Metz12]"로 요약할 수 있다. 자바나 C#과 같이 '도트(.)'를 이용해 메세지 전송을 표현하는 언어에서는 "오직 하나의 도트만 사용하라(use only on dot)"라는 말로 요약되기도 한다. 디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메세지를 전송하도록 프로그래밍해야 한다. 모든 클래스 C와 C에 구현된 모든 메서드 M에 대해서, M이 메세지를 전송할 수 있는 모든 객체는 다음에 서술된 클래스의 인스턴스여야 한다. 이때 M에 의해 생성된 객체나 M이 호출하는 메서드에 의해 생성된 객체, 전역 변수로 선언된 객체는 모두 M의 인자로 간주한다.

    • M의 인자로 전달된 클래스(C 자체를 포함)
    • C의 인스턴스 변수의 클래스

    위 설명이 이해하기 어렵다면 클래스 내부의 메서드가 아래 조건을 만족하는 인스턴스에만 메세지를 전송하도록 프로그래밍해야 한다라고 이해해도 무방하다[Larman 2004].

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

    데이터 중심 설계로 만들어본 할인 가능 여부를 체크하는 코드 중 ReservationAgency를 보자.

    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;
                }
            }
    
            Money fee;
            if (discountable) {
            ...
        }
    }

    이 코드의 가장 큰 단점은 ReservationAgency와 인자로 전달된 Screening과 ReservationAgency 사이의 결합도가 너무 높기 때문에 Screening의 내부 구현을 변경할 때마다 ReservationAgency도 함께 변경된다는 것이다. 문제의 원인은 ReservationAgency가 Screening뿐만 아니라 Movie와 DiscountCondition에도 직접 접근하기 때문이다. 이전에 결합도 문제를 해결하기 위해 수정한 버전을 보자.

    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의 내부 구조에 결합돼 있지 않기 때문에 Screening의 내부 구현을 변경할 때 ReservationAgency를 함께 변경할 필요가 없다.

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

    screening.getMovie().getDiscountConditions();

    메세지 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메세지를 전송한다. 매세지 수신자의 내부 정보를 자세히 알게되어 수신자의 캡슐화는 무너지고 전송자가 수신자의 내부 구현에 강하게 결합된다. 디미터 법칙을 따르도록 코드를 개선하면 메세지 전송자는 더 이상 메세지 수신자의 내부 구조에 관해 묻지 않게 된다. 단지 자신이 원하는 것이 무엇인지를 명시하고 단순히 수행하도록 요청한다.

    screening.calculateFee(audienceCount);

    디미터 법칙은 훌륭한 메세지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다는 사실을 강조한다. 묻지 말고 시켜라는 이런 스타일의 메세지 작성을 장려하는 원칙을 가리키는 용어다. 객체의 정보를 이용하는 행동을 객체의 외부가 아닌 내부에 위치시키기 때문에 자연스럽게 정보와 행동을 동일한 클래스 안에 두게된다. 이 원칙에 따르도록 메세지를 결정하다 보면 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다. 상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체함으로써 인터페이스를 향상시켜라.

     

    디미터 법칙과 묻지 말고 시켜라 스타일은 객체의 퍼블릭 인터페이스를 깔끔하고 유연하게 만들 수 있는 훌륭한 설계 원칙이지만 절대적인 법칙은 아니다. 원칙이 현재 상황에 부적합하다고 판단된다면 과감하게 원칙을 무시할 줄 알아야 한다. 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 상관 없는 책임들을 한꺼번에 떠안게 되기 떄문에 결과적으로 응집도가 낮아진다.

    클래스는 하나의 변경 원인만을 가져야 한다. 서로 상관없는 책임들이 함께 뭉쳐있는 클래스는 응집도가 낮으면 작은 변경으로도 쉽게 무너질 수 있다. 따라서 디미터 법칙과 묻지 말고 시켜라 원칙을 무작정 따르면 애플리케이션은 응집도가 낮은 객체로 넘쳐날 것이다.

    영화 예매 시스템의 PeriodCondition 클래스를 살펴보자. isSatisfiedBy 메서드는 screening에게 질의한 사영 시작 시간을 이용해 할인 여부를 결정한다. 이 코드는 얼핏 보기에는 Screening의 내부 상태를 가져와서 사용하기 떄문에 캡슐화를 위반한 것으로 보일 수 있다.

    public class PeriodCondition implements DiscountCondition {
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.getStartTime().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 whenScreened.getDayOfWeek().equals(dayOfWeek) &&
                    startTime.compareTo(whenScreened.toLocalTime()) <= 0 &&
                    endTime.compareTo(whenScreened.toLocalTime()) >= 0;
        }
    }
    
    public class PeriodCondition implements DiscountCondition {
        @Override
        public boolean isSatisfiedBy(Screening screening) {
            return screening.isDiscountable(dayOfWeek, startTime, endTime)
        }
    }

    하지만 이렇게 하면 Screening이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이것이 Screening이 담당해야 하는 본질적인 책임인가? 그렇지 않다. Screening의 본질적인 책임은 영화를 예매하는 것이다. Screening이 직접 할인 조건을 판단하게 되면 객체의 응집도가 낮아진다. 반면 PeriodCondition의 입장에서는 할인 조건을 판단하는 책임이 본질적이다.

    게다가 Screening은 PeriodCondition의 인스턴스 변수를 인자로 받기 떄문에 PeriodCondition의 인스턴스 변수 목록이 변경될 경우에도 영향을 받게 된다. 이것은 Screening과 PeriodCondition 사이의 결합도를 높인다. 따라서 Screening의 캡슐화를 향상시키는 것보다 Screening의 응집도를 높이고 Screening과 PeriodCondition 사이의 결합도를 낮추는 것이 전체적인 관점에서 더 좋은 방법이다.

     

    명시적인 의존성

    아래 코드는 한 가지 실수로 인해 결합도가 불필요하게 높아졌다. 그 실수는 무엇일까?

    public class Movie {
        ...
        private DiscountPolicy discountPolicy;
    
        public Movie(String title, Duration runningTime, Money fee) {
            ...
            this.discountPolicy = new AmountDiscountPolicy;
        }
    }

    Movie의 인스턴스 변수인 discountPolicy는 추상 클래스인 DiscountPolicy 타입으로 선언돼 있다. Movie는 추상화에 의존하기 떄문에 이 코드는 유연하고 재사용 가능할 것처럼 보인다. 하지만 안타깝게도 생성자를 보면 그렇지 않다는 사실을 알 수 있다. disountPolicy는 DiscountPolicy 타입으로 선언돼 있지만 생성자에서 구체 클래스인 AmountDiscountPolicy의 인스턴스를 직접 생성해서 대입하고 있다. 따라서 Movie는 추상 클래스인 DiscountPolicy뿐만 아니라 구체 클래스인 AmountDiscountPolicy에도 의존하게 된다.

    추상 클래스와 구체 클래스 모두에 의존하는 Movie

    이 예제에서 알 수 있는 것처럼 결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상 클래스나 인터페이스로 선언하는 것만으로는 부족합다. 클래스 안에서 구채 클래스에 대한 모든 의존성을 제거해야만 한다. 하지만 런타임에 Movie는 구체 클래스의 인스턴스와 협력해야 하기 때문에 Movie의 인스턴스가 AmountDiscountPolicy의 인스턴스인지 PercentDiscountPolicy의 인스턴스인지를 알려줄 수 있는 방법이 필요하다. 다시 말해서 Movie의 의존성을 해결해 줄 수 있는 방법이 필요한 것이다.

    의존성을 해결하는 방법에는 생성자, setter 메서드, 메서드 인자를 사용하는 3가지 방식이 존재한다. 여기서의 트릭은 인스턴스 변수의 타입은 추상 클래스나 인터페이스로 정의하고 생성자, setter 메서드, 메서드 인자로 의존성을 해결할 때는 추상 클래스를 상속받거나 인터페이스를 실체화한 구체 클래스를 전달하는 것이다. 아래와 같이 말이다.

    public class Movie {
        ...
        private DiscountPolicy discountPolicy;
    
        public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
            ...
            this.discountPolicy = discountPolicy;
        }
    }

    생성자의 인자가 추상 클래스 타입으로 선언됐기 때문에 이제 객체를 생성할 때 생성자의 인자로 DiscountPolicy의 자식 클래스 중 어떤 것이라도 전달할 수 있다. 따라서 런타임에 AmountDiscountPolicy의 인스턴스나 PercentDiscountPolicy의 인스턴스를 선택적으로 전달할 수 있다. Movie 인스턴스는 생성자의 인자로 전달된 인스턴스에 의존하게 된다.

    의존성의 대상을 생성자의 인자로 전달받는 방법과 생성자 안에서 직접 생성하는 방법 사이의 가장 큰 차이점은 퍼블릭 인터페이스를 통해 할인 정책을 설정할 수 있는 방법을 제공하는지 여부다. 생성자의 인자로 선언하는 방법은 Movie가 DiscountPolicy에 의존한다는 사실을 Movie의 퍼블릭 인터페이스에 드러내는 것이다. 이것은 setter 메서드를 사용하는 방식과 메서드 인자를 사용하는 방식의 경우에도 동일하다. 모든 경우에 의존성은 명시적으로 퍼블릭 인터페이스에 노출된다. 이를 명시적인 의존성(explicit dependency)이라고 부른다.

    반면 Movie 내부에서 AmountDiscountPolicy의 인스턴스를 직접 생성하는 방식은 Movie가 DiscountPolicy에 의존한다는 사실을 감춘다. 다실 말해 의존성이 퍼블릭 인터페이스에 표현되지 않는다. 이를 숨겨진 의존성(hidden dependency)이라고 부른다.

    의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 직접 살펴볼 수밖에 없다. 커다란 클래스에 정의된 긴 메서드 내부 어딘가에서 인스턴스를 생성하는 코드를 파악하는 것은 쉽지 않을뿐더러 심지어 고통스러울 수도 있다.

    더 커다란 문제는 의존성이 명시적이지 않으면 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다는 것이다. 코드 수정은 언제나 잠재적으로 버그의 발생 가능성을 내포한다. 의존성을 명시적으로 드러내면 코드를 직접 수정해야 하는 위험을 피할 수 있다. 실행 컨텍스트에 적절한 의존성을 선택할 수 있기 때문이다.

    의존성은 명시적으로 표현돼야 한다. 의존성을 구현 내부에 숨겨두지 마라. 유연하고 재사용 가능한 설계란 퍼블릭 인터페이스를 통해 의존성이 명시적으로 드러나는 설계다 명시적인 의존성을 사용해야만 퍼블릭 인터페이스를 통해 컴파일타임 의존성을 적절한 런타임 의존성으로 교체할 수 있다.

    클래스가 다른 클래스에 의존하는 것은 부끄러운 일이 아니다. 의존성은 다른 객체와의 협력을 가능하게 해주기 떄문에 바람직한 것이다. 경계해야 할 것은 의존성 자체가 아니라 의존성을 감추는 것이다.

     

    댓글

Designed by Tistory.