웹 워커를 언제 꺼내는가: 지뢰찾기와 체스의 차이
브라우저 안에서 도는 데스크톱을 만들고 있습니다. 창과 Dock과 터미널은 있는데 그 위에 올릴 앱이 없어서, 유틸리티 칸에 지뢰찾기를 하나 넣었습니다. 잘 돌길래 다음으로 체스를 만들기 시작했는데, 규칙을 다 짜고 엔진을 붙이려는 자리에서 한 번 멈춰야 했습니다. 지뢰찾기에는 없던 문제가 있었기 때문입니다.
지뢰찾기의 계산은 마이크로초
지뢰찾기에서 칸을 누르면 프로그램이 하는 일은 둘입니다. 지뢰가 있는지 보고, 빈 칸이면 이웃으로 퍼뜨리며 엽니다. 고급 판이 30×16이니 칸은 480개고, 최악의 경우에도 칸 수에 비례하는 만큼만 훑습니다.
더 중요한 것은 상대가 없다는 점입니다. 판은 첫 클릭 때 이미 정해지고 그 뒤로 프로그램은 아무것도 고르지 않습니다. 계산이 짧아서가 아니라 애초에 생각할 것이 없습니다. 그래서 스레드를 나눌 이유가 없었습니다. 1초마다 도는 시계가 하나 있지만 하는 일이 숫자를 하나 올리는 것뿐이라 화면을 막지 않습니다.
체스에서 멈추는 것은 판이 아니라 데스크톱
체스에는 상대가 있습니다. 상대가 있다는 말은 내가 둔 수마다 상대의 답을 따져야 하고, 그 답마다 내 다음 수를 또 따져야 한다는 뜻입니다. 가지가 곱해집니다.
제가 만든 엔진의 고급은 한 수에 1.2초를 씁니다. 시작 위치에서 6수 앞을 보며 595,171개의 판을 훑었습니다. 요즘 기기에서 무거운 양은 아닙니다. 문제는 총량이 아니라 그 1.2초 동안 브라우저가 무엇을 못 하느냐입니다.
브라우저 탭에서 자바스크립트는 한 스레드에서 돕니다. 클릭을 받는 것도, 화면을 그리는 것도, 타이머를 깨우는 것도 같은 줄에 섭니다. 그 줄에 1.2초짜리 계산을 세우면 나머지가 전부 뒤로 밀립니다. 체스 창만 굳는 것이 아니라 데스크톱 전체가 굳습니다. 창이 안 끌리고, 메뉴가 안 열리고, Dock 아이콘도 반응하지 않습니다. 만들고 있는 것이 브라우저 안의 데스크톱이라 이 흠은 유독 크게 보입니다. 창이 안 끌리는 순간 그냥 웹페이지가 되니까요.
메인 스레드에서 잰 최대 프레임 간격 1201ms
감으로 정하기 싫어서 두 경우를 재 봤습니다. 헤드리스 크롬으로 체스 창을 열어 고급으로 한 수를 두게 하고, 1.6초 동안 requestAnimationFrame이 몇 번 불리는지와 프레임 사이 최대 간격을 기록했습니다.
| 그린 프레임 | 최대 프레임 간격 | |
|---|---|---|
| 엔진을 워커에서 돌릴 때 | 97 | 22ms |
| 같은 크기 작업을 메인 스레드에서 | 27 | 1201ms |
22ms는 그동안 화면이 계속 움직였다는 뜻이고, 1201ms는 1.2초 동안 한 프레임도 그리지 못했다는 뜻입니다.
솔직히 적자면 아래쪽은 엔진을 실제로 메인 스레드에 옮겨 잰 값이 아닙니다. 같은 1.2초를 붙잡는 동기 루프를 대신 세웠습니다. 엔진을 두 벌로 두고 싶지 않았고, 화면을 막는 것은 계산의 내용이 아니라 점유 시간이라 이 대체로 충분하다고 봤습니다.
워커가 다른 스레드에 있다는 증거
숫자만으로도 답은 나오지만 구조도 확인했습니다. 빌드 결과에 엔진이 별도 청크로 떨어지고, 운영 페이지를 연 상태에서 크롬에 스레드 목록을 물어보면 page 옆에 worker가 하나 더 있습니다. 개발자 도구의 Sources 탭에도 메인 스레드와 나란히 뜹니다.
메인 스레드에서 돌았다면 22ms가 나올 수 없습니다. 한 스레드에서 계산과 렌더링을 같이 하니까요. 그 숫자 자체가 다른 곳에서 돌았다는 증거입니다.
화면을 모르는 코드만 옮길 수 있습니다
워커로 옮기는 일 자체는 배선 몇십 줄입니다. 창을 열 때 워커를 하나 만들고, 판과 난이도를 postMessage로 보내고, 답으로 온 수를 판에 놓으면 끝입니다. 만드는 데 몇 ms가 들기 때문에 수마다 만들지 않고 창 하나에 하나만 두고 씁니다.
옮길 수 있느냐는 그 전에 정해집니다. 규칙과 평가·탐색을 처음부터 화면과 분리해 뒀습니다. 판을 받아 수를 돌려주는 순수 함수 덩어리라 React도 DOM도 들어 있지 않고, 그래서 파일을 그대로 워커에서 부를 수 있었습니다. 화면 코드와 섞여 있었다면 옮기기 전에 떼어내는 일부터 했어야 합니다. 워커 안에는 document가 아예 없어서 섞인 코드는 그 자리에서 터집니다.
이 제약은 불편해 보이지만 덕분에 두 스레드가 같은 화면을 동시에 고쳐서 깨지는 사고가 구조적으로 없습니다. 주고받는 값도 공유가 아니라 복사입니다. 체스 판은 128바이트짜리 배열이라 복사 비용은 없는 것이나 마찬가지입니다.
실무에서 제일 고마운 것은 terminate()였습니다. 창을 닫거나 무르기를 누르면 생각하던 계산이 그 자리에서 끝납니다. 메인 스레드였다면 끝날 때까지 기다리는 것 말고 방법이 없습니다.
경계선은 게임이 아니라 CPU를 붙잡는 시간
처음에 저는 게임이면 워커라고 생각했습니다. 재 보니 기준은 게임이 아니었습니다. 지뢰찾기도 게임인데 필요 없었고, 필요를 만든 것은 한 번에 CPU를 붙잡는 시간이었습니다.
같은 지뢰찾기도 규칙 한 줄이면 경계선을 넘습니다. 요즘 지뢰찾기는 추측 없이 논리만으로 풀리는 판만 주는 경우가 많은데, 그러려면 판을 뽑고 솔버로 풀어 보고 안 되면 다시 뽑아야 합니다. 고급에서는 수백 번 다시 뽑는 일이 생기고, 그 순간 지뢰찾기도 워커가 필요한 앱이 됩니다.
제가 넣은 첫 칸 안전 규칙은 그것과 다릅니다. 첫 클릭을 보고 그 칸과 이웃을 뺀 나머지에 지뢰를 뿌리는 것뿐이라 여전히 훑기 한 번입니다. 지뢰를 미리 뿌려 두면 첫 수에 터지는 판이 나오는데, 그건 실력도 운도 아니라 그냥 나쁜 경험이라 이 규칙은 넣어 뒀습니다.
기준을 시간으로 잡으면 판단이 간단해집니다. 서버 응답을 기다리는 일은 아무리 오래 걸려도 워커가 필요 없습니다. 기다리는 동안 메인 스레드는 놀고 있으니까요. 주기적으로 도는 시계도 하는 일이 없으면 막지 않습니다. 한 번에 수백 ms 이상 CPU를 붙잡는 계산일 때만 꺼내면 됩니다.
같은 이름의 다른 워커, 서버 쪽
이 블로그에는 워커 이야기가 이미 여러 편 있습니다. Worker는 제품이 아니다에서 다룬 워커는 서버에서 큐를 읽는 프로세스입니다. 컨테이너로 따로 띄우고, 큐에 쌓인 일을 꺼내 처리하고, 죽으면 다시 살아나 이어받습니다. 이번 글의 워커는 브라우저 안에서 돌고, 큐도 컨테이너도 없고, 탭을 닫으면 같이 사라집니다.
이유는 같습니다. 오래 걸리는 일이 빠른 일을 막지 못하게 하는 것입니다. 서버에서는 긴 작업이 다음 요청을 막고, 브라우저에서는 긴 계산이 다음 프레임을 막습니다. 지키려는 것이 응답 시간이냐 프레임이냐가 다를 뿐 구조는 같습니다. 부탁을 메시지로 던지고 결과를 나중에 받습니다.
비용은 많이 다릅니다. 체스 엔진을 서버에서 돌렸다면 열 명이 동시에 두는 순간 1.2초짜리 계산이 열 몫 필요합니다. 컨테이너를 늘리거나 큐를 두고 순서를 기다리게 해야 합니다. 브라우저에서 돌리면 백 명이 둬도 서버 부하는 0입니다. 파일 하나를 한 번 내려보낸 것으로 끝나고 계산은 각자 기기에서 일어납니다. 이번 작업으로 인프라는 한 줄도 늘지 않았습니다.
덧붙이면 워커라는 이름은 한 군데 더 쓰입니다. gunicorn이나 nginx의 워커 프로세스는 요청을 나눠 받는 복제본이고, Cloudflare Workers는 엣지에서 도는 서버 코드입니다. 넷 다 이름이 같고 층이 다릅니다. 대신 일해 주는 쪽이라는 뜻만 공유합니다.
같은 고급이 기기마다 다른 상대가 됩니다
고급은 깊이가 아니라 시간으로 끊습니다. 1.2초 안에 볼 수 있는 만큼 보고 멈춥니다. 워커라서 가능한 설정입니다. 메인 스레드였다면 시간을 늘리는 일이 곧 멈추는 시간을 늘리는 일이라 이런 선택지가 없습니다.
대신 빠른 기기에서는 더 깊이 보고 느린 기기에서는 덜 봅니다. 같은 고급이 기기마다 다른 상대가 된다는 뜻인데, 이게 문제인지 특징인지는 아직 판단하지 못했습니다. 사람과 두는 감각으로는 자연스러운 쪽 같고, 난이도를 숫자로 약속한 적은 없으니 당분간 이대로 두려고 합니다.
난이도를 만들면서 만난 다른 문제 하나는 여기 적지 않았습니다. 최선에서 얼마 안 떨어진 수들 중 하나를 고르게 해서 초급을 약하게 만들었는데, 그 장치가 엉뚱하게 동작해 공짜로 놓인 퀸을 두고 왕을 옆으로 옮겼습니다. 원인이 탐색 알고리즘 자체에 있어서 따로 한 편이 필요합니다.
함께 읽기
- React error 300: 조건부 return 뒤의 훅과 가끔 나는 오류관리자 화면에 들어갈 때마다 오류 카드가 한 번씩 떴습니다. 「문제가 발생했습니다. 잠시 후 다시 시도해 주세요.」 다시 시도를 누르면 그냥 들어가집니다. 그래서 한동안 넘겼습니다.
- iframe 안의 분석 스크립트: 자사 사이트를 창으로 띄웠을 때 생기는 일저희 홈페이지를 저희가 만든 브라우저 안 데스크톱(os.duolabs.co.kr)에 창 하나로 띄웠습니다. 데모를 한 번 열어 본 사람이 방문 기록에는 두 번 찍혔습니다. 처음에는 창을 두 개 여니까 그렇겠거니 했는데, 세어 보니 문제는 개수가 아니라 그 숫자가 우리 통계 전부를 통과하고 있다는 쪽이었습니다.
- WebP가 JPEG보다 커질 때: 사진 종류별 WebP·AVIF 실측인쇄기 롤러 견적 사이트를 만들면서 화면에 나가는 사진을 전부 WebP로 정했습니다. 제품 사진 네 장으로 재 보고 내린 결론이었습니다. 그 뒤에 설계도 사진 세 장이 들어왔고, 같은 스크립트에 그대로 태웠습니다. 이 글을 쓰려고 다시 재 보니 그중 두 장은 WebP가 JPEG보다 컸습니다.
- 웹 퍼블리셔와 프론트엔드 개발자 차이: 산출물이 갈리는 지점견적서에 이런 두 줄이 나란히 놓이는 경우가 있습니다.
- GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.