[Spring Boot] 의존성 주입에 대해서 알아보자

고리·2022년 12월 30일
0

Server

목록 보기
2/12
post-thumbnail

블로그에 있는 파일 업로드 예제를 따라 하다가 Intellij로 부터 이런 경고 메시지를 받았다.

Field injection is not recommended

뭔가하고 찾아보니 Field injection을 사용하지 말고 setter나 constructor를 사용하라고 한다. 방법이 3개나 되는데 나는 한 가지 방법만 배웠다.

점프투스프링부트로 스프링을 배울 때 의존성 주입을 이렇게 배웠다.

@RequiredArgsConstructor
@Controller
public calss TestController {
	private final TestService testService
    
    @GetMapping("/")
    public void testFunc(Model model) {
    	...
    }
}

여기서 @RequiredArgsConstructor는 롬복이 제공하는 애너테이션으로 final이 붙은 속성을 포함하는 생성자를 자동으로 생성하는 역할을 한다. 따라서 스프링 의존성 주입 규칙에 의해 TestService 객체가 자동으로 주입된다.

그런데 정작 의존성 주입이 왜 필요한지 설명해달라고 요청받았을 때 버벅거린다. 그러니 의존성 주입 규칙부터 살펴보자


의존성 주입

객체 a가 객체 b를 의존한다.

이 말은 이렇게 바꿀 수 있다.

객체 b가 변하면 객체 a에 영향을 준다.

이것은 객체 내부에서 다른 객체를 생성했을 때 발생하는 구조이다. 이것을 강한 결합이라 하는데 클래스 A 내부에서 객체 b를 직접 생성한다면 객체 a가 객체 b를 의존한다. 따라서 의존하는 객체 b를 c로 바꾸고 싶은 경우에 클래스 A를 수정해야 한다.

이것을 코드로 표현해보자

class A {
	private B b;
    public A() {
    	b = new B();
    }
}

// b를 c로 바꾸고 싶다면?

class A {
	private C c;
    public A() {
    	c = new C();
    }
}

Setter, Constructor Based Injection

이번에는 객체를 주입(DI) 받는 것을 생각해보자. 이는 외부에서 생성한 객체 B를 클래스 A로 넘겨받는 것을 의미한다. 느슨한 결합이라 말하기도 한다. 기본적으로 두 가지 방식을 사용할 수 있는데 Setter Based InjectionConstructor Based Injection이다.

이것을 코드로 표현해보자

class A { // Setter Based Injection
	private B b;
    
    public void setB(B b) { // setter
    	this.b = b;
    }
}

// OR

class A { // Constructor based Injection
	private B b;
    
    public A(B b) { // constructor
    	this.b = b;
    }
}

간단하지 않은가? 그저 객체 b를 직접 참조하지 않고, 인자로 넘겨주었을 뿐이다. 실제로 Rúnar Bjarnason란 사람이 한 세미나에서 DI란 인자로 넘기는 것을 있어보이게 말한거라고 했다.

이 블로그(완전 정리 잘됨)에서는 추상화를 해치지 않고 의존성을 인자로 넘기는 것이 DI의 전부라고 말한다.

하지만 여전히 b를 c로 바꾸기 위해서 클래스 A를 수정해야 한다. 이럴 때는 객체 a를 개발자 객체 b를 노트북이라고 생각하자.

개발자와 노트북 둘 중 어떤 것이 더 중요할까? 개발자다. 하지만 더 중요한 개발자가 덜 중요한 노트북에 의존하는 것은 잘못되었다.
또한 객체 c를 컴퓨터라고 생각해보자. 개발자는 서비스를 만들기 위해 노트북이 필요하거나 컴퓨터가 필요하다. 혹은 커피가 필요할 수 있다.

이제 덜 중요한 노트북이 더 중요한 개발자를 의존하게 만들고, 다양한 필수품인 노트북, 컴퓨터, 커피 등Essential개발자에게 주입해 서비스를 개발하기 위해서는 인터페이스를 통한 추상화가 필요하다.

결국 노트북, 컴퓨터, 커피가 Essential 인터페이스에 의존하게된다. 이제 다양한 종류의 필수품을 개발자에게 넘겨줄 수 있게 되었고 추상화를 사용해 더 중요한 모듈이 덜 중요한 모듈에 의존하는 관계를 뒤집었다.

이것을 코드로 구현해보자. 편의를 위해 모든 클래스와 인터페이스를 하나의 자바 파일에 담았다.

class Developer { // Constructor Based Injection
    private final Essential essential;

    public Developer(Essential essential) {
        this.essential = essential;
    }

    public void start() {
        essential.devService();
    }
}

interface Essential { // 노트북, 컴퓨터 등
    void devService();
}

class EssentialImpl implements Essential{
    @Override
    public void devService() {
    	System.out.println("DEVELOPING");
    }
}


public class Main {
    // 개발자 객체를 만들어 Essential을 주입해 서비스 개발을 시킨다.
    public static void main(String[] args) {
        Developer developer = new Developer(new EssentialImpl());
        developer.start();
    }
}

여기서는 Constructor Based Injection을 사용했다. final을 사용할 수 있어 객체의 불변성을 보장하기 때문에 Setter Based Injection에 비해 안전하다.

Setter Based Injection은 Developer 클래스의 essential 초기화 없이 객체를 생성할 수 있으므로 essential이 필요한 start() 함수를 실행할 때 NullPointerException이 발생한다. 이게 무슨 말이냐면

class Developer { // Setter Based Injection
    private Essential essential; // setEssential을 사용한 초기화 필요!

    public void setEssential(Essential essential) {
        this.essential = essential;
    }

    public void start() {
        essential.devService();
    }
}

interface Essential {
    void devService();
}
class EssentialImpl implements Essential{
    @Override
    public void devService() {
    	System.out.println("DEVELOPING");
    }
}

public class Main {
    public static void main(String[] args) {
        Developer developer = new Developer();
        developer.start(); // NullPointerException 발생
    }
}

위처럼 초기화되지 않은 값을 사용해 메소드 호출이 가능한 것이다. 여기서 NullPointerException이 발생한다. setEssential()을 사용한 essential의 초기화로 널포인트에러를 피해야 한다. 이 부분을 잘 생각해보면 누군가 essential에 null을 넣게 된다면 역시 널포인트에러가 발생함을 알 수 있다.
이게 꼭 단점은 아니다. 필요할 때 DI가 가능하기 때문에 유연한 의존성 관리가 가능하다.

좋은 디자인 패턴은 Constructor를 사용한 DI라고 하고 공식 docs도 Constructor Based Injection을 권장한다.


스프링에서의 의존성 주입

지금까지 Setter, Constructor based injection을 알아보았다. spring은 의존성 주입을 더 간편하게 해주는 @Autowired 애너테이션을 제공하는데 이 애너테이션을 특정 필드에 부여하면 spring은 IoC 컨테이너 안에 존재하는 특정 필드와 같은 타입의 Bean을 찾아 필드에 자동으로 주입해준다.

IoC(Inversion of Control)를 최대한 간단히 "생성자를 직접 만들지 말고 프레임워크에게 맡기자" 라고 설명할 수 있다.

이것을 코드로 작성해보자

class Developer { // Setter Based Injection
    private Essential essential;

    public setEssential(Essential essential) {
        this.essential = essential;
    }

    public void start() {...}
}


// @Autowired를 사용하면

@Controller
class Developer { // Field Based injection
	@Autowired
    private final Essential essential;

    public void start() {...}
}

Field Based Injection

완전 간단해진 게 보인다. 하지만 이 방법이 Field Based Injection이다.
앞서 Field injection is not recommended라는 경고메시지를 받았는데 필드 주입 방식을 사용하면 여러 문제가 생긴다. 이 문제들은 대부분 Setter injection 때 발생하는 문제와 유사하다.

  • 가변성
    final 선언이 불가능해 객체가 언제든 변할 수 있다.

  • 순환 참조
    Computer와 Coffee가 서로 의존하는 상태를 코드로 살펴보자

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
class CoffeeImpl implements Coffee {
    @Autowired
    private Computer computer;

    @Override
    public void coffeeMake() {
        computer.computerStart();
    }
}

interface Coffee {
    void coffeeMake();
}

@Service
class ComputerImpl implements Computer {
    @Autowired
    private Coffee coffee;

    @Override
    public void computerStart() {
        coffee.coffeeMake();
    }
}

interface Computer {
    void computerStart();
}
class Developer {
    private ComputerImpl computerImpl;

    public Developer(ComputerImpl computerImpl) {
        this.computerImpl = computerImpl;
    }

    public void start() {
        computerImpl.computerStart();
    }
}

@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);

        Developer developer = new Developer(new ComputerImpl());
        developer.start();
    }
}

위의 코드를 실행하면 다음과 같은 오류 메시지를 확인할 수 있다.

지나치기 쉬운 문제점은 컴파일 때 해당 오류를 발견한 것이 아니라 런타임에 오류를 발견한 것이다. 실제 코드가 호출되기 전까지는 오류를 발견할 수 없다..!

여기서 우리는 Field injection, Setter injection은 순환 참조를 객체 생성 시점에 발견할 수 없다는 점을 알 수 있다.

Constructor Based Injection

위의 Field injection code를 Constructor injection code로 바꿔보자

@Service
class CoffeeImpl implements Coffee {
    private final Computer computer;

    @Autowired
    public CoffeeImpl(Computer computer) {
        this.computer = computer;
    }

    @Override
    public void coffeeMake() {
        computer.computerStart();
    }
}

interface Coffee {
    void coffeeMake();
}

@Service
class ComputerImpl implements Computer {
    private final Coffee coffee;

    @Autowired
    public ComputerImpl(Coffee coffee) {
        this.coffee = coffee;
    }
    @Override
    public void computerStart() {
        coffee.coffeeMake();
    }
}

interface Computer {
    void computerStart();
}

위처럼 IDE에서 컴파일 자체를 거부한다. Developer객체 생성 시점에 ComputerImpl가 초기화 되어야 하고 ComputerImple객체 생성 시점에 CoffeeImple객체의 초기화, CoffeeImple생성 시점에 ComputerImple초기화, 다시 ComputerImple초기화 ... 가 필요하다.

이렇게 컴파일 단계에서 순환참조를 예방할 수 있다.


@RequiredArgsConstructor

다행히도 스프링은 Constructor injection을 위한 애너테이션도 제공한다. @RequiredArgsConstructor 애너테이션은 final이 붙은 필드에 대한 생성자를 생성한다.

위의 자바 코드를 애너테이션을 사용해 더 간단하게 바꿀 수 있다.

@Service
@RequiredArgsConstructor
class CoffeeImpl implements Coffee {
    private final Computer computer;

    @Override
    public void coffeeMake() {
        computer.computerStart();
    }
}

interface Coffee {
    void coffeeMake();
}

@Service
@RequiredArgsConstructor
class ComputerImpl implements Computer {
    private final Coffee coffee;
    
    @Override
    public void computerStart() {
        coffee.coffeeMake();
    }
}

interface Computer {
    void computerStart();
}

이것이 바로 이 게시물의 가장 위에서 본 코드이자 스프링 학습 중 첫 번째로 배웠던 의존성 주입 방법이다.

이상으로 의존성 주입의 필요성과 주입 방법에 대해 알아보았다.

profile
Back-End Developer

0개의 댓글