스프링을 공부하다 보면 IoC와 DI라는 용어를 계속 마주치게 된다. 처음에는 이 개념들이 어려워 보이지만, 한번 제대로 이해하고 나면 스프링의 핵심이 왜 이것들인지 깨닫게 된다.
사실 이 개념들은 단순히 스프링만의 것이 아니라, 좋은 객체지향 설계의 기본 원칙들이다. 스프링은 이런 원칙들을 프레임워크 차원에서 자동으로 지원해 주는 것이다.
오늘은 IoC와 DI를 개념부터 동작 원리, 실제 코드까지 상세하게 살펴보겠다.
참고로, 경력자 면접에서 아직도 물어보는 개념이기도 하다.
제어의 역전 (IoC: Inversion of Control)
제어의 역전은 이름 그대로 제어권이 뒤바뀌는 것을 의미한다.
기존에는 내가 필요한 객체를 new로 개발자가 직접 생성하고 관리했다면, IoC에서는 외부(스프링 컨테이너)가 이 모든 것을 대신 해준다. 프로그램의 제어 흐름이 개발자에게서 프레임워크로 넘어가는 것이다.
// 기존 방식 - 내가 직접 제어한다
public class OrderService {
private PaymentService paymentService = new PaymentService(); // 내가 직접 생성
public void processOrder(Order order) {
paymentService.processPayment(order);
}
}
// IoC 적용 - 스프링이 제어한다
@Service
public class OrderService {
private final PaymentService paymentService;
// 스프링이 PaymentService를 만들어서 주입해준다
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void processOrder(Order order) {
paymentService.processPayment(order);
}
}
이렇게 되면 OrderService는 PaymentService의 구체적인 구현체가 무엇인지 알 필요가 없다. 그냥 주어진 것을 사용하기만 하면 된다.
의존관계 주입 (DI - Dependency Injection)
의존관계는 크게 두 가지로 나눌 수 있다.
1. 정적 클래스 의존관계
정적 의존관계는 코드를 보면 바로 알 수 있다. 실행하지 않아도 import 구문만 봐도 어떤 클래스에 의존하는지 파악할 수 있다.
import com.example.service.PaymentService; // 이 import만 봐도 의존관계를 안다
@Service
public class OrderService {
private PaymentService paymentService; // PaymentService에 의존한다
}
정적 의존관계의 특징
- 컴파일 타임에 결정된다
- 코드만 봐도 의존관계를 알 수 있다
- 클래스 다이어그램을 그릴 수 있다
- 하지만 실제로 어떤 구현체가 사용될지는 모른다
2. 동적 객체 의존관계
동적 의존관계는 실행 시점에 실제로 어떤 구현체가 주입되는지에 따라 결정된다. 이것이 DI의 핵심이다.
@Service
public class OrderService {
private final PaymentService paymentService;
// 실행할 때까지는 어떤 PaymentService 구현체가 들어올지 모른다
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
동적 의존관계의 특징
- 런타임에 결정된다
- 실제로 어떤 구현체가 주입될지는 실행해봐야 안다
- 같은 코드라도 설정에 따라 다른 구현체가 주입될 수 있다
- 이 덕분에 유연한 프로그래밍이 가능하다
같은 OrderService 코드라도 아래와 같이 사용 가능
- 개발 환경에서는 MockPaymentService가 주입
- 테스트 환경에서는 FakePaymentService가 주입
- 운영 환경에서는 CreditCardPaymentService가 주입
이것이 바로 동적 의존관계의 힘이다! 코드는 그대로 두고 환경이나 설정만 바꿔서 다른 동작을 하게 할 수 있다.
- 정적 의존관계: "어떤 타입에 의존하는가?" (컴파일 타임에 결정)
- 동적 의존관계: "실제로 어떤 구현체가 사용되는가?" (런타임에 결정)
DI는 이 두 의존관계를 분리해서, 정적으로는 인터페이스에만 의존하고 동적으로는 스프링이 적절한 구현체를 주입해주는 방식이다.
IoC와 DI, 뭐가 다른 거야?
IoC와 DI는 자주 함께 언급되어서 같은 개념으로 오해받기 쉽다. 하지만 둘의 관점이 다르다.
차이점
IoC (제어의 역전) | DI (의존관계 주입) | |
관점 | "누가 제어하는가?" | "어떻게 연결하는가?" |
핵심 | 제어권의 주체가 바뀜 | 의존관계를 외부에서 주입 |
의미 | "스프링아, 너가 객체 생성하고 관리해줘" | "필요한 의존성은 너가 주입해줘" |
구현 방식 | - DI (가장 대표적) - 이벤트 핸들러 - 서비스 로케이터 패턴 등 |
- 생성자 주입 - 세터 주입 - 필드 주입 |
장점 | - 객체 간 결합도 낮춤 - 유연한 구조 설계 가능 - 테스트 용이성 ↑ |
- 객체 간 결합도 낮춤 (특히 생성 시점) - Mock 객체 주입으로 단위 테스트 용이 - 가독성, 유지보수성 ↑ |
관계 | 상위 개념 |
둘의 관계
결국 DI는 IoC를 구현하는 하나의 방법이다.
스프링이 제어권을 가지고(IoC), 그 제어권을 이용해서 의존성을 주입해주는(DI) 방식으로 동작한다.
// IoC: 스프링이 객체 생성과 관리를 담당한다
// DI: 그리고 의존관계를 외부에서 주입해준다
@Service
public class OrderService {
private final PaymentService paymentService; // 인터페이스에만 의존 (정적)
public OrderService(PaymentService paymentService) { // 구현체는 외부에서 주입 (동적)
this.paymentService = paymentService;
}
}
프레임워크 vs 라이브러리로 IoC 이해하기
IoC의 핵심은 제어권이 누구에게 있느냐다. 이를 가장 쉽게 이해할 수 있는 방법이 프레임워크와 라이브러리를 비교하는 것이다.
라이브러리는 내가 호출한다 - 제어권이 나에게 있다
// 라이브러리 사용 - 내가 필요할 때 호출한다
public class Calculator {
public int getMax(int a, int b) {
return Math.max(a, b); // 내가 Math 라이브러리를 호출
}
public void printCurrentTime() {
System.out.println(LocalDateTime.now()); // 내가 시간 라이브러리를 호출
}
}
라이브러리를 사용할 때는 내가 주도권을 가진다. 내가 필요할 때 라이브러리의 기능을 가져다 쓴다. 언제 호출할지, 어떤 메서드를 호출할지 모든 걸 내가 결정한다.
프레임워크는 나를 호출한다 - 제어권이 프레임워크에게 있다
// 프레임워크 사용 - 프레임워크가 내 코드를 호출한다
@Controller
public class UserController {
@GetMapping("/users")
public String getUsers() {
// 스프링이 언제 이 메서드를 호출할지 결정한다
// 내가 이 메서드를 직접 호출하지 않는다
return "users";
}
}
프레임워크를 사용할 때는 프레임워크가 주도권을 가진다.
내가 메서드를 직접 호출하는 게 아니라, 프레임워크가 적절한 시점에 내 메서드를 호출해준다.
예를 들어 위 코드에서 getUsers() 메서드
- 누군가 /users URL로 GET 요청을 보낼 때
- 스프링이 자동으로 이 메서드를 찾아서 실행
- 내가 직접 getUsers()를 호출하지 않음
이게 바로 "제어의 역전"이다!
- 일반적인 프로그래밍: 내가 필요한 것을 내가 호출 → 내가 제어
- IoC 프로그래밍: 프레임워크가 필요할 때 내 코드를 호출 → 프레임워크가 제어
IoC Container가 Bean들을 관리하는 것도 같은 맥락이다.
- 기존: 내가 필요한 객체를 내가 직접 new로 생성한다
- IoC: 스프링이 필요한 객체를 미리 만들어 두고, 필요할 때 주입해준다
IoC, DI를 사용하는 이유 (장점)
1. 유연성 확보: 구현체를 자유롭게 바꿀 수 있다
// 만약 이렇게 작성했다면?
public class OrderService {
private CreditCardPaymentService paymentService = new CreditCardPaymentService();
}
이 경우 나중에 결제 방식을 바꾸려면 OrderService 코드를 직접 수정해야 한다. 카카오페이로 바꾸려면? 네이버페이로 바꾸려면? 매번 코드를 고쳐야 한다.
하지만 DI를 사용하면 OrderService 코드는 전혀 건드리지 않고도 결제 서비스를 마음대로 바꿀 수 있다.
// 이렇게 인터페이스에만 의존하면
public class OrderService {
private final PaymentService paymentService; // 인터페이스에만 의존
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
// 나중에 이런 구현체들을 자유롭게 교체할 수 있다
@Service @Primary
public class KakaoPayService implements PaymentService { ... }
@Service
public class NaverPayService implements PaymentService { ... }
@Service
public class TossPayService implements PaymentService { ... }
2. 테스트 용이성: Mock으로 안전한 테스트
// DI 없이 테스트한다면?
public class OrderServiceTest {
@Test
public void testCreateOrder() {
OrderService service = new OrderService();
// 실제 PaymentService가 동작한다 -> 실제 결제가 일어날 수 있다!
// 테스트할 때마다 돈이 빠져나간다면... 끔찍하다
}
}
// DI를 사용하면 테스트가 쉽다
public class OrderServiceTest {
@Test
public void testCreateOrder() {
// 가짜 PaymentService를 만들어서 주입한다
PaymentService mockPaymentService = mock(PaymentService.class);
when(mockPaymentService.processPayment(any())).thenReturn(true);
OrderService service = new OrderService(mockPaymentService, ...);
// 이제 실제 결제 없이 안전하게 테스트할 수 있다!
}
}
3. 책임 분리: 각자의 역할에만 집중
외부에서 주입 시 OrderService는 오직 주문 처리에만 집중하고, 결제 방식 선택은 설정에서 담당한다. 각각의 책임이 명확해진다.
// DI 적용 전 - OrderService가 너무 많은 걸 알고 있다
public class OrderService {
public void processOrder(Order order) {
// OrderService가 어떤 결제 서비스를 사용할지 직접 결정한다
if (order.getAmount().compareTo(new BigDecimal("100000")) > 0) {
CreditCardPaymentService cardService = new CreditCardPaymentService();
cardService.processPayment(order);
} else {
BankTransferPaymentService bankService = new BankTransferPaymentService();
bankService.processPayment(order);
}
}
}
// DI 적용 후 - 각자의 책임만 가진다
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService; // 어떤 구현체인지 관심 없다
}
public void processOrder(Order order) {
// 그냥 주문 처리 로직에만 집중한다
paymentService.processPayment(order);
}
}
// 결제 방식 선택은 별도의 설정에서 담당한다
@Configuration
public class PaymentConfig {
@Bean
@ConditionalOnProperty(name = "payment.type", havingValue = "card")
public PaymentService creditCardPaymentService() {
return new CreditCardPaymentService();
}
@Bean
@ConditionalOnProperty(name = "payment.type", havingValue = "bank")
public PaymentService bankTransferPaymentService() {
return new BankTransferPaymentService();
}
}
4. 확장성: 새로운 기능 추가가 쉬움
// 새로운 결제 서비스를 만든다
@Service
public class CryptocurrencyPaymentService implements PaymentService {
@Override
public boolean processPayment(Order order) {
System.out.println("암호화폐로 결제 처리: " + order.getAmount());
// 블록체인 결제 로직...
return true;
}
}
이게 끝이다! OrderService 등 다른 기존 코드는 전혀 수정할 필요가 없다. 설정에서 어떤 PaymentService를 사용할지만 바꾸면 된다.
5. 환경별 설정 - 같은 코드, 다른 동작
같은 코드로 개발환경에서는 안전하게 테스트하고, 운영환경에서는 실제 결제를 처리할 수 있다.
// 개발환경 - 로그만 찍는다
@Service
@Profile("dev")
public class LogPaymentService implements PaymentService {
@Override
public boolean processPayment(Order order) {
System.out.println("개발환경 결제 로그: " + order.getAmount());
return true;
}
}
// 운영환경 - 실제 결제 처리
@Service
@Profile("prod")
public class RealPaymentService implements PaymentService {
@Override
public boolean processPayment(Order order) {
// 실제 PG사 API 호출
return pgClient.processPayment(order);
}
}
// OrderService는 환경에 상관없이 동일한 코드
@Service
public class OrderService {
private final PaymentService paymentService; // 환경에 따라 다른 구현체가 주입됨
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
만약 IoC, DI가 아니라 100개의 클래스에 new로 객체를 선언해 놨다면... ?
실무에서 IoC의 진짜 가치를 느낄 수 있는 상황을 생각해보자.
// 이런 코드가 100개 클래스에 흩어져 있다고 생각해보자
public class OrderService {
private NaverPayService paymentService = new NaverPayService(); // 하드코딩
}
public class SubscriptionService {
private NaverPayService paymentService = new NaverPayService(); // 하드코딩
}
public class RefundService {
private NaverPayService paymentService = new NaverPayService(); // 하드코딩
}
// ... 97개 클래스가 더 있다고 생각해보자
해당 이제 네이버페이 말고 카카오페이로 new 선언해서 작업한 모든 코드를 변경해야 한다면?
수정을 한다고 했는데, 다 못했다면? 운영하면서 왜 제어의 역전 IoC를 사용했는지 뼈저리게 느끼게 될것이다.
만약 IoC를 사용했다면, 아래와 같이 변경하면 된다.
// 설정 한 곳만 바꾸면 끝
@Configuration
public class PaymentConfig {
@Bean
public PaymentService paymentService() {
// 이 한 줄만 바꾸면 모든 곳에서 카카오페이를 사용한다
return new KakaoPayService();
}
}
이것이 바로 IoC의 진짜 가치다. 개발자들이 왜 스프링을 사랑하는지 알 수 있는 대목이다.
특히 대기업이나 금융권 같이 결제 서비스를 자주 바꿔야 하는 곳에서는 이런 차이가 생산성에 엄청난 영향을 준다.
Bean 등록하고 주입하는 방법
이제 실제로 스프링에서 Bean을 등록하고 주입하는 방법들을 살펴보자.
1. Component Scanning - 자동으로 찾아서 등록
애노테이션을 붙여두면 스프링이 알아서 찾아서 Bean으로 등록한다. 편리하다!
@Service // @Component를 상속받은 특수한 애노테이션
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
@Repository // 마찬가지로 @Component의 특수화
public class UserRepository {
public User findById(Long id) {
return new User();
}
}
@Controller // @Component의 특수화
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
}
2. Java Configuration - 직접 설정해서 등록
복잡한 설정이 필요할 때 유용하다.
@Configuration
public class AppConfig {
@Bean
public UserRepository userRepository() {
return new UserRepository();
}
@Bean
public UserService userService() {
return new UserService(userRepository()); // 의존성을 직접 주입
}
@Bean
public UserController userController() {
return new UserController(userService());
}
}
3. 의존성 주입 방법들
3-1) 생성자 주입 - 추천 방법
@Service
public class OrderService {
private final PaymentService paymentService;
private final InventoryService inventoryService;
// 생성자로 의존성을 받는다
public OrderService(PaymentService paymentService,
InventoryService inventoryService) {
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
}
생성자 주입 추천 이유
- final 키워드로 불변성 보장
- 필수 의존성을 명확하게 드러냄
- 순환 참조를 컴파일 타임에 발견 가능
- 테스트할 때도 편함
3-2)Setter 주입 - 선택적 의존성용
@Service
public class OrderService {
private PaymentService paymentService;
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
3-3) 필드 주입 - 간단하지만 권장 안함
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // 테스트하기 어렵다
}
필드 주입은 코드가 간단해 보이지만, 테스트할 때 의존성을 주입하기 어려워서 권장하지 않는다.
정리하면,
- IoC는 제어권을 개발자가 아닌 프레임워크에 넘겨 유연한 구조를 만드는 원칙이고,
- DI는 그 원칙을 구현하는 대표적인 방법이다.
스프링은 이 두 가지 개념을 자연스럽게 지원해 개발자가 복잡한 객체 생성/관리에 신경 쓰지 않고 비즈니스 로직에만 집중할 수 있도록 도와준다.
프로젝트가 커지고 클래스 수가 많아질수록, IoC와 DI가 주는 유연성과 생산성의 차이는 눈덩이처럼 커진다.
결국 IoC와 DI는 단순히 “개념 정리”로 끝나는 게 아니라, 실무에서 살아남고 더 나은 코드를 작성하기 위한 필수 원칙이다.
'Java | spring > Spring' 카테고리의 다른 글
스케줄링 보다는 Spring Batch! 기본개념 (0) | 2025.08.23 |
---|---|
스프링 AOP 기본 개념 잡기! (1) | 2024.01.03 |
Spring boot 3.2.0 마이그레이션 가이드 (+끝 없는 오류 해결 방법!) (1) | 2023.12.21 |
스프링 트랜잭션 @Transactional 개념 (+주요 설정값) (0) | 2021.06.04 |
알고는 써야지! 기본 spring annotation 종류 (0) | 2021.05.17 |
댓글