DI(Dependency Injection)

Recfli·2025년 3월 12일
0

DI란 무엇인가?

DI는 Dependency Injection의 약자로 객체의 생성과 의존 관계 설정을 외부에서 담당하게 하여, 코드의 확장성과 유연성을 높이는 설계 기법이다.

예를 들어 아래와 같은 코드가 있다고 해보자. Spring이라는 클래스가 있고 거기에는 Java랑 MySQL이라는 객체가 들어간다고 해보자.

그러면 우리는 순수한 자바 코드로 만든다면 일반적으로 코드를 다음과 같이 작성하게 될 것이다.

public class Spring {
    private final Java java = new Java17(...);
    private final MySQL mysql = new MySQL5.6(...); 
    
    ...
}

위와 같이 Java와 MySQL 구체 클래스가 Spring 클래스와 강결합되어 생기는 문제점이 있다. 만약에 MySQL에 버전이 여러 개 있다고 해보자.

위의 코드 구조에서는 만약 MySQL 5.6으로 구현체가 들어가 있는 상태에서 MySQL8.0, MySQL9.0으로 넘어가는 것에 대해 직접 소스 코드를 바꾸지 않는다면 불가능하다. 그 이유는 Spring이라는 클래스의 내부 필드에서 Java 인터페이스의 구현체인 Java17을 선택하는 책임을 가지고 있기 때문이고, MySQL 인터페이스의 구현체인 MySQL5.6을 선택하는 책임을 가지고 있기 때문이다.

이는 클래스 의존관계와 런타임 의존관계라는 개념으로도 설명을 할 수 있다. 클래스 의존관계는 실제 코드 상의 의존관계이고, 런타임 의존관계는 실제 프로그램의 클래스가 JVM에서 바이너리 코드로 바뀌고 그게 실제로 OS에 종속된 명령어로 돌아갈 때 사용하고 있는 객체에 대한 개념 쯤으로 이해하면 될 것 같다.

말이 어려워서 그렇지 컴파일 시점에 체크하는 의존 관계랑 런타임 시점에 실제로 동작하는 의존관계라고 말해도 될 것 같다. 그래서 전자는 문제가 있다면 실행해볼 때 알 수 있고, 후자는 실제로 그 코드가 동작할 때 알 수 있다.

위의 코드에서 강결합이 되었다는 점은 사실상 클래스 의존관계와 런타임 의존관계가 같다는 것을 의미한다. 그래서, 아래와 같이도 표현할 수 있겠다.

하지만 확장성을 위해서는 원본 소스 코드를 변경하지 않고도 내부 구현에 대해서 변경될 수 있는 게 좋다. 이는 내부 필드의 초기화 과정에 대한 책임을 외부로 넘김으로써 가능하게 해준다.

만약에 위의 코드를 다음과 같이 바꿨다고 해보자.

public class Spring {
    private final Java java;
    private final MySQL mysql; 
    
    public Spring(Java java, MySQL mysql) {
        this.java = java;
        this.mysql = mysql;
    }
    ...
}

그러면 이제 해당 Spring 코드를 실행하는 Main에서는 다음과 같이 코드를 작성할 수 있다.

public class Main {
    
    public static void main(String[] args) {
        Java java = new Java17(...);
        MySQL mysql = new MySQL8.0(...);
        
        Spring spring = new Spring(java, mysql);
        ...
    }
}

위의 코드는 이전과 달리, Spring은 직접적으로 본인의 내부 필드를 초기화하고 구현체를 선택할 권리가 없다. 대신 Main이 Spring 클래스의 내부 필드를 초기화하고 구현체를 선택하는 책임을 이제 가지게 된다.

위의 변화에서 Main의 책임과 역할은 다음과 같이 정리를 할 수 있다.

  • 어플리케이션 영역에서 사용될 객체를 생성한다.
  • 각 객체 간의 의존 관계를 설정한다.
  • 어플리케이션을 실행한다.

또한, Spring에 객체를 선정하는 역할이 분리되기 전과 다르게 클래스 의존관계와 런타임 의존관계는 다음과 같이 변하게 된다. 코드 상에서 구조는 복잡해진 것 같지만 실제 결과물은 같은 것이다.

위의 변경을 통해 Main에게 구현체 선택의 권리를 넘김으로써 Spring 클래스는 기존의 코드에 존재하던 구체 구현체를 정하는 역할을 분리하게 되었다. 이를 통해 개방-폐쇄 원칙을 지킬 수 있게 된 것이다.

실제로는 위처럼 Main이 아니라 제 3의 무언가가 이걸 대체할 것이고 Spring 같은 경우에는 이 역할을 스프링 컨테이너가 한다. 보통 이런 컨테이너 역할을 프레임워크에서 지원해주지 않는다면 서비스 로케이터 등을 직접 구현해서 객체 생성에 대한 책임을 지정해주어야 한다.

이외에도 이런 DI가 있을 때의 장점은 테스트 코드를 작성할 때에도 도움이 된다. 만약에 테스트 코드를 작성할 때 아직 내부적인 구현체가 완성되지 못한 경우, 아니면 외부 시스템과 연관이 있어서 직접 실제처럼 테스트를 하지 못하는 경우에는 내부 구현체를 Mocking해서 돌릴 필요가 있을 때가 있다.

이 때, DI가 지원되지 않는다면, 직접 소스 코드를 수정을 할 때에 내부 코드를 Mock 객체로 변경을 해놓고, 다시 테스트가 끝나면 원래 객체로 바꾸어야 하는 불편함이 존재한다. 프로젝트의 규모가 커질수록, 서로 간의 컨텍스트 공유가 적을 수록 개발자가 임의로 이런 변경해야 하는 사항이 많아지면 실수할 가능성이 굉장히 높아진다.

대부분의 버그는 변수 순서를 잘못 쓰거나, 나중에 변경해야지하고 변경하지 못한 경우가 많았다.

DI는 test profile에서만 기존 객체를 Mock 객체로 바꿔주어 이런 실수를 할 가능성을 줄여주는 역할도 할 수 있다.


DI를 사용하는 두 가지 방법

1. 생성자 활용 방법

생성자를 통해 전달 받은 객체를 필드에 보관한 뒤 활용을 하는 방법이다. 아래의 코드를 보면 생성자를 통해 전달받은 객체로 내부의 코드를 실행하고 있는 모습을 볼 수 있다.

public class Spring {
    private final Java java;
    private final MySQL mysql;

    public Spring(Java java, MySQL mysql) {
        this.java = java;
        this.mysql = mysql;
    }
    
    public void RunCode() {
        java.run();
        mysql.run();
    }
    ...
}

이 방법의 장점은 객체를 생성하는 시점에 필요한 모든 의존 객체를 준비할 수 있다. 생성자 방식은 생성자를 통해서 필요한 의존 객체를 전달받기 때문에 객체를 생성하는 시점에서 의존 객체가 정상인지 확인할 수 있다. 물론, 정확하게 말하면 논리적으로 객체 간 의존성이 맞는 지를 체크해주는 거지 내 버그는 코드를 잘못 짠거다.

보통은 스프링에서는 순환 참조된 것들, 없어서 null로 되어있는 것들을 찾아서 코드를 실행하기전 컴파일 시점에 예외를 발생시키고 없는 빈들에 대해서 찾을 수 없다라고 메시지를 내보낸다는 장점이 있다.

아마 한번쯤은 닭이 먼저인가 알이 먼저인가처럼 A서비스에 대해서 B서비스 객체를 필요로 하고 B서비스에 대해 A서비스가 필요하게 코드를 짜버렸다면, 에러 로그로 순환형 화살표가 뜨는 모습을 볼 수 있었을 것이다.

따라서 대부분의 사람들은 생성자 주입 방식을 권장하고, 스프링에서도 테스트 코드를 제외한 곳에서도 이 방식을 권장한다.

2. 설정 메서드 방식

설정 메서드를 통해 객체에 대해서 설정으로 의존 객체를 받는 방식이다. 이전의 생성자 방식을 설정 메서드 방식으로 바꾸면 다음과 같다.

public class Spring {
    private final Java java;
    private final MySQL mysql;

    public Spring() {
    }
    
    public void setJava(Java java) {
        this.java = java;
    }
    
    public void setMySQL(MySQL mysql) {
        this.mysql = mysql;
    }
    
    public void RunCode() {
        java.run();
        mysql.run();
    }
    ...
}

이 방법은 객체를 생성한 이후에 의존 객체를 설정할 수 있기 때문에 객체의 메서드를 실행하는 과정에서 NullPointException이 발생할 가능성을 높인다. 이러한 단점이 있지만 설정 메서드를 쓸 때 장점은 다음과 같다.

  • 어떤 이유로 인해 의존할 객체가 나중에 생성이 된다면 설정 메서드 방식을 사용하는 게 좋다.
  • 의존할 객체가 많은 경우, 설정 메서드 방식은 어떤 의존 객체가 설정되는 지 보다 쉽게 알 수 있어 가독성을 높여준다.

하지만, 전자로 작성해본 경험이 없어서 전자는 모르겠지만, 후자의 경우에는 Configuration 설정을 지원하는 프레임워크라면 그걸 사용하는 게 좋다. 그리고, 런타임 시점에 전략이 달라져야 한다면 차라리 다형성을 활용해 특정한 인터페이스나 추상 클래스를 대상으로 상속을 받은 걸 모두 스프링 빈에 대해서 맵 형태로 등록 받자. 그리고, 등록된 스프링 빈을 파라미터로 받은 구체 클래스 혹은 전략에 따라 선택해서 사용하는 게 좋다. 그런데 이렇게 해본 적이 없다.

※ 참고자료

개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴, 최범균 저

profile
성장하고 싶은 신입 BE개발자

0개의 댓글