권과장의 블로그
개발

넥사크로 Playwright 크롤링 1탄: 입력·클릭 해결

넥사크로로 만든 협력사 포털을 Playwright 파이썬으로 자동화하며 로그인 전에 막혔던 네 가지 문제를 다룹니다. fill()이 안 먹는 입력칸, click()이 안 되는 버튼, 화면 밖 로그인 바, 입력을 가로막는 공지 팝업의 원인과 해결 코드입니다.

10분 읽기
넥사크로 Playwright 크롤링 1탄: 입력·클릭 해결 썸네일
넥사크로 Playwright 크롤링 1탄: 입력·클릭 해결 썸네일
권과장

낮에는 회사에서 일하고, 밤에는 코드를 만지고, 주말에는 부엌에 있습니다. 겪은 것을 정리해 두는 곳입니다.

목차

넥사크로(Nexacro)로 만든 사이트는 Playwright의 fill()과 click()이 거의 통하지 않습니다. 값은 키보드로 한 글자씩 흘려 넣고, 버튼은 마우스 좌표로 누르고, 창은 1920x1080으로 띄워야 겨우 움직였습니다. 대형 유통사의 협력사 포털에서 발주를 자동으로 가져오려다 하나씩 부딪힌 문제들입니다. 사내 수발주 프로그램을 시놀로지 NAS로 직접 만든 뒤, 거래처 발주까지 자동으로 받아 오고 싶어서 시작한 작업이기도 합니다

그전까지는 사람이 직접 포털에 들어가 하루 10건 이상의 발주를 처리하느라 하루 2~3시간을 썼습니다. 막힌 곳이 많아 글을 두 편으로 나눴습니다. 1탄은 로그인 화면을 통과하기까지 막혔던 네 가지를 다룹니다. 2탄은 로그인 뒤에 캔버스 그리드에서 데이터를 꺼내고, PDF를 받고, SMS 인증을 넘기는 이야기입니다.

한 줄 요약 넥사크로는 사람이 하는 것처럼 조작해야 반응합니다. 마우스 좌표 클릭, 느린 키 입력, 넓은 창이 기본입니다.

넥사크로 사이트가 자동화하기 까다로운 이유

넥사크로 화면은 전체가 div로 그려지고, 요소 id에 프레임 경로 전체가 붙어 있습니다. 그래서 평소 쓰던 셀렉터, 클릭, 입력 방식이 하나씩 어긋납니다. 이 글의 코드는 2026년 9월 기준 Playwright 파이썬 동기 API로 작성했습니다.

요소 id는 [id="…"] 셀렉터로 잡는다

넥사크로 요소는 #아이디 방식이 아니라 속성 셀렉터 [id="…"]로 잡아야 합니다. 개발자 도구로 열어 보면 id가 mainframe.VFrameSet.HFrameSet.VFrameSet.WorkFrameSet.form_ORDERLIST.form.…처럼 길게 이어집니다. id 안에 점이 들어 있어서, #을 붙이면 CSS가 점 뒤를 클래스 이름으로 읽어 버립니다. 속성 셀렉터에 id 전체를 따옴표로 넣으면 정확히 잡힙니다.

# id에 점(.)이 들어 있어서 #id 방식으로는 못 잡는다SEL = '[id="mainframe.VFrameSet.HFrameSet.VFrameSet.WorkFrameSet.form_ORDERLIST.form.…"]'el = page.wait_for_selector(SEL)

경로 끝부분(…)은 화면과 요소마다 달라집니다. 경로가 워낙 길어서 저는 자주 쓰는 셀렉터를 상수로 빼 두고 씁니다.

장고 안에서 돌리면 DB 접근이 막힌다

장고 안에서 Playwright 동기 API를 실행하면 DB 접근이 막히므로, Playwright 작업은 별도 작업 스레드에서 돌려야 합니다. Playwright가 안에서 이벤트 루프를 돌리다 보니, 장고가 지금 비동기 환경이라고 오해하고 ORM 호출을 거부합니다. 장고는 이런 경우 SynchronousOnlyOperation 오류를 냅니다.

import threadingfrom playwright.sync_api import sync_playwrightdef run_portal_job():    with sync_playwright() as p:        ...  # 로그인, 조회, 다운로드threading.Thread(target=run_portal_job).start()

화면 문제를 파기도 전에 이 오류부터 만나서 당황했습니다. 사이트는 열어 보지도 못했는데 DB가 먼저 막히니 순서가 이상하다 싶었습니다. 스레드로 떼어 낸 뒤로는 이 문제가 다시 나오지 않았습니다.

Playwright fill()로 값을 넣어도 로그인이 안 될 때

넥사크로 입력칸에는 page.fill() 대신, 클릭한 뒤 keyboard.type()으로 한 글자씩 쳐야 합니다. fill()은 화면에 보이는 글자만 바꾸고 넥사크로 내부 모델에는 반영하지 않았습니다. 그래서 로그인 버튼을 누르면 아이디와 비밀번호가 빈 값으로 전송됩니다.

이 증상이 까다로운 건 눈으로 보기엔 값이 멀쩡히 들어가 있다는 점입니다. 코드도 오류 없이 끝나기 때문에 어디가 문제인지 짐작하기 어렵습니다. 넥사크로 입력칸은 화면에 보이는 값과 내부 모델이 따로 놉니다. 제가 겪은 바로는 사람이 치듯 키 입력이 하나씩 들어와야 내부 값이 갱신됐습니다.

def _type_nexacro(page, selector, text):    el = page.wait_for_selector(selector)    box = el.bounding_box()    page.mouse.click(box["x"] + box["width"] / 2,                     box["y"] + box["height"] / 2)    page.keyboard.press("Control+a")    page.keyboard.press("Delete")    page.keyboard.type(text, delay=40)

순서는 이렇습니다.

  1. 입력칸이 뜰 때까지 기다린 뒤 가운데 좌표를 계산한다.

  2. 그 좌표를 마우스로 클릭해 커서를 넣는다.

  3. Ctrl+A, Delete로 남아 있던 값을 지운다.

  4. 글자 사이를 40밀리초씩 띄워 한 글자씩 입력한다.

delay=40은 키 하나를 누를 때마다 40밀리초를 쉰다는 뜻입니다. 10자짜리 비밀번호도 0.4초면 끝나서 전체 속도에는 거의 영향이 없습니다. 기존 값을 지우는 단계를 빼면, 재시도할 때 아이디가 두 번 이어 붙는 일이 생기니 꼭 넣어 두는 편이 좋습니다.

버튼이 click()으로 안 눌리고 get_by_text는 타임아웃 날 때

넥사크로 버튼은 화면에 보이는 요소의 가운데 좌표를 page.mouse.click()으로 눌러야 반응합니다. element.click()이나 자바스크립트의 click()은 이 포털 버튼에 먹지 않았습니다. 여러 방법을 바꿔 봤지만 매번 통한 건 마우스 좌표 클릭뿐이었습니다.

글자로 버튼을 찾을 때도 함정이 있습니다. get_by_text("닫기").first.click()은 화면에 안 보이는 같은 이름의 요소를 먼저 잡아서 타임아웃이 났습니다. 넥사크로 화면에는 숨겨진 같은 글자 요소가 함께 있어서, 첫 번째로 잡힌 요소가 보이는 버튼이라는 보장이 없습니다.

주의 get_by_text()의 .first는 화면에 보이는 첫 번째가 아니라 문서 순서상 첫 번째 요소를 잡습니다.

그래서 보이는 요소만 골라 좌표를 모으는 함수를 따로 만들었습니다. 조건은 세 가지입니다. offsetParent가 있어야 하고, 너비와 높이가 0보다 커야 하고, innerText가 찾는 글자와 정확히 일치해야 합니다. 부모와 자식 요소가 같은 글자로 겹쳐 잡히기도 해서, 같은 좌표는 하나로 합친 뒤 클릭합니다.

JS_FIND = """(t) => {  const out = [];  for (const e of document.querySelectorAll('div,button,a,span')) {    if (!e.offsetParent || (e.innerText || '').trim() !== t) continue;    const r = e.getBoundingClientRect();    if (r.width > 0 && r.height > 0)      out.push({x: r.x + r.width / 2, y: r.y + r.height / 2});  }  return out; }"""def click_text(page, text):    boxes = page.evaluate(JS_FIND, text)    for x, y in {(round(b["x"]), round(b["y"])) for b in boxes}:        page.mouse.click(x, y)

"조회"처럼 화면에 하나뿐인 버튼은 click_text(page, "조회") 한 줄로 끝납니다. 이 함수는 뒤에 나오는 공지 팝업 닫기에서도 그대로 다시 씁니다.

로그인 바가 안 보이면 창을 1920x1080으로 띄운다

로그인 화면이 비어 보인다면 브라우저를 --window-size=1920,1080으로 띄우면 됩니다. 이 포털은 로그인 바가 화면 오른쪽 바깥에 그려져서, 기본 창 크기에서는 보이지도 않고 클릭도 안 됐습니다. 이 포털의 넥사크로 화면은 반응형이 아니라서 창이 좁다고 알아서 줄어들지 않습니다.

처음에는 로그인 화면이 통째로 비어 있는 줄 알았습니다. 창을 넓혀 보니 그제야 오른쪽에서 로그인 바가 나타났습니다. 좌표 클릭은 화면 밖에 있는 요소를 누를 수 없습니다. 그러니 넥사크로 자동화에서는 창 크기가 곧 클릭할 수 있는 범위인 셈입니다.

browser = p.chromium.launch(args=["--window-size=1920,1080"])page = browser.new_page(viewport={"width": 1920, "height": 1080})

Playwright는 페이지를 만들 때 viewport 기본값이 1280x720이라서, 창 크기 옵션과 viewport를 같이 맞춰 두는 편이 안전합니다. 작은 노트북 화면으로 이 포털을 쓰던 분들은 로그인을 어떻게 했을지 잠깐 궁금해졌습니다.

넥사크로 공지 팝업이 입력을 가로막는 문제

아이디와 비밀번호 입력이 씹힌다면 자동화 감지보다 공지 팝업을 먼저 의심해야 합니다. 이 포털은 로그인 화면에 공지 팝업이 여러 장 겹쳐 뜨고, 그 팝업이 입력칸을 덮고 있었습니다. 저는 이 원인을 찾는 데 이틀을 썼습니다.

처음엔 사이트가 자동화 브라우저를 알아보고 막는 줄 알았습니다. 그래서 브라우저 지문을 진짜 크롬처럼 꾸몄습니다. UA, navigator.webdriver, WebGL 렌더러, 플러그인 수, 플랫폼까지 손댔지만 입력은 여전히 씹혔습니다. 그 이틀 동안은 정말 사이트와 숨바꼭질을 하는 기분이었습니다.

실제 원인은 겹겹이 뜬 공지 팝업이었습니다. 넥사크로 팝업이라 자바스크립트 click으로는 닫히지 않았습니다. 그래서 앞에서 만든 좌표 클릭을 "7일동안 보지 않기" → "닫기" → "확인" 순서로 1초 간격을 두고 8번 반복했습니다. 겹친 팝업을 한 장씩 벗겨 내는 방식입니다.

CONFIRM = '[id*="popupFrame"][id$="btn_confirm"]'def clear_popups(page, rounds=8):    for _ in range(rounds):        click_text(page, "7일동안 보지 않기")        click_text(page, "닫기")        for el in page.query_selector_all(CONFIRM):            box = el.bounding_box()            if box:                page.mouse.click(box["x"] + box["width"] / 2,                                 box["y"] + box["height"] / 2)        page.wait_for_timeout(1000)

여기서 "확인"만큼은 글자로 찾지 않습니다. 화면 본문에도 인증번호 확인 같은 다른 "확인" 버튼이 있어서 잘못 누를 수 있기 때문입니다. 그래서 id에 popupFrame이 들어가고 btn_confirm으로 끝나는 알림창 버튼만 누르도록 셀렉터를 좁혔습니다.

팝업을 한 번 정리했다고 입력이 늘 성공하지는 않았습니다. 그래서 값을 넣은 뒤 실제로 들어갔는지 검증하는 단계를 붙였습니다

  1. clear_popups()로 겹친 팝업을 정리한다.

  2. _type_nexacro()로 아이디와 비밀번호를 입력한다.

  3. 값이 실제로 들어갔는지 확인한다.

  4. 비어 있으면 1번으로 돌아가 팝업부터 다시 정리하고 재입력한다.

입력이 안 되면 봇 감지를 의심하기 전에, 화면 위에 무엇이 덮여 있는지부터 봐야 합니다. 브라우저 지문을 고치는 일은 한참 뒤, 인증문자 문제를 풀 때 다시 등장합니다. 그 이야기는 2탄에 적겠습니다.

이틀을 헤맨 뒤로 화면부터 보기로 했다

이 포털 하나를 붙이는 데 3일이 걸렸고, 가장 아까운 건 지문을 고치던 이틀이었습니다. 돌아보면 넥사크로 앞에서는 똑똑한 우회보다 사람처럼 누르고 치게 만드는 쪽이 늘 빨랐습니다. 증상과 원인이 전혀 다른 곳에 있을 수 있다는 것도 이번에 몸으로 배웠습니다. 다음에 다른 사이트를 붙일 때는 코드를 고치기 전에 그 순간의 화면부터 확인하려고 합니다. 로그인 뒤에는 캔버스로 그려진 그리드라는 더 큰 벽이 기다리고 있었는데, 그 이야기는 2탄에서 이어 가겠습니다.

본문에서 이어진 글과 비슷한 주제의 글입니다.

개발

수발주 업무 자동화, 직접 만든 사내 프로그램

매일 아침 거래처 사이트를 하나씩 열어 발주를 확인하던 업무를 자동화했습니다. 사이트마다 다른 화면을 어떻게 다뤘는지, 이틀을 헤맨 인증 문제의 진짜 원인은 무엇이었는지 적었습니다.

8분 읽기

개발

안전 서류 자동화, 사진 한 장으로 위험성평가

사진을 올리면 위험성평가 초안이 나오고, 지게차 작업계획서와 교육 자료도 자동으로 만들어집니다. 안전관리자가 직접 만든 사내 프로그램 이야기입니다. 자동화하지 않고 남겨 둔 것도 함께 적었습니다.

5분 읽기