본문 바로가기
Framework/Spring Boot

[Spring Boot] 의존성 주입(DI)과 IoC 컨테이너: 객체의 생과 사를 스프링에게 맡기다

반응형

우리가 자바(Java) 학원에서 처음 프로그래밍을 배울 때, 어떤 클래스의 기능을 사용하려면 무조건 new 키워드를 사용해 메모리에 객체를 찍어내야 한다고 배웠습니다. 예를 들어 UserService에서 UserRepository가 필요하면 UserRepository repository = new UserRepository(); 처럼 직접 생성했습니다. 하지만 실무 스프링 부트(Spring Boot) 프로젝트 코드를 아무리 뒤져봐도 new 키워드를 찾아보기는 하늘의 별 따기입니다.
개발자가 객체를 직접 만들지 않는데 도대체 프로그램이 어떻게 굴러가는 걸까요? 이 거대한 미스터리를 푸는 열쇠이자, 전 세계 수많은 자바 개발자들을 스프링의 노예(?)로 만들어버린 가장 위대하고 핵심적인 철학이 바로 IoC(Inversion of Control, 제어의 역전)와 DI(Dependency Injection, 의존성 주입)입니다.

이번 포스팅에서는 "스프링 프레임워크의 심장"이라 불리는 DI와 IoC 컨테이너의 뼈를 때리는 실무 원리에 대하여 다뤄보겠습니다.

🔄 1. IoC (제어의 역전): 주도권을 스프링에게 뺏기다

과거의 자바 개발자들은 프로그램의 제어권(Control)을 온전히 자신이 쥐고 있었습니다. 언제 객체를 생성할지(new), 언제 삭제할지 개발자가 직접 통제했습니다. 하지만 스프링 프레임워크를 도입하는 순간, 이 제어권은 '개발자'에서 '스프링'으로 통째로 넘어갑니다. 이것이 바로 제어의 역전(Inversion of Control)입니다.

스프링 부트가 실행될 때, 스프링은 프로젝트 패키지들을 싹 다 뒤져서 @Controller, @Service, @Repository, @Component 등 특별한 어노테이션이 붙은 클래스들을 찾습니다. 그리고 개발자 대신 자기가 직접 new를 호출하여 객체를 생성한 뒤, 거대한 'IoC 컨테이너(또는 ApplicationContext)'라는 바구니에 담아서 관리합니다. 이 바구니에 담긴 객체들을 우리는 특별히 빈(Bean)이라고 부릅니다. 보통 빈은 메모리 낭비를 막기 위해 단 1개만 생성되어 돌려 쓰는 싱글톤(Singleton) 패턴으로 관리됩니다.


💉 2. DI (의존성 주입): 바구니에서 꺼내서 꽂아주다

제어권이 넘어갔으니, 이제 A 클래스에서 B 클래스가 필요할 때 A 클래스가 직접 new B()를 할 수 없습니다. 대신 스프링에게 "나 B 객체 좀 필요한데, 네 바구니에 있으면 나한테 줘!"라고 요청(의존)해야 합니다. 그러면 스프링이 바구니에서 B 객체(Bean)를 꺼내서 A 클래스 안에 쏙 주입(Injection)해 줍니다. 이것이 바로 DI입니다.


💻 3. 실무 DI 3가지 방법 (생성자 주입이 진리인 이유)

스프링에서 의존성을 주입받는 방법은 크게 3가지가 있습니다. 하지만 실무에서는 무조건 3번째인 생성자 주입(Constructor Injection)만을 사용해야 합니다.

❌ 1. 필드 주입 (Field Injection) - 절대 금지

@Service
public class UserService {
    @Autowired // 코드가 아주 짧아서 과거에 유행했으나, 지금은 최악의 안티 패턴입니다.
    private UserRepository userRepository;
}

이 방식은 코드가 짧아 편하지만, 외부에서 userRepository를 변경할 방법이 원천 차단됩니다. 따라서 순수한 자바 코드로 단위 테스트(Unit Test)를 작성할 때 모의 객체(Mock)를 집어넣을 수가 없어서 테스트가 박살 납니다.

❌ 2. Setter 주입 (Setter Injection) - 사용 안함

setUserRepository() 메서드에 @Autowired를 붙이는 방식입니다. 외부에서 값을 바꿀 수는 있지만, 누군가 실수로 런타임 중에 Setter를 호출해버리면 시스템 구조가 무너지는 치명적 결함이 있습니다.

✅ 3. 생성자 주입 (Constructor Injection) - 실무 표준

@Service
public class UserService {
    
    // final 키워드를 붙일 수 있어서 불변성(Immutability)이 보장됩니다.
    private final UserRepository userRepository;

    // 스프링 4.3부터 생성자가 단 1개뿐이면 @Autowired를 생략해도 스프링이 알아서 주입해 줍니다!
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

생성자 주입의 가장 강력한 장점은 '안전성'입니다. final 키워드를 쓸 수 있어서 앱이 실행된 이후에 누군가 실수로 객체를 바꿔치기할 수 없습니다. 또한 테스트 코드를 짤 때 new UserService(mockRepository) 처럼 내가 원하는 가짜 객체를 쉽게 집어넣을 수 있습니다.


✨ 4. Lombok의 @RequiredArgsConstructor 마법

위의 생성자 주입 코드가 완벽하긴 한데, 의존해야 할 객체가 10개로 늘어나면 생성자 코드가 30줄이 넘어가는 지옥이 펼쳐집니다. 이때 롬복(Lombok)이 구원투수로 등장합니다.

@Service
@RequiredArgsConstructor // 🌟 final이 붙은 필드들을 모아서 자동으로 생성자를 만들어주는 궁극의 마법!
public class UserService {
    
    // 이제 개발자는 그냥 final 선언만 툭툭 던져놓으면 DI 세팅이 끝납니다.
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;
    
    // 눈에는 보이지 않지만, 롬복이 생성자 코드를 알아서 짜서 스프링에게 넘겼습니다.
}

🎯 5. 마무리 및 다음 단계

지금까지 자바의 핵심 철학인 결합도(Coupling)를 극도로 낮추어 부품을 쉽게 갈아 끼울 수 있게 만들고, 유지보수와 테스트를 천국으로 이끄는 스프링의 궁극기, IoC 컨테이너와 생성자 기반 DI(의존성 주입)에 대해 다루어 보았습니다. 이제 여러분은 빈(Bean)과 생명주기를 완벽히 통제할 수 있습니다.

객체 간의 통신이 완벽해졌으니 이제 서비스를 운영할 차례입니다. 그런데 어떤 악성 유저가 회원가입 창에서 숫자만 들어가야 할 나이 칸에 "안녕"이라는 문자를 넣거나, 존재하지 않는 상품 아이디를 URL로 찔러대서 시스템 내부 깊은 곳에서 NullPointerException이 터져버렸습니다. 스프링 부트는 기본적으로 하얀색의 끔찍한 "Whitelabel Error Page"를 사용자에게 뱉어냅니다. 이는 실무에서 절대 용납될 수 없는 아마추어의 증거입니다! 이어지는 07단계 포스팅에서는 "모든 에러를 한 곳으로 빨아들여 예쁜 JSON 에러 포맷으로 클라이언트에게 반환한다!" @RestControllerAdvice를 활용한 전역 예외 처리(Global Exception Handling) 완벽 실무 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형