공유된 기술노트 · 스프링부트 · 스프링부트 API
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 저장소)은 새로운 캡슐로 만들어야 함.
위 예제에서 FileOrderRepository를 JdbcOrderRepository로 교체할 때 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);
}
}이제 FileOrderRepository를 JdbcOrderRepository로 교체할 때, 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의 역할을 자세히 살펴보겠습니다.
이 글은 책 《스프링 부트 4.0으로 카페 백엔드 만들기》 · 1장 에 실려 있습니다.
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.