.NET 배포 시 PublishTrimmed 옵션을 사용할 때는 주의해야 합니다. (.NET Publish 주요 Option들도 같이 살펴보아요)

요즘 .NET으로 Console Application을 만드는 재미에 빠졌습니다. 비즈니스를 테스트하면서, 터미널 환경에서 직관적이고 가볍게 사용할 수 있으며, 터미널의 지원을 통해 CI/CD 자동화 도구에서 부담없이 실행가능하다는 점 때문이죠.

하지만 Visual Studio debug로 실행할 때는 잘 되어도, dotnet publish 직후에는 안될 수도 있습니다. 아직 debug과 release publish 차이를 잘 모르는 개발자도 있겠지만… 그것은 넘어가고…

아무튼 publish할 때는 단순 debug때와는 다르게 여러가지 추가적인 짓들을 할 수 있습니다.

우선은, Visual Studio를 통해 게시(Publish)를 눌렀을 때 게시 프로필을 통한 화면을 볼 수 있으며 이것은 dotnet publish와 완벽하게 호환됩니다. 물론 오히려 dotnet publish 명령어로 배포하는 것이 더 많고 강력한 옵션들을 사용할 수 있고, Visual Studio 2022로는 주요한 최적화 기능만 사용할 수 있습니다.

※ 쉬운 이해를 위해 일상적인 대화의 저속한 표현을 사용하였으니, 양해부탁드리겠습니다.

-sc|–self-contained={true|false}

이 중에서 배포 모드 의 자체 포함 기능은 저의 최애 기능입니다. dotnet publish 명령어를 사용할 때 --self-contained 라고 불리는 기능과 같은 기능입니다. 바로 Application에 .NET을 넣어버리는 기능으로, 사용자의 PC에 .NET이 설치되어 있지 않아도 .NET Application을 실행 가능한 멋진 기능입니다.

.NET을 설치하면 많은 저장공간이 필요한데, 용량이 그렇게까지 크지는 않은 걸보면 추측이지만 내가 생성한 .NET Assembly에서 필요한 기능만 쏙 뽑아서 합쳐지는게 아닐까 생각이 듭니다.


-p:PublishSingleFile={true|false}

MSBuild 옵션은 -p를 붙여서 위와 같이 사용해야한다고 하네요.

.NET을 빌드하면 기본적으로 Assembly 단위로 여러개의 .dll 파일이 생성됩니다. .NET Framework 시절에도 Fody를 통해 이 어셈블리들을 하나로 합쳐서 배포하는 방법은 존재했지만, .NET 부터는 공식 옵션으로 지원을 시작했습니다.

그리고 특히, 아까 앞서 말한대로, CI/CD용 system에는 단일 파일로 배포해주면 배포도 간결하기 때문에 빨리 배포해 버리고 잊어버릴 수 있는 뇌내 Fire And Forget을 적용할 수 있어 뇌의 과부화 뇌절 부담을 덜어줍니다. (물론 Assembly 단위로 다 나눠서 배포할 때 변경된 Assembly만 교체할 수 있다는 점은 매력적이지만, 지속적으로 Machine을 관리하는 사람이 없다면 이마저도 불편할 수 있다는 사실…)

물론 완벽하게 단일파일로 나오지는 않고 SkiaSharp 처럼 Native DLL이 필요한 기술적 한계가 존재하는 것들은 합쳐지지 않고 별도로 빠지니까 괜한 시간 버리지 마시고, 그냥 옵션만 켜서 배포하면 됩니다.


-p:ReadyToRun={true|false}

MSBuild 옵션은 -p를 붙여서 위와 같이 사용해야한다고 하네요.

ReadyToRun 옵션은 쉽게 말해서 코드를 보니, 미리 예측할 수 있는 Native 코드(운영체제의 자체 API) 호출 부분은 미리 컴파일해서 JIT의 코드 최초 1회성 코드 컴파일 부분조차 없에준다는 옵션입니다.

JIT 컴파일링의 원리는 다들 아시는대로, 쉽게말하자면, 프로그램을 빌드할 때 대충 기워서 만든 다음, 실행해서 프로세스가 되면 그때서야 비로소 배포한 시스템의 리소스를 끌어다가 디테일한 부분을 완성시켜나가는 방식입니다. 어느쪽에서 실행시간을 부담할지의 득과 실을 따진 트레이드 오프의 산물이죠.

다만 개발자들은 일정 수준이 올라가면 꼭, 편리하면서 또 자그마한 퍼포먼스들을 중첩시켜서 고성능을 챙기고 싶어하는… 네이버처럼 심플하고 화려하게 해주세요 같은 것들을 꼭 하고 싶어하기에, JIT 전용인 .NET 환경을 어떻게든 AOT 형태로 바꿔보려고 노력하기 시작했고(그놈의 C++과 Rust에 어떻게든 안밀려 볼려고…비교 좀 그만해 제발…), JIT의 산물인 IL과 System API 직접 호출할 수 있는 코드(원래는 런타임 중에 해야함)를 미리 빌드해버린…

IL 코드와 Native 코드가 혼재한 혼종을 탄생시켰고 그것이 ReadyToRun 입니다. 그래서 용량이 그냥 JIT으로 대충 기워서 빌드한 .NET Assembly보다 용량이 3배정도 많습니다. 다만 Native Code를 호출하는데 한하여서는 JIT 처럼 Runtime에서 코드 컴파일 및 실행이 없다보니, Warm Start가 아니라 Cold Start 면에서는 최적화가 되었다고 볼 수 있는 옵션입니다.

그리고 이 부분은 잘 모르는 개념이지만, Native Code를 빌드해주는 도구로서 CrossGen2 가 사용된다고 합니다. 그리고 ReadyToRun은 JIT을 사용할 수 있기 때문에 기존의 대다수의 JIT 기반으로 build된 Nuget Package들을 자유롭게 이용할 수 있는 것이 장점입니다.

시스템의 물리적 저장공간만 충분하다면 편의성과 퍼포먼스를 모두 챙길 수 있는 좋은 옵션이라고 생각합니다.

다만 왜인지는 모르겠는데, dotnet publish 를 통해 dotnet cli를 이용하면 ReadyToRun 옵션을 모든 아키텍쳐에 대해 사용할 수 있지만, Visual Studio 2022에서는 win-x86, win-x64, win-arm, win-arm64 같은 Windows OS를 대상으로 하는 아키텍쳐에 대해서만 활성화되고, Linux, MacOS(osx) 계열에서는 활성화되지 않습니다.


-p:PublishTrimmed={true|false}

MSBuild 옵션은 -p를 붙여서 위와 같이 사용해야한다고 하네요.

오늘 제가 이 글을 쓰게 만든 주요 원인입니다. 위 사진에 보면, 제가 체크하지 않은 옵션이 있습니다.

사용되지 않는 코드 자르기 옵션인데요. 저는 단순하게 이것이 Code Inline 최적화 수준의 직관적이고 단순한 최적화라고 생각했습니다. 주석같은 것은 Release 환경에서는 당연히 지워지고 빌드되겠지만…

사실 코드 자르기 라고하길래 무슨 의미인지 직관적으로 다가 오지 않아서 사용했던 것이 큽니다. 그리고 설명한 간단하게만 읽어보면 요약했을 때

“Assembly 용량을 줄인다.”

이렇게 다가왔습니다. 하지만 제약사항이 있던 것은 몰랐죠.

Trim을 다듬기 라고네요…ㅎㅎ
저는 MiniExcel 을 사용하여 메모리가 들고 있는 정보를 Excel 문서로 내보내는 기능을 사용하면서 개발하다가, 개발 할 때는 잘되었지만, PublishTrimmed 옵션을 사용해서 dotnet publish 하니까 익명타입을 읽어 들일 수 없다는 오류가 발생 했습니다.

내가 만든 어셈블리는 Trimming 된 .NET App 제약사항에 걸리지 않더라도, 내가 사용하는 Nuget Package가 위 제약사항 해당된다면 문제가 됩니다. 그리고 MiniExcel 뿐만 아니라 많은 기존의 Nuget Package들이 Reflection Code Emit 방식을 사용하고 있었을 것이고 Source Generator 형식으로 넘어가지 못한 Package도 많을 것이기에 직접 꼭 테스트해보고 사용해야할 것 같습니다.

11개의 좋아요

https://www.sysnet.pe.kr/2/0/13159

이런 글도 있네요.

2개의 좋아요

오옷 감사합니다. 링크드인에도 게시했는데 이 글도 추가해야겠네요!

특히 pdb파일도 실행파일 하나에 합쳐주는 옵션이 아주 유용하네요.

Visual Studio 2022 에서는 아래 프로젝트 옵션에서 선택 가능하며,

게시 프로필에서는 아래처럼 설정 가능합니다.

게시 프로필보다는 프로젝트 속성에서 DebugType을 지정하는 것이 더 유리한 이유는, 실행가능한 주요 .NET Console Application Project가 있고 참조하는 .NET Class Library Project가 있다면 Class Library Project의 pdb 파일은 실행 어셈블리에 합쳐지지 않습니다.

따라서 배포했을 때 깔끔하게 exe 파일 한개로 배포하고 싶다면 참조하는 프로젝트들의 프로젝트 속성에서 pdb 파일들을 모두 설정해주셔야 합니다. (게시 프로필이 아니라)

2개의 좋아요

-p:PublishSingleFile=true

요 옵션을 사용할 경우 함께 빌드된 어셈블리들의 Assembly.Location 이 정상적으로 반환되지 않습니다. (string.Empty 로 반환됨!)

만약 리플렉션이나 Assembly 관련 타입에 접근할 때에 요런 것들도 테스트한 후 사용하는 것을 추천드려요. ㅇㅅㅇ!

4개의 좋아요

여담입니다만, Avalonia 같이 GUI이면서 Native AOT나 trimmed mode 컴필레이션을 지원하는 곳에서도 리플렉션에 의존하지 않을 수 있게 정적 타입 명시를 통해서 MVVM을 이용할 수 있게 옵션을 만드는 점, ASP .NET Core가 Minimal API 기반으로 재편되고 있는 점, 그리고 고전적인 P/Invoke API 대신 Static Source Generator 기반으로 P/Invoke 시그니처를 자동 생성하는 부분이나 Statis Source Generator 기반 인프라가 증설되는 것도 모두 이런 컴필레이션 옵션을 염두에 둔 부분들로 보시면 좋습니다. :smiley:

7개의 좋아요

ReadyToRun에 대해서 전혀 모르고 있었는데, 좋은 설명 감사합니다.

2개의 좋아요

와 노하우 감사합니다!!

제가 부 주제마다 이런 걸 달아놔서, 좀 더 상세하게 남기기 위해서 탐색해봤습니다.

dotnet msbuild 명령 - .NET CLI | Microsoft Learn

몰랐던 사실 중 하나는 dotnet builddotnet msbuild -restore 가 같은 명령어였다는 점입니다.

-p를 붙여야 한다는 점은 아무래도 아래 부분 같습니다.

dotnet msbuild -property:Configuration=Release

-property가 -p로 축약되고, msbuild의 옵션을 사용해서 Configuration의 옵션을 지정하면 되는 느낌이네요.

dotnet cli(dotnet 명령어로 시작하는 도구들)가 생기면서 기존에 MSBuild와 호환을 높이기 위해 선택한 멋진 방법 같습니다. 이런 형태가 아니었다면 아마도 파이프라인 형태로 넘겼을 것 같네요.

리플렉션이 여러모로 지양되어야하는기술은 맞지만, 이미 그리 개발되버린 상황에서도 Aot / Trimming 에 적절히 호환되도록 관리할수는 있습니다. (결국 최적화의 혜택을 국소적으로 포기하는것에 가깝지만)

msbuild상에서 <TrimmerRootDescriptor> 를 지정하거나, [DynamicDependency] 속성으로 코드상에서 특정 타입에 대한 트리밍예외를 직접 지정할수도 있고,

[DynamicallyAccessedMembers] 속성으로 코드상 제네릭파라미터로 호출된 타입에 대해 자연스럽게 트리밍 예외를 부여할수도 있습니다. (제일 권장)

예전에 트리밍이 지원되지 않는 라이브러리가 부분적으로 지원되도록 수정작업을 해본적이 있어서, 대충 어떤느낌으로 덧붙이면 되는지 확인해보세요

DI supports Aot | trimming by naratteu · Pull Request #151 · mayuki/Cocona

2개의 좋아요

그렇군요. 답변 감사합니다. @naratteu 님.

Source Generator 쪽 라이브러리는 생소해서 글을 달아주신 시간부터 지금까지 쭉 이해만 하고 있었습니다. 큰 인사이트 감사드립니다.

다른 것은 이해를 잘 못했지만 DynamicallyAccessedMembersAttribute 에 대해서는 좀 이해한 것 같습니다.

요컨대 원래 리플렉션 작성한 코드 중 실질적으로 참조가 이뤄지지 않고 런타임에서 동적으로 호출하는 형식이라 정적분석이 어려워서 Trim 되는 요소들에 대해서 DynamicallyAccessedMembersAttribute 속성을 명시한 것들에 대해 잘려나가지 않게 코드 보존을 허용해라 라는 것일까요?

그래서 이 커밋에 대해서는 완전한 AOT 지원을 위해 저 속성들을 제거하신 의도이실까요?


그런데 하나 이해가 되지 않는 부분은 이 PR을 언급하신 CAF에서의 Merged 된 부분에서는 어떤 의도로 언급하신 것인지 여쭤봐도 될까요?

Bug Fix - IArgumentParser Attribute doesn’t work in Method by naratteu · Pull Request #139 · Cysharp/ConsoleAppFramework

코드를 보니까 확장메서드를 추가하셔서 null로 들어간 부분에 대해 new EquatableTypeSymbol(ITypeSymbol) 을 하고 계신 것 같아서요.

이 부분이 언급하신 PR과의 연관성에 대해서 의문을 갖게 되었습니다.

※ 추가적으로 제가 즐겨 쓰는 ConsoleAppFramework에 기여해주셔서 감사합니다. 오픈소스 기여자들 부럽고 멋지네요.

1개의 좋아요

네 트리밍을 하게되면 정적분석으로 최초진입점에서부터 명시적으로 호출되는 모든 연결고리를 추적하여 호출되지 않는것으로 보이는 코드를 전부 제거하기때문에, DI를 통해 생성시키려던 객체의 생성자라던가 리플렉션을 통해 동적으로 호출하려던 함수 등 까지 모조리 사라져버리는 불상사가 발생합니다. 그래서 일부 코드에 대해서만 예외적으로 “잘려나가지 않게 코드 보존을 허용해라” 하기위한것들이 맞습니다.

  • 그 방법중에 가장 원시적인 방법이 특정 어셈블리 - 네임스페이스 - 클래스명의 - 어떤 맴버를 정리하지 말라고 <linker>를 작성하는것이 있고, #

  • typeof() 를 사용해 똑같이 열거하되 외부소스가 아닌 내부소스단에서 그나마 코드트래킹이 수월하도록 개선된 방법도 있습니다. #

    • 언급하신 이 커밋 의 수정전 코드가 바로 위 방법을 사용하여 응용단에서 트리밍하지 않을요소들을 일일히 명시한것이고,
  • 수정후 코드에서 그 대신 라이브러리단의 ICoconaLiteServiceCollection 확장함수 파라미터에 DynamicallyAccessedMembersAttribute 를 적용하여 “DI에 추가한 타입은 ↔ 생성자 트리밍예외이다” 라는 명확한 관계성을 코드에 우아하게 담아내고자 한것입니다.


CAF가 언급된것은 크게 연관성이 있던것은 아니고, 유사한프로젝트에 대한 링크 겸 당시에 양쪽 PR중 한군데라도 좀 반영이 되어야 제가 작업하던 프로젝트에 잘 가져다 쓸수있던 상황이였던지라 가만히 안기다리고 괜한 투정을 좀 부렸었네요…:sweat_smile:

1개의 좋아요

그렇군요. 상세한 추가 설명 정말 감사드립니다. 명확해졌습니다~

1개의 좋아요

오늘 있었던 일을 LinkedIn에 게시글로 정리해서 남겨봤습니다. 꽤 기네요.

용량이 도대체 왜 다른거지…