DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 망가뜨리는지의 판단표

· · COM, DLL, .NET, C#, C++, 하위 호환성, 버저닝, 레거시 기술, 기존 자산 활용, 판단표

“이번 수정, DLL만 바꿔치기하면 되나요? 아니면 호출 측도 재빌드해야 하나요?”── 여러 앱에서 참조하는 공통 DLL이나 COM 컴포넌트를 유지보수하다 보면, 릴리스할 때마다 이 질문에 답하게 됩니다. 답을 잘못하면 고객사에서 동작 중인 오래된 EXE가 실행되지 않게 되거나, 더 나쁘게는 실행은 되는데 계산 결과가 조용히 바뀌어 버리기도 합니다.

까다로운 점은 이 판단이 “어쩐지 위험해 보인다”는 감각으로 이루어지기 쉽다는 것입니다. 실제로는 어떤 변경이 호환성을 깨는지는 거의 기계적으로 판정할 수 있습니다. 네이티브 DLL에는 익스포트와 호출 규약의 규칙이 있고, COM에는 “인터페이스는 불변”이라는 명문화된 철칙이 있으며1, .NET에는 Microsoft 자신이 .NET 라이브러리 개발에서 사용하는 호환성 변경 규칙 목록이 있습니다2.

이 블로그에서는 “COM / ActiveX / OCX란 무엇인가“에서 COM의 기본을, “COM이란 무엇인가 - Windows COM 설계가 지금도 아름다운 이유“에서 그 설계 사상을 해설했습니다. 이 글에서는 DLL・COM・.NET 어셈블리 각각에 대해 “어떤 변경이 호출 측을 망가뜨리는지”를 판단표 형태로 정리하고, 부득이하게 깨야 할 때의 절차까지 정리합니다.

이 글에서 사용하는 용어

판단표를 읽으려면 바이너리 수준의 용어가 몇 가지 필요합니다. 자세한 내용은 각 장에서 다루지만, 먼저 한 줄씩 짚어 두면 읽기가 수월해집니다.

용어 한 줄 의미
ABI(Application Binary Interface) 컴파일이 끝난 바이너리끼리 지키는 약속. 인수를 넘기는 방법, 구조체의 메모리 배치, 심볼 이름을 붙이는 방식 등 소스 코드가 아니라 기계어 수준의 계약
호출 규약(calling convention) ABI의 일부. 인수를 레지스터와 스택 중 어느 쪽으로, 어떤 순서로 넘길지, 쌓인 인수를 호출 측과 호출된 측 중 누가 정리할지의 약속(__cdecl / __stdcall 등)
vtable(가상 함수 테이블) 함수 포인터를 정해진 순서로 나열한 표. COM 인터페이스의 실체이며, 호출 측은 “몇 번째 슬롯인가”라는 위치로 목적하는 메서드를 호출
익스포트 서수(ordinal) DLL의 익스포트 테이블에서 각 함수에 부여되는 번호. 호출 측은 함수 이름 대신 이 번호로 임포트할 수도 있음
IID / CLSID / ProgID 순서대로 COM 인터페이스(계약)의 식별자, 구현 클래스의 식별자, CLSID에 대응된 사람이 읽을 수 있는 별칭(4.1절)
강력한 이름(strong name) .NET 어셈블리를 “이름+버전+컬처+공개 키 토큰”과 서명으로 고유하게 식별하는 구조
바인딩 리다이렉트 .NET Framework에서 호출 측이 요구하는 어셈블리 버전을, 실제로 로드할 다른 버전으로 바꿔 읽게 하는 구성 파일의 설정

1. 먼저 결론

  • 호환성에는 바이너리 호환(재빌드 없이 동작)・소스 호환(재빌드하면 동작)・동작 호환(동작이 바뀌지 않음)의 3계층이 있습니다. “재빌드 불필요=안전”이 아니라 동작 호환까지 포함해서 판단해야 합니다.3
  • 네이티브 DLL의 기본선은 “익스포트 추가는 안전, 기존 익스포트의 변경・삭제는 파괴적”입니다. 함수 시그니처・호출 규약・구조체 레이아웃은 바이너리 계약 그 자체입니다.
  • COM의 인터페이스는 공개하면 불변(immutable)입니다. 공개 후 메서드 추가・삭제・재배열은 사양 위반이며, 변경은 새로운 IID를 가진 새 인터페이스(IFoo→IFoo2)로 추가합니다.41
  • VB6/VBA 클라이언트는 사전 바인딩으로 vtable 상의 위치를 각인하기 때문에, 인터페이스 레이아웃 변경으로 가장 쉽게 깨지는 호출 측입니다.
  • .NET에서는 “public API의 무엇이 파괴적인가”가 Microsoft의 호환성 변경 규칙으로 공개되어 있으며, 메서드 삭제나 시그니처 변경뿐 아니라 가상화(virtual 추가)나 매개변수 이름 변경까지 파괴적 변경으로 분류됩니다.2
  • 시맨틱 버저닝은 “파괴적 변경이면 메이저를 올린다”는 규약이지만, 무엇이 파괴적 변경인지의 정의를 선언해야 비로소 기능합니다.5 이 글의 판단표를 그 정의로 사용할 수 있습니다.
  • 호환성을 부득이하게 깨야 할 때는 신구 병행 제공→비권장 기간→호출 측 전수조사→폐지의 순서로 진행합니다. 갑자기 바꿔치기하지 않는 것이 원칙입니다.

2. 호환성의 3계층 ── 누가, 언제 깨지는가

“하위 호환성”이라는 한마디로 불리는 것은 실제로는 3개의 계층으로 나뉩니다. .NET 공식 문서에서도 파괴적 변경은 소스 호환・바이너리 호환・동작 호환의 관점에서 분류되어 있습니다.3

계층 의미 깨지면 일어나는 일 주로 곤란한 사람
바이너리 호환 호출 측을 재빌드하지 않고도 새 DLL로 동작 시작 시 엔트리포인트 미검출, 실행 중 MissingMethodException, 크래시 고객사에서 동작 중인 오래된 EXE, 재빌드할 수 없는 타사 제작 앱
소스 호환 호출 측을 재빌드하면 동작 다음 빌드 시 컴파일 오류 사내의 다른 팀, 소스를 가진 개발자
동작 호환 사양으로서의 동작이 바뀌지 않음 오류가 나지 않은 채 결과・타이밍・예외의 종류가 바뀜 최종 사용자(그리고 장애 조사를 하는 모든 사람)

이 3계층은 중첩으로 생각하면 관계를 파악하기 쉬워집니다.

[동작 호환]동작이 바뀌지 않음                ← 가장 바깥쪽
  └─[소스 호환]재빌드하면 동작
       └─[바이너리 호환]재빌드하지 않고 동작   ← 가장 안쪽

안쪽일수록 조건이 엄격하고, 안쪽을 지킬수록 호출 측의 수고는 작아집니다. 이 3계층에서 중요한 것은 안쪽 계층이 무사해도 바깥쪽 계층은 깨질 수 있다는 점입니다. 예를 들어 기존 함수의 반환값 의미를 바꾸는 수정은 바이너리 호환도 소스 호환도 유지된 채 동작 호환만을 깨뜨립니다. 이런 종류의 변경은 링크 오류도 컴파일 오류도 나지 않기 때문에 판단표 중에서 가장 놓치기 쉬운 행입니다.

반대로 호출 측 전원이 소스를 가지고 있어서 동시에 재빌드할 수 있는 환경(단일 리포지토리의 사내 시스템 등)이라면, 지켜야 할 것은 소스 호환과 동작 호환뿐이고 바이너리 호환은 요건에서 제외할 수 있습니다. “자신의 DLL을 호출하는 측에 재빌드할 수 없는 바이너리가 있는가”가 판단표를 읽을 때의 첫 번째 분기입니다.

3. 네이티브 DLL(C/C++)의 호환성 판단표

네이티브 DLL의 호환성은 익스포트 테이블과 호출 규약, 그리고 메모리 레이아웃으로 결정됩니다. DLL이 어떻게 탐색되고 로드되는지는 “Windows에서 DLL 이름 해결의 메커니즘“에서 해설한 대로이지만, 로드에 성공한 후의 호환성은 다음 표로 판단할 수 있습니다.

변경 내용 바이너리 호환 비고
익스포트 함수의 추가 깨지지 않음 가장 안전한 확장 수단. 다만 .def 파일의 암묵적 서수에 의존하고 있으면 추가 위치에 따라 기존 서수가 재배정되므로, 서수 링크를 하는 클라이언트가 있다면 기존 서수를 명시적으로 고정하고 끝에 추가
익스포트 함수의 삭제・이름 변경 깨짐 임포트 해결에 실패, 로드 시점 또는 GetProcAddress에서 오류
기존 함수의 시그니처 변경(인수 추가・삭제・타입 변경, 반환값 타입 변경) 깨짐 스택・레지스터를 통한 전달이 어긋남. 반환값도 마찬가지로, 정수(RAX)에서 부동소수점(XMM0)으로 바꾸면 호출 측은 예전 ABI로 쓰레기값을 읽음. 오류가 나지 않고 폭주하는 경우도 있음
호출 규약의 변경(__cdecl__stdcall) 깨짐(32bit) x86에서는 스택 정리 책임이 뒤바뀌어 스택 파괴로 이어짐. x64는 호출 규약이 단일이라 이 지정들이 사실상 무시되므로, 이 행은 32bit DLL 이야기
익스포트 서수(ordinal)의 변경 조건부로 깨짐 서수 링크를 하는 호출 측이 다른 함수를 호출. 이름 링크만이라면 영향 없음
호출 측이 확보하는 구조체에 멤버 추가 깨짐 오래된 호출 측은 작은 크기로 확보해서 넘겨줌(후술하는 cbSize 관습으로 완화 가능)
공개 구조체의 패킹・정렬 변경(#pragma pack, /Zp, 툴체인 변경) 깨짐 멤버를 하나도 건드리지 않아도 기존 멤버의 오프셋과 전체 크기가 바뀜. cbSize로도 위치 어긋남은 구제할 수 없으므로, 공개 헤더에서 패킹을 명시적으로 고정해 둠
DLL 측에서만 확보・해제하는 구조체의 내부 변경 깨지지 않음 포인터(핸들)만 외부에 노출하는 설계라면 내부는 자유롭게 바꿀 수 있음
반환값・오류 코드의 의미 변경 깨지지 않음(하지만 동작 호환이 깨짐) 링크는 성공하는데 동작이 바뀜, 가장 발견이 늦어지는 패턴
C++ 클래스를 직접 익스포트해서 데이터 멤버・가상 함수 추가 깨짐 객체 크기나 vtable 레이아웃이 바뀜. 비가상 멤버 함수 추가만이라면 레이아웃은 바뀌지 않아 기존 클라이언트를 직접 깨지는 않지만, 애초에 C++ 클래스의 직접 익스포트는 컴파일러 간 호환성이 없어 이런 판단을 매번 강요받는 시점에서 ABI로서 취약함

이 표는 “앞으로 무엇을 바꿀 것인가”를 정하기 위한 것이지만, 현장에서는 반대 방향, 즉 “고객사에서 나온 이 증상은 어느 행의 변경이 원인인가”를 찾는 일이 더 많습니다. 위의 각 행이 실제로 어떻게 표면화되는지 대응 관계를 정리해 둡니다.

현장에서 나오는 증상 의심해야 할 행
시작 시 “프로시저 진입점을 찾을 수 없습니다” 계열의 대화상자가 뜨며, 애초에 앱이 실행되지 않는다. NTSTATUS는 0xC0000139(STATUS_ENTRYPOINT_NOT_FOUND, 원문은 “The procedure entry point %hs could not be located in the dynamic link library %hs.”)6 익스포트 함수의 삭제・이름 변경. C++에서는 시그니처를 바꾼 결과 이름 수식(맹글링된 이름)이 바뀌어 결국 “다른 이름이 된” 경우도 같은 증상이 됨
GetProcAddressNULL을 반환하고, 앱이 자체 오류 메시지를 표시 위와 동일. 지연 로드・동적 로드를 하는 호출 측에서는 이런 형태로 표면화됨
DLL 자체를 찾지 못해 실행되지 않는다. NTSTATUS는 0xC0000135(STATUS_DLL_NOT_FOUND)6 호환성이 아니라 배치・검색 순서 문제. 판단표를 보기 전에 DLL의 탐색 경로를 의심
함수에서 돌아온 직후에 다운된다. 디버그 빌드(/RTCs 또는 /RTC1)에서는 런타임 검사가 스택 포인터 파괴로 이를 잡아냄 호출 규약의 변경(32bit). Microsoft도 스택 포인터의 파괴는 호출 규약 불일치로 일어날 수 있다고 명시하고 있습니다7. 릴리스 빌드에서는 검출되지 않아 전혀 다른 곳에서 다운됨
오류는 나지 않지만 구조체의 특정 멤버만 값이 이상해진다. 드물게 버퍼 오버런 구조체에 멤버 추가, 또는 패킹・정렬 변경. cbSize 관습(3.1절)을 넣어 두지 않으면 원인 규명이 어려움
.NET 쪽 호출 측에서 MissingMethodException이 발생 .NET 쪽의 멤버 삭제・이름 변경・시그니처 변경(5장)
오류는 전혀 나지 않는데 장표나 집계 수치가 바뀌었다 반환값・오류 코드의 의미 변경. 동작 호환만 깨진 상태로, 발견이 가장 늦어짐

이 표에서 도출되는 설계 지침은 예나 지금이나 변함없습니다. 경계는 C ABI(extern “C” 함수와 단순한 구조체)로 한정하고, 확장은 함수 추가로 한다는 것입니다. C#으로 네이티브 DLL을 만드는 경우도 마찬가지로, “C#을 Native AOT로 네이티브 DLL로 만드는 방법“에서 다룬 익스포트 측면도 이 표대로 관리합니다.

3.1 cbSize 관습 ── 구조체를 확장 가능하게 만드는 Win32의 지혜

“구조체에 멤버를 추가하면 깨진다”에 대한 고전적인 대책이 구조체의 맨 앞에 크기 필드를 두는 Win32의 관습입니다. 호출 측은 cbSize에 자신이 컴파일 시점에 알고 있던 구조체 크기를 넣어서 넘기고, DLL 측은 그 크기를 보고 “이 호출 측은 어느 세대의 구조체를 알고 있는가”를 판별합니다.

typedef struct KS_CONFIG {
    DWORD cbSize;      // 호출 측이 sizeof(KS_CONFIG)를 설정
    DWORD dwMode;
    DWORD dwTimeout;
    // 향후 멤버는 반드시 끝에 추가
} KS_CONFIG;

// DLL 측: cbSize로 세대를 판별하고, 오래된 호출 측에는 기본값으로 동작
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // 새 호출 측
} else {
    timeout = DEFAULT_TIMEOUT;      // 오래된 호출 측
}

판정에 sizeof(KS_CONFIG)가 아니라 FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)를 사용하는 것은 “dwTimeout까지 존재하는 세대인가”를 끝 멤버 단위로 판정하기 위해서입니다. sizeof로 판정하면 앞으로 끝에 멤버를 더 추가한 순간 조건이 엄격해져, “dwTimeout은 알지만 새 멤버는 모르는” 세대의 호출 측까지 오래된 것으로 취급되어 버립니다. 멤버마다 이런 형태로 판정해 두면 확장할 때마다 기존 판정을 다시 쓰지 않아도 됩니다.

실제로 Windows API의 NOTIFYICONDATA 구조체는 바로 이 방식으로 세대 관리되고 있으며, cbSize에 설정하는 값에 따라 오래된 Shell32.dll과의 호환성을 유지할 수 있음이 공식적으로 문서화되어 있습니다.8 자체 제작 DLL의 공개 구조체에도 처음 버전부터 cbSize를 넣어 두면 이후의 확장이 “파괴적 변경”에서 “판단표의 안전한 쪽”으로 옮겨갑니다. 다만 멤버 추가는 반드시 끝에, 기존 멤버의 타입・순서 변경은 여전히 금지입니다. 하나 더, 출력용 구조체에서는 DLL 측의 책임이 늘어납니다. 쓰기・초기화는 반드시 넘겨받은 cbSize 범위 안으로 한정할 것. 새 sizeof만큼을 무조건 써 버리면 오래된 호출 측이 확보한 작은 버퍼를 넘어서게 되어, 이 관습이 막으려 했던 파괴를 DLL 측이 일으키게 됩니다.

4. COM 인터페이스의 철칙 ── 공개하면 변경 금지

COM은 이 문제에 가장 명쾌한 답을 낸 기술입니다. COM 사양에서는 인터페이스가 다음 규칙을 따릅니다.

  • 인터페이스는 고유한 IID(인터페이스 ID)를 가진다.1
  • 인터페이스는 불변(immutable)이다. 한 번 작성・공개하면 정의의 어떤 부분도 변경해서는 안 된다.1
  • 메서드의 추가・삭제나 의미 변경은 “기존 인터페이스의 새 버전”이 아니라 다른 IID를 가진 새 인터페이스를 만드는 것을 의미한다.4

왜 이렇게까지 엄격한가 하면, COM 인터페이스의 실체가 vtable(함수 포인터 표)이라는 바이너리 레이아웃이기 때문입니다. C++이나 VB6 클라이언트는 컴파일 시점에 “세 번째 슬롯이 GetName”이라는 위치를 각인합니다. 공개 후 메서드를 삽입하면 오래된 클라이언트는 아무 오류도 없이 다른 메서드를 호출하게 됩니다. 그렇기 때문에 COM은 “변경”이라는 조작 자체를 사양에서 없애고, 대신 다음 확장 절차를 마련했습니다.

// v1: 공개 완료. 이제 일절 변경하지 않음
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: 새로운 IID를 가진 새 인터페이스. ICalc를 상속해서 확장
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

위의 IDL은 uuid1111....처럼 생략한 이미지 예시로, 이대로는 컴파일할 수 없습니다. 실제로는 Visual Studio에 딸린 GUID 생성 도구(guidgen)나 uuidgen 명령으로 생성한 완전한 GUID를 적습니다. 같은 GUID를 두 인터페이스에 사용하면 계약을 구별할 수 없게 되므로, 인터페이스를 늘릴 때마다 반드시 새로 생성합니다.

구현 클래스(coclass)는 ICalcICalc2 양쪽을 구현하고, 오래된 클라이언트는 지금까지처럼 ICalc로, 새 클라이언트는 QueryInterfaceICalc2를 요청해서 사용합니다. RPC/COM의 공식 버저닝 이론에서도 “기존 인터페이스를 상속한 새 인터페이스가 마이너 버전 업에 해당하고, 기존 메서드나 타입을 바꾸는 것이라면 상속하지 않는 완전히 새로운 인터페이스(메이저 버전 업에 해당)가 필요하다”고 정리되어 있습니다.9 QueryInterface를 통해 호출 측이 대응 상황을 실행 시점에 안전하게 확인할 수 있다는 점이 이 방식을 성립시키고 있습니다. 이 구조가 지닌 설계상의 아름다움은 “COM이란 무엇인가“에서 파고든 대로입니다.

4.1 CLSID・ProgID・IID의 역할 분담

COM의 버저닝을 생각할 때는 세 종류의 식별자 역할을 나누어 생각합니다.10

  • IID는 인터페이스(계약)의 식별자입니다. 계약이 바뀌면 반드시 새로운 IID가 됩니다.
  • CLSID는 구현 클래스의 식별자입니다. 같은 CLSID인 채로 구현을 바꿔치기하는 것은, 공개된 인터페이스의 계약을 지키는 한 자유롭습니다.
  • ProgID는 사람이 읽을 수 있는 별칭(KomuraSoft.Calc.1)으로, CLSID에 대한 대응을 레지스트리에서 찾기 위한 것입니다. 버전 번호가 붙은 ProgID와, 항상 최신판을 가리키는 버전 비의존 ProgID(KomuraSoft.Calc)를 함께 등록하는 관습이 있으며, 후자는 CurVer로 최신판에 대응됩니다.10

즉 “구현의 버전 업”은 CLSID와 ProgID의 세계 이야기이고, “계약의 변경”은 IID의 세계 이야기이므로 섞어서는 안 됩니다. 레지스트리 등록 자체를 피하고 싶은 경우의 선택지는 “Reg-Free COM이란 무엇인가“에서 다루고 있습니다.

4.2 VB6/VBA 클라이언트가 특히 쉽게 깨지는 이유

VB6나 VBA에서 참조 설정(사전 바인딩)으로 COM 컴포넌트를 사용하는 경우, 컴파일 시점에 타입 라이브러리를 읽어서 호출을 해결합니다. 사전 바인딩은 IntelliSense와 타입 검사가 작동하고 실행도 빠른 권장 형태이지만11, 그 대가로 타입 라이브러리의 레이아웃에 강하게 결합됩니다. 인터페이스의 vtable이 바뀌는 것은 물론이고, 타입 라이브러리상의 정의가 바뀌기만 해도 “프로젝트를 열었더니 참조가 깨져 있었다”, “실행 중 오류 430/438이 발생한다” 같은 형태로 표면화됩니다.

이 때문에 VB6/VBA/Excel 매크로가 호출 측에 있는 컴포넌트에서는 인터페이스 불변의 철칙을 가장 엄격하게 지켜야 합니다. 타입 라이브러리에도 버전(major.minor)이 있어, 계약을 늘렸다면 올려서 관리합니다. .NET 쪽에서 VBA로 타입이 있는 형태로 공개하는 경우의 타입 라이브러리 생성은 “.NET 8의 DLL을 형 있게 VBA에서 쓰는 방법 - COM 공개 + dscom으로 TLB를 생성“에서 해설하고 있습니다. 한편 CreateObject만으로 사용하는 지연 바인딩 클라이언트는 이름으로 해결하기 때문에 레이아웃 변경에는 강하지만, 메서드의 의미 변경(동작 호환)의 영향은 마찬가지로 받습니다.

5. .NET 어셈블리의 호환성 ── 공식 규칙으로 기계적으로 판정한다

.NET에는 Microsoft가 .NET 라이브러리 자체의 개발에서 사용하는 “호환성을 위한 변경 규칙”이 공개되어 있으며, 변경이 허용(✔️)・금지(❌)・판단 필요(❓)로 분류되어 있습니다.2 자사 라이브러리의 판단 기준으로 그대로 채택할 수 있다고 명시되어 있으므로, 주요 항목을 발췌합니다.

public API에 대한 변경 판정 보충
메서드・타입・멤버의 추가 ✔️ 원칙적으로 안전 다만 기존 오버로드 해결을 바꾸는 추가는 주의. 공개 struct에 인스턴스 필드 추가는 예외로, 크기・레이아웃이 바뀌어 상호운용이나 unsafe 사용자를 깨뜨림
public 타입・멤버의 삭제・이름 변경 ❌ 파괴적 MissingMethodException 등으로 실행 시점에 깨짐
시그니처 변경(인수의 추가・삭제・순서・타입, 반환값 타입) ❌ 파괴적 바이너리・소스 양쪽을 깨뜨림
매개변수 이름 변경 ❌ 파괴적 C#의 이름 있는 인수와 VB의 지연 바인딩을 깨뜨림. 놓치기 쉬움
멤버에 virtual 추가 ❌ 파괴적 “추가니까 안전”으로 보이는 대표적인 함정. 호출 IL(call/callvirt)의 불일치가 일어날 수 있음
virtual 삭제, 가상 멤버의 abstract화 ❌ 파괴적 파생 클래스의 오버라이드가 깨짐
non-sealed 공개 타입에 추상 멤버 추가 ❌ 파괴적 기존 파생 클래스가 구현을 가지고 있지 않음
타입의 sealed화 ❌ 파괴적 기존 파생 클래스가 컴파일 불가능해짐
인터페이스에 멤버 추가 ❓ 판단 필요 기본 구현(DIM)을 붙이면 기존 구현 클래스를 깨지 않고 넘어갈 수 있지만 조건이 많음(아래 참조)
상수・열거값의 값 변경, 열거형 멤버의 이름 변경・삭제 ❌ 파괴적 값은 컴파일 시점에 호출 측에 내장됨
더 파생된 예외를 던지도록 변경 ✔️ 허용 기존 catch가 계속 동작하기 때문
기존 코드 경로에서 새로운 종류의 예외를 던짐 ❌ 파괴적 새 매개변수 값에서만 던지는 것은 가능

이 표에서 유일하게 ❓(판단 필요)인 것이 “인터페이스에 멤버 추가”이며, 실무에서도 가장 망설이는 항목입니다. 기본 구현(DIM: Default Interface Members, 기본 인터페이스 멤버)을 붙이면 기존 구현 클래스에 구현 누락을 일으키지 않고 멤버를 늘릴 수 있지만, 공식 규칙이 제시하는 조건은 다음과 같습니다.2

  • 이용 측의 최소 요건이 .NET Core 3.0 / C# 8.0으로 올라갑니다. DIM은 이 버전에서 도입되었기 때문에, 기본 구현을 추가한 시점에서 그보다 낮은 런타임을 사용하는 이용자는 뒤처지게 됩니다. .NET Framework는 대상 외이므로, .NET Framework 상의 클라이언트가 하나라도 남아 있는 라이브러리에서는 이 완화책을 쓸 수 없습니다.
  • DIM에 대응하지 않는 언어가 있습니다. .NET은 여러 언어에서 사용되기 때문에, C# 이외에서 구현된 인터페이스에서는 기본 구현을 믿을 수 없습니다.
  • 어느 기본 구현을 호출할지를 런타임이 정할 수 없는 상황이 있습니다. 여러 인터페이스가 얽힌 구성에서는 기본 구현의 해결이 모호해질 수 있습니다.
  • C# 13 이후, ref struct가 구현하는 인터페이스에 기본 인스턴스 멤버를 추가하는 것은 소스 파괴적 변경입니다. ref struct는 박싱도 인터페이스 타입으로의 변환도 할 수 없으므로 기본 구현으로 폴백할 수 없고, 인스턴스 멤버는 반드시 명시적으로 구현해야 합니다.

한편, 정적이고 non-abstract・non-virtual한 멤버의 추가는 허용됩니다.2 “멤버를 추가하고 싶지만 이용자에게 .NET Framework가 남아 있다”는 경우에는 인터페이스를 건드리지 않고, 4장의 COM과 같은 발상으로 새로운 인터페이스를 추가하거나, 확장 메서드로 대체할 수 없는지를 먼저 검토하는 것이 안전합니다.

COM의 “인터페이스 불변”만큼 단순하지는 않지만, 사상은 같습니다. 공개 API는 계약이며, 계약에 대한 추가는 허용되지만 기존 계약의 변경은 허용되지 않습니다. 그리고 가상 메서드나 매개변수 이름처럼 “언뜻 안전해 보이는 변경”이 파괴적 쪽으로 분류되어 있다는 점이야말로, 감각이 아니라 표로 판단해야 하는 이유입니다.

5.1 강력한 이름과 세 가지 버전 번호

.NET 어셈블리에는 여러 버전 번호가 있으며 역할이 다릅니다.12

  • AssemblyVersion: 런타임이 어셈블리의 식별・로드에 사용하는 유일한 버전입니다. 강력한 이름이 붙은 어셈블리에서는 .NET Framework의 CLR이 엄격한 일치를 요구하기 때문에, 올릴 때마다 호출 측의 바인딩 리다이렉트가 필요해집니다(.NET/.NET Core는 상위 버전을 자동으로 받아들입니다). 공식 가이드는 리다이렉트를 줄이기 위해 메이저 버전만 AssemblyVersion에 반영할 것을 제안하고 있습니다.
  • FileVersion(AssemblyFileVersion): 탐색기의 속성에서 보일 뿐 런타임의 동작에는 영향을 주지 않습니다. CI의 빌드 번호를 기입하는 곳으로 권장됩니다.
  • InformationalVersion: 사람을 위한 자유 문자열입니다. semver 형식의 패키지 버전이나 소스의 커밋 해시를 기록합니다.

즉 실무에서는 “호환성 선언은 패키지/제품 버전(semver)으로 하고, AssemblyVersion은 메이저만, FileVersion으로 빌드를 추적한다”는 3계층 구성이 다루기 쉬운 형태입니다.

6. 버전 번호를 붙이는 법 ── semver는 “정의”가 있어야 비로소 기능한다

시맨틱 버저닝(semver)의 요점은 세 줄로 쓸 수 있습니다. 호환성이 없는 변경을 했다면 MAJOR, 하위 호환이 있는 기능 추가는 MINOR, 하위 호환이 있는 버그 수정은 PATCH를 올립니다.5

간과되기 쉬운 것은 semver 사양의 첫 번째 요구 사항이 “semver를 사용하는 소프트웨어는 공개 API를 선언해야 한다“는 점입니다.5 무엇이 공개 API인지를 선언하지 않으면 “호환성이 없는 변경”의 판정 기준이 존재하지 않아, 메이저를 올려야 할지 여부가 담당자의 기분으로 정해집니다. semver가 기능하지 않는 현장은 대부분 번호를 붙이는 방식이 아니라 이 선언을 생략하고 있습니다.

사내 배포 DLL에서의 현실적인 운용은 다음과 같은 형태가 됩니다.

  1. 공개 API의 범위를 선언한다 ── 네이티브 DLL이라면 익스포트 함수와 공개 헤더, COM이라면 IDL/타입 라이브러리, .NET이라면 public 타입・멤버. “이 외에는 내부 구현으로 예고 없이 바뀐다”고 명기합니다.
  2. 파괴적 변경의 정의를 채택한다 ── 이 글의 3장・5장 판단표, 그리고 .NET의 변경 규칙2을 “자사의 정의”로서 리포지토리에 둡니다.
  3. 판정을 자동화한다 ── .NET이라면 Package Validation / ApiCompat 도구로 이전 버전과의 바이너리 호환을 기계적으로 확인할 수 있습니다.13 리뷰에서의 “아마 괜찮을 것”을 배제할 수 있습니다.
  4. 릴리스 노트에 호환성 항목을 둔다 ── “재빌드 불필요/재빌드 권장/파괴적 변경 있음”의 3값을 매번 명기합니다. 서두의 “바꿔치기만 하면 되나요?”에 대한 답을, 물어보기 전에 문서로 내놓는 구조입니다.

7. 부득이하게 호환성을 깨야 할 때의 절차

판단표에서 “파괴적”으로 판정된 변경이 도저히 필요해졌을 때는 바꿔치기가 아니라 병행 제공으로 진행합니다.

  1. 신구를 병행 제공한다 ── COM이라면 IFoo2를 추가하고 IFoo를 남긴다(4장). 네이티브 DLL이라면 새 함수(FooEx)의 추가나, 별명의 새 DLL을 병존시킨다. .NET이라면 메이저 버전을 올린 새 패키지로 내놓고, 기존 메이저는 버그 수정만 계속한다.
  2. 비권장 기간을 둔다 ── .NET이라면 [Obsolete] 속성으로 컴파일 시점에 경고를 낼 수 있습니다. 네이티브/COM에서는 헤더의 주석과 릴리스 노트로 선언하고, 폐지 예정일을 명기합니다. “언젠가 없앤다”가 아니라 날짜를 정하는 것이 핵심입니다.
  3. 호출 측을 전수조사한다 ── 사내 소스 검색, 설치 프로그램의 배포 기록, COM이라면 레지스트리의 참조 상황으로부터 “누가 아직 예전 API를 호출하고 있는가”를 목록화합니다. 여기서 재빌드할 수 없는 바이너리(퇴사자의 도구, 타사 제작 앱)가 발견되면, 그만큼 예전 API의 수명을 연장하거나 래퍼로 다리를 놓습니다.
  4. 예전 API를 삭제한다 ── 전수조사로 호출 측이 0임을 확인한 후 삭제하고, 메이저 버전을 올립니다.

이 절차는 비용이 듭니다. 그렇기 때문에 역설적으로, 최초 공개 시점에 판단표를 의식해서 API를 작게 설계하는 것(공개하지 않으면 호환성의 의무는 생기지 않는다)이 가장 큰 호환성 대책이 됩니다.

8. 정리

  • 호환성은 바이너리・소스・동작의 3계층으로 생각합니다. 재빌드가 불필요해도 동작 호환은 깨질 수 있습니다.3
  • 네이티브 DLL은 “추가는 안전, 기존 익스포트・시그니처・구조체 레이아웃의 변경은 파괴적”입니다. 구조체에는 cbSize를 두어 확장 여지를 만듭니다.8
  • COM 인터페이스는 공개하면 불변입니다. 변경은 새 IID의 새 인터페이스(IFoo2)로 추가하고, QueryInterface로 판별시킵니다.149 사전 바인딩을 하는 VB6/VBA 클라이언트가 있는 경우는 특히 엄격하게 지킵니다.
  • .NET은 공식 호환성 변경 규칙으로 기계적으로 판정할 수 있습니다. 가상화・매개변수 이름 변경・sealed화 등 “언뜻 안전해 보이는 변경”이 파괴적으로 분류되어 있다는 점에 주의합니다.2
  • AssemblyVersion은 메이저만・FileVersion으로 빌드 추적・semver로 호환성 선언이라는 3계층 구성이 현실적입니다.12
  • semver는 공개 API와 파괴적 변경의 정의를 선언해야 비로소 기능합니다.5 판단표를 정의로 채택하고, Package Validation 등으로 자동 확인합니다.13
  • 깨야 할 때는 병행 제공→비권장 기간→전수조사→삭제. 갑자기 바꿔치기하지 않는 것이 고객사의 오래된 EXE를 지킵니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 다른 시스템에서 참조하는 DLL・COM 컴포넌트・.NET 라이브러리의 호환성 설계, 공개 API의 전수조사와 버저닝 방침 정비, 기존 클라이언트를 깨뜨리지 않는 확장(IFoo2 방식・병행 제공)의 설계・구현을 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Interface Design Rules. COM 객체가 구현하는 인터페이스는 고유한 IID를 가져야 한다는 점, 작성・공개 후에는 정의의 어떤 부분도 변경해서는 안 된다(불변이다)는 점에 대해.  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET). .NET의 API 변경이 허용・금지・판단 필요로 분류된다는 점, public 타입・멤버의 삭제나 이름 변경, 시그니처 변경, 매개변수 이름 변경, virtual의 추가・삭제, sealed화, 상수・열거값의 값 변경 등이 금지(파괴적)라는 점, 인터페이스에 멤버 추가가 판단 필요라는 점, 라이브러리 개발자가 자신의 라이브러리에 대한 평가 기준으로 사용할 수 있다는 점에 대해.  2 3 4 5 6 7

  3. Microsoft Learn, Breaking changes (.NET library guidance). 파괴적 변경이 소스 파괴・동작 파괴・바이너리 파괴로 분류된다는 점, 바이너리 파괴에서는 이전 버전에 대해 컴파일된 어셈블리가 MissingMethodException 등으로 실행 시점에 실패한다는 점에 대해.  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces. COM 인터페이스는 불변이며, 메서드의 추가・삭제나 의미 변경은 기존 인터페이스의 새 버전이 아니라 새로운 인터페이스를 만드는 것을 의미한다는 점, IID가 계약을 고유하게 정의한다는 점에 대해.  2 3

  5. semver.org, Semantic Versioning 2.0.0. 호환성이 없는 API 변경에서 MAJOR, 하위 호환이 있는 기능 추가에서 MINOR, 하위 호환이 있는 버그 수정에서 PATCH를 올린다는 점, semver를 사용하는 소프트웨어는 공개 API를 선언해야 한다는 점, 공개 API에 대한 하위 호환성이 없는 변경에서는 MAJOR 버전을 반드시 올려야 한다는 점에 대해.  2 3 4

  6. Microsoft Learn, MS-ERREF 2.3.1 NTSTATUS Values. STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139)가 “The procedure entry point %hs could not be located in the dynamic link library %hs.”이고, STATUS_DLL_NOT_FOUND(0xC0000135)가 “This application has failed to start because %hs was not found.”라는 점에 대해.  2

  7. Microsoft Learn, /RTC (Run-time error checks). /RTCs(및 /RTC1)가 스택 포인터 검증을 수행해 스택 포인터의 파괴를 검출한다는 점, 그 파괴가 호출 규약의 불일치(DLL이 __stdcall로 익스포트한 함수를 __cdecl의 함수 포인터로 호출하는 등)로 일어날 수 있다는 점, /RTC는 릴리스(최적화) 빌드에서는 사용할 수 없다는 점에 대해. 

  8. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). cbSize 멤버에 구조체 크기를 설정한다는 점, 구조체가 세대를 거치며 확장되어 왔다는 점, cbSize에 적절한 값을 설정함으로써 예전 버전의 Shell32.dll과 호환성을 유지한 채 이용할 수 있다는 점에 대해.  2

  9. Microsoft Learn, The Versioning Theory for RPC and COM. COM에서는 기능 확장을 위해 새로운 인터페이스를 만드는 것이 최선이라는 점, 기존 인터페이스를 상속한 새 인터페이스가 마이너 버전에 해당하고 기존 메서드나 타입의 변경에는 상속하지 않는 완전히 새로운 인터페이스(메이저 버전에 해당)가 필요하다는 점, QueryInterface로 대응 상황을 확인할 수 있다는 점에 대해.  2

  10. Microsoft Learn, COM Registry Keys. CLSID가 COM 클래스를 식별하는 GUID라는 점, ProgID가 사람이 읽을 수 있는 문자열을 CLSID에 대응시키지만 고유성은 보장되지 않는다는 점, 버전 비의존 ProgID가 CurVer로 최신 버전의 클래스에 대응된다는 점, Interface 키가 IID를 등록한다는 점에 대해.  2

  11. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). VBA에서는 참조 설정에 의한 사전 바인딩이 권장되며, 지연 바인딩(CreateObject/ProgID)은 코드 작성 시점에 멤버가 보이지 않고 실행 성능도 떨어진다는 점, 사전 바인딩에는 대상 개체 라이브러리에 대한 참조 설정이 필요하다는 점에 대해. 

  12. Microsoft Learn, Versioning (.NET library guidance). AssemblyVersion이 런타임의 로드에 사용되며 .NET Framework에서는 강력한 이름으로 엄격한 일치가 요구된다는 점, AssemblyVersion에는 메이저 버전만 포함할 것이 제안된다는 점, FileVersion은 Windows에서의 표시용으로 실행 시점 동작에 영향을 주지 않는다는 점, InformationalVersion이 추가 버전 정보 기록용이라는 점, NuGet 패키지 버전에 semver 2.0.0 사용이 권장된다는 점에 대해.  2

  13. Microsoft Learn, NuGet package compatibility rules. 바이너리 파괴적 변경을 피해야 한다는 점, Package Validation이나 ApiCompat 도구로 기준 버전과의 호환성을 자동으로 검출할 수 있다는 점, AssemblyVersion을 릴리스 간에 내려서는 안 된다는 점에 대해.  2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

ActiveX 이관

COM / ActiveX / OCX 자산을 유지할지, 감쌀지, 교체할지의 단계적 판단을 정리한 토픽 페이지입니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

DLL에 함수를 추가하는 것만으로는 호출 측을 재빌드할 필요가 없나요?
익스포트 함수를 추가하는 것뿐이라면, 기존 호출 측은 그대로 동작하는 것이 원칙입니다. 기존 함수의 이름・시그니처・호출 규약・익스포트 서수를 바꾸지 않는 한, 임포트 해결은 지금까지와 다름없이 성립하기 때문입니다. 다만 호출 측이 확보해서 넘기는 구조체에 멤버를 추가한 경우나, 기존 함수의 반환값・오류 코드의 의미를 바꾼 경우에는 재빌드 없이도 동작하지만 동작 호환이 깨질 수 있습니다. '함수 추가는 안전, 기존 시그니처의 변경은 파괴적'이 기본선입니다.
COM 인터페이스에 나중에 메서드를 추가하면 안 되는 이유는 무엇인가요?
COM 인터페이스는 공개하면 불변(immutable)이라는 것이 COM 사양상의 규칙이기 때문입니다. 인터페이스는 vtable(함수 포인터의 나열)이라는 바이너리 레이아웃의 계약이며, 메서드를 삽입・삭제・재배열하면 오래된 바이너리는 컴파일 시점에 각인된 위치에서 다른 메서드를 호출하게 됩니다. 끝에 추가하는 경우라면 기존 슬롯의 위치는 바뀌지 않지만, 이번에는 새 클라이언트가 '추가되어 있을 것'이라는 전제로 오래된 컴포넌트를 붙잡아 존재하지 않는 슬롯을 호출하는 사고가 일어나므로, 동일한 IID인 채로의 추가는 역시 허용되지 않습니다. 기능을 늘리고 싶다면 새로운 IID를 가진 새 인터페이스(IFoo2)를 추가하고, 기존 IFoo는 그대로 남깁니다. 호출 측은 QueryInterface로 신구 어느 쪽에 대응하는지를 안전하게 판별할 수 있습니다.
.NET의 AssemblyVersion・FileVersion・InformationalVersion은 어떻게 구분해서 사용하나요?
AssemblyVersion은 런타임이 어셈블리를 식별・로드하기 위해 사용하는 유일한 버전으로, 강력한 이름(strong name)을 사용하면 .NET Framework가 엄격한 일치를 요구하기 때문에 올릴 때마다 바인딩 리다이렉트가 필요해집니다. 이 때문에 메이저 버전만 반영하는 운용이 공식 가이드에서 제안되고 있습니다. FileVersion은 탐색기의 속성에 표시될 뿐 런타임 동작에는 영향을 주지 않으며, CI의 빌드 번호 등을 기입하기에 적합합니다. InformationalVersion은 사람을 위한 자유 문자열로, semver 형식의 버전이나 커밋 해시를 기록합니다.
시맨틱 버저닝(semver)을 도입하면 호환성 문제는 해결되나요?
semver만으로는 해결되지 않습니다. semver는 '하위 호환성이 없는 변경을 했다면 메이저를 올린다'는 규약이지만, 그 전제로 '무엇이 공개 API이고 무엇이 파괴적 변경인지'를 선언할 것을 요구합니다. 이 정의가 없는 채로 버전 번호만 붙여서는 판단이 사람마다 흔들려 기능하지 않습니다. 네이티브 DLL이라면 이 글의 판단표 같은 기준을, .NET이라면 Microsoft의 호환성 변경 규칙을 '자사의 파괴적 변경 정의'로 채택하고 릴리스 절차에 편입해야 비로소 semver가 의미를 가집니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기