Spring을 사용하다 보면 자연스럽게 @Autowired나 생성자 주입을 쓰게 됩니다. 그런데 문득 생각해보면, 왜 우리가 직접 new로 객체를 생성하지 않는 걸까요? 이미 익숙하게 사용하고 있지만, 한 번쯤 개념을 정리해두면 좋을 것 같아서 글을 써봅니다.
의존성(Dependency)이란?
먼저 "의존성"이 무엇인지부터 짚어보겠습니다.
public class OrderService {
private PaymentProcessor paymentProcessor = new PaymentProcessor();
public void processOrder(Order order) {
paymentProcessor.pay(order.getAmount());
}
}
위 코드에서 OrderService는 PaymentProcessor를 사용합니다. 즉, OrderService가 정상적으로 동작하려면 PaymentProcessor가 필요합니다. 이런 관계를 "OrderService는 PaymentProcessor에 의존한다"라고 표현합니다.
의존성 자체는 문제가 아닙니다. 객체지향 프로그래밍에서 객체들이 서로 협력하는 것은 자연스러운 일이니까요. 문제는 어떻게 의존성을 관리하느냐입니다.
직접 new를 사용할 때의 문제점
위 코드처럼 클래스 내부에서 직접 new로 의존 객체를 생성하면 어떤 문제가 생길까요?
1. 테스트가 어려워진다
public class OrderServiceTest {
@Test
void 주문_처리_테스트() {
OrderService orderService = new OrderService();
// PaymentProcessor가 실제로 결제를 시도한다!
// 테스트할 때마다 진짜 돈이 빠져나갈 수도...
orderService.processOrder(new Order(10000));
}
}
OrderService를 테스트하고 싶은데, 내부에서 PaymentProcessor를 직접 생성하기 때문에 가짜(Mock) 객체로 교체할 방법이 없습니다. 결제 로직을 건너뛰고 OrderService의 로직만 테스트하고 싶은데, 그게 안 됩니다.
2. 구현체를 바꾸기 어렵다
public class OrderService {
// 카카오페이에서 토스페이로 바꾸려면?
// 이 코드를 직접 수정해야 한다
private PaymentProcessor paymentProcessor = new KakaoPayProcessor();
}
결제 수단을 카카오페이에서 토스페이로 바꾸려면, OrderService 코드 자체를 수정해야 합니다. OrderService가 한 군데면 그나마 낫지만, 여러 곳에서 KakaoPayProcessor를 직접 생성하고 있다면 전부 찾아서 바꿔야 합니다.
3. 강한 결합(Tight Coupling)
OrderService가 PaymentProcessor라는 구체적인 클래스에 의존하고 있습니다. 두 클래스가 단단하게 묶여 있어서, 하나를 수정하면 다른 하나도 영향을 받을 가능성이 높습니다.
DI(Dependency Injection, 의존성 주입)
DI는 이 문제를 해결하는 간단한 아이디어입니다. 객체가 필요로 하는 의존성을 외부에서 넣어준다는 것입니다.
생성자 주입 방식
public class OrderService {
private final PaymentProcessor paymentProcessor;
// 외부에서 PaymentProcessor를 주입받는다
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public void processOrder(Order order) {
paymentProcessor.pay(order.getAmount());
}
}
이제 OrderService는 자신이 어떤 PaymentProcessor를 사용할지 모릅니다. 그저 외부에서 주어진 것을 사용할 뿐입니다.
이렇게 하면 뭐가 좋아지나요?
테스트가 쉬워집니다:
public class OrderServiceTest {
@Test
void 주문_처리_테스트() {
// 가짜 PaymentProcessor를 주입
PaymentProcessor mockProcessor = new MockPaymentProcessor();
OrderService orderService = new OrderService(mockProcessor);
orderService.processOrder(new Order(10000));
// 실제 결제 없이 OrderService 로직만 테스트 가능!
}
}
구현체 교체가 유연해집니다:
// 상황에 따라 다른 구현체를 주입할 수 있다
OrderService kakaoService = new OrderService(new KakaoPayProcessor());
OrderService tossService = new OrderService(new TossPayProcessor());
인터페이스와 함께 사용하면 더 좋다
public interface PaymentProcessor {
void pay(int amount);
}
public class KakaoPayProcessor implements PaymentProcessor {
@Override
public void pay(int amount) {
// 카카오페이 결제 로직
}
}
public class TossPayProcessor implements PaymentProcessor {
@Override
public void pay(int amount) {
// 토스페이 결제 로직
}
}
OrderService가 구체 클래스가 아닌 인터페이스에 의존하도록 하면, 어떤 구현체든 주입받을 수 있습니다. 이것이 바로 느슨한 결합(Loose Coupling)입니다.
IoC(Inversion of Control, 제어의 역전)
IoC는 DI보다 넓은 개념입니다. 간단히 말해, 프로그램의 흐름을 개발자가 아닌 프레임워크가 제어한다는 원칙입니다.
제어가 역전됐다?
원래(전통적인 방식)는 이랬습니다:
public class Application {
public static void main(String[] args) {
// 개발자가 직접 객체를 생성하고
PaymentProcessor processor = new KakaoPayProcessor();
OrderService orderService = new OrderService(processor);
// 직접 메서드를 호출한다
orderService.processOrder(new Order(10000));
}
}
개발자가 모든 것을 제어합니다. 객체를 언제 생성할지, 어떤 의존성을 넣을지, 언제 메서드를 호출할지 전부 개발자가 결정합니다.
IoC가 적용되면 이렇게 바뀝니다:
@Service
public class OrderService {
private final PaymentProcessor paymentProcessor;
// 프레임워크가 알아서 주입해준다
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}
@Component
public class KakaoPayProcessor implements PaymentProcessor {
// ...
}
개발자는 "이 객체가 필요해"라고 선언만 하고, 실제 객체 생성과 주입은 프레임워크(Spring Container)가 담당합니다. 제어권이 개발자에서 프레임워크로 넘어간 것입니다.
Spring에서의 DI/IoC
Spring은 IoC 컨테이너를 통해 DI를 구현합니다. 개발자가 해야 할 일은 간단합니다:
1. Bean으로 등록한다
@Component // 또는 @Service, @Repository 등
public class KakaoPayProcessor implements PaymentProcessor {
// ...
}
@Service
public class OrderService {
private final PaymentProcessor paymentProcessor;
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}
2. Spring이 알아서 해준다
Spring 컨테이너가 애플리케이션 시작 시점에:
@Component등이 붙은 클래스들을 스캔합니다- 해당 클래스들의 인스턴스(Bean)를 생성합니다
- 생성자를 보고 필요한 의존성을 파악합니다
- 알맞은 Bean을 찾아서 주입합니다
개발자는 new를 직접 호출할 필요가 없습니다. 그저 "이런 의존성이 필요해"라고 선언만 하면 됩니다.
주입 방식 세 가지
// 1. 생성자 주입 (권장)
@Service
public class OrderService {
private final PaymentProcessor paymentProcessor;
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}
// 2. Setter 주입
@Service
public class OrderService {
private PaymentProcessor paymentProcessor;
@Autowired
public void setPaymentProcessor(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}
// 3. 필드 주입 (비권장)
@Service
public class OrderService {
@Autowired
private PaymentProcessor paymentProcessor;
}
생성자 주입이 권장되는 이유:
final키워드를 사용할 수 있어 불변성을 보장합니다- 의존성이 누락되면 컴파일 시점에 에러가 발생합니다
- 테스트 시 의존성을 명시적으로 주입할 수 있습니다
정리
핵심만 정리하면 다음과 같습니다:
- 의존성(Dependency): 객체가 다른 객체를 필요로 하는 관계
- DI(Dependency Injection): 필요한 의존성을 외부에서 주입받는 방식
- IoC(Inversion of Control): 객체 생성과 관리의 제어권을 프레임워크에 위임하는 원칙
직접 new를 안 쓰는 이유:
- 테스트가 쉬워집니다 (Mock 객체를 주입할 수 있으므로)
- 구현체 교체가 유연해집니다 (코드 수정 없이 설정만 바꾸면 됨)
- 결합도가 낮아집니다 (인터페이스에 의존하므로)
결국 DI와 IoC는 유연하고 테스트하기 쉬운 코드를 작성하기 위한 설계 원칙입니다. Spring이 이 모든 것을 편하게 해주니까 우리는 비즈니스 로직에만 집중할 수 있는 것이고요.
'프레임워크 > 스프링' 카테고리의 다른 글
| DispatcherServlet의 역할: 요청이 들어와서 응답이 나갈 때까지의 여정 (1) | 2025.12.19 |
|---|---|
| Spring Bean의 생명주기(Lifecycle) - 객체 생성부터 소멸까지 (0) | 2025.12.18 |
| Spring_내용설명_02) Spring Framework의 Bean과 Container (0) | 2019.05.21 |
| Spring_내용설명_01) Spring Framework의 IoC(Inversion of Control)란 (0) | 2019.05.21 |
| 토비의 스프링 용어 정리 (0) | 2018.11.20 |