14장. 일관성 있는 협력


객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다.
하지만 재사용은 꽁짜로 얻어지지 않는다.
재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다

아래 예제를 통해 일관성 있는 협력 패턴이, 이해하기 쉽고 직관적이라는 것을 알아보자


01. 핸드폰 과금 시스템 변경하기

기본 정책 확장

11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 아래와 같이 수정해보자

  • 고정요금 방식
    • ex) 10초당 18원
  • 시간대별 방식
    • ex) 00 ~ 19시 10초당 18원
    • ex) 19 ~ 24시 10초당 15원
  • 요일별 방식
    • ex) 평일 10초당 38원
    • ex) 공휴일 10초당 19원
  • 구간별 방식
    • ex) 초기 1분 10초당 50원
    • ex) 1분 이후 10초당 20원

조합 가능한 요금 계산 순서


위의 사진처럼 무수히 많은 조합이 나오기에 설계가 중요해졌다.

클래스 구조

고정요금 방식 구현하기

가장 간단한 고정요금이다. 일반요금제와 동일하다.

public class FixedFeePolicy extends BasicRatePolicy {
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 계산하기 위해서는 통화 기간을 정해진 시간대별로 나눈후 시간대별로 서로 다른 계산 규칙을 적용해야 한다.
시간대별 방식의 통화 요금일 계산하기 위해서는 통화의 시작, 종료시간, 시작일자, 종료일자도 함께 고려되어야 한다.

시간대별 통화 시간을 관리하는 클래스 DateTimeInterval

public class DateTimeInterval {
    public static DateTimeInterval of(LocalDateTime from, LocalDateTime to) {
        return new DateTimeInterval(from, to);
    }

    public static DateTimeInterval toMidnight(LocalDateTime from) {
        return new DateTimeInterval(from, LocalDateTime.of(from.toLocalDate(), LocalTime.of(23, 59, 59, 999_999_999)));
    }

    public static DateTimeInterval fromMidnight(LocalDateTime to) {
        return new DateTimeInterval(LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)), to);
    }

    public static DateTimeInterval during(LocalDate date) {
        return new DateTimeInterval(
                LocalDateTime.of(date, LocalTime.of(0, 0)),
                LocalDateTime.of(date, LocalTime.of(23, 59, 59, 999_999_999)));
    }

    private DateTimeInterval(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }

    public Duration duration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }

    public LocalDateTime getTo() {
        return to;
    }

    public List<DateTimeInterval> splitByDay() {
        if (days() > 0) {
            return split(days());
        }
        return Arrays.asList(this);
    }

    private long days() {
        return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
    }

    private List<DateTimeInterval> split(long days) {
        List<DateTimeInterval> result = new ArrayList<>();
        addFirstDay(result);
        addMiddleDays(result, days);
        addLastDay(result);
        return result;
    }

    private void addFirstDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.toMidnight(from));
    }

    private void addMiddleDays(List<DateTimeInterval> result, long days) {
        for(int loop=1; loop < days; loop++) {
result.add(DateTimeInterval.during(from.toLocalDate().plusDays(loop)));
        }
    }

    private void addLastDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.fromMidnight(to));
    }

    public String toString() {
        return "[ " + from + " - " + to + " ]";
    }
}

요일별 방식 구현하기

요일별 방식은 요일별로 요금 규칙을 다르세 설정할 수 있다.
각 규칙은 요일의 목록, 단위 시간, 단위 요금이라는 3가지 요소로 구성된다.

public class DayOfWeekDiscountRule {
    public Money calculate(DateTimeInterval interval) {
        if (dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
            return amount.times(interval.duration().getSeconds() / duration.getSeconds());
        }
        return Money.ZERO;
    }
}

요일별 방식 역시 통화 기간이 여러 날에 걸쳐 있을 수 있다.
시간대별 방식과 동일하게 통화 기간을 날짜 경계로 분리하고 각 통화 기간을 요일별로 설정된 요금 정책에 따라 적절하게 계산해야 한다.

public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.getInterval().splitByDay()) {
            for(DayOfWeekDiscountRule rule: rules) { result.plus(rule.calculate(interval));
            }
        }
        return result;
    }
}

잠시 지금까지 구현한 고정요금, 시간대별, 요일별 방식의 클래스를 다시 살펴보자.
겉으로 보기에는 문제가 없이 잘 구현된 것 같아 보인다. FixedFeePolicy, TimeOfDayDiscountPolicy, DayOfWeekDiscountPolicy
세 클래스는 통화 요금을 정확하게 계산하고 있고 응집도와 결합도 측면에서도 특별히 문제는 없어 보인다.
그러나 이 클래스들을 함께 모아놓고 보면 문제점이 보인다

문제는 이 클래스들이 유사한 문제를 해결하고 있음에도 설계의 일관성이 없다는 것이다


02. 설계에 일관성 부여하기

설계에 일관성을 부여하기 위해서는 다음과 같은 연습이 필요하다

  • 다양한 설계 경험 익히기
  • 디자인 패턴을 학습하고 변경이라는 문맥안에 적용해보기
  • 협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르기
    • 변하는 개념을 변하지 않는 개념으로부터 분리
    • 변하는 개념을 캡슐화

조건 로직 대 객체 탐색

먼저, 조건 로직에 대해서 코드를 통해서 살펴보자.
4장에서 나왔던 ReservationAgency 예제이다.

public class ReservationAgency {
    public Reservation reserve(...) {
        for (DiscountCondition condition : movie,getDiscountConditions()) {
            if (condition.getType() == DiscountCondtionType.PERIDOD) {
                // 기간조건
            } else {
                // 회차조건
            }
        }

        if (discountable) {
            switch(movie.getMovieType)) {
                case AMOUNT_DISCOUNT: // 금액 할인 정책
                case PERCENT_DISCOUNT: // 비율 할인 정책
                case NONE_DISCOUNT: // 할인 정책 없음
            }
        } else {
            // 할인 정책이 불가능한 경우
        }
    }
}

위와 같이 조건 로직으로 구현한다면 변경에 취약하고 유지보수의 어려움성이 생긴다.
조건로직이란 위와 같이 'if ~ else' 를 통해 비즈니스 로직에 덕지덕지 붙여 놓은 것이다.

우리는 추상화와 다형성이라는 것을 배웠기에 인터페이스를 이용해 객체 탐색으로 변경할 수 있다.

public class Movie {
    private DiscountPolicy discountPolicy;
    public Money calculteMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

이처럼 다형성은 조건 로직을 객체 사이의 이동으로 바꾸어 준다.
따라서, 할인 조건에 맞는 메시지만 주고 받으면 되기 때문에 변경이 용이해진다


03. 일관성 있는 기본 정책 구현하기

변경 분리하기

일관성 있는 협력을 만들기 위한 첫 단계는 변하는 개념과 변치 않는 개념을 분리하는 것이다
앞에서 본 핸드폰 과금 시스템의 기본 정책에서 변하는 부분과 변하지 않는 부분을 다시 살펴보자


단위요금이 시간당 요금을 계산하는 반면, 적용조건은 형식이 다르다른 것을 알 수 있다.
이것으로 적용조건은 변하는 부분이고, 단위요금은 변치 않는 다는 것으로 분리할 수 있다는 것을 유추할 수 있다.

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.
여기서 변하지 않는 것은 ‘규칙’이다. 변하는 것은 ‘적용조건’이다.
따라서 규칙으로부터 적용조건을 분리하여 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화의 서브타입으로 만들어야 한다.
그 후에 규칙이 적용조건을 표현하는 추상화를 합성관계로 연결하는 것이 객체의 캡슐화이다.

추상화 수준에서 협력 패턴 구현하기

먼저, ‘적용조건’을 표현하는 추상화인 FeeCondtion

public interface FeeCondition {
    List<DateTimeInterval> findTimeIntervals(Call call);
}

다음으로 FeeFule, 단위요금과 적용조건을 인스턴스변수로 사용한다

public class FeeRule {
    private FeeCondition feeCondition;
    private FeePerDuration feePerDuration;

    public Money calculateFee(Call call) {
        return feeCondition.findTimeIntervals(call)
                .stream()
                .map(each -> feePerDuration.calculate(each))
                .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }
}

FeePerDuartion클래스는 단위 시간당 요금이라는 개념을 표현한다.
또한, 이정보를 이용해 일정기간의 요금을 계산하는 calculate 메서드를 구현한다

public class FeePerDuration {
    private Money fee;
    private Duration duration;

    public Money calculate(DateTimeInterval interval) {
        return fee.times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
    }
}

구체적인 협력 구현하기

이제 맨 처음 앞서 구현한 구체적인 협력 클래스를 인터페이스를 이용해 구현해보자
TimeOfDayFeeCondition 시간대별 정책

public class TimeOfDayFeeCondition implements FeeCondition {
    private LocalTime from;
    private LocalTime to;

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval().splitByDay()
                .stream()
                .filter(each -> from(each).isBefore(to(each)))
                .map(each -> DateTimeInterval.of(LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
              LocalDateTime.of(each.getTo().toLocalDate(), to(each))))
                .collect(Collectors.toList());
    }

    private LocalTime from(DateTimeInterval interval) {
        return interval.getFrom().toLocalTime().isBefore(from) ?
                from : interval.getFrom().toLocalTime();
    }

    private LocalTime to(DateTimeInterval interval) {
        return interval.getTo().toLocalTime().isAfter(to) ?
                to : interval.getTo().toLocalTime();
    }
}

DayOfWeekFeeCondition, 요일별정책

public class DayOfWeekFeeCondition implements FeeCondition {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval()
                .splitByDay()
                .stream()
                .filter(each ->
 dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                .collect(Collectors.toList());
    }
}

DurationFeeCondition 구간별정책

public class DurationFeeCondition implements FeeCondition {
    private Duration from;
    private Duration to;

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        if (call.getInterval().duration().compareTo(from) < 0) {
            return Collections.emptyList();
        }

        return Arrays.asList(DateTimeInterval.of(
                call.getInterval().getFrom().plus(from),
                call.getInterval().duration().compareTo(to) > 0 ?
                       call.getInterval().getFrom().plus(to) :
                        call.getInterval().getTo()));
    }
}

맨처음 짯던 정책들과 비교를 해보자. 각 클래스마다 일관성이 생기게 되었다.
우리는 변하는 개념과 변치 않는 개념을 분리해서 변경을 캡슐화 하였다.
이처럼 변경을 캡슐화해 협력을 일관성 있게 만들면 어떤 장점을 얻을 수 있는지 명확하게 보여준다


마찬가지로, 추가적인 정책이 필요하다면 FeeCondition으로부터 구현을 해주면 된다.

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
변경을 캡슐화 하는 방법이 협력에 참여하는 객체들의 역할과 책임을 결정하고 이렇게 결정된 협력이 코드의 구조를 결정한다.
따라서, 변경의 방향을 파악할 수 있는 감각을 기르는 것이 중요하다.


참조

  • 조영호님의 오브젝트 '일관성 있는 협력'

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

[Object] 다형성

Books/Object 2021. 8. 12. 00:48

12장 다형성


01. 다형성

다형성(Polymorphism)
‘많은’ - poly
‘형태’ - ‘morph’의 합성어로 많은 형태를 가질 수 있는 능력을 의미

‘Computer Science’ 에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고,
이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다.

'객체지향 프로그래밍'에서는 다형성을 4개로 분류하여 정의한다.

  • 오버로딩 다형성
    • 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우
  • 강제 다형성
    • 자동적인 타입 변환 방식
  • 매개변수 다형성
    • 제네릭 프로그맹과 관련이 깊다
  • 포함 다형성
    • 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제 수행되는 행동이 달라진다
    • 서브타입 다형성이라고도 부른다

02. 상속의 양면성

객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두가지 관점을 함께 고려해야 한다.
상속의 경우도 마찬가지이다.

  • 부모 클래스에 정의한 모든 데이터를 자식 클래스의 인스터에 포함시킬 수 있다 -> 데이터 관점의 상속
  • 데이터 뿐만 아니라 메서드도 포함 시킬 수 있다 -> 행동 관점의 상속

상속은 부모 클래스에서 정의한 것을 자동적으로
공유하고 재사용 할 수 있는 메커니즘으로 보이지만,
이 관점은 상속을 오해한 것

  • 상속의 목적은 코드 재사용이 아니다
  • 상속은 프로그램을 구성하는 개념들 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것
  • 타입 계층에 대한 고민 없이 상속을 이용하면 유지보수가 어려워 진다

상속을 사용한 강의 평가

상속의 메카니즘을 이해하기 위해 예제를 살펴보자. 수강생들의 성적을 계산하는 프로그램

Lecture.java

public class Lecture {
    private int pass; // 이수여부 판단할 기준 점수
    private String title; // 과목명
    private List<Integer> scores = new ArrayList<>(); // 학생들의 성적 리스트

    public Lecture(String title, int pass, List<Integer> scores) {
        this.title = title;
        this.pass = pass;
        this.scores = scores;
    }

    public double average() {
        return scores.stream().mapToInt(Integer::intValue).average().orElse(0);
    }

    public List<Integer> getScores() {
        return Collections.unmodifiableList(scores);
    }

    public String evaluate() {
        return String.format("Pass:%d Fail:%d", passCount(), failCount());
    }

    private long passCount() {
        return scores.stream().filter(score -> score >= pass).count();
    }

    private long failCount() {
        return scores.size() - passCount();
    }
}

이수 기준 70점, 5명의 대한 설정 통계

Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50,45));
String evaluration = lecture.evaluate(); // 결과 => "Pass:3, Fail:2"


다음으로 좀더 기능을 확장해보자. Lecture의 출력 결과에 등급별 통계를 추가

GradeLecture.java

public class GradeLecture extends Lecture {
    private List<Grade> grades;

    public GradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
        super(name, pass, scores);
        this.grades = grades;
    }

    @Override
    public String evaluate() {
        return super.evaluate() + ", " + gradesStatistics();
    }

    private String gradesStatistics() {
        return grades.stream().map(grade -> format(grade)).collect(joining(" "));
    }

    private String format(Grade grade) {
        return String.format("%s:%d", grade.getName(), gradeCount(grade));
    }

    private long gradeCount(Grade grade) {
        return getScores().stream().filter(grade::include).count();
    }

    public double average(String gradeName) {
        return grades.stream()
                .filter(each -> each.isName(gradeName))
                .findFirst()
                .map(this::gradeAverage)
                .orElse(0d);
    }

    private double gradeAverage(Grade grade) {
        return getScores().stream()
                .filter(grade::include)
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0);
    }
}


등급의 이름과 각등긥 범위를 정의

Grade.java

public class Grade {
    private String name; // 등급
    private int upper,lower; // 상한선, 하한선

    private Grade(String name, int upper, int lower) {
        this.name = name;
        this.upper = upper;
        this.lower = lower;
    }

    public String getName() {
        return name;
    }

    public boolean isName(String name) {
        return this.name.equals(name);
    }

    public boolean include(int score) {
        return score >= lower && score <= upper;
    }
}

GradeLecture 클래스의 evaluate 메서드를 살펴 보자. 부모의 evaluate를 재정의하여 사용하고 있다.
이처럼 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것을 메서드 오버라이딩이라 한다

Lecture와 GradeLecture의 average를 살펴보자. 메서드 이름은 같지만 시그니처가 다르다.
둘은 대체되지 않으며 호출하는 방법이 다르므로 공존하게 된다. 이처럼 메서드 이름은 동일하지만 시그니처는 다른 메서드를 메서드 오버로딩이라 한다

데이터 관점의 상속

위에서 구현한 클래스를 인스턴스로 생성해보자

Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50,45));
Lecture lecture = new GradeLecture("객체지향 프로그래밍",
    70, Arrays.asList(    new Grade("A", 100, 95),
                        new Grade("B", 94, 80),
                        new Grade("C", 79, 70),
                        new Grade("D", 69, 50),
                        new Grade("F", 49, 0)),
        Arrays.asList(81, 95, 75, 50, 45));

아래 사진과 같이 데이의 관점에서 표현할 수 있다


즉, 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 된다

행동 관점의 상속

행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다. 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다.


03. 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

성적 계산 프로그램에 각 교수별로 강의에 대한 성적 통계 기능을 추가해 본다

Professor.java 통계를 계산

public class Professor {
    private String name;
    private Lecture lecture;

    public Professor(String name, Lecture lecture) {
        this.name = name;
        this.lecture = lecture;
    }

    public String compileStatistics() {
        return String.format("[%s] %s - Avg: %.1f", name,
                lecture.evaluate(), lecture.average());
    }
}
Professore professor = new Profeesor("다익스트라", new Lecture(...));
Professore professor = new Profeesor("다익스트라", new GradeLecture(...));

Professor의 생성자 인자 타입은 Lecture로 선언돼 있지만 Lecture, GradeLecture 모두 올수가 있다.
코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라
실행되는 메서드가 달라지는 것은 업캐스팅과 동적 바인딩 메카니즘이 작용하기 때문이다

  • 부모 클래스 타입으로 선언된 변수에서 자식 클래스의 인스턴스를 할당하는 것을 업캐스팅이라 한다
  • 선언된 변수 타입이 아니라도 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 컴파일 시점이 아니라 실행시점에 결정하기 때문인데, 이를 동적 바인딩이라 한다

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼빌릭 인터페이스에 합쳐지기 때문에 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다.

Lecture lecture = new GradeLecture(...);

반대의 경우를 다운캐스팅(downcasting)이라 한다

Lecture lecture = new GradeLecture(...);
GradeLecture gradeLecture = (GradeLecture) lecture;

동적 바인딩

컴파일 시점이 아닌 메서드를 런타임에 결정하는 방식을 동적바인딩, 지연바인딩 이라 한다. (앞 chapter에 나와 있는 추상화와 의존성을 같이 봐두면 좋다)


04. 동적 메서드 탐색과 다형성

객체지향 시스템은 다음과 같은 규칙으로 실행할 메서드를 선택한다

  1. 먼저, 자신을 생성한 클래스에 적합한 메서드가 있는지 검사
  2. 없을 경우 부모 클래스에서 메스드를 탐색, 있을때까지 상속 계층을 따라 올라간다
  3. 최상위 계층까지 탐색 하여 없을 경우 예외 발생

자동적인 메시지 위임

상속을 이용할 경우, 메시지 수신을 처리할 대상을 자동으로 찾게 된다.
즉, 명시적으로 코드를 작성할 필요가 없고, 상속 계층에 따라 부모 클래스에게 위임 된다.
이런 관점에서 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.

메서드 오버라이딩

Lecture lecture = new GradeLecture(...);
lecture.evalute();

위의 코드를 실행해보자. Lecture.evaluate -> 는 GradeLecture.evaluate에 재정의 되어 있다.
실행시, self 참조에 의해 자기 자신의 클래스를 먼저 탐색하게 된다. 따라서, GradeLecture.evaluate()가 실행되게 되고, 오버라이딩된 부모 클래스의 메서드를 감추게 한다.

동적메서드 탐색이 자식 클래스에서 부모 클래스 방향으로 진행된다는 것을 기억하면 오버라이딩의 결과는 당연하다.

메서드 오버로딩

Lecture lecture = new GradeLecture(...);
lecture.avaerage();

위의 코드를 실행해보자. 오버라이딩과 마찬가지로 selft 참조를 하게 된다.
GradeLecture에는 시그니처가 다르기에 부모 클래스에서 해당 시그니처를 찾게 된다.

이처럼 시그니처가 다르기 때문에 동일한 이름의 메서드가 공존하는 경우를 오버로딩이라 부른다.

동적인 문맥

lecture.evaluate()

위의 메시지 전송만으로 어떤 클래스의 메서드가 실행될지 알 수 없다는 것을 이해하였다. 여기서 중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀐다는 것이다. 그리고 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조이다

이해할 수 없는 메시지

동적 메서드 탐색을 통해, 메시지를 수신하면 부모 클래스까지 탐색하여 찾게된다. 만약, 최상위 계층까지 탐색후 원하는 메시지가 없다면 어떻게 될까?

정적 타입 언어와 이해할 수 없는 메시지

정적 타입 언어에서는 상속 계층 전체를 탐색후 메시지를 처리할 메서드를 발견하지 못하면 컴파일 에러를 발생시킨다

Lecture lecture = new GradeLecture(...);
lecture.unknownMessage(); // 컴파일 에러!!

동적 타입 언어와 이해할 수 없는 메시지

동적 타입 언어도 자식으로 부터 부모 방향으로 메서드를 탐색한다. 차이점이라면 컴파일단계가 존재하지 않아 실제 코드를 실행해보기 전엔 메시지 처리 가능 여부를 알 수 없다는 점이다.

super

Self 참조의 가장 큰 특징은 동적이라는 점이다. Self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 super 참조이다.

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다. 이럴 경우, 부모 클래스의 변수나 메서드에 접근하기 위해 super 참조라는 내부 변수를 제공한다.

public class GradeLecture extends Lecture {
    @Override
    public String evaluate() {
        return super.evalute() + ", " + gradesStatistics();
    }
}

또한, super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공한다.
그 메서드가 조상 클래스 어딘가에 있기만 하면 성공적으로 탐색이 가능하다


참조

  • 조영호님의 오브젝트 12장 “다형성”

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

11장 합성과 유연한 설계


상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.

  • 상속은 부모 클래스를 재사용한다.
  • 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용 한다.
  • 상속 관계는 is-a 관계라고 부르고 합성 관계는 has-a 관계라고 부른다.

상속과 합성은 이처럼 재사용 기법으로 많이 쓰이는 방법이지만 대체로 합성을 권하고 있다. 간략하게 이유를 살펴보고 합성에 대해 알아보도록 하자

먼저, 상속은 자식클래스 정의에 부모 클래스의 이름을 덧붙이는 것만으로 코드를 재사용할 수 있다. 부모클래스의 정의를 물려 받으며 코드를 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있다. 그러나 상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세히 알아야 하기 때문에 자식과 부모 사이의 결합도가 높아질 수 밖에 없다. 결과적으로 코드를 재사용하기 쉬운 방버이기 하지만 결합도가 높아지는 치명적인 단점이 있다.

합성은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함된 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서, 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화 할 수 있기 때문에 더 안정적인 코드를 얻을 수 있다.

상속 관계는 클래스 사이의 정적인 관계인데 비해 합성 관계는 객체 사이의 동적인 관계이다. 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있다. 따라서, 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다.

01. 상속을 합성으로 변경하기

불필요한 인터페이스 상속 문제

10장에서 알아본 Stack이다.
Stack은 Vector를 상속 받아서 사용했기 때문에 문제가 발생하였다. 상속을 제거하고 Vector를 내부 변수로 바꾸어 주자.

public class Stack<E> {
    private Vector<E> elements = new Vector<>();
    public E push(E item) {
        elements.addElement(item);
        return item;
    }

    public E pop() {
        if (elements.isEmpty()) {
            throw new EmptyStackException();
        }
        return elements.remove(elements.size() -1);
    }
}

이제 Stack의 public 인터페이스엔 10장에서 문제가 되었던 add와 같은 퍼블릭 인터페이스가 포함되지 않게 되었다.
이렇듯 상속을 합성 관계로 변경함으로써 Stack의 규칙이 어겨졌던 것을 막을수 있게 된다

메서드 오버라이딩의 오작용 문제

마찬가지로 10장에서 보았던 예제를 같이 살펴보자.
InstrumentedHashSet의 경우 부모의 메서드를 호출하여 addCount가 원하는 결과를 같지 못하는 문제가 있었다.
다만, 여기서는 해당 퍼블릭 메서드를 사용해야 하기 때문에 위에서 사용하였던 내부 인스턴스 변수로 사용할 수 없다.
이번에는 인터페이스를 이용하면 해결할 수 있다.

public class InstrumentedHashSet<E> implements Set<E> {
    private int addCount = 0;
    private Set<E> set;

    @Override
    public boolen add(E e) {
        addCount++;
        return set.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }
}

HashSet에 대한 구현 결합도는 제거 되었고, 퍼블릭 인터페이스는 그대로 유지할 수 있게 되었다.

부모 클래스와 자식 클래스의 동시 수정 문제

마찬가지로 10장에서 살펴보았던 예를 같이 보도록 하겠다

public class PersonalPlaylist {
    private Playlist playlist = new Playlist();

    public void append(Song song) {
        playlist.append(song);
    }

    public void remove(Song song) {
        playlist.getTracks().remove(song);
        playlist.getSingers().remove(song.getSinger());
    }
}

합성으로 변경하더라도 가수별 노래목록을 유지하기 위해 Playlist와 PersonalPlaylist를 함께 수정해주어야 하는 문제는 해결 되지는 않았다. 그렇다 하더라도 여전히 합성을 사용하는게 좋은데, 향후에 Playlist의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문이다.

간단하게 상속을 합성으로 변경하는 것에 대해 알아보았는데,
여기서 기억해야 할 것은 합성은 상속과 비교해 안정성과 유연성이라는 장점을 제공한다.
또한, 구현이 아니라 인터페이스에 의존하면 설계가 유연해진다는 것이다.


02. 상속으로 인한 조합의 폭발적인 증가

상속으로 인해 결합도가 높아지면 코드를 수정하는데 필요한 작업의 양이 과도하게 늘어나게 된다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다

  • 하나의 기능을 추가하거나 수정할때 불필요하게 많은 수의 클래스를 수정한다
  • 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다

기존 정책과 부가 정책 조립하기

10장에 있었던 핸드폰 요금제를 살펴보고, 추가로 부가정책(세금, 기본요금할인)을 추가한다고 가정해보자

  • 기본정책으로 - 일반요금제, 심야요금제가 존재
  • 세금 정책은 기본 정책 계산이 끝난후 계산이 끝난 결과에 세금을 부과
  • 세금 정책은 Optional 하다
  • 부가정책은 임의의 순서로 적용 가능하다

위 가정으로 조합 가능한 요금 계산방법이다. 이것을 상속으로 구현해본다고 가정해보자.


위 조합으로 구성된 클래스가 1번이고, 새로운 부가정책을 추가하게 된다면 2번과 같은 방식으로 추가가 될것이다. 자세한 내용은 생략하였지만 상속으로 인해 조합이 추가 된다면 엄청난 고통이 수반될 것은 뻔한 그림이다.


03. 합성 관계로 변경하기

상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합별로 클래스를 추가해주어야 한다. 이것을 클래스 폭발 문제라 한다. 합성을 이용하면 런타임에 객체의 관계를 변경할 수 있기 때문에 유연한 설계가 가능해진다.

기본 정책 합성하기

실행 시점에 합성 관계를 이용해 정책들을 조합할 수 있게 인터페이스를 만들어 줍니다
정책, RatePolicy.java

public interface RatePolicy {
    Money calculateFee(Phone phone);
}

기본 정책, BasicRatePolicy.java

public abstract class BasicRatePolicy implements RatePolicy {
    @Override
    public Money calculateFee(Phone phone) {
        Money result = Money.ZERO;

        for (Call call : phone.getCalls()) {
            result.plus(calculateCallFee(call));
        }
        return result;
    }

    protected abstract Money calculateCallFee(Call call);
}

일반요금제, RegularPolicy.java

public class RegularPolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;

    public RegularPolicy(Money amount, Duration seconds) {
        this.amount = amount;
        thiis.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

심야요금제, NightlyDiscountPolicy.java

public class NightlyDiscountPolicy extends BasicRatePolicy {
    private static final int LAST_NIGHT_HOUR = 22;
    private Money nightlyAmount
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPolicy(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;    
        this.regularAmount = regularAmount;
        thiis.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getFrom().getHour() >= LAST_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }

        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

기본 정책을 이용해 요금을 계산할 수 있도록 Phone을 수정하자

public class Phone {
    private RatePolicy ratePolicy;
    private List<Call> calls = new ArrayList<>();

    public Phone(RatePolicy ratePolicy) {
        this.ratePolicy = ratePolicy;
    }

    public List<Call> getCalls() {
        return Collections.unmodifiableList(calls);
    }

    public Money calculateFee() {
        return ratePolicy.calculateeFee(this);
    }
}

Phone 내부에 RatePolicy에 대한 참조가 포함돼어 있다는 것에 주목하자. 이것이 바로 합성이다. Phone이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy라는 인터페이스로 정의되어 있다는 것에도 주목하자.

Phone regularPhone = new Phone(new RegularPolicy(Money.wons(10), Duration.ofSecons(10)));
Phone nightlyPhone = new Phone(new NightlyDiscountPolicy(Money.wons(5), Money.wons(10), Duration.ofSecons(10)));

부가 정책 적용하기

위에서 만들었던 RatePolicy 인터페이스를 이용하여 정책을 추가해보자
부가정책, AddiionalRatePolicy.java

public abstract class AddiionalRatePolicy implements RatePolicy {
    private RatePolicy next;
    public AddiionalRatePolicy(RatePolicy next) {
        this.next = next;
    }

    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calucateFee(phone);
        return afterCalcurated(fee);
    }

    abstract protected Moeny afterCalcurated(Money fee);
}

세금정책, TaxablePolicy.java

public class TaxablePolicy extends AdditionalRatePolicy {
    private double taxRatio;
    public TaxablePolicy(double taxRatio, RatePolicy next) {
        super(next);
        this.taxRatio = taxRatio;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.plus(fee.times(taxRatio));
    }
}

기본요금 할인 정책, RateDiscountablePolicy.java

public class RateDiscountablePolicy extends AdditionalRatePolicy {

    private Money discountAmount;
    public RateDiscountablePolicy(Money discountAmount, RatePolicy next) {
        super(next);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.minus(discountAmount);
    }
}


완성된 다이어그램을 살펴보자. 아까보다 좀더 명확하게 정책 구분이 되고, 추가에 대한 클래스 폭발 문제도 개선되었다.

새로운 정책 추가하기

합성의 진가는 정책을 추가하거나 수정할 때 발휘 된다. 아래의 다이어그램처럼 간단히 클래스만 추가해주면 된다

객체 합성이 클래스 상속보다 더 좋은 방법이다

객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법은 상속이다. 하지만 상속은 코드 재사용을 위한 우아한 해결책은 아니다. 부모 클래스의 세부적인 구현에 자식 클래스가 강하게 결합하기 때문에 수정/추가에 대해 번거로움이 발생한다.

코드를 재사용하면서 건던한 결합도를 유지할 수 있는 더 좋은 방법은 합성이다. 상속이 구현을 재사용하는데 비해 합성은 객체의 퍼블릭 인터페이스를 재사용하기 때문이다.


참조

  • 조영호님의 오브젝트 “11장 합성과 유연한 설계”

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

10장 상속과 코드 재사용


전통적인 상속이란,
클래스안에 정의된 인스턴스 변수와 메서드를
새로운 클래스에 자동으로 추가하는 것이다


01. 상속과 중복 코드

중복 코드의 경우 많은 생각을 하게 만든다.
이미 존재하는데도 새로운 코드를 만든 이유는 무엇일까? 아니면 단순한 실수 일까?

DRY 원칙

앤드류 헌트 & 데이비드 토마스
프로그래머들은 DRY 원칙을 따라야 한다

  • DRY는 ‘반복하지 마라’ Don’t Repeat Yourself의 첫글자들이다
    중복된 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다

중복과 변경

중복 코드 살펴보기

Call.java (개별 통화기간)

public class Call {
    private LocalDateTime from;
    private LocalDateTime to;

    public Call(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }

    public Duration getDuration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }
}

Phone.java (일반 요금제 전화기)

public class Phone {
    private Money amount; // 단위요금
    private Duration seconds; // 단위시간
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration duration) {
        this.amount = amount;
        this.duration = duration;
    }

    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }

        return result
    }
}

기존 요금제 외에 심야요금제 요구사항이 추가 되었다고 가정해보자

NightlyDiscountPhone.java(심야 요금제 전화기)

public class NightlyDiscountPhone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds; // 단위시간
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration duration) {
        this.amount = amount;
        this.duration = duration;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                result = result.plus(nightlyAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
            result = result.plus(regularAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }

        return result
    }
}

쉽게 요구사항을 반영했지만, 중복이 많이 발생하였다. 당장은 모르겠지만, 많은 중복은 엄청난 비용을 요구하게 된다

중복 코드 수정하기

통화 요금에 세금을 계산하는 로직을 추가해보자, 그러나 요금을 계산하는 로직은 Phone과 NightlyDiscountPhone 양쪽 모두에 구현돼 있기 때문에 두 클래스를 모두 수정해야 한다
Phone.java

public class Phone {
    ...
    private double taxRate;

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }

        return result.plus(result.times(taxRate));
    }
}

NightlyDiscountPhone.java

public class NightlyDiscountPhone {
    ...
    private double taxRate;

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                result = result.plus(nightlyAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
            result = result.plus(regularAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }

        return result.minus(result.times(taxRate));
    }
}

문제는, 중복 코드 수정시 모든 중복 코드를 같이 바꿔줘야 한다는 것이다. 만약, Phone만 수정한채 배포가 된다면 심야요금제의 세금이 포함되지 않고 계산될 것이다.

타입 코드 사용하기

두 클래스 사이의 중복코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
다만, 타입 코드를 사용하게 되면 높은 결합도 문제에 직면하게 된다

Phone.java

public class Phone {
    private static final int LATE_NIGHT_HOUR = 22;
    enum PhoneType { REGULAR, NIGHTLY }

    private PhoneType phoneType;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds; 
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration duration) {
        this(PhoneType.REGULAR, amount, Money.ZERO, Money.ZERO, seconds);
    }

    public Phone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this(PhoneType.NIGHTLY, Money.ZERO, nightlyAmount , regularAmount, seconds);
    }

    public Phone(PhoneType type, Money amount, nightlyAmount, Money regularAmount, Duration seconds) {
        this.type = type;
        this.amount = amount;
        this.regularAmount = regularAmount;
        this.nightlyAmount = nightlyAmount;
        this.seconds = seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            if (type == PhoneType.REGULAR) {
                result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                    result = result.plus(nightlyAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
                    } else {
                        result = result.plus(regularAmount(call.getDuration().getSeconds() / seconds.getSeconds()));
                    }
                }
            }
        }

        return result
    }
}

상속을 이용해서 중복 코드 제거하기

객체지향은 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다. 그것은 바로 상속이다.

public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration duration) {
        super(reguarAmount, seconds);
        this.nightlyAmount = nightlyAmount;
    }

    public Money calculateFee() {
        Money result = super.calculateFee();

        Money nightlyFee = Money.ZERO;
        for (Call call : calls) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                nightlyFee = nightlyFee.plus(getAmount().minus(nightlyAmount).times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }

        return result.minus(nightlyFee);
    }
}

super 참조를 통해 부모 클래스의 메서드를 호출한다. 10시 이전엔 Phone 요금제를 통해서 계산하고, 10시 이후 심야타임은 NightlyDiscountPhone을 통해 요금을 계산한다

강하게 결합된 Phone과 NightlyDiscountPhone

다만, 상속은 부모 클래스와 자식 클래스 사이의 강결합을 발생시킨다. calcaluateFee 메서드를 확인해보면 super를 참조해 가격을 계산하고 차감하는 방식이다. 여기에서 세금을 부과하는 요구사항이 추가 된다면 어떻게 될까?

자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. Super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거해야 한다.


02. 취약한 기반 클래스 문제

부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 부른다

불필요한 인터페이스 상속 문제

자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Properties와 java.util.Stack이다. 두 클래스의 공통점은 부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반 될 수 있다는 것이다.

Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");

stack.add(0, "4th");
assertEquals("4th", stack.pop()); // 에러!

Pop 메서드의 반환값은 “3rd”이기 때문에 에러가 난다. 이유는 vector의 add 메서드를 이용해 스택 맨앞에 “4th”를 추가했고, Stack의 pop은 가장 위에 있는 “3rd”를 꺼냈기 때문이다.
Stack이 규칙을 무너뜨릴 수 있는 여지가 있는 Vector의 퍼블릭 인터페이스까지 함께 상속을 받았다.
상속을 사용할 경우 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨드릴 수 있는 것도 숙지하고 있어야 한다

메서드 오버라이딩의 오작용 문제

이펙티브 자바의 나오는 유명한 예이다.

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolen add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

InstrumentedHashSet<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("java", "Ruby", "Scala"));

대부분의 사람들은 addCount의 값이 3이 될 것이라 생각하지만, 실제 값은 6이다. 그 이유는 부모 클래스인 HashSet의 addAll 메서드 안에서 add 메서드를 호출하기 때문이다.

자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.


03. Phone 다시 살펴보기

추상화에 의존하자

NightlyDiscountPhone의 가장 큰 문제점은 Phone에 강하게 결합돼 있기 때문에 Phone이 변경될 경우 함께 변경될 가능성이 높다는 것이다. 이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다

차이를 메서드로 추출하라

중복 코드 안에서 차이점을 별도의 메서드로 추출해라

먼저, Phone과 NightlyDiscountPhone의 중복과 차이점을 분리한다

중복 코드를 부모 클래스로 올려라

위의 코드에서 중복된 메서드는 공통화로 빼고, 다른 부분은 추상메서드로 구현해보자

public abstract class Phone {
    private List<Call> calls = new ArrayList<>();
    public Money calculateFee() {
        Money result = Money.ZERO;
        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }
    abstract protected Money calculateCallFee(Call call);
}

Phone.java

public class Phone extends RegularPhone {
    private Money amount;
    private Duration seconds;

    public RegularPhone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

NightlyDiscountPhone.java

public class NightlyDiscountPhone extends AbstractPhone {
    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        } else {
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
}

자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있었다. 여기서 우리는 추상화가 핵심이라는 것을 알 수 있다

여기서 세금을 추가하게 되면 자식 변수를 추가해야 하는데 이에 따라 자식 클래스의 생성자 로직도 변경이 불가피하다. 다만, 중요한 것은 인스턴스 변수의 추가에 따른 생성자의 수정보다 로직에 대한 변경으로 수정을 최소화 하는 것이 중요하다.

참조

  • 조영호님의 오브젝트 10장 "상속과 코드 재사용"

'Books > Object' 카테고리의 다른 글

[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
[Object] 객체 분해  (0) 2021.05.29
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

유연한 설계 (chapter9)

유연한 설계를 위한 다양한 방법을 알아보자. 여기서는 다음과 같이 알아볼 예정이다.

  • 개방-폐쇄 원칙
  • 생성 사용 분리
  • 의존성 주입
  • 의존성 역전 원칙

개방-폐쇄 원칙

로버트 마틴
“소프트웨어 개채(클래스, 모듈, 함수 등등)는 확장에 열려 있어야 하고,
수정에 대해서는 닫혀 있어야 한다”

키워드는 확장수정이다. 이 키워드를 애플리케이션 관점에서 바라보자

  • 확장에 대해 열려 있다
    • 요구사항이 변경될 때, 변경에 맞게 새로운 동작을 추가해 기능을 확장 할 수 있다
  • 수정에 닫혀 있다
    • 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 추가/변경 할 수 있다

이렇듯, 개방-폐쇄 원칙은 기존의 코드를 수정하지 않고 애플리케이션의 기능을 확장할 수 있는 설계라 할 수 있다.
개방-폐쇄에 대해 좀더 알고 싶다면 uncle bob의 블로그를 참조해보자
Clean Coder Blog

컴파일 의존성을 고정 시키고 런타임 의존성을 변경하라

개방-폐쇄 원칙은 사실 런타임,컴파일타임 의존성에 관한 이야기이다

  • 런타임 의존성 : 실행시 협락에 참여하는 객체들 사이의 관계이다
  • 컴파일타임 의존성 : 코드에서 드러나는 클래스들 사이의 관계이다

이제 의존성을 위에서 언급한 확장과 수정에 대해서 빗대어 알아보자

  • 기능 추가 : Amount, Percent 이외에 Overlapped, None 등의 정책이 추가 가능하다
  • 수정 : 해당 정책 클래스 내에서만 이루어진다

컴파일타임 의존성을 유지지하며 런타임 의존성을 변경할 경우, 코드의 유연한 설계가 가능해진다

추상화가 핵심이다

개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다

추상화란 핵심적인 부분만 남기고 불필요한 부분을 생략하는 기법이다.
추상화 과정을 거치면 문맥이 바뀌더라도 변하지 않는 부분만 남게 되고,
문맥에 따라 변하는 부분은 생략된다.

아래 코드를 살펴보자

public abstract class DiscountPolicy {
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition each: conditions) {
            if (each.isSatisfiedBy(screening) {
                return getDiscountAmount(screening);
            }
        }
    }
    abstract protected Money getDiscountAmount(Screening screening);
}

문맥이 바뀌더라도 할인여부를 판단하는 로직은 바뀌지 않는다. 변하지 않는 부분을 고정하고 변하는 부분을 생략하는 추상화 매커니즘이 개방-폐쇄 원칙의 기반이 된다


생성 사용 분리

Movie가 오직 DiscountPolicy라는 추상화에만 의존하기 위해서는 Movie 내부에서 구체 클래스에 의존해서는 안된다. 다음 코드를 살펴보자

public class Movie {
    ...
    private DiscountPolicy discountPolicy;
    public Movie(...) {
        ...
        this.discountPolicy = new AmountDiscountPolicy(...);
    }
}

Movie 내부 생성자에서 AmountDiscountPolicy를 직접 생성하기 때문에 다른 할인 정책을 적용하는 것이 어렵다. 이러한 코드는 확장이나 수정에 유연하게 대처하는 것이 어렵기 때문에 위에서 알아본 개방-폐쇄 원칙을 위반한다

유연하고 재사용 가능한 코드를 사용하려면 생성과 사용을 분리 해야 한다.

public class Client {
    public Money getAvatarFree() {
        Movie avatar = new Movie(..., new AmountDiscountPolicy(...));
        return avatar.getFee();
    }
}

위와 같이 컨텍스트에 대한 결정권을 Client(Movie외부)로 옮김으로써 유연한 설계가 가능해졌다

Factory 추가하기

생성 책임을 Movie -> Client로 옮김으로써 Movie의 특정 컨텍스트에 묶이는 것을 해결하였다. 하지만, Movie를 사용하는 Client에서도 특정 컨텍스트에 묶이질 않길 바란다면 어떻게 해결해야 할까?

아래의 코드를 살펴보자

public class Factory {
    public Movie createAvatarMovie() {
        return new Movie(..., new AmountDiscountPolicy(...));
    }
}

public class Client {
    private Factory factory;
    public Client(Factory factory) {
        this.factory = factory;
    }
    public Money getAvatarFree() {
        Movie avatar =     factory.createAvatarMovie();
        return avatar.getFee();
    }
}

이 문제는 Movie에서 해결한 방법과 마찬가지로 결정권자를 옮김으로 해결할 수 있다
이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 Factory라고 한다

순수한 가공물에게 책임 할당하기

앞서 책임을 할당할 때, 정보 전문가에게 할당하라는 원칙을 배웠습니다. 하지만, 방금 언급한 Factory는 Information Expert와는 거리가 먼 모델이라는 것을 알수 있습니다.

크레이그 라만
“시스템을 객체로 분해하는 데는 크게 두가지 방식이 존재한다.
첫번째는 표현적 분해이고, 다른 하나는 행위적 분해이다”

이처럼 표현적 분해는 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것을 말합니다. 하지만, 모든 책임을 도메인 객체에게 할당할 수 없는 경우가 생깁니다. 이럴 경우에는 Factory 처럼 가상의 기계적 개념들이 필요한데, 이처럼 창조되는 도메인과 무관한 인공적인 객체를 PURE FABRICATION(순수한 가공물) 이라고 부릅니다. 이것을 행위적 분해라고 가르킵니다.

정리하자면, 일차적으로 도메인 모델에 책임을 할당하는 것을 고려하고, 그것이 여의치 않다면 대안으로 삼을 수 있는 것이 FACTORY와 같은 PURE FABRICATION 입니다.


의존성 주입

public class Movie {
    ...
    private DiscountPolicy discountPolicy;
    public Movie(...) {
    }
}

위의 코드를 살펴보자. 생성과 사용을 분리하며 Movie에는 오로지 인스턴스를 사용하는 책임만 남게 되었다. 이처럼 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입 이라고 부른다.

의존성을 해결하기 위해 다음과 같은 3가지 메카니즘을 이용한다. 8장에서도 나온 내용이기에 간략히 정리한다.

  • 생성자 주입
    Movie avatar = new Movie(..., new AmountDiscountPolicy(...));
  • setter 주입
    avatar.setDiscountPolicy(new AmountDiscountPolicy(...));
  • 메서드 주입
    avatar.calcalateDiscountAmount(screening, new AmountDiscountPolicy(...));

가장 좋은 방법은 생성자와 수정자를 같이 이용하는 것이다. 시스템의 안전성을 보장해주고 필요에 따라 의존성을 변경할 수 있기 때문에 유연성을 향상시킬 수 있다

숨겨진 의존성은 나쁘다

의존성 주입 이외에도 의존성을 해결할 수 있는 다양한 방법이 존재한다. 그 중에서 가장 널리 사용되는 대표적인 방법은 SERVICE LOCATOR 패턴이다

외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청한다

public class Movie {
    private DiscountPolicy discountPolicy;
    public Movie(...) {
        this.discountPolicy = ServiceLocator.discountPolicy();
    }
}

public class ServiceLocator {
    private DiscountPolicy discountPolicyl
    private static ServiceLocator soleInstance = new ServiceLocator();
    public static void provide(DiscountPolicy discountPolicy) {
        soleInstance.discountPolicy = discountPolicy;
    }
}

이제 SERVICE LOCATOR 패턴을 이용해 인스턴스를 생성해보자

ServiceLocator.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie(...);
ServiceLocator.provide(new PercentDiscountPolicy(...));
Movie avatar1 = new Movie(...);

위와 같이 쉽게 의존성 문제를 해결할 수 있다. 다만 의존성이 숨겨져 있다는 치명적인 단점이 있다. 아래의 코드를 살펴보자

Movie avatar = new Movie(...);

위의 코드는 NPE 문제가 발생할 것이다. 그 이유는 ServiceLocator로 DiscountPolicy를 주입시켜주지 않았기 때문이다.

결론적으로, SERVICE LOCATOR 패턴을 사용하지 말라는 것은 아니다. 다만, 숨겨진 의존성보다 명시적인 의존성을 먼저 고려하는 것이 시스템의 안정을 위해 좋다는 것이다.


의존성 역전 원칙

추상화와 의존성 역전

구체클래스에 의존하는 Movie 클래스를 살펴보자. 결합도가 높아지고 재사용성이 낮아진 것을 확인할 수 있다.

public class Movie {
    private AmountDiscountPolicy discountPolicy;
}

객체 사이의 협력이 존재할 때, 그 협력의 본질을 담고 있는 것은 상위 수준의 정책이다.
여기서의 본질은 영화의 가격을 계산하는 것이다. 할인 금액을 계산하는 것은 협력의 본질이 아니다.

그러나 상위 수준의 클래스가 하위 수준의 클래스에 의존하면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받게 된다. 즉, 하위 수준의 AmountDiscountPolicy를 PercentCountPolicy로 변경해서 상위 수준의 Movie가 영향을 받아서는 안된다.

이 경우 해결할 수 있는 방법이 위에서 알아본 추상화 기법이다.
위의 언급한 내용들을 정리를 해보면

  1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안된다. 둘다 모두 추상화에 의존해야 한다
  2. 추상화는 구체적인 사항에 의존해서는 안된다. 구체적인 사항은 추상화에 의존해야 한다.
    이를 의존성 역전 원칙 이라 한다

결론

우리는 런타임의존성, 캄파일타임 의존성을 배우며 유연하고 재사용 가능한 설계에 대해 알아보았다. 하지만 이러한 설계가 항상 옳고 좋은 것은 아니다.
설계의 미덕은 단순함과 명확함이지만 이러한 설계는 복잡성을 높이기 때문이다.

따러서, 필자(조영호님)는 아래와 같은 융통성을 제시한다

  • 미래에 일어나지 않을 변경에 대비하여 꼭 복잡한 설계를 할 필요는 없다
  • 단순하고 명확한 해법이 만족스럽다면 유연성을 제거해도 된다
  • 의존성이 생기는 이유는 객체의 협력과 책임이 있기 때문이다
  • 객체 생성에 초점을 맞추기 보다 객체의 역할과 책임의 균형을 맞추는 것에 초점을 맞추자

참조

  • object 코드로 이해하는 객체지향 설계 (chapter9 유연한 설계) - 조영호님

'Books > Object' 카테고리의 다른 글

[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
[Object] 객체 분해  (0) 2021.05.29
[Object] 메시지와 인터페이스  (0) 2021.05.25
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

의존성 관리하기 (chapter8)

잘 설계된 객체지향 애플리케이션 이란?

  • 작고 응집도 높은 객체들로 구성
  • 책임의 초점이 명확하고 한가지 일을 잘 수행
  • 이러한 객체들이 모여 협력을 함

협력을 위해서 의존성이 필요.
하지만 과도한 협력은 의존성 문제로 애플리케이션의 변경을 힘들게 함
이러한 관점에서 객체지향 설계란 의존성을 관리하고 의존성을 정리하는 기술이라고 할 수 있다


의존성 이해하기

변경과 의존성

객체는 협력을 위해 다른 객체를 필요로 하고 두 객체 사이에 의존성이 생기게 된다
의존성은 실행시점과 구현시점에 따라 서로 다른 의미를 갖는다

  • 실행시점
    • 의존하는 객체가 정상적으로 동작하기 위해서는 실행시 의존 객체가 존재해야함
  • 구현 시점
    • 의존 객체가 변경될 경우 의존하는 객체도 함께 변경
public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    ...
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
        startTime.compareTo(screening,getStartTime().toLocalTime()) <= 0 &&
        endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

위의 코드를 해당 시점에 경우로 나누어 살펴보자

  • 실행시점
    • isSatisfiedBy() 실행시 Screening 인스턴스가 존재해야 한다. 그렇지 않다면 해당 내부의 로직 수행중에 에러가 날 것이다
  • 구현시점
    • isSatisfiedBy() 내부는 DayOfWeek, Screening, LocalTime, DiscountCondition에 영향을 받는다.
    • 따라서 해당 클래스나 변수가 변경이 생기게 된다면 PeriodCondition에게 당연히 영향을 끼친다

따라서, 두 요소 사이의 의존성은 의존되는 요소가 변경 될때 의존하는 요소도 함께 변경될 수 있음을 의미한다

의존성 전이

PeriodCondition -> Screening (직접 의존)
Screening -> Movie (직접 의존)

PeriodCondition ---> Movie (간접 의존)

Screeing의 내부 코드는 Movie, LocalDateTime, Customer에 의존하고 있다.
위의 그림을 잠깐 살펴보자.

[직접의존] - PeriodCondition은 Screening에, Screeing은 Movie에 의존하고 있다.
이렇듯 객체가 다른 객체에 의존하고 있을 경우 ‘직접 의존한다’라고 한다

[간접의존] - PeriodCondition은 Movie에 직접 의존하지 않고 있다.
코드상에 드러나지는 않지만 PeriodCondition->Screening->Moive로 이어지는 연결고리가 잠재적으로 영향을 미칠 수 있다.
이렇듯, 직접 연관되지 않지만 연관된 객체에 의존된 관계가 맺어질 경우 ‘간접 의존한다’ 라고 한다

런타임 의존성과 컴파일타임 의존성

의존성과 관련해서 또 다뤄야 할 부분은 런타임 의존성과 컴파일 의존성이다

  • 런타임 의존성
    • 애플리케이션이 실행되는 시점에 의존성
  • 컴파일 의존성
    • 컴파일 시점에 작성된 코드에 따라 문맥에 의존

아래의 예제를 살펴보자

public class Movie {
    ...
    private DiscountPolicy discountPolicy;

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

코드상에서 Movie는 DiscountPolicy에 의존하고 있다.
어느 부분에도 AmountDiscountPolicy, PercentDiscountPolicy이 명시되어 있지 않다.
이렇듯, 코드상에는 구체화된 할인정책이 명시되어 있지 않지만 런타임 의존성을 이용해서 의존 관계를 가질 수 있다.

클래스가 어떠한 인스턴스 타입과 협력하는지 코드상에서 알 수 없게 되면
코드의 재사용이 가능해지고 다양한 협력관계를 가질 수 있는 장점이 있다.

이렇듯 런타임 의존성과 컴파일 의존성은 서로 다를 수 있다. 이러한 다름 때문에 프로그래머는 유연하고 재사용 가능한 코드를 설계할 수 가 있다

컨텍스트 독립성

클래스가 구체적인 클래스를 알게 될수록, 그 클래스가 사용하는 문맥에 강하게 결합된다
따라서, 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄진다면
다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다

결국 의존하는 관계에서는 실행될 컨텍스트에 대한 구체적 정보를 최대한 적게 알도록 설계해야 한다

의존성 해결하기

결국 컴파일 타임 의존성이 구체적인 런타임 의존성으로 대체되어야 재사용 가능한 코드가 작성될 수 있다.
이처럼 컴파일타임 의존성을 실행 컨텍스에 맞는 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다

의존성을 해결하기 위해서는 다음과 같은 세 가지 방법을 사용한다

  1. 객체를 생성하는 시점에 생성자를 통해 의존성 해결
    Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000), new AmountDiscountPolicy(...));
    생성자를 통해 주입하여 준다
  1. 객체를 생성후 setter 메서드를 통해 의존성 해결
    Movie avatar = new Movie(...);
    avatar.calculateFee(...); // 예외발생
    avatar.setDiscountPolicy(new AmountDiscountPolicy(...));
  • set method를 구현해주어야 한다
  • set을 하기 전까지 객체가 불안정하여 NullPointerException이 발생할 수 있다
  1. 메서드 실행 시 인자를 이용해 의존성 해결
    public class Movie {
     public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
         return fee.minus(discountPolicy.calculateDiscountAmount(screening));
     }
    }

가장 좋은 방법은 생성자와 수정자를 같이 이용하는 것이다. 시스템의 안전성을 보장해주고 필요에 따라 의존성을 변경할 수 있기 때문에 유연성을 향상시킬 수 있다


유연한 설계

설계를 유연하고 재사용 가능하게 만들기로 결정했다면 의존성을 관리하는데 유용한 몇가지 원칙을 익힐 필요가 있다. 먼저 의존성과 결합도의 관계를 살표보자

의존성과 결합도

객체지향 패러다임의 근간은 협력이다.

객체들은 협력을 위해 서로의 존재와 책임을 알아야 한다. 이러한 지식들이 객체 사이의 의존성을 낳는다.
의존성은 객체들의 협력을 가능케 하는 매개체이고 바람직한 것이다. 다만, 과하면 문제가 될 수 있다.

다음 코드를 살펴보자

Movie는 PercentDiscountPolicy에 의존한다. 의존한다는 사실 자체는 바람직한 관계이다.
다만, Movie가 PercentDiscountPolicy라는 구체적 클래스에 의존하기 때문에 다른 할인 정책을 문맥에서 사용하기 힘들어졌다.

그렇다면 바람직한 의존성이란 무엇인가? 바람직한 의존성은 재사용과 관련이 있다.
의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다.

이러한 의존성을 세련된 단어로 표현한 것이 결합도이다.
두 요소가 의존성이 바람직할때를 느스한 결합도, 약한 결합도라 한다.
반대로, 두 요소 사이의 의존성이 바람직하지 못할때 단단한 결합도 또는 강한 결합도라고 표현한다

지식이 결합을 낳는다

더 구체화된 지식일 수록 강한 결합으로 연결된다.
Movie -> PercentDiscountPolicy (강결합)
Movie -> DiscountPolicy (느슨한 결합)

더 많이 알수록 더 많이 결합되는 구조이다. 더 많이 알고 있다는 것은 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미하기 때문이다.
결합도를 느슨하기 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에 최대한 감추는 것이 중요하다.

이러한 목적을 달성하기 위해 가장 효과적인 방법이 추상화이다

추상화에 의존하라

추상화란 어특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다.
추상화를 이용하면 불필요한 정보를 감추고 지식의 양을 줄일 수 있다

PercentDiscountPolicy보다 DiscountPolicy가 지식의 양이 더 적다.
Movie와 DiscountPolicy 사이의 결합도가 더 느스한 이유는 Movie가 구체적인 대상이 아닌 추상화에 의존하기 때문이다

  • 구체 클래스
  • 추상 클래스
  • 인터페이스

클래스는 3개의 타입에 대하여 의존할 수 있다. 당연히 인터페이스와 의존 관계를 맺는 것이 느스한 결합을 만들어 준다

명시적인 의존성

아래의 코드를 살펴보며 의존성에 대한 문제를 찾아보자

public class Movie {
    ...
    private DiscountPolicy discountPolicy;

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

New AmountDiscountPolicy가 결합도를 높이고 있다.

public class Movie {
    ...
    private DiscountPolicy discountPolicy;

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

명시적인 부분을 제거하여 결합도를 낮추어 주자. 이제 객체를 생성할 때 생성자의 인자로 DiscountPolicy의 어떠한 자식 클래스도 전달할 수 있다.
따라서, 런타임에 해당 인스턴스를 선택적으로 전달할 수 있게 되었다.

이처럼 의존하는 사실을 Public 인터페이스에 드러내는 것을 명시적인 의존성이라 한다.
반면, 내부에 AmountDiscountPolicy를 직접 생성하는 방식을 의존하는 사실은 감춘다고 하여 숨겨진 의존성이라 한다.

숨겨진 의존성의 경우 직접 코드 내부를 살펴보아야 하고, 인스턴스를 생성하는 코드를 훓어 파악해야 하므로 버그의 가능성도 내포하고 있다.
따라서, 우리는 재사용성을 높이고 안정성을 높이는 명시적인 의존성을 선택해야 한다.

new는 해롭다

클래스는 인스턴스를 생성할 수 있게 new 연산자를 제공한다. 하지만 잘못 사용한다면 두 객체 사이의 결합도가 높아질 수 있다.

  1. New 연산자로 구체 클래스 이름을 직접 기술한다.
  2. 구체 클래스 뿐만 아니라, 어떠한 인자를 이용해 클래스를 생성할지 인자 정보도 알고 있어야 한다
public class Movie {
    ...
    private DiscountPolicy discountPolicy;

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

두개를 비교해보면 구체 클래스를 생성하는 것이 많은 고통을 유반한다는 것을 볼 수 있다

가끔은 생성해도 무방하다

new가 꼭 나쁜것만은 아니다. 생성되는 비율이 다를 경우 아래와 같이 여러 생성자를 이용하여 구현할 수 있다.
해당 생성자는 아래의 생성자를 호출하는 방식이다. 이런 방식으로 구현함으로써 유연성도 가져갈 수있따.

### 컨텍스트 확장하기

표준 클래스에 대한 의존은 해롭지 않다

의존성이 문제가 되었던 것은 변경 가능성으로 인해 유발되는 파생효과들이다.
다만, 표준 클래스처럼 변화가 거의 없는 것에 해당한다면 크게 문제될 것이 없다.


참조

  • 오브젝트, 코드로 이해하는 객체지향 설계, chapter8 의존성 관리하기 [조영호]

'Books > Object' 카테고리의 다른 글

[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
[Object] 객체 분해  (0) 2021.05.29
[Object] 메시지와 인터페이스  (0) 2021.05.25
[Object] 책임 할당하기  (0) 2021.05.16
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

객체분해 (chapter7)


사람의 기억은 '단기'/'장기' 기억이 있음
일반적으로 단기기억 -> 장기기억으로 옮겨짐

조지밀러의 매직넘버 7
“단기 기억 공간에 일반적으로 5 ~ 9개의 아이템을 집어 넣을 수 있다”

저자가 말하고 싶은 것은?

“핵심은 실제로 문제를 해결하기 위해 사용하는 저장소는
장기 기억 저장소가 아닌 단기 기억 저장소이다”

  • 하지만 단기기억 공간에는 공간의 제약이 있다
    • 불필요한 정보를 제거하고 현재의 문제 해결에 대한 핵심 정보만 남긴다 (추상화)
    • 추상화의 일반적인 방법은 문제의 크기를 줄이는 것
    • 이처럼 큰 문제를 작은 문제로 나누는 것을 분해 라 한다

추상화와 분해는 인류가 창조한 가장 복잡한 문제 해결 방법
그 분야는 바로 소프트웨어이다

아래에서 객체 분해에 대해 알아보자

  • 프로시저 추상화와 데이터 추상화
  • 프로시저 추상화와 기능 분해
  • 모듈
  • 데이터 추상화와 추상 데이터 타입
  • 클래스

프로시저 추상화와 데이터 추상화

어셈블리어? 숫자로 된 기계어 범벅 …

  • 고수준 언어, 기계적 사고를 강요하는 낮은 수준의 명령어들을 탈피를 시도
  • 인간의 눈높이에서 기계 독릭접이고 의미 있는 추상화를 제공하려는 시도

현대적인 프로그래밍 언어를 특징 짓는 두 가지 추상화 메카니즘

  1. 프로시저 추상화
  2. 데이터 추상화
    소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작
    시스템을 분해 하려면 프로시저/데이터 추상화에서 중심으로 할 것을 정해야 한다
  • 프로시저 추상화

    • 소프트웨어가 무엇을 해야 하는지 추상화
    • 프로시저 중심 시스템 분해 -> 기능분해
  • 데이터 추상화

    • 소프트웨어가 무엇을 알아야 하는지 추상화
    • 데이터 중심 시스템 분해 -> 두가지 방법이 존재
      • 데이터를 중심으로 타입 추상화 -> 추상 데이터 타입
      • 데이터를 중심으로 프로시저를 추상화 -> 객체 지향

프로시저 추상화와 기능 분해

  • 기능 vs 데이터

    • 기능이 오랜 시간 시스템 분해의 기준으로 사용됨
    • 알고리즘 분해, 기능 분해 라고도 함
    • 기능 분해의 관점에서 추상화의 단위는 프로시저
  • 프로시저

    • 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아 놓음
    • 로직을 추상화하고 중복을 방지하는 추상화 방법
  • 전통적인 기능 분해 방법

    • 하향식 접근법
      • 시스템을 구성하는 가장 최상위 기능을 정의
      • 이 최상위 기능을 작은 단계의 하위 기능으로 분해해 나감
      • 분해의 마지막 하위 기능이 프로그래밍 언어로 구현
  • 급여 관리 시스템

급여 계산을 풀어서 하향식으로 구현하였다. 보기에는 이상적으로 보인다

하향식 기능 분해의 문제점

  1. 하나의 메인 함수라는 비현실적인 아이디어
    • 대부분의 시스템에서는 하나의 메인 기능이란 개념은 존재하지 않음
    • 대부분의 경우 추가되는 기능은 최초에 배포된 메임 함수의 일부가 아님
    • 새로운 기능 추가시, 매번 메인 함수를 수정
      • 큰 틀을 바꾸는 것이기 때문에 급격한 변경 가능성이 생김
  1. 비즈니스 로직과 사용자 인터페이스의 결합
    • 설계 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요 ..
      • (사용자로부터 소득 세율 입력 받고, 급여를 계산하여 출력)
    • 문제는 사용자 인터페이스 변경은 자주 바뀌는 부분
    • 사용자 인터페이스가 바뀌지만 이 부분이 비즈니스 로직에도 영향을 주는 기이한 설계
  1. 성급하게 결정된 실행 순서

    • ‘무엇을 해야 하는지’가 아니라 ’어떻게 동작 해야하는지’에 초점을 맞춘다
    • 절차적인 순서로 진행 되기에 시간의 제약이 생긴다
    • 실행 순서나 조건, 빈복적인 제어 구조를 미리 결정해야 분해를 진행할 수 있다
      • 중앙집중 제어 스타일로 귀속
      • 모든 제어의 흐름 결정이 상위 함수에서 이뤄지게 된다
      • 따라서, 기능의 변경이 일어나게 되면 상위 함수까지 모두 수정이 발생
  2. 데이터 변경으로 인한 파급효과

    • 어떤 함수가 어떤 데이터를 사용하는지 추적이 어렵다
    • 데이터가 어떤 함수에 의존하는지 파악하기 위해서는 모든 함수를 열어 화인해야 한다
  • 하향식 접근법은 하나의 알고리즘 구현이나 배치 처리에는 적합

모듈

하향식 접근법은 위에 언급한 것처럼 많은 고통을 수반한다.
본질적인 문제를 해결하기 위해 접근을 통제하는 방법을 이용해야 한다

정보 은닉과 모듈

* 퍼블릭 인터페이스를 제공하여 접근을 통제한다 -> 정보 은닉
* 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리
* 모듈은 변경 가능성 있는 비밀은 내부로 감추고 쉽게 변경되지 않을 인터페이스를 외부에 제공
  • 모듈
    • 서브 프로그램 이라기보다는 책임의 할당이다
    • 모듈이 감추어야 할 것
      • 복잡성
      • 변경 가능성


이전 전역변수 였던 $employees, $basePays, $hourlys 등이 모듈 내부에 선언되었다

모듈의 장점과 한계

  1. 모듈 내부의 변수가 변경 되더라도 모듈 내부에만 영향을 미친다
  2. 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다
  3. 모듈화를 진행하여 네임스페이스 오염을 방지한다
    1. 내부변수, 함수를 이용하기 때문

모듈은 프로시저 추상화 보다는 높은 추상화 개념을 제공한다.
다만, 인스턴스의 개념을 제공하지 않기 때문에 모듈은 단지 모든 직원 정보를 가지고 있는 모듈일 뿐이다

(여기서 말하는 모듈은 우리가 아는 모듈이 아닌 프로시저의 추상화를 데이터와 함수의 단위로 묶은것 같다 ..)


데이터 추상화와 추상 데이터 타입

추상 데이터 타입

  • 타입
    • 변수에 저장할 수 있는 내용물의 종류와변수에 적용될 수 있는 연산의 가짓수
    • 저장된 값에 대해 수행될 수 있는 연산의 집합을 결정

리스코프
“추상 데이터 타입
추상 객체의 클래스를 정의한 것으로
추상 객체에 사용할 수 있는 오퍼레이션을 이용해 규정된다”

추상 데이터 타입을 정의하려면

  • 타입 정의를 선언할 수 있어야 한다
  • 타입의 인스턴스를 다루기 위해 오퍼레이션 집합을 정의할 수 있어야 한다
  • 제공된 오퍼레이션을 통해서만 데이터를 조작할 수 있어야 한다 (퍼블릭 인터페이스)
  • 타입에 대해 여러개의 인스턴스를 생성할 수 있어야 한다

클래스

클래스는 추상 데이터 타입인가?

  • 데이터 추상화를 기반으로 시스템을 분해하는 공통점이 있다
  • 명확히는 조금 다르다
    • 클래스는 상속과 다형성을 지원한다
      상속과 다형성을 지원 -> 객체지향 프로그래밍
      지원 X -> 객체기반 프로그래밍 이라 부른다

  • 둘의 차이는 무엇일까?
    • 추상 데이터 타입은 오퍼레이션에 종속된다
    • 클래스 객체는 이러한 차이를 다형성으로 해결한다
    • 이처럼 기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙이라 한다

참조

  • 오브젝트, 코드로 이해하는 객체지향 설계 - 7장. 객체분해 (조영호님 저)

'Books > Object' 카테고리의 다른 글

[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
[Object] 객체 분해  (0) 2021.05.29
[Object] 메시지와 인터페이스  (0) 2021.05.25
[Object] 책임 할당하기  (0) 2021.05.16
[Object] 설계 품질과 트레이드오프  (0) 2021.05.08
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

메시지와 인터페이스 (chapter6)


  • 객체지향적 프로그램 을지향 ->
    • 클래스가 아니라 객체에 초점 ->
      • 구체적으로 객체의 책임에 초점

중요한 것은 책임이 객체가 수신할 메시지의 기반이 된다는 것


협력과 메시지

  • 객체는 서로 협력의 관계에 있고 메세지를 주고 받는다!! (Chapter3)
  • 메시지는 객체 사이의 매개체이다
    • 즉 유일한 접근 방법은 메세지

클라이언트 - 서버 모델

  • 두 객체 사이의 협력 관계를 설명하기 위한 전통적인 방법 클라이언트-서버모델
  • 협력안에서 메세지 전송쪽을 클라이언트라 지칭
  • 협력안에서 메세지 수신쪽을 서버라 지칭
    협력은 클라이언트가 서버의 서비스를 요청하는 단방향 상호작용

  • Screening은 메시지를 전송하는 클라이언트, Movie는 메시지를 수신하는 서버

  • Movie은 메시지를 전송하는 클라이언트, DiscountPolicy는 메시지를 수신하는 서버

  • 이처럼 객체는 클라이언트와 서버 모두의 역할을 수행할 수 있다

메세지 구조

메시지와 메서드

  • 메시지 전송자는 어떤 메시지를 전송할지, 메시지 수신자는 어떤 메시지를 수신할지만 알면 된다. (즉, 어떠한 클래스가 전송하는지, 어떠한 클래스가 수신하는지 몰라도 된다)

condition의 경우 PeriodCondition, SequenceCondition 인스턴스가 올 수 있다. 컴파일시점과 실행시점의 인스턴스가 다르기 때문에, 이러한 구조의 메커니즘은 두 객체 사이의 결합도를 낮춤으로써 유연하고 확장 가능한 코드를 작성할 수 있게 만든다.

용어 정리

  • 메시지
    • 객체간 협력을 위해 사용하는 의사소통 메커니즘
  • 오퍼레이션
    • 객체가 다른 객체에게 제공하는 추상적인 서비스
  • 메서드
    • 메시지에 응답하기 위해 실행되는 코드 블록. 즉, 메서드는 오퍼레이션의 구현이다
  • 퍼블릭 인터페이스
    • 객체가 협력에 참여하기 위해 외부에서 수힌할 수 있는 메시지의 묶음, 집합이다
  • 시그니처
    • 시그니처는 오퍼레이션의 이름과 파라미터를 합쳐 부르는 말이다

인터페이스와 설계 품질

인터페이스 설계 품질을 위해 다음과 같은 원칙을 고려해보자

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스

디미터 법칙

“낯선 자에게 말하지 말라” - [Larman]
“오직 인접한 이웃하고만 말하라” - [Metz]

  • 객체 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라
  • ‘오직 하나의 .(도트)를 이용하라’ 라고 요약 되기도 한다. 아래는 위반되는 코드이다.
    screening.getMovie().getDiscountCondition();

즉, 디미터 법칙은 객체가 객체 자신이 자율적 존재가 되어야 함을 강조한다

묻지 말고 시켜라

  • 객체의 상태에 관해 묻지 말고 원하는 것을 시켜라. 이런 스타일의 메시지 작성을 장려하는 원칙이다.
  • 메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메신지 수신자의 상태를 바꿔서는 안된다.
  • 이 원칙을 따르면, 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을수 있다.

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

  • 메서드를 명명하는 두가지 방법
    첫번째, 메서드가 작업을 어떻게 수행할지 나타내도록 이름을 지어라

다만 이런 스타일은 좋지 않다

public class PeriodCondition {
    public boolean isSatisfiedByPeriod(Screening screeing) {}
}

public class SequenceCondition {
    public boolean isSatisfiedBySequence(Screening screeing) {}
}
  • 메서드에 대해 제대로된 커뮤니케이션이 안된다. 두가지 모두 할인 조건을 판단하는 작업을 수행하지만, 두 메서드의 이름이 다르기 때문에 내부 구현을 잘 모른다면 두 메서드가 동일한 작업을 수행한다는 사실을 알기 어렵다
  • 더 큰 문제는 메서드 수준에서 캡슐화를 위반한다. 할인 여부를 판단하는 방법이 변경된다면 메서더의 이름 역시 바뀌어야 할 것이다.

두번째, ‘어떻게’가 아니라 ‘무엇’을 하는지를 드러내라

public class PeriodCondition implements DiscountCondition {
    public boolean isSatisfied(Screening screeing) {}
}

public class SequenceCondition implements DiscountCondition {
    public boolean isSatisfied(Screening screeing) {}
}
  • 동일한 목적을 수행한다는 것을 명시하기 위해 메서드 이름을 통일하였다
  • 다만, 동일한 계층의 타입으로 묶어 주어야 한다. 인터페이스를 이용하자

이처럼, 어떻게 하느냐가 아니라 무엇을 하느냐에 초점을 맞추어 보자. 이런식으로 메서드의 이름을 통일하면 의도를 드러낼 수 있고, 인터페이스를 이용해 유연한 설계가 가능해진다.


원칙의 함정

디미터법칙, 묻지 말고 시켜라 법칙 등의 법칙이 깔끔하게 유연한 설계를 도와주지만 절대적인 것은 아니다. 잊지 말아야 할 것은 설계는 트레이드오프의 산물이라는 점이다.

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

IntStream.of(1,2,3).filter(x -> x >10).distinct().count();

dot을 한줄에 여러개 사용하였다.
디미터 법칙은 결합도와 관련된 것이다. 결합도는 객체의 내부 구조가 외부로 노출되는 경우를 한정하기 때문에 디미터법칙을 위반하지 않는다.

public class PeriodCondition implements DiscountCondition {
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) && startTime.compareTo(screeing.getStartTime().toLocalTime()) <= 0 && endTime.compareTo(screeing.getStartTime().toLocalTime()) >= 0;
    }
}

해당 로직을 묻지도 말고 시켜라 원칙을 적용해, Screening으로 옮겨 보자

public class Screeing {
    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 객체가 할인 조건을 책임 지는게 맞는 것일까? 당연히 그렇지 않다. 객체의 응집도가 낮아지게 되었다.

소프트웨어는 트레이드오프의 산물이기에 설계에 있어서 절대적인 법칙은 존재하지 않는다.


명령-쿼리 분리 원칙

명령 쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 떄 참고할 지침을 제공한다.

  • 루틴

    • 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 것
    • 프로시저와 함수로 구분된다
    • 프로시저
      • 부수효과를 발생시키지만 값을 반환할 수 없다
    • 함수
      • 값을 반환할 수 있지만 부수효과를 발생시킬 수 없다
  • 명령

    • 객체의 상태를 수정하는 오퍼레이션
  • 쿼리

    • 객체와 관련된 정보를 반환하는 오퍼레이션

중요한 것은 어떤 오퍼레이션도 명령이거나 동시에 쿼리여서는 안된다. 따라서 명령과 쿼리를 분리하기 위해서 다음의 두가지 규칙을 준수해야 한다

  1. 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다
  2. 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다

아래의 예시를 살펴보자

public class Event {
    public boolean isSatisFied(RecurringSchedule schedule) {
        if (...) {
            reschedule(schedule);
            return false;
        }
        return true;
    }
}

boolean이라는 상태값을 반환하지만, 중간에 reschedule을 호출한다, 그리고 schedule을 바꾸어 버린다 … 이 코드를 다음과 같이 분리해보자

public class Event {
    public boolean isSatisFied(RecurringSchedule schedule) {
        if (...) {
            return false;
        }
        return true;
    }
}

if (!event.isSatisfied(schedule)) {
    event.reschedule(schedule);
}    

명령과 쿼리를 분리함으로써 내부적인 버그를 해결할 수 있다.


참조

오브젝트 - 코드로 이해하는 객체지향 설계, 6장 메시지와 인터페이스

'Books > Object' 카테고리의 다른 글

[Object] 의존성 관리하기  (0) 2021.06.10
[Object] 객체 분해  (0) 2021.05.29
[Object] 메시지와 인터페이스  (0) 2021.05.25
[Object] 책임 할당하기  (0) 2021.05.16
[Object] 설계 품질과 트레이드오프  (0) 2021.05.08
[Object] 역할, 책임, 협력  (0) 2021.05.03
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

책임 할당하기 (chapter5)


책임 주도 설계를 향해

  • 데이터보다 행동을 먼저 결정
  • 협력이라는 문맥 안에서 책임을 결정

데이터보다 행동을 먼저 결정하라
“객체지향에 갓 입문한 사람들이 가장 많이 저지르는 실수는
객체의 행동이 아닌 데이터에 초점을 맞추는 것”

  • 데이터 중심 설계
    • 객체가 포함해야 하는 데이터를 먼저 선정
    • 그 후, 데이터를 처리하는 데 필요한 행동을 구현
  • 책임 중심 설계
    • 객체가 수행할 책임을 먼저 정의
    • 그 후, 책임을 처리하는 데 필요한 데이터를 정의

설계 패러다임의 변경

'객체의 데이터 중심 설계' -> '책임 중심 설계'로 설계 방식을 바꿔야 한다

협력이라는 문맥 안에서 책임을 결정하라
“책임은 객체의 입장이 아니라,
객체가 참여하는 협력에 적합해야 한다”

  • 협력에 적합한 책임을 할당하기
    • 먼저, 수행할 메세지를 정의
    • 메시지를 수행할 객체를 선택
    • 즉, 객체가 메시지를 선택하는 게 아니라, 메시지가 객체를 선택하도록 설계

책임 할당을 위한 GRASP 패턴

크레이그 라만
“GRASP - General Responsibility Assignment Software Pattern”
일반적인 책임 할당을 위한 소프트웨어 패턴의 약자로
책임 할당시 지침으로 삼을 수 있는 원칙들의 집합

도메인 개념에서 출발

  • 설계를 시작하기 전 도메인에 대한 개략적인 모습을 그려보자
    • 도메인 개념들을 책임 할당의 대상으로 사용하면 코드의 투영하기 수월해진다

정보 전문가에게 책임을 할당

  • “정보전문가 패턴”
    • 책임을 수행하는데 필요한 정보를 가지고 있는 객체에게 할당하라
  • 메시지를 먼저 정의하고, 그 메시지를 수행할 객체를 선정하라
    • 그 정보를 가장 잘 아는 객체를 선정하고 책임을 할당하라

높은 응집도와 낮은 결합도 (chapter4)

  • 응집도 (Cohesion)
    • 모듈에 포함된 내부 요소들이 하나의 책임/ 목적을 위해 연결되어있는 연관된 정도
  • 결합도
    • 다른 모듈과의 의존성이 정도

창조자에게 객체 생성 책임을 할당

CREATOR 패턴
“객체 A를 생성해야 할때, 그 객체를 가장 잘 아는
객체 B에게 객체 생성 책임을 할당하라”

  • B가 A 객체를 포함하거나 참조
  • B가 A 객체를 기록
  • B가 A 객체를 긴밀하게 사용
  • B가 A 객체를 초기화하는데 필요한 데이터 정보를 포함 (B가 A의 정보 전문가)

구현을 통한 검증

  • 변경에 취약
public class DiscountCondition {

  public boolean isSatisfiedBy(Screening screening) {
     if (type == DiscountConditionType.PERIOD) {
        return isSatisfiedByPeriod(screening);
     }
     return isSatisfiedBySequence(screening);
  }

  private boolean isSatisfiedByPeriod(Screening screening) {
        ...
  }

  private boolean isSatisfiedBySequence(Screening screening) {
        ...
    }
}
  • 새로운 할인 조건 추가
  • 순번 조건 로직의 변경
  • 기간 조건 로직의 변경

    해당 클래스는 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다.
    응집도가 낮다는 것은 서로 연관성이 없는 기능이나 데이터가 뭉쳐 있다는 것을 의미한다


책임 주도 설계의 대안

클래스는 오직 하나의 작업만 수행하고,
하나의 변경 이유만 가지는 작고 명확하고
응집도 높은 메서드들로 구성되어야 한다.

몬스터 메서드

  • 어떤 일을 수행하는지 한눈에 파악하기 어렵다
  • 하나의 메서드 안에 너무 많은 작업을 처리한다
  • 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다
  • 로직의 일부만 재사용하는 것이 불가능 하다

변경과 유연성 - 개발자로서 변경에 대비하는 방법

  • 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계
  • 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 유연하게 작성

책임주도 설계 방법이 익숙하지 않다면 일단 데이터 중심으로 구현한 뒤 이를 리펙터링을 통해 유사한 결과를 얻을 수 있다는 점이다. 아직, 책임 주도 설계가 익숙하지 않다면 동작하는 코드를 작성후 리팩토링을 하는 것도 좋은 대안이 될 수 있다


참조

  • 오브젝트 코드로 이해하는 객체지향 설계 [조영호], 5.책임 할당하기

'Books > Object' 카테고리의 다른 글

[Object] 객체 분해  (0) 2021.05.29
[Object] 메시지와 인터페이스  (0) 2021.05.25
[Object] 책임 할당하기  (0) 2021.05.16
[Object] 설계 품질과 트레이드오프  (0) 2021.05.08
[Object] 역할, 책임, 협력  (0) 2021.05.03
[Object] 객체지향 프로그래밍  (0) 2021.04.25
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

설계 품질과 트레이드오프 (chapter4)


객체지향 설계
“올바른 객체에게 올바른 책임을 할당하면서
낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동이다”

더 좋은 객체설계를 위해

  • 객체가 가지는 데이터보다 책임에 초점을 맞추어 설계를 하자
    • 퍼블릭 인터페이스를 제공하여 객체의 자유도를 높이자
    • 메세지를 통해 상호작용하여 결합도 낮은 설계를 하자

설계 트레이드오프

객체지향 설계에 3가지 품질 척도

  • 캡슐화
  • 응집도
  • 결합도

캡슐화

변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법

  • 객체 내부에 무엇을 캡슐화 해야 하는가?
    • 경될 수 있는 어떤 대상이라도 캡슐화 해야 한다!

응집도

모듈 내부에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다
모듈 내의 요소들이 하나의 목적을 위해 협력한다면 높은 응집도를 가진다.
서로 다른 목적을 추구한다면 그 모듈은 낮은 응집도를 가진다.
객체지향 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 있는 책임을 할당했는지를 나타낸다.

  • 응집도 변경의 관점에서 - 변경이 발생할때 모듈 내부에서 발생하는 변경의 정도
  • 응집도가 높을 경우 변경이 발생하면, 오직 하나의 모듈만 수정하면 된다. 반면, 응집도가 낮을 경우 변경이 발생하면 여로 모듈을 수정하게 된다.

결합도

의존성의 정도를 나타낸다. 다른 모듈에 얼마나 많은 지식을 갖고 있는지를 나타내는 척도이다.
객체지향 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다

  • 결합도 변경의 관점에서 - 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도
  • 결합도가 낮을 경우 수정해야 하는 모듈이 적지만, 결합도가 높을 경우 변경이 발생하게 되면 다른 모듈까지 같이 수정하게 된다.

데이터 중심 설계의 문제점

캡슐화 위반

오직 메서드만을 통해 내부 상태에 접근한다. 어떠한 점이 캡슐화를 위반한 것일까?

public class Movie {
    private Money fee;
    public Money getFee() { 
        return fee; 
    } 

    public void setFee(Money fee) {
        this.fee = fee; 
    } 
}
  • 접근자와 수정자를 통해 Money타입의 fee라는 상태가 존재한다는 것을 알 수 있다
    • 캡슐화를 위반하는 이유는 객체가 수행할 책임이 아니라 내부에 저장할 데이터에 초점을 맞췄기 때문이다

높은 결합도

public class ReservationAgency {
    ... if (discountable) { 
        fee = movie.getFee().minus(discountAmount).times(audienceCount); 
    } else { 
        fee = movie.getFee().times(audienceCount); 
    } 
}
  • Movie의 fee는 private한 상태이지만, 구현 로직을 보면 전혀 private하지 않다.

결국 데이터 중심의 설계로, ReservationAgency는 높은 결합도를 가지게 되었다. 따라서, 이 모듈을 변경하게 되면 다른 모듈까지 많은 변경을 요구하게 된다.

낮은 응집도

ReservationAgency에는 할인 가격을 계산한다는 이유로 변경 이유가 서로 다른 코드를 전부 뭉쳐놓는다. 따라서 하나의 요구사항을 반영하기 위해서는 여러 모듈을 수정해야한다. 응집도가 낮은것이다.


자율적인 객체

  • 접근자, 수정자를 통해 객체의 내부상태에 접근을 허용하였다. get/set 메서드를 제거하고 클래스 내부에서 책임를 지도록 변경하자

데이터 중심 설계의 문제점

  • 데이터 중심 설계는 너무 이른 시기에 데이터에 관해 결정하도록 강요한다
  • 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 operation을 결정한다

정리

  • 객체 설계시 캡슐화를 먼저 고려해보자. 그러면 낮은 결합도를 가지며, 높은 결합도있는 모듈로 설계할 수 있다
  • 우리가 알고 있는 캡슐화와 저자가 말하고 있는 캡슐화는 다른 조금 다른 영역이다
    • private로 클래스 변수를 지정한다고 캡슐화는 아니다
    • 접근자(get), 수정자(set)이 꼭 필요할까 의심해보자
    • 접근자와 수정자를 제공한다는 것은 결국 public interface로 접근 가능하다는 뜻이다
  • 결국 객체의 자율화란, 내부로 감추는 것인데 srp 원칙을 고려하여 책임을 할당해보자. 클래스 변수는 내부구현으로 감추도록!!

참조

Object 코드로 이해하는 객체지향 설계 chapter4

'Books > Object' 카테고리의 다른 글

[Object] 메시지와 인터페이스  (0) 2021.05.25
[Object] 책임 할당하기  (0) 2021.05.16
[Object] 설계 품질과 트레이드오프  (0) 2021.05.08
[Object] 역할, 책임, 협력  (0) 2021.05.03
[Object] 객체지향 프로그래밍  (0) 2021.04.25
[Object] 객체 설계  (0) 2021.04.22
블로그 이미지

사용자 yhmane

댓글을 달아 주세요