공유된 기술노트 · 스프링부트 · 스프링부트 API

newlec · 2026. 9. 20.

1-2. DI와 IoC — 스프링의 핵심 아이디어

1. DI가 무엇인지: OOP의 일반 원리

스프링의 가장 기본적인 기능은 DI(Dependency Injection, 의존성 주입)입니다. 하지만 DI는 스프링 고유의 기술이 아니라, 객체 지향 프로그래밍(OOP)의 일반 원리 중 하나입니다.

  • 의존성(Dependency): 클래스 A가 클래스 B를 사용(호출, 생성 등)하는 관계.
  • 주입(Injection): A가 B를 직접 생성(new)하지 않고, 외부에서 B의 인스턴스를 전달받아 사용하는 것.

DI의 핵심 목적은 클래스 간의 결합도(Coupling)를 낮추는 것입니다. A가 B의 구체적인 구현 클래스에 종속되지 않고, 추상화(인터페이스)에만 의존하도록 만드는 것이 목표입니다.

2. 순수 자바 예제: 강결합의 문제

ncafe 백엔드에서 주문 처리 로직을 구현한다고 가정해 봅시다. OrderService는 주문을 저장하기 위해 OrderRepository를 사용해야 합니다.

초기 구현: new 키워드 사용

개발자는 OrderService 내부에서 OrderRepository를 직접 생성합니다. 초기에는 파일 기반 저장소가 필요하다고 가정하고 FileOrderRepository를 구현했습니다.

// 1. 인터페이스 정의
public interface OrderRepository {
    void save(Order order);
}

// 2. 초기 구현체: 파일 기반
public class FileOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) {
        // 파일에 주문 정보 기록 로직
        System.out.println("File에 주문 저장: " + order.getId());
    }
}

// 3. 서비스 클래스: 직접 new로 생성 (강결합)
public class OrderService {
    // 문제: OrderService가 FileOrderRepository에 강하게 결합됨
    private final OrderRepository repository = new FileOrderRepository(); 

    public void createOrder(Order order) {
        repository.save(order);
    }
}

이 코드는 동작합니다. 하지만 여기서 OCP(개방/폐쇄 원칙) 위반의 씨앗이 숨어 있습니다.

3. 수정사항 발생: 요구사항 변경

프로젝트 진행 중, 파일 기반 저장소가 성능과 확장성 문제를 일으킨다는 피드백이 들어옵니다. 이제 JDBC를 이용한 데이터베이스 저장소로 교체해야 합니다.

문제 상황

JdbcOrderRepository를 새로 만들었습니다. 하지만 OrderService를 다시 살펴보면?

// 기존 코드
private final OrderRepository repository = new FileOrderRepository();

OrderService의 코드를 수정하여 new JdbcOrderRepository()로 바꿔야 합니다.

// 수정된 코드 (불필요한 수정 발생)
public class OrderService {
    private final OrderRepository repository = new JdbcOrderRepository(); // 코드를 고쳐야 함!

    public void createOrder(Order order) {
        repository.save(order);
    }
}

4. 객체지향 5원칙의 두 번째 원칙 : OCP — 왜 수정하면 안 되는가?

OCP(Open/Closed Principle, 개방/폐쇄 원칙)는 "소프트웨어 엔티티(클래스, 모듈 등)는 확장에는 개방적이되, 수정에는 폐쇄적이어야 한다"는 원칙입니다.

  • 수정 폐쇄(Closed): 기존에 잘 동작하던 코드(OrderService)를 수정하지 않아야 함.
    • 수정을 못하게 하는 이유는 기존 코드는 이미 이전 버전에서 사용했던 코드입니다. 과거의 흔적을 지우면서 코드를 수정함녀 그 코드의 내역을 알 수가 없고, 어떤 내용이 개선되었는지, 이전은 어땠는지를 알 수가 없습니다. 이건 마치 위키북을 만들면서 이전 기록을 없애는 것과 마찬가지이기 때문입니다.
  • 확장(Open): 새로운 기능(예: Jdbc 저장소)은 새로운 캡슐로 만들어야 함.

위 예제에서 FileOrderRepositoryJdbcOrderRepository로 교체할 때 OrderService를 수정해야 했으므로 OCP 위반입니다.

  • 위험: OrderService를 수정할 때마다 회귀 테스트(Regression Test)를 다시 수행해야 하고, 버그가 발생할 가능성이 높아집니다.
  • 본질: OrderService는 "어떤 저장소를 사용할지"를 결정할 권한이 없어야 합니다. 그것은 설치/구성(Configuration)의 문제이지, 비즈니스 로직의 문제가 아닙니다.

5. 스프링이 해결한다: 제어의 역전 (IoC)

스프링은 IoC(Inversion of Control, 제어의 역전)를 통해 이 문제를 해결합니다.

  • IoC: 객체의 생성과 연결(조립)에 대한 제어권을 개발자 코드에서 컨테이너로 넘기는 것.
  • DI: IoC를 구현하는 구체적 기법.

스프링 컨테이너가 OrderService를 생성할 때, 어떤 OrderRepository 구현체를 주입할지 결정합니다. OrderService인터페이스(OrderRepository)에만 의존하므로, 구현체가 바뀌어도 OrderService 코드는 변경되지 않습니다.

// 스프링 컨테이너가 생성하는 OrderService
// 개발자는 new를 쓰지 않고, 생성자 인자로 주입받음
public class OrderService {
    private final OrderRepository repository;

    // 생성자: 컨테이너가 이 생성자를 호출하며, 적절한 Repository를 주입
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void createOrder(Order order) {
        repository.save(order);
    }
}

이제 FileOrderRepositoryJdbcOrderRepository로 교체할 때, OrderService 코드는 아무런 수정 없이 그대로 유지됩니다. 오직 컨테이너의 설정(또는 애너테이션)만 변경하면 됩니다.

6. 실습: 스프링 부트 4.0에서 DI 적용

스프링 부트 4.0 환경에서 @Component생성자 주입(Constructor Injection)을 사용하여 위 코드를 실제로 적용해 보겠습니다.

1. 인터페이스와 구현체 정의

// OrderRepository.java
public interface OrderRepository {
    void save(Order order);
}

// FileOrderRepository.java
@Component
public class FileOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) {
        System.out.println("File에 주문 저장: " + order.getId());
    }
}

// JdbcOrderRepository.java
@Component
public class JdbcOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) {
        System.out.println("DB에 주문 저장: " + order.getId());
    }
}

참고: 실제 프로젝트에서는 동시에 두 개의 @Component가 같은 인터페이스를 구현하면 빈 충돌이 발생합니다. 실습에서는 한 번에 하나만 활성화하거나, @Primary@Qualifier를 사용하여 구분합니다. 여기서는 개념 이해를 위해 FileOrderRepository만 활성화한다고 가정합니다.

2. Service 클래스 작성 (생성자 주입)

// OrderService.java
@Service
public class OrderService {
    private final OrderRepository repository;

    // 스프링은 이 생성자를 찾아, 컨테이너에 등록된 OrderRepository 빈을 주입합니다.
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void createOrder(Order order) {
        repository.save(order);
    }
}

3. 실행과 검증

Application 클래스에서 OrderService를 주입받아 실행합니다.

@SpringBootApplication
public class NcafeApplication {
    public static void main(String[] args) {
        SpringApplication.run(NcafeApplication.class, args);
    }
}

// 테스트용 Controller 또는 CommandLineRunner
@RestController
public class TestController {
    private final OrderService orderService;

    public TestController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/test")
    public String test() {
        orderService.createOrder(new Order(1L, "Latte"));
        return "OK";
    }
}
  • 현재 상태: FileOrderRepository가 활성화되어 있으므로, /test를 호출하면 File에 주문 저장: 1이 출력됩니다.
  • 변경 시나리오: FileOrderRepository@Component를 제거하고 JdbcOrderRepository@Component를 활성화합니다.
  • 결과: OrderService 코드를 아무것도 건드리지 않고 다시 실행하면, DB에 주문 저장: 1이 출력됩니다.

이것이 DI와 IoC의 힘입니다.

7. 핵심 개념 정리 + 다음 노트(1-3) 예고

개념 설명
DI (Dependency Injection) 객체가 스스로 의존성을 생성하지 않고, 외부에서 주입받아 사용하는 기법.
IoC (Inversion of Control) 객체의 생성과 생명 주기 관리에 대한 제어권을 개발자 코드에서 컨테이너로 넘기는 원칙.
OCP (Open/Closed Principle) 확장에는 개방적이고, 수정에는 폐쇄적인 원칙. DI는 OCP를 실현하는 핵심 수단.
생성자 주입 스프링에서 가장 권장되는 DI 방식. 의존성을 final 필드로 선언하여 불변성을 보장하고, 빈 누락 시 즉시 예외를 발생시킴.

다음 노트 1-3. 빈(Bean)과 컨테이너 — 스프링의 심장이 도는 곳에서는, 스프링 컨테이너가 어떻게 빈을 생성하고 관리하는지, 그리고 ApplicationContext의 역할을 자세히 살펴보겠습니다.

이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.