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

댓글을 달아 주세요

객체분해 (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

댓글을 달아 주세요

역할, 책임, 협력

객체지향의 본질이란 무엇일까?

객체지향의 본질은 ”협력하는 객체들의 공동체” 를 창조하는 것이다

객체지향 설계의 핵심

  • 협력을 구성하기 위해 적절한 객체를 찾고 적절한 책임을 할당하는 과정이다
    즉, 객체지향 패러다임 관점에서 핵심은 역할, 책임, 협력이다

영화예매 시스템

  • 다양한 객체들이 상호작용을 하고 있다

  • 제어흐름은 한 객체에 의한 통제가 아닌 다양한 객체들 사에이 균형있게 분배되어 있다

  • 이처럼 객체들이 기능 구현을 위해 수행하는 상호작용을 협력 이라고 한다

  • 객체가 협력에 참여하기 위해 수행하는 로직을 책임 이라고 한다

  • 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성한다


협력

협력

객체들이 기능 구현을 위해 수행하는 상호작용

  • 메세지 전송은 객체 사이의 협력을 위해 사용하는 유일한 커뮤니케이션 수단
  • 외부의 객체는 오직 메세지만 전송할 수 있다
  • 메세지를 수신한 객체는 어떻게 처리할지 스스로 결정한다
    • ‘객체가 자신의 일을 스스로 처리할 수 있는 자율적인 존재’


상영객체는 메세지를 전송하고, 영화 객체는 메서드를 수행한다. 상영객체는 영화 객체 내부의 구현을 알 수 없다. -> 자율적인 객체를 만드는 가장 기본적인 방법은 “캡슐화”


책임

책임

객체가 협력에 참여하기 위해 수행하는 로직 또는 행동
객체의 책임은 ‘무엇을 알고 있는가?’ / ‘무엇을 할 수 있는가’로 구분된다

  • 하는것

    • 객체를 생성하거나 계산을 수행
    • 다른 객체의 행동을 시작시키는 것
    • 다른 객체의 활동을 제어하고 조절
  • 아는것

    • 사적인 정보에 관해 아는것
    • 관련된 객체에 관해 아는것
    • 자신이 유도하거나 계산할 수 있는것에 대해 아는것

책임은 하는것 & 아는것과 구분된다. 또한 협력할 객체를 상태값으로 가지고 있다.

책임 할당

  • 협력에 필요한 지식과 방법을 가장 잘 아는 객체에게 도움을 요청 - ‘정보 전문가’


영화를 예약하기 위해서는, 영화 회차/시간 정보를 알아야한다. 이 정보를 가장 잘 아는 것은 상영 객체이다. 또한 요금을 계산 -> 상영은 요금 정보가 없기에 Movie에게 책임을 위임한다

책임 할당시 고려해야 할 요소

  • 메세지가 객체를 결정

    • 객체에게 책임을 할당시 필요한 메세지를 먼저 식별
    • 메시지를 처리할 객체를 나중에 선택
      • 객체가 메시지를 선택하는 것이 아니라, 메시지가 객체를 선택한다
  • 행동이 상태를 결정

    • 객체의 행동은 객체가 협력에 참여할 수 있는 유일한 방법
    • 협력이 객체의 행동을 결정하고 행동이 객체의 상태를 결정한다. 그 행동이 바로 객체의 책임

역할

역할

객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합

유연하고 재사용 가능한 협력

  • 역할을 통해 유연하고 재사용 가능한 협력을 얻을 수 있다


할인 요금을 계산하는 두개의 객체가 있다. 두 객체를 같이 사용하는 대신 하나의 슬롯으로 구성하여 교대로 바꿔뀔수 있게 구성한다. 이 슬롯이 ‘역할’

  • 역할을 구현하는 가장 일반적인 방법은 추상 클래스와 인터페이스 사용

참조

  • 오브젝트 코드로 이해하는 객체지향 설계 chapter3

'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

댓글을 달아 주세요

객체 지향 프로그래밍

객체지향

객체지향

“말 그대로 객체를 지향하는 것”

클래스(Class)란?

  • 객체를 정의하고 만들어 내기 위한 설계도 혹은 틀
  • 상태값(변수)와 행위(메서드)로 구성된다

객체(Object)란?

  • 소프트웨어 세계에 구현할 대상
  • 클래스에 선언된 모양 그대로 생성된 실체

인스턴스(Instance)란?

  • 설계도를 바탕으로 소프트웨어 세계에 구현된 구체적인 실체

객체지향 프로그래밍을 하려면

  • 클래스가 아닌 객체에 초점을 맞추어야 한다
  • 객체를 먼저 정의하고 클래스를 정의하라
  • 객체를 독립적인 존재가 아닌, 협력/의존하는 공동체의 일원으로 봐라

도메인의 구조를 따르는 프로그램 구조

도메인

“문제를 해결하기 위해 사용자가 프로그래음 사용하는 분야”

  • 클래스 이름은 대응되는 도메인 개념의 이름과 동일하거나 유사해야함
  • 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사해야 함
  • 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 함


클래스

  • 클래스의 경계를 구분 짓는다
    • 상태값은 private, 행위는 public으로 구성한다
  • 클래스와 내부와 외부를 구분 짓는다
    • 경계의 명확성이 객체의 자율성을 보장한다
    • 프로그래머에게 구현의 자유를 보장한다

자율적인 객체

  • 객체는 상태와 행위를 함께 가지는 복합적인 존재
  • 캡슐화와 접근 제어
    • 객체를 자율적인 존재로 만들기 위해서
    • 객체의 상태는 숨기고 행위만 외부에 공개
public class Money {

    private final BigDecimal amount;

    public static Money wons(long amount) {
        return new Money(BigDecimal.valueOf(amount));
    }

    Money(BigDecimal amount) ... 중략
    public Money plus(Money amount) ... 중략
    public Money minus(Money amount) ... 중략
    public Money times(double percent) ... 중략
    public boolean isLessThan(Money other) ... 중략
    public boolean isGreaterThanOrEqual(Money other) ... 중략
}

객체화
“하나의 인스턴스 변수만 포함하더라도 비즈니스 로직에 관여한다면 객체로 만들어라”

  • 명확하게 의미를 전달할 수 있다
  • 설계의 명확성과 유연성을 높여준다

협력
“메세지를 통해 객체가 다른 객체와 상호 작용을 한다”

  • 객체는 캡슐화를 통해 상태값을 내부로 숨긴다
  • 다른 객체와 협력을 하는 유일한 방법은 메세지를 전송하는 것뿐
  • 다른 객체에 요청이 도착하면 메세지를 수신, 이처럼 수신된 메세지를 처리하는 자신만의 방법을 메서드라 부른다

시간 의존성

컴파일 시간 의존성과 실행 시간 의존성
“컴파일 시간 의존성과 실행 시점의 의존성이 서로 다를 수 있다”

// 금액 할인정책에 의존
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000), new AmountDiscountPolicy(Money.wons(800), ...));
// 비율 할인정책에 의존
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000), new PercentDiscountPolicy(0,1, ...));

public class Movie {
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
  • Movie.calculateMovieFee()는 코드레벨에서 어떠한 DiscountPolicy에 의해 calculateDiscountAmount() 실행될지 몰라도 된다
    • 코드의 의존성과 실행 시점의 의존성이 다르면 코드를 이해하기 어려워 질 수 있다
    • 반면, 코드는 유연해지고 확장 가능성이 생기는 트레이드오프의 산물

상속과 다형성

상속
“상속은 객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법”

상속

  • 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 전이
  • 부모 클래스의 구현을 공유하면서도 행동이 다른 자식 클래스에 대하여 구현이 용이
  • 업캐스팅 - 자식 클래스가 부모클래스를 대신함
    • 컴파일러는 코드상에서 부모클래스가 나오는 모든 장소에서 자식 클래스를 허용

다형성

* 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행
* 동일한 메세지를 전달하지만, 실제로 어떤 메서드가 실행 될지는 객체의 클래스에 의해 결정

즉, 상속은 코드의 재사용이 아닌 다형적인 협력을 위해 사용해야 한다

단점

* 상속을 하게 되면 캡슐화가 위반될 수 있다
* 객체 설계가 유연하지 않게 된다

단점을 극복하기 위해 다음과 같이 인스턴스 변수를 이용해 실행 시점에 할인 정책을 변경할 수 있도록 코드를 추가해주자

public class Moive {
    private DiscountPolicy discountPolicy;

    public void chanseDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}
Movie avatar = new Movie(“아바타”, Duration.ofMinutes(120), Money.wons(10000), new AmountDiscountPolicy(Money.wons(800), …));
avatar.chanseDiscountPolicy(new PercentDiscountPolicy(0,1, …));
  • 코드의 재사용이 목적이라면 상속보다는 합성을 고려해보자!!

참조

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

'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

댓글을 달아 주세요

객체지향 프로그래밍 (OOP)

오브젝트 - [조영호님 저]


객체 설계 (chapter1)

좋은 설계란 무엇일까?
역할과 책임에 대하여 많은 얘기가 오가지만 결국 중요한 것은 객체간의 의존성이다

  • 설계의 핵심은 의존성 이다
  • 좋은 설계는 의존성을 어떻게 하는지에 달려있다.
  • 의존성에 따라 설계가 바뀐다

의존성

객체가 의존 관계에 있을때, 객체가 변하면 의존하는 객체도 변경될 수 있다.
* 양방향을 피해라
* 다중성이 적은 방향을 선택하라
* 의존성의 사이클을 제거하라 (triangle)
* 의존하는 필요없다면 제거하라


객체 관계

  • 연관 관계 (Association)
    class A {
      private B b;
    }
  • 의존관계 (Dependency)
    파라미터에 또는 return 타입, 메서드 내부에서 인스턴스를 생성할 경우

    class A {
      public B method(B b) {
          return new B();
      }
    }

  • 상속관계 (Inheritance)

    class A extends B {
    }

  • 실체화 관계 (Realization)

    class A implements B {
    }


문제가 되는 설계

‘로버트 마틴’ - 소프트웨어 모듈이 가져야 하는 세가지 원칙


“모든 모듈은 제대로 실행 되어야 하고,
변경이 용이해야 하며,
이해하기 쉬워야 한다”

책의 저자는 "로버트 마틴"이 말한 소프트웨어가 가져야 하는 3가지 원칙에 대하여 말한다.
아래 예제는 위에서 말한 첫번째 원칙만 해당한다. 아래 예제를 살펴보자.

Theater가 enter() 통해 4개의 객체에 의존하고 있다. 로직이 너무 복잡한다

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);
    }
}
  • 1) enter()에서 파라미터로 관람객에 의존한다.
  • 2) 관람객의 가방에 접근하여 초대권이 있는지 확인한다 (초대권이 있을경우)
    • 2-1) 판매원이 매표소에가서 티켓을 가져온다
    • 2-2) 관람객에 가방에 티켓을 넣어준다
  • 3) (초대권이 없을경우)
    • 3-1) 판매원이 매표소에가서 티켓을 가져온다
    • 3-2) 관람객에 가방에서 티켓가격만큼 금액을 차감한다
    • 3-3) 판매원이 매표소로 가서 티켓가격만금 금액을 합산한다
    • 3-4) 관람객에 가방에 티켓을 넣어준다

예제는 너무 복잡하여 변경에 취약하다.

  • 변경에 취약한 코드
    • 결합도가 높은 코드 -> 의존성이 강하다
    • 결합도가 낮은 코드 -> 합리적인 수준으로 의존한다

객체 사이의 결합도를 낮춰 변경이 용이한 설계를 해야 한다


개선된 설계

Theater가 가지고 있던 많은 책임들을 각 클래스에 전이 시켰다

  • 변경에 유연한 코드
    • 자율적인 존재가 되도록 설계를 변경
      • 캡슐화(encapsulation)
        • 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감춘다
        • 내부로의 접근을 제한하여 객체들간의 결합도를 낮출 수 있다
    • 자기 자신의 문제는 스스로 해결
      • 객체 내부의 상태를 캡슐화한다
      • 객체는 오직 메시지를 통해서만 상호작용하도록 만든다
      • 연관성 없는 작업은 다른 객체에게 위임하여 응집도를 높인다

객체지향적 설계

객체지향적 설계를 위해서는 객체간의 의존 관계를 적절하게 서술하고,
각 객체가 자율적이며 응집도 높은 코드를 작성해야 한다.

참고 자료

YouTube - 우아한 객체지향 [조영호님]
수정본 우아한 객체지향

'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

댓글을 달아 주세요