상속보단 조립(조합, 컴포지션)을 사용해라.

상속보단 조립을 사용해라.

(Prefer composition over inheritance)

Inheritance Is Evil. Stop Using It.

상속의 단점

상속을 사용하는 것은 아주 위험하고 해롭다. 상속을 사용하는 것을 지금 당장 멈춰라. 여러 개의 메서드와 필드를 가진 부모 클래스를 만든다고 상상해봐라. 그리고 그 클래스를 확장(extends)해서 자식 클래스를 만든다고 상상해라. 그러면 앞으로 어떤 일이 벌어질 것인가?

  • 어떤 클래스를 상속받는다는 것은 그 클래스에 의존한다는 뜻이다. 이 때문에 상위 클래스의 변경이 하위 클래스에 영향을 주기 때문에, 최악의 경우 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다. 이런 이유 때문에, 클래스 계층도가 커질수록 상위 클래스를 변경하는 것은 점점 어려워진다.
  • 상속을 하면 mocking을 사용해서 테스트를 할 수가 없어서, 테스트하기가 어렵다.
  • 상속을 하면 유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다. 예를 통해 살펴보자. 파일 보관소를 구현한 Storage 클래스가 있다고 가정하자. ‘압축 기능을 추가한 보관소’와 ‘파일을 암호화해서 저장해주는 보관소’를 추가해달라는 요구사항이 들어와서, 코드의 재사용을 줄이기 위해 Storage를 상속받아 CompressedStorage, EncryptedStorage 클래스를 만들었다.

    그런데 만약 ‘압축을 먼저 하고 암호화하는 저장소’를 만들려고 하는데, 코드 재사용을 줄이기 위해 상속을 활용한다고 가정해보자. 하지만, 자바에서는 다중 상속을 지원하지 않기 때문에 CompressedStorageEncryptedStorage를 동시에 상속해서 압축을 먼저 하고 암호화하는 저장소인 CompressedEncryptedStorage 클래스를 만들 수가 없다. 어쩔 수 없이 한 개의 클래스만 상속받고 다른 기능은 별도로 구현해야 한다. 이러한 형태로 필요한 기능의 조합이 증가할 수록, 클래스의 개수는 함께 증가하게 된다.

  • 상속을 하게 되면 부모 클래스의 private을 제외한 모든 메서드를 상속받으므로, 나도 모르게 클라이언트한테 자식 클래스에 대한 API를 제공할 떄 오해의 여지가 있을만한 불필요한 메서드들을 제공하게 된다. 이로 인해 코드를 오용하는 경우가 발생한다.

상속 대신에 조립을 사용하는 방법

조립을 사용하면 상속의 단점을 완전히 해결할 수 있다. 그리고 상속으로 구현할 수 있는 건 모두 조립으로 구현할 수 있다. 어떻게 상속 대신에 조립으로 모두 대체할 수 있는 지 알아보자.

  • 클래스를 사용할 때, 인터페이스 클래스final 클래스만 사용해라. (extends를 사용하는 클래스, abstract를 사용하는 추상 클래스는 사용하지 마라.)

    → 인터페이스를 사용하면 각각의 객체가 어떤 식으로 로직을 수행하는 지(구현부)를 알 수가 없기 때문에, 객체 간의 협력을 위주로 생각하게 된다.

  • 클래스의 생성자에 의존성을 주입할 경우에는 반드시 ‘인터페이스’만 주입해라.

    → 여러가지 의존을 하는 방법 중에 인터페이스를 주입하는 방법만을 사용하는 것은 여러 부작용을 제거할 것이다. 인터페이스 구현을 변경하더라도 인터페이스 추상화에만 의존하기 때문에 다른 콘크리트 클래스에는 영향을 주지 않게 된다.

    → 인터페이스 덕분에 mocking을 사용해서 테스트를 쉽게 할 수 있다.

  • 인스턴스를 만드는 것을 관리할 때, DI Container(Dependency Injection Container)를 사용해라.
  • 만약 하나의 클래스에 너무 많은 의존성을 주입한다고 느낀다면, 클래스의 책임이 큰 건 아닌 지 체크하고 인터페이스 분리 원칙에 입각해 다시 체크해봐라.
  • 필요에 따라 특정 인터페이스를 구현하기 위해, 복잡한 행동(로직)을 여러 개의 final 클래스로 분리한다.
  • 기본 동작의 변경 없이 의미론적 수준에서 확장 목적으로만 의미가 있을 때’만’ 상속을 사용한다.

상속보다 조립을 사용하라는 이유

  • 상속을 사용하다보면 변경의 관점에서 유연함이 떨어진 가능성이 높다.
  • 상속에 비해 조립을 사용하면 구조/구현이 조금 더 복잡해지지만, 변경의 유연함을 확보하는 데서 오는 장점이 더 크다.

그렇다면 상속은 언제 사용할까?

밑의 모든 조건을 만족할 때 상속을 사용할 수 있다.

  1. 상속을 사용할 때는 재사용이라는 관점이 아닌 기능의 확장이라는 관점에서 상속을 적용해야 한다.
  2. 명확한 IS-A 관계가 성립되어야 한다.
  3. 상위 클래스의 기능을 하위 클래스가 확장해 나갈 때 사용할 수 있다.

하지만 여전히 조립 대신에 상속으로 구현하는 것을 추천하지 않는다. 왜냐하면 위의 조건을 만족한다고 하더라도 클래스의 개수가 불필요하게 증가하는 문제가 발생하거나, 상위 클래스의 변경이 어려워지는 등 상속의 단점이 발생할 가능성이 크다.

상속 대신에 조립을 활용하면 코드 중복이 발생하지 않을까?

Is “composition over inheritance” violating “dry principle”?

밑의 코드를 실행시키는 클래스를 작성한다고 가정하자.

HomePage homePage = new HomePage();
homePage.checkSessionValid();
homePage.getUserId();

EditInfoPage editInfoPage = new EditInfoPage();
editInfoPage.checkSessionValid();
editInfoPage.getUserId();

상속, 조립을 사용하기 전 코드

public class HomePage {
    private String userId;
    private String session;
    
    public boolean checkSessionValid() {
        ...
    }

    public String getUserId() {
        ...
    }
}

public class EditInfoPage {
    private String userId;
    private String session;
    
    public boolean checkSessionValid() {
        ...
    }

    public String getUserId() {
        ...
    }
}

위 코드를 봤을 때, HomePage 클래스와 EditInfoPage 클래스의 중복이 많이 발생한 것을 볼 수 있다. 그래서 중복을 없애기 위해 HomePageEditInfoPage를 추상화한 LoginPage를 만들고 상속을 활용해보자.

상속을 사용한 코드

public class LoginPage {
    private String userId;
    private String session;
    
    public boolean checkSessionValid() {
            ...
    }

    public String getUserId() {
            ...
    }
}

public class HomePage extends LoginPage {
}

public class EditInfoPage extends LoginPage {
}

상속을 사용해보니 기존의 코드보다 중복이 줄어서 훨씬 간결해진듯하다. 하지만 ‘상속보다는 조립을 사용해라(Prefer Composition Over Inheritance)‘라는 지침을 들어본 적이 있을 것이다. 그래서 상속 대신에 조립을 사용해서 코드를 수정해보자.

조립을 사용한 코드

public class LoginPage {
    private String userId;
    private String session;

    public boolean checkSessionValid() {
            ...
    }

    public String getUserId() {
            ...
    }
}

public class HomePage {
    private LoginPage loginPage;

    public boolean checkSessionValid() {
            return loginPage.checkSessionValid();
    }

    public String getUserId() {
            return loginPage.getUserId();
    }
}

public class EditInfoPage {
    private LoginPage loginPage;

    public boolean checkSessionValid() {
            return loginPage.checkSessionValid();
    }

    public String getUserId() {
            return loginPage.getUserId();
    }
}

조립을 사용해보니 생각보다 중복된 코드가 많이 발생한다. 만약 LoginPage 클래스의 메서드가 더 늘어난다면 HomePageEditInfoPage 클래스에 중복된 코드들도 같이 늘어날 것이다.

그렇다면 DRY(Don’t Repeat Yourself)의 원칙을 어기는 것임과 동시에, 중복된 코드가 많이 발생해서 유지 보수에 불편한 것은 아닐까?

(먼저 DRY에 대한 오해를 풀기 위해 밑의 페이지를 참고해라. - DRY (Don’t Repeat Yourself))

위에서 코드들을 봤을 때, 상속(Inheritance)은 자식 클래스가 부모 클래스의 public 메서드들을 그대로 물려받기 때문에 여러 줄의 코드들을 줄여준다. 그래서 만약에 부모 클래스에 10개의 메서드가 있다면, 각각의 자식클래스에 10개의 메서드를 일일이 작성해줄 필요가 없다.

만약 조립(Composition)으로 구현을 했다면, 새로 생성하려는 클래스마다 10개의 메서드 각각을 처리해주는 위임 메서드를 작성해야만 한다. 하지만 이러한 비용은 감수할만하다. 왜냐하면 위임 메서드에서는 실제 논리를 포함하지 않는다. 즉, DRY 원칙을 지킨 거싱다. 따라서 해당 논리를 변경해야 하는 일이 발생하더라도 해당 로직을 가지고 있는 한 곳에서만 로직을 수정하면 된다.

따라서 조립(Composition)을 사용함으로써 발생하는 비용은 상속(Inheritance)의 단점들에 비하면 아주 미미하므로, 상속(Inheritance)보다 조립(Composition)을 사용할 것을 추천한다.