‘배리어프리’ 가치를 위해, 웹 기술의 한계를 넘어
node-gyp 빌드 파이프라인, N-API 네이티브 애드온1. 웹 기술의 한계
인턴시절 진행한
상용 배리어프리 키오스크 프로젝트의 핵심 요구사항 중 하나는 '개인화된 음성 안내'였습니다. 공공장소에서 스피커로 음성 안내가 나가는 대신, 사용자가 헤드폰을 연결하면 헤드폰으로만 안내를 제공하는 '배리어프리' 기능이었습니다.하지만 이 기능은 명확한 기술적 한계에 부딪혔습니다.
보안 및 개인정보 보호 정책으로 인해, 웹기술만으로는 브라우저나 Electron의 렌더러 프로세스(웹뷰) 환경에서 사용자의 하드웨어(헤드폰 연결 여부) 상태를 직접 감지할 수 없었습니다.
이미 일렉트론과 리액트를 활용해 프로젝트를 거의 진행한 상황에서, 해당 기능을 위해 스택을 변경할 수 없었고, "웹이라서 안된다"는 말은, 프로젝트의 핵심 가치를 포기해야 한다는 의미였습니다.
이러한 문제 상황에서 저는"할 수 없다"는 결론 대신, "어떻게 하면 할 수 있을까?"라는 질문을 던지며,
'요구사항을 구현하는 개발자'가 아니라 '문제를 해결하는 엔지니어'로서 접근했습니다.
그리고 웹 기술의 경계를 넘어 OS의 저수준 영역에 직접 접근해야 한다는 결론에 도달했습니다.
2. C++부터 Electron React까지의 아키텍처
먼저 Electron이 웹(UI)과 네이티브(OS) 접근성을 모두 가진다는 점에 주목했습니다.
이 "양면성"을 활용해, C++ 코드를 Node.js가 호출할 수 있는 네이티브 애드온(.node 파일)으로 빌드하는 아키텍처를 설계했습니다.
- [Native - C++]: Windows의 Core Audio API와 COM 인터페이스를 직접 호출하여 오디오 장치를 열거하는 C++ 코드를 작성합니다.
- [Binding - N-API]: 이 C++ 코드를 N-API 프레임워크를 사용해 Node.js와 바인딩합니다.
N-API를 선택한 이유는 명확했습니다. N-API는 Application Binary Interface)안정성을 보장합니다. 즉, 한 번 컴파일된 네이티브 애드온(
.node 파일)이 향후 Node.js 버전이 업그레이드되어도 재컴파일 없이 계속 작동하는 높은 호환성을 제공합니다.- [Build - node-gyp]:
node-gyp로 C++ 코드를 컴파일하여audio_detector.node파일을 생성합니다.
- [Backend - Electron Main]: Electron의 메인 프로세스(Node.js)에서
.node파일을require()로 로드하고, IPC 통신을 준비합니다.
- [Frontend - React]: 렌더러 프로세스(React)가 IPC로 메인 프로세스에 감지를 요청하고, 결과를 받아 UI에 반영합니다.
3. C++과 COM으로 OS에 접근하기
해당 기능 구현에 오랜 시간을 쏟을 수 없는 상황에서, 웹 개발자에게 익숙치 않은 C++ 포인터와 COM 객체는 큰 기술적 장벽이었습니다.
이러한 장벽을 넘기 위해 생성형 AI를 전략적으로 활용하는 접근법을 택했습니다.
먼저 Windows 공식 문서를 통해 아키텍처를 정의한 뒤, AI를 활용하여 C++ 보일러플레이트 코드 작성을 가속화하는 방식으로
audio_detector.h와 audio_detector.cpp를 신속하게 완성했습니다.
IMMDeviceEnumerator::EnumAudioEndpoints(mmdeviceapi.h) - Win32 apps
EnumAudioEndpoints 메서드는 지정된 조건을 충족하는 오디오 엔드포인트 디바이스 컬렉션을 생성합니다.
- 설계: 단순한 함수가 아닌, 리소스 관리가 용이한 C++ 클래스(
AudioDetector)를Napi::ObjectWrap으로 감쌌습니다.
- 안정성: 핵심인 "안정적인 리소스 관리"를 위해,
CoInitialize(COM 초기화)와IMMDeviceEnumerator(장치 열거자) 생성을 생성자에서 처리하고,CoUninitialize와Release를 소멸자에서 처리하도록 명확히 지시하여 메모리 누수를 방지했습니다.
audio_detector.h (헤더 파일)#pragma once #include <napi.h> #include <windows.h> #include <mmdeviceapi.h> #include <endpointvolume.h> #include <functiondiscoverykeys_devpkey.h> class AudioDetector : public Napi::ObjectWrap<AudioDetector> { public: // N-API 모듈 초기화를 위한 정적 메서드 static Napi::Object Init(Napi::Env env, Napi::Object exports); // N-API 생성자 AudioDetector(const Napi::CallbackInfo& info); // N-API 소멸자 ~AudioDetector(); private: // N-API 생성자 참조 (JavaScript 클래스 구동용) static Napi::FunctionReference constructor; // JavaScript에서 호출할 실제 C++ 메서드 Napi::Value IsAnalogHeadphoneConnected(const Napi::CallbackInfo& info); // Windows Core Audio API의 핵심 객체 포인터 IMMDeviceEnumerator* pEnumerator; };
실제 장치 탐색 로직은
IsAnalogHeadphoneConnected 메서드에 구현했습니다. EnumAudioEndpoints로 활성 오디오 장치를 열거하고, 각 장치의 IPropertyStore에서 PKEY_Device_FriendlyName (장치 이름)을 가져왔습니다.특히,
wcsstr 함수를 사용해 장치 이름에 'Headphone', 'Jack' 뿐만 아니라, OS 언어 설정에 따른 한국어("헤드폰", "이어폰")까지 검사하여 탐지 정확도를 높였습니다.
audio_detector.cpp (구현 파일)#include "audio_detector.h" #include <string> #include <windows.h> // 클래스의 정적 멤버 변수 선언 Napi::FunctionReference AudioDetector::constructor; // 유니코드(wchar_t) 문자열을 UTF-8 문자열로 변환하는 헬퍼 함수 std::string WideCharToMultiByteHelper(const wchar_t* wstr) { int size_needed = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, NULL, 0, NULL, NULL); std::string strTo(size_needed, 0); WideCharToMultiByte(CP_UTF8, 0, wstr, -1, &strTo[0], size_needed, NULL, NULL); return strTo; } // Node.js 모듈 초기화 함수 (main.cpp에서 호출됨) Napi::Object AudioDetector::Init(Napi::Env env, Napi::Object exports) { Napi::HandleScope scope(env); // JavaScript 클래스 정의 Napi::Function func = DefineClass(env, "AudioDetector", { InstanceMethod("isAnalogHeadphoneConnected", &AudioDetector::IsAnalogHeadphoneConnected), }); // 생성자 참조 저장 constructor = Napi::Persistent(func); constructor.SuppressDestruct(); // 모듈에 클래스 등록 exports.Set("AudioDetector", func); return exports; } // AudioDetector 클래스 생성자 (new AudioDetector() 호출 시) AudioDetector::AudioDetector(const Napi::CallbackInfo& info) : Napi::ObjectWrap<AudioDetector>(info), pEnumerator(nullptr) { // 1. COM 라이브러리 초기화 CoInitialize(nullptr); // 2. MMDeviceEnumerator COM 객체 생성 HRESULT hr = CoCreateInstance( __uuidof(MMDeviceEnumerator), // COM 객체 ID nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), // 인터페이스 ID (void**)&pEnumerator // 멤버 변수에 포인터 저장 ); if (FAILED(hr)) { Napi::Error::New(info.Env(), "Failed to create device enumerator").ThrowAsJavaScriptException(); } } // 소멸자: C++ 객체 소멸 시 리소스 정리 AudioDetector::~AudioDetector() { if (pEnumerator) { pEnumerator->Release(); // COM 객체 참조 해제 } CoUninitialize(); // COM 라이브러리 해제 } // 아날로그 헤드폰 연결 상태 확인 메서드 Napi::Value AudioDetector::IsAnalogHeadphoneConnected(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); // Enumerator 유효성 검사 if (!pEnumerator) { return Napi::Boolean::New(env, false); } // 1. 활성 오디오 렌더링 장치 목록 가져오기 IMMDeviceCollection* pDevices = nullptr; HRESULT hr = pEnumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pDevices); if (FAILED(hr)) { return Napi::Boolean::New(env, false); } UINT count; pDevices->GetCount(&count); bool found = false; // 2. 모든 장치를 순회 for (UINT i = 0; i < count && !found; i++) { IMMDevice* pDevice = nullptr; hr = pDevices->Item(i, &pDevice); if (SUCCEEDED(hr)) { // 3. 장치의 속성 스토어 열기 IPropertyStore* pProps = nullptr; hr = pDevice->OpenPropertyStore(STGM_READ, &pProps); if (SUCCEEDED(hr)) { PROPVARIANT varName; PropVariantInit(&varName); // 4. 장치의 "친숙한 이름" (FriendlyName) 속성 가져오기 hr = pProps->GetValue(PKEY_Device_FriendlyName, &varName); if (SUCCEEDED(hr)) { wchar_t* desc = varName.pwszVal; // 유니코드(wchar_t) 문자열 // 5. 키워드 검사 (영문 + 한글) bool hasJack = wcsstr(desc, L"Jack") != nullptr; bool hasHeadphone = (wcsstr(desc, L"Headphone") != nullptr) || (wcsstr(desc, L"\xD5E4\xB4DC\xD3F0") != nullptr); // "헤드폰" bool hasEarphone = (wcsstr(desc, L"Earphone") != nullptr) || (wcsstr(desc, L"\xC774\xC5B4\xD3F0") != nullptr); // "이어폰" if (hasJack || hasHeadphone || hasEarphone) { found = true; } PropVariantClear(&varName); } pProps->Release(); } pDevice->Release(); } } pDevices->Release(); return Napi::Boolean::New(env, found); }
4. node-gyp로 빌드 파이프라인 정복하기
C++ 코드를 Node.js가
require() 할 수 있는 .node 파일로 컴파일하는 것은 node-gyp의 역할로,binding.gyp 파일에 빌드 명세서를 작성했습니다.main.cpp (N-API 모듈 진입점)#include <napi.h> #include "audio_detector.h" // 우리가 작성한 C++ 클래스 헤더 // 모듈 초기화 함수 Napi::Object InitAll(Napi::Env env, Napi::Object exports) { // AudioDetector 클래스를 초기화하고 모듈에 등록 return AudioDetector::Init(env, exports); } // N-API 모듈 등록 NODE_API_MODULE(audio_detector, InitAll)
binding.gyp (빌드 명세서){ "targets": [{ "target_name": "audio_detector", "cflags!": [ "-fno-exceptions" ], "cflags_cc!": [ "-fno-exceptions" ], "sources": [ "./app/module/audio_detector/audio_detector.cpp", "./app/module/audio_detector/main.cpp" ], "include_dirs": [ "<!@(node -p \"require('node-addon-api').include\")", "./app/module/audio_detector" ], "libraries": [ // Windows Core Audio API (COM) 사용에 필수적인 라이브러리 링크 "-lole32", "-luuid" ], "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS", "WIN32_LEAN_AND_MEAN" ], // Windows 컴파일러(MSVS) 설정 "conditions": [ ['OS=="win"', { "defines": [ "_HAS_EXCEPTIONS=1" ], "msvs_settings": { "VCCLCompilerTool": { // C++ 예외 처리 활성화 "ExceptionHandling": 1 } } }] ] }] }
여기서 핵심은
libraries였습니다. CoCreateInstance 같은 COM 함수를 사용하기 위해선 ole32.lib와 uuid.lib를 링커에 알려줘야 했고, 이 설정을 통해 빌드에 성공할 수 있었습니다. 5. 배포 electron-builder와 .node 파일 연동
빌드 후, 잘 동작하는것을 확인했고, 이
.node 파일을 개발 환경과 프로덕션(배포) 환경 모두에서 동작하게 만들어야 했습니다.1.
package.json 빌드 스크립트
electron-builder가 앱을 패키징하기 전에 C++ 애드온을 먼저 빌드(rebuild-audio)하고, TypeScript가 참조할 수 있는
dist 폴더로 복사(copy-audio)하는 파이프라인을 스크립트로 구축했습니다.2.
electron-builder 배포 설정
electron-builder는 기본적으로 .node 파일을 패키징에 포함하지 않으므로, extraResources 옵션을 사용해, 컴파일된 dist/audio_detector.node 파일을 최종 설치 파일(.exe)에 포함되도록 명시했습니다.package.json (스크립트 및 빌드 설정 발췌){ "name": "hotel-kiosk", "version": "0.0.15", "main": "dist/app/Main.js", "build": { "appId": "com.hotel-kiosk.app", "files": ["build/**/*", "dist/**/*", "node_modules/**/*", "package.json"], "extraResources": [ // ... (다른 리소스들) ... { // 1. 빌드된 .node 파일을 "from": "dist/audio_detector.node", // 2. 설치된 앱의 루트(resources 폴더)에 복사 "to": "." } // ... (다른 리소스들) ... ], "directories": { "output": "./output" }, // ... (기타 설정) ... }, "scripts": { "start": "concurrently \"yarn react-start\" \"wait-on http://localhost:3000 && yarn electron-start\"", // 1. C++ 네이티브 애드온 빌드 "rebuild-audio": "node-gyp rebuild", // 2. 빌드된 .node 파일을 TS 출력 폴더(dist)로 복사 "copy-audio": "copyfiles -f build/Release/audio_detector.node dist/", // 3. 전체 빌드 파이프라인 (C++ 빌드 -> 복사 -> TS 빌드 -> React 빌드 -> Electron 패키징) "build": "yarn rebuild-audio && yarn copy-audio && tsc && yarn react-build && electron-builder --publish always", "react-start": "craco start", "react-build": "craco build", "electron-start": "tsc && electron .", "deploy": "electron-builder --publish always" }, "devDependencies": { // ... "concurrently": "^8.2.2", "copyfiles": "^2.4.1", "electron": "^32.0.0", "electron-builder": "^24.13.3", "node-gyp": "^(버전)" // devDependencies에 node-gyp 필요 // ... } }
이를 통해, 개발 환경(
require('./dist/audio_detector.node')) 뿐만 아니라, 사용자가 설치한 프로덕션 환경(
require(path.join(process.resourcesPath, 'audio_detector.node')))에서도 네이티브 모듈이 안정적으로 동작하는 것을 보장할 수 있었습니다.단순 코드를 구현하는게 아닌, 엔지니어로서의 시각
- 핵심 가치 실현: 사용자가 헤드폰을 연결하는 즉시 개인 안내 모드로 자동 전환되어, '배리어프리'라는 프로젝트의 핵심 가치를 기술적으로 실현할 수 있었습니다
- 기술적 한계 돌파: 웹 개발자로서 C++, COM, N-API,
node-gyp빌드 시스템을 적용하며,
웹 기술만으로는 불가능했던 헤드폰 연결 감지 기능을 구현하고 문제 해결의 스펙트럼을 넓힐 수 있었습니다
- DevOps 경험: C++ 코드 작성뿐만 아니라, 컴파일, 빌드 자동화,
electron-builder를 통한 프로덕션 배포 파이프라인까지 End-to-End로 구축을 진행했습니다.
이 경험은, 제게 특정 기술 스택에 갇히지 않고 마주한 문제의 본질을 파악하고 해결하는 것의 중요성을 깨닫게 해 주었습니다. 또한, '웹의 한계'를 '종점'으로 받아들이는 대신, 이를 새로운 기술을 학습하고 적용할 기회로 삼는 계기가 되었습니다