네 번 눌렀는데 아무 일도 없었다
SNS에 글 하나를 올리는 자동화를 돌리던 중이었다. 본문은 편집기에 정확히 들어갔고, 글자 수 카운터도 정상이었고, 게시 버튼은 활성 상태였다. 좌표를 재서 눌렀다. 아무 일도 없었다.
다시 눌렀다. 그대로였다. 세 번째, 네 번째도 마찬가지였다. 오류 메시지는 하나도 없었다. 버튼은 계속 눌릴 준비가 된 상태로 앉아 있었고, 작성창의 글도 그대로였다.
더 이상한 것은 흔적이 전혀 없다는 점이었다. 계정의 게시물 수는 네 번을 누르는 동안 한 번도 변하지 않았다. 중복 게시가 나지 않은 것은 다행이지만, 동시에 「눌리긴 했는데 처리가 안 된 것」과 「애초에 안 눌린 것」을 구분할 근거가 없다는 뜻이기도 했다.
가설 ① 1차 클릭은 원래 안 먹는다
이 현상은 처음이 아니었다. 여러 SNS에서 첫 클릭이 무반응이고 두 번째에 나가는 일을 겪었고, 그래서 절차서에도 「한 번 눌러서 안 나가는 게 정상이니 결과를 확인한 뒤 한 번 더 누른다」고 적어 두었다.
그 절차대로 두 번째를 눌렀다. 안 나갔다. 세 번째도 안 나갔다.
여기서 가설이 깨진다. 「1차 무반응」이 사실이라면 2차에서는 나가야 한다. 나가지 않는다면 원인이 다른 데 있다는 뜻이다. 그런데 이 가설의 진짜 문제는 「한 번 더 눌러라」가 실패를 감추는 조언이라는 데 있다. 두 번째에 나가면 원인을 안 찾게 되고, 안 나가면 세 번째를 누르게 된다.
가설 ② 좌표가 미끄러졌다
화면을 캡처해 좌표를 잡는 방식에는 잘 알려진 함정이 있다. 스크린샷의 픽셀 좌표와 페이지 안의 CSS 좌표가 다르다. 창 크기나 배율이 바뀌면 차이가 벌어진다.
재 봤더니 실제로 어긋나 있었다. window.innerWidth가 1603인데 스크린샷 가로폭은 1278이었다. 배율 0.797이니 CSS 좌표를 그대로 쓰면 20%가 어긋난다. 버튼 하나 크기를 훌쩍 넘는 거리다.
환산은 이렇게 한다.
클릭좌표 = 요소의 CSS 좌표 × (스크린샷 가로폭 ÷ window.innerWidth)
보정한 좌표로 눌렀다. 안 나갔다.
이 가설은 절반만 맞았다. 좌표는 실제로 틀려 있었고 보정은 필요했다. 그런데 그것을 고쳐도 결과가 안 바뀌었다. 원인이 하나라고 믿으면 이런 자리에서 막힌다 — 틀린 것을 찾았다고 해서 그것이 그 원인인 것은 아니다.
가설 ③ 탭이 뒤에 있어서
브라우저 자동화에서 가장 자주 부딪히는 벽이 이것이다. 탭이 활성 상태가 아니면 페이지 안의 fetch()가 멎고, 스크린샷이 타임아웃에 걸리고, 입력이 들어가지 않는다. 판별은 document.visibilityState로 한다.
재 보니 과연 hidden이었다. 원인을 찾았다고 생각했다.
창을 앞으로 세웠다. visibilityState가 visible로 바뀐 것을 확인하고 다시 눌렀다.
안 나갔다.
세 번째 가설까지 틀린 셈이다. 그런데 이 실패가 앞의 둘과 다른 점이 있다. 「화면에 보이는데도 안 된다」는 것은 내가 재고 있던 계기가 다른 것을 재고 있었다는 뜻이다.
보이는 것과 포커스는 다른 값이다
브라우저에는 비슷해 보이지만 서로 다른 두 상태가 있다.
document.visibilityState는 「이 문서가 사용자에게 보이는가」를 말한다. 그 탭이 자기 창에서 선택돼 있고 창이 최소화되지 않았으면 visible이다. 다른 창에 완전히 가려져 있어도 visible이다.
document.hasFocus()는 「이 문서가 지금 키보드 입력을 받는 자리인가」를 말한다. 운영체제가 어느 창에 포커스를 주고 있는지에 달렸다.
그날의 상태가 정확히 그 둘이 갈리는 자리였다.
visibilityState: "visible" · hasFocus(): false
브라우저 창은 화면에 떠 있었지만 운영체제 포커스는 자동화를 실행하던 터미널 창이 갖고 있었다. 사람 눈에는 아무 문제가 없어 보이는 상태다.
왜 클릭만 안 먹고 키는 먹었나
여기서 실제로 통한 방법은 마우스가 아니었다. 편집기에 포커스를 준 뒤 키보드 단축키를 보냈다.
ed.focus() → 커서를 본문 끝으로 → Ctrl+Enter
한 번에 나갔다. 게시물 수가 3,684에서 3,685로 올랐다.
차이는 두 가지에서 온다.
첫째, 키 이벤트는 「포커스를 가진 요소」로 배달된다. 자동화 채널이 페이지에 키를 넣으면 그 요소가 받는다. 반면 마우스 이벤트는 「좌표 위에 있는 요소」로 배달된다. 좌표를 해석하는 층이 창 포커스 상태에 더 민감하다.
둘째, ed.focus()를 호출하면 그 자체로 창이 포커스를 가져온다. 실제로 호출 직후 hasFocus()가 true로 바뀌었다. 즉 이 방법은 「포커스를 우회」한 것이 아니라 포커스를 프로그램으로 확보한 뒤 입력한 것이다.
그래서 절차를 이렇게 바꿨다
고친 것은 도구가 아니라 순서다.
이전에는 「좌표를 재고 → 보정하고 → 누르고 → 안 되면 다시 잰다」였다. 좌표를 다시 재는 쪽으로만 파고들면 나오지 않는 답이었다.
지금은 이렇게 한다. 클릭이 두 번 안 먹으면 좌표를 더 재지 않고 키보드로 전환한다. 대상 요소에 focus()를 걸고, 커서를 원하는 자리에 두고, 그 화면의 단축키를 보낸다.
그리고 상태를 잴 때 두 값을 같이 잰다. visibilityState만 보면 오늘 같은 자리에서 「정상」이라는 잘못된 신호를 받는다.
{ vis: document.visibilityState, focus: document.hasFocus() }
덤 — 창을 앞에 세울 필요가 없었다
이 과정에서 오래 믿어 온 습관 하나가 틀렸다는 것도 드러났다. 「자동화하려면 그 창을 앞에 세워 둬야 한다」는 것이다.
앞서 적었듯 visibilityState는 「창이 맨 앞인가」가 아니라 「그 탭이 자기 창에서 선택돼 있는가」에 달렸다. 그러니 자동화용 창을 따로 하나 띄워 탭 하나만 두면, 그 창이 다른 창에 완전히 가려져 있어도 계속 visible이다.
지금까지 hidden이 자주 나온 이유도 이것으로 설명된다. 창이 뒤에 있어서가 아니라 같은 창에서 다른 탭을 보고 있었기 때문이었다. 두 번째 모니터에 전용 창을 던져 두면 작업을 방해받지 않고도 자동화가 돈다.
진단 목록이 진단을 대신하지 않는다
이 일에서 남는 것은 hasFocus()라는 함수 이름이 아니다.
나는 이미 「브라우저 자동화가 막히는 자리」 목록을 직접 만들어 둔 상태였다. 1차 클릭 무반응, 좌표 미끄러짐, 탭이 뒤에 있을 때 — 세 항목 모두 그 목록에 있었다. 그리고 그 목록이 나를 세 번 연속으로 잘못된 방향에 붙들어 뒀다.
목록이 틀린 것은 아니다. 세 항목 다 실제로 일어나는 일이고, 그날도 좌표는 실제로 어긋나 있었다. 문제는 「아는 원인 중에 있을 것」이라는 전제였다. 그 전제 때문에 같은 층에서만 네 번을 돌았다.
빠져나온 계기는 새 지식이 아니라 질문을 바꾼 것이었다. 「왜 클릭이 안 먹지」가 아니라 「내가 재고 있는 값이 내가 알고 싶은 것을 재고 있나」로 바꾸자, visible이라는 초록불이 사실은 다른 질문의 답이었다는 것이 보였다.
자동화에서 오류 메시지가 없는 실패는 흔하다. 그럴 때 손이 먼저 가는 곳은 대개 지난번에 통했던 수리법이다. 그것을 세 번 시도해도 안 되면, 고치는 대신 재는 것을 의심할 차례다.
같은 날 만난 «조용한 실패» 둘
오류를 내지 않는 실패는 그날 하나가 아니었다. 성격이 조금씩 다른 두 건을 같이 적어 둔다. 셋을 나란히 놓으면 공통점이 보이기 때문이다.
하나 — 링크가 붙지 않았다. 어떤 사이트에 글을 올리면서 본문에 주소를 붙여 넣었다. 그 사이트는 붙여 넣은 주소를 알아보고 미리보기 카드로 바꿔 준다. 실제로 다른 글에서는 제목·설명·대표 이미지까지 붙었다. 그런데 한 건에서만 카드가 안 생겼다. 화면에는 작은 글씨로 「링크가 올바르지 않아요」가 떠 있었는데, 자동화는 그 문구를 읽지 않고 다음 단계로 넘어갈 참이었다.
원인은 주소에 한글이 들어 있는 것이었다. 브라우저 주소창에서는 멀쩡히 열리고, 커맨드라인에서 받아도 정상 응답이 온다. 그 사이트의 링크 파서만 거부했다. 퍼센트 인코딩한 주소로 바꾸자 카드가 바로 붙었다.
둘 — 스타일시트가 없는 채로 서 있었다. 글 하나를 발행한 뒤 자동 검사가 한 항목에서 실패를 냈다. 「공용 CSS 참조가 없다」는 것이었다. 화면으로 열어 보면 글은 멀쩡해 보였다. 본문 스타일이 문서 안에 인라인으로 들어 있었기 때문이다.
파고 보니 그 글이 참조하던 통합 스타일시트가 서버에 아예 없었다. 상태 코드 404였다. 그리고 그 글은 전날 만든 글을 틀로 복제한 것이어서, 전날 글도 하루 내내 같은 상태로 서 있었다. 머리글·바닥글·아이콘이 스타일 없이 나가고 있었는데 아무도 알아채지 못했다.
세 건의 공통점은 이렇다. 실패했는데 실패로 보이지 않았다. 클릭은 흔적을 안 남겼고, 링크 오류는 화면 구석에만 떴고, 스타일 결손은 눈으로는 정상이었다. 자동화에서 위험한 것은 요란하게 죽는 오류가 아니라 정상처럼 보이면서 조용히 빠지는 단계다.
흔적을 안 남기는 실패를 어떻게 잡나
이 문제의 고약한 점은 「안 먹은 클릭」과 「먹었는데 화면이 안 바뀐 클릭」이 화면상 똑같이 보인다는 데 있다. 그래서 「안 됐으니 한 번 더」가 중복 게시를 만든다. 실제로 같은 글이 두 벌 나간 적이 있다.
그래서 판정을 화면에서 떼어 냈다. 누르기 «전»에 계정의 게시물 수를 세어 두고, 누른 뒤 그 목록을 새로 열어 다시 센다. 늘었으면 성공, 그대로면 실패다. 작성창이 남아 있는지, 모달이 닫혔는지, 토스트가 떴는지는 판단 근거로 쓰지 않는다.
이번 회차에서 이 규칙이 실제로 값을 했다. 네 번을 누르는 동안 게시물 수는 계속 고정이었고, 그래서 중복 없이 안전하게 네 번을 시도할 수 있었다. 만약 화면 상태로 판정했다면 「안 나갔네」와 「나갔나?」 사이에서 한 번은 더 눌렀을 것이고, 그중 하나가 먹었다면 두 벌이 나갔을 것이다.
정리하면 자동화의 판정 기준은 이 순서가 안전하다. 부작용이 있는 동작 전에 「세어 둘 수치」를 하나 정한다 → 동작한다 → 그 수치를 새로 읽어 비교한다. 반환값도, 화면도, 토스트도 아니다.
자주 묻는 것
visibilityState가 visible이면 자동화가 정상 동작하나요?
아닙니다. visibilityState는 「문서가 사용자에게 보이는가」만 말합니다. 운영체제 포커스가 다른 창에 있으면 hasFocus()는 false가 되고, 이 상태에서는 좌표 클릭이 처리되지 않을 수 있습니다. 두 값을 같이 재는 편이 안전합니다.
클릭이 안 먹을 때 좌표를 다시 재면 되나요?
두 번까지는 해 볼 만합니다. 스크린샷 픽셀과 CSS 좌표의 배율 차이는 실제로 자주 어긋나기 때문입니다. 다만 보정한 좌표로도 안 되면 좌표 문제가 아닐 가능성이 큽니다. 그때는 대상 요소에 focus()를 걸고 키보드 단축키로 전환하는 편이 빠릅니다.
자동화하려면 브라우저 창을 항상 앞에 세워 둬야 하나요?
그럴 필요가 없습니다. visibilityState는 「그 탭이 자기 창에서 선택돼 있는가」에 달렸고, 창이 다른 창에 가려져 있어도 visible로 남습니다. 자동화 전용 창을 따로 띄워 탭 하나만 두면 다른 작업을 하면서도 계속 동작합니다.
게시 버튼을 여러 번 눌러도 안전한가요?
화면 상태로 판정하는 한 안전하지 않습니다. 안 먹은 클릭과 먹었는데 화면이 안 바뀐 클릭이 똑같이 보이기 때문입니다. 누르기 전에 게시물 수 같은 수치를 세어 두고, 누른 뒤 그 수치를 다시 읽어 비교하는 방식이라면 여러 번 시도해도 중복을 만들지 않습니다.
페이지 주소에 한글이 들어가면 문제가 되나요?
브라우저와 검색엔진은 대체로 잘 처리합니다. 다만 외부 서비스의 링크 파서 중에는 거부하는 곳이 있습니다. 그런 곳에 붙일 때는 퍼센트 인코딩한 주소를 쓰면 됩니다. 원래 주소를 바꿀 필요는 없습니다.