2편에서는 인터페이스를 통해 다양한 구현체를 갈아끼우며 유연한 구조를 만들었죠.
그런데 이런 질문이 생깁니다.
UserService userService = new UserService(new EmailService());
→ 이걸 누가 만들고 주입할까?
하드코딩 대신, 객체 생성을 한 곳에서 관리할 수는 없을까요?
의존성 주입은 말 그대로 필요한 객체를 외부에서 주입해주는 방식입니다.
public class UserService {
private final NotificationService notificationService;
// 생성자 주입
public UserService(NotificationService notificationService) {
this.notificationService = notificationService;
}
}
public class UserService {
@Autowired
private NotificationService notificationService;
}
public class UserService {
private NotificationService notificationService;
@Autowired
public void setNotificationService(NotificationService notificationService) {
this.notificationService = notificationService;
}
}
생성자 주입은 불변성과 테스트 편의성 측면에서 가장 권장됩니다.
UserService userService = new UserService(new EmailNotificationService());
AppConfig config = new AppConfig();
UserService userService = config.userService();
객체 생성을 외부에 맡기면서, 구현체를 바꾸고 테스트하기 쉬워집니다.
public class AppConfig {
public UserService userService() {
return new UserService(notificationService());
}
public NotificationService notificationService() {
return new EmailNotificationService();
}
}
public class Main {
public static void main(String[] args) {
AppConfig config = new AppConfig();
UserService userService = config.userService();
userService.process();
}
}
이 구조 자체가 스프링의 IoC 컨테이너를 흉내낸 것입니다!
AppConfig가 모든 객체를 만들고 연결해주죠.
📌 예를 들어 테스트 환경에서
FakeNotificationService를 만들어AppConfig에서 주입하면 쉽게 테스트가 가능합니다.
또는 Slack 연동 구현체를 교체하더라도AppConfig의 return 값만 바꾸면 UserService 코드는 수정할 필요가 없습니다.
| 주입 방식 | 특징 | 단점 |
|---|---|---|
| 생성자 주입 | 가장 명확하고 불변성 보장 | 순환 참조 시 주의 필요 |
| 필드 주입 | 코드 간결 | 테스트 어려움, final 사용 불가 |
| 세터 주입 | 선택적 의존성 가능 | 객체 불완전 상태로 존재 가능 |
스프링은 생성자 주입을 가장 권장합니다.
다음과 같은 실무 경험이 있습니다:
실무 프로젝트에서 처음에는 이메일만 지원하던 알림 서비스에 카카오 알림톡과 슬랙 연동이 추가되었습니다.
기존 코드가 new로 직접 구현체를 생성하고 있어 확장 시 많은 수정이 필요했고, 테스트 코드도 함께 깨졌습니다.
이후 DI 구조로 바꾼 뒤에는NotificationService인터페이스 기반으로 주입만 바꿔서 Slack, Kakao 구현체를 적용할 수 있었고,
테스트도FakeNotificationService를 주입하는 방식으로 간단하게 처리할 수 있었습니다.
DI는 단순한 설계 원칙이 아니라, 협업과 유지보수에 큰 차이를 만들어냅니다.
@Configuration
public class AppConfig {
@Bean
public NotificationService notificationService() {
return new EmailNotificationService();
}
@Bean
public UserService userService() {
return new UserService(notificationService());
}
}
그리고 실행 시 아래처럼 ApplicationContext로부터 객체를 받아옵니다:
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);
스프링이 객체를 생성하고 의존성을 주입해줍니다. 우리는 그저 "필요한 걸 꺼내 쓰면" 됩니다.
@Service
public class EmailNotificationService implements NotificationService { ... }
@RequiredArgsConstructor
@RestController
public class UserController {
private final NotificationService notificationService;
@PostMapping("/notify")
public void send() {
notificationService.send("API 호출됨");
}
}
@Component, @Service, @Repository 등으로 등록된 빈은@Autowired 또는 @RequiredArgsConstructor를 통해 주입됩니다.또한 실무에서는 다음과 같은 기술도 자주 사용됩니다:
@Qualifier("emailService") → 여러 구현체 중 특정 이름을 지정@Primary → 기본 구현체 지정@Profile("test") → 환경에 따라 다른 빈 등록이러한 방법들은 인터페이스 기반 설계를 더욱 유연하게 만들어줍니다.
| 개념 | 설명 |
|---|---|
| DI | 객체를 외부에서 주입하는 방식 |
| IoC | 객체 제어권이 프레임워크(컨테이너)로 넘어감 |
| AppConfig | 객체를 생성하고 연결하는 설정 역할 |
| 스프링 DI | @Configuration + @Bean + @Autowired로 구현 |
| 이점 | 유연성, 확장성, 테스트 편의성, 스프링과 찰떡 |
오늘 내용은 꽤 심오하군요!