본문 바로가기
이직&취업/Spring Framework

Spring Framework DI (의존성 주입) 란?

by journeylabs 2025. 3. 20.
728x90
반응형

목차

    Spring Framework DI (의존성 주입) 가이드

    Spring Framework의 핵심 기능 중 하나인 DI (Dependency Injection, 의존성 주입)는 애플리케이션 개발 방식을 혁신적으로 변화시킨 중요한 개념입니다. DI를 통해 개발자는 코드 간의 결합도를 낮추고, 유연하고 테스트하기 쉬운 코드를 작성할 수 있습니다. 본 블로그에서는 DI의 개념부터 실제 예제, 주의사항까지 상세하게 다루어 Spring Framework DI에 대한 완벽한 이해를 돕고자 합니다.

    1. DI (의존성 주입)란 무엇인가?

    DI는 객체 간의 의존 관계를 객체 스스로가 아닌 외부에서 설정해주는 디자인 패턴입니다. 전통적인 방식에서는 객체가 필요한 다른 객체를 직접 생성하거나 찾아 사용했지만, DI에서는 객체가 필요로 하는 의존성을 외부에서 주입받습니다. 이를 통해 객체는 의존성에 대한 책임을 덜고 자신의 역할에 집중할 수 있게 됩니다.

    핵심: 객체가 필요로 하는 의존성을 외부에서 제공하여 객체 간의 결합도를 낮추는 디자인 패턴

    2. DI의 주요 개념

    DI를 이해하기 위해서는 몇 가지 핵심 개념을 알아야 합니다.

    • 의존성 (Dependency): 어떤 객체가 다른 객체의 기능이나 데이터에 의존하는 관계를 의미합니다.
    • 의존성 주입 (Dependency Injection): 외부에서 객체에게 필요한 의존성을 제공하는 행위를 의미합니다.
    • DI 컨테이너 (DI Container): 의존성 주입을 담당하는 프레임워크 또는 라이브러리를 의미합니다. Spring Framework에서는 ApplicationContext가 DI 컨테이너 역할을 합니다.

    3. 주요 용어 설명

    DI와 관련된 몇 가지 주요 용어를 자세히 살펴보겠습니다.

    • IoC (Inversion of Control, 제어의 역전): 객체의 생성, 의존성 관리 등 객체 생명 주기에 대한 제어 권한이 개발자가 아닌 DI 컨테이너에게 넘어가는 것을 의미합니다. DI는 IoC를 구현하는 구체적인 방법 중 하나입니다.
    • Bean: Spring IoC 컨테이너가 관리하는 객체를 의미합니다. Spring 설정 파일 (XML, Annotation, Java Config)에 정의된 설정 정보를 바탕으로 생성되고 관리됩니다.
    • Wiring: Bean 간의 의존 관계를 연결하는 과정을 의미합니다. Spring은 XML 설정, Annotation, Java Config 등 다양한 방식으로 Wiring을 지원합니다.

    4. DI의 특징

    DI는 다음과 같은 중요한 특징을 가지고 있습니다.

    • 낮은 결합도 (Loose Coupling): 객체 간의 의존 관계가 느슨해져 코드 변경에 따른 영향이 최소화됩니다.
    • 높은 재사용성 (High Reusability): 객체가 특정 의존성에 종속되지 않기 때문에 다양한 환경에서 재사용하기 용이합니다.
    • 쉬운 테스트 (Easy Testability): 의존성을 Mock 객체로 대체하여 객체 단위의 독립적인 테스트가 가능합니다.
    • 코드 가독성 향상 (Improved Readability): 객체의 역할과 의존 관계가 명확하게 드러나 코드 이해도가 높아집니다.
    • 유지보수성 향상 (Improved Maintainability): 코드 변경 시 영향 범위가 적어 유지보수가 용이합니다.

    5. DI 예제 및 예제 상세 설명

    간단한 예제를 통해 DI의 동작 방식을 이해해 보겠습니다.

     

    예제 시나리오:

    사용자에게 메시지를 보내는 기능을 제공하는 MessageService가 있습니다. MessageService는 메시지를 실제로 전송하는 MessageSender에 의존합니다.

     

    전통적인 방식 (DI 미적용):

    public class MessageService {
        private MessageSender messageSender = new EmailSender(); // 직접 객체 생성
    
        public void sendMessage(String message, String recipient) {
            messageSender.send(message, recipient);
        }
    }
    
    public class EmailSender implements MessageSender {
        public void send(String message, String recipient) {
            // 이메일 전송 로직
            System.out.println("Sending email to " + recipient + ": " + message);
        }
    }
    
    public interface MessageSender {
        void send(String message, String recipient);
    }

    이 방식에서는 MessageService EmailSender에 직접적으로 의존하고 있습니다. 만약 메시지 전송 방식을 SMS로 변경하고 싶다면 MessageService 코드를 수정해야 합니다.

     

    DI 적용 방식:

    public class MessageService {
        private MessageSender messageSender;
    
        // 생성자 주입
        public MessageService(MessageSender messageSender) {
            this.messageSender = messageSender;
        }
    
        public void sendMessage(String message, String recipient) {
            messageSender.send(message, recipient);
        }
    }
    
    public class EmailSender implements MessageSender {
        public void send(String message, String recipient) {
            // 이메일 전송 로직
            System.out.println("Sending email to " + recipient + ": " + message);
        }
    }
    
    public class SMSSender implements MessageSender {
        public void send(String message, String recipient) {
            // SMS 전송 로직
            System.out.println("Sending SMS to " + recipient + ": " + message);
        }
    }
    
    public interface MessageSender {
        void send(String message, String recipient);
    }

    DI를 적용한 코드에서는 MessageService MessageSender 인터페이스에 의존하고, 실제 구현체는 외부에서 생성자를 통해 주입받습니다. 이제 메시지 전송 방식을 변경하고 싶다면 MessageService 코드를 수정할 필요 없이, Spring 설정 파일에서 MessageSender 구현체만 변경하면 됩니다.

     

    Spring 설정 (Java Config):

    @Configuration
    public class AppConfig {
        @Bean
        public MessageSender emailSender() {
            return new EmailSender();
        }
    
        @Bean
        public MessageService messageService() {
            return new MessageService(emailSender()); // 의존성 주입
        }
    }

     

    DI 적용 방법:

    1. 생성자 주입 (Constructor Injection)

    개념:

    생성자 주입은 객체의 생성자를 통해 의존성을 주입하는 방식입니다. Spring 컨테이너는 Bean을 생성할 때 생성자를 호출하며, 생성자의 매개변수를 통해 필요한 의존성을 전달합니다.

    장점:

    • 필수 의존성 보장: 생성자 주입은 객체 생성 시 필수적인 의존성을 반드시 주입하도록 강제할 수 있습니다. 따라서 객체가 불완전한 상태로 생성되는 것을 방지할 수 있습니다.
    • 불변성 확보: 생성자를 통해 주입된 의존성은 객체 생성 이후에는 변경할 수 없도록 만들 수 있습니다. 이는 객체의 안정성을 높이고, 예상치 못한 side effect를 방지하는 데 도움이 됩니다.
    • 테스트 용이성: 생성자를 통해 의존성을 주입받기 때문에 단위 테스트 시 Mock 객체를 주입하기 용이합니다.

    단점:

    • 유연성 부족: 객체 생성 시 모든 의존성을 주입해야 하므로, 선택적인 의존성이 필요한 경우 불편할 수 있습니다.
    • 순환 참조 문제: 객체 간에 순환 참조가 발생할 경우, 생성자 주입 방식으로는 해결하기 어려울 수 있습니다.

    예제 코드:

    public class OrderService {
        private final ProductRepository productRepository;
        private final PaymentService paymentService;
    
        // 생성자 주입
        public OrderService(ProductRepository productRepository, PaymentService paymentService) {
            this.productRepository = productRepository;
            this.paymentService = paymentService;
        }
    
        public void processOrder(String productId, int quantity) {
            Product product = productRepository.getProduct(productId);
            paymentService.processPayment(product.getPrice() * quantity);
            // 주문 처리 로직
        }
    }

     

    Spring 설정 (Java Config):

    @Configuration
    public class AppConfig {
        @Bean
        public ProductRepository productRepository() {
            return new ProductRepository();
        }
    
        @Bean
        public PaymentService paymentService() {
            return new PaymentService();
        }
    
        @Bean
        public OrderService orderService() {
            return new OrderService(productRepository(), paymentService());
        }
    }

     

    설명:

    OrderService ProductRepository PaymentService에 의존하며, 생성자를 통해 의존성을 주입받습니다. OrderService는 생성 시 ProductRepository PaymentService가 반드시 필요하며, 생성 이후에는 이 의존성을 변경할 수 없습니다.

    2. 수정자 주입 (Setter Injection)

    개념:

    수정자 주입은 객체의 Setter 메서드를 통해 의존성을 주입하는 방식입니다. Spring 컨테이너는 Bean을 생성한 후 Setter 메서드를 호출하여 의존성을 설정합니다.

    장점:

    • 유연성: 선택적인 의존성을 주입하는 데 유용합니다. 필요한 경우에만 Setter 메서드를 호출하여 의존성을 설정할 수 있습니다.
    • 순환 참조 해결: 객체 간에 순환 참조가 발생할 경우, 수정자 주입 방식을 사용하여 해결할 수 있습니다.

    단점:

    • 필수 의존성 보장 불가: Setter 메서드를 사용하기 때문에 필수 의존성을 반드시 주입하도록 강제할 수 없습니다. 객체가 불완전한 상태로 사용될 가능성이 있습니다.
    • 불변성 확보 어려움: Setter 메서드를 통해 의존성을 변경할 수 있기 때문에 객체의 불변성을 확보하기 어렵습니다.
    • 테스트 시 복잡성 증가: Setter 메서드를 여러 번 호출하여 의존성을 설정해야 하므로, 테스트 코드가 복잡해질 수 있습니다.

    예제 코드:

    public class UserService {
        private UserRepository userRepository;
        private EmailService emailService;
    
        // 수정자 주입
        public void setUserRepository(UserRepository userRepository) {
            this.userRepository = userRepository;
        }
    
        public void setEmailService(EmailService emailService) {
            this.emailService = emailService;
        }
    
        public void registerUser(String userId, String email) {
            // 사용자 등록 로직
            userRepository.createUser(userId, email);
            if (emailService != null) {
                emailService.sendWelcomeEmail(email);
            }
        }
    }

     

    Spring 설정 (Java Config):

    @Configuration
    public class AppConfig {
        @Bean
        public UserRepository userRepository() {
            return new UserRepository();
        }
    
        @Bean
        public EmailService emailService() {
            return new EmailService();
        }
    
        @Bean
        public UserService userService() {
            UserService userService = new UserService();
            userService.setUserRepository(userRepository()); // 의존성 주입
            userService.setEmailService(emailService()); // 의존성 주입
            return userService;
        }
    }

     

    설명:

    UserService UserRepository EmailService에 의존하며, Setter 메서드를 통해 의존성을 주입받습니다. EmailService는 선택적인 의존성이므로, 주입되지 않아도 UserService는 정상적으로 동작할 수 있습니다.

    3. 필드 주입 (Field Injection)

    개념:

    필드 주입은 객체의 필드에 @Autowired 어노테이션을 사용하여 의존성을 주입하는 방식입니다. Spring 컨테이너는 Bean을 생성한 후 리플렉션을 사용하여 필드에 의존성을 주입합니다.

    장점:

    • 간편함: 코드가 간결하고 DI 설정이 간단합니다.

    단점:

    • 테스트 어려움: 필드에 직접 접근하여 의존성을 주입하기 때문에 단위 테스트 시 Mock 객체를 주입하기 어렵습니다.
    • 불변성 확보 불가: 필드에 직접 접근하여 의존성을 변경할 수 있기 때문에 객체의 불변성을 확보하기 어렵습니다.
    • 캡슐화 위반: 필드에 직접 접근하여 의존성을 주입하기 때문에 객체의 캡슐화를 위반할 수 있습니다.
    • DI 컨테이너 의존성 증가: 필드 주입은 Spring 컨테이너에 강하게 의존하게 됩니다.
    • 상속의 어려움: 상속 시 부모 클래스의 필드에 주입된 의존성을 재사용하기 어렵습니다.

    예제 코드:

    public class ProductService {
        @Autowired // 필드 주입
        private ProductRepository productRepository;
    
        public Product getProduct(String productId) {
            return productRepository.getProduct(productId);
        }
    }

     

    Spring 설정 (Java Config):

    @Configuration
    public class AppConfig {
        @Bean
        public ProductRepository productRepository() {
            return new ProductRepository();
        }
    
        @Bean
        public ProductService productService() {
            return new ProductService();
        }
    }

     

    설명:

    ProductService ProductRepository에 의존하며, @Autowired 어노테이션을 사용하여 필드에 의존성을 주입받습니다. Spring 컨테이너는 ProductService Bean을 생성한 후 productRepository 필드에 ProductRepository Bean을 주입합니다.

     

    주의:

    필드 주입은 코드가 간결하다는 장점은 있지만, 테스트 어려움, 불변성 확보 불가, 캡슐화 위반, DI 컨테이너 의존성 증가 등 다양한 단점을 가지고 있습니다. 따라서 필드 주입보다는 생성자 주입이나 수정자 주입을 사용하는 것이 좋습니다.

     

    Spring Framework에서는 다음과 같은 세 가지 방법으로 DI를 적용할 수 있습니다.

    • 생성자 주입 (Constructor Injection): 생성자를 통해 의존성을 주입하는 방식입니다. 필수적인 의존성에 사용됩니다.
    • 수정자 주입 (Setter Injection): Setter 메서드를 통해 의존성을 주입하는 방식입니다. 선택적인 의존성에 사용됩니다.
    • 필드 주입 (Field Injection): 필드에 @Autowired 어노테이션을 사용하여 의존성을 주입하는 방식입니다. 간편하지만, 테스트가 어렵고 결합도가 높아질 수 있어 사용을 지양하는 것이 좋습니다.

    6. DI 주의사항

    DI를 사용할 때 다음과 같은 사항에 주의해야 합니다.

    • 과도한 DI: 모든 객체에 DI를 적용하는 것은 오히려 코드를 복잡하게 만들 수 있습니다. 객체의 역할과 책임을 명확히 구분하고, 필요한 경우에만 DI를 적용해야 합니다.
    • 순환 참조 (Circular Dependency): 객체 간에 서로를 의존하는 순환 참조가 발생하면 애플리케이션이 정상적으로 시작되지 않을 수 있습니다. 순환 참조를 해결하기 위해서는 설계를 변경하거나, @Lazy 어노테이션을 사용하는 방법을 고려해야 합니다.
    • DI 컨테이너에 대한 과도한 의존: DI 컨테이너에 너무 의존적인 코드는 특정 컨테이너에 종속되어 이식성이 떨어질 수 있습니다. 가능한 한 인터페이스를 사용하여 DI 컨테이너에 대한 의존성을 최소화해야 합니다.
    • 테스트 용이성을 위한 설계: DI는 테스트 용이성을 높여주지만, 설계를 잘못하면 오히려 테스트가 어려워질 수 있습니다. 객체의 역할을 명확히 구분하고, 인터페이스를 활용하여 Mock 객체 생성을 용이하게 해야 합니다.

    DI는 Spring Framework를 사용하는 개발자에게 필수적인 개념입니다. DI를 통해 개발자는 코드의 유연성, 재사용성, 테스트 용이성을 높여 효율적인 개발을 할 수 있습니다. 본 블로그에서 설명한 DI의 개념, 주요 용어, 특징, 예제, 주의사항을 숙지하고 실제 프로젝트에 적용해 봄으로써 DI에 대한 완벽한 이해를 얻을 수 있을 것입니다.

    728x90
    반응형