# 무인 회귀는 성능이 아니라 복구 구조가 만든다

> 실기기를 밤새 물려 300 사이클을 무인으로 완주했다. 완주를 만든 것은 빠른 코드가 아니라 끊는 법이었다.

- 수치: 밤 하나 300사이클 유지
- 사람이 보는 화면: https://aitryed.com/ko/writing/agents/unattended-regression/
- 반대 언어: https://aitryed.com/en/writing/agents/unattended-regression/index.md
- 이 사이트 안내(AI용): https://aitryed.com/ko/llms.txt

---

**실제 휴대폰을 밤새 물려 놓고 300번을 사람 없이 완주했습니다. 완주를 만든 건 «빠른 코드»가 아니라 «끊는 법»이었습니다.**

## 「무인」이 무슨 뜻인가

밤에 걸어 두고 자면, 아침에 결과가 나와 있어야 합니다.

그런데 대부분의 자동화는 **밤새 못 돕니다.** 어딘가에서 멈춥니다. 그리고 멈추면 **아침까지 그대로** 서 있습니다.

| | 결과 |
|---|---|
| 새벽 2시에 멈춤 | **6시간 낭비.** 아침에 발견 |
| 끝까지 돌고 실패 목록만 남김 | **아침에 목록을 읽으면 됨** |

이 차이를 만드는 게 무엇인지가 이 글의 주제입니다.

## 답 — 빠르기가 아니라 「끊는 법」입니다

직관적으로는 **더 빠르게 만들면** 될 것 같습니다. 아닙니다.

> **한 곳이 실패했을 때 그 실패가 «어디까지 번지느냐»가 정합니다.**

| 실패가 | 결과 |
|---|---|
| **전체로 번지면** | 거기서 밤이 끝남 |
| **그 하나로 갇히면** | **나머지는 계속 돎** |

그리고 이 판정은 [같은 루프를 만들 때도 나왔습니다](/ko/built/android-qa/loop/index.md) — 거기서도 완주를 만든 건 속도가 아니라 실패를 가두는 구조였습니다. **성능을 두 배로 올려도 멈추는 자리가 그대로면 밤은 똑같이 끝납니다.**

## 「실패보다 나쁜 것은 대기」입니다

이게 제일 중요한 한 줄입니다.

실패는 **기록에 남고 아침에 읽을 수 있습니다.** 대기는 **아무것도 안 남습니다.**

「응답을 기다리는 중」으로 6시간 서 있으면, 아침에 보이는 건 <strong>「아직 진행 중」</strong>입니다. 실패한 것도 아니고 성공한 것도 아닙니다. **그 밤에 무슨 일이 있었는지 알 수가 없습니다.**

그래서 **기다리는 자리마다 「여기까지만 기다린다」를 정해** 뒀습니다. 시간이 넘으면 **실패로 적고 다음으로** 갑니다.

**실패로 적히면 정보가 되고, 기다리면 아무것도 안 남습니다.**

## 실패의 주어를 «미리» 정해 뒀습니다

또 하나의 장치입니다.

돌리는 검사 목록을 **이미 전부 통과하는 걸로 확인된 것**으로 잡았습니다. 그러면 실패가 나올 때 **의심할 대상이 하나로 좁혀집니다** — 제품은 어제까지 통과했으니 남는 건 제 쪽입니다.

이 장치를 [루프 쪽에서는 「공짜 판정관」이라 불렀습니다](/ko/built/android-qa/loop/index.md). 판정해 주는 물건을 따로 만든 게 아니라, **무엇을 돌릴지 고르는 것만으로** 그 역할이 생겼기 때문입니다.

그래서 아침에 목록을 열 때 **「이게 제품 문제인가 내 문제인가」를 한 건씩 가릴 필요가 없습니다.**

## 「조작이 틀리는 비용」과 「판정이 틀리는 비용」은 다릅니다

그래서 **증거를 갈랐습니다.**

| | 틀리면 | 무엇으로 |
|---|---|---|
| **조작**(누르기) | **다시 누르면 됨** — 싸다 | 화면을 보고 |
| **판정**(됐는지 확인) | **틀린 결과가 기록에 남음** — 비싸다 | **기록을 읽어서** |

비용이 다르니 **같은 증거를 쓰면 안 됩니다.** 조작은 대충 맞아도 되고, 판정은 정확해야 합니다.

## 그리고 「배터리 1%」가 통과 조건이었습니다

작은데 재미있는 발견입니다.

밤새 돌리려면 **기기가 아침까지 살아 있어야** 합니다. 그래서 밤 동안 배터리가 얼마나 줄었는지를 봤는데, **1%포인트**였습니다.

이게 **무인의 통과 조건 중 하나**가 됐습니다. 아무리 잘 돌아도 **배터리가 아침 전에 나가면 무인이 아닙니다.**

## 무인 반복이 하는 일은 「발견」이 아니라 「누적」입니다

마지막 통찰입니다.

한 번 돌려서 안 나오던 게, **300번 돌리니 나왔습니다.** 그런데 그건 **자동화가 똑똑해서가 아닙니다.**

**드물게 일어나는 일은 많이 돌려야 한 번 나옵니다.** 자동화가 하는 일은 **많이 돌리는 것**이지 잘 찾는 게 아닙니다.

이걸 구분해야 하는 이유는 — **「우리 자동화가 버그를 잡았다」와 「많이 돌렸더니 나왔다」는 다음에 할 일이 다르기** 때문입니다. 앞엣것이면 자동화를 더 똑똑하게 만들고, 뒤엣것이면 **더 많이 돌리면** 됩니다.

## 못 한 것 — 그리고 이 목록이 왜 중요한가

아래 자세한 기록에 **못 한 것이 여덟 가지** 적혀 있습니다. 여기서 한 줄만 옮겨 둡니다.

> **이 목록의 절반은 「안 재서 모른다」이지 「재 봤더니 괜찮다」가 아닙니다.**

왜 굳이 적냐면 — 무인 회귀에서 제일 위험한 문장이 <strong>「지금까지 아무 일 없었다」</strong>이기 때문입니다.

아무 일 없었던 건 **괜찮아서**일 수도, **안 보고 있어서**일 수도 있습니다. 그리고 **안 잰 자리를 이름 붙여 남기지 않으면, 그 침묵이 근거처럼 쌓입니다.**

특히 하나만 짚어 두면 — **시각적인 버그는 여전히 못 봅니다.** 판정을 기록에 둔 구조의 대가입니다. 화면이 깨져도 **기록상 정상이면 통과로 찍힙니다.** 그 자리는 사람 몫이고, **무인이 커질수록 그 사람 몫이 어디인지가 더 흐려집니다.**

> **[도판]** 한 기기 · 한 빌드 · 한 밤의 분포다. 여러 밤·여러 기기의 분산은 재지 않았다.
>
> 밤 하나의 해부. 300 사이클을 9시간 57분 동안 무인으로 돌렸고 사이클 벽시계는 최소 98.8초 중앙값 111.5초 최대 161.8초로 흔들렸다. 완주 조건은 실패 0 크래시 0 사이클당 검증 지점 26개 전부 성립이고, 배터리는 96퍼센트에서 95퍼센트로 1포인트 줄었다. 최대는 중앙값의 1.45배이며, 완주를 만든 것은 속도가 아니라 흔들림이 다음 사이클로 번지지 않은 것이다.

## 여기부터는 자세한 기록입니다

밤새 도는 자동화를 만든 것은 빠른 코드가 아니라 **끊는 법**이었다. 실기기 한 대를 USB로 물려 **300 사이클 · 9시간 57분**을 무인으로 완주했고 실패 0 · 크래시 0이었는데, 그 완주를 만든 부품은 셋이다 — 실패해도 국소적으로 끝나는 복구 사다리, 영원히 기다리지 않게 하는 끊기, 그리고 판정을 픽셀이 아니라 로그에 둔 것. 사이클은 중앙값 111.5초인데 최대 161.8초까지 흔들렸고(중앙값의 **1.45배**), 그래도 완주한 이유는 빨라서가 아니라 **흔들림이 옆으로 번지지 않아서**였다. 배터리는 96%에서 95%로 1%p 줄었다 — 소모가 충전보다 빠르면 코드가 아무리 완벽해도 밤샘은 성립하지 않으므로, 이건 성능 지표가 아니라 **통과 조건**이다. 그리고 무인이 실제로 산출한 것은 새로운 발견이 아니었다. 300 사이클 중 <strong>147회(49%)</strong>에서 같은 경고가 로그에 찍혔는데, 그 경고는 내내 거기 있었고 아무도 세지 않았을 뿐이다. **빈도는 사람이 못 세는 종류의 증거다.** 마지막으로, 이 자산이 얼마나 오래 사는지는 케이스가 무엇에 의존하느냐로 정해졌다 — 실기기용으로 쓴 126개가 전송층만 갈아끼우자 PC 빌드에서 그대로 살아났고, 그건 설계를 잘해서가 아니라 기기 어휘가 **맨 아래 한 층에만** 있었기 때문이며 반쯤 운이었다.

## 이 글이 답하는 질문

- [실기기를 무인으로 얼마나 오래 돌릴 수 있나](#무인-10시간을-만든-것은-성능이-아니라-복구-구조다)
- [안드로이드 실기기를 밤새 물려 무인으로 돌릴 수 있나](#배터리-1p-가-무인의-통과-조건이다)
- [게임 클라이언트는 UI 트리가 안 잡히는데 어떻게 조작하나](#조작이-틀리는-비용과-판정이-틀리는-비용은-다르다--그래서-증거를-갈랐다)
- [게임 QA 자동화에서 판정을 로그로 한다는 게 무슨 뜻인가](#조작이-틀리는-비용과-판정이-틀리는-비용은-다르다--그래서-증거를-갈랐다)
- [무인 야간 회귀가 도중에 멈추면 무슨 일이 생기나](#무인에서-실패보다-나쁜-것은-대기다)
- [자동화가 낸 FAIL 이 제품 문제인지 내 문제인지 어떻게 가르나](#정답을-아는-시험지로-돌리면-실패의-주어가-정해진다)
- [밤새 돌려서 실제로 무엇을 찾을 수 있나](#무인-반복이-하는-일은-발견이-아니라-누적이다)
- [실기기용 테스트 케이스를 PC 빌드에 그대로 쓸 수 있나](#기기-의존은-맨-아래-한-층뿐이었다)
- [무인 회귀가 무엇을 못 보나](#못-한-것)

런이 느릴 때 **어디가 병목인지 짚는 방법**은 [네 번 다 숫자는 맞았다](/ko/writing/measurement/baselines/index.md)에 있고, 결과가 **PASS 도 FAIL 도 아닐 때 어디에 넣는지**는 [판정 임계 편](/ko/writing/verdicts/threshold/index.md)에 있다. 이 글은 그 사이 — **사람이 안 볼 때 루프가 어떻게 살아남는가**만 다룬다.

## 무인 10시간을 만든 것은 성능이 아니라 복구 구조다

실기기 한 대를 USB로 물려 **300 사이클 · 9시간 57분**을 사람 없이 돌렸다. 실패 0, 크래시 0, 사이클당 검증 지점 26개가 300회 전부 성립했다. 이 300 사이클은 **수렴이 끝난 뒤의 스모크 런**이다 — 자동화를 고치는 루프는 따로 있었고, 그 루프가 두 사이클 연속 전건 통과에 도달한 다음에 돌린 것이 이 밤이다.

여기서 눈여겨볼 값은 완주 시간이 아니라 **분포**다. 사이클 벽시계는 중앙값 111.5초, 최소 98.8초, 최대 161.8초였다. **최대가 중앙값의 1.45배**다. 열 시간짜리 무인 런에서 어떤 사이클은 다른 사이클보다 절반 가까이 더 걸렸다는 뜻이고, 그런 흔들림은 실기기를 쓰는 한 없앨 수 없다 — 기기가 열을 받고, 서버가 잠깐 느려지고, 로딩이 한 번 튄다.

이 편차를 「최적화 대상」으로 읽었다면 손댈 곳은 성능이었을 것이다. 사이클을 111초에서 90초로 줄이는 작업 말이다. 그런데 **완주를 만든 것은 그 작업이 아니라, 그 편차가 다음 사이클로 번지지 않게 한 복구 사다리**였다. 161.8초짜리 사이클이 하나 있어도 그다음 사이클은 다시 111초에서 시작했다. 만약 한 번 늘어진 사이클이 다음 사이클의 상태를 오염시켰다면, 300회를 도는 동안 그 오염이 누적돼 어딘가에서 멈췄을 것이다.

산수로 보면 더 분명하다. 사이클을 20% 줄이면 열 시간짜리 밤이 여덟 시간이 된다. 나쁘지 않은 개선이다. 그런데 **한 사이클의 실패가 다음 다섯 사이클을 먹는 구조**라면, 밤새 그런 사고가 세 번만 나도 열다섯 사이클이 날아가고 어느 시점에는 루프가 아예 못 돌아온다. 앞의 개선은 밤을 **짧게** 만들고 뒤의 개선은 밤을 **끝나게** 만든다. 무인에서 필요한 것은 뒤쪽이고, 앞쪽만 계속하면 빠른데 아침에 죽어 있는 루프가 나온다.

한 가지는 밝혀 둔다. 이 300 사이클은 **자동화가 다 고쳐진 뒤의 런**이다. 고치는 과정은 이 숫자에 안 들어 있고, 그 루프는 아래에서 따로 다룬다. 그러니까 이 절의 「실패 0」은 「자동화가 원래 튼튼했다」가 아니라 「튼튼해진 다음에 잰 값」이라는 뜻이다.

**여기서 값을 치른 교훈은 무인의 지표가 평균이 아니라는 것이다.** 무인에서 봐야 하는 값은 「한 사이클이 몇 초냐」가 아니라 <strong>「한 사이클의 실패가 몇 사이클을 먹느냐」</strong>다. 앞의 값은 개선하면 밤이 짧아지고, 뒤의 값은 개선하지 않으면 **밤이 아예 안 끝난다.** 둘은 다른 작업이고, 순서를 바꾸면 빠른데 완주 못 하는 루프가 나온다.

## 배터리 1%p 가 무인의 통과 조건이다

9시간 57분을 USB로 물려 무인 주행한 뒤 배터리가 **96%에서 95%로** 떨어졌다. 순감 1%p다. 측정 해상도가 정수 % 한 자리라 이 값 자체는 거칠지만, 이 한 줄이 밤샘의 성립 여부를 먼저 결정한다.

이유는 단순하다. USB로 물려 놓고 돌리면 충전과 소모가 동시에 일어나는데, **소모가 충전보다 빠르면 밤샘 자체가 안 된다.** 코드가 아무리 완벽하고 복구 사다리가 아무리 촘촘해도 아침에 기기가 꺼져 있으면 그 밤은 0이다. 그래서 이 값은 성능 지표가 아니라 **통과 조건**이다 — 넘으면 좋은 값이 아니라, 못 넘으면 나머지 전부가 무의미해지는 값.

이 값을 재라고 알려 준 문서가 없었다. 자동화 글은 전부 통과율과 소요 시간을 재는데, <strong>밤샘의 첫 번째 실패 모드는 「기기가 꺼져 있었다」</strong>다. 코드 결함이 아니라 전력 수지 문제이고, 소프트웨어 지표 어디에도 안 나타난다. 리포트는 「런이 중단됨」까지만 말하고, 왜 중단됐는지는 기기를 집어 들어야 안다.

그리고 이 값은 거칠다는 것도 같이 적어야 한다. 해상도가 정수 % 한 자리라 96 → 95 는 **실제 순감이 0.01%p 일 수도 1.99%p 일 수도** 있다. 그래서 이 한 스텝으로 「그럼 며칠도 돌릴 수 있겠네」로 넘어가면 안 된다 — 1.99%p 였다면 스무 시간쯤에서 수지가 뒤집힌다. 이 글이 말할 수 있는 것은 <strong>「열 시간짜리 한 밤은 전력 수지 안에 들어왔다」</strong>까지다.

**여기서 값을 치른 교훈은 이 값이 사후에 못 재는 종류라는 것이다.** 시작 시각과 종료 시각의 배터리를 **둘 다** 찍어 두지 않으면 다음 날 아침에는 영영 모른다. 로그를 아무리 뒤져도 어제 밤 11시의 배터리는 없다. 무인 런을 설계할 때 「런 시작 전에 찍어야 하는 것」 목록이 따로 필요한 이유가 이것이고, 그 목록은 코드가 아니라 **런 자체의 준비물**이다.

## 조작이 틀리는 비용과 판정이 틀리는 비용은 다르다 — 그래서 증거를 갈랐다

이 화면은 붙잡을 게 없다. 클라이언트가 화면 전체를 네이티브 서피스 하나에 그려서, 접근성 트리를 뽑으면 **버튼도 텍스트도 좌표도 0인 빈 껍데기**가 나온다. 그래서 [조작 경로는 스크린샷 → 비전 → 좌표 탭 하나뿐](/ko/built/android-qa/driving/index.md)이고, 그 정확도를 못 믿으니 **눌러서 화면이 안 바뀌면 다시 찾는** 구조로 짰다.

문제는 같은 도구를 판정에까지 쓸 때 생긴다. 처음에 그렇게 했고, 그게 이 프로젝트에서 제일 크게 틀린 판단이었다. **조작이 틀리는 비용과 판정이 틀리는 비용은 크기가 다르다.** 조작은 틀려도 복구된다 — 잘못 누르면 화면이 안 바뀌고, 안 바뀌면 다시 찾는다. 비용은 재시도 한 번이고 그 사이클 안에서 흡수된다. 판정은 틀리면 **그대로 결과가 된다.** 되돌릴 지점이 없고, 아침에 보는 리포트에 그 값이 이미 들어가 있다. 게다가 틀린 방향이 한쪽으로 기운다 — 애매한 화면을 비전으로 보면 대개 **통과 쪽**으로 기울고, 그러면 초록불 하나가 조용히 늘어난다.

사람이 있는 실행에서는 이 차이가 안 드러난다. 애매하면 사람이 화면을 보고 판정하면 되기 때문이다. **무인에는 그 사람이 없다.** 그래서 판정을 어디에 두느냐가 「정확도 개선」이 아니라 <strong>「무인이 성립하느냐」</strong>의 문제가 된다.

> **[도판]** 조작은 눈으로, 판정은 로그로. 이 분리가 없으면 장시간 무인이 성립하지 않는다.
>
> 조작이 틀렸을 때와 판정이 틀렸을 때 그다음에 무슨 일이 일어나는지를 대조한 그림. 조작이 틀리면 화면이 안 바뀌고 다시 찾아서 복구되므로 비용이 한 번의 재시도로 끝난다. 판정이 틀리면 그대로 결과가 되어 리포트에 실리고 되돌릴 지점이 없다. 그래서 증거 우선순위를 수신 RPC와 상태 전이를 화면보다 위에 두었다.

증거 우선순위를 **«수신 RPC > 상태 전이 > 화면»** 으로 고정했다. 앞의 둘을 쓸 수 있었던 것은 순전히 처음의 단정이 틀린 덕이다. 「로그로는 내부 상태를 알 수 없다」고 적어 두고 있었는데, **막혀 있던 채널은 표준 출력 하나뿐**이었다. 엔진이 기기 안에 따로 떨어뜨리는 파일 쪽에는 상태 전이도 RPC 송수신도 빠짐없이 들어 있었고, 그 파일을 한 번 열어 본 것이 이 프로젝트에서 제일 값싸게 얻은 개선이다.

남의 기법을 검토할 때도 같은 순서가 필요했다. 어떤 디바이스 팜의 화면 덤프가 2,350ms 라는 실측을 보고 그것을 수확으로 적었는데, [코드를 전수 대조하니 내 파이프라인에 그 호출이 0건](/ko/teardowns/qa/toss-nebula/index.md)이었다. 접근성 트리가 안 잡히는 화면이라 10배 빨라질 대상 자체가 존재하지 않았다. 그리고 그 사실은 내 프로젝트 문서에 이미 적혀 있었다 — 남의 벤치마크를 읽는 데 쓴 시간이 내 문서 한 장을 여는 시간보다 길었다.

조작 쪽에도 대가가 있다. 좌표로 누르는 구조는 **정확히 눌러서 통과한 것과 대충 눌러서 통과한 것이 같은 통과로 기록된다.** 화면이 바뀌었으니 다음으로 넘어가고, 리포트에는 초록불 하나가 남는다. 지금 그 둘을 가르는 계수가 없어서 폴백이 늘어나고 있는지도 모르는데, 이건 판정을 로그로 옮겨서 해결된 문제가 아니라 **조작 쪽에 그대로 남아 있는 문제**다.

**여기서 값을 치른 교훈은 두 가지다.** 하나 — 판정을 픽셀에서 로그로 옮기고 나서야 장시간 무인이 가능해졌다. **비전 판정은 애매한 화면에서 사람을 부르는데, 무인에는 부를 사람이 없다.** 둘 — 남의 벤치마크를 재기 전에 「그 호출이 내 런에서 실제로 일어나는가」를 먼저 확인해야 한다. **2,350ms 는 실측이 맞았고 대상이 틀렸다.**

## 무인에서 실패보다 나쁜 것은 대기다

무인 런에서 제일 비싼 상태는 실패가 아니다. **멈춰서 기다리는 상태**다. 실패한 케이스는 기록을 남기고 루프가 다음으로 넘어가니 그 밤의 나머지는 살아 있다. 반면 기다리는 루프는 아침까지 한 칸도 못 나가고, 열 시간을 쓰고 얻는 것이 0이다. 같은 열 시간인데 산출이 하나는 부분이고 하나는 전무다.

제일 어려운 것이 **무증상 정지**였다. 예외도 안 나고 판정도 안 틀린 채로 루프가 그냥 서 버린다. 에러를 세는 장치는 이걸 못 본다 — 셀 대상이 애초에 없기 때문이고, 카운터를 아무리 정교하게 만들어도 결과는 같다. 그래서 [감지를 실패 수가 아니라 화면이 바뀌는지에 걸고](/ko/built/android-qa/loop/index.md), 한동안 아무 변화가 없으면 복구를 단계별로 시도하다 마지막에 **끊도록** 만들었다. 끊는 순간 그 상태는 「대기」에서 「실패」로 바뀌고, 실패는 루프가 처리할 줄 아는 상태다.

이 구조를 만들면서 알게 된 게 하나 있다. **복구 사다리와 절단은 같이 있어야 한다.** 복구만 있고 절단이 없으면 복구 시도 자체가 새로운 무한 대기가 되고, 절단만 있고 복구가 없으면 잠깐 튄 화면 하나에 밤이 끝난다. 실제로 단계를 세 칸으로 나눈 이유가 그것이다 — 가벼운 것부터 시도하다가, 다 안 먹히면 그 사이클을 버리고 다음으로 간다.

> **[도판]** 실패를 줄이는 작업과 대기를 없애는 작업은 반대 방향이다. 타임아웃을 늘리면 실패는 줄고 대기는 는다.
>
> 무인 런에서 나올 수 있는 세 상태의 대조. 실패는 에러 카운터에 1이 올라가고 로그에 남고 다음 케이스로 넘어간다. 대기는 카운터가 0인 채로 멈춰 밤새 아무것도 하지 않는다. 끊김은 화면 변화량 축이 감지해 단계 복구를 시도하다 절단한다. 가운데 대기 칸만 에러 카운터가 0이라서 에러를 세는 축으로는 영원히 잡히지 않는다.

반례가 남의 회고에 있었다. [24시간 테스트를 돌리려고 타임아웃을 전부 30시간으로 올린 사례](/ko/teardowns/qa/asleep-qa/index.md)인데, 그건 **멈춤 감지기를 끈 것**이다. 그 한 번의 테스트에 한해서는 정당하지만 상시 기본값이 되면 「멈춘 세션」과 「긴 세션」을 가를 수단이 통째로 사라진다.

같은 유형이 기기 런 밖에도 있다. **다른 시스템의 예다** — 내 작업용 하네스에서 [보조 앱이 꺼진 채 권한 훅만 살아 있으면](/ko/teardowns/harness/clawd-on-desk/index.md) 내가 거절하지 않았는데 「도구 사용이 거절됐다」가 뜬다. 실기기 런에 권한 훅은 없지만, **증상이 조용해서 무인 런을 통째로 날린다**는 성격이 같다.

**여기서 값을 치른 교훈은 감지 축을 분리해야 한다는 것이다.** 「실패를 줄이자」와 「대기를 없애자」는 반대 방향의 작업이고, 타임아웃을 늘리면 실패는 줄지만 대기는 늘어 무인에서는 그 교환이 손해다. 그리고 무증상 정지는 에러가 0인 상태라서, **에러를 세는 축으로는 영원히 안 잡힌다.** 종료 코드가 0인 채로 아무것도 안 한 런이 얼마나 비싼지는 [무인 회귀에서 제일 무서운 것](/ko/writing/agents/silent-success/index.md)에 따로 적었다.

## 정답을 아는 시험지로 돌리면 실패의 주어가 정해진다

이 절의 주어는 밤샘 300 사이클이 아니라, 그 앞에 있던 **126케이스 시트 런을 반복하며 자동화를 고친 루프**다. 두 루프는 다르다.

그 루프의 설계가 특이했다. 검증 대상 빌드를 **이미 전부 통과하는 것으로 확인된 케이스 세트**로 돌렸다. 그러면 전제가 하나 선다 — **제품이 전건 통과라면, 자동화가 내는 모든 FAIL 은 자동화 자신의 결함이다.** 자동화 도구를 만들 때 제일 어려운 질문이 「이 실패가 제품 문제인가 내 문제인가」인데, [정답을 아는 시험지로 채점하면 그 질문이 통째로 사라진다](/ko/built/android-qa/loop/index.md).

이 질문이 왜 비싼지는 겪어 보면 안다. FAIL 하나를 앞에 두고 제품 쪽을 의심하면 빌드를 바꿔 가며 재현을 해야 하고, 내 쪽을 의심하면 스크립트를 뜯어야 한다. **어느 쪽인지 모르면 둘 다 해야 하고**, 대개는 둘 다 하다가 어느 쪽도 못 끝낸다. 시험지의 정답을 미리 아는 상태로 만들면 그 갈림길 자체가 없어진다 — 모든 FAIL 의 주어가 처음부터 「나」다.

물론 공짜는 아니다. 이 배치에서는 **제품 결함을 못 찾는다.** 전건 통과가 전제이므로 제품 쪽에서 새로 나온 문제는 정의상 시야 밖이고, 이 루프의 산출은 오직 「자동화가 얼마나 튼튼해졌나」뿐이다. 그래서 이건 회귀 테스트가 아니라 **자동화의 자체 시험**이고, 두 목적을 한 런에 섞으면 FAIL 의 주어가 다시 흐려진다.

사이클 규약도 못 박았다. 돌린다 → 진단한다 → 고친다 → 원장에 적는다 → 다시 돌린다. **진단 없는 재실행은 금지.** 이 한 줄이 없으면 「한 번 더 돌려 보자」가 손이 먼저 가는 선택이 되는데, 그러면 어쩌다 통과하는 케이스가 손도 안 댄 채로 통과율에만 반영된다. 숫자는 좋아지고 자동화는 그대로다.

종료 조건도 착수 전에 적었다 — **전건 기계 판정 통과 · 차단 0 · 그게 두 사이클 연속.** 밤샘 300 사이클은 이 루프가 수렴한 **뒤에** 돌린 런이고, 그래서 실패 0이 나온 것이다. 두 숫자를 같은 루프의 것으로 읽으면 앞뒤가 안 맞는다.

**여기서 값을 치른 교훈은 끝나는 조건을 착수 전에 적어야 한다는 것이다.** 안 적으면 끝이 안 난다 — 자동화에는 늘 조금 더 손볼 데가 남아서, 기준이 없으면 「아직 완벽하지 않다」로 무한히 이어진다. **두 사이클 연속**을 붙인 것도 같은 이유다. 한 번의 전건 통과와 안정된 루프는 화면이 같고, 그 둘을 가르는 수단은 **반복 말고 없다.**

## 무인 반복이 하는 일은 발견이 아니라 누적이다

밤새 돌려서 얻은 유일한 「자동화 결함이 아닌 것」은 자동화가 똑똑해서 찾은 게 아니다. **그 신호는 내내 거기 있었고, 반복이 그걸 무시할 수 없는 크기로 키웠을 뿐이다.**

[한 밤 300 사이클 가운데 147 사이클의 로그에 같은 경고가 들어 있었다](/ko/built/android-qa/findings/index.md). 렌더 설정값 하나가 파일에는 분명히 지정돼 있는데 실제로는 반영되지 않는다는 내용이다. 새로 생긴 경고가 아니다 — 300번 내내 있었고, 사이클 하나만 놓고 보면 로그 한 줄이라 눈이 그냥 지나간다. **147/300 이라는 형태가 되고 나서야** 무시할 수 없어졌다.

> **[도판]** 빈도는 사람이 못 세는 종류의 증거다. 사람이 열 번 돌리고 다섯 번 봤다면 그건 인상이지 데이터가 아니다.
>
> 빈도 누적의 대조. 사람이 손으로 열 번 돌려 다섯 번 같은 경고를 보면 가끔 나네 정도로 남지만, 300번 중 147번이면 다른 종류의 사실이 된다. 비율은 둘 다 절반 가까이로 같은데 표본 크기가 서른 배 다르다. 빈도는 사람이 못 세는 종류의 증거다.

이게 무인 반복의 실제 산출이다. **자동화가 똑똑해서 못 보던 걸 본 게 아니다.** 판정 로직은 이 경고를 볼 줄도 몰랐고, 지금도 모른다. 달라진 것은 같은 신호를 300번 모았다는 것뿐이고, 그 모으기는 사람이 못 하는 일이다 — 사람이 손으로 300번 돌리려면 열 시간을 앉아 있어야 하고, 그렇게 앉아 있으면 로그를 300번 읽지 못한다.

여기서 비율이 아니라 **표본**이 일한다는 점이 중요하다. 사람이 열 번 돌려 다섯 번 봤어도 비율은 똑같이 절반이다. 그런데 그건 「가끔 나네」로 남고 아무 조치도 안 부른다. 같은 절반이 147/300 이 되면 **버그 리포트를 쓸 수 있는 형태**가 된다. 비율은 동일한데 한쪽만 행동을 부르는 것이고, 차이를 만든 것은 통찰이 아니라 반복 횟수다.

이 발견 뒤에 판정 항목을 하나 늘렸다. **런이 끝나면 경고 종류별 빈도표가 같이 나온다.** 중요한 것은 이 표가 **전건 통과인 런에서도 나온다**는 점이다. 전부 초록불인 런에서 무엇이 쌓이고 있었는지를 보는 유일한 창이라, 통과 여부와 분리해 두지 않으면 통과한 밤에는 아무도 안 본다. 실패한 밤에만 로그를 여는 습관은 자연스럽지만, **이 종류의 신호는 정확히 성공한 밤에 쌓인다.**

**여기서 값을 치른 교훈은 빈도표를 붙이고 나서 드러났다.** 셀 수 있게 된 것은 빈도뿐이고 **기점은 여전히 못 센다** — 이 신호가 언제부터 있었는지 아무도 모른다. 첫 밤의 로그에도 있었는지, 그 전 빌드에도 있었는지 확인할 방법이 없다. 그래서 여기서 한 칸 더 나가지 않는 것이 중요했다. 이건 제품 결함으로 확정된 것이 아니라 **「자동화 결함은 아니다」까지**다.

## 기기 의존은 맨 아래 한 층뿐이었다

**이 절의 숫자는 같은 케이스 세트를 PC 빌드로 돌린 런의 것이다.** 실기기용으로 쓴 케이스 126개를 다른 플랫폼에 옮겨야 했는데, 다시 쓰는 대신 [전송층만 갈아끼웠다](/ko/built/android-qa/index.md) — 하네스가 기기 명령을 기대하는 자리에 파일 브리지를 끼우고 명령 표면은 그대로 뒀다. 결과는 PC 빌드 풀런 126케이스 **937초 완주**였다.

통한 이유는 설계를 잘해서가 아니다. **케이스가 애초에 기기에 관심이 없었기 때문**이다. 케이스가 아는 것은 «여기를 누르고, 기다리고, 이게 떴는지 확인한다» 뿐이고, 그 문장 어디에도 안드로이드가 들어가지 않는다. 기기라는 단어는 맨 아래 통로 한 곳에만 있었고, 거기만 바꾸자 위쪽 126개가 손 하나 안 대고 넘어왔다.

그런데 이 결과를 「이식성 좋은 설계」로 읽으면 안 된다. 그 한 층에 실제로 무엇이 남아 있는지를 세어 보면 답이 나온다 — 남아 있는 것은 **기기 명령을 보내고 받는 통로 하나**뿐이고, 그게 한 층에 모여 있었던 것은 애초에 그 자리에 다른 선택지가 없었기 때문이다. 화면을 붙잡을 방법이 없어서 조작이 좌표 탭 하나로 좁혀졌고, 좁혀진 표면은 갈아 끼우기도 쉽다. **제약이 만들어 준 이식성**이지 내가 설계한 이식성이 아니다.

비교해 보면 분명하다. 접근성 트리가 제대로 나오는 앱이었다면 케이스는 당연히 그 트리를 썼을 것이다 — 「이름이 확인 인 버튼을 누른다」가 「좌표 (620, 880) 을 누른다」보다 모든 면에서 낫기 때문이다. 그리고 그 순간 케이스마다 그 플랫폼의 어휘가 박히고, 이식은 **한 층 교체가 아니라 126개 재작성**이 된다. 이번에 그 일을 안 겪은 이유는 내가 잘 참아서가 아니라 **쓸 수 있는 어휘가 처음부터 없었기** 때문이다.

그래서 이 이식이 알려 주는 것은 설계 원칙이 아니라 **점검 항목** 하나다. 케이스 본문을 열어 플랫폼 고유 단어가 몇 개나 들어가 있는지 세어 보면, 그 세트가 다음 플랫폼에서 얼마나 살아남을지가 대충 나온다. 이번 세트에서 그 숫자는 0이었다.

**여기서 값을 치른 교훈은 이 성공을 근거로 쓰면 안 된다는 것이다.** 기기 어휘가 한 층에만 있었던 건 반쯤 운이었고, **다음 플랫폼에서 같은 운을 기대할 근거가 없다.** 조작 표면이 넓은 플랫폼 — 접근성 트리가 제대로 나오는 앱 — 으로 옮기면 케이스가 그 트리의 어휘를 쓰게 되고, 그 순간 기기 의존은 한 층이 아니라 전 층에 퍼진다. 이번에 안 겪은 이유는 **처음부터 쓸 수 있는 어휘가 없었기 때문**이다.

## 못 한 것

- **단계별 소요 수치를 성능 지표로 인용할 수 없다.** [왜 못 쓰는지는 루프 쪽에 적어 뒀다](/ko/built/android-qa/loop/index.md) — 요약하면 그 숫자에 제품 시간과 내가 넣은 대기가 함께 들어 있고, 둘이 분리돼 있지 않다. 분리하려면 계측을 따로 붙여야 하는데 아직 안 붙였다. 그래서 이 글의 어떤 수치도 「제품이 몇 초」로는 못 읽는다.
- **배터리 연속 주행 한계는 안 재봤다.** 순감 1%p 는 측정 해상도와 같은 크기라 실제 순감이 0.01%p 일 수도 1.99%p 일 수도 있다. 한 스텝 변화로는 배율을 말할 수 없으므로 「며칠 연속 가능」은 이 글에 쓰지 않는다. 승격 경로는 있다 — **배터리를 %가 아니라 mAh 로 남기면 다음 밤에 잰다.**
- **좌표 폴백의 계수를 안 세고 있다.** 이 결함은 [남의 디바이스 팜이 화면에 그 계수를 띄우는 것을 보고](/ko/teardowns/qa/asleep-qa/index.md) 알았다 — 내 리포트에는 그 칸이 없다. 없으면 초록불의 **질이 떨어지는 것을 초록불로만 보게 된다.** 무인 회귀에서 제일 나쁜 종류의 눈금 부재다.
- **자동화가 대상을 얼마나 방해하는지 재본 적이 없다.** 재는 도구가 대상을 얼마나 느리게 만드는지를 같이 재서 내놓은 사례를 봤는데, 내 쪽에는 그 값이 아예 없다. 그래서 이 글의 사이클 분포에 자동화 자신의 오버헤드가 얼마나 섞여 있는지도 모른다.
- **시각 버그는 여전히 못 본다.** 판정을 로그에 둔 구조의 대가다. 텍스처가 깨져도, 이펙트가 안 나와도, 상태 전이와 RPC 가 정상이면 통과로 찍힌다. 그 자리는 사람이고, 무인이 커질수록 그 사람의 몫이 어디인지가 더 흐려진다.
- **임계값을 재측정하지 않았다.** 남의 발표에서 본 프레임·로딩 임계 몇 개는 원 자료가 스스로 「재측정 필요」로 태그해 둔 값이라 인용하지 않았고, 내 쪽에서 다시 잰 적도 없다.
- **표본이 밤 하나다.** 300 사이클은 한 기기·한 빌드·한 밤의 분포다. 여러 밤·여러 기기의 분산은 재지 않았고, 그래서 「최대가 중앙값의 1.45배」가 이 밤의 특성인지 이 셋업의 특성인지 모른다.
- **이 목록의 절반은 「안 재서 모른다」이지 「재봤더니 괜찮다」가 아니다.** 무인 회귀에서 제일 위험한 문장이 <strong>「지금까지 아무 일 없었다」</strong>라서, 안 잰 자리를 이름 붙여 남기지 않으면 그게 근거처럼 쌓인다.
