객체지향 #7부 - 상속과 합성을 통한 코드 재사용

객체지향 #7부 - 상속과 합성을 통한 코드 재사용

객체지향의 장점 중 하나인 코드 재사용에 기법 중 하나는 새로운 클래스를 추가하는 방법이다. 그 중 하나인 상속에 대해 살펴보자.

상속은 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법이다.
상속을 통해 코드를 재사용하면서도 새로운 클래스를 빠르게 구현할 수 있다.

재사용성 없는 중복 코드가 왜 싫어?

재사용성 없는 코드는 변경에 취약하다.
Don't Repeat Yourself! (DRY 원칙)

소프트웨어는 변경에 자유롭고 유연해야 한다. 이름 자체에 소프트가 들어가기도 한다.
중복 코드는 코드 수정에 필요한 노력을 몇 배로 증가시킨다.
뭐가 중복이지? 어디까지 퍼져있는거지? 뭐가 바뀌면 어떻게 바뀌는거지? 뭐가 영향을 받는거지? 등등
중복 코드의 수정 방법은 새로운 중복 코드를 생성하는 것이다.
점점 버그가 날 확률이 쌓여간다. 빨리 내 코드를 DRY 하게 수정해야 한다.

여기서 중복이란 모양이 아니라 변경에 대한 반응이 어떠한가? 이다.




중복 코드를 제거하는 방법 중 하나로 상속을 사용한다. 그러나 이 상속은 결합도를 높인다. 어떤 자식 클래스를 만들 때 부모 클래스에 대한 전체 지식이 필요하기 때문이다.
부모와 자식 클래스간에 강한 결합은 또 수정을 힘들게 하는 악순환을 만든다.

재사용을 위해 상속을 사용할 경우, super 라는 키워드를 최대한 배제하여야 한다. (결합도의 원인)

취약한 기반 클래스 문제

super사용, 잘못된 상속 설계의 상태에서 재사용 목적으로 상속을 사용할 때 자식 클래스가 부모 클래스에 취약해지는 경우를 취약한 기반 클래스 문제 라는 명칭을 가진다.


기반인 부모 클래스를 살펴보는 것 만으로 변경에 대한 확신을 가질 수 없고, 상속 받은 자식 클래스에 대한 전체 점검을 해야 하게 된다.



상속에 대한 4가지 취약한 기반 클래스 문제 예시

  • 강하게 부모와 자식이 결합된다.
    • 상속은 자식이 부모 클래스의 세부사항에 의존하도록 하기 때문에 캡슐화를 약화시킨다.
  • 부모 클래스가 취약한 기반을 가지고 있으면 자식도 부실하다.
    • 상속받은 부모 클래스의 메서드가 자식 클랙스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
  • 자식이 부모의 불필요한 인터페이스를 상속받을 수 있다.
    • 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
  • 부모 클래스의 변경이 자식 클래스에 영향을 미친다.
    • 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다.
    • 부모 클래스는 시간이 지남에 따라 변경될 수 있고, 자식 클래스는 부모 클래스의 변경에 영향을 받는다.


상속은 재사용성을 위해 캡슐화를 희생하는 선택이다.




그럼 상속을 쓰지 말라는거야?

아니다, 추상화에 더 신경쓰면 리스크를 줄여서 쓸 수 있다.
코드 중복을 제거하기 위해 상속을 도입할 때 따르는 원칙 두 가지는 아래와 같다.

  • 두 메서드가 유사해보이면 차이점을 추출해봐라.
    • 다 추출해봤는데 동일해보이면 합칠 수 있다.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.
    • 부모 클래스의 구체 메서드를 자식으로 내리는 것 보다 자식 클래스의 추상 메서드를 부모로 올리는 것이 재사용성, 응집도에서 더 좋은 결과를 얻는다.



두 가지 원칙 사용해 상속 보완하여 사용하기 (추상화)

중복 코드를 펼쳐놓고 차이점을 별도의 메서드로 추출하는 것이 시작이다.

변하는 부분을 찾고 이를 캡슐화하라.

//file: `공통 추출 추상화 하고 싶은 두 객체`

// Rectangle 클래스
class Rectangle {
  String color;

  void draw() {
    System.out.println("색깔: " + color);
    System.out.println("사각형을 그립니다.");
  }
}

// Circle 클래스
class Circle {
  String color;

  void draw() {
    System.out.println("색깔: " + color);
    System.out.println("원을 그립니다.");
  }
}


위의 예시에서 Rectangle과 Circle 클래스의 draw 메서드는 유사하다.
그러나 차이점이 있다면 사각형을 그리는지 원을 그리는지에 대한 차이점이다.
이 두 객체를 추상화하여 차이점을 분리하면 공통된 부분인 색깔그리기가 나온다.
추출된 색깔 그리기를 공통된 부분을 부모로 올려 부모 객체를 생성해주고, 다른 메서드를 각각 구현하면 아래 예시와 같다.

//file: `공통 추상화`

// 추상 클래스 Shape
abstract class Shape {
    String color;

    void draw() {
        System.out.println("색깔: " + color);
        drawShape();
    }

    abstract protected void drawShape();
}

// Rectangle 클래스는 Shape를 상속받음
class Rectangle extends Shape {
    @Override
    void drawShape() {
        System.out.println("사각형을 그립니다.");
    }
}

// Circle 클래스는 Shape를 상속받음
class Circle extends Shape {
    @Override
    void drawShape() {
        System.out.println("원을 그립니다.");
    }
}


이제 Rectangle과 Circle 클래스는 서로 다른 변경의 이유를 가지게 된다.
놀랍게도 공통으로 추출된 Shape도 하나의 변경 이유만을 가지게 된다.
부모 - 자식간의 추상화된 메서드에만 의존하게 설계가 바뀌었기 때문이다.
또한 새로운 모양에 대한 추가도 쉽게 가능해졌다.




아무리 고쳐도 리스크 있는 상속, 굳이 써야해?

상속에 대한 결합을 완벽히 피할 수 있는 방법은 없다.
위의 두 가지 (차이점 추출 / 추상화) 기법을 사용해도 부모 클래스에서 인스턴스 변수 추가를 하면 자식은 모두 영향을 받는다.(생성자 변경 때문)
중복 코드의 제거를 위해 일종의 리스크를 안고 상속을 사용하는 것이다.
그래도 가급적 변화에 대한 전파 영향은 인스턴스 추가가 적기 때문에 기능에 대한 추상화를 진행하고 상속을 사용하는게 좋다.

이런 리스크 없는 방법은 없을까? 있다.
상속 이후에 많은 개발자들이 고민하여 내놓은 대답은 합성이다.




합성이 상속보다 좋아?

합성은 부분 코드를 재사용한다. 그리고 상속은 컴파일시에 의존성이 해결되지만, 합성은 런타임시에 의존성이 해결된다.
합성은 내부에 포함되는 구현이 아닌 인터페이스에 의존하기 때문에 낮은 결합을 가지고,변경에 대한 영향이 적어 안정적인 코드를 얻을 수 있다.

목적이 코드 재사용에 한정된다면, 합성은 상속보다 더 좋은 방법이다.



합성의 문제 해결 방식

  • 합성은 부모의 불필요한 오퍼레이션을 제거할 수 있다.
    • 새롭게 정의된 객체에서 필요한 오퍼레이션만을 정의할 수 있다.
    • 의도치 않은 기능이 추가되어 발생하는 문제를 방지할 수 있다.
//file: `경우1 - 부모의 오퍼레이션이 불필요했던 경우`

class Engine {
  void start() {
    // 엔진 시작 로직
  }

  void stop() {
    // 엔진 정지 로직
  }
}

class Car {
  private Engine engine;

  Car(Engine engine) {
    this.engine = engine;
  }

  void startCar() {
    engine.start();
  }
}


합성을 사용하면, 부모 클래스의 불필요한 오퍼레이션을 상속받지 않고, 필요한 기능만을 선택적으로 사용할 수 있습니다.
예를 들어, Engine 클래스가 있고, 이를 Car 클래스가 사용하는 경우를 생각해볼 수 있습니다.



  • 퍼블릭 인터페이스를 그대로 제공해야 할 경우, implements로 해결 가능하다.
    • 수정되지 않아도 될 메서드들을 포워딩 메서드로 제공한다.
//file: `경우2 - 퍼블릭 인터페이스를 그대로 제공해야 할 경우`

interface Vehicle {
  void start();
  void stop();
}

class Engine {
  void start() {
    // 엔진 시작 로직
  }

  void stop() {
    // 엔진 정지 로직
  }
}

class Car implements Vehicle {
  private Engine engine;

  Car(Engine engine) {
    this.engine = engine;
  }

  
  //Car에서 Engine의 start 메서드를 재정의
  @Override
  public void start() {
    //custom logic 추가  
    engine.start();
  }

  //포워딩 메서드
  @Override
  public void stop() {
    engine.stop();
  }
}

합성을 사용하면서도, 특정 인터페이스를 구현하는 클래스를 만들 수 있습니다.
이를 통해 외부에 제공되는 인터페이스는 그대로 유지하면서 내부 구현을 변경할 수 있습니다.
예를 들어, Vehicle 인터페이스를 구현하는 Car 클래스를 생각해볼 수 있습니다.



  • 인터페이스로 제공하기 때문에, 부모 변경의 영향 전파를 막을 수 있다.
//file: `경우3 - 부모 변경의 영향 전파를 막을 수 있다.`


class Engine {
  void start() {
    // 엔진 시작 로직
  }

  // 엔진 클래스가 변경되어도 Car 클래스는 영향을 받지 않음
  void newFunction() {
    // 새로운 기능
  }
}

class Car {
  private Engine engine;

  Car(Engine engine) {
    this.engine = engine;
  }

  void startCar() {
    engine.start();
  }
}


위의 예제에서는 Engine 클래스가 변경되더라도 Car 클래스는 영향을 받지 않습니다.
그러나, engine의 start 메서드가 변경되어도 Car로 영향 전파를 최소화 할 수 있습니다.



상속의 경우의 수 문제점

앞서 설명한 상속은 중복 코드 발생에 대한 문제점이 있었다.
이를 해결하기 위해 추상화를 통해 공통된 부분을 부모로 올리는 방법을 사용했다.
그러나 이는 그냥 완화일 뿐, 실제 문제 발생에 대한 해결은 되지 않는다.


상속은 추상화 - 구현체에 대해 조합이 가능한 개수만큼 구현체가 늘어나야 한다.
예를 들어 Car와 Truck 각각에 대해 GasEngine과 ElectricEngine을 적용하기 위해 총 4개의 클래스를 생성해야 한다.
만약 엔진 타입이나 차량 타입이 더 많아진다면, 필요한 클래스의 수는 기하급수적으로 증가하게 된다.

//file: `상속을 사용한 확장의 폭발 예시`

class GasCar extends Vehicle {
}

class ElectricCar extends Vehicle {
}

class GasTruck extends Vehicle {
}

class ElectricTruck extends Vehicle {
}

여기에 새로운 차량 타입이 추가되면, 새로운 Gas, Electric클래스를 추가해야 한다.



이런 문제를 클래스 폭발, 조합의 폭발 문제라고 한다.
상속의 한계는 코드 재사용을 촉진하지만, 다양한 조합을 필요로 하는 경우에는 유연하지 못하고 관리가 어려워진다.
이러한 문제를 해결하기 위해서는 합성과 같은 다른 접근 방법을 고려하는 것이 좋다.
합성을 사용하면, 각각의 기능을 독립적인 클래스로 분리하여 필요에 따라 조합할 수 있으므로, 조합의 폭발 문제를 효과적으로 해결할 수 있다.


상속관계는 컴파일 타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에 변경이 불가능하다.
따라서 여러 기능을 조합해야 하는 설계에 상속이 들어가면 경우별로 클래스를 추가해야 한다.



합성은 동일한 상황에서 확장과 변경이 필요한 경우에도 유연하게 대처할 수 있다.
런타임 시점에 결정이 된다는 점은 상속과 합성의 가장 큰 차이점이다.
역할만 잘 정의되어 있다면, 어떤 객체가 들어와도 잘 동작할 수 있기 때문에, 확장과 변경이 필요한 경우에도 유연하게 대처할 수 있다.

//file: `합성을 사용한 예시`

interface Engine {
  void start();
  void stop();
}

class GasEngine implements Engine {
  @Override
  public void start() {
    // 가스 엔진 시작 로직
  }

  @Override
  public void stop() {
    // 가스 엔진 정지 로직
  }
}

class ElectricEngine implements Engine {
  @Override
  public void start() {
    // 전기 엔진 시작 로직
  }

  @Override
  public void stop() {
    // 전기 엔진 정지 로직
  }
}

class Vehicle {
  private Engine engine;

  Vehicle(Engine engine) {
    this.engine = engine;
  }

  void start() {
    engine.start();
  }

  void stop() {
    engine.stop();
  }
}

class Car extends Vehicle {
  Car(Engine engine) {
    super(engine);
  }
}

class Truck extends Vehicle {
  Truck(Engine engine) {
    super(engine);
  }
}

엔진의 역할과 차량을 분리해서 차와 엔진을 합성시켰다.
사용자의 입장에서는 더이상 엔진+트럭의 경우의 수 만큼 클래스가 필요한 것이 아니라
런타임 시점에 원하는 엔진, 원하는 차를 조합하여 사용할 수 있다.




결론

결국 재사용성적인 측면에서 합성이 상속보다 좋은 방법이다.
좋은 사용법은 인터페이스로 역할에 대한 추상화를 하고, 그 책임을 수행하는 구체적인 객체들에 대해서 경우에 따라서 Base abstract class 를 만들고, 이를 상속받아 구현하는 것이다.
상속을 재사용성을 줄이기 위해서가 아닌, 타입 계층을 구조화 하기 위해 사용해야 한다.

상속을 사용하는 목적이 코드의 재사용성이 아니라, 클라이언트 입장에서 동일하게 행동하는 그룹으로 묶기 위함이어야 한다.
런타임시에 다형성을 통해 객체끼리 메세지를 처리하기 위한 적합 객체를 찾아내는 것이고,
상속은 이런 메세지를 찾기 위해 탐색 경로를 클래스 계층으로 구현하기 위한 방법이라는 점을 깨달을 수 있다.

다음에는 설계에서 재사용성 이상의 의미를 찾기 위해 다형성에 대한 내용을 정리하고자 한다.