목차
spring을 사용해 개발을 하다보면 @Autowired나 생성자로 객체를 주입받아 쓴다. 너무나 당연하게 써왔었다. 그런데 문득 궁금해졌다. "그냥 내가 만들어 쓰면 안되나?" 라는 생각이 들었다. 이 의존성 주입이 왜 스프링의 핵심 개념인지 비유를 통해 이해해보자.
1. 스프링의 핵심중 하나인 DI
스프링 프레임워크를 사용한다는 것은 의존성 주입(Dependency Injection, DI)의 개념 위에서 개발한다는 것과 같다. DI는 스프링의 3대 핵심 프로그래밍 모델 중 하나이며, 현대적인 객체지향 설계를 가능하게 하는 기반 기술이다.
많은 개발자가 DI를 단순히 어노테이션(@Autowired)을 통해 객체를 받아오는 기술 정도로 이해하지만, DI의 본질은 객체 간의 결합도를 낮추고 유연성을 극대화하는 설계 패턴에 있다. DI는 정확히 무엇인지, 왜 반드시 필요한지, 그리고 스프링을 통해 어떻게 올바르게 구현하는지 알아보자.
2. DI는 왜 필요할까?
DI의 필요성을 이해하기 위해, DI가 적용되지 않은 코드를 먼저 살펴보자.
2.1. 예시
DI가 없는 코드는 납땜된 일체형 컴퓨터와 같다. 컴퓨터(하나의 객체)가 그래픽카드(다른 객체)를 사용해야 할 때, 메인보드에 그래픽카드를 납땜해서 고정해 버린것과 같다.
문제점
- 강한 결합: 그래픽카드가 고장 나거나 더 좋은 성능의 제품으로 교체하려면 메인보드 전체를 뜯어고쳐야 한다.
- 유연성 부재: 다른 제조사의 그래픽카드는 호환조차 불가능하다.
2.2. 강하게 결합된 클래스
상점(Store)이 연필(Pencil)을 판매하는 상황을 코드로 구현해 보자.
(DI 적용 전)
// 연필 클래스
public class Pencil {
public void write() {
System.out.println("연필 사용");
}
}
// 상점 클래스
public class Store {
// 1. Store가 Pencil이라는 구체적인 클래스에 직접 의존한다.
private Pencil pencil;
public Store() {
// 2. Store가 직접 Pencil 객체를 생성한다.
this.pencil = new Pencil();
}
public void doBusiness() {
pencil.write();
}
}
이 코드는 앞서 비유한 납땜된 컴퓨터와 정확히 같은 문제를 가진다.
- 강한 결합도: Store 클래스는 Pencil이라는 구체적인 클래스와 강하게 결합되어 있다.
- 유연성 및 확장성 저하: 만약 Store가 Pencil이 아닌 Eraser를 판매하도록 변경해야 한다면,
Store 클래스의 생성자(new Pencil())를 포함한 내부 코드 전체를 수정해야 한다. 이것은 객체지향 설계의 핵심 원칙인 OCP(개방-폐쇄 원칙)를 위반하게 된다.
3. DI를 통한 문제 해결
DI는 이 납땜을 표준 슬롯 방식으로 바꾸는 해결책이다.
3.1. 표준 슬롯의 도입
문제를 해결한 컴퓨터는 메인보드에 PCIe라는 표준 규격 슬롯을 제공한다.
- 제조사(NVIDIA, AMD)는 이 표준 규격에 맞는 그래픽카드를 생산한다.
- 사용자(개발자)는 어떤 그래픽카드든 슬롯에 꽂기만 하면(주입) 컴퓨터가 정상 작동한다.
- 결과: 부품 교체가 자유롭고(유연성), 메인보드는 그래픽카드가 무엇인지 알 필요가 없다(결합도 낮춤).
3.2. DI를 통한 결합도 낮추기
이 해결책을 코드로 구현하는 과정은 다음과 같다.
1단계: 표준 규격(인터페이스) 정의
- Pencile과 Eraser를 합칠 수 있는 제품(Product)이라는 표준 규격을 만든다.
// 1. 표준 규격(인터페이스) 정의
public interface Product {
void use();
}
// 2. 규격에 맞는 구현체(부품)들
public class Pencil implements Product {
@Override
public void use() {
System.out.println("연필 사용");
}
}
public class Eraser implements Product {
@Override
public void use() {
System.out.println("지우개 사용");
}
}
2단계: 슬롯을 만들고 외부에서 주입
- Store 클래스는 더 이상 Pencil 같은 구체적인 부품을 몰라도 된다. Product라는 표준 슬롯만 알면 된다.
(DI 적용 후)
// 상점 클래스
public class Store {
// 1. Product라는 표준 규격(인터페이스)에만 의존한다. (결합도 낮춤)
private final Product product;
// 2. 객체를 직접 생성(new)하지 않는다.
// 외부에서 생성된 객체를 생성자를 통해 주입 받는다.
public Store(Product product) {
this.product = product;
}
public void doBusiness() {
product.use(); // 주입받은 부품의 기능을 사용
}
}
Store는 이제 판매 라는 자신의 핵심 책임에만 집중한다. 어떤 제품을 팔지에 대한 결정(객체 생성 및 선택)은 Store의 관심사에서 제외된다.
3.3. 스프링(DI 컨테이너)의 역할
위 예시에서 외부의 역할을 하는 것이 바로 스프링 컨테이너(DI Container)다.
개발자가 Pencil이나 Eraser 같은 부품(@Component, @Service 등)을 스프링 컨테이너에 등록해두면, 스프링은 Store가 필요로 할 때(Store의 생성자를 호출할 때) 적절한 부품을 찾아 자동으로 주입해준다.
이처럼 객체의 생성과 관계 설정의 제어권이 개발자(Store)로부터 프레임워크(스프링)로 넘어간 것을 제어의 역전(Inversion of Control, IoC)이라고 부르며, DI는 이 IoC를 구현하는 핵심 방식이다.
4. 생성자 주입
- 스프링에서 의존성을 주입하는 방법은 필드 주입, 수정자(Setter) 주입, 생성자 주입 등이 있다. 스프링 공식 문서에서는 생성자 주입을 권장한다.
4.1. (비권장) 필드 주입
(권장하지 않음)
// 필드 주입 방식
@Service
public class StoreService {
@Autowired
private ProductRepository productRepository; // <-- 필드에 바로 주입
}
코드는 간결하지만 final 선언이 불가능해 불변성이 깨진다. 또한 스프링 컨테이너 없이는 객체를 생성할 수 없어 단위 테스트가 상당히 불편하다. 추가로 순환 참조 같은 문제를 발견하기 어렵다는 큰 단점도 있다.
4.2. (권장) 생성자 주입
(가장 권장됨)
// 생성자 주입 방식
@Service
public class StoreService {
// 1. final 키워드로 불변성을 보장한다.
private final ProductRepository productRepository;
// 2. 생성자를 통해 의존성을 주입받는다.
// (스프링 4.3 이후 생성자가 하나면 @Autowired 생략 가능)
public StoreService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
}
- 불변성 확보: 객체가 생성된 이후 의존성이 변경될 일이 없어 안전하다.
- 필수 의존성 보장: 객체 생성 시점에 반드시 필요한 의존성이 누락되는 것을 컴파일 시점에 방지한다. (NPE 방지)
- 테스트 용이성: 스프링 컨테이너 없이도, 순수 자바 코드로 new StoreService(new MockRepository())처럼 가짜(Mock) 객체를 주입하며 쉽게 테스트할 수 있다.
- 순환 참조 감지: 애플리케이션 실행 시점에 순환 참조 오류를 즉시 발견할 수 있다.
5. 결론
DI를 적용하는 이유는 명확하다.
- 관심사의 분리: 객체는 자신의 책임(로직)에만 집중하고, 의존성 관리는 외부에 맡긴다.
- 낮은 결합도: 표준 인터페이스에 의존하므로 구현체를 쉽게 교체할 수 있다.
- 높은 유연성 및 확장성: 기능 변경이나 확장에 유연하게 대처할 수 있다.
- 테스트 용이성: 단위 테스트 작성이 쉬워져 견고한 코드를 만들 수 있다.
DI는 단순한 기술이 아니라, 유지보수하기 좋고 유연하며 테스트하기 쉬운 코드를 작성하기 위한 객체지향 설계의 핵심 원칙이다. 스프링을 사용한다면 생성자 주입 방식을 통해 이 이점들을 극대화하는 것이 바람직하다고 생각된다.
'공부메모 & 오류해결 > Spring Boot' 카테고리의 다른 글
| [JPA] N+1 문제의 원인과 3가지 해결 방안 (0) | 2025.11.06 |
|---|---|
| [Spring] 제어의 역전(Inversion of Control, IoC) 이란 무엇일까? (1) | 2025.11.05 |
| [Spring Boot] 실시간검색어 구현하기 (3) | 2025.07.13 |
| [Spring Boot] Gzip 압축을 통해 로딩 성능 최적화 하기 (0) | 2025.01.14 |
| [Elasticsearch] 엘라스틱 서치의 Analyzer, Tokenizer 정리(Nori_tokenizer) (0) | 2024.04.18 |
남건욱's 공부기록