[Daily] DI / DIP
🔧 DI (Dependency Injection)란?
DI(의존성 주입)은 객체가 직접 필요한 의존 객체를 생성하지 않고, 외부에서 주입받는 방식
📌 예: “자동차(Car)”에 “엔진(Engine)”이 필요할 때
❌ 직접 생성 (의존성 직접 관리)
class Car {
private Engine engine = new Engine();
// Car가 직접 Engine을 생성함
}
✅ 의존성 주입 방식
class Car {
private Engine engine;
public Car(Engine engine) { // 외부에서 Engine을 주입받음
this.engine = engine;
}
}
이렇게 하면 Car는 어떤 종류의 엔진이 들어오는지 몰라도 되고, 테스트도 더 유연하게 할 수 있다.
🧩 주요 용어 정리
용어 | 설명 |
---|---|
Dependency (의존성) | 한 객체가 다른 객체에 기능적으로 의존하고 있을 때, 그 다른 객체를 “의존성”이라고 부름. 예: Car → Engine |
Injection (주입) | 의존 객체를 직접 생성하지 않고, 외부에서 넣어주는 행위 |
DI Container | 의존성들을 관리하고 주입해주는 컨테이너. Spring의 ApplicationContext 가 대표적 |
Constructor Injection | 생성자를 통해 의존성을 주입 (가장 권장되는 방식) |
Setter Injection | setter 메서드를 통해 의존성 주입 |
Field Injection | 필드에 직접 주입 (@Autowired 등). 간단하지만 테스트 어려움 |
IoC (Inversion of Control) | “제어의 역전” — 객체의 생성과 생명주기 관리를 개발자가 아니라 프레임워크가 담당하는 개념. DI는 IoC를 구현하는 한 방식 |
🏗️ DI를 사용하는 이유
- 결합도 낮춤 → 코드가 더 유연하고 확장 가능
- 테스트 쉬움 → 가짜(Mock) 객체 주입해서 단위 테스트 가능
- 유지보수 편리 → 새로운 의존성으로 쉽게 교체 가능
- 관심사 분리 → 객체는 자신의 로직에만 집중하고 의존성 관리는 외부에 맡김
🔁 자주 함께 언급되는 개념
개념 | 설명 |
---|---|
Service, Repository 등 역할 분리 | DI를 통해 각 계층 간 결합을 느슨하게 만들 수 있음 |
@Component, @Service, @Repository | Spring이 자동으로 Bean으로 등록해서 DI 가능하게 함 |
@Autowired, @Inject, @Qualifier | 의존성을 주입하는 방법과 조건을 지정함 |
🔧 Spring에서의 DI 예제
🎯 목표
Car
가Engine
에 의존- 다양한 방식으로 Engine을 주입 (DI)
- DI를 통해 Car는 어떤 Engine이든 받아서 쓸 수 있도록 설계
🔹 인터페이스 및 구현체
public interface Engine {
void start();
}
@Component
public class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine starting...");
}
}
@Component
public class ElectricEngine implements Engine {
@Override
public void start() {
System.out.println("Electric engine starting...");
}
}
🔹 Car 클래스 (DI 적용)
방법 1: 생성자 주입 (가장 권장됨)
@Component
public class Car {
private final Engine engine;
@Autowired // 생략 가능 (Spring 4.3 이상)
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car is moving");
}
}
방법 2: 필드 주입 (테스트 불편, 비권장)
@Component
public class Car {
@Autowired
private Engine engine;
public void drive() {
engine.start();
System.out.println("Car is moving");
}
}
방법 3: Setter 주입 (선택적 의존성에 적합)
@Component
public class Car {
private Engine engine;
@Autowired
public void setEngine(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car is moving");
}
}
🔧 필드 주입이 테스트에서 불편한 이유
❗ 문제: 주입을 직접 할 수 없다
필드 주입은 아래처럼 private
필드에 @Autowired
를 붙여 자동 주입
@Component
public class Car {
@Autowired
private Engine engine;
public void drive() {
engine.start();
}
}
이런 경우 테스트 코드에서 직접 engine
을 주입할 수 없다
Car car = new Car();
로 생성해도engine
이null
engine
을 주입하려면 리플렉션을 써야 함:
@Test
void testDrive_withReflection() throws Exception {
Car car = new Car();
Engine mockEngine = mock(Engine.class);
Field field = Car.class.getDeclaredField("engine");
field.setAccessible(true);
field.set(car, mockEngine); // 강제로 주입
car.drive();
}
✅ 생성자 주입과 비교
생성자 주입이면, 다음과 같이 쉽게 테스트 가능:
@Test
void testDrive() {
Engine mockEngine = mock(Engine.class);
Car car = new Car(mockEngine); // 직접 주입 가능
car.drive();
verify(mockEngine).start(); // 검증도 가능
}
💡 필드 주입의 다른 단점
항목 | 내용 |
---|---|
❌ 불변성 없음 | 생성 시점에 주입되지 않아서 final 키워드 못 씀 |
❌ 명시적 의존성 없음 | 어떤 의존성이 필요한지 생성자 시그니처로는 알 수 없음 |
❌ 테스트 어려움 | 위에서 본 것처럼 mock 주입, 의존성 교체가 어렵고 우회적 |
❌ 유지보수 어려움 | 코드만 보고는 어떤 객체가 들어오는지 추론이 어려움 |
✅ 정리
- 필드 주입은 편리해 보이지만, 유닛 테스트, 불변성, 명시성 측면에서 모두 불리함
- 그래서 Spring 공식 가이드, Josh Long 같은 전문가들도 “항상 생성자 주입을 쓰라”고 권장 함
- 테스트에서는 특히 mock 객체를 주입하기 어려워서 정말 불편함
✅ Setter 주입이 “선택적 의존성”에 적합한 이유
어떤 의존성은 있으면 좋지만, 없어도 동작이 가능한 경우 → 꼭 생성자에 넣을 필요가 없음
→ 그래서 setter로 필요할 때만 주입하도록 설계
@Component
public class MyService {
private Logger logger;
@Autowired(required = false) // 주입 안 돼도 오류 안 남
public void setLogger(Logger logger) {
this.logger = logger;
}
public void doSomething() {
if (logger != null) {
logger.log("Starting...");
}
System.out.println("Doing something important!");
}
}
Logger
는 없어도 로직은 돌아감- 있을 경우에만 로깅 수행
- 따라서 Setter 주입이 적합
✅ Setter 주입의 장점
항목 | 설명 |
---|---|
✔ 선택적 의존성 표현 가능 | @Autowired(required = false) 로 있으면 주입, 없으면 넘어감 |
✔ 나중에 바꿀 수 있음 | Bean 생성 이후 다른 객체로도 주입 가능 (동적으로 바꾸고 싶을 때) |
✔ 테스트에서 mock 쉽게 주입 가능 | setLogger(mockLogger) 처럼 주입 가능 |
❌ Setter 주입의 단점
항목 | 설명 |
---|---|
❌ 불변 객체 설계 불가 | setter는 값 변경 허용 → 상태가 바뀔 수 있음 |
❌ 의존성 누락 방지 어려움 | 주입을 깜빡해도 컴파일러가 안 잡음 (런타임 오류 유발 가능) |
❌ 명시적 의존성 표현 약함 | 클래스만 보고는 필수인지 선택인지 바로 파악 어려움 |
✅ 비교 요약
구분 | 생성자 주입 | Setter 주입 | 필드 주입 |
---|---|---|---|
📌 용도 | 필수 의존성 | 선택적 의존성 | 가볍게 쓰기 쉬움 (but 비권장) |
🔒 불변성 | 유지 가능 | 유지 불가 | 유지 불가 |
🔍 테스트 용이성 | 매우 좋음 | 좋음 | 나쁨 |
🧩 명시성 | 높음 | 낮음 | 낮음 |
🔚 정리
- Setter 주입은 “필수는 아닌, 옵션적인” 의존성을 주입할 때 사용
- 그 외에는 대부분 생성자 주입이 정석
- Spring에서는 setter 주입도 가능하지만, 무분별하게 사용하면 **설계가 모호해지고 유지보수 어려워짐
🔧 2. DIP (Dependency Inversion Principle)
📘 DIP란?
의존성 역전 원칙:
“상위 모듈(비즈니스 로직)은 하위 모듈(구현)에 의존해서는 안 되며,
둘 다 추상(인터페이스)에 의존해야 한다.”
❌ DIP 위반 예
public class Car {
private GasEngine engine = new GasEngine();
// 구현체에 의존 (강한 결합)
}
Car
는 GasEngine
이라는 구체 클래스에 의존 → 확장성 낮고, 테스트 어려움
✅ DIP 만족 예
public class Car {
private Engine engine;
public Car(Engine engine) { // 인터페이스(추상)에 의존
this.engine = engine;
}
}
Car
는 Engine
이라는 추상 타입에만 의존 → 유연하고 테스트 용이
✅ 1. Engine
추상화(인터페이스) 정의
public interface Engine {
void start();
}
Engine
은 인터페이스이므로 “행위(기능)”만 정의Car
는 이제 이 추상 타입만 알면 됨
✅ 2. GasEngine
구현체 정의 (DIP 만족)
public class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine started.");
}
}
GasEngine
은Engine
인터페이스를 구현한 클래스- 이 구현체는 실제 기능을 수행하는 클래스
✅ 3. Car
클래스는 추상화에만 의존
public class Car {
private final Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
}
}
- 이제
Car
는 더 이상GasEngine
이 뭔지 몰라도 됨 - 오직
Engine
인터페이스가 제공하는 기능(start()
)만 사용
✅ 4. 사용하는 쪽에서 주입 (DI)
public class Main {
public static void main(String[] args) {
Engine engine = new GasEngine(); // 어떤 구현체를 쓸지는 여기서 결정
Car car = new Car(engine);
car.drive(); // "Gas engine started." 출력
}
}
Car
는Engine
타입을 받아서 사용하므로 DIP 만족- 구현체(
GasEngine
,ElectricEngine
등)는 주입을 통해 교체 가능
✅ 테스트도 쉬워짐 (모의 객체)
public class MockEngine implements Engine {
public boolean started = false;
@Override
public void start() {
started = true;
}
}
@Test
void testCarDrives() {
MockEngine mock = new MockEngine();
Car car = new Car(mock);
car.drive();
assertTrue(mock.started);
}
💡 핵심 요약
구분 | DIP 위반 | DIP 만족 |
---|---|---|
의존성 | GasEngine 직접 생성 |
Engine 인터페이스 주입 |
결합도 | 높음 (변경 어려움) | 낮음 (유연함) |
테스트 | 어려움 (Mock 불가) | 쉬움 (인터페이스로 대체 가능) |
확장성 | GasEngine 만 가능 |
ElectricEngine 등 다른 엔진도 가능 |
🔁 DIP vs DI 차이
개념 | 설명 |
---|---|
DIP (원칙) | 설계 원칙. “추상화에 의존하라, 구현에 의존하지 마라.” |
DI (기술) | 구현 기술. “의존 객체를 외부에서 주입해라.” (이 원칙을 실현하는 방법 중 하나) |
즉, DI는 DIP를 구현하기 위한 수단 중 하나다.
✅ DIP(의존성 역전 원칙) 핵심 정리
상위 모듈(정책)이 하위 모듈(구현)에 의존하지 말고,
둘 다 추상화(인터페이스)에 의존하라는 원칙.
📌 DIP를 제대로 이해하기 위해 꼭 알아야 할 추가 개념들
1. 상위/하위 모듈이 뭐냐?
- 상위 모듈: 핵심 비즈니스 로직, 규칙, 정책 등 (예:
OrderService
) - 하위 모듈: 구체적인 구현 (예:
MemoryOrderRepository
,JdbcOrderRepository
)
// DIP 위반 (상위가 하위에 직접 의존)
public class OrderService {
private MemoryOrderRepository repository = new MemoryOrderRepository();
}
// DIP 만족 (상위도 하위도 인터페이스에 의존)
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
2. DIP를 적용하면 얻는 실질적 이점
이점 | 설명 |
---|---|
✅ 확장성 | 새로운 구현체를 쉽게 붙일 수 있음 (RedisRepo , MongoRepo 등) |
✅ 테스트 용이 | FakeRepo , MockRepo 를 바로 주입해서 테스트 가능 |
✅ 유지보수 편의 | 비즈니스 로직과 구현이 분리되어 변경 영향 ↓ |
✅ 의존 방향 명확 | 상위 정책이 구현 세부사항에 끌려가지 않음 |
3. DIP는 SRP(단일 책임 원칙)와 짝이다
- DIP는 단일 책임 원칙(SRP)과 함께 “역할과 구현을 분리”하는 설계를 가능하게 해줘야함.
- 예:
OrderService
는 “주문 처리”라는 역할만 집중하고, 저장 방식은 관심 없음.
4. DIP를 실현하는 방법은 DI
- DIP는 설계 원칙이고,
- DI(Dependency Injection)는 그걸 구현하는 기술/패턴
- Spring은 DI를 통해 DIP를 자연스럽게 실현하게 해준다.
👉 DIP를 제대로 이해하려면:
- 단순히 “인터페이스 써야 한다”는 수준을 넘어서서,
- “의존 방향이 추상화로 향하도록 설계하는 것”이 핵심이고,
- DI는 그걸 도와주는 기술이라는 점을 이해해야 한다.
댓글남기기