자동화 파이프라인12 분 읽기

매번 찍히던 '리셋 후 재시도'가 복구가 아니라 원인이었습니다

실기기를 조종하는 봇의 계정 전환이 3주 동안 8~19% 실패했습니다. 원인을 세 번 고쳤는데 실패율이 안 떨어졌습니다. 진짜 범인은 모든 로그에 있던 '앱 리셋 재시도' 한 줄 — 봇이 화면을 빠져나가는 대신 목록 행의 'Dismiss' 버튼을 여덟 번 누르고 있었습니다.

#automation#android#adb#debugging#gotchas
두 패널 도식. 왼쪽은 로그 한 줄 'ensure_home failed -> app reset, retry'와 '복구, 무해함'이라는 해석, 220번의 전환에 240번 찍혔다는 표기. 오른쪽은 각 행 오른쪽에 작은 x 버튼이 달린 목록과 '여덟 번 탭, 화면은 그대로', '뒤로가기 0회'.
복구 코드가 매번 발화하면, 그건 복구가 아니라 증상입니다.

당신의 로그에 매번 찍히는데 아무도 안 보는 줄이 있습니까?

저는 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

...E14회로 압도적 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. 세션은 항상 저 세 화면 중 하나에서 끝납니다. → 탈출 루틴 1차 시도가 계정마다 확정 실패.
  2. 그래서 봇이 매 계정마다 force-stop 후 콜드런치를 탑니다.
  3. 콜드런치 직후 화면은 아직 로딩입니다. → 헤더 후보=[](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줄

  1. 당신의 복구 경로는 얼마나 자주 발화합니까? 세어 보세요. 예외 경로가 상시 경로면 그건 예외가 아닙니다.
  2. 라벨로 UI 요소를 찾고 있습니까? 그 라벨이 목록 행에도 붙어 있지 않은지 확인하세요. 같은 화면에 몇 개 있는지 세는 게 제일 싼 검사입니다.
  3. 실패를 건별로만 읽고 있습니까? 실행 단위로 결과를 한 줄 문자열로 찍어 보세요. 분포가 원인을 가리킵니다 — 저는 ...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주 로그에 한 번도 안 터졌습니다 — 아직 안 밟았을 뿐입니다.

로그에 매번 찍히는데 한 번도 의심 안 해 본 줄, 지금 하나 떠오르십니까? 그거 몇 번 찍히는지부터 세어 보세요.

관련 글