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

[스프링 1-3] 동일 타입 빈 여러개(@Qualifier, @Primary), 빈 이름 중복, DI 구현 방법(생성자, 필드, 세터 주입)

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

이전 포스팅 IOC/DI와 DIP에 이어

 

이번 포스팅에서는 동일 타입 빈이 여러개, 빈 이름 중복, DI 구현 방법에 대해 알아보겠다.

 

목차

 

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

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

[잠깐] 스프링 빈으로 사용되는 클래스명 변경 발생시

- 빈 이름으로 의존관계를 맺은 경우
   해당 빈을 호출하는 모든 곳에서 주입하는 참조속성명(빈 이름)을 변경시켜줘야한다. 
- @Qualifier로 의존관계 맺은 경우
    호출하는 곳에서 변경할게 없다.

Qualifier는 빈의 식별자인 빈 이름을 통해 의존관계를 맺는 것에서 한단계 추상화 시켜줄 수 있는 옵션이다.
우리가 인터페이스를 사용하는 이유 중 하나는 구현체보다 변경 가능성이 낮아 의존관계에 있는 객체에 영향도를 줄일 수 있기 때문이다. 마찬가지로 Qualifier도 빈 이름이 변경되더라도 Qualifier에 의존하면 의존관계 설정의 영향도를 줄일 수 있다.

예를 들어, 인터페이스 하위에 이미 구현체가 여러개 존재하고 새로운 구현체를 만든다고 해보자.
새로운 구현체를 만드는 이유에는 다른 구현체와는 구별되는 역할, 목적, 사용용도가 있기 때문이 아닌가?
그리고 이를 더욱 구체화하여 클래스명을 짓지 않는가?

Qualifier 또한 구현체마다 고유하게 설정해야하지만 빈의 식별자인 빈의 이름보다는 추상적으로 설정하여
의존관계 설정을 구현 클래스명으로 설정하는 것보다 유연한 변경에 유연한 구조를 갖출 수 있다. 

 

@Primary

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

 

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

 

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

 

빈 이름 중복

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

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

 

스프링의 빈 이름은 유일해야한다.(빈 이름이 빈의 식별자이다.) 

 

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

  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에 등록된 빈을 오버라이딩하고 자신의 빈을 등록한 것을 볼 수 있다.

 

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

 

 

DI 3가지 방식

필드 주입

@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(
  var apple: Apple,
  var banana: Banana,
)

 

 

각각의 방식 장단점 비교

테스트 코드 작성

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

스프링 테스트를 진행하는 경우 IOC Context를 통해 빈을 띄우고 DI를 그대로 적용받기에 세 방식의 차이는 없다.

하지만 컨텍스트를 띄우지 않고 단위 테스트를 진행하는 경우 생성자나 세터를 통해 POJO방식으로 객체를 주입할 수 없는 필드 주입 방식의 경우 테스트 코드에서 의존 객체를 주입하는데 어려움이 있다.

물론, 모키토 프레임워크와 같은 테스트 프레임워크를 사용하면 DI를 사용할 수 있기에 차이점은 미미해진다.

허나, 간단한 테스트(??, 의존관계 설정 직접 POJO로 하는게 더 편리한 수준)에서도 테스트프레임워크를 사용해야하는 것 또한 다소 불편한 방식이다.

 

예제. 필드주입

@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입니다.

 

 

필드주입과 생성자 주입 모두 테스트가 가능하고 불가능하고의 차이는 없다.다만 얼마나 더 편리하게 할 수 있냐의 차이는 분명 존재한다.위와 같이 간단한 의존관계가 있는 경우에도 필드 주입은 테스트 프레임워크를 사용해야한다.

간단한 테스트를 위해 복잡하게 테스트하는 것은 꽤나 좋지 않은 방식인 것 같다.

 

객체의 불변성

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

생성자나 필드의 경우 수정자 메서드가 없기에 객체의 불변성을 보장할 수 있다.
(물론 필드는 private으로 선언되어야함)

하지만 수정자 메서드인 setter를 통해 DI가 이루어지게되면 의존관계 객체를 수정할 수가 있기에 객체의 불변성이 깨져 의존관계가 복잡해질 수 있는 가능성을 열어 놓게 된다.

(ex. 일반적으로 의존 객체가 실행 로직 중간에 변경되는 일이 거의 없기에 개발자는 객체가 변경되었다고 생각하기 어렵다. 그러나 누군가 한명이라도 변경하고 사용하고 이와 관련한 오류가 일어났다면 다른 개발자들은 디버깅에 어려움을 겪을 수 있다.)

 

순환참조

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

 

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

 

필드 주입이나 세터 주입의 경우 빈 객체가 사용되는 시점에 의존관계가 설정되니 런타임 환경인 서비스 구동 중에 순환참조가 발생합니다.

그에반면 생성자 주입의 경우 생성시점에 의존관계를 설정하니 IOC Context가 모든 빈을 생성는 스프링 프로그램 구동시점에 순환참조 에러를 확인할 수 있습니다.

따라서 생성자 주입이 순환참조를 구동시점에 빠르게 알아차릴 수 있다는 장점이 있습니다.

 

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

 

[잠깐] 순환참조 해결법 @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. 초기에 빈을 모두 생성하면 JVM의 메모리 공간 부족 여부를 구동시점에 파악할 수 있다. 허나 lazy initial 빈을 많이 등록하게 되면 사용시점에 빈이 등록되며 메모리 공간에 할당 되기에 구동시점에 알 수 있는 에러를 런타임 중에 알게 될 수 있다. 
=> 이는 좀 심각해 보인다. 실제로 Lazy Init이 문제인지 다른게 문제인지 알기 어려운 부분일거 같아 Lazy Init 사용은 이와 관련해서는 치명적일 거 같다. 

 

 

결과 비교

테스트 코드 : 생성자, 세터 > 필드

객체 불변성 : 생성자, 필드 > 세터

순환참조 : 생성자=세터 =필드

 

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

 

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

(객체의 불변성을 보장한다는 것은 생성시점 이후 변경될 일이 없기에 개발자로서 이 객체가 속성의 변경이 없음을 믿을 수 있기에 디버깅의 부담도 줄일 수 있다. 이는 객체지향적 설계로 좋은 방식이다. )

반응형