엑셀 VBA ‘Bad DLL calling convention’ 오류 완벽 해결 가이드(64비트·stdcall·PtrSafe 정리)

이 글의 목적은 엑셀 VBA에서 외부 DLL 호출 시 발생하는 “Bad DLL calling convention” 오류의 근본 원인과 해결 절차를 체계적으로 정리하여, 32비트·64비트 환경 모두에서 재현 없이 안정적으로 함수를 호출하도록 돕는 것이다.

1. 오류의 정체와 발생 메커니즘

“Bad DLL calling convention”은 VBA가 외부 DLL 함수 호출 후 스택 정리나 레지스터 복귀 상태가 기대와 다를 때 발생하는 런타임 오류이다. 호출 규약(call­ing convention), 인자 수·형식, 포인터 크기, 문자열 인코딩, 구조체 정렬이 선언과 실제 구현 간에 어긋나면 스택 언밸런스가 생겨 즉시 또는 지연된 시점에 오류가 나타난다. 가장 흔한 원인은 다음과 같다.

  • VBA Declare에서 StdCall을 전제했으나 실제 DLL 함수가 cdecl인 경우이다.
  • 64비트 Office에서 PtrSafe, LongPtr 누락으로 포인터 크기가 잘못 전달된 경우이다.
  • ByValByRef를 반대로 선언하여 값/주소가 바뀐 경우이다.
  • char*·wchar_t*·LPSTR·LPWSTR 등 문자열 포인터 처리 오류이다.
  • 구조체(UDT) 정렬 및 패킹 규칙 미스매치이다.
  • 콜백(AddressOf) 함수가 stdcall 규약을 따르지 않거나 시그니처가 다른 경우이다.
주의 : 동일한 코드가 어떤 PC에서는 동작하고 다른 PC에서는 실패하는 경우가 많다. 이유는 Office 비트수, DLL 빌드 옵션, Windows 지역/코드페이지, VC++ 런타임 버전 등 환경 요인이 다르기 때문이다.

2. 호출 규약 이해: stdcall vs cdecl

호출 규약은 “누가 스택을 정리하는가, 인자를 어떤 순서로 전달하는가, 어떤 레지스터를 보존하는가”를 정의한다. VBA는 기본적으로 Windows API 스타일의 StdCall과의 호환을 가정한다.

규약스택 정리인자 전달VBA 호환성비고
stdcall피호출자(callee)우→좌매우 좋다WinAPI 기본 규약이다.
cdecl호출자(caller)우→좌주의가변 인자 함수에 흔하다.
thiscall/fastcall가변레지스터/스택 혼합권장 안 함C++ 멤버 함수 등이다.

VBA Declare는 규약을 직접 표기하지 않으며 기본 가정은 stdcall이다. 따라서 DLL이 cdecl로 빌드된 함수라면 Alias_FunctionName@N처럼 decorated name을 억지로 맞추더라도 스택 정리는 여전히 어긋날 수 있다. 가장 안전한 해법은 DLL 함수를 stdcall로 재노출하거나, COM 래퍼 또는 중간 C DLL을 만들어 인터페이스를 맞추는 것이다.

3. 32비트 vs 64비트 Office와 PtrSafe·LongPtr

64비트 Office(VBA7)는 포인터 크기가 8바이트이다. 32비트(VBA6)는 4바이트이다. 포인터를 Long으로 받던 관습을 64비트에서는 LongPtr로 바꿔야 한다. 또한 Declare 문에 PtrSafe 한정자를 붙여야 한다.

' 32비트/64비트 공용 선언 패턴 #If VBA7 Then Private Declare PtrSafe Function MyFunc Lib "mylib.dll" (ByVal p As LongPtr) As Long #Else Private Declare Function MyFunc Lib "mylib.dll" (ByVal p As Long) As Long #End If 
주의 : 포인터, 핸들(HANDLE), 포인터 크기와 동일한 정수는 반드시 LongPtr로 선언해야 한다. 64비트에서 Long은 4바이트로 고정이며 포인터를 담기에 부족하다.

4. 인자 전달: ByVal vs ByRef, 값형·포인터형 구분

ByVal은 값을 복사해 전달하고 ByRef는 변수의 주소를 전달한다. C 시그니처와 1:1 매핑을 의식해야 한다.

C/C++ 선언의미VBA 선언 예시설명
int f(int x)ByVal x As Long32비트 정수 값 전달이다.
int f(int* p)포인터ByVal p As LongPtr포인터 값 자체를 전달한다.
int f(double* p)배열/포인터ByVal p As LongPtrp(0) 주소를 넘긴다.
int f(SOME_STRUCT* s)구조체 포인터ByVal s As LongPtrUDT 주소를 넘긴다.
int f(SOME_STRUCT s)구조체 값ByRef s As SOME_STRUCT값 복사보다 ByRef를 권장한다.
주의 : 문자열은 특별 취급이 필요하다. VBA의 String은 기본 BSTR(길이 앞붙은 유니코드)이다. C의 char*와 동일하지 않다.

5. 문자열 매핑: LPSTR/LPWSTR/BSTR

DLL 함수가 ANSI(LPSTR)를 받는지, 유니코드(LPWSTR)를 받는지 확인한다. WinAPI 스타일은 대부분 …A(ANSI)와 …W(Wide) 두 엔트리를 가진다. VBA String을 그대로 넘기면 BSTR 포인터가 넘어가므로 C 쪽이 LPWSTR(UTF-16) 또는 BSTR을 기대해야 안전하다.

DLL 기대VBA 권장 선언보조 설명
LPWSTR (UTF-16)ByVal s As LongPtr 또는 적합한 Declare로 직접 전달대개 StrPtr(s) 주소를 넘긴다.
LPSTR (ANSI)VBA String → 바이트 배열로 변환 후 주소 전달코드페이지 주의, NUL 종료 보장 필요이다.
BSTR그냥 ByVal s As String으로 전달상대가 COM 호환일 때만 권장된다.
' LPWSTR을 기대하는 DLL 예시 #If VBA7 Then Private Declare PtrSafe Function UseWide Lib "mylib.dll" _ Alias "UseWide" (ByVal pwsz As LongPtr) As Long #Else Private Declare Function UseWide Lib "mylib.dll" _ (ByVal pwsz As Long) As Long #End If
Sub CallUseWide()
Dim s As String
s = "테스트"
' 유니코드 널 종료 보장
s = s & vbNullChar
Dim ret As Long
ret = UseWide(StrPtr(s))
End Sub
주의 : StrPtr, VarPtr는 문서화되지 않은 함수로 간편하지만, 잘못된 캐스팅이나 수명 관리가 얽히면 크래시로 직결된다. 철저히 테스트해야 한다.

6. 구조체(UDT)와 패킹, 정렬

C의 #pragma pack 또는 기본 ABI 정렬과 VBA UDT의 메모리 레이아웃이 다르면 필드 오프셋이 어긋난다. 다음과 같이 바이트 채움(Byte 패딩)으로 강제 정렬을 맞출 수 있다.

' C 쪽: #pragma pack(push, 1) ' struct REC { short a; int b; }; ' 총 6바이트
Type REC
a As Integer ' 2바이트
b As Long ' 4바이트
End Type

' 만약 C가 pack(8)이라면 필드 사이에 패딩이 들어가 8바이트가 될 수 있다.
' 이 경우 Byte 패딩 필드를 명시적으로 추가해 오프셋을 일치시킨다.

구조체 포인터를 요구하는 함수에는 UDT 변수의 주소를 넘긴다.

#If VBA7 Then Private Declare PtrSafe Function UseRec Lib "mylib.dll" (ByVal pRec As LongPtr) As Long #Else Private Declare Function UseRec Lib "mylib.dll" (ByVal pRec As Long) As Long #End If
Sub CallUseRec()
Dim r As REC
r.a = 1: r.b = 2
Dim ret As Long
ret = UseRec(VarPtr(r))
End Sub
주의 : UDT 내부에 String 같은 가변 길이 COM 타입을 넣고 원시 DLL에 주소를 넘기는 것은 위험하다. 가능하면 모든 필드를 고정 길이 원시 타입으로 정의한다.

7. 배열 전달: SAFEARRAY vs 원시 포인터

VBA의 동적 배열은 내부적으로 SAFEARRAY이다. COM을 이해한 DLL이 SAFEARRAY*를 받는다면 ByRef arr() As T 선언으로 호환 호출이 가능하다. 반면 C 함수가 double*처럼 원시 포인터를 요구하면 배열의 첫 원소 주소를 넘겨야 한다.

' SAFEARRAY* 요구 ' C: HRESULT Sum([in] SAFEARRAY(double)* psa, [out, retval] double* result);
' VBA
#If VBA7 Then
Private Declare PtrSafe Function Sum Lib "mylib.dll" (arr() As Double, result As Double) As Long
#Else
Private Declare Function Sum Lib "mylib.dll" (arr() As Double, result As Double) As Long
#End If

' 원시 포인터 요구
#If VBA7 Then
Private Declare PtrSafe Function SumRaw Lib "mylib.dll" (ByVal p As LongPtr, ByVal n As Long) As Double
#Else
Private Declare Function SumRaw Lib "mylib.dll" (ByVal p As Long, ByVal n As Long) As Double
#End If

Sub CallSumRaw()
Dim a(0 To 9) As Double
a(0) = 1: a(1) = 2
Dim s As Double
s = SumRaw(VarPtr(a(0)), 10)
End Sub

8. 콜백(AddressOf)과 규약 일치

DLL이 콜백을 요구할 때, 콜백 함수는 stdcall 규약을 전제로 설계되어야 한다. VBA에서는 규약을 명시할 수 없으므로, DLL 설계에서 콜백을 stdcall로 노출해야 한다. 시그니처가 한 파라미터라도 다르면 즉시 스택 불일치가 발생한다.

' C: typedef int (WINAPI *PFN)(int code, void* ctx); // WINAPI=stdcall ' int Register(PFN cb, void* ctx);
#If VBA7 Then
Private Declare PtrSafe Function Register Lib "mylib.dll" (ByVal pfn As LongPtr, ByVal ctx As LongPtr) As Long
#Else
Private Declare Function Register Lib "mylib.dll" (ByVal pfn As Long, ByVal ctx As Long) As Long
#End If

' VBA 콜백 대상
Public Function MyCb(ByVal code As Long, ByVal ctx As LongPtr) As Long
MyCb = code + 1
End Function

Sub UseCb()
Dim ret As Long
ret = Register(AddressOf MyCb, 0)
End Sub
주의 : 콜백이 클래스 멤버 메서드일 수 없다. 모듈 범위의 Public 표준 함수만 AddressOf로 전달 가능하다.

9. 환경 점검 체크리스트

  • Office 비트수 확인: 파일 > 계정 > Excel 정보에서 “64비트/32비트” 확인한다.
  • VBA 모듈 상단에 #If VBA7 Then 분기 도입 여부를 확인한다.
  • DLL 함수 규약·이름 장식 확인: dumpbin /exports 또는 의존성 뷰어로 확인한다.
  • 유니코드/ANSI 엔트리 구분: FunctionW vs FunctionA를 구분한다.
  • 정수 폭과 부호 일치: int(C) ↔ Long(VBA, 32비트), 64비트 포인터는 LongPtr이다.
  • 구조체 레이아웃 재검증: C 헤더와 필드 오프셋을 1:1로 맞춘다.
  • 가변 인자(printf류) 호출 금지: VBA에서 직접 호출하지 않는다.

10. 안정 선언 템플릿 모음

' 10-1. 핸들/포인터 인자 1개 #If VBA7 Then Private Declare PtrSafe Function Fn1 Lib "mylib.dll" (ByVal h As LongPtr) As Long #Else Private Declare Function Fn1 Lib "mylib.dll" (ByVal h As Long) As Long #End If
' 10-2. 문자열 입력 LPWSTR
#If VBA7 Then
Private Declare PtrSafe Function UseTextW Lib "mylib.dll" (ByVal pwsz As LongPtr) As Long
#Else
Private Declare Function UseTextW Lib "mylib.dll" (ByVal pwsz As Long) As Long
#End If

' 10-3. 바이트 배열(ANSI) 전달
#If VBA7 Then
Private Declare PtrSafe Function UseAnsi Lib "mylib.dll" (ByVal p As LongPtr, ByVal n As Long) As Long
#Else
Private Declare Function UseAnsi Lib "mylib.dll" (ByVal p As Long, ByVal n As Long) As Long
#End If

Sub CallUseAnsi()
Dim s As String: s = "ABC"
Dim b() As Byte
b = s & vbNullChar ' ANSI 변환 필요 시 StrConv 사용
Dim ret As Long
ret = UseAnsi(VarPtr(b(0)), UBound(b) - LBound(b) + 1)
End Sub

11. C 시그니처별 실제 매핑 예시

C 프로토타입요구 규약VBA Declare
__declspec(dllexport) int __stdcall Add(int a, int b);
stdcall
#If VBA7 Then Declare PtrSafe Function Add Lib "mylib.dll" (ByVal a As Long, ByVal b As Long) As Long #Else Declare Function Add Lib "mylib.dll" (ByVal a As Long, ByVal b As Long) As Long #End If
__declspec(dllexport) int sum_d(const double* p, int n);
기본 규약 확인 필요
#If VBA7 Then Declare PtrSafe Function sum_d Lib "mylib.dll" (ByVal p As LongPtr, ByVal n As Long) As Long #Else Declare Function sum_d Lib "mylib.dll" (ByVal p As Long, ByVal n As Long) As Long #End If
__declspec(dllexport) int __stdcall UseWide(const wchar_t* s);
stdcall
#If VBA7 Then Declare PtrSafe Function UseWide Lib "mylib.dll" (ByVal pwsz As LongPtr) As Long #Else Declare Function UseWide Lib "mylib.dll" (ByVal pwsz As Long) As Long #End If

12. 잘못된 선언으로 인한 전형적 증상

  • 즉시 “Bad DLL calling convention” 또는 “Access Violation”이 발생한다.
  • 반환값이 엉뚱하며 이후 다른 VBA 코드가 비정상 동작한다.
  • 한 번은 성공하고 두 번째 호출에서 실패한다(스택 누수의 전형적인 징후이다).
주의 : 스택이 어긋나면 실패 지점이 호출 위치가 아닌 전혀 다른 코드에서 나타날 수 있다. 따라서 각 인자·규약·정렬을 선언 단계에서 100% 일치시키는 것이 최우선이다.

13. 진단 절차: 원인 축소 방법

  1. DLL 내보내기 이름과 규약 확인: dumpbin /exports mylib.dll@N 패턴(stdcall) 여부를 본다.
  2. 최소 재현: 인자 하나짜리 함수부터 성공시키고 점진적으로 확장한다.
  3. 문자열 분리 검증: 동일 함수의 ANSI/유니코드 버전을 각각 테스트한다.
  4. UDT 검증: C쪽에서 sizeof()와 필드 오프셋을 출력하여 VBA와 비교한다.
  5. 64비트 전용 빌드 확인: LongPtr 누락 여부를 전수 점검한다.

14. ActiveX(참조 등록) vs 원시 DLL(Declare)

regsvr32로 등록하는 OCX/ActiveX DLL은 타입 라이브러리를 통해 자동 마샬링된다. VBA에서는 “도구 > 참조”로 참조 추가 후 일반 객체 메서드처럼 호출한다. 반면 원시(native) DLL은 Declare로 수동 선언해야 하며 모든 규약·레이아웃을 직접 맞춰야 한다. 두 방식을 혼용하지 말고 라이브러리 성격을 명확히 구분한다.

15. 예외 케이스와 우회 전략

  • 가변 인자(...) 함수: 직접 호출하지 않는다. C 래퍼에서 고정 인자 함수로 감싸고 그 래퍼를 VBA에서 호출한다.
  • 템플릿·멤버 함수(thiscall): 외부 extern "C" __stdcall 래퍼를 만든다.
  • 복잡한 구조체·문자열 혼재: COM 인터페이스 또는 C#·VB.NET으로 UnmanagedExports 스타일의 얇은 래퍼를 만든다.

16. 안전 점검 자동화 스니펫

' 실행 중 비트수/런타임 확인 Sub EnvInfo() #If Win64 Then Debug.Print "Process: 64-bit" #Else Debug.Print "Process: 32-bit" #End If #If VBA7 Then Debug.Print "VBA: 7+" #Else Debug.Print "VBA: 6" #End If End Sub 

17. 문제 해결 절차 요약

  1. Office 비트수와 VBA 버전 식별한다.
  2. DLL 함수의 규약과 문자 집합, 내보내기 이름을 확인한다.
  3. 포인터·핸들은 LongPtr, Declare에는 PtrSafe를 적용한다.
  4. 문자열은 기대 타입에 맞게 StrPtr 또는 바이트 배열로 전달한다.
  5. UDT/배열은 레이아웃과 길이 정보를 1:1로 일치시킨다.
  6. 콜백은 모듈 범위 함수로, 시그니처와 stdcall 일치를 보장한다.
  7. 가변 인자·C++ 특수 규약은 래퍼를 설계한다.

18. 자주 발생하는 선언 오류와 수정 예

' 잘못된 예: 64비트에서 포인터를 Long으로 받음 Declare PtrSafe Function ReadMem Lib "mylib.dll" (ByVal p As Long) As Long
' 수정
#If VBA7 Then
Declare PtrSafe Function ReadMem Lib "mylib.dll" (ByVal p As LongPtr) As Long
#Else
Declare Function ReadMem Lib "mylib.dll" (ByVal p As Long) As Long
#End If

' 잘못된 예: ByRef로 주소가 이중 참조됨
Declare PtrSafe Function SetVal Lib "mylib.dll" (ByRef v As Long) As Long

' C: int SetVal(int v); // 값 전달
' 수정
Declare PtrSafe Function SetVal Lib "mylib.dll" (ByVal v As Long) As Long

19. 디버깅 팁

  • 인자 수를 줄여 한 개씩 증가시켜 실패 지점을 찾는다.
  • 호출 직전 인자 값을 Debug.Print로 기록하여 범위를 검증한다.
  • 문자열은 NUL 종료를 보장하고 길이 초과를 방지한다.
  • UDT 크기를 LenB(udt)로 출력해 C의 sizeof와 비교한다.

20. 최종 체크리스트

항목점검 내용결과
규약DLL이 stdcall을 사용하거나 래퍼로 stdcall을 노출한다. 
비트수PtrSafe 사용, 포인터는 LongPtr로 선언한다. 
문자열LPWSTR/LPSTR/BSTR 차이를 반영한다. 
UDT패킹·정렬·크기 일치 확인한다. 
배열SAFEARRAY vs 원시 포인터 구분한다. 
콜백AddressOf 대상은 모듈 범위, 시그니처·규약 일치한다. 
가변 인자직접 호출 금지, C 래퍼로 고정 인자화한다. 

FAQ

“PtrSafe”를 붙였는데도 오류가 난다. 원인은 무엇인가?

PtrSafe는 64비트 호환 선언임을 표시하는 한정자일 뿐이다. 포인터 매개변수를 모두 LongPtr로 바꾸지 않았거나 규약·문자열·UDT가 어긋나면 여전히 실패한다.

WinAPI 선언을 복사해서 썼는데 실패한다. 왜 그런가?

출처의 선언이 32비트 전용이거나 ANSI 버전(...A)일 수 있다. 현재 환경의 비트수와 문자 집합에 맞게 수정해야 한다.

cdecl DLL을 VBA에서 쓸 수 있는가?

안정적으로는 권장하지 않는다. 중간 래퍼 DLL 또는 COM 컴포넌트를 만들어 stdcall 고정 인터페이스로 노출하는 것이 안전하다.

BSTR과 LPWSTR은 같은가?

둘 다 UTF-16을 쓰지만 메모리 레이아웃이 다르다. BSTR은 길이 프리픽스가 붙는다. DLL이 BSTR을 기대하는지 LPWSTR을 기대하는지 문서로 구분해야 한다.

UDT에 String 필드를 넣어도 되나?

원시 DLL에 주소를 직접 넘길 경우 권장하지 않는다. 고정 길이 버퍼나 바이트 배열로 대체하거나 COM 마샬링을 사용한다.