Spring IoC와 DI 완벽 가이드: 제어의 역전과 의존성 주입의 모든 것
제어의 역전(IoC)이란 무엇인가
제어의 역전(Inversion of Control, IoC)은 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하도록 하는 프로그래밍 원칙이다. 전통적인 프로그래밍에서는 개발자가 작성한 코드가 직접 외부 라이브러리의 코드를 호출하지만, IoC가 적용된 구조에서는 외부 라이브러리(프레임워크)의 코드가 개발자의 코드를 호출한다. 이것이 바로 “제어의 역전”이라는 이름이 붙은 이유다.
일상적인 예를 들어보면, 전통적인 방식은 우리가 직접 재료를 사서 요리를 하는 것과 같다. 어떤 재료를 사용할지, 어떤 순서로 조리할지 모두 우리가 결정한다. 반면 IoC가 적용된 방식은 레스토랑에서 식사하는 것과 같다. 우리는 메뉴만 선택하고, 실제 요리 과정은 셰프에게 맡긴다. 어떤 재료를 쓸지, 어떤 순서로 조리할지는 셰프가 결정한다.
프로그래밍에서 IoC의 가장 큰 장점은 결합도(coupling)를 낮춘다는 것이다. 컴포넌트들이 서로 직접 의존하지 않고 추상화에 의존하게 되면, 각 컴포넌트를 독립적으로 개발하고 테스트할 수 있다. 또한 컴포넌트를 교체하거나 업그레이드할 때 다른 부분에 미치는 영향을 최소화할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// IoC가 적용되지 않은 코드
public class OrderService {
// 직접 구체 클래스에 의존
private MySQLOrderRepository repository = new MySQLOrderRepository();
public void createOrder(Order order) {
repository.save(order);
}
}
// IoC가 적용된 코드
public class OrderService {
// 인터페이스에 의존, 구현체는 외부에서 주입
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(Order order) {
repository.save(order);
}
}
첫 번째 예제에서 OrderService는 MySQLOrderRepository라는 구체적인 구현에 직접 의존한다. 만약 데이터베이스를 PostgreSQL로 변경하거나 테스트를 위해 메모리 기반 저장소를 사용하고 싶다면, OrderService의 코드를 수정해야 한다. 두 번째 예제에서는 OrderService가 OrderRepository 인터페이스에만 의존하고, 실제 구현체는 생성자를 통해 외부에서 주입받는다. 이렇게 하면 OrderService의 코드를 변경하지 않고도 다양한 구현체를 사용할 수 있다.
의존성 주입(DI)의 개념
의존성 주입(Dependency Injection, DI)은 IoC를 구현하는 디자인 패턴 중 하나다. 객체가 필요로 하는 의존성을 직접 생성하는 것이 아니라, 외부에서 생성하여 주입해주는 방식이다. Spring Framework는 DI를 핵심 기능으로 제공하며, 이를 통해 느슨한 결합(loose coupling)을 달성한다.
DI가 없는 코드와 있는 코드를 비교해보면 그 차이가 명확하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// DI가 없는 코드: 의존성을 직접 생성
public class PaymentService {
private CreditCardProcessor processor;
private TransactionLogger logger;
public PaymentService() {
// 구체적인 구현체를 직접 생성
this.processor = new StripeCreditCardProcessor();
this.logger = new DatabaseTransactionLogger();
}
public void processPayment(Payment payment) {
processor.process(payment);
logger.log(payment);
}
}
// DI가 적용된 코드: 의존성을 외부에서 주입
public class PaymentService {
private final CreditCardProcessor processor;
private final TransactionLogger logger;
// 생성자를 통해 의존성 주입
public PaymentService(CreditCardProcessor processor, TransactionLogger logger) {
this.processor = processor;
this.logger = logger;
}
public void processPayment(Payment payment) {
processor.process(payment);
logger.log(payment);
}
}
DI가 적용된 코드는 여러 가지 이점을 제공한다. 첫째, 테스트가 용이해진다. 테스트 시 목(mock) 객체를 쉽게 주입할 수 있어 단위 테스트를 작성하기가 훨씬 쉬워진다. 둘째, 코드의 재사용성이 높아진다. 같은 클래스를 다른 구현체와 함께 사용할 수 있다. 셋째, 유지보수가 쉬워진다. 구현체를 변경할 때 해당 클래스의 코드를 수정할 필요가 없다.
Spring IoC 컨테이너
Spring Framework의 핵심은 IoC 컨테이너다. IoC 컨테이너는 객체의 생성, 구성, 생명주기 관리를 담당한다. 개발자가 설정 정보(XML, 어노테이션, Java Config)를 제공하면, 컨테이너가 이를 기반으로 객체들을 생성하고 의존성을 주입한다.
Spring IoC 컨테이너는 두 가지 주요 인터페이스로 구현된다. BeanFactory는 가장 기본적인 IoC 컨테이너 인터페이스로, 빈의 생성과 의존성 주입을 담당한다. ApplicationContext는 BeanFactory를 상속하면서 국제화 지원, 이벤트 발행, 리소스 로딩 등의 추가 기능을 제공한다. 실제 애플리케이션에서는 거의 항상 ApplicationContext를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
// BeanFactory 사용 (잘 사용하지 않음)
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("beans.xml"));
MyBean bean = factory.getBean("myBean", MyBean.class);
// ApplicationContext 사용 (권장)
ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml");
MyBean bean = context.getBean("myBean", MyBean.class);
// Java Config 기반 ApplicationContext
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MyBean bean = context.getBean(MyBean.class);
ApplicationContext는 BeanFactory에 비해 다음과 같은 추가 기능을 제공한다. 메시지 소스를 통한 국제화(i18n) 지원, 애플리케이션 이벤트를 발행하고 리스닝하는 기능, 웹 애플리케이션에서 사용할 수 있는 WebApplicationContext, 빈의 지연 로딩이 아닌 사전 로딩(pre-loading)을 통한 빠른 실패(fail-fast) 지원.
빈(Bean)과 빈 정의
Spring에서 빈(Bean)은 IoC 컨테이너가 관리하는 객체를 말한다. 빈은 애플리케이션의 핵심 구성요소로, 컨테이너에 의해 생성되고 조립되며 관리된다. 빈을 정의하는 방법은 크게 세 가지가 있다.
XML 기반 설정
전통적인 방식으로, XML 파일에 빈 정의를 작성한다. 요즘은 잘 사용하지 않지만, 레거시 프로젝트에서 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 빈 정의 -->
<bean id="orderRepository" class="com.example.MySQLOrderRepository"/>
<!-- 생성자 주입 -->
<bean id="orderService" class="com.example.OrderService">
<constructor-arg ref="orderRepository"/>
</bean>
<!-- 세터 주입 -->
<bean id="paymentService" class="com.example.PaymentService">
<property name="processor" ref="creditCardProcessor"/>
<property name="logger" ref="transactionLogger"/>
</bean>
</beans>
어노테이션 기반 설정
클래스에 어노테이션을 붙여 빈으로 등록하는 방식이다. 컴포넌트 스캔(Component Scan)과 함께 사용된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 컴포넌트 스캔 대상 어노테이션
@Component // 일반 컴포넌트
@Service // 서비스 계층
@Repository // 데이터 접근 계층
@Controller // 웹 컨트롤러
// 예시
@Repository
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// 구현
}
}
@Service
public class OrderService {
private final OrderRepository repository;
@Autowired // 의존성 자동 주입
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(Order order) {
repository.save(order);
}
}
Java Config 기반 설정
자바 클래스에서 @Configuration과 @Bean 어노테이션을 사용하여 빈을 정의한다. 현재 가장 권장되는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class AppConfig {
@Bean
public OrderRepository orderRepository() {
return new MySQLOrderRepository();
}
@Bean
public OrderService orderService() {
// 메서드 호출을 통해 의존성 주입
return new OrderService(orderRepository());
}
@Bean
public PaymentService paymentService(CreditCardProcessor processor,
TransactionLogger logger) {
// 파라미터를 통해 의존성 주입
return new PaymentService(processor, logger);
}
}
의존성 주입 방법
Spring에서 의존성을 주입하는 방법은 세 가지가 있다. 각 방법에는 장단점이 있으며, 상황에 따라 적절한 방법을 선택해야 한다.
생성자 주입 (Constructor Injection)
생성자를 통해 의존성을 주입받는 방식이다. Spring 공식 문서에서 권장하는 방법이며, 대부분의 상황에서 가장 좋은 선택이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// Spring 4.3부터 단일 생성자는 @Autowired 생략 가능
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public void registerUser(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user.getEmail());
}
}
생성자 주입의 장점은 다음과 같다. 첫째, 불변성(immutability)을 보장할 수 있다. final 키워드를 사용할 수 있어 한번 주입된 의존성이 변경되지 않음을 보장한다. 둘째, 필수 의존성을 명확하게 표현한다. 생성자 파라미터로 선언된 의존성은 반드시 제공되어야 객체를 생성할 수 있다. 셋째, 테스트가 용이하다. 생성자를 통해 목 객체를 쉽게 주입할 수 있다. 넷째, 순환 의존성을 컴파일 타임에 감지할 수 있다. A가 B를 필요로 하고 B가 A를 필요로 하면 객체 생성이 불가능하다.
세터 주입 (Setter Injection)
세터 메서드를 통해 의존성을 주입받는 방식이다. 선택적 의존성에 적합하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class NotificationService {
private EmailSender emailSender;
private SmsSender smsSender;
@Autowired
public void setEmailSender(EmailSender emailSender) {
this.emailSender = emailSender;
}
@Autowired(required = false) // 선택적 의존성
public void setSmsSender(SmsSender smsSender) {
this.smsSender = smsSender;
}
public void notify(User user, String message) {
if (emailSender != null) {
emailSender.send(user.getEmail(), message);
}
if (smsSender != null) {
smsSender.send(user.getPhone(), message);
}
}
}
세터 주입은 의존성이 선택적인 경우나, 런타임에 의존성을 변경해야 하는 경우에 사용한다. 하지만 객체가 불완전한 상태로 생성될 수 있다는 단점이 있다.
필드 주입 (Field Injection)
필드에 직접 @Autowired를 붙여 주입받는 방식이다. 코드가 간결하지만 여러 문제점이 있어 권장되지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ProductService {
@Autowired // 권장하지 않음
private ProductRepository productRepository;
@Autowired
private PriceCalculator priceCalculator;
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
}
필드 주입의 문제점은 다음과 같다. 첫째, 테스트하기 어렵다. 리플렉션 없이는 목 객체를 주입할 수 없다. 둘째, 의존성이 숨겨진다. 클래스의 public API만 봐서는 어떤 의존성이 필요한지 알 수 없다. 셋째, 불변성을 보장할 수 없다. final 키워드를 사용할 수 없다. 넷째, 단일 책임 원칙 위반을 감지하기 어렵다. 필드를 추가하기가 너무 쉬워서 클래스가 비대해지기 쉽다.
@Autowired의 동작 원리
@Autowired 어노테이션은 Spring이 의존성을 자동으로 주입하도록 지시한다. Spring은 타입을 기반으로 적절한 빈을 찾아 주입한다.
1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ShoppingCartService {
private final ProductRepository productRepository;
private final PricingService pricingService;
@Autowired
public ShoppingCartService(ProductRepository productRepository,
PricingService pricingService) {
this.productRepository = productRepository;
this.pricingService = pricingService;
}
}
Spring은 다음 순서로 주입할 빈을 결정한다. 먼저 타입으로 매칭되는 빈을 찾는다. 매칭되는 빈이 하나면 그 빈을 주입한다. 매칭되는 빈이 여러 개면 필드명이나 파라미터명으로 빈 이름을 매칭한다. 그래도 결정할 수 없으면 @Qualifier나 @Primary 어노테이션을 확인한다. 최종적으로 결정할 수 없으면 예외가 발생한다.
@Qualifier를 사용한 빈 선택
동일한 타입의 빈이 여러 개 있을 때 @Qualifier로 특정 빈을 지정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public interface PaymentGateway {
void process(Payment payment);
}
@Component("stripeGateway")
public class StripePaymentGateway implements PaymentGateway {
@Override
public void process(Payment payment) {
// Stripe 결제 처리
}
}
@Component("paypalGateway")
public class PayPalPaymentGateway implements PaymentGateway {
@Override
public void process(Payment payment) {
// PayPal 결제 처리
}
}
@Service
public class PaymentService {
private final PaymentGateway gateway;
@Autowired
public PaymentService(@Qualifier("stripeGateway") PaymentGateway gateway) {
this.gateway = gateway;
}
}
@Primary를 사용한 기본 빈 지정
여러 빈 중 기본으로 사용할 빈을 지정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
@Primary // 기본 빈으로 지정
public class StripePaymentGateway implements PaymentGateway {
@Override
public void process(Payment payment) {
// Stripe 결제 처리
}
}
@Component
public class PayPalPaymentGateway implements PaymentGateway {
@Override
public void process(Payment payment) {
// PayPal 결제 처리
}
}
@Service
public class PaymentService {
private final PaymentGateway gateway;
// @Qualifier 없이도 StripePaymentGateway가 주입됨
@Autowired
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
빈 스코프(Bean Scope)
빈 스코프는 빈의 생명주기와 가시성을 정의한다. Spring은 여러 종류의 스코프를 제공한다.
Singleton 스코프 (기본값)
컨테이너당 하나의 인스턴스만 생성된다. 모든 요청에서 동일한 인스턴스가 반환된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Scope("singleton") // 기본값이므로 생략 가능
public class SingletonBean {
private int counter = 0;
public int increment() {
return ++counter;
}
}
// 사용
@Autowired
private SingletonBean bean1;
@Autowired
private SingletonBean bean2;
// bean1과 bean2는 동일한 인스턴스
// bean1.increment() -> 1
// bean2.increment() -> 2 (같은 인스턴스이므로)
Prototype 스코프
요청할 때마다 새로운 인스턴스가 생성된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Scope("prototype")
public class PrototypeBean {
private int counter = 0;
public int increment() {
return ++counter;
}
}
// 사용
@Autowired
private PrototypeBean bean1;
@Autowired
private PrototypeBean bean2;
// bean1과 bean2는 서로 다른 인스턴스
// bean1.increment() -> 1
// bean2.increment() -> 1 (다른 인스턴스이므로)
웹 애플리케이션 스코프
웹 애플리케이션에서만 사용할 수 있는 스코프들이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestScopedBean {
// HTTP 요청당 하나의 인스턴스
}
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionScopedBean {
// HTTP 세션당 하나의 인스턴스
}
@Component
@Scope(value = WebApplicationContext.SCOPE_APPLICATION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ApplicationScopedBean {
// ServletContext당 하나의 인스턴스
}
빈 생명주기(Bean Lifecycle)
Spring은 빈의 생명주기를 세밀하게 관리한다. 빈이 생성되고 소멸되는 과정에서 다양한 콜백을 활용할 수 있다.
생명주기 콜백
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Component
public class MyBean implements InitializingBean, DisposableBean {
// 1. 생성자 호출
public MyBean() {
System.out.println("1. 생성자 호출");
}
// 2. 의존성 주입 (@Autowired 등)
// 3. @PostConstruct
@PostConstruct
public void postConstruct() {
System.out.println("3. @PostConstruct");
}
// 4. InitializingBean.afterPropertiesSet()
@Override
public void afterPropertiesSet() {
System.out.println("4. afterPropertiesSet");
}
// 5. @Bean(initMethod = "init")
public void init() {
System.out.println("5. init-method");
}
// 빈 사용
// 6. @PreDestroy
@PreDestroy
public void preDestroy() {
System.out.println("6. @PreDestroy");
}
// 7. DisposableBean.destroy()
@Override
public void destroy() {
System.out.println("7. DisposableBean.destroy");
}
// 8. @Bean(destroyMethod = "cleanup")
public void cleanup() {
System.out.println("8. destroy-method");
}
}
권장되는 방식은 @PostConstruct와 @PreDestroy 어노테이션을 사용하는 것이다. 이 방식은 Spring에 종속되지 않고 JSR-250 표준을 따른다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class ResourceManager {
private Connection connection;
@PostConstruct
public void init() {
// 빈 초기화 후 리소스 할당
this.connection = createConnection();
System.out.println("Connection established");
}
@PreDestroy
public void cleanup() {
// 빈 소멸 전 리소스 해제
if (connection != null) {
connection.close();
}
System.out.println("Connection closed");
}
}
프로파일(Profile)
Spring 프로파일을 사용하면 환경별로 다른 빈 설정을 적용할 수 있다. 개발, 테스트, 운영 환경에서 서로 다른 데이터소스나 서비스를 사용해야 할 때 유용하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 개발 환경용 설정
@Configuration
@Profile("dev")
public class DevConfig {
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("schema.sql")
.build();
}
}
// 운영 환경용 설정
@Configuration
@Profile("prod")
public class ProdConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://prod-server:3306/mydb");
config.setUsername("prod_user");
config.setPassword("prod_password");
return new HikariDataSource(config);
}
}
프로파일 활성화는 여러 방법으로 할 수 있다.
1
2
3
4
5
6
7
8
# application.properties
spring.profiles.active=dev
# 또는 JVM 옵션
-Dspring.profiles.active=prod
# 또는 환경 변수
export SPRING_PROFILES_ACTIVE=prod
실전 활용 패턴
인터페이스 기반 설계
DI의 이점을 최대화하려면 인터페이스 기반 설계를 사용해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 인터페이스 정의
public interface NotificationSender {
void send(String recipient, String message);
}
// 이메일 구현
@Component
@Profile("!sms")
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String recipient, String message) {
// 이메일 발송 로직
}
}
// SMS 구현
@Component
@Profile("sms")
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String recipient, String message) {
// SMS 발송 로직
}
}
// 서비스에서 사용
@Service
public class AlertService {
private final NotificationSender sender;
public AlertService(NotificationSender sender) {
this.sender = sender;
}
public void alert(String recipient, String message) {
sender.send(recipient, message);
}
}
이벤트 기반 아키텍처
Spring의 이벤트 시스템을 활용하면 느슨한 결합을 더욱 강화할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 이벤트 정의
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
public Order getOrder() {
return order;
}
}
// 이벤트 발행
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void createOrder(Order order) {
// 주문 생성 로직
saveOrder(order);
// 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(this, order));
}
}
// 이벤트 리스너
@Component
public class OrderNotificationListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
Order order = event.getOrder();
// 알림 발송 로직
System.out.println("Order created: " + order.getId());
}
}
@Component
public class InventoryUpdateListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
Order order = event.getOrder();
// 재고 업데이트 로직
System.out.println("Updating inventory for order: " + order.getId());
}
}
테스트에서의 DI 활용
DI의 가장 큰 장점 중 하나는 테스트 용이성이다. 목 객체를 쉽게 주입하여 단위 테스트를 작성할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 테스트 대상 서비스
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public void registerUser(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user.getEmail());
}
}
// 단위 테스트
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void registerUser_shouldSaveUserAndSendEmail() {
// given
User user = new User("test@example.com", "Test User");
// when
userService.registerUser(user);
// then
verify(userRepository).save(user);
verify(emailService).sendWelcomeEmail("test@example.com");
}
}
면접 대비 핵심 정리
Spring IoC/DI 관련 면접 질문에서 자주 나오는 주제들을 정리하면 다음과 같다.
IoC와 DI의 차이점은 IoC가 제어 흐름을 역전시키는 원칙이고, DI는 IoC를 구현하는 구체적인 패턴이라는 것이다. DI 외에도 서비스 로케이터 패턴 등으로 IoC를 구현할 수 있다.
생성자 주입이 권장되는 이유는 불변성 보장, 필수 의존성 명시, 테스트 용이성, 순환 의존성 감지 등의 장점이 있기 때문이다.
@Autowired와 @Inject의 차이점은 @Autowired는 Spring 전용이고 required 속성을 제공하며, @Inject는 JSR-330 표준이고 required 속성이 없다는 것이다.
빈 스코프의 종류는 singleton(기본값), prototype, request, session, application 등이 있다.
@Component와 @Bean의 차이점은 @Component는 클래스 레벨에 붙여 자동 스캔되고, @Bean은 메서드 레벨에 붙여 @Configuration 클래스 내에서 명시적으로 빈을 정의한다는 것이다.
순환 의존성 문제를 해결하는 방법은 설계를 재검토하는 것이 가장 좋고, 필요하다면 세터 주입이나 @Lazy 어노테이션을 사용할 수 있다.
Spring IoC와 DI를 깊이 이해하면 더 유연하고 테스트하기 쉬운 애플리케이션을 설계할 수 있다. 핵심은 “높은 응집도, 낮은 결합도”라는 객체지향 원칙을 실현하는 것이다.