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

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

수정 이력(5건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대한 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
판단표를 읽는 데 필요한 용어(ABI, 호출 규약, vtable, 익스포트 서수 등)를 글 앞의 표에 정의했습니다. 아울러 증상에서 의심할 행을 찾을 수 있는 역조회 표, 기본 구현이 있는 인터페이스 멤버를 추가할 수 있는 조건, 3계층의 관계도를 추가했습니다.
본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174360)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 깨뜨리는지의 판단표」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/dll-com-interface-backward-compatibility/

DOI(등록된 아카이브)
10.5281/zenodo.22174360
DOI(마지막 등록 버전)
10.5281/zenodo.22174361

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

까다로운 점은 이 판단이 “왠지 위험할 것 같다”는 감각으로 이루어지기 쉽다는 것입니다. 실제로는 어떤 변경이 호환성을 깨는지는 거의 기계적으로 판정할 수 있습니다. 네이티브 DLL에는 export와 calling convention의 규칙이 있고, 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 인터페이스의 실체이며, 호출 측은 “몇 번째 슬롯인가”라는 위치로 목적 메서드를 호출함
export ordinal(서수) DLL의 export 테이블에서 각 함수에 붙는 번호. 호출 측은 함수 이름 대신 이 번호로 import할 수도 있음
IID / CLSID / ProgID 차례로 COM 인터페이스(계약)의 식별자, 구현 클래스의 식별자, CLSID에 대응된 사람이 읽을 수 있는 별칭(4.1절)
강력한 이름(strong name) .NET 어셈블리를 “이름+버전+컬처+공개 키 토큰”과 서명으로 고유하게 식별하는 메커니즘
binding redirect .NET Framework에서, 호출 측이 요구하는 어셈블리 버전을 실제로 로드할 다른 버전으로 바꿔 읽게 하는 구성 파일 설정

1. 먼저 결론

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

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 27건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

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

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

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

이 3계층은 중첩으로 보면 관계가 잡히기 쉽습니다.

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

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

반대로 호출 측이 모두 소스를 가지고 동시에 재빌드할 수 있는 환경(단일 리포지토리의 사내 시스템 등)이라면, 지켜야 할 것은 소스 호환과 동작 호환뿐이고 바이너리 호환은 요건에서 빼도 됩니다. ** “내 DLL의 호출 측에 재빌드할 수 없는 바이너리가 있는가”**가 판단표를 읽는 첫 분기입니다.

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

네이티브 DLL의 호환성은 export 테이블과 calling convention, 그리고 메모리 레이아웃으로 정해집니다. DLL이 어떻게 검색되고 로드되는지는 “Windows DLL 이름 확인의 구조“에서 설명한 대로이지만, 로드에 성공한 뒤의 호환성은 다음 표로 판단할 수 있습니다.

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

이 표는 “이제부터 무엇을 바꿀지”를 정하기 위한 것이지만, 현장에서는 반대로, 즉 “고객사에서 나온 이 증상은 어느 행의 변경이 원인인가”를 찾는 일이 더 많아집니다. 위 각 행이 실제로 어떻게 겉으로 드러나는지를 대응시켜 둡니다.

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

이 표에서 이끌어 내는 설계 지침은 예전부터 바뀌지 않습니다. 경계는 C ABI(extern “C” 함수와 단순한 구조체)로 한정하고, 확장은 함수 추가로 한다는 것입니다. C#에서 네이티브 DLL을 만드는 경우에도 같고, “C# Native AOT DLL을 C/C++에서 호출하는 방법“에서 다룬 export 면도 이 표대로 관리합니다.

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
  • 메서드 추가・삭제나 의미(semantics) 변경은 “옛 인터페이스의 새 버전”이 아니라, 다른 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화 ❌ 파괴적 파생 클래스의 오버라이드가 깨짐
sealed가 아닌 공개 형에 추상 멤버 추가 ❌ 파괴적 기존 파생 클래스가 구현을 갖지 않음
형의 sealed화 ❌ 파괴적 기존 파생 클래스가 컴파일 불가가 됨
인터페이스에 멤버 추가 ❓ 판단 필요 기본 구현(DIM: Default Interface Members, 기본 인터페이스 멤버)을 붙이면 기존 구현 클래스를 깨지 않고 멤버를 늘릴 수 있지만, 조건이 많음(아래)
상수・열거값의 값 변경, 열거 멤버의 이름 변경・삭제 ❌ 파괴적 값은 컴파일 시점에 호출 측에 박힘
더 파생된 예외를 던지도록 바꿈 ✔️ 허용 기존 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이 엄격한 일치를 요구하므로, 올릴 때마다 호출 측 binding redirect가 필요해집니다(.NET/.NET Core는 상위 버전을 자동으로 받아들입니다). 공식 가이드는 redirect를 줄이기 위해 메이저 버전만 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이면 export 함수와 공개 헤더, COM이면 IDL/타입 라이브러리, .NET이면 public 형・멤버. “그 밖의 것은 내부 구현이며 예고 없이 바뀐다”고 명시합니다.
  2. 파괴적 변경의 정의를 채택한다 ── 이 글의 3장・5장 판단표, 그리고 .NET의 변경 규칙2을 “자사의 정의”로 리포지토리에 둡니다.
  3. 판정을 자동화한다 ── .NET이면 Package Validation / ApiCompat 도구로 이전 버전과의 바이너리 호환을 기계적으로 검사할 수 있습니다.13 리뷰에서의 “아마 괜찮겠지”를 배제할 수 있습니다.
  4. 릴리스 노트에 호환성 란을 둔다 ── “재빌드 불필요/재빌드 권장/파괴적 변경 있음”의 세 값을 매번 명시합니다. 서두의 “교체만 하면 될까요?”에 대한 답을, 묻기 전에 문서로 내놓는 구조입니다.

7. 호환성을 깨뜨릴 수밖에 없을 때의 절차

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

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

이 절차는 비용이 듭니다. 그래서 역설적으로, 처음 공개할 때 판단표를 의식해 API를 작게 설계하는 것(공개하지 않으면 호환성 의무는 생기지 않는다)이 최대의 호환성 대책이 됩니다.

8. 정리

  • 호환성은 바이너리・소스・동작의 3계층으로 생각합니다. 재빌드가 불필요해도 동작 호환은 깨질 수 있습니다.3
  • 네이티브 DLL은 ** “추가는 안전, 기존 export・시그니처・구조체 레이아웃 변경은 파괴적”**. 구조체에는 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)가 스택 포인터 검증을 수행해 스택 포인터 파괴를 검출한다는 점, 그 파괴가 calling convention 불일치(DLL이 __stdcall로 export한 함수를 __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 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기