Microsoft MVP성태의 닷넷 이야기
제니퍼 .NET: 21. 제니퍼 닷넷 - Ninject DI 프레임워크의 성능 분석 [링크 복사], [링크+제목 복사]
조회: 24619
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

제니퍼 닷넷 - Ninject DI 프레임워크의 성능 분석

세상이 복잡해지다 보니, 예전에는 고려할 대상에도 없었던 새로운 것들이 나타나기도 합니다. 그중의 하나가 바로 IoC 컨테이너, 또는 Dependency Injection 프레임워크라고 불리는 것들인데요.

며칠 전에, 지인 한 분이 고객사의 성능 컨설팅을 의뢰받았는데 이에 대한 보고자료로 '제니퍼 닷넷'을 써보고 싶다는 연락이 왔습니다. 그쵸... 아무래도 썰렁한 텍스트 보고서보다는, 그래픽 요소가 가미되면 훨씬 더 ^^ 감동을 줄 수 있을 테니까요.

웹 애플리케이션 개발을 막 완료한 그 고객사는 내부적으로 Dog Food 식으로 테스트를 하는 과정에서 10명 정도의 사용자 수준에서도 CPU 부하가 심각해지는 등의 성능 저하를 겪었다고 합니다. 개인적으로도 재미있는 사례가 될 듯하여 ^^ 같이 만나서 '제니퍼 닷넷'을 통해 문제점 분석을 하기 시작했습니다.




해당 응용 프로그램의 구조는 MVC 3 + Ninject를 사용하는 특징이 있었습니다. MVC Controller에는 별다르게 복잡한 메서드 호출 없이 전형적인 Biz + Dac 구조의 DB 호출을 이용한 로직만을 포함하고 있었고, DB 호출 자체도 그다지 느리지 않았습니다.

좀 더 문제를 추적하기 위해, method_all_profile 옵션으로 프로파일링을 해보았습니다.

다행히 여기서 문제점이 대충 들어났는데요. 다음과 같이 Ninject.Web.Mvc.NinjectDependencyResolver.GetService 하위에 Resolve 관련해서 엄청난 중첩 호출이 발생하고 있었습니다.

ninject_profile_1.png

마지막에 <>c__DisplayClass4.<Create>b__2 메서드 호출 하위로도 재귀적으로 반복되는 것을 확인할 수 있었는데, 아래의 그림에서 다시 보면,

ninject_profile_2.png
(이미지의 일부 내용은 의도적으로 검정색 처리를 해서 숨겼습니다.)

총 3600개가 넘는 함수 호출 중에서 실제로 MVC Controller의 Action 메서드에서 수행한 것은 3553 - 3457 = 96개의 호출에 불과했던 것입니다. (참고로, 어떤 페이지는 총 17845번의 호출 중에서 104번의 호출만 실제 업무에 필요한 호출인 경우도 있었습니다.)

즉, 나머지 Ninject의 작업이 과다하게 발생하는 것이 문제였습니다. 전체적으로 웹 페이지 호출에 대한 X-View 정보를 보니 다음과 같이 응답시간과 CPU 시간이 비슷하게 높은 것으로 나옵니다.

ninject_profile_3.png

결국 CPU 부하의 주된 요인은 aspx에 대해 한 번 호출할 때마다 Type Resolve 작업이 매번 발생하고, 게다가 그것이 매우 CPU-intensive한 작업이라는 점입니다. 문제를 더욱 심각하게 만든 것은, 하나의 aspx 페이지가 렌더링 되는 클라이언트 측에서 ajax 호출을 통해 다수의 서버 페이지를 다시 호출하는 것이었습니다.

파악해 보니, 로그인 페이지 한 번에 aspx 요청들이 18개나 발생되었습니다. (그리고, 그 하나의 aspx 요청들마다 다시 Ninject의 Type Resolving 작업이 발생했습니다.)

이런 경우는 어쩌면 '아는 것이 병'이었을 수도 있습니다. DI 프레임워크인 Ninject를 쓰지 않았다면 괜찮았을 수도 있고, 혹은 Ajax 호출로 쪼개지 않았다면 괜찮았을 수도 있습니다. 다시 말해, '전통적인 웹 애플리케이션 제작 방식'이었다면 이런 정도의 웹 애플리케이션을 만드는 데 있어 성능 문제는 없었을 것입니다.




원인은 대충 그렇다 치고... 그렇다면 Ninject가 저런 식으로 무거웠다면 실제로 많은 불평들이 있었을 텐데, 그렇지 않았다는 점이 이상했습니다. 그래서 좀 더 살펴보았는데요.

가만히 보니, 유독 느린 호출들은 MVC Controller 안에 Injection되는 인터페이스들의 숫자가 상대적으로 더 많았습니다. 그래서, 위의 3457개의 호출이 발생하는 MVC Controller 안에 일부러 (호출은 안하지만) 2개의 Injection 인터페이스를 생성자에 추가했더니... 실제 Biz 호출은 96개로 동일했지만, 전체적인 호출 건수는 39455건으로 10배 이상 증가를 했습니다.

다시 의문이 생겼습니다. 비례적으로 증가하지 않았다는 사실이지요.

그래서, 추가했던 2개의 인터페이스 중에서 하나씩 번갈아 다시 독자적으로 추가해 보면서 부하가 어느 정도로 심한지 체크해 보았습니다. 편의상, 해당 인터페이스들을 IA, IB, IC라고 해보면 결과는 다음과 같았습니다.

IA: 3553 호출
IB: 12931 호출
IC: 27718 호출

전체 합치면 44202로 39455보다 많지만... 그런 세세한 차이는 무시하고 대충 비율적으로 IC 인터페이스의 Injection 작업이 가장 무겁다는 것을 짐작할 수 있습니다.

이제, IC와 IA에 어떤 주요한 차이점이 있는지 NinjectModule을 상속받은 타입의 Load 메서드에서 해당 인터페이스에 대해 지정된 Concrete 타입을 알아내서 비교를 해보면 될 것 같습니다. 다행히, IC와 IA 모두 하나의 Assembly 안에 정의된 타입들이어서 상이한 Assembly 간의 차이점까지 살펴볼 필요는 없어졌습니다.

추적 결과, 문제가 더욱 간단해 지더군요. IA를 구현한 클래스는 내부에 다시 Injection 될 인터페이스를 가지고 있는데 그 수가 하나에 불과했습니다.

하지만, IC는 달랐는데... 대충 다음과 같이 복잡한 Injection 구조를 가지고 있었습니다.

IC - ICda
   - IUM
        - IUMda
        - IOM
        - ICodM
            - ICodMda
   - IOM
        - IOMda
        - IUMda
   - IMM
        - IMMda
        - IComM
            - IComMda
   - IAM
        - IAMda
        - IUM


내부적으로 다시 IAM은 IUM을 포함하고 / IUM은 IOM을 포함하고 있어서 최종적으로 다음과 같이 펼쳐질 수 있습니다.

IC - ICda
   - IUM
        - IUMda
        - IOM
            - IOMda
            - IUMda
        - ICodM
            - ICodMda
   - IOM
        - IOMda
        - IUMda
   - IMM
        - IMMda
        - IComM
            - IComMda
   - IAM
        - IAMda
        - IUM
            - IUMda
            - IOM
                - IOMda
                - IUMda
            - ICodM
                - ICodMda

결론은, 중첩된 Injection이 많을수록 그에 대한 Type Resolving 시간이 급격하게 늘어난다는 점입니다.




그래도 혹시, 환경상의 차이로 인해 그럴 수 있으니 위와 같은 정보를 바탕으로 간단하게 프로젝트를 구성해서 확인하는 것이 좋을 것 같습니다. (향후, 좋은 예제 코드로 쓰일 수도 있고. ^^)

다음의 글에 따라서, MVC 없이 단순 WebForm에 Ninject로 IA, IC와 동일한 구조로 작성을 해보았는데요.

How can I implement Ninject or DI on asp.net Web Forms?
; http://stackoverflow.com/questions/4933695/how-can-i-implement-ninject-or-di-on-asp-net-web-forms

Ninject 관련 DLL들의 참조를 추가하고, Global.asax의 코드를 다음과 같이 입력해 주었습니다.

public class Global : NinjectHttpApplication
{
    protected override IKernel CreateKernel()
    {
        var kernel = new StandardKernel();
        kernel.Load(Assembly.GetExecutingAssembly());

        return kernel;
    }
}

이후, 문제가 되었던 IC, IA 인터페이스에 관해서 유사하게 구현해 주고 이를 테스트할 수 있는 2개의 웹 페이지를 만들었습니다.

public partial class TestIA : Ninject.Web.PageBase
{
    [Inject]
    public IA A { get; set; }
    protected void Page_Load(object sender, EventArgs e)
    {
        A.Do();
    }
}

public partial class TestIC : Ninject.Web.PageBase
{
    [Inject]
    public IC C { get; set; }
    protected void Page_Load(object sender, EventArgs e)
    {
        C.Do();
    }
}

그 결과, TestIA.aspx 호출에는 773번의 호출이 있었던 반면, TestIC.aspx 호출에는 14169번의 호출이 있었습니다. 범인이 확실해졌군요. 중첩된 Injection을 하는 경우 Ninject는 Type Resolving에 심각한 CPU 사용량을 보이고 있었습니다.




그런데... 모든 IoC 컨테이너들이 이런 영향이 있을까요? 그래서 마이크로소프트의 Unity 컨테이너를 위와 동일한 상황으로 만들어 보았습니다.

patterns & practices - Unity
; https://github.com/unitycontainer/unity

Using Unity in ASP.NET - a Simple Example
; https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff650806(v=pandp.10)

Unity And Asp.Net WebForms
; http://dotnetninja.wordpress.com/2008/05/21/unity-and-aspnet-webforms/

ASP.NET Web Services Dependency Injection using Unity
; http://ruijarimba.wordpress.com/2011/12/27/asp-net-web-services-dependency-injection-using-unity/

역시 동일하게 IA, IC 인터페이스를 구현하여 테스트해 보았는데, TestIA.aspx은 526번의 호출이 있었던 반면, TestIC.aspx인 경우 4860번의 호출이 있었습니다. 10배 정도 차이가 나는군요.

물론, 호출 횟수만으로 Unity와 Ninject의 성능을 비교하는 것은 말이 안됩니다. 함수 하나가 가질 수 있는 라인 수는 다양하기 때문에, 좀 더 확실한 성능 비교를 위해서는 부하테스트가 필요했습니다.

CPU 100%가 치도록 부하 테스트를 걸었는데, 결과는 다음과 같이 Ninject가 1000 TPS 처리량을 보여준 반면, Unity는 약 두배 정도의 2000 TPS를 보여줌으로써 성능이 더 좋았습니다.

[그림: Ninject 부하 테스트 시 1000 TPS]
ninject_profile_4.png

[그림: Unity 부하 테스트 시 2000 TPS]
ninject_profile_5.png

음... Unity가 상대적으로 Ninject에 비해 성능은 좋았지만, 그것도 결국 Injection될 인터페이스가 증가함에 따라 호출 횟수가 커짐을 볼 수 있었습니다. 어찌 보면, 성능을 위한 웹 사이트라면 DI 프레임워크를 쓰는 것은 답이 아닙니다.




그렇다면, 이쯤에서 적절한 해결책으로 어떤 것이 있을까 생각해 봐야 합니다. 고민을 하는 중에, 다행히 지인이 좋은 개선안을 하나 내놓았습니다. 바로, Ninject의 바인딩을 한 번만 할 수 있도록 Singleton Scope을 가지도록 하는 것입니다.

this.Bind<IA>().To<CIA>().InSingletonScope();

이렇게 되면, aspx 페이지에서 선언된 DI 속성에 넣어질 인스턴스들이 한 번만 생성되고 그것들이 재사용되어져서 중첩된 DI 속성들에 대해서 type resolving하는 시간이 처음 한 번만 소요되고 이후로는 그 부하가 0으로 떨어집니다.

하지만, 부작용은 있지 않을까요?

맞습니다. singleton이다 보니 해당 클래스에 '멤버 변수'를 가지고 동작해서는 안됩니다. 왜냐하면 다수의 스레드들이 동시에 해당 클래스를 접근해서 메서드를 호출하기 때문에 멤버 변수가 언제 어떻게 바뀌거나 사용될지 장담할 수 없기 때문에 적절한 동기화 처리를 함께 고려해야 합니다. 물론, 그런 경우에는 다시 동기화로 인한 잠김 문제가 발생하기 때문에 다시 성능 문제로 이어질 수 있습니다.

그러고 보면, 예전 COM+ 개체를 만들때 Stateless하게 만드는 것이 권장되었는데요. 그것이 진리가 아닌가 생각됩니다. DI 컨테이너를 사용한다면, 상태를 가지지 않은 Singleton 개체 운영이 답이라는 것이지요. ^^

다행이라면, 해당 고객사는 DI되는 모든 클래스들이 (완전히는 아니고) 대부분 멤버 변수가 하나도 없는 구조였습니다.

첨부된 파일은 IA, IC 인터페이스 예제로 NinjectUnity로 구현한 예입니다. (위에서 부하테스트에 사용된 바로 그 예제입니다.)

기타, 다음의 글이 약간의 연관성이 있겠군요. ^^

MEF가 적용된 ASP.NET 웹 사이트를 제니퍼 닷넷으로 모니터링 해본 결과!
; https://www.sysnet.pe.kr/2/0/1203

MEF를 ASP.NET에 성능 손실 없이 적용하려면?
; https://www.sysnet.pe.kr/2/0/1204




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/17/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2012-07-02 03시26분
[실용주의] Java의 대표적인 DI framework인 Spring에서는 Instance 생성시 Singlton이 기본 입니다.(설정에 따라 바꿀 수 있지만..) Spring .net 도 테스트 결과가 궁금하군요.
다각적인 성능 분석에 좋은 공부 하고 갑니다. 감사합니다.
[guest]
2012-07-03 01시23분
넵. 우리 회사 내의 자바 개발자 분들도 ^^ Spring에서는 Singleton이 기본이라고 하시더군요. dynamic하게 바인딩하는 것이 분명 성능상 문제가 되기 때문에 그런 기본값이 적용된 것이겠지요.
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13598정성태4/16/2024208닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더파일 다운로드1
13597정성태4/15/2024292닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력파일 다운로드1
13596정성태4/14/2024508닷넷: 2238. C# - WAV 기본 파일 포맷파일 다운로드1
13595정성태4/13/2024512닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)파일 다운로드1
13594정성태4/12/2024743닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)파일 다운로드1
13593정성태4/8/2024938닷넷: 2235. MSBuild - AccelerateBuildsInVisualStudio 옵션
13592정성태4/2/20241185C/C++: 165. CLion으로 만든 Rust Win32 DLL을 C#과 연동
13591정성태4/2/20241152닷넷: 2234. C# - WPF 응용 프로그램에 Blazor App 통합파일 다운로드1
13590정성태3/31/20241067Linux: 70. Python - uwsgi 응용 프로그램이 k8s 환경에서 OOM 발생하는 문제
13589정성태3/29/20241131닷넷: 2233. C# - 프로세스 CPU 사용량을 나타내는 성능 카운터와 Win32 API파일 다운로드1
13588정성태3/28/20241185닷넷: 2232. C# - Unity + 닷넷 App(WinForms/WPF) 간의 Named Pipe 통신파일 다운로드1
13587정성태3/27/20241133오류 유형: 900. Windows Update 오류 - 8024402C, 80070643
13586정성태3/27/20241262Windows: 263. Windows - 복구 파티션(Recovery Partition) 용량을 늘리는 방법
13585정성태3/26/20241089Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 "Process V2"
13584정성태3/26/20241042개발 환경 구성: 708. Unity3D - C# Windows Forms / WPF Application에 통합하는 방법파일 다운로드1
13583정성태3/25/20241145Windows: 261. CPU Utilization이 100% 넘는 경우를 성능 카운터로 확인하는 방법
13582정성태3/19/20241218Windows: 260. CPU 사용률을 나타내는 2가지 수치 - 사용량(Usage)과 활용률(Utilization)파일 다운로드1
13581정성태3/18/20241365개발 환경 구성: 707. 빌드한 Unity3D 프로그램을 C++ Windows Application에 통합하는 방법
13580정성태3/15/20241131닷넷: 2231. C# - ReceiveTimeout, SendTimeout이 적용되지 않는 Socket await 비동기 호출파일 다운로드1
13579정성태3/13/20241493오류 유형: 899. HTTP Error 500.32 - ANCM Failed to Load dll
13578정성태3/11/20241620닷넷: 2230. C# - 덮어쓰기 가능한 환형 큐 (Circular queue)파일 다운로드1
13577정성태3/9/20241850닷넷: 2229. C# - 닷넷을 위한 난독화 도구 소개 (예: ConfuserEx)
13576정성태3/8/20241539닷넷: 2228. .NET Profiler - IMetaDataEmit2::DefineMethodSpec 사용법
13575정성태3/7/20241662닷넷: 2227. 최신 C# 문법을 .NET Framework 프로젝트에 쓸 수 있을까요?
13574정성태3/6/20241552닷넷: 2226. C# - "Docker Desktop for Windows" Container 환경에서의 IPv6 DualMode 소켓
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...