OOP — 상속과 코드 재사용

Jiyun Park
17 min readMay 31, 2021

--

Object 코드로 이해하는 객체지향 설계 책 리뷰

객체지향 코드 재사용 방법

  1. 합성 : 새로운 클래스의 인스턴스 안에 (기존 클래스의 인스턴스)를 포함시키는 방법 has-a 관계
  2. 상속 : (기존 클래스 안에 정의된 인스턴스 변수와 메서드)를 자동으로 새로운 클래스에 추가 is-a 관계

[코드 재사용을 위해서는] 객체 합성이 클래스 상속보다 더 좋은 방법이다.[GOF94]

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

상속과 합성은 재사용의 대상이 다르다. 상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경할 수 있다. 다시 말해서 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체할 수 있는 것이다. p.347

상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.

Ch 10. 상속과 코드 재사용

상속이 가지는 문제점을 통해 상속을 사용할 때 주의해야할 점을 파악하자. 그리고 합성으로 변경해서 더 나은 코드 재사용법을 이해하자.

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

java.util.Properties & java.util.Stack

⚠️ 부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반 될 수 있다 : Stack 에게 상속된 Vector 의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다.

  • 맨 마지막 위치에서만 요소를 추가/제거하는 Stack 의 규칙 위반
Stack<String> stack = new Stack<>();
stack.push("1");
stack.push("2");
stack.push("3");
stack.add(0,"4");
assertEquals("4", stack.pop()); // 에러 !
  • 합성으로 변경하기
/*
더 이상 불필요한 Vector 의 오퍼페이션들이
Stack 클래스의 퍼블릭 인터페이스를 오염시키지 않는다.
클라이언트는 오직 Stack 에서 정의한 오퍼레이션만 사용할 수 있다.
*/
public class Stack<E> = {
private Vector<E> elements = new Vector<>();
public E push (E ietm) {
elements.addElement(item);
return item;
}
public E pop() {
if(elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
}

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

HashSet & InstrumentedHashSet

⚠️ super 참조를 이용해서 부모 클래스의 메서드를 사용할 경우 부모 클래스의 메서드 안에서 예상치 못한 실행이 있을 수 있다.

  • InstrumentedHashSet 에서 addCount 세기
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0; // 요소를 추가한 횟수
/*
한 번에 하나의 요소를 추가하는 add 메서드와
다수의 요소를 추가하는 addAll 메서드 6 addCount 를 증가시킨 후
super 참조를 이용해 부모 클래스의 메서드를 호출해서 요소를 추가한다.
*/

@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);
}
}
  • 실행결과
InstrumentedHashSet<String> lang = new InstrumentedHashSet<>(); lang.addAll(Arrays.asList("Java", "Ruby", "Scala")); 
// 실행결과 addCount 6

why? 부모 클래스인 HashSetaddAll 메서드 안에서 add 메서드를 호출하기 때문에 결과적으로 InstrumentedHashSetaddAll 메서드가 호출돼서 addCount 에 3이 더해지고, 그 후 super.addAll 메서드가 호출되고 제어는 부모 클래스인 HashSet에서 각각의 요소를 추가하기 위해 내부적으로 add 메서드를 호출하여 addCount 가 3번 더 더해진다.

  • 합성으로 변경하기
public class InstrumentedHashSet<E> implements Set<E> {
private int addCount = 0;
private Set<E> set;
public InstrumentedHashSet(Set<E> set) {
this.set = set;
}
@Override
public boolean add(E e) {
addCount++;
return set.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}

/*
Forwarding method : HashSet이 제공하는 퍼블릭 인터페이스를 그대로 제공
*/
@Override public boolean remove(Object o) { return set.remove(o); }
@Override public void clear() { set.celear(); }
@Override public boolean equals(Object o) { return set.equals(o); }
@Override public int hasCode() { return set.hasCode(); }
...

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

⚠️ 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스의 요구사항이 변경되었을 때 상속으로 구현된 자식 클래스까지 수정되어야 할 수도 있다.

음악목록을 저장하는 Playlist 클래스에 가수별 노래 제목도 저장되어야하는 상황 발생

  • 음악 정보를 저장하는 Song 클래스
public class Song {
private String singer;
private String title;public Song(String singer, String title) {
this.singer = singer;
this.title = title;
}
public String getSinger() {
return singer;
}
public String getTitle() {
return title;
}
}
  • 음악목록을 저장하는 Playlist 클래스
public class Playlist {
private List<Song> tracks = new ArrayList<>();
private Map<String, String> singers = new HashMap<>(); // 추가 요구사항

public void append(Song song) {
getTracks().add(song);
}
public List<Song> getTrack() {
return tracks;
}
public Map<String, String> getSingers() { // 추가 요구사항
return singers;
}
}
  • 음악을 삭제할 수 있는 PersonalPlaylist 클래스를 상속으로 구현
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger()); // 추가 요구사항의 사이드 이펙트
}
}

코드 재사용이 필요한 시나리오, ‘심야 요금제 추가’

[기존 상황] 개별 통화 시간을 저장하는 클래스 Call & 통화 내역을 갖고 통화 요금을 계산하는 클래스 Phone

  • 개별 통화 시간을 저장하는 클래스 Call
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
public class Phone {
private Money account;
private Duration seconds;
private List<Call> calls = new ArrayList<>();

public Phone(Money amount, Duration seconds) {
this.amount = amount;
this.seconds = seconds;
}
public void call(Call call) {
calls.add(call);
}
public List<Call> getCalls() {
return calls;
}
public Money getAmount() {
return amount;
}
public Duration getSeconds() {
return seconds;
}
public Money calculateFee() {
Money result = Money.ZERO;
for(Call call : getCalls()) {
result = result.plus(amount.times(call.getDuration().getSeconds()/seconds.getSeconds()));
}
return result;
}

[변경 발생] 심야요금제 추가

기본 요금제 vs 심야 요금제를 구분해야하는 상황 → Phone 과 유사한 NightlyDiscountPhone 필요 → 이미 존재하는 클래스와 유사한 클래스가 필요하다면 상속을 이용해 코드를 재사용

  • Phone 클래스를 상속하여 NightlyDiscountPhone 클래스 생성
public class NightlyDiscountPhone extends Phone {
private static final int LATE_NIGHT_HOUR = 22;
private Money nightlyAmount;

public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
super(regularAmount, seconds); // 10시 이전 요금은 부모 클래스 사용
this.nightlyAmount = nightlyAmount;
}
@Override
public Money calculateFee() {
Money result = super.caculateFee();
// super 참조를 통해 부모 클래스의 메서드 호출
Money nightlyFee = Money.ZERO;for(Call call : getCalls()) {
if(call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
nightlyFee = nightlyFee.plus(getAmount().minus(nightlyAmount)
.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
return result.minus(nightlyFee);
}
}

(취약점) super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 부모 클래스의 변경에 취약해진다.

상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속 계층의 상위에 위치한 클래스에 가해지는 작은 변경만으로도 상속 계층에 속한 모든 자손들이 급격하게 요동칠 수 있다.

상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 이것은 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다. 따라서 상속은 결합도를 높인다. 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.

상속도 추상화에 의존하자

추상 클래스를 활용한 상속

🌱 상속을 도입할 때 따르는 두 가지 원칙

1.1 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라.

1.2 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.

2. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.

변경된 구조

public abstract class Phone {}public class RegularPhone extends Phone { ... }
public class NightlyDiscountPhone extends Phone { ... }

step 1. 유사한 두 클래스의 메서드에서 서로 다른 부분을 별도의 메서드로 추출한다.

  • 기본 요금제를 계산하는 RegularPhone 클래스
public class RegularPhone {
private Money account;
private Duration seconds;
private List<Call> calls = new ArrayList<>();

public RegularPhone(Money amount, Duration seconds) {
this.amount = amount;
this.seconds = seconds;
}
public void call(Call call) {
calls.add(call);
}
public List<Call> getCalls() {
return calls;
}
public Money getAmount() {
return amount;
}
public Duration getSeconds() {
return seconds;
}
/* 이하 업데이트된 부분 */
public Money calculateFee() {
Money result = Money.ZERO;
for(Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result;
}
private Money calculateCallFee(Call call) {
return amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
  • 심야 요금제를 계산하는 NightlyDiscountPhone 클래스
public class NightlyDiscountPhone {
private static final int LATE_NIGHT_HOUR = 22;
private Money nightlyAmount;

public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
super(regularAmount, seconds); // 10시 이전 요금은 부모 클래스 사용
this.nightlyAmount = nightlyAmount;
}
/* 이하 업데이트된 부분 */
public Money caculateFee() {
Money result = Money.ZERO;

for(Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result;
}
private 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());
}
}
}

step 2. 중복 코드를 추상 부모 클래스로 만든다.

calculateCallFee() 메서드의 경우 내부 구현이 서로 다르다. 따라서 메서드의 구현은 그대로 두고 공통 부분인 시그니처만 부모 클래스로 이동시켜야한다.

  • RegularPhone 과 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);
}

결과물.

→ 클래스들이 추상화에 의존하기 때문에 얻어지는 장점

✅ 자식 클래스인 RegularPhone 과 NightlyDiscountPhone 은 부모 클래스인 Phone 의 구체적인 구현에 의존하지 않는다. 부모 클래스에서 정의한 추상 메서드인 calculateCallFee 에만 의존한다.

✅ 요금 계산과 관련된 상위 수준의 정책을 구현하는 Phone이 세부적인 요금 계산 로직을 구현하는 RegularPhone 과 NightlyDiscountPhone 에 의존하지 않는다.

✅ 새로운 요금제를 추가하기 쉽다. Phone 을 상속받는 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩 하면 된다.

코드 재사용

DRY (Don’t Repeat Yourself)

중복 코드를 제거하자. “중복 코드는 변경을 방해한다.” 모든 중복 코드를 식별했고 함께 수정했다고 하자. 더 큰 문제는 중복 코드를 서로 다르게 수정하기가 쉽다든 것이다. … 민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야한다. 기회가 생길 때마다 코드를 DRY 하게 만들기 위해 노력하라. p.315

변경이 계속 일어나는 이유. “프로그램의 본질은 비즈니스와 관련된 지식을 코드로 변환하는 것이다. 이 지식은 항상 변한다. 그에 맞춰 지식을 표현하는 코드 역시 변경해야 한다. 그 이유가 무엇이건 일단 새로운 코드를 추가하고 나면 언젠가는 변경될 것이라고 생각하는 것이 현명하다.” p.309

Sign up to discover human stories that deepen your understanding of the world.

--

--

No responses yet

Write a response