Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 2개 있습니다.)
(시리즈 글이 5개 있습니다.)
닷넷: 2161. .NET Conf 2023 - Day 1 Blazor 개요 정리
; https://www.sysnet.pe.kr/2/0/13446

닷넷: 2163. .NET 8 - Dynamic PGO를 결합한 성능 향상
; https://www.sysnet.pe.kr/2/0/13448

닷넷: 2178. C# - .NET 8부터 COM Interop에 대한 자동 소스 코드 생성 도입
; https://www.sysnet.pe.kr/2/0/13470

닷넷: 2180. .NET 8 - 함수 포인터에 대한 Reflection 정보 조회
; https://www.sysnet.pe.kr/2/0/13475

닷넷: 2181. C# - .NET 8 JsonStringEnumConverter의 AOT를 위한 개선
; https://www.sysnet.pe.kr/2/0/13476




C# - .NET 8부터 COM Interop에 대한 자동 소스 코드 생성 도입

이번 글은 .NET Conf 2023에 있었던 아래의 영상에 대해 베낀 것입니다. ^^

COM Source Generation: An evolution of COM interop in .NET | .NET Conf 2023
; https://youtu.be/DZd1SGd7dSU




마이크로소프트가 Native AOT를 위해 전방위로 Source Generator를 활용하고 있습니다. 일례로, ASP.NET Core의 경우 MapGet과 같은 메서드 호출을 Request Delegate Generator(Souce Generator + Interceptor) 기능을 활용해 해결하는가 하면 Json Serializer/Deserializer도 System.Text.Json에 컴파일 시 소스 코드 생성을 통해 (성능뿐만 아니라) AOT 문제까지 해결하고 있습니다. 게다가 지난 글의 LibraryImport 옵션도 AOT 상황에서 문제가 될 수 있는 DllImport를 소스 코드 생성기를 활용해 해결(?)하고 있습니다.

그리고, 마침내 COM Interop 관련한 것에도 ^^ 소스 코드 생성을 제공하고 있습니다.

.NET 8: COM Source Generator

* Generate implementations of ComWrappers
* Marshal in C#
* Compatible with trimming and Native AOT

* [ComImport] => [GeneratedComInterface]
* [ComVisible(true)] => [GeneratedComClass]
* [MarshalAs(UnmanagedType.Interface)] in [LibraryImport]
* "Lightbulb' Code Fixers

어떻게 되는지 간단하게 실습을 해볼까요? ^^




우선, PublishAot를 통한 AOT 컴파일 시에는 (내부적으로 Reflection을 사용하는) Activator를 통한 방법은 지원하지 않습니다. 다른 방법인 Win32 Interop을 활용해야 하는데요,

[DllImport("Ole32.dll")]
internal static extern int CoCreateInstance(Guid rclsid, IntPtr pUnkOuter, CLSCTX dwClsCtx, Guid riid, out nint ppv);

nint pValue;

int hr = CoCreateInstance(CLSID_ATLSimpleObject, IntPtr.Zero,
            CLSCTX.INPROC_SERVER, IID_IATLSimpleObject, out pValue);

object simpleObj = Marshal.GetObjectForIUnknown((IntPtr)pValue);

물론, 순수 포인터로 다루기에 별다르게 마샬링 작업이 필요 없으므로 DllImport를 통한 코드 수행이 AOT 환경에서도 잘됩니다. 하지만, 문제는 그 포인터로부터 닷넷에서 활용할 수 있는 개체를 호출하는 방법이 마땅치 않습니다. 이를 자동화하는 코드가 (위의 예처럼) Marshal.GetObjectForIUnknown으로 제공하긴 하지만, 이것 역시 내부적으로 Reflection을 사용하기 때문에 AOT 환경에서 사용할 수 없습니다. 실제로 위의 코드는 PublishAot 옵션과 함께 "dotnet publish" 배포까지는 잘 통과하지만, 실행 시 다음과 같은 오류가 발생합니다.

Unhandled Exception: System.NotSupportedException: COM Interop requires ComWrapper instance registered for marshalling.
   at System.Runtime.InteropServices.ComWrappers.ComObjectForInterface(IntPtr) + 0x76
   at Program.Main(String[] args) + 0x94
   at ConsoleApp1!<BaseAddress>+0xdaf70

혹은 DllImport의 함수를 (런타임에 인터페이스 마샬링을 하도록) 다음과 같이 정의해 사용하면,

[DllImport("Ole32.dll")]
internal static extern int CoCreateInstance(
    Guid rclsid, IntPtr pUnkOuter, CLSCTX dwClsCtx,
    Guid riid,
    [MarshalAs(UnmanagedType.Interface)] out object instance);

object objValue;
CoCreateInstance(CLSID_ATLSimpleObject, IntPtr.Zero,
                CLSCTX.INPROC_SERVER, IID_IUnknown, out objValue);

일반적인 빌드라면 정상 동작했겠지만, PublishAot 옵션을 추가한 경우에는 컴파일에는 경고를, 실행 시에는 런타임 오류를 발생시킵니다.

[컴파일 경고]
warning IL2050: P/invoke method 'Program.CoCreateInstance(Guid, nint, CLSCTX, Guid, out Object)' declares a parameter with COM marshalling. Correctness of COM interop cannot be guaranteed after trimming. Interfaces and interface members might be removed.


[실행 시 예외]
Unhandled exception. System.NotSupportedException: Built-in COM has been disabled via a feature switch. See https://aka.ms/dotnet-illink/com for more information.
at System.Runtime.InteropServices.Marshal.GetObjectForIUnknownNative(IntPtr pUnk)
at Program.Main(String[] args)


결국 MarshalAs 특성을 지정했다면 (사용자가 직접 Marshal.GetObjectForIUnknown을 호출했던 작업을) 런타임 내부에서 자동으로 하는 것에 불과하기 때문에 오류가 나는 것은 당연합니다.

자, 이제 남은 방법은, 직접 마샬링 작업을 처리하면 됩니다. 어차피 CoCreateInstance가 반환한 pValue는 COM 개체의 this 값, 즉 vtable을 가리키는 주소로 시작하므로 이를 해석해 적절하게 함수 호출을 하면 되는데요, 예를 들어 아래의 코드는 AddRef와 Release 함수를 직접 호출하는 것을 보여줍니다.

nint pValue;
int hr = CoCreateInstance(CLSID_ATLSimpleObject, IntPtr.Zero,
            CLSCTX.INPROC_SERVER, IID_IUnknown, out pValue);

nint vtable = *((nint *)pValue);
nint *vtablePtr = (nint*)vtable;

// IUnknown 인터페이스 함수 3개 + IDispatch 인터페이스 함수 4개 + 사용자 COM 개체가 구현한 함수는 1개로 가정
// vtable의 함수에 대한 주소를 나열
for (int i = 0; i < (3 + 4 + 1); i ++)
{
    nint pFunc = vtablePtr[i];
    Console.WriteLine($"vtable[{i}] = {pFunc:X}");
}

// 그중에서 IUnknown 인터페이스의 2개 함수(AddRef, Release)에 대한 포인터를 가져와서,
delegate* unmanaged[Stdcall]<nint, int> addRefFunc = (delegate* unmanaged[Stdcall]<nint, int>)vtablePtr[1];
delegate* unmanaged[Stdcall]<nint, int> releaseFunc = (delegate* unmanaged[Stdcall]<nint, int>)vtablePtr[2];

int refCount = addRefFunc(pValue); // AddRef 직접 호출
Console.WriteLine(refCount);
for (int i = 0; i < refCount; i ++)
{
    Console.WriteLine(releaseFunc(pValue)); // Release 직접 호출
}

/* 출력 결과
vtable[0] = 7FFBC6861D1B
vtable[1] = 7FFBC6861681
vtable[2] = 7FFBC6861415
vtable[3] = 7FFBC68617DA
vtable[4] = 7FFBC6861CE9
vtable[5] = 7FFBC6861947
vtable[6] = 7FFBC6861DBB
vtable[7] = 7FFBC68618A2
2
1
0
*/




기존에는 런타임에 저 작업을 구성해 RCW/CCW로 연결했지만, AOT를 위해서는 저 작업을 컴파일 시점에 모두 마쳐야 합니다. 바로 그 목표를 해결하기 위해 .NET 8부터 추가된 것이 GeneratedComInterface 특성(문서)입니다.

방법은 간단한데요, 원하는 COM 인터페이스의 정의를,

[Guid("047b2642-74d5-4fb9-8e89-023dfe4aed75")]
public interface IATLSimpleObject
{
    void ShowInfo();
}

GeneratedComInterface 특성을 추가 후, partial을 추가하면 됩니다.

[Guid("047b2642-74d5-4fb9-8e89-023dfe4aed75")]
[GeneratedComInterface()]
public partial interface IATLSimpleObject
{
    void ShowInfo();
}

(partial에서 눈치채셨겠지만) 그럼 C# 컴파일러는 IATLSimpleObject의 마샬링 작업을 처리하는 소스 코드를 자동으로 생성해 줍니다. 그래서, 이제는 다음과 같이 Native 포인터로부터 .NET COM 개체를 얻어와 사용할 수 있습니다.

nint pValue;
int hr = CoCreateInstance(CLSID_ATLSimpleObject, IntPtr.Zero,
        CLSCTX.INPROC_SERVER, IID_IATLSimpleObject, out pValue);

IATLSimpleObject? simpleObj =
    global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller.ConvertToManaged((void*)pValue);
global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller.Free((void*)pValue);

Console.WriteLine(simpleObj); // System.Runtime.InteropServices.Marshalling.ComObject

simpleObj?.ShowInfo();

그래도 ConvertToManaged와 Free에 대한 약간 귀찮은 작업이 추가되었는데요, 이것조차도 .NET 7부터 추가된 LibraryImport를 사용하면,

[LibraryImport("Ole32")]
private static partial int CoCreateInstance(
    Guid rclsid, IntPtr pUnkOuter, CLSCTX dwClsContext, Guid riid, out IATLSimpleObject ppObj);

LibraryImport 특성에 대한 소스 코드 생성기는 저렇게 인자로 들어온 COM Interface에 GeneratedComInterface 특성이 부여되어 있다면 자동으로 ConvertToManaged, Free 작업을 처리하는 코드를 생성해 줍니다. 따라서 LibraryImport + GeneratedComInterface가 합작하면 간단하게 COM 개체를 사용할 수 있는 코드가 탄생하게 됩니다.

IATLSimpleObject pObj;
int hr = CoCreateInstance(CLSID_ATLSimpleObject, IntPtr.Zero,
                CLSCTX.INPROC_SERVER, IID_IATLSimpleObject, out pObj);

Console.WriteLine(pObj); // System.Runtime.InteropServices.Marshalling.ComObject

pObj.ShowInfo();




그런데 실제로 저 코드대로 따라 해 보면, COM 개체의 메서드를 호출하는 (대부분의) 경우 정작 예외가 발생하는 것을 보게 될 것입니다. ^^;

System.Runtime.InteropServices.Marshalling.ComObject
Fatal error. System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt.
    at <IATLSimpleObject>F31946FAB9252FCD84CDCF1432DA985EC6ABBECBDC40F381FF3A34F83531C558D__InterfaceImplementation.global::IATLSimpleObject.ShowInfo()
    at Program.Main(System.String[])

그 이유를, GeneratedComInterface 특성을 부여해 생성된 소스 코드에서 찾을 수 있습니다.

file unsafe partial interface InterfaceImplementation
{
    internal static void** CreateManagedVirtualFunctionTable()
    {
        void** vtable = (void**)global::System.Runtime.CompilerServices.RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(global::IATLSimpleObject), sizeof(void*) * 4);
        {
            nint v0, v1, v2;
            global::System.Runtime.InteropServices.ComWrappers.GetIUnknownImpl(out v0, out v1, out v2);
            vtable[0] = (void*)v0;
            vtable[1] = (void*)v1;
            vtable[2] = (void*)v2;
        }

        {
            vtable[3] = (void*)(delegate* unmanaged[MemberFunction]<global::System.Runtime.InteropServices.ComWrappers.ComInterfaceDispatch*, int> )&ABI_ShowInfo;
        }

        return vtable;
    }
}

보는 바와 같이, vtable에 IUnknown의 함수 3개와, 사용자 구현한 함수를 offset 3번부터 이어서 초기화하고 있습니다. 다시 말해, IDispatch를 포함한 COM 개체, 예를 들어 dual 설정이 된 COM 개체들은 지원하지 않고 있는 것입니다.

실제로 마이크로소프트의 블로그 문서에서 다음과 같은 제약을 설명하고 있습니다.

* No support for IDispatch-based interfaces.
    Support for these interfaces may be manually implemented using a local definition of the IDispatch interface.
* No support for IInspectable-based interfaces.
    Use the CsWinRT tool to generate the interop code for these interfaces.
* No support for apartment affinity.
    All COM objects are assumed to be free-threaded. Support for apartment affinity may be manually implemented using the StrategyBasedComWrappers type and custom strategy implementations.
* No support for COM properties.
    These may be manually implemented as methods on the interface.
* No support for COM events.
    These may be manually implemented using the underlying COM APIs.
* No support for using the new keyword to activate a COM CoClass.
    Use LibraryImportAttribute to P/Invoke to the CoCreateInstance API to activate the CoClass.

사실 이게 아주 심각한 제약인데요, 현존하는 거의 모든 COM 개체들은 IDispatch까지를 포함한 경우가 대부분이기 때문입니다. 따라서 만약 다른 사람이 만든 COM 개체라면 IUnknown만 기반으로 하는 COM 개체를 제공해 줄 때까지 기다려야 하고, 심지어 자신이 만든 COM 개체라고 해도 습관적으로 IDispatch를 지원했을 것이므로 일부러 그것을 제외한 COM 개체를 하나 더 제공해야만 합니다.

혹은, 저도 실습하지는 않았지만 다음의 문서에 따라,

Tutorial: Use the ComWrappers API
; https://learn.microsoft.com/en-us/dotnet/standard/native-interop/tutorial-comwrappers

ComWrappers를 상속해 적절한 수준으로, 가령 IDispatch의 4개 함수에 대한 기능은 아니더라도 vtable만 넘어가도록 초기화하는 등의 작업만 해주는 것으로 적절하게 소스 코드 생성기와 연계할 수 있을 듯합니다.

그런 의미에서, 어찌 보면 이런 상황이 이해는 갑니다. IDispatch에는 DISPARAMS나 VARIANT와 같은 복잡한 마샬링 작업을 수반하는 요소들이 있기 때문에 아마도 이번에 한꺼번에 구현해 릴리스하는 것은 부담이 있었을 것입니다. 이와 관련해 다음의 이슈를 보면,

Provide a COM source generator #66674
; https://github.com/dotnet/runtime/issues/66674

"Checkpoint 4: IDispatch support" 항목에서, 요구가 있다면 IDispatch 지원을 고려해 볼 거라고 언급은 하고 있으니 아마도 점진적으로 이 부분은 개선이 될 것으로 예상합니다.

위의 문서에서 한 가지 더 재미있는 것은 "Checkpoint 2: WinForms compatibility"의 내용입니다. 현재 WinForms의 근간에 IUnknown 기반의 COM 개체가 주를 이루고, 소수의 IDispatch COM 개체가 접근성(accessibility) 영역으로 사용되고 있다고 합니다. 따라서, 마이크로소프트 입장에서 향후 IDispatch AOT를 해결한다면 Windows Forms 응용 프로그램을 AOT 빌드할 수 있다는 것을 의미하기 때문에 충분히 논의가 될 가치는 있어 보입니다. (어쩌면, IDispatch를 Full Support로 구현하지는 않더라도 최소한 WinForms에 사용한 IDispatch interop이 가능한 범위로는 하지 않을까요? ^^)




부가적인 이야기를 몇 개 덧붙이자면, 해당 블로그 문서에 의하면, 이번 "Source Generated COM Interop"을 System.Transactions 패키지에 도입했다고 합니다. 즉, 기존에는 해당 dll을 사용하면 AOT가 불가능했지만 이제는 가능해졌다는 이야기일 것입니다.

아울러, AOT 실행에 대한 호환성 여부를 도와주는 IsAotCompatible 옵션이 추가되었다고 하는데요,

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <!-- 생략 -->
        <IsAotCompatible>true</IsAotCompatible>
        <!-- IsTrimmable, EnableTrimAnalyzer, EnableSingleFileAnalyzer, EnableAotAnalyzer 4개의 값을 true로 설정한 효과 -->
    </PropertyGroup>

</Project>

자신의 프로젝트를 AOT로 마이그레이션하고 싶다면 저 옵션의 도움을 받아 경고가 발생하는 것마다 하나씩 처리해 나가면 되겠습니다. (아울러 "How to make libraries compatible with native AOT" 글도 참고하시면 좋습니다.)

마지막으로, COM 인터페이스에 대한 소스 생성을 GeneratedComInterface로 하는 것처럼, 닷넷 managed 개체를 Native에 COM 개체로써 넘길 때에도 (AOT를 위한) 마샬링을 해결하는 용도로 GeneratedComClass 특성이 있습니다.

[GeneratedComClass]
[Guid("...")]
class GetStringImpl: IGetString
{
  string? _string;
  public string? GetString() => _string;
}

별다르게 어려울 것이 없으니 이에 대한 예제는 생략합니다. ^^

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




그렇습니다. 그러니까 저는 결국 이번 글을 쓰려고 예제 코드를 작성하는 과정에서, 이거저거 하다 보니까 엉뚱하게 아래와 같은 글들을 썼던 것입니다. ^^;

C# - (Interop DLL 없이) CoClass를 이용한 COM 개체 생성 방법
; https://www.sysnet.pe.kr/2/0/13469

C# - .NET Core/5+부터 달라진 RCW(Runtime Callable Wrapper) 대응 방식
; https://www.sysnet.pe.kr/2/0/13468

C# - Unhandled exception. System.Runtime.InteropServices.COMException (0x800080A5)
; https://www.sysnet.pe.kr/2/0/13467

C# - DllImport 메서드의 AOT 지원을 위한 LibraryImport 옵션
; https://www.sysnet.pe.kr/2/0/13466

MSBuild - CopyToOutputDirectory가 "dotnet publish" 시에는 적용되지 않는 문제
; https://www.sysnet.pe.kr/2/0/13465

C# - .NET 7부터 UnmanagedCallersOnly 함수 export 기능을 AOT 빌드에 통합
; https://www.sysnet.pe.kr/2/0/13464

.NET Core 3/5+ 기반의 COM Server를 registry 등록 없이 사용하는 방법
; https://www.sysnet.pe.kr/2/0/13461

.NET 6+ 기반의 COM Server 내에 Type Library를 내장하는 방법
; https://www.sysnet.pe.kr/2/0/13460

.NET Core 3/5+ 기반의 COM Server를 기존의 regasm처럼 등록하는 방법
; https://www.sysnet.pe.kr/2/0/13459

.NET Core/5+ 기반의 COM Server를 tlb 파일을 생성하는 방법(tlbexp)
; https://www.sysnet.pe.kr/2/0/13458

이 정도면... 그야말로 야크 털 깎기의 전형적인 사례라고 볼 수 있겠죠? ^^;




오류 상황에 대해 하나 더 정리하면, 위의 본문에서 예를 든 것은 C/C++ COM 개체의 경우였고, 만약 이것을 C# COM 개체로 테스트하면,

nint pValue;
int hr = CoCreateInstance(CLSID_MyNetCodeUnk, IntPtr.Zero, CLSCTX.INPROC_SERVER, IID_IMyNetCodeUnk, out pValue);

CoCreateInstance 단계에서, PublishAot 옵션을 true로 설정하고 빌드(dotnet build)만 한 경우에는 hr 값이 0x800080a7(-2147450713)를 반환하며 오류가 발생합니다. 검색해 보면,

// https://github.com/dotnet/runtime/blob/main/docs/design/features/host-error-codes.md

Error returned by hostfxr_get_runtime_delegate when managed feature support for native host is disabled.

여전히 "dotnet build" 상태는 관리 코드로 실행하는 중이라 "native host is disabled." 설명이 맞습니다. 다른 문구는 잘 이해는 안 가지만, ^^; 어쨌든, 정작 배포(dotnet publish)하고 나면 다시 정상적으로 동작을 합니다.





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

[연관 글]






[최초 등록일: ]
[최종 수정일: 12/19/2023]

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

비밀번호

댓글 작성자
 




1  2  3  4  5  6  7  [8]  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13432정성태10/31/20232454오류 유형: 878. 탐색기의 WSL 디렉터리 접근 시 "Attempt to access invalid address." 오류 발생
13431정성태10/31/20232790스크립트: 60. 파이썬 - 비동기 FastAPI 앱을 gunicorn으로 호스팅
13430정성태10/30/20232678닷넷: 2153. C# - 사용자가 빌드한 ICU dll 파일을 사용하는 방법
13429정성태10/27/20232960닷넷: 2152. Win32 Interop - C/C++ DLL로부터 이중 포인터 버퍼를 C#으로 받는 예제파일 다운로드1
13428정성태10/25/20233035닷넷: 2151. C# 12 - ref readonly 매개변수
13427정성태10/18/20233250닷넷: 2150. C# 12 - 정적 문맥에서 인스턴스 멤버에 대한 nameof 접근 허용(Allow nameof to always access instance members from static context)
13426정성태10/13/20233408스크립트: 59. 파이썬 - 비동기 호출 함수(run_until_complete, run_in_executor, create_task, run_in_threadpool)
13425정성태10/11/20233195닷넷: 2149. C# - PLinq의 Partitioner<T>를 이용한 사용자 정의 분할파일 다운로드1
13423정성태10/6/20233173스크립트: 58. 파이썬 - async/await 기본 사용법
13422정성태10/5/20233319닷넷: 2148. C# - async 유무에 따른 awaitable 메서드의 병렬 및 예외 처리
13421정성태10/4/20233395닷넷: 2147. C# - 비동기 메서드의 async 예약어 유무에 따른 차이
13420정성태9/26/20235585스크립트: 57. 파이썬 - UnboundLocalError: cannot access local variable '...' where it is not associated with a value
13419정성태9/25/20233219스크립트: 56. 파이썬 - RuntimeError: dictionary changed size during iteration
13418정성태9/25/20233925닷넷: 2146. C# - ConcurrentDictionary 자료 구조의 동기화 방식
13417정성태9/19/20233454닷넷: 2145. C# - 제네릭의 형식 매개변수에 속한 (매개변수를 가진) 생성자를 호출하는 방법
13416정성태9/19/20233259오류 유형: 877. redis-py - MISCONF Redis is configured to save RDB snapshots, ...
13415정성태9/18/20233756닷넷: 2144. C# 12 - 컬렉션 식(Collection Expressions)
13414정성태9/16/20233514디버깅 기술: 193. Windbg - ThreadStatic 필드 값을 조사하는 방법
13413정성태9/14/20233710닷넷: 2143. C# - 시스템 Time Zone 변경 시 이벤트 알림을 받는 방법
13412정성태9/14/20236992닷넷: 2142. C# 12 - 인라인 배열(Inline Arrays) [1]
13411정성태9/12/20233492Windows: 252. 권한 상승 전/후 따로 관리되는 공유 네트워크 드라이브 정보
13410정성태9/11/20235031닷넷: 2141. C# 12 - Interceptor (컴파일 시에 메서드 호출 재작성) [1]
13409정성태9/8/20233888닷넷: 2140. C# - Win32 API를 이용한 모니터 전원 끄기
13408정성태9/5/20233843Windows: 251. 임의로 만든 EXE 파일을 포함한 ZIP 파일의 압축을 해제할 때 Windows Defender에 의해 삭제되는 경우
13407정성태9/4/20233588닷넷: 2139. C# - ParallelEnumerable을 이용한 IEnumerable에 대한 병렬 처리
13406정성태9/4/20233557VS.NET IDE: 186. Visual Studio Community 버전의 라이선스
1  2  3  4  5  6  7  [8]  9  10  11  12  13  14  15  ...