공유된 기술노트 · 스프링부트 · 스프링 프레임워크 Core
스프링부트: DI의 필요성과 IoC 컨테이너
1. DI의 필요성: 왜 의존성 주입인가?
객체지향 프로그래밍 5원칙에서 **개방-폐쇄 원칙(Open-Closed Principle)**은 "확장에는 열려 있지만, 변경에는 폐쇄되어 있어야 한다"는 원칙입니다. 즉, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 합니다.
이 원칙이 중요한 이유는 다음과 같습니다.
- 변경에 따른 리스크 최소화: 기존 코드를 수정하면, 이미 정상적으로 동작하고 있는 다른 기능에 예상치 못한 버그(회귀 오류)가 발생할 수 있습니다. 기존 코드를 건드리지 않고 확장만 하면 이러한 리스크를 크게 줄일 수 있습니다.
- 유지보수성 향상: 시스템이 커질수록 코드가 복잡해지고, 한 줄의 수정이 전체 시스템에 어떤 영향을 미치는지 파악하기 어려워집니다. 변경에 폐쇄된 구조는 이러한 복잡성을 관리하기 쉽게 만들어 줍니다.
- 확장성 보장: 새로운 요구사항이 들어왔을 때, 기존 구조를 깨뜨리지 않고 새로운 클래스나 모듈을 추가하는 방식으로 대응할 수 있어 시스템의 생명주리를 늘릴 수 있습니다.
또한, Composition Has A 관계는 클래스 A가 클래스 B를 '가지고 있다'는 것을 의미하며, 이는 클래스 간의 결합도(Coupling)를 낮추어야 할 중요한 구조입니다.
여기서 "결합도를 낮춘다"는 말은 구체적인 구현에 대한 의존도를 줄인다는 뜻입니다. Car가 Engine을 new로 직접 생성하면 Car는 Engine이라는 구체적인 클래스에 강하게 묶여(Coupling) 버립니다. 하지만 Engine을 인터페이스나 추상 클래스로 정의하고, 실제 구현체(GasEngine, ElectricEngine 등)를 외부에서 주입(DI)받도록 하면, Car는 Engine의 추상 개념에만 의존하게 됩니다.
즉, Car는 "어떤 엔진이든 동작할 수 있는 구조"를 가지므로, 특정 엔진 구현체가 바뀌어도 Car 코드를 수정할 필요가 없어집니다. 이것이 바로 결합도를 낮추는 것입니다.
// Composition Has A 예시
public class Car {
private Engine engine; // Car는 Engine을 '가지고 있다'
}만약 Car 클래스가 Engine을 직접 생성(new)한다면, Engine의 구현체가 변경될 때마다 Car 클래스의 코드도 수정해야 합니다. 이는 개방-폐쇄 원칙에 위배됩니다. 의존성 주입(Dependency Injection, DI) 은 이러한 문제를 해결하기 위해 도입되었습니다.
2. 기존 방식의 한계: 직접 생성의 문제
DI를 사용하지 않고 객체를 직접 생성하는 방식을 살펴보면 다음과 같은 문제가 발생합니다.
public class Car {
private Engine engine;
// 생성자에서 직접 객체 생성
public Car() {
this.engine = new Engine();
}
}문제점:
- 결합도 증가:
Car클래스가Engine클래스에 강하게 결합됩니다. - 테스트 어려움:
Car를 테스트할 때Engine을 모킹(Mocking)하거나 대체하기가 어렵습니다. 모킹이란 실제 객체를 대신해 테스트 시나리오에 필요한 동작만 수행하도록 만든 가짜 객체를 의미합니다.Engine을 직접 생성하면, 테스트 시 실제 엔진 로직이 실행되거나 외부 자원(하드웨어 등)에 의존하게 되어Car의 로직만 격리하여 검증하기가 복잡해집니다. - 유지보수 비용:
Engine의 구현이ElectricEngine으로 변경되면Car클래스의 소스 코드를 수정해야 합니다.
3. 스프링과 IoC: 제어의 역전
제어의 역전(Inversion of Control, IoC) 은 객체의 생성과 생명주기에 대한 제어권을 개발자(코드)에서 프레임워크(컨테이너)로 넘기는 것을 의미합니다. 여기서 생명주기란 객체가 메모리에 생성된 시점부터 소멸(반납)되는 시점까지의 전 과정을 말합니다.
쉬운 비유: 레스토랑의 주방과 웨이터
- 기존 방식 (개발자가 직접 new): 고객이(클라이언트) 주방(의존 객체)에 가서 요리(객체 생성)를 직접하고, 그 요리를 먹습니다. 고객이 주방의 내부 구조를 알아야 하고, 요리에 직접 관여해야 합니다.
- IoC 방식 (스프링 컨테이너): 고객이 웨이터(스프링 컨테이너)에게 "요리해줘"라고 요청만 합니다. 웨이터는 주방(의존 객체)에 가서 요리를 만들고, 완성된 요리를 고객에게 전달합니다. 고객은 주방의 내부 구조를 몰라도 되고, 웨이터만 믿으면 됩니다.
IoC 컨테이너의 역할
스프링 컨테이너는 다음과 같은 일을 대신 수행합니다.
- Bean 생성: 설정 정보(아노테이션 등)를 기반으로 객체를 생성합니다. 여기서 Bean 이란 순수 자바 객체를 말합니다.
- 의존성 연결: 객체 A가 객체 B를 필요로 하면, 컨테이너가 B를 찾아서 A에 주입(Injection)합니다.
- 생명주기 관리: 객체의 생성, 초기화, 소멸 과정을 관리합니다.
4. 실습: 메이븐 프로젝트로 DI 적용하기
이제 메이븐(Maven) 프로젝트를 통해 스프링 프레임워크를 사용해 DI를 직접 적용해 보겠습니다.
4.1. 메이븐 프로젝트 생성 및 의존성 추가
pom.xml 파일에 스프링 코어 라이브러리를 추가합니다. (최신 안정판 버전을 사용)
<dependencies>
<!-- Spring Core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.0</version> <!-- 예시 버전, 최신 버전 확인 권장 -->
</dependency>
<!-- 테스트용 (선택 사항) -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>4.2. 예제 클래스 작성
DI를 이해하기 위해 먼저 스프링 없이 직접 객체를 생성하는 방식을 작성해 보겠습니다.
1. 의존성으로 사용할 클래스: MessageService
public class MessageService {
public String getMessage() {
return "Hello, Spring DI!";
}
}2. 의존성을 주입받을 클래스: GreetingApp (스프링 미적용)
MessageService를 직접 생성(new)하여 사용합니다.
public class GreetingApp {
private final MessageService messageService;
public GreetingApp() {
// 직접 객체 생성
this.messageService = new MessageService();
}
public void run() {
System.out.println(messageService.getMessage());
}
}3. 메인 클래스 및 실행 (스프링 미적용)
public class Main {
public static void main(String[] args) {
GreetingApp app = new GreetingApp();
app.run();
}
}왜 이 방식이 변경에 취약한가?
위 코드에서 MessageService의 구현체가 KoreanMessageService로 변경되어야 한다고 가정해 보겠습니다.
이 때 우리는 기존 코드를 단순히 수정하는 방법을 사용해서는 안됩니다. 모든 수정에서의 변화는 버전으로 관리 되어야 합니다. 이전 버전은 기존 사용되던 프로그램이 계속 사용할 수 있도록 버전을 유지해야 합니다.
그래서 수정사항이 들어오면 그 수정사항에 대한 구현체를 새로 만들어서, 수정하고 그 프로그램은 새로운 버전의 프로그램이 되어야 합니다.
GreetingApp클래스 내부에서 수정해야 할new MessageService()부분을 찾습니다.- 그 코드를 이제
new KoreanMessageService()로 수정해야 합니다. - 그런데, 그러기 위해서는 결국
GreetingApp을 수정해야만 하는 상황이 발생합니다.MessageService를 직접 수정하는 것을 피하려고 했지만 그것을 수정하지 않고 교체하는 방법을 가져가려고 해도 결국 어딘가는 코드를 수정해야만 하는 상황이 발생합니다.
이처럼 구체적인 구현체에 대한 의존도가 높기 때문에, 구현체 변경 시 영향을 받는 코드 범위가 넓어지고 유지보수 비용이 증가합니다.
이제 스프링을 추가하여 제어의 역전(IoC) 을 적용해 보겠습니다.
1. 의존성으로 사용할 클래스: MessageService (스프링 빈 등록)
@Component 애노테이션을 추가하여 스프링 컨테이너가 관리하도록 합니다.
import org.springframework.stereotype.Component;
@Component
public class MessageService {
public String getMessage() {
return "Hello, Spring DI!";
}
}2. 의존성을 주입받을 클래스: GreetingApp (스프링 적용)
생성자 주입을 통해 MessageService를 주입받습니다. new 연산자를 사용하지 않습니다.
import org.springframework.stereotype.Component;
@Component
public class GreetingApp {
private final MessageService messageService;
// 생성자 주입 (Spring 4.3 이후부터 단일 생성자가 있으면 @Autowired 생략 가능)
public GreetingApp(MessageService messageService) {
this.messageService = messageService;
}
public void run() {
System.out.println(messageService.getMessage());
}
}3. 스프링 컨테이너 설정 및 메인 클래스
스프링 컨테이너가 빈을 스캔하고 생성하도록 설정합니다.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan // 현재 패키지 및 하위 패키지를 스캔하여 빈을 등록
public class AppConfig {
}
public class Main {
public static void main(String[] args) {
// 1. 스프링 컨테이너 생성 (제어의 역전: 컨테이너가 객체 생성을 담당)
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class)) {
// 2. 컨테이너에서 빈 가져오기
GreetingApp app = context.getBean(GreetingApp.class);
// 3. 실행
app.run();
}
}
}변화:
GreetingApp은 더 이상MessageService를 직접 생성하지 않습니다.MessageService의 구현체가 변경되더라도,GreetingApp의 코드는 수정할 필요가 없습니다.- 스프링 컨테이너가
MessageService의 구현체를 찾아서 주입해주므로, 구현체 변경에 대한 리스크가 컨테이너 설정(또는 애노테이션)으로 분리됩니다.
1. 의존성으로 사용할 클래스: MessageService
public class MessageService {
public String getMessage() {
return "Hello, Spring DI!";
}
}2. 의존성을 주입받을 클래스: GreetingApp
@Autowired 또는 생성자 주입을 통해 MessageService를 주입받습니다. 여기서는 생성자 주입을 권장합니다.
import org.springframework.stereotype.Component;
@Component
public class GreetingApp {
private final MessageService messageService;
// 생성자 주입 (Spring 4.3 이후부터 단일 생성자가 있으면 @Autowired 생략 가능)
public GreetingApp(MessageService messageService) {
this.messageService = messageService;
}
public void run() {
System.out.println(messageService.getMessage());
}
}3. MessageService도 빈(Bean)으로 등록하기
import org.springframework.stereotype.Component;
@Component
public class MessageService {
public String getMessage() {
return "Hello, Spring DI!";
}
}4.3. 메인 클래스 및 실행
스프링 컨테이너를 초기화하고, 빈을 가져와서 실행하는 메인 클래스를 작성합니다.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan // 현재 패키지 및 하위 패키지를 스캔하여 빈을 등록
public class AppConfig {
}
public class Main {
public static void main(String[] args) {
// 1. 스프링 컨테이너 생성
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class)) {
// 2. 컨테이너에서 빈 가져오기
GreetingApp app = context.getBean(GreetingApp.class);
// 3. 실행
app.run();
}
}
}4.4. 실행 결과
Hello, Spring DI!4.5. 코드 분석
AppConfig의@ComponentScan은MessageService와GreetingApp을 빈으로 등록합니다.Main에서AnnotationConfigApplicationContext를 생성하면 스프링 컨테이너가 시작됩니다.context.getBean(GreetingApp.class)를 호출하면, 컨테이너는GreetingApp의 생성자가MessageService를 필요로 한다는 것을 감지합니다.- 컨테이너는 이미 등록되어 있는
MessageService빈을 찾아서GreetingApp의 생성자에 주입(Injection) 합니다. - 개발자는
new MessageService()를 직접 호출하지 않았지만,GreetingApp은 정상적으로 동작합니다. 이것이 DI입니다.
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.