🍀 Spring Boot

[Spring] 의존성 주입(DI, Dependency Injection) (생성자 주입을 사용해야 하는 이유)

dmaolon 2022. 10. 6. 09:59

📌 DI(Dependency Injection)

DI(Dependency Injection)이란, 객체를 직접 생성하는 것이 아니라 외부에서 생성 후 주입시켜주는 방식이다.
즉, 의존 관계를 외부에서 결정하고 주입하는 것을 의미한다.
 
< A 클래스에서 B 클래스를 사용해야 하는 경우 (의존 관계) >

interface Book { // 더 다양한 Book을 의존받을 수 있도록 인터페이스로 추상화
	...
}

class ScienceBook implements Book {
	...
}

class EnglishBook implements Book {
	...
}

public class Library {
    private Book book;

    public Library() {
        this.book = new ScienceBook();
//        this.book = new EnglishBook();
    }
}

A 클래스 내부에서 B 객체를 생성하게 된다면, B 객체의 변화가 A 클래스에도 영향이 미치게 된다.
Library 클래스 내부에 Book 객체가 생성되어, Book 객체에 수정 및 변화는 그대로 Library 클래스에게 영향이 미치게 된다. 따라서, 강한 결합도를 가지게 된다.
이렇게 상위 클래스 Library이 하위 클래스 Book의 영향을 받아 의존하게 되는 관계를 외부에서 제어하도록 하여 의존성을 분리해보면 아래와 같다.

public class Library {
    private Book book;

    public Library(Book book) {
        this.book = book;
    }
}

외부에서 의존관계를 결정하고 주입하는 방식을 사용하여 결합도를 낮춘다. (느슨한 결합)
이러한 형태로 의존관계를 주입해주는 것을 DI(Dependency Injection)이라 한다.
 
장점

  • 재사용성이 높아진다.
  • 모의 객체를 주입할 수 있으므로 단위 테스트에 용이하다.
  • 코드의 단순화로 가독성이 높아진다.
  • 의존성이 줄어들어 종속 코드가 감소한다.
  • 결합도를 낮추고, 유연성과 확장성 증가

 

📌 의존성 주입 방식

❔ Setter Injection(수정자 주입)

public class Library {
    private Book book;

    @Autowired
    public void setBook(Book book) {
        this.book = book;
    }

    @Autowired
    public void readBook() {
        book.readBook();
    }
}
interface Book {
    void readBook();
}

class ScienceBook implements Book {
    @Override
    public void readBook() {
        System.out.println("Science");
    }
}
public class Main {
    public static void main(String[] args) {
        Library library = new Library();
        library.setBook(new ScienceBook());
        library.readBook();
    }
}

Library 클래스에서는 Book에 대해 어떻게 구현되어있는지, 구현체에 대해서 알지 못한다.
Library의 readBook()은 Book 타입 객체의 메서드를 실행해야 하므로, Book 타입 객체에 대해 의존적이다.
Main 클래스에서 두 번째 줄이 없더라도 즉, setBook을 통해서 Book 구현체를 주입해주지 않더라도 Library 객체의 생성에 아무런 문제가 발생하지 않는다.
하지만, 세 번째 줄 readBook 메서드를 실행하게 될 때, 바로 여기서 Setter Injection의 문제점을 확인할 수 있게 된다.
book.readBook() 메서드가 실행될 때, 객체가 주입되지 않는다면, Book은 null이기 때문에 NullPointerException이 발생한다.
 

❔ Field Injection

public class Library {

    @Autowired
    private Book book;

    public void readBook() {
        book.readBook();
    }
}

@Autowired 어노테이션을 통해 간단하게 의존성을 주입할 수 있다. 보시다시피 코드가 간결한데다가 간단하게 의존성을 주입하기 때문에 많이 사용되었다.
하지만 필드 주입의 경우, 외부에서 접근이 불가능하다는 단점이 있으므로, 원하는대로 수정을 하여 주입해주는 것이 불가능하다. 따라서, 단위 테스트의 중요도가 높아지는 지금, 모의 객체를 주입해줄 수 없는 필드 주입은 사용이 매우 불편하다.
또한, Spring처럼 DI를 지원하는 프레임워크가 있어야만 사용이 가능하다.
해당 어노테이션 @Autowired를 계속 사용하여 여러 개의 의존성을 주입할 수가 있는데, 이러한 의존성 주입의 남발은 생성자의 매개변수가 많아지고 객체의 역할이 많아지면서 단일 책임의 원칙에 위배될 수 있다.
그러나 이러한 위배될 수 있는 행동을 잡아주지 못하기 때문에 문제가 된다.
 

❔ Constructor Injection

public class Library {
    private Book book;

    public Library(Book book) {
        this.book = book;
    }

    public void readBook() {
        book.readBook();
    }
}
public class Main {
    public static void main(String[] args) {
        Library library = new Library(new ScienceBook());
        library.readBook();
    }
}

이제 가장 중요하고 앞으로도 꾸준히 사용하게 될 생성자 주입 방식이다.
Setter 주입에서의 문제점이었던 객체가 주입되지 못해 NULL이 되는 문제점을 생성자 인지로 주입하는 이 방식을 사용하면 자연스럽게 해결할 수 있다.
Main 클래스를 보면, 주입이 필요한 객체를 주입하지 않는다면, Library 객체를 생성할 수 없다.
즉, 인자로 null을 넣어주지 않는 한, NullPointerException 발생 가능성을 줄일 수 있다.
또한, private final Book book;으로 final을 사용할 수 있다는 점이다.
final은 선언과 함께 초기화가 되어야 하며, 값을 변경할 수 없게 되므로 불변성을 가질 수 있다.
또한, 부에서 접근할 수 있기 때문에 모의 객체를 생성하여 주입할 수 있으므로 테스트에 용이하다.

class TestBook implements Book { ... }

 

📌 정리

❔ Setter Injection

  • final을 사용할 수 없다.
  • 주입이 필요한 객체를 주입해주지 않더라도 객체가 생성된다.
  • 따라서, NullPointerException의 발생 가능성이 있다.
  • 객체 생성 후에도 의존성을 주입할 수 있으며, 의존 관계의 변경 및 선택이 필요한 경우에 사용이 가능하지만, 의존 관계가 변경되는 상황이 발생하지 않도록 설계하는 것이 좋다.

 

❔ Field Injection

  • final을 사용할 수 없다.
  • 외부에서 변경이 불가능하여 테스트 코드 작성이 불편하다.
  • DI를 지원하는 프레임워크가 필요하다.
  • 단일 책임 원칙의 위배 가능성이 있다.

 

❔ Constructor Injection

  • final의 사용이 가능하다.
  • 따라서, 생성자 호출 시점에 단 한 번만 호출되어 불변성을 지닌다. (객체의 불변성 확보)
  • 주입이 필요한 객체를 주입해주지 않는다면, 컴파일 오류로 객체 생성이 불가능하다.
  • 프레임워크에 의존하지 않고 사용이 가능하다.
  • 모의 객체를 생성하여 주입해주는 등 테스트 코드 작성에 용이하다.

 
+) 순환 참조
서로가 서로를 참조하는 경우에 발생하는 문제이다.
SpringBoot 2.6 이전에는 생성자 주입을 통해서만 애플리케이션 구동 시에 순환 참조에 대한 에러를 캐치하여 방지할 수 있었다.
(Setter와 필드 주입의 경우, 실행 도중에 에러가 발생하게 됨)

@Service
public class ScienceBookServiceImpl implements BookService {

    @Autowired
    private LibraryService libraryService;

    @Override
    public void readBook() {
        System.out.println("ScienceBook");
    }
}
@Service
public class LibraryService {

    @Autowired
    private BookService bookService;

    public void readBook() {
        bookService.readBook();
    }
}

그러나 보시다시피 필드 주입을 사용해주어도 애플리케이션 구동 시에 순환참조를 캐치하여 에러를 발생시켜준다.
2.6 부터는 패치가 되어, 필드 주입을 통해서도 순환 참조를 캐치할 수 있다고 한다.
따라서, 순환 참조를 방지한다는 것이 오로지 생성자 주입만의 강점이라고는 하기 어려운 것 같다.
 
 
스프링 - 생성자 주입을 사용해야 하는 이유, 필드인젝션이 좋지 않은 이유
생성자 주입을 @Autowired를 사용하는 필드 주입보다 권장하는 하는 이유
[Spring] 생성자 주입 vs 필드 주입 vs 수정자 주입

반응형