엔지니어링 노트설치·운영과 품질

데스크톱 앱이 화면만 받을지 설치본까지 받을지는 셸 지문 하나로 정한다

화면이 설치본 안에 들어 있는 데스크톱 앱은 버튼 문구 하나를 고쳐도 설치본을 새로 내야 합니다. Windows 사용자는 릴리스마다 설치본을 받고 재시작 안내를 봅니다. 설치본 교체를 사내 절차로 관리하는 고객사는 그 절차를 릴리스마다 다시 밟습니다. 화면을 설치본 밖으로 빼서 서버에서 받게 하면 이 비용은 사라집니다. 대신 새 화면이 옛 설치본 위에서 돌다가 특정 기능에서만 조용히 실패하는 조합이 생깁니다.

이 조합을 막으려면 두 가지를 판정해야 합니다. 앱은 서버의 새 화면을 받아도 되는지 판정하고, 릴리스 파이프라인은 이번 릴리스가 설치본을 내야 하는지 판정합니다. OwlAI 데스크톱 앱은 두 판정에 같은 값 하나를 씁니다. 설치본에 실제로 들어가는 파일의 내용 해시인 셸 지문입니다. 이 글은 그 값에 무엇을 넣었는지, 그리고 두 판정을 왜 한 값으로 묶었는지 설명합니다.

셸 지문 하나가 화면 수신과 설치본 출고를 함께 정한다
그림 1. 셸 지문 하나가 화면 수신과 설치본 출고를 함께 정한다

화면을 서버에서 받으면 새 화면이 옛 셸 위에서 돈다

데스크톱 앱은 Electron으로 만들었고 두 부분으로 나뉩니다. 셸은 설치본에 들어가는 부분입니다. Electron 런타임(Chromium과 Node), 내 PC의 폴더와 파일을 다루는 동작, 화면과 셸이 주고받는 IPC(프로세스 간 호출), 업데이트 로직, 앱이 믿는 서명 공개키 목록이 셸에 있습니다. 화면은 웹과 같은 React 번들이고, 채팅·회의·플레이북 같은 기능 화면이 모두 여기 들어 있습니다.

앱은 화면을 접속한 서버에서 받습니다. 서버의 프론트엔드 이미지가 화면 zip과 서명된 매니페스트를 내주고, 앱은 설치본에 실린 공개키로 서명을 확인한 번들만 설치합니다. 이 갱신을 화면 번들 업데이트(OTA, over-the-air)라고 부릅니다. 앱은 시작하고 30초 뒤와 그 뒤 4시간마다 서버를 확인하고, 서버의 화면 판이 지금 화면과 다르면 새 번들을 받습니다.

셸과 화면을 나누면 둘이 늘 같은 판이라는 보장이 사라집니다. 서버는 이미 새 판인데 사용자의 셸은 아직 옛 판일 수 있습니다. 새 화면이 셸의 파일 쓰기 동작에 인자 하나를 더 넘긴다고 해 봅시다. 옛 셸은 그 인자를 모르니 무시하고 예전 방식대로 파일을 씁니다. 화면은 정상으로 뜨고 오류도 나지 않으며, 결과만 틀립니다.

손으로 올리는 셸 버전은 이름이 그대로인 변경을 놓친다

흔한 해법은 셸에 버전 번호를 붙이고, 화면 번들에 「이 화면이 요구하는 최소 셸 버전」을 적는 것입니다. OwlAI 셸에도 이 번호가 있습니다. 셸이 화면에 내놓는 API의 이름 목록을 스냅샷으로 저장해 두고, 이름이 바뀌었는데 번호를 올리지 않으면 테스트가 실패합니다. 화면이 새로 생긴 API를 부르는 경우는 이 번호로 막힙니다.

이 번호는 앞 절의 예를 잡지 못합니다. 이름은 그대로 두고 인자나 셸 안쪽 동작만 바꾼 변경은 이름 목록을 바꾸지 않기 때문입니다. 사람이 번호를 올리는 규칙으로 메우려 해도, 빠뜨리는 순간 앞 절의 조용한 실패가 다시 생깁니다.

설치본을 낼지 정하는 쪽에도 흔한 해법이 있습니다. git 변경 내역에서 셸 디렉터리가 바뀌었는지 보는 것입니다. 계산은 단순하지만 두 가지가 걸립니다. 화면 번들을 만드는 프론트엔드 이미지 빌드는 Docker 안에서 돌고 거기에는 .git이 없습니다. 그리고 앱의 판정과 릴리스의 판정이 서로 다른 근거로 계산되면, 둘이 엇갈리는 릴리스가 생깁니다. 셸은 바뀌었다고 화면은 거절하는데 설치본은 나가지 않으면, 그 릴리스의 화면은 어느 앱에도 내려가지 않습니다.

그래서 셸 버전 번호는 이름 목록 검사로 남기고, 그 위에 파일 내용으로 계산하는 지문을 하나 더 둡니다. 앱과 릴리스는 모두 이 지문을 봅니다.

셸 지문은 설치본에 들어가는 것만 해시한다

셸 지문은 설치본에 실제로 들어가는 것의 내용 해시입니다. 입력은 다음 네 묶음입니다.

입력 넣는 이유
셸 코드 디렉터리 전체(테스트·산출물 제외), 지문 계산 스크립트 자신, 패키징 설정, 설치본 아이콘 셸 코드가 한 줄만 바뀌어도 지문이 바뀐다. 계산 방식이 바뀌어도 바뀐다
package.json의 셸 부분: 이름, 타입, dependencies, Electron과 electron-builder 판 설치본에 실리는 런타임과 의존성
잠금 파일에서 dependencies가 실제로 설치하는 패키지 전부(하위 의존성까지)와 Electron 판 package.json의 범위 표기(^6.8.3)가 같아도 실제 설치 판이 바뀌면 설치본 안의 코드가 바뀐다
화면 번들 서명 키 목록의 릴리스용 키, 셸 업데이트 피드의 서명 공개키 키를 바꾸는 릴리스는 새 키를 믿는 셸이 퍼져야 한다

마지막 줄 덕분에 서명 키 교체를 따로 챙기지 않아도 됩니다. 키 목록이 바뀐 릴리스는 지문이 바뀌니 설치본이 나갑니다. 깔린 앱 대부분이 새 키를 믿는 셸로 올라온 뒤에 서명 키를 바꾸면, 옛 셸이 새 키로 서명된 화면을 거절하는 일도 생기지 않습니다.

화면 전용 의존성은 devDependencies에 둡니다. 번들러가 그것을 화면에 묶어 넣으므로 설치본에 실을 필요가 없고, 지문에도 들지 않습니다. 화면의 차트 라이브러리만 올린 릴리스가 화면 업데이트로만 나가는 것은 이 배치 덕분입니다. 실수로 dependencies로 옮기면 설치본 안의 앱 아카이브(app.asar)가 커집니다. 셸 의존성만 실리면 약 23MB인데, 빌드는 60MB를 넘으면 실패합니다.

같은 커밋이면 설치본 빌드에서도 이미지 빌드에서도 같은 지문이 나와야 합니다. 그래서 빌드 중에 바뀌는 값은 빼거나 맞춥니다.

  • 태그 빌드는 package.json에 버전을 넣으면서 키 순서를 바꿉니다. 지문은 키를 정렬한 JSON을 보고 버전 칸은 뺍니다.
  • 태그 빌드는 서명 키 목록 파일을 정리합니다. 지문은 그 파일을 그대로 해시하지 않고 릴리스용 키만 셉니다.
  • Windows 빌드 호스트의 체크아웃은 줄바꿈이 다릅니다. 텍스트 파일은 줄바꿈을 맞춘 뒤 해시합니다.

입력 파일이 하나라도 없으면 계산은 실패합니다. 빠진 파일을 건너뛰고 다른 값을 내면 두 빌드의 지문이 아무도 모르게 갈라지기 때문입니다.

앱은 지문이 같은 화면만 받는다

화면 번들은 자기를 빌드한 소스의 셸 지문을 매니페스트에 싣고, 셸은 빌드할 때 계산한 자기 지문을 설치본 안에 들고 있습니다. 앱은 두 값이 같을 때만 그 화면을 받습니다. 셸이 바뀐 릴리스의 화면은 옛 셸에 내려가지 않으므로 앞에서 본 조합이 생기지 않습니다.

지문이 다를 때 앱이 하는 일은 서버 판에 따라 갈립니다.

  • 서버가 이 설치본보다 새 릴리스면 지금 화면을 유지하고 「최신 업데이트를 받으려면 앱을 업데이트해야 합니다.」를 보여 줍니다. 동시에 셸 업데이트 확인을 바로 한 번 당깁니다.
  • 서버가 이 설치본보다 옛 릴리스로 되돌아갔으면 아무 안내 없이 지금 화면을 유지합니다. 셸을 내리는 업데이트는 받지 않으므로 「앱을 업데이트하라」는 안내는 거짓이 됩니다.

지문이 같고 화면 판만 다르면 앱은 서버를 따라갑니다. 서버가 옛 판으로 되돌아가면 화면도 그 판으로 내려갑니다.

릴리스는 지문이 바뀔 때만 설치본을 낸다

릴리스 파이프라인은 지난 게시가 다운로드 디렉터리에 남긴 「깔린 셸의 지문」을 읽고, 이번 소스의 지문과 비교합니다. 같으면 Windows·macOS·Linux 설치본 빌드가 빌드 없이 성공으로 끝나고 게시도 건너뜁니다. 서버 판과 화면 번들은 그대로 나갑니다. 다르면 세 플랫폼의 설치본을 만들어 게시하고, 그 지문을 다음 릴리스가 읽을 수 있게 남깁니다.

판정이 애매하면 설치본을 냅니다. 애매한 경우는 다음과 같습니다.

  • 지난 게시의 피드나 깔린 셸의 지문을 읽지 못할 때(지문을 남기기 전에 게시된 설치본 포함)
  • 플랫폼별 피드의 설치본 버전이 서로 다를 때
  • 같은 태그를 다시 돌릴 때
  • 판정 스크립트가 실패할 때

애매할 때 설치본을 내는 것은 두 실수의 비용이 다르기 때문입니다. 필요 없는 설치본을 한 번 더 내면 사용자는 설치본을 한 번 더 받고 재시작합니다. 내야 할 설치본을 빠뜨리면 모든 앱이 그 릴리스의 화면을 거절하고, 새 셸은 어디에도 없으니 앱은 다음 릴리스가 설치본을 낼 때까지 옛 화면에 머뭅니다.

셸 지문 비교로 설치본을 낼지 화면만 낼지 판정
그림 2. 셸 지문 비교로 설치본을 낼지 화면만 낼지 판정

게시 직전에는 세 값을 대조하고, 하나라도 다르면 게시하지 않습니다.

  1. 빌드된 설치본에 실린 지문이 빌드 전에 계산한 소스의 지문과 같은가
  2. 세 플랫폼의 판정과 지문이 같은가
  3. 그 지문이 같은 태그로 만든 서버 화면 번들의 지문과 같은가

1번은 태그 빌드가 넣는 버전이나 키 목록 정리가 지문에 섞이지 않았는지 봅니다. 섞인 설치본이 나가면 그 앱은 자기 서버의 화면을 모두 거절합니다. 3번이 어긋난 채로 게시되면 모든 앱에 「앱을 업데이트해야 합니다」가 뜹니다.

구축형 서버는 업데이트가 완료로 끝난 뒤에야 그 판의 설치본을 앱에 내줍니다. 반입만 한 판이나 적용 도중 실패해 되돌린 판의 설치본은 내주지 않습니다. 설치본이 없는 판(화면만 바뀐 릴리스)으로 올라가면 서버는 직전에 내주던 설치본을 계속 내줍니다. 앱 쪽의 셸은 접속한 서버의 피드 하나만 보고, 그 피드의 서명이 설치본에 실린 릴리스 키로 확인될 때만 업데이트를 받습니다.

설계에서 고른 것

설치본 판정과 화면 수신 판정을 따로 두는 대신 한 값으로 묶었습니다. 따로 두면 각 판정을 그 목적에 맞게 다듬을 수 있습니다. 그러나 두 판정이 한 번이라도 엇갈리면 화면이 어느 앱에도 내려가지 않는 릴리스가 생기고, 그 상태는 다음 설치본이 나갈 때까지 이어집니다. 한 값을 쓰면 지문이 바뀐 릴리스에는 언제나 설치본이 나갑니다.

git 변경 내역 대신 파일 내용으로 지문을 계산했습니다. git으로 계산하면 정규화할 것이 적습니다. 그러나 .git이 없는 이미지 빌드에서도 같은 값이 나와야 해서, 빌드 중에 바뀌는 값을 하나씩 빼는 비용을 냈습니다. 정규화를 하나 빠뜨리면 지문이 갈라지는데, 게시 전 대조 1번과 3번이 그때 게시를 멈춥니다.

셸은 빌드 때 환경 변수를 읽지 않습니다. 빌드 환경 변수로 셸 동작을 바꾸면 설정이 유연하지만, 그 값은 지문에 들지 않아 바뀌어도 설치본이 다시 나가지 않습니다. 그래서 셸 동작은 셸 디렉터리 안의 파일과 패키징 설정에만 두고, 테스트가 빌드 때 도는 셸 코드에서 환경 변수 읽기를 막습니다.

설치본을 일부러 내야 할 때는 파이프라인 스위치 대신 셸 버전 파일을 올리는 커밋을 넣습니다. 이 파일이 셸 디렉터리 안에 있어 지문이 바뀝니다. 「설치본 강제」 스위치가 손은 덜 가지만, 스위치를 켠 릴리스는 지문이 그대로인 채 설치본만 나가 두 판정의 근거가 갈라집니다. 소스 밖에서만 바뀌는 값이 생기면 이 커밋을 사람이 넣어야 하는 것이 대가입니다.

설치본 교체를 사내 절차로 관리하는 환경이라면, 설치본이 나가는 릴리스는 셸 지문이 바뀐 릴리스뿐이라는 점을 그 절차의 기준으로 삼을 수 있습니다. 앱 정보 화면은 화면과 설치본의 판이 다르면 「버전 {화면} (설치본 {셸})」처럼 둘 다 보여 줍니다. 설치본 판이 그대로라면 그 릴리스는 화면만 바꾼 것입니다.

어떤 문제를 해결하나요?

설치·운영과 품질의 다른 글