Code/OOP

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

noahkim_ 2025. 4. 4. 01:33

조영호 님의 "오브젝트" 책을 정리한 글입니다.

 

1. 협력과 메시지

구분 정의 구성 요소 / 특징 예시
메시지 객체 간 협력을 위한 의사소통 매커니즘 오퍼레이션 명 + 인자 order.place(item)
메시지 전송 한 객체가 다른 객체에게 메시지를 보내는 행위 메시지를 전달함 A 객체가 B 객체에게 place() 메시지 전송
메서드 메시지를 수신했을 때 실행되는 실제 절차 내부 구현 중심 public void place(Item item) { /* 처리 로직 */ }
오퍼레이션 객체가 외부에 제공하는 추상적인 서비스 인터페이스에 정의된 메시지
(구현 제외)
place(item)
퍼블릭 인터페이스 외부에 노출된 오퍼레이션들의 집합 오퍼레이션 목록 place(item), cancel(orderId) 등
시그니처 오퍼레이션의 명세 오퍼레이션 명 + 파라미터 목록
(반환 타입 제외)
place(item: Item)

 

 

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

        // ... 
    }
}
public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);
    }
}
구분 적용 전 적용 후
문제점 Screening 객체와의 결합도가 높음 메시지 수신자의 내부 구현에 의존하지 않음
원인 ReservationAgency가 Screening의 내부 구현에 직접 접근 메시지를 통해 협력
결과 Screening 내부가 변경되면 ReservationAgency도 함께 변경됨 낮은 결합도
정보 은닉 정보 은닉 실패 → 내부 구조가 외부에 노출됨 정보 은닉 성공 → 내부 구조는 외부에 은폐됨
설계 품질 유지보수 어려움 (변경에 취약) 유연하고 견고한 설계 가능 (변경에 강함)

 

묻지 말고 시켜라

  • 훌륭한 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 함

 

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

항목 의도를 드러내는 선택자 의도를 드러내는 인터페이스
정의 메서드 이름이 무엇을 하는지를 드러내는 방식
무엇을 목적으로 메시지를 보낼지를 기반으로 정의
초점 객체의 책임에 집중 클라이언트의 의도에 집중
설계 방향 내부 구현을 숨기고 책임 중심으로 이름 짓기
외부 사용자가 목적에 맞게 사용할 수 있는 인터페이스 설계
장점 - 내부 구조 은닉
- 책임 중심 설계
- 가독성, 유지보수 향상
- 메시지 목적 명확
- 협력 관계 중심의 자연스러운 설계
- 인터페이스 중심 개발 가능
예시 isExpired(), hasAvailableSeats(), isDiscountable()
reserve(), isSatisfiedBy(), payWith(CreditCard card)
요구되는 사고방식 “무엇을 판단하는가”에 대한 고민 “왜 메시지를 보내는가”에 대한 고민

 

 

함께 모으기

디미터 원칙 위반
더보기
더보기
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);
        }
    }
}

 

묻지 말고 시켜라
더보기
더보기
public class Theater {
    private TicketSeller ticketSeller;

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

    public void enter(Audience audience) {
        ticketSeller.setTicket(audience);
    }
}
public class Audience {
    private Bag bag;

    public Audience(Bag bag) { this.bag = bag; }

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

 

인터페이스에 의도를 드러내자
더보기
더보기
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. 원칙의 함정

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

  • 기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것

 

결합도와 응집도의 충돌

  • 디미터 법칙과 묻지말고 시켜라 법칙을 적용할 때 항상 긍정적인 결과로 귀결되는 것은 아님
  • 즉, 가끔씩은 필요에 따라 물어야 함
구분 설명 예시 / 의미
✅ 장점
캡슐화 강화
내부 구조 노출 없이 메시지 전송으로 협력 가능
책임 분리
객체 스스로 책임을 지게 유도 → 객체 응집도 향상
결합도 감소 (일반적으로) 전송자-수신자 간의 느슨한 연결 유지
⚠️ 주의점
퍼블릭 인터페이스 오염 가능성
“시키기 위해” 만든 메서드가 수신자의 책임과 무관할 경우 인터페이스의 응집도 저하
결합도 증가 가능성
지나치게 위임된 로직이 호출자와 수신자 사이의 불필요한 오퍼레이션을 발생하게 함

 

 

변경 전
더보기
더보기
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;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return this.dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                this.startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                this.endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
}
문제 설명
캡슐화 위반
Screening 객체의 구현 세부사항에 의존하고 있음
- 내부 데이터에 직접 접근 (screening.getWhenScreened().getDayOfWeek())
높은 결합도
Screening 객체 내부의 구현이 바뀌면, PeriodCondition도 수정해야 함

 

적용 후
더보기
더보기
public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    // constructor..

    public boolean isSatisfiedBy(Screening screening) {
        return screening.isDiscountable(dayOfWeek, startTime, endTime);
    }
}
public class Screening {
    private LocalDateTime whenScreened;
    
    // fields, constructors..
    
    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        return whenScreened.getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(whenScreened.toLocalTime()) <= 0 &&
                endTime.compareTo(whenScreened.toLocalTime()) >= 0;
    }
}
항목 설명 문제점
책임 분배 오류 할인 조건 판단 로직이 Screening으로 이동
Screening은 자신이 할인 대상인지 판단하는 책임을 갖는 것이 자연스럽지 않음. 원래는 PeriodCondition이 가져야 할 책임
응집도 저하 하나의 객체가 여러 책임을 갖게 됨
Screening 클래스가 상영 시간 관리 외에도 할인 조건 판단까지 담당 → 관심사 분리 실패
결합도 증가 PeriodCondition의 상태 변경이 Screening 구현에 영향
할인 조건이 바뀌면 PeriodCondition만 바꾸면 되는 것이 아니라, Screening 내부 로직도 함께 변경 필요
디자인 왜곡 "묻지 말고 시켜라"를 적용했지만 객체 설계 원칙 위반
Tell, Don't Ask는 객체가 자기 책임 범위 안에서 행동할 때 의미가 있음.
현재는 억지로 책임을 떠넘긴 사례

 

4. 명령-쿼리 분리 원칙

루틴
  • 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능
항목 설명 반환값 부수효과
프로시저 반환값 없이 부수효과(상태 변경 등)를 발생시키는 루틴 ❌ 없음 ✅ 있음
함수 값을 반환하고 부수효과가 없는 루틴 ✅ 있음 ❌ 없음

 

오퍼레이션
항목 설명 상태 변경 반환값
명령 (Command) 객체의 상태를 변경하는 오퍼레이션 ✅ 있음 선택적 (있을 수도 있음)
쿼리 (Query) 객체의 상태를 조회만 하는 오퍼레이션 ❌ 없음 ✅ 있음
  • 명령-쿼리 분리 원칙: 상태변경 or 반환값 하나만 기능을 가짐
  • 명령-쿼리 인터페이스: 명령-쿼리 분리 원칙에 따라 인터페이스를 나누는 원칙

 

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

더보기
더보기
public class Event {
    private String subject;
    private LocalDateTime from;
    private Duration duration;

    // constructor

    public boolean isSatisfied(RecurringSchedule schedule) {
        if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
           !from.toLocalTime().equals(schedule.getFrom()) ||
           !duration.equals(schedule.getDuration())) {
            reschedule(schedule);

            return false;
        }

        return true;
    }

    private void reschedule(RecurringSchedule schedule) {
        from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistaince(schedule)), schedule.getFrom());
        duration = schedule.getDuration();
    }

    private long daysDistaince(RecurringSchedule schedule) {
        return schedule.getDayOfWeek().getValue() - from.getDayOfWeek().getValue();
    }
}
public class RecurringSchedule {
    private String subject;
    private DayOfWeek dayOfWeek;
    private LocalTime from;
    private Duration duration;

    // constructor, getter..
}
문제
@Test
void isSatisfied_bug() {
    Event meeting = new Event("회의", LocalDateTime.of(2019, 5, 9, 10, 30), Duration.ofMinutes(30));
    RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY, LocalTime.of(10, 30), Duration.ofMinutes(30));

    assert meeting.isSatisfied(schedule) == false;
    assert meeting.isSatisfied(schedule) == true;
}
항목 내용
🐞 문제 현상
isSatisfied() 호출 시 처음엔 false, 이후 재호출 시 true 반환
🔍 원인
isSatisfied() 내에서 reschedule()이 호출되어 상태 변경 발생
⚠️ 근본 문제
isSatisfied()가 명령(Command) 과 쿼리(Query) 역할을 동시에 수행
🤯 버그가 발견되기 어려운 이유 - 함수명이 판단만 하는 것처럼 보임
- 부수효과(side effect)를 예상하기 어려움
- 같은 조건에서도 시점에 따라 결과가 달라짐
🐛 결과 - 실행 결과 예측 어려움
- 디버깅 어려움
- 유지보수 중 버그 양산 가능성

 

 

해결
더보기
더보기
public class Event {
    private String subject;
    private LocalDateTime from;
    private Duration duration;

    // constructor

    public boolean isSatisfied(RecurringSchedule schedule) {
        if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
           !from.toLocalTime().equals(schedule.getFrom()) ||
           !duration.equals(schedule.getDuration())) {
            return false;
        }

        return true;
    }

    public void reschedule(RecurringSchedule schedule) {
        from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistaince(schedule)), schedule.getFrom());
        duration = schedule.getDuration();
    }

    private long daysDistaince(RecurringSchedule schedule) {
        return schedule.getDayOfWeek().getValue() - from.getDayOfWeek().getValue();
    }
}
@Test
void isSatisfied_separation() {
    Event meeting = new Event("회의", LocalDateTime.of(2019, 5, 9, 10, 30), Duration.ofMinutes(30));
    RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY, LocalTime.of(10, 30), Duration.ofMinutes(30));

    if (meeting.isSatisfied(schedule)) meeting.reschedule(schedule);
}
  • 해결책은 명령과 쿼리를 분리하는 것
    • isSatisfied()는 판단만 수행
    • 상태 변경은 별도 메서드 (reschedule())로 분리

 

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

참조 투명성
항목 내용
🔍 정의
어떤 표현식 e를 해당 값으로 대체해도 프로그램의 의미나 결과가 변하지 않는 특성
📌 관련 개념 - 불변성
- 동일성 (동일한 입력 → 동일한 출력)
- 부수효과 없음
✅ 특징 - 함수 호출을 값처럼 다룰 수 있음
- 실행 순서에 의존하지 않음
🧠 장점 - 코드를 부분적으로 이해하거나 예측하기 쉬움
- 버그 발생 가능성 감소
- 디버깅 용이
- 테스트 용이
📌 명령과 쿼리 분리 효과
- 명령형 언어에서도 참조 투명성의 장점을 누릴 수 있음
- 명령(상태 변화)과 쿼리(값 조회)를 분리함으로써 예측 가능한 코드 구현 가능
  • 명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 누릴 수 있음

 

책임에 초점을 맞춰라