조영호 님의 "오브젝트" 책을 정리한 글입니다.
1. 책임 주도 설계를 향해
데이터보다 행동을 먼저 결정하라
- 객체에게 중요한 것은 데이터가 아니라 행동 (곧 객체의 책임을 의미함)
- 객체는 협력을 위한 존재이기 때문
협력이라는 문맥 안에서 책임을 결정하라
개념 | 설명 |
책임 할당 원칙 | - 메시지를 먼저 결정한 후 객체를 선택해야 함 - 즉, 메시지가 객체를 결정함 |
설계 순서 |
✅ 메시지 → ✅ 행동(책임) → ✅ 상태(데이터)
|
장점 | - 캡슐화를 지키기 훨씬 쉬움 |
책임 주도 설계
- 책임부터 정한 후, 책임을 담당할 객체를 결정하는 것
- 책임이 어느정도 정리될 때까지 내부 상태에 대해 관심을 가지지 않음
2. 책임 할당을 위한 GRASP 패턴
도메인 개념에서 출발하기
- 설계 시작 전, 도메인에 대한 개략적인 모습을 그려 보기
항목 | 내용 |
방법 | 도메인 개념들을 책임 할당의 대상으로 사용 |
목표 | 책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보 제공 |
장점 |
도메인의 모습을 코드에 자연스럽게 반영할 수 있음
|
정보 전문가에게 책임을 할당하라
단계 | 설명 |
1. 기능을 책임으로 인식 | 애플리케이션이 제공할 기능을 책임으로 정의 |
2. 메시지로 표현 | 책임을 메시지 전달로 나타냄 |
3. 메시지 수신 객체 결정 |
송신자의 의도를 반영하여 수신 객체 결정
|
4. 정보 전문가에게 할당 |
정보를 가장 잘 알고 있는 객체에게 책임을 부여
|
5. 외부 도움 요청 |
스스로 처리할 수 없는 작업은 다른 객체에 요청
|
6. 반복적인 과정 | 위 과정을 반복하면서 책임을 적절히 분배 |
높은 응집도와 낮은 결합도
- 책임 할당을 위해 정보 전문가 패턴 뿐만 아니라 여러 무수한 패턴이 존재함
- 여러 설계 패턴중 높은 응집도와 낮은 결합도를 목표로 책임을 할당
창조자에게 객체 생성 책임을 할당하라
창조자 패턴
- 객체를 생성 책임을 할당할 때 기준
기준 | 객체 생성 책임을 가질 수 있는 대상 |
포함 관계 | 최종 결과물 객체를 포함하는 객체 |
사용 관계 | 최종 결과물 객체를 사용하는 객체 |
초기화 데이터 보유 |
최종 결과물 객체를 초기화하는데 필요한 데이터를 가지는 객체
|
적용
- 영화 예매 시스템의 최종 결과물은 Reservation 인스턴스를 생성하는 것
- Screening 객체는 Reservation 객체를 생ㅅ어하는데 필요한 데이터에 대한 전문가 (영화, 상영 시간, 상영 순번 등)
3. 구현을 통한 검증
더보기
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
// constructor, getter..
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calcuateFee(audienceCount), audienceCount);
}
private Money calcuateFee(int audienceCount) {
return movie.calcuateMovieFee(this).times(audienceCount);
}
}
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
// constructor, getter..
public Money calcuateMovieFee(Screening screening) {
if (isDiscountable(screening)) return fee.minus(calculateDiscountAmount());
return fee;
}
private Money calculateDiscountAmount() {
switch (movieType) {
case AMOUNT_DISCOUNT: return calculateAmountDiscountAmount();
case PERCENT_DISCOUNT: return calculatePercentDiscountAmount();
case NONE_DISCOUNT: return calculateNoneDiscountAmount();
}
throw new IllegalArgumentException();
}
private Money calculateAmountDiscountAmount() {
return discountAmount;
}
private Money calculatePercentDiscountAmount() {
return fee.times(discountPercent);
}
private Money calculateNoneDiscountAmount() {
return Money.ZERO;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream().anyMatch(c -> c.isSatisfiedBy(screening));
}
}
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
// constructor, getter...
public boolean isSatisfiedBy(Screening screening) {
if (type == DiscountConditionType.PERIOD) return isSatisfiedByPeriod(screening);
return isSatisfiedBySequence(screening);
}
private boolean isSatisfiedByPeriod(Screening screening) {
return this.dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
this.startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
this.endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return this.sequence == screening.getSequence();
}
}
변경 대상 | 변경 내용 | 설명 |
Screening
|
reserve() | 예매 기능 추가 |
calculateFee() | Movie에게 요금 계산 위임 | |
Movie |
calculateMovieFee() | 요금 계산 담당 |
calculateXXXDiscountFee() | 할인 방식별 개별 메서드 통합 | |
isDiscountable() | 할인 여부 확인 | |
DiscountCondition | isSatisfiedBy() | 기존 isSatisfiedByXXX() 통합 Screening을 전달받아 할인 조건 확인 |
DiscountCondition 개선하기
문제점: 변경에 취약함
기존 메서드 | 변경에 대한 문제점 |
isSatisfiedBy() |
새로운 할인 조건 추가 시 분기문 수정 및 상태 변수 추가 가능성 존재
|
isSatisfiedBySequence() |
순번 조건 판단 로직 변경 시 구현 수정 및 상태 변수 변경 가능성 존재
|
isSatisfiedByPeriod() |
기간 조건 판단 로직 변경 시 구현 수정 및 상태 변수 변경 가능성 존재
|
- 응집도가 낮음
- 서로 다른 이유로 변경이 발생
- 각 변경이 코드에 영향을 미치는 시점이 서로 다를 수 있음
문제 해결 방법
- 변경의 이유에 따라 각각의 클래스로 분리해야 함
변경의 이유가 하나 이상인 클래스 찾는 방법
판단 기준 | 설명 | 예시 | 해결 방법 |
인스턴스 변수가 초기화되는 시점 | 응집도가 높은 클래스는 모든 속성을 함께 초기화 | 조건: dayOfWeek, startTime, endTime 순번: sequence |
함께 초기화되는 속성을 기준으로 코드 분리
|
인스턴스 변수를 사용하는 방식 | 응집도가 높은 클래스는 모든 메서드가 모든 속성을 사용 | isSatisfiedByPeriod() → dayOfWeek, startTime, endTime isSatisfiedBySequence() → sequence |
속성 그룹과 해당 그룹을 사용하는 메서드를 기준으로 코드 분리
|
타입 분리하기
두 타입을 개별 클래스로 분리
더보기
public class PeriodCondition {
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;
}
}
public class SequenceCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return this.sequence == screening.getSequence();
}
}
- 문제 해결: 높은 응집성 달성
- 새로운 문제 발생: 결합도 높아짐 (클라이언트 코드인 Movie에서 각 클래스들의 리스트들을 가지게 됨)
다형성을 통해 분리하기
더보기
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class PeriodCondition implements DiscountCondition { ... }
public class SequenceCondition implements DiscountCondition { ... }
- 두 클래스는 할인 여부를 판단하는 동일한 책임을 수행 (할인 여부를 판단하는 방법만 다를 뿐)
- 클라이언트가 역할에 결합되므로 구체적인 클래스는 알 필요가 없어짐
- 역할은 객체의 구체적인 타입을 추상화 할 수 있음
- 구현을 공유할 필요에 따라 추상 클래스 혹은 인터페이스 타입을 선택하여 사용하기
변경으로부터 보호하기
변경 보호 패턴
개념 | 설명 | 효과 |
추상화가 구체적인 타입을 캡슐화 | 클라이언트 코드가 특정 구현에 의존하지 않도록 인터페이스나 추상 클래스를 사용 | 새로운 할인 조건을 추가하더라도 클라이언트 코드에 영향을 주지 않음 |
구현체를 추가하는 방식으로 확장 가능 | 새로운 할인 조건을 추가할 때 기존 코드를 수정하는 대신, 새로운 클래스를 구현하면 됨 | OCP(개방-폐쇄 원칙) 준수 유지보수 용이 |
Movie 클래스 개선하기
변경 보호 패턴을 활용
더보기
public abstract class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
public Movie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountConditions = Arrays.asList(discountConditions);
}
protected Money getFee() { return fee; }
public List<DiscountCondition> getDiscountConditions() { return discountConditions; }
public Money calcuateMovieFee(Screening screening) {
if (isDiscountable(screening)) return fee.minus(calculateDiscountAmount());
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream().anyMatch(c -> c.isSatisfiedBy(screening));
}
abstract protected Money calculateDiscountAmount();
}
public class AmountDiscountMovie extends Movie {
private Money discountAmount;
public AmountDiscountMovie(String title, Duration runningTime, Money fee, Money discountAmount, DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
this.discountAmount = discountAmount;
}
@Override
protected Money calculateDiscountAmount() {
return discountAmount;
}
}
public class PercentDiscountMovie extends Movie {
private double percent;
public PercentDiscountMovie(String title, Duration runningTime, Money fee, double percent, DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
this.percent = percent;
}
@Override
protected Money calculateDiscountAmount() {
return getFee().times(percent);
}
}
public class NoneDiscountMovie extends Movie {
public NoneDiscountMovie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
}
@Override
protected Money calculateDiscountAmount() {
return Money.ZERO;
}
}
개념 | 설명 | 효과 |
각 메서드를 각 클래스로 분리 | 영화 타입별로 개별 클래스로 분리하여 책임을 명확히 함 | 코드 응집도 증가 유지보수 용이 |
공유할 구현을 추상 클래스로 추상화 | 공통 기능을 상위 추상 클래스로 정의 세부 사항을 하위 클래스에서 구현 |
코드 중복 제거 일관성 유지 |
Movie.getFee() 가시성을 protected로 제한 | 해당 메서드는 하위 클래스에서만 사용하도록 제한 | 캡슐화 강화 불필요한 외부 접근 차단 |
변경과 유연성
개념 | 상속 | 합성 |
구현 방식 | 하위 클래스가 상위 클래스를 확장 (extends) | 기존 객체가 다른 객체를 포함 (has-a) |
객체 생성 | 새로운 하위 클래스 인스턴스 생성 필수 | 생성자에서 필요한 구현체를 인자로 전달 |
유연성 | 실행 시점에서 어떤 구현체가 사용될지 한눈에 파악 어려움 | 런타임에 객체 교체 가능, 유연성 증가 |
코드 변경 영향 | 하위 클래스 추가 시, 기존 코드에 영향 가능성 있음 | 구현체 추가하더라도 추가 코드 변경 없음 |
캡슐화 | 상속 구조가 깊어지면 캡슐화가 약해질 수 있음 | 캡슐화를 유지하면서 기능 확장 가능 |
- 상속은 부모 자식간 결합이 존재함 (부모의 변경에 자식이 영향을 받음)
- 합성이 캡슐화와 유연성에 더 좋음
4. 책임 주도 설계의 대안
메서드 응집도
더보기
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
boolean discountable = checkDiscountable(screening);
Money fee = calculateFee(screening, discountable, audienceCount);
return createReservation(screening, customer, audienceCount, fee);
}
private Money calculateFee(Screening screening, boolean discountable, int audienceCount) {
if (discountable) return screening.getMovie().getFee().minus(calculateDiscountedFee(screening.getMovie())).times(audienceCount);
return screening.getMovie().getFee().times(audienceCount);
}
private Money calculateDiscountedFee(Movie movie) {
switch (movie.getMovieType()) {
case AMOUNT_DISCOUNT: return calcuateAmountDiscountedFee(movie);
case PERCENT_DISCOUNT: return calcuatePercentDiscountedFee(movie);
case NONE_DISCOUNT: return calcuateNoneDiscountedFee(movie);
}
throw new IllegalArgumentException();
}
private Money calcuateAmountDiscountedFee(Movie movie) { return movie.getDiscountAmount(); }
private Money calcuatePercentDiscountedFee(Movie movie) { return movie.getFee().times(movie.getDiscountPercent()); }
private Money calcuateNoneDiscountedFee(Movie movie) { return Money.ZERO; }
private Reservation createReservation(Screening screening, Customer customer, int audienceCount, Money fee) { return new Reservation(customer, screening, fee, audienceCount); }
private boolean checkDiscountable(Screening screening) { return screening.getMovie().getDiscountConditions().stream().anyMatch(c -> c.isSatisfiedBy(screening)); }
}
- 긴 메서드를 각각 책임에 따라 내부 구현으로 나누어 호출하기
객체를 자율적으로 만들자
- 자율적인 객체를 만드는 방법은 자신이 소유하고 있는 데이터를 자기 스스로 처리하는 것
- 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드 이동시키기
'Code > OOP' 카테고리의 다른 글
[오브젝트] 8. 의존성 관리하기 (0) | 2025.04.04 |
---|---|
[오브젝트] 6. 메시지와 인터페이스 (0) | 2025.04.04 |
[오브젝트] 4. 설계 품질과 트레이드오프 (0) | 2025.04.03 |
[오브젝트] 3. 역할, 책임, 협력 (0) | 2025.04.03 |
[오브젝트] 2. 객체지향 프로그래밍 (1) | 2025.04.02 |