당신의 로그에 매번 찍히는데 아무도 안 보는 줄이 있습니까?
저는 3주 동안 그 줄을 보면서 "아, 복구가 잘 되고 있네"라고 읽었습니다. 그게 원인이었습니다.
증상
USB로 물린 안드로이드 폰을 adb로 직접 조종하는 봇이 있습니다. 소셜 앱 계정 네 개를 순환하면서 계정을 전환하고 각 계정에서 짧게 일하고 넘어갑니다.
계정 전환이 8~19% 실패했습니다. 로그에 남는 모양은 두 가지였습니다.
헤더 상단바 없음(후보=[]) — reset 후 재시도 ... 42건
ensure_home 1차 실패 → 앱 리셋 재시도 ... 30건둘 다 "앱을 재시작했는데도 기대한 화면이 아니다"입니다. 전체 시도 500회에 실패 76회.
세 번 고쳤는데 실패율이 안 떨어졌습니다
먼저 찾은 것들은 전부 진짜 결함이었습니다.
- 락이 층을 잘못 잡고 있었습니다. 같은 폰을 조종하는 두 번째 잡이 있는데, 락 검사가 파이썬 안쪽에 있었습니다. 그 위의 셸이 이미
adb로 앱을 띄운 뒤였습니다. 락은 있었는데 이미 폰을 건드린 다음이었습니다. - 재시작 대기가 시계였습니다.
force-stop후 고정sleep(6). 실측해 보니 앱이 포그라운드에 오기까지 2.5~3.2초, 원하는 탭이 보이기까지 7.9~9.4초였습니다. 6초에 돌아오니 호출부는 아직 스플래시인 화면을 보고 판정했습니다. - 포그라운드 판정이 한 샷이었습니다. 다른 앱이 잠깐 앞에 나오면 즉시 포기. 그런데 그 불일치는 대부분 지나가는 상태였습니다.
세 개 다 고쳤습니다. 실측으로 기전까지 확인했습니다. 그리고 실패율은 안 떨어졌습니다.
여기서 멈춰야 했습니다. 세 번 고쳤는데 안 나아지면 그건 가설이 틀린 게 아니라 찾는 층이 틀린 겁니다.
건별로 말고 모양으로 봤습니다
실패 76건을 하나씩 읽는 대신, 버스트마다 계정 네 개의 결과를 문자 네 개로 찍었습니다. .은 성공, E는 실패.
2026-08-16 11:00 ...E
2026-08-17 06:00 ...E
2026-08-22 17:00 ...E
2026-08-23 06:00 ...E
2026-08-24 17:00 ...E
2026-08-25 13:00 ...E
2026-09-01 22:00 ...E...E가 14회로 압도적 1위였습니다. 마지막 계정 하나만 실패합니다. 그리고 그 앞줄은 언제나 직전 계정의 정상 종료였습니다.
실패는 "봇이 일하다가" 나는 게 아니었습니다. 일을 끝내고 다음 계정으로 넘어갈 때 났습니다.
그러면 질문이 바뀝니다. 세션이 끝났을 때 화면은 어디에 있나?
화면을 세어 봤습니다
세션이 끝나는 화면은 셋 중 하나입니다. 각 화면에서 봇이 "모달 닫기 버튼"으로 인식하는 노드를 세어 봤습니다. 이 봇의 탈출 루틴은 이렇게 생겼습니다.
8회 반복:
원하는 탭이 보이면 -> 성공
닫기 버튼이 있으면 -> 그걸 탭 # 모달 먼저 닫는다
없으면 -> back()DISMISS 목록에는 Not now, Close, Skip, Cancel, Dismiss 같은 라벨이 들어 있습니다. 실기기에서 센 결과입니다.
| 세션이 끝나는 화면 | "닫기"로 잡히는 노드 |
|---|---|
| 팔로워 목록 | 4개 |
| 필터 적용된 목록 | 2개 |
| 추천 목록 | 8개 (row_recommended_hide_icon_button) |
content-desc="Dismiss". 모달이 아닙니다. 행마다 붙은 "이 추천 숨기기" 버튼입니다.
누르면 추천 하나가 사라지고 화면은 그대로입니다. 그러니 다음 반복에서도 닫기 버튼이 또 있습니다. 여덟 번을 그렇게 태웁니다. back()은 한 번도 안 눌립니다.
당신이라면 어떻게 고치시겠습니까
여기서 제일 쉬운 길은 DISMISS 목록에서 "Dismiss"를 빼는 것입니다. 한 줄이면 됩니다.
쓰지 않았습니다. 라벨 목록은 개명 한 번, 언어 하나에 조용히 죽습니다. 앱이 그 버튼을 Hide로 바꾸면 같은 함정이 다시 생기고, 다른 화면의 진짜 모달이 Dismiss를 쓰면 이번엔 못 닫습니다.
대신 라벨이 아니라 개수를 봤습니다.
모달의 닫기 버튼은 화면에 하나입니다. 여러 개면 그건 모달이 아니라 목록 행입니다.
cands = find_all(root, lambda t, d: t in DISMISS or d in DISMISS)
if len(cands) == 1:
tap(*cands[0]) # 진짜 모달만 닫는다
else:
back() # 0개든 여러 개든 뒤로개수는 개명에도 다국어에도 안 흔들립니다. 라벨은 흔들립니다.
도미노를 되감으면
- 세션은 항상 저 세 화면 중 하나에서 끝납니다. → 탈출 루틴 1차 시도가 계정마다 확정 실패.
- 그래서 봇이 매 계정마다
force-stop후 콜드런치를 탑니다. - 콜드런치 직후 화면은 아직 로딩입니다. →
헤더 후보=[](42건)이거나, 루프가back()으로 앱을 나가버려ensure_home 실패(30건).
앞서 고친 세 개는 전부 3번을 고치는 것이었습니다. 레이스는 줄였지만 레이스가 벌어지는 조건은 그대로였습니다. 1번이 매 전환마다 콜드런치를 강제하고 있었으니까요.
그리고 1번의 지문은 처음부터 로그에 있었습니다. ensure_home 1차 실패 → 앱 리셋 재시도 — 시도 220회에 240건. 매 전환마다 한 번 이상입니다. 저는 그걸 "복구가 잘 되고 있다"로 읽었습니다.
복구 코드가 상시 발화하면 그건 복구가 아니라 증상입니다.
검증
세션이 끝나는 그 화면에서 시작해 실제로 전환시켰습니다.
| 검증 | 결과 |
|---|---|
| 탈출 루틴 — 세 화면 각각 | 3/3 성공, 재시작 0회 (7.9s · 12.4s · 12.5s) |
| 계정 전환 — 위 화면에서 4계정 | 4/4 성공, 재시작 0회 (27~33s) |
수정 전이면 이 여덟 건이 전부 재시작을 탔습니다. 함정 노드가 상시 존재하니 확정적입니다.
자가진단 3줄
- 당신의 복구 경로는 얼마나 자주 발화합니까? 세어 보세요. 예외 경로가 상시 경로면 그건 예외가 아닙니다.
- 라벨로 UI 요소를 찾고 있습니까? 그 라벨이 목록 행에도 붙어 있지 않은지 확인하세요. 같은 화면에 몇 개 있는지 세는 게 제일 싼 검사입니다.
- 실패를 건별로만 읽고 있습니까? 실행 단위로 결과를 한 줄 문자열로 찍어 보세요. 분포가 원인을 가리킵니다 — 저는
...E하나로 수색 범위를 500건에서 화면 셋으로 줄였습니다.
솔직한 부분
아직 판정 안 났습니다. 위 검증은 제가 손으로 만든 조건이고, 무인 운행 표본은 0입니다. 사후에 정하면 아무 결과나 성공으로 읽히니 기준을 먼저 못박았습니다.
- 기준선:
리셋 재시도1.09/전환 (240건 / 220전환) - PASS: 다음 3순환(약 12전환)에서 2건 이하. 기준선이 참이면 기대값이 13건입니다.
전환 실패율 자체는 표본이 느리게 차서(하루 16~24전환) 별도로 이틀 뒤에 봅니다. 실패가 남으면 그때는 화면 XML이 파일로 남게 해 뒀습니다.
설명 못 한 것도 남았습니다. 500건 중 4건(0.8%)은 다른 모양입니다 — 전환기 시트는 열렸는데 계정 행을 못 찾은 2건, 탭 자체가 실패한 2건. 표본이 작아 원인을 못 좁혔습니다. 이번 원인으로 뭉뚱그리지 않고 그대로 열어 둡니다.
그리고 이 조사에서 곁다리로 나온 지뢰가 하나 있습니다. 세션 함수가 중간 반환에서 튜플이 아니라 스칼라를 돌려주고 있었습니다. 호출부는 f, cold = session(...)로 받습니다. 그 경로를 타는 순간 TypeError로 순환 전체가 죽고 남은 계정이 통째로 사라집니다. 3주 로그에 한 번도 안 터졌습니다 — 아직 안 밟았을 뿐입니다.
로그에 매번 찍히는데 한 번도 의심 안 해 본 줄, 지금 하나 떠오르십니까? 그거 몇 번 찍히는지부터 세어 보세요.