[Spring] 다양한 의존성 주입 방법과 생성자 주입을 사용해야 하는 이유

Spring 프레임워크의 핵심 기술 중 하나가 바로 DI(Dependency Injection, 의존성 주입)이다.

의존성 주입은 객체 간의 의존성을 줄이고, 유지 보수성을 높이기 위해 사용한다.

Spring 컨테이너에 여러 컴포넌트를 Bean으로 등록하여 생명주기 관리를 위임하고, 이 Bean으로 등록한 객체를 주입받아서 사용할 수 있다. 의존성 주입 덕분에 개발자가 객체 생성에 대한 부분을 신경 쓰지 않고 비즈니스 로직에만 집중할 수 있게 된다.

 

Spring 프레임워크와 같은 DI 프레임워크를 이용하면 다양한 의존성 주입을 이용하는 방법이 있다.

🔹 다양한 의존성 주입 방법

1. 생성자 주입 (Constructor Injection)

생성자 주입(Constructor Injection)은 생성자를 통해 의존 관계를 주입하는 방법이다.

@Service
public class UserService {
    private UserRepository userRepository;
    private MemberService memberService;
    
    @Autowired
    public UserService(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }
}

생성자 주입은 생성자의 호출 시점에 1회 호출되는 것이 보장되기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있다. Spring 프레임워크에서는 생성자 주입을 적극 지원하고 있기 때문에, 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하고 있다.

 

그렇기 때문에 위의 코드는 아래와 동일한 코드가 된다.

@Service
public class UserService {
    private UserRepository userRepository;
    private MemberService memberService;
    
    public UserService(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }
}

또한 final 키워드를 사용해 한 번 초기화된 값이 이후 변경될 수 없도록 보장할 수 있다.

생성자 주입은 클래스(객체)가 생성될 때, 의존성이 함께 주입된다. 이때, 생성자를 통해 필드에 값을 할당해 한 번 값이 설정된 후에는 변경이 되지 않도록 final을 사용할 수 있다.

하지만 수정자 주입이나 필드 주입에서는 클래스 인스턴스가 먼저 생성된 후에, 나중에 주입이 이루어진다. fianl 필드는 선언과 동시에 초기화되거나, 생성자에서 초기화되어야 한다. 하지만 수정자 주입이나 필드 주입 방식은 앞서 말한 것처럼 클래스가 이미 생성된 이후에 의존성이 주입되기 때문에, 이 시점에 final을 사용할 수 없다.

 

2. 수정자 주입 (Setter Injection)

수정자 주입은 필드 값을 변경하는 Setter 메서드를 통해 의존 관계를 주입하는 방법이다.

Setter 주입은 만들어진 객체를 통해 여러 번 호풀이 가능하므로 생성자 주입과 다르게 1회 호출을 보장하지 못한다.

그리고 Setter 주입은 생성자 주입과 다르게 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다. (실제 변경이 필요한 경우는 극히 드물다.)

@Service
public class UserService {
    private UserRepository userRepository;
    private MemberService memberService;
    
    @Autowired
    public setUserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    @Autowired
    public setMemberService(MemberService memberService) {
        this.memberService = memberService;
    }
}

수정자 주입 방식은 @Autowired로 주입할 대상이 없는 경우에는 오류가 발생한다.

주입할 대상이 없어도 동작하도록 하려면 @Autowired(required=false)를 통해 설정할 수 있다.

 

3. 필드 주입 (Field Injection)

필드 주입(Field Injection)은 필드에 바로 의존 관계를 주입하는 방법이다.

@Service
public class UserService {
    @Autowired 
    private UserRepository userRepository;
    @Autowired
    private MemberService memberService;
}

필드 주입 방법을 사용하면 코드가 간결해지지만 외부에서 접근이 불가능하다는 단점이 있다.

이는 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용하지 않게 되었다.

(필드 주입은 의존성을 직접적으로 주입받기 때문에, 테스트 시 모의(mock) 객체를 쉽게 주입할 수 없어 테스트 작성이 어려워진다.)

또한 필드 주입은 반드시 DI 프레임워크가 존재해야 하므로 반드시 사용을 지양해야 한다.

 

IntelliJ에서 필드 주입을 사용하면 Field injection is not recommended라는 경고 문구가 발생한다.

Spring Team recommends: “Always use constructor based dependency injection in your beans. Always use assertions for mandatory dependencies”.

 

4. 일반 메서드 주입 (Method Injection)

일반 메서드를 통해 의존 관계를 주입하는 방식이다.

수정자 주입과 동일하며 사용하면 한 번에 여러 필드를 주입받을 수 있도록 메서드를 작성할 수도 있다.

일반적으로 잘 사용되지 않는다고 한다.

@Service
public class UserService {
    private UserRepository userRepository;
    private MemberService memberService;
    
    @Autowired
    public void init(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }
}

 

🔹 생성자 주입을 사용해야 하는 이유

Spring을 포함한 DI 프레임워크의 대부분이 생성자 주입을 권장한다.

 

1. 객체 불변성(Immutability) 확보

실제로 개발을 하다 보면 의존 관계의 변경이 필요한 상황은 거의 없다.

하지만 수정자 주입이나 일반 메서드 주입을 이용하면 불필요하게 수정의 가능성을 열어두어 유지 보수성을 떨어뜨린다.

그러므로 생성자 주입을 통해 변경의 가능성을 배제하고 불변성을 보장하는 것이 좋다. 이는 오류를 사전에 방지할 수 있는 장점을 가지기도 한다.

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public void someMethod() {
        productRepository = null;
        productRepository.call();
    }
}

필드 주입을 사용한 코드는 final 코드를 사용할 수 없다. 따라서 런타임 시점에 변경될 수 있다.

위 코드에서는 null을 참조하도록 변경했기 때문에 NullPointerException이 발생할 것이다.

 

하지만 생성자 주입을 사용한다면 이와 같은 상황을 컴파일 시전에 방지할 수 있다.

@Service
public class ProductService {
    private final ProductRepository productRepository;
    
    public ProductRepository(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public void someMethod() {
        // final 변수에 값을 재할당 할 수 없다.
        productRepository = null;
    }
}

 

2. 테스트 코드의 작성

생성자 주입 사용 시 테스트 코드를 더 편리하게 작성할 수 있다.

테스트가 특정 프레임워크에 의존하는 것은 테스트 격리가 어렵고 유연성이 감소해 좋지 않다. 따라서 독립적으로 인스턴스화가 가능한 POJO(Plain Old Java Object)로 작성하는 것이 좋은데, 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수 자바 코드로 단위 테스트를 작성하는 것이 어렵다.

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;
    
    public void createProduct(String name) {
        productRepository.create(name);
    }
}

 

예를 들어 위와 같은 필드 주입 방식 코드에 대해 순수 자바 테스트 코드를 작성하면 다음과 같다.

public class ProductServiceTest {
    @Test
    public void createProductTest() {
        ProductService productService = new ProductService();
        productService.createProduct("상품 1");
    }
}

위의 테스트 코드는 Spring 위에서 동작하지 않으므로 의존관계 주입이 되지 않는다.

따라서 productRepository가 null이 되어 create 호출 시 NPE가 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 변경 가능성을 열어두게 되는 단점을 갖게 된다.

반대로 테스트 코드에서 @Autowired를 사용하기 위해 스프링을 사용하면 단위 테스트가 아닐뿐더러 컴포넌트들을 등록하고 초기화하는 시간 때문에 테스트 비용이 증가하게 된다. 그렇다고 대안으로 리플렉션을 사용하면 깨지기 쉬운 테스트가 된다.

반례로 생성자 주입 사용 시 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test 객체를 생성자로 넣어 편리함을 얻을 수도 있다.

 

3. final 키워드 작성 및 Lombok 과의 결합

생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며 컴파일 시점에 누락된 의존성을 확인할 수 있다.

반면에 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없다.

 

final 키워드로 필드를 명시적으로 선언하면 코드의 가독성과 안전성을 높일 수 있으며, 다른 개발자들이 해당 필드가 불변이라는 것을 알 수 있어 코드의 이해를 도울 수 있는 장점이 있다.

 

또한 final 키워드를 붙이면 Lombok과 결합되어 코드를 간결하게 작성할 수 있다. Lombok에는 final 변수를 위한 생성자를 대신 생성해 주는 @RequiredArgsConstructor가 있는데 생성자 주입 코드를 Lombok과 결합시키면 다음과 같이 편하게 작성할 수 있다.

@Service
@RequiredArgsConstructor
public class ProductService {
    private ProductRepository productRepository;
    private UserRepository userRepository;
    
    public void createPoduct(String name) {
        productRepository.create(name);
    }
}

이러한 코드가 가능한 이유는 앞서 설명했듯 Spring에서는 생성자가 1개인 경우 @Autowired를 생략할 수 있도록 도와주고 있으며, 해당 생성자를 Lombok으로 구현하였기 때문이다.

 

4. 스프링에 독립적인 코드 작성

필드 주입 시 @Autowired를 이용해야 하는데, 이것은 스프링이 제공하는 어노테이션이다.

그러므로 @Autowired를 사용하려면 ProductService에 스프링 의존성이 생긴다.

import org.springframework.beans.factory.annotation.Autowired;
// 스프링 의존성이 ProductService에 import되어 코드로 박힌다.

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;
}

우리가 사용하는 프레임워크는 언제 바뀔지 모를 뿐만 아니라, 상품과 관련된 책임을 지는 ProductService에 스프링 코드가 박혀버리는 것은 바람직하지 않다. 프레임워크는 비즈니스 로직을 작성하는 서비스 계층에서 알아야 할 대상이 아니다.

물론 이는 필요한 자바 파일을 import 해야 하는 정적 언어인 자바의 한계이기도 하나, 가능한 스프링 없이 코드가 작성되면 더욱 유연한 코드 확보가 가능하다.

 

5. 순환 참조 방지

생성자 주입 사용 시 애플리케이션 구동 시점(객체의 생성 시점)에 순환 참조 에러를 예방할 수 있다.

 

예를 들어 다음과 같이 필드 주입을 사용해 서로 호출하는 코드가 있다.

@Service
public class ProductService {
    @Autowired
    private ReviewService reviewService;
    
    @Override
    public List<Review> getReview(Long productId) {
        return reviewService.getReview(productId);
    }
}

ProductService가 이미 ReviewService에 의존하고 있는데, ReviewService도 ProductService에 의존하고 있다.

@Service
public class ReviewService {
    @Autowired
    private ProductService productService;
    
    @Override
    public List<Review> getProductReview(Long productId) {
        return productService.getReview(productId);
    }
}

위의 두 메서드는 서로를 계속 호출할 것이고, 메모리에 함수의 CallStack이 계속 쌓여 StackOverflow 에러가 발생한다.

Caused by: java.lang.StackOverflowError: null
at com.example.ProductService.getReview(ProductService.java:20) ~[main/:na]
at com.example.ReviewService.getProductReview(ReviewService.java:14) ~[main/:na]
at com.example.ProductService.getReview(ProductService.java:20) ~[main/:na]
at com.example.ReviewService.getProductReview(ReviewService.java:14) ~[main/:na]

 

문제는 애플리케이션이 아무 오류가 없이 구동돼 실제 코드가 호출되기 전까지 문제를 발견할 수 없다는 것이다.

 

🤔 그렇다면 생성자 주입을 사용한 경우에는 어떻게 될까?

@Service
public class ProductService {
    private final ReviewService reviewService;
    
    public ReviewService(ReviewService reviewService) {
        this.reviewService = reviewService;
    }
}
@Service
public class ReviewService {
    private final ProductService productService;
    
    public ProductService(ProductService productService) {
        this.productService = productService;
    }
}

애플리케이션 실행 시 애플리케이션 구동 시점(객체의 생성 시점)에 BeanCurrentlyInCreationException 에러가 발생하여 오류를 사전에 알 수 있다.

Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| reviewService defined in file [~~~\ReviewService.class]
↑ ↓
| productService defined in file [~~~\ProductService.class]
└─────┘

 

그러한 차이가 나는 이유는 Bean에 등록하기 위한 객체를 생성하는 과정에서 다음과 같은 순환 참조가 발생하기 때문이다.

new ProductService(new ReviewService(new ProductService(new ReviewService()...)))

 

🤔왜 이런 차이가 날까?

 

실행 결과의 차이가 나는 이유는 생성자 주입과 필드 주입/수정자 주입의 의존성 주입 시점이 다르기 때문이다.

 

@Autowired를 이용한 필드 주입을 했을 때, 애플리케이션 구동 시점에 에러가 발생하지 않는 이유는 빈의 생성과 의존성 주입이 분리되어 있기 때문이다. 

하지만 생성자 주입은 객체의 생성과 의존성 주입이 동시에 이루어지기 때문에, 순환 참조 이슈를 애플리케이션 구동 시점에 즉시 확인할 수 있다. 반면에 @Autowired를 이용한 필드 주입은 의존성 주입이 객체 생성 이후에 이루어지므로, 순환 참조 이슈를 객체 생성이 완료된 후에 확인할 수 있다.

 

참고로 Spring Boot 2.6부터는 기본적으로 순환 참조가 허용되지 않도록 변경되었다.


즉, 생성자 주입에 대해 정리해 보자면 아래와 같다.

  • 객체의 불변성을 확보할 수 있다.
  • 테스트 코드의 작성이 이용해진다.
  • final 키워드를 사용할 수 있고, Lombok과 결합하여 코드를 간결하게 작성할 수 있다.
  • 스프링에 독립적인 코드를 작성할 수 있다.
  • 순환 참조 에러를 애플리케이션 구동(객체의 생성) 시점에 파악하여 방지할 수 있다.

 

 

 

 

 

📃 reference