프레임워크 유지 – 종속성 주입 패턴 탐색

게시 됨: 2022-03-11

IoC(Inversion of Control)에 대한 전통적인 관점은 서비스 로케이터와 DI(Dependency Injection) 패턴이라는 두 가지 접근 방식 사이에 경계선을 그리는 것 같습니다.

내가 아는 거의 모든 프로젝트에는 DI 프레임워크가 포함되어 있습니다. 사람들은 상용구 코드를 최소화하거나 전혀 사용하지 않고 클라이언트와 종속성(일반적으로 생성자 주입을 통해) 간의 느슨한 결합을 촉진하기 때문에 이 서비스에 끌립니다. 이는 빠른 개발에 유용하지만 일부 사람들은 코드를 추적하고 디버그하기 어렵게 만들 수 있다는 것을 알게 됩니다. "장면의 마법"은 일반적으로 반성을 통해 달성되며, 이는 전체 세트의 새로운 문제를 가져올 수 있습니다.

이 기사에서는 Java 8+ 및 Kotlin 코드베이스에 적합한 대체 패턴을 살펴보겠습니다. DI 프레임워크의 대부분의 이점을 유지하면서 외부 도구 없이 서비스 로케이터처럼 간단합니다.

동기 부여

  • 외부 의존성 피하기
  • 반사를 피하십시오
  • 생성자 주입 촉진
  • 런타임 동작 최소화

다음 예에서는 다양한 소스를 사용하여 콘텐츠를 얻을 수 있는 TV 구현을 모델링합니다. 다양한 소스(예: 지상파, 케이블, 위성 등)에서 신호를 수신할 수 있는 장치를 구성해야 합니다. 다음 클래스 계층을 구축할 것입니다.

임의의 신호 소스를 구현하는 TV 장치의 클래스 계층

이제 Spring과 같은 프레임워크가 우리를 위해 모든 것을 연결하는 전통적인 DI 구현으로 시작하겠습니다.

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

우리는 다음과 같은 사실을 알았습니다.

  • TV 클래스는 TvSource에 대한 종속성을 표현합니다. 외부 프레임워크는 이것을 보고 구체적인 구현(지상파 또는 케이블)의 인스턴스를 주입합니다.
  • 생성자 주입 패턴을 사용하면 대체 구현으로 TV 인스턴스를 쉽게 빌드할 수 있으므로 쉽게 테스트할 수 있습니다.

우리는 좋은 출발을 하고 있지만, 이를 위해 DI 프레임워크를 도입하는 것이 다소 무리일 수 있음을 알고 있습니다. 일부 개발자는 구성 문제(긴 스택 추적, 추적할 수 없는 종속성) 디버깅 문제를 보고했습니다. 우리 고객은 또한 제조 시간이 예상보다 조금 더 길며 우리 프로파일러는 반사 호출에서 속도가 느려진다고 표현했습니다.

대안은 Service Locator 패턴을 적용하는 것입니다. 간단하고 리플렉션을 사용하지 않으며 우리의 작은 코드베이스에 충분할 수 있습니다. 또 다른 대안은 클래스를 그대로 두고 클래스 주변에 종속성 위치 코드를 작성하는 것입니다.

많은 대안을 평가한 후 이를 공급자 인터페이스 계층으로 구현하기로 결정했습니다. 각 종속성은 클래스의 종속성을 찾고 주입된 인스턴스를 구성하는 유일한 책임을 지는 관련 공급자를 갖습니다. 우리는 또한 사용하기 쉽도록 공급자를 내부 인터페이스로 만들 것입니다. 각 공급자가 종속성을 찾기 위해 다른 공급자와 혼합되기 때문에 Mixin 주입이라고 합니다.

내가 이 구조를 결정한 이유에 대한 자세한 내용은 세부 정보 및 근거에서 자세히 설명하지만 여기에 짧은 버전이 있습니다.

  • 종속성 위치 동작을 분리합니다.
  • 인터페이스를 확장하는 것은 다이아몬드 문제에 속하지 않습니다.
  • 인터페이스에는 기본 구현이 있습니다.
  • 종속성이 없으면 컴파일이 불가능합니다(보너스 포인트!).

다음 다이어그램은 종속성과 공급자가 상호 작용하는 방식을 보여주고 구현은 아래에 설명되어 있습니다. 또한 종속성을 구성하고 TV 개체를 구성하는 방법을 보여주기 위해 기본 메서드를 추가합니다. 이 예제의 더 긴 버전은 이 GitHub에서도 찾을 수 있습니다.

공급자와 종속성 간의 상호 작용

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

이 예에 대한 몇 가지 참고 사항:

  • TV 클래스는 TvSource에 의존하지만 어떤 구현도 알지 못합니다.
  • TV.Provider는 TvSource를 빌드하기 위해 tvSource() 메서드가 필요하기 때문에 TvSource.Provider를 확장하며 구현되지 않은 경우에도 사용할 수 있습니다.
  • 지상파 및 케이블 소스는 TV에서 서로 바꿔서 사용할 수 있습니다.
  • Terrestrial.Provider 및 Cable.Provider 인터페이스는 구체적인 TvSource 구현을 제공합니다.
  • main 메소드에는 TV 인스턴스를 가져오는 데 사용되는 TV.Provider의 구체적인 구현 MainContext가 있습니다.
  • 프로그램은 TV를 인스턴스화하기 위해 컴파일 타임에 TvSource.Provider 구현이 필요하므로 Cable.Provider를 예로 포함합니다.

세부사항 및 근거

우리는 행동 패턴과 그 이면의 추론을 보았습니다. 당신은 지금까지 그것을 사용해야 한다고 확신하지 못할 수도 있고 당신이 옳을 것입니다. 그것은 정확히 은색 총알이 아닙니다. 개인적으로는 대부분의 면에서 서비스 로케이터 패턴보다 우월하다고 생각합니다. 그러나 DI 프레임워크와 비교할 때 장점이 상용구 코드를 추가하는 오버헤드를 능가하는지 평가해야 합니다.

공급자는 종속성을 찾기 위해 다른 공급자를 확장합니다.

공급자가 다른 공급자를 확장하면 종속성이 함께 바인딩됩니다. 이는 잘못된 컨텍스트 생성을 방지하는 정적 유효성 검사의 기본 기반을 제공합니다.

서비스 로케이터 패턴의 주요 문제점 중 하나는 종속성을 해결하는 일반 GetService<T>() 메서드를 호출해야 한다는 것입니다. 컴파일 시 종속성이 로케이터에 등록된다는 보장이 없으며 프로그램이 런타임에 실패할 수 있습니다.

DI 패턴도 이 문제를 다루지 않습니다. 종속성 해결은 일반적으로 사용자에게 대부분 숨겨져 있는 외부 도구에 의한 리플렉션을 통해 수행되며 종속성이 충족되지 않으면 런타임에도 실패합니다. IntelliJ의 CDI(유료 버전에서만 사용 가능)와 같은 도구는 일정 수준의 정적 검증을 제공하지만 주석 전처리기가 있는 Dagger만이 의도적으로 이 문제를 해결하는 것으로 보입니다.

클래스는 DI 패턴의 일반적인 생성자 주입을 유지합니다.

이것은 필수는 아니지만 개발자 커뮤니티에서 확실히 원합니다. 한편으로는 생성자만 보고 클래스의 종속성을 즉시 확인할 수 있습니다. 다른 한편, 그것은 많은 사람들이 고수하는 종류의 단위 테스트를 가능하게 합니다. 그것은 종속성의 모형으로 테스트 대상을 구성함으로써입니다.

그렇다고 다른 패턴이 지원되지 않는다는 것은 아닙니다. 사실, Mixin Injection이 테스트를 위한 복잡한 종속성 그래프 구성을 단순화한다는 것을 알게 될 수도 있습니다. 주제의 공급자를 확장하는 컨텍스트 클래스만 구현하면 되기 때문입니다. 위의 MainContext 는 모든 인터페이스에 기본 구현이 있으므로 빈 구현을 가질 수 있는 완벽한 예입니다. 종속성을 바꾸려면 해당 공급자 메서드를 재정의하기만 하면 됩니다.

TV 수업에 대한 다음 테스트를 살펴 보겠습니다. TV를 인스턴스화해야 하지만 클래스 생성자를 호출하는 대신 TV.Provider 인터페이스를 사용하고 있습니다. TvSource.Provider에는 기본 구현이 없으므로 직접 작성해야 합니다.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

이제 TV 클래스에 다른 종속성을 추가해 보겠습니다. CathodeRayTube 종속성은 TV 화면에 이미지가 나타나도록 하는 마법과 같은 역할을 합니다. 향후 LCD 또는 LED로 전환할 수 있기 때문에 TV 구현에서 분리됩니다.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

이렇게 하면 방금 작성한 테스트가 여전히 예상대로 컴파일되고 통과한다는 것을 알 수 있습니다. TV에 새로운 종속성을 추가했지만 기본 구현도 제공했습니다. 즉, 실제 구현을 사용하려는 경우 이를 모의할 필요가 없으며 테스트에서 원하는 모의 세분성 수준으로 복잡한 개체를 만들 수 있습니다.

이것은 복잡한 클래스 계층(예: 데이터베이스 액세스 계층만)에서 특정한 것을 모의하려는 경우에 유용합니다. 이 패턴을 사용하면 때때로 혼자 있는 테스트보다 선호되는 일종의 사교적인 테스트를 쉽게 설정할 수 있습니다.

귀하의 선호도에 관계없이 각 상황에서 귀하의 요구에 더 잘 맞는 모든 형태의 테스트로 전환할 수 있다는 확신을 가질 수 있습니다.

외부 종속성 피하기

보시다시피 외부 구성 요소에 대한 참조나 언급이 없습니다. 이것은 규모나 보안 제약이 있는 많은 프로젝트의 핵심입니다. 또한 프레임워크가 특정 DI 프레임워크에 커밋할 필요가 없기 때문에 상호 운용성에도 도움이 됩니다. Java에서는 호환성 문제를 완화하는 JSR-330 Java Standard용 종속성 주입과 같은 노력이 있었습니다.

반사를 피하십시오

서비스 로케이터 구현은 일반적으로 리플렉션에 의존하지 않지만 DI 구현은 리플렉션을 사용합니다(Dagger 2의 주목할만한 예외 제외). 이것은 프레임워크가 모듈을 스캔하고, 종속성 그래프를 해결하고, 객체를 반사적으로 구성해야 하기 때문에 애플리케이션 시작 속도를 늦추는 주요 단점이 있습니다.

Mixin Injection을 사용하려면 서비스 로케이터 패턴의 등록 단계와 유사하게 서비스를 인스턴스화하는 코드를 작성해야 합니다. 이 약간의 추가 작업으로 반사 호출이 완전히 제거되어 코드가 더 빠르고 간단해집니다.

최근 내 관심을 끌고 반사를 피함으로써 혜택을 받은 두 개의 프로젝트는 Graal의 Substrate VM과 Kotlin/Native입니다. 둘 다 네이티브 바이트 코드로 컴파일되며 이를 위해서는 컴파일러가 사용자가 수행할 모든 반사 호출을 미리 알아야 합니다. Graal의 경우 작성하기 어렵고 정적으로 확인할 수 없으며 자주 사용하는 도구를 사용하여 쉽게 리팩토링할 수 없는 JSON 파일에 지정됩니다. 처음부터 리플렉션을 피하기 위해 Mixin Injection을 사용하는 것은 네이티브 컴파일의 이점을 얻는 좋은 방법입니다.

런타임 동작 최소화

필요한 인터페이스를 구현하고 확장하여 종속성 그래프를 한 번에 하나씩 구성합니다. 각 공급자는 프로그램에 순서와 논리를 가져오는 구체적인 구현 옆에 있습니다. 이러한 종류의 레이어링은 이전에 Mixin 패턴이나 Cake 패턴을 사용한 적이 있는 경우 익숙할 것입니다.

이 시점에서 MainContext 클래스에 대해 이야기할 가치가 있습니다. 종속성 그래프의 루트이며 큰 그림을 알고 있습니다. 이 클래스는 모든 공급자 인터페이스를 포함하며 정적 검사를 활성화하는 데 중요합니다. 예제로 돌아가서 구현 목록에서 Cable.Provider를 제거하면 다음이 명확하게 표시됩니다.

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

여기서 일어난 일은 앱이 사용할 구체적인 TvSource를 지정하지 않았고 컴파일러가 오류를 포착했다는 것입니다. 서비스 로케이터와 리플렉션 기반 DI를 사용하면 모든 단위 테스트가 통과하더라도 프로그램이 런타임에 충돌할 때까지 이 오류가 눈에 띄지 않을 수 있습니다! 패턴을 작동시키는 데 필요한 상용구 작성의 단점보다 이러한 이점과 우리가 보여준 다른 이점이 더 크다고 생각합니다.

순환 종속성 잡기

CathodeRayTube 예제로 돌아가서 순환 종속성을 추가해 보겠습니다. TV 인스턴스를 주입하기 위해 TV.Provider를 확장한다고 가정해 보겠습니다.

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

컴파일러는 순환 상속을 허용하지 않으며 우리는 이러한 종류의 관계를 정의할 수 없습니다. 대부분의 프레임워크는 이런 일이 발생하면 런타임에 실패하고 개발자는 프로그램을 실행하기 위해 이를 해결하는 경향이 있습니다. 이 안티 패턴은 실제 세계에서 찾을 수 있지만 일반적으로 잘못된 디자인의 표시입니다. 코드 컴파일에 실패하면 너무 늦기 전에 더 나은 솔루션을 찾도록 권장해야 합니다.

객체 구성의 단순성 유지

DI보다 SL을 선호하는 주장 중 하나는 디버깅이 간단하고 쉽다는 것입니다. 예제에서 종속성을 인스턴스화하는 것은 공급자 메서드 호출의 체인일 뿐이라는 것이 분명합니다. 종속성의 소스를 역추적하는 것은 메서드 호출을 시작하고 최종 위치를 확인하는 것만 큼 간단합니다. 제공자에서 바로 종속성이 인스턴스화되는 위치를 정확하게 탐색할 수 있기 때문에 디버깅은 두 가지 대안보다 간단합니다.

서비스 수명

주의 깊은 독자는 이 구현이 서비스 수명 문제를 해결하지 못한다는 것을 알아차렸을 것입니다. 공급자 메서드에 대한 모든 호출은 새 객체를 인스턴스화하여 이를 Spring의 Prototype 범위와 유사하게 만듭니다.

이것 및 기타 고려 사항은 이 기사의 범위를 약간 벗어납니다. 저는 단지 세부 사항을 산만하게 하지 않고 패턴의 본질을 제시하고 싶었기 때문입니다. 그러나 제품의 전체 사용 및 구현은 평생 지원이 포함된 전체 솔루션을 고려해야 합니다.

결론

의존성 주입 프레임워크에 익숙하거나 고유한 서비스 로케이터를 작성하는 데 익숙하다면 이 대안을 탐색할 수 있습니다. 방금 본 믹스인 패턴을 사용하여 코드를 더 안전하고 추론하기 쉽게 만들 수 있는지 확인하십시오.

관련 항목: JS 모범 사례: TypeScript 및 종속성 주입을 사용하여 Discord 봇 빌드