공부메모 & 오류해결/Spring Boot

[Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까?

남건욱 2025. 11. 4. 19:23

목차

    반응형

    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는 단순한 기술이 아니라, 유지보수하기 좋고 유연하며 테스트하기 쉬운 코드를 작성하기 위한 객체지향 설계의 핵심 원칙이다. 스프링을 사용한다면 생성자 주입 방식을 통해 이 이점들을 극대화하는 것이 바람직하다고 생각된다.

    반응형
    프로필사진

    남건욱's 공부기록