Steven
Steven5 분 소요

우리 앱을 종료할 수 없게 만든 안전장치

자동 업데이트는 우리가 출시한 기능 중 가장 어려웠다 — 앱을 망가뜨리는 것을 막으려고 나흘 동안 여섯 번을 릴리스했다. 최악의 버그는 신중하려다 우리 손으로 만든 것이었다: 실패한 업데이트를 문자 그대로 종료할 수 없는 프로세스로 바꿔버린 500밀리초짜리 "안전" 검사.

엔지니어링
Electron
안정성
GeekBye 릴리스
우리 앱을 종료할 수 없게 만든 안전장치

모든 데스크톱 앱 개발자는 딱 한 번 자동 업데이트를 과소평가한다. 이미 풀린 문제처럼 보인다 — 라이브러리가 새 버전을 내려받고 앱을 재시작한다. 그러다 그것을 출시하면, "앱을 재시작한다"는 것이 프로그램에게 시킬 수 있는 가장 위험한 일 중 하나라는 것을 배우게 된다. 왜냐하면 그 일은 앱이 스스로를 허물고 있는, 오류에 대한 여유가 가장 적은 바로 그 순간에 일어나기 때문이다.

GeekBye의 자동 업데이트는 안정화하는 데 나흘 동안 여섯 번의 릴리스 — v1.5.14부터 v1.5.19까지 — 가 걸렸다. 이 글은 그 구간에서 최악이었던 버그, 우리가 신중하려다 스스로 만들어낸 그 버그에 관한 이야기다.

여섯 번의 릴리스, 그리고 정말 중요했던 하나

이 흐름은 평범하게 시작됐다. v1.5.14는 부끄러운 오타 수준의 버그를 고쳤다: 업데이트 피드가 존재하지 않는 GitHub 저장소 이름을 가리키고 있어서, 업데이터가 404를 확인하고 있었던 것이다. v1.5.15는 수동 "Check for Updates" 버튼과 제대로 된 오류 메시지를 추가했다. 그러고 나서 quitAndInstall 버그들이 시작됐고, 릴리스는 빠르게 이어졌다 — 업데이트 메커니즘이 망가지면, 그 수정본을 업데이트 메커니즘을 통해서 출시할 수 없기 때문이다. 매 반복이 수동 재설치 도박이다.

정말 중요한 것은 v1.5.18이다. 그 내용 전체가 단일 커밋이었고, 그 제목은 지금도 나를 움츠러들게 한다: restore original quitAndInstall behavior to prevent unkillable app.

"신중함"이 어떻게 앱을 망가뜨렸나

상황은 이렇다. 업데이트가 내려받아지면, Electron의 quitAndInstall이 앱을 닫고 새 버전으로 교체하기로 되어 있다. 이전 릴리스에서 누군가는 — 합리적으로 — 무조건 종료하는 것이 위험하다고 걱정했다. 설치가 오류를 낸다면? 모든 것이 건강해 보일 때만 종료하는 편이 더 안전하지 않을까?

그래서 코드에는 그럴듯해 보이는 가드가 생겼다:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

논리는 이렇다: 설치를 시작하고, 0.5초를 기다린 다음, updateDownloaded 플래그가 여전히 true일 때만 최종 app.quit()을 강제한다 — 그렇지 않으면 사용자가 발이 묶이지 않도록 앱을 열어 둔다.

함정은 한 줄 떨어진 오류 핸들러 안에 있다. 그 핸들러는 this.updateDownloaded = false로 설정했다. 그러니 실패한 설치를 그려 보라: error 이벤트가 발생하고 플래그를 지운다. 하지만 quitAndInstall이미 해체를 시작한 상태였다 — 창을 닫고 앱의 종료 리스너를 제거해 놓았다. 그러고 나서 500ms 타이머가 깨어나, 이제 false가 된 플래그를 검사하고, "오류 감지됨, 앱을 열어 두자"고 결정하며, app.quit()을 건너뛴다.

이제, 특히 macOS에서 가능한 최악의 상태가 된다. macOS는 마지막 창이 닫혔다는 이유만으로 앱을 종료하지 않는다 — 모든 Mac 앱이 의존하는 window-all-closed 동작이다. 그래서 프로세스는 여전히 살아 있지만, 창도 없고, 메뉴 바 경로도 없으며, 종료 리스너까지 뜯겨 나간 상태다. 클릭할 것이 아무것도 없다. Cmd-Q는 대화할 상대가 없다. 유일한 탈출구는 Activity Monitor에서의 Force Quit뿐이다. "안전" 검사가 실패한 업데이트 — 복구 가능한 성가심 — 를 죽일 수 없는 좀비로 바꿔 놓은 것이다.

해결책: 해체는 반드시 무조건적이어야 한다

v1.5.18의 수정은 거의 공격적으로 지루한데, 바로 그게 핵심이다. 영리함을 제거한다:

  1. 방해할 수 있는 window-all-closedbefore-quit 리스너를 제거한다.
  2. 모든 창을 파괴한다 — window.close()가 아니라 window.destroy(). close는 핸들러에 의해 거부될 수 있지만, destroy는 그럴 수 없다. 종료를 감행하기로 마음먹었다면, 정중하게 부탁하지 않는다.
  3. quitAndInstall을 호출한다.
  4. app.quit()무조건 호출한다.

플래그도, 타이머도, "혹시 모르니 열어 두자"도 없다. 종료 경로에 관한 진실은, 반쯤 끝난 종료가 어느 쪽 결과보다도 나쁘다는 것이기 때문이다. 완전히 종료되는 것은 괜찮다. 완전히 열려 있는 것도 괜찮다. 절대 도달해서는 안 되는 유일한 상태는 허물어졌지만 여전히 실행 중 인 상태다 — 그리고 그것이 바로 조건부 종료가 당신을 발이 묶이게 만드는 상태다.

같은 주에 얻은 두 가지 교훈

종료할 수 없는 버그가 헤드라인이지만, 여섯 번의 릴리스 혼란은 훔쳐 갈 만한 다른 두 가지 습관을 단단하게 만들었다.

크래시 텔레메트리를 넓은 키워드가 아니라 정확한 시그니처로 필터링하라. 혼란의 한복판에서, 우리는 오류 리포팅이 permission, token, microphone 같은 단어를 포함한 것은 무엇이든 버리도록 설정되어 있음을 발견했다 — 노이즈를 줄이려던 시도가, 그런 단어를 우연히 언급한 실제 크래시를 조용히 삼키고 있었던 것이다. 우리는 그 담요 같은 필터를 뜯어내고, 정확한 거부 문자열(권한이 거부될 때 macOS가 내는 특정 메시지)과 ERR_NETWORK_CHANGED 같은 특정 일시적 네트워크 코드로 대체했다. 노이즈 감소와 버그 은폐는 같은 손잡이를 반대 방향으로 돌린 것이다. 느낌으로 필터링하면, 봐야 했던 바로 그것을 걸러내게 된다.

모든 자동 경로에는 수동 탈출구가 필요하다. 자동 업데이트는 본질적으로 최선 노력(best-effort)이다 — 네트워크는 불안정하고, 설치는 실패한다. 그래서 각 실패 모드마다 사람의 대체 수단을 붙였다: 수동 "Check for Updates" 버튼, 지수 백오프 재시도, 재확인 타이머, 그리고 — 사용자의 수동 시도가 실패한 경우를 위해 특별히 마련한 — "앱을 삭제하고 웹사이트에서 다시 설치하세요"라는 평이한 문구의 메시지. 자동 경로는 편의이고, 수동 경로는 보증이다.

핵심 교훈

  1. 되돌릴 수 없는 동작을 감싼 가드는 그 동작 자체보다 더 위험하다. 조건부 종료는 나쁜 업데이트가 앱을 종료시키는 것을 막으려 했고, 대신 종료하는 것보다도 종료하지 않는 것보다도 나쁜 상태를 만들어냈다. 종료 경로와 설치 경로는 무조건적이고 멱등해야 한다 — 다른 핸들러가 언제든 뒤집을 수 있는 가변 플래그에 게이팅되어서는 안 된다.
  2. macOS에서 "창이 없음"은 "앱이 없음"이 아니다. 모든 해체 로직은 창 없는 프로세스가 계속 실행되는 플랫폼을 고려해야 한다. 행복한 경로만이 아니라 실패 경로를, 실제 OS에서 테스트하라.
  3. 업데이트 시스템을 통해 출시하는 기능은 업데이트 시스템을 통해 테스트할 수 없다. 그 비대칭성이야말로 자동 업데이트가 편집증적이고, 무조건적이며, 수동으로 철저히 검증된 코드를 받을 자격이 있는 이유다. 쉬운 방법으로 고칠 수 있는 것은 오직 그것이 이미 작동한 다.

이것은 결국 GeekBye v2가 된 안정성 작업의 가장 초기 챕터다. 그 길이 어디로 이어졌는지는 버전 2가 실제로 무엇을 요구하는가(v2.0.0)와, 소프트웨어를 완벽하게 출시하는 것의 해부학에 담긴 전체 흐름을 보라.

관련 글

연결이 끊겼다고 앱 전체가 죽어서는 안 된다 — 그런데 우리 앱은 죽었다
Steven
Steven5 분 소요

연결이 끊겼다고 앱 전체가 죽어서는 안 된다 — 그런데 우리 앱은 죽었다

백엔드가 회의 도중에 오프라인이 됐을 때, 그것은 단지 전사를 멈춘 게 아니었습니다 — 앱 전체를 죽였습니다. 원인은 처리되지 않은 단 하나의 이벤트였고, 수정은 열 줄이었습니다. 이것은 GeekBye가 예전에 자신을 죽이던 상황들을 통과하면서도 로그인을 유지하고 연결을 유지하도록 만든 릴리스 묶음의 이야기입니다.

엔지니어링
안정성
Electron
침묵은 하중을 견디고 있었다
Steven
Steven6 분 소요

침묵은 하중을 견디고 있었다

GeekBye v1의 마지막 두 릴리스는 같은 불편한 진실에 관한 것입니다: 진짜 네트워크 위의 실시간 전사는 무손실이 아니며, 정직한 수는 그런 척하기를 멈추는 것이라는 진실. v1.8.20은 재연결 도중 오디오 청크를 드롭하기 전에 그 모든 사본을 디스크에 간직했고, 전사의 간극을 소리 내어 표시하기 시작했습니다. v1.9.0은 대역폭을 아끼려고 침묵을 보내기를 멈췄고 — 그 침묵이야말로 문장이 끝났음을 알기 위해 전사 엔진이 쓰던 바로 그 신호였음을 발견했습니다. 무언가를 던져 버리는 일의 대가에 관한 두 릴리스입니다.

엔지니어링
Audio
안정성
PDF 라이브러리 없이 회의를 PDF로 인쇄하기
Steven
Steven7 분 소요

PDF 라이브러리 없이 회의를 PDF로 인쇄하기

GeekBye는 회의를 PDF로 내보내는데, 코드 어디에도 PDF 라이브러리가 없습니다. 보이지 않는 브라우저 창에서 HTML을 렌더링하고 그것을 인쇄합니다. 그 선택이 이 이야기의 전부입니다: 그것은 기능을 만들기 쉽게 했고, 동시에 진짜 브라우저가 가진 온갖 고장을 안겨 주었습니다 — 흰 깜빡임, URL 길이 제한, 그리고 스크린샷을 반으로 자른 페이지 나눔. 가장 못생긴 버그의 해결책은 CSS 한 줄이었습니다.

엔지니어링
Electron
데스크톱