Tick Tick Boom

시간이 다 가기 전에

개발/자바,스프링

Spring의 Dependency Injection(의존성 주입)과 방법론

bbingle 2025. 4. 24. 17:30

 

저는 의존성 주입(함수에 값 넣기)를 좋아합니다.

Ioc? DI?

Inversion Of Control('제어의 역전') 이란 어떤 프로그램에 대한 실행, 생성 등의 제어권을 외부로 넘기는 설계방식이다.

프로그램 내부에서 제어권을 너무 많이 가지고 있을 경우, 프로그램에서 수정,변경 등이 일어나게 될 경우에 확인하고 수정해야할 영역이 넓어지기 때문에 제어를 외부에 위임하여 프로그램 자체의 변경이 많이 일어나지 않게 한다.

 

Dependency Injection(의존성 주입)이란 IoC를 수행하는 여러 프레임워크의 방법론중에 하나로, 위에서 언급했던 제어권, 그중에서도 의존하는 객체의 생성, 초기화 등을 '외부'로 위임하는 방법이다.

 

즉, IoC는 프로그램에서의 어떠한 종류의 제어이든지 외부로 위임하는 설계방식 자체를 말하는 것이고,

DI는 IoC를 실현하는 여러가지 방법론중에 하나인 것이다.

Spring에서 의존성 주입

의존성 주입을 해주기 위해서는 '외부' 역할을 담당할 무엇인가가 필요하고,

Spring에서는 그것을 Spring Container (IoC 컨테이너) 가 맡는다.

Spring Container 는 개발자가 구현한 클래스의 객체를 생성, 초기화, 관리하고, 생명주기까지 관리하는 총체적인 역할을 맡는데, (이때 Spring container가 관리하는 객체를 Bean 이라고 부른다.)

 

의존성 주입은, Bean 끼리에 한해서만 이뤄진다.

당연히, 외부에서 관리할 수 있는 객체여야 생성도 하고 주입도 하고 관리할 수 있으니까!

 

Spring에서 의존성 주입을 사용할 때 보통의 패턴은 인터페이스 / 구현체를 분리하고, 특정 구현체가 아닌 인터페이스에 대한 의존성을 가지게 하여 의존관계 역전 원칙 (Dependency inversion principle) 을 지킬 수 있게 한다.

 

이렇게 DI 를 사용함으로써 다음과 같은 장점들을 얻어낼 수 있다.

  • 두 객체 간의 관계라는 관심사의 분리
  • 두 객체 간의 결합도를 낮춤
  • 객체의 유연성을 높임
  • 테스트 작성을 용이하게 함

이때 이 의존성을 관리하는 Bean 들에 대한 생성, 관리등을 모든 사용자의 요청에 대해 관리하게 되면, 서비스의 규모가 커질 수록 성능적 부담또한 크게 증대될 것이다.

 

Spring 에서는 이를 해결하기 위해 singleton 패턴을 사용한다.

이에 대해서는 망나니 개발자님의 글을 확인하자.

https://mangkyu.tistory.com/151

Spring에서 의존성 주입 방법

이제 본격적으로 Spring에서 의존성 주입을 실행하는 방법을 알아보자.

공식문서에서는

  • 생성자 기반 의존성 주입
  • 세터 기반 종속성 주입

이렇게 두가지로 주요 사용을 소개하고 있는데

우리는 여기에 더불어 필드(field)기반 주입까지 알아보자.

0. 테스트 환경 마련

의존성 주입을 테스트해보기 위해 테스트 환경부터 마련해보려고 한다.

// 클래스 Alphapackage org.gdsccau.team5.testdi.components;

import org.springframework.stereotype.Component;

@Component
public class Alpha {
  public String sayHello() {
    return "hello from Alpha";
  }
}

----
// 클래스 Bravopackage org.gdsccau.team5.testdi.components;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

---
// 클래스 Charliepackage org.gdsccau.team5.testdi.components;

public class Charlie {

  public String sayHello() {
    return "hello from Charlie";
  }
}

클래스는 위와 같이 구성하였다.

의존관계를 보자면, 아래와 같다.

 

찰리의 경우 @Component 어노테이션을 붙여주지 않았고, 따라서 IoC 컨테이너에 포함되지 않았다는 사실을 알 수 있다.

현재 Bravo 클래스의 구현을 보면 사용(의존)하고자 하는 객체는 있으나, 실질적으로 해당 객체들을 생성하거나 주입받는 부분이 없다.

이 부분을 구현하면서 확인해볼 예정이다.

 

다음으로 테스트 코드이다.

테스트 코드의 경우 아래와 같이 구성했다.

  • Bravo의 bean 생성 확인
  • Alpha의 bean 생성 확인
  • Charlie의 bean 생성 실패 확인
  • Bravo의 Alpha 의존성 주입 확인
  • Bravo의 Charlie 의존성 주입 실패 확인
@SpringBootTest
public class DependencyInjectionTest {

  @Autowired
  private ApplicationContext context;

  @Autowired
  private Bravo bravo;

  @Test
  void B는_ApplicationContext에서_조회된다() {
    Bravo bravo = context.getBean(Bravo.class);
    assertThat(bravo).isNotNull();
  }

  @Test
  void B는_A를_의존성으로_정상_주입받는다() {
    assertThat(bravo).isNotNull();
    assertThat(bravo.delegateToAlpha()).isEqualTo("hello from Alpha");
  }

  @Test
  void A는_ApplicationContext에서_조회된다() {
    Alpha alpha = context.getBean(Alpha.class);
    assertThat(alpha.sayHello()).isEqualTo("hello from Alpha");
  }

  @Test
  void C는_빈으로_등록되지_않았기_때문에_조회_불가() {
    assertThatThrownBy(() -> context.getBean(Charlie.class))
        .isInstanceOf(org.springframework.beans.factory.NoSuchBeanDefinitionException.class);
  }

  @Test
  void A는_C를_의존성으로_정상_주입받지_못한다() {
    assertThat(bravo).isNotNull();
    assertThatThrownBy(() -> bravo.delegateToCharlie())
        .isInstanceOf(NullPointerException.class);
  }

}

 

 

1. 생성자 기반 의존성 주입

생성자 기반 의존성 주입은 특정 객체 A가 의존하는 객체 B를 불변하게 선언하고, 생성자를 통해서 객체 B를 주입받는 방식이다.

크게, @Autowired annotation 을 통한 주입과 Spring Configuration 분리를 통해서 수행이 가능하다.

 

1-1 @Autowired 를 이용한 생성자 기반 의존성 주입

 

@Autowired 는 이름에 나와 있듯이, 자동으로 생성자를 통해서 주입을 받는 방식을 말한다.

 

IoC 컨테이너에서 Bean (IoC 컨테이너에서 관리하는 객체)을 생성할 때에는 기본 생성자 라는 것이 필요한데, 생성자가 하나인 경우, 해당 생성자가 기본 생성자가 되고, 생성자가 여러개인 경우에는 @Autowired 어노테이션을 통해 명확히 기본 생성자를 설정해주어야 한다.

 

아래의 사례들을 통해 확인해보자.

 

1-1-1 생성자가 하나이며 인자가 있는 경우

 

생성자가 하나인 경우 Autowired 어노테이션의 생략이 가능한데, 위에서 언급한 것 처럼 기본 생성자가 자동으로 유일한 생성자로 설정되기 때문이다.

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  @Autowired //생략 가능
  public Bravo(Alpha alpha) {
    this.alpha = alpha;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

 

그러나, 만약 기본 생성자에 존재 Bean 객체가 아닌 것을 주입받고자 하는 경우에는 어떻게 될까?

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  @Autowired
  public Bravo(Alpha alpha, Charlie charlie) {
    this.alpha = alpha;
    this.chrlie = charlie;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

이 경우, 아래와 같이 bean 을 찾을 수 없다는 오류가 발생한다.

 

 

의존성 주입은 bean 만 가능하다는 것을 다시 확인 할 수 있다.

 

1-1-2 생성자가 하나이지만, 인자가 없는 경우 (NoArgsConstructor)

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  public Bravo() {
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

 

 

테스트 결과를 통해 알 수 있듯이 경우, 생성자를 통해 bean 이 생성되더라도, 의존성 주입이 정상적으로 이뤄지지 않는 다는 것을 알 수 있다.

의존성을 주입 받을 수 있는 방법이 없으므로 당연한 결과이긴 하다.

 

1-1-3 생성자가 여러개인 경우

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  public Bravo(final Charlie charlie, final Alpha alpha) {
    this.charlie = charlie;
    this.alpha = alpha;
  }

  public Bravo(Alpha alpha) {
    this.alpha = alpha;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

생성자가 여러개인 경우에는 위코드 처럼 Autowired 를 통해 기본생성자가 지정되지 않은 경우 오류가 발생한다.

 

 

Application Context, 즉 Ioc 컨테이너를 시작하면서 bravo bean을 생성하기 위한 기본 생성자가 존재하지 않는 다는 내용의 에러 이다.

이를 해결하기 위해서, Bean으로 관리되는 객체를 주입받을 수 있는 기본생성자를 Autowired 어노테이션으로 주입받아야한다.

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  public Bravo(final Charlie charlie, final Alpha alpha) {
    this.charlie = charlie;
    this.alpha = alpha;
  }
	
	@Autowired
  public Bravo(Alpha alpha) {
    this.alpha = alpha;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

 

 

1-2 Configuration 을 통한 생성자 기반 의존성 주입

@Autowired 어노테이션이 이름 그대로 자동으로 의존성을 주입하는 방법이라면, Configruation 클래스를 직접 구현하여, 의존성을 수동으로 주입할 수 있다.

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  public Bravo(final Charlie charlie, final Alpha alpha) {
    this.charlie = charlie;
    this.alpha = alpha;
  }

  public Bravo(Alpha alpha) {
    this.alpha = alpha;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

아까 오류가 났던 @Autowired 가 지정되지 않은 클래스를 가져와보도록 하자.

이 상태에서 아래와 같이 Configuration 클래스를 추가해보도록 하자

@Configuration
public class DIConfig {

  @Bean
  public Bravo bravo(Alpha alpha) {
    return new Bravo(alpha);
  }
}
  • @Configuration 어노테이션이 적힌 클래스의 경우 Ioc 컨테이너가 Bean 을 생성하기 전에 먼저 확인하는 메타데이터를 모아놓는 역할을 하게된다.
  • @Bean 어노테이션이 적힌 클래스의 경우 IoC 컨테이너에 적재된 Bean을 파라미터로 받고, return 하는 객체를 IoC 컨테이너에 적재할 수 있도록한다.

이제 테스트를 통해 의존성 주입이 제대로 수행되었는지 확인해보면

 

제대로 수행되었음을 확인할 수 있다.

 

2. setter 기반 의존성 주입

setter 기반의 의존성 주입의 경우 생성자 기반 의존성 주입과 비슷하게 @Autowired 어노테이션을 이용한 의존성 주입 혹은 Configuration 을 통한 의존성 주입이 가능하다.

그러나, final 키워드를 사용하여 의존성 객체를 선언할 수 없다는 점과, @Autowired 키워드를 생략할 수 없다는 차이점이 있다.

@Component
public class Bravo {

  Charlie charlie;
  Alpha alpha;

  @Autowired
  public void setBravo(Alpha alpha) {
    this.alpha = alpha;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

3. 필드 기반 의존성 주입

가장 기피되는 의존성주입 방법이다.

단순히 선언한 의존성 객체에 Autowired 키워드를 넣는 것으로 사용할 수 있다.

@Component
public class Bravo {

  Charlie charlie;
  @Autowired
  Alpha alpha;

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

마찬가지로, final 키워드를 사용할 수 없다.

4. 생성자 기반 vs setter 기반 vs 필드 기반 의존성 주입

이제 이 3가지를 비교해보자.

spring 공식문서에서는 생성자 기반, setter 기반만을 소개하고 있으며,

사용에서의 차이는 아래와 같이 설명했다.

필수적인 의존성은 생성자 주입으로, 선택적인 의존성은 setter 주입으로

 

상황 추천 DI 방식 이유
필수 의존성 (예: Repository, Service) 생성자 주입 안정성, 불변성, 테스트 용이성
선택적 의존성 (예: Logger, 설정 객체) Setter 주입 기본값 제공, 나중에 수정 가능
외부 클래스, 라이브러리 가능한 방식 사용 구조상 강제됨

 

생성자 주입만이 불변성을 보장하고, 테스트에 대한 용이성도 뛰어나기 때문에 기본적으로 생성자 기반 의존성 주입을 사용하고,
특수한 경우, 예를 들어 동적으로 설정이 변경될 수 있는 경우 에는 setter 주입을 사용하는 것도 방법이라는 것이 문서에서의 설명이다.

예시로 아래와 같이 코드를 작성할 경우

@Component
public class Bravo {

  CharlieInterface charlie;
  @Autowired
  Alpha alpha;
  
  public Bravo(Alpha alpha) {
    this.alpha = alpha;
  }

  @Autowired(required = false)
  public void setCharlie(CharlieInterface charlie) {
    this.charlie = charlie;
  }

  public String delegateToAlpha() {
    return alpha.sayHello();
  }

  public String delegateToCharlie() {
    return charlie.sayHello();
  }
}

charlie의 경우 ioc 컨테이너에 포함되지 않았기 때문에, 의존성 주입이 수행되지는 않지만, 만약 ChaileInterface 의 구현체가 추후에 IoC 컨테이너에 추가된다면, 해당 클래스에서 의존성 주입이 정상적으로 수행되는 것이다.

 

그렇다면, 필드 기반 의존성 주입은?

 

그냥 권장되지 않는다.

아래와 같은 문제점들 때문이다.

  • 숨겨진 의존성 (생성자나 외부로 노출된 의존성 주입구가 없기 때문에, 직접 클래스를 까봐야함.)
  • final을 사용하지 못해 불변성을 보장받지 못함
  • 테스트 환경에서의 문제점 (의존성을 주입해줄 방법이 없고, 제한적임)

결론

의존성을 주입해주는 방법이 많다보니, 한번 정리해 봤는데, 그냥 ‘생성자 기반 의존성 주입을 쓰자.’ 가 결론인 것 같다.

다만, 불변성과 싱글톤의 관계, IoC 컨테이너와 Bean 등 새로운 내용들에 대해 자세히 공부해볼 수 있어서 의미있는 시간이었던 것 같다.

 

레퍼런스

망나니 개발자 - 의존성 주입

망나니 개발자 - 싱글톤

마틴 파울러 블로그 - IoC와 의존성 주입

Spring 공식문서 - 의존성 주입