[Spring Boot / 백엔드] 스프링 컨테이너 이해하기 - 제어의 역전(IoC), 의존성 주입(DI), 스프링 빈

백엔드 개발을 공부하다 보면 Controller, Service, Repository 같은 클래스를 자연스럽게 나누게 됩니다.

처음에는 저도 그냥 역할에 맞게 클래스를 분리하고, Controller에서 Service를 호출하면 되는 정도로만 이해했습니다.

그런데 스프링을 쓰다 보면 어느 순간 이런 궁금증이 생깁니다.

  • Controller 객체는 누가 만들지?
  • Service는 언제 생성되지?
  • 둘은 어떻게 연결되는 걸까?

직접 new로 만든 것도 아닌데, 어떻게 알아서 주입되어 동작하는지 의문이 들었습니다.

그래서 이번 글에서는 워크북 내용을 바탕으로 스프링 빈이 무엇인지, IoC와 DI가 어떤 의미인지,

그리고 스프링이 Controller와 Service 같은 객체를 어떻게 만들고 연결하는지를 정리해보려고 합니다.

이번 글에서 다룰 내용은 다음과 같습니다.

  • 스프링 빈(Bean)이란 무엇인가
  • IoC(Inversion of Control)란 무엇인가
  • DI(Dependency Injection)란 무엇인가
  • 스프링 컨테이너는 객체를 어떻게 관리하는가
  • Controller와 Service는 어떻게 연결되는가
  • 생성자 주입이 왜 권장되는가

왜 스프링은 객체를 직접 만들지 않게 할까?

자바를 처음 배울 때 객체는 보통 직접 생성했습니다.

MemberService memberService = new MemberService();

이 방식은 단순하고 직관적이지만, 애플리케이션 규모가 커질수록 문제가 생깁니다.

예를 들어 Service가 Repository를 필요로 하고, Controller가 다시 Service를 필요로 한다면 각 객체 안에서 직접 new를 통해 다른 객체를 만들게 될 가능성이 높습니다.

그러면 객체들이 서로 강하게 묶이게 되고, 구현이 바뀔 때마다 관련된 코드도 함께 수정해야 합니다. 결국 객체 생성, 연결, 생명주기를 개발자가 전부 책임져야 하니 코드가 점점 복잡해지고 테스트도 어려워집니다.

스프링은 바로 이 부분을 대신 관리해주기 위해 등장합니다. 즉, 객체를 누가 만들고, 어떻게 연결할지에 대한 책임을 개발자 대신 스프링이 맡는 것입니다.


스프링 빈(Bean)이란?

스프링을 이해할 때 가장 먼저 알아야 하는 개념 중 하나가 바로 빈(Bean) 입니다. 빈은 쉽게 말하면 스프링 컨테이너가 관리하는 객체입니다.

우리가 일반 자바에서 new로 직접 객체를 만드는 대신, 스프링이 객체를 생성하고 보관하고 필요한 곳에 꺼내서 넣어주는 방식이라고 보면 됩니다.

예를 들어 다음과 같은 클래스가 있다고 해보겠습니다.

@Service
public class MemberService {
}

이 클래스에 @Service가 붙어 있으면, 스프링은 이 클래스를 스캔해서 Bean으로 등록합니다. 즉, 이제 이 객체는 개발자가 직접 생성하는 것이 아니라 스프링 컨테이너 안에서 관리되는 객체가 됩니다.

보통 @Component, @Service, @Controller, @Repository 같은 어노테이션이 붙은 클래스들이 Bean으로 등록됩니다. 정리하면, 빈은 단순한 자바 객체가 아니라 스프링이 생성하고 관리하는 특별한 객체라고 볼 수 있습니다.


스프링 컨테이너란?

그렇다면 빈을 관리하는 주체는 누구일까요? 바로 스프링 컨테이너입니다.

스프링 컨테이너는 애플리케이션이 실행될 때 필요한 객체들을 생성하고, 객체들 사이의 의존관계를 연결하고, 필요한 순간에 적절한 객체를 꺼내서 사용할 수 있게 관리합니다.

쉽게 말하면 스프링 컨테이너는 다음 같은 역활을 합니다.

  • 어떤 객체를 만들지 결정하고
  • 그 객체를 저장하고
  • 서로 연결하고
  • 전체 생명주기를 관리하는 역할

즉, Controller와 Service가 따로 존재하는 것이 아니라 스프링 컨테이너 안에서 함께 관리되며 연결된다고 이해하면 훨씬 편합니다.


IoC란?

스프링 이야기를 하다 보면 꼭 등장하는 개념이 IoC(Inversion of Control) 입니다. 한국어로는 제어의 역전이라고 부릅니다.

말이 조금 어렵지만, 핵심은 단순합니다. 원래는 개발자가 객체를 만들고, 필요한 객체를 연결하고, 실행 흐름을 직접 제어합니다.

그런데 스프링에서는 그 제어권이 개발자에게 있지 않고 스프링 컨테이너로 넘어갑니다.

즉,

  • 객체 생성
  • 객체 초기화
  • 객체 연결
  • 실행 흐름의 일부

를 스프링이 대신 관리합니다. 이것이 바로 제어의 역전입니다.

예를 들어 우리가 웹 요청을 처리할 때도 직접 Controller 객체를 만들고 메서드를 호출하는 것이 아니라, 요청이 들어오면 스프링이 적절한 Controller를 찾아 실행합니다.

이처럼 내가 직접 제어하던 것을 프레임워크가 대신 제어하는 구조가 IoC입니다.

스프링이 프레임워크라고 불리는 이유도 여기에 있습니다.


DI란?

IoC가 “제어권이 스프링에게 있다”는 큰 원칙이라면, DI는 그 원칙을 실제 코드 수준에서 구현하는 방법입니다. DI는 Dependency Injection, 즉 의존성 주입입니다.

여기서 의존성이란, 한 객체가 다른 객체를 필요로 하는 관계를 말합니다.

예를 들어 Controller는 Service를 필요로 하고, Service는 Repository를 필요로 할 수 있습니다.

이때 객체가 필요한 의존성을 직접 new로 생성하지 않고, 외부에서 넣어주는 방식이 바로 DI입니다.

예를 들어 다음과 같은 코드가 있습니다.

@Service
public class MemberService {
}
@RestController
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
}

여기서 MemberController는 MemberService를 필요로 합니다. 중요한 점은 Controller 안에서 new MemberService()를 하지 않는다는 것입니다.

그 대신 스프링이 이미 만들어둔 MemberService Bean을 찾아서 MemberController 생성자에 넣어줍니다.

즉, 필요한 객체를 직접 만들지 않고 주입받는 구조가 DI입니다.


왜 DI가 필요할까?

DI가 필요한 이유는 결국 결합도를 낮추기 위해서입니다.

만약 객체가 필요한 의존성을 직접 생성한다면 이렇게 됩니다.

public class MemberController {

    private MemberService memberService = new MemberService();
}

이 방식은 간단해 보이지만, MemberService 구현이 바뀌거나 테스트용 객체로 바꿔야 할 때 유연하게 대응하기 어렵습니다.

반면 DI를 사용하면 Controller는 “나는 MemberService가 필요해”라고만 선언하면 됩니다.

어떤 구현체를 넣을지, 언제 만들지, 어떻게 관리할지는 스프링이 처리합니다.

그래서 DI를 사용하면 다음과 같은 장점이 있습니다.

  • 객체 간 결합도가 낮아짐
  • 구현 변경이 쉬워짐
  • 테스트가 쉬워짐
  • 역할 분리가 더 명확해짐

즉, DI는 단순히 편하게 객체를 넣어주는 기능이 아니라 유지보수하기 좋은 구조를 만들기 위한 핵심 방식이라고 볼 수 있습니다.


스프링은 Controller와 Service를 어떻게 만들고 연결할까?

이제 가장 궁금했던 부분으로 돌아가 보겠습니다. 스프링은 실제로 Controller와 Service 같은 객체를 어떻게 만들고 연결할까요?

흐름은 대략 이렇게 이해할 수 있습니다.

1. 애플리케이션이 실행된다

보통 Spring Boot 프로젝트는 @SpringBootApplication이 붙은 메인 클래스로 시작합니다.

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

이때 스프링이 실행되면서 스프링 컨테이너가 함께 올라옵니다.

2. 컴포넌트 스캔이 일어난다

스프링은 프로젝트를 스캔하면서 @Component, @Controller, @Service, @Repository 같은 어노테이션이 붙은 클래스를 찾습니다. 그리고 이 클래스들을 Bean으로 등록합니다.

즉,

  • MemberController
  • MemberService
  • MemberRepository

같은 객체들이 스프링 컨테이너 안에 생성됩니다.

3. 의존관계를 확인한다

스프링은 각 Bean이 어떤 객체를 필요로 하는지도 함께 확인합니다. 예를 들어 MemberController 생성자에 MemberService가 있으면 “이 Controller는 MemberService가 필요하구나”라고 판단합니다.

4. 필요한 Bean을 주입한다

그다음 스프링은 컨테이너 안에서 적절한 MemberService Bean을 찾아 MemberController에 넣어줍니다. 같은 방식으로 MemberService가 MemberRepository를 필요로 한다면 Repository Bean도 함께 주입됩니다.

결국 구조는 이런 느낌입니다.

@RestController
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
}
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}
@Repository
public class MemberRepository {
}

이 코드를 보면 Controller는 Service를 알고 있고, Service는 Repository를 알고 있습니다. 하지만 각 객체가 서로를 직접 생성하지는 않습니다. 이 연결을 전부 스프링이 대신 관리합니다.

즉, 객체 생성은 스프링이 하고, 객체 연결도 스프링이 한다는 점이 핵심입니다.


Bean은 어떻게 등록할까?

스프링에서 Bean을 등록하는 방법은 크게 두 가지로 볼 수 있습니다.

1. 컴포넌트 스캔 방식

가장 자주 보는 방식입니다.

@Service
public class MemberService {
}
@Repository
public class MemberRepository {
}

이처럼 클래스에 어노테이션을 붙이면 스프링이 자동으로 Bean으로 등록합니다.

@Controller, @Service, @Repository는 내부적으로 @Component 계열이라서 컴포넌트 스캔 대상이 됩니다.

2. 설정 클래스에서 직접 등록하는 방식

외부 라이브러리 객체처럼 클래스에 어노테이션을 붙일 수 없는 경우에는

@Configuration과 @Bean을 사용해서 직접 등록할 수 있습니다.

@Configuration
public class AppConfig {

    @Bean
    public MyClient myClient() {
        return new MyClient();
    }
}

이 방식은 스프링이 자동으로 찾기 어려운 객체를 개발자가 명시적으로 Bean으로 등록하고 싶을 때 자주 사용합니다.


의존성 주입 방식에는 어떤 것들이 있을까?

스프링에서 의존성을 주입하는 방식은 대표적으로 세 가지가 있습니다.

1. 생성자 주입

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

생성자를 통해 의존성을 주입받는 방식입니다. 가장 권장되는 방식으로 많이 사용됩니다.

이유는 다음과 같습니다.

  • 객체 생성 시점에 필요한 의존성이 반드시 주입됨
  • final을 사용할 수 있어 불변성을 지키기 좋음
  • 테스트 코드 작성이 쉬움
  • 의존관계가 코드에 명확하게 드러남

2. Setter 주입

@Service
public class MemberService {

    private MemberRepository memberRepository;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

setter 메서드를 통해 주입받는 방식입니다.

선택적인 의존성에는 사용할 수 있지만, 필수 의존성이 빠져도 객체가 생성될 수 있어 안정성이 떨어질 수 있습니다.

3. 필드 주입

@Service
public class MemberService {

    @Autowired
    private MemberRepository memberRepository;
}

코드는 짧지만 권장되지는 않는 방식입니다. 의존관계가 외부에서 잘 보이지 않고, 테스트하기도 어렵기 때문입니다.


왜 생성자 주입이 가장 많이 권장될까?

처음 스프링을 배울 때는 필드 주입이 가장 편해 보일 수 있습니다. 코드도 짧고 바로 사용할 수 있기 때문입니다.

그런데 프로젝트가 커질수록 생성자 주입의 장점이 훨씬 분명해집니다.

생성자 주입은 객체를 만들 때 필요한 의존성을 반드시 받도록 강제합니다.

즉, 필요한 객체가 없으면 아예 생성 자체가 되지 않기 때문에 런타임에서 뒤늦게 NullPointerException이 나는 상황을 줄일 수 있습니다.

또한 어떤 객체를 필요로 하는지가 생성자에 그대로 드러나기 때문에 코드를 처음 보는 사람도 구조를 이해하기 쉽습니다.

그래서 실무나 학습에서 모두 의존성이 필수라면 생성자 주입을 우선적으로 사용하는 것이 가장 자연스럽다고 느꼈습니다.


결국 Controller와 Service는 왜 편하게 사용할 수 있을까?

우리가 Spring Boot에서 코드를 짤 때는 보통

  • Controller 작성
  • Service 작성
  • Repository 작성

정도만 해두면 알아서 연결되어 동작합니다.

그 이유는 스프링이 아래 과정을 대신 해주기 때문입니다.

  • 클래스를 스캔해서 Bean으로 등록하고
  • 필요한 의존관계를 파악하고
  • 적절한 객체를 생성자 등에 주입하고
  • 전체 객체 생명주기를 관리하기 때문입니다

즉, 개발자는 비즈니스 로직에 더 집중하고, 객체 생성과 연결 같은 반복적인 작업은 프레임워크가 맡는 구조입니다.

이게 바로 스프링이 생산성을 높여주는 핵심 이유 중 하나라고 생각합니다.


마무리

이번 내용을 정리하면서 느낀 것은, 스프링에서 Controller와 Service가 “그냥 연결되는 것처럼 보이는 이유” 뒤에는 생각보다 중요한 구조적 개념들이 숨어 있다는 점이었습니다.

처음에는 저도 @Service, @Controller만 붙이면 되는 줄 알았습니다.

하지만 그 안에는 스프링 컨테이너가 객체를 Bean으로 관리하고, IoC를 통해 제어권을 가져가고, DI를 통해 필요한 객체를 서로 연결해주는 흐름이 있었습니다.

결국 스프링은 단순히 편리한 프레임워크가 아니라, 객체를 더 유연하고 유지보수하기 좋게 관리하도록 도와주는 구조를 제공하는 프레임워크라고 느꼈습니다.

특히 이번 내용을 공부하면서, Controller에서 Service를 호출하고, Service에서 Repository를 사용하는 흐름이 단순한 코드 호출이 아니라 스프링이 설계한 객체 관리 방식 위에서 동작하고 있다는 점이 더 명확해졌습니다.

앞으로는 단순히 어노테이션을 붙이는 데서 끝나지 않고,

  • “이 객체는 왜 Bean으로 관리될까?”,
  • “왜 생성자 주입을 권장할까?”,
  • “스프링은 이 객체들을 어떤 순서로 만들고 연결할까?”

를 함께 생각하면서 코드를 봐야겠다고 느꼈습니다.