소켓 프로그래밍이 궁금해서 계속해 보던 중 윈도우와 리눅스 두 진영에서 같은 코드로 컴파일할 수 없을지 문득 생각이 들어서 알아보다가 포스팅하게 됐습니다. 😎

typing

🔥 크로스 플랫폼

💡 크로스 플랫폼(Cross-Platform)은 하나의 소프트웨어, 코드, 프로그램이 윈도우, macOS, 리눅스, 안드로이드, iOS 등 서로 다른 여러 운영체제나 기기 환경에서 동시에 작동하는 것을 뜻합니다.

크로스 플랫폼 프레임워크·라이브러리 대표적인 것은 아래와 같습니다.

용도도구주 언어특징
모바일·웹·데스크톱 앱FlutterDart하나의 코드베이스로 여러 플랫폼 앱을 만들고, 자체 UI 렌더링 방식으로 화면 모양을 비교적 일관되게 유지해요.
모바일 앱React NativeJavaScript/TypeScriptReact 기술을 활용해 iOS·Android 앱을 만들어요. 네이티브 UI 요소를 사용하는 방식이라 플랫폼별 모양과 동작이 반영돼요.
모바일·데스크톱 앱.NET MAUIC#공통 C# 코드로 Android, iOS, macOS, Windows 앱을 만드는 .NET 프레임워크예요. .NET과 Visual Studio를 쓰는 개발자에게 자연스러워요.
데스크톱 앱ElectronJavaScript/TypeScript, HTML, CSS웹 기술로 Windows·macOS·Linux용 데스크톱 앱을 만들어요. 웹 개발 경험을 활용하기 쉽지만, 앱에 브라우저 런타임이 포함돼 앱 크기와 메모리 사용량이 커질 수 있어요.
앱의 공통 로직 공유Kotlin MultiplatformKotlin화면까지 전부 공통화하기보다, 여러 플랫폼에서 쓸 로직을 공유하고 각 플랫폼의 UI는 따로 만들 수도 있어요.
모바일 앱IonicJavaScript/TypeScript, HTML, CSS웹 기술과 웹 UI를 바탕으로 모바일 앱을 만들고, 플러그인을 통해 카메라 같은 기기 기능을 사용할 수 있어요.
모바일 앱—레거시Xamarin.FormsC#과거 C#으로 여러 플랫폼 앱을 만들 때 쓰였어요. 지금은 후속 기술인 .NET MAUI를 우선 살펴보는 편이 좋습니다.

😵 나의 사례

우선 비동기 처리를 위해 사용해야 할 API가 달랐습니다. 윈도우는 IOCP를, 리눅스는 epoll을 사용하죠.

사용자 입장에서는 단순히 커널에 소켓 또는 파일디스크립터만 등록해 놓고 데이터가 들어오는 시점만 알면 될 텐데 운영체제별로 다르게 짜야 하고, 방식도 달라서 아마 곤란할 것입니다. 저도 그랬고…

그래서 처음 시도했던 방법은 바로 “전처리기"로 나누는 것이었습니다.

실패 #1

1
2
3
4
5
6
7
bool init() {
#if defined(_WIN32)
    // 윈도우일 땐 어쩌고 저쩌고...
#elif defined(__linux__)
    // 리눅스일 땐 어쩌고 저쩌고...
#endif
}

함수 안에서 위와 같이 전처리기로 운영체제를 구별했습니다. 짧은 코드면 모르겠는데 조금만 길어져도 엄청 복잡하게 보이고, 매개변수나 반환형이 다른 경우는 함수 안에서 구별하는 것이 불가능했습니다. 😖

그래서 이번엔 전처리기를 먼저 두고 함수 자체를 두 개로 썼죠.

실패 #2

1
2
3
4
5
6
7
8
9
#if defined(_WIN32)
bool add(SOCKET sock) {
    // 윈도우일 땐 어쩌고 저쩌고...
}
#elif defined(__linux__)
bool add(int sock) {
    // 리눅스일 땐 어쩌고 저쩌고...
}
#endif

무언가 크게 잘못된 걸 깨닫고, 지금까지 짰던 코드를 모두 버리고 다른 방법을 찾아 봤습니다. 😭

개선 #1

운영체제별 타입이 다른 것을 일단 통일시키기로 합니다. 아래 코드와 같이 말이죠.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#if defined(_WIN32)
    #include <winsock2.h>
    #define MY_SOCK_INVALID_SOCKET INVALID_SOCKET

    typedef SOCKET Socket;

#elif defined(__linux__)
    #define MY_SOCK_INVALID_SOCKET (-1)

    typedef int Socket;

#endif

class Server {
private:
    Socket _listenSock;

    ...
};

이렇게 타입을 통일시키면 사용자 입장에서 하나의 공통되고 통일된 헤더 파일을 보고 기능을 사용할 수 있게 됩니다.

그리고 나중에 새로운 플랫폼이 추가 되더라도 전처리기에 해당하는 플랫폼을 추가하면 똑같은 인터페이스를 유지할 수 있게 됩니다.

개선 #2

MFC를 다룰 때도 그랬었고, 여태까지 .h 와 .cpp 는 같은 파일 이름으로 한 쌍을 이루어서 사용했었고, 그렇게 해야만 되는 줄 알았는데 하나의 헤더 파일에 여러 소스 코드 파일로 구현해도 상관없었습니다. 😲

1
2
3
4
5
6
7
8
/include/
└── comm/
    └── server.h
/src/
└── comm/
    ├── server.cpp
    ├── server_windows.cpp
    └── server_linux.cpp

헤더 파일에는 통일을 시켜주고, 소스 파일은 플랫폼별로 나누어서 해당 플랫폼의 소스만 컴파일되도록 하면 전처리기 없이 더욱 더 깔끔한 분리가 가능합니다. 👍

CMake는 프로젝트를 여러 플랫폼과 빌드 도구에서 빌드할 수 있도록 설정하는 빌드 시스템 생성기이며 CMakeLists.txt에 빌드 규칙을 적으면, CMake가 현재 환경에 맞춰 Makefile, Ninja 파일, Visual Studio 프로젝트 파일 등을 만듭니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if(WIN32)
    target_link_libraries(test PRIVATE ws2_32)
    target_sources(socket PRIVATE
        src/comm/server_windows.cpp
    )
elseif(UNIX)
    target_sources(socket PRIVATE
        src/comm/server_linux.cpp
    )
endif()

🏁 정리

  1. 운영체제별 다른 데이터 타입을 전처리기와 typedef 로 통일시킨다.
  2. 구현부는 파일로 나누고 빌드 시스템 또는 빌드 툴을 이용해서 해당 플랫폼에 맞는 소스 파일을 컴파일·링킹 되도록 한다.
  3. 바퀴를 다시 만들지 말자. Boost 사용하자.
good