C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출하기
· 小村 豪 · C#, .NET, Native AOT, C++, Windows 개발, 네이티브 연동
앞선 글 C#에서 네이티브 DLL을 쓸 때 C++/CLI 래퍼가 유력한 이유 에서는 C#에서 C++을 호출할 때의 경계면을 정리했습니다. 이번에는 방향을 반대로 돌려서, C/C++에서 C#을 호출하는 이야기입니다.
C#으로 쓴 로직을 기존 C/C++ 앱에서 호출하고 싶다, 하지만 P/Invoke는 방향이 반대이고, C++/CLI나 COM까지 끌어오기에는 과하다—이런 상황이 있습니다. 특히 네이티브 앱 본체는 그대로 유지하면서 판정 로직, 문자열 처리, 설정 해석, 계산 규칙 같은 부분만 C# 쪽으로 옮기고 싶을 때입니다.
COM으로도 다리를 놓을 수는 있지만, 이번에는 더 in-process에 가깝고, 더 DLL답게 가는 방식입니다. .NET의 Native AOT라면 클래스 라이브러리를 네이티브 공유 라이브러리로 발행할 수 있고, UnmanagedCallersOnly가 붙은 메서드를 C의 엔트리 포인트로 노출할 수 있습니다. 즉, C#을 「호출당하는 쪽의 네이티브 DLL」로 쓸 수 있습니다.
단, 뭐든지 그대로 경계를 넘길 수 있는 것은 아닙니다. string, List<T>, 예외, 소유권을 경계에 흘리면 분위기가 금방 나빠집니다. 이 글에서는 Windows + C++의 최소 예제를 가지고, 어떤 상황에서 이 구성이 잘 맞는지, 어떤 API 모양으로 만들면 잘 안 깨지는지를 정리합니다. Linux / macOS도 생각은 거의 같지만, 코드 예는 Windows DLL을 전제로 합니다.
1. 먼저 결론 (한마디로)
- C/C++에서 C# 로직을 in-process로 호출하고 싶다면, Native AOT +
UnmanagedCallersOnly는 상당히 유력한 선택입니다. - 단, export되는 것은 어디까지나 C 함수의 입구 입니다.
string이나List<T>를 그대로 노출하는 세계가 아닙니다. - 실무에서는
create/destroy/operate처럼 평평한 C API로 떨어뜨리고, 수명 관리와 에러 코드를 명시하는 편이 안정적입니다. - C++의 클래스나 STL을 자연스럽게 다루고 싶다면 C++/CLI가, 등록·자동화·프로세스 경계가 필요하다면 COM이 더 맞습니다.
요약하면, C#을 네이티브 DLL의 내용물로 쓸 수는 있지만, 경계면은 .NET이 아니라 C ABI로 설계한다 는 이야기입니다. 이 부분을 딱 정리하고 가면 꽤 재미있는 무기가 됩니다.
2. 먼저 보는 구분표
| 하고 싶은 것 | 유력 후보 | 이유 |
|---|---|---|
| C#에서 C 함수군을 호출 | P/Invoke | 방향이 자연스럽고 가장 자연스러움 |
| C#에서 C++ 라이브러리를 자연스럽게 다룸 | C++/CLI | C++ 타입, 소유권, 예외, std::wstring 등을 C++ 쪽에서 흡수하기 쉬움 |
| 32bit / 64bit나 프로세스 경계를 넘음 | COM / IPC | in-process DLL만으로는 못 넘음 |
| C/C++에서 C# 로직을 네이티브 DLL로 호출 | Native AOT + UnmanagedCallersOnly |
C의 entry point를 직접 export할 수 있음 |
이 구성이 잘 맞는 것은, 「네이티브 쪽이 주인공이고, C#은 부품으로 호출되는」 장면입니다. P/Invoke나 C++/CLI와는 정확히 방향이 반대입니다.
3. 구성도
flowchart LR
Cpp["C / C++ 앱"] -->|cdecl 함수 호출| Dll["Native AOT로 발행한 C# DLL"]
Dll --> Exports["UnmanagedCallersOnly 붙은 export"]
Exports --> Core["C#의 업무 로직"]
Exports --> Store["핸들 테이블 / 상태 관리"]
모양은 단순합니다. 중요한 것은 경계면을 C 함수에 맞춰 둔다 는 점입니다. C# 내부 구현은 클래스든 컬렉션이든 LINQ든 상관없지만, 바깥에 노출하는 면은 flat하게 갑니다.
4. 최소 구성
여기서는 C++ 쪽에서 「가산기」를 만들어 값을 더하고, 마지막에 합계를 얻는 최소 예제로 갑니다. 실무에서는 판정 엔진이든, 설정 해석이든, 간단한 파서든 상관없습니다. 한마디로 네이티브 쪽은 handle을 들고 조작 함수를 순서대로 호출하는 형태입니다.
4.1. C# 프로젝트
먼저 클래스 라이브러리를 준비합니다.
<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
핵심은 2가지입니다.
- Native AOT publish를 활성화할 것
- 포인터 인자를 쓸 것이므로
unsafe를 허용할 것
이 글의 샘플은 net8.0을 전제로 하지만, 생각 자체는 .NET 9 / 10에서도 똑같습니다.
4.2. Export할 C# 코드
UnmanagedCallersOnly가 붙은 메서드가 네이티브 쪽에서 보이는 입구가 됩니다. 여기서는 handle을 정수로 발급하고, 내부 상태는 C# 쪽 dictionary로 관리합니다.
// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace KomuraSoft.NativeAotSample;
internal static class NativeStatus
{
public const int Ok = 0;
public const int InvalidArgument = -1;
public const int InvalidHandle = -2;
public const int UnexpectedError = -3;
}
internal sealed class Accumulator
{
public long Total { get; private set; }
public void Add(int value)
{
Total += value;
}
}
internal static class AccumulatorStore
{
private static readonly object s_gate = new();
private static readonly Dictionary<nint, Accumulator> s_instances = new();
private static long s_nextHandle = 0;
public static int Create(out nint handle)
{
try
{
var instance = new Accumulator();
handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);
lock (s_gate)
{
s_instances.Add(handle, instance);
}
return NativeStatus.Ok;
}
catch
{
handle = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Add(nint handle, int value)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
return NativeStatus.InvalidHandle;
}
instance.Add(value);
return NativeStatus.Ok;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
public static int GetTotal(nint handle, out long total)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
total = 0;
return NativeStatus.InvalidHandle;
}
total = instance.Total;
return NativeStatus.Ok;
}
}
catch
{
total = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Destroy(nint handle)
{
try
{
lock (s_gate)
{
return s_instances.Remove(handle)
? NativeStatus.Ok
: NativeStatus.InvalidHandle;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
}
public static unsafe class NativeExports
{
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_create",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorCreate(nint* outHandle)
{
if (outHandle == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.Create(out var handle);
*outHandle = handle;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_add",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorAdd(nint handle, int value)
{
return AccumulatorStore.Add(handle, value);
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_get_total",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorGetTotal(nint handle, long* outTotal)
{
if (outTotal == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.GetTotal(handle, out var total);
*outTotal = total;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_destroy",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorDestroy(nint handle)
{
return AccumulatorStore.Destroy(handle);
}
}
하는 일은 꽤 소박합니다.
- 네이티브 쪽에 노출하는 것은
intptr_t의 handle뿐 - 상태 본체는 C# 쪽에서 보유
- create / add / get / destroy를 flat한 함수로 분해
- 반환값은 에러 코드, 출력값은 포인터 인자로 돌려줌
이 형태로 해 두면, C# 내부 구현을 나중에 갈아엎어도 C 쪽 ABI는 상당히 안정적입니다.
4.3. 발행 커맨드
공유 라이브러리로 publish합니다.
dotnet publish -r win-x64 -c Release /p:NativeLib=Shared
이걸로 bin/Release/net8.0/win-x64/publish/ 아래에 네이티브 DLL이 나옵니다. Windows면 .dll, Linux면 .so, macOS면 .dylib 입니다.
중요한 건 RID마다 publish한다 는 점입니다. win-x64로 만든 것을 win-arm64 전제로 쓸 수는 없고, 호출 측과 DLL의 bitness도 맞춰야 합니다.
4.4. C++ 쪽 호출 예
이번에는 import lib 이야기는 잠시 접어 두고, LoadLibrary / GetProcAddress로 직접 호출합니다. 이 형태라면 무엇이 export되어 있고 어떤 시그니처로 받아야 하는지가 잘 보입니다.
/* native_api.h */
#pragma once
#include <stdint.h>
enum km_status
{
KM_STATUS_OK = 0,
KM_STATUS_INVALID_ARGUMENT = -1,
KM_STATUS_INVALID_HANDLE = -2,
KM_STATUS_UNEXPECTED_ERROR = -3
};
typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>
#include "native_api.h"
template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
FARPROC proc = ::GetProcAddress(module, name);
if (proc == nullptr)
{
std::cerr << "GetProcAddress failed: " << name << '\n';
std::exit(EXIT_FAILURE);
}
return reinterpret_cast<T>(proc);
}
int main()
{
HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
if (module == nullptr)
{
std::cerr << "LoadLibraryW failed" << '\n';
return EXIT_FAILURE;
}
auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");
intptr_t handle = 0;
if (create(&handle) != KM_STATUS_OK)
{
std::cerr << "create failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 10) != KM_STATUS_OK)
{
std::cerr << "add(10) failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 20) != KM_STATUS_OK)
{
std::cerr << "add(20) failed" << '\n';
return EXIT_FAILURE;
}
std::int64_t total = 0;
if (getTotal(handle, &total) != KM_STATUS_OK)
{
std::cerr << "get_total failed" << '\n';
return EXIT_FAILURE;
}
std::cout << "total = " << total << '\n';
if (destroy(handle) != KM_STATUS_OK)
{
std::cerr << "destroy failed" << '\n';
return EXIT_FAILURE;
}
handle = 0;
// Native AOT의 공유 라이브러리는 언로드 전제로는 쓰지 않는다.
// FreeLibrary(module);
return EXIT_SUCCESS;
}
이 예에서 C++ 쪽에서 보이는 것은 「함수 포인터로 호출할 수 있는 C API」뿐입니다. 안쪽이 C#로 쓰여 있다는 사실은 거의 의식할 필요가 없습니다.
5. 잘 안 깨지는 API 모양
Native AOT로 export할 수 있는 것은 재미있지만, 실무에서는 무엇을 export하지 않는가 가 더 중요합니다.
5.1. C ABI에 맞추기
경계에 내보내는 타입은 처음부터 다음 근처로 맞추는 편이 얌전합니다.
int32_t/int64_t/double같은 기본형- 레이아웃 고정 struct
intptr_t/void*상당의 handleuint8_t*와 길이
반대로 처음부터 바깥에 흘리고 싶지 않은 것은 다음입니다.
stringobjectList<T>TaskSpan<T>- C++의 클래스나
std::vector,std::wstring
이런 것들을 그대로 경계 너머로 넘기려 하면 경계면이 금방 탁해집니다. C#의 사정을 C++에 흘리지 않고, C++의 사정도 C#에 너무 흘리지 않는다 가 중요합니다.
5.2. 문자열은 포인터 + 길이 + 버퍼 용량으로 다룬다
문자열을 주고받고 싶어지면 바로 string을 내보내고 싶어지지만, 여기는 꾹 참는 편이 좋습니다. 라이브러리 경계에서는 예를 들어 다음과 같은 형태로 떨어뜨리는 것이 명료합니다.
int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);
결국 문자 인코딩, 길이, 누가 버퍼를 확보하는가 를 먼저 정해 둔다는 이야기입니다. Windows이니까 UTF-16에 맞추는 선택지도 있지만, 다른 언어까지 염두에 둔다면 UTF-8이 다루기 편한 경우가 많습니다.
5.3. 예외를 경계 너머로 넘기지 않는다
네이티브 함수 경계는 예외의 표현으로는 그리 친절하지 않습니다. 적어도 managed 예외를 그대로 호출자에게 흘리는 설계는 하지 않는 편이 안전합니다.
실무에서는,
- 반환값은 status code
- 실제 데이터는 out 버퍼나 포인터 인자
- 필요하다면
get_last_error형식으로 추가 정보를 취득
정도로 해 두면 다루기 쉽습니다.
화려하지는 않지만, 이런 수수한 설계가 뒤에 가서 효과를 봅니다. 경계면에서 갑자기 격투기를 시작하지 않는다는 이야기입니다.
5.4. 호출 규약을 고정한다
샘플에서는 CallConvCdecl을 명시했습니다. 생략하면 플랫폼 기본의 호출 규약이 되지만, 헤더나 함수 포인터 타입을 고정하고 싶다면 여기서 명시해 버리는 편이 사고가 덜 납니다.
특히 x86을 상대할 가능성이 있다면 여기를 애매하게 두면 나중에 힘들어집니다. x64에서는 잘 드러나지 않아도 규칙을 먼저 정해 두는 편이 좋습니다.
5.5. Export 메서드는 얇게, 본체는 따로
UnmanagedCallersOnly가 붙은 메서드는 일반 managed 코드에서 그대로 호출하는 것을 전제로 하지 않습니다. 그래서 거기에 업무 로직을 다 쓰기 시작하면 테스트도 어려워집니다.
샘플에서도 실체의 관리는 AccumulatorStore에 두고, export되는 NativeExports는 얇은 입구만 담당합니다. 이건 꽤 중요합니다.
- export 메서드: ABI의 창구
- 내부 클래스: 평범한 C# 로직
이런 분업으로 해 두면 C++와의 경계와 C# 본체 코드를 따로 생각할 수 있습니다.
6. 잘 맞는 경우
이 구성이 기분 좋게 맞는 건 예를 들어 다음과 같은 장면입니다.
- 기존 C/C++ 앱은 그대로 두고, 일부 업무 로직만 C#으로 옮기고 싶다
- .NET 런타임의 사전 설치를 배포 전제로 삼고 싶지 않다
- export하는 함수 면을 작게 유지할 수 있다
- 미래에 Rust, Go 등 다른 언어에서도 같은 C API로 부르고 싶을 수 있다
특히 네이티브 앱은 그대로, 갈아끼우기 쉬운 로직 레이어만 C#으로 쓴다 는 구성에 상성이 좋습니다. UI나 장비 제어는 C++ 그대로, 판정·계산·설정 룰은 C#, 라는 식의 분리입니다.
7. 그래도 안 맞는 경우
물론 이건 만능이 아닙니다. 안 맞는 장면도 분명히 있습니다.
- C++의 클래스나
std::vector, 예외를 그대로 다루고 싶다- 이럴 때는 C++/CLI나 네이티브 쪽 래퍼가 자연스럽습니다.
- COM 등록, VBA / Office 자동화, Explorer 확장 같은 세계로 들어가고 싶다
- 여기는 COM 문맥으로 생각하는 편이 좋습니다.
- 32bit / 64bit를 다리 놓고 싶거나 프로세스 경계를 넘고 싶다
- in-process DLL이 아니라 COM / IPC / 별도 프로세스 구성 쪽이 깔끔합니다.
- 플러그인을 나중에 언로드하고 싶다
- Native AOT 공유 라이브러리는 언로드 전제로 쓰지 않는 편이 좋습니다.
- 의존 라이브러리가 reflection이나 동적 코드 생성에 크게 의존한다
- AOT publish의 warning이 난다면 그 warning을 대충 무시하지 않는 편이 안전합니다.
요컨대, C ABI로 잘라낼 수 있는가 가 갈림길입니다. 잘라낼 수 없다면 다른 다리가 더 깔끔합니다.
8. 걸려 넘어지기 쉬운 지점
끝으로, Native AOT export에서 수수하게 걸려 넘어지기 쉬운 점들을 모아둡니다.
UnmanagedCallersOnly를 붙이는 메서드는static이어야 합니다.- generic 메서드나 generic class 안에는 둘 수 없습니다.
- named export로 하고 싶다면
EntryPoint를 붙입니다. ref/in/out은 쓰지 말고, 포인터 인자로 돌려주는 형태가 좋습니다.- export되는 것은 publish 대상 어셈블리 쪽 메서드입니다. 참조처 라이브러리의 메서드에 속성을 붙여도 그대로는 바깥에 나오지 않습니다.
- 호출 측과 DLL의 bitness는 맞춰야 합니다.
- publish warning은 꽤 중요합니다. AOT / trimming의 warning이 나 있다면 먼저 그쪽을 정리하는 편이 안전합니다.
이 근방은 전부 「알고 나면 그렇지요」라는 이야기들입니다. 하지만 모르는 상태에서 한 번 밟아 보면 상당히 시큰한 시간이 흐릅니다.
9. 정리
C/C++에서 C#을 부르고 싶을 때 먼저 떠오르는 건 COM이나 C++/CLI, 혹은 별도 프로세스입니다. 전부 올바른 선택지입니다.
다만 in-process 네이티브 DLL로 C# 로직을 꽂고 싶다 면 Native AOT + UnmanagedCallersOnly는 꽤 재미있는 선택지입니다.
요점을 다시 정리하면 다음입니다.
- C#을 그대로 노출하지 말고, C ABI로 flatten한다
- handle 기반으로 수명 관리를 명시한다
- 예외가 아니라 error code로 경계를 넘는다
- 호출 규약을 고정한다
- export 메서드는 얇게, 내부 로직과 분리한다
하는 일은 화려하지 않습니다. 하지만 이런 「경계를 어떻게 자를까」가 나중 유지보수에 크게 영향을 줍니다. 네이티브 자산을 살리면서 로직 레이어만 C#의 생산성으로 가져오고 싶을 때, 이 구성은 기억해 둬서 손해 볼 일이 없습니다.
10. 참고 자료
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- C#에서 네이티브 DLL을 쓸 때 C++/CLI 래퍼가 유력한 이유 - 합동회사 고무라소프트 Blog
- 32bit 앱에서 64bit DLL을 호출하는 방법 - COM 브리지가 쓸모 있는 케이스 스터디 - 합동회사 고무라소프트 Blog
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스
Windows 앱이 자식 프로세스에 의존할 때, 기동 API보다 프로세스 트리의 소유권과 종료 절차의 설계가 안정성을 좌우합니다. Job Object로 수명을 묶고, 종료 전파를 분리하며, stdout/stderr를 비동기로 흘리고 watchdo...
시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
시리얼 통신 앱이 가끔 멈추거나 응답이 어긋나는 진짜 원인은 byte stream의 메시지 경계, 타임아웃, 재접속, single writer 설계에 있습니다. 실무에서 무너지기 쉬운 함정과 먼저 정리할 체크리스트를 한 번에 정리했습니다.
공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
공유 메모리는 단순히 빠른 IPC가 아니라 동기, 가시성, 수명, ABI, 권한의 책임을 앱 측이 떠맡는 구조입니다. 본 글은 함정과 베스트 프랙티스를 정리하여 SPSC 링 버퍼나 더블 버퍼, 고정 헤더, 오프셋 참조 등 사고율을 내리는 설계 첫...
.NET의 Native AOT란 무엇인가 - JIT, ReadyToRun, trimming과의 차이를 먼저 정리
Native AOT가 publish 시점에 .NET 앱을 정적으로 굳히는 배포 모델임을 JIT, ReadyToRun, trimming, source generator와 비교해 정리하고, 어느 앱에 잘 맞고 어디서 막히는지 실무 관점에서 판별 기준...
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
32비트 / 64비트 상호 운용
32비트 / 64비트 상호 운용, 네이티브 경계, 관련된 Windows 설계 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
Windows 소프트웨어 유지 보수 & 현대화
기존 Windows 소프트웨어에 대한 단계적 업그레이드, 기능 추가, 64비트 대응, 유지 보수 가능한 재구조화를 지원합니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크