Spring DI와 IoC

박시시·2022년 12월 6일
0

SPRING

목록 보기
1/2

DI와 IoC

스프링은 좋은 객체지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크이다. 그러면 좋은 객체지향 프로그래밍이란 무엇일까?
좋은 객체지향의 요소 중 하나는 분명 '유연하고 변경이 용이하다'라는 특성을 가진다. 이는 자바 언어의 다형성을 활용하여 달성할 수 있다.
즉, 인터페이스를 통해 역할을 표현하고 실제 동작은 그 구현체에서 이뤄지게끔 하여 변경이 용이한 구조로 설계할 수 있다는 것이다.

아주 일반적인 예제인 MemberRepository 인터페이스를 떠올려 보면 될 것이다. 다른 객체에선 MemberRepository의 역할을 알고, 그 객체와 협력하면 된다.
실제로는 MemberRepository의 구현체인 MemoryMemberRepository든 JdbcMemberRepository에서 실제 동작이 이뤄질 것이나 MemberRepository와 협력하고 있는 객체는 알 수도, 알 필요도 없게 된다. 이렇게 함으로써 클라이언트에서의 코드 변경 없이 쉽게 구현 내용을 변경할 수 있다.

그러면 스프링없이 순수 자바로 위의 다형성을 반영하여 코드를 짤 수 있을까? 아래 코드를 봐보자.

public class MemberService {
	//  private MemberRepository memberRepository = new MemoryMemberRepository();
	private MemberRepository memberRepository = new JdbcMemberRepository();
}

문제가 없어 보일수도 있으나 위 코드는 OCP, DIP 모두를 지키지 못한다.
OCP: 위 주석처리에서도 볼 수 있듯이 repository 구현체가 변경되면 클라이언트인 MemberService 코드에 변경이 일어난다.
DIP: MemberService 클래스는 memberRepository 인터페이스 뿐만 아니라 JdbcMemberRepository 구현체에도 의존한다.

즉 다형성 만으로는 OCP, DIP를 지킬 수 없는 것이다. 가장 근본적인 문제는 MemberService에서 어떤 repository를 사용할지를 결정하여 생성하고 연결하는 관심사를 떠앉고 있다는 점이다.

관심사 분리를 통한 해결

위의 문제점은 의존성 주입을 통해 해결할 수 있다. 일단 코드를 추상에만 의존하도록 수정해보자.

public class MemberService {
	private MemberRepository memberRepository;
}

이제 MemberService는 MemberRepository 추상에만 의존하게 되었다.
하지만 (당연히)위의 코드는 NPE가 발생한다.
MemberService는 자신의 역할을 수행하게끔 하고 memberRepository 역할에 맞는 구현체를 생성하고 지정하는 책임을 분리하자. 즉 관심사를 분리하는 것이다.

여기서 의존성 주입의 개념이 들어간다.

public class MemberService {
	private MemberRepository memberRepository;

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

memberRepository는 외부에서 주입받게끔 하고 MemberService 본연의 책임에만 집중할 수 있도록 한다.

이제 memberRepository 역할에 맞는 구현체를 생성하고 지정하는 책임을 지는 config용 클래스를 만들자.

public class AppConfig {
	public MemberService memberService() {
  	return new MemberServiceImpl(memberRepository());
	}

	public MemberRepository memberRepository() {
		return new MemoryMemberRepository();
	}	
}

AppConfig 활용

public class MemberApp {
	public static void main(String[] args) {
		AppConfig appConfig = new AppConfig();
		MemberService memberService = appConfig.memberService();
		Member member = new Member(1L, "memberA", Grade.VIP);
		memberService.join(member);
	} 
}

위의 과정을 통해 SRP(관심사 분리), DIP, OCP 등을 지킬 수 있게 되었다.

IoC, DI, 컨테이너

AppConfig 처럼 객체를 외부에서 생성하고 관리하며, 의존관계를 연결해주는 것을 IoC 컨테이너 혹은 DI 컨테이너라 한다.
또한 기존에 MemberService 구현객체가 직접 필요한 구현체, 예를 들면 MemoryMemberRepository 과 같은 객체를 생성하고 연결했다면 이러한 제어 흐름을 컨테이너 혹은 프레임워크에 넘겨주는 것을 제어의 역전, 즉 IoC라 부른다.
그리고 위에서 본 의존성 주입, 즉 DI가 이러한 IoC를 구현하기 위한 하나의 패턴인 것이다.

이렇게 의존성 주입을 통해
1. 서로 강하게 결합되었던 클래스들의 결합도를 낮출 수 있고
2. 객체의 유연성을 높일 수 있으며
3. 무엇보다 테스트 작성이 용이해 질 수 있다.

스프링 컨테이너

AppConfig를 Spring에서 관리하게끔 변경해보자.

@Configuration
public class AppConfig {
	@Bean
	public MemberService memberService() {
		return new MemberServiceImpl(memberRepository());
	}

	@Bean
	public MemberRepository memberRepository() {
		return new MemoryMemberRepository();
	}
}
public class OrderApp {
	public static void main(String[] args) {
		// AppConfig appConfig = new AppConfig();
		// MemberService memberService = appConfig.memberService();
		// OrderService orderService = appConfig.orderService();

		ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
		
		MemberService memberService = applicationContext.getBean("memberService", MemberService.class);

		long memberId = 1L;
		Member member = new Member(memberId, "memberA", Grade.VIP);
		memberService.join(member);
	}
}

위의 ApplicationContext를 스프링 컨테이너라 부르며 이 컨테이너가 스프링에서의 IoC 컨테이너이다.
즉 이 스프링 컨테이너는 빈이라 불리우는 객체를 초기화하고 관리하며 의존관계를 조립하는 역할을 한다. 더불어 빈의 라이프 사이클도 관리한다.

Bean이란? 스프링 빈은 스프링 IoC 컨테이너에 의해 관리되는 객체를 말한다. 스프링 IoC 컨테이너에 의해 초기화 되고, 조립되고, 관리되는 객체이다.

ApplicationContext에는 직접 객체를 생성하고 의존성 연결을 해주는 코드가 들어있진 않다. 그렇기에 @Configuration이 붙어있는 config용 클래스를 설정 정보로 사용한다. 해당 설정 파일 안에 @Bean이 달린 메서드를 모두 호출 후 반환된 객체를 스프링 컨테이너에 등록하게 된다.

그렇다면 직접 AppConfig 설장 파일을 만들어서 사용하는 것과 ApplicationContext로 관리하는 것의 차이는 무엇일까?
물론 ApplicationContext로 관리한다는 것은 스프링 프레임워크에서 관리한다는 뜻이니 여러면에서 이점이 있겠으나 그 중 하나를 꼽자면 바로 스프링 빈이 싱글톤으로 관리된다는 점이다.

웹 애플리케이션과 싱글톤

AppConfig appConfig = new AppConfig();
//1. 조회: 호출할 때 마다 객체를 생성
MemberService memberService1 = appConfig.memberService();
//2. 조회: 호출할 때 마다 객체를 생성
MemberService memberService2 = appConfig.memberService();

순수 DI 컨테이너였던 AppConfig는 요청을 할 때 마다 객체를 새로 생성한다.
유저가 요청할 때 마다 객체를 생성하고 소멸되게 하는 것은 메모리 낭비이며 비효율적이다. 이를 방지하기 위해서는 객체를 딱 1개만 생성되게 하여 공유해 사용하는 것이다.
이렇게 객체를 하나 생성하여 공유하는 방식을 싱글톤 패턴이라 한다.

직접 자바로 싱글톤 패턴을 구현하면 여러가지 문제가 있다.
1. 클라이언트가 구체 클래스에 의존하게 된다. 이로인해 DIP, OCP 원칙을 위반할 가능성이 높다.
2. private 생성자로 인해 상속이 힘들다.
3. 테스트가 어렵다
등등의 문제가 존재한다.

스프링에서는 이러한 싱글톤 패턴의 단점을 보완하기 위해 직접 싱글톤 객체를 생성하고 관리해주는 기능을 제공하며 이를 싱글톤 레지스트리라 한다. 스프링 컨테이너가 이러한 기능을 담당하고 있다.

ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
//1. 조회: 호출할 때 마다 같은 객체를 반환
MemberService memberService1 = ac.getBean("memberService", MemberService.class);
//2. 조회: 호출할 때 마다 같은 객체를 반환
MemberService memberService2 = ac.getBean("memberService", MemberService.class);

싱글톤 방식 주의점
싱글톤 객체는 반드시 무상태(stateless)로 설계해야 한다. 즉,
1. 특정 클라이언트에 의존적인 필드가 있으면 안되며
2. 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
3. 가급적 읽기만 해야하며
4. 필드 대신에 자바에서 공유되지 않는 지역변수나 파라미터, 쓰레드로컬 등을 사용해야 한다.

ApplicationContext에서 어떻게 싱글톤 레지스트리 기능을 제공할 수 있을까

@Configuration
public class AppConfig {
	@Bean
	public MemberService memberService() {
		return new MemberServiceImpl(memberRepository());
	}

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

위 코드를 다시 봐보면 memberRepository() 메서드 호출을 통해 MemoryMemberRepository 객체를 생성하고 있다. 만약 여러 곳에서 해당 코드를 사용하게 된다면 싱글톤이 깨지는 것 아닌가 라는 생각을 할 수도 있다.

사실 ApplicationContext에 AppConfig를 넘기게 되면 AppConfig를 그대로 쓰지 않는다.
일단 ApplicationContext에 넘긴 AppConfig 역시 스프링 빈으로 등록되어야 한다. 이때 AppConfig를 그대로 빈으로 등록하는 것이 아니라 CGLIB이라는 바이트 코드 조작 라이브러리를 사용하여 AppConfig를 상속받은 임의의 클래스를 빈으로 등록하게 된다.

아래와 같은 코드가 동적으로 만들어질 것으로 예상할 수 있다.

예상코드

@Bean
public MemberRepository memberRepository() {
	if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) { 
		return 스프링 컨테이너에서 찾아서 반환;
	} else { //스프링 컨테이너에 없으면
		기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록 return 반환
	} 
}

정리하자면, @Bean으로 수동 등록할 때 내부의 의존관계를 직접 메서드 호출로 주입하는 경우 @Configuration 적용이 안되어있다면 싱글톤이 보장 안된다. @Configuration 어노테이션을 적용했을 때 AppConfig는 CGLIB에 의해 코드 조작이 되어 빈으로 등록되게 된다.

컴포넌트 스캔과 의존관계 자동 주입

위의 빈 등록 및 의존관계 주입 방법은 수동으로 등록하는 방법이다. 등록할 빈이 수백개가 된다면 유지하기 힘든 방법이기도 하다.
스프링에서는 이러한 설정 정보 없이도 자동으로 스프링 빈을 등록하는 방법인 컴포넌트 스캔 기능을 제공한다. 의존관계 역시 @Autowired 어노테이션을 통해 가능하다.

작동 방식 및 스캔 범위

@ComponentScan은 @Component가 붙은 모든 클래스를 스프링 빈으로 등록한다. 그리고 생성자에 @Autowired를 지정하면 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아 주입시켜준다.

보통 스캔의 범위는 지정된 basePackage를 시작 위치로 하위 패키지 모두를 탐색하게된다. 디폴트로는 @ComponentScan은이 붙은 클래스의 패키지가 시작 위치가 된다.

스프링부트에서의 컴포넌트 스캔

그렇다면 스프링부트 프로젝트에는 이러한 어노테이션들이 어디 붙어 있는걸까?
@SpringBootApplication 어노테이션 안을 살펴보면 @ComponentScan을 확인할 수 있다.
스캔 기본 대상 역시 @Controller, @Service, @Repository, @Configuration 등의 어노테이션이 붙어 있다면 스캔의 대상이 되는데, 안을 살펴보면 @Component 어노테이션이 붙어 있는 것을 알 수 있다.

의존관계 주입 방법

의존관계 주입에는 크게 3가지가 있다(일반 메서드 주입까지 4가지이나 자주 사용되지 않기에 제외).
생성자 주입, 수정자 주입, 그리고 필드 주입이다.

생성자 주입

@Service
public class MemberService {
	private MemberRepository memberRepository;

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

생성자 호출 시점에 딱 1번만 호출되는 것이 보장된다. 만약 생성자가 1개만 존재시 @Autowired는 생략 가능하다(물론 스프링 빈에만 해당).

수정자 주입

@Service
public class MemberService {
	private MemberRepository memberRepository;

	@Autowired
  public void setMemberRepository(MemberRepository memberRepository) {
      this.memberRepository = memberRepository;
  }
}

선택, 변경 가능성이 있는 의존관계에 주로 사용된다.

필드 주입

@Service
public class MemberService {
	@Autowired
	private MemberRepository memberRepository;
}

가장 간단하나 테스트가 힘든 점, DI 프레임워크에 의존적인 점 등으로 인해 사용되지 않는다. 아래에서 좀 더 자세히 살펴보겠다.

필드 주입을 권장하지 않는 이유

  1. DI 컨테이너 의존성으로 인해 테스트가 어려움
    @Autowired를 통해 필드 주입을 하면 스프링을 통해서만 의존성 주입이 가능하게 된다. 즉 스프링(혹은 DI 컨테이너)에 의존적이라는 소리다. 이렇게 하면 스프링 빈들이 스프링의 DI 컨테이너에 강한 결합을 하게 된다. DI 컨테이너 없이도 단위테스트에서 (직접 의존성 주입을 하는 식으로) 인스턴스화 시킬 수 있어야 하는데 필드 주입의 경우 단위 테스트를 위해 스프링 프레임워크를 직접 띄워야 하는 경우가 생기는 것이다.

  2. 의존성 감춤
    생성자나 수정자를 통한 의존성 주입의 경우 public으로 필요한 의존성에 대한 정보를 제공하게 된다. 하지만 필드 주입의 경우 의존성이 눈에 보이지 않는다.

  3. 순환 참조
    순환 참조란 클래스 A가 클래스 B를 참조하는데 클래스 B가 클래스 A를 다시 참조하는 경우 혹은 2개 이상의 클래스들이 다른 클래스들을 참조하며 순환이 발생하는 것을 말한다.

class A {
	@Autowired
	private B b;

	public void aMethod() {
		b.bMethod();
	}
}

class B {
	@Autowired
	private A a;

	public void bMethod() {
		a.aMethod();
	}
}

이 경우 객체 생성 시점에는 문제가 보이지 않는다. 빈으로는 문제 없이 등록되는 것이다.
하지만 실제 실행시점에 StackOverflowError가 발생한다. 서로 참조하는 문제 때문이다.
(setter 주입의 경우도 마찬가지이다. 객체 생성시점에 순환참조가 발생하고 있는 걸 알 수 없기 때문에 setter 호출 시에 알게 되는 것이다)

생성자 주입의 경우 순환 참조가 발생했을 시에는 BeanCurrentlyCreationExeption을 발생시킴으로써 객체 생성 시점에 미리 알 수 있다.

  1. 불변하게 선언이 불가
    필드 주입을 하려면 final로 선언할 수 없다. 생성자 주입도 마찬가지이다. 그렇기에 객체 자체가 state safe하지 않다.
class A {
	@Autowired
	private SomeService service;

	public void aMethod() {
		service.doSomething();
		service = new SomeService();
	}
}

위와 같은 식의 코드가 작성될 가능성이 있다. 생성자 주입을 사용하여 final로 선언한다면 위와 같은 식으로 새 객체를 할당할 수 없게 된다.

생성자 주입을 사용해야 하는 이유

위의 설명에서 어느정도 생성자 주입을 사용해야 하는 이유가 나왔지만 다시 한 번 정리해 보자면 아래와 같다.

  1. 불변
    대부분의 의존관계 주입은 한 번 일어나면 애플리케이션 종료 전까지 의존관계를 변경할 일이 거의 없다. 즉 객체를 불변으로 설계하는 것이 유리하다는 뜻이다. 하지만 수정자 주입의 경우 setter 메서드를 퍼블릭으로 열어두고 의존성을 주입해야 한다. 즉 바로 위 코드예제 처럼 누군가 실수로 변경을 해버릴 수 있다는 것이며 변경하면 안되는 메서드를 퍼블릭으로 열어두는 것은 좋은 설계가 아니기도 하다.

  2. 누락을 방지
    수정자 주입의 코드를 테스트한다고 해보자.

public class OrderService {
    private MemberRepository memberRepository;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Order createOrder(Long memberId, String item, int itemPrice) {
        // 코드 생략
    }
}
class OrderServiceTest {
    @Test
		void createOrder() {
			OrderServiceImpl orderService = new OrderServiceImpl();
			orderService.createOrder(1L, "itemA", 10000);
		}
}

위의 테스트 코드는 작성 시에는 문제가 없다. 하지만 실행하게 되면 NPE가 뜨게 된다. 수정자 주입을 통한 의존관계 주입이 누락되어서이다.
반면 생성자 주입을 사용하게 되면 주입할 데이터가 누락시 바로 컴파일 오류가 발생하게 될 것이다.
OrderServiceImpl orderService = new OrderServiceImpl(); -> 컴파일 에러 발생

즉 사전에 미리 누락된 의존성을 캐치할 수 있다.
수정자 주입 사용시 테스트 길이가 길어지고, 양도 많아지게 되면 분명 휴먼에러가 발생할 수 밖에 없을 것이다.

  1. final 키워드 사용 가능
    위의 두가지 장점과 같은 맥락에서, 생성자 주입은 final 키워드를 사용할 수 있기 때문에 불변성을 갖고, 누락을 방지할 수 있게 된다.
    final 키워드가 붙어 있게 되면 값이 누락되는 오류를 미리 컴파일 시점에 막을 수 있고 수정자 주입 등과 같이 나중에 값을 따로 할당하여 불변성을 깨는 코드를 작성할 수 없게 된다.

참조

인프런 스프링핵심원리 - 기본편
https://mangkyu.tistory.com/150
https://mangkyu.tistory.com/125
https://www.baeldung.com/inversion-control-and-dependency-injection-in-spring
https://shanepark.tistory.com/368
https://zorba91.tistory.com/238
https://devlopsquare.tistory.com/115
https://yaboong.github.io/spring/2019/08/29/why-field-injection-is-bad/

0개의 댓글