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)와, 소프트웨어를 완벽하게 출시하는 것의 해부학에 담긴 전체 흐름을 보라.

관련 글

AI 노트테이커는 왜 회의 중간에 녹음을 멈추는가
Steven
Steven4 분 소요

AI 노트테이커는 왜 회의 중간에 녹음을 멈추는가

우리 앱이 상대방이 한창 말하는 도중에 우리 회의 두 건을 스스로 끝내버렸습니다. 포렌식 추적의 끝에는 당신 목소리밖에 듣지 못하던, 선의로 만든 유휴 타이머가 있었고 — 데스크톱 전체를 잠가버릴 수 있는 두 번째 버그도 나왔습니다. 둘 다 GeekBye v2.0.9에서 고쳤습니다.

안정성
회의
엔지니어링
AI 전사는 왜 기술 용어를 잘못 알아듣는가 (그리고 우리는 어떻게 고쳤나)
Steven
Steven5 분 소요

AI 전사는 왜 기술 용어를 잘못 알아듣는가 (그리고 우리는 어떻게 고쳤나)

라이브 세션이 "what is the pointer in C++"를 "what is the point in life"로 받아적었습니다. 그 전사 기록에서 GeekBye v2.0.11까지 이어진 포렌식 추적기 — keyterm 바이어싱, 연결을 끊어버리던 타이밍 레이스, 그리고 우리의 수정이 역효과를 낸 날의 이야기입니다.

전사
안정성
엔지니어링
소프트웨어를 완벽하게 출시하는 해부학: 테스트가 놓친 것을 코드 리뷰가 잡아낸 방법
Steven
Steven5 분 소요

소프트웨어를 완벽하게 출시하는 해부학: 테스트가 놓친 것을 코드 리뷰가 잡아낸 방법

GeekBye v2 시리즈 전반에 걸쳐 똑같은 일이 계속 벌어졌습니다. 개발자의 머신에서는 모든 테스트를 통과한 수정이, 코드 리뷰에 이르러서는 거의 모든 사용자에게 실패했을 것이라는 사실이 증명됩니다. 이것은 아홉 번의 릴리스 뒤에 있었던 작업 흐름 — 리뷰 게이트, 수정보다 먼저 잡아내는 진단, 그리고 "내 컴퓨터에선 되는데"를 "된다"로 바꿔놓는 출시 전 테스트 규율 — 의 이야기입니다.

엔지니어링
프로세스
코드 리뷰