[Spring] IoC 컨테이너와 DI에 대해서 가장 기본적인 CRUD 예제로 이해하기

2022. 11. 30. 07:26BackEnd

대부분 IoC 컨테이너와 DI에 대한 예제를 찾아보면 다들 너무나도 잘 정리해주신 글들이 많습니다. 그래서 저는 조금 다른 예시로 기본적인 CRUD 예제를 가지고 IoC 컨테이너와 DI를 제가 아는대로 한번 정리해보려고 합니다. 사실 DI나  보통 처음 백엔드 자바 스프링 할 때, 토비의 스프링보면서 UserDao 리팩터링하면서 IoC와 DI를 익히기 보다는 아마도 김영한님 강의 보면서 기본적인 CRUD 만들면서 익히는 분들이 더 많다고 생각이 들어서 저도 그렇게 해볼랍니다. DI에 대한 WIKI를 보면 벌써 부터 쉽지가 않아 보입니다. 흑흑 

 

간단한 프로젝트 구성 : Github Link

( 스프링 어플리케이션 프로젝트 생성 및 기본 Controller, Service, Repository, Entity를 생성할 줄 안다는 전제로 시작합니다. 제가 만든 예시는 Gradle Multi-Module 구성으로 Entity와 Repository는 core 모듈에 있고, Service는 export-api 모듈에 있습니다.)


 

DI란? ( Dependency Injection )

한마디로 정의하면 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉입니다.

토비의 스프링(p.114)에 기술된 내용을 보면 아래 세 가지 조건을 충족하는 작업을 말합니다.

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
  • 의존 관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.

아래 코드를 보면서 설명하겠습니다. 아래 코드를 동작시켰을 때 위 조건이 충족되는지 봅시다.

@Service
public class MemberService {
  private MemberRepository memberRepository;

  public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
  }

  public Long signup (Member member){
    return memberRepository.save(member).getId();
  }
}

MemberService라는 객체는 MemberRepository를 사용하여 정의한 메소드를 수행합니다. 코드레벨에서 의존관계를 표시하면 아래와 같습니다. ( MemberRepository는 마커 인터페이스입니다. )

첫번째 조건인 코드에서 런타임 시점에서의 의존관계는 드러나지 않습니다. 위 코드를 실행하면, MemberService의 signup 메서드가 정상 동작하는 것을 확인할 수 있는데요. 그렇다는 말인 즉슨 MemberRepository를 정상적으로 생성자를 통해 주입을 받았다는 이야기가 됩니다.

 

즉 런타임 상황에서 Service 객체는  MemberRepository( 해당 인터페이스가 상속 받고 있는 Layer에 대한 설명은 생략하겠습니다. )를 구현하는 객체로 부터 의존관계를 주입받은 것이 되고, signup 메서드는 save라는 메서드를 사용함으로서 사용 의존관계를 형성하는 것입니다.

 

설명이 복잡한데, 쉽게 요약하면 MemberService는 프로그램이 실행된 런타임 환경에서 외부로 부터(제 3자) MemberRepository라는 레퍼런스를 3자로부터 넘겨 받았다. 그래서 잘 동작한다. 요렇게 보면 되겠습니다. 

 


그럼 여기서 3자는 누구인데? 

스프링 IoC 컨테이너요~!

 

그러나 MemberService 입장에서는 사실 누가 주는지는 그렇게 중요하지 않습니다. OOP 프로그래밍 관점에서 보면 객체간의 메시징을 통해서 외부로 부터 MemberRepository 객체를 전달받아서 MemberService에 선언한 MemberRepository 상태를 갱신한 것일뿐입니다. 즉 MemberService는 메시징을 위한 방법만 잘 정의를 하고 있으면 된다는 것입니다.

 

말을 조금 바꿔보면 DI를 구현하는 방법이라고 해보겠습니다. 외부에서 의존관계를 결정하는 것이기에 클래스 변수를 결정하는 방법이 곧 DI를 구현하는 방법이 되기 때문입니다. 보통 생성자를 이용한 방법과 메소드를 이용한 방법이 있습니다. ( 토비의 스프링에 따르면 Setter라고 하는 수정자 메소드와 여러개의 파라미터를 가질 수 있는 일반 메서드를 사용한 방식이 있다고 합니다. )

 


DI 장점

  • 의존성이 줄어듭니다. 위 예시에서 MemberRepository가 변경이 되더라도 MemberService는 수정할 필요가 없습니다. ( Strategy Pattern ) 이를 통해서 MemberService 자체가 변화하는 경우가 아니라면 외부 요인에 의한 변경을 막아주고, 외부 오브젝트인 MemberRepository는 자유롭게 수정이나 확장이 가능합니다. ( 개방 폐쇄의 원칙 )
  • 위 이야기를 조금 다르게 표현하면 낮은 결합도를 가지게 된 것이고, 자신의 책임과 관심사에만 집중하는 응집도가 높은 코드를 작성할 수 있도록 합니다.
  • 이렇게 되면 테스트도 간편하고 가독성도 높일 수 있습니다.

 


IoC와 IoC 컨테이너

 

IoC란 제어의 역전입니다. 프로그래머가 작성한 프로그램이 재사용 라이브러리의 흐름 제어를 받게되는 소프트웨어 디자인 패턴이라고 합니다. (위키) DI가 유용하다는 사실이 인지가 되고나서 DI를 사용하는데, 여전히 전체 코드 어디서엔가는 Service에 들어가는 객체를 생성해야하고, 주입을 해주어야 하는 코드가 생깁니다. 근데 이게 왜 문제가 되느냐? 개발자가 생성된 객체를 직접관리를 해야되는데, 비즈니스 로직이 복잡해질 수록 그것도 난이도가 점점 증가합니다. 그리고 직접 관리 과정에서 의존 관계가 꼬여버릴 수 있기 때문이죠. 그래서 이를 재사용 라이브러리 ( IoC 컨테이너와 같은 )에게 제어하라고 하는 것입니다. 그래서 제어의 역전 ( 개발자가 안한다. )

 

다시 위 예제로 돌아가면 MemberService는 MemberRepository 주입 받는데, 프로그램이 기동되면 IoC 컨테이너가 의존 관계를 설정하고 객체를 생성 한 다음 알아서 주입을 해주게 됩니다.

 

( 스프링의 IoC 컨테이너 내용을 좀 봐야하는데... 사실 내용이 꽤나 방대합니다. 토비의 스프링 2권 1장을 할애하여 대략 180p 가량의 내용이기 때문입니다. 또 1권 1장에도 DI 설명을하면서 IoC 내용이 포함되어 있어 사실 책만 보았을 때 저도 안다고 말할 자신이 없습니다. 그래서 최대한 심플하게 이해한 바를 적어봅니다.)

 

IoC 컨테이너란?

스프링에서 IoC 방식으로 빈을 관리한다는 의미에서 애플리케이션 컨텍스트나 빈 팩토리를 컨테이너 혹은 IoC 컨테이너라고 명명한다고 합니다. ( 토비의 스프링 1권 p 104 ) 여기서 애플리케이션 컨텍스트가 빈 팩토리를 상속하고 있는 구조이기에 IoC 컨테이터는 아래 내용으로 정의 할 수 있습니다. ( 토비의 스프링 2권 p 52 )

IoC 컨테이너 = ApplicationContext 인터페이스를 구현한 클래스의 오브젝트

 

IoC 컨테이너가 의존 관계를 설정하고 객체를 생성 하여 주입하기 위해서는 아래와 같은 작업이 필요합니다.

  • MemberRepository를 일단 컨테이너에 등록을 해줘야 합니다.
  • 등록된 MemberRepository를 찾아야 합니다.
  • MemberRepository를 MemberService에 주입해야 합니다.

 

컨테이너 등록은 어떻게 할까요? ( 토비의 스프링 2권 p.85 )

@Service
public class MemberService {
  private MemberRepository memberRepository;

  public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
  }

  public Long signup (Member member){
    return memberRepository.save(member).getId();
  }
}

위 코드에서 @Service 애너테이션이 그 역할을 당당합니다. @Service를 IDE에서 타고들어가보면 @Component라는 애너테이션을 볼 수 있는데, @Component 애너테이션은 스프링 빈으로 자동 등록하는 역할을 합니다.

 

만약 이를 애너테이션을 쓰지 않는다고하면 아래와 같이 직접 등록하는 방법도 있습니다.

@Configuration
public class SpringConfig{
    @Bean
    public MemberRepository memberRepository() {
      return new MemoryMemberRepository();
    }
}

 

등록한 정보를 어떻게 스캔할까? 모든 프로그램은 main으로 부터 시작을 하게됩니다. 여기서 @SpringBootApplication 애너테이션을 눌러봅시다.

@SpringBootApplication
public class ApiApplication {

  public static void main(String[] args) {
    SpringApplication.run(ApiApplication.class, args);
  }
}

아래 딱 보니까 ComponentScan이라는 애너테이션이 보입니다. 한번더 타고 들어가 봅니다.

@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
	... 중략
}

 

맨 상단 JavaDoc 설명을 보면 아래와 같습니다. 즉 프로그램이 기동될 때 자바 코드로나 XML을 통해 등록된 빈을 스캔한다는 설명입니다.

Configures component scanning directives for use with @Configuration classes. Provides support parallel with Spring XML's <context:component-scan> element.

실무에서 XML은 현재 잘 안쓴다고하는데, 무튼 코드에 '등록하세요!'라고 명시를 해두면 프로그램 시작과 동시에 scan이 일어나고 컨테이너에 등록하여 관리하게 됩니다.

 

 

마지막으로 의존관계를 설정하는 방법입니다. ( 토비의 스프링 2권 p 109 )

여러 방법이 있지만, 위 코드 베이스로 계속 설명을 이어가겠습니다.

 

스프링 프레임워크는 리플렉션이라는 방법을 사용합니다. 리플렉션이란?

  • 구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들을 접근할 수 있도록 해주는 자바 API
  • 런타임에서 동적으로 특정 클래스의 정보를 추출해낼 수 있는 프로그램 기법

즉 등록된 정보를 바탕으로 맞는 객체를 주입해줄 수 있다는 것이 핵심입니다. 리플렉션에 대해서는 또 한세월 작성해야되기에 블로그 주소를 하나 남겨봅니다.

 

결론은 IoC 컨테이너에 등록하면 런타임에서 스캔하고 DI를 한다였습니다.

 

 


위 내용과 관련하여 반드시 알아야되는 내용이 또 있습니다. 바로 IoC Bean의 Scope와 라이프 사이클입니다. 등록된 Bean은 생성만 되는 것이 아니라 소멸이 되기도 합니다. 소멸에 대한 명시를 코드에 직접해줄 수 있지만 보통 Container가 Shutdown 되면서 자동으로 호출하기에 간단한 CRUD 구현에서는 별도로 명시를 하지 않았습니다.

https://www.oreilly.com/library/view/hands-on-high-performance/9781788838382/249b1941-f09c-4b7f-b6c8-fc7ebde09b01.xhtml

 

위 도식을 요약하면 아래와 같습니다.

스프링 컨테이너 생성 -> 스프링 빈 생성 -> DI -> 초기화 콜백 -> 사용 -> 소멸전 콜백 -> 컨테이너 종료

 

여기서 좀 재미있는 부분은 객체 생성과 초기화를 구분하는 점인데요. 만약에 DB 커넥션이나 네트워크 소켓 커넥션 등 초기화 작업이 많은 경우는 생성과 동시에 초기화를 하게되면 처리 지연이 발생할 수 있고 유지보수 관점에서 문제가 발생했을 경우 디버깅이 어려울 수 있어 구분을 합니다.

 

 


정리

약 250p를 요약하여 적다보니 다소 두서가 없습니다. 하지만 이 글을 정리하면서 IoC와 DI가 뭔지, 스프링에서는 이를 어떻게 구현하는지를 잘 알아야 한다는 것을 정리하고 싶었습니다.