Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

커피머신 키오스크 UI 개발기 (3) — GitHub Pages 배포와 CDN 장애 우회 본문

외주 개발일지

커피머신 키오스크 UI 개발기 (3) — GitHub Pages 배포와 CDN 장애 우회

HM소프트 2026. 6. 21. 06:09
반응형

같은 정적 산출물을 데모(하위 경로)와 임베디드 장비(루트)에 동시 배포하는 파이프라인을 만들었다. BASE_PATH 경로 확정, 백엔드 없는 fetch 심, .nojekyll 자동 생성, 그리고 공식 배포 액션이 CDN 장애로 다운됐을 때 gh-pages 직접 미러링으로 우회한 과정과 Yocto·Apache 장비 배포까지 다룬다.

코드가 멀쩡해도 배포가 안 되는 날

한 커피머신 제조사의 키오스크 UI를 만든 시리즈의 마지막 편이다. 1편에서는 기기와의 WebSocket 실시간 연동(추출구 상태 봉투, 재연결, 폴링 인계)을, 2편에서는 PWA 풀스크린과 태블릿 가독성을 다뤘다. 두 편 모두 "내 코드 안의 문제"였다. 그런데 이 프로젝트에서 정작 가장 많은 시간을 빼앗은 건 코드가 아니라 배포였다.

이 UI는 두 곳에 동시에 산다. 하나는 실제 장비 — 임베디드 보드 위에서 Apache가 서빙하는 키오스크 화면이고, 다른 하나는 회의·검토용으로 GitHub Pages에 올리는 데모 사이트다. 같은 산출물을 두 환경에 떨궈야 하니, 빌드 스크립트 하나가 두 타깃의 차이를 모두 흡수해야 했다. 거기에 더해, GitHub Pages 배포를 자동화하려던 순간 공식 배포 액션이 CDN 장애로 통째로 다운되는 일이 겹쳤다. 코드는 정상인데 사이트가 안 올라가는, 개발자가 가장 무력해지는 상황이었다.

이번 글은 그 배포 파이프라인을 처음부터 끝까지 따라간다. 정적 사이트 빌더가 무엇을 흡수하는지, GitHub Pages가 어떻게 막혔고 어떤 순서로 우회했는지, 그리고 실제 장비에는 어떻게 같은 산출물을 올렸는지까지. 화면 코드 한 줄 없이 "배포만으로" 한 편을 채울 수 있다는 게, 이 프로젝트가 알려준 가장 현실적인 교훈이었다.

두 개의 배포 타깃 — 같은 산출물, 다른 루트

먼저 전체 그림부터 그리자. 소스는 EJS 템플릿(views/)과 정적 자산(public/)이다. 빌드 스크립트 build.js가 이걸 정적 HTML로 렌더해 docs/ 디렉터리에 쏟아낸다. 그리고 그 docs/가 두 갈래로 흘러간다.

여기서 핵심은 같은 docs/가 두 곳으로 간다는 점이다. 데모 사이트는 https://<유저>.github.io/<레포명>/ 같은 하위 경로에서 서빙되고, 실제 장비의 Apache는 루트(/)에서 서빙한다. 이 한 가지 차이 — 경로 prefix가 있느냐 없느냐 — 가 빌드 스크립트가 흡수해야 할 첫 번째 변수였다. /css/app.css라고 박힌 절대 경로가 데모에서는 /<레포명>/css/app.css가 돼야 하고, 장비에서는 그대로 /css/app.css여야 한다.

그래서 빌더의 입구에 BASE_PATH라는 환경 변수를 뒀다. 기본값은 데모용 하위 경로이고, 장비용으로 빌드할 때는 BASE_PATH=''(또는 /)를 넘긴다. 사소해 보이지만 함정이 하나 있었다 — /를 그대로 두면 / + /css//css로 이어붙어 깨진다. 그래서 입구에서 /를 빈 문자열로 접어버린다.

빌더가 모든 페이지를 렌더한 뒤 이 fixHtmlPaths()를 한 번 통과시킨다. 정규식이 href/src의 루트 절대경로 앞에만 prefix를 붙이고, 프로토콜-상대(//로 시작하는) URL은 건드리지 않는다. "빌드 타임에 경로를 확정한다"는 결정이 이 프로젝트의 배포 단순함을 떠받친다. 런타임에 base를 추론하는 영리한 방법도 있지만, 키오스크처럼 환경이 고정된 곳에서는 빌드 산출물이 곧 정답이어야 디버깅이 쉽다.

서버 없이 API를 흉내내기 — 정적 모드 fetch 심

다음 변수는 더 까다로웠다. 실제 장비에는 C++ 웹서버가 있어 /api/spouts 같은 동적 엔드포인트가 진짜로 응답한다. 그런데 데모 사이트와 Apache 정적 서빙에는 백엔드가 없다. 화면 코드는 곳곳에서 fetch('/api/...')를 호출하는데, 정적 환경에는 그걸 받아줄 서버가 없으니 전부 404가 난다.

선택지는 둘이었다. 모든 화면의 fetch 호출을 정적/동적 분기로 고쳐 쓰거나, 호출을 가로채는 얇은 층을 한 곳에 심거나. 화면이 십수 개라 전자는 유지보수 지옥이고, 후자가 훨씬 깔끔했다. 그래서 빌드 타임에 <head> 최상단에 fetch 심(shim)을 주입한다. GET /api/<x> 요청을 정적 파일 /api/<x>.json으로 몰래 다시 쓰는 것이다.

심을 <head> 맨 위에 박는 게 핵심이었다. 화면들이 초기 렌더 도중 body의 인라인 스크립트에서 fetch를 날리기 때문에, 심이 그보다 늦게 설치되면 원본 호출이 그대로 새어나가 404가 난다. 그리고 목 데이터는 mock/*.json을 빌드 때 docs/api/로 그대로 복사해 둔다. 즉 목 JSON 파일이 곧 가짜 API가 된다. POST(로그인·공장초기화 같은)는 일부러 손대지 않아 정적 환경에서 404로 떨어지는데, 이건 버그가 아니라 "정적 데모에는 쓰기 동작이 없다"는 의도된 no-op이다.

이 설계 덕에 1편의 펌웨어 버전 체크 버튼 같은 동적 기능도 정적 데모에서 그럴듯하게 동작한다. POST가 404로 떨어지면 화면 쪽 catch가 600ms짜리 시뮬레이션 결과로 폴백하도록 해 뒀기 때문이다. 실제 장비에서는 같은 POST가 C++ 서버를 때려 진짜로 동작한다. 하나의 코드가 환경에 따라 다르게 행동하되, 화면 코드는 그 차이를 모른다 — 분기를 빌드 층과 fetch 심 층으로 끌어내린 결과다.

첫 번째 벽: Page build failed — .nojekyll

여기까지가 빌드의 세계였고, 이제 GitHub Pages에 올리는 단계로 넘어간다. 그리고 곧바로 익숙한 함정에 빠졌다. 사이트를 푸시했는데 'Page build failed'가 뜨면서 배포가 errored 상태로 반복됐다.

원인은 Jekyll이었다. GitHub Pages는 기본적으로 올라온 파일을 Jekyll로 처리하려 든다. 그런데 이 사이트에는 XML 선언으로 시작하는 SVG 아이콘과 한글이 잔뜩 든 manifest.json이 있었고, Jekyll의 Liquid 파서가 이런 파일에서 파싱 실패를 일으켰다. 해결은 정석대로 빈 .nojekyll 파일을 사이트 루트에 두는 것 — 그러면 Jekyll 처리를 통째로 건너뛰고 정적 파일을 있는 그대로 서빙한다.

다만 손으로 만들지 않고 빌드가 매번 자동 생성하게 했다. rmrf(DOCS)로 산출물을 청소하고 다시 빌드할 때마다 .nojekyll이 날아가기 때문이다. 사람이 기억해야 하는 단계는 언젠가 빠뜨린다.

// build.js — 산출물 청소 + .nojekyll 자동 생성
function rmrf(dir) {
  // Windows + 탐색기가 dir 자체를 열고 있으면 rmSync(dir) 가 EPERM.
  // dir 은 보존하고 children 만 지우는 패턴으로 우회.
  if (fs.existsSync(dir)) {
    for (const entry of fs.readdirSync(dir)) {
      try { fs.rmSync(path.join(dir, entry), { recursive: true, force: true }); } catch (e) {}
    }
  }
}

rmrf(DOCS);
mkdirp(DOCS);
// ... 페이지 렌더 + 자산 복사 ...

// Jekyll 처리 스킵 — 없으면 SVG/JSON 에서 Liquid 파서가 'Page build failed'
fs.writeFileSync(path.join(DOCS, '.nojekyll'), '');

여기서 rmrf가 디렉터리 자체가 아니라 자식만 지우는 것도 흉터 하나다. Windows에서 탐색기가 docs/ 폴더를 열어둔 채 빌드를 돌리면 fs.rmSync(dir)가 EPERM으로 터졌다. 디자인 검토를 하느라 산출물 폴더를 열어두는 일이 잦아서, 폴더는 남기고 안만 비우도록 바꿨다. 실제로 빌드가 rm -rf로 검토용 산출물(설계 문서 엑셀)을 통째로 날린 사고도 한 번 있어서, 이후로는 빌드 스크립트가 "필요한 것보다 많이 지우지 않는지"를 늘 의심하게 됐다.

진짜 벽: 공식 배포 액션이 CDN 장애로 다운

.nojekyll로 정석 문제는 풀었지만, 이번엔 Pages의 legacy 빌더 자체가 말썽이었다. 같은 커밋인데도 빌드 시도마다 결과가 달랐다. 어떤 때는 'building' 상태로 무한히 멈췄고, 어떤 때는 errored로 떨어졌으며, 새로 추가한 파일(.nojekyll을 포함해)이 라이브 사이트에 아예 반영되지 않았다. legacy 빌더는 로그도 거의 안 남아서, 무엇이 잘못됐는지 들여다볼 창조차 없었다.

그래서 modern한 GitHub Actions 기반 배포로 전환하기로 했다. actions/deploy-pages 계열을 쓰면 docs/ 변경 시 자동 트리거되고, 로그가 Actions UI에 명확히 남고, 수동 재실행도 되고, concurrency로 중복 배포도 막을 수 있다. 정공법이었다.

그런데 여기서 이 프로젝트 최대의 벽을 만났다. 배포에 쓰이는 공식 액션들이 동작하지 않았다. actions/configure-pages, actions/upload-pages-artifact, actions/deploy-pages — 이들이 내부적으로 codeload CDN에서 리소스를 내려받는데, 그 다운로드가 실패한 것이다. 내 워크플로 문법 문제도, 권한 문제도 아니었다. GitHub 인프라 쪽의 일시적 CDN 장애였다. 평소라면 "기다리면 풀린다"가 정답이지만, 검토 일정이 코앞이라 마냥 기다릴 수 없었다.

우회를 단계적으로 — SHA pin → 단계 제거 → 직접 미러링

무력하게 기다리는 대신, "같은 결과(산출물을 Pages가 서빙)를 내는 다른 경로"를 단계적으로 찾아 나섰다.

1단계 — 액션을 SHA로 고정(pin). 액션을 @v4 같은 태그가 아니라 특정 커밋 SHA로 고정해 봤다. 버전에 따라 CDN에서 내려받는 경로가 달라, 운 좋게 멀쩡한 경로를 탈 수도 있다는 기대였다. 일부는 회피됐지만 근본 해결은 아니었다.

2단계 — 문제의 단계를 제거. 가장 발목을 잡은 건 configure-pages 단계였다. 이게 CDN 장애로 다운돼 워크플로가 그 자리에서 멈췄다. 그래서 이 단계 자체를 워크플로에서 들어내고, 그 역할(Pages 소스를 workflow 모드로 전환하는 일)을 GitHub API를 직접 호출해 한 번만 처리했다.

3단계 — gh-pages 브랜치에 직접 미러링. 결국 가장 확실한 우회는, 공식 Pages 배포 파이프라인에 의존하지 않는 것이었다. docs/ 내용을 gh-pages 브랜치에 직접 git push로 미러링하면, Pages가 그 브랜치를 그대로 서빙한다. 다운로드가 실패하던 액션들은 전부 건너뛰고, 멀쩡히 동작하는 actions/checkout 하나만 쓰면 됐다. CDN 장애가 있는 구간을 통째로 우회한 셈이다.

이 워크플로의 디테일 몇 가지가 마음에 든다. docs/mktemp -d로 만든 임시 디렉터리에 복사해 거기서 새 git 히스토리를 만들고 --force로 푸시한다. 즉 gh-pages 브랜치는 항상 "최신 산출물 한 장"만 들고 있고 히스토리가 쌓이지 않는다. contents: write 권한과 secrets.GITHUB_TOKEN만으로 외부 시크릿 없이 push가 되고, concurrency: pages-deploy + cancel-in-progress: false로 배포가 겹쳐도 서로 덮어쓰지 않게 직렬화했다. 공식 액션이라는 블랙박스를 30줄짜리 셸 스크립트로 대체하니, 장애에서 자유로워졌을 뿐 아니라 무슨 일이 일어나는지 전부 보이게 됐다.

교훈: 배포 파이프라인도 "의존성"이다

이 경험이 남긴 가장 큰 깨달음은 이거다. 배포 파이프라인도 의존성이고, 의존성은 장애가 난다. 우리는 보통 npm 패키지나 외부 API만 의존성으로 생각하지만, "공식 배포 액션"도 다운로드 가능한 외부 리소스에 묶인 의존성이다. 거기에 전적으로 묶여 있으면, 그게 다운된 날 배포 자체가 멈춘다 — 내 코드는 완벽한데도.

대비책은 거창하지 않다. "같은 결과를 내는 다른 경로"를 하나쯤 알고 있는 것. Pages 서빙의 본질은 "정적 파일을 특정 위치에 두는 것"이지, "특정 공식 액션을 쓰는 것"이 아니다. legacy 빌더, modern 액션, SHA pin, gh-pages 미러링 — 이 네 가지는 전부 같은 목적지로 가는 다른 길이었다. 한 길이 막혔을 때 다른 길을 떠올릴 수 있느냐가, 일정을 지키느냐 멈추느냐를 갈랐다. 추상화의 편의에만 익숙해지면 그 아래의 본질을 잊는데, 인프라 장애는 바로 그 본질을 다시 묻는 시험이었다.

실제 장비로 — Yocto 레시피와 Apache 서빙

데모 배포가 떠들썩했던 것과 달리, 실제 장비 배포는 조용하지만 더 본질적인 작업이었다. 키오스크는 임베디드 보드 위에서 돈다. 여기에 UI를 올리는 표준 방식은 Yocto 레시피(.bb)로 패키징해 이미지에 굽는 것이다. 빌드된 docs/와 Apache vhost 설정을 묶어 하나의 패키지로 만든다.

레시피가 하는 일은 명확하다. 산출물을 Apache 문서 루트(/var/www/html)에 복사하고 권한을 755/644로 맞춘 뒤, vhost 설정을 sites-available에 설치한다. 그리고 pkg_postinst(타깃의 첫 부팅 시 실행되는 후처리)에서 필요한 Apache 모듈을 켜고, 포트 80을 두고 다투지 않도록 기본 사이트를 끄고 우리 사이트를 활성화한 뒤, 부팅 시 Apache가 자동 시작되도록 enable한다. 모든 명령에 || true를 붙인 게 임베디드의 흔적이다 — 후처리 중 한 단계가 실패해도 설치 자체는 멈추지 않게 해서, 장비가 벽돌이 되는 걸 막는다.

루트에서 서빙하기 — Apache vhost의 pretty URL

장비의 Apache는 데모와 결정적으로 다른 점이 있다. 루트(/)에서 서빙하고(그래서 BASE_PATH=''로 빌드하고), 백엔드 fetch 심도 도는 동시에, 페이지들이 /recipes처럼 슬래시 없는 URL로 들어와도 받아줘야 한다. 빌더는 /recipes/index.html 형태로 뽑는데 말이다.

리라이트 규칙이 두 단계다. 먼저 요청이 실제 파일도 디렉터리도 아니면 /<경로>/index.html이 있는지 확인하고 있으면 그리로 보낸다. 그래도 없으면 404.html로 폴백하는데, 이 404 페이지는 빌더가 index.html(메인으로 리다이렉트하는 페이지)을 그대로 복사해 만든 것이라, 잘못된 경로로 들어와도 SPA처럼 메인으로 흘러간다. 압축(mod_deflate)을 켠 것도 임베디드의 현실 때문이다 — 보드의 네트워크 링크가 넉넉하지 않아서, HTML·CSS·JS·JSON을 gzip으로 줄이는 것이 체감 속도에 직결됐다. 캐시 정책도 둘로 갈랐다. 목 JSON은 5분만 캐시해 데모 중 데이터 변경이 빨리 보이게 하고, 이미지·폰트 같은 정적 자산은 1년을 캐시해 재방문 시 네트워크를 아꼈다.

PWA 자산도 배포의 일부 — Service Worker와 BASE_PATH

마지막으로, 2편에서 만든 PWA 풀스크린도 사실은 배포 문제와 얽혀 있었다. Service Worker는 자신이 위치한 경로가 곧 scope다. /js/sw.js에 두면 scope가 /js/로 제한돼 사이트 전체를 못 덮는다. 그래서 빌더가 sw.js를 사이트 루트로 복사하도록 했고, 등록 코드에서도 BASE_PATH를 붙여 scope를 보정했다.

// public/js/install.js — BASE_PATH 보정한 Service Worker 등록
if ('serviceWorker' in navigator) {
  // 데모(하위 경로) 배포 시 prefix 필요. build.js 가 window.BASE_PATH 주입.
  var swUrl = (window.BASE_PATH || '') + '/sw.js';
  window.addEventListener('load', function () {
    navigator.serviceWorker.register(swUrl, {
      scope: (window.BASE_PATH || '') + '/'      // scope 도 BASE_PATH 기준
    }).catch(function () { /* 등록 실패해도 사이트는 정상 동작 */ });
  });
}

여기서도 같은 BASE_PATH가 흐른다. 빌드 타임에 window.BASE_PATH를 주입해 뒀기 때문에, Service Worker 등록·manifest 경로·fetch 심이 전부 하나의 진실(prefix가 있느냐 없느냐)을 공유한다. 데모(하위 경로)에서는 /<레포명>/sw.js로, 장비(루트)에서는 /sw.js로 등록된다. 등록이 실패해도 .catch로 삼켜 사이트는 정상 동작하게 한 것도 일부러다 — PWA는 "있으면 좋은" 부가 기능이지, 그게 깨졌다고 본 화면까지 죽으면 안 되니까.

배포가 코드를 바꾸지 않았는지 — E2E로 다시 묶기

배포 우회를 하다 보면 산출물에 손이 많이 간다. SHA pin을 바꾸고, 단계를 빼고, 미러링으로 갈아엎는 동안 "정작 화면 기능은 그대로인가?"가 불안해진다. 그래서 1편의 실시간 연동을 Playwright E2E로 다시 검증해, 배포 변경이 동작을 깨지 않았음을 못 박았다.

이 테스트가 검증하는 건 1편의 핵심 계약이다. WS로 들어온 라이브 필드(진행률·상태)는 실시간으로 갱신되고, WS가 건드리지 않는 정적 필드(누적 추출 횟수)는 폴링이 계속 채워야 한다. 두 번째 테스트는 한 발 더 나가 목 WS를 끊었을 때 폴링이 다시 인계받아 값이 목 API 값으로 되돌아오는지까지 본다. 배포 트러블슈팅으로 워크플로와 빌드 산출물을 한참 주물렀어도, 이 E2E가 초록불이면 "사용자가 보는 동작은 그대로"라는 확신을 가질 수 있었다. 인프라를 흔드는 작업일수록, 기능을 고정하는 테스트가 안전벨트가 된다.

정리하며

  • 같은 산출물을 데모(하위 경로)와 장비(루트) 두 곳에 배포하는 차이는 BASE_PATH 하나로 흡수했다. 경로 확정은 런타임이 아니라 빌드 타임에.
  • 백엔드 없는 정적 환경은 <head> 최상단의 fetch 심으로 GET /api/<x>/api/<x>.json으로 재작성해 흉내냈다. 분기를 화면이 아니라 빌드 층으로 끌어내렸다.
  • Pages의 Page build failed.nojekyll(빌드가 자동 생성)로 해결. 빌드는 "필요한 것보다 많이 지우지 않는지" 늘 의심할 것.
  • 공식 배포 액션이 CDN 장애로 다운됐을 때, SHA pin → 단계 제거 → gh-pages 직접 미러링으로 단계적 우회했다. 블랙박스 액션을 30줄 셸 스크립트로 대체하니 장애에서 자유로워졌다.
  • 배포 파이프라인도 의존성이다. "같은 결과를 내는 다른 경로"를 하나쯤 알고 있으면 인프라 장애에 멈추지 않는다.
  • 실제 장비는 Yocto 레시피 + Apache vhost(pretty URL·gzip·캐시 분리)로 배포했고, 후처리는 || true로 장비가 벽돌이 되지 않게 방어했다.
  • PWA Service Worker scope·manifest·fetch 심까지 전부 같은 BASE_PATH 하나를 공유한다 — 하나의 진실이 두 환경의 차이를 일관되게 흡수한다.
  • 인프라를 흔든 뒤엔 Playwright E2E로 기능을 다시 묶어, "사용자가 보는 동작은 그대로"임을 확인했다.

세 편에 걸쳐 임베디드 커피머신 키오스크 UI를 정리했다. 1편은 기기와의 실시간 연동, 2편은 키오스크다운 풀스크린 UX, 그리고 3편은 배포의 견고성. 결국 이 프로젝트가 가장 많이 가르쳐 준 건 "내 코드 밖"의 문제 — 네트워크 불안정, CDN 장애, 두 갈래 배포 환경 — 에 어떻게 대비하느냐였다.

반응형