devseop08 님의 블로그

[오브젝트] 메시지와 인터페이스 본문

Architecture/객체지향설계

[오브젝트] 메시지와 인터페이스

devseop08 2025. 6. 28. 12:57
  • 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 데 도움이 되는 설계 원칙과 기법을 익히고 적용해봐야 한다.

1. 협력과 메시지

클라이언트-서버 모델

  • 두 객체 사이의 협력 관계를 설명하기 위해 사용하는 전통적인 메타포, 클라이언트-서버 모델
  • 협력 안에서 메시지를 전송하는 객체를 클라이언트, 메시지를 수신하는 객체를 서버라고 부른다.
  • 협력은 클라이언트가 서버의 서비스를 요청하는 단방향 상호 작용
  • 객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행
  • 협력의 관점에서 객체는 두 가지 종류의 메시지 집합으로 구성
  • 하나는 객체가 수신하는 메시지의 집합
  • 다른 하나는 외부의 객체에게 전송하는 메시지의 집합

  • 협력과 관련된 다양한 용어의 의미와 차이점을 이해해야 한다.

메시지와 메시지 전송

  • 메시지는 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단
  • 메시지 전송: 한 객체가 다른 객체에게 도움을 요청하는 것
  • 메시지 전송자: 메시지를 전송하는 객체
  • 메시지 수신자: 메시지를 수신하는 객체
  • 메시지는 오퍼레이션명과 인자로 구성되며
  • 메시지 전송은 메시지에 메시지 수신자를 추가한 것이다.
  • 메시지 전송은 메시지 수신자, 오퍼레이션명, 인자의 조합이다.
  • 메시지와 메서드
  • 메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇인가에 달려 있다.
  • 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다.
  • 코드 상에서 동일한 이름의 변수에게 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라질 수 있다.
  • 메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨하게 결합되게 한다.
  • 실행 시점에 메시지와 메서드를 바인딩 => 두 객체 사이의 결합도를 낮춤

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

  • 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 인터페이스라고 부른다.
  • 프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션이라고 부른다.
  • 오퍼레이션은 내부의 구현 코드는 제외하고 메시지와 관련된 시그니처를 가리킴
  • 메서드는 메시지를 수신했을 때 실제로 실행되는 코드
  • 오퍼레이션은 구현이 아닌 추상화, 메서드는 오퍼레이션을 구현한 것
  • 퍼블릭 인터페이스와 메시지의 관점에서 보면 메서드 호출보다는 오퍼레이션 호출이라는 용어를 사용하는 것이 더 적절(메시지 전송 -> 오퍼레이션 호출 -> 메서드 실행)

시그니처

  • 오퍼레이션의 이름과 파라미터 목록을 합쳐 시그니처라고 부른다.
  • 오퍼레이션은 실행 코드 없이 시그니처만을 선언한 것이다.

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

  • 좋은 인터페이스는 최소한의 인터페이스 + 추상적인 인터페이스
  • 최소주의를 따르면서 추상적인 인터페이스를 설계할 수 있는 가장 좋은 방법은 책임 주도 설계를 따르는 것
  • 책임 주도 설계 방법은 메시지를 먼저 선택 => 협력과는 무관한 오퍼레이션이 퍼블릭 인터페이스에 스며드는 것을 방지
  • 메시지가 객체를 선택하게 함 => 클라이언트의 의도를 메시지에 표현, 추상적인 오퍼레이션이 인터페이스에 자연스럽게 스며들게 됨
  • 책임 주도 설계 이외의 퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법
    • 디미터 법칙
    • 묻지 말고 시켜라
    • 의도를 드러내는 인터페이스
    • 명령-쿼리 분리

디미터 법칙

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(codition.getDayOfWeek())
                      &&
                      condition.getStartTime()
                                    .compareTo(screening.getWhenScreened()
                                        .toLocalTime()) <= 0
                      &&
                      condition.getEndTime()
                                  .compareTo(screening.getWhenScreened()
                                        .toLocalTime()) >= 0;    
            }else{    // 순번 조건
                discountable = condition.getSequence() == screening.getSequence();
            }

            if(discountable){
                break;
            }
        }

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

  • 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙이 디미터 법칙
  • 디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍해야 한다.
  • 클래스 내부의 메서드가 아래 조건을 만족하는 인스턴스에만 메시지를 전송하도록 프로그래밍해야 한다
    • 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의 내부 구조에 결합돼 있지 않기 때문에 Screening의 내부 구현을 변경할 때 ReservationAgency를 함께 변경할 필요가 없다.
  • 디미터 법칙을 따르면 부끄럼타는 코드를 작성할 수 있다
    • 부끄럼타는 코드란 불필요한 어떤 것도 다른 객체에게 보여주기 않으며, 다른 객체의 구현에 의존하지 않는 코드를 말한다.
    • 캡슐화 원칙이 클래스 내부의 구현을 감춰야 한다는 사실을 강조한다면 디미터 법칙은 협력하는 클래스의 캡슐화를 지키기 위해 접근해야 하는 요소를 제한
    • 디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조
  • 디미터 법칙을 위반하는 코드의 전형적인 모습
screening.getMovie().getDiscountConditions();
  • 메시지 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메시지를 전송하는 코드를 기차 충돌이라고 부른다.
  • 묻지 말고 시켜라
  • ReservationAgency는 Screening 내부의 Movie에 접근하는 대신 요금을 계산하는 데 필요한 정보를 잘 알고 있는 Screening에게 요금을 책임을 할당
  • 디미터 법칙은 훌륭한 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다는 사실을 강조한다.
  • 메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안 된다.
  • 묻지 말고 시켜라 원칙을 따르면 객체의 정보를 이용하는 행동을 객체의 외부가 아닌 내부에 위치시키기 때문에 자연스럽게 정보와 행동을 동일한 클래스 안에 두게 된다.
  • 내부의 상태를 묻는 오퍼레이션을 퍼블릭 인터페이스에 포함시키고 있다면 더 나은 방법은 없는지 고민해봐야 한다.
  • 상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체해야 한다.

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

  • 메서드의 이름을 짓는 두 가지 방법
    • 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓기
    • '어떻게'가 아니라 '무엇을' 하는지를 드러내기
  • 어떻게 수행하는지를 드러내는 이름이란 메서드의 내부 구현을 설명하는 이름 => 협력을 설계하기 시작하는 이른 시기부터 클래스의 내부 구현에 관해 고민할 수 밖에 없다.
  • 무엇을 하는지를 드러내는 메서드의 이름을 짓기 위해서는 객체가 협력 안에서 수행해야 하는 책임에 관해 고민 => 클라이언트의 의도에 부합하도록 메서드의 이름을 짓게 됨
  • 클라이언트의 관점에서 동일한 작업을 수행하는 메서드들을 하나의 타입 계층으로 묶을 수 있는 가능성이 커진다.(역할의 구현: 추상 클래스, 인터페이스)
  • 의도를 드러내는 선택자: 무엇을 하느냐에 따라 메서드의 이름을 짓는 패턴
  • 의도를 드러내는 선택자를 인터페이스 레벨로 확장한 의도를 드러내는 인터페이스: 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 한다.
  • 객체에게 묻지 말고 시키되 구현 방법이 아닌 클라이언트의 의도를 드러내야 한다.함께 모으기
디미터 법칙을 위한하는 티켓 판매 도메인
  • Theater의 enter 메서드는 디미터 법칙을 위반한 코드의 전형적인 모습을 잘 보여준다
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();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}
  • Theter가 인자로 전달된 audience와 인스턴스 변수인 ticketSeller에게 메시지르 전송하는 것 자체는 문제가 없지만 문제는 Theater가 audience와 ticketSeller 내부에 포함된 객체에도 직접 접근한다는 것 => 디미터 법칙을 위반하게 된다.
  • 근본적으로 디미터 법칙을 위반한 설계는 인터페이스와 구현의 분리 원칙을 위반
  • Audience가 Bag을 포함한다는 사실은 Audience의 내부 구현에 속하며 Audience는 자신의 내부 구현을 자유롭게 변경할 수 있어야 하는데 퍼블릭 인터페이스에 getBag을 포함시키는 순간 객체이의 구현이 퍼블릭 인터페이스를 통해 외부로 새어나가고 만다.
  • 디미터 법칙을 위반한 설계와 코드는 요구사항 변경에 취약해질 뿐만 아니라 디미터 법칙을 위반 코드는 사용하기도 어렵다.
  • 클라이언트 객체의 개발자는 Audience와 TicketSeller의 퍼블릭 인터페이스 뿐만 아니라 내부 구조까지 속속들이 알고 있어야 한다.
묻지 말고 시켜라
  • TicketSeller와 Audience는 묻지 말고 시켜라 스타일을 따르는 퍼블릭 인터페이스를 가져야 한다.
public class Theater {
    private TicketSeller ticketSeller;

    public Theater(TicketSeller ticketSeller){
        this.ticketSeller = ticketSeller;
    }

    public void enter(Audience audience) {
        ticketSeller.setTicket(audience);
    }
}
public class TicketSeller {

    public void setTicket(Audience audience) {
        if(audience.getBag().hasInvitation){
            Ticket ticket = ticketSeller.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);
        }
    }
}
  • TicketSeller가 원하는 것은 인자로 전달된 Audience가 Ticket을 보유하도록 만드는 것
public class TicketSeller {
    private TicketOffice ticketOffice;

    public void setTicket(Audience audience) {
        ticketOffice.plusAmount(audience.setTicket(ticketOffice.getTicket()));
    }
}
public class Audience {
    private 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();
        }
    }
}
  • Audience의 setTicket 메서드는 현재 Bag에게 원하는 일을 시키기 전에 hasInvitation 메서드를 이용해 초대권을 가지고 있는지를 묻는다.
  • 묻지 말고 시켜라 원칙에 따라 Audience의 setTicket 메서드 구현을 Bag의 setTicket 메서드로 이동시키자
public class Audience {
    private Bag bag;

    public Long setTicket(Ticket ticket) {
        return bag.setTicket(ticket);
    }
}
public class Bag {
    private Ticket ticket;

    public Long setTicket(Ticket ticket){
        if(hasInvitation()){
            this.ticket = ticket;
            return 0L;
        }else {
            this.ticket = ticket;
            minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }

    private boolean hasInvitation() {
        return invitation != null;
    }

    private void minusAmount(Long amount) {
        this.amount = amount;
    }
}
인터페이스에 의도를 드러내자
public class ticketSeller {
    public void sellTo(Audience audience) { ... }
}

public class Audience {
    public Long buy(Ticket ticket) { ... }
}

public class Bag {
    public Long hold(Ticket ticket) { ... }
}
  • 오퍼레이션은 클라이언트가 객체에게 무엇을 원한는지를 표현해야 한다.
  • 객체 자신이 아닌 클라이언트의 의도를 표현하는 이름을 가져야 한다.

3. 원칙의 함정

  • 디미터 법칙과 묻지 말고 시켜라 스타일은 객체의 퍼블릭 인터페이스를 깔끔하고 유연하게 만들 수 있는 설계 원칙이지만 절대적인 법칙은 아니다.

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

IntStream.of(1, 15, 20, 3, 9).filter(x -> x > 10).distinct().count();
  • of, filter, distinct 메서드 모두 동일하게 IntStream 타입의 인스턴스를 반환한다.
  • 이들은 IntStream의 인스턴스를 또 다른 IntStream 인스턴스로 변환하기 때문에 디미터 법칙을 위반하지 않는다.
  • 디미터 법칙은 결합도와 관련된 것이며 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.
  • 기차 충돌처럼보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것이다.

결합도와 응집도의 충돌

  • 묻지 말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍적적인 결과로만 귀결되는 것은 아니다.
  • 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. => 객체가 상관없는 책임들을 한꺼번에 떠안게 되기 때문에 결과적으로 응집도가 낮아진다.
  • 클래스는 하나의 변경 원인만을 가져야하는데 디미터 법칙과 묻지 말고 시켜라 원칙을 무작정 따르면 애플리케이션은 응집도가 낮은 객체로 넘쳐나게 된다.
public class PeriodCondition implements DiscountCondition {
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
            startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
            endTime.compareTo(screening.getStartTime().toLocalTime()) >=0 ;
    }
}
  • 위 코드는 Screening의 내부 상태를 가져와서 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.
  • 할인 여부를 판단하는 로직을 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 {
    public boolean isSatisfiedBy(Screening screening) {
        return screening.isDiscountable(dayOfWeek, startTime, endTime);
    }
}
  • Screening의 본질적인 책임이 아닌 책임을 떠안게 된다.
  • Screening의 본질적인 책임은 영화를 예매하는 것인데 직접 할인 조건을 판단하게 되면 객체의 응집도가 낮아진다.
  • Screening은 PeriodCondition의 인스턴스 변수를 인자로 받기 때문에 PeriodCondition의 인스턴스 변수 목록이 변경될 경우에도 영향을 받게 되는데 이것은 Screening과 PeriodCondition 사이의 결합도를 높인다.
  • 디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료 구조인지에 달려있다.
  • 객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는 것이 좋지만 자료구조라면 당연히 내부를 노출해야하므로 디미터 법칙을 적용할 필요가 없다.
  • 객체에게 시키는 것이 항상 가능한 것은 아니다. 가끔씩은 물어야 한다.

4. 명령-쿼리 분리 원칙

  • 객체의 상태를 수정하는 오퍼레이션 = 명령
  • 객체와 관련된 정보를 반환하는 오퍼레이션 = 쿼리
  • 명령-쿼리 분리 원칙의 요지는 오퍼레이션부수효과를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야만 한다는 것이다.
  • 어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안 된다.
  • 명령과 쿼리를 분리하기 위한 두 가지 규칙
      1. 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다.
      1. 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다.
  • 명령은 상태를 변경할 수 있지만 상태를 반환해서는 안 된다.
  • 쿼리는 상태를 반환할 수 있지만 상태를 변경해서는 안 된다.
  • 명령과 쿼리를 분리하면 코드가 예측 가능하고 디버깅이 용이하며 유지 보수가 수월해진다.

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

  • 명령과 쿼리를 엄격하게 분류하면 객체의 부수효과를 제어하기 수월해진다.
  • 명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 제한적이나마 누릴 수 있게 된다.
  • 컴퓨터와 수학의 세계를 나누는 가장 큰 특징은 부수효과의 존재 유무다.
    • 프로그램에서 부수효과를 발생시키는 두 가지 대표적인 문법은 대입문과 프로시저(객체 상태를 변경하는 함수)다.
    • 수학의 경우 x의 값을 초기화한 후에는 값을 변경하는 것이 불가능하지만 프로그램에서는 대입문을 이용해 다른 값으로 변경하는 것이 가능
    • 함수는 내부에 부수효과를 포함할 경우 동일한 인자를 전달하더라도 부수효과에 의해 그 결괏값이 매번 달라질 수 있다.
  • 참조 투명성 = 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성
  • 수학에서 함수는 동일한 입력에 대해 항상 동일한 값을 반환하기 때문에 수학의 함수는 참조 투명성을 만족시키는 이상적인 예
  • 수학에서의 함수는 불변성을 만족하기 때문에 부수효과의 발생을 방지하고 참조 투명성을 만족시킨다.
  • 참조 투명성을 만족하는 식이 제공하는 두 가지 장점
      1. 모든 함수를 이미 알고 있는 하나의 결과값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다.
      1. 모든 곳에서 함수의 결괏값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다.
  • 객체지향 패러다임이 객체의 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝지만 명령-쿼리 분리 원칙은 부수효과를 가지는 명령으로부터 부수효과를 가지지 않는 쿼리를 명백하게 분리함으로써 제한적이나마 참조 투명성의 혜택을 누릴 수 있게 된다.

책임에 초점을 맞춰라

  • 책임 주도 설계 방법에 따라 메시지가 객체를 결정하게 하라.
  • 메시지를 먼저 선택하는 방식이 디미터 법칙, 묻지 말고 시켜라 스타일, 의도를 드러내는 인터페이스, 명령-쿼리 분리 원칙에 미치는 긍정적인 영향
      1. 디미터 법칙: 수신할 객체를 알지 못한 상태에서 메시지를 먼저 선택하기 때문에 객체의 내부 구조에 대해 고민할 필요가 없어지게 되어 디미터 법칙을 위반할 위험을 최소화시킬 수 있다.
      1. 묻지 말고 시켜라: 클라이언트의 관점에서 메시지를 선택하기 때문에 필요한 정보를 물을 필요 없이 원하는 것을 표현한 메시지를 전송하면 된다.
      1. 의도를 드러내는 인터페이스: 메시지를 전송하는 클라이언트의 관점에서 메시지의 이름을 정한다.
      1. 명령-쿼리 분리 원칙: 객체가 단순히 어떤 일을 해야 하는지 뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 위한 방법에 관해 고민하게 되므로 예측 가능한 협력을 위해 명령과 쿼리를 분리하게 될 것이다.