본문 바로가기
Framework & Lib & API/스프링

[스프링 1-2] IOC/DI와 DIP

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

이전 포스팅에서 관심사의 분리와 상속보다 합성에 대해서 다루었다.

 

이번 포스팅에서는 IOC/DI와 DIP에 대해 알아보겠다.

 

목차

  • 관심사의 분리
  • 상속보다 합성
  • IOC/DI
  • DIP
  • 동일 타입 빈이 여러개인 경우
  • 빈 이름 중복
  • DI 구현 방법
  • 스프링의 IOC Context
  • 싱글톤 레지스트리

 

IOC(Inversion of Control, 제어의 역전)

제어의 역전이란 프로그램의 제어 흐름 구조가 뒤바뀌는 것이다.

프로그램의 제어권이 개발자가 작성한 코드에 있는 것이 아니라 외부에 존재하는 것을 말한다.

이때 프로그램의 제어권에 포함되는 내용은 사용할 객체 결정, 객체 생성, 메서드 호출 등 프로그래밍 언어로 표현할 수 있는 모든 행위를 포함한다. 

제어의 역전을 통해 제어권을 외부에 넘긴다는 것은 그에 대한 책임도 넘긴다는 것이다.

 

예를 들면 IOC 개념이 적용된 DI(의존주입) 프레임워크에서 객체 생성과 사용 객체 결정은 프레임워크가 제공하니 이에대한 책임도 프레임워크가 갖고 개발자는 객체 생성, 사용 객체 결정에 대한 책임에서 벗어난다.
(프로그래밍 코드로 책임을 벗어날 수 있다는 것, xml 설정이나, 자바 config, 어노테이션 설정 같은 것은 필요할 수 있음)

 

제어의 역전 예시

  • DI : 객체 생성 및 생명 주기 관리 제어의 역전
    객체 결정, 생성, 사용, 관리의 책임을 외부로 돌림(일반적으로 사용하는 쪽 책임)
    => 사용하는 쪽에서 직접 생성자를 통해 객체를 생성하지 않고 스프링 IOC 컨테이너가 미리 생성된 객체의 참조를 주입시켜줌
  • 프레임워크 : 실행 로직 제어의 역전
    개발자의 소스코드가 프레임워크에 의해 실행됨(템플릿/콜백 메서드)
    => 프레임워크가 적절할 시점에 나의 소스코드를 실행켜줌 예를들면 사용자의 요청이 온 경우 서블릿을 통해 전달 받은 클라이언트 요청을 내가 정의한 컨트롤러의 경로와 http 메서드 타입에 일치한 메서드를 실행시킴 

위와 같이 제어의 역전은 객체 생성과 생명주기에 관하여 적용될 수 있고, 실행 로직 전체가 제어의 역전 개념이 포함될 수 있다.

 

DI에 적용된 객체 생성과 관리에 대한 제어의 역전 개념을 좀 더 살펴보자

우리(객체)는 우리가 어떤 객체를 사용할지와 객체 생성, 객체가 갖고 있는 행위의 사용 권한을 갖고있다.

당연히 이에 대한 책임도 우리가 가지고 있다.

하지만 제어의 역전을 적용하면 사용 객체 결정, 객체 생성, 메서드 호출과 같은 권한을 외부에게 넘기고 그에 대한 책임도 외부에서 갖게끔 할 수 있다. DI는 객체결정과 객체 생성의 책임을 외부에 넘기는 것이다.
객체를 사용하는 쪽에서 생성자를 통해 생성하지 않아도 되며 어떤 객체를 사용할지 구체적으로 알 필요도 없다.

 

반응형

 

DI(Dependency Injection, 의존 주입)

DI는 의존관계주입이라는 용어로 IOC 개념이 적용되어있다.

의존에 대해서 정의하고 가자. ‘의존한다’는 것은 객체의 변경이 다른 객체에 영향을 미친다는 것이다.

class Hamburger(val source: Source, val patty: Patty){
  fun price() {
      return source.price()+patty.price()
  }
}

 

위 클래스에서 Patty 객체의 price가 변경되면 Hamburger 객체에도 영향을 미친다.

하지만 Hamburger 객체의 변경은 patty에 영향을 미치지 않는다.

 

이렇게 의존성은 객체의 변경이 다른 객체의 변경에 영향을 미치는지 여부로 판단하고

두 객체의 의존성에는 방향성이 존재한다.

 

위의 경우에서는 Hamburger → Patty로 Hamburger는 Patty에 의존하지만 Patty는 Hamburger에 의존하지 않는다.

 

그렇다면 의존관계 주입이라는건 무엇인가?

 

의존관계 주입은 의존하는 객체를 주입시켜준다는 것이다.

 

객체 주입은 실제 객체 주입이라기 보다 참조 주소값을 주입시켜주는 것이다. 각각의 객체는 힙영역에 그대로 존재한다. 객체가 복사되어 의존하는 객체에 포함되는 것은 아니다.

 

이제 DI 의존관계 주입의 정의를 다시 내리면 IOC 개념이 적용되어 의존하는 객체를 외부에서 주입시켜주는 것이다. 즉, 생성자를 통해 직접 생성하여 사용하지 않아도 된다는 것이다.

 

이전 포스팅의 예제를 다시 사용하여 IOC와 DI에 대해 알아보자.

class GiveawayMachineComp(
  val customInfoExtract: CustomInfoExtract,
) {
  fun `5자리 랜덤 숫자로 고객 정보 추출`(): CustomInfo {
      val 추첨번호 = (0..9).map { (0..9).random() }
      return customInfoExtract.`당첨 고객 정보 추출`(추첨번호)
  }

  fun `5자리 랜덤 문자열로 고객 정보 추출`(): CustomInfo {
      // 당첨 코드 추출
      val chars = ('A'..'Z')
      val 추첨코드 = (1..5)
          .map { chars.random() }
      return customInfoExtract.`당첨 고객 정보 추출`(추첨코드)
  }
}

 

우리는 위와 같은 클래스를 실행시키기 위해 아래와 같이 소스 코드를 구현하였다.

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

 

GiveawayMachineComp 생성자를 보면 CustomInfoExtract 객체를 생성하여 주입시켜주었다.

 

만약 다른 객체로 변경이 필요하다면 프로그램의 제어권을 가진 main 메서드에서 GiveawayMachineComp객체의 생성에 대한 책임을 가지고 있기 때문에 해당 메서드에서 GiveawayMachineComp의 참조 속성 객체를 다른 객체로 생성하여 주입 시켜줘야한다.

 

하지만 DI 가 적용된다면 위와 같은 객체 생성의 책임을 외부에 넘겨 변경 없이 진행할 수 있다.

@SpringBootTest
class GiveawayMachineCompDITest(
  @Autowired
  val giveawayMachineComp: GiveawayMachineComp,
) {
  @Test
  fun test() {
      val 고객정보 = giveawayMachineCompDI.`5자리 랜덤 문자열로 고객 정보 추출`()
      print(고객정보)
  }
}

 

SpringBootTest를 통해 진행하였다.

 

GiveawayMachineComp를 Autowired를 통해 의존 주입받게 되면 GiveawayMachineComp의 참조속성인 CustomInfoExtract도 함께 생성하여 주입시켜준다.

 

코드를 보더라도 어떤 곳에서도 객체를 생성하는 코드가 없다.

 

참고로 GiveawayMachineComp와 CustomInfoExtract에 @ComponentScan 어노테이션을 추가하여 스프링 빈으로 등록하였다.(스프링  빈에 대한 설명은 뒤이어)

 

DI는 이렇게 객체 생성의 책임을 외부에 돌려 객체 생성에서 자유로울 수 있다.

즉, 개발자가 객체 생성 관련된 코드를 구현하지 않아도 된다는 것을 의미하며 클래스의 참조속성이 수시로 변경되더라도 사용하는 쪽에서 정확히 말하면 객체 생성이 필요한 곳에서 우리가 코드 변경할 필요가 없다는 것이다.

 

아래와 같이 GiveawayMachine에 변경이 생겼다고 해보자.

class GiveawayMachineComp(
  val customInfoExtract: CustomInfoExtract,
  val testClass: TestClass1,
  val testClass: TestClass2,
  val testClass: TestClass3,
)

 

TestClass1,2,3가 참조속성으로 추가되었다. 

 

객체 생성을 우리가 직접 해야할 경우 어떻게 변하겠나

val 추첨기계 = GiveawayMachineComp(ACustomerInfoExtract(), TestClass1(), TestClass2(), TestClass3())
 

TestClass1,2,3가 아래와 같이 참조 속성이 추가되었다면

class TestClass1(val testClassDepth1: TestClassDepth1)
class TestClass2(val testClass2Depth1: TestClass2Depth1)
class TestClass3(val testClass3Depth1: TestClass3Depth1)

 

우리의 코드는 다시 이렇게 변할 것이다.

val 추첨기계 = GiveawayMachineComp(ACustomerInfoExtract(), TestClass1(TestClassDepth1()), TestClass2(TestClass2Depth1()), TestClass3(TestClass3Depth1()))

 

만약 GiveawayMachineComp를 사용하는 곳이 많다면 모든 곳에서 위와 같은 변경을 수시로 진행시켜줘야한다.

 

할일이 굉장히 많아지며 가독성 또한 좋지 않다.

 

하지만 @Component 어노테이션을 각 클래스에 붙이면 스프링은 해당 클래스를 객체화하고 @Autowired 어노테이션이 붙은 참조속성에 해당 객체를 주입시켜준다. 

스프링의 DI 방식이다. 

 

IOC 개념이 적용된 DI를 잘 사용하기 위해서는 DIP 원칙을 지키는 것이 좋다.

 

 

DIP

  • 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

 

즉, 상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다는 것으로 그림으로 표현하면 아래와 같다.

 

 

상위 클래스인 햄버거가 하위 클래스인 소고개 패티에 의존하지 않고 중간에 패티라는 인터페이스 타입의 추상화에 상위클래스와 하위 클래스 모두 의존하는 방식을 DIP라고 한다.

 

추상화에 의존함으로써 서로의 구체적인 정보를 알지 못하게 함으로써 결합도를 느슨하게 할 수 있다.

 

그렇다면 왜 DI를 DIP를 통해 구현하는게 바람직하는 것일까

 

아래와 같은 에제를 통해 알아보겠다.

interface Patty {
  fun price(): Int
}

class BeefPatty() : Patty {
  override fun price(): Int {
      return 2000
  }
}

class PorkPatty() : Patty {
  override fun price(): Int {
      return 1800
  }
}

class ChickenPatty() : Patty {
  override fun price(): Int {
      return 1500
  }
}
class Hamburger(val patty: Patty) {

  fun hamburgerPrice(): Int {
      return patty.price() + 1000
  }
}
 

위와 같이 햄버거와 패티의 의존관계를 설정하고 패티의 구현체들을 생성하였다.

@Configuration
class AppConfig {
  @Bean
  fun beefPatty(): BeefPatty {
      return BeefPatty()
  }
  @Bean
  fun chickenPatty(): ChickenPatty {
      return ChickenPatty()
  }
  @Bean
  fun porkPatty(): PorkPatty {
      return PorkPatty()
  }
  @Bean
  fun hamburger(): Hamburger {
      return Hamburger(beefPatty())
  }
}

 

그리고 AppConfig에 스프링 빈 설정을 하고 위와 같이 Hamburger에 beefPatty를 넣어주었다.

@SpringBootTest
class HamburgerTest(
  @Autowired
  val hamburger: Hamburger,
) {
  @Test
  fun test() {
      print(hamburger.hamburgerPrice())
  }
}

// 결과 : 3000

 

그리고 스프링부트 테스트를 통해 실행하니 3000원이 나왔다. beefPatty 2000원에 기본값 1000원이 더해져 3000원이 나왔다. 

 

이번에 AppConfig에 beefPatty 대신 porkPatty 객체를 주입시켜보자.

@Bean
fun hamburger(): Hamburger {
  return Hamburger(porkPatty())
}

 

이때 테스트를 다시 진행시켜 보면 결과는 2800원이 나온다. porkPatty 1800원에 기본값 1000원이 더해졌으니 결과값이 맞다.

 

우리는 아무런 자바 소스 코드를 변경하지 않고 config 설정만 변경하여 소고기 패티에서 돼지고기 패티로 변경시켰다.

 

DI를 할 때 DIP 원칙을 지킨다면 위와 같이 기존 소스 코드 변경없이 기능을 변경시키는 느슨한 결합을 가져갈 수 있다.

 

사실 위와 같이 AppConfig를 통해 의존관계를 설정할 수 있지만 Config 없이 각 클래스에 직접 @Component, @Controller, @Service 등과 같은 어노테이션을 붙여 빈으로 등록하여 사용하는 경우가 있다.

 

자 그렇다면

interface Patty {  ...  }
@Component
class BeefPatty() : Patty {  ...  }
@Component
class PorkPatty() : Patty {  ...  }
@Component
class ChickenPatty() : Patty {  ...  }
@Component
class Hamburger(@Autowired val patty: Patty) { ... }

 

위와 같이 선언되어있을 때, 어떤 patty가 Hamburger에 주입될까

 

이전의 테스트 코드를 그대로 돌려보았더니 

 

Parameter 0 of constructor in com.yojic.springstudy.toby.chap1.di.Hamburger required a single bean, but 3 were found

와 같은 에러가 발생했다. 하나의 싱글 빈이 필요한데 3개가 발견됬다는 것이다.

 

위와 같이 DIP 원칙을 적용하여 @Autowired로 객체를 주입시켜주는 경우 동일 타입 빈이 복수개이면 스프링이 어떤 구현체를 주입할지 몰라 에러가 나타날 수 있다.

 

위와 관련된 설명을 아래 포스팅에서 진행하겠다.

https://developer111.tistory.com/126

 

[스프링 1-3] 동일 타입 빈이 여러개인 경우, 빈 이름 중복, DI 구현 방법

 

developer111.tistory.com

 

반응형