홈 › 읽을거리 › 개발노트

체스 엔진을 직접 만들고 검증한 기록

개발노트 · 2026-10-09 · GamePool 편집팀

체스는 2026년 10월 9일에 올라간 게임이고, 컴퓨터 상대는 외부 엔진이 아니라 한 파일 안에 직접 쓴 약 260줄의 JavaScript 입니다. 이 글은 그 엔진을 만들고 검증한 기록입니다. 수치는 게임 소스(public/games/chess/index.html 의 engineSrc 영역)를 그대로 불러와 직접 다시 실행해 확인한 값만 적었고, 소스에서 확인되지 않는 의도는 적지 않았습니다. 게임 파일이 아직 커밋되기 전에 쓴 글이라 git 이력은 인용하지 않고 소스와 실행 결과로 서술합니다.

구성: 한 파일, 규칙과 탐색은 같은 코드

엔진은 index.html 안의 script 태그(type="text/plain") 한 곳에 들어 있고, 같은 글자 덩어리를 화면 스레드와 Web Worker 가 함께 씁니다. 칸은 0(a8)부터 63(h1)까지의 숫자이고, 수 하나는 출발 칸, 도착 칸, 승진 기물, 특수 표시(앙파상·캐슬링·폰 두 칸)를 한 정수에 묶어 다룹니다. 수를 두고 되돌리는 make/unmake 는 스택으로 상태를 복원하고, 합법 수는 "모양대로 갈 수 있는 수를 모두 만들고 내 킹이 공격받는 수를 버리는" 방식입니다. 캐슬링 권리는 비트 4개로 기억하고, 킹이나 룩이 움직이거나 룩이 잡히면 칸별 마스크로 해당 권리를 지웁니다.

저장소 규칙은 게임을 외부 의존성 없는 단일 파일로 만드는 것이고, 실제 구현도 그렇습니다. 같은 파일 아래쪽의 화면 코드(캔버스 그리기, 끌어놓기, 키보드)가 이 엔진의 API 를 호출합니다. 사용하는 함수는 fromFen, toFen, legalMoves, make, unmake, san, perft, think, status, evaluate 입니다. 엔진이 FEN 문자열로 국면을 주고받는 구조라, 검증 스크립트는 게임 파일에서 이 영역만 잘라 Node 에서 그대로 실행합니다. 이 글의 모든 실행 결과가 그렇게 나온 값입니다.

perft: 규칙 구현이 맞는지 세어서 확인하기

체스 규칙 구현이 맞는지 확인하는 가장 확실한 방법은 perft 입니다. 어떤 국면에서 가능한 모든 합법 수를 깊이 d 까지 펼쳤을 때 말단 국면이 몇 개인지를 세어, 공개된 기준값과 비교합니다. 하나의 수라도 합법성이 틀리면 합계가 어긋납니다. 이 엔진의 결과는 다섯 개 국면 모두 기준값과 일치했습니다.

  • 시작 국면: 깊이 1~4 가 20 / 400 / 8,902 / 197,281, 깊이 5 는 4,865,609 (Node 에서 약 0.95초).
  • Kiwipete(캐슬링·핀·앙파상이 많은 국면): 깊이 1~3 이 48 / 2,039 / 97,862, 깊이 4 는 4,085,603.
  • 국면 3(앙파상 핀 등 종반): 깊이 1~4 가 14 / 191 / 2,812 / 43,238, 깊이 5 는 674,624.
  • 국면 4(프로모션과 체크 위주): 깊이 1~3 이 6 / 264 / 9,467, 깊이 4 는 422,333.
  • 국면 5(프로모션 선택): 깊이 1~3 이 44 / 1,486 / 62,379, 깊이 4 는 2,103,487.

일치했다는 사실이 무엇을 보증하는지는 따로 확인했습니다. 엔진 소스를 한 군데씩 일부러 망가뜨린 변형을 만들어 같은 국면에서 세어 본 것입니다. 앙파상을 아예 빼면 Kiwipete 깊이 2 에서 2,038(기준 2,039)이 나와 1개 차이로 걸렸습니다. 캐슬링 때 지나는 칸의 공격 검사를 빼면 Kiwipete 깊이 2 가 4개 늘었고, 체크 중 캐슬링을 허용하면 국면 5 깊이 3 에서 99개 늘었습니다. 프로모션을 퀸만으로 제한하면 국면 4 깊이 2 에서 36개 줄었습니다. 폰 두 칸 전진 때 가운데 칸 점유를 무시하면 시작 국면 깊이 1~3 은 통과했지만 깊이 4 에서 354개 늘었습니다.

반대로 놓치는 것도 있었습니다. 룩이 제자리를 떠나거나 잡혀도 캐슬링 권리를 지우지 않도록 바꾼 변형은 Kiwipete 깊이 4, 국면 4 깊이 4, 국면 5 깊이 4, 국면 3 깊이 5 를 모두 통과했습니다. 룩이 나갔다 돌아와 캐슬링하려면 반 수로 다섯 번이 필요한데 깊이 4 는 거기까지 닿지 않기 때문입니다. 킹과 룩 네 개만 남긴 국면에서 깊이 5 를 세었을 때에야 정상 엔진 7,594,526 과 변형 7,601,568 로 7,042개 갈렸습니다. 그래서 perft 일치가 보증하는 것은 "그 깊이까지의 수 생성과 상태 복원이 정확하다"이지 모든 규칙이 정확하다는 뜻이 아닙니다. 또한 perft 는 합법 수의 개수만 보므로 50수·3회 반복·기물 부족 무승부 판정, SAN 표기, 평가 함수는 전혀 검증하지 않습니다. 그 부분은 별도 국면을 만들어 확인했습니다. 예를 들어 같은 색 칸 비숍끼리 남으면 무승부, 다른 색이면 계속, 나이트 둘과 킹 대 킹은 계속으로 판정하는지 하나씩 넣어 보았습니다.

평가 함수와 탐색: 단순한 부품만 썼다

평가 함수는 기물 가치와 위치표 두 가지뿐입니다. 폰 100, 나이트 320, 비숍 330, 룩 500, 퀸 900 이 기본이고, 기물마다 64칸짜리 위치 점수표를 더합니다. 표의 값은 작업 기록상 널리 알려진 단순화된 평가 함수의 표를 바탕으로 했습니다. 킹은 폰을 제외한 기물 가치를 양쪽 합쳐 2,600 이하가 되면 종반표로 바꿉니다. 시작 국면은 양쪽 합이 6,400 이라 중반표가 적용되고, 중반표는 킹이 구석에 있을수록 높은 점수를, 종반표는 중앙에 있을수록 높은 점수를 줍니다. 흑은 표를 위아래로 뒤집어 같은 표를 씁니다. 이동성, 폰 구조, 킹 안전 같은 항목은 넣지 않았습니다.

탐색은 알파-베타 가지치기를 쓰는 negamax 입니다. 체크 상태에서는 깊이를 1 연장하고(ply 24 까지), 깊이가 0 에 닿으면 잡기 수만 이어서 보는 잡기 탐색(qsearch, ply 12 를 넘으면 평가값 반환)으로 넘어가 수가 교환 한가운데서 끊기지 않게 합니다. 수 순서는 잡기를 값이 큰 말을 작은 말로 잡는 순(MVV-LVA)으로 먼저, 프로모션은 그다음, 잡지 않는 수 중 가지를 잘랐던 킬러 수를 그 뒤에 둡니다. 반복 심화는 깊이 1 부터 목표 깊이까지 하나씩 올리며, 한 단계가 끝날 때마다 루트의 수를 점수순으로 다시 정렬합니다. 메이트 점수는 30000 에서 수를 둔 횟수를 뺀 값이라 빠른 메이트가 더 높게 평가되고, 메이트가 발견되면 더 깊이 들어가지 않습니다.

시간 제한은 노드 2,048개마다 한 번씩 시계를 확인합니다. 제한이 지나면 탐색을 중단하고, 끝까지 마치지 못한 깊이는 버리고 직전 깊이 결과를 씁니다. 그래서 어려움이 "깊이 7까지"라고 해도 실제로는 국면에 따라 달라집니다. 실측하면 시작 국면에서는 깊이 7 까지 1.4초(약 167만 노드)에 마치지만, 말이 많이 남은 Kiwipete 에서는 2.3초 제한에 깊이 6(약 167만 노드), 두 번째 캡처의 중반 국면에서는 깊이 5(약 187만 노드)까지만 갔습니다.

난이도 세 단계: 값과 실측

난이도는 소스의 LEVELS 에 숫자 세 개로 정의되어 있습니다. 쉬움은 깊이 2·0.4초·점수 차 160 이내인 수 중 무작위에 12% 는 완전 무작위, 보통은 깊이 4·1.2초·점수 차 12 이내인 수 중 무작위, 어려움은 깊이 7 까지 반복 심화에 2.3초 제한입니다. 쉬움과 보통은 모든 수의 점수를 정확히 구해 비슷한 수를 고르고, 어려움은 최선의 수 하나만 구하므로 루트에서도 창을 좁혀 가지치기를 더 합니다.

같은 국면에서 세 번씩 돌린 평균은 이렇습니다. 시작 국면은 쉬움 약 5ms·864노드, 보통 약 30ms·29,532노드, 어려움 약 1.4초·1,665,675노드(깊이 7)였습니다. Kiwipete 는 쉬움 182ms·143,898노드, 보통 1,129ms·985,683노드(깊이 4 까지, 제한 1.2초에 거의 닿음), 어려움 2,314ms·1,673,216노드(깊이 6)였습니다. 두 번째 캡처의 11수 국면은 쉬움 3ms, 보통 약 225~330ms(242,392노드), 어려움 약 2.3초였습니다. 같은 국면에서 보통의 노드 수는 매번 242,392 로 같았는데, 탐색 자체는 무작위가 없고 마지막에 고르는 단계에서만 무작위를 쓰기 때문입니다.

이 설계가 실제 승부에서 어떤 차이를 만드는지도 확인했습니다. 킹+퀸 또는 킹+룩 대 킹 다섯 국면에서 백을 난이도별로 돌리고 흑은 보통으로 맞섰습니다. 쉬움은 15판 중 메이트가 0판이었고, 50수 무승부 9판, 반복 1판, 그리고 5판은 퀸이나 룩을 흑 킹에게 내줘 기물 부족으로 끝났습니다. 보통은 15판 중 메이트 9판, 반복 2판, 50수 3판, 기물 부족 1판이었고, 어려움은 5판 모두 13~27수 만에 메이트였습니다. 쉬움의 12% 무작위는 한 수 메이트도 놓칩니다. 백랭크 메이트 국면에서 Ra8# 을 놓친 것이 200번 중 24번, 질식 메이트 국면에서 Nf7# 을 놓친 것이 200번 중 20번이었습니다. 12% 를 모든 합법 수 중에서 아무거나 고르는 값으로 보면 놓칠 기댓값은 약 11% 이므로 소스의 설정과 어긋나지 않습니다.

장기 소스와 나란히 놓으면 쉬움·보통·어려움의 점수 차 값 160·12·0 과 보통의 시간 제한 1,200ms 가 같습니다(장기는 깊이 1·3·5, 시간 300·1200·2600ms). 두 엔진은 같은 저장소의 다른 게임이므로 비교해 볼 만하지만, 같은 값을 쓴 이유는 소스로 확인되지 않아 적지 않습니다. 장기 소스에는 Worker 라는 단어가 없다는 것도 확인했습니다.

Web Worker 를 쓴 이유: 화면이 2.3초 멈추지 않게

어려움은 한 수에 최대 2.3초를 씁니다. 화면 스레드에서 이 계산을 돌리면 그동안 애니메이션, 입력, 소리가 모두 멈춥니다. Node 의 worker_threads 로 실험한 값은 이렇습니다. 같은 엔진 코드를 화면 스레드에서 돌렸을 때는 계산 2,305ms 동안 10ms 로 돌려 둔 타이머의 최대 간격이 2,315ms 로 늘어나 사실상 멈췄습니다. 워커로 돌렸을 때는 왕복이 2,365ms 걸렸지만 타이머의 최대 간격은 11ms 에 그쳤습니다. 브라우저의 Worker 와 완전히 같지는 않지만, 같은 단일 스레드 JavaScript 라는 점에서 구조는 같습니다.

소스에서 확인되는 구현은 이렇습니다. 엔진 텍스트에 onmessage 한 줄을 붙인 문자열을 Blob URL 로 만들어 워커를 띄우고, 메시지에 작업 번호와 FEN, 난이도, 반복 기록을 실어 보내 결과를 받습니다. Worker 를 만들지 못하는 환경에서는 30ms 뒤 화면 스레드에서 같은 함수를 실행하는 대비가 들어 있습니다. 힌트 버튼도 같은 길을 쓰며 난이도 3(어려움)으로 묻고, 3초 동안 파란 칸으로 보여 줍니다.

반복 국면 처리, 그리고 글을 쓰다 발견해 고친 결함

반복은 두 군데에서 처리합니다. 게임 쪽은 국면 키(FEN 의 말 배치, 차례, 캐슬링, 앙파상 칸 네 항목)마다 나온 횟수를 세고, 같은 국면이 세 번 나오면 status 가 3회 반복 무승부로 끝냅니다. 무르기를 하면 그 횟수도 되돌립니다. 엔진 쪽은 think 안에서 "이미 두 번 나온 국면으로 가는 수는 무승부(0점)로 평가"하도록 되어 있습니다. 소스의 주석도 같은 내용입니다.

그런데 이 글에 쓸 수치를 다시 실행하다 이 장치가 작동하지 않는다는 것을 발견했습니다. 처음 소스에서는 think 의 루트 반복문이 수를 두고(make) 난 뒤에 rep(m) 을 불렀는데, rep 는 안에서 같은 수를 한 번 더 두어 국면 키를 만듭니다. 이미 둔 수를 다시 두면 말이 사라진 엉터리 국면이 만들어지므로, 반복 기록과 결코 일치하지 않습니다. 실제로 백 퀸 f6, 백 킹 d5, 흑 킹 h7 국면에서 모든 합법 수의 결과 국면을 "두 번 나왔음"으로 표시한 뒤 보통 엔진에 물었더니 점수가 0 이 아니라 990 이었고(수정본은 0), 어려움 엔진에게 최선인 Ke6 한 수만 반복으로 표시해도 원본은 그 수를 그대로 골랐습니다(수정본은 Qg5). 킹+퀸 대 킹을 보통끼리 30판(시작 국면 세 곳 × 10판) 둔 결과는 원본이 메이트 24판·반복 무승부 6판, make 를 하기 전에 rep 를 계산하도록 고친 사본이 30판 모두 메이트였습니다. 한 번씩 돌린 결과입니다. 이 결함은 글을 쓰던 같은 날 고쳤습니다. 지금 소스는 반복 여부를 두기 전에 계산하고(isRep), 수정 후 같은 시험에서 모든 수를 반복으로 표시하면 점수가 0 으로 나오는 것을 다시 확인했습니다. 수정 전 코드는 첫 커밋 전이라 변경 이력에는 남아 있지 않고, 이 서술은 작업 중 실행 결과에 근거합니다.

실제로 겪은 버그: 컴퓨터가 한 번도 두지 않던 문제

체스 개발 중 가장 헷갈렸던 버그는 규칙이 아니라 비동기 순서였습니다. 증상은 간단했습니다. 내가 첫 수를 두면 상단 상태줄이 "컴퓨터 생각 중…"으로 바뀐 채 영영 그대로이고, 컴퓨터는 한 번도 두지 않았습니다. 엔진이 느린 것인지 응답을 받는 쪽의 문제인지 가르기 위해 워커 자체가 정상으로 응답하는지를 따로 확인했고, 워커는 정상이었습니다. 그래서 응답을 받는 쪽 코드를 의심했습니다.

원인은 작업 번호 jobId 를 다루는 순서였습니다. 소스에서 newGame 은 jobId 를 올리고, 응답을 받는 쪽은 번호가 다르거나 판이 이미 끝났으면 응답을 버립니다. 그런데 작업 번호를 요청 전에 읽어 두고 요청 안에서 번호를 올리는 순서였기 때문에, 응답이 돌아올 때마다 번호가 항상 어긋났고 정상 응답이 매번 버려졌습니다. 수정은 요청 직전에 번호를 올리고 그 값을 그대로 넘기는 것입니다. 지금 소스(aiTurn 과 hint)는 const myJob = ++jobId 로 번호를 올려 요청에 넘기고, 응답이 왔을 때 myJob !== jobId 이면 버립니다. 최초 커밋 전에 고친 것이라 수정 전 코드는 이력에 남아 있지 않고, 이 서술은 당시 작업 기록과 현재 소스를 따른 것입니다.

이 버그의 까다로운 점은 응답이 번호 비교에서 조용히 버려지기 때문에 증상이 "생각 중" 표시 하나뿐이라는 것이었습니다. 같은 번호 규칙을 힌트 버튼(hint 함수)도 쓰는데, 현재 소스에서는 두 곳 모두 요청 직전에 번호를 올립니다.

이 엔진이 못하는 것

소스에서 확인되는 한계를 정리합니다. 먼저 오프닝 책이 없습니다. 시작 국면에서 어려움은 3번 모두 e4 로 같은 수를 두고, 보통은 계산 결과 비슷한 수 중 무작위로 고르므로 e4 9·Nf3 9·d4 7·Nc3 5 로 퍼질 뿐 이론 수순을 외워서 두지는 않습니다. 둘째, 트랜스포지션 테이블이 없습니다. 같은 국면이 다른 수순으로 다시 나와도 처음부터 다시 계산하며, 소스에 해시나 테이블 관련 코드는 없습니다. 소스에서 해시·테이블 관련 단어를 검색해도 나오지 않습니다. 셋째, 널무브 가지치기, 늦은 수 줄이기, 어스피레이션 윈도 같은 현대 엔진의 탐색 기법이 없어서 같은 시간에 도달하는 깊이가 얕습니다. 넷째, 평가 함수가 위치표뿐이라 폰 구조나 킹 주변 안전을 직접 보지 못합니다.

그래서 사람이 보기에 어색한 판이 나옵니다. 어려움끼리 12수를 두면 8수에 퀸을 맞바꾸고 캐슬링을 한 번도 하지 않았습니다. 룩으로 메이트하는 마무리는 보통이 6판 중 2판만 끝냈습니다(나머지는 50수 무승부 3판, 기물 부족 1판). 그 이유까지는 확인하지 못했습니다. 또 앞서 적었듯 반복 회피가 현재 소스에서는 작동하지 않고, 탐색은 반복 국면을 루트에서만 보며 트리 안에서는 알지 못합니다. 규칙 구현은 perft 로 보증되지만 엔진의 실력에는 한계가 분명합니다. 이 글의 모든 수치는 2026년 10월 9일 시점의 소스 기준이며, 이후 소스가 바뀌면 달라질 수 있습니다.

관련 게임 플레이하기

다른 읽을거리

개발노트알까기에서 승리 소리가 끝없이 울린 이유게임이 끝난 뒤에도 승패 효과음이 반복되고 승리 횟수가 계속 오르던 알까기 버그를 settle() 흐름과 한 줄 수정, 같은 유형 점검 결과로 정리했습니다.개발노트스도쿠 힌트 뒤 되돌리기로 칸이 잠기던 버그스도쿠에서 힌트를 쓴 직후 되돌리기를 누르면 빈 칸이 고정되어 판을 끝낼 수 없던 버그의 재현 순서와 원인, 한 줄 수정, 같은 유형의 상태 버그를 점검한 방법입니다.입문장기 입문 가이드: 말 움직임부터 외통 몰기까지장기의 말 움직임, 포와 차의 쓰임, 궁성 대각선, 장군과 외통의 원리, 초보가 자주 하는 실수를 GamePool 장기 기준으로 정리했습니다.입문알까기 입문: 힘 조절, 각도, 충돌 물리 활용법알까기를 이기려면 얼마나 세게, 어느 방향으로 튕겨야 할까요? 당김 길이와 이동 거리, 충돌 때 돌이 움직이는 방식과 전술을 정리했습니다.칼럼장기·오목·알까기를 화면으로 옮기며 내린 결정들장기 AI의 읽는 수 1·3·5, 오목에 금수를 넣지 않은 선택과 6목 처리, 알까기 마찰·반발 값과 AI 후보 수까지. 소스에서 확인되는 구현 사실로 풀어 썼습니다.입문체스 입문: 기물 움직임부터 첫 체크메이트까지체스 규칙을 처음부터 구현하며 엔진에 직접 넣어 본 국면으로 기물 움직임, 캐슬링 조건, 앙파상, 프로모션, 무승부 규칙, 오프닝 원칙, 기본 체크메이트와 난이도별 결과를 정리한 입문 글입니다.