스프링 의존성 주입(DI)의 이해


최근 스프링 부트로 프로젝트를 진행하면서 의존성 주입(Dependency Injection, DI) 에 대해 깊이 있게 공부하게 됐다. 처음에는 단순히 @Autowired 어노테이션만 사용하면 되는 줄 알았는데, 실제로는 스프링의 핵심 개념 중 하나라는 걸 알게 됐다.

의존성 주입(DI)이란?

의존성 주입은 객체가 필요로 하는 의존성을 외부에서 주입받는 방식이다. 쉽게 말해서, 클래스가 사용하는 객체를 직접 생성하지 않고 외부에서 받아서 사용하는 것이다.

의존성(Dependency) 이란 한 객체가 다른 객체에 의존하는 관계를 의미한다. 예를 들어, UserServiceUserRepository를 사용한다면 UserServiceUserRepository에 의존한다고 할 수 있다.

기존 방식 vs DI 방식

기존 방식 (강한 결합):

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);
    }
}

스프링 컨테이너와 IoC

스프링에서 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) 이라고 한다.

스프링에서 DI를 구현하는 방법

1. 생성자 주입 (권장)

가장 권장되는 방식이다. 불변성 보장순환 의존성 방지가 가능하다.

@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 키워드 사용 가능
  • 필수 의존성: 객체 생성 시점에 모든 의존성이 주입됨
  • 순환 의존성 조기 발견: 애플리케이션 시작 시점에 오류 발생

2. 필드 주입

간단하지만 테스트하기 어렵고 순환 의존성 문제가 발생할 수 있다.

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private EmailService emailService;
    
    // 리플렉션을 통해 주입되므로 테스트 시 Mock 주입이 어려움
}

3. Setter 주입

선택적 의존성에 사용하지만, 불변성 보장이 안 된다.

@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;
    }
}

빈(Bean) 등록 방법

1. 어노테이션 기반

@Component  // 일반적인 컴포넌트
@Service    // 비즈니스 로직 계층
@Repository // 데이터 접근 계층
@Controller // 웹 계층

2. 설정 클래스 기반

@Configuration
public class AppConfig {
    
    @Bean
    public UserRepository userRepository() {
        return new UserRepository();
    }
    
    @Bean
    public UserService userService(UserRepository userRepository) {
        return new UserService(userRepository);
    }
}

3. 외부 라이브러리 빈 등록

@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 계층

@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 계층

@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);
    }
}

Controller 계층

@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);
    }
}

DI의 장점

1. 테스트 용이성

@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");
    }
}

2. 유연성과 확장성

// 인터페이스를 통한 다형성
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, "환영합니다!", "회원가입을 축하드립니다.");
    }
}

3. 객체 생명주기 관리

스프링이 객체의 생성과 소멸을 관리해주므로 개발자가 신경 쓸 필요가 없다.

@Component
public class DatabaseConnectionManager {
    
    @PostConstruct
    public void init() {
        System.out.println("데이터베이스 연결 초기화");
    }
    
    @PreDestroy
    public void cleanup() {
        System.out.println("데이터베이스 연결 정리");
    }
}

빈 스코프(Bean Scope)

스프링에서는 빈의 생명주기를 다양하게 관리할 수 있다.

@Service
@Scope("singleton")  // 기본값: 애플리케이션당 하나의 인스턴스
public class SingletonService {
    // 싱글톤으로 관리됨
}

@Service
@Scope("prototype")  // 요청할 때마다 새로운 인스턴스 생성
public class PrototypeService {
    // 매번 새로운 객체 생성
}

// 웹 환경에서만 사용 가능
@Service
@Scope("request")   // HTTP 요청당 하나의 인스턴스
public class RequestScopeService {
    // 요청별로 관리됨
}

주의사항

1. 순환 의존성

@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. 설계 재검토 (추천)
// - 공통 기능을 별도 서비스로 분리
// - 이벤트 방식으로 결합도 낮추기

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;
    }
}

실무 팁

1. Optional 의존성 처리

@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));
    }
}

2. 조건부 빈 등록

@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();
    }
}

DI의 현실적 고려사항

이론적으로는 모든 의존성을 인터페이스로 추상화하는 것이 좋다고 하지만, 실무에서는 상황에 따라 판단해야 한다.

인터페이스가 불필요한 경우

// 굳이 인터페이스가 필요없는 경우
@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);  // 항상 이메일로만 발송
    }
}

실무에서의 판단 기준

인터페이스를 만드는 경우:

  • 여러 구현체가 실제로 필요한 경우 (결제 서비스: 카드, 계좌이체, 페이 등)
  • 환경에 따라 구현이 달라지는 경우 (개발/운영환경의 알림 서비스)
  • 외부 API 연동 (언제든 다른 업체로 변경 가능)
// 실제로 여러 구현체가 필요한 경우
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 { }

구현체만 사용하는 경우:

  • 내부 비즈니스 로직 (바뀔 가능성이 거의 없음)
  • 단순한 유틸리티 클래스
  • 이미 표준 인터페이스가 있는 경우 (Spring의 기본 컴포넌트들)

결국 ”정말 필요한가?“를 먼저 생각해보고, 무작정 인터페이스를 만들기보다는 실제 요구사항과 변경 가능성을 고려해서 결정하는 것이 중요하다.

마무리

의존성 주입은 처음에는 복잡해 보이지만, 한 번 익숙해지면 코드의 유지보수성과 테스트 용이성을 크게 향상시킨다. 특히 스프링 부트에서는 생성자 주입을 사용하는 것을 권장하며, @Autowired 어노테이션 없이도 자동으로 주입된다.

DI를 잘 활용하기 위한 핵심 포인트:

생성자 주입을 기본으로 사용하자. 불변성과 필수 의존성을 보장할 수 있어서 안전하다.

인터페이스를 활용하자. 구현체가 바뀌어도 코드 수정이 최소화된다.

순환 의존성을 피하자. 설계를 다시 검토해보는 신호일 수 있다.

너무 많은 의존성은 피하자. 단일 책임 원칙을 지키고 있는지 확인해보자.

실무적 핵심 포인트:

오버엔지니어링 주의: 구현체가 하나뿐인데 굳이 인터페이스를 만들 필요는 없음

상황별 판단: 실제 변경 가능성과 요구사항을 고려해서 결정

강결합이 적절한 경우: 핵심 비즈니스 로직이나 고정된 기능들

실용적 접근: “정말 필요한가?”를 먼저 생각해보기


참고 자료:



Written by@[namu]
모바일, 스마트폰, 금융, 재테크, 생활 정보 등