IoC를 공부하려고 보니,
스프링 공식문서에서는
IoC는 DI로도 알려져 있다라고 설명하고 있다.
(제어를 역전한다는게 도대체 무슨 말이야..?)
DI -> 디자인패턴
IoC -> 설계원칙
그렇다면? DI 패턴을 사용하여 IoC 설계원칙을 구현하고 있다! 라고 생각할 수 있지 않을까?
(DI != IoC)
의존성이 존재하는 코드
DI -> Dependency Injection (의존성 주입)
먼저 DI를 알아보고 싶은데,
먼저 의존성이 존재하는 코드를 살펴보자
class Engine {
public void start() {
System.out.println("엔진 시작");
}
}
class Car {
// Engine에 직접 의존
private Engine engine = new Engine();
public void drive() {
engine.start();
System.out.println("차가 달립니다");
}
}
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.drive();
}
}
이 코드를 보면 Car를 drive 하려면 엔진을 start해야만 한다는 것을 알 수 있다.
Car가 Engine에 직접 의존하고 있으므로,
클래스 간 의존성을 갖고 있다고 볼 수 있고,
클래스 간 강한 결합(tight coupling)을 갖고 있다고 말할 수 있다.
아래 코드는 인터페이스를 사용하여 의존성을 낮춘 예시를 보여준다.
// 1. 인터페이스 정의로 결합도 낮춤
interface Engine {
void start();
}
// 2. 구체적인 엔진 구현
class GasolineEngine implements Engine {
public void start() {
System.out.println("가솔린 엔진 시작");
}
}
// 3. Car 클래스 - 생성자를 통해 의존성 주입
class Car {
private Engine engine; // 인터페이스에 의존
// 생성자를 통한 의존성 주입으로 느슨한 결합 구현
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("차가 달립니다");
}
}
// 4. 실행
public class Main {
public static void main(String[] args) {
Engine engine = new GasolineEngine(); // 필요에 따라 다른 엔진으로 교체 가능
Car car = new Car(engine);
car.drive();
}
}
이번 코드는
Car가 Engine 클래스에 직접 의존하는 이전 예시와는 다르게,
Engine 인터페이스에 의존하는 것을 보여주고 있다.
그리고 해당 인터페이스는
생성자를 통해서 의존성을 주입 받는 것을 알 수 있다.
(의존성 주입에는 필드주입, 메서드 주입, 생성자 주입 등이 존재한다.)
엔진도 원할 때 마다 원하는 엔진으로 만들 수 있고,
해당 엔진을 Car가 주입받으며
drive를 할수 있게 된다.
인터페이스에 의존하면서, 이를 통해 언제든지 원하는 구현체를 만들 수 있고, 외부로부터 구현체를 주입받으면서
느슨한 결합(Loose Coupling)을 가진다는 것을 보여주고 있다.
의존성 주입 (DI)
의존성에 대해서 알아 보았으니,
의존성을 주입받는 방식에 대해서 알아보자.
의존성 주입을 풀어서 설명하자면,
필요한 객체(의존관계를 가진)를 다른 객체에 전달하는 것이다.
위에서 잠깐 살펴보았지만,
의존성을 주입받는 방식에는 보통 3가지가 존재한다.
필드 주입, 메서드 주입, 생성자 주입
이 방식들 중에서도 우리는 생성자 주입을 많이 사용한다. (물론 절대적인 것은 아니다)
#Consumer가 class라고 가정
#기타 코드들은 생략
#필드 주입
Food food;
food = new Chicken();
#메서드 주입
Food food;
void setFood(Food food){
this.food = food;
}
Consumer consumer = new Consumer();
consumer.setFood(new Chicken());
#생성자 주입
Food food;
public Consumer(Food food) {
this.food = food;
}
Consumer consumer = new Consumer(new Chicken());
위 코드에서 볼 수 있는 것 처럼,
필드는 필드를 통해 의존성을 주입
메서드는 메서드(setter)를 통해 의존성을 주입
생성자는 생성자를 통해 의존성을 주입
하는 것을 알 수 있다.
// 결합도가 높은 코드
class OrderService {
private PaymentProcessor processor = new CreditCardProcessor(); // 직접 생성
}
// 의존성 주입 사용
class OrderService {
private PaymentProcessor processor;
public OrderService(PaymentProcessor processor) { // 외부에서 주입
this.processor = processor;
}
}
이처럼,
의존성 주입은 외부로 부터 객체를 주입받기 때문에
객체간의 결합도를 낮춰주고,
코드의 유지보수성과 확장성을 향상시켜 준다.
Spring은 생성자 주입을 권장한다
이 3가지 주입 방식 중에서도
Spring은 생성자 주입 방식을 권장하고 있다.
그 이유는 3가지 정도 있는데,,,
1. 불변성 보장
@Service
public class OrderService {
private final PaymentProcessor paymentProcessor;
@Autowired
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}
필드주입과, 메서드(setter)주입은 나중에 주입된 의존성을 변경할 수 있기 때문에
객체의 불변성을 보장해주기는 어렵다.
2. 의존성 누락 방지
메서드 주입과 필드 주입 방식은 의존성 주입이 필수가 아니기 때문에
의존성 주입이 안된 불완전한 객체가 생성될 위험이 있다
하지만, 생성자 주입은
객체 생성시점에 의존성 주입을 강제하기 때문에
의존성이 주입되지 않았다면 컴파일 시, 오류를 감지하여
의존성 누락을 방지할 수 있다.
3. 순환참조 방지
@Component
public class ClassA {
@Autowired
private ClassB classB;
public void doSomething() {
System.out.println("ClassA is doing something with ClassB");
}
}
@Component
public class ClassB {
@Autowired
private ClassA classA;
public void doSomething() {
System.out.println("ClassB is doing something with ClassA");
}
}
ClassA와 ClassB는 서로 필드 주입으로 의존하고 있다
Spring은 빈을 생성한 후에 @Autowired를 통해 의존성을 주입하려고 시도하지만,
두 개의 빈이 서로 필요로 하고 있으므로
빈 생성이 완료되지 않은 상태에서 런타임 단에서
오류가 발생할 수 있다. (순환참조 발생가능)
만약 아래와 같이 생성자 주입을 사용하는 경우,
@Component
public class ClassA {
private final ClassB classB;
@Autowired
public ClassA(ClassB classB) {
this.classB = classB;
}
public void doSomething() {
System.out.println("ClassA is doing something with ClassB");
}
}
@Component
public class ClassB {
private final ClassA classA;
@Autowired
public ClassB(ClassA classA) {
this.classA = classA;
}
public void doSomething() {
System.out.println("ClassB is doing something with ClassA");
}
}
Spring 컨테이너가 ClassA를 생성하려면 ClassB가 필요하고, ClassB를 생성하려면 ClassA가 필요하므로 순환 참조가 발생한다.
하지만 생성자 주입을 사용했기 때문에 Spring은 애플리케이션 시작 시점에 이를 감지하고
컴파일 시점에서 에러를 발생시켜주어서 순환참조를 방지할 수 있다!
IoC (Inversion of Control), 제어의 역전
IoC가 뭔지에 대해서는,
프레임워크와 라이브러리를 통해 설명이 가능하다.
라이브러리를 사용하는 어플리케이션은 어플리케이션의 제어 흐름을 라이브러리에 내주지 않는다.
단지 필요한 시점에 라이브러리에 작성된 객체를 적재적소에 가져다 쓸 뿐이다.
하지만 프레임워크를 사용한 어플리케이션의 경우,
어플리케이션 코드에 작성한 객체들을 프레임워크가 필요한 시점에 가져다가
프로그램을 구동하기 때문에 프로그램의 제어권이 프레임워크로 역전된다.
IoC는 간단히 프로그램의 제어 흐름 구조가 뒤바뀌는 것이라고 할 수 있다.
DI를 통해 객체를 외부로 부터 주입을 받고 객체를 생성하고 있는데,
// 결합도가 높은 코드
class OrderService {
private PaymentProcessor processor = new CreditCardProcessor(); // 직접 생성
}
// 의존성 주입 사용
class OrderService {
private PaymentProcessor processor;
public OrderService(PaymentProcessor processor) { // 외부에서 주입
this.processor = processor;
}
}
이미 위에서 보았던 이 코드 처럼
CreditProcessor를 직접생성하는 건
PaymentProcessor가 제어 권한을 갖고 있다고 볼 수 있지만
DI패턴과 같이, 생성자 주입을 통해 외부로 부터 객체 의존성을 주입받아 사용하는 것은
제어의 권한은 외부 Processor로 역전되었다라고 볼 수 있다.
(외부에서 주입되는 객체가 달라지면, 생성되는 객체도 달라짐)
그래서 우리는
DI 패턴을 사용하여 IoC 설계 원칙을 구현하고 있다
라고 말할 수 있는 것이다.
'Back-End' 카테고리의 다른 글
DTO, DAO, VO 차이점 (0) | 2025.04.11 |
---|---|
클라이언트에서 서버로 쿠키가 전달되지 않는다 (React, Spring) (0) | 2025.04.06 |
Kafka를 사용하는 이유 (0) | 2025.03.28 |
MSA에서 SAGA Pattern을 사용하는 이유 (0) | 2025.03.28 |
Path Variable과 Request Param 의도는 다르다 (차이점) (0) | 2025.03.19 |