다양한 의존관계 주입 방법

의존성 주입 ( DI )

의존성 주입(Dependency Injection, DI)은 객체 지향 프로그래밍에서 객체 간의 관계를 설정하는 방식 중 하나입니다.

객체가 필요한 의존성( 다른 객체 )을 직접 생성하지 않고 외부에서 주입받는 방식을 말합니다.

이를 통해 객체 간의 결합도를 낮추고, 유연한 코드와 테스트 가능성을 높일 수 있습니다.

 

만약 우리가 자동차를 만들고 있다고 했을 때, 자동차는 엔진, 타이어, 핸들 등 여러 부품이 필요합니다. 만약 자동차가 엔진을 스스로 만들어야 한다면, 자동차 클래스 내부에서 엔진을 직접 생성해야 합니다. 이 경우, 자동차와 엔진이 강하게 결합되어 있어서 자동차를 테스트하거나 엔진을 교체하는 것이 매우 어렵습니다.

 

하지만 의존성 주입을 사용하면, 자동차가 엔진을 외부에서 전달받는 방식이 됩니다. 즉, 자동차 클래스는 엔진을 직접 만들지 않고, 엔진을 외부에서 주입받아서 사용합니다. 그럼 손쉽게 엔진을 교체하거나 엔진만 따로 테스트를 할 수 있습니다.

public class Car {
    private Engine engine;

    public Car() {
        this.engine = new Engine(); // 직접 엔진을 생성
    }

    public void start() {
        engine.run();
    }
}

 

코드에서 Car는 Engine을 직접 생성하고 있기 때문에 Car와 Engine이 강하게 결합되어 있습니다.

Engine을 교체하려면 Car도 변경해야 하는 문제점이 있습니다.

왜 의존성 주입이 중요할까?

첫 번째로 유연성 증가입니다.

객체 간의 결합도를 낮추면, 각 객체가 독립적으로 동작할 수 있습니다.

즉, 객체가 바뀌거나 교체될 때 다른 객체에 미치는 영향이 적습니다.

두 번째로 테스트 용이성입니다.

의존성을 외부에서 주입받기 때문에, 테스트할 때 필요에 따라 Mock 객체나 Stub 객체를 사용하여 테스트할 수 있습니다.

이런 방식으로 실제 객체가 아닌 가짜 객체를 주입받아 테스트할 수 있습니다.

마지막으로 코드 재사용성입니다.

의존성 주입을 통해 특정 기능을 가진 객체가 다른 객체들에 의존하는 방식으로 코드를 재사용할 수 있습니다.

 

DI를 구현하는 네 가지 방법

1. 생성자 주입

2. 수정자 주입( Setter 주입 )

3. 필드 주입

4. 일반 메서드 주입

 

생성자 주입

생성자 주입은 객체를 생성할 때 필요한 의존성을 생성자를 통해 전달받는 방식입니다.

이 방식은 주입받는 의존성을 필수적으로 받아야 하므로, 객체가 불완전하게 생성되는 것을 방지하고, 안정성과 유연성을 제공합니다.

실무에서 가장 많이 사용되는 DI 방법입니다.
@Component
public class Student {
    private final Teacher teacher; // 의존성

    // 생성자를 통한 의존성 주입 ( @Autowired 생략 가능 )
    @Autowired
    public Student(Teacher teacher) {
        this.teacher = teacher;
    }

    public void study() {
        teacher.teach();
    }
}

 

특징

1. 의존성을 반드시 전달받아야 하므로 객체가 불완전하게 생성되는 것을 방지합니다.

 

생성자 주입의 가장 큰 특징은 의존성을 반드시 전달받아야만 객체를 생성할 수 있다는 점입니다.

즉, 필요한 의존성이 주입되지 않으면 객체를 생성할 수 없기 때문에 객체가 불완전한 상태로 생성되는 것을 막을 수 있습니다.

@Component
public class Car {
    private final Engine engine;

    // 생성자 주입
    @Autowired
    public Car(Engine engine) {
        this.engine = engine;  // 스프링이 Engine을 자동으로 주입
    }

    public void start() {
        engine.run();  // 의존성 주입이 자동으로 이루어짐
    }
}

 

만약 Engine 이 스프링 컨테이너에 등록되어 있지 않으면, 스프링은 NoSuchBeanDefinitionException 오류를 발생시킵니다. 또한 만약 Engine이 @Autowired된 후 null일 경우, NullPointerException이 발생할 수 있습니다.

스프링은 기본적으로 객체가 제대로 주입되지 않았을 때 예외를 던지므로, 이를 명시적으로 처리할 필요가 없습니다.

 

2. 주입받은 의존성을 변경할 수 없기 때문에 안정성이 높습니다.

생성자 주입을 사용하면, 주입받은 의존성 객체가 불변(immutable) 상태가 됩니다.

즉, 생성된 후에는 의존성을 변경할 수 없기 때문에 객체의 상태가 예기치 않게 변경되는 것을 방지할 수 있습니다.

이로 인해 안정성이 높아지고, 예측 가능한 동작을 보장합니다.

@Component
public class Car {
    private final Engine engine;

    // 생성자 주입
    @Autowired
    public Car(Engine engine) {
        this.engine = engine;  // 스프링이 Engine을 자동으로 주입
    }

    public void start() {
        engine.run();  // 의존성 주입이 자동으로 이루어짐
    }
}

// 다른곳에서 설정값을 바꾸는 것 자체가 불가능

Car.engine = "4기통"; // X 불가능

 

장점

필수 의존성을 강제할 수 있습니다. ( 필요한 객체가 항상 주입된다는 소리 )

객체가 완전히 초기화된 상태로 사용이 가능합니다.

테스트하기 쉽습니다.

 

생성자 주입의 여러 가지 장점 중 하나인 테스트하기 쉽다는 말은 무엇일까요?

테스트 시 의존성을 변경하거나 대체하는 작업이 매우 쉽습니다. 생성자 주입은 의존성을 외부에서 주입하므로, 테스트 환경에서 원하는 구현체를 손쉽게 전달할 수 있습니다.

public interface Engine {
    void run();
}

public class DieselEngine implements Engine {
    @Override
    public void run() {
        System.out.println("Diesel engine running");
    }
}

public class ElectricEngine implements Engine {
    @Override
    public void run() {
        System.out.println("Electric engine running");
    }
}
@Test
void testWithDifferentEngines() {
    // Diesel 엔진으로 테스트
    Engine dieselEngine = new DieselEngine();
    Car carWithDiesel = new Car(dieselEngine);
    carWithDiesel.start();

    // Electric 엔진으로 테스트
    Engine electricEngine = new ElectricEngine();
    Car carWithElectric = new Car(electricEngine);
    carWithElectric.start();
}

단점

의존성 개수가 많아질수록 생성자 코드가 길어질 수 있습니다.

 

수정자 주입 ( Setter 주입 )

수정자 주입은 클래스의 필드를 설정자 메서드(Setter)를 통해 주입하는 방식입니다.

이 방식에서는 생성자 대신, 객체가 생성된 후 스프링 컨테이너가 설정자 메서드를 호출하여 의존성을 주입합니다.

위에 코드 예시를 보면서 설명하자면 스프링 컨테이너는 Car 객체를 생성하고 이 시점에는 Car 객체의 Engine 필드가 null 상태로 있다가 객체들이 다 생성된 후 스프링 컨테이너에서 해당 객체에 필요한 필드들을 찾아서 주입해 주는 방식입니다.

 

@Component
public class Student {
    private Teacher teacher; // 의존성

    // Setter 메서드를 통한 의존성 주입
    @Autowired
    public void setTeacher(Teacher teacher) {
        this.teacher = teacher;
    }

    public void study() {
        teacher.teach();
    }
}

 

특징

1. 의존성을 선택적으로 주입할 수 있습니다. ( 필수가 아닌 경우에 유용 )

수정자 주입은 필수가 아닌 의존성에서 유용합니다. 스프링에서 @Autowired 와 함께 required = false를 설정하면, 스프링 컨테이너가 해당 의존성을 찾지 못하더라도 객체를 생성할 수 있습니다.

@Component
public class Car {
    private Engine engine;

    // 수정자 메서드
    @Autowired(required = false) // 의존성 주입이 선택적
    public void setEngine(Engine engine) {
        this.engine = engine;
    }

    public void start() {
        if (engine != null) {
            engine.run();
        } else {
            System.out.println("No engine installed. Car cannot start.");
        }
    }
}

// Test
@SpringBootTest
public class CarTest {
    @Autowired
    private Car car;

    @Test
    public void testCarWithoutEngine() {
        car.start(); // Engine이 주입되지 않으면 "No engine installed. Car cannot start." 출력
    }
}

 

2. 객체를 생성한 후 의존성을 주입하므로 주입 시점에 유연성이 있습니다.

수정자 주입을 사용하면 객체 생성 후 의존성을 나중에 교체하거나 주입할 수 있습니다.

이는 객체의 상태를 동적으로 변경해야 할 때 유용합니다.

// 여러 빈 등록
public interface Engine {
    void run();
}

@Component
public class DieselEngine implements Engine {
    @Override
    public void run() {
        System.out.println("Diesel engine is running!");
    }
}

@Component
public class ElectricEngine implements Engine {
    @Override
    public void run() {
        System.out.println("Electric engine is running!");
    }
}

// Test
@SpringBootTest
public class CarTest {
    @Autowired
    private Car car;

    @Autowired
    private DieselEngine dieselEngine;

    @Autowired
    private ElectricEngine electricEngine;

    @Test
    public void testCarWithDynamicEngine() {
        // Diesel Engine 주입
        car.setEngine(dieselEngine);
        car.start(); // Diesel engine is running!

        // Electric Engine으로 교체
        car.setEngine(electricEngine);
        car.start(); // Electric engine is running!
    }
}

 

장점

선택적 의존성 주입에 적합합니다.

의존성을 나중에 교체할 수 있는 유연성이 있습니다.

 

단점

필수 의존성을 강제할 수 없습니다. ( 주입하지 않아도 컴파일러가 에러를 내지 않습니다. )

이러한 문제는 개발자가 실수로 주입하지 않았을 때 런타임 에러가 난다는 큰 단점입니다.

 

필드주입

필드 주입은 의존성을 클래스의 필드에 직접 주입하는 방식입니다.

필드에 @Autowired 를 붙여 스프링이 자동으로 의존성을 주입합니다.

들어가기에 앞서 필드 주입은 코드를 간결하게 작성할 수 있지만, 몇 가지 단점 때문에 실무에서는 크게 권장되지 않는 경우가 많습니다.

 

@Component
public class Student {
    @Autowired
    private Teacher teacher; // 의존성

    public void study() {
        teacher.teach();
    }
}

 

장점

가장 간단하고 코드가 짧아 개발자가 빠르게 사용할 수 있습니다.

 

단점

테스트가 어렵습니다. ( Mock 객체를 주입하거나 의존성을 교체하기 어렵기 때문입니다. )

테스트 시 모킹(mocking)을 통해 의존성을 주입하거나 교체해야 하는데, 필드 주입은 이를 쉽게 처리할 수 없습니다. 생성자 주입이나 수정자 주입은 테스트 코드에서 의존성을 주입하는 방법을 제공하지만, 필드 주입은 그렇지 않습니다.

@Test
public void testCarStartWithoutSpring() {
    // Car 객체를 직접 생성해야 하는 경우, Engine 주입 불가
    Car car = new Car();
    car.start(); // NullPointerException 발생
}

 

또한 DI 프레임워크(Spring) 이 없으면 객체를 사용할 수 없고, 객체가 완전히 초기화되지 않은 상태에서 사용될 가능성이 높습니다. 따라서 되도록이면 필드 주입은 사용하지 않는 것을 추천합니다.

 

일반 메서드 주입

일반 메서드 주입은 의존성을 전달받기 위해 아무 메서드에나 주입하는 방식입니다.

주로 특정 기능을 실행할 때 필요한 의존성을 주입하는 데 사용됩니다.

일반 메서드 주입도 필드 주입처럼 실무에서 잘 사용하지 않습니다.

특징

의존성을 동적으로 전달받을 수 있습니다.

의존성이 항상 필요한 상황이 아닌 경우에 유용합니다.

@Component
public class Student {
    public void study(Teacher teacher) { // 메서드 인자로 의존성 주입
        teacher.teach();
    }
}

 

장점

특정 시점에만 의존성을 주입받을 수 있습니다.

필요한 경우에만 의존성을 전달하므로 메모리 사용을 절약합니다.

 

단점

코드가 일관적이지 않을 수 있습니다. ( 어떤 의존성이 메서드를 통해 주입될지 예측하기 너무 어려움 )

일반적으로 많이 사용되지는 않습니다. ( 제 생각입니다. )