본문으로 바로가기

개요

  • Dependency Injection (의존관계 주입) 이란
    • Setter Based Injection (수정자를 통한 주입)
    • Constructor based Injection (생성자를 통한 주입)
  • 스프링에서 사용할 수 있는 DI 방법 세가지
  • 생성자 주입을 이용한 순환참조 방지
  • 생성자 주입이 테스트 코드 작성하기 좋은 이유

서론

의존관계 주입을 받을때는 아무생각없이 당연하게 @Autowired 를 사용한 필드주입 방식을 사용해왔다.

그런데 어느날 갑자기(?) 인텔리제이에서 경고메시지를 보여준다는 것을 보게 되었다. 항상 경고는 표시되고 있었겠지만 무시하다가 갑자기 궁금해졌다. 필드인젝션을 사용하고 있는 @Autowired 에 하이라이트 표시가 되면서 나오는 경고메시지는

Field injection is not recommended … Always use constructor based dependency injection in your beans

 

Why???? Always??? 

 

실제 회사에서 이런 사례를 겪기도 했었다. ㅠㅠ 따라서 이번에 이 참에 복습하는 차원으로 다시 정리하고자 한다.

 

기존에 의존관계 주입에 대한 정리한 내용은 아래를 참조하시면 종류별로 확인이 가능합니다.

다양한 의존관계 주입 방법

Dependency Injection (의존관계 주입)

이유를 알기 위해서는 DI 에 대한 이해가 필요하다. DI 는 스프링에서만 사용되는 용어가 아니라 객체지향 프로그래밍에서는 어디에서나 통용되는 개념이다.

강한 결합

객체 내부에서 다른 객체를 생성하는 것은 강한 결합도를 가지는 구조이다. A 클래스 내부에서 B 라는 객체를 직접 생성하고 있다면, B 객체를 C 객체로 바꾸고 싶은 경우에 A 클래스도 수정해야 하는 방식이기 때문에 강한 결합이다.

느슨한 결합

객체를 주입 받는다는 것은 외부에서 생성된 객체를 인터페이스를 통해서 넘겨받는 것이다. 이렇게 하면 결합도를 낮출 수 있고, 런타임시에 의존관계가 결정되기 때문에 유연한 구조를 가진다.

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

 

Setter Based Injection (수정자를 통한 주입)

의존관계 주입에는 크게 생성자 주입, 수정자 주입 두가지 방법이 있다.

코드를 한번 보자. 클래스나 인터페이스 이름만 Controller, Service, ServiceImpl 로 지정했지 스프링과는 상관이 없는 순수 자바로만 짜여진 코드이다.

먼저 수정자를 이용한 의존관계 주입을 보자.

public class Controller {
    private Service service;

    public void setService(Service service) {
        this.service = service;
    }

    public void callService() {
    	if(service == null) {
            throw new IllegalStateException("Service is null. Please set the Service instance using setService() method.");
        }
    
        service.doSomething();
    }
}
public interface Service {
    void doSomething();
}
public class ServiceImpl implements Service {
    @Override
    public void doSomething() {
        System.out.println("ServiceImpl is doing something");
    }
}
public class Main {
    public static void main(String[] args) {
        Controller controller = new Controller();

        // 어떤 구현체이든, 구현체가 어떤방법으로 구현되든 Service 인터페이스를 구현하기만 하면 된다.
        controller.setService(new ServiceImpl1());
        controller.setService(new ServiceImpl2());

        controller.setService(new Service() {
            @Override
            public void doSomething() {
                System.out.println("Anonymous class is doing something");
            }
        });

        controller.setService(
          () -> System.out.println("Lambda implementation is doing something")
        );

        // 어떻게든 구현체를 주입하고 호출하면 된다.
        controller.callService();
    }
}

 

(참고) 익명클래스나 람다로 구현할 수 있었던 것은 Service 인터페이스가 함수형 인터페이스이기 때문이다.

  • Controller 클래스의 callService() 메소드는 Service 타입의 객체에 의존하고 있다.
  • Service 는 인터페이스이고, 인터페이스는 인스턴스화 할 수 없으므로 인터페이스의 구현체가 필요하다.
  • Service 인터페이스를 구현하기만 했다면 어떤 타입의 객체라도 Controller 에서 사용할 수 있는데 (다형성) Controller 는 이 구현체의 내부 동작을 아무 것도 알지 못하고 알 필요도 없다.
  • main 함수에서 Controller 클래스를 사용하는 것을 보면, 수정자 메소드인 setService() 에 Service 인터페이스의 구현체만 넘겨주면 된다.

어떤 구현체이든, 구현체가 어떤방법으로 구현되든, Service 인터페이스를 구현하기만 하면 된다.

신박하다?

수정자 주입으로 의존관계 주입은 런타임시에 할 수 있도록 낮은 결합도를 가지게 구현되었다. 하지만 문제는 수정자를 통해서 Service 의 구현체를 주입해주지 않아도 Controller 객체는 생성가능하다. Controller 객체가 생성가능하다는 것은 내부에 있는 callService() 메소드도 호출 가능하다는 것인데, callService() 메소드는 service.doSomething() 을 호출하고 있으므로

NullPointerException 이 발생한다.

주입이 필요한 객체가 주입이 되지 않아도 얼마든지 객체를 생성할 수 있다는 것이 문제다.

위 코드에서 setService() 메소드는 수정자 주입 방식으로 Service 객체를 주입하는 메소드입니다. callService() 메소드에서는 service 객체의 null 여부를 검사하여 null이라면 IllegalStateException을 발생시키도록 합니다. 이렇게 예외처리를 함으로써, 런타임시에 NullPointerException이 발생하는 것을 막을 수 있습니다.

 

다른방법으로 이 문제를 해결 할 수 있는 방법이 생성자 주입이다.

 

Constructor based Injection (생성자를 통한 주입)

Controller 에 setter 를 없애고, 생성자를 이용해서 주입한다.

public class Controller {
    private Service service;

    public Controller(Service service) {
        this.service = service;
    }

    public void callService() {
        service.doSomething();
    }
}

이렇게 생성자 주입을 해주면 사용하는 쪽은 아래와 같이 바뀐다.

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

        // Controller controller = new Controller(); // 컴파일 에러

        Controller controller1 = new Controller(new ServiceImpl());
        Controller controller2 = new Controller(
            () -> System.out.println("Lambda implementation is doing something")
        );
        Controller controller3 = new Controller(new Service() {
            @Override
            public void doSomething() {
                System.out.println("Anonymous class is doing something");
            }
        });

        controller1.callService();
        controller2.callService();
        controller3.callService();
    }
}

이를 통해 두가지 이득과 한가지 보너스 이득이 생긴다.

  1. null 을 주입하지 않는 한 NullPointerException 은 발생하지 않는다.
  2. 의존관계 주입을 하지 않은 경우에는 Controller 객체를 생성할 수 없다. 즉, 의존관계에 대한 내용을 외부로 노출시킴으로써 컴파일 타임에 오류를 잡아낼 수 있다.

보너스 이득은 final 을 사용할 수 있다는 것이다. final 로 선언된 레퍼런스타입 변수는 반드시 선언과 함께 초기화가 되어야 하므로 setter 주입시에는 의존관계 주입을 받을 필드에 final 을 선언할 수 없다.

public class Controller {
    private final Service service; // final 추가

    public Controller(Service service) {
        this.service = service;
    }

    public void callService() {
        service.doSomething();
    }
}

final 의 장점은 누군가가 Controller 내부에서 service 객체를 바꿔치기 할 수 없다는 점이다.

스프링에서 필드주입은 수정자를 통한 주입과 유사한 방식으로 이루어진다. 이제 슬슬 생성자 주입의 장점이 보이기 시작한다.

 

스프링에서의 DI 방법 세가지

스프링에서는 수정자 주입, 생성자 주입과 더불어 필드 주입이란걸 할 수 있다. 필드 주입은 수정자를 통한 주입과 유사한 방식으로 이루어지기 때문에, 수정자를 통한 주입의 단점은 Field Injection 을 사용할 때의 단점을 그대로 가진다.

더불어, 수정자 주입은 스프링 컨테이너가 아닌 외부에서 수정자를 호출해서 주입할 수 있는 방법이라도 열려있지만, 필드주입은 스프링 컨테이너 말고는 외부에서 주입할 수 있는 방법이 없다.

아래는 각 DI 방법에 대한 간단한 예제다. 뒤에서도 쓰기 위해서 예제를 Student, Course 관련된 내용으로 변경했다.

 

Field Injection

@Service
public class StudentServiceImpl implements StudentService {

    @Autowired
    private CourseService courseService;

    @Override
    public void studentMethod() {
        courseService.courseMethod();
    }

}

Setter based Injection

@Service
public class StudentServiceImpl implements StudentService {

    private CourseService courseService;

    @Autowired
    public void setCourseService(CourseService courseService) {
        this.courseService = courseService;
    }

    @Override
    public void studentMethod() {
        courseService.courseMethod();
    }
}

Constructor based Injection

@Service
public class StudentServiceImpl implements StudentService {

    private final CourseService courseService;

    @Autowired
    public StudentServiceImpl(CourseService courseService) {
        this.courseService = courseService;
    }

    @Override
    public void studentMethod() {
        courseService.courseMethod();
    }
}

인텔리제이에서 보여주는 경고메시지는 위 두 예제 중 아래에 있는 Constructor based Injection 을 사용하라는 것이다.

지금까지 살펴본 생성자 주입의 장점은

 

1.NullPointerException 을 방지할 수 있다.

- 객체가 생성될 때 모든 필수 의존성이 주입되므로, NullPointerException과 같은 런타임 예외가 발생할 가능성이 줄어듭니다. 의존성이 null인 상태로 객체가 생성되는 것을 방지할 수 있습니다.

 

2. 주입받을 필드를 final 로 선언 가능하다. 불변(Immutable) 객체

- Constructor Injection을 사용하면 주입받을 필드를 final로 선언할 수 있습니다. 이렇게 하면 한 번 설정된 값을 변경할 수 없으므로, 객체의 상태를 보호하고 예기치 않은 변경을 방지하는 불변(Immutable) 객체를 만들 수 있습니다.

 

3. 의존성 추적 및 테스트 용이성

Constructor Injection은 클래스의 생성자 시그니처를 통해 어떤 의존성이 필요한지 명확하게 드러나기 때문에 코드를 이해하고 디버깅하기 쉽습니다. 또한, 단위 테스트 작성 시에도 의존성을 쉽게 대체(Mocking 등)할 수 있어 테스트 용이성이 좋아집니다.

 

4. 의존성 순환 해결

-  생성자에서 모든 의존성을 한 번에 주입하기 때문에, 순환 참조로 인한 초기화 문제를 방지할 수 있습니다.

 

5. 강력한 컴파일 타임 검사

- Constructor Injection은 컴파일 타임에 많은 종류의 오류를 검출하는 강력한 타입 체크 기능을 제공합니다. 올바른 타입의 인스턴스만 주입될 수 있도록 보장됩니다.

 

정도인데 또 다른 장점을 소개하고자 한다. 이는 스프링에서만 유용한 방법인 것 같다.

 

생성자 주입을 이용한 순환참조 방지 

개발하다보면 여러 서비스들 간에 의존관계가 생기게 되는 경우가 있다. 이 예제에서는 CourseService 에서 StudentService 에 의존하고, StudentService 가 CourseService 에 의존하는 경우를 볼 것이다.

(실무에서도 종종 볼수 있음)

 

Field Injection 의 경우

public interface CourseService {
    void courseMethod();
}
@Service
public class CourseServiceImpl implements CourseService {

    @Autowired
    private StudentService studentService;

    @Override
    public void courseMethod() {
        studentService.studentMethod();
    }
}
public interface StudentService {
    void studentMethod();
}
@Service
public class StudentServiceImpl implements StudentService {

    @Autowired
    private CourseService courseService;

    @Override
    public void studentMethod() {
        courseService.courseMethod();
    }
}

이 상황은 StudentServiceImple 의 studentMethod() 는 CourseServiceImpl 의 courseMethod() 를 호출하고, CourseServiceImpl 의 courseMethod() 는 StudentServiceImple 의 studentMethod() 를 호출하고 있는 상황이다. 서로서로 주거니 받거니 호출을 반복하면서 끊임없이 호출하다가 결국 StackOverflowError 를 발생시키고 죽는다.

 

2019-08-28 00:14:56.042 ERROR 46104 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.StackOverflowError] with root cause


java.lang.StackOverflowError: null
    at com.yaboong.alterbridge.tmp.CourseServiceImpl.courseMethod(CourseServiceImpl.java:26) ~[classes/:na]
    at com.yaboong.alterbridge.tmp.StudentServiceImpl.studentMethod(StudentServiceImpl.java:25) ~[classes/:na]
    at com.yaboong.alterbridge.tmp.CourseServiceImpl.courseMethod(CourseServiceImpl.java:26) ~[classes/:na]
    at com.yaboong.alterbridge.tmp.StudentServiceImpl.studentMethod(StudentServiceImpl.java:25) ~[classes/:na]
    at com.yaboong.alterbridge.tmp.CourseServiceImpl.courseMethod(CourseServiceImpl.java:26) ~[classes/:na]
…

이게 순환참조의 문제인데, 실제 코드가 호출이 되기 전까지는 아무것도 알지 못한다. 스프링 애플리케이션 구동도 너무나 잘된다. 여기서 궁금했던게 하나 있다.

왜 빈 생성이 잘 되는거지…?

수정자 주입이나 필드 주입시에 스프링 ApplicationContext 를 통해서 현재 로딩된 빈 목록을 출력하면 사이클 호출 로직을 가진 두개의 빈이 모두 떠있는 것을 확인할 수 있었다. 아니 사이클 호출을 하고 있는데 빈이 어떻게 생성될 수 있는거지? 생성은 안하고 빈 목록만 가지고 있다가 lazy 로딩하는 방식인건가? 근데 따로 lazy init 옵션을 주지 않으면 lazy 로딩은 적용 되지 않는다던데…?

객체생성시점에서 순환참조가 일어나는 것과 객체생성 후 비즈니스 로직상에서 순환참조가 일어나는 것은 완전히 다른 이야기인데, 하나로 묶어서 생각하고 있었기 때문에 이런 이상한 질문에 빠졌던 것이다.

필드 주입이나, 수정자 주입은 객체 생성시점에는 순환참조가 일어나는지 아닌지 발견할 수 있는 방법이 없다.

Constructor based Injection 의 경우

@Service
public class CourseServiceImpl implements CourseService {

    private final StudentService studentService;

    @Autowired
    public CourseServiceImpl(StudentService studentService) {
        this.studentService = studentService;
    }

    @Override
    public void courseMethod() {
        studentService.studentMethod();
    }
}
@Service
public class StudentServiceImpl implements StudentService {

    private final CourseService courseService;

    @Autowired
    public StudentServiceImpl(CourseService courseService) {
        this.courseService = courseService;
    }

    @Override
    public void studentMethod() {
        courseService.courseMethod();
    }
}

이 경우에도 애플리케이션이 구동이 잘 될까? 실행해보면 아래와 같은 로그가 찍히면서 앱 구동이 실패한다

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  courseServiceImpl defined in file [/Users/yaboong/.../CourseServiceImpl.class]
↑     ↓
|  studentServiceImpl defined in file [/Users/yaboong/.../StudentServiceImpl.class]
└─────┘

빈 생성시 아래와 같은 로직이 수행되면서 어떤 시점에 스프링이 그것을 캐치해서 순환참조라고 알려주는 것 같다.

new CourseServiceImpl(new StudentServiceImpl(new CourseServiceImpl(new ...)))

이처럼 생성자 주입을 사용하면 객체 간 순환참조를 하고 있는 경우에 스프링 애플리케이션이 구동되지 않는다.

컨테이너가 빈을 생성하는 시점에서 객체생성에 사이클관계가 생기기 때문이다!

개선 방안으로는 여러 가지가 있을 수 있는데, 그 중 한 가지 방법은 아래와 같습니다:

  1. 서비스 분리: 가능하다면 서비스의 책임을 재구성하여 순환 참조를 제거하는 것이 가장 바람직합니다. 현재 CourseService와 StudentService가 서로에게 의존하고 있는데, 이것은 각각의 서비스가 너무 많은 책임을 가지고 있음을 나타낼 수 있습니다. 역할과 책임에 따라 더 세분화된 서비스로 분리하여 순환 참조를 제거할 수도 있습니다.
@Service
public class CourseServiceImpl implements CourseService {

    private final StudentService studentService;

    @Autowired
    public CourseServiceImpl(StudentService studentService) {
        this.studentService = studentService;
    }

    @Override
    public void courseMethod() {
        //student service method call here...
    }
}

@Service
public class StudentServiceImpl implements StudentService {

    private CourseServiceImpl courseServiceImpl;

    @Autowired
    public void setCourseServiceImpl(CourseServiceImpl courseServiceImpl){
        this.courseServiceImpl = courseServiceImpl;
     }

     @Override
     public void studentMethod() {
         //course service method call here...
     }
}

 

위 예제에서 보듯이, 생성자 대신 setter 메소드를 사용하여 한 쪽의 의존성(CourseServiceImpl)을 주입하면 순환 참조 문제를 해결할 수 있습니다. 이 경우 CourseServiceImpl이 먼저 생성되고, 그 다음에 StudentServiceImpl이 생성되며, 마지막으로 StudentServiceImpl에 대한 참조를 가진 CourseServiceImpl의 setter 메소드가 호출됩니다.

 

이렇게 하면 두 서비스 사이의 순환 참조 문제를 해결할 수 있지만, 앞서 언급한 바와 같이 일부 단점들도 함께 고려해야 합니다. 특히 Setter Injection을 사용할 때는 객체가 일시적으로 불완전한 상태에 있을 수 있다는 점과 필드에 final 키워드를 사용하여 불변성을 보장할 수 없다는 점 등을 유의해야 합니다.

 

 

수정자 주입을 사용하면 아주 잘 구동되고 순환참조를 하고 있는 부분에 대한 호출이 이루어질 경우 StackOverflowError 를 뱉기 때문에, 오류를 뱉을 수 밖에 없는 로직을 품고 애플리케이션이 구동되는 것이다.

마지막으로, 생성자 주입을 사용하면 단위테스트 작성하기가 좋아진다.

 

Constructor Injection만으로는 해결할 수 없는 걸까?

 

순환 참조 문제는 생성자 주입(Constructor Injection)만으로는 해결하기 어렵습니다. 생성자 주입은 객체가 생성될 때 모든 의존성을 주입받아야 하기 때문에, 순환 참조가 발생하면 서로가 서로를 기다리게 되어 결국 애플리케이션이 구동되지 않게 됩니다.

이 문제를 해결하는 가장 바람직한 방법은 설계 단계에서 순환 참조를 피하는 것입니다. 이는 객체 지향 설계 원칙 중 하나인 단일 책임 원칙(Single Responsibility Principle)과 관련이 있습니다. 한 클래스 또는 모듈이 너무 많은 책임을 가지고 있다면, 그것들을 분리하여 각각의 클래스 또는 모듈이 자신의 책임만을 가지도록 하는 것이 좋습니다.

그러나 만약 이런 설계 변경이 어렵거나 불가능한 경우에는 필드 주입(Field Injection) 혹은 Setter Injection과 같은 다른 의존성 주입 방식을 사용하여 순환 참조 문제를 해결할 수 있습니다. 이러한 방식들은 순환 참조 문제를 회피할 수 있으나, 앞서 언급한 바와 같이 일부 단점들도 함께 고려해야 합니다.

 

테스트 코드 작성하기 좋다

아직 테스트 코드를 열심히 짜보거나 하지는 않았지만, 요즘 테스트 코드의 중요성을 깨닫고 공부를 하고 있는 중이다. (참 일찍도 깨달았다 미련한 것)

CourserServiceImpl 이 가진 메소드들에 대해서 단위테스트를 수행하고 싶은 경우, field injection 을 사용해서 작성된 클래스라면 단위테스트시 의존관계를 가지는 객체를 생성해서 주입할 수가 없다. 할 수 있는 방법이 없다! 스프링의 IoC 컨테이너가 다 생성해서 주입해 주는 방식이고 외부로 노출되어 있는 것이 하나도 없기 때문이다. 그래서 의존관계를 가지고 있는 메소드의 단위테스트를 작성하면 (courseMethod() 같은) NullPointerException 이 발생한다.

하지만, constructor based injection 을 사용해 작성된 클래스라면 CourseServiceImpl 객체를 생성할 때 원하는 구현체를 넘겨주면 되고, 구현체를 넘겨주지 않은 경우에는 객체생성 자체가 불가능하기 때문에 테스트하기도 편하다.

 

요약

생성자 주입방식은 아래와 같은 장점을 가진다

  • 의존관계 설정이 되지 않으면 객체생성 불가 -> 컴파일 타임에 인지 가능, NPE 방지
  • 의존성 주입이 필요한 필드를 final 로 선언가능 -> Immutable
  • (스프링에서) 순환참조 감지가능 -> 순환참조시 앱구동 실패
  • 테스트 코드 작성 용이

필드 인젝션은 아래와 같은 장점을 가진다

  • 편하다는 것 말고는 없다

요약

생성자 주입방식은 권장, 적극 권유한다.

 

참조사이트 : https://yaboong.github.io/spring/2019/08/29/why-field-injection-is-bad/