JaeWon's Devlog
article thumbnail
반응형

회사에서 소스를 분석하는 도중에 의존성 주입을 필드에 @Autowired 어노테이션을 사용하지 않고, 생성자를 생성하여 주입하고 있었다. 그리고 종종 생성자에는 아예 @Autowired 어노테이션도 사용되지 않고 @RequiredArgsConstructor을 사용하고 있었다.

항상 필드 주입으로 사용하고 있었어서, 궁금해서 시니어 분께 여쭤보니 @Autowired는 deprecated 돼가는 분위기이고, 또한 생성자 주입을 권고하고 있기도 하다는 답변을 받았다.

추가로, 인텔리제이에서도 Autowired 어노테이션을 사용하면 아래와 같이 경고메시지를 보여주고 있었다.(매번 자세히 확인도 안 하고 넘어갔는데...)

대충 번역해보자면

"필드 주입은 권장하지 않습니다. 항상 빈에서 생성자 기반으로 종속성을 주입하여 사용하십시오."

진짜 스프링 팀에서 추천한 말인지는 모르겠지만, 일단 경고도 하고 있고, 시니어 분의 말도 있어 이에 대해서 구글링도 해보고 정리를 해보고자 한다.(심지어 항상(Always)?? 이라는 말도 있다...)


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

- 이유를 알기 위해서는 간단하게 DI 에 대해 이해가 필요합니다.

- DI 는 객체지향 프로그래밍에서는 전체적으로 통용되는 개념.

1-1. 강한 결합

- 객체 내부에서 다른 객체를 생성하는 것은 강한 결합을 가진다 라고 한다.

- A 클래스에서 B 라는 객체를 직접 생성하고 있을 때, B 객체를 C 객체로 바꾸고 싶은 경우에는 A 클래스도 수정해야 하기 때문에 강한 결합이라고 한다.

1-2. 느슨한 결합

- 객체를 주입 받는다는 것은 외부에서 생성된 객체를 인터페이스를 통해 넘겨받는 것이다.

- 이러한 방법은 결합도를 낮추고, 런타임 시에 의존관계가 결정되기 때문에 유연한 구조를 가진다.

SOLID 원칙에서 O에 해당하는 Open Closed Principle 을 지키기 위해서 디자인 패턴 중 전략 패턴을 사용하는데, 생성자 주입을 사용하게 되면 이 전략패턴을 사용하는 게 된다.

2. 의존성 주입 방법

- 의존성 주입 방법에는 크게 수정자(setter) 주입, 필드 주입, 생성자 주입 방법이 있다.

  • 수정자(setter) 주입
private DependencyA dependencyA;
private DependencyB dependencyB;
private DependencyC dependencyC;

@Autowired
public void setDependencyA(DependencyA dependencyA) {
    this.dependencyA = dependencyA;
}

@Autowired
public void setDependencyB(DependencyB dependencyB) {
    this.dependencyB = dependencyB;
}

@Autowired
public void setDependencyC(DependencyC dependencyC) {
    this.dependencyC = dependencyC;
}
  • 필드(Field) 주입
@Autowired
private DependencyA dependencyA;

@Autowired
private DependencyB dependencyB;

@Autowired
private DependencyC dependencyC;
  • 생성자(Constructor) 주입
private DependencyA dependencyA;
private DependencyB dependencyB;
private DependencyC dependencyC;

@Autowired
public Dependency(DependencyA dependencyA, DependencyB dependencyB, DependencyC dependencyC) {
    this.dependencyA = dependencyA;
    this.dependencyB = dependencyB;
    this.dependencyC = dependencyC;
}

3. 잘못된 부분???

- 위의 3가지 주입 방법을 살펴보았을 때 필드 주입이 제일 보기 좋다.

- 매우 짧고, 간결하게 코드가 구성된다.(필드위에 @Autowired 어노테이션만 추가하면 된다.)

- DI 컨테이너가 종속성을 제공하기 위한 특별한 생성자 또는 설정자가 없다.

3-1. 단일 책임 원칙 위반

- 새로운 의존성을 추가하기는 매우 쉽다.

- DI에 생성자를 사용할 때 특정 시점 또는 생성자의 매개변수가 많아진다면 이는 명백히 잘못된다는 의미를 가진다.

- 종속성이 너무 많다는 것은 일반적으로 클래스에 너무 많은 책임을 지게 된다는 의미를 가진다.

- 이는 단일 책임 원칙 및 관심사 분리에 대한 위반이다.

- 무한한 확장이 편하게 하는 필드 주입은 이러한 위험 신호를 주지 않는다.

3-2. 의존성 은닉

- DI 컨테이너를 사용하는 것은 클래스가 더 이상 자신의 종속성을 관리할 책임이 없다는 것을 의미한다.

- 클래스가 더 이상 종속성을 획득할 책임이 없으면 공용 인터페이스를 사용하여 명확하게 전달해야 한다.

- 이렇게 하면, 클래스에 필요한 것이 무엇이고 선택 사항(설정자)인지 필수 항목(생성자) 인지도 명확해진다.

3-3. 불변성

- 필드 주입은 객체를 변경 가능하게 하고, 불변성을 제공하는 final 을 사용할 수 없다.

4. 수정자 주입 vs 생성자 주입

- 필드 주입은 위에 설명에서 처럼 권고하지 않는 방법이다.

- 그렇다면, 수정자 주입과 생성자 주입 중 어떠한 것을 사용해야 하는지 간단한 예제를 통해서 확인해보겠습니다.

4-1. 수정자(Field) 주입

- 간단하게 아래 코드를 통해서 확인해본다.

- Fruit 이란 인터페이스를 만들고, Apple이란 클래스는 이를 상속받는다.

public interface Fruit {
    void apple();
}
public class Apple implements Fruit {
    @Override
    public void apple() {
        System.out.println("사과를 판매합니다.");
    }
}

- Store 라는 클래스는 Fruit 을 의존받아 사용한다.

public class Store {

    private Fruit fruit;

    @Autowired
    public void setFruit(Fruit fruit){
        this.fruit = fruit;
    }

    public void saleApple(){
        fruit.apple();
    }
}

- Main 클래스에서 Store 를 사용한다.

public class Main {
    public static void main(String[] args){

        Store store = new Store();

        store.setFruit(new Apple());
        store.saleApple();

        store.setFruit(new Fruit() {
            @Override
            public void apple() {
                System.out.println("바나나를 판매합니다.");
            }
        });
        store.saleApple();
    }
}

- Main 을 실행하면 결과는 아래와 같다.

사과를 판매합니다.
바나나를 판매합니다.

- 위 내용을 간단하게 정리해보자면,

  • Store 클래스에서 saleApple() 메소드는 Fruit 타입의 객체에 의존한다.
  • Fruit 은 인터페이스이고, 인터페이스는 인스턴스화 할 수 없기 때문에 인터페이스의 구현체가 필요하다.
  • Fruit 인터페이스를 구현하기만 했다면 어떤 타입의 객체라도 Store 에서 사용할 수 있는데(다형성) Store 은 이 구현체의 내부 동작을 아무것도 알지 못하고 알 필요가 없다.
  • Main 클래스에서 Store 클래스를 사용할 때, 수정자 메소드인 setFruit() 에 Fruit 인터페이스의 구현체(new Apple())만 넘겨주면 된다.

- 정리한 내용은 결국 "어떤 구현체이든, 구현체가 어떤 방법으로 구현되든 Fruit 인터페이스를 구현하기만 하면 된다" 이다.

- 수정자 주입으로 의존관계 주입은 런타임 시에 할 수 있도록 낮은 결합도 를 가진다.

- 하지만, 수정자 주입을 통해서 Fruit 의 구현체를 주입해주지 않아도 Store 객체는 생성이 가능하다.

- 이는 Store 객체 내부에 있는 saleApple() 메소드도 호출이 가능하다는 것을 의미하는데 만약 Fruite 구현체 없이 호출하게 되면 NPE(NullPointerException)이 발생한다.

public class Main {
    public static void main(String[] args){

        Store store = new Store();

        //store.setFruit(new Apple());
        store.saleApple(); // --> NPE 발생
    }
}

- 결국 수정자 주입 시에도 아래 문제가 발생하는 것이다.

  • 주입이 필요한 객체가 주입이 되지 않아도 얼마든지 객체를 생성할 수 있다.
  • 객체가 주입이 되지 않으면 NPE(NullPointerException)이 발생한다.

4-2. 생성자(Constructor) 주입

- 생성자 주입은 수정자 주입에서 발생한 문제를 해결할 수 있다.

- Store 클래스에서 setter 주입 대신 생성자 주입을 한다.

public class Store {

    //	생성자 주입을 사용하면 final 변수도 사용이 가능하다.
    private final Fruit fruit;

    public Store (Fruit fruit){
        this.fruit = fruit;
    }

    public void saleApple(){
        fruit.apple();
    }
}

- Store 클래스가 변경됨에 따라 Main 클래스도 아래와 같이 바꿔준다.

public class Main {
    public static void main(String[] args){

        //  Store store = new Store(); // --> 기본생성자가 없으므로 컴파일 에러 발생

        Store store = new Store(new Apple());
        store.saleApple();

        Store store2 = new Store(new Fruit() {
            @Override
            public void apple() {
                System.out.println("바나나를 판매합니다.");
            }
        });

        store2.saleApple();
    }
}

- 이렇게 생성자 주입을 사용 시에는 다음과 같은 장점이 생긴다.

  • Null 을 강제로 주입하지 않는 한 NPE(NullPointerException)이 발생하지 않는다.
  • 의존관계를 주입하지 않은 경우 Store 객체를 생성할 수 없다.
    (의존관계에 대한 내용을 외부로 노출시킴으로써 컴파일 타임에 오류를 찾을 수 있다.)
  • final 변수를 사용할 수 있다.
    (final 변수는 반드시 선언과 함께 초기화가 되어야 하므로 setter 주입 시에는 사용할 수 없다.)

- 추가로 생성자 주입을 사용 시 순환 참조 방지도 가능하다.

- 예를들어, 아래와 같이 서로 의존하도록 설계가 되는 경우가 있다.

public interface Member {
    void memberMethod();
}
public interface User {
    void userMethod();
}
@Service
public class MemberImpl implements Member {

    @Autowired
    private User user;

    @Override
    public void memberMethod() {
        user.userMethod();
    }
}
@Service
public class UserImpl implements User {

    @Autowired
    private Member member;

    @Override
    public void userMethod() {
        member.memberMethod();
    }
}

- 위 코드를 살펴보면, MemberImpl 에서는 userMethod를 호출하고, UserImpl 에서는 반대로 memberMethod를 호출한다.

- 만약 userMethod나 memberMethod를 호출하게 되면, 서로 계속 호출이 반복되면서 StackOverFlow Error 가 발생할 것이다.
   (참고한 곳에서는 애플리케이션 실행이 된 것 같은데, 작성자는 아래 에러와 함께 애플리케이션 실행이 되지 않았다... 확인할 수 없었음)

- 이것이 순환 참조이다.

- 만약, 생성자 주입을 사용하게 되면 애플리케이션 구동이 되기 전에 아래와 같은 로그가 찍히면서 구동에 실패한다.

- 간단하게 의미는 빈 생성 시 아래와 같이 생성되어 불가능하다 라고 알려주는 것이다.

new MemberImpl(new UserImpl(new MemberImpl(new UserImpl ...)))...)))

- 이렇듯 생성자 주입을 사용하여, 순환 참조가 있는 메소드를 확인하여 수정할 수 있다.

5. 결론

- 필드 주입은 대부분 환경에서 사용하지 말아야 한다.

- 수정자 주입 또는 생성자 주입을 사용하여 의존성 주입을 한다.(꼭 하나 선택해야 하는 것은 아니며, 같이 사용할 수도 있다.)

- 생성자 주입은 필수 종속성과 불변성을 목표로 할 때 더 적합하며, 수정자 주입은 선택적 종속성에 적합하다.

- 생성자 주입을 사용할 때도, Lombok의 @RequiredArgsConstructor을 사용하여 구현하는 것이 좋다.(코드 간결화)

- 생성자 주입은 아래 장점을 가진다.

  • 의존 관계가 설정이 되지 않으면 객체 생성이 불가능(컴파일 타임에 인지 가능, NPE 방지)
  • 의존성 주입이 필요한 필드를 final 로 선언 가능(imuutable, 불변성)
  • 순환 참조 감지
  • 테스트 코드 작성 용이

참고

- https://yaboong.github.io/spring/2019/08/29/why-field-injection-is-bad/

- https://velog.io/@kmdngmn/Spring-Autowired-%EB%8C%80%EC%8B%A0-RequiredArgsConstructor-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

- https://www.vojtechruzicka.com/field-dependency-injection-considered-harmful/

반응형
profile

JaeWon's Devlog

@Wonol

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!