2023-12-20 14:12
최근 스프링 부트로 프로젝트를 진행하면서 의존성 주입(Dependency Injection, DI) 에 대해 깊이 있게 공부하게 됐다. 처음에는 단순히 @Autowired 어노테이션만 사용하면 되는 줄 알았는데, 실제로는 스프링의 핵심 개념 중 하나라는 걸 알게 됐다.
의존성 주입은 객체가 필요로 하는 의존성을 외부에서 주입받는 방식이다. 쉽게 말해서, 클래스가 사용하는 객체를 직접 생성하지 않고 외부에서 받아서 사용하는 것이다.
의존성(Dependency) 이란 한 객체가 다른 객체에 의존하는 관계를 의미한다. 예를 들어, UserService가 UserRepository를 사용한다면 UserService는 UserRepository에 의존한다고 할 수 있다.
기존 방식 (강한 결합):
public class UserService {
private UserRepository userRepository;
public UserService() {
this.userRepository = new UserRepository(); // 직접 생성
}
public User findUser(Long id) {
return userRepository.findById(id);
}
}DI 방식 (약한 결합):
@Service
public class UserService {
private final UserRepository userRepository;
// 생성자를 통한 의존성 주입
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findUser(Long id) {
return userRepository.findById(id);
}
}스프링에서 DI가 가능한 이유는 IoC 컨테이너(Inversion of Control Container) 때문이다. 기존에는 개발자가 직접 객체를 생성하고 관리했지만, 스프링에서는 컨테이너가 이 역할을 대신한다.
// 스프링 없이 직접 객체 관리
public class Main {
public static void main(String[] args) {
UserRepository userRepository = new UserRepository();
EmailService emailService = new EmailService();
UserService userService = new UserService(userRepository, emailService);
// 객체들의 생명주기를 직접 관리해야 함
}
}스프링을 사용하면 컨테이너가 자동으로 객체를 생성하고 의존성을 주입해준다. 이를 제어의 역전(IoC, Inversion of Control) 이라고 한다.
가장 권장되는 방식이다. 불변성 보장과 순환 의존성 방지가 가능하다.
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// 생성자 주입 (스프링 4.3부터 @Autowired 생략 가능)
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
}생성자 주입의 장점:
final 키워드 사용 가능간단하지만 테스트하기 어렵고 순환 의존성 문제가 발생할 수 있다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
// 리플렉션을 통해 주입되므로 테스트 시 Mock 주입이 어려움
}선택적 의존성에 사용하지만, 불변성 보장이 안 된다.
@Service
public class UserService {
private UserRepository userRepository;
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 선택적 의존성의 경우
@Autowired(required = false)
public void setOptionalService(OptionalService optionalService) {
this.optionalService = optionalService;
}
}@Component // 일반적인 컴포넌트
@Service // 비즈니스 로직 계층
@Repository // 데이터 접근 계층
@Controller // 웹 계층@Configuration
public class AppConfig {
@Bean
public UserRepository userRepository() {
return new UserRepository();
}
@Bean
public UserService userService(UserRepository userRepository) {
return new UserService(userRepository);
}
}@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/testdb");
dataSource.setUsername("user");
dataSource.setPassword("password");
return dataSource;
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper());
}
public void save(User user) {
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
jdbcTemplate.update(sql, user.getName(), user.getEmail());
}
}@Service
@Transactional
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());
}
public User getUserById(Long id) {
return userRepository.findById(id);
}
}@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
userService.registerUser(user);
return ResponseEntity.ok(user);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
}@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void testRegisterUser() {
// Given
User user = new User("김철수", "kim@example.com");
// When
userService.registerUser(user);
// Then
verify(userRepository).save(user);
verify(emailService).sendWelcomeEmail("kim@example.com");
}
}// 인터페이스를 통한 다형성
public interface EmailService {
void sendEmail(String to, String subject, String content);
void sendWelcomeEmail(String email);
}
@Service
@Profile("dev")
public class MockEmailService implements EmailService {
@Override
public void sendEmail(String to, String subject, String content) {
System.out.println("Mock Email sent to: " + to);
}
@Override
public void sendWelcomeEmail(String email) {
System.out.println("Mock Welcome email sent to: " + email);
}
}
@Service
@Profile("prod")
public class SmtpEmailService implements EmailService {
@Override
public void sendEmail(String to, String subject, String content) {
// 실제 SMTP 구현
}
@Override
public void sendWelcomeEmail(String email) {
sendEmail(email, "환영합니다!", "회원가입을 축하드립니다.");
}
}스프링이 객체의 생성과 소멸을 관리해주므로 개발자가 신경 쓸 필요가 없다.
@Component
public class DatabaseConnectionManager {
@PostConstruct
public void init() {
System.out.println("데이터베이스 연결 초기화");
}
@PreDestroy
public void cleanup() {
System.out.println("데이터베이스 연결 정리");
}
}스프링에서는 빈의 생명주기를 다양하게 관리할 수 있다.
@Service
@Scope("singleton") // 기본값: 애플리케이션당 하나의 인스턴스
public class SingletonService {
// 싱글톤으로 관리됨
}
@Service
@Scope("prototype") // 요청할 때마다 새로운 인스턴스 생성
public class PrototypeService {
// 매번 새로운 객체 생성
}
// 웹 환경에서만 사용 가능
@Service
@Scope("request") // HTTP 요청당 하나의 인스턴스
public class RequestScopeService {
// 요청별로 관리됨
}@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(ServiceB serviceB) {
this.serviceB = serviceB;
}
}
@Service
public class ServiceB {
private final ServiceA serviceA; // 순환 의존성!
public ServiceB(ServiceA serviceA) {
this.serviceA = serviceA;
}
}해결 방법:
// 1. @Lazy 어노테이션 사용
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) {
this.serviceB = serviceB;
}
}
// 2. 설계 재검토 (추천)
// - 공통 기능을 별도 서비스로 분리
// - 이벤트 방식으로 결합도 낮추기// 안티패턴: 너무 많은 의존성
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final SmsService smsService;
private final NotificationService notificationService;
private final LogService logService;
private final ValidationService validationService;
private final CacheService cacheService;
// ... 더 많은 의존성들
// 생성자가 너무 복잡해짐 -> 단일 책임 원칙 위반 신호
}개선 방법:
// 관련 서비스들을 그룹화
@Component
public class NotificationManager {
private final EmailService emailService;
private final SmsService smsService;
private final PushNotificationService pushService;
public NotificationManager(EmailService emailService,
SmsService smsService,
PushNotificationService pushService) {
this.emailService = emailService;
this.smsService = smsService;
this.pushService = pushService;
}
public void sendAllNotifications(String message, User user) {
emailService.sendEmail(user.getEmail(), "알림", message);
smsService.sendSms(user.getPhone(), message);
pushService.sendPush(user.getDeviceId(), message);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
private final NotificationManager notificationManager;
// 의존성이 줄어들고 응집도가 높아짐
public UserService(UserRepository userRepository,
NotificationManager notificationManager) {
this.userRepository = userRepository;
this.notificationManager = notificationManager;
}
}@Service
public class UserService {
private final UserRepository userRepository;
private final Optional<CacheService> cacheService;
public UserService(UserRepository userRepository,
Optional<CacheService> cacheService) {
this.userRepository = userRepository;
this.cacheService = cacheService;
}
public User findUser(Long id) {
// 캐시 서비스가 있으면 사용, 없으면 무시
return cacheService
.map(cache -> cache.getUser(id))
.orElseGet(() -> userRepository.findById(id));
}
}@Configuration
public class EmailConfig {
@Bean
@ConditionalOnProperty(name = "email.enabled", havingValue = "true")
public EmailService realEmailService() {
return new SmtpEmailService();
}
@Bean
@ConditionalOnMissingBean(EmailService.class)
public EmailService mockEmailService() {
return new MockEmailService();
}
}이론적으로는 모든 의존성을 인터페이스로 추상화하는 것이 좋다고 하지만, 실무에서는 상황에 따라 판단해야 한다.
// 굳이 인터페이스가 필요없는 경우
@Service
public class UserService {
private final UserRepository userRepository; // JPA Repository는 이미 인터페이스
private final PasswordEncoder passwordEncoder; // Spring Security의 표준 인터페이스
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
}
// 오버엔지니어링 예시
public interface UserService { // 구현체가 하나뿐인데 굳이?
User findUser(Long id);
}
@Service
public class UserServiceImpl implements UserService {
// 실제로는 구현체가 바뀔 일이 거의 없음
}@Service
public class OrderService {
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final EmailNotificationService emailService; // 이메일 발송은 고정
// 핵심 비즈니스 로직에서는 강결합이 더 명확할 수 있음
public void processOrder(Order order) {
inventoryService.reserveItems(order.getItems());
paymentService.processPayment(order.getPayment());
emailService.sendOrderConfirmation(order); // 항상 이메일로만 발송
}
}인터페이스를 만드는 경우:
// 실제로 여러 구현체가 필요한 경우
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}
@Service
public class CreditCardProcessor implements PaymentProcessor { }
@Service
public class BankTransferProcessor implements PaymentProcessor { }
@Service
public class KakaoPayProcessor implements PaymentProcessor { }구현체만 사용하는 경우:
결국 ”정말 필요한가?“를 먼저 생각해보고, 무작정 인터페이스를 만들기보다는 실제 요구사항과 변경 가능성을 고려해서 결정하는 것이 중요하다.
의존성 주입은 처음에는 복잡해 보이지만, 한 번 익숙해지면 코드의 유지보수성과 테스트 용이성을 크게 향상시킨다. 특히 스프링 부트에서는 생성자 주입을 사용하는 것을 권장하며, @Autowired 어노테이션 없이도 자동으로 주입된다.
생성자 주입을 기본으로 사용하자. 불변성과 필수 의존성을 보장할 수 있어서 안전하다.
인터페이스를 활용하자. 구현체가 바뀌어도 코드 수정이 최소화된다.
순환 의존성을 피하자. 설계를 다시 검토해보는 신호일 수 있다.
너무 많은 의존성은 피하자. 단일 책임 원칙을 지키고 있는지 확인해보자.
오버엔지니어링 주의: 구현체가 하나뿐인데 굳이 인터페이스를 만들 필요는 없음
상황별 판단: 실제 변경 가능성과 요구사항을 고려해서 결정
강결합이 적절한 경우: 핵심 비즈니스 로직이나 고정된 기능들
실용적 접근: “정말 필요한가?”를 먼저 생각해보기
참고 자료: