프로젝트 규모가 작을 때는 필요한 객체를 new로 만들면 된다. 하지만 서비스가 커지면 객체를 누가 생성하고, 누구에게 전달하고, 언제 폐기할지를 일관되게 관리하기 어려워진다.
스프링의 IoC(Inversion of Control, 제어의 역전)는 객체의 생성과 연결을 애플리케이션 코드 바깥으로 분리하는 설계 방식이다. 이를 구현하는 스프링의 핵심 구성요소가 IoC 컨테이너다.
class OrderService {
private final DiscountPolicy discountPolicy = new FixedDiscountPolicy();
}
OrderService가 할인 정책을 직접 만들고 있다. 나중에 정률 할인으로 바꾸거나 테스트용 가짜 객체로 교체하려면 서비스 구현을 수정해야 한다.
구현체를 외부에서 받도록 설계를 바꿔보자.
class OrderService {
private final DiscountPolicy discountPolicy;
OrderService(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
이제 서비스는 DiscountPolicy가 어떻게 생성됐는지 몰라도 사용할 수 있다. 서비스가 필요로 하는 객체를 외부에서 제공하는 것을 DI(Dependency Injection, 의존성 주입)라고 한다. DI는 제어의 역전을 구현하는 대표적인 방법이다.
즉, IoC는 제어권의 위치를 바꾸는 원칙이고, DI는 그 원칙을 코드에 적용하는 방법이라고 생각하면 된다.
스프링이 관리하는 객체를 Bean(빈)이라고 부른다.
ApplicationContext
├── orderController: OrderController
├── orderService: OrderService
└── discountPolicy: FixedDiscountPolicy
OrderController → OrderService → DiscountPolicy
이 그림에서 컨테이너는 빈을 등록하고, 의존 관계를 해석하고, 필요한 곳에 빈을 주입한다. 기본적인 빈 생명주기와 스코프도 관리한다.
스프링에는 BeanFactory 인터페이스가 있고, ApplicationContext는 이를 확장하여 이벤트 발행, 메시지 처리, 리소스 접근 등 다양한 기능을 제공한다. 실제 애플리케이션에서는 보통 ApplicationContext 기반 환경을 사용한다.
모든 자바 객체가 빈은 아니다. new Order()로 만든 주문 데이터 객체나 HTTP 요청에서 생성되는 DTO가 자동으로 스프링 빈이 되는 것은 아니다.
가장 손쉬운 방법은 스프링이 인식하는 애노테이션을 사용하는 것이다.
package com.example.shop.service;
import org.springframework.stereotype.Service;
@Service
public class PriceService {
public int discountedPrice(int price) {
return price >= 50000 ? price - 5000 : price;
}
}
@Service는 @Component 계열 애노테이션이다. @Controller, @Repository, @Configuration 등도 역할에 맞게 사용한다. 애플리케이션 시작 시 컴포넌트 스캔이 대상 클래스를 찾아 빈으로 등록한다.
package com.example.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@SpringBootApplication이 위치한 com.example.shop 패키지 아래의 컴포넌트들을 기본적으로 탐색한다. 다른 패키지에 있는 서비스가 등록되지 않는다면 스캔 범위를 먼저 확인해야 한다.
@Configuration과 @Bean외부 라이브러리 객체처럼 클래스에 직접 @Component를 붙이기 어려울 때는 설정 클래스를 사용할 수 있다.
package com.example.shop.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TimeConfig {
@Bean
public Clock systemClock() {
return Clock.systemUTC();
}
}
여기서 스프링 컨테이너가 관리하는 빈은 systemClock 메서드가 반환한 Clock 객체다. 메서드 이름은 기본 빈 이름으로 사용된다.
다른 빈에서 시간 정보가 필요하다면 생성자로 주입받을 수 있다.
package com.example.shop.service;
import java.time.Clock;
import java.time.Instant;
import org.springframework.stereotype.Service;
@Service
public class OrderTimeService {
private final Clock clock;
public OrderTimeService(Clock clock) {
this.clock = clock;
}
public Instant now() {
return Instant.now(clock);
}
}
이렇게 하면 운영 환경에서는 실제 시계를, 테스트 환경에서는 고정된 시계를 전달해 시간에 의존적인 기능을 검증하기 좋다.
ApplicationContext에서 빈을 직접 꺼낼 수도 있다import org.springframework.context.ApplicationContext;
// 스프링 관리 객체 안에서 주입받았다고 가정
private final ApplicationContext context;
public void inspect() {
PriceService service = context.getBean(PriceService.class);
System.out.println(service.discountedPrice(60000));
}
출력값은 55000이다. 그러나 일반적인 업무 코드에서는 getBean()을 곳곳에서 호출하기보다 필요한 의존성을 생성자로 받는 편이 좋다. 의존성이 코드에 분명하게 드러나고 테스트하기도 쉬워진다.
기본 스프링 빈 스코프는 singleton이다. 이는 일반적으로 컨테이너 하나에 빈 정의당 하나의 객체 인스턴스를 사용한다는 뜻이다. JVM 전체에서 무조건 단 하나의 인스턴스라는 의미는 아니다.
여러 HTTP 요청이 동일한 서비스 빈을 동시에 사용할 수 있으므로, 다음과 같은 코드는 조심해야 한다.
@Service
public class UnsafeOrderCounter {
private int totalOrders = 0;
public void ordered() {
totalOrders++; // 동시에 실행되면 증가 횟수가 유실될 수 있음
}
}
totalOrders++는 스레드 안전한 단일 연산이 아니다. 서버의 싱글톤 서비스에서는 가급적 요청별로 달라지는 값을 필드에 보관하지 않고, 메서드 내부의 지역 변수나 데이터베이스, 동시성에 맞는 저장소를 활용해야 한다.
prototype, request, session 같은 다른 스코프도 있지만, 스코프를 바꾸는 것보다 먼저 객체가 어떤 상태를 보관해야 하는지 설계하는 편이 중요하다.
대표적인 오류는 다음과 같다.
No qualifying bean of type 'com.example.shop.DiscountPolicy' available
진단 순서는 다음과 같다.
@Component 계열 애노테이션이나 @Bean 등록이 있는가?구현체가 여러 개라면 @Primary나 @Qualifier로 선택 기준을 제공할 수 있다. 무조건 애노테이션을 더 붙이기 전에 컨테이너에 어떤 빈이 등록됐는지를 살펴보는 게 빠르다.
ApplicationContext는 빈 관리뿐 아니라 다양한 인프라 기능을 제공한다.@Service 등 컴포넌트 스캔 또는 @Bean으로 빈을 등록할 수 있다.getBean() 직접 조회보다 생성자 주입이 명확하다.