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

댓글을 달아 주세요

객체 지향 프로그래밍

객체지향

객체지향

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

클래스(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

댓글을 달아 주세요

Item18

상속보다는 컴포지션을 사용하라

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.

잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.


메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

  • 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
    • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한줄 건드리지 않은 하위 클래스가 오작동할 수 있다.
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet(){}

    public InstrumentedHashSet(int initCap, float loadFactor){
        super(initCap, loadFactor);
    }

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

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

    public int getAddCount() {
        return addCount;
    }
}

public class Item18 {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));

        System.out.println(s.getAddCount());
    }
}
  • getAddCount()의 결과가 3을 반환하리라 생각하겠지만 6을 반환한다. HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다.
  • 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다.
    • 하지만 이처럼 자신의 다른 부분을 사용하는 '자기사용' 여부는 해당 클래스의 내부 구현방식에 해당하며, 자바 플랫폼 전반적인 정책인지, 그 다음 릴리즈에도 유지될지 알 수 없다.
  • 그렇다면 재정의 대신 새로운 메서드를 추가하면 괜찮을까?
    • 괜찮은 방법이라 생각할수도 있지만, 위험이 전혀 없는 것은 아니다.
    • 다음 릴리스에 상위 클래스에 새 메서드가 추가 됐는데, 운 없게도 하필 추가된 메서드와 시그니처가 같고 반환 타입이 다를 수도 있다. 컴파일 문제가 바로 발생한다.

상속대신 컴포지시션을 이용

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.
컴포지션을 통해 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환하게 한다.
새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.

public class ForwardingSet<E> implements Set<E> {
  private final Set<E> s;

  public ForwardingSet(Set<E> s) {
    this.s = s;
  }

  public void clear() {
    s.clear();
  }

  public boolean contains(Object o) {
    return s.contains(o);
  }

  public boolean isEmpty() {
    return s.isEmpty();
  }

  public int size() {
    return s.size();
  }

  public Iterator<E> iterator() {
    return s.iterator();
  }

  public boolean add(E e) {
    return s.add(e);
  }

  public boolean remove(Object o) {
    return s.remove(o);
  }

  public boolean containsAll(Collection<?> c) {
    return s.containsAll(c);
  }

  public boolean addAll(Collection<? extends E> c) {
    return s.addAll(c);
  }

  public boolean removeAll(Collection<?> c) {
    return s.removeAll(c);
  }

  public boolean retainAll(Collection<?> c) {
    return s.retainAll(c);
  }

  public Object[] toArray() {
    return s.toArray();
  }

  public <T> T[] toArray(T[] a) {
    return s.toArray(a);
  }

  @Override
  public boolean equals(Object o) {
    return s.equals(o);
  }

  @Override
  public int hashCode() {
    return s.hashCode();
  }

  @Override
  public String toString() {
    return s.toString();
  }
}

public class InstrumentedSet<E> extends ForwardingSet<E> {
  private int addCount = 0;

  public InstrumentedSet(Set<E> s) {
    super(s);
  }

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

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

  public int getAddCount() {
    return addCount;
  }
}

public class Item18 {
  public static void main(String[] args) {
    InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
    s.addAll(List.of("틱", "탁탁", "펑"));
    System.out.println(s.getAddCount());

    InstrumentedSet<String> s2 = new InstrumentedSet<>(new HashSet<String>());
    s2.addAll(List.of("틱", "탁탁", "펑"));
    System.out.println(s2.getAddCount());
  }
}

결론

  • 상속은 강렬하지만 캡술화를 해친다는 문제가 있다.
  • 상속은 상위 클래스와 하위 클래스가 is-a 관계일 때만 써야 한다.
  • 상위 클래스와 하위 클래스의 패키지가 다를 경우에는 is-a 관계라도 문제가 발생할 수 있다.
  • 상속의 취약점을 피하려면 상속 대신 컴포지션 전달을 사용하자.

참조

블로그 이미지

yhmane

댓글을 달아 주세요

프로토타입

객체 생성자

객체를 생성하는 함수를 생성자 함수라고 부릅니다. 대문자로 선언한고 new로 할당하여 사용합니다.

function Animal(type, name, sound) {
    this.type = type;
    this.name = name;
    this.sound = sound;
    this.say = function() {
      console.log(this.sound);
    }
}

const dog = new Animal('개', '두부', '멍멍');
const cat = new Animal('고양이', '휴지', '야옹');
dog.say();
cat.say();
  • this.say가 매번 할당 되고 있음

프로토타입

객체 생성자로 만든 함수에 공유할수 있는 값이나 함수를 설정하는 것

// 적용전
function Animal(type, name, sound) {
    this.type = type;
    this.name = name;
    this.sound = sound;
}

function say() {
    console.log(this.sound);
}

dog.say = say;
cat.say = say;
const dog = new Animal('개', '두부', '멍멍');
const cat = new Animal('고양이', '휴지', '야옹');
dog.say();
cat.say();
// 적용후
function Animal(type, name, sound) {
    this.type = type;
    this.name = name;
    this.sound = sound;
}

Animal.prototype.say = function() {
    console.log(this.sound);
}

Animal.prototype.value = 1;
const dog = new Animal('개', '두부', '멍멍');
const cat = new Animal('고양이', '휴지', '야옹');
dog.say();
cat.say();

객체 생성자 상속

call을 이용해 javascript도 상속을 구현할 수 있다

//  상속을 사용하지 않을 경우
function Dog(name, sound) {
    this.type = '개';
    this.name = name;
    this.sound = sound;
}

function Cat(name, sound) {
    this.type = '고양이';
    this.name = name;
    this.sound = sound;
}

Dog.prototype.say = function() {
    console.log(this.sound);
}

Cat.prototype.say = function() {
    console.log(this.sound);
}

const dog = new Dog('두부', '멍멍');
const cat = new Cat('휴지', '야옹');
// 상속을 적용
function Animal(type, name, sound) {
    this.type = type;
    this.name = name;
    this.sound = sound;
}

Animal.prototype.say = function() {
    console.log(this.sound);
}

function Dog(name, sound) {
    Animal.call(this, '개', name, sound);
}

function Cat(name, sound) {
    Animal.call(this, '고양이', name, sound);
}

Dog.prototype = Animal.prototype;
Cat.prototype = Animal.prototype;

const dog = new Animal('개', '두부', '멍멍');
const cat = new Animal('고양이', '휴지', '야옹');
dog.say();
cat.say();

클래스

ES6에서 클래스 문법이 도입 되었다. 프로토타입 작업을 조금 더 간략하게 사용할 수 있게 되었다.

class Animall {
    constructor(type, name, sound) {
      this.type = type;
      this.name = name;
      this.sound = sound;
    }
    say(){
        console.log(this.sound);
    }
}

const dog = new Animal('개', '두부', '멍멍');
const dog = new Animal('고양이', '휴지', '야옹');

dog.say();
cat.say();

클래스 상속

객체 생성자와 마친가지로 클래스 상속이 가능

class Animall {
    constructor(type, name, sound) {
      this.type = type;
      this.name = name;
      this.sound = sound;
    }
    say(){
        console.log(this.sound);
    }
}

class Dog extends Animal {
    constructor(name, sound) {
      super('개', name, sound);
    }
}

class Cat extends Animal {
    constructor(name, sound) {
      super('고양이', name, sound);
    }
}

const dog = new Dog('두부', '멍멍');
const dog = new Cat('휴지', '야옹');

dog.say();
cat.say();
블로그 이미지

yhmane

댓글을 달아 주세요

들어가며


 이전 포스팅까지는 자바의 기초문법에 대하여 정의하였습니다. 기본적인 문법구조나 변수 선언등에 대해서 알아보았고, 이번 포스팅에선 자바의 가장 두드러지는 특징인 객체지향의 대하여 알아보도록 하겠습니다.


객체지향의 개념


 객체지향이란 컴퓨터 프로그래밍 패러다임중의 한 종류입니다. 기존 명령어를 중심으로 나열하는 프로그래밍기법에서 벗어나, 객체 모델을 바탕으로 프로그램을 구체화하고 개발하는 프로그래밍 기법을 의미합니다. 객체지향의 특징에는 상속, 추상화, 다형, 추상화 이라는 특징이 있습니다.

 위에 언급된 특성은 객체지향의 핵심적인 특징으로 꼭 이해하시고 가야 합니다. 객체지향의 특징을 설명하기 전에 클래스, 객체, 인스턴스를 짚고 넘어가도록 하겠습니다.


  • 클래스

멤버변수와 함수로 구성된 논리적인 설계도


class Student {

private String name;

private int age;

}


  • 객체

클래스에 선언된 모응 그대로 생성된 실체


public class Main {

public static void main(String[] args) {

Student student;

}

}


  • 인스턴스

생성되어진 객체가 메모리에 올려진 것


public class Main {

public static void main(String[] args) {

Student student = new Student();

}

}


객체와 인스턴스는 일반적으로는 통용하여 표현하나, 메모리에 올려진 여부로 객체와 인스턴스를 판단합니다.



객체지향의 특징


 객체지향에는 다음과 같은 특징이 있습니다. 캡슐화, 상속, 다형성, 추상화 모두 중요한 개념입니다. 


  • 캡슐화

 캡슐화는 생성한 객체를 어떤 메서드와 필드로 어떻게 일을 수행할지 외부에 숨기는 특성을 말합니다. 캡슐화 은닉화 라고 하며 보호하고자 하는 데이터의 값을 외부에서 직접 접근하는 것을 방지하기 위해 나온 개념입니다. 접근제어자를 이용하여 값을 은닉하고, public method로 값을 통제합니다.


캡슐화 예제

 class Student {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge() {
this.age = age;
}
}


public class Main {

public static void main(String[] args) {

Student student = new Student();


student.setName("윤호");

student.setAge(20);


System.out.println("학생의 이름 : " + student.getName());

System.out.println("학생의 나이 : " + student.getAge());

}

}


  • 상속

 클래스는 추상화된 슈퍼클래스와 구체화된 서브 클래스로 구성됩니다. 예를 들면 사람(슈퍼클래스)와 학생(서브클래스)
 지정 예약어 extends를 이용하여 상속을 이용합니다. 하나의 부모클래스는 여러 자식을 가질 수 있지만, 반대는 성립하지 않습니다.
 자식은 부모의 값이나 행위를 상속받아 사용할 수 있습니다. 

상속 예제

class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge() {
this.age = age;
}

public void run() {
System.out.println("달리기를 합니다");
}
}

class Student extends Person() {
public void study() {
System.out.println("공부를 합니다");
}
}


public class Main {

public static void main(String[] args) {

Student student = new Student();


student.setName("윤호");

student.setAge(20);


System.out.println("학생의 이름 : " + student.getName());

System.out.println("학생의 나이 : " + student.getAge());


student.run();

student.study();

}

}


  • 다형성

 클래스의 상속 관계를 이용하여 슈퍼클래스가 같은 서브 클래스의 동일한 요청을 다르게 처리할 수 있는 특징을 말합니다. 예를 들어, 사람이라는 클래스가 있고 아침에 이동하다는 행위를 합니다. 사람을 상속받은 학생클래스는 아침에 학교로 이동하고, 사람을 상속박은 직장인 클래스는 아침에 회사로 이동합니다. 아래 예제를 보도록 하겠습니다.

다형성 예제


class Person {
public void move() {
System.out.println("이동합니다");
}
}

class Student extends Person() {
@Override
public void move() {
System.out.println("학교로 이동합니다");
}
}

class Student extends Worker() {
@Override
public void move() {
System.out.println("일터로 이동합니다");
}
}

public class Main {

public static void main(String[] args) {

Student student = new Student();

Worker worker = new Worker();


student.move();

worker.move();

}

}


  • 추상화

 매우 중요한 개념입니다. 객체의 공통된 특징을 묶어서 추출한다는 개념인데, 다음 포스팅에서 클래스에 대하여 설명하며 추상클래스, 인터페이스와 함께 덧붙이도록 하겠습니다.


정리

 

 이번 포스팅에선 자바의 핵심 개념인 객체지향에 대하여 글을 작성하였습니다. 자바는 프로그램을 구조화하는 특징을 가졌으며 추상화, 상속, 캡슐화, 다형성라는 중요한 특성이 있습니다. 이러한 기능으로 구조적으로 재활용성이 높아져 생산성이나 유지보수 효율성을 높인 프로그래밍 언어입니다!!


출처


- 자바의 정석

- just 자바

블로그 이미지

yhmane

댓글을 달아 주세요