0%

Frontend

USB 들고 전시장 가던 날은 끝났어요

전*진·

USB 들고  전시장 가던 날은 끝났어요

안녕하세요. 전시 콘텐츠를 개발하고 있는 프론트엔드 개발팀입니다.

지난 9월, 국립청주해양과학관 해파리 전시에 들어갈 키오스크 앱 세 개를 만들었어요. 해파리의 한살이를 따라가는 생활사 앱, 8개 질문으로 나와 닮은 해파리를 찾아 주는 MBTI 앱, 관람객이 QR로 보낸 메시지를 해파리들이 말풍선으로 읽어 주는 메시지 앱이에요. 셋 다 Electron으로 만든 Windows 앱이고, 전체화면·단축키 차단·관리자 화면 같은 공통 키오스크 동작은 kiosk-core라는 패키지 하나에 모아 두었어요.

전시 앱은 설치했다고 끝나지 않아요. 문구 한 줄, 애니메이션 타이밍, 현장 모니터에서만 보이는 흰 조각 같은 고칠 거리가 오픈 직전과 직후에 몰려서 나와요. 문제는 키오스크가 사무실이 아니라 멀리 떨어진 전시장에 있다는 거였어요. 버그 하나를 고칠 때마다 USB를 들고 전시장에 갈 수는 없었어요.

그래서 목표를 한 문장으로 정했어요.

개발 Mac에서 태그 하나를 push하면, 현장 방문 없이 키오스크가 새 버전으로 바뀐다. 실패하면 그 이유가 화면과 로그에 남는다.

이 글은 이 문장을 이루기까지 이틀 동안 버전을 열한 번 올리며 겪은 일을 정리한 기록이에요. electron-updater를 붙이는 방법은 공식 문서에 잘 나와 있으니, 여기서는 문서에는 없고 현장에서만 만난 문제들을 중심으로 이야기해 볼게요.

이전에는 사람이 움직였고, 지금은 태그가 움직여요

01-before-after.png

1. 재료는 이미 다 갖춰져 있었어요

원격 업데이트라고 하면 업데이트 서버부터 떠올리기 쉬운데요. 저희는 서버를 새로 만들지 않았어요. 필요한 재료가 이미 손에 있었거든요.

  • 사내 GitLab과 macOS Runner: 이미 테스트를 돌리던 CI 환경이에요.

  • electron-builder: Windows용 NSIS 설치 파일과 함께 latest.yml이라는 버전 안내 파일을 만들어 줘요.

  • electron-updater의 generic provider: URL 하나만 알려 주면 그 아래 latest.yml을 읽어서 새 버전인지 판단해요.

  • GitLab Generic Package Registry: 아무 파일이나 경로를 정해 올리고 내려받을 수 있는 저장소예요. 업데이트 서버 역할을 맡기기에 충분했어요.

이 넷을 이으면 아래 그림처럼 돼요.

태그 하나가 키오스크에 닿기까지

02-architecture.png

태그가 곧 릴리스예요

main은 보호 브랜치라서 모든 변경은 MR로만 들어와요. 릴리스는 <앱>-v<버전> 형식의 태그를 push하는 것 자체예요. 태그가 올라오면 해당 앱의 릴리스 Job이 돌아요.

# .gitlab-ci.yml (발췌)
.release_app:
  stage: release
  script:
    # 태그 버전과 package.json 버전이 일치해야 잘못된 버전 배포를 막을 수 있다
    - TAG_VERSION="${CI_COMMIT_TAG#${APP_NAME}-v}"
    - PKG_VERSION="$(node -p "require('./apps/${APP_NAME}/package.json').version")"
    - |
      if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
        echo "태그 버전($TAG_VERSION)과 package.json 버전($PKG_VERSION)이 다릅니다."
        exit 1
      fi
    - npm ci --prefer-offline --no-audit
    - npm run dist:win -w "apps/${APP_NAME}"   # electron-vite build && electron-builder --win
    # 설치 파일·blockmap·latest.yml을 <앱>/latest 채널에 올린다
    - |
      for f in "apps/${APP_NAME}/release/${APP_NAME}-setup-${PKG_VERSION}.exe" \
        "apps/${APP_NAME}/release/${APP_NAME}-setup-${PKG_VERSION}.exe.blockmap" \
        "apps/${APP_NAME}/release/latest.yml"; do
        curl --fail --silent --show-error \
          --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
          --upload-file "$f" \
          "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${APP_NAME}/latest/$(basename "$f")"
      done

release:mbti:
  extends: .release_app
  variables: { APP_NAME: mbti }
  rules:
    - if: '$CI_COMMIT_TAG =~ /^mbti-v\d+\.\d+\.\d+$/'

여기서 중요한 줄은 맨 위의 버전 검사예요. 태그는 1.0.12인데 package.json은 아직 1.0.11이라면, 키오스크는 "이미 최신"이라고 판단하거나 엉뚱한 버전을 받게 돼요. 사람은 이런 실수를 언젠가 꼭 하니까, CI가 마지막 관문에서 막도록 했어요.

태그 파이프라인에서는 테스트(verify)를 다시 돌리지 않아요. 태그는 MR과 main 파이프라인에서 이미 검증된 커밋에만 달기 때문에, 같은 검증을 세 번 돌릴 이유가 없었거든요. 덕분에 태그를 push하면 바로 빌드부터 시작해요.

latest.yml 한 장이 CI와 키오스크 사이의 약속이에요

CI와 키오스크는 서로를 몰라요. 둘이 공유하는 건 Registry의 <앱>/latest/ 경로에 놓인 latest.yml 한 장뿐이에요.

version: 1.0.12
files:
  - url: mbti-setup-1.0.12.exe
    sha512: e8Yn6NMNMKh0FoujpEkm9bRy...
    size: 112942594
path: mbti-setup-1.0.12.exe
releaseDate: '2026-09-04T08:51:01.152Z'

키오스크는 이 파일의 version을 자기 버전과 비교해요. 더 높으면 url의 설치 파일을 받고, sha512로 무결성을 확인해요. 같은 경로에 새 파일을 덮어 올리면 GitLab이 가장 최근 것을 내려주기 때문에, 이 경로가 자연스럽게 "최신 채널"이 돼요.

이 단계에서 밟은 지뢰도 두 개 있었어요.

  • 설치 파일명은 ASCII여야 해요. 제품 표시명은 해파리로알아보는MBTI처럼 한글인데, GitLab Generic Package는 한글이나 공백이 들어간 파일명을 받지 않아요. electron-builder.yml의 artifactName을 mbti-setup-${version}.${ext}로 고정했어요.

  • 산출물 보관처는 한 곳으로 정했어요. 처음엔 113MB 설치 파일을 Job 아티팩트로도 남기려다가, 업로드는 다 성공해 놓고 Job이 413 Request Entity Too Large로 실패했어요. 설치 파일은 Registry에만 두기로 했어요.

키오스크 쪽은 이게 전부예요

// packages/kiosk-core/src/electron/index.ts (발췌)
const { autoUpdater } = await import('electron-updater');
autoUpdater.setFeedURL({ provider: 'generic', url: config.updateUrl });
autoUpdater.autoDownload = true;
// 차등 다운로드는 서버의 Range 지원에 의존해 실패 여지가 있다 — 전체 다운로드로 고정
autoUpdater.disableDifferentialDownload = true;

const check = () => autoUpdater.checkForUpdates().catch(() => {});
check();                                // 앱이 켜지자마자 한 번
setInterval(check, 6 * 3600 * 1000);    // 그 뒤로 6시간마다

업데이트 주소(updateUrl)는 코드가 아니라 설치 폴더의 config.json에서 읽어요. 그래서 앱을 다시 빌드하지 않고도 현장에서 채널을 바꾸거나 업데이트를 끌 수 있어요. 그리고 업데이트가 어떤 이유로 실패하더라도 전시는 절대 멈추지 않는다는 원칙을 지켰어요. 업데이트는 어디까지나 덤이고, 관람객의 체험이 본업이니까요.

여기까지는 교과서대로였어요. 문제는 실제 키오스크 PC에 올린 다음부터였어요.

2. 현장 PC에서는 하나씩 터졌어요

파이프라인을 돌리고 실제 키오스크 PC에 올리면서 문제가 연달아 터졌어요. 이틀 동안 겪은 일을 커밋 시각 순으로 정리하면 이래요.

시각

증상

실제 원인

조치

9/2 16:12

릴리스 Job이 413으로 실패

설치 파일을 Job 아티팩트로도 보관하려 함

보관처를 Registry 하나로

9/2 17:27

심야 재시작 때 설치가 충돌

재실행과 설치기의 실행 시점이 겹침

심야 재시작 기능을 삭제

9/3 09:24

다운로드는 됐는데 설치가 안 됨

PC 전원을 끄면 "종료 시 설치"가 설치기와 함께 죽음

PIN 입력 시 명시적으로 설치

9/3 10:34

업데이트가 아무 반응이 없음

config.json이 BOM 때문에 파싱 실패 → 업데이트가 꺼진 기본값으로 실행

BOM 허용, 파싱 실패를 화면에 표시

9/3 11:11

net::ERR_ABORTED

서버의 401을 Electron이 요청 취소로 바꿔 버림

원인 진단 + Registry 익명 다운로드

9/3 11:50

설치 중 파일 사용 중 오류

남아 있던 Electron 보조 프로세스와 설치기의 경합

창 정리 → 2초 대기 → 설치

9/3 12:48

Failed to uninstall old application files: 2

Program Files 설치의 권한·보안 간섭

사용자 폴더 설치로 전환

9/3 13:24

그래도 같은 오류

보안 프로그램이 제거기 파일을 손상시킴

앱이 제거기를 미리 삭제

9/3 13:33

v1.0.11 릴리스 — 전 구간 검증 완료

이 중에서 가장 오래 헤맸던 세 가지를 자세히 이야기해 볼게요.

사건 파일 #1. 원인을 지워 버리는 에러, net::ERR_ABORTED

처음 설계에서는 GitLab 프로젝트를 Private으로 두고, 키오스크마다 읽기 전용 Deploy Token을 config.json에 넣어 인증했어요. 그런데 어느 날 관리자 화면에 이 한 줄만 남아 있었어요.

net::ERR_ABORTED의 정체

03-err-aborted.png


ERR_ABORTED는 "요청이 중간에 취소됐다"는 뜻이라 네트워크 문제처럼 보여요. 실제로는 이런 일이 벌어지고 있었어요.

  1. 서버가 토큰을 거부하면서 401 Unauthorized와 함께 WWW-Authenticate: Basic 헤더를 보내요.

  2. Electron의 네트워크 스택은 이 응답을 받으면 "아이디·비밀번호를 입력받아야 한다"며 login 이벤트를 발생시켜요.

  3. 브라우저라면 로그인 창을 띄우겠지만, 키오스크 메인 프로세스에는 그 이벤트에 답해 줄 코드가 없어요.

  4. 아무도 응답하지 않으니 요청이 취소되고, 최종적으로 net::ERR_ABORTED만 남아요. "401"이라는 진짜 원인은 이 과정에서 사라져요.

그래서 먼저 원인이 보이게 만들었어요. electron-updater는 이 login 이벤트를 그대로 전달해 주기 때문에, 여기서 진짜 원인을 기록할 수 있어요.

let authRejected = false;
autoUpdater.on('login', () => {
  authRejected = true;
  logUpdate(`401 인증 거부 — 앱이 읽은 토큰: ${describeToken(config.updateToken)}`);
  updateStatus = { state: 'error', message: '서버 인증 거부(401) — config.json의 updateToken 확인 필요' };
});

describeToken은 토큰 전체가 아니라 gldt-…Abc (25자)처럼 앞 5자, 뒤 3자, 길이만 보여 줘요. 현장 담당자가 파일에 적힌 토큰과 앱이 실제로 읽은 토큰이 같은지 대조할 수 있게 하려는 거예요.

그런데 원인이 보이자 한 가지가 분명해졌어요. 토큰을 사람이 손으로 config.json에 옮겨 적는 과정 자체가 장애 지점이었어요. 그래서 이 문제를 고치는 대신 없애기로 했어요. GitLab에는 소스는 Private으로 두면서 Package Registry만 익명 다운로드를 허용하는 설정이 있어요. 전시 앱의 설치 파일은 비밀이 아니니 잃을 게 없었어요. 이 설정 하나로 인증 실패라는 장애 범주가 통째로 사라졌어요.

💡 Electron에서 원격 리소스를 받다가 이유 없는 ERR_ABORTED를 만나면, 서버가 401이나 407을 보내고 있지 않은지 먼저 의심해 보세요.

사건 파일 #2. 언제 설치할 것인가

다운로드까지 됐다면 남은 건 설치예요. electron-updater의 기본값인 autoInstallOnAppQuit은 앱이 종료될 때 설치기를 실행해 줘요. 편해 보이지만 현장에서는 두 가지 문제가 드러났어요.

첫째, 앱을 끄지 않고 PC 전원을 바로 내리면 설치기도 함께 꺼지면서 아무 흔적 없이 실패해요. 현장에서 실제로 확인한 일이에요. 처음에는 매일 밤 앱을 재시작하는 "심야 재시작" 기능에 설치를 묶어 보려 했는데, 재실행과 설치기가 겹치면서 또 충돌이 났어요. 결국 심야 재시작 기능을 아예 지웠어요. 기능이 하나 줄면 경합도 하나 줄어요.

그래서 설치 시점을 사람이 정하도록 바꿨어요. 운영자가 관리자 화면에서 "다운로드 완료"를 확인하고 PIN을 입력하면 그때 설치해요. 관람 중에 앱이 갑자기 꺼지지 않으면서도, 확실히 설치되는 방법이에요.

둘째, PIN을 누르자마자 설치를 시작하면 이번에는 파일이 사용 중이라는 오류가 났어요.

종료하자마자 설치하면 생기는 일

04-install-race.png

Electron 앱은 메인 프로세스 말고도 GPU, 렌더러 같은 보조 프로세스를 함께 띄워요. 메인이 종료 명령을 받아도 보조 프로세스는 1~2초 정도 더 살아 있는데, 그 사이에 설치기가 구버전 파일을 지우려다가 걸린 거예요. 그래서 순서를 바꿨어요.

autoUpdater.on('update-downloaded', (info) => {
  updateStatus = { state: 'downloaded', version: info.version };
  installDownloadedUpdate = () => {
    installingUpdate = true;   // window-all-closed에서 바로 종료하지 않게
    win?.destroy();            // ① 창부터 정리해서 보조 프로세스를 내려보내고
    win = null;
    // ② 2초 기다린 뒤 ③ 무음 설치(isSilent) + 설치 후 자동 실행(isForceRunAfter)
    setTimeout(() => autoUpdater.quitAndInstall(true, true), 2000);
  };
});

ipcMain.on('kiosk:quit', () => {
  if (installDownloadedUpdate) installDownloadedUpdate();  // 받아 둔 업데이트가 있으면 설치
  else app.quit();
});

autoInstallOnAppQuit도 켜 두었어요. 정상적으로 종료되는 경우라면 그때도 설치될 기회가 있으니까요. 다만 저희가 믿는 경로는 PIN이에요.

사건 파일 #3. 같은 에러 메시지, 세 번의 추적

마지막 문제가 가장 이상했어요. 설치기가 이런 메시지를 띄우며 멈췄어요.

Failed to uninstall old application files. Please try running the installer again.: 2

중간에 "앱을 닫을 수 없다(cannot be closed)"는 경고도 나왔는데, 앱 프로세스가 하나도 떠 있지 않은 상태에서도 똑같이 나왔어요. 저희는 이 메시지를 세 번 추적했어요.

첫 번째 추적: 프로세스 경합. 사건 파일 #2의 2초 대기가 여기서 나왔어요. 실제로 있던 문제를 하나 고친 건 맞지만, 오류는 그대로였어요.

두 번째 추적: 설치 위치. 처음에는 C:\Program Files 아래에 설치하는 관리자 설치(perMachine: true)였어요. 이 위치는 권한과 보안 프로그램의 간섭을 많이 받아요. 사용자 폴더 설치(%LOCALAPPDATA%\Programs\<앱>)로 바꾸면 설치와 업데이트에 관리자 권한(UAC)이 아예 필요 없어요. VS Code나 Slack처럼 스스로 업데이트하는 Electron 앱들이 쓰는 방식이기도 해요. 좋은 변화였지만, 현장 PC에서는 여전히 같은 오류가 났어요.

세 번째 추적: 설치기의 코드. 결국 electron-builder가 생성하는 NSIS 스크립트(installUtil.nsh)를 직접 읽었어요. 새 버전 설치기는 파일을 깔기 전에 레지스트리에 적힌 구버전 제거기를 실행하고, 그 종료 코드를 확인해요.

; installUtil.nsh — 구버전 제거 결과 처리 (발췌)
IfErrors 0 +3
DetailPrint "Uninstall was not successful. Not able to launch uninstaller!"
Return                ; 제거기를 '실행조차 못 하면' 로그만 남기고 설치를 계속한다

${if} $R0 != 0
  MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
  SetErrorLevel 2
  Quit                ; 제거기가 실행됐는데 0이 아닌 코드로 끝나면 설치를 중단한다
${endif}

그리고 현장 PC를 다시 들여다보다가, 설치 직후 생성된 제거기 파일(Uninstall .exe)이 손상되어 있다는 걸 발견했어요. 현장 PC에 설치된 보안 프로그램이 새로 생긴 실행 파일을 건드린 것으로 보였어요. 손상된 제거기는 무결성 검사에서 실패해 종료 코드 2를 돌려주고, 설치기는 1초 간격으로 다섯 번 재시도한 끝에 "cannot be closed"라는 엉뚱한 경고를 띄우고 멈췄던 거예요.

이미지 업로드 필요: 제거기가 없으면, 설치기는 그냥 지나가요

01-before-after.png

위 스크립트에서 탈출구가 보였어요. 제거기를 실행조차 못 하면, 설치기는 오류로 보지 않고 다음 단계로 넘어가요. 우회 경로가 아니라 코드에 적힌 정상 경로예요. 그래서 앱이 켜질 때마다 자기 설치 폴더의 제거기를 지우게 했어요.

// 시작 시마다 제거기를 지워, 다음 업데이트 때 설치기가 제거 단계를 건너뛰게 한다
try {
  const installDir = path.dirname(app.getPath('exe'));
  for (const name of fs.readdirSync(installDir)) {
    if (name.startsWith('Uninstall') && name.endsWith('.exe')) {
      fs.rmSync(path.join(installDir, name));
      logUpdate(`제거기 사전 삭제: ${name}`);
    }
  }
} catch { /* 실패해도 전시는 계속 */ }

부작용은 Windows "앱 및 기능" 목록에서 제거 버튼이 동작하지 않는다는 것뿐이에요. 전시 키오스크에서는 앱을 지울 일이 거의 없고, 필요하면 설치 폴더를 지우면 돼요.

한 가지 주의할 점이 있어요. 제거기를 지우는 건 이미 설치되어 있는 앱이 시작할 때 하는 일이에요. 그래서 효과는 이 코드가 들어간 버전이 한 번 깔린 다음 업데이트부터 나타나요. 저희는 이 코드를 v1.0.11에 넣었고, 이 버전으로 다운로드부터 설치, 재실행까지 전 구간 검증을 마쳤어요. 그 뒤로 나간 릴리스 23개도 모두 같은 경로를 탔어요.

3. 가장 비쌌던 건 버그가 아니라 침묵이었어요

돌아보면 이틀 중 가장 많은 시간을 쓴 건 버그를 고치는 일이 아니었어요. 무슨 일이 일어났는지 알아내는 일이었어요. 업데이트 확인이 실패해도 화면에는 아무것도 남지 않아서, 원인 하나를 잡는 데 현장 왕복이 여러 번 필요했어요. 현장에 가지 않으려고 만드는 시스템인데, 정작 그 시스템을 고치려고 현장에 가야 했던 거예요.

대표적인 사례가 config.json이었어요. 편집기로 파일을 저장하는 과정에서 UTF-8 BOM이 붙었고, JSON.parse가 실패했어요. 앱은 아무 말 없이 기본값으로 실행됐는데, 기본값에는 업데이트 주소가 없어서 자동 업데이트가 꺼진 채로 돌고 있었어요. 화면만 봐서는 완벽하게 정상이었어요.

export function loadKioskConfig(): LoadedKioskConfig {
  const configPath = resolveConfigPath(app.getPath('exe'), process.env, !app.isPackaged);
  try {
    // 메모장 등이 붙이는 UTF-8 BOM은 JSON.parse를 깨뜨리므로 제거
    const raw = fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, '');
    return { config: mergeConfig(JSON.parse(raw)), loadError: null };
  } catch (e) {
    const missing = (e as NodeJS.ErrnoException).code === 'ENOENT';
    return {
      config: mergeConfig({}),
      // 파일이 없는 건 의도된 기본값 실행이지만, 있는데 못 읽은 건 반드시 알린다
      loadError: missing ? null : `config.json 읽기 실패 — ${String((e as Error).message).slice(0, 120)}`,
    };
  }
}

이 일을 겪고 나서 원칙을 하나 세웠어요. 조용히 실패하는 곳을 남기지 않는다. 그리고 모든 상태를 관리자 PIN 화면 한 곳에 모았어요.

현장의 유일한 운영 화면, 관리자 PIN

06-admin-pin.png
  • 버전: 업데이트가 실제로 적용됐는지 현장에서 바로 확인해요.

  • 상태 줄: 다운로드 진행률, 완료 안내, 오류 사유를 사람이 읽을 수 있는 말로 보여 줘요. 설정 파일 읽기 실패도 여기에 떠요.

  • 업데이트 다시 확인: 앱을 재시작하지 않고 바로 다시 시도해요. 업데이트 확인 주기가 6시간이라, 급할 때는 이 버튼을 눌러요.

  • update.log: 모든 이벤트를 %APPDATA%\<제품명>\update.log에 한 줄씩 남겨요. 현장에서 이 파일만 받아도 무슨 일이 있었는지 알 수 있어요.

2026-09-04T09:02:10.118Z 제거기 사전 삭제: Uninstall 해파리로알아보는MBTI.exe
2026-09-04T09:02:10.540Z 업데이트 확인 시작
2026-09-04T09:02:11.031Z 새 버전 발견: 1.0.12
2026-09-04T09:03:27.664Z 다운로드 완료: 1.0.12
2026-09-04T09:20:45.207Z PIN 종료 → 창 정리 후 무음 설치 시작

update.log 예시 (실제 로그 문구에 맞춰 재구성한 것으로, 시각은 예시예요)

관리자 화면으로 들어가는 방법에도 함정이 있었어요. 좌상단 모서리를 5초 안에 다섯 번 누르면 열리는데, 생활사 앱에서는 아무리 눌러도 열리지 않았어요. 원인은 세 가지였어요. 관리자 화면이 실제로는 실행되지 않는 엔트리 파일에만 붙어 있었고, 3D 씬 코드가 stopPropagation()으로 포인터 이벤트를 삼키고 있었고, 렌더링이 무거워 이벤트 처리가 밀리면서 Date.now()로 잰 탭 간격이 실제보다 길어졌어요.

const onDown = (e: PointerEvent) => {
  // timeStamp: 처리가 밀려도 실제 입력 시점 기준으로 탭 간격을 잰다
  if (detect(e.clientX, e.clientY, e.timeStamp)) setOpen(true);
};
// capture 단계에서 들어서, 화면 코드가 이벤트를 삼켜도 감지되게 한다
window.addEventListener('pointerdown', onDown, true);

운영 화면은 어떤 상황에서도 열려야 해요. 문제가 생겼을 때 들어가야 하는 문이니까요.

4. 결과: 9월 한 달, 태그 47개

05-nsis-branch.png

9월 한 달, 태그 47개가 이 길을 지나갔어요

07-releases.png

앱

릴리스 태그

구축·검증 (9/2~9/3)

운영 중 배포 (9/3~9/30)

lifecycle (생활사)

24

12

12

mbti

16

12

4

message (메시지)

7

—

7

합계

47

24

23

처음 이틀 동안의 태그 24개는 파이프라인을 고치느라 달았어요. 그 뒤 4주 동안의 23개는 전시 콘텐츠를 고치느라 달았고요. 중간에 합류한 메시지 앱은 kiosk-core를 그대로 쓰고 릴리스 Job 몇 줄만 추가해서 같은 길에 올렸어요.

release:message:
  extends: .release_app
  variables: { APP_NAME: message }
  rules:
    - if: '$CI_COMMIT_TAG =~ /^message-v\d+\.\d+\.\d+$/'

지금 릴리스는 이렇게 진행돼요.

  1. 버전을 올리는 커밋을 MR로 main에 머지해요.

  2. git tag mbti-v1.0.15 && git push origin mbti-v1.0.15

  3. CI가 몇 분 안에 Windows 설치 파일을 만들어 Registry에 올려요.

  4. 키오스크가 다음 확인 주기(또는 재시작 시점)에 새 버전을 백그라운드로 받아요.

  5. 현장 운영자가 관리자 화면에서 "다운로드 완료"를 보고 PIN을 넣어요.

개발자가 하는 일은 2번까지예요.

5. 아직 남아 있는 숙제

모든 결정에는 대가가 있었어요. 숨기지 않고 적어 둘게요.

  • 제거기 사전 삭제는 우회책이에요. 근본적인 해결은 보안 프로그램에 예외를 등록하는 거예요. 기관과 협의할 수 있다면 그쪽이 더 깔끔해요.

  • 익명 Registry는 같은 네트워크의 누구나 설치 파일을 받을 수 있게 해요. 지금은 전시 앱 바이너리라 받아들였지만, 민감한 자산이 번들에 들어가는 날에는 다시 검토해야 해요. 반대로 이 설정을 실수로 끄면 모든 키오스크의 업데이트가 멈춰요.

  • 다운그레이드는 안 돼요. electron-updater는 낮은 버전으로 돌아가지 않아요. 롤백이 필요하면 이전 소스를 더 높은 버전으로 다시 릴리스해요.

  • CI Runner가 개발 Mac 한 대예요. Mac이 잠들어 있으면 파이프라인이 대기 상태로 멈춰요. 지금 규모에서는 감당할 만하지만, 앱이 늘어나면 전용 Runner가 필요할 거예요.

마치며

이틀 동안 버전을 열한 번 올리면서 배운 걸 네 가지로 정리해 볼게요.

하나, 원격 운영은 관측에서 시작해요. 원인을 모르면 고칠 수 없고, 원인이 화면에 없으면 결국 누군가 현장에 가야 해요. 조용한 실패를 없애는 일이 가장 먼저였어요.

둘, 고치기보다 없애기를 먼저 생각해요. 토큰 인증, Program Files 설치, 심야 재시작, 손상되는 제거기. 하나하나 방어 코드를 붙이는 대신 장애가 생길 자리를 지웠더니 시스템이 오히려 단순해졌어요.

셋, 막히면 남의 코드를 읽어요. NSIS 스크립트 안에는 문서 어디에도 없던 정상 경로가 있었어요. 블랙박스처럼 보이는 도구도 결국 누군가 작성한 코드예요.

넷, 사람의 손 하나는 남겨 둬요. 완전 자동 설치는 매력적이지만, 관람객 앞에서 앱이 갑자기 꺼지는 위험과 맞바꿀 만큼은 아니었어요. 현장 운영자의 PIN 한 번이 가장 확실한 안전장치였어요.

이제는 문구 수정 요청이 와도 USB를 챙기지 않아요. 태그를 하나 달고, 현장에 "관리자 화면에서 다운로드 완료가 뜨면 PIN 한 번 눌러 주세요"라고 메시지 한 통을 보내면 돼요. 비슷한 고민을 하는 분들께 이 기록이 작은 지도가 되면 좋겠어요.


사용한 환경

Electron 38 · electron-builder 26 (NSIS) · electron-updater 6 · electron-vite · GitLab CI (macOS Runner) · GitLab Generic Package Registry

읽기 도구

약 27분 읽기

이 글이 도움이 되었나요?

Work with GIWorks

프로젝트에 GIWorks의 경험이 필요하신가요?

전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.

문의하기

다음으로 읽기