IoC & DI 에 대한 오해 (Feat. Spring IoC & DI)

WIZ·2023년 11월 20일
3

OOP

목록 보기
3/3

들어가기 전에 앞서 IoC & DI(Inversion of Control & Dependency Injection) 에 대한 오해를 조금 풀어보려고한다.

우리는 Spring Boot 를 처음 공부하면서 자연스럽게 Spring 의 3대 특징 중 하나인 IoC & DI 에 대해서 공부한다. 그래서 자연스럽게 IoC & DI 가 Spring 에서 제공해주는 특별한 개념처럼 느끼게된다. 아닐수도 있겠지만.. 적어도 나는 그랬다!

IoC (Inversion of Control) : 객체의 생성 & 관리에 대한 책임을 개발자가 아니라 Framework 로 역전시키는 것
DI (Dependency Injection) : Framework 가 관리하고 있는 객체(Bean) 을 필요한 곳에서 의존하고자 주입받는 것

하지만, 이러한 생각은 오해다!

IoC & DI 는 Spring 과 아예 상관없는 독립적인 개념이며, Spring 에서 IoC & DI 라는 개념을 구현해줬을 뿐이다.

이 오해를 풀기 전에는 "IoC & DI 가 어떤 부분에서 객체지향적 설계에 도움이 될까요?", "IoC & DI 가 왜 필요하고 어떤 장점이 있죠?" 와 같은 질문에 명확히 대답하기 어렵다.

이제부터 앞서 소개한 오해를 풀어보자.


Dependency Injection (DI)


Dependency Injection (이하 DI) 는 생각보다 복잡한 개념이 아니다.
간단히 설명해보자면, 다른 객체를 의존해야하는데 직접 new 를 통해 객체를 생성해 의존하기는 싫고 외부로부터 생성된 객체를 전달받음으로써 의존하겠다는 것이다.

이러한 DI 를 하기 위한 방법으로는 생성자 주입방식, Setter 주입방식, 메소드 인자 주입방식으로 세 가지 방법이 있다.

어?
세 가지 방법인 것은 맞지만 메소드 인자 주입 방식이 아니라 필드 주입 방식이 아닌가?
이러한 의문이 생겼다면 IoC & DI 가 Spring 에서 나온 개념이라고 오해하고 있는 것이다.
Spring 이 세상에 없다고 생각하고 순수한 IoC & DI 개념에만 초점을 맞춰야한다.

그럼 특정 객체를 의존하려고 할 때, 직접 객체를 생성하지 않고 외부로부터 객체를 주입받고자하는 근본적인 이유가 무엇일까?

이 이유가 DI 가 필요한 이유이다!!

또, 이 이유를 이해하고나면 위에서 잠깐 언급했던 "IoC & DI 가 어떤 부분에서 객체지향적 설계에 도움이 될까요?", "IoC & DI 가 왜 필요하고 어떤 장점이 있죠?" 질문에대한 실마리를 찾을 수 있을 것이다.

Client 객체가 의존하고자하는 객체를 직접 new 를 통해 생성하게되면 구체적인 객체를 의존하게 된다.
핵심은 위 내용이지만, 아직 잘 안와닿을 수 있기 때문에 두 가지 구체적인 설명을 통해 위 문장을 이해해보자.

  1. new 를 통해서 호출하는 생성자 자체도 구체적이고 자주 변경될 수 있다.
    즉, 객체가 생성되는 방식이 변경되어서 생성자가 변한다면, 그 변경이 Client 객체로 전파될 수 있음을 의미한다. (변경은 의존성을 타고 전파되기 때문이다)
    해당 객체를 의존하는 Client 객체가 많으면 많을수록 그 변경의 전파 범위는 커지고 당연히 변경에 유연하지 않은 코드가 만들어지게 된다.
  2. Client 객체가 추상화 타입이 아닌 구체적인 타입을 의존할 수밖에 없다.
    new 를 통해 객체를 생성한다는 것 자체가 구체적인 타입을 의존하는 것이고, 이는 의존하고 있는 객체가 다른 객체로 확장(대체)될 수 없는 높은 결합도를 가져오게된다.

위 설명들을 통해 new 를 통해 객체를 생성함으로써 구체적인 객체를 의존하게되면, 캡슐화가 깨질 뿐만 아니라 OCP(Open Close Principle)를 위배한다는 사실도 알 수 있다.

내가 말하고자 하는 것은 절대 DI 가 정답이라는 것은 아니다 ㅡ.ㅡ
단지, DI 가 필요한 이유에 대해서 소개하고 있는 것이다.
위에서 소개한 이슈를 감안하고서도 구체적인 객체를 의존할만하다고 Trade-Off 된다면 그 선택이 더 옳은 선택일 수 있다.

이러한 이유로 Client 객체는 의존하고자하는 객체를 생성하지 않고, 외부로부터 주입받기를 원하는 것이다.
다시말해 이러한 이유로 DI 가 필요한 것이다.

이번 포스팅에서 중요한 내용은 아니지만 이해를 돕기위해 앞서 소개한 세 가지 DI 방법을 코드를 통해 살펴보고 넘어가자.

생성자(Constructor) 주입방식

class MemberService(
	private val memberRepository: MemberRepository
) {
	
    @Transactional
    fun register(command: MemberRegisterCommand) {
    	// ...
    }
}
val memberService = MemberService(memberRepository)  // DI
memberService.register(command)

생성자 주입방식은 가장 흔히 사용되는 DI 방법이라고 생각한다.
해당 객체에서 지속적으로 다른 객체에 대한 의존성을 가지고 싶을 때 생성자 주입방식을 선택할 수 있다.

Setter 주입방식

// Spring Security 의 AbstractAuthenticationProcessingFilter 일부 발췌
open class AbstractAuthenticationProcessingFilter {
	// ...
	val successHandler: AuthenticationSuccessHandler = SavedRequestAwareAuthenticationSuccessHandler()
	// ...

	fun setAuthenticationHandler(successHandler: AuthenticationSuccessHandler) {
		assert(successHandler == null) { "successHandler cannot be null" }
		this.successHandler = successHandler
	}
}

인스턴스 변수의 Setter 를 통해 의존성을 주입하는 방법이다.

Setter 를 통해 의존성을 주입할 수 있는 시점은 런타임이다. 때문에 런타임 시점에 의존하는 객체가 지속적으로 바뀌어야하는 상황에서 주로 사용된다. 런타임 시점에 의존하고 있는 객체가 바뀐다는 것은 다른 구현객체로 확장(대체)됨을 의미하고, 당연히 추상화 타입을 의존하는 경우일 것이라 예상해볼 수 있다.

예시 코드로 발췌한 Spring Security 쪽 소스코드를 보면 기본적인 구현객체를 세팅해주고, Setter 주입방식을 통해 개발자가 원하는 또 다른 구현객체로 대체할 수 있는 케이스가 굉장히 많이 존재한다.

Method Argument 주입방식

class Member(
	val id: Long,
	val email: String,
	val password: String
) {

	fun passwordValidate(encoder: PasswordEncoder, password: String): Boolean {
		return encoder.matches(password, this.password)
	}
}

이게 무슨 의존성 주입이야? 라고 생각할 수 있겠지만 메소드 인자로 PasswordEncoder 객체를 전달하는 것 또한 의존성 주입방식 중 하나이다.
메소드 인자 주입방식은 일반적으로 해당 메소드에서 일시적으로 특정 객체와의 의존성이 필요한 경우에 주로 사용한다.


추상화 타입을 의존하기 위해서는 DI 가 필요하다?


추상화 타입에 대해서는 이전에 작성한 포스팅을 먼저 보고 오는 것을 추천한다.
캡슐화와 추상화 타입

이번 포스팅에서 말하고 싶은 것은 추상화 타입을 의존하기 위해서는 반드시 DI 가 필요하다는 것이다.
이를 이해하기 위해서는 "추상화 타입을 의존하는 이유는 무엇일까?" 에 대한 답을 알아야한다.

추상화 타입을 의존하는 이유에는 여러가지가 있겠지만 이번 포스팅의 맥락에서 중요한 것은,
실제로 의존할 구체적인 객체가 다른 객체로 확장(대체)되어도 그 변경에 유연한 구조를 만들 수 있기 때문이라는 것이다.

즉, 추상화 타입을 의존한다는 말은 구체적인 객체를 직접 의존하지 않겠다는 것이다.
하지만 결국 런타임에서 프로그램이 실행되려면 구체적인 객체가 필요한 것은 사실이다.
추상화 타입만 가지고는 프로그램이 동작할 수 없다.

new 로 객체를 직접 생성하지 않으면서 런타임 시점에는 구체적인 객체를 의존할 수 있는 방법으로 DI 가 사용되는 것이다.

물론 앞서 Setter 주입 방식에서 소개한 것과 같이 기본적으로 의존하는 구체적인 객체를 Default 로 두고 변경 가능하도록 선택지를 주는 구조도 있긴하다.


DI 와 Spring DI 의 차이


앞서 소개했듯이 순수한 DI 의 주입방식은 "생성자 주입방식, Setter 주입방식, 메소드 인자 주입방식" 으로 3가지다.

반면에 우리가 익숙하게 알고있는 Spring DI 의 주입방식은 "생성자 주입방식, Setter 주입방식, 필드 주입방식" 으로 3가지일 것이다.

필드 주입방식은 Spring 에서 @Autowired 를 이용해 의존성을 주입해주는 한 가지 기능일 뿐이다.
따라서 Spring 이 없는 세계에서는 필드 주입방식은 불가능하다.

(물론 Reflection 을 이용해 필드 주입방식을 구현할 수 있긴하겠지만.. 별로 자연스러운 방법은 아닐 것이다)

Spring DI 의 필드 주입방식을 사용하면 테스트 코드 작성에 어려움이 생긴다는 말을 들어보았을 것이다.

단위 테스트는 Spring 없이 객체를 생성해 메소드를 테스트하는 경우가 많고, 해당 객체의 의존성을 채워주기 위해 DI 를 해야하는데 해당 객체가 필드 주입방식을 하려한다면 Reflection 과 같은 부자연스러운 방법을 사용할 수밖에 없기 때문이다.

반면에, 생성자 주입은 Spring 이 없는 환경에서도 얼마든지 가능하기 때문에 단위 테스트 작성시 아무런 문제가 되지 않는다.

순수한 DI 와 Spring DI 가 하는 역할 자체는 동일하다.
또한 위에서 소개한 DI 를 하는 이유 자체도 동일하다.

하지만, 순수한 DI 에 대해서 이해하고 Spring DI 로 넘어오는 것과 그 반대로 학습하는 것에는 큰 차이가 있다고 생각한다.

Spring DI 는 단지 Spring Framework 위에서 개발할 때 조금 더 쉽게 DI 할 수 있도록 Framework 가 도와주기 위한 기능일 뿐이다.

따라서 당연히 순수한 DI 에 대한 이해를 먼저 하고나서 그에 대한 확장인 Spring DI 를 생각해야한다.
그래야 가장 위에서 언급했던 질문인 "IoC & DI 가 어떤 부분에서 객체지향적 설계에 도움이 될까요?", "IoC & DI 가 왜 필요하고 어떤 장점이 있죠?" 와 같은 질문에 명확한 답을 내리기 쉬워질 것이라 생각한다.

여기까지 내용을 읽었다면 DI 가 사실은 Spring 으로부터 나온 개념이 아니라 순수한 개념이었고, Spring 이 DI 를 보다 쉽게 할 수 있도록 Framework 차원에서 구현해준 기능이 Spring DI 라는 사실까지 이해했을 것이다.


Inversion of Control (IoC)


들어가기 전에 헷갈리지 말아야할 것 중 하나는 Inversion of Control(이하 IoC) 와 IoC Container 를 분리해서 생각해야한다는 것이다.

각각에 대해서 약간의 설명을 통해 이해도를 높여보자.

IoC 는 우리가 작성한 프로그램이 Framework 의 제어를 받게되는 디자인 패턴을 말한다.
사실 그 대상을 Framework 로 제한할 필요도 없을 것 같긴 하지만 이해를 돕기위해 제한적인 설명으로 작성했다.

IoC Container 는 IoC 를 구현한 것으로 우리가 흔히 알고있는 Spring IoC 를 떠올리면 이해가 쉽다.

Framework 에 객체를 등록하면 Framework 에 의해서 그 객체가 관리해주기 위한 것이 IoC Container 이다. Spring 에서 말하는 IoC 도 이러한 IoC Container 를 말하고, BeanFactory(ApplicationContext) 가 Spring 이 IoC 를 구현한 IoC Container 이다.

그렇다면 이러한 IoC Container 가 생김으로써 코드에 어떤 변화가 생기는지 간략한 코드를 통해서 살펴보자.

fun main() {
	val memberRepository = SimpleMemberRepository()
	val memberService = MemberService(memberRepository)  // Constructor Dependency Injection
}

class MemberService(
	private val memberRepository: MemberRepository
) {
	// ...
}

MemberService 입장에서 직접 객체를 생성하지 않았고 외부에서 생성된 구체적인 객체를 DI 받았다.
하지만, 결국 우리는 어디선가 이 객체를 생성하고 관리해주고 필요한 시점에 의존성 주입을 해줘야한다.

IoC Container 없이 전체 프로젝트에 대해서 이러한 작업을 해주려면 객체 사이의 계층이 생기는 등의 영향으로 인해 복잡도가 엄청나게 높아지게된다.

그럼 IoC Container 가 이러한 문제를 어떻게 해결해주는지 간략한 Spring 코드를 통해 이해해보자.

// 코드는 가장 익숙한 Spring 을 이용해 작성했다!
@Bean
fun memberRepository = SimpleMemberRepository()

@Service
class MemberService(
	private val memberRepository: MemberRepository
) {
	// ...
}

그렇다면 IoC Container 를 사용했을 때의 단점은 없을까?

객체 생성과 사용의 거리가 멀어져서 컴파일 시점에 에러를 발견할 수 없다는 단점이 있긴 하다. 하지만, 이 부분은 최근에는 IntelliJ 와 같은 IDE 의 지원을 받아 어느정도 해소가 가능한 것 같다.


이번 포스팅을 통해서 말하고 싶었던 것은 Spring IoC 의 동작방식이 아니다!
DI 와 마찬가지로 IoC 와 Spring IoC 를 분리해서 생각하자는 것이다.

이와 관련해서 마지막으로 정리해보자.

IoC 는 개념이고 그 개념을 Framework 가 구현해논 개념중 하나가 IoC Container 이다.
그리고 Spring Framework 에서도 이러한 IoC Container 를 구현해줬는데 우리가 말하던 Spring IoC 는 바로 이 것을 의미한다.


결론


많은 개발자들이 IoC & DI 가 Spring 에서 나온 개념이라고 오해한다.

IoC & DI 를 생각할 때 우선 Spring 을 빼고 개념자체를 생각했으면 좋겠다.
그래야 해당 개념들이 왜 필요한지 핵심을 바라볼 수 있고, 이 핵심이 Spring 으로 어떻게 이어지는지 연결지을 수 있게된다.

이번 포스팅에서 하고 싶었던 말 몇 가지를 정리하며 포스팅을 마무리 지어본다.

  1. DI 가 무엇이고 왜 필요한지를 먼저 이해할 필요가 있다.
  2. Spring 이 DI 를 어떻게 구현해놨고, 순수한 DI 와는 어떤 차이점이 있는지 이해할 필요가 있다.
  3. 1, 2번을 통해 DI 와 Spring DI 를 명확히 분리해서 생각하고, DI 의 필요성과 장점이 Spring DI 로 이어지는 맥락을 이해해야한다.
  4. IoC 와 IoC Container 를 분리해서 생각할 필요가 있다.
  5. Spring IoC Container 가 어떻게 구현되어있는지 살펴볼 필요가 있다.

2개의 댓글

comment-user-thumbnail
2023년 11월 20일

좋은 글 잘 읽었습니다. 간략히 요약하자면 IoC를 구현하기 위해 사용하는 개념이 DI가 되겠네요.
글에서 Inversion of Control, Dependency Injection 개념과 실제 Spring 에서 제공하는 구현을 분리해 생각해야 함을 지속적으로 강조해주고 계시는데, 스프링이 아닌 곳에서 IoC, DI 가 적용된 예는 어떤 것이 있을까요?

1개의 답글