스프링에서는 외부에서 의존성을 주입하는 것이 가장 큰 특징입니다.
스프링에서는 왜 외부에서 의존성을 주입하는 방식을 사용할까요?
외부에서 주입을 하게되는 경우 느슨한 결합으로 강한 의존성을 줄이고 객체의 변경이 다른 객체에 미치는 영향을 최소화 한다는 특징이 있습니다. 이것은 테스트를 용이하게 하고 코드의 가독성과 유지 보수성을 높여줍니다. 확장성과 재사용성에서도 유용합니다.
하지만 이렇게 이론적인 것들을 들어서는 이해하기가 어렵습니다.
너무 추상적이죠
저 역시 처음 자바를 공부할 때 이론으로만 달달 외우던 것들이었습니다.
그럼 이해를 위해 코드를 한번 작성해 봅시다.
첫번째로 살펴볼 것은 생성자 주입방식입니다.
가장 보편적으로 사용되기도 하고 스프링 공식 문서에서 추천하고 있는 방식이기도 합니다.
생성자주입 방식은 말 그대로 생성자를 통해 객체를 주입하는 방식입니다.
의존성을 객체를 생성할 때 생성자를 통해 주입한다는 말입니다.
public class ExampleService {
private Dependency dependency;
public ExampleService(Dependency dependency) {
this.dependency = dependency;
}
}
클래스는 인스턴스화를 통해 객체를 만들어내기 위해 존재한다고해도 과언이 아닙니다.
하지만 자바에서는 초기값을 정해줘야 컴파일 단계에서 오류가 나지 않는 특징이 있습니다.
따라서 클래스가 존재할때는 반드시 생성자가 있어야합니다.
자바는 상냥하게 기본생성자를 만들어주기도하지만 명시적으로 명확하게 하기 위해 기본 생성자라고 할지라도 적는 경우가 있습니다.
자동차와 엔진을 예로들어서 생성자 주입을 이해해봅시다.
public interface Engine {
void start();
}
public class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine starts.");
}
}
엔진을 인터페이스로 만들었습니다.
인터페이스 엔진을 상속받아 가스엔진을 오버라이드로 재정의하였습니다.
이때 엔진을 Car클래스에서 어떻게 의존받는지 적어봅시다.
public class Car {
private final Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void startCar() {
engine.start();
}
}
Car 클래스는 Engine에 의존하고 있으며 생성자를 통해 주입되고 있습니다.
starCar 메서드는 Engine의 start메서드를 호출하여 실행됩니다.
그럼 메인 메서드에서 어떤식으로 객체가 만들어질 수 있는지 봐볼까요?
public class Main {
public static void main(String[] args) {
Engine engine = new GasEngine();
Car car = new Car(engine);
car.startCar();
}
}
GasEngine인스턴스를 생성하고 engine변수에 넣어 Car객체를 만들때 참조합니다.
이경우 만들어진 car객체의 startCar() 메서드가 실행되면 "Gas engine starts." 를 확인할 수 있습니다.
이를 통해 Car 객체가 생성될때 Engine 의존성이 주입되어 차의 엔진이 어떤 종류인지에 따라 startCar메서드의 동작이 결정됩니다.
이렇게 생성자 주입 방식은 필요한 의존성을 명확하게 만들고,
객체의 불변성을 보장(final)하는 등 여러가지 장점을 제공하고 있습니다.
하지만 단점도 존재합니다.
생성자 주입의 단점
객체 생성을 할 때 필요한 모든 의존성을 제공해야 하므로, 의존성이 많은 경우에 생성자의 인자가 많아집니다. 이는 코드를 이해하고 유지보수하는데 어려움을 줄 수 있습니다. 또한 순환참조가 발생하여 문제가 발생할 수 있습니다. 순환참조의 경우 대부분 디자인의 문제이기 때문에 처음부터 다시 고민하여 로직을 짜는 것이 좋습니다.
두번째, Setter 주입방식을 살펴봅시다.
이론적인 장점으로 객체를 생성할 때 한번에 주입하는 생성자 주입과 다르게 객체를 생성한 후에도 의존성을 변경할 수 있어서 상대적으로 유연합니다. 선택적인 의존성에 대해 적합한데 이는 모든 의존성을 제공하지 않고 일부만 설정할 수 있기 때문입니다.
이론만 접근하면 너무 추상적이기 때문에 이전에 사용한 Engine과 Car를 사용하여 다시 예시 코드를 작성해 보겠습니다.
public class Car {
private Engine engine;
public void setEngine(Engine engine) {
this.engine = engine;
}
public void startCar() {
if (engine != null) {
engine.start();
} else {
System.out.println("Engine is not installed.");
}
}
}
여기서는 Setter를 통해 의존성을 주입하고 있습니다. 이것만봐서는 객체를 생성한 이후에 의존성을 변경할 수 있다는 것이 잘 와닿지 않을텐데요. 그럼 여기서 메인메서드를 보겠습니다.
public class Main {
public static void main(String[] args) {
Car car = new Car();
// Try to start the car without an engine
car.startCar(); // This will print "Engine is not installed."
// Now set the engine and try again
Engine engine = new GasEngine();
car.setEngine(engine);
car.startCar(); // This time, it will print "Gas engine starts."
}
}
메인 메서드에서 처음에는 엔진이 없는 자동차를 만듭니다.
이때 starCar()메서드를 시작해도 엔진이 없어서 자에 시동이 걸리지 않습니다.
이때 GasEngine 인스턴스를 생성하고 이를 Car의 세터를 통해 주입합니다.
그러고나서 다시 starCar()메서드를 실행하면 시동이 걸리는 것을 확인할 수 있습니다.
이때 만약 GasEngine이 아니라 전기엔진, 가솔린엔진, 수소엔진 등 여러가지 엔진 버전을 만들어놓은 뒤 세터로 필요한 엔진을 사용하여 객체를 만들 수 있을 것입니다. 생성자 주입 방식에 비해 유연하다는 점은 바로 이런 점 때문입니다.
세번째로 필드 주입 방식을 살펴봅시다.
필드주입방식은 간단하고 직관적이라는 장점이 있습니다.
필드 주입방식은 @Autowired를 사용하는것으로 상대적으로 간편합니다.
import org.springframework.beans.factory.annotation.Autowired;
public interface Engine {
void start();
}
public class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine starts.");
}
}
public class ElectricEngine implements Engine {
@Override
public void start() {
System.out.println("Electric engine starts.");
}
}
public class Car {
// 필드 주입
@Autowired
private Engine engine;
public void startEngine() {
engine.start();
}
}
하지만 final로 선언될 수 없기 때문에 불변성을 깨뜨릴 수 있습니다. 또한 주입되는 필드를 명시적으로 확인하거나 변경하기 어렵기 때문에 유지보수와 테스트가 어려울 수 있습니다. 스프링 공식문서에서도 생성자 주입이나 세터주입 방식이 권장되는 이유입니다.
마지막으로 인터페이스 주입 방식을 살펴봅시다.
인터페이스 주입방식의 장점은 특정 인터페이스를 구현한 클래스만 의존성을 주입받을 수 있도록 제한할 수 있습니다. 의존성을 주입받는 객체가 주입받을 수 있는 의존성의 타입을 명시적으로 알 수 있습니다.
역시 이론적으로는 너무 어려우니 코드를 살펴봅시다.
public interface Engine {
void start();
}
public class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine starts.");
}
}
이 부분은 동일하게 적용하고 자동차가 엔진 주입을 받을 수 있도록 인터페이스를 정의해볼까요?
public interface EngineInjectable {
void injectEngine(Engine engine);
}
EngineInjectable 인터페이스를 구현하고 이 인터페이스를 구현하는 구현체를 만들어 봅시다.
public class Car implements EngineInjectable {
private Engine engine;
@Override
public void injectEngine(Engine engine) {
this.engine = engine;
}
public void startCar() {
if (engine != null) {
engine.start();
} else {
System.out.println("Engine is not installed.");
}
}
}
여기서 Car클래스는 EngineInjectable를 통해 Engine의존성을 주입받고 있는 것을 볼 수 있습니다.
마지막으로 이것을 사용하면 메인 클래스를 만들어봅시다.
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.startCar(); // 엔진이 없어서 시동안걸림
Engine engine = new GasEngine();
car.injectEngine(engine);
car.startCar(); // 가스엔진이 시동걸림
}
}
중요한 것을 한번 더 짚고 집어보자면
생성자 주입의 장점
1. 객체가 생성될 때 필요한 모든 의존성이 주입되기 때문에 일관성을 보장할 수 있습니다.
2. 의존성이 'final'로 선언될 수 있기 때문에 일단 한번 설정되면 변경되지 않습니다. 이는 불변성을 보장하게 되고 스레드 안전성을 확보할 수 있게 됩니다