스프링 컨테이너에 대해 알아보자(feat. IoC, DI)

코린이·2024년 9월 18일
post-thumbnail

이전 포스팅에서 스프링이 뭔지에 대해 간단하게 알아보았습니다.
그래서 이번에는 스프링의 핵심 아키텍쳐인 스프링 컨테이너에 대해 알아보고자 합니다.
그전에 몇가지 집고 넘어가야 할 개념들이 있어 빠르게 한번 알아보겠습니다.

IoC(Inverse of Control)

제어의 역전(Inverse of Control, IoC)는 사용할 객체를 직접 생성하지 않고, 객체의 생명주기 관리를 외부(스프링 컨테이너)에 위임하는 설계 방식입니다.

IoC는 프레임워크를 설계하는 방식 중 하나입니다. IoC을 사용하게 되면 객체의 생명주기를 개발자가 아닌 외부에서 관리하기 때문에 개발자는 비지니스 로직에 더욱 집중할 수 있다는 장점이 있습니다.

그렇다면 개발자가 작성한 클래스로 만들어진 객체를 스프링 프레임워크가 관리할 수 있도록 해야되는데 어떻게 그게 가능할까요?
스프링에서는 DI(Dependency Injection)라는 개념을 사용하여 해결합니다.

DI(Dependency Injection)

의존성 주입(DI, Dependency Injection)은 제어의 역전을 구현하는 방법 중의 하나로, 외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴 입니다.

스프링에서는 특정 객체가 사용할 또 다른 객체를 직접 생성하지 않고 외부 컨테이너가 생성한 객체를 주입받아 사용합니다. DI를 사용하지 않은 코드와 사용한 코드를 서로 비교하는 예제를 통해 DI가 뭔지 그리고 왜 사용해야 하는지 자세히 알아보겠습니다.

DI를 사용해야 하는 이유

알림을 보낼 때 메시지 서비스에서 제공하는 전송 메시지 문구를 사용하여 알림을 보내는 기능을 구현한다고 가정해보겠습니다.

// 메일 발송 서비스 클래스
class EmailService {
    public void sendEmail(String message) {
        System.out.println("Sending email: " + message);
    }
}

// 알림 서비스 클래스
class NotificationService {
    private EmailService emailService = new EmailService(); // 직접 객체를 생성

    public void sendNotification(String message) {
        emailService.sendEmail(message);
    }
}

// 사용 예시
public class Main {
    public static void main(String[] args) {
        NotificationService notificationService = new NotificationService();
        notificationService.sendNotification("Hello, DI without!");
    }
}

위의 코드는 의존성 주입 없이 NotificationService 객체에 EmailService 객체를 직접 넣은 예시 코드입니다. 해당 코드의 단점은 무엇일까요?

1. 객체가 서로 강한 결합을 가지고 있다.

  • NotificationServiceEmailService 객체를 직성 생성하였기 때문에 가장 의존 관계를 가지고 있습니다. 그렇기 때문에 EmailService의 구현이 변경되면 NotificationService도 수정해야 합니다.
  • 지금은 EmailServicesendEmail 메소드만 사용하고 있어 복잡하지 않지만 코드가 커지고 EmailService 에서 다양한 메소드를 사용할 경우 수정해야할 코드는 기하급수적으로 늘어날 것입니다.

2. 객체들 간의 관계가 아니라 클래스들 간의 관계가 맺어져 있다.

  • 또한 위의 NotificationServiceEmailService는 객체들 간의 관계가 아니라 클래스들 간의 관계가 맺어져 있다는 문제가 있습니다.
  • 올바른 객체지향적 설계라면 객체들 간에 관계가 맺어져야 합니다.

위의 코드를 의존성 주입 방식을 활용하여 아래 코드와 같이 개선해보았습니다.

// 메일 발송 서비스 인터페이스
interface MessageService {
    void sendMessage(String message);
}

// 메일 발송 서비스 구현 클래스
class EmailService implements MessageService {
    public void sendMessage(String message) {
        System.out.println("Sending email: " + message);
    }
}

// 알림 서비스 클래스
class NotificationService {
    private MessageService messageService;

    // 의존성 주입: 외부에서 객체를 주입받음
    public NotificationService(MessageService messageService) {
        this.messageService = messageService;
    }

    public void sendNotification(String message) {
        messageService.sendMessage(message);
    }
}

// 사용 예시
public class Main {
    public static void main(String[] args) {
        MessageService emailService = new EmailService();
        NotificationService notificationService = new NotificationService(emailService); // 의존성 주입
        notificationService.sendNotification("Hello, DI with!");
    }
}

MessageService 라는 인터페이스를 하나 추가했고 NotificationService 클래스에는 이전과 달리 객체를 직접 생성하는 것이 아닌 messageService 인터페이스를 매개변수로 받고 있습니다. 이후 main() 함수에 messageService의 구현체(EmailService)를 생성하고 주입받고 있습니다.

인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있게 해줍니다.

스프링에서도 특정 객체가 사용할 또 다른 객체를 직접 생성하지 않고 외부 컨테이너가 생성한 객체를 주입받아 사용합니다.

스프링에서 의존성을 주입하는 방법

스프링에서는 다양한 의존성 주입 방식을 지원합니다.

생성자 주입(Constructor Injection)

  • 생성자를 통해 의존 관계를 주입하는 방법입니다.
  • 생성자 주입은 생성자의 호출 시점에 1회 호출 되는 것이 보장합니다. 그렇기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있습니다.
  • 또한 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공합니다.
@Service
public class UserService {

    private UserRepository userRepository;
    private MemberService memberService;

		@Autowired // 생략 가능
    public UserService(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }

}

수정자 주입(Setter Injection)

  • 필드 값을 변경하는 Setter를 통해서 의존 관계를 주입하는 방법입니다.
  • Setter 주입은 생성자 주입과 다르게 주입받는 객체가 변경될 가능성이 있는 경우에 사용합니다.
  • @Autowired로 주입할 대상이 없는 경우에는 오류가 발생하며 주입할 대상이 없어도 동작하도록 하려면 @Autowired(required = false)를 통해 설정할 수 있습니다.
  • 스프링 초기에는 수정자 주입이 자주 사용되었는데, 그 이유는 바로 getX, setX 등 프로퍼티를 기반으로 하는 자바 기본 스펙 때문이였습니다. 하지만 시간이 지나면서 점차 수정자 주입이 아닌 다른 방식이 주목받게 되었습니다.
@Service
public class UserService {

    private UserRepository userRepository;
    private MemberService memberService;

    @Autowired
    public void setUserRepository(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Autowired
    public void setMemberService(MemberService memberService) {
        this.memberService = memberService;
    }
} 

필드 주입(Field Injection)

  • 필드에 바로 의존 관계를 주입하는 방법입니다.
  • 필드 주입을 이용하면 코드가 간결해져서 과거에 상당히 많이 이용되었던 주입 방법이빈다.
  • 하지만 필드 주입은 외부에서 접근이 불가능하다는 단점이 존재하는데, 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용되지 않게 되었다.
  • 그렇기에 애플리케이션의 실제 코드와 무관한 테스트 코드나 설정을 위해 불가피한 경우에만 이용하도록 해야합니다.
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private MemberService memberService;

}

Spring Container

스프링 컨테이너는 스프링 프레임워크의 핵심 컴포넌트입니다.
스프링 컨테이너는 주로 객체의 생명주기를 관리하고, 생성된 객체들의 추가적인 기능들을 제공합니다.
앞서 설명한 IoC의 실체라고 볼 수 있습니다.
스프링에서는 자바 객체를 빈(Bean)이라고 부릅니다.

  • 스프링 컨테이너의 역할
    • 의존성 주입: 객체 간의 의존성을 코드가 아닌 외부 설정 파일이나 애노테이션을 통해 설정할 수 있습니다. Spring Container가 이러한 의존성을 자동으로 주입하여 객체를 구성합니다.
    • 객체 생성 및 관리: Spring Container는 애플리케이션에서 필요한 빈(bean, 객체)을 생성하고, 해당 빈들의 라이프사이클을 관리합니다.
  • 장점
    1. 프로그램의 진행 흐름과 구체적인 구현을 분리시킬 수 있습니다.
    2. 개발자는 비즈니스 로직에 집중할 수 있습니다.
    3. 구현체 사이의 변경이 용이합니다.
    4. 객체 간 의존성이 낮아집니다.

Application Context

일반적으로 Spring Container는 ApplicationContext를 의미합니다.
ApplicationContextBeanFactory를 비롯한 여러가지 인터페이스를 다중 상속한 인터페이스 입닌다.

상속을 받은 각각의 부모 인터페이스들의 역할은 다음과 같습니다.

  • BeanFactory : Bean을 관리하고 검색하는 기능을 제공하는 인터페이스
  • MessageSource : 메세지 다국화를 위한 인터페이스
  • EnvironmentCapable : 개발, 운영 등 환경을 분리해서 처리하고, 애플리케이션 구동에 필요한 정보들을 관리하기 위한 인터페이스
  • ApplicationEventPublisher : 이벤트를 발행하고 구독하는 모델을 편리하게 지원하는 인터페이스
  • ResourceLoader : 파일, class path 등 리소스를 읽어오기 위한 인터페이스

Spring 공식문서 상 컨테이너를 사용해야 할때 특별한 이유가 없다면, ApplicationContext 를 사용하라고 권장합니다. 그 이유는 BeanFactory를 포함한 여러 인터페이스의 모든 기능을 ApplicationContext가 포함하고 있기 때문입니다.

참고자료

https://hudi.blog/inversion-of-control/
https://mangkyu.tistory.com/125
https://lucas-owner.tistory.com/39
https://innovation123.tistory.com/167
https://junhkang.tistory.com/43

profile
호기심이 많고, 문제를 끝까지 해결하려는 집념이 강한 개발자입니다.

0개의 댓글