LODY/정리

Spring 부트 / 제어의 역전과 의존성 주입

제어의 역전과 의존성 주입

🟣 객체를 누가 만들어 주는가

작은 서버라도 클래스 하나로는 안 끝난다. 컨트롤러가 서비스를 부르고, 서비스가 리포지토리를 부르고, 리포지토리가 DB 연결을 쓴다. 이 사슬을 직접 엮으면 코드가 이렇게 된다.

public class OrderController {
    private final OrderService service =
        new OrderService(new OrderRepository(new DataSource()));
}

문제는 OrderController가 자기 일(주문 요청 처리)만 하는 게 아니라, 자기가 쓸 부품을 어떻게 조립하는지까지 떠안는다는 데 있다. OrderService의 생성자가 바뀌면 이 컨트롤러도 고쳐야 하고, 테스트할 때 가짜 리포지토리를 끼우기도 어렵다. 앱을 만들 때 화면 하나가 네트워크 클라이언트를 직접 init 하던 코드가 커지면서 겪던 불편과 정확히 같은 종류다.

스프링의 출발점은 이 조립 책임을 객체에서 떼어 내는 것이다. 객체는 "나는 OrderService가 필요하다"고 선언만 하고, 실제로 만들어 넣어 주는 일은 프레임워크가 한다. 제어의 흐름이 뒤집혔다고 해서 이것을 제어의 역전(IoC)이라 부르고, 필요한 객체를 밖에서 넣어 주는 구체적 방식을 의존성 주입(DI)이라 부른다.


🟣 스프링 컨테이너와 빈

스프링은 애플리케이션이 뜰 때 객체들을 미리 만들어 한곳에 담아 둔다. 이 저장소가 스프링 컨테이너이고, 컨테이너가 관리하는 객체 하나하나를 빈(Bean)이라 부른다. 개발자가 new로 만들던 객체를 이제 컨테이너가 만들어 들고 있다가, 필요한 곳에 꽂아 준다.

어떤 클래스를 빈으로 삼을지는 애너테이션으로 표시한다. @Component가 기본이고, 역할에 따라 이름만 다른 @Service, @Repository, @Controller를 쓴다. 그리고 필요한 빈은 생성자로 받는다.

@Service
public class OrderService {
    private final OrderRepository repository;
 
    // 생성자 파라미터를 컨테이너가 채워 넣는다
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

OrderServiceOrderRepository를 어디서 얻는지 모른다. 그저 생성자로 받겠다고 선언할 뿐이고, 컨테이너가 자기가 들고 있는 OrderRepository 빈을 찾아 넣는다. 조립의 책임이 개별 객체에서 컨테이너로 옮겨 간 것이다.

스프링 컨테이너OrderControllerOrderServiceOrderRepositoryDataSource주입주입주입

🚀 생성자 주입을 기본으로 쓰는 이유

주입하는 방법은 생성자, 필드, 세터 세 가지가 있다. 필드에 바로 @Autowired를 붙이면 코드가 짧아 보이지만, 스프링을 거치지 않고는 객체를 만들 수 없게 된다. 테스트에서 순수하게 new OrderService(mockRepo)로 만들 길이 막힌다.

생성자 주입은 다르다. 의존이 생성자 파라미터로 드러나므로 이 클래스가 무엇에 기대는지 한눈에 보이고, final로 선언해 한 번 주입되면 바뀌지 않게 고정할 수 있다. 무엇보다 스프링 없이도 new로 만들어 테스트할 수 있다. 그래서 실무에서는 생성자 주입을 기본으로 두고, 선택적 의존에만 세터를 쓴다. 프레임워크에 덜 묶이는 쪽을 고르는 판단이다.


🟣 요청 하나가 흘러가는 길

DI로 부품이 엮이고 나면, 실제 HTTP 요청이 이 부품들을 타고 흐른다. 앱이 POST /orders를 호출했을 때 서버 안에서 벌어지는 일을 층으로 보면 이렇다.

iOS 앱POST /ordersController요청·응답 변환Service비즈니스 규칙RepositoryDB 접근JSON호출저장

컨트롤러는 얇게 둔다. HTTP 요청을 자바 객체로 받고 결과를 JSON으로 돌려주는 변환만 맡고, 실제 규칙은 서비스에 있다. 이렇게 층을 나누면 같은 주문 생성 로직을 웹 요청에서 부르든 배치 작업에서 부르든 서비스는 그대로 재사용된다. 클라이언트에서 화면(뷰)과 로직(뷰모델·유스케이스)을 나누던 감각이 서버에서도 그대로 통한다.

🚀 스프링부트가 줄여 준 것

스프링은 원래 XML로 빈을 일일이 등록하고 서버(WAS)를 따로 띄워야 했다. 스프링부트는 이 준비 과정을 걷어 냈다. @SpringBootApplication이 붙은 클래스 하나를 실행하면 컴포넌트 스캔이 패키지를 훑어 빈을 자동으로 등록하고, 내장 톰캣이 함께 떠서 바로 요청을 받는다.

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

여기에 자동 설정(auto-configuration)이 더해진다. 클래스패스에 DB 드라이버가 있으면 데이터소스를, 웹 의존성이 있으면 톰캣을 알아서 설정한다. 관례를 따르면 설정을 거의 안 써도 도는 것이 스프링부트의 핵심이고, 덕분에 서버를 처음 만지는 입장에서도 요청 처리라는 본질에 빠르게 닿을 수 있었다. 다만 자동으로 채워지는 게 많은 만큼, 무엇이 어떤 조건으로 켜졌는지는 따로 들여다봐야 실무에서 문제를 짚을 수 있다.


🟣 정리

  • 객체가 자기 부품까지 직접 조립하면 결합이 강해진다. 제어의 역전은 그 조립 책임을 컨테이너로 옮기고, 의존성 주입은 필요한 객체를 밖에서 넣어 주는 방식이다.
  • 컨테이너는 @Component 계열로 표시된 클래스를 빈으로 만들어 들고 있다가 생성자로 꽂아 준다. 개발자는 new 대신 "필요하다"는 선언만 한다.
  • 생성자 주입을 기본으로 쓰면 의존이 드러나고 final로 고정되며 스프링 없이 테스트할 수 있다. 프레임워크에 덜 묶이는 선택이다.
  • 요청은 컨트롤러·서비스·리포지토리 층을 타고 흐른다. 컨트롤러는 얇게, 규칙은 서비스에 둔다. 클라이언트의 화면·로직 분리와 같은 원리다.
  • 면접에서 DI를 물으면 "객체를 밖에서 넣는다"에서 멈추지 말고, 왜 생성자 주입인지(테스트 용이성·불변성)까지 답하는 편이 깊이가 드러난다.

컨테이너가 객체를 엮어 요청을 처리하는 뼈대는 섰다. 그런데 리포지토리가 실제로 DB와 만나는 지점, 자바 객체가 어떻게 DB의 행이 되고 다시 객체로 돌아오는지는 아직 비어 있다. 다음 장의 JPA가 그 자리를 채운다.