증명사진 키오스크 시스템

Unity 기반. 하드웨어 네이티브 통합, 셰이더 이미지 프로세싱, CI/CD 환경 구축.

2025.06 ~ 현재|1인 설계 · 구현 · 배포 · 운영
UnityC#C++P/InvokeHLSLOpenCV
1 / 6

개요

증명사진 키오스크 시스템

증명사진/배경사진 촬영-보정-결제-인쇄 키오스크. Unity 6 기반 Windows Standalone.

  • 개발 형태: 1인 설계 / 구현 / 배포 / 운영
  • 기간: 2025.06~ (진행 중)

배경

회사에서 운영 중이던 키오스크는 외주 업체가 Unity로 개발한 것이었다. 핵심 로직 — 카메라 SDK, 프린터 SDK, 이미지 프로세싱 — 은 C++ 네이티브 DLL로 구현되어 있었고, 소스코드는 제공되지 않았다. UI는 Unity, 코어 로직은 블랙박스 DLL이라는 구조.

C++로 코어를 만든 이유 자체는 합리적이었다. SDK가 C/C++이고, COM/STA 스레드 처리는 네이티브가 자연스럽다. 하지만 소스 없이 바이너리만 넘긴 구조는 유지보수를 포기한 것과 같았다.

기존 문제

  • 아키텍처 부재: 하나를 고치면 다른 곳이 깨지는 구조. 변경 영향 범위 예측 불가
  • 로깅 부재: 현장 장애 시 원인 파악에 3~7일 소요. 복구 가능 여부조차 답할 수 없었음
  • CS 과중: 결제 후 사진 미출력 반복. 개발자가 직접 현장을 봐야 하는 구조
  • 컬러 파이프라인 부재: 소프트웨어 단의 이미지 보정이 전무. 현장 직원이 카메라 세팅과 프린터 드라이버를 직접 조정하는 방식이라 촬영 품질이 기기·환경마다 달랐고, 일관된 출력 퀄리티를 보장할 수 없었음

해결

키오스크 소프트웨어 전체를 새로 설계하고 구현. 블랙박스 DLL에 의존하던 기존 구조를 걷어내고, 촬영부터 인쇄까지 모든 핵심 로직을 직접 작성. 13개 런타임 시스템, 6종 하드웨어 네이티브 SDK 통합, GPU 이미지 파이프라인, 빌드·배포 인프라까지 1인 풀스택으로 구축.

Systems Facade. 13개 런타임 시스템을 단일 접근점으로 통합. SystemManager가 초기화 순서와 생명주기를 제어하고, null-checked 프로퍼티로 안전한 접근을 보장.

하드웨어 통합. 6종 하드웨어의 네이티브 SDK를 C++ Wrapper DLL, P/Invoke, 동적 DLL 로더(Reflection.Emit)로 단일 프로세스에 통합. SDK마다 다른 통신 방식과 스레딩 모델을 각 구현체에서 흡수하고, 상위 코드에는 공통 인터페이스만 노출.

GPU 이미지 보정. CommandBuffer 기반 4-pass 셰이더 파이프라인 직접 구현. Color Correction, Bilateral Blur, Unsharp Mask를 GPU에서 처리. 게임 엔진의 렌더링 인프라를 사진 보정 전용으로 전환.

비즈니스 임팩트

  • CS 건수 60% 감소
  • 버그 수정 사이클: 기존 3~5일 소요 → 당일 배포
  • 하드웨어 교체 시 구현체 하나 추가 + 등록 한 줄. 나머지 코드 변경 없음

기술 스택

영역기술
시스템 아키텍처C# / Unity 6, UniTask
네이티브 통합C++, P/Invoke, COM Interop, RS-232
이미지 처리HLSL / ShaderLab
얼굴 감지Python, OpenCV (YuNet ONNX)
인프라Supabase, Addressables

문서 구성

문서내용
01-아키텍처-설계Systems Facade, 디바이스 추상화, 3단계 초기화, Config 자동 생성
02-이미지-처리GPU 셰이더 파이프라인, 인쇄 합성 엔진, Texture 메모리 관리, 얼굴 감지
03-네이티브-통합C++ Wrapper DLL, 동적 DLL 로더, COM/STA 스레딩, 결제 단말 통합
04-배포-인프라에셋/코드 분리 배포, 온프레미스 Supabase, 빌드 파이프라인, 오류 보고
05-프로덕션-안정성인쇄 검증 큐, MCU 타이밍
2 / 6

아키텍처 설계

13개 런타임 시스템, 3종 프린터, Canon DSLR, 2종 결제 단말기를 단일 Unity 프로세스에서 통합 운용하는 키오스크의 구조 설계 기록.

Systems Facade: 단일 접근점

문제

13개 런타임 시스템(Printer, Camera, Payment, Network, Statistics, UI, Audio 등)을 개별 참조하는 구조. 의존성이 분산되고 초기화 상태 확인이 누락되면서, 하나를 고치면 다른 곳이 깨지는 상황이 반복됐다. 변경의 영향 범위를 예측할 수 없으니 수정 자체를 꺼리게 되고, 버그 수정에 3~5일이 걸리는 악순환.

접근

static partial class Systems를 단일 접근점으로 설계. 모든 시스템 프로퍼티가 null 검사와 초기화 검사를 내장하므로, 호출 측에서 상태를 확인할 필요가 없다.

csharp
public static PrinterSystem Printer
{
    get
    {
        var printerSystem = SystemManager.Instance.PrinterSystem;
        if (printerSystem == null || !printerSystem.IsInitialized)
            return null;
        return printerSystem;
    }
}

13개 시스템 전체에 동일 패턴 적용. Systems.Printer, Systems.Camera처럼 접근하면 안전한 참조가 보장된다.

난관

모든 시스템에 같은 규칙을 적용할 수 없었다.

  • Network: 연결에 실패해도 시스템 객체 자체는 존재해야 한다. 초기화 검사 없이 항상 반환. 연결 상태는 별도 프로퍼티로 분리.
  • IOBoard: COM 포트 설정(IO_BOARD_PORT != COMPORT.NONE)을 먼저 확인해야 한다. 포트가 NONE이면 시스템 자체를 생성하지 않음.
  • Systems.Error(): 로깅, 네트워크 알람 전송, UI 에러 화면 전환을 동시에 처리해야 하는 복합 관심사. 단순한 프로퍼티 위임이 아니라 여러 시스템의 조율이 필요.
아키텍처의 실질적 가치

아키텍처의 가치는 미래 시점의 유지보수 편의가 아니라, 지금 당장 수정-검증 사이클을 줄여주는 데 있다. 버그의 영향 범위가 예측 가능해지면 수정 속도가 올라간다. 기존에 3~5일 걸리던 수정이 당일 배포로 단축된 이유는 코드 품질이 아니라, 영향 범위가 명확한 구조 덕분이다.

결과

  • 새 하드웨어 추가: 구현체 하나 작성 + Systems 프로퍼티 등록 한 줄. 나머지 코드 변경 없음
  • CS 건수 60% 감소. 수정-검증 속도 향상이 주 요인

디바이스 추상화: 공통 인터페이스와 벤더 특수성의 양립

문제

6종 하드웨어가 각각 다른 통신 방식, 스레딩 모델, 에러 패턴을 가진다. 프린터 3종의 SDK가 각각 다른 인쇄 방식을 요구하고, 카메라는 DSLR(COM/STA)과 WebCam(DirectShow)이 완전히 다른 캡처 경로를 사용하며, 결제 단말 2종은 TCP/IP와 RS-232로 통신 프로토콜 자체가 다르다. 모든 디바이스를 하나의 방식으로 통일하면 특정 벤더에서 조용히 실패하는 상황이 반복됐다.

접근

디바이스 카테고리별 abstract 기본 클래스를 정의하고, 벤더별 구현체에서 각 SDK의 특수성을 흡수하는 구조를 채택했다. 상위 코드에는 공통 인터페이스(Initialize, Print/Capture/Pay, GetStatus)만 노출하되, 각 구현체가 자신의 SDK 특성에 맞는 최적 경로를 선택한다.

flowchart TD
    subgraph Printer["abstract Printer"]
        P1["Printer A\nNative SDK 직접 전송"]
        P2["Printer B\nGDI+ Spooler"]
        P3["Printer C\nGDI+ + 90deg 회전"]
    end

    subgraph Camera["abstract Camera"]
        C1["DSLR Camera\nC++ Wrapper + COM/STA"]
        C2["WebCam\nDirectShow"]
    end

    subgraph CardReader["abstract CardReader"]
        R1["Reader A\nTCP/IP"]
        R2["Reader B\nRS-232"]
    end

    Printer --> I["공통 인터페이스\nInitialize / Action / GetStatus"]
    Camera --> I
    CardReader --> I

프린터를 예로 들면, SDK 특성에 따라 인쇄 경로를 분기한다:

프린터상태 모니터링인쇄 경로이미지 포맷판단 근거
A사Native SDKNative SDK 직접 전송RAW RGBSDK 인쇄 기능이 안정적
B사Native SDKWindows GDI+ SpoolerJPGSDK에 인쇄 함수 없음
C사Native SDKGDI+ Spooler + 90deg 회전JPGB사와 동일 + 하드웨어 회전 요구

카드리더도 동일한 패턴이다. 한쪽은 TCP/IP 소켓, 다른 쪽은 RS-232 시리얼 통신으로 프로토콜이 완전히 다르지만, 상위 코드에서는 Pay(), Cancel(), GetStatus()만 호출한다. 현장 설치 시 설정 파일의 벤더 지정만으로 전환이 가능하다.

난관

SDK의 print() 함수가 에러 없이 성공을 리턴하는데 실제로는 인쇄되지 않는 상황이 반복됐다. GDI+의 PrintDocument.Print()도 스풀러 제출만 보장할 뿐 실제 인쇄 성공 여부를 반환하지 않는다. 특정 카드리더는 OPERATION_BUSY 응답을 반환하는 경우가 있어 재시도 로직이 필요했다. 각 벤더 SDK의 실패 모드가 다르기 때문에, 추상화 계층에서 "성공"의 정의 자체를 벤더별로 다르게 해석해야 했다.

추상화와 특수성의 균형

추상화의 핵심은 공통 인터페이스를 만드는 것이 아니라, 각 구현체의 특수한 실패 모드를 얼마나 존중할 수 있느냐에 있다. "이 SDK는 어떤 전제 하에서 만들어졌는가"를 파악하는 것이 통합의 실질적 시작점. 깔끔한 통일을 강제하면 특정 벤더에서 조용히 실패하고, 그 실패는 현장에서 고객이 먼저 발견한다.

결과

  • 공통 인터페이스로 상위 코드의 벤더 의존성 제거
  • 새 디바이스 추가 시 구현체 하나 작성 + Config 등록. 나머지 코드 변경 없음
  • 프린터 3종, 카메라 2종, 카드리더 2종, IOBoard, 지폐기를 동일한 패턴으로 관리

3단계 초기화: Hard/Soft Dependency 분리

구조

시스템 초기화를 3단계로 분리. 데이터/UI 기반 → 시스템 객체 생성 → 하드웨어 실제 초기화 순서.

flowchart TD
    subgraph Phase1["Phase 1: InitializeData -- 데이터/UI 기반"]
        A1["Addressables"]
        A2["ResourceSystem"]
        A3["DataSystem"]
        A4["UISystem"]
        A5["InputSystem"]
    end

    subgraph Phase2["Phase 2: SetRuntime -- 시스템 객체 생성"]
        B1["Audio, Printer, Camera,\nUtility, Network, Statistics,\nPayment, IOBoard, Loading"]
    end

    subgraph Phase3["Phase 3: InitDevice -- 하드웨어 초기화"]
        C1["Network — 10%"]
        C2["Printer — 20%"]
        C3["Camera — 40%"]
        C4["Payment — 60%"]
        C5["IOBoard — 70% (재시도 3회)"]
        C6["Statistics — 80%"]
        C7["Utility — 90%"]
        C8["Audio — 100%"]
    end

    Phase1 --> Phase2 --> Phase3
    C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7 --> C8

핵심 설계 판단

Hard dependency와 Soft dependency의 구분.

  • Hard: Printer, Camera, Payment. 실패 시 에러 화면을 띄우고 즉시 중단. 이 장비 없이는 키오스크의 핵심 기능(촬영-인쇄-결제)이 불가.
  • Soft: Network. 실패해도 로컬 모드로 계속 동작. 통계 전송과 원격 모니터링은 연결 복구 시 재개.

IOBoard 재시도 로직. COM 포트 핸들이 완전히 해제되지 않은 상태에서 재생성하면 실패한다. Destroy 후 2프레임 대기(UniTask.Yield() x2), 이후 재생성. 최대 3회 재시도.

프로세스 단일 인스턴스 보장. Windows Named Mutex(Global\iFoto_HS_SingleInstance)로 중복 실행 차단. 키오스크 환경에서 프로세스가 두 개 뜨면 USB, COM 포트 핸들이 충돌하므로, 기존 인스턴스가 있으면 Application.Quit().

Shutdown은 초기화의 역순. Camera가 먼저 해제(USB 핸들을 빠르게 반환). 각 해제 단계는 독립적 try-catch로 감싸서, 한 시스템의 해제 실패가 나머지를 막지 않는 구조.


Config 자동 생성: 설정 변경이 코드 변경이 되지 않도록

문제

현장마다 프린터 모델, COM 포트, 파일 경로, 결제 단말 종류가 다르다. 설정 하나 바꿀 때마다 빌드를 새로 만들어 배포하는 것은 현실적이지 않다.

접근

Config partial class의 필드를 리플렉션으로 스캔, 24개 SCREAMING_SNAKE_CASE 정적 프로퍼티를 자동 생성하는 ConfigGenerator 구현.

flowchart LR
    A["Config.xml\n(현장별 설정)"] --> B["Config partial class\n(필드 정의)"]
    B --> C["ConfigGenerator\n(Reflection 스캔)"]
    C --> D["Systems.PRINTER_MODEL\nSystems.CAMERA_MODEL\n... 24개 프로퍼티"]

핵심 판단

  • 파일이 실제로 변경된 경우에만 기록. 불필요한 리컴파일 방지
  • 필드를 Config partial class에 추가하면 Systems 프로퍼티가 자동으로 생성됨. 별도 등록 코드 불필요
  • 빌드 없이 Config.xml만 교체하면 다른 장비 구성으로 전환 가능

결과

현장 배포 시 Config.xml 하나만 교체. 프린터 모델, 카메라 소스, 결제 단말 종류 전환에 재빌드 불필요.

3 / 6

이미지 처리 파이프라인

카메라 원본에서 인쇄 출력까지, GPU 셰이더 기반 컬러 보정과 오프스크린 합성을 거치는 이미지 처리 파이프라인. Unity의 렌더링 인프라를 비-게임 이미지 프로세싱에 전용하는 구조.

GPU 셰이더 기반 컬러 보정

문제

Unity는 게임 엔진이지 이미지 프로세싱 도구가 아니다. Texture2D.GetPixels/SetPixels는 CPU 기반으로, DSLR 원본(6000x4000) 처리에 수 초가 걸린다. GPU 경로는 RenderTexture + Shader 조합뿐인데, 이는 렌더링 파이프라인 전체를 이미지 처리 용도로 전용하는 것.

접근

CommandBuffer 기반 4-pass 멀티패스 파이프라인을 설계.

flowchart TD
    A[Source Texture]
    A --> B["Pass 0: Color Correction (입력→rt1)<br/>10개 파라미터"]
    B --> C["Pass 1: Bilateral Blur (rt1→rt2)<br/>Edge-preserving 피부 보정"]
    C --> D["Pass 2: Unsharp Mask H (rt2→rt3)<br/>수평 샤프닝"]
    D --> E["Pass 3: Unsharp Mask Final (rt3→rtFinal)<br/>최종 선명화"]
    E --> F[Output RenderTexture]

Pass 0의 Color Correction 파라미터 10개:

파라미터역할
Exposure노출 보정
Contrast명암 대비
Highlights밝은 영역 복원
Shadows어두운 영역 보정
Whites흰색 포인트
Blacks검정 포인트
Temp색온도 (Kelvin 매핑)
Tint녹색-자홍 축
Vibrance채도가 낮은 색만 선택적 강화
Saturation전체 채도
csharp
commandBuffer.Blit(input, rt1, material, 0);     // ColorCorrection
commandBuffer.Blit(rt1, rt2, material, 1);        // BilateralBlur
commandBuffer.Blit(rt2, rt3, material, 2);        // UnsharpMask H
commandBuffer.Blit(rt3, rtFinal, material, 3);    // UnsharpMask Final
Graphics.ExecuteCommandBuffer(commandBuffer);

난관

게임 포스트 프로세싱과 증명사진 보정의 요구사항은 근본적으로 다르다. 게임은 분위기를 만들면 되지만, 증명사진은 정확한 색 재현이 기준. 색온도를 Kelvin 스케일에 정확히 매핑하는 작업은 게임 셰이더 가이드에서 다루지 않는 영역이라 직접 수학 모델을 구현해야 했다.

HSL 셰이더로 색상 범위별(Reds, Oranges, Yellows 등) 개별 제어가 필요했다. 피부톤만 따로 보정하거나 배경색만 조절하는 식의 선택적 컬러 그레이딩은 포스트 프로세싱 스택에 없는 기능.

운용 환경에 따라 두 가지 품질 레벨이 필요:

용도모드적용 PassMaterial
카메라 프리뷰 (30fps LiveView)경량Pass 0만cameraFilterLight
촬영 후 프리뷰풀 필터Pass 0-3cameraFilterFull
프린터 출력풀 필터Pass 0-3printFilterMat

프리뷰에서는 블러/샤프닝을 생략해야 프레임 드롭 없이 실시간 표시가 가능하고, 인쇄에서는 전 패스를 적용해야 출력 품질을 확보할 수 있다. 카메라/프린터별 Material을 분리하여 디바이스 프로파일에 따라 파라미터 세트를 독립 관리.

해결

렌더링 인프라의 전용

게임 엔진의 렌더링 파이프라인을 이미지 처리 파이프라인으로 전용하는 구조 자체가 핵심 결정. RenderTexture + CommandBuffer 조합으로 GPU에서 이미지 처리를 수행하면 CPU 기반 대비 수십 배 빠르지만, API가 이미지 처리를 위해 설계된 것이 아닌 만큼 파라미터 체계부터 셰이더 수학까지 직접 설계가 필요.

결과

실시간 프리뷰(30fps LiveView에 필터 적용)와 고품질 인쇄 출력을 단일 파이프라인으로 처리. 품질 레벨 분기로 프리뷰 성능과 인쇄 품질을 동시에 확보.


인쇄용 이미지 합성: PhotoBuilder

문제

인화지 규격(6x4인치)에 맞는 이미지를 프린터 DPI 기준 픽셀로 생성해야 한다. 프린터마다 요구하는 이미지 포맷이 다르고, 레이아웃 규격(1컷, 2컷, 여권용 등)도 다양하다.

접근

오프스크린 Camera + RenderTexture로 렌더링 후 인코딩하는 구조.

flowchart LR
    A[보정된 사진] --> B[Prefab 레이아웃<br/>Canvas 기반]
    B --> C[오프스크린 Camera<br/>RenderTexture]
    C --> D{프린터별 인코더}
    D --> E[RAW RGB]
    D --> F[JPG]
    D --> G[JPG + 90° 회전]
  • Prefab 기반 레이아웃 관리: 새 규격 추가 시 프리팹만 만들면 코드 변경 불필요
  • Unity Canvas 시스템을 인쇄 레이아웃 도구로 전용: 에디터에서 시각적으로 레이아웃을 편집하고, 런타임에서 그대로 렌더링

난관: 인코더 분기

프린터 제조사마다 요구하는 이미지 포맷이 달라 인코더를 분기 처리해야 했다.

프린터인코더이유
SINFONIAToRaw() — RAW RGB네이티브 SDK가 RAW 바이트 배열 요구
DNPToJpg() — JPGGDI+ PrintDocument가 JPG 디코딩
CPM1ToJpg() + 90° 회전GDI+ 경유 + 하드웨어 특성 (가로 급지)

CPM1의 경우 프린터가 용지를 가로로 급지하기 때문에 소프트웨어 단에서 이미지를 90° 회전시킨 뒤 전달해야 한다. 이런 하드웨어 특성은 문서화되어 있지 않아 실물 테스트로 파악.

결과

레이아웃 추가가 코드 변경 없이 프리팹만으로 가능. 프린터별 인코딩, 리사이즈, 회전 처리를 PhotoBuilder가 일괄 담당하여 상위 로직에서는 프린터 종류를 의식할 필요 없음.


Texture 메모리 관리

문제

Texture2D의 이중 메모리 구조

Texture2D는 managed 래퍼와 네이티브 메모리가 분리된 구조. GC가 래퍼 객체를 수거해도 네이티브 메모리는 해제되지 않는다. 반드시 Destroy()를 명시적으로 호출해야 하며, 누락 시 인쇄당 42-62MB씩 누적.

항목계산메모리
DSLR 원본 1장6000 x 4000 x 4byte (RGBA32)~96MB
필터 RenderTexture 4장 + 원본 + 렌더 타겟96MB x 6수백 MB

4-5회 인쇄 후 OOM 크래시 발생. 인쇄당 42-62MB씩 누적되고 있었다.

난관

게임 텍스처와 사진 처리 텍스처는 수명 모델이 근본적으로 다르다.

게임 텍스처사진 처리 텍스처
해상도2K-4K6K (DSLR 원본)
수명씬 수명, 자동 언로드매 인쇄마다 로드→처리→인코딩→해제
관리Unity 에셋 시스템수동 생성/파괴
누수 위험낮음높음 (명시적 해제 누락 시 즉각 누적)

ApplyPrintFilter의 결과가 PhotoFilter 내부 RenderTexture의 참조를 그대로 반환하고 있어, 호출부에서 Destroy() 시 원본 RenderTexture가 파괴되는 소유권 충돌 문제도 존재.

해결

csharp
// 모든 Texture2D 생성 경로에 try/finally + Destroy() 패턴 적용
Texture2D temp = null;
try {
    temp = new Texture2D(width, height, TextureFormat.RGBA32, false);
    // 처리 로직
} finally {
    if (temp != null) Destroy(temp);
}
  • HashSet<Texture2D>로 이미 Destroy된 텍스처의 중복 Destroy 방지
  • ApplyPrintFilter 결과는 복사본을 생성하여 반환, source RenderTexture는 PhotoFilter가 소유권 유지 (소유권 분리)
  • 모든 Texture 생성 경로를 추적하여 명시적 수명 관리 적용

결과

인쇄당 42-62MB 누수 제거. 연속 인쇄 횟수 제한 해소. 장시간 연속 운영에서도 메모리 사용량이 안정적으로 유지.


얼굴 감지: 외부 마이크로서비스

문제

Unity에는 실용적인 얼굴 감지 라이브러리가 없다. 외부 솔루션이 필요한데, 여권사진 규격의 수치를 그대로 적용하면 문제가 생긴다.

여권사진 규격: 얼굴 크기 3.2~3.6cm (3.5cm 기준) 실제 인화지: 4.5cm 높이

3.5cm / 4.5cm = 77.8%이지만, 규격서의 3.2~3.6cm를 절대값으로 적용하면 0.4cm = 약 9%의 오차가 발생한다. 인화지 크기가 달라지면 파라미터 자체가 무의미해진다.

접근

Python + OpenCV YuNet을 별도 HTTP 서버(port 5000)로 분리하는 마이크로서비스 구조.

flowchart LR
    A[Unity 클라이언트] -->|HTTP POST<br/>이미지 바이트| B[Python 서버<br/>:5000]
    B --> C[OpenCV YuNet<br/>ONNX 추론]
    C --> D[얼굴 좌표 반환]
    D --> A

난관과 해결

cm → 비율 전환: face_size 파라미터를 0-20 정수값에서 face_ratio 0.50-0.80으로 재정의. 비율 기반이므로 인화지 크기에 무관하게 동작.

동시 요청 처리: ThreadPoolExecutor(max_workers=1)로 순차 추론 보장. 다중 스레드에서 ONNX 추론을 동시에 실행하면 GIL 충돌과 메모리 경합이 발생하므로, 큐잉으로 직렬화.

초기 추론 지연: ONNX 런타임의 JIT 컴파일로 첫 추론에 수 초가 소요된다. 서버 시작 시 더미 이미지로 워밍업을 수행하여 실제 요청 시 지연 제거.

결과

Unity 프로세스와 독립적으로 배포/업데이트 가능한 얼굴 감지 서비스 확보. 비율 기반 파라미터로 인화지 규격 변경에도 코드 수정 없이 대응.

4 / 6

네이티브 SDK 통합

Unity는 C# 레이어를 제공하지만, 하드웨어 제어는 결국 네이티브 경계를 넘어야 한다. 카메라 SDK, 결제 단말, 센서류 — 6종의 하드웨어 SDK를 Unity 런타임에서 안정적으로 구동하기 위해 C++ Wrapper DLL 작성과 동적 DLL 로더를 직접 구현했다.

카메라 SDK: C++ Wrapper DLL

문제

Canon EDSDK는 COM 기반으로 STA(Single-Threaded Apartment) 스레딩을 요구한다. C#에서 P/Invoke로 직접 호출하면 콜백을 수신할 수 없고, 메시지 펌프 부재로 USB 통신이 불안정해진다. LiveView 프레임 수신, 촬영 완료 이벤트 — 전부 COM 메시지에 의존하는데, Unity의 메인 루프는 이를 처리하지 않는다.

접근

C++ Wrapper DLL을 직접 작성. COM/STA 스레딩을 C++ 레벨에서 처리하고, Unity 측에는 단순한 C 함수 인터페이스만 노출.

flowchart LR
    A["Unity (C#)"] -->|P/Invoke| B["EDSWrapper.dll\n(C++)"]
    B --> C["Canon EDSDK\n(C/C++)"]
    C -->|USB| D["Camera"]

스레드 모델

스레드역할
Main ThreadLiveView 텍스처 업데이트 (Blit, LoadImage), 이벤트 처리, UI
ThreadPoolInitialize, Connect, TakePicture 등 블로킹 네이티브 호출
LiveView Loop33ms 간격 프레임 획득 — 메인 스레드에서 직접 실행

UniTask.RunOnThreadPool로 블로킹 네이티브 호출을 ThreadPool에 위임한다. LiveView 프레임은 메인 스레드에서 호출해야 Texture2D 접근 충돌이 발생하지 않는다. Unity의 그래픽 API는 메인 스레드 외 접근을 허용하지 않기 때문이다.

난관

메시지 펌프 기아 현상. 태스크 큐를 한 번에 비우면 COM 이벤트가 굶어서 USB 통신이 죽는다. 해결: 하나 처리 → 펌프 → 하나 처리 → 펌프. interleaved 방식으로 COM 메시지 루프에 숨 쉴 틈을 준다.

DLL 언로딩 불가. Unity 에디터는 DLL을 에디터 종료 전까지 풀지 않는다. 개발 중 DLL 수정 → 에디터 재시작이 반복되었고, 빌드 파이프라인에서는 문제없지만 개발 속도에 직접적 영향.

SEH 래핑. 네이티브 크래시 발생 시 SEH(Structured Exception Handling) 래핑 없이는 Unity 프로세스 전체가 사망한다. Wrapper DLL 내에서 SEH로 감싸 managed 측에 에러 코드를 반환하도록 처리.

카메라 37초 auto power-off. SDK 문서에 미기재된 동작이다. 카메라가 37초 무통신 시 자동 전원 차단되며, EdsEventType.Shutdown 이벤트를 실제로 받아보고서야 파악했다. 주기적 keepalive 호출로 대응.

에러 복구

예기치 않은 연결 해제 시 자동 재연결 흐름:

flowchart TD
    A["OnCameraDisconnected"] --> B{"_isIntentional\nDisconnect?"}
    B -->|false| C["AutoReconnect()"]
    C --> D["2000ms USB 안정화 대기"]
    D --> E["Reset()\nStopLiveView → Disconnect → Dispose"]
    E --> F["1000ms 대기"]
    F --> G["Initialize 재호출"]
    G --> H{"성공?"}
    H -->|Yes| I["재시도 카운트 복원"]
    H -->|No| J{"재시도 10회\n미만?"}
    J -->|Yes| D
    J -->|No| K["연결 실패"]

예외 계층:

flowchart TD
    A["EDSException\n(base)"] --> B["CameraNotFoundException"]
    A --> C["CameraBusyException\n(IsRecoverable = true)"]
    A --> D["TimeoutException"]
    A --> E["CaptureException"]
    A --> F["LiveViewException"]

IsRecoverable 플래그로 재시도 가능 여부를 호출 측에서 판단한다. CameraBusyException은 촬영 직후 연속 호출에서 빈번하게 발생하며, 짧은 대기 후 재시도하면 대부분 해소된다.

P/Invoke 경계에서의 이해

Unity는 "C#을 실행하는 환경"이 아니라 "네이티브 엔진 위에 C# 레이어를 올린 것"이다. P/Invoke를 쓰면 그 경계에 서게 되고, managed 메모리와 네이티브 메모리, Unity 메인 스레드와 OS 스레딩 모델 양쪽을 모두 이해해야 한다. GC가 네이티브 포인터를 수거하지 않고, 네이티브 크래시는 managed 예외를 던지지 않는다. 두 세계의 규칙이 다르다는 전제에서 출발해야 안정적인 통합이 가능.


동적 DLL 로더: SDK 클래스

문제

6종 하드웨어 SDK를 런타임에 동적 로드해야 한다. [DllImport]는 빌드 시점에 DLL 경로가 결정되므로, 현장마다 다른 SDK 구성에 대응할 수 없다. A 현장은 KSNET 결제 단말, B 현장은 UBCN — 같은 빌드로 양쪽을 커버해야 했다.

접근

kernel32.dll의 LoadLibraryEx + GetProcAddress와 System.Reflection.Emit으로 동적 delegate 타입을 런타임 생성.

flowchart TD
    A["SDK.Call(T)\n('DllName', 'FuncName', args)"] --> B["GetFunc()\nDLL 로드 + GetProcAddress"]
    B --> C["GetDelegateType(T)\nReflection.Emit 동적 delegate 생성"]
    C --> D["Marshal.GetDelegate\nForFunctionPointer()"]
    D --> E["DynamicInvoke(args)"]

4단계 캐싱

캐시 레벨키용도
dllsDLL 이름 → IntPtrDLL 핸들 재사용
functions(DLL, 함수) → IntPtr함수 포인터 재사용
delegates(DLL, 함수, Type, Convention) → Delegatedelegate 인스턴스 재사용
types시그니처 문자열 → TypeReflection.Emit 생성 타입 재사용

lock(lockObj)로 모든 캐시 접근 보호. LOAD_WITH_ALTERED_SEARCH_PATH 플래그로 DLL의 종속 DLL을 해당 DLL 위치 디렉토리에서 먼저 탐색하도록 설정했다. AppDomain.DomainUnload와 Application.quitting에서 FreeLibrary 호출 + 캐시 초기화로 리소스 누수 방지.

Attribute 기반 SDK 래퍼 패턴

csharp
[GetSDK("CxStat64")]
public abstract class CxStat_M
{
    [GetFunction]
    public abstract int PortInitialize(string portName);

    [GetFunction]
    public abstract int GetStatus(int portNum);
}
Attribute 기반 SDK 래퍼 패턴

abstract 클래스에 Attribute를 선언하면, 런타임에 SDK 클래스가 해당 DLL을 로드하고 함수 포인터를 자동 바인딩. 새 SDK 추가 시 Attribute 선언만으로 통합 가능하며, 호출 코드는 일반 메서드 호출과 동일한 형태 유지.

결과

SDK DLL을 빌드 시점이 아닌 런타임에 로드. 현장별 SDK 구성 차이를 재빌드 없이 대응. 4단계 캐싱으로 반복 호출 시 DLL 로드와 함수 탐색 오버헤드 제거.


결제 단말: 인코딩과 프로토콜

문제

카드리더 2종이 각각 다른 통신 방식과 인코딩을 사용한다.

벤더통신 방식인코딩프로토콜 특이사항
KSNETTCP/IPEUC-KR고정 길이 패킷
UBCNRS-232EUC-KROPERATION_BUSY 응답 반환 가능

Unity Mono 런타임에는 EUC-KR 인코딩이 포함되어 있지 않다. 한글 카드사명, 승인 메시지 등이 깨진다.

접근

abstract CardReader 기본 클래스를 정의하고 벤더별 구현체로 분리했다. 공통 인터페이스(결제 요청, 취소, 상태 조회)는 동일하게 유지하면서 통신 레이어만 교체.

해결

인코딩. PeterO.Encoding NuGet 패키지로 EUC-KR 디코딩 처리. Mono 런타임의 인코딩 제약을 우회.

타임아웃과 재시도. CancellationToken 기반 타임아웃 처리. UBCN 단말은 OPERATION_BUSY 응답을 반환하는 경우가 있어 최대 3회 재시도 로직 적용. 결제 중 사용자 취소 요청도 CancellationToken으로 전파.

통신 안정성. RS-232 직렬 통신은 바이트 단위 수신이므로, 패킷 경계를 직접 판별해야 한다. STX/ETX 마커 기반 패킷 조립 + 체크섬 검증으로 불완전 패킷 필터링.

결과

동일한 CardReader 인터페이스로 KSNET(TCP/IP)과 UBCN(RS-232) 양쪽을 투명하게 교체 가능. 현장 설치 시 설정 파일의 벤더 지정만으로 전환.

5 / 6

배포 인프라

종량제 200GB/월 네트워크, USB 수동 배포, 전체 빌드 재배포. 이 세 가지 제약을 해결하기 위해 에셋/코드 분리 배포 전략과 온프레미스 Supabase 기반 배포 파이프라인을 설계하고 구축한 기록.

제약 조건과 기존 방식의 한계

네트워크 제약

현장 키오스크는 종량제 인터넷을 사용한다. 약 1000대의 전체 기기 합산 월간 업로드/다운로드 200GB가 한도이며, 이 안에서 앱 업데이트, 통계 동기화, 고객 사진 전송, 오류 리포트가 모두 처리되어야 한다.

USB 수동 배포의 한계

이전에는 빌드 결과물을 USB에 담아 현장 직원이 기기마다 직접 복사했다. 이 방식에서 발생한 문제:

  • 배포 한 건에 현장 방문이 필요
  • 긴급 버그픽스에도 물리적 이동 시간이 소요
  • 롤백 시 이전 버전 USB를 다시 들고 가야 함
  • 여러 기기에 서로 다른 버전이 설치되는 상황 발생

결과적으로 배포, 오류 대응, 롤백, 업데이트 모두 현장 직원의 물리적 이동에 의존하는 구조였고, 기민한 대응이 불가능했다.


배포 빈도 분석과 분리 전략

분석

실제 배포 이력을 분석한 결과, 에셋(UI 텍스처, 오디오, 씬)이 변경되는 경우는 드물었다. 대부분의 배포는 코드 버그픽스와 로직 수정이었다. Unity의 기본 빌드는 에셋과 코드가 하나의 바이너리로 묶이기 때문에, 코드 한 줄을 고쳐도 전체 빌드를 다시 만들어야 했다. 여기에 네이티브 DLL(프린터 SDK, 카드리더 SDK)과 그 종속 라이브러리가 빌드 바이너리의 상당 부분을 차지했다.

분리 전략

이 분석을 바탕으로 세 가지를 분리했다.

에셋과 코드의 분리. Unity Addressables로 에셋을 독립 번들로 빌드하고, 코드만 포함된 Player Build를 별도 배포. 에셋만 변경됐으면 번들만 서버에 올리고, 코드만 변경됐으면 앱 바이너리만 배포한다.

네이티브 DLL의 분리. 동적 DLL 로더(SDK 클래스, Reflection.Emit 기반)를 구현하여 네이티브 SDK를 빌드에 포함하지 않고 런타임에 외부 경로(D:\DATA_IFOTO\SDK)에서 로드. 프린터 SDK 4종(ChcusbProxy, CxStat64, Cx2Stat64, CPUSBM1)과 종속 라이브러리가 빌드 바이너리에서 제외되어 배포 크기가 줄어든다. (동적 DLL 로더의 기술적 구현은 네이티브 SDK 통합 참조)

앱/에셋/DLL 3중 분리의 효과. 코드 수정만 있는 배포에서는 Player Build ZIP만 전송. 에셋 번들은 이전 캐시를 사용하고, 네이티브 DLL은 현장에 이미 설치되어 있으므로 배포 대상에서 제외.


온프레미스 Supabase 서버

선택 이유

자체 서버에서 Supabase를 Docker로 운영한다. 클라우드 Supabase가 아닌 온프레미스를 선택한 이유는 운영 자유도가 선택 기준이었기 때문이다. 장애 발생 시 SSH로 접속하여 Docker 컨테이너 로그 확인, PostgreSQL 직접 쿼리, 설정 변경 후 즉시 재배포가 가능해야 했다.

Supabase의 세 가지 역할

단순한 데이터베이스가 아니라, 배포 파이프라인의 백엔드 전체를 담당한다.

역할Supabase 기능용도
버전 관리 DBPostgreSQL + REST APIapp_versions 테이블로 채널별 버전 관리, RLS 정책으로 접근 제어
바이너리 CDNStorage Public 버킷앱 빌드 ZIP, Addressables 번들을 인증 없이 HTTP GET으로 배포
사진 전달 CDNStorage Public 버킷고객 촬영 사진을 업로드하고 Public URL을 QR 코드로 인코딩하여 전달

세 버킷 모두 Public 읽기가 가능하다. 키오스크가 인증 없이 에셋과 빌드를 다운로드하고, 고객이 QR 코드로 사진에 접근할 수 있어야 하기 때문이다. 업로드는 Service Role Key로만 가능하며, RLS 정책으로 is_active = true인 버전만 공개 조회를 허용한다.

실시간 오류 보고

기기별 장애 발생 시 error_report 테이블에 실시간 전송한다. device_id, device_type, message, timestamp를 기록하여 어떤 기기에서 어떤 장비가 언제 어떤 오류를 냈는지 원격으로 즉시 추적할 수 있다.

csharp
// 각 디바이스 시스템에서 오류 감지 시
Systems.Network.Alarm(DeviceType.Printer, "Paper empty").Forget();
Systems.Network.Alarm(DeviceType.Camera, "Disconnected").Forget();

device_status 테이블은 프린터, 카메라, IOBoard, 카드리더, 지폐기의 현재 상태를 기기별로 기록한다. 이 두 테이블의 조합으로 기존에 3~7일 걸리던 장애 원인 파악이 즉시 추적으로 전환됐다.


빌드 배포 파이프라인

전체 흐름

flowchart LR
    subgraph 개발PC["개발 PC"]
        A1["Unity Editor\nAddressables Build"]
        A2["Unity Editor\nPlayer Build"]
        A3["BuildUploader\n(Qt 6, C++17)"]
    end

    subgraph 서버["온프레미스 Supabase"]
        B1["Storage\naddressables/"]
        B2["Storage\napp-builds/"]
        B3["DB\napp_versions"]
    end

    subgraph 현장["키오스크"]
        C1["UpdateController\n버전 체크 + 카탈로그 비교"]
    end

    A1 -->|".bundle, .hash"| A3
    A2 -->|"ZIP"| A3
    A3 -->|"델타 업로드"| B1
    A3 -->|"ZIP + 메타데이터"| B2
    A3 -->|"버전 등록"| B3
    B1 -->|"번들 다운로드"| C1
    B2 -->|"앱 다운로드"| C1
    B3 -->|"버전 조회"| C1

BuildUploader: Qt 6 배포 도구

Tools/BuildUploader/에 위치한 Qt 6 기반 데스크톱 GUI 도구. 두 가지 업로드 모드를 제공한다.

앱 빌드 업로드. ZIP 파일 선택 시 파일명에서 버전을 자동 추출. Supabase Storage에 업로드 후 app_versions 테이블에 버전 정보(채널, checksum, 릴리즈 노트)를 등록한다. 채널은 stable, beta, dev 중 선택하며, 키오스크는 자신의 채널에 해당하는 최신 active 버전만 조회한다.

Addressables 델타 업로드. SHA256 매니페스트(.upload-manifest.json)와 비교하여 변경분만 업로드한다. 파일 크기 비교를 SHA256 해시 연산보다 먼저 수행하여, 변경되지 않은 파일에 대한 해시 연산을 회피한다.

이전 매니페스트 로드 | 파일별 비교: 매니페스트에 없음 -> [NEW] -> 업로드 대상 파일 크기 불일치 -> [CHANGED] -> 업로드 대상 크기 동일 + SHA256 불일치 -> [CHANGED] -> 업로드 대상 크기 동일 + SHA256 일치 -> [OK] -> 스킵 | 매니페스트에만 존재 (폴더에 없음) -> 서버에서 삭제

클라이언트 자동 업데이트

UpdateController가 앱 시작 시 실행한다. 순서가 중요하다. 앱이 먼저 업데이트되어야 새 에셋 스키마와 호환되므로 순서를 강제했다.

  1. 앱 버전 체크: Supabase REST API로 채널별 최신 active 버전 조회, System.Version 파싱으로 비교
  2. 앱 다운로드: ZIP 다운로드 + SHA256 체크섬 검증. 동일 파일이 이미 존재하고 체크섬이 맞으면 다운로드 스킵
  3. Addressables 카탈로그 비교: CheckForCatalogUpdates()로 해시 비교, 변경 시 새 카탈로그 수신
  4. 번들 다운로드: GetDownloadSizeAsync()가 0보다 크면 변경된 번들만 다운로드
배포 인프라의 가치

Supabase를 단순한 데이터베이스로 활용한 것이 아니라, Storage를 CDN으로, REST API를 버전 관리 엔드포인트로, RLS를 접근 제어로 활용하여 배포 파이프라인의 백엔드 전체를 구성했다. 200GB/월 종량제 제약 하에서 에셋/코드/DLL 3중 분리와 델타 업로드로 전송량을 최소화하면서, USB 수동 배포를 원격 자동 배포로 전환한 구조.


오프라인 내결함성

네트워크 연결이 없어도 키오스크의 핵심 기능(촬영, 인쇄, 결제)은 동작해야 한다.

초기화 타임아웃 (10초). Supabase 클라이언트 초기화에 10초 타임아웃을 적용한다. 실패 시에도 IsInitialized = true를 설정하여 이후 코드에서 무한 재시도에 빠지지 않도록 한다.

자동 재연결 (5초). Send() 호출 시 연결이 끊긴 상태면 5초 타임아웃으로 재연결을 시도한다. HttpRequestException 발생 시 IsConnected = false로 상태를 갱신하고 조용히 실패한다.

통계 동기화 복구. StatisticsSystem의 RecordLoop는 로컬 파일을 우선 저장한다. 네트워크 복구 시 Math.Max 병합으로 로컬과 서버 중 큰 값을 취하여 데이터 손실 없이 동기화한다. 통계 카운터는 단조증가하므로 큰 값이 항상 최신이다.

결과

  • USB 수동 배포 완전 제거. 원격 배포로 전환
  • 에셋만 변경 시 앱 재빌드 없이 번들만 업로드하여 배포 완료
  • 델타 업로드로 전송량 90% 이상 절감
  • error_report 실시간 전송으로 장애 원인 파악 3~7일에서 즉시 추적으로 전환
  • 네트워크 장애 시에도 핵심 기능 정상 동작
6 / 6

프로덕션 안정성

"API가 성공을 리턴했는데 실제로는 실패한" 상황, 예고 없이 잠드는 카메라, 명령을 씹는 MCU를 다루면서 형성된 검증 전략.

인쇄 검증 큐: SDK 에러 코드를 믿지 않는 이유

문제

DNP/CPM1 프린터의 인쇄 경로는 GDI+ PrintDocument.Print()를 사용한다. 이 함수는 스풀러 제출만 보장하고 실제 인쇄 성공 여부를 반환하지 않는다. 용지 걸림이나 리본 소진 시에도 예외 없이 성공을 반환한다. 고객이 결제했는데 사진이 나오지 않는 상황이 반복됐다.

접근

SDK 에러 코드 대신 물리적 상태(용지 잔량)를 기준으로 인쇄 성공을 판정하는 Print Verification Queue를 설계했다. 소프트웨어의 리턴값이 아닌 하드웨어의 물리적 변화를 신뢰 기준으로 삼는 구조.

구현

flowchart TD
    A["인쇄 시작"] --> B["paperBefore 스냅샷"]
    B --> C["Queue에 등록"]
    C --> D["RecordLoop\n(백그라운드)"]

    D --> E{"pendingPrints\n있는가?"}
    E -->|Yes| F["5초 간격 폴링"]
    E -->|No| G["5분 대기"] --> D

    F --> H{"PaperRemain\n< paperBefore?"}
    H -->|Yes| I["성공\nRecordPrint"]
    H -->|No| J{"30초\n경과?"}
    J -->|No| F
    J -->|Yes| K["실패\nRecordPrintFailure"]

    I --> D
    K --> D

EnqueuePrintRecord()가 delayCts.Cancel()을 호출하면 5분 sleep에서 즉시 깨어난다(LinkedTokenSource 패턴). delayCts.Cancel()은 대기만 취소하고 상위 ct는 건드리지 않는다.

판정 기준조건결과
용지 감소PaperRemain < paperBefore성공 → RecordPrint
타임아웃30초 내 변화 없음실패 → RecordPrintFailure
유휴 상태pendingPrints 없음5분 대기 → 루프 유지
SDK 리턴값의 신뢰 한계

하드웨어를 다루면 "정상 리턴 = 정상 동작"이라는 가정을 버려야 한다. SDK가 말하는 것과 실제 하드웨어 상태 사이에는 항상 간극이 있고, 독립적인 검증 수단이 유일한 신뢰 기준.

결과

인쇄 성공/실패 판정 정확도 확보. 실패 시 자동 통계 기록과 Supabase 원격 알람 연동.


IOBoard MCU: 명령 누락과 타이밍

문제

IOBoard MCU가 BILL 이벤트 처리 직후에 새 명령을 보내면 누락. 상태 비트맵에서 카운터 연결 필드는 값이 반전('0' = 연결됨). 누적 금액은 합산이 아닌 대입(하드웨어의 누적 총액). 문서화되지 않은 동작이 대부분이었다.

해결

StopBill 시퀀스 — 명령 누락에 대한 재시도 패턴:

flowchart TD
    A["2000ms 대기"] --> B["SetBill OFF"]
    B --> C["2000ms 대기"]
    C --> D["QueryBillStatus"]
    D --> E{"billStillOn?"}
    E -->|Yes| F{"재시도 3회\n미만?"}
    F -->|Yes| B
    F -->|No| G["강제 종료"]
    E -->|No| H["2000ms 대기"]
    H --> I["ResetBill"]

lastCmd 타임아웃 — stale 응답 매칭 방지:

flowchart LR
    A["명령 전송"] --> B["3000ms 경과"]
    B --> C["lastCmd = None\n(stale 응답 방지)"]

에러 복구 — COM 포트 핸들 해제 대기:

flowchart TD
    A["연속 에러 30회"] --> B["TryReconnect"]
    B --> C["Destroy"]
    C --> D["2프레임 대기"]
    D --> E["재생성"]
    E --> F{"성공?"}
    F -->|No| G{"재시도 3회\n미만?"}
    G -->|Yes| C
    G -->|No| H["복구 실패"]
    F -->|Yes| I["정상 운영 재개"]
시퀀스대기 시간재시도비고
StopBill2000ms/단계최대 3회상태 확인 후 반복
lastCmd 리셋3000ms-stale 응답 방지
에러 복구2000ms 간격최대 3회COM 포트 핸들 해제 대기
재생성2프레임-Unity 프레임 기반 대기
문서 없는 SDK와의 작업

문서가 없는 SDK와 일할 때는 에러를 재현하고 패턴을 찾는 것이 문서를 읽는 것보다 빠른 경우가 많다. 함수 시그니처에서 동작을 역추적하고, "이 SDK는 어떤 전제 하에서 만들어졌는가"를 추론하는 것이 통합의 시작점.

증명사진 키오스크 시스템 · 2025.06 ~ 현재