조영호 님의 "오브젝트" 책을 정리한 글입니다.
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를 해당 값으로 대체해도 프로그램의 의미나 결과가 변하지 않는 특성
|
📌 관련 개념 | - 불변성 - 동일성 (동일한 입력 → 동일한 출력) - 부수효과 없음 |
✅ 특징 | - 함수 호출을 값처럼 다룰 수 있음 - 실행 순서에 의존하지 않음 |
🧠 장점 | - 코드를 부분적으로 이해하거나 예측하기 쉬움 - 버그 발생 가능성 감소 - 디버깅 용이 - 테스트 용이 |
📌 명령과 쿼리 분리 효과 |
- 명령형 언어에서도 참조 투명성의 장점을 누릴 수 있음
- 명령(상태 변화)과 쿼리(값 조회)를 분리함으로써 예측 가능한 코드 구현 가능 |
- 명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 누릴 수 있음
책임에 초점을 맞춰라
'Code > OOP' 카테고리의 다른 글
[오브젝트] 9. 유연한 설계 (0) | 2025.04.05 |
---|---|
[오브젝트] 8. 의존성 관리하기 (0) | 2025.04.04 |
[오브젝트] 5. 책임 할당하기 (0) | 2025.04.03 |
[오브젝트] 4. 설계 품질과 트레이드오프 (0) | 2025.04.03 |
[오브젝트] 3. 역할, 책임, 협력 (0) | 2025.04.03 |