<Dynamic Linking Library>
'뇌를 자극하는 윈도우즈 시스템 프로그래밍'의 마지막 장입니다.
DLL은 아마 게임을 하면서도, 프로그래밍을 하면서도 종종 보셨을 놈입니다.
'~~.dll이 없습니다'라는 오류문구를 보면 짜증도 나고 그랬던 기억이 있으실겁니다.
오늘 이 DLL이라는 놈을 다뤄볼까 합니다.
[라이브러리와 printf]
DLL에 대한 내용을 다루기에 앞서서 질문을 하나 하고 가려고 합니다.
책에서도 똑같이 질문을 했었는데, 저도 못맞췄습니다.
크게 걱정하실건 없습니다.
이번 글에서 다 정리를 하고 넘어갈 부분입니다.
[질문]
우선 예제 코드를 보면서 이야기를 해보겠습니다.
// Hello.c
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello, World!\n");
return 0;
}
코드 설명이고 뭐고 할 필요도 없을만큼 이제는 국민 코드가 된 예제입니다.
굳이 C나 JAVA, Python 등을 가리지 않고 다 첫 걸음을 뗄 때 작성해보는 코드죠.
헛소리는 그만하고 본론으로 넘어가겠습니다.
위 코드에서는 printf 함수가 호출이 되고 있습니다.
그렇다면 실행 가능한 printf 함수의 바이너리는 어디에 있을까요?
위에 헤더로 선언되어 있는 stdio.h 안에 있을까요?
그렇다면 한 번 확인해봅시다.
_Check_return_opt_
_CRT_STDIO_INLINE int __CRTDECL printf(
_In_z_ _Printf_format_string_ char const* const _Format,
...)
보다시피 뭔가 복잡한 놈이 있는데 이게 stdio.h에서 우리가 찾던 printf입니다.
정의가 아닌 선언만 있습니다.
다시 말해서 stdio.h는 printf 함수의 선언만 들어가 있습니다.
함수의 정의는 없으므로 우리가 찾는 printf 함수의 바이너리는 여기에 없다는 말입니다.
어찌보면 당연한 일이긴 합니다.
일반적으로 헤더 파일에는 함수의 정의가 아닌 함수의 선언들을 모아놓는데 사용하니까요.
[해답은 라이브러리에]
답을 못맞추셨다고 아쉬워 할 필요는 없습니다.
제목에도 써져있듯이 답은 '라이브러리(Library)'에 있습니다.
항상 라이브러리라는 말을 입에 달고 살았지만 답이 여기서 나올 줄은 모르시는 분들도 있었을겁니다.
제가 그래서 그런거 아니에요
라이브러리라는 것을 간단하게 정의하면 다음과 같습니다.
"여러 프로그램에서 자주 사용하는 함수와 데이터들을 실행이 가능한 바이너리 형태로 묶어놓은 파일"
그래서 printf 함수의 선언은 stdio.h라는 헤더 파일에 있습니다.
실제 함수의 정의가 컴파일된 바이너리 코드는 라이브러리에 존재하는 것이고요.
printf 함수의 정의가 들어가있는 라이브러리들은 다음과 같습니다.

여기 있는 라이브러리들은 ANSI 표준 C함수들로 구성이 되어있습니다.
그래서 'C 런타임 라이브러리(C Run-Time Library)'라고 합니다.
여기에는 printf 뿐만이 아니라 scanf와 같은 다양한 ANSI 표준의 C함수의 바이너리 코드가 포함되어 있습니다.
사실 라이브러리는 위에 있는 4개가 전부가 아닙니다.
가장 대표적인 것만 나열을 한 것인데 여기서 일부는 정적(Static) 라이브러리입니다.
그리고 일부는 동적 연결(Dynamic Link) 라이브러리입니다.
또 라이브러리 파일 이름에 마지막으로 d로 끝나는 놈들이 보일겁니다.
얘네는 디버그 모드로 컴파일을 할 때 사용하는 라이브러리입니다.
그게 아닌 라이브러리는 Release(배포)용으로 사용한다고 알고 계시면 될 것 같습니다.
[라이브러리 작성에 대한 동기]
이제 라이브러리라는 놈의 정체도 알게되었습니다.
생각해보면 별 것도 아닌 놈인거 같은데 까짓거 우리도 직접 한 번 만들어봅시다.
일단 뭘 만들어볼까 싶은데, 거창할 것 까지는 없으니까 이걸 갖고 한 번 만들어보죠.
[LibSwap.cpp]
// LibSwap.cpp
#include <stdio.h>
#include <tchar.h>
void swap(int* v1, int* v2);
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d %d\n", a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d %d\n", a, b);
return 0;
}
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
"Hello, World!"를 출력하는 것만큼 포인터를 다루면 보게 되는 정말 기본적인 코드입니다.
우리의 목표는 swap이라는 함수를 라이브러리로 만들어보는 것입니다.
[라이브러리 작성]
사실 라이브러리 작성이라고 해서 되게 어려울거라는 생각이 들겁니다.
근데 Visual Studio를 사용하시면 라이브러리를 만드는 것은 되게 간단합니다.
'새 프로젝트 만들기'를 누르면 거기에 '정적 라이브러리' 라는 항목이 있습니다.
일단 그걸 눌러서 진행하시면 됩니다.
이제 printf 함수 때를 한 번 생각해봅시다.
printf 함수의 선언을 담고 있는 헤더 파일인 stdio.h가 있었고, 별도로 라이브러리가 있었던 것을 기억하실겁니다.
우리도 마찬가지로 swap 함수의 선언을 담을 swap.h와 함수의 정의를 swap.cpp에 넣도록 하겠습니다.
[Swap.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Swap.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
void swap(int* v1, int* v2);
[Swap.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Swap.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
라이브러리를 만들다보면 pch.h나 framework.h 같은 파일들이 같이 추가될겁니다.
여기는 건드리실 필요가 없고, 위의 예제 코드처럼 그냥 헤더파일을 포함해주시기만 하면 됩니다.
컴파일을 하면 정적 라이브러리가 잘 생성되어 있을겁니다.
[라이브러리의 활용]
라이브러리 생성까지 마쳤으면 다음 코드를 작성해서 우리가 만든 라이브러리가 잘 돌아가는지 확인해봅시다.
[SwapLibTest.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapLibTest.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "Swap.h"
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
return 0;
}
당연한 이야기지만, 헤더 파일은 이 소스코드 파일이 같이 있는 경로에 놔둬야 합니다.
이제 프로젝트를 빌드 해봅시다!

그러면 에러가 나옵니다. 이런씨
코드를 잘못 써서 그런거 아닌가 싶으실텐데, 이건 컴파일 에러가 아닙니다.
링킹 에러에요.
문법적으로 아무런 하자도 없었고, 컴파일까지는 다 됐는데 링커가 화를 내는겁니다.
"swap이라는 함수가 뭔데 이 씨"
다시 말해서 우리가 만든 라이브러리가 어디 있는지를 알려주지 않으면 링커는 모르는겁니다.
라이브러리가 어디에 있다는 것을 링커한테 알려줄 필요가 있습니다.

프로젝트 속성에 들어가서 '링커-입력'을 누르면 맨 위에 '추가 종속성'이라는 것이 있습니다.
저기에 우리가 만든 라이브러리를 추가해줍시다.
굳이 프로젝트 속성에 매번 들어가기 귀찮다면 프로그램 코드 상에 포함시킬 라이브러리를 지정할 수 있습니다.
#pragma comment (lib, "포함할 라이브러리 이름")
지금 위의 예를 들면 이렇게 쓸 수 있겠네요.
#pragma comment(lib, "SwabStaticLib.lib");
이 라이브러리를 가져다쓰라는 것까지는 링커한테 알려줬습니다.
다음은 라이브러리의 경로를 알려줘야 하는데, 이건 두 가지의 방법이 있습니다.
1) 현재 디렉터리를 기반으로
제일 단순한 방법이면서도 제일 간단한 방법입니다.
우리가 만든 라이브러리 파일을 소스코드가 있는 파일에 옮겨주면 끝납니다.
헤더 파일도 옮겨보신 적이 있을테니 이걸 옮기는 것은 식은죽 먹기겠죠?
2) include 디렉터리를 알려주기

두 번째 방법은 프로젝트 속성에서 'C/C++' 탭을 누르면 '추가 포함 디렉터리'라는 것이 있습니다.
저기에 우리가 만든 라이브러리의 경로를 지정해주면 끝입니다.
두 가지 방법 중 편한 것을 취사선택하시면 됩니다.
여기까지 다 끝났다면 다시 빌드를 해보시길 바랍니다.
원하는 결과대로 잘 나올겁니다.
[STATIC LIBRARY]
알고 있으시겠지만 우리가 만든 것은 정적 라이브러리입니다.
이번 글의 제목은 동적 링킹 라이브러리(DLL)인데, 정작 만든 것은 정적 라이브러리죠.
정적 라이브러리가 뭔지를 알면 DLL과의 차이를 비교할 수 있기 때문에 정적 라이브러리부터 만들어 본 것입니다.
이제 정적 라이브러리가 만들어지는 과정과 실행파일이 만들어지는 전체 과정을 한 번 톺아보겠습니다.

먼저 Swap.cpp와 Swap.h를 컴파일해서 SwapStaticLib.lib라는 정적 라이브러리를 생성했습니다.
그리고 Swap.h와 SwapLibTest.cpp를 컴파일해서 SwapLibTest.obj라는 오브젝트 파일을 생성했죠.
마지막에는 이 둘이 링커를 통해 하나로 묶여서(Linking) SwapLibTest.exe라는 실행 파일을 만들었습니다.
실행파일을 만드는, Linking을 주도하는 대상은 링커(Linker)입니다.
여기서 중요한 사실은 라이브러리가 실행파일의 내부에 포함이 된다는 것입니다.
그래서 뜯어내는 것이 불가능합니다.
실행파일이 처음 만들어질 때부터 아예 묶여버리기 때문입니다.
이런 유형의 라이브러리를 '정적 라이브러리(Static Library)'라고 하는 것입니다.
[또 다른 라이브러리 DLL]
정적 라이브러리에 반대되는 개념은 당연히 '동적 라이브러리'가 되겠죠.
정확하게 말하면 '동적 연결 라이브러리(Dynamic Linking Library)'라고 합니다.
근데 실제로도 DLL로 많이 알고 있고 길게 쓰기도 귀찮으니 DLL이라고 하겠습니다.
[DLL(Dynamic Linking Library)에 대한 이해]
DLL이라는 것은 게임을 하거나 웹 브라우저 등등 Windows 기반에서 사용하다 보면 한 번쯤은 접하게 됩니다.
프로그램을 실행시키려고 했더니 난데 없이 에러가 뜹니다.
'~~.dll을 찾지 못했습니다.'
이 때 보신게 그 DLL입니다.
그리고 Windows 개발을 하겠다는 것은 앞으로 DLL을 모를 수가 없게 될 겁니다.
이제 DLL이라는 것에 대해서 좀 더 근본적인 이야기를 해보겠습니다.
앞서 정적 라이브러리를 이야기하면서 링킹과 링커에 대한 이야기를 했습니다.
링커는 링크 과정(링킹)을 통해서 실행파일을 생성해냅니다.
이 때 확장자로 .exe를 갖는 실행 파일을 만들어 내게 됩니다.
그런데 .dll이라는 확장자를 가진 라이브러리도 만드는 것이 가능합니다.
요놈이 바로 DLL의 정체입니다.
[DLL과 정적 라이브러리의 차이점]
일단 DLL이라는 놈이 어떻게 만들어지는 지는 알게 됐습니다.
사실 정적 라이브러리나 DLL 모두 라이브러리라는 공통점이 있습니다.
정적 라이브러리는 정적인 특성을 지니고 DLL은 동적인 특성을 지닌다는 차이가 있습니다.
"거 이름 갖고 말장난 그만 치고 제대로 설명하세요 욕나오기 전에"
정확히 말을 안하니까 아무래도 뭘 기준으로 정적이고 뭘 기준으로 동적이다라는 말이 어려울 수 있습니다.
충분히 이해가 됩니다.
정확히는 "실행 가능한 프로그램에서 라이브러리를 가져다 쓰는 방법에 따른 차이점이 있다"라고 해야됩니다.
이제 그걸 설명해보도록 하겠습니다.
1) 정적 라이브러리의 특성
정적 라이브러리는 우리가 직접 만들어보기도 했고, 대충 어떤 놈인지는 감이 오실겁니다.
만약 우리가 만들었던 SwapStaticLib.lib라는 놈을 기준으로 3개의 프로그램이 이 라이브러리를 쓴다고 가정해봅시다.
그러면 프로그램이 생성되는 과정은 위의 그림과 같이 나타낼 수 있습니다.

컴파일과 링크 과정을 통해서 생성되는 실행파일을 잘 보시길 바랍니다.
실행파일에 라이브러리 SwapStaticLib.lib의 바이너리 코드를 포함하고 있습니다.
라이브러리 코드를 완전히 포함해서 실행파일(.exe)를 생성하는 형태의 링크를 '정적 링크(Static Link)'라고 합니다.
왜 정적이라고 했는지 이제 좀 감이 오실까요?
이미 실행파일에 하나로 묶여서 뜯어내려고 해도 움직이지 않는 정적인 상태가 되어서 그렇습니다.
그렇다면 정적 링크의 장점은 뭘까요?
바로 실행의 독립성입니다.
실행파일만 만들어진다면 이후에는 라이브러리가 있던 없던 실행 파일에 다 들어가있으니 어디서든 실행이 가능합니다.
물론 단점도 존재합니다.
바로 세 개의 실행파일은 해당 라이브러리의 크기만큼 메모리 공간을 더 차지하게 된다는 것입니다.
이건 단순히 프로그램이 실행될 때만 메모리 공간을 더 차지하는 것이 아닙니다.
하드디스크에 저장되어 있는 상태에서도 마찬가지입니다.

아직 DLL에 대한 특성을 이야기하지 않았습니다.
하지만 위의 그림을 통해서 비교를 해보면 정적 라이브러리의 단점을 명확하게 알 수 있습니다.
보다시피 하드 디스크에 총 세 개의 실행 파일이 저장되어 있는 상태입니다.
모든 실행 파일이 동일한 라이브러리를 포함하고 있기 때문에 메모리 공간을 그만큼 더 차지하고 있습니다.
반면에 DLL은?
세 개의 실행 파일이 동일한 라이브러리를 사용한다고 하면 이 라이브러리를 별도로 저장할 수 있겠죠.
그리고 공유할 수만 있다면 메모리를 절약하는 것도 가능합니다.
오른쪽에 보이는 그림이 바로 DLL을 사용했을 때 얻을 수 있는 장점입니다.
2) DLL의 특성
정적 라이브러리에 대한 이야기는 충분히 했으니 이제 본론인 DLL에 대해서 이야기를 해보겠습니다.
위에서는 DLL의 장점 중 하나를 보여드렸는데, 이게 DLL을 대표하는 특성은 아닙니다.
말 그대로 장점 중 하나인거죠.
실제로 DLL은 메인 메모리에서 진가를 발휘하게 됩니다.
위의 그림에서 정적 라이브러리를 기반으로 한 AAA와 DLL을 기반으로 한 AAA를 실행했다고 가정해봅시다.

일단 AAA가 실행 중이고, 총 다섯 개의 페이지 (2, 3, 5, 8, 9)가 메인 메모리에 올라와 있는 상황입니다.
왼쪽에 있는 그림은 정적 라이브러리를 기반으로 한 AAA.exe의 상황입니다.
정적 라이브러리는 실행파일의 일부로 빌드가 되었기 때문에 정적 라이브러리도 포함해서 가상 메모리를 구성합니다.
이제 반대편을 보면 DLL을 기반으로 한 AAA.exe의 상황입니다.
DLL은 실행파일의 일부로 포함이 되지 않고 독립적으로 저장되는 라이브러리입니다.
그런데 AAA가 이 라이브러리를 사용하기 때문에 AAA의 가상 메모리 영역에 할당되는 것은 똑같습니다.
DLL을 사용하고자 하는 프로세스는 자신의 가장 메모리 주소에 DLL을 매핑(Mapping)시킬 필요가 있습니다.
그래야 DLL이 제공하는 함수를 호출할 수 있기 때문입니다.
즉, 두 개의 파일(실행파일과 DLL파일)이 하나의 가상 메모리를 구성하는 형태가 되는 것이죠.
지금 이 그림만 놓고 봤을 때는 사실 차이를 잘 느끼기가 어려울 수 있습니다.
그렇다면 BBB.exe까지 실행시켜본다면 어떻게 될까요?
여기서 동일한 DLL을 참조하는 프로세스를 하나 더 실행 시키면 메모리를 활용하는 방식에서 차이점을 알 수 있습니다.
BBB.exe를 실행시킨다고 가정해보겠습니다.
그러면 AAA.exe는 잠시 멈추고 BBB.exe가 실행됩니다.
여기서 프로세스 BBB도 8, 9번 페이지를 필요로 한다는 가정을 놓고 가겠습니다.
그러면 다음과 같은 상태를 보이게 됩니다.

보다시피 왼쪽에는 동일한 정적 라이브러리를 포함하는 두 개의 프로세스를 보여주고 있습니다.
AAA가 실행을 멈추고 BBB가 실행을 하기 때문에 메인 메모리에는 BBB의 페이지가 올라갑니다.
페이지 2, 3, 5는 동일하지만 이건 BBB의 페이지입니다.
그리고 정적 라이브러리 영역에 있는 페이지 8과 9도 BBB의 페이지입니다.
다시 말해서 컨텍스트 스위칭이 일어난 것입니다.
동일한 페이지를 사용한다고 하더라도 이걸 반환하고 새롭게 로딩을 하게 됩니다.
이번에는 DLL 기반의 프로세스 AAA와 BBB의 상황을 보도록 하겠습니다.
우선 메인 메모리에는 똑같이 2, 3, 5, 8 ,9가 올라가 있는 상황입니다.
페이지 2, 3, 5는 프로세스 BBB의 페이지가 됩니다.
그런데! 여기서 8과 9는 BBB의 페이지가 아닌 AAA가 사용하던 페이지가 그대로 유지가 됩니다.
다시 말하면 별도의 파일을 사용하는 DLL을 AAA와 BBB가 공유하게 되는 것입니다.
이게 DLL의 가장 돋보이는 특성이자 장점이라고 볼 수 있습니다.
둘 이상의 프로세스가 동일한 DLL을 공유하게 되는 경우에, 메인 메모리에서 페이지 단위로 공유가 이뤄집니다.
만약에 8, 9가 아닌 DLL의 다른 페이지를 프로세스 BBB에서 필요로 했다면 위와 같은 그림은 아니게 됩니다.
메인 메모리에 올라와 있지 않은 페이지를 필요로 하기 때문에 새로 올려야 하는 것이죠.
그래서 DLL은 페이지 단위로 공유가 이뤄진다고 하는 것입니다.
[DLL 제작 1: 암묵적 연결(Implicit Linking)]
이제 DLL을 한 번 만들어봅시다.
앞에서 정적 라이브러리 형태로 작성했던 SwapStaticLib.lib를 DLL로 만들어보는 걸로 하겠습니다.
크게 두 가지의 방법을 통해서 DLL을 사용하는 것이 가능합니다.
그 중 첫 번째 방법인 암묵적 연결(Implicit Linking) 방법을 사용하려고 합니다.
두 번째 방법인 명시적 연결(Explicit Linking)은 조금 이따 다루도록 하겠습니다.
상대적으로 명시적 연결이 좀 더 쉽기 때문입니다.
DLL 제작에 필요한 파일은 아까의 정적 라이브러리때와 마찬가지로 딱 둘만 있으면 됩니다.
[SwapDll.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
__declspec(dllimport)
void swap(int* v1, int* v2);
[SwapDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
__declspec(dllexport)
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
뭔가 크게 바뀐 부분은 없는데 __declspec(dllimport), __declspec(dllexport)라는 놈이 있습니다.
이건 외부에 제공할(Export), 그리고 외부로부터 제공 받을(Import) 함수 및 변수 선언에 사용이 됩니다.
C/C++ 문법에서는 볼 수 없었던 선언이라 생소할겁니다.
당연히 볼 수 없었던 선언이 맞습니다.
MS에서 제공하는 추가적인 선언문이거든요.
__declspec(dllimport)는 DLL로부터 제공받을(Import) 함수를 선언할 때 사용합니다.
그래서 헤더 파일을 보면 swap 함수의 선언 앞에 이게 추가가 되어있죠.
이건 swap 함수를 DLL로부터 제공을 받겠다는 의미입니다.
다음은 __declspec(dllexport)라는 놈입니다.
얘는 반대로 DLL이 외부에 제공할(Export) 함수를 선언할 때 사용합니다.
그래서 Swap 함수의 선언과 정의 앞에 이 문장을 붙이고 있습니다.
이건 "이어서 등장하는 swap 함수를 외부에서 사용할 수 있는 형태의 DLL로 라이브러리화 하겠다" 라는 것입니다.
그래서 쉽게 말하면 전자는 받겠다는 뉘앙스고, 후자는 주겠다는 뉘앙스로 이해하시면 됩니다.
이제 DLL을 만들면 되는데, 정적 라이브러리 때와 마찬가지로 '동적 라이브러리'라고 대놓고 있습니다.
그걸 눌러서 만드시면 됩니다.
여기서 필요한 것은 SwapDll.cpp 하나만 있으면 됩니다.
일단 이걸 가지고 빌드를 하면 실행파일이 생성되는 위치에 두 개의 파일이 만들어질겁니다.
SwapDll.lib, SwapDll.dll
가만 보니 DLL만 만들어진게 아니라 lib 파일도 생성이 되었습니다.
그래서 DLL이랑 정적 라이브러리가 동시에 생성된 것인가 생각할 수도 있습니다.
그런데 여기서 생성된 lib파일은 정적 라이브러리를 생성할 때 만들어진 lib 파일과는 성격이 다릅니다.
이 lib 파일은 DLL이 제공하고자 하는 함수 정보(이름 정보)를 갖고 있습니다.
그래서 실제로는 다음과 같은 용도로 사용하게 됩니다.

여기서 DllTest.cpp는 DLL이 제공하는 swap 함수를 호출하는 코드로 구성되게 됩니다.
일단은 DllTest.cpp가 컴파일러에 의해서 컴파일이 되면 DllTest.obj 파일이 생성되게 됩니다.
이 과정에서 필요한 것은 __declspec(dllimport) void swap(int* v1, int* v2)라는 선언이 있는 헤더파일이 필요합니다.
호출을 하는 함수의 선언 정보만 있어도 컴파일러에서는 문제가 되질 않기 때문입니다.
그래서 앞서 만들었던 헤더 파일인 SwapDll.h가 여기서 사용이 됩니다.
다음은 링커에 의한 링크 과정을 거쳐야 합니다.
아까 정적 라이브러리 과정에서도 오류를 맛을 봐서 미리 짐작하고 있으실겁니다.
실행파일을 생성할 때에는 함수의 선언만 있는 것이 아니라 정의까지 존재해야 합니다.
그런데 DLL은 정적 라이브러리와 다르게 프로그램이 실행되는 시간에 참조하는 것이 목적입니다.
여기서 라이브러리를 같이 링크를 해버리면 정적 라이브러리와 다를 바가 없어지기 때문입니다.
이와 같은 문제를 해결하기 위해 DLL이 만들어질 때에 lib 파일도 같이 만들어지는 것입니다.
DLL을 생성할 때 같이 만들어지는 lib 파일은 링커가 실행파일을 만드는데 필요한 정보가 담겨있습니다.
그래서 SwapDll.lib 파일은 링커한테 다음과 같은 정보를 주게 됩니다.
"DllTest.obj에서 호출하는 swap 함수는 SwapDll.dll이라는 파일에 있으니까 거기서 찾아다 써!"
링커는 이 정보를 토대로 실행파일을 만들어내게 됩니다.
그래서 프로그램의 실행시간에 해당 DLL을 참조하는 실행 파일을 만들게 되는 것입니다.
여기서 한가지 확인 차 질문을 해보려고 합니다.
lib 파일은 언제, 그리고 dll 파일이 필요한 순간은 언제일까요?
"lib 파일은 링크 과정에서, dll 파일은 실행할 때에 필요합니다"
라고 답하셨다면 지금까지의 과정을 잘 이해하신 것이 됩니다.
이제 거의 다 왔습니다.
이제 아래의 코드를 작성해서 프로젝트를 생성하면 됩니다.
[DllTest.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: DllTest.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#pragma comment(lib, "SwapDll.lib")
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "SwapDll.h"
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
return 0;
}
여기서는 헤더파일을 포함하고 있는데, 헤더 파일 내부에서 DLL에 swap 함수가 있다는 선언을 추가해뒀습니다.
물론 헤더파일을 추가하지 않는 방법도 있습니다.
헤더파일에서 썼었던 __declspec(dllimport) 부분을 추가하면 됩니다.
__declspec(dllimport) void swap(int* v1, int* v2);
그리고 정적 라이브러리에서 참조할 lib 파일 이름을 명시했던 것처럼 여기서도 lib 파일 이름을 명시해줘야 합니다.
위의 예제 코드에서는 pragma comment를 통해서 이 부분을 명시를 하였습니다.
마지막으로 정적 라이브러리 예제 실행 시 했던 방법대로 lib 파일을 옮기거나 include 파일의 경로를 설정하면 됩니다.
그러면 프로젝트 빌드는 문제 없이 됩니다.
이제 남은 것은 프로그램을 실행하는 것인데, 기본적으로 DLL은 표준 검색 경로를 통해서 찾습니다.
표준 검색 경로에 대해서는 이전에도 소개했던 바가 있기 때문에 생략하도록 하겠습니다.
물론 가장 쉬운 방법은 실행파일이 있는 디렉터리에 갖다 놓으면 되기 때문에 거기에 dll 파일을 옮기면 됩니다.
옮기고 난 뒤에 실행을 하면, 문제 없이 결과가 나오는 것을 보실 수 있을 겁니다.
[DLL과 extern 선언]
이 내용은 정리할까 말까 하다가 정리를 하기로 했습니다.
아무래도 알아두면 좋을 내용이기도 하고, C++을 공부하면서도 배웠던 내용이기도 합니다.
개인적으로는 난독화와 관련있는 개념이라고 생각하긴 했는데, 뭔가 맞는거 같으면서도 아닌 것 같습니다.
아직 배움이 부족한 탓인지 난독화라는 개념까지 접목해야할 개념으로 판별하기에는 좀 어렵네요.
검색을 해보니까 '함수 오버로딩'과 관련된 개념이라고 보는 것이 더 정확한 것 같습니다.
이 부분에 대해서는 추후에 다룰 수 있다면 제대로 다뤄보도록 하겠습니다.
일단은 복잡하게 생각하지 않고 책에 있는 개념대로 내용을 정리하려고 합니다.
우선 Windows 시스템 프로그래밍이기 때문에 다들 Visual Studio를 쓰고 있으실 거라고 생각합니다.
Visual Studio에서는 확장자가 .c냐 .cpp냐에 따라서 컴파일러가 달라집니다.
쉽게 말하면 .c면 C 컴파일러를 이용해서 바이너리 코드를 작성하게 됩니다.
.cpp면 당연히 C++ 컴파일러를 이용해서 바이너리 코드를 작성하게 되겠죠.
우리가 작성했던 DLL도 그렇고, DLL을 사용한 예제 코드도 확장자를 .cpp로 뒀으니 C++ 컴파일러를 사용했을 겁니다.
그런데 만약에 DLL을 .c로 만들게 되면 어떻게 될까요?
다시 말해서 라이브러리를 만들 때 사용한 컴파일러와 라이브러리를 이용한 예제 코드의 컴파일러가 다르다면?
이걸 확인하는 것은 그리 어렵지 않으니 바로 한 번 해보도록 합시다.
Case1) 기존에 만든 DLL은 그대로 사용하고, 라이브러리를 이용한 예제 코드의 확장자만 .c로 바꾸기
빌드를 해보면 컴파일까지는 문제없이 수행이 됩니다.
다만 링커쪽에서 에러가 발생할거에요.
LNK2019라는 에러가 나올겁니다.
왜 이런 에러가 나오는지에 대한 이유를 설명하자면, C++의 컴파일러와 연관이 있습니다.
C++의 컴파일러는 컴파일을 하는 과정에서 '네임 맹글링(Name Mangling)'이라는 작업을 하게 됩니다.
네임 맹글링이라는 것은 단어 뜻 그대로 '이름을 뭉개버린다'는 것인데, 정의되어 있는 함수의 이름을 바꿔버립니다.
컴파일러가 정해놓은 규칙에 따라서요.

네임 맹글링이 된 예시를 한 번 가져와봤습니다.
앞에서 링커 에러가 났을 때 봤던 에러 중 하나입니다.
가만 보시면 '?swap@@YAXPEAH0@Z'라는 식으로 swap 함수의 이름이 이상하게 짬뽕이 되어 있습니다.
이게 네임 맹글링이 일어난 사례입니다.
이 네임 맹글링이라는 놈은 'C++의 함수 오버로딩'과 관련이 있는 내용입니다.
여기에 대해서는 추후에 다룬다고 했으니 더 언급하진 않겠습니다.
여하튼, 명확한 사실은 컴파일이 완료된 바이너리 코드에서는 이름이 바뀐다는 것입니다.
그리고 이름을 바꿔버리는 규칙은 컴파일러에 따라서 다릅니다.
요약하자면 C++ 컴파일러라고 다 똑같은 C++ 컴파일러가 아니라는 말입니다.
이를테면 MS사의 C++ 컴파일러로 라이브러리를 컴파일했습니다.
이 라이브러리는 gcc의 C++ 컴파일러로 컴파일된 프로그램에서는 사용할 수 없습니다.
네임 맹글링 규칙이 다르기 때문입니다.
여기서 발생한 LNK2019 에러 역시 위와 같은 맥락에서 발생한 것입니다.
라이브러리를 사용하는 예제 코드는 C 컴파일러로 컴파일했기 때문에 네임 맹글링이 발생하지 않았습니다.
그런데 DLL은 C++ 컴파일러로 컴파일을 했으니 네임 맹글링이 발생하게 됩니다.
DLL에 있는 swap 함수는 swap이라는 이름이 아닌 다른 이름이 되었으니까요.
그래서 링커는 다음과 같이 불평을 합니다.
"swap이라는 이름의 함수를 찾아보니까 안보이는데?"
동일한 컴파일러 기반에서 빌드를 하면 동일한 네임 맹글링 규칙때문에 이와 같은 문제는 일어나지 않습니다.
그런데! DLL은 라이브러리입니다.
어디서건 이 라이브러리를 필요로 하면 사용이 가능해야 된다는 것이죠.
그래서 C로 구현된 프로그램에서도 C++로 빌드된 라이브러리를 쓰고 싶을 수도 있습니다.
이 때 사용할 수 있는 방법은 C++ 컴파일러가 네임 맹글링을 하지 못하도록 막으면 됩니다.
extern "C"
네임 맹글링을 막기 위해 사용되는 C++에서 사용되는 키워드입니다.
함수의 정의 앞에 이 문장이 들어가면 네임 맹글링이 발생하지 않습니다.
이 문장을 추가해서 DLL을 만들어보시기 바랍니다.
[SwapDll.cpp - extern "C"를 추가한 경우]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
extern "C" __declspec(dllexport)
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
이렇게 하면 LNK2019 에러가 발생하지 않는 것을 알 수 있습니다.
Case2) SwapDll.h는 그대로 놔두고 SwapDll.cpp에 extern "C" 키워드를 추가
그리고 SwapDllTest.c는 그대로 둔다.
아마 위의 내용을 그대로 잘 따라오셨다면 큰 문제 없이 빌드도 되고 실행되는 것을 확인할 수 있을겁니다.
Case3) Case2까지 진행한 상황에서 SwapDllTest.c를 다시 SwapDllTest.cpp로 바꿔서 빌드
이번에도 에러가 발생합니다.
아까 봤던 LNK2019라는 놈이 나올거에요.

보다시피 이름이 _imp_?swap@@YAXPEAH0@Z와 같은 식으로 뭉개져서 나오는 것을 보실 수 있을겁니다.
네임 맹글링은 함수 정의만이 아니라 함수 호출문의 이름에서도 발생하게 되는 것입니다.
지금까지의 상황을 요약하면 다음과 같습니다.
"함수 정의 부분에서는 네임 맹글링을 막았지만 헤더 파일에 있는 함수 호출문은 네임 맹글링이 발생한 상황"
그래서 이 부분을 해결하기 위해서는 헤더파일인 SwapDll.h의 함수 선언부에도 extern "C"를 붙여주면 해결이 됩니다.
Case4) Case3에서 사용한 DLL은 그대로 사용, DllTest.cpp에서 포함하는 헤더인 SwapDll.h에 extern "C"를 추가
이제 라이브러리로 제공하는 swap 함수도, 이 라이브러리를 사용하는 swap 호출 문장도 이름 변경이 발생하지 않습니다.
즉, 컴파일러에 상관없이 swap 함수를 호출하는 것이 가능합니다.
꽤 내용이 길어졌습니다.
Case 1~4를 통해서 아래의 결론을 내리기 위해 번거로운 과정을 거쳤습니다.
결론은 다음과 같습니다.
1) DLL을 통해서 제공하고자 하는 함수에는 다음의 문장을 붙인다.
extern "C" __declspec(dllimport)
2) DLL에 존재하는 함수를 호출하고자 하는 경우에는 다음 문장을 붙인다.
extern "C" __declspec(dllexport)
[DLL 제작 2: 명시적 연결(Explicit Linking)]
'암묵적 연결'을 통한 DLL 사용 방법을 알았으니 또 다른 방법인 '명시적 연결'에 의한 DLL 사용 방법을 소개할까 합니다.
암묵적/묵시적 연결 방법은 DLL을 참조하는 방식에 따라서 구분하게 됩니다.
소스코드 내부에 DLL 연결 코드가 명시적으로 존재한다면 '명시적 연결' 방법입니다.
반대로 소스코드 내에 DLL 연결에 대한 명시적인 코드가 없다면 '암묵적 연결' 방법이 되는 것이죠.
명시적 연결 방법에서는 lib 파일을 필요로 하지 않습니다.
필요한 DLL을 명시적으로 지정하기 때문입니다.
그래서 암묵적 연결에서 사용했던 lib 파일에 관련된 모든 작업들이 명시적 연결에서는 불필요한 작업이 됩니다.

위의 그림은 명시적 연결 방법을 통해 DLL을 참조하는 과정입니다.
여기서 사용되는 함수는 총 3개입니다.
LoadLibrary 함수는 필요한 DLL을 프로세스 가상 메모리에 매핑할 때 사용합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya
LoadLibraryA 함수(libloaderapi.h) - Win32 apps
지정된 모듈을 호출 프로세스의 주소 공간에 로드합니다. (LoadLibraryA)
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryw
LoadLibraryW 함수(libloaderapi.h) - Win32 apps
지정된 모듈을 호출 프로세스의 주소 공간에 로드합니다. (LoadLibraryW)
learn.microsoft.com
GetProcAddress 함수는 가상 메모리에 매핑된 DLL에서 필요한 함수의 포인터를 획득할 때 사용합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress
GetProcAddress 함수(libloaderapi.h) - Win32 apps
지정된 DLL(동적 연결 라이브러리)에서 내보낸 함수 또는 변수의 주소를 검색합니다.
learn.microsoft.com
마지막으로 FreeLibrary 함수를 호출해서 DLL을 반환합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-freelibrary
FreeLibrary 함수(libloaderapi.h) - Win32 apps
로드된 DLL(동적 연결 라이브러리) 모듈을 해제하고 필요한 경우 참조 수를 줄입니다.
learn.microsoft.com
여기서 DLL의 반환은 가상 메모리에서의 반환을 의미하며 물리 메모리에서의 반환을 뜻하는 것은 아닙니다.
DLL을 참조하는 프로세스가 하나도 존재하지 않을 때 물리 메모리에서 DLL이 반환된다는 것을 알아두시길 바랍니다.
추가로 레퍼런스 카운트(참조 횟수, Reference Count)에 대한 이야기를 좀 하고 나서 예제 코드를 보이도록 하겠습니다.
커널 오브젝트의 Usage Count와 유사하게 프로세스 내부적으로 DLL의 레퍼런스 카운트를 계산하게 됩니다.
LoadLibrary 함수를 호출하면 지정된 DLL의 레퍼런스 카운트는 1씩 증가하고, FreeLibrary 함수에 의해 1 감소합니다.
그리고 레퍼런스 카운트가 0이 될 때 해당 DLL은 프로세스의 가상 메모리에서 해제됩니다.
위에서도 굵은 글씨로 강조한 부분이지만 물리 메모리가 아닌 가상 메모리라는 점을 알아두시길 바랍니다.
그리고 DLL의 레퍼런스 카운트는 프로세스별로 독립적입니다.
다시 말해서 공유하는 프로세스의 횟수를 나타내는 정보가 아니라는 점입니다.
이처럼 레퍼런스 카운트를 두는 이유는 커널 오브젝트의 소멸 시점을 정하는 것과 유사합니다.
프로그램 실행 중에 DLL을 가상 메모리에 할당/해제 시점을 정할 수 있는 것이죠.
암묵적 연결 방법을 사용하게 되면 메모리 할당과 해제의 시점을 정할 수 없습니다.
이제 예제 코드를 통해서 위 세 함수를 사용한 명시적 연결 방법을 보겠습니다.
[ExplicitDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: ExplicitDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 명시적 DLL 활용 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "SwapDll.h"
typedef void (*SWAP_FUNC)(int*, int*);
int _tmain(int argc, TCHAR* argv[])
{
HMODULE hinstLib;
SWAP_FUNC SwapFunction;
hinstLib = LoadLibrary(TEXT("SwapDll"));
if (hinstLib == NULL)
{
_tprintf(TEXT("LoadLibrary Failed\n"));
return -1;
}
SwapFunction = (SWAP_FUNC)GetProcAddress(hinstLib, "swap");
if (SwapFunction == NULL)
{
_tprintf(TEXT("GetProcAddress failed\n"));
return -1;
}
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
SwapFunction(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
BOOL isSuccess = FreeLibrary(hinstLib);
if (isSuccess == NULL)
{
_tprintf(TEXT("FreeLibrary failed\n"));
return -1;
}
return 0;
}
이제 명시적 연결방법을 사용할 때 얻을 수 있는 이점에 대해서 크게 세 가지 정도를 놓고 볼 수 있습니다.
1) DLL이 필요한 시점에서 로딩하고, 불필요해지면 반환하기 때문에 메모리 절약 가능
이는 앞서 설명했던 레퍼런스 카운트를 기반으로 얻을 수 있는 장점입니다.
2) 프로그램 실행 중에 DLL의 교체 및 선택 가능
예를 들면 A.dll과 B.dll이 있다고 가정해보겠습니다.
두 개의 DLL 안에 정의되어 있는 함수 C는 이름은 동일하지만 기능이 다릅니다.
그래서 사용자의 입력 또는 선택에 따라서 A 또는 B.dll 중 하나를 선택해서 메모리에 로딩하는 것이 가능합니다.
3) 암묵적 연결 방식과 비교하면 명시적 연결 방식은 실행에 걸리는 시간이 짧고, DLL 로딩 시간 분산이 가능
암묵적 연결 방식은 프로그램 실행 전에 필요한 모든 DLL을 메모리에 로딩하게 됩니다.
그래서 실행까지 걸리는 시간이 길 수 있습니다.
반면에 명시적 연결 방식은 필요한 순간에 하나씩 DLL을 로딩하기 때문에 실행까지 걸리는 시간이 짧습니다.
그리고 DLL 로딩에 걸리는 시간을 분산하는 것도 가능합니다.
이처럼 세 가지의 장점이 있음에도 암묵적 연결 방법이 좀 더 선호된다고 합니다.
그 이유는 코드에서만 봐도 아시겠지만 암묵적 연결 방법은 코드가 굉장히 간결합니다.
그리고 사용하기도 훨씬 쉽죠.
함수를 셋이나 가져다 쓸 필요도 없으니까요.
그래서 성능적인 측면을 최대한 고려해야 하는 상황이라면 명시적 연결 방법을 쓸 수 있습니다.
하지만 그런 상황이 아니라면 보편적으로는 암묵적 연결 방법이 선호된다고 볼 수 있습니다.
[한 번 이상 로드될 수 있는 DLL]
이게 제목만 봐서는 무슨 말인지 잘 이해가 안될 수도 있는 개념입니다.
여기서 DLL의 특성에 대해서 소개를 했었는데, 이걸 말로 풀어보면 다음과 같이 설명할 수도 있습니다.
"DLL은 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 메모리에 존재한다."
여기서 말하는 메모리가 도대체 어떤 메모리를 지칭하는 것인지 좀 난감합니다.
우리가 알고 있는 메모리만 해도 가상 메모리, 메인 메모리, 물리 메모리 등이 있는데 말이죠.
일단 케이스 별로 좀 나눠서 이해를 해봅시다.

이전에 DLL의 특성을 설명하면서 봤던 그림을 기준으로 이야기를 해보겠습니다.
1) 메모리 == 가상 메모리 (틀린 표현)
만약 위에서 말하는 메모리가 가상 메모리라고 하면 잘못된 표현이 됩니다.
가상 메모리에 올라갔다는 말은 메모리의 주소에 DLL이 매핑이 되었다는 것을 의미합니다.
그래서 프로세스 AAA의 가상 메모리 주소에 매핑된 DLL은 프로세스 AAA가 소멸되면 같이 소멸됩니다.
"프로세스 BBB가 아직 실행 중이라면 맞는 표현 아닌가요?"
유감스럽게도 프로세스 AAA와 BBB의 가상 메모리 공간은 엄연히 다른 공간입니다.
그래서 가상 메모리의 관점으로 생각해본다면 이것은 DLL의 특징도, 장점도 될 수 없습니다.
2) 메모리 == 메인 메모리 (RAM, 물리 메모리(RAM+HDD)) (맞는 표현)
이제 메모리가 메인 메모리를 뜻한다고 하면 이 말은 맞는 표현일까요?
맞습니다.
물론 페이지 단위의 교체가 발생해서 메인 메모리에 있는 페이지가 교체가 될 수도 있긴 합니다.
페이지 교체 알고리즘에 의해서 빈번하게 발생하기도 하고요.
그렇지만 이 말이 왜 맞는 표현인지를 지금부터 설명을 하려고 합니다.
일단 위에 있던 DLL의 특성에 대해서 좀 명확하게 다시 정의를 하고 가겠습니다.
"DLL은 물리 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 물리 메모리에 존재한다."
저자분도 했던 이야기지만 이게 좀 많이 추상적인 개념인지라 말이 좀 어렵습니다.
천천히 하나하나 쪼개어가면서 이 말이 무슨 뜻인지 이해를 해봅시다.
(단계 1)
AAA.exe가 실행된다.
그런데 이 실행파일은 Best.dll이라는 라이브러리를 필요로 한다.
그래서 Best.dll은 가상 메모리 영역에 매핑되면서 메인(물리) 메모리에 올라가게 된다.
(단계 2)
Best.dll이라는 라이브러리를 필요로 하는 BBB.exe도 실행된다.
그런데 이번에는 메인(물리) 메모리에 Best.dll을 올리지 않는다.
이전 단계에서 이미 메인(물리) 메모리에 올렸기 때문이다.
올라간 메모리를 그대로 참조할 수 있도록 BBB 프로세스의 가상 메모리 영역에 매핑을 한다.

지금까지의 과정을 그림으로 표현하면 위와 같습니다.
그리고 이 그림에서 보여주는 메커니즘을 Memory Mapping이라고 합니다.
DLL은 이 메커니즘을 기반으로 완성이 됩니다.
여기서 주목해야 할 부분은 메커니즘 말고도 Best.dll이 할당된 가상 메모리 주소입니다.
보다시피 AAA와 BBB, 모든 프로세스가 동일한 주소에 할당을 했습니다.
프로세스에 동일한 주소를 할당하는 것을 OS에서 지원을 하고 있으며, 이로 인해서 DLL 공유가 가능한 것입니다.
다시 말해서 두 프로세스가 동일한 DLL을 동일한 가상 주소에 매핑했기 때문에 페이지 단위로 공유가 가능해집니다.
(단계 3)

AAA 프로세스가 종료되었다.
하지만 AAA.exe를 실행했을 때 메모리에 올라갔던 Best.dll은 남아있는 상태이다.
프로세스 BBB의 실행을 위해서다.
(단계 4)
마지막으로 프로세스 BBB도 종료되었다.
이제 Best.dll을 참조하는 프로세스는 하나도 존재하지 않으므로 Best.dll도 반환된다.
다시 말해서 Best.dll에 할당된 물리 메모리를 반환한다.
이 4단계를 통해서 알 수 있는 사실은 앞에서 말했던 것과 같습니다.
"DLL은 물리 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 물리 메모리에 존재한다."
다시 말해서 한 번 로드되면 RAM에 남아있을 확률은 굉장히 높게 됩니다.
물론 사용의 빈도가 낮아지게 되면 우리가 알고 있는 HDD의 swap 파일로 저장될 수도 있습니다.
그래서 '메인 메모리'라고 한정하지 않고 '물리 메모리(RAM + HDD)'라고 한 것입니다.
앞에서 소개한 케이스는 한 번 만 로드될 수 있는 DLL에 대한 경우를 설명한 것입니다.
이제 마지막으로 한 번 이상 로드 될 수 있는 DLL에 대해서 설명하고 마무리를 지으려고 합니다.
여러분도 아시다시피 처음에 DLL을 빌드하면 할당되어야 할 가상 메모리 주소가 링커(Linker)에 의해 결정됩니다.
만약 Best.dll을 0x10000000번지에 매핑하기로 결정했다 하면?
Best.dll을 사용하는 모든 프로세스는 0x10000000 번지에 Best.dll의 주소를 매핑시킬겁니다.
그런데 이런 경우는 어떻게 될까요?

보다시피 프로세스 AAA와 BBB가 각각 동일한 주소에 Best.dll과 Nice.dll을 가상 메모리에 매핑한 상태입니다.
여기서 프로세스 BBB가 Best.dll을 필요로 하게 되는 상황이 됐습니다.
그래서 Best.dll을 올리려고 하는데!
프로세스 AAA가 매핑해놓은 주소에 Nice.dll이 떡하니 자리를 잡고 있습니다.
이미 자리를 잡고 있는데 방을 빼라고 할 수는 없지 않습니까.

별 수가 없으니 프로세스 BBB는 어쩔 수 없이 다른 주소의 메모리 영역에 Best.dll을 올리게 됩니다.
이와 같은 상황이 발생하면 두 개의 Best.dll이 물리 메모리에 올라가게 됩니다.
다시 말해서 DLL은 한 번만 로드되는 것이 아니라 한 번 이상 로드될 수도 있음을 기억해두시길 바랍니다.
[헤더파일을 몇 개까지 만들어야 할까]
이제 DLL에 대한 대략적인 설명은 끝났습니다.
이제 DLL을 위한 헤더파일을 정의하는 방법을 다뤄볼까 합니다.
DLL을 잘 이해하는 것과 헤더파일을 깔끔하게 설계하는 것은 다른 차원의 이야기니까요.
[필요한 헤더파일의 개수는 최소한 세 개]
C++ 컴파일러로 빌드된 DLL이 있다고 가정해봅시다.
이 DLL에는 C, C++에서도 사용할 수 있게 만들었습니다.
그러면 여기서 필요한 헤더파일의 몇 개가 될까요?
아마 DLL을 만들면서 느끼셨을지 모르겠지만, 프로그램을 만드는 것과 큰 차이는 없다고 느끼셨을 겁니다.
그래서 정적 라이브러리 때도 그랬고, DLL때도 마찬가지로 헤더파일이 필요했죠.
swap이라는 함수로 엄청 간단한 예를 들어서 하나 밖에 필요하지 않았지만요.
앞으로 만들게 될 DLL은 이렇게 단순하지 않습니다.
DLL을 구성하는 파일의 갯수도 많아질 겁니다.
그리고 둘 이상의 함수 호출이 하나의 DLL 인터페이스로 구성한다면 헤더파일이 분명히 필요해질겁니다.
우리가 사용하는 ANSI 표준 함수를 사용하기 위한 stdio.h만 보면 바로 납득이 가실겁니다.
이제 필요한 헤더파일의 갯수를 생각해보겠습니다.
가장 먼저 DLL을 암묵적 연결 방식을 통해 사용한다고 하면 헤더파일이 필요합니다.
여기서 DLL을 빌드할 때 필요로 하는 함수 선언과, DLL을 참조할 때 필요로 하는 함수 선언이 다를 수 있습니다.
그래서 하나는 import 선언, 하나는 export 선언을 해야하죠.
여기서만 해도 헤더 파일은 두 개가 필요합니다.
이제 DLL을 사용하는 사용자의 관점에서 보겠습니다.
C++ 컴파일러를 쓸지, C 컴파일러를 쓸지 우리는 모릅니다.
그래서 C++ 컴파일러를 쓴다고 하면?
extern "C" 선언이 들어가야 네임 맹글링이 발생하지 않습니다.
반대로 C 컴파일러를 쓴다고 하면?
extern "C" 선언이 들어가면 안됩니다.
이건 C++에서 사용되는 키워드라서 C 컴파일러에서는 알아먹을 수가 없거든요.
그래서 C 컴파일러용 헤더 파일 따로, C++ 컴파일러용 헤더 파일을 따로 만들어야 됩니다.
이렇게 생각하고 DLL을 만들어서 배포하게 되면 최소 3개의 헤더 파일이 필요하다는 결론이 나오게 됩니다.
[하나의 헤더파일로 모두 지원하기]
"근데 꼭 그렇게 해야 하나요?"
사실 위에서 든 예시는 '아무런 대책도 없이 DLL을 개발하고 배포할 경우'에 대해서 예를 든 것입니다.
다시 말하면 DLL을 만들어서 배포하는데 3개의 헤더파일까지 갈 필요가 없다는 것입니다.
헤더파일이 많으면 많아질수록 관리하기 힘든 것도 문제지만, 문제가 발생할 여지도 큽니다.
어떤거 하나를 바꾸면 다른 헤더파일들도 바꿔야되는 경우들도 있으니까요.
그래서 가능하다면 DLL도 하나의 헤더파일로 모든 상황에 적합하게 설계하는 것이 좋습니다.
DLL의 헤더 파일과 DLL의 소스 코드, 그리고 DLL을 활용한 예제 코드를 보면서 확인해보겠습니다.
[Calculator.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Calculator.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#ifndef __CALCULATOR_H__
#define __CALCULATOR_H__
#ifdef __cplusplus
extern "C"
{
#endif
#ifdef _COMPLING_DLL_CALCULATOR
#define LIBSPEC __declspec(dllexport)
#else
#define LIBSPEC __declspec(dllimport)
#endif
LIBSPEC int Add(int a, int b);
LIBSPEC int Min(int a, int b);
LIBSPEC double Div(double a, double b);
LIBSPEC double Mul(double a, double b);
#ifdef __cplusplus
}
#endif
#endif
이번 주제의 핵심이 되는 헤더파일입니다.
보다시피 조건부 컴파일 매크로를 통해서 헤더파일이 중복 포함되지 않도록 한 부분을 보실 수 있습니다.
여기서 첫 번째 포인트는 __cplusplus를 쓴 부분입니다.
Visual Studio의 C++ 컴파일러로 컴파일할 때 기본적으로 정의하는 매크로입니다.
그래서 해당 매크로의 정의 유무를 통해서 C++ 컴파일러가 사용되는지 C컴파일러가 사용되는지를 가릴 수 있습니다.
그 다음으로 주목할 포인트는 export와 import를 매크로 선언 여부에 따라서 가르는 부분입니다.
_COMPLING_DLL_CALCULATOR라는 매크로의 선언 유무를 통해 export와 import의 유무를 정할 수 있게 됩니다.
위와 같이 헤더파일을 정의하면 우리가 앞에서 최소 세 개의 헤더파일이 필요하다고 했던 것이 하나로 줄어들었습니다.
DLL을 만드는 데 있어서 헤더파일을 설계하는 것도 중요하다는 것을 기억해두시길 바랍니다.
이제 마지막으로 DLL을 만들기 위한 코드와, DLL을 참조하는 프로그램의 소스코드를 보겠습니다.
[Calculator.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Calculator.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#include "pch.h"
#define _COMPLING_DLL_CALCULATOR
#include "Calculator.h"
LIBSPEC int Add(int a, int b)
{
return a + b;
}
LIBSPEC int Min(int a, int b)
{
return a - b;
}
LIBSPEC double Div(double a, double b)
{
return a / b;
}
LIBSPEC double Mul(double a, double b)
{
return a * b;
}
[UseDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: UseDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "Calculator.h"
#pragma comment(lib, "Calculator.lib")
int _tmain(int argc, TCHAR* argv[])
{
_tprintf(TEXT("result Add: %d \n"), Add(5, 3));
_tprintf(TEXT("result Min: %d \n"), Min(5, 3));
_tprintf(TEXT("result Mul: %e \n"), Mul(5.0, 3.0));
_tprintf(TEXT("result Div: %e \n"), Div(5.0, 3.0));
return 0;
}
단순한 사칙 연산 계산기를 프로그램으로 만든 것이기 때문에 별 다른 설명은 더 필요 없을 것이라고 생각합니다.
마지막으로 확인차 UseDll.cpp를 UseDll.c로 바꿔서 빌드를 해보시기만 하면 됩니다.
문제 없이 실행되는 것을 확인하실 수 있을겁니다.
[글을 마치며]
오늘부로 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'에 대한 정리를 모두 마치게 되었습니다.
한동안은 Windows 시스템 프로그래밍과 관련된 글은 더 올라오지 않을 겁니다.
처음으로 시스템 프로그래밍이라는 분야를 접하면서 모르는 것이 정말 많았다는 것을 새삼 느끼게 됩니다.
아직도 배울 것이 많고, 부족함이 많다는 것을 매일마다 느끼고 있습니다.
그래도 이 책을 끝까지 보면서 제프리 리처 아저씨가 쓴 'Windows via C/C++'을 마주할 용기가 조금은 생겼습니다.
같이 보려고 구매를 했었는데 그건 보다가 엄두가 안나서 내려놨던 책이거든요.
나중에는 그 책을 읽고 공부했던 내용을 여기에 정리할 것 같습니다.
물론 바로 뒤이어서 쓰는 것은 아니고요.
다음에 정리할 주제는 'TCP/IP 소켓 프로그래밍'입니다.
여기서도 '뇌를 자극하는 윈도우즈 시스템 프로그래밍' 저자분이 쓴 책을 가지고 공부를 합니다.
그 책도 지금 쓰레드 부분까지 읽었는데, 여기서 공부했던 것이 많은 도움이 됐습니다.
네트워크도 결국 시스템 프로그래밍을 모르면 안된다는 것을 많이 느끼게 되더라고요.
마지막이라고 잡소리가 꽤 길었습니다.
결론만 말하면 앞으로 TCP/IP 소켓 프로그래밍, 정확히 말하면 네트워크 프로그래밍에 대한 내용을 정리할 예정입니다.
그리고 네트워크 프로그래밍에 대해 공부한 내용을 정리한 이후에는 뭘 다룰지 고민 중입니다.
시스템 프로그래밍을 다시 다룰 것인지, 자료구조와 알고리즘을 다시 다룰 것인지...
공부해야 할 것이 많다보니 그건 그때 가서 다시 정리해야겠습니다.
많이 부족한 내용이지만 봐주신 분들에게 감사드리며, TCP/IP 소켓 프로그래밍 공부 글로 다시 찾아뵙겠습니다.
<Dynamic Linking Library>
'뇌를 자극하는 윈도우즈 시스템 프로그래밍'의 마지막 장입니다.
DLL은 아마 게임을 하면서도, 프로그래밍을 하면서도 종종 보셨을 놈입니다.
'~~.dll이 없습니다'라는 오류문구를 보면 짜증도 나고 그랬던 기억이 있으실겁니다.
오늘 이 DLL이라는 놈을 다뤄볼까 합니다.
[라이브러리와 printf]
DLL에 대한 내용을 다루기에 앞서서 질문을 하나 하고 가려고 합니다.
책에서도 똑같이 질문을 했었는데, 저도 못맞췄습니다.
크게 걱정하실건 없습니다.
이번 글에서 다 정리를 하고 넘어갈 부분입니다.
[질문]
우선 예제 코드를 보면서 이야기를 해보겠습니다.
// Hello.c
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello, World!\n");
return 0;
}
코드 설명이고 뭐고 할 필요도 없을만큼 이제는 국민 코드가 된 예제입니다.
굳이 C나 JAVA, Python 등을 가리지 않고 다 첫 걸음을 뗄 때 작성해보는 코드죠.
헛소리는 그만하고 본론으로 넘어가겠습니다.
위 코드에서는 printf 함수가 호출이 되고 있습니다.
그렇다면 실행 가능한 printf 함수의 바이너리는 어디에 있을까요?
위에 헤더로 선언되어 있는 stdio.h 안에 있을까요?
그렇다면 한 번 확인해봅시다.
_Check_return_opt_
_CRT_STDIO_INLINE int __CRTDECL printf(
_In_z_ _Printf_format_string_ char const* const _Format,
...)
보다시피 뭔가 복잡한 놈이 있는데 이게 stdio.h에서 우리가 찾던 printf입니다.
정의가 아닌 선언만 있습니다.
다시 말해서 stdio.h는 printf 함수의 선언만 들어가 있습니다.
함수의 정의는 없으므로 우리가 찾는 printf 함수의 바이너리는 여기에 없다는 말입니다.
어찌보면 당연한 일이긴 합니다.
일반적으로 헤더 파일에는 함수의 정의가 아닌 함수의 선언들을 모아놓는데 사용하니까요.
[해답은 라이브러리에]
답을 못맞추셨다고 아쉬워 할 필요는 없습니다.
제목에도 써져있듯이 답은 '라이브러리(Library)'에 있습니다.
항상 라이브러리라는 말을 입에 달고 살았지만 답이 여기서 나올 줄은 모르시는 분들도 있었을겁니다.
제가 그래서 그런거 아니에요
라이브러리라는 것을 간단하게 정의하면 다음과 같습니다.
"여러 프로그램에서 자주 사용하는 함수와 데이터들을 실행이 가능한 바이너리 형태로 묶어놓은 파일"
그래서 printf 함수의 선언은 stdio.h라는 헤더 파일에 있습니다.
실제 함수의 정의가 컴파일된 바이너리 코드는 라이브러리에 존재하는 것이고요.
printf 함수의 정의가 들어가있는 라이브러리들은 다음과 같습니다.

여기 있는 라이브러리들은 ANSI 표준 C함수들로 구성이 되어있습니다.
그래서 'C 런타임 라이브러리(C Run-Time Library)'라고 합니다.
여기에는 printf 뿐만이 아니라 scanf와 같은 다양한 ANSI 표준의 C함수의 바이너리 코드가 포함되어 있습니다.
사실 라이브러리는 위에 있는 4개가 전부가 아닙니다.
가장 대표적인 것만 나열을 한 것인데 여기서 일부는 정적(Static) 라이브러리입니다.
그리고 일부는 동적 연결(Dynamic Link) 라이브러리입니다.
또 라이브러리 파일 이름에 마지막으로 d로 끝나는 놈들이 보일겁니다.
얘네는 디버그 모드로 컴파일을 할 때 사용하는 라이브러리입니다.
그게 아닌 라이브러리는 Release(배포)용으로 사용한다고 알고 계시면 될 것 같습니다.
[라이브러리 작성에 대한 동기]
이제 라이브러리라는 놈의 정체도 알게되었습니다.
생각해보면 별 것도 아닌 놈인거 같은데 까짓거 우리도 직접 한 번 만들어봅시다.
일단 뭘 만들어볼까 싶은데, 거창할 것 까지는 없으니까 이걸 갖고 한 번 만들어보죠.
[LibSwap.cpp]
// LibSwap.cpp
#include <stdio.h>
#include <tchar.h>
void swap(int* v1, int* v2);
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d %d\n", a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d %d\n", a, b);
return 0;
}
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
"Hello, World!"를 출력하는 것만큼 포인터를 다루면 보게 되는 정말 기본적인 코드입니다.
우리의 목표는 swap이라는 함수를 라이브러리로 만들어보는 것입니다.
[라이브러리 작성]
사실 라이브러리 작성이라고 해서 되게 어려울거라는 생각이 들겁니다.
근데 Visual Studio를 사용하시면 라이브러리를 만드는 것은 되게 간단합니다.
'새 프로젝트 만들기'를 누르면 거기에 '정적 라이브러리' 라는 항목이 있습니다.
일단 그걸 눌러서 진행하시면 됩니다.
이제 printf 함수 때를 한 번 생각해봅시다.
printf 함수의 선언을 담고 있는 헤더 파일인 stdio.h가 있었고, 별도로 라이브러리가 있었던 것을 기억하실겁니다.
우리도 마찬가지로 swap 함수의 선언을 담을 swap.h와 함수의 정의를 swap.cpp에 넣도록 하겠습니다.
[Swap.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Swap.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
void swap(int* v1, int* v2);
[Swap.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Swap.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
라이브러리를 만들다보면 pch.h나 framework.h 같은 파일들이 같이 추가될겁니다.
여기는 건드리실 필요가 없고, 위의 예제 코드처럼 그냥 헤더파일을 포함해주시기만 하면 됩니다.
컴파일을 하면 정적 라이브러리가 잘 생성되어 있을겁니다.
[라이브러리의 활용]
라이브러리 생성까지 마쳤으면 다음 코드를 작성해서 우리가 만든 라이브러리가 잘 돌아가는지 확인해봅시다.
[SwapLibTest.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapLibTest.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 정적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "Swap.h"
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
return 0;
}
당연한 이야기지만, 헤더 파일은 이 소스코드 파일이 같이 있는 경로에 놔둬야 합니다.
이제 프로젝트를 빌드 해봅시다!

그러면 에러가 나옵니다. 이런씨
코드를 잘못 써서 그런거 아닌가 싶으실텐데, 이건 컴파일 에러가 아닙니다.
링킹 에러에요.
문법적으로 아무런 하자도 없었고, 컴파일까지는 다 됐는데 링커가 화를 내는겁니다.
"swap이라는 함수가 뭔데 이 씨"
다시 말해서 우리가 만든 라이브러리가 어디 있는지를 알려주지 않으면 링커는 모르는겁니다.
라이브러리가 어디에 있다는 것을 링커한테 알려줄 필요가 있습니다.

프로젝트 속성에 들어가서 '링커-입력'을 누르면 맨 위에 '추가 종속성'이라는 것이 있습니다.
저기에 우리가 만든 라이브러리를 추가해줍시다.
굳이 프로젝트 속성에 매번 들어가기 귀찮다면 프로그램 코드 상에 포함시킬 라이브러리를 지정할 수 있습니다.
#pragma comment (lib, "포함할 라이브러리 이름")
지금 위의 예를 들면 이렇게 쓸 수 있겠네요.
#pragma comment(lib, "SwabStaticLib.lib");
이 라이브러리를 가져다쓰라는 것까지는 링커한테 알려줬습니다.
다음은 라이브러리의 경로를 알려줘야 하는데, 이건 두 가지의 방법이 있습니다.
1) 현재 디렉터리를 기반으로
제일 단순한 방법이면서도 제일 간단한 방법입니다.
우리가 만든 라이브러리 파일을 소스코드가 있는 파일에 옮겨주면 끝납니다.
헤더 파일도 옮겨보신 적이 있을테니 이걸 옮기는 것은 식은죽 먹기겠죠?
2) include 디렉터리를 알려주기

두 번째 방법은 프로젝트 속성에서 'C/C++' 탭을 누르면 '추가 포함 디렉터리'라는 것이 있습니다.
저기에 우리가 만든 라이브러리의 경로를 지정해주면 끝입니다.
두 가지 방법 중 편한 것을 취사선택하시면 됩니다.
여기까지 다 끝났다면 다시 빌드를 해보시길 바랍니다.
원하는 결과대로 잘 나올겁니다.
[STATIC LIBRARY]
알고 있으시겠지만 우리가 만든 것은 정적 라이브러리입니다.
이번 글의 제목은 동적 링킹 라이브러리(DLL)인데, 정작 만든 것은 정적 라이브러리죠.
정적 라이브러리가 뭔지를 알면 DLL과의 차이를 비교할 수 있기 때문에 정적 라이브러리부터 만들어 본 것입니다.
이제 정적 라이브러리가 만들어지는 과정과 실행파일이 만들어지는 전체 과정을 한 번 톺아보겠습니다.

먼저 Swap.cpp와 Swap.h를 컴파일해서 SwapStaticLib.lib라는 정적 라이브러리를 생성했습니다.
그리고 Swap.h와 SwapLibTest.cpp를 컴파일해서 SwapLibTest.obj라는 오브젝트 파일을 생성했죠.
마지막에는 이 둘이 링커를 통해 하나로 묶여서(Linking) SwapLibTest.exe라는 실행 파일을 만들었습니다.
실행파일을 만드는, Linking을 주도하는 대상은 링커(Linker)입니다.
여기서 중요한 사실은 라이브러리가 실행파일의 내부에 포함이 된다는 것입니다.
그래서 뜯어내는 것이 불가능합니다.
실행파일이 처음 만들어질 때부터 아예 묶여버리기 때문입니다.
이런 유형의 라이브러리를 '정적 라이브러리(Static Library)'라고 하는 것입니다.
[또 다른 라이브러리 DLL]
정적 라이브러리에 반대되는 개념은 당연히 '동적 라이브러리'가 되겠죠.
정확하게 말하면 '동적 연결 라이브러리(Dynamic Linking Library)'라고 합니다.
근데 실제로도 DLL로 많이 알고 있고 길게 쓰기도 귀찮으니 DLL이라고 하겠습니다.
[DLL(Dynamic Linking Library)에 대한 이해]
DLL이라는 것은 게임을 하거나 웹 브라우저 등등 Windows 기반에서 사용하다 보면 한 번쯤은 접하게 됩니다.
프로그램을 실행시키려고 했더니 난데 없이 에러가 뜹니다.
'~~.dll을 찾지 못했습니다.'
이 때 보신게 그 DLL입니다.
그리고 Windows 개발을 하겠다는 것은 앞으로 DLL을 모를 수가 없게 될 겁니다.
이제 DLL이라는 것에 대해서 좀 더 근본적인 이야기를 해보겠습니다.
앞서 정적 라이브러리를 이야기하면서 링킹과 링커에 대한 이야기를 했습니다.
링커는 링크 과정(링킹)을 통해서 실행파일을 생성해냅니다.
이 때 확장자로 .exe를 갖는 실행 파일을 만들어 내게 됩니다.
그런데 .dll이라는 확장자를 가진 라이브러리도 만드는 것이 가능합니다.
요놈이 바로 DLL의 정체입니다.
[DLL과 정적 라이브러리의 차이점]
일단 DLL이라는 놈이 어떻게 만들어지는 지는 알게 됐습니다.
사실 정적 라이브러리나 DLL 모두 라이브러리라는 공통점이 있습니다.
정적 라이브러리는 정적인 특성을 지니고 DLL은 동적인 특성을 지닌다는 차이가 있습니다.
"거 이름 갖고 말장난 그만 치고 제대로 설명하세요 욕나오기 전에"
정확히 말을 안하니까 아무래도 뭘 기준으로 정적이고 뭘 기준으로 동적이다라는 말이 어려울 수 있습니다.
충분히 이해가 됩니다.
정확히는 "실행 가능한 프로그램에서 라이브러리를 가져다 쓰는 방법에 따른 차이점이 있다"라고 해야됩니다.
이제 그걸 설명해보도록 하겠습니다.
1) 정적 라이브러리의 특성
정적 라이브러리는 우리가 직접 만들어보기도 했고, 대충 어떤 놈인지는 감이 오실겁니다.
만약 우리가 만들었던 SwapStaticLib.lib라는 놈을 기준으로 3개의 프로그램이 이 라이브러리를 쓴다고 가정해봅시다.
그러면 프로그램이 생성되는 과정은 위의 그림과 같이 나타낼 수 있습니다.

컴파일과 링크 과정을 통해서 생성되는 실행파일을 잘 보시길 바랍니다.
실행파일에 라이브러리 SwapStaticLib.lib의 바이너리 코드를 포함하고 있습니다.
라이브러리 코드를 완전히 포함해서 실행파일(.exe)를 생성하는 형태의 링크를 '정적 링크(Static Link)'라고 합니다.
왜 정적이라고 했는지 이제 좀 감이 오실까요?
이미 실행파일에 하나로 묶여서 뜯어내려고 해도 움직이지 않는 정적인 상태가 되어서 그렇습니다.
그렇다면 정적 링크의 장점은 뭘까요?
바로 실행의 독립성입니다.
실행파일만 만들어진다면 이후에는 라이브러리가 있던 없던 실행 파일에 다 들어가있으니 어디서든 실행이 가능합니다.
물론 단점도 존재합니다.
바로 세 개의 실행파일은 해당 라이브러리의 크기만큼 메모리 공간을 더 차지하게 된다는 것입니다.
이건 단순히 프로그램이 실행될 때만 메모리 공간을 더 차지하는 것이 아닙니다.
하드디스크에 저장되어 있는 상태에서도 마찬가지입니다.

아직 DLL에 대한 특성을 이야기하지 않았습니다.
하지만 위의 그림을 통해서 비교를 해보면 정적 라이브러리의 단점을 명확하게 알 수 있습니다.
보다시피 하드 디스크에 총 세 개의 실행 파일이 저장되어 있는 상태입니다.
모든 실행 파일이 동일한 라이브러리를 포함하고 있기 때문에 메모리 공간을 그만큼 더 차지하고 있습니다.
반면에 DLL은?
세 개의 실행 파일이 동일한 라이브러리를 사용한다고 하면 이 라이브러리를 별도로 저장할 수 있겠죠.
그리고 공유할 수만 있다면 메모리를 절약하는 것도 가능합니다.
오른쪽에 보이는 그림이 바로 DLL을 사용했을 때 얻을 수 있는 장점입니다.
2) DLL의 특성
정적 라이브러리에 대한 이야기는 충분히 했으니 이제 본론인 DLL에 대해서 이야기를 해보겠습니다.
위에서는 DLL의 장점 중 하나를 보여드렸는데, 이게 DLL을 대표하는 특성은 아닙니다.
말 그대로 장점 중 하나인거죠.
실제로 DLL은 메인 메모리에서 진가를 발휘하게 됩니다.
위의 그림에서 정적 라이브러리를 기반으로 한 AAA와 DLL을 기반으로 한 AAA를 실행했다고 가정해봅시다.

일단 AAA가 실행 중이고, 총 다섯 개의 페이지 (2, 3, 5, 8, 9)가 메인 메모리에 올라와 있는 상황입니다.
왼쪽에 있는 그림은 정적 라이브러리를 기반으로 한 AAA.exe의 상황입니다.
정적 라이브러리는 실행파일의 일부로 빌드가 되었기 때문에 정적 라이브러리도 포함해서 가상 메모리를 구성합니다.
이제 반대편을 보면 DLL을 기반으로 한 AAA.exe의 상황입니다.
DLL은 실행파일의 일부로 포함이 되지 않고 독립적으로 저장되는 라이브러리입니다.
그런데 AAA가 이 라이브러리를 사용하기 때문에 AAA의 가상 메모리 영역에 할당되는 것은 똑같습니다.
DLL을 사용하고자 하는 프로세스는 자신의 가장 메모리 주소에 DLL을 매핑(Mapping)시킬 필요가 있습니다.
그래야 DLL이 제공하는 함수를 호출할 수 있기 때문입니다.
즉, 두 개의 파일(실행파일과 DLL파일)이 하나의 가상 메모리를 구성하는 형태가 되는 것이죠.
지금 이 그림만 놓고 봤을 때는 사실 차이를 잘 느끼기가 어려울 수 있습니다.
그렇다면 BBB.exe까지 실행시켜본다면 어떻게 될까요?
여기서 동일한 DLL을 참조하는 프로세스를 하나 더 실행 시키면 메모리를 활용하는 방식에서 차이점을 알 수 있습니다.
BBB.exe를 실행시킨다고 가정해보겠습니다.
그러면 AAA.exe는 잠시 멈추고 BBB.exe가 실행됩니다.
여기서 프로세스 BBB도 8, 9번 페이지를 필요로 한다는 가정을 놓고 가겠습니다.
그러면 다음과 같은 상태를 보이게 됩니다.

보다시피 왼쪽에는 동일한 정적 라이브러리를 포함하는 두 개의 프로세스를 보여주고 있습니다.
AAA가 실행을 멈추고 BBB가 실행을 하기 때문에 메인 메모리에는 BBB의 페이지가 올라갑니다.
페이지 2, 3, 5는 동일하지만 이건 BBB의 페이지입니다.
그리고 정적 라이브러리 영역에 있는 페이지 8과 9도 BBB의 페이지입니다.
다시 말해서 컨텍스트 스위칭이 일어난 것입니다.
동일한 페이지를 사용한다고 하더라도 이걸 반환하고 새롭게 로딩을 하게 됩니다.
이번에는 DLL 기반의 프로세스 AAA와 BBB의 상황을 보도록 하겠습니다.
우선 메인 메모리에는 똑같이 2, 3, 5, 8 ,9가 올라가 있는 상황입니다.
페이지 2, 3, 5는 프로세스 BBB의 페이지가 됩니다.
그런데! 여기서 8과 9는 BBB의 페이지가 아닌 AAA가 사용하던 페이지가 그대로 유지가 됩니다.
다시 말하면 별도의 파일을 사용하는 DLL을 AAA와 BBB가 공유하게 되는 것입니다.
이게 DLL의 가장 돋보이는 특성이자 장점이라고 볼 수 있습니다.
둘 이상의 프로세스가 동일한 DLL을 공유하게 되는 경우에, 메인 메모리에서 페이지 단위로 공유가 이뤄집니다.
만약에 8, 9가 아닌 DLL의 다른 페이지를 프로세스 BBB에서 필요로 했다면 위와 같은 그림은 아니게 됩니다.
메인 메모리에 올라와 있지 않은 페이지를 필요로 하기 때문에 새로 올려야 하는 것이죠.
그래서 DLL은 페이지 단위로 공유가 이뤄진다고 하는 것입니다.
[DLL 제작 1: 암묵적 연결(Implicit Linking)]
이제 DLL을 한 번 만들어봅시다.
앞에서 정적 라이브러리 형태로 작성했던 SwapStaticLib.lib를 DLL로 만들어보는 걸로 하겠습니다.
크게 두 가지의 방법을 통해서 DLL을 사용하는 것이 가능합니다.
그 중 첫 번째 방법인 암묵적 연결(Implicit Linking) 방법을 사용하려고 합니다.
두 번째 방법인 명시적 연결(Explicit Linking)은 조금 이따 다루도록 하겠습니다.
상대적으로 명시적 연결이 좀 더 쉽기 때문입니다.
DLL 제작에 필요한 파일은 아까의 정적 라이브러리때와 마찬가지로 딱 둘만 있으면 됩니다.
[SwapDll.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
__declspec(dllimport)
void swap(int* v1, int* v2);
[SwapDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
__declspec(dllexport)
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
뭔가 크게 바뀐 부분은 없는데 __declspec(dllimport), __declspec(dllexport)라는 놈이 있습니다.
이건 외부에 제공할(Export), 그리고 외부로부터 제공 받을(Import) 함수 및 변수 선언에 사용이 됩니다.
C/C++ 문법에서는 볼 수 없었던 선언이라 생소할겁니다.
당연히 볼 수 없었던 선언이 맞습니다.
MS에서 제공하는 추가적인 선언문이거든요.
__declspec(dllimport)는 DLL로부터 제공받을(Import) 함수를 선언할 때 사용합니다.
그래서 헤더 파일을 보면 swap 함수의 선언 앞에 이게 추가가 되어있죠.
이건 swap 함수를 DLL로부터 제공을 받겠다는 의미입니다.
다음은 __declspec(dllexport)라는 놈입니다.
얘는 반대로 DLL이 외부에 제공할(Export) 함수를 선언할 때 사용합니다.
그래서 Swap 함수의 선언과 정의 앞에 이 문장을 붙이고 있습니다.
이건 "이어서 등장하는 swap 함수를 외부에서 사용할 수 있는 형태의 DLL로 라이브러리화 하겠다" 라는 것입니다.
그래서 쉽게 말하면 전자는 받겠다는 뉘앙스고, 후자는 주겠다는 뉘앙스로 이해하시면 됩니다.
이제 DLL을 만들면 되는데, 정적 라이브러리 때와 마찬가지로 '동적 라이브러리'라고 대놓고 있습니다.
그걸 눌러서 만드시면 됩니다.
여기서 필요한 것은 SwapDll.cpp 하나만 있으면 됩니다.
일단 이걸 가지고 빌드를 하면 실행파일이 생성되는 위치에 두 개의 파일이 만들어질겁니다.
SwapDll.lib, SwapDll.dll
가만 보니 DLL만 만들어진게 아니라 lib 파일도 생성이 되었습니다.
그래서 DLL이랑 정적 라이브러리가 동시에 생성된 것인가 생각할 수도 있습니다.
그런데 여기서 생성된 lib파일은 정적 라이브러리를 생성할 때 만들어진 lib 파일과는 성격이 다릅니다.
이 lib 파일은 DLL이 제공하고자 하는 함수 정보(이름 정보)를 갖고 있습니다.
그래서 실제로는 다음과 같은 용도로 사용하게 됩니다.

여기서 DllTest.cpp는 DLL이 제공하는 swap 함수를 호출하는 코드로 구성되게 됩니다.
일단은 DllTest.cpp가 컴파일러에 의해서 컴파일이 되면 DllTest.obj 파일이 생성되게 됩니다.
이 과정에서 필요한 것은 __declspec(dllimport) void swap(int* v1, int* v2)라는 선언이 있는 헤더파일이 필요합니다.
호출을 하는 함수의 선언 정보만 있어도 컴파일러에서는 문제가 되질 않기 때문입니다.
그래서 앞서 만들었던 헤더 파일인 SwapDll.h가 여기서 사용이 됩니다.
다음은 링커에 의한 링크 과정을 거쳐야 합니다.
아까 정적 라이브러리 과정에서도 오류를 맛을 봐서 미리 짐작하고 있으실겁니다.
실행파일을 생성할 때에는 함수의 선언만 있는 것이 아니라 정의까지 존재해야 합니다.
그런데 DLL은 정적 라이브러리와 다르게 프로그램이 실행되는 시간에 참조하는 것이 목적입니다.
여기서 라이브러리를 같이 링크를 해버리면 정적 라이브러리와 다를 바가 없어지기 때문입니다.
이와 같은 문제를 해결하기 위해 DLL이 만들어질 때에 lib 파일도 같이 만들어지는 것입니다.
DLL을 생성할 때 같이 만들어지는 lib 파일은 링커가 실행파일을 만드는데 필요한 정보가 담겨있습니다.
그래서 SwapDll.lib 파일은 링커한테 다음과 같은 정보를 주게 됩니다.
"DllTest.obj에서 호출하는 swap 함수는 SwapDll.dll이라는 파일에 있으니까 거기서 찾아다 써!"
링커는 이 정보를 토대로 실행파일을 만들어내게 됩니다.
그래서 프로그램의 실행시간에 해당 DLL을 참조하는 실행 파일을 만들게 되는 것입니다.
여기서 한가지 확인 차 질문을 해보려고 합니다.
lib 파일은 언제, 그리고 dll 파일이 필요한 순간은 언제일까요?
"lib 파일은 링크 과정에서, dll 파일은 실행할 때에 필요합니다"
라고 답하셨다면 지금까지의 과정을 잘 이해하신 것이 됩니다.
이제 거의 다 왔습니다.
이제 아래의 코드를 작성해서 프로젝트를 생성하면 됩니다.
[DllTest.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: DllTest.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#pragma comment(lib, "SwapDll.lib")
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "SwapDll.h"
int _tmain(int argc, TCHAR* argv[])
{
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
swap(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
return 0;
}
여기서는 헤더파일을 포함하고 있는데, 헤더 파일 내부에서 DLL에 swap 함수가 있다는 선언을 추가해뒀습니다.
물론 헤더파일을 추가하지 않는 방법도 있습니다.
헤더파일에서 썼었던 __declspec(dllimport) 부분을 추가하면 됩니다.
__declspec(dllimport) void swap(int* v1, int* v2);
그리고 정적 라이브러리에서 참조할 lib 파일 이름을 명시했던 것처럼 여기서도 lib 파일 이름을 명시해줘야 합니다.
위의 예제 코드에서는 pragma comment를 통해서 이 부분을 명시를 하였습니다.
마지막으로 정적 라이브러리 예제 실행 시 했던 방법대로 lib 파일을 옮기거나 include 파일의 경로를 설정하면 됩니다.
그러면 프로젝트 빌드는 문제 없이 됩니다.
이제 남은 것은 프로그램을 실행하는 것인데, 기본적으로 DLL은 표준 검색 경로를 통해서 찾습니다.
표준 검색 경로에 대해서는 이전에도 소개했던 바가 있기 때문에 생략하도록 하겠습니다.
물론 가장 쉬운 방법은 실행파일이 있는 디렉터리에 갖다 놓으면 되기 때문에 거기에 dll 파일을 옮기면 됩니다.
옮기고 난 뒤에 실행을 하면, 문제 없이 결과가 나오는 것을 보실 수 있을 겁니다.
[DLL과 extern 선언]
이 내용은 정리할까 말까 하다가 정리를 하기로 했습니다.
아무래도 알아두면 좋을 내용이기도 하고, C++을 공부하면서도 배웠던 내용이기도 합니다.
개인적으로는 난독화와 관련있는 개념이라고 생각하긴 했는데, 뭔가 맞는거 같으면서도 아닌 것 같습니다.
아직 배움이 부족한 탓인지 난독화라는 개념까지 접목해야할 개념으로 판별하기에는 좀 어렵네요.
검색을 해보니까 '함수 오버로딩'과 관련된 개념이라고 보는 것이 더 정확한 것 같습니다.
이 부분에 대해서는 추후에 다룰 수 있다면 제대로 다뤄보도록 하겠습니다.
일단은 복잡하게 생각하지 않고 책에 있는 개념대로 내용을 정리하려고 합니다.
우선 Windows 시스템 프로그래밍이기 때문에 다들 Visual Studio를 쓰고 있으실 거라고 생각합니다.
Visual Studio에서는 확장자가 .c냐 .cpp냐에 따라서 컴파일러가 달라집니다.
쉽게 말하면 .c면 C 컴파일러를 이용해서 바이너리 코드를 작성하게 됩니다.
.cpp면 당연히 C++ 컴파일러를 이용해서 바이너리 코드를 작성하게 되겠죠.
우리가 작성했던 DLL도 그렇고, DLL을 사용한 예제 코드도 확장자를 .cpp로 뒀으니 C++ 컴파일러를 사용했을 겁니다.
그런데 만약에 DLL을 .c로 만들게 되면 어떻게 될까요?
다시 말해서 라이브러리를 만들 때 사용한 컴파일러와 라이브러리를 이용한 예제 코드의 컴파일러가 다르다면?
이걸 확인하는 것은 그리 어렵지 않으니 바로 한 번 해보도록 합시다.
Case1) 기존에 만든 DLL은 그대로 사용하고, 라이브러리를 이용한 예제 코드의 확장자만 .c로 바꾸기
빌드를 해보면 컴파일까지는 문제없이 수행이 됩니다.
다만 링커쪽에서 에러가 발생할거에요.
LNK2019라는 에러가 나올겁니다.
왜 이런 에러가 나오는지에 대한 이유를 설명하자면, C++의 컴파일러와 연관이 있습니다.
C++의 컴파일러는 컴파일을 하는 과정에서 '네임 맹글링(Name Mangling)'이라는 작업을 하게 됩니다.
네임 맹글링이라는 것은 단어 뜻 그대로 '이름을 뭉개버린다'는 것인데, 정의되어 있는 함수의 이름을 바꿔버립니다.
컴파일러가 정해놓은 규칙에 따라서요.

네임 맹글링이 된 예시를 한 번 가져와봤습니다.
앞에서 링커 에러가 났을 때 봤던 에러 중 하나입니다.
가만 보시면 '?swap@@YAXPEAH0@Z'라는 식으로 swap 함수의 이름이 이상하게 짬뽕이 되어 있습니다.
이게 네임 맹글링이 일어난 사례입니다.
이 네임 맹글링이라는 놈은 'C++의 함수 오버로딩'과 관련이 있는 내용입니다.
여기에 대해서는 추후에 다룬다고 했으니 더 언급하진 않겠습니다.
여하튼, 명확한 사실은 컴파일이 완료된 바이너리 코드에서는 이름이 바뀐다는 것입니다.
그리고 이름을 바꿔버리는 규칙은 컴파일러에 따라서 다릅니다.
요약하자면 C++ 컴파일러라고 다 똑같은 C++ 컴파일러가 아니라는 말입니다.
이를테면 MS사의 C++ 컴파일러로 라이브러리를 컴파일했습니다.
이 라이브러리는 gcc의 C++ 컴파일러로 컴파일된 프로그램에서는 사용할 수 없습니다.
네임 맹글링 규칙이 다르기 때문입니다.
여기서 발생한 LNK2019 에러 역시 위와 같은 맥락에서 발생한 것입니다.
라이브러리를 사용하는 예제 코드는 C 컴파일러로 컴파일했기 때문에 네임 맹글링이 발생하지 않았습니다.
그런데 DLL은 C++ 컴파일러로 컴파일을 했으니 네임 맹글링이 발생하게 됩니다.
DLL에 있는 swap 함수는 swap이라는 이름이 아닌 다른 이름이 되었으니까요.
그래서 링커는 다음과 같이 불평을 합니다.
"swap이라는 이름의 함수를 찾아보니까 안보이는데?"
동일한 컴파일러 기반에서 빌드를 하면 동일한 네임 맹글링 규칙때문에 이와 같은 문제는 일어나지 않습니다.
그런데! DLL은 라이브러리입니다.
어디서건 이 라이브러리를 필요로 하면 사용이 가능해야 된다는 것이죠.
그래서 C로 구현된 프로그램에서도 C++로 빌드된 라이브러리를 쓰고 싶을 수도 있습니다.
이 때 사용할 수 있는 방법은 C++ 컴파일러가 네임 맹글링을 하지 못하도록 막으면 됩니다.
extern "C"
네임 맹글링을 막기 위해 사용되는 C++에서 사용되는 키워드입니다.
함수의 정의 앞에 이 문장이 들어가면 네임 맹글링이 발생하지 않습니다.
이 문장을 추가해서 DLL을 만들어보시기 바랍니다.
[SwapDll.cpp - extern "C"를 추가한 경우]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: SwapDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 동적 라이브러리 기본 예제
* 이전 버전 내용:
*/
#include "pch.h"
extern "C" __declspec(dllexport)
void swap(int* v1, int* v2)
{
int temp = *v1;
*v1 = *v2;
*v2 = temp;
}
이렇게 하면 LNK2019 에러가 발생하지 않는 것을 알 수 있습니다.
Case2) SwapDll.h는 그대로 놔두고 SwapDll.cpp에 extern "C" 키워드를 추가
그리고 SwapDllTest.c는 그대로 둔다.
아마 위의 내용을 그대로 잘 따라오셨다면 큰 문제 없이 빌드도 되고 실행되는 것을 확인할 수 있을겁니다.
Case3) Case2까지 진행한 상황에서 SwapDllTest.c를 다시 SwapDllTest.cpp로 바꿔서 빌드
이번에도 에러가 발생합니다.
아까 봤던 LNK2019라는 놈이 나올거에요.

보다시피 이름이 _imp_?swap@@YAXPEAH0@Z와 같은 식으로 뭉개져서 나오는 것을 보실 수 있을겁니다.
네임 맹글링은 함수 정의만이 아니라 함수 호출문의 이름에서도 발생하게 되는 것입니다.
지금까지의 상황을 요약하면 다음과 같습니다.
"함수 정의 부분에서는 네임 맹글링을 막았지만 헤더 파일에 있는 함수 호출문은 네임 맹글링이 발생한 상황"
그래서 이 부분을 해결하기 위해서는 헤더파일인 SwapDll.h의 함수 선언부에도 extern "C"를 붙여주면 해결이 됩니다.
Case4) Case3에서 사용한 DLL은 그대로 사용, DllTest.cpp에서 포함하는 헤더인 SwapDll.h에 extern "C"를 추가
이제 라이브러리로 제공하는 swap 함수도, 이 라이브러리를 사용하는 swap 호출 문장도 이름 변경이 발생하지 않습니다.
즉, 컴파일러에 상관없이 swap 함수를 호출하는 것이 가능합니다.
꽤 내용이 길어졌습니다.
Case 1~4를 통해서 아래의 결론을 내리기 위해 번거로운 과정을 거쳤습니다.
결론은 다음과 같습니다.
1) DLL을 통해서 제공하고자 하는 함수에는 다음의 문장을 붙인다.
extern "C" __declspec(dllimport)
2) DLL에 존재하는 함수를 호출하고자 하는 경우에는 다음 문장을 붙인다.
extern "C" __declspec(dllexport)
[DLL 제작 2: 명시적 연결(Explicit Linking)]
'암묵적 연결'을 통한 DLL 사용 방법을 알았으니 또 다른 방법인 '명시적 연결'에 의한 DLL 사용 방법을 소개할까 합니다.
암묵적/묵시적 연결 방법은 DLL을 참조하는 방식에 따라서 구분하게 됩니다.
소스코드 내부에 DLL 연결 코드가 명시적으로 존재한다면 '명시적 연결' 방법입니다.
반대로 소스코드 내에 DLL 연결에 대한 명시적인 코드가 없다면 '암묵적 연결' 방법이 되는 것이죠.
명시적 연결 방법에서는 lib 파일을 필요로 하지 않습니다.
필요한 DLL을 명시적으로 지정하기 때문입니다.
그래서 암묵적 연결에서 사용했던 lib 파일에 관련된 모든 작업들이 명시적 연결에서는 불필요한 작업이 됩니다.

위의 그림은 명시적 연결 방법을 통해 DLL을 참조하는 과정입니다.
여기서 사용되는 함수는 총 3개입니다.
LoadLibrary 함수는 필요한 DLL을 프로세스 가상 메모리에 매핑할 때 사용합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya
LoadLibraryA 함수(libloaderapi.h) - Win32 apps
지정된 모듈을 호출 프로세스의 주소 공간에 로드합니다. (LoadLibraryA)
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryw
LoadLibraryW 함수(libloaderapi.h) - Win32 apps
지정된 모듈을 호출 프로세스의 주소 공간에 로드합니다. (LoadLibraryW)
learn.microsoft.com
GetProcAddress 함수는 가상 메모리에 매핑된 DLL에서 필요한 함수의 포인터를 획득할 때 사용합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress
GetProcAddress 함수(libloaderapi.h) - Win32 apps
지정된 DLL(동적 연결 라이브러리)에서 내보낸 함수 또는 변수의 주소를 검색합니다.
learn.microsoft.com
마지막으로 FreeLibrary 함수를 호출해서 DLL을 반환합니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/libloaderapi/nf-libloaderapi-freelibrary
FreeLibrary 함수(libloaderapi.h) - Win32 apps
로드된 DLL(동적 연결 라이브러리) 모듈을 해제하고 필요한 경우 참조 수를 줄입니다.
learn.microsoft.com
여기서 DLL의 반환은 가상 메모리에서의 반환을 의미하며 물리 메모리에서의 반환을 뜻하는 것은 아닙니다.
DLL을 참조하는 프로세스가 하나도 존재하지 않을 때 물리 메모리에서 DLL이 반환된다는 것을 알아두시길 바랍니다.
추가로 레퍼런스 카운트(참조 횟수, Reference Count)에 대한 이야기를 좀 하고 나서 예제 코드를 보이도록 하겠습니다.
커널 오브젝트의 Usage Count와 유사하게 프로세스 내부적으로 DLL의 레퍼런스 카운트를 계산하게 됩니다.
LoadLibrary 함수를 호출하면 지정된 DLL의 레퍼런스 카운트는 1씩 증가하고, FreeLibrary 함수에 의해 1 감소합니다.
그리고 레퍼런스 카운트가 0이 될 때 해당 DLL은 프로세스의 가상 메모리에서 해제됩니다.
위에서도 굵은 글씨로 강조한 부분이지만 물리 메모리가 아닌 가상 메모리라는 점을 알아두시길 바랍니다.
그리고 DLL의 레퍼런스 카운트는 프로세스별로 독립적입니다.
다시 말해서 공유하는 프로세스의 횟수를 나타내는 정보가 아니라는 점입니다.
이처럼 레퍼런스 카운트를 두는 이유는 커널 오브젝트의 소멸 시점을 정하는 것과 유사합니다.
프로그램 실행 중에 DLL을 가상 메모리에 할당/해제 시점을 정할 수 있는 것이죠.
암묵적 연결 방법을 사용하게 되면 메모리 할당과 해제의 시점을 정할 수 없습니다.
이제 예제 코드를 통해서 위 세 함수를 사용한 명시적 연결 방법을 보겠습니다.
[ExplicitDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: ExplicitDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 명시적 DLL 활용 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "SwapDll.h"
typedef void (*SWAP_FUNC)(int*, int*);
int _tmain(int argc, TCHAR* argv[])
{
HMODULE hinstLib;
SWAP_FUNC SwapFunction;
hinstLib = LoadLibrary(TEXT("SwapDll"));
if (hinstLib == NULL)
{
_tprintf(TEXT("LoadLibrary Failed\n"));
return -1;
}
SwapFunction = (SWAP_FUNC)GetProcAddress(hinstLib, "swap");
if (SwapFunction == NULL)
{
_tprintf(TEXT("GetProcAddress failed\n"));
return -1;
}
int a = 10;
int b = 20;
_tprintf(TEXT("Before: %d, %d \n"), a, b);
SwapFunction(&a, &b);
_tprintf(TEXT("After: %d, %d\n"), a, b);
BOOL isSuccess = FreeLibrary(hinstLib);
if (isSuccess == NULL)
{
_tprintf(TEXT("FreeLibrary failed\n"));
return -1;
}
return 0;
}
이제 명시적 연결방법을 사용할 때 얻을 수 있는 이점에 대해서 크게 세 가지 정도를 놓고 볼 수 있습니다.
1) DLL이 필요한 시점에서 로딩하고, 불필요해지면 반환하기 때문에 메모리 절약 가능
이는 앞서 설명했던 레퍼런스 카운트를 기반으로 얻을 수 있는 장점입니다.
2) 프로그램 실행 중에 DLL의 교체 및 선택 가능
예를 들면 A.dll과 B.dll이 있다고 가정해보겠습니다.
두 개의 DLL 안에 정의되어 있는 함수 C는 이름은 동일하지만 기능이 다릅니다.
그래서 사용자의 입력 또는 선택에 따라서 A 또는 B.dll 중 하나를 선택해서 메모리에 로딩하는 것이 가능합니다.
3) 암묵적 연결 방식과 비교하면 명시적 연결 방식은 실행에 걸리는 시간이 짧고, DLL 로딩 시간 분산이 가능
암묵적 연결 방식은 프로그램 실행 전에 필요한 모든 DLL을 메모리에 로딩하게 됩니다.
그래서 실행까지 걸리는 시간이 길 수 있습니다.
반면에 명시적 연결 방식은 필요한 순간에 하나씩 DLL을 로딩하기 때문에 실행까지 걸리는 시간이 짧습니다.
그리고 DLL 로딩에 걸리는 시간을 분산하는 것도 가능합니다.
이처럼 세 가지의 장점이 있음에도 암묵적 연결 방법이 좀 더 선호된다고 합니다.
그 이유는 코드에서만 봐도 아시겠지만 암묵적 연결 방법은 코드가 굉장히 간결합니다.
그리고 사용하기도 훨씬 쉽죠.
함수를 셋이나 가져다 쓸 필요도 없으니까요.
그래서 성능적인 측면을 최대한 고려해야 하는 상황이라면 명시적 연결 방법을 쓸 수 있습니다.
하지만 그런 상황이 아니라면 보편적으로는 암묵적 연결 방법이 선호된다고 볼 수 있습니다.
[한 번 이상 로드될 수 있는 DLL]
이게 제목만 봐서는 무슨 말인지 잘 이해가 안될 수도 있는 개념입니다.
여기서 DLL의 특성에 대해서 소개를 했었는데, 이걸 말로 풀어보면 다음과 같이 설명할 수도 있습니다.
"DLL은 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 메모리에 존재한다."
여기서 말하는 메모리가 도대체 어떤 메모리를 지칭하는 것인지 좀 난감합니다.
우리가 알고 있는 메모리만 해도 가상 메모리, 메인 메모리, 물리 메모리 등이 있는데 말이죠.
일단 케이스 별로 좀 나눠서 이해를 해봅시다.

이전에 DLL의 특성을 설명하면서 봤던 그림을 기준으로 이야기를 해보겠습니다.
1) 메모리 == 가상 메모리 (틀린 표현)
만약 위에서 말하는 메모리가 가상 메모리라고 하면 잘못된 표현이 됩니다.
가상 메모리에 올라갔다는 말은 메모리의 주소에 DLL이 매핑이 되었다는 것을 의미합니다.
그래서 프로세스 AAA의 가상 메모리 주소에 매핑된 DLL은 프로세스 AAA가 소멸되면 같이 소멸됩니다.
"프로세스 BBB가 아직 실행 중이라면 맞는 표현 아닌가요?"
유감스럽게도 프로세스 AAA와 BBB의 가상 메모리 공간은 엄연히 다른 공간입니다.
그래서 가상 메모리의 관점으로 생각해본다면 이것은 DLL의 특징도, 장점도 될 수 없습니다.
2) 메모리 == 메인 메모리 (RAM, 물리 메모리(RAM+HDD)) (맞는 표현)
이제 메모리가 메인 메모리를 뜻한다고 하면 이 말은 맞는 표현일까요?
맞습니다.
물론 페이지 단위의 교체가 발생해서 메인 메모리에 있는 페이지가 교체가 될 수도 있긴 합니다.
페이지 교체 알고리즘에 의해서 빈번하게 발생하기도 하고요.
그렇지만 이 말이 왜 맞는 표현인지를 지금부터 설명을 하려고 합니다.
일단 위에 있던 DLL의 특성에 대해서 좀 명확하게 다시 정의를 하고 가겠습니다.
"DLL은 물리 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 물리 메모리에 존재한다."
저자분도 했던 이야기지만 이게 좀 많이 추상적인 개념인지라 말이 좀 어렵습니다.
천천히 하나하나 쪼개어가면서 이 말이 무슨 뜻인지 이해를 해봅시다.
(단계 1)
AAA.exe가 실행된다.
그런데 이 실행파일은 Best.dll이라는 라이브러리를 필요로 한다.
그래서 Best.dll은 가상 메모리 영역에 매핑되면서 메인(물리) 메모리에 올라가게 된다.
(단계 2)
Best.dll이라는 라이브러리를 필요로 하는 BBB.exe도 실행된다.
그런데 이번에는 메인(물리) 메모리에 Best.dll을 올리지 않는다.
이전 단계에서 이미 메인(물리) 메모리에 올렸기 때문이다.
올라간 메모리를 그대로 참조할 수 있도록 BBB 프로세스의 가상 메모리 영역에 매핑을 한다.

지금까지의 과정을 그림으로 표현하면 위와 같습니다.
그리고 이 그림에서 보여주는 메커니즘을 Memory Mapping이라고 합니다.
DLL은 이 메커니즘을 기반으로 완성이 됩니다.
여기서 주목해야 할 부분은 메커니즘 말고도 Best.dll이 할당된 가상 메모리 주소입니다.
보다시피 AAA와 BBB, 모든 프로세스가 동일한 주소에 할당을 했습니다.
프로세스에 동일한 주소를 할당하는 것을 OS에서 지원을 하고 있으며, 이로 인해서 DLL 공유가 가능한 것입니다.
다시 말해서 두 프로세스가 동일한 DLL을 동일한 가상 주소에 매핑했기 때문에 페이지 단위로 공유가 가능해집니다.
(단계 3)

AAA 프로세스가 종료되었다.
하지만 AAA.exe를 실행했을 때 메모리에 올라갔던 Best.dll은 남아있는 상태이다.
프로세스 BBB의 실행을 위해서다.
(단계 4)
마지막으로 프로세스 BBB도 종료되었다.
이제 Best.dll을 참조하는 프로세스는 하나도 존재하지 않으므로 Best.dll도 반환된다.
다시 말해서 Best.dll에 할당된 물리 메모리를 반환한다.
이 4단계를 통해서 알 수 있는 사실은 앞에서 말했던 것과 같습니다.
"DLL은 물리 메모리에 한 번 올라가면, 이 DLL을 공유하는 프로세스가 모두 종료될 때까지 물리 메모리에 존재한다."
다시 말해서 한 번 로드되면 RAM에 남아있을 확률은 굉장히 높게 됩니다.
물론 사용의 빈도가 낮아지게 되면 우리가 알고 있는 HDD의 swap 파일로 저장될 수도 있습니다.
그래서 '메인 메모리'라고 한정하지 않고 '물리 메모리(RAM + HDD)'라고 한 것입니다.
앞에서 소개한 케이스는 한 번 만 로드될 수 있는 DLL에 대한 경우를 설명한 것입니다.
이제 마지막으로 한 번 이상 로드 될 수 있는 DLL에 대해서 설명하고 마무리를 지으려고 합니다.
여러분도 아시다시피 처음에 DLL을 빌드하면 할당되어야 할 가상 메모리 주소가 링커(Linker)에 의해 결정됩니다.
만약 Best.dll을 0x10000000번지에 매핑하기로 결정했다 하면?
Best.dll을 사용하는 모든 프로세스는 0x10000000 번지에 Best.dll의 주소를 매핑시킬겁니다.
그런데 이런 경우는 어떻게 될까요?

보다시피 프로세스 AAA와 BBB가 각각 동일한 주소에 Best.dll과 Nice.dll을 가상 메모리에 매핑한 상태입니다.
여기서 프로세스 BBB가 Best.dll을 필요로 하게 되는 상황이 됐습니다.
그래서 Best.dll을 올리려고 하는데!
프로세스 AAA가 매핑해놓은 주소에 Nice.dll이 떡하니 자리를 잡고 있습니다.
이미 자리를 잡고 있는데 방을 빼라고 할 수는 없지 않습니까.

별 수가 없으니 프로세스 BBB는 어쩔 수 없이 다른 주소의 메모리 영역에 Best.dll을 올리게 됩니다.
이와 같은 상황이 발생하면 두 개의 Best.dll이 물리 메모리에 올라가게 됩니다.
다시 말해서 DLL은 한 번만 로드되는 것이 아니라 한 번 이상 로드될 수도 있음을 기억해두시길 바랍니다.
[헤더파일을 몇 개까지 만들어야 할까]
이제 DLL에 대한 대략적인 설명은 끝났습니다.
이제 DLL을 위한 헤더파일을 정의하는 방법을 다뤄볼까 합니다.
DLL을 잘 이해하는 것과 헤더파일을 깔끔하게 설계하는 것은 다른 차원의 이야기니까요.
[필요한 헤더파일의 개수는 최소한 세 개]
C++ 컴파일러로 빌드된 DLL이 있다고 가정해봅시다.
이 DLL에는 C, C++에서도 사용할 수 있게 만들었습니다.
그러면 여기서 필요한 헤더파일의 몇 개가 될까요?
아마 DLL을 만들면서 느끼셨을지 모르겠지만, 프로그램을 만드는 것과 큰 차이는 없다고 느끼셨을 겁니다.
그래서 정적 라이브러리 때도 그랬고, DLL때도 마찬가지로 헤더파일이 필요했죠.
swap이라는 함수로 엄청 간단한 예를 들어서 하나 밖에 필요하지 않았지만요.
앞으로 만들게 될 DLL은 이렇게 단순하지 않습니다.
DLL을 구성하는 파일의 갯수도 많아질 겁니다.
그리고 둘 이상의 함수 호출이 하나의 DLL 인터페이스로 구성한다면 헤더파일이 분명히 필요해질겁니다.
우리가 사용하는 ANSI 표준 함수를 사용하기 위한 stdio.h만 보면 바로 납득이 가실겁니다.
이제 필요한 헤더파일의 갯수를 생각해보겠습니다.
가장 먼저 DLL을 암묵적 연결 방식을 통해 사용한다고 하면 헤더파일이 필요합니다.
여기서 DLL을 빌드할 때 필요로 하는 함수 선언과, DLL을 참조할 때 필요로 하는 함수 선언이 다를 수 있습니다.
그래서 하나는 import 선언, 하나는 export 선언을 해야하죠.
여기서만 해도 헤더 파일은 두 개가 필요합니다.
이제 DLL을 사용하는 사용자의 관점에서 보겠습니다.
C++ 컴파일러를 쓸지, C 컴파일러를 쓸지 우리는 모릅니다.
그래서 C++ 컴파일러를 쓴다고 하면?
extern "C" 선언이 들어가야 네임 맹글링이 발생하지 않습니다.
반대로 C 컴파일러를 쓴다고 하면?
extern "C" 선언이 들어가면 안됩니다.
이건 C++에서 사용되는 키워드라서 C 컴파일러에서는 알아먹을 수가 없거든요.
그래서 C 컴파일러용 헤더 파일 따로, C++ 컴파일러용 헤더 파일을 따로 만들어야 됩니다.
이렇게 생각하고 DLL을 만들어서 배포하게 되면 최소 3개의 헤더 파일이 필요하다는 결론이 나오게 됩니다.
[하나의 헤더파일로 모두 지원하기]
"근데 꼭 그렇게 해야 하나요?"
사실 위에서 든 예시는 '아무런 대책도 없이 DLL을 개발하고 배포할 경우'에 대해서 예를 든 것입니다.
다시 말하면 DLL을 만들어서 배포하는데 3개의 헤더파일까지 갈 필요가 없다는 것입니다.
헤더파일이 많으면 많아질수록 관리하기 힘든 것도 문제지만, 문제가 발생할 여지도 큽니다.
어떤거 하나를 바꾸면 다른 헤더파일들도 바꿔야되는 경우들도 있으니까요.
그래서 가능하다면 DLL도 하나의 헤더파일로 모든 상황에 적합하게 설계하는 것이 좋습니다.
DLL의 헤더 파일과 DLL의 소스 코드, 그리고 DLL을 활용한 예제 코드를 보면서 확인해보겠습니다.
[Calculator.h]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Calculator.h
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#ifndef __CALCULATOR_H__
#define __CALCULATOR_H__
#ifdef __cplusplus
extern "C"
{
#endif
#ifdef _COMPLING_DLL_CALCULATOR
#define LIBSPEC __declspec(dllexport)
#else
#define LIBSPEC __declspec(dllimport)
#endif
LIBSPEC int Add(int a, int b);
LIBSPEC int Min(int a, int b);
LIBSPEC double Div(double a, double b);
LIBSPEC double Mul(double a, double b);
#ifdef __cplusplus
}
#endif
#endif
이번 주제의 핵심이 되는 헤더파일입니다.
보다시피 조건부 컴파일 매크로를 통해서 헤더파일이 중복 포함되지 않도록 한 부분을 보실 수 있습니다.
여기서 첫 번째 포인트는 __cplusplus를 쓴 부분입니다.
Visual Studio의 C++ 컴파일러로 컴파일할 때 기본적으로 정의하는 매크로입니다.
그래서 해당 매크로의 정의 유무를 통해서 C++ 컴파일러가 사용되는지 C컴파일러가 사용되는지를 가릴 수 있습니다.
그 다음으로 주목할 포인트는 export와 import를 매크로 선언 여부에 따라서 가르는 부분입니다.
_COMPLING_DLL_CALCULATOR라는 매크로의 선언 유무를 통해 export와 import의 유무를 정할 수 있게 됩니다.
위와 같이 헤더파일을 정의하면 우리가 앞에서 최소 세 개의 헤더파일이 필요하다고 했던 것이 하나로 줄어들었습니다.
DLL을 만드는 데 있어서 헤더파일을 설계하는 것도 중요하다는 것을 기억해두시길 바랍니다.
이제 마지막으로 DLL을 만들기 위한 코드와, DLL을 참조하는 프로그램의 소스코드를 보겠습니다.
[Calculator.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: Calculator.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#include "pch.h"
#define _COMPLING_DLL_CALCULATOR
#include "Calculator.h"
LIBSPEC int Add(int a, int b)
{
return a + b;
}
LIBSPEC int Min(int a, int b)
{
return a - b;
}
LIBSPEC double Div(double a, double b)
{
return a / b;
}
LIBSPEC double Mul(double a, double b)
{
return a * b;
}
[UseDll.cpp]
/*
* Windows System Programming - Dynamic Linking Library (DLL)
* 파일명: UseDll.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: DLL 활용 예제 - 계산기
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include "Calculator.h"
#pragma comment(lib, "Calculator.lib")
int _tmain(int argc, TCHAR* argv[])
{
_tprintf(TEXT("result Add: %d \n"), Add(5, 3));
_tprintf(TEXT("result Min: %d \n"), Min(5, 3));
_tprintf(TEXT("result Mul: %e \n"), Mul(5.0, 3.0));
_tprintf(TEXT("result Div: %e \n"), Div(5.0, 3.0));
return 0;
}
단순한 사칙 연산 계산기를 프로그램으로 만든 것이기 때문에 별 다른 설명은 더 필요 없을 것이라고 생각합니다.
마지막으로 확인차 UseDll.cpp를 UseDll.c로 바꿔서 빌드를 해보시기만 하면 됩니다.
문제 없이 실행되는 것을 확인하실 수 있을겁니다.
[글을 마치며]
오늘부로 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'에 대한 정리를 모두 마치게 되었습니다.
한동안은 Windows 시스템 프로그래밍과 관련된 글은 더 올라오지 않을 겁니다.
처음으로 시스템 프로그래밍이라는 분야를 접하면서 모르는 것이 정말 많았다는 것을 새삼 느끼게 됩니다.
아직도 배울 것이 많고, 부족함이 많다는 것을 매일마다 느끼고 있습니다.
그래도 이 책을 끝까지 보면서 제프리 리처 아저씨가 쓴 'Windows via C/C++'을 마주할 용기가 조금은 생겼습니다.
같이 보려고 구매를 했었는데 그건 보다가 엄두가 안나서 내려놨던 책이거든요.
나중에는 그 책을 읽고 공부했던 내용을 여기에 정리할 것 같습니다.
물론 바로 뒤이어서 쓰는 것은 아니고요.
다음에 정리할 주제는 'TCP/IP 소켓 프로그래밍'입니다.
여기서도 '뇌를 자극하는 윈도우즈 시스템 프로그래밍' 저자분이 쓴 책을 가지고 공부를 합니다.
그 책도 지금 쓰레드 부분까지 읽었는데, 여기서 공부했던 것이 많은 도움이 됐습니다.
네트워크도 결국 시스템 프로그래밍을 모르면 안된다는 것을 많이 느끼게 되더라고요.
마지막이라고 잡소리가 꽤 길었습니다.
결론만 말하면 앞으로 TCP/IP 소켓 프로그래밍, 정확히 말하면 네트워크 프로그래밍에 대한 내용을 정리할 예정입니다.
그리고 네트워크 프로그래밍에 대해 공부한 내용을 정리한 이후에는 뭘 다룰지 고민 중입니다.
시스템 프로그래밍을 다시 다룰 것인지, 자료구조와 알고리즘을 다시 다룰 것인지...
공부해야 할 것이 많다보니 그건 그때 가서 다시 정리해야겠습니다.
많이 부족한 내용이지만 봐주신 분들에게 감사드리며, TCP/IP 소켓 프로그래밍 공부 글로 다시 찾아뵙겠습니다.