본문 바로가기
Backend

[Spring] DI(Dependency Injection); 의존 주입이란?

by persi0815 2024. 12. 2.
'초보 웹 개발자를 위한 스프링 5 프로그래밍 입문' 챕터 3을 읽고 정리한 내용입니다. 

 

Dependency(의존)이란? 

: 변경에 의해 영향을 전파받는 관계로, 한 클래스가 다른 클래스의 메서드를 실행할 때 ‘의존’한다고 표현한다. 

 

의존하는 객체를 구하는 방법

의존하는 대상이 있다면, 그 대상을 구하는 방법이 필요한데, 가장 쉬운 방법으로 의존 대상 객체를 직접 생성할 수 있다. 

 

1. 직접 생성

pulbic calsss MemberService{
	// 의존 객체 직접 생성
	private MemberDao memberDao = new MemberDao();
	..
}

MemberService sc = new MemberService();

 

new MemberService()를 호출하면 MemberService 생성자가 실행되는데, MemberService의 필드에서 new MemberDao()가 선언되어 있으므로, MemberService 객체가 생성되기 전에 MemberDao 객체가 먼저 생성된다. 이렇게 MemberDao 객체가 생성된 후, MemberService의 필드에 할당된다. 

 

위와 같이 의존 대상 객체를 직접 생성하는 방식은 유지보수 관점에서 문제점을 유발할 수 있다. 

 

또 다른 방법으로는, 의존 주입(DI)이 있다. 

 

2. DI

pulbic calsss MemberService{
	private MemberDao memberDao;
	
	// 생성자 통해 의존 객체를 전달받음
	public MemberService(MemberDao memberDao){
		this.memberDao = memberDao;
	}
	..
}

MemberDao memberDao = new MemberDao();
MemberService sc = new MemberService(memeberDao);

 

 

Dependency Injection은 객체간의 의존 관계를 주입하는 것인데, 위와 같이 service 객체 사용시 생성자에 MemberDao 객체를 전달하는 방식이다. 그리고, 생성자를 통해 의존 객체를 전달받는다. 

 

DI 방식은 실제 의존 주입 대상이 되는 객체를 생성하는 부분만 수정만 하면되고, 의존 객체를 일일이 직접 생성해줄 필요가 없기 때문에 변경이 보다 유연하다는 장점이 있다. 

 

객체 조립기
DI 방식으로 의존 관계를 주입하기 위해서는 우선 의존 주입의 대상을 생성해야 한다.
이때, main에서 의존 대상 객체를 생성하고 주입하게 되면 가독성이 떨어진다. 
그래서, 객체를 생성하고 의존 객체를 주입하는 클래스를 따로 작성하는데, 이를 객체 조립기라고 한다. 
객체 조립기 클래스는 자신의 생성하고 조립한 객체를 리턴하는 메서드를 제공한다. 
이러한 객체 조립기를 이용하면 코드 유지보수 할 때나 상속관계를 이용할 때, 코드 수정하기가 용이하다. 

 

Spring은 DI(Dependency Injection)를 지원하는 조립기 역할을 하며, 필요한 객체를 생성하고, 생성된 객체에 의존성을 주입해준다.

Spring에서는 (@Configuration이 붙은 클래스 내부에서) @Bean 애노테이션이 선언된 메서드를 통해 객체(Bean)를 생성하고, 이 객체를 스프링 컨테이너에 등록하여 관리 대상으로 만든다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration  // 스프링 설정 클래스
public class AppConfig {
    
    @Bean  // 스프링 컨테이너에서 관리할 Bean 등록
    public MemberService memberService() {
        return new MemberService(memberDao());
    }

    @Bean
    public MemberDao memberDao() {
        return new MemberDao();
    }
}

 

사실 스프링 컨테이너는 객체의 생성부터 생명주기 관리, 의존성 주입까지 과정 전체를 담당하는데, 이에 대해 좀 더 자세히 알아보자.

 

Spring Container

 

1) 역할

1. 객체(Bean) 생성

: 컴포넌트 스캔이나 수동 설정(@Bean) 등을 통해 애플리케이션에서 필요한 객체를 생성한다. 

 

2. Bean의 생명주기 관리

: 객체 생성 → 초기화(@PostConstruct) → 사용 → 소멸(@PreDestroy) 과정을 관리한다. 

*싱글돈 스코프의 경우, 애플리케이션 내에서 하나의 객체만 유지한다. 

 

3. 의존성 주입 (DI, Dependency Injection)

: 객체 간의 의존성을 자동으로 주입하여 개발자가 직접 의존성을 연결할 필요를 없앴다.

 

4. 객체 검색 및 제공

: 필요할 때 스프링 컨테이너에서 객체를 가져와 사용할 수 있는데, 컨테이너의 getBean() 통해 사용할 객체 구할 수 있다. 

 

5. AOP (Aspect-Oriented Programming) 지원

 : 트랜잭션 관리, 로깅, 보안 등을 프록시 기반으로 지원한다. 

 

2) Spring Container의 구조

Spring Container는 크게 두 가지 인터페이스 계층으로 구성된다. 

 

1. BeanFactory (최상위 컨테이너)

: 가장 기본적인 컨테이너로, DI(의존성 주입) 만을 담당한다. 

BeanFactory factory = new XmlBeanFactory(new FileSystemResource("app-config.xml")); // (Deprecated)

 

- Lazy Initialization (지연 초기화) 방식을 사용하여 Bean을 필요할 때만 생성하여 메모리 절약 가능하다. 

- 실무에서는 직접 사용하지 않으며, 주로 ApplicationContext 내부에서 활용된다. 

 

2. ApplicationContext

: BeanFactory를 확장하여, 스프링에서 가장 많이 사용하는 컨테이너이다.

// Java 기반 설정
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

 

- Eager Initialization (미리 초기화)방식을 사용하여 애플리케이션 실행 시점에 Bean을 생성한다. 

- 추가적으로 AOP, 이벤트 핸들링, 환경변수 및 프로퍼티 관리 등 기능을 제공한다. 

- 주요 구현체는 다음과 같다. 

AnnotationConfigApplicationContext 자바 기반 설정 사용 (@Configuration)
ClassPathXmlApplicationContext XML 기반 설정 사용
GenericApplicationContext 유연한 컨테이너 (Java & XML 지원)

 

그래서 자바 기반에서는 아래와 같이 설정 클래스를 이용해서 컨테이너를 생성해야 한다. 

@Configuration
@ComponentScan
public class AppConfig {}

public static void main(String[] args) {
    ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
    MyService myService = context.getBean(MyService.class);
    myService.doSomething();
}

 

Spring Boot에서는 @SpringBootApplication이 기본 설정 파일 역할을 하며,
이 설정을 통해 SpringApplication.run()이 실행될 때 스프링 컨테이너(ApplicationContext)가 자동으로 생성된다.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BackendApplication {

	public static void main(String[] args) {
		SpringApplication.run(BackendApplication.class, args); // 내부적으로 ApplicationContext를 생성
	}
}

 

SpringApplication.run() 호출 시, SpringApplication 객체가 생성되고, 설정 클래스를 분석하여 적절한 ApplicationContext 구현체를 결정한다. 이후, 선택된 구현체를 사용하여 스프링 컨테이너(ApplicationContext)가 생성된다.

 

이 과정에서 @ComponentScan이 동작하여, @Component, @Service, @Controller, @Repository 등의 애노테이션이 붙은 클래스를 자동으로 탐색하고 빈(Bean)으로 등록한다.

 

스프링 컨테이너는 이렇게 등록된 빈을 생성, 관리, 의존성 주입(DI) 등의 역할을 수행한다. 컴포넌트 스캔(Component Scan)에 대한 상세 내용은 추후 자세히 알아보자.

 

@SpringBootApplication은 다음 세 가지 어노테이션을 포함한다. 

@SpringBootConfiguration: 스프링이 설정 클래스를 인식하여 빈으로 등록할 수 있도록 함
@EnableAutoConfiguration: Spring Boot의 자동 설정(Auto Configuration)을 활성화하여, 컨테이너가 필요한 빈을 자동으로 등록하도록 함
@ComponentScan: 현재 클래스가 속한 패키지와 하위 패키지를 스캔하여, 빈(객체)을 자동으로 등록하도록 함 

 

 

다시 DI로 돌아와보자. DI 방식에는 세 가지가 있다.  

1. Field Injection (필드 주입)

: 객체의 필드에 직접 의존성을 주입하는 방식으로, 주로 Spring의 @Autowired를 필드에 붙여 사용한다. 

@Component
public class SampleController {
    @Autowired
    private SampleService sampleService;
}

 

@Autowired는 스프링의 자동 주입 기능을 위한 애노테이션으로, 스프링 컨테이너가 해당 타입의 빈을 찾아 필드에 자동으로 할당한다. 즉, 설정 클래스에서 명시적으로 의존성을 주입하지 않아도 스프링이 관리하는 빈을 자동으로 주입해준다. 이때, @Service, @Component, @Bean 등을 통해 이미 생성된 빈을 스프링 컨테이너에서 가져와 주입하게 된다.

 

그러나 자동 주입 시 몇 가지 문제가 발생할 수 있다. 먼저, 주입 대상과 일치하는 빈이 존재하지 않는 경우 예외가 발생하며, 애플리케이션이 정상적으로 실행되지 않는다. 반대로, 주입 대상과 일치하는 빈이 두 개 이상 존재할 경우 Spring은 어떤 빈을 주입해야 할지 알 수 없기 때문에 "No qualifying bean of type" 오류가 발생한다. 이를 해결하기 위해 @Qualifier 애노테이션을 사용하여 특정 빈을 지정할 수 있으며, 만약 @Qualifier가 설정되지 않았다면 빈의 이름을 기본 한정자로 사용하여 주입한다.

 

필드 주입 방식은 코드가 간결하고 사용하기 쉽지만, 몇 가지 단점이 존재한다. 먼저, 테스트와 유지보수가 어려워진다. @Autowired로 인해 객체가 외부에서 주입되므로 생성자를 통한 주입보다 의존성을 명확하게 설정하기 어렵고, 단위 테스트 시 별도의 조작이 필요할 수 있다. 또한, 객체 간 의존성이 명시적이지 않아 코드의 가독성이 떨어진다. 더불어, 순환 의존성 문제가 발생할 경우 디버깅이 어렵다. @Autowired로 인해 스프링 컨테이너가 빈을 주입하는 과정에서 서로 의존하는 두 개의 빈이 무한 재귀적으로 호출될 수 있으며, 이러한 순환 참조 문제는 생성자 주입보다 해결하기 어렵다.

 

또한, 단일 책임 원칙(SRP, Single Responsibility Principle) 위반 가능성이 높다. @Autowired 선언 아래 개수 제한 없이 필드를 추가할 수 있기 때문에 생성자의 파라미터가 많아지고, 하나의 클래스가 과도한 책임을 가지는 문제가 발생할 수 있다. 마지막으로, 필드에 final을 선언할 수 없기 때문에 객체가 변할 가능성이 존재한다. 생성자 주입 방식과 달리, 필드 주입은 객체 변경을 방지할 수 있는 강제성이 없으므로 불변성을 유지하기 어렵다.

 

결과적으로, 필드 주입 방식은 코드가 간결하고 사용하기 편하다는 점 외에는 명확한 장점이 적으며, 유지보수성과 테스트 용이성 측면에서 생성자 주입 방식보다 권장되지 않는다.

 

2. Setter Injection (수정자 주입)

: 객체 생성 후, 세터 메서드를 통해 의존성을 주입하는 방식이다. 

 

세터 메서드의 이름을 통해 어떤 의존 객체가 주입되는지 명확하게 알 수 있다. 또한, 객체 생성 시점에는 의존성이 없어도 객체가 생성될 수 있다는 특징이 있다. 즉, 필수 의존성이 주입되지 않더라도 객체는 생성될 수 있기 때문에, 주입이 보장되지 않는 경우 문제가 발생할 수 있다.

 

Setter Injection은 상황에 따라 의존성 주입이 가능하다는 점에서 선택적인 의존성을 사용할 때 유용하다. 즉, 특정 의존성이 필수가 아닌 경우 적합하게 사용할 수 있으며, 의존성을 변경해야 하는 경우에도 유연하게 대처할 수 있다. 이러한 이유로 과거 Spring 3.x 문서에서는 Setter Injection을 권장했었다.

 

그러나 Setter Injection에는 몇 가지 문제가 존재한다. 필수 의존성인지 여부를 명확하게 알 수 없으며, 객체 생성 이후에도 필수 의존성이 주입되지 않을 가능성이 있다. 예를 들어, 아래 코드에서 Car 객체는 Engine을 필수적으로 필요로 하지만, Setter Injection을 사용하면 객체가 생성된 후에도 Engine이 주입되지 않을 가능성이 있다.

@Component
public class Car {
    private Engine engine; // 필수 의존성
    // 의존성이 여러개라면 세터 메서드 여러개 사용해야

    @Autowired
    public void setEngine(Engine engine) { // 파라미터 하나
        this.engine = engine;
    }

    public void drive() {
        if (engine == null) {
            throw new IllegalStateException("Engine이 주입되지 않았습니다!");
        }
        System.out.println("Driving with " + engine.getName());
    }
}

 

위 코드에서 Car 객체는 Engine이 필수적으로 필요하지만, Spring 컨테이너가 Engine을 주입하지 못했거나, 개발자가 Car 객체를 수동으로 생성한 경우 engine 필드는 null 상태로 남는다. 이 상태에서 drive() 메서드를 호출하면 engine이 null이므로 런타임 오류가 발생할 수 있다.

 

이러한 문제를 해결하기 위해서는 Setter Injection이 아닌 생성자 주입(Constructor Injection)을 사용하는 것이 좋다.

생성자 주입 방식에서는 의존성이 제공되지 않으면 객체 자체가 생성될 수 없으므로, 필수 의존성이 누락된 상태로 객체가 생성되는 일을 원천적으로 방지할 수 있다.

 

결론적으로, Setter Injection은 선택적인 의존성이 필요한 경우에는 유용하지만, 필수 의존성이 있는 경우에는 생성자 주입 방식이 더 안전하고 권장되는 방식이다.

 

3. Constructor Injection(생성자 방식)

: 의존성을 객체 생성 시점에 생성자의 매개변수로 전달받아 주입하는 방식이다. 

 

객체가 생성될 때 반드시 필요한 의존성을 제공받아야 하므로, 객체가 완전한 상태로 초기화되며 사용할 수 있다. 또한, 의존성이 주입된 후 변경할 수 없으므로, 객체의 불변성을 보장할 수 있다.

 

Spring에서는 생성자에 @Autowired를 붙이거나 생략하면 자동으로 의존성을 주입한다. 단일 생성자의 경우 @Autowired 애노테이션을 생략해도 자동 주입이 동작한다.

@Component
public class SampleService {
    private SampleDAO sampleDAO;
 
    @Autowired
    public SampleService(SampleDAO sampleDAO) {
        this.sampleDAO = sampleDAO;
    }
}

@Component
public class SampleController {

	private final SampleService sampleService = new SampleService(new SampleDAO());
    
	...
}

 

위 코드에서 SampleService 객체는 SampleDAO가 필수적으로 필요하며, 생성자를 통해 의존성을 주입받는다. 이 방식에서는 SampleDAO 없이 SampleService 객체를 생성할 수 없으므로, 필수 의존성이 누락되는 문제를 방지할 수 있다.

 

이처럼 생성자 주입을 사용하면 필수적인 의존성이 제공되지 않으면 객체 자체를 생성할 수 없기 때문에, 객체가 항상 완전한 상태로 생성되도록 보장할 수 있다. 해당 이유로 Spring 공식 문서에서도 생성자 주입을 가장 권장하는 방식으로 소개하고 있다.

 

또한, Spring 4.3 이후부터는 클래스에 생성자가 하나만 있는 경우 @Autowired를 생략해도 자동 주입이 동작하므로, 코드가 더 간결해지고 가독성이 향상된다. 생성자 주입은 필드 주입의 단점들을 보완할 수 있는 장점도 가진다. 필드 주입을 사용하면 의존성이 명확하지 않으며, 객체 생성 후에도 의존성이 주입되지 않을 가능성이 존재하지만, 생성자 주입을 사용하면 이러한 문제가 발생하지 않는다. 즉, 의존성이 주입되지 않으면 객체 자체를 생성할 수 없기 때문에, 필수 의존성이 누락되는 일을 방지할 수 있다.

@RestController
@RequestMapping("/sms")
public class SmsController {

    private final SmsService smsService;
    private final UserService userService;

	// 단일 생성자이므로 @Autowired 생략 가능
    public SmsController(SmsService smsService, UserService userService) {
        this.smsService = smsService;
        this.userService = userService;
    }
}

다중 생성자의 경우에는 @Autowired를 명시적으로 추가해 어떤 생성자를 사용할지 지정해야 한다. 

 

또한, 생성자 주입 방식은 순환 의존성을 컴파일 단계에서 감지할 수 있는 장점이 있다. 예를 들어, Controller → Service 또는 Handler → Service와 같은 관계에서 서로 의존하는 객체가 존재하면, 필드 주입이나 세터 주입에서는 순환 의존성이 런타임에서야 감지되어 디버깅이 어려운 반면, 생성자 주입을 사용하면 컴파일 단계에서 오류가 발생하므로 문제를 사전에 해결할 수 있다.

 

생성자 주입 방식의 또 다른 장점은 final 키워드를 사용할 수 있다는 점이다. 필드를 final로 선언하면 한 번 주입된 의존성이 변경될 수 없기 때문에 객체의 불변성을 보장할 수 있다. 반면, 필드 주입이나 세터 주입 방식에서는 final을 사용할 수 없으므로, 객체의 상태가 변경될 가능성이 존재한다.

 

결론적으로, 생성자 주입 방식은 Spring에서 가장 권장되는 방식이며, 의존성을 명확하게 관리할 수 있도록 하고, 객체의 불변성을 보장하며, 유지보수성을 높일 수 있다. 또한, 순환 의존성을 사전에 감지할 수 있어 안정성이 뛰어나며, 단일 생성자의 경우 @Autowired를 생략할 수 있어 코드가 간결해진다는 장점도 갖는다. 따라서, 생성자 주입 방식은 Spring에서 의존성을 관리하는 가장 안전하고 권장되는 방식이다. 

 

추가로, @RequiredArgsConstructor이라는 final 필드@NonNull 필드를 대상으로 생성자를 자동 생성해주는 Lombok 어노테이션을 사용하면 생성자를 따로 작성안해줘도 된다. 

@RestController
@RequiredArgsConstructor
@RequestMapping("/sms")
public class SmsController {

    private final SmsService smsService;
    private final UserService userService;
    
	  /* @RequiredArgsConstructor 통해 자동으로 생성됨
	  public SmsController(SmsService smsService, UserService userService) {
		    this.smsService = smsService;
		    this.userService = userService;
		}*/
 }