의존성을 직접 주입하면 실제로 개선되는걸까?
에반 • 피플팀에서 채용을 담당하고 있어요
안녕하세요 백엔드 미트 팀 마루입니다!
테스트 Warning 로그 중 Injector를 주입하는 경우, ~6500%까지 느려질 수 있다 라는 문구를 보고 근본적인 원인 파악과 실험을 하게되었습니다! (Guice는 구글에서 제공하는 DI 프레임워크입니다.)
Guice 공식 문서에도 ‘injecting the injector’를 지양하라는 내용만 적혀있고, 구체적인 이유가 적혀 있지 않아 원인 파악을 위해 디버깅 및 실험을 하게 되었습니다.
본격적으로 내용을 살펴보기에 앞서, Guice의 Injector 주입 문제를 이해하는 데 필요한 핵심 용어를 정리하고, 의존성 주입 방식을 비교해보며 개념을 정리해보겠습니다.
클래스 간의 의존성은 단방향으로, 그래프를 형성할 수 있습니다. Guice에서는 의존성을 해결하는데 필요한 정보를 Module(모듈)에 정의합니다. 이때 인터페이스와 구현체를 매핑하는 것을 binding이라고 합니다. 즉, Module에 정의된 binding을 보고 인스턴스를 생성할 때 정확히 어떤 의존성을 주입할지 결정합니다.
injector가 인스턴스를 생성하기 위해서는 binding이 필요합니다. module 안에 등록된 binding을 explicit binding(명시적 바인딩)이라고 합니다. explicit binding이 없는 경우, injector는 Just-In-Time(JIT) binding을 시도합니다.
[참고] Guice가 생성자 주입이 가능하다고 판단하는 경우는 다음과 같습니다.
@Inject
어노테이션을 명시적으로 사용한 경우:JIT binding은
@Inject
어노테이션이 없으면 여러 개 생성자가 있는 클래스에서는 작동할 수 없습니다non-private 기본 생성자가 non-private class에 있는 경우:
private class의 private 생성자도 지원하지만, reflection 때문에 느립니다
JIT binding이 일어날 때, jitBindingData에 락을 걸기 때문에 성능 저하 이슈가 발생할 수 있습니다. 또한, JIT binding은 의존성이 런타임에 binding 되므로 explicit binding 의존성 추적이 어려우므로 explicit binding을 사용하는 것이 좋습니다. (참고)
Injector가 갖고 있는 JIT binding과 explicit binding의 결과입니다. injector는 의존성을 해결할 때 해당 dependency graph(의존성 그래프)를 참고합니다!
기본 용어를 정리했으니, 본격적으로 Guice에서 발생한 워닝 이슈와 성능 저하의 근본 원인에 대해 구체적으로 분석해보겠습니다.
먼저 해당 warning 에 대해서 좀 더 알아가 보겠습니다.
해당 warning 에서는 @Assisted Provider<T>
, Injector
를 주입하는 대신 의존 객체를 @Assisted T
로 직접 주입하는 것을 권장한다고 나와있습니다. 이러한 권장 사항은 Guice 내부에서 AssistedInject를 처리하는 방식과 관련된 성능 최적화를 위한 것입니다.
특히 FactoryModuleBuilder와 Assisted를 사용하는 경우, Guice가 의존성을 해결하는 과정에서 어떤 일이 벌어지는지를 이해하면 이 이유를 명확히 알 수 있습니다. 아래 예시를 통해 구체적으로 살펴보겠습니다.
위와 같이 FactoryModuleBuilder와 Assisted를 사용하는 경우, Guice의 Factory는 Assisted 의존성을 해결하기 위해 parent injector를 상속받는 child injector를 생성합니다. 즉, Factory로 객체를 생성할 때마다 child injector가 필요합니다.
이때 Assisted로 주입 받는 값들은 런타임에 결정되는 동적 의존성입니다. 동적 의존성은 애플리케이션을 띄울 때 작성하는 Module에 명시적으로 바인딩(explicit binding)되어 있지 않기 때문에, Guice는 JIT(Just-In-Time) binding 을 통해 필요한 순간에 즉시 바인딩을 시도합니다.
JIT binding이 일어날 때 child injector는 기본적으로 parent injector에 먼저 바인딩을 시도합니다. 이는 동일한 의존성을 사용하는 다른 child Injector들도 이미 바인딩된 결과를 재사용할 수 있도록 하기 위함입니다. 하지만 parent Injector에 새로운 바인딩을 추가하려면 Injector의 상태를 변경해야 하고, 이를 위해 lock을 걸어야 합니다. (Injector가 중간에 변하면 정합성이 깨지기 때문입니다.)
문제는 child Injector가 생성될 때마다 parent Injector에 lock을 걸어야 하다 보니, child Injector가 많이 생성되면 애플리케이션 성능에 큰 부담을 줄 수 있다는 점입니다.
필요한 의존성을 Provider<T>
로 주입하는 대신, 아예 T
타입의 실제 인스턴스를 직접 @Assisted
로 주입하는 것입니다.
이처럼 직접 인스턴스를 주입하면, child injector를 생성한 후에 런타임에 주입된 값을 기준으로 캐싱할 수 있습니다. 즉, 같은 인스턴스를 사용하는 경우에는 이전에 만든 child Injector를 재사용할 수 있어 불필요한 lock과 바인딩 비용을 줄일 수 있습니다. (공식 문서에 따르면 약 6500%까지 성능 개선이 가능하다고 합니다.)
만약 @Assisted Provider<T>
로 Provider를 주입받았다면, 상황이 다릅니다.
이 경우 provider.get()
를 호출할 때마다 T
에 대한 의존성 해결이 필요합니다. 즉, 인스턴스를 요청할 때마다 동적으로 새로운 바인딩이 필요하기 때문에, child injector를 캐싱해서 재사용할 수 없습니다.
마찬가지로 injector 자체를 주입해서 필요한 의존성을 injector.getInstance()
로 호출하는 경우에도, 매번 의존성 해결이 필요하기 때문에 child injector를 재사용할 수 없습니다.
앞서 Factory와 AssistedInject를 사용할 때 발생하는 성능 이슈를 살펴봤다면, 이제는 실제로 제가 마주했던 테스트 코드를 함께 살펴보겠습니다. Warning이 발생했던 테스트 코드는 Factory를 통해 객체를 생성하는 방식은 아니었습니다. 대신, 아래처럼 Injector 자체를 생성자에서 직접 주입받아 사용하는 구조였습니다.
이 경우에는 Factory와 @Assisted
를 사용하는 구조가 아니기 때문에, child Injector가 생성되는 상황은 아니라고 판단했습니다. 하지만 injector 자체를 주입하고 있어서 인스턴스 생성마다 동적으로 의존성을 해결해야하는 문제가 있으므로, 여전히 성능 저하 이슈가 있을 것으로 생각했습니다.
호출할 때마다 JIT binding이 발생하거나 해당 인스턴스의 동적 의존성을 해결해야하기 때문입니다. 특히 위 코드처럼 getInstance()를 반복적으로 호출하는 경우, 호출 횟수에 비례해서 Injector가 동적으로 의존성을 해결하는 횟수가 늘어나고, 결국 애플리케이션 전체 성능에 악영향을 줄 수 있다고 생각했습니다.
이 물음을 해결하기 위해, 코드를 다음 두 가지 방식으로 나누어 비교 실험을 진행했습니다.
injector 자체를 직접 주입하는 방식
필요한 의존성을 직접 주입하는 방식
→ 필요한 의존성(ChatDao
, MessageDao
..)를 MeetEventHistoriesView
생성자에서 직접 주입받는 방향으로 수정하면 성능이 개선될 것으로 기대했습니다.
앞서 MeetEventHistoriesView 코드 리팩토링을 고민하면서, Injector를 직접 주입하는 방식과 필요한 의존성을 직접 주입하는 방식 사이에 실제 성능 차이가 존재할지 궁금해졌습니다. 그래서 간단한 실험 코드를 작성해 직접 비교해보았습니다.
아래는 Injector 주입 방식과 직접 의존성 주입 방식을 비교하는 실험 코드입니다. 해당 코드로 두 가지 케이스를 비교합니다. (실험에 사용한 코드 전문은 본문 하단에 첨부했습니다.)
Injector
자체를 주입 받습니다. run
메서드 안에서 필요한 의존성(Service
)을 매번 getInstance()
로 가져옵니다. (기존의 MeetEventHistoriesView와 동일한 구조)
필요한 의존성(Service
)만 직접 주입받습니다. run
메서드 안에서는 주입받은 의존성을 직접 사용합니다. (기존의 MeetEventHistoriesView를 수정한 방향)
ConsumerWithInjector
와 ConsumerWithDirectInjection
객체를 100,000번 생성하고, 각 개체의run
메서드를 실행 했습니다. 이때 ConsumerWithInjector
는 run 내부에서 injector.getInstance()
를 사용하고, ConsumerWithDirectInjection
는 생성자에서 직접 주입받은 Service
를 바로 사용합니다. 이 과정에서 두 방식 간의 성능 차이가 발생할 것으로 예상했습니다.
Injects the injector 평균: 10.98ms
Direct Injection 평균: 5.66ms
실제로 예측과 동일하게 Injector를 직접 주입하는 방식이 더 느리다는 것을 알 수 있습니다.
앞서 살펴본 실험을 통해, Injector를 직접 주입할 경우 성능 저하가 발생한다는 사실은 확인할 수 있었습니다. 그렇다면 성능이 느려지는 정확한 원인은 무엇일까요?
처음에는 GitHub Issue #435에서 다루어진 것처럼, "injector.getInstance()
를 호출할 때마다 내부에서 락(lock) 경합이 발생하는 것 아닐까?"라고 예상했습니다.
디버깅을 통해 확인해본 결과, Guice.createInjector()
로 Injector가 처음 생성될 때만 binding이 null이며, 이때만 getJustInTimeBinding()
을 통해 JIT binding이 일어나고, jitBindingData
에 lock을 건다는 점을 알 수 있었습니다. 즉, injector가 제일 처음 생성될 때만 jitBindingData
에 lock을 걸기 때문에 lock으로 인한 병목 현상으로 성능 저하가 일어나는 것이 아니었습니다.
다음으로 생각한 원인은 “injector.getInstance()로 인스턴스를 생성하는 과정 자체가 오버헤드가 크기 때문에 성능 저하가 발생한다”였습니다. injector는 인스턴스를 생성할 때마다 dependency graph를 확인합니다.
즉, Injector는 인스턴스를 생성할 때마다 다음과 같은 과정을 거칩니다:
Module에 정의된 Binding을 찾는다 (없으면 JIT Binding 수행)
InternalFactory를 가져온다
ConstructorInjector를 통해 객체를 생성한다
필요한 모든 의존성을 해결할 때까지 1~3번이 재귀적으로 일어납니다
인스턴스 생성 시 reflection을 쓰는 경우도 존재
위 추측이 맞다면, 이전 실험에서 ConsumerWithInjector
보다 run 메서드 내부에 getInstance()를 1회 더 호출하면 선형적으로 실행결과가 늘어날 것으로 예상했습니다.
이 가설을 검증하기 위해, getInstance()
호출 횟수를 인위적으로 늘려 추가 실험을 진행했습니다.
ConsumerWithInjectorTwice
는 run 메서드 안에서 getInstance를 2번 더 실행합니다.
즉, ConsumerWithInjectorTwice 인스턴스 자체를 만들때 1회, run()
호출 시 2회 더 호출하므로 총 3회의 getInstance 호출이 일어납니다.
Injects the injector 평균: 10.64ms (getInstance 2회 실행)
Direct Injection 평균: 5.63ms (getInstance 1회 실행)
Injector getInstance twice 평균: 14.51ms (getInstance 3회 실행)
실험 결과 getInstance 호출 횟수에 대해 선형적으로 실행 속도가 증가하는 것을 확인했습니다
추가 참고: 이번 실험에서는 serviceImpl
특별한 의존성이 없는 간단한 객체였기 때문에 선형적으로 증가하는 경향이 명확하게 보였습니다.
물론 실험에서는 serviceImpl
인스턴스를 생성할 때 필요한 의존성이 아무것도 없으므로 선형적으로 증가하는 것이 성립했습니다.
serviceImpl
에 추가적으로 의존성이 생기거나 dependency graph가 복잡해지면 선형 증가가 보장되지 않지만, getInstance 호출 횟수와 실행 속도가 비례 관계라는 점은 여전히 성립합니다.
실제 애플리케이션에서는 의존성 그래프가 더 복잡할 수 있으므로, 성능 차이는 더 크게 벌어집니다.
위 모든 실험 결과를 통해 다음과 같은 사실을 확인할 수 있었습니다.
Injector를 직접 주입하면...
매 getInstance()
를 호출하는 과정에서 Injector가 dependency graph를 순회하며 동적으로 의존성을 해결해야 하기 때문에 오버헤드가 발생합니다.
필요한 의존성을 직접 주입하면...
Injector가 binding된 의존성을 즉시 참조함으로써 불필요한 의존성 해결 단계를 건너뛸 수 있습니다.
즉, "필요한 의존성은 직접 주입하자." 라는 Guice의 권고가 실제로도 성능 최적화에 도움이 된다는 것을 실험을 통해 검증할 수 있었습니다. Injector를 직접 주입하는 방식은 Guice에서도 공식적으로 권장하지 않는 패턴입니다. 이번 실험과 분석을 통해 “필요한 의존성은 생성자 주입을 통해 명시적으로 직접 주입하는 것이 가장 바람직하다”는 점을 다시 한번 확인할 수 있었습니다.
마지막으로 한 가지 짚고 넘어가야 할 점이 있습니다.
위에서 다룬 [2. Guice의 Warning 이슈 분석]와 [3. Test code에서 관찰한 성능 저하]는 모두 공통적으로 "Injector를 직접 주입하는 상황"에서 발생했지만, 문제가 발생하는 근본적인 원인은 서로 다릅니다.
[2. Guice의 Warning 이슈 분석] 에서는 객체 생성마다 child Injector가 새로 생성되고, 이 과정에서 parent Injector에 lock이 걸려 성능 저하가 발생하는 문제입니다. 즉, child injector를 캐싱해서 최적화할 수 없기 때문에 성능 저하가 발생합니다.
[3. Test code에서 관찰한 성능 저하] 에서는
Injector
를 직접 주입한 후,getInstance()
를 호출할 때마다 JIT(Just-In-Time) binding 혹은 Injector의 dependency graph를 반복적으로 참조하면서 오버헤드가 발생한 문제입니다.즉, 두 경우 모두 Injector를 직접 주입하는 것이 문제의 출발점이긴 하지만, 성능이 저하되는 메커니즘은 다릅니다.
🐶 여기까지 긴 글 읽어주셔서 감사합니다! 이번 경험을 통해 Guice 내부 동작 방식과 Injector 주입이 성능에 미치는 영향을 조금 더 깊이 이해할 수 있었습니다.
비슷한 고민을 하고 계신 분들께 이 글이 작은 도움이 되었기를 바랍니다!
[1] https://github.com/google/guice/wiki/InjectingTheInjector
[3] https://github.com/google/guice/issues/435
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다