본문 바로가기
IT/Spring boot

[Spring boot] Spring DI와 생성자 주입의 이유

by kyu-nahc 2024. 8. 7.
반응형

Dependency Injection란? (의존관계 주입)

DI는 Spring에만 적용되는 것이 아닌 모든 객체지향 프로그래밍에서는 사용되는 개념이다.
먼저 의존성이란 객체를 생성 및 사용함에 있어서 의존 관계를 가지고 있는 경우이다.
따라서 의존 관계 주입이란 객체를 직접 생성하는 것이 아닌 외부에서 생성 후 주입하는 것이다.

이는 강한 결합느슨한 결합의 개념과 연결되어 있다. 

강한 결합

public class TightCoupling {

    private Data1 data1 = new Data1();

    public void printData(){
        data1.printData1();
    }
}

public class Data1 {
    public void printData1(){
        System.out.println("Data1");
    }
}

 

강한 결합이란 Class A에서 Class B의 객체를 직접 생성하여 해당 객체를 사용하는 것이다.
따라서 만약 Class A에서 Class B 객체를 사용하지 않고,

Class C를 사용하고 싶다면 Class A의 코드도 변형시켜야 된다.

따라서 만약 Data2 클래스를 이용하고 싶다면 아래와 같이 TightCoupling의 객체 생성 부분과

관련된 메소드도 수정해야 한다. 이를 강한 결합 관계라고 부른다.

public class TightCoupling {

    private Data2 data2 = new Data2();

    public void printData(){
        data2.printData2();
    }
}

public class Data2 {

    public void printData2(){
        System.out.println("Data2");
    }
}

 

느슨한 결합

public interface Data {
    void print();
}

public class Data1 implements Data{
    @Override
    public void print(){
        System.out.println("Data1");
    }
}

public class Data2 implements Data{

    @Override
    public void print(){
        System.out.println("Data2");
    }
}

public class LooseCoupling {

    private Data data = new Data1();

    public void printData(){
        data.print();
    }
}

 

느슨한 결합이란 강한 결합과 달리 추상화(Interface)에 의존한다.

즉 인터페이스를 이용하여 구체적인 Class에 의존하는 것이 아닌 인터페이스에 의존하여

Client가 알아야 할 지식의 양도 적어지고 결합이 느슨해진다.

느슨한 결합에서는 new로 객체 생성을 한 부분만 수정해 주면 된다.


하지만 아직 다른 객체를 사용하기 위해서는 Main Class(LooseCoupling)를

수정해야 하는 문제점은 존재한다. 이를 해결하기 위한 의존관계 주입(DI)을 사용한다.

의존관계 주입은 위에서 말한 것처럼 외부에서 객체를 생성하여 주입하기 때문에
결합도는 더 낮아지고, 런타임시에 의존 관계가 결정되므로 유연하다는 장점을 가진다.

기본적인 DI는 수정자를 통한 주입과, 생성자를 통한 주입으로 나누어진다.

 

public class Controller {
    private Service service;

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

    public void doAction(){
        service.printService();
    }
}

public interface Service {
    void printService();
}

public class ServiceImpl implements Service{
    @Override
    public void printService(){
        System.out.println("Do Service");
    }
}

public class Main {

    public static void main(String[] args){

        Controller controller = new Controller();

        controller.setService(new ServiceImpl());
        controller.doAction();
    }
}

 

위의 코드는 Spring의 Controller, Service와 상관없으며 예시를 들기 위한 Class이다.

  • Controller Class의 doAction() 메소드는 Service 객체에 의존하고 있다.
  • Service는 인터페이스로 이를 구현하는 구현체가 ServiceImpl이다.
  • Controller Class의 doAction() 메소드는
    Service 객체의 printService() 메소드를 호출할 뿐 안의 구현내용에는 관심이 없다.
  • Main Class에서는 Controller Class 생성 후
    Service 객체를 serService() 메소드를 통해 주입해 준다.

수정자 주입을 통해 결합도를 낮추고 런타임시에 Main Class에서 Controller Class의 수정 없이

여러 개의 ServiceImpl 객체를 생성하여 수정자를 통해 넘겨줄 수 있다.

 

하지만 Main Class에서는 Service 객체를 구현하지 않아도 Controller Class 객체를 생성 가능하다.

이에 대한 문제점은 무엇일까?

바로 NullPointException이 발생할 수 있다.
즉 주입이 필요한 객체가 수정자를 통해 주입되지 않아도 Class 객체를 생성할 수 있다는 것이다.
Controller Class 객체를 생성하였다는 것은 해당 Class의 doAction() 메소드도 호출가능하다.
하지만 주입이 되지 않았다면 Service 객체의 printService()를 호출하는 과정에서

주입된 객체가 없으므로 NullPointException이 발생하게 되는 것이다.

이러한 문제점을 해결할 수 있는 방법이 생성자를 통한 주입이다.

 

 

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

public class Controller {
    private Service service;

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

    public void doAction(){
        service.printService();
    }
}

public class Main {

    public static void main(String[] args){

        // This is Compile Error
        //Controller controller = new Controller();

        Controller controller = new Controller(new ServiceImpl());
        controller.doAction();
    }
}

 

위의 코드는 수정자 주입 방법에서 생성자 주입 방법으로 수정한 코드이다.

생성자 주입 방법으로 인해 두 가지의 장점을 가지게 된다.

  • NullPointException이 발생하지 않는다.
  • final을 통해 값이 변하지 않는 객체의 불변성이 보장된다.
Controller controller = new Controller(null);

 

위의 코드처럼 의도적으로 null을 생성자 매개변수로 넘기지 않는 이상

Service를 구현한 구현체를 넘겨준다면

Controller 객체를 생성한 후 NullpointException이 발생하지 않는다.

 

또한 final로 선언된 참조 타입 변수는 Class 선언과 동시에 초기화가 되어야 하므로
생성자 주입을 통해서는 final 키워드로 Service 객체의 불변성을 보장할 수 있지만
수정자 주입은 Controller 객체 선언 시에 Service를 초기화 하지 않으므로 final로 선언할 수가 없다.

 

이는 매우 중요한 것으로 final로 선언시 누군가가 Controller 내부에서 Service 객체를 변경할 수 없다.
대부분의 의존 관계 주입은 한번 일어나면 애플리케이션 종료 시점까지 의존 관계를 변경할 일이 없다.
오히려 대부분의 의존 관계는 종료 지점까지 불변성을 보장해야 한다.

만약 수정자 주입을 사용하면 setter 메서드를 public으로 열어둔다.

public으로 열어둔다면 개발 과정에서 누구든지 접근가능하고

해당 Service 객체의 불변성이 보장되지 않는다.

따라서 생성자 주입은 수정자 주입과 다르게 2가지의 장점을 가진다.

  • NullPointException을 막을 수 있다.
  • 객체의 불변성을 보장할 수 있다.

 

Spring DI란? 

Spring에서 사용하는 DI는 무엇이 있을까?
Spring에서는 위의 수정자, 생성자를 포함하여 필드 주입이라는 것을 사용한다.
따라서 Spring에서는 3개의 DI를 사용할 수 있다.

  • 수정자 주입
  • 생성자 주입
  • 필드 주입

Spring Setter Injection (수정자 주입) 

public class Controller {
    
    private Service service;

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

    public void doAction(){
        service.printService();
    }
}

 

Spring Constructor Injection (생성자 주입) 

public class Controller {
    
    private Service service;

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

    public void doAction(){
        service.printService();
    }
}

 

위에서 설명한 것처럼 생성자 주입의 장점들을 가진다.
또한 @Autowired를 사용하지 않고도 생성자 주입을 사용할 수 있는 2가지 방법이 있다.

  • Lombok의 @RequiredArgsConstructor 어노테이션 사용
  • 단일 생성자의 사용
@RequiredArgsConstructor
public class Controller {

    private final Service service;

    public void doAction(){
        service.printService();
    }
}

 

생성자 주입의 장점으로 final 선언할 수 있다는 것이다.

필드 변수에 대해 final로 선언 후 Lombok의 @RequiredArgsConstructor 어노테이션을 이용할 경우
final 변수들에 대해서는 생성자를 자동으로 생성해 준다.

단일 생성자의 사용 

public class Controller {

    private Service service;
    private UserService userSerivce;

    public Controller(Service service, UserService userSerivce){
        this.service = service;
        this.userService = userService;
    }
    
    public void doAction(){
        service.printService();
    }
}

 

Spring 4.3 이후 부터는 단일 생성자인 경우 @Autowired를 사용하지 않고도
의존성 주입이 가능하다. 따라서 단일 생성자를 생성하고 해당 필드변수들을 주입해 주면 된다.

 

Spring Field Injection (필드 주입) 

public class Controller {
    
    @Autowired
    private Service service;

    public void doAction(){
        service.printService();
    }
}

 

필드 주입은 가장 간단한 방법이라고 할 수 있다. Bean으로 등록된 Class를 사용하고자 할 때,
해당 Bean 객체를 사용하려는 Class에 field로 선언한 뒤 @Autowired를 붙여주면 된다.
Spring 컨테이너에서 @Autowired 어노테이션을 붙인 객체에 대해 자동으로 의존관계를 주입해 준다.
위의 방법이 제일 간단하지만 여러 가지 문제점을 가진다.

  • 참조 관계를 알기가 어렵다.
  • 수정자 주입은 외부에서 객체를 생성하여 주입이 가능하지만
    필드 주입은 Spring IoC 컨테이너가 직접 주입하므로 외부에서 주입할 방법도 없다.
  • 순환참조가 일어날 수 있다.
  • 필드에 final 키워드를 사용하지 못한다.

 

순환참조란? 

Project를 진행하다 보면 여러 Service 계층에 대해 서로 의존성을 가지는 경우가 있다.
순환참조랑 Class A -> Class B를 참조하면서 Class B -> Class A를 참조하는 경우이다.
예제 코드는 다음과 같다.

@Service
public class UserService {

    @Autowired
    private MemberService memberService;
    
    public void callUserService(){
        memberService.callMemberService();
    }
}

@Service
public class MemberService {

    @Autowired
    private UserService userService;

    public void callMemberService(){
        userService.callUserService();
    }
}

 

위의 상황은 MemberService는 UserService를 의존하며 callMemberService()는
UserService의 callUserService()를 호출한다. 또한 UserService는 MemberService를 의존하며
callUserService()는 MemberService의 callMemberService()를 호출한다.
즉 서로 function call을 주고받으며 끊임없이 호출하는 구조이다.
하지만 이 상태로  애플리케이션을 구동시키면 문제없이 실행되고

해당 메소드를 호출해야만 에러가 발생한다. 여기서 중요한 점은 실행 중에 오류가 발생한다는 것이고

이는 애플리케이션 구동 중에 큰 문제점을 가져올 수 있다.
이는 수정자 주입을 사용하였을 때도 마찬가지이다.

반면 생성자 주입을 사용하면 어떻게 될까?

@Service
@RequiredArgsConstructor
public class MemberService {
    private final UserService userService;

    public void callMemberService(){
        userService.callUserService();
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final MemberService memberService;

    public void callUserService(){
        memberService.callMemberService();
    }
}
***************************
APPLICATION FAILED TO START
***************************

Description:

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

┌─────┐
|  memberService defined in file [C:\Users\MemberService.class]
↑     ↓
|  userService defined in file [C:\Users\UserService.class]
└─────┘

 

위와 같이 컴파일 에러가 발생하게 된다.
생성자 주입 방식을 사용하면 순환 참조 문제를 해결하는 것이 아니라,
순환 참조 문제를 애플리케이션 실행 시점에 알려주기 때문에, 실행 전 순환 참조 문제를 해결할 수 있다.

그렇다면 생성자 주입과 수정자, 필드 주입 간의 이러한 차이가 발생하는 이유는 무엇일까?

바로 Spring Bean의 생명주기와 관련이 있다.

 

Spring bean의 생명주기 

생성자 주입, 필드 주입, 수정자 주입 모두 Bean 객체로 등록한 경우에만 가능하다.
이는 모두 Application 실행 후 IoC 컨테이너의 BeanFactory에 의해 관리된다.

먼저 Spring Bean의 생명주기를 살펴보면 다음과 같다.

  1. 스프링 IoC 컨테이너 생성
  2. 스프링 빈 생성
  3. 의존관계 주입
  4. 초기화 콜백 메소드 호출
  5. 사용
  6. 소멸 전 콜백 메소드 호출
  7. 스프링 종료

여기서 생성자 주입 방식과, 수정자, 필드 주입방식과의 위와 같은 차이점은 2~3의 관계를 보면 된다.
생성자 주입 방식은 Spring Bean 객체를 생성 시점에 의존관계 주입이 동시에 일어난다.
하지만 수정자, 필드 주입 방식은 Spring Bean 객체를 생성한 후에 의존관계를 주입하게 된다.
이러한 차이점으로 생성자 주입방식에서는 애플리케이션 실행 후에 바로 순환참조 관계를 파악가능하다.

 

@Service
public class ServiceA {

    private ServiceB serviceB;
    public ServiceA(ServiceB serviceB){
        this.serviceB = serviceB;
    }
}
@Service
public class ServiceB {

    private ServiceA serviceA;
    public ServiceB(ServiceA serviceA){
        this.serviceA = serviceA;
    }
}
***************************
APPLICATION FAILED TO START
***************************

Description:

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

┌─────┐
|  serviceA defined in file [C:\Users\ServiceA.class]
↑     ↓
|  serviceB defined in file [C:\Users\ServiceB.class]
└─────┘

 

따라서 위에서 단순하게 서로의 객체를 필드변수로 가지고 있고,
생성자 주입 방식을 사용하면 애플리케이션 실행 시점에 바로 오류가 난다.
즉 생성자 주입을 통해 Bean 객체를 생성하는 경우 ServiceA 객체는 ServiceB를 찾고
또 ServiceB 객체는 ServiceA를 찾으면서 순환참조 문제
를 가지게 되는 것이다.

하지만 수정자, 필드 주입은 모두 ServiceA와 ServiceB의 객체를 생성한 후에 의존성 주입을 하므로
Bean 객체의 생성 및 의존성 주입 단계에서는 문제가 되지 않는다.

그렇다면 아까처럼 호출관계에서 문제가 되는 것이다.

@Service
public class UserService {

    @Autowired
    private MemberService memberService;
    
    public void callUserService(){
        memberService.callMemberService();
    }
}

@Service
public class MemberService {

    @Autowired
    private UserService userService;

    public void callMemberService(){
        userService.callUserService();
    }
}

 

아까 예시를 들었던 코드이다.

위에서 이는 순환참조의 문제라고 하였지만 사실 이는 순환호출문제였던 것이다.
필드 주입을 사용하므로 Bean 객체를 생성할 때, 순환참조의 문제는 발생하지 않는다.
하지만 호출하는 과정에서 위와 같은 코드가 존재한다면

무한대로 서로를 호출하는 순환호출 문제가 발생하는 것이다.
따라서 이를 방지하기 위해 애초에 순환참조의 관계를 파악하고 방지하기 위해서

생성자 주입방식을 사용하는 것이다.

따라서 생성자 주입의 장점은 다음과 같다.

  • NullPointException을 막을 수 있다.
  • 객체의 불변성을 보장할 수 있다.
  • (Spring) 순환 참조를 감지 가능 - 순환참조가 존재할 시 애플리케이션 구동 실패

따라서 이와 같은 장점을 가지고 있기 때문에 생성자 주입을 사용한다.

 

반응형

loading