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

[스프링 개념] 동일 타입 빈, 빈 이름 중복, DI 구현 방법

by 코딩공장공장장 2024. 1. 2.

1. 동일 타입 빈이 여러개인 경우 주입되는 빈 기준


@Autowired를 통한 빈 주입 기준은 아래와 같다.

  1. 타입 기준
  2. 이름 기준

의존 주입 시 해당 클래스 타입의 스프링 빈을 찾고 존재하지 않으면 이름을 기준으로 빈을 찾는다.

이때, 해당 클래스 타입 또는 이름에 해당하는 객체가 없다면 에러를 발생시킨다.

또한 해당 클래스 타입에 존재하는 스프링 빈이 여러개이면 이름이 일치하는 것을 찾는데 이때 이름이 일치하는 것이 없다면 스프링은 어떤 빈을 주입시킬지 판단할 수 없어 에러를 발생시킨다. 에러는 런타임에 발생된다.

 

스프링 의존성은 런타임 의존이기에 의존관계 설정과 관련한 모든 에러는 런타임에 나타난다.

런타임 의존성에 관련한 내용은 아래 글을 확인하자.

컴파일 타임 의존성과 런타임 의존성

 

 

예제. 빈이 존재하지 않는 경우

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

 

위와 같이 스프링 빈으로 등록되지 않은 빈을 의존 주입 받으려고 하는 경우 구동시점에 에러를 발생시킨다.

 

예제. 빈이 여러개인 경우

위와 동일한 예제에서 아래와 같이 Beef타입의 구현체를 스프링 빈으로 등록했다.

@Component
class BeefPatty() : Patty {  ...  }
@Component
class PorkPatty() : Patty {  ...  }
@Component
class ChickenPatty() : Patty {  ...  }

 

구동을 하면 

Parameter 0 of constructor in ***. ***. ***. Hamburger required a single bean, but 3 were found:

라는 에러가 발생된다.

하나의 빈을 주입해야하는데 세개의 빈이 발견되었다는 것이다.

 

이와 같은 경우 우리는 아래와 같이 beefPatty를 참조속성명으로 선언하여 이름으로 빈을 주입시킬 수 있다.

@Component
class Hamburger(@Autowired val beefPatty: Patty) { ... }

 

하지만 이는 좋지 않은 방법이다. 추상화에 의존하는 구현 클래스명을 필드명에 그대로 적는다면 의존관계설정이 구현클래스에 강하게 결합된다. 

 

그렇다면 구현체가 여러개일 때 이름에 의존하지 않고 어떻게 의존관계를 설정할까?

@Qualifier

@Qualifier("special")
@Component
class BeefPatty() : Patty
@Component
class Hamburger(@Qualifier("special") val patty: Patty)

위와 같이 Qualfier를 통해 별칭을 만들어 줄 수 있다. 

빈의 이름을 바꾸는 것이 아닌 추가 별칭을 하나 만들고 별칭으로 주입하도록 하는 것이다.

별칭은 가급적이면 클래스명이 포함되기 보다는 역할이나 사용목적, 용도로 이름을 지정하는 것이 좋다.

@Qualifier를 쓰는 목적은 구현 클래스명과 결합도를 낮추기 위한 것이니 이름을 포함하는 것은 왠만하면 피하자.

이름과 결합도가 높아지는게 무슨 문제가 있나 생각할 수 있겠지만 스프링에서 빈 이름은 식별자이다. 

가급적이면 구체적인것 보다 추상적인 것에 의존하고자 식별자인 이름보다 별칭을 사용하도록 하자.

@Primary

@Primary
@Component
class PorkPatty() : Patty
@Component
class Hamburger(val patty: Patty)

 

Primary 어노테이션을 이용하면 빈 등록의 우선권을 갖고 빈을 주입시킬 수 있다. 

 

DIP를 통해 의존관계를 설정한 경우 구현 클래스가 여러 개이고 이름으로 빈이 등록되는 강한 결합을 원치 않는 경우 위와 같은 방식으로 의존관계 설정을 할 수 있다. 주의 해야할 것은 의존관계 설정 대상 객체를 찾지 못하는 에러는 컴파일 타임이 아니라 런타임에서 이뤄진다. 반드시 직접 테스트를 진행해봐야 한다.

 

2. 빈 이름 중복


코틀린이나 자바에서 패키지가 다르면 같은 이름이 동일한 클래스를 생성할 수 있다.

스프링 빈의 이름도 중복 등록이 가능한가?

스프링의 빈 이름은 빈을 구별하는 식별자이기에 고유해야한다.

 

허나 빈이름이 중복인데 예외가 발생하지 않는 경우가 있다.

  1. config에 설정되어있고 @Component와 같은 어노테이션으로도 설정된 경우
    => 어노테이션을 통해 등록된 빈들이 먼저 등록되고 이후에 config에 설정된 빈들이 등록되어 덮어쓰기와 같이 먼저 등록된 빈이 무효해진다.

    스프링 구버전에서는 자동으로 설정됬으나 최신 버전에서는 아래와 같은 설정을 해야 테스트 가능 
    spring.main.allow-bean-definition-overriding=true

  2. 두 Config에 이름이 같은 빈이 등록된 경우
    => 컴파일 순서에 따라 뒤에 등록되는 Config의 빈이 먼저 등록된 빈을 오버라이딩하고 등록된다.
    물론 Config에서 @Import를 통해 Import하는 쪽이 덮어쓰기하여 빈 이름이 중복되는 경우에도 등록할 빈을 정할 수 있다.
@Configuration
class ParentConfigWithAnnotation {
    @Bean
    fun parent() = Person("parent")

    @Bean
    fun overrideTest() = Person("is overridden?")
}

@Configuration
@Import(ParentConfigWithAnnotation::class)
class ChildConfigWithAnnotation {
    @Bean
    fun child() = Person("child")

    @Bean
    fun overrideTest() = Person("Yes, it is overridden")
}

 

위와 같이 설정하고 

@Test
fun `@Import를 통한 Config설정과 빈 이름 중복시 등록되는 빈`() {
    val childContext = AnnotationConfigApplicationContext(ChildConfigWithAnnotation::class.java)

    val parentBean = childContext.getBean("parent", Person::class.java)
    val childBean = childContext.getBean("child", Person::class.java)
    val overrideTestBean = childContext.getBean("overrideTest", Person::class.java)

    println(parentBean.name)
    println(childBean.name)
    println(overrideTestBean.name)
}

 

위와 같은 테스트코드로 이름이 중복된 빈을 보면 

parent
child
Yes, it is overridden

 

@Import 한 쪽에서 기존 Config에 등록된 빈을 오버라이딩하고 자신의 빈을 등록한 것을 볼 수 있다.

 

[잠깐] 여기서 오버라이딩은 상속의 오버라이딩 개념이 아니고 파일 덮어쓰기처럼 이전에 등록된 빈의 주소가 덮어써지는 것

 

 

3. DI 구현방법 - 필드, 세터, 생성자 주입


필드 주입

@Component
class FieldDi {
  @Autowired
  lateinit var apple: Apple
  @Autowired
  lateinit var banana: Banana
}

 

세터 주입(일반 메서드 주입)

@Component
class SetterDi {
  lateinit var apple: Apple
  lateinit var banana: Banana

  @Autowired
  @JvmName("setAppleFromKotlin")
  private fun setApple(newApple: Apple) {
      apple = newApple
  }

  @Autowired
  @JvmName("setBananaFromKotlin")
  private fun setBanana(banana: Banana) {
      this.banana = banana
  }
}

 

코틀린 var 키워드 사용시 세터 기본으로 생성되어 세터 메서드 중복 막기위해 위와 같이 선언

사실 세터 규칙에 어긋나고 일반 메서드 주입이라고 할 수 있겠지만 동작 방식은 동일

생성자 

@Component
class ConstructorDi(
  private val apple: Apple,
  private val banana: Banana,
)

 

각각의 방식 장단점 비교

i) 테스트 코드 작성 

필드 주입의 경우 순수 자바코드로 테스트 코드 작성하기가 어렵다.

하지만 컨텍스트를 띄우지 않고 단위 테스트를 진행하는 경우 필드 주입 방식은 의존 객체를 주입하는데 어려움이 있다.

 

물론, 스프링 테스트나 모키토 프레임워크를 사용하면 DI를 사용할 수 있기에 차이점은 미미해지만,

테스트시 프레임워크의 도움을 많이 받는 것은 성능 면에서도 좋지 못하기에 아무래도 필드 주입은 테스트 코드 작성면에서 좋지 못하다.

 

예제. 필드주입

@Component
class Apple {
  fun appleMethod(): String = "apple입니다."
}
interface Banana {
  fun bananaMethod(): String
}
@Component
class PhilipineBanana : Banana {
  override fun bananaMethod(): String = "필리핀 banana입니다."
}
@Component
class ThaiBanana : Banana {
  override fun bananaMethod(): String = "태국 banana입니다."
}
@Component
class FieldDi {
  @Autowired
  private lateinit var apple: Apple

  @Autowired
  private lateinit var banana: Banana

  fun diWorkingMethod() {
      println("${apple.appleMethod()}, ${banana.bananaMethod()}")
  }
}

 

필드주입 - 모키토 프레임워크로 테스트

@ExtendWith(MockitoExtension::class)
class FieldDiTest() {
  @Spy
  var banana: Banana = ThaiBanana()

  @Spy
  lateinit var apple: Apple = Apple()

  @InjectMocks
  lateinit var fieldDi: FieldDi

  @Test
  fun `테스트`() {
      fieldDi.diWorkingMethod()
  }
}

// 결과 : null, 태국 banana입니다.

 

예제. 생성자 주입

@Component
class ConstructorDi(
     var apple: Apple,
     var banana: Banana
) {
  fun constructorWorkingMethod() {
      println("${apple.appleMethod()}, ${banana.bananaMethod()}")
  }
}

 

생성자 주입 -  POJO 방식으로 단위테스트

class ConstructorDiTest() {
  @Test
  fun `테스트`() {
      val constructorDi = ConstructorDi(ThaiBanana(), Apple())
      constructorDi.diWorkingMethod()
  }
}

// 결과 : apple입니다., 태국 banana입니다.

 

위와 같이 같은 로직에 대한 테스트를 진행하더라도 필드 주입은 테스트프레임워크를 사용해야하고,

생성자 주입은 테스트 프레임워크 없이 테스트 가능하다.

 

ii) 객체의 불변성

객체의 불변성은 객체 생성(초기화) 이후 값을 변경하지 못하는 것을 얘기한다.

생성자나 필드의 경우 수정자 메서드가 없기에 객체의 불변성을 보장할 수 있다.

setter주입의 경우 setter를 열어놓기에 객체의 불변성이 깨지는 단점을 갖게된다.

 

iii) 순환참조

많은 글들에서 생성자 주입이 순환참조를 구동시점에 빠르게 알아차릴 수 있다는 장점이 있다고 하지만 이는 너무 먼 옛날 얘기이다.

스프링 최신버전에서는 필드주입이나 세터주입이나 구동시점에 순환참조 에러를 발생시켜 순환참조에러 발생시점에 차이는 없다.

@Service
class CircularReferenceA {
  @Autowired
  lateinit var circularReferenceB: CircularReferenceB
}
@Service
class CircularReferenceB {
  @Autowired
  lateinit var circularReferenceA: CircularReferenceA
}

 

위와 같이 A가 B를 참조하고 B가 A를 참조할 때, A를 생성하려고 하니 B가 필요해서 B를 생성하려고 했더니 A가 필요해서 참조가 계속 순환되는 현상을 순환참조라고 한다.

 

[잠깐] 순환참조 해결법 @Lazy 적절한가?

아래는 스프링 공식 문서의 내용이다.

SpringApplication allows an application to be initialized lazily. 
When lazy initialization is enabled, beans are created as they are needed rather than during application startup.
 As a result, enabling lazy initialization can reduce the time that it takes your application to start. In a web application, enabling lazy initialization will result in many web-related beans not being initialized until an HTTP request is received.
A downside of lazy initialization is that it can delay the discovery of a problem with the application. 
1. If a misconfigured bean is initialized lazily, a failure will no longer occur during startup and the problem will only become apparent when the bean is initialized. 2. Care must also be taken to ensure that the JVM has sufficient memory to accommodate all of the application’s beans and not just those that are initialized during startup.
 For these reasons, lazy initialization is not enabled by default and it is recommended that fine-tuning of the JVM’s heap size is done before enabling lazy initialization.

[스프링 공식문서] : 
https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.spring-application.lazy-initialization


@Lazy를 적용하면 @Lazy가 적용된 객체를 의존 주입 받는 대상인 객체는 구동시점에 빈을 생성하지 않고 사용시점에 생성된다. 따라서 A, B가 순환참조를 이루고 있고 A가 먼저 사용되면 B가 생성되고 A 생성시 B를 주입한다.
이때까지도 B의 의존관계는 설정되지 않고 B가 사용될 때, A의 의존관계가 설정된다.


하지만 이 방식에는 치명적인 단점이 있다. 
빈을 늦게 초기화하기 때문에 그와 관련한 문제를 구동시점에 발견하는게 아니라 추후에 발견할 수 있다.

 

1. 구동시점에 알 수 있는 빈 설정 및 의존관계 에러를 사용시점에 파악하게 된다.

@Component
class CircularReferenceA(
    @Lazy var circularReferenceB: CircularReferenceB,
    @Autowired var noSuchBean: NoSuchBean,
)
@Component
class CircularReferenceB(
    var circularReferenceA: CircularReferenceA,
)
spring.main.lazy-initialization=true

(application.properties 설정해야함)  

 

위의 예제에서 noSuchBean은 스프링 빈으로 등록되지 않은 객체이다.

@Lazy를 적용하지 않으면 CircularReferenceA를 생성하며 noSuchBean이 없다는 걸 파악하여 구동이 실패하고 에러를 확인할 수 있는데, @Lazy로 객체 생성을 사용시점으로 미뤄 초기에 발견할 수 있는 에러를 사용시점에 알게된다.


2. 빈 생성으로 인한 메모리 부족 오류를 구동시점이 아닌 사용 중에 알게된다.
=> 이는 좀 심각해 보인다. 실제로 Lazy Init이 문제인지 다른게 문제인지 알기 어려운 부분일거 같아 Lazy Init 사용은 이와 관련해서는 치명적일 거 같다. 

 

[DI 방식 종합 결과 비교]

  • 테스트 코드 : 생성자, 세터 > 필드
  • 객체 불변성 : 생성자, 필드 > 세터
  • 순환참조 : 생성자=세터 =필드

전체적으로 비교해보니 생성자가 가장 좋다.

 

의존관계가 복잡하지 않는 객체의 테스트 코드에서 테스트 프레임워크 없이 POJO방식으로 테스트를 진행할 수 있는 것과 객체를 불변하게 만들어 사용할 있는 것은 생성자 주입의 큰 장점이다.

반응형