뜯어본 생각공개

토스 네뷸라 디바이스 팜

가져올 기법이 0종이었습니다. 대신 남의 원칙 하나가 제 코드의 갈라진 곳을 찾아줬습니다.

한 회사가 휴대폰 수백 대를 서버실에 꽂아 두고, 직원 누구나 인터넷으로 빌려 쓸 수 있게 만든 시스템입니다. 잘 만들었는데 제가 가져올 기법은 0개였습니다. 그런데 이 조사가 헛되지 않았습니다.

① 클라이언트 — 웹 콘솔 · SDK · CLI · API 직접 호출 모든 입구가 공개 API 하나만 쓴다 ② 서버 — 오케스트레이션 점유 · 배정 · 해제 분산 락 한 기기에 두 테스트가 붙는 간섭 차단 실행 요청 큐 · 러너 여러 대 실행량이 늘면 러너를 늘려 수평 확장 ③ 에이전트 — 호스트에서 도는 다리. 연결 기기를 자동 발견해 중계 iOS 는 맥 · 안드로이드는 리눅스 — 호스트가 갈린다 ④ 기기 — 1대당 상주 컨트롤러. 직접 만든 계층이고 이 글의 핵심이다
기기 수백 대를 서버실에 24시간 물려두고, 전사 누구나 API 호출 하나로 점유해 조작하는 사내 공용 인프라.

「휴대폰 도서관」을 회사 안에 만든 이야기입니다

앱을 만들면 여러 기종에서 다 잘 도는지 확인해야 합니다. 화면 크기도 다르고 안드로이드 판도 다르고 제조사마다 다릅니다.

그래서 회사마다 테스트용 휴대폰을 사서 쌓아 둡니다. 그런데 팀마다 따로 쌓아 두면 이렇게 됩니다.

  • 옆 팀에 있는 기종이 필요한데 어디 있는지 모릅니다
  • 찾아가서 누가 쓰고 있는지 물어봐야 합니다
  • 같은 기종을 팀마다 따로 삽니다

이 회사는 그걸 한곳에 모아 놓고 도서관처럼 빌려 쓰게 만들었습니다. 자리에 앉아서 「저 기종 하나 주세요」 하면 원격으로 붙습니다. 휴대폰 15대로 시작해 1년 만에 100대를 넘겼습니다.

제일 큰 결정 — 「남의 것을 고쳐 쓰는 대신 바닥부터 새로 만들었다」

이미 나와 있는 도구들이 있는데 안 썼습니다. 이유가 셋이었습니다.

걷어낸 것 왜
한 번 쓸 때마다 「연결 절차」를 밟는 방식 연결에만 매번 15~40초. 기기가 많아질수록 실패가 급증
화면을 내 컴퓨터 프로그램으로 받는 전제 이 사람들은 웹 브라우저로 여러 명이 동시에 봐야 했음
화면 녹화가 케이블을 독차지하는 방식 독차지하면 보는 것과 조작하는 것을 동시에 못 함

세 번째가 재미있습니다. 독차지 안 하는 방식도 있긴 한데, 그러면 화면이 초당 10~15장짜리 슬라이드쇼가 됩니다. 손가락으로 끄는 조작이 사실상 불가능합니다.

「13.5배 빠르다」는 숫자는 그대로 인용하면 안 됩니다

이 시스템의 간판 숫자가 「버튼 클릭 52밀리초 대 702밀리초 = 13.5배」입니다.

빠른 건 사실입니다. 그런데 이 숫자를 「우리 게 13.5배 좋다」로 읽으면 안 됩니다. 비교한 두 쪽이 같은 일을 하고 있지 않기 때문입니다. 한쪽은 연결 절차를 매번 밟고 한쪽은 안 밟습니다. 절차를 없앤 만큼 빠른 것이지 같은 일을 13.5배 잘하는 게 아닙니다.

제가 채택했던 것을 다음 판에서 스스로 기각했습니다

여기가 이 편에서 제일 값나간 자리입니다.

처음 이 글을 쓸 때 하나를 가져오겠다고 적었습니다. 「화면 위 요소를 이름으로 찾아서 누르는 방식」이었습니다. 실제로 그게 좌표로 누르는 것보다 훨씬 정확합니다.

두 번째 판을 쓰면서 제 코드를 다시 봤습니다. 그 방식을 쓰는 호출이 제 작업에 0건이었습니다.

이유가 있었습니다. 제가 다루는 건 게임 화면입니다. 일반 앱은 화면에 버튼·글자 같은 부품이 각각 올라가 있어서 이름으로 찾을 수 있는데, 게임은 화면 전체를 그림 한 장처럼 통째로 그립니다. 그래서 이름으로 찾을 부품이 애초에 없습니다.

더 뼈아픈 건 이겁니다 — 그 사실이 제 프로젝트 문서에 이미 적혀 있었습니다. 제가 쓴 것을 제가 안 읽고 남의 것을 가져오려 한 것입니다.

그래서 배워 온 건 0개인데, 고칠 것이 하나 나왔습니다

가져올 기법은 없었지만, 이 사람들이 세운 원칙 하나를 자로 삼아 제 코드를 재 봤습니다. 원칙은 이랬습니다.

모든 도구가 공개된 입구 하나를 통해서만 동작한다.

입구가 하나면 그 입구에만 안전장치를 걸면 됩니다. 제 것을 재 보니 이렇게 나왔습니다.

겹쳐 쓰기 방지 오류 분류 실제 실행이 쓰는 길인가
제가 만든 정식 입구 있음 있음 ✗ 안 씀
실제로 쓰이던 다른 길 없음 없음 ✓ 이쪽만 씀

정리하면 이렇습니다. 안전장치를 제대로 만들어 뒀는데, 실제 실행은 그 문을 안 지나가고 있었습니다.

이런 결함은 화면이 조용합니다. 장치가 있으니 없다고 경고가 뜨지도 않고, 안 지나가니 작동하지도 않습니다. 남의 원칙을 자로 대 보기 전에는 보이지 않았습니다.

그 뒤 — 찾은 결함은 절반만 고쳤습니다 (2026-09 점검)

고친 절반. 실제 실행이 지나가는 길에 「한 번에 한 실행만」 잠금을 걸었습니다(2026-08-26). 기기는 한 대인데, 두 번째 실행은 이제 기기를 건드리기 전에 거부됩니다.

남은 절반. 입구는 아직 두 벌입니다. 실제 실행이 쓰는 쪽에는 명령 하나하나를 줄 세우는 장치도, 오류를 종류별로 나누는 장치도 여전히 없습니다. 못 합친 이유가 있습니다 — 그 사이에 실행 시간을 재는 계측을 실제로 쓰이는 쪽에 달았는데, 입구를 합치면 그 계측이 오류 없이 0건을 내기 시작합니다. 계측부터 옮겨야 합칠 수 있습니다.

그 계측이 답도 하나 냈습니다. 한 번 실행한 시간의 32.1%가 화면 캡처였고, 한 장에 약 3.3초가 걸렸습니다(2026-08-12). 그래서 캡처를 영상 스트림으로 통째로 옮기려다 멈췄습니다 — 판정 몇 가지가 색을 보는데 그 스트림은 흑백이라서입니다. 대신 같은 화소를 압축해서 받는 방식을 넣었고(2026-09-23), 다음 정규 실행에서 잽니다.

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

기법 0종, 도입 0건입니다. 초판에서 하나 채택했던 것이 2판에서 기각됐습니다 — 근거로 삼은 실측이 맞았는데 그 호출이 제 파이프라인에 0건이었습니다. 게임 클라이언트는 화면을 네이티브 서피스 하나에 그려서 접근성 트리가 애초에 안 잡히고, 그 사실은 제 프로젝트 문서에 이미 적혀 있었습니다. 그런데 이 조사가 헛되지 않았습니다 — 이 시스템의 원칙 하나를 자로 삼아 제 코드를 재보니 관문이 두 벌이었습니다.

무엇을 만든 것인가

팀마다 맥미니에 폰 대여섯씩 꽂아 각자 굴리던 작은 팜들을 하나로 통합한 것이 출발점이다. 맥미니 5대·기기 15대·개발자 1명으로 시작해 1년 만에 기기 100대를 넘겼다.

사용자가 보는 건 이게 전부다.

POST /device/occupy   { platform: "android", tags: ["smoke"] }
POST /actions/click   { x: 540, y: 1200 }

“기기 하나 잡아 줘 → 거기 (540,1200)을 눌러 줘.” 그 기기가 어느 맥미니에 물렸는지도, 드라이버 세팅도 알 필요가 없다.

기존 도구를 키우는 대신 기기 계층부터 새로 만들었다

이 글에서 제일 큰 결정이다. 규모를 키울수록 기존 자동화 프레임워크의 구조적 한계에 반복해서 부딪혔고, 임시 수리보다 소유가 낫다고 판단했다.

무엇을 걷어냈나 왜
세션 개념 세션 기동에 매번 15~40초, 규모가 커질수록 세션 실패가 급증, 관리 비용이 기기 수에 비례
데스크톱 전제 미러링 기존 도구는 “기기 화면 → 데스크톱 앱” 전제라 서버 경유 브라우저 다중 배포에 안 맞는다
USB를 독점하는 캡처 독점하면 조작과 공존이 안 된다. 다른 방식은 10~15fps 슬라이드쇼

세션을 걷어낸 자리에 상시 떠 있는 컨트롤러 + 상태 없는 HTTP 호출을 놓았다. 그리고 인터페이스를 스펙 하나로 정의해 클라이언트를 자동 생성한다.

그 소유가 만든 부수 효과가 진짜 값어치다. 자체 한글 입력기(글자가 안 깨진다), 사내 빌드 즉시 설치, 보안 정책을 드라이버 규격 안에서 강제 — 스펙을 소유하니 남의 도구가 지원해 주기를 기다릴 필요가 없다.

깨본 결과 — 13.5배는 동종 비교가 아니다

간판 수치가 이렇다. 엘리먼트 클릭이 52ms 대 702ms = 13.5배.

그런데 저자가 스스로 단서를 붙였다 — 격차의 약 80%가 기존 도구의 화면 안정 대기이고, 그 대기를 끄면 2~3배로 좁혀진다. 즉 안전장치를 켠 값과 끈 값의 비교다. 정직하게 밝힌 건 좋은데, 표에서 “13.5배”만 떼어 인용하면 틀린 말이 된다. 선택 자체는 합리적이다 — 실시간으로 보며 조작하는 용도엔 속도가 맞다. 다만 철학의 교환이지 공짜 이득이 아니다.

더 큰 결손은 따로 있다. p50만 있고 p99가 없다. 테스트 자동화를 죽이는 건 중앙값이 아니라 꼬리다. 세션·명령 실패율, 기기 가동률, 플레이키 감소폭 — 안정성 수치가 글 전체에 하나도 없다.

나머지도 적어 둔다. 효과 근거가 증언 두 건이고 측정 조건·표본이 없다. 가장 가까운 오픈소스 선행 사례를 비교 대상에서 뺐다 — “왜 그게 아니라 자체 구축인가”가 답해지지 않았다. 그리고 iOS 캡처 경로가 특정 OS 계층이라 호스트를 통일 못 하는 구조적 비용이 생기는데 비용으로 서술되지 않았다. 마지막으로 드라이버가 비공개라 제3자가 표의 수치를 검증할 수 없다.

내 환경을 재봤다 — 그리고 대상이 틀렸다

실기기 하나로 읽기 전용 항목만 쟀다(조작계는 기기 상태를 바꾸므로 제외).

구간 분해 — 병목이 어디인가 UI 덤프 2,350ms 기기 안에서 소모 — 사실상 100% 스크린샷 1,863ms 기기 안에서 소모 95% 전송 5% 프로세스 기동 왕복 73ms = 4% ⇒ “케이블로 바꾸면 빨라진다” 기각 — 최대 이득 5% · “호스트에 상시 프로세스를 띄운다” 기각 — 최대 4% 저쪽의 이득은 호스트가 아니라 기기 안에서 나온다. 호스트 최적화로 오독하면 헛수고한다 그리고 더 큰 것 — 위 UI 덤프 호출이 내 파이프라인에 0건이었다
읽기 전용 항목만 n=5~7로 측정. 전송 구간과 프로세스 기동을 따로 떼어 재보니 둘 다 병목이 아니었다.

UI 덤프가 2,350ms로 저쪽의 10배 느렸다. 그래서 초판에서 기기 안 상주 컨트롤러를 유일한 수확으로 채택했다.

2판에서 기각됐다. 코드를 전수 대조하니 제 파이프라인에 그 호출이 0건이었다.

이유는 제 프로젝트 문서에 이미 적혀 있었다. 게임 클라이언트는 화면 전체를 네이티브 서피스 하나에 그린다 — 접근성 트리가 애초에 안 잡힌다. 그래서 제 화면 조작은 스크린샷 → 비전 → 좌표 탭 루프다. 그 도구를 붙여도 빈 트리가 온다. 10배 빨라질 대상 자체가 존재하지 않는다.

2,350ms는 실측이 맞았는데 대상이 틀렸다. 벤치마크를 재기 전에 “이 호출이 내 런에서 실제로 일어나는가”를 먼저 확인했어야 했다.

같은 실측이 가설 두 개를 더 기각했다. “케이블로 바꾸면 빨라진다” — 전송 구간이 전체의 5%라 최대 이득이 5%다. “프로세스를 상시 띄워 재사용한다” — 왕복이 73ms라 최대 4%다. 저쪽의 이득은 호스트가 아니라 기기 안에서 나오는데, 그걸 호스트 최적화로 오독하면 헛수고한다.

남은 건 남의 기법이 아니라 제 쪽 규율

이 글에서 “모든 도구가 공개 API 하나로 만들어졌다”고 한 지점을 자로 삼아 제 코드를 재봤다.

게이트웨이를 우회해 명령을 직접 부르는 곳이 7개 파일 14건이었다. 래퍼를 경유하는 파일과 거의 반반이다.

그런데 더 나쁜 게 나왔다. 우회 파일이 문제가 아니라 관문 자체가 두 벌이었다.

락 에러 분류 실제 런이 쓰는 것
정식 관문 있음 있음 ✗
다른 한 벌 없음 없음 ✓

정식 관문의 방어가 실제 런에는 하나도 안 걸려 있었다. 나중에 계측기를 붙일 때도 이 사실 때문에 방어 없는 쪽에 달아야 했다.

판정

기법 0종, 도입 0건. 이 조사가 만든 건 제 코드에 대한 발견 하나다.

그게 벤치마킹의 정직한 결과일 때가 있다. 남의 시스템에서 베낄 게 없어도, 그 시스템의 원칙을 자로 삼아 제 걸 재보면 갈라진 데가 보인다.

기각한 것에도 재개 조건을 붙여 뒀다 — 대상이 일반 앱이 되거나, 빌드가 접근성 노드를 노출하도록 바뀔 때.

2026-09 기준 — 관문 두 벌은 절반 고쳤고, 붙인 계측이 답을 냈다

  • 런 락(runlock.py, 2026-08-26) — 진입점 셋(시트 러너·녹화·루프)이 공유한다. 두 번째 런은 기기를 건드리기 전에 exit 2 로 거부되고, 죽은 런의 락은 다음 런이 회수한다. ⚠ 생존 확인에 os.kill(pid, 0) 을 쓰지 않는다 — Windows 에선 확인이 곧 살해다(OpenProcess 로 본다).
  • 관문 두 벌은 그대로 — 정식 관문(명령 직렬화 락·에러 코드)과 실제 런이 쓰는 한 벌(맨 subprocess)이 아직 갈라져 있다. adb 계측 훅이 실제 런 쪽에만 달려 있어서, 정식 관문으로 합치는 리팩터를 먼저 하면 계측이 에러 없이 0건이 된다. 훅을 먼저 옮긴다.
  • 계측 결과(2026-08-12 런) — screencap 177회·누적 543.5초 = 런 벽시계 1,691.4초의 32.1%, p50 3,309.8ms. 사전 추정(10~30%)을 넘었다. 분모(런 전체)를 먼저 남겨 둔 덕에 보였다 — 차이는 파일로 안 남는 판정용 캡처가 추정에서 빠진 탓으로 보인다.
  • 그런데 Live(흑백 스트림)로 통째 이관은 기각(2026-09-23 코드 확인) — 강화 구간 색(초록·주황)·추적창 금색·메뉴 비율을 보는 호출자가 있고, 증거 사진은 원본이어야 한다. 대신 원시 RGBA 를 기기에서 압축해 받아 PC 에서 PNG 로 싸는 방식(같은 화소)과 호출자별 계측을 넣었다. 다음 정규 런에서 p50 을 3.3초와 견줘 기본값을 정한다.