배포와 인프라8 분 읽기

테스트 200개가 통과했고, 버튼은 아무것도 하지 않았습니다

영상은 실제로 만들어졌습니다. 두 플랫폼에서 mp4 바이트까지 확인했고 웹은 이미 프로덕션에 올렸습니다. 그런데 iOS에서 자랑하기를 누르면 아무 일도 일어나지 않았습니다.

#testing#verification#ios#reality-check#gotchas#first-principles
엔진은 검증됐다: 유닛 200개 통과, iOS 1,409,864 바이트, Android 2,353,253 바이트 mp4 생성 확인. 경로는 검증되지 않았다: 공유 시트가 뜨지 않았고, 새 랜딩 페이지로 가는 링크가 코드에 0건이었다.
엔진을 직접 부르는 테스트는 엔진 테스트지 기능 테스트가 아닙니다.

걸어서 딴 영토를 8초 영상으로 만들어 공유하는 기능을 만들고 있었습니다. 연출은 웹에 한 벌만 두고, 앱은 화면 밖 WebView로 그 페이지를 띄워 영상을 받아오는 구조입니다.

검증은 꼼꼼히 했다고 생각했습니다.

웹 유닛 테스트      200개 통과
iOS 유닛 테스트     326개 통과
iOS 실기기 렌더     1,409,864 바이트 mp4, 진행률 1.0, ftyp 확인
Android 렌더        2,353,253 바이트 mp4, 진행률 1.0, ftyp 확인

두 플랫폼에서 실제 mp4 파일이 나왔습니다. 바이트 수를 셌고, mp4 헤더의 ftyp 박스까지 확인했습니다. 웹은 프로덕션에 배포했습니다.

그리고 배포 직전에 돌린 코드 리뷰가 이렇게 말했습니다.

iOS에서 mp4를 다 만들고도 공유 시트가 뜨지 않습니다.

무엇을 검증했던 걸까

제 엔드투엔드 테스트는 이렇게 생겼습니다.

let renderer = TerritoryReelRenderer()
let url = try await renderer.render(payload: payload) { ... }
XCTAssertGreaterThan(data.count, 200_000)

렌더러를 직접 불렀습니다. 엔진은 완벽하게 증명됐습니다. 그런데 사용자는 렌더러를 부르지 않습니다. 사용자는 버튼을 누릅니다.

버튼을 누르면 이렇게 됩니다. 클레임 결과 시트가 떠 있는 상태에서 → 자랑하기를 누르고 → 몇 초 뒤 렌더가 끝나면 → 공유 시트를 띄웁니다. 그런데 SwiftUI는 같은 뷰에서 두 번째 시트를 조용히 버립니다. 클레임 시트가 아직 떠 있으니 공유 시트는 나타나지 않습니다. 에러도, 로그도, 크래시도 없습니다.

더 아픈 건 이겁니다. 같은 파일 84행에 제가 몇 달 전 남긴 주석이 있었습니다.

// A sheet may already be up. SwiftUI silently drops a
// second concurrent sheet, so dismiss any open one first…

알고 있었고, 적어뒀고, 그대로 반복했습니다.

두 번째: 아무도 갈 수 없는 페이지

같은 리뷰가 하나 더 짚었습니다.

저는 공유 링크가 도착할 랜딩 페이지를 만들었습니다. /t/<영토id> 라우트, 지도 배경 OG 카드, 그 영토만 조회하는 DB 함수, 마이그레이션까지. 꽤 공들였습니다.

그런데 공유 문구에 붙는 링크는 이랬습니다.

https://plotta.ootssu.com

루트 도메인입니다. /t/ 를 만드는 코드를 전체 검색하면 0건이었습니다. 랜딩도, OG 카드도, RPC도, 마이그레이션도 전부 아무도 도달할 수 없는 죽은 코드였습니다. 유통이 목표인 기능에서 링크 쪽 절반이 통째로 없었던 셈입니다.

만든 것과 닿는 것은 다릅니다.

당신이라면 어디를 먼저 의심하겠습니까

여기서 잠깐 생각해볼 만합니다. 테스트가 200개 통과하고, 실제 산출물까지 손에 쥐었고, 배포까지 했습니다. 그 상태에서 "기능이 동작하지 않는다"는 말을 들으면 어디를 봅니까?

저는 렌더러를 봤을 겁니다. 그게 제가 검증한 곳이고, 그래서 자신 있는 곳이니까요. 실제 문제는 검증하지 않은 네 줄짜리 화면 전환 코드에 있었습니다.

왜 통과했나

세 가지가 겹쳤습니다.

첫째, 유닛 테스트는 화면 전환을 모릅니다. 코디네이터가 shareItems 를 채우는 것까지는 테스트했습니다. 그걸 실제로 화면에 띄우는 코드는 테스트 밖입니다.

둘째, 엔드투엔드가 엔진에서 시작했습니다. 진입 버튼이 아니라 렌더러에서 출발했으니, 버튼과 렌더러 사이의 모든 것이 검증 범위 밖이었습니다.

셋째, 새 라우트를 만들면서 "거기로 가는 링크"를 검증 대상으로 생각하지 않았습니다. 페이지가 200을 반환하는 건 확인했습니다. 그 URL을 만드는 코드가 있는지는 확인하지 않았습니다.

자가진단 3가지

지금 만들고 있는 기능에 대입해 보세요.

  1. 당신의 엔드투엔드 테스트는 어디서 시작합니까? 사용자가 누르는 버튼입니까, 아니면 당신이 자신 있는 함수입니까?
  2. 새로 만든 라우트·화면으로 가는 링크가 코드에 존재합니까? grep 한 번이면 됩니다. 200을 반환한다는 건 도달 가능하다는 뜻이 아닙니다.
  3. 주석으로 남겨둔 함정을 그 파일에서 다시 밟고 있지 않습니까? 제 경우엔 같은 파일 84행에 답이 적혀 있었습니다.

솔직한 부분

배포 전 코드 리뷰가 유일한 방어선이었습니다. 돌리지 않았다면 "동작하지 않는 기능"을 그대로 앱 심사에 올렸을 겁니다.

초록불이 "동작한다"를 뜻하지 않는 걸 겪은 게 처음도 아닙니다 — 워크플로는 2주 내내 초록불이었고, 사이트는 2주 내내 낡아 있었습니다. 반대 방향으로 틀린 적도 있습니다: 테스트를 전부 통과시켰더니 프로덕션에 유저가 생겼다.

그리고 저는 이 교훈을 배운 직후에 또 틀렸습니다. 진입점을 프로필과 지도로 넓히면서, 지도에서 시작한 공유에 같은 시트 가드를 빠뜨렸습니다. 두 번째 리뷰가 그걸 잡았습니다. 같은 세션에서 같은 계열로 두 번입니다.

수동 확인은 다음 리팩터링을 넘지 못합니다. 그래서 진짜 해결은 "다음엔 조심하자"가 아니라, 진입 버튼에서 시작하는 검증을 처음부터 세우는 것입니다.

당신의 기능은 마지막으로 버튼부터 검증된 게 언제입니까?

관련 글