본문 바로가기
OOP

[진행중]상속보다 합성을(composition over inheritance)

by 코딩공장공장장 2023. 12. 31.

 

 

메서드 수준에서 관심사 분리

 

메서드가 하나의 관심사만 갖도록 분리하자는 내용이다.

class GiveawayMachine {
      fun `당첨번호로 당첨고객 추출`() {
          // 당첨 번호 추출
          val 추첨번호 = (0..9).map { (0..9).random() }

          // api 요청으로 고객 정보 추출 로직
          val customRequest = HttpCustomRequest("https://www.mycorp.com")
          customRequest.setParameter("추첨번호", 추첨번호.toString())
          val CustomInfo = customRequest.get()
          customRequest.close()
      }

      fun `당첨코드로 당첨고객 추출`() {
          // 당첨 코드 추출
          val chars = ('A'..'Z')
          val 추첨코드 = (1..5)
              .map { chars.random() }

          // api 요청으로 고객 정보 추출 로직
          val customRequest = HttpCustomRequest("https://www.mycorp.com")
          customRequest.setParameter("추첨번호", 추첨코드.toString())
          val customInfo = customRequest.get()
          customRequest.close()
      }
}

 

위의 두 메서드는 ‘당첨번호 추출’, ‘고객 정보 추출’ 이라는 두 관심사가 모두 묶여있다.

 ‘고객 정보 추출’에 변경이 일어난다면 두 메서드 모두에서 모두 변경이 필요하다.

두 관심사를 포함하는 메서드가 더욱 많이 존재했다면  더욱 많은 변경을 필요로 했을 것이다.

 

메서드가 하나의 관심사만 갖도록 분리하면 각 메서드에서 변경이 일어난 경우 해당 메서드 한 곳에서만 수정을 진행하면 된다.

class GiveawayMachineComp() {
  fun `5자리 랜덤 숫자 추출`(): List<Int> {
      val 추첨번호 = (0..9).map { (0..9).random() }
      return 추첨번호
  }

  fun `5자리 랜덤 문자열 추출`(): List<Char> {
      // 당첨 코드 추출
      val chars = ('A'..'Z')
      val 추첨코드 = (1..5)
          .map { chars.random() }
      return 추첨코드
  }

  fun `당첨 고객 정보 추출`(추첨번호: List<Any>): CustomInfo {
      // api 요청으로 고객 정보 추출 로직
      val customRequest = HttpCustomRequest("https://www.mycorp.com")
      customRequest.setParameter("추첨번호", 추첨번호.toString())
      val customInfo = customRequest.get()
      customRequest.close()
      return customInfo
  }
}

 

 물론 정상적으로 동작하는지는 아래와 같이 테스트를 진행해야할 것이다.

class GiveawayMachineTest {
  @Test
  fun machineTest {
    val 추첨기계 = GiveawayMachine()
    val 추첨번호 = 추첨기계.`5자리 랜덤 숫자 추출`()
    val customInfo = 추첨기계.`당첨 고객 정보 추출`(추첨번호)
    print(customInfo)
  }
}

 

 

추상 클래스를 통한 변하는 부분과 변하지 않는 부분 분리

 

위의 경품 추첨 시스템을 라이브러리화하여 다른 기업들이 사용할 수 있게 구현해보자.

단, 고객 정보에 접근 하는 것은 각 기업들이 커스터마이징하여 사용할 수 있게끔 하자.

 

따라서 아래와 같이 '당첨 고객 정보 추출()'이라는 추상 메서드를 포함한 추상클래스를 제공하고 각 기업에서 상속을 받아 구현체를 만들어 사용할 수 있도록 하였다.

abstract class AbstractGiveawayMachine {
  fun `5자리 랜덤 숫자 추출`(): List<Int> {
      val 추첨번호 = (0..9).map { (0..9).random() }
      return 추첨번호
  }

  fun `5자리 랜덤 문자열 추출`(): List<Char> {
      // 당첨 코드 추출
      val chars = ('A'..'Z')
      val 추첨코드 = (1..5).map { chars.random() }
      return 추첨코드
  }

  protected abstract fun `당첨 고객 정보 추출`(추첨번호: List<Any>): CustomInfo
}

 

추상 클래스를 통해 변하지 않는 부분을 구현 메서드로 정의하고 변하는 부분을 추상 메서드로 제공하는 것이다.

즉, 변하는 부분은 상속을 통해 오버라이딩하여 기능을 확장시켜 나갈 수 있도록 하는 것이다.

 

이는 OCP와도 관련된 내용으로 기존 소스코드의 변경 없이 기능을 확장시켜 나갈 수 있다.

class AGiveawayMachine : AbstractGiveawayMachine() {
    override fun `당첨 고객 정보 추출`(추첨번호: List<Any>): CustomInfo {
        // api 요청으로 고객 정보 추출 로직
        val customRequest = HttpCustomRequest("https://www.a-corp.com")
        customRequest.setParameter("추첨번호", 추첨번호.toString())
        val customInfo = customRequest.get()
        customRequest.close()
        return customInfo
    }
}
@Test
fun machineTest() {
  val 추첨기계: AbstractGiveawayMachine = AGiveawayMachine()
  val 추첨번호 = 추첨기계.`5자리 랜덤 숫자 추출`()
  val customInfo = 추첨기계.`당첨 고객 정보 추출`(추첨번호)
  print(customInfo)
}

 

우리는 위와 같이 구현체를 만들고 테스트 코드를 통해 테스트 해볼 수 있다. 이전 소스코드와 다른 부분은 AGiveawayMachine 객체를 생성했다는 것이다. 이부분이 굉장히 중요하다. 설명은 뒤에 DI와 관련해서 하겠다.

 

추상 클래스를 상속하는 것의 장점은 상위 클래스에 구현부가 있기 때문에 하위 클래스에서 해당 메서드를 그대로 사용할 수 있다는 것이다. 하위 클래스에 기능이 그대로 복제된다.

 

하지만 이는 결국 치명적인 단점이 될 수도 있다. 바로 상위 클래스와 하위 클래스가 강하게 결합된다.

하위 클래스가 상위 클래스의 구현부를 알고 있는것 아닌가, 이부분에 대해서는 아래 ‘상속보다 합성을’ 에서 설명하겠다.



인터페이스를 통한 클래스 분리



 

이번에는 아에 ‘당첨 고객 정보 전달’ 메서드를 별도의 클래스로 분리하고 인터페이스로 정의하여 구현 클래스를 통해 사용하는 방식으로 구현해보겠다.

 

좀 전에 추상 클래스와 달리 단순히 인터페이스를 정의하고 구현하는게 아니라 별도의 인터페이스를 분리하고 해당 인터페이스 타입을 기존 GiveawayMachine에서 참조 속성으로 정의하였다.

이렇게 참조 속성을 두는 방식을 합성이라고 한다. 따라서 추상 클래스와 인터페이스의 차이로 볼게 아니라 상속과 합성에 대한 차이로 예제를 이해해야한다. 물론 관심사의 분리를 위해 클래스 자체를 분리했다는 것도 포인트이다.

interface CustomInfoExtract {
  fun `당첨 고객 정보 추출`(추첨번호: List<Any>): CustomInfo
}
class GiveawayMachineComp(
  val customInfoExtract: CustomInfoExtract,
){
      // 동일한 로직
}

 

위와 같이 인터페이스를 정의하고 GiveawayMachineComp에서 인터페이스 타입을 참조 속성으로 정의하였다.

fun main(args: Array<String>) {
  val 추첨기계 : GiveawayMachineComp = GiveawayMachineComp(ACustomerInfoExtract())
  val 추첨번호 = 추첨기계.`5자리 랜덤 숫자 추출`()
  val customInfo = 추첨기계.`당첨 고객 정보 추출`(추첨번호)
  print(customInfo)
}

 

그리고 위와 같이 사용할 수 있다. 

 

메서드를 호출하는 곳 아래 소스를 보면

val 추첨기계 : GiveawayMachineComp = GiveawayMachineComp(ACustomerInfoExtract())

 

 달라진 부분은  추상클래스와 객체를 생성하는 부분만 달라졌다.

 

이 객체 생성 부분이 달라진 것은 추상클래스에서 말한 것처럼 중요하다. 마찬가지로 DI를 설명하면서 함께 설명하겠다.

 

반응형

상속보다 합성을

상속이란 추상클래스나 구체클래스를 상속하는 것을 얘기하고,

합성이란 클래스나 모듈을 조합하는 즉, 클래스 안에 다른 클래스를 참조속성으로 가지고 있는 경우이다.

 

이전 예제는 단순히 관심사의 분리 개념만 적용된 것은 아니다.

객체지향 프로그래밍의 ‘상속보다 합성을’이라는 개념도 적용되어있다.

 

책의 예제에 나와있지 않은 한가지 예시를 더 들어보겠다.

 

 

우리의 경품 추천 솔루션이 업그레이드를 거듭하여 5자리 랜덤 숫자가 아니라 1~45의 숫자 중 5개를 뽑아서 추첨을 할 수 있는 시스템을 개발했다. 하지만 이 기능은 프리미엄 기능으로 추가 요금을 지불한 고객사에게만 제공할 것이다.

 

상속으로 구현한 경우 어떻게 할 것인가?

 

기존 AbstractGivawayMachine에 기능 추가할 수 있나? 하지 못한다. 왜냐하면 돈을 지불하지 않는 고객사들도 공짜로 사용할 수 있게 되기 때문이다.

 

그렇다면 새로운 상속관계를 하나 더 만들고 고객사들에게 새로운 클래스를 상속받게 하도록 요청할 수 있다.

만약 새로운 기능들이 지속적으로 추가되고 우리의 기능을 선택적으로 구매할 수 있게 솔루션을 제공한다면 이와 같은 상속구조는 더욱 복잡하게 일어날 것이다. 이같이 가능한 모든 조합에 대해 상속구조를 만들어주기 위해 수많은 클래스가 만들어지는 것을 클래스 폭발이라고 한다.

 

[여기서 잠깐] 클래스 폭발 문제

 

 

위 그림처럼 하나의 기능을 가진 두 추상클래스를 통해 만들 수 있는 모든 조합은2C1+2C2=3이다.

 

만약에 3가지 기능이 있다면 3C1+3C2+3C3=7으로 가능한 조합은 7개의 클래스이다. 

 

여기서 구현체가 반드시 하나일 필요는 없으므로 각 경우에 대해 구현체가 여러개라면 그 수는 더더욱 늘어날 것이다.

 

 

합성은 어떻게 되나 보자.

 

class GiveawayMachine(
  val customInfoExtract: CustomInfoExtract,
  val premiumMath: PremiumMath
) {
  fun `5자리 랜덤 숫자 추출`(): List<Int> {
      val 추첨번호 = premiumMath.random(length = 5)
      return 추첨번호
  }

  // 기존 로직 동일
}
interface PremiumMath {
    fun random(length: Int): List<Int>
}
class BasicExtract : PremiumMath {
    override fun random(length: Int): List<Int> {
        return (0..9).map { (0..9).random() }
    }
}
class PremiumExtract : PremiumMath {
    override fun random(length: Int): List<Int> {
        return (0..9).map { (1..45).random() }
    }
}

 

PremeiumMath을 합성하였고 기존 (0..9).map { (0..9).random() } 코드에서 premiumMath.random(length=5)로 변경되었고 생성자에 val premiumMath: PremiumMath가 추가되었다.

 

고객사의 소스코드는 변경할 필요가 없다.

 

만약 PremiumMath를 통해 더욱 다양한 기능을 제공한다면 우리는 새로운 클래스를 만들며 기존 소스코드 변경없이 확장시켜 나갈 수 있다.

 

또한 상속구조와 합성구조 도식화를 봐보자. ‘5자리 랜덤문자 추출’ 메서드에서 변경이 일어났을 때 변경에 영향을 직접적으로 받는 클래스의 갯수가 몇개인가?

 

상속의 경우 4개이다.

 

상속은 상위 클래스의 메서드가 하위 클래스로 그대로 복제 확장된다.

 

상속은 캡슐화가 깨져있는 구조이다. 

 

아래 그림에 복제된 코드를 색칠하였다. 해당 메서드들이 변경되는 해당 메서드를 포함하는 모든 클래스에 영향을 미친다.



그에 반면 합성은 어떤가, 기존의 GiveawayMachine 클래스 자신을 제외하고 그 어떤 클래스에도 영향을 미치지 않았다.

아래 합성 도식화를 보면 특정 클래스에 존재하는 메서드가 다른 클래스에도 동일하게 존재하는 경우가 없다.

주입 하는 객체들도 인터페이스에 의존하여 주입 되니 캡슐화가 깨지지 않았다.

즉, 해당 메서드의 책임이 각 클래스에서 다른 클래스로 확장 되지 않았다는 것이다.
(당첨 고객 정보 전달 메서드는 각자 오버라이딩하여 다르게 구현되니 당연히 고려하지 않는다.)

 



BasicExtract의 랜덤 숫자 추출() 메서드 변경은 BasicExtract에서 책임질 일이지 인터페이스를 통해 의존하여 BasicExtract의 구현부를 전혀 모르는 GiveawayMachine은 책임이 없다.

 

이처럼 상속은 구현부가 하위 클래스에 그대로 복제되니 구현부의 변경에 큰 영향을 받는다. 

반면에 합성은 인터페이스를 통해 캡슐화가 잘 지켜지기에 변경의 영향이 상대적으로 적다.

 

따라서 위에서 언급한 클래스 폭발이나 캡슐화, 결합도 측면에서 상속보다 합성을 사용하는 것이 좋은 방식이다.

 

IOC/DI는 아래 링크를 통해 계속 진행하겠다.

 

https://developer111.tistory.com/125

반응형