일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 구글 비공개 테스트 20명
- 플레이스토어 비공개 테스트
- 우테코
- 프리코스
- 객체지향설계
- 구글 플레이 비공개 테스트
- 구글 플레이 스토어 배포 방법
- git
- 운영체제 #CS지식
- 플레이 스토어 20명
- 기능명세서
- 객체지향
- 설계
- 커밋 메시지
- 클린코드
GYUD-TECH
[스터디: 오브젝트] 상속과 코드 재사용 본문
이번 장에서는 상속과 코드의 재사용에 대해 다룬다.
프로그래밍을 공부하면, 중복코드를 줄이라는 말을 쉽게 들을 수 있는데, 왜 중복코드를 줄여야 하는지에 대해서 깊게 생각하진 않았던 것 같다.
왜 중복코드를 줄여야할까?
중복 코드의 문제점
지금까지 객체지향을 공부하면서 모든 문제는 변경에서 시작되었다.
애플리케이션의 변경은 필수적이고, 이제까지 변경에 유연한 코드를 만들기 위해서 여러가지 설계 기법을 공부했다.
중복코드는 변경을 어렵게 한다.
만약 중복 코드를 작성한 로직에 변경이 일어난다면, 중복 부분을 모두 찾고 일일이 변경해줘야 한다.
이로 인해 시간도 많이 걸릴 뿐더러, 버그의 가능성도 높아진다.
따라서 중복된 코드를 최대한 없에야 하는데, 이를 Dry(Don't Repeat Yourself) 원칙 이라고 하기도 한다.
상속
중복 코드를 없에는 한가지 방법으로 상속이 있다.
상속을 통해서 공통되는 코드를 부모클래스에서만 작성하고, 자식 클래스에서는 부모 클래스의 메서드를 호출하여 사용할 수 있다.
아래 예시는 휴대전화의 기본 요금제의 요금을 계산하는 기능을 코드로 작성한 것이다.
public class Phone {
private Money regularAmount;
private Duration seconds;
public Phone(Money regularAmount, Duration seconds) {
this.regularAmount = regularAmount;
this.seconds = seconds;
}
protected Money calculateFee() {
// 기본 요금을 계산 로직
}
}
10시 이후에 할인을 적용해주는 심야 요금제가 추가된다면, Phone 클래스를 상속받아서 추가적인 부분만 구현하면, 중복된 코드를 줄일 수 있다.
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);
this.nightlyAmount = nightlyAmount;
}
@Override
public Money calculateFee() {
// 기본 요금을 계산
Money result = super.calculateFee();
// 저녁 할인 요금을 계산
return 기본요금 - 할인요금;
}
}
코드의 중복을 최대한 줄이기 위하여 Overriding 한 calculateFee() 메서드에서 부모 클래스의 calculateFee() 메서드를 호출하여 일부 값을 계산하고, 할인요금만 따로 계산하여 전체 요금을 계산했다.
이렇게 코드를 작성한 후에 다른사람이 이 코드를 읽는다고 가정해보자.
심야 요금제의 요금이 있음에도 불구하고, 할인 요금을 계산할 떄 기본 요금을 할인 금액만 따로 구해서 이를 빼주는건 직관적이지 않다.
NightlyDiscountPhone 객체의 요금 계산방식을 이해하기 위해서는 Phone 클래스의 calculateFee() 메서드도 봐야한다.
위의 경우에는 두 클래스간의 간단한 상속이기 때문에 이 과정이 크게 힘들진 않지만, 상속의 깊이가 깊다면 로직을 이해하는데 많은 시간이 걸릴 것이다.
자식 클래스의 구현을 이해하는데 부모 클래스를 살펴봐야한다는 것은 자식 클래스와 부모 클래스가 강하게 결합되어 있다는 것을 의미한다.
이렇게 발생한 강한 결합으로 상속을 사용하여 중복 코드를 제거했음에도 불구하고, 변경에 취약한 설계가 만들어졌다.
상속이 가지는 문제점
다시 한번 강조하지만 상속은 자식 클래스와 부모 클래스를 강하게 결합시킨다.
이로 인해 아래 문제들이 발생할 수 있다.
- 불필요한 인터페이스 상속 문제
- 부모클래스와 자식 클래스의 동시 수정 문제
- 클래스 폭발 문제
- 메서드 오버라이딩의 오작용 문제
1번에서 3번까지의 문제들은 이전에 객체지향의 사실과 오해를 읽으면서 추가적으로 정리한 내용이 있어 아래 글의 상속 부분을 자세히 읽어보면 쉽게 이해할 수 있을 것 이다.
https://gyuwon-tech.tistory.com/29
[스터디: 객체지향의 사실과 오해] 추상화기법, 상속, 합성
추상화 기법 추상화를 사용하는 이유는 도메인의 복잡성을 단순화하고, 직관적인 모델을 만들 수 있기 때문이다. 이러한 추상화의 기법에는 3가지가 있다. 분류와 인스턴스화 일반화와 특수화
gyuwon-tech.tistory.com
4번 메서드 오버라이딩의 오작용 문제는 처음 공부하였는데 꽤나 치명적인 문제라고 생각하여 추가적으로 정리하였다.
메서드 오버라이딩의 오작용 문제
자바의 HashSet에 요소를 추가할 때마다 이를 기록해주는 기능을 더한 InstrumentedHashSet 클래스가 필요하다고 가정해보자.
대부분의 기능은 HashSet 클래스에 구현되어 있기 때문에 상속을 통해서 중복을 제거하고 추가적인 부분만 코드로 작성하였다.
public class InstrumentHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCouunt++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
요소의 개수를 저장하기 위해서 addCount라는 인스턴스 변수를 추가하였고, HashSet의 add() 와 addAll() 메서드를 Overriding 하여 addCount를 증가시켜 주는 기능을 추가하였다.
이렇게 코드를 작성하고 아래와 같이 3개의 원소를 넣어보자.
InstrumentedHashSet<String> languages = new InstrumentedHashSet();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));
3개의 원소를 추가했기 때문에 addCount를 출력하면 3이 출력되는게 정상이지만, 위 예시는 6이라는 값이 출력된다.
그 이유는 InstrumentHashSet의 addAll() 메서드에서 super.addAll()을 통해서 부모 클래스의 addAll() 메서드를 호출했기 때문이다.
HashSet의 addAll() 메서드는 내부적으로 add() 메서드를 호출한다.
결국 동적 바인딩으로 인해 InstrumentHashSet의 add() 메서드가 3번 호출되고 addCount가 다시 증가해서 6이라는 값이 나오는 것이다.
문제의 원인은 개수가 중복으로 count 되는 것이기 때문에 addAll()의 개수 증가부분을 지워서 이 문제를 해결할 수 있다.
public class InstrumentHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCouunt++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
return super.addAll(c);
}
}
추후에 기능이 변경되어서 InstrumentHashSet 클래스의 add 메서드만 2씩 증가시켜야 한다고 가정해보자.
add() 의 addCount++ 만 addCount += 2 로 수정하면 간단하게 해결할 수 있다.
하지만 전혀 수정하지 않은 addAll()의 계산 결과도 2씩 증가하게 되었다.
만약 기능 변경을 다른 개발자가 수행했다면, 자신도 모르는 사이에 버그를 생성한 것이다.
위의 코드만 보고는 왜 addAll() 의 메서드도 수정되었는지 도무지 알 수 없다.
상속은 부모 클래스와 자식 클래스를 강하게 결합하기 떄문에 메서드 오버라이딩 시 예상치 못한 문제가 발생할 수 있다.
이로 인해 에러의 가능성이 높아지고, 코드를 이해하고, 기능을 변경하는 것이 어려워졌다.
상속을 통해서 중복된 코드를 제거하는 효과를 얻었지만, 클래스 간 결합도가 증가하는 트레이드오프가 발생하였다.
완벽한 캡슐화를 위해서는 상속을 사용하지 않거나, 중복 제거를 포기하는 방법을 선택해야할까?
추상화에 의존
상속으로 인해 결합도가 높아진것도 맞지만 이 표현은 중간에 한 과정이 생략된 표현이다.
상속을 통해 부모의 구현에 의존했기 때문에 결합도가 높아진 것이다.
따라서 구현이 아닌 추상화에 의존하도록 코드를 변경하면 문제를 해결할 수 있다.
비슷한 두 클래스를 상속으로 연결하는 것이 아니라, 공통적인 부분을 추출하여 새로운 추상화된 클래스를 생성하고 이를 상속받으면 된다.
새로운 클래스에는 두 클래스의 공통적인 인터페이스를 먼저 옮기고, 필요한 필드와 메서드를 하나씩 옮겨주면 공통된 기능을 부모 클래스에 모을 수 있다.
이때, 공통적인 메서드는 일반 메서드로 선언하고, 서로 다르게 구현되는 메서드는 추상 클래스로 선언하여 개별 객체에서 구현해서 사용하도록 한다.
이렇게 하면 중복을 제거함과 동시에 부모 클래스와 자식 클래스의 구현을 분리할 수 있다.
하지만 이것 마저도 부모와 자식이 완전히 분리된 것은 아니다.
만약 부모 클래스에 새로운 인스턴스 변수를 추가한다고 가정해보자.
부모 클래스는 당연히 변경해줘야하지만, 자식 클래스의 생성자 역시 변경해줘야한다.
객체 생성 로직은 개별적으로 구현되어 있기 때문에 공통적이라 할지라도 변경해줘야 하는 것이다.
객체 생성 로직까지는 분리하지 못했지만, 중복 코드를 없엠과 동시에 핵심적인 비즈니스 로직을 분리하여 변경에 용이하게 만들었다.
상속으로 인한 클래스간의 결합을 피할 수는 없지만, 결합도를 최대한 낮추어서 상속의 문제점을 최대한 해결한 설계이다.
상속은 결합도의 증가라는 위험성이 있지만, 중복 코드의 제거와 코드의 재사용이라는 엄청난 장점을 가지고 있다.
코드를 재사용함으로써 구현 시간을 단축할 뿐만 아니라 오류를 최소화 할 수 있다.
상속이 코드의 재사용이라는 측면에서 매우 강력한 도구인 것은 사실이지만, 지나친 남용과 오용은 애플리케이션의 확장과 이해를 방해한다.
이러한 트레이드오프 관계를 고려하여 정말 필요한 경우에만 상속을 활용하자.
상속보다는 합성을 사용하자.
'스터디' 카테고리의 다른 글
[OS 모의 면접 스터디] 운영체제 개요 (0) | 2024.03.06 |
---|---|
[스터디: 오브젝트] 합성과 믹스인 (0) | 2024.03.04 |
[스터디: 오브젝트] 유연한 설계 (1) | 2024.02.26 |
[스터디: 오브젝트] 의존성 관리 (0) | 2024.02.22 |
[스터디: 오브젝트] 디미터 법칙과 트레이드오프 (0) | 2024.02.19 |