1. DI 한 줄 정의
DI(Dependency Injection)는 필요한 객체를 직접 만들지 않고 컨테이너가 대신 만들어 연결해 주는 방식이다. 코드는 “나는 이런 역할이 필요해”만 선언하고, 구체 구현의 생성·선택·수명 관리는 컨테이너가 맡는다.
interface Sender { void send(String msg); }
class ConsoleSender implements Sender {
public void send(String msg) { System.out.println(msg); }
}
class SlackSender implements Sender {
public void send(String msg) { /* 슬랙 API 호출 */ }
}
class NotificationService {
private final Sender sender; // 역할(인터페이스)에 의존
public NotificationService(Sender sender) { // 컨테이너가 주입
this.sender = sender;
}
public void notify(String msg) { sender.send("[NOTICE] " + msg); }
}
이 코드에서 볼 수 있듯이 서비스는 Sender라는 “역할”만 알고 콘솔이든 슬랙이든 구체 구현을 모른다. 구현 교체가 쉬워지고 테스트에서는 가짜 구현을 꽂아 빠르게 검증할 수 있다. 바로 이 지점이 DI의 가치다.
2. 왜 DI가 필요한가
필요할 때마다 new로 구현을 직접 만들기 시작하면 코드가 구현 세부에 묶인다. 교체가 어려워지고, 테스트에서 원하는 동작만 떼어 검증하기도 힘들다. 컨테이너가 생성과 연결을 맡으면 구성은 바깥으로 빠지고, 서비스는 본연의 동작에만 집중할 수 있다.
// (안 좋은 예) 구현을 직접 생성 → 고정
class BadService {
private final ConsoleSender sender = new ConsoleSender();
void work(){ sender.send("fixed"); }
}
// (좋은 예) 역할만 알고, 구현은 주입 → 교체 쉬움
class GoodService {
private final Sender sender;
GoodService(Sender sender){ this.sender = sender; }
void work(){ sender.send("flexible"); } // Console → Slack 교체 OK
}
BadService는 항상 ConsoleSender를 끌고 다녀서 환경이 바뀌면 클래스 자체를 수정해야 한다. 반면 GoodService는 역할에만 의존하기 때문에 운영에서는 슬랙, 개발에서는 콘솔처럼 손쉽게 바꿔 끼운다. 유지보수성과 테스트 용이성이 체감되는 차이점이다.
3. 주입 방식 3가지
권장 순서: 생성자 > 세터 > 필드. 생성자는 필수 의존성에, 세터는 선택/교체 가능한 의존성에, 필드는 새 코드에서는 지양하는 편이 안전하다.
3-1. 생성자 주입 (권장)
import org.springframework.stereotype.Service;
@Service
class OrderService {
private final Sender sender; // 불변 필수 의존성
public OrderService(Sender sender) { // 단일 생성자 → 자동 주입
this.sender = sender;
}
public void place(){ sender.send("order placed"); }
}
생성자 주입을 쓰면 객체가 만들어지는 순간부터 “완전한 상태”가 보장된다. 필드를 final로 유지해 불변성이 생기고, 테스트에서는 원하는 구현(또는 더블)을 그대로 넘겨 검증할 수 있다. 스프링이 기본으로 권장하는 방식이다.
3-2. 세터 주입
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
class ReportService {
private Sender sender; // 선택 의존성
@Autowired
public void setSender(Sender sender){ this.sender = sender; }
public void publish(){ if(sender != null) sender.send("report"); }
}
플러그인처럼 “있으면 사용, 없어도 동작”하는 경우에 잘 맞는다. 다만 필수 의존성까지 세터로 받기 시작하면 빈 상태가 생길 수 있어 안정성이 떨어진다.
3-3. 필드 주입 (지양)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
class LegacyService {
@Autowired
private Sender sender; // 프레임워크에 강결합
public void doWork(){ sender.send("legacy"); }
}
보기에 가장 간단하지만, 순수 자바 환경에서 주입이 어려워 테스트가 불편하고 필드를 final로 만들 수도 없다. 새로 작성하는 코드에서는 피하는 편이 낫다.
4. 구성 기반 주입과 빈 선택
외부 라이브러리처럼 생성 과정이 복잡한 객체는 @Configuration의 @Bean 메서드로 등록하면 구성과 코드가 깔끔히 분리된다. 같은 타입의 빈이 여러 개라면 @Primary로 기본값을 정하고, 필요 지점에서는 @Qualifier로 구체 구현을 지정한다.
import org.springframework.context.annotation.*;
interface Greeter { String hi(String name); }
class EnglishGreeter implements Greeter { public String hi(String n){ return "Hi, " + n; } }
@Configuration
class AppConfig {
@Bean Greeter greeter(){ return new EnglishGreeter(); } // 빈 등록
@Bean WelcomeService welcomeService(Greeter greeter){ // 의존성 주입
return new WelcomeService(greeter);
}
}
class WelcomeService {
private final Greeter greeter;
WelcomeService(Greeter greeter){ this.greeter = greeter; }
public void welcome(){ System.out.println(greeter.hi("world")); }
}
위처럼 @Bean 메서드의 파라미터로 다른 빈을 받아 자연스럽게 주입할 수 있다. 외부 라이브러리 객체도 동일한 방식으로 구성에 포함시키면 된다.
import org.springframework.context.annotation.Primary;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
interface Pay { void pay(int amount); }
@Service @Primary
class CardPay implements Pay { public void pay(int a){ System.out.println("card " + a); } }
@Service("cashPay")
class CashPay implements Pay { public void pay(int a){ System.out.println("cash " + a); } }
@Service
class CheckoutDefault { // 기본: CardPay
private final Pay pay;
public CheckoutDefault(Pay pay){ this.pay = pay; }
}
@Service
class CheckoutCashOnly { // 특정 지점: 현금
private final Pay pay;
public CheckoutCashOnly(@Qualifier("cashPay") Pay pay){ this.pay = pay; }
}
같은 타입 구현이 늘어날수록 “무엇을 넣을지”를 명확히 해야 한다. @Primary로 전역 기본값을 정해두고, 현금만 쓰는 지점처럼 예외가 필요한 곳에는 @Qualifier로 구체 이름을 지정하면 충돌 없이 의도를 드러낼 수 있다.
5. 자주 막히는 포인트와 정리
순환 의존성(A→B, B→A)이 보이면 인터페이스 분리나 이벤트 발행으로 끊어주는 게 안전하다. 선택 의존성은 세터 주입이나 Optional<T>로 의도를 드러내고, 프로토타입 빈을 매번 새로 받고 싶다면 ObjectProvider<T>를 고려한다. 기본 원칙은 간단하다. 생성자 주입을 기본으로 사용하고, 필드는 불변(final)로 유지한다.
정리: DI는 “필요를 선언하고 연결은 컨테이너가 한다”는 설계다. 생성자 주입을 중심에 두고, 상황에 따라 세터/구성 빈, @Primary/@Qualifier를 조합하면 교체와 테스트에 강한 구조를 오래 가져갈 수 있다.
'백엔드' 카테고리의 다른 글
| [프로젝트] 실시간 강의실 예약 시스템 개발(어드민 WS대회) (0) | 2025.11.02 |
|---|---|
| [Spring Security] Basic Authentication (0) | 2025.10.23 |
| [Spring Boot] Spring IoC - 컨테이너, Bean 등록/스코프/생명주기 (0) | 2025.10.11 |
| [Spring Boot] Spring IoC - 개념 (0) | 2025.10.11 |
| [SpringBoot] Bean이란? (0) | 2025.10.11 |