스프링 프레임워크 강의 시리즈
3강: 스프링 IoC 컨테이너와 의존성 주입(DI)
소개
스프링 프레임워크의 가장 중요한 핵심 개념 중 두 가지는 IoC(제어의 역전)와 DI(의존성 주입)입니다. 이 두 개념은 스프링이 어떻게 객체를 관리하고 애플리케이션 구성 요소 간의 의존 관계를 처리하는지를 이해하는 데 필수적입니다. 이번 강의에서는 이 개념들을 상세히 살펴보고 실제 코드로 구현해 보겠습니다.
학습 목표
- IoC(Inversion of Control)의 개념과 장점 이해하기
- DI(Dependency Injection)의 개념과 다양한 구현 방법 학습하기
- 스프링 빈(Bean)의 개념과 생명주기 파악하기
- 실제 코드로 다양한 의존성 주입 방법 구현해보기
IoC(Inversion of Control) 컨테이너
IoC란 무엇인가?
IoC(Inversion of Control, 제어의 역전)는 프로그램의 제어 흐름을 개발자가 아닌 프레임워크가 관리하는 디자인 패턴입니다. 전통적인 프로그래밍에서는 개발자가 직접 필요한 객체를 생성하고 메서드를 호출했지만, IoC 패턴에서는 이러한 제어권이 프레임워크로 넘어갑니다.
기존 방식
개발자가 필요한 객체를 직접 생성하고 관리
public class UserService {
private UserRepository userRepository;
public UserService() {
// 직접 의존 객체 생성
this.userRepository = new UserRepositoryImpl();
}
}
IoC 방식
프레임워크가 객체를 생성하고 필요한 곳에 주입
public class UserService {
private UserRepository userRepository;
// 프레임워크가 객체를 생성하여 주입
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
IoC 컨테이너의 역할
스프링의 IoC 컨테이너는 애플리케이션 컴포넌트의 중앙 저장소입니다. 주요 역할은 다음과 같습니다:
- 객체(빈) 생성 및 관리
- 의존성 주입
- 빈의 생명주기 관리
- 빈 간의 와이어링(연결)
스프링 프레임워크에서 IoC 컨테이너는 주로 두 가지 인터페이스로 구현됩니다:
- BeanFactory: 기본적인 IoC 컨테이너 기능 제공
- ApplicationContext: BeanFactory를 확장하여 엔터프라이즈 기능 추가 (이벤트 발행, 국제화 지원 등)
참고: 실제 개발에서는 주로 ApplicationContext를 사용합니다. ApplicationContext는 BeanFactory의 모든 기능을 포함하면서도 추가적인 기능을 제공하기 때문입니다.
의존성 주입(Dependency Injection)
의존성 주입(DI)은 IoC를 구현하는 방법 중 하나로, 객체가 필요로 하는 의존 객체를 직접 생성하는 대신 외부(IoC 컨테이너)에서 주입받는 패턴입니다.
DI의 장점
- 낮은 결합도: 컴포넌트 간의 결합도가 낮아져 유지보수가 용이해집니다.
- 테스트 용이성: 의존성을 쉽게 모의(mock) 객체로 대체할 수 있어 단위 테스트가 용이합니다.
- 코드 재사용성: 같은 인터페이스를 구현한 다른 구현체로 쉽게 교체할 수 있습니다.
- 병렬 개발: 인터페이스가 정의되면 여러 개발자가 독립적으로 개발할 수 있습니다.
DI 예시
// 인터페이스 정의
public interface MessageService {
String getMessage();
}
// 구현체
public class EmailService implements MessageService {
@Override
public String getMessage() {
return "이메일 메시지";
}
}
// 의존성 주입을 사용하는 클래스
public class NotificationService {
private final MessageService messageService;
// 생성자를 통한 의존성 주입
public NotificationService(MessageService messageService) {
this.messageService = messageService;
}
public void sendNotification() {
String message = messageService.getMessage();
System.out.println("알림 전송: " + message);
}
}
빈(Bean)의 개념
스프링에서 ‘빈(Bean)’은 스프링 IoC 컨테이너가 관리하는 객체를 의미합니다. 스프링은 이 빈들의 생성, 설정, 관리를 담당합니다.
빈 정의 방법
스프링에서 빈을 정의하는 주요 방법은 다음과 같습니다:
XML 기반 설정
<!-- applicationContext.xml -->
<beans>
<bean id="messageService"
class="com.example.EmailService" />
<bean id="notificationService"
class="com.example.NotificationService">
<constructor-arg ref="messageService" />
</bean>
</beans>
Java 기반 설정
@Configuration
public class AppConfig {
@Bean
public MessageService messageService() {
return new EmailService();
}
@Bean
public NotificationService notificationService() {
return new NotificationService(messageService());
}
}
자동 빈 등록 (컴포넌트 스캔)
스프링은 클래스에 특정 어노테이션을 붙여 자동으로 빈으로 등록할 수 있습니다:
- @Component: 일반적인 스프링 관리 컴포넌트
- @Service: 비즈니스 로직을 처리하는 서비스 계층 컴포넌트
- @Repository: 데이터 접근 계층 컴포넌트
- @Controller: 웹 요청을 처리하는 컨트롤러 컴포넌트
- @RestController: RESTful 웹 서비스를 제공하는 컨트롤러
컴포넌트 스캔 예시
// 스프링이 자동으로 빈으로 등록
@Service
public class EmailService implements MessageService {
@Override
public String getMessage() {
return "이메일 메시지";
}
}
// 자동 의존성 주입
@Service
public class NotificationService {
private final MessageService messageService;
// 생성자에 @Autowired가 생략됨 (단일 생성자인 경우)
public NotificationService(MessageService messageService) {
this.messageService = messageService;
}
// 비즈니스 로직
public void sendNotification() {
// ...
}
}
빈의 생명주기
스프링 빈은 다음과 같은 생명주기를 가집니다:
- 빈 인스턴스화
- 의존성 주입
- 빈 이름 인식 처리 (BeanNameAware)
- 빈 팩토리 인식 처리 (BeanFactoryAware)
- 애플리케이션 컨텍스트 인식 처리 (ApplicationContextAware)
- 초기화 전 처리 (BeanPostProcessor.before)
- 초기화 메서드 호출 (@PostConstruct, InitializingBean)
- 초기화 후 처리 (BeanPostProcessor.after)
- 빈 사용
- 소멸 메서드 호출 (@PreDestroy, DisposableBean)
- 빈 소멸
참고: 빈의 생명주기 콜백 메서드를 통해 초기화 및 정리 작업을 수행할 수 있습니다:
@Component
public class DatabaseService {
private Connection connection;
@PostConstruct
public void initialize() {
// 데이터베이스 연결 초기화
System.out.println("데이터베이스 연결 초기화");
// connection = DriverManager.getConnection(...);
}
@PreDestroy
public void cleanup() {
// 데이터베이스 연결 종료
System.out.println("데이터베이스 연결 종료");
// if (connection != null) connection.close();
}
}
의존성 주입 방법
스프링에서는 주로 세 가지 방법으로 의존성을 주입할 수 있습니다:
1. 생성자 주입 (Constructor Injection)
생성자를 통해 의존성을 주입받는 방법입니다. 스프링 4.3부터는 단일 생성자의 경우 @Autowired 어노테이션이 생략 가능합니다.
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// 생성자 주입
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
}
장점:
- 필수 의존성을 명확하게 표현할 수 있습니다.
- 불변성(immutability)을 보장합니다.
- 순환 참조를 컴파일 시점에 감지할 수 있습니다.
- 테스트가 용이합니다.
2. 세터 주입 (Setter Injection)
setter 메서드를 통해 의존성을 주입받는 방법입니다.
@Service
public class UserService {
private UserRepository userRepository;
private EmailService emailService;
// setter 주입
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Autowired
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
}
장점:
- 선택적 의존성을 주입할 수 있습니다.
- 순환 참조에 대한 해결책을 제공합니다.
- 의존성을 나중에 변경할 수 있습니다(그러나 이는 단점이 될 수도 있음).
3. 필드 주입 (Field Injection)
필드에 직접 @Autowired 어노테이션을 붙여 의존성을 주입받는 방법입니다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
// 메서드...
}
주의: 필드 주입은 다음과 같은 이유로 권장되지 않습니다:
- 테스트가 어렵습니다.
- 불변성을 보장할 수 없습니다.
- 순환 참조를 감지하기 어렵습니다.
- 의존성이 숨겨져 있어 클래스의 책임을 이해하기 어렵습니다.
스프링 팀의 권장사항
스프링 팀은 일반적으로 생성자 주입을 권장합니다. 그 이유는:
- 필수 의존성을 명확하게 표현할 수 있습니다.
- final 필드를 사용하여 불변성을 보장할 수 있습니다.
- 순환 참조를 조기에 발견할 수 있습니다.
- DI 컨테이너에 덜 의존적인 코드를 작성할 수 있습니다.
실습 예제
이제 앞서 배운 개념들을 실제 코드로 구현해보겠습니다. 간단한 사용자 관리 시스템을 예로 들어 다양한 의존성 주입 방법을 적용해 보겠습니다.
1. 프로젝트 구조
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ ├── Application.java
│ │ ├── config/
│ │ │ └── AppConfig.java
│ │ ├── model/
│ │ │ └── User.java
│ │ ├── repository/
│ │ │ ├── UserRepository.java
│ │ │ └── UserRepositoryImpl.java
│ │ └── service/
│ │ ├── EmailService.java
│ │ ├── EmailServiceImpl.java
│ │ ├── UserService.java
│ │ └── UserServiceImpl.java
│ └── resources/
│ └── application.properties
└── test/
└── java/
└── com/
└── example/
└── service/
└── UserServiceTest.java
2. 도메인 모델
package com.example.model;
public class User {
private Long id;
private String username;
private String email;
// 생성자
public User() {}
public User(Long id, String username, String email) {
this.id = id;
this.username = username;
this.email = email;
}
// Getter 및 Setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "', email='" + email + "'}";
}
}
3. 리포지토리 계층
package com.example.repository;
import com.example.model.User;
import java.util.List;
import java.util.Optional;
public interface UserRepository {
User save(User user);
Optional findById(Long id);
List findAll();
void deleteById(Long id);
}
package com.example.repository;
import com.example.model.User;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class UserRepositoryImpl implements UserRepository {
private final Map users = new HashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(idGenerator.getAndIncrement());
}
users.put(user.getId(), user);
return user;
}
@Override
public Optional findById(Long id) {
return Optional.ofNullable(users.get(id));
}
@Override
public List findAll() {
return new ArrayList<>(users.values());
}
@Override
public void deleteById(Long id) {
users.remove(id);
}
}
4. 서비스 계층
package com.example.service;
public interface EmailService {
void sendWelcomeEmail(String to);
void sendPasswordResetEmail(String to);
}
package com.example.service;
import org.springframework.stereotype.Service;
@Service
public class EmailServiceImpl implements EmailService {
@Override
public void sendWelcomeEmail(String to) {
// 실제로는 이메일 발송 로직이 들어갑니다.
System.out.println("환영 이메일을 " + to + "로 발송했습니다.");
}
@Override
public void sendPasswordResetEmail(String to) {
// 실제로는 이메일 발송 로직이 들어갑니다.
System.out.println("비밀번호 재설정 이메일을 " + to + "로 발송했습니다.");
}
}
package com.example.service;
import com.example.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService {
User registerUser(User user);
Optional getUserById(Long id);
List getAllUsers();
void deleteUser(Long id);
void resetPassword(Long id);
}
package com.example.service;
import com.example.model.User;
import com.example.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// 생성자 주입
@Autowired
public UserServiceImpl(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
@Override
public User registerUser(User user) {
User savedUser = userRepository.save(user);
emailService.sendWelcomeEmail(user.getEmail());
return savedUser;
}
@Override
public Optional getUserById(Long id) {
return userRepository.findById(id);
}
@Override
public List getAllUsers() {
return userRepository.findAll();
}
@Override
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
@Override
public void resetPassword(Long id) {
userRepository.findById(id).ifPresent(user -> {
// 비밀번호 재설정 로직 (실제로는 랜덤 비밀번호 생성 등의 로직이 들어갑니다)
emailService.sendPasswordResetEmail(user.getEmail());
});
}
}
5. 애플리케이션 설정
package com.example.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("com.example")
public class AppConfig {
// 필요한 경우 여기에 @Bean 메서드를 추가할 수 있습니다.
}
package com.example;
import com.example.model.User;
import com.example.service.UserService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import com.example.config.AppConfig;
public class Application {
public static void main(String[] args) {
// 스프링 IoC 컨테이너 생성
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class);
// UserService 빈 가져오기
UserService userService = context.getBean(UserService.class);
// 사용자 등록
User user1 = new User(null, "user1", "user1@example.com");
User user2 = new User(null, "user2", "user2@example.com");
userService.registerUser(user1);
userService.registerUser(user2);
// 모든 사용자 조회
System.out.println("=== 모든 사용자 ===");
userService.getAllUsers().forEach(System.out::println);
// 비밀번호 재설정
System.out.println("=== 비밀번호 재설정 ===");
userService.resetPassword(1L);
// 컨텍스트 종료
context.close();
}
}
6. 테스트 코드
package com.example.service;
import com.example.model.User;
import com.example.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
private UserService userService;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
userService = new UserServiceImpl(userRepository, emailService);
}
@Test
public void testRegisterUser() {
// 준비
User user = new User(null, "testUser", "test@example.com");
User savedUser = new User(1L, "testUser", "test@example.com");
when(userRepository.save(user)).thenReturn(savedUser);
// 실행
User result = userService.registerUser(user);
// 검증
assertEquals(1L, result.getId());
assertEquals("testUser", result.getUsername());
assertEquals("test@example.com", result.getEmail());
verify(userRepository, times(1)).save(user);
verify(emailService, times(1)).sendWelcomeEmail("test@example.com");
}
@Test
public void testGetUserById() {
// 준비
Long userId = 1L;
User user = new User(userId, "testUser", "test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// 실행
Optional result = userService.getUserById(userId);
// 검증
assertTrue(result.isPresent());
assertEquals(userId, result.get().getId());
assertEquals("testUser", result.get().getUsername());
verify(userRepository, times(1)).findById(userId);
}
@Test
public void testGetAllUsers() {
// 준비
List users = Arrays.asList(
new User(1L, "user1", "user1@example.com"),
new User(2L, "user2", "user2@example.com")
);
when(userRepository.findAll()).thenReturn(users);
// 실행
List result = userService.getAllUsers();
// 검증
assertEquals(2, result.size());
assertEquals("user1", result.get(0).getUsername());
assertEquals("user2", result.get(1).getUsername());
verify(userRepository, times(1)).findAll();
}
}
7. 다양한 의존성 주입 방법 비교
위의 예제에서는 생성자 주입을 사용했습니다. 이제 다른 주입 방법을 살펴보겠습니다:
세터 주입 방식
@Service
public class UserServiceImpl implements UserService {
private UserRepository userRepository;
private EmailService emailService;
// 세터 주입
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Autowired
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
// 나머지 메서드는 동일
}
필드 주입 방식
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
// 나머지 메서드는 동일
}
위에서 살펴본 세 가지 주입 방법 중에서 스프링 팀은 생성자 주입을 권장합니다. 앞서 설명한 장점들 덕분에 생성자 주입이 가장 안전하고 유지보수하기 좋은 방법입니다.
요약
이번 강의에서 배운 내용
- IoC(제어의 역전): 프로그램의 제어 흐름을 개발자가 아닌 프레임워크가 관리하는 디자인 패턴
- DI(의존성 주입): IoC를 구현하는 방법으로, 객체가 필요로 하는 의존 객체를 외부에서 주입받는 패턴
- 스프링 빈: 스프링 IoC 컨테이너가 관리하는 객체
- 의존성 주입 방법: 생성자 주입, 세터 주입, 필드 주입
- 실제 코드 예제: 사용자 관리 시스템을 통한 의존성 주입 구현
다음 강의 미리보기
다음 강의에서는 스프링 AOP(Aspect-Oriented Programming)에 대해 알아보겠습니다. AOP는 로깅, 트랜잭션 관리, 보안 등의 공통 관심사를 모듈화하는 방법으로, 스프링의 또 다른 핵심 기능입니다.
실습 과제
이번 강의에서 배운 내용을 바탕으로 다음 과제를 수행해 보세요:
- 제공된 코드를 바탕으로 간단한 제품 관리 시스템(Product, ProductRepository, ProductService)을 구현하세요.
- 세 가지 의존성 주입 방법을 모두 사용해보고, 각각의 장단점을 비교해 보세요.
- 스프링 빈의 생명주기 콜백 메서드(@PostConstruct, @PreDestroy)를 사용하는 예제를 작성해 보세요.
답글 남기기