최근에 조용호, 객체지향의 사실과 오해를 감명깊게 읽은 후

 

 

조용호, 오브젝트를 열심히 읽어나가고 있습니다.

 

 

해당 책의 10장 상속편을 인상깊게 읽고 생각정리 겸 내용을 공유하기위해 글을 남기게 되었습니다.

 

상속이란?

 

일부 개발 분야를 제외하고는 모든 곳에서 객체지향적 프로그래밍이 사용됩니다.

 

 

그런의미에서 '상속'은 개발자에게 괴장히 익숙한 단어라고 생각합니다.

 

 

2학년 수업에 처음으로 C++언어로 클래스 상속개념을 접했을 때가 엊그제 같내요.

 

 

상속은 특정 객체의 코드를 상위 코드에서 재사용할 수 있도록 해주는 강력한 기능입니다.

 

 

좋은 기능이기도 하지만, 치명적인 단점이 있기에 논란이 많은 기능이고도 하지요.

 

 

상속의 단점

상속을 사용하는 전제는 우선 중복코드가 발생했고 중복 코드를 없애려는 시도입니다.

 

 

중복코드를 상위 클래스로 분류하고 해당 코드를 하위 클래스에서 가져와 쓰거나 오버라이딩을 하는 식이지요.

 

 

단점1, 강한 결합력(취약한 기반 클래스 문제)

상속은 상위 객체의 상태(ex 프로퍼티), 인터페이스를 하위클래스가 그대로 가져온다는 점에서

 

 

결합력은 필수적으로 높아지게 됩니다.

 

 

그림과 같이 상위 클래스의 코드를 다양한 하위 클래스에서 사용하는 상태에서 상위 클래스 매서드를 수정한다고 가정해 봅시다.

 

 

이 경우 모든 사이드 이팩트를 컨트롤할 수 있을까요?

 

 

많은 개발자가 참여하는 현업 프로젝트에는 의도치 않은 동작이 있으면 안됩니다.

 

 

해당 객체의 모든 하위 클래스를 새롭게 테스트하는 등과 같은 많은 자원이 요구됩니다.

 

 

결과적으로 상위 클래스가 변동에 취약한 상태로 머무르게 됩니다.

 

 

이러한 상황이 발생하는 이유는 상위 클래스와 하위 클래스의 강한 결합력 때문입니다.

 

 

단점2, 캡슐화 위반

 

아래와 같은 상위 클래스와 하위 클래스가 있습니다.

 

 

출력결과를 예측해봅시다.

class Super {
    
    func calcFee() {
        print("super.calcFee")
        self.calcCardFee()
    }
    
    func calcCardFee() {
        print("super.calcCardFee")
    }
}

class Child: Super { }

let child = Child()

child.calcFee()

 

 

"super.calcFee" 이후에 "super.calcCardFee"가 출력되는 것을 예측할 수 있습니다.

 

 

하지만 이 경우는 어떨까요? 자식 클래스가 부모 클래스의 일부 함수를 재정의 했습니다.

class Child: Super {
    
    override func calcCardFee() {
        print("child.calcCardFee")
        super.calcCardFee()
    }
}

 

결과는 "super.calcFee" 이후에 "child.calcCardFee"가 출력됩니다.

 

 

자식 클래스의 코드만으로 해당 동작을 예측하기는 힘듭니다.

 

 

부모 클래스의 함수들이 어떤 관계를 맻고 있는지 즉, 어떻게(How)동작하는지 알아야합니다.

 

 

캡슐화 위반입니다.

 

 

'오브젝트' 에서는 이러한 상황을 막기위해 상위클래스의 모든 함수 구현에 대한 자세한 명세를 작성해야된다고 합니다.

 

 

특정 클래스의 동작이 아니라 구현에 대한 명세? What이 아닌 How에 대한 명세?

 

 

말자체로 캡슐화를 위반합니다.

 

더보기
더보기

캡슐화.. 캡슐화!? 지켜야 하는 이유 🤔

 

캡슐화를 지켜야 한다는 언급이 잠시 많았습니다.

캡슐화가 중요한 이유는 객체의 내부 구현을 숨기기 때문입니다.

왜 숨겨야 하냐고요?

 

예시를 들어 설명해보겠습니다.

 

카페에서 커피를 주문하실 때 바리스타가 누군지 생각해보신 적이 있으실까요?

아닙니다, 커피가 나오는 것만 관심이 있지요.

바리스타에 크게 관심이 없기에 우리는 어떤 바리스타의 커피도 마실 수가 있습니다.

커피만 나오면 누구든 상관 없거든요.

 

덕분에 손님들은 별 생각 없이(=추가적인 고려 없이, 쉽게) 주문을 하고

바리스타가 그만둬고 사장님은 새로운 바리스타를 고용하면 그만이겠죠.

 

유연하고, 쉽게 파악할 수 있는 협력관계가 만들어 집니다. 그게 제가생각하는 핵심입니다.

 

단점3, 불필요한 인터페이스 노출

 

상속을 사용하면 언어에 따라서 조금씩 차이가 있게지만,

 

 

상위 클래스의 모든 인터페이스를 하위 클래스에게 노출하게됩니다.

 

 

특정 클래스의 일부분을 재사용하기위한 목적으로 하위 클래스를 만들어도 하위 클래스는 모든 인터페이스를 사용할 수 있습니다.

 

 

원하지 않는 상황은 얘기치 못한 행동, 오류를 만들어 냅니다.

 

 

책에서 이런말이 있더군요.

 

좋은 설계란 제대로 쓰기 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다. [Meyers05]

 

 

중복코드

잠시 돌아와서 우리가 왜 상속을 써야했는지 생각해보면 중복코드를 막기위해서 입니다.

 

 

중복코드를 왜 막아야하는 것일까요?

 

문제점1, 유지보수가 힘들다

당연한 말이지만 모든 로직이 중복적으로 구현되어 있다는 것은 수정 발생시 모든 곳에 수정이 발생합니다.

 

 

그러면 될 것 같지만, 중복코드를 모두 찾는 것은 상상만해도 힘든 작업입니다.

 

 

컴파일에러를 발생하는 것도 아니니깐 말이죠.

 

 

어찌어찌 모든 코드를 수정해도 수정된 코드가 있는 모든 모듈을 테스트해야겠죠.

 

 

가장 기피해야하는 상황입니다.

 

 

문제점2, 일관성

문제점1과 유사한 문제입니다.

 

 

중복코드를 수정할 때 기존 코드가 특별한 에러를 만드는 코드가 아니라면 중복이라 식별하기 어렵습니다.

 

 

따라서 몇몇 코드는 수정되지 못하게되고 로직의 일관성이 심하게 훼손됩니다.

 

 

문제점3, 중복은 중복을 낳는다.

 

1, 2번과 유사한 말입니다. 중복을 없애지 않고 새로운 기능을 부여하려면 모든 코드를 수정해야합니다.

 

 

중복코드가 새로운 중복코드를 부르는 꼴입니다. 반드시 잘라내야할 악순환입니다.

 

 

 

상속을 잘 사용하는 방법

 

그래서 상속을 사용하지 말아야하나? 아닙니다. 상속은 코드 중복을 줄여준다는 점에서 훌륭한 기술입니다.

 

 

상속보다 합성이 좋다고는 하지만 합성은 하지못하는 것들중 상속은 할 수 있는 것이 있기에 필요한 기능입니다.

 

 

책에서는 객체지향적 사고를 바탕으로한 훌륭한 상속 사용법을 소개합니다.

 

 

방법은 아래와 같습니다.

 

1. 변하는 것과 변하지 않는 것을 분리해라

2. 중복되는 것을 상위 클래스로 이동시켜라

3. 추상화에 의존해라

 

 

놀랍게도 각단계는 Solid윈칙을 내포하고 있습니다.

 

 

1번의 경우 OCP를 2번의 경우 SRP를 유발하고 3번의 경우 DIP구조를 만들어냅니다.

 

 

알뜰폰과 일반 휴대폰의 전화요금 책정 방식이 다르다는 요구사항을 예시로 들어 보겠습니다.

 

1. 변하는 것과 변하지 않는 것을 분리해라

 

해당 요구사항에서 변화는 것과 변하지 않는 것은 무엇인가요?

 

 

변하는 것은 통화당 요금을 책정하는 방식입니다. (알뜰폰의 경우 할인된 요금제가 적용됩니다.)

 

 

변하지 않는 것은 전화건수당 요금을 책정한다는 로직입니다.

 

2. 중복되는 것을 상위 클래스로 이동시켜라

 

앞서 언급한 내용들을 코드로 옮기면 다음과 같습니다.

 

 

변하지 않는 것은 상위로 변하는 것은 하위로를 지킨 코드입니다.

class 폰 {
    
    var 진행한통화: [통화]
    
    init(진행한통화: [통화]) {
        self.진행한통화 = 진행한통화
    }
    
    func 전체요금계산() -> Double {
        진행한통화.reduce(0, { 부분, 개별통화 in
            부분 + 단일요금계산(개별통화)
        })
    }
    
    // Abstract
    func 단일요금계산(_ 통화: 통화) -> Double { fatalError() }
}

class 알뜰폰: 폰 {
    override func 단일요금계산(_ 통화: 통화) -> Double {
        통화.시간 * 1.0
    }
}

class 일반폰: 폰 {
    override func 단일요금계산(_ 통화: 통화) -> Double {
        통화.시간 * 0.8
    }
}

 

※ Swift는 타입 식별자를 유니코드인 한글로 정의할 수 있습니다 개쩔죠?

 

 

3. 추상화에 의존해라

 

작성한 코드는 이미 3번내용을 지켰습니다.

 

 

상위 클래스는 추상 매서드인 "단일요금계산"에 의존하고 있기 때문입니다. 역전된 의존성 입니다.

 

 

추상클래스에 대한 구현은 하위 클래스로 부터 '주입'되는 것 역시 확인할 수 있습니다.

 

 

 

맻음말

이런 저런 시도를 했지만 상속은 까다롭고

 

 

명확한 이유없이 사용시 많은 위험을 야기합니다.

 

 

하지만 상속이 훌륭한 선택지가 될 수 있는 케이스들이 있음으로

 

 

책에서 배운 내용을 잘 숙지하려고 합니다.

 

 

좋은 책 집필해주신 조용호님께 감사를 전하며 글을 마칩니다.

 

 

다음장인 합성에 대해서도 많은 것을 깨닫게 된다면 글로 다뤄보겠습니다. 감사합니다.