기록보다 기억을

September 04, 2021

의존성 주입의 짤막한 역사

의존성 주입의 짤막한 역사

A Brief History of Dependency Injection - MVP Java

  • 이 포스트는 위 링크의 A Brief History of Dependency Injection를 번역한 글이다.

의존성 주입의 역사

의존성 주입에 대해 처음 들어봤고 그게 왜 프로젝트에 그렇게 많이 쓰이는지 궁금하다면, 의존성 주입의 짤막한 역사를 공부해보는 것이 좋을 것이다. 이 이야기는 아주아주 오래 전에 있었던 new 키워드로부터 시작한다…

의존성 주입의 역사

이론물리학자 스티븐 호킹은 그의 명저 <시간의 역사> 에서 그는 그 유명한 수식 e=mc^2만을 사용하여 우주의 역사를 설명한다. 나는 그의 작업에 영감을 받아 우리가 오늘날 의존성 주입(Dependency Injection)이라 불리는 것에 어떻게 도달하게 되었는지에 대한 역사적 조망을 해보려 한다.

이 포스트의 후반부에서는 오늘날 가장 많이 쓰이는 의존성 주입 프레임워크인 스프링 프레임워크(Spring Framework) 얘기를 많이 할 것이다(스프링이 싫다면 죄송). 역사가 승자에 의해 쓰여진다고는 하지만, 구글 주스(Goole Guice)에 대해서는 특별히 언급을 해야겠다. 물론 그 외에 다른 의존성 주입 프레임워크도 더 많이 있지만 그건 여기서 할 말은 아니다.

혹시 구글 주스가 작동하는 방식에 흥미가 있다면 아래의 영상을 참조해라…

https://youtu.be/wNclLOTxQjk

*역주: 스프링의 의존성 주입을 이해하고 사용해본 적 있다면 굳이 볼 필요 없음

의존성 주입 이전의 자바

우린 모두 new 키워드에 대해서는 잘 안다. 우리는 모두 new 키워드로 우리가 만든 객체들에 생명을 불어넣지 않는가? 인공위성(Satellite) 객체를 만들고 이름을 변수로 전달하는 예제를 살펴보자.

Satellite radarsat2 = new Satellite("Radarsat-2");

그저 객체를 만들기만 하는 것은 정말 쉽다. 하지만 우리는 여기서 값 객체(Value Object)와 같은 단순한 객체를 만드는 것이 아니고 뭔가 더 복잡한 객체를 만드는 중이다. 그것이 인공위성이든 자동차든 집이든 회사든 요점은 객체가 제대로 만들어져서 쓸모가 있기 위해 추가적인 작업들이 필요하다는 점이다.

인공위성이 제대로 작동하기 위해서는 유용한 서브시스템(sub-systems)들이 필요하다. 전력, 행동 제어, 통신, 온도 등 필요한 서브시스템들을 인공위성에 추가해보자

// i.e: SubSystem is an Interface type
SubSystem power = new Power();
SubSystem ac = new AttitudeControl ();
...
Satellite radsat2 = new Satellite("Radarsat-2");
radsat2.addSubSystem(power);
radsat2.addSubSystem(ac);

이제 객체를 생성한다는 것이 얼마나 복잡한지 아시겠지? 아직 모든 서브시스템들을 나타내지도 않았고 제대로 추가한 것도 아니다. 위의 예제에서는 서브시스템을 추가하는 코드를 한 줄씩 딱 떨어지도록 단순화했다. e=mc^2처럼 한 줄로 설명하기는 그른 셈이다.

요점은 객체를 위의 예제에서와 같이 직접 생성한다면 애플리케이션에서 객체를 여러 번 만들 때 너무 복잡해진다는 것이다. 테스트할때 역시 이 모든 객체를 직접 만들어야된다는 사실도 잊지 말자.

스파이더맨이 말한 것처럼 큰 힘에는 큰 책임이 따른다(스파이더맨은 그런 말 안했음). 또 다른 주목할 점은 new 키워드를 쓸 때마다 코드가 구현에 강하게 결합된다는 점이다.

또한 객체 생성의 책임은 코드 베이스와 섞여서 유지보수를 힘들게 한다. 나중에 객체가 생성되는 부분에 변경이 있을 때 어디어디를 바꿔야하는지 하나하나 찾아서 바꿔야한다. 테스트 부분도 역시 바꿔야 한다는 사실도 잊지 말자!

생성(Creation)의 디자인 패턴

디자인 패턴이라는 개념은 이미 있었지만, 바야흐로 1994년에 GoF(사인방, Gang of Four)라는 네 명의 구루들이 디자인 패턴(Design Patterns: Elements of Reusable Object-Oriented Software) 이라는 책을 출간하였다.

이 책에 나오는 패턴들은 위에서 언급한 문제들을 해결하기 위해 사용되었고 자바 진영에서도 그 주요 기법들을 사용하게 되었다. 디자인 패턴들의 여러 항목 중에서 생성 분야가 여기서의 주제와 관련이 있다. 생성과 관련한 디자인 패턴은

  • 싱글톤 패턴Singleton Pattern
  • 팩토리 패턴Factory Pattern
  • 추상 팩토리 패턴Abstract Factory Pattern
  • 프로토타입 패턴Prototype Pattern
  • 빌더 패턴Builder Pattern

등이 있을 것이다. 각각의 패턴은 각자의 용도가 있지만 결국엔 객체 생성의 책임을 팩토리 클래스(Factory Class)에게로 제한하는 것이 목적이다.

팩토리 클래스에게 책임을 전가함으로써 클라이언트 코드에서 new 키워드를 제거하고 팩토리 클래스 안으로 격리하는 것이다. 이러한 분리는 어떤 좋은 점이 있냐면…

  • 객체 생성부를 변경하기 쉽게 만들어 유지보수하기 쉽고 유연하게 만든다.

  • 테스트하기 좋아진다(의존성을 모킹하기 훨씬 쉽다).

    우리가 위의 인공위성 예제를 팩토리 디자인 패턴으로 풀어보자.

/* Now using Satellite Interface as an Abstraction */
public class Radarsat2 implements Satellite
@Override
public void dumpStoredTelemetry(){
/* implementation details */
...
}
....
}
/* The Factory Class producing the Satellite.
Notice the new keyword has been removed from
client code.
*/
public class SatelliteFactory{
public Satellite createSatellite (String satelliteName)
{
if (satelliteName.equal("Radarsat-2"))
{
Satellite radsat2 = new Radarsat2();
radsat2.addSubSystem(new Power());
radarsat2.addSubSystem(new AttitudeControl());
...
return radsat2;
}
else if (satelliteName.equals("Scisat-1"))
{
Satellite scisat1 = new Scisat1();
...
}
...
}
}

클라이언트 코드에서 팩토리 사용하기

이 팩토리 클래스들을 클라이언트 코드에서 어떻게 사용할 수 있을까? 몇 가지 방법들이 고안되었고 그 중 일부는 팩토리 클래스를 생성하기 위해 new 키워드를 사용한다. 그런데 우리 new는 쓰지 않기로 하지 않았나? 음… main() 메서드 같은 곳에서는 new 키워드를 쓰는 것이 보통 허용되긴 하지만 그 말이 맞기는 맞다.

다른 방법들은 new 키워드를 쓰지 않기 위해 getInstance() 같은 정적 메서드를 사용한다.

팩토리는 이제 여기저기서 사용되기 시작했다. 이제 개발자들은 팩토리의 생성자나 세터에 의존성을 명시적으로 선언한 다음 마구 생성해서 클라이언트 코드에 넘겨주기 시작했다. 당연히 나은 추상화 수준을 갖게 되었지만 이 팩토리들을 유지보수하고 하나하나 주입해주는건 완전 구닥다리 90년대 스타일이다!

그러니 시계바늘을 달리게 해야할 것인데, 우선 이보전진을 위해 일보후퇴를 해보자. 객체 생성과 실행 호출 그래프(execution call graph) 사이에 우리가 놓치고 있는 것이 있다.

대따 큰 컨텍스트 팩토리

2000년대 초반에 우린 거대한 팩토리들을 갖게 되었다. Context, Locator, Register…(J2EE가 가장 유명하고 이런 짓을 많이 했다) 이 팩토리들로 램프의 지니에게 소원을 비는 것처럼 엄청나게 많은 객체들을 생성할 수 있게 되었다.

하지만 J2EE 뿐만이 아니고 다른 사람들도 이런 프레임워크를 엄청 많이 만들어서, 결국 표준이 필요하게 되었다.

J2EE(오늘날 Java EE로 알려진)는 자바 엔터프라이즈 환경에서는 꽤나 독점적이었기에 개발자들은 선택의 여지가 없었다. J2EE를 배우는 것은 너무 복잡해서 hello world를 찍는 것조차 힘들었다 진짜로. 지금은 Java EE가 되어서 완전 나아졌다. 이는 2002-2007년 사이에 생겨나서 Java EE와 경합했던 많은 다른 자바 프레임워크들 덕분이다.

개발자들은 각자의 애플리케이션 설계를 J2EE에 맞게 맞춰야 했다. 그러기 위해 거대하고 모든 정보를 알고 있는 팩토리 클래스와, 필요도 없는 다른 객체들에게 전역 접근이 가능한 많은 클래스들을 통제할 방법을 찾아내야했다.

빈 생성자(Empty Constructor)를 통해 객체를 생성하는 것은 쉬워졌으나 내부적으로는 대따 큰 팩토리를 통해서 생성되는 것이었다(그리고 아무도 그 대따 큰 팩토리의 내부를 보고싶어하지 않았다). 이것들이 결국 몇 가지 문제를 만들어내었으니.

  • 의존성을 모킹mocking하기가 어려워 테스트가 힘들었다. 그 때는 지금처럼 테스트가 중요하게 생각되지 않기는 했다.

  • 객체가 어떻게 작동하는지 불투명했다. 생성자나 메서드에서 다른 객체에 대해 몰래 접근했기 때문이다. 작동을 이해하기 위해 Javadoc은 쓸모없었고, 코드 속으로 뛰어들어야 내부의 추악한 진실을 볼 수 있었다.

  • 다른 프로젝트에서 클래스를 재사용하기 어려웠다. 클래스 재사용을 하려면 그게 필요 없더라도 팩토리들이 참조하는 모든 의존성을 전부 가져와야 했다.

    전역 접근가능한 상태(global accessible state) 에 접근하는 객체 생성자의 예제를 하나만 볼까? (이러한 코드를 여전히 많이 볼 수 있다)

public MyConstructor(){
//globally accessible via public static method
//Thanks Singleton design pattern!
collaboratorZ = MegaContext.getInstance().
getCollaboratorY().
getCollaboratorZ();
}

위의 예제에서 팩토리(MegaContext)는 너무 많은 것에 관여하고 엉뚱한 것을 요청한다. 이 대따 큰 팩토리는 CollaboratorZ를 직접 요청하는 것이 아니라, getCollaboratorZ()라는 방식으로 간접적으로 호출한다. 이건 사용하기도 쉽지 않았다. 결국 이 모든 사단을 지긋지긋해했던 어떤 똑똑한 사람들이 변화를 이끌어내기 시작했다.

의존성 주입 프레임워크의 여명

마침내 2003년 경량급 프레임워크가 등장했다. 스프링 프레임워크에 대해 다들 들어는 봤을 것이다. 뭐, 구글 주스도 있었지만 2007년까지 외부로 공개되지 않고 구글에서만 사용되었다.

간단히 말해서 스프링이나 구글 주스와 같은 DI 프레임워크는 팩토리처럼 작동한다. 이 말인즉 팩토리 코드를 더 이상 직접 작성하거나 유지보수하지 않아도 된다는 말이다. 최고지? 이 점은 걱정할 거리를 또 하나 줄여주는데, 멍청하고 반복적인 코드를 작성할 필요가 없다는 점이다. 오직 해야할 일은 DI 프레임워크에 생성하고싶은 객체를 등록해서 언제 생성하면 되는지 알려주기만 하는 것이다. 그러면 프레임워크가 알아서 필요한 때에 객체를 주입해준다.

여기서부터는 스프링 프레임워크(의존성 주입 기능에 국한하여)를 중심으로 설명할 것이다.

첫 번째 물결, XML을 통한 주입

처음엔 객체들을 XML을 통해 주입했다. 스프링은 XML의 <bean> 엘리먼트를 통해 팩토리에서 관리할 객체를 지정했다. 이 설정은 클라이언트 코드에게는 완전히 외부적인 것이었다.

// imagine a file named "satellite-beans.xml"
<beans>
<bean id="radsat2" class="com.mvpjava.Radasat2">
<constructor-arg ref = "powerSubsystem" >
<constructor-arg ref = "attitudeControlSubsystem">
<bean>
....
//other satellite objects and SubSystems via <bean>
<beans>

위의 XML은 Radarsat2 클래스를 스프링 빈으로 등록하고(이제 스프링의 통제하에 있다) 어떻게 인스턴스화 할 것인지와 생성될 때 어떤 객체와 관계를 가질지에 대한 가이드를 제공한다. 클라이언트 코드는 아래 예시와 같이 프레임워크에게 의존성을 주입해달라고 요청하면 된다.

ApplicationContext context = new ClassPathXmlApplicationContext("satellite-beans.xml");
Satellite r2 = context.getBean("radsat2", Satellite.class);

음… 이건 완벽하지는 않지만 이건 경량급 솔루션(lightweight solution)이기 때문에 J2EE에 비하면 엄청난 발전이었다. 이건 무거운 컨테이너에 대한 어떠한 의존성이 필요없는 POJO로 만들 수 있었고 다른 프로젝트에서도 재사용될 수 있었으며 침투적이지도 않았다. 그래서 모두가 스프링을 좋아했다.

문제는 XML이 너무 장황해지고 너무 많은 XML파일이 필요해진다는 점이었다. XML은 가독성도 별로였다. XML을 읽으며 객체들이 어떻게 얽혀있는지 아는 것은 상당히 복잡한 일이었다.

두 번째 물결: 애노테이션

애노테이션은 JDK 1.5에서 공식적으로 처음 탄생했고 스프링과 같은 프레임워크는 애노테이션을 의존성 주입에 바로 도입했다. 마침내 클라이언트 코드와 컨텍스트 오브젝트(Context Object) 사이에 접착제가 생긴 것이다. 이제 우리는 정말 팩토리도 필요없이 요청하기만 하면 된다

//constructor
@Autowired
public Radarsat2 (Power power){
this.power = power;
}

스프링의 @Autowired 애노테이션(구글 주스에서는 @Inject다)은 생산성을 높이는데 도움을 주었다. 애노테이션은 깔끔하고 생성자, 세터, 필드에 주입된 의존성을 명시적으로 설명하는 문서 역할까지 한다. 위의 예제는 스프링 DI 프레임워크에게 스프링 빈으로 등록된 Power 타입의 객체를 주입하라고 요청하는 것이다.

여전히 객체를 등록하는데 XML을 쓸 수 있지만 이제 우리는 애노테이션으로 빈을 등록할 수 있게 되었다. 원하는 POJO 객체에 @Component 애노테이션을 붙이기만 하면 짠! 스프링 컨테이너가 스캔해서 스프링 빈으로 등록해준다.

제어의 역전 Inversion of Control

DI 프레임워크는 @Autowired 를 통해서 팩토리를 만들어주고 객체에 대해 new 키워드를 수행해준다. 그리고 요청한 객체를 완전히 초기화된 상태로 생성해서 돌려준다. 객체가 의존성을 띠고 있다면 DI 프레임워크가 의존성을 제공하는 것이다.

당신은 더 이상 이 모든 과정에 책임이 없다. 책임은 DI 프레임워크가 진다. 통제(control)자가 당신에게서 프레임워크로 역전된 것이다. 그래서 제어의 역전IoC라는 말이 여기서 나온다.

이렇게 되면 디미터 법칙을 지키기 쉬워진다. 테스트도 쉬워지고 로직에 관여하는 객체들을 식별하는 일이 쉬워진다.

하지만 이것도 완벽하지는 않았다. 어떤 사람들은 DI 설정이 애노테이션과 함께 여기저기 흩뿌려져 있는 것이 별로라고 생각했다. 그들은 (XML이 그랬던 것처럼) DI 설정이 코드 베이스와 분리되어있기를 원했다. 또한 의존성 중 어떤 것들이 주입될지를 프로그래밍을 통해 결정하고 싶었다(예를 들어 토요일에는 이 객체를 주입하라든가).

세 번째 물결: 순수 자바 코드를 통한 의존성 주입

마침내 스프링에게 어떻게 객체를 생성할지를 순수한 자바 코드를 통해 지시할 수 있게 되었다. 바로 JavaConfig라는 방식이다(구글 주스는 처음부터 이 방식으로 시작했다). 이 방식을 처음 들었을 때 깜짝 놀랐는데, 결국 360도 돌아서 다시 처음으로 돌아온 것처럼 느껴졌기 때문이다! 이 모든 것을 다시 코딩하는게 무슨 의미가 있지? 나는 당시에는 구글 주스를 몰랐기 때문에 내게 이건 완전 새로운 생각이었다.

자바 코드로 의존성을 규정하는 것은 훌륭한 생각으로 밝혀졌다. 이건 팩토리 코드와는 상관없이 그저 DI 컨테이너에서 어떻게 객체를 생성할지를 자바 코드로 나타내는 것이었고 자연스러웠다. 핵심은 이 코드가 완전히 클라이언트 코드와 별개라는 점이었고(XML처럼) 그렇게 위에서 언급한 DI 설정의 분산 문제를 해결했다.

JavaConfig가 풀어낸 다른 문제는 의존성 주입에 대한 프로그래밍 가능한 결정을 내릴 수 있게 되었다는점이다. JavaConfig는 Java 코드였기 때문에 if문만 추가하면 되는 일이었다.

JavaConfig의 예제가 있다. @Configuration 애노테이션을 필요로 한다. (스프링이 관리하는 객체인) 스프링 빈은 @Bean 애노테이션이 필요하다. 빈 id는 보통 메소드의 이름이 된다.

//this Class is external to your client code
@Configuration
public class SatelliteConfig
{
@Bean
public Satellite radarsat2()
{
//remember this code? Its back!
Satellite radsat2 = new Radarsat2();
radsat2.addSubSystem(new Power());
radarsat2.addSubSystem(new AttitudeControl());
return radsat2;
}
@Bean
.....
}

클라이언트 코드에서 Satelite 타입의 의존성에 @Autowired 애노테이션을 쓴다면, 위의 코드에서 작성한 radarsat2 메서드로 만들어진 객체를 얻을 수 있게 될 것이다. 이렇게 XML지옥에서 벗어나 DI 설정을 외부화 하면서 Java 세계로 돌아오게 된 것은 좋은 일이다. 처음엔 낯설었지만 지금은 나도 JavaConfig만 사용한다.

요약

중요한 것은 이제 거의 모든 new 연산자가 DI 프레임워크가 관리하는 팩토리에 속해있게 되었다는 것이다. 프레임워크는 객체 생성 그래프를 관리한다. 한 번 애플리케이션이 시작되면, DI 프레임워크는 클라이언트 코드가 요청하는 의존성들을 주입해준다.

객체들이 모두 주입되고 나면 실행 호출 그래프가 시작된다. 이 두 그래프를 분리함으로써, 애플리케이션의 와이어링이나 행동을 쉽게 변경할 수 있고 테스트도 쉬워진다.

이상이 역사적 관점에서 본 의존성 주입에 대한 내 견해다. 디테일은 다를 수 있지만 요지는 이렇다는 말이다.

*역주: 구글 주스는 애노테이션 기반 DI전략으로 스프링에 도전했고 구글 주스 덕분에 스프링에 애노테이션 기반의 DI가 도입되었다.

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=seotorm&logNo=10047649745

post
공부가 싫어