의존성 주입(DI)과 IoC 컨테이너의 모방

기 원·2025년 4월 18일

Java를 Spring 처럼!

목록 보기
3/5

Java를 Spring 처럼! - 의존성 주입(DI)과 IoC 컨테이너 흉내내기


1. 도입 - "객체는 누가 만들어야 할까?"

2편에서는 인터페이스를 통해 다양한 구현체를 갈아끼우며 유연한 구조를 만들었죠.
그런데 이런 질문이 생깁니다.

UserService userService = new UserService(new EmailService());
이걸 누가 만들고 주입할까?

하드코딩 대신, 객체 생성을 한 곳에서 관리할 수는 없을까요?


2. 의존성 주입(Dependency Injection)의 개념

의존성 주입은 말 그대로 필요한 객체를 외부에서 주입해주는 방식입니다.

생성자 주입

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;
    }
}

생성자 주입은 불변성과 테스트 편의성 측면에서 가장 권장됩니다.


3. IoC란 무엇인가?

IoC (Inversion of Control) = 제어의 역전

  • 기존: 객체를 직접 생성하고 사용하는 구조
  • IoC: 객체 생성/관리 주체를 개발자가 아닌 외부로 전환

코드 비교

IoC 적용 전

UserService userService = new UserService(new EmailNotificationService());

IoC 적용 후

AppConfig config = new AppConfig();
UserService userService = config.userService();

객체 생성을 외부에 맡기면서, 구현체를 바꾸고 테스트하기 쉬워집니다.


4. 자바로 IoC 컨테이너 흉내내기

직접 객체를 조립하는 설정 클래스 만들기

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 코드는 수정할 필요가 없습니다.


5. DI 방식 비교

주입 방식특징단점
생성자 주입가장 명확하고 불변성 보장순환 참조 시 주의 필요
필드 주입코드 간결테스트 어려움, final 사용 불가
세터 주입선택적 의존성 가능객체 불완전 상태로 존재 가능

스프링은 생성자 주입을 가장 권장합니다.


6. 왜 이렇게까지 해야 하나요?

다음과 같은 실무 경험이 있습니다:

실무 프로젝트에서 처음에는 이메일만 지원하던 알림 서비스에 카카오 알림톡과 슬랙 연동이 추가되었습니다.
기존 코드가 new로 직접 구현체를 생성하고 있어 확장 시 많은 수정이 필요했고, 테스트 코드도 함께 깨졌습니다.
이후 DI 구조로 바꾼 뒤에는 NotificationService 인터페이스 기반으로 주입만 바꿔서 Slack, Kakao 구현체를 적용할 수 있었고,
테스트도 FakeNotificationService를 주입하는 방식으로 간단하게 처리할 수 있었습니다.

DI는 단순한 설계 원칙이 아니라, 협업과 유지보수에 큰 차이를 만들어냅니다.


7. 스프링에서는 어떻게 처리할까?

@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);

스프링이 객체를 생성하고 의존성을 주입해줍니다. 우리는 그저 "필요한 걸 꺼내 쓰면" 됩니다.


8. 실무에서는 어떻게 사용되나?

@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") → 환경에 따라 다른 빈 등록

이러한 방법들은 인터페이스 기반 설계를 더욱 유연하게 만들어줍니다.


9. 오늘의 정리

개념설명
DI객체를 외부에서 주입하는 방식
IoC객체 제어권이 프레임워크(컨테이너)로 넘어감
AppConfig객체를 생성하고 연결하는 설정 역할
스프링 DI@Configuration + @Bean + @Autowired로 구현
이점유연성, 확장성, 테스트 편의성, 스프링과 찰떡
profile
노력하고 있다니까요?

1개의 댓글

comment-user-thumbnail
2025년 4월 18일

오늘 내용은 꽤 심오하군요!

답글 달기