Spring 스프링 의존성 주입(Dependency Injection)이란?

suover

spring

의존성 주입(Dependency Injection)이란?

프로그램을 개발하다 보면, 여러 클래스나 객체들은 서로 필요한 기능을 사용하기 위해 관계를 맺습니다. 예를 들어, OrderService가 PaymentService를 이용해 결제 로직을 수행하는 경우, OrderService는 PaymentService에 의존하고 있다고 표현합니다. 이때 PaymentService는 OrderService가 필요로 하는 의존성이 됩니다.

의존성이 많아질수록 코드 구조가 복잡해지기 마련입니다. 예전에 흔히 사용하던 전통적인 방식은, 개발자가 직접 필요한 객체를 생성(new 키워드 사용)하거나 설정함으로써 의존성을 연결했습니다. 하지만 이렇게 하면 클래스 간 결합도가 매우 높아져서, 유지보수나 테스트가 어려워지고 코드의 유연성이 떨어지는 문제점이 발생합니다.

의존성 주입(Dependency Injection)의 개념

의존성 주입은 객체가 사용할 의존성을 외부에서 주입해주는 개념으로, 스프링 프레임워크의 핵심 원리이자, 보다 넓은 의미로는 IoC(Inversion of Control, 제어의 역전) 컨테이너가 제공하는 가장 대표적인 기능입니다.

  • IoC(Inversion of Control, 제어의 역전)
    원래 객체가 스스로 의존성을 찾아서(new 등의 방법으로) 주도적으로 생성∙사용하는 방식이 아니라, 스프링과 같은 컨테이너가 대신 객체의 생성과 관계 설정을 제어하고 관리해주는 것을 의미합니다.
  • DI(Dependency Injection, 의존성 주입)
    위의 IoC 원리를 구체적으로 적용하는 방법으로, 필요한 곳에 필요한 의존 객체를 직접 생성하지 않고 외부에서 주입받는 형태입니다.

스프링 프레임워크에서 의존성을 주입받기 위해서는, 주로 어노테이션을 이용하여 IoC 컨테이너(스프링 컨테이너)에게 필요한 객체를 등록(Bean 등록)하고 그 객체를 주입받게 됩니다.

스프링에서의 의존성 주입 방식

스프링 프레임워크에서는 크게 다음과 같은 방식으로 의존성을 주입할 수 있습니다.

  1. 생성자 주입(Constructor Injection)
  2. Setter 주입(Setter Injection)
  3. 필드 주입(Field Injection)

스프링 공식 문서나 다양한 베스트 프랙티스에서는 생성자 주입을 가장 권장합니다. 그 이유를 포함해 각 방식의 특징을 자세히 살펴보겠습니다.

생성자 주입(Constructor Injection)

Java
@Component
public class OrderService {

    private final PaymentService paymentService;

    // 생성자를 통해 의존성을 주입
    @Autowired
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void order() {
        // paymentService를 이용해 결제 로직 처리
        paymentService.pay();
    }
}
Java

  • 장점
    1. 불변(immutable) 보장: final 키워드를 사용하면 해당 의존 객체가 변경되지 않음을 보장합니다.
    2. 필수 의존성 보장: 생성자에 필요한 인자를 명시적으로 선언해야 하므로, 주입받아야 할 객체가 누락되면 컴파일 혹은 런타임 에러로 빠르게 문제를 인지할 수 있습니다.
    3. 테스트 용이성: 생성자로 필요한 의존성을 주입받으므로, 테스트 코드에서 의존성(예: Mock 객체)을 손쉽게 주입할 수 있어 유연합니다.
  • 주의사항
    • 순환 참조(Circular Reference) 문제가 발생하면 런타임 에러가 발생합니다. 예를 들어 A가 B를 생성자로 받아주입 받고, B가 다시 A를 생성자로 받으려 하면 서로 무한 루프 상태가 되므로 스프링이 에러를 발생시킵니다.

세터 주입(Setter Injection)

Java
@Component
public class OrderService {

    private PaymentService paymentService;

    // Setter 메서드를 통해 의존성을 주입
    @Autowired
    public void setPaymentService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void order() {
        paymentService.pay();
    }
}
Java

  • 특징
    1. 선택적 의존성: 꼭 필요한 객체가 아닐 경우(옵션성)에는 세터 메서드를 통해 의존성을 주입받도록 하면, 필요할 때만 주입이 가능하도록 설정할 수 있습니다.
    2. 재설정 가능: 런타임에 의존 객체가 변경되어야 하는(특이한) 상황에서 setter를 이용해 재주입할 수 있습니다.
  • 주의사항
    • 필수 의존성에 세터 주입만 사용하면, 의존성 누락 시 주입받지 않은 상태(NullPointerException)로 실행될 가능성이 있습니다.
    • 일반적으로 필수 의존성은 생성자 주입, 옵션 의존성이나 다양한 환경 설정이 필요한 경우에만 세터 주입을 사용하는 것이 좋습니다.

필드 주입(Field Injection)

Java
@Component
public class OrderService {

    @Autowired
    private PaymentService paymentService;

    public void order() {
        paymentService.pay();
    }
}
Java

  • 특징
    • 가장 간단히 보이고 코드를 줄일 수 있으므로 초보자들이 빠르게 사용하기 쉽습니다.
  • 권장되지 않는 이유
    1. 테스트가 어려움: 필드가 private로 선언된 경우, 테스트 코드에서 Mock 객체 등을 주입하기 위해 리플렉션(Reflection)을 사용해야 하는 번거로움이 생깁니다.
    2. 객체 간 결합도 증가: 필드 기반으로 바로 주입되어 버리면, 다른 설정이나 로직으로 주입 과정을 제어하기가 어려워집니다.
    3. 생성 시점 불명확: 스프링 컨테이너가 해당 필드를 언제, 어떻게 주입하는지 파악이 어려워 디버깅이 복잡해질 수 있습니다.

스프링 공식 문서나 많은 커뮤니티에서 필드 주입은 지양하고, 생성자 주입을 최우선적으로 사용하도록 가이드를 제시하고 있습니다.

의존성 주입 어노테이션

스프링의 DI를 활용하기 위해서는 @Autowired, @Inject, @Resource 등의 어노테이션을 사용할 수 있습니다.

1. @Autowired
  • 스프링 프레임워크에서 제공하는 어노테이션으로, 타입(Type)을 기준으로 빈을 찾아 주입합니다.
  • 생성자, 세터, 필드 등에 적용할 수 있습니다.
2. @Inject
  • 자바 표준(JSR-330)의 어노테이션으로, 동작 방식은 @Autowired와 유사합니다.
  • 사용상 큰 차이는 없으나, 스프링 생태계에서는 대부분 @Autowired가 더 자주 사용됩니다.
3. @Resource
  • 자바 표준(플랫폼 표준) 어노테이션이며, 이름(name)을 기준으로 빈을 주입합니다.
  • @Resource(name=”beanName”)처럼 사용할 때 빈 이름이 명시적으로 일치해야 합니다.
  • 특별한 이유가 없다면 스프링에서는 주로 @Autowired 혹은 @Inject를 사용하는 편입니다.

빈(Bean) 등록과 스프링 컨테이너 동작 원리

스프링 컨테이너(IoC 컨테이너)는 다음 단계를 통해 빈을 스캔하고 의존성을 주입합니다.

  1. 빈 스캐닝(Bean Scanning)
    • @ComponentScan 어노테이션이 달린 곳부터 패키지를 스캔하여 @Component, @Service, @Repository, @Controller 등으로 표시된 클래스를 찾아 빈 등록을 진행합니다.
    • XML 설정을 사용하는 경우에는 <context:component-scan base-package=”…”/> 설정 등이 사용됩니다.
  2. 빈 생성(Bean Creation)
    • 찾은 클래스들을 바탕으로 스프링이 내부적으로 객체를 생성합니다.
    • 스프링 빈의 라이프사이클을 거쳐 객체를 초기화합니다.
  3. 의존성 주입(Dependency Injection)
    • 스프링 컨테이너가 각 빈이 필요로 하는 의존성을 파악해, 생성자/세터/필드를 통해 주입합니다.
    • @Autowired 어노테이션 등으로 지정된 위치에 빈을 찾아 매핑합니다.
  4. 빈 사용(Bean Usage)
    • 최종 완성된 빈들이 준비되면, 애플리케이션이 필요로 할 때 적절한 빈을 가져와서(컨테이너가 관리) 활용하게 됩니다.

의존성 주입의 확장: 프로파일, 조건부 빈 등록

프로파일(Profile) 활용

스프링에서는 개발/테스트/운영 환경에 따라 서로 다른 빈을 주입하고 싶을 때 프로파일 기능을 사용할 수 있습니다.

Java
@Configuration
@Profile("dev")
public class DevDataSourceConfig {
    
    @Bean
    public DataSource devDataSource() {
        // 개발 환경에서 사용할 DataSource 설정
        return new HikariDataSource(...);
    }
}

@Configuration
@Profile("prod")
public class ProdDataSourceConfig {

    @Bean
    public DataSource prodDataSource() {
        // 운영 환경에서 사용할 DataSource 설정
        return new HikariDataSource(...);
    }
}
Java

  • 애플리케이션 실행 시점에 -Dspring.profiles.active=dev 같은 식으로 프로파일을 지정하면 해당 프로파일에 맞는 빈만 활성화되고, 주입됩니다.

조건부 빈 등록(Conditional Bean)

스프링 4부터 지원하는 @Conditional 애노테이션을 사용하면 특정 조건을 만족할 때만 빈을 등록할 수 있습니다. 예를 들어, 특정 라이브러리가 클래스패스에 있을 경우에만 빈을 생성하도록 할 수 있습니다.

Java
@Configuration
@ConditionalOnClass(name = "com.example.ExternalLibrary")
public class ConditionalConfig {

    @Bean
    public MyService myService() {
        // com.example.ExternalLibrary 클래스가 있으면 빈이 등록됨
        return new MyService();
    }
}
Java

이처럼 스프링 DI는 단순 의존성 주입을 넘어, 다양한 조건과 환경 설정에 따라 유연하게 빈을 구성하고 주입하는 데까지 범위가 확장됩니다.

실무에서의 DI 베스트 프랙티스

  1. 생성자 주입을 최우선적으로 사용
    • DI로 관리되는 대부분의 컴포넌트(서비스, 레포지토리 등)는 생성자 주입이 기본이며 필수 의존성을 명확히 드러낼 수 있습니다.
  2. 필드 주입은 지양
    • 테스트가 어려워지고, 스프링 컨테이너 외부에서 사용할 때나 리팩토링 시에 문제를 야기할 수 있으므로 특별한 경우를 제외하고 사용하지 않는 것이 좋습니다.
  3. 옵션 의존성은 세터 주입
    • 꼭 필요한 객체가 아닌 경우나, 런타임에 재설정이 필요한 특수한 경우에만 세터 주입을 사용합니다.
  4. @Autowired 생략 가능
    • 스프링 부트 2.6+ 버전에서는 생성자가 하나만 존재하면 @Autowired를 생략해도 DI가 가능합니다. 코틀린(Kotlin)에서 constructor 한 개만 있는 경우가 이에 해당됩니다.
  5. @Qualifier 사용
    • 같은 타입의 빈이 여러 개 있을 때, 특정 빈을 주입하고 싶다면 @Qualifier(“빈이름”)을 사용해 명시적으로 지정할 수 있습니다.
  6. 테스트 시에는 @MockBean, @SpyBean 사용
    • 스프링 부트 테스트에서 @MockBean이나 @SpyBean으로 의존 객체를 Mock/Spy로 주입해 테스트할 수 있습니다.
    • DI 원리를 그대로 테스트 환경에 적용하여 유연하고 빠른 단위 테스트, 통합 테스트 작성이 가능합니다.

예제 프로젝트

아래는 간단한 Spring Boot 프로젝트에서 DI를 적용한 예시 구조입니다.

Markdown
└─ src
   ├─ main
   │  ├─ java
   │  │  └─ com.example.demo
   │  │     ├─ DemoApplication.java
   │  │     ├─ service
   │  │     │  ├─ OrderService.java
   │  │     │  └─ PaymentService.java
   │  │     ├─ repository
   │  │     │  └─ OrderRepository.java
   │  │     └─ controller
   │  │        └─ OrderController.java
   │  └─ resources
   │     └─ application.properties
   └─ test
      └─ java
         └─ com.example.demo
            └─ OrderServiceTest.java
Markdown

PaymentService 인터페이스와 구현체

Java
package com.example.demo.service;

public interface PaymentService {
    void pay();
}
Java

Java
package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class CreditCardPaymentService implements PaymentService {

    @Override
    public void pay() {
        System.out.println("신용카드 결제 처리");
    }
}
Java

OrderService – 생성자 주입

Java
package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final PaymentService paymentService;

    // 생성자 주입
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void processOrder() {
        System.out.println("주문 로직 처리");
        paymentService.pay();
        System.out.println("주문 완료");
    }
}
Java

OrderController – 간단한 REST 컨트롤러

Java
package com.example.demo.controller;

import com.example.demo.service.OrderService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    private final OrderService orderService;

    // 생성자 주입
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/order")
    public String order() {
        orderService.processOrder();
        return "Order Processed!";
    }
}
Java

위 구조는 아주 간단하지만, DI의 기본 흐름을 잘 보여줍니다.

  • OrderController → OrderService → PaymentService 순으로 의존이 연결되며, 스프링 컨테이너가 객체를 만들어 연결해 줍니다.
  • 우리는 직접 new OrderService(…) 등으로 객체를 생성하지 않고, 스프링이 의존성을 주입해줍니다.

정리 및 결론

스프링 의존성 주입(DI)은 스프링 프레임워크가 제공하는 가장 핵심적인 기능 중 하나로, IoC 원리를 바탕으로 객체의 생성과 의존 관계 설정을 프레임워크(스프링 컨테이너)가 담당합니다. 이를 통해 결합도를 낮추고, 테스트와 유지보수가 쉬운 유연한 애플리케이션 아키텍처를 구성할 수 있습니다.

  • 생성자 주입을 통한 불변성 보장
    생성자 주입 방식을 사용하면, 클래스가 가져야 할 필수 의존성을 명확하게 드러내고 실수로 인한 주입 누락을 방지할 수 있습니다.
  • 스프링 부트의 자동 구성(Auto Configuration)
    스프링 부트는 빈 등록과 의존성 주입 과정을 매우 간결하게 만들어 주어, 최소한의 설정으로도 강력한 DI 환경을 구축할 수 있습니다.
  • 유연한 확장성
    환경(프로파일)이나 특정 조건(Conditional)에 따라 다른 빈을 주입할 수 있어, 배포나 운영 환경이 달라져도 코드의 변경 없이 유연한 대응이 가능합니다.

결론적으로, 스프링을 활용한 DI는 객체지향적 설계 원칙을 실제 프로젝트에서 구현하기에 매우 적합한 패턴이며, 유지보수성과 확장성을 모두 만족하는 강력한 도구입니다.

Leave a Comment