가상 체스 코치 제작기: Stockfish 계산, Claude 설명, ElevenLabs 음성 출력
요약
체스 엔진 Stockfish와 LLM을 결합하여 사용자가 게임 분석을 깊이 이해할 수 있도록 도와주는 코치 시스템을 개발했습니다. 이 시스템은 모든 답변의 근거를 엔진에서 나온 사실(facts)에 기반하도록 설계되었으며, 아키텍처 최적화 및 오류 방지 기법을 적용했습니다.
핵심 포인트
- LLM에게 포지션 대신 '사실'을 공급하여 환각 현상을 방지함.
- Stockfish WASM과 네이티브 프로세스 풀을 활용해 안정적인 엔진 구동 환경 구축.
- 평가(Evaluation)를 단순 숫자가 아닌 문장으로 제공하여 모델의 오해석 방지.
- 브라우저/서버 아키텍처 분리와 LRU 캐시 도입으로 성능 및 메모리 문제 해결.
게임을 패배할 때마다 엔진 분석을 열어보면 항상 같은 것을 보게 됩니다. −2.3과 화살표 말이죠. 괜찮습니다, 제 수가 잘못됐다는 건 알겠습니다. 하지만 왜일까요? 저는 무엇을 봤어야 했을까요? Stockfish는 그 답을 어떤 그랜드마스터보다 잘 알고 있지만, 그것을 말로 설명해주지는 못합니다.
그래서 저는 Stockfish처럼 보드판을 보고, 인간처럼 설명하며, 화상 통화 스타일의 화면으로 말을 걸어주는 코치를 만들었습니다. 첫 버전은 빠르게 완성되었습니다. 하지만 거짓말하지 않도록 가르치는 데는 훨씬 오랜 시간이 걸렸습니다.
이 글에서는 아키텍처, 포지션 대신 사실(facts)을 LLM에 공급하는 방법, 모든 답변이 보드판과 어떻게 검증되는지, 22개 언어의 음성 기능, 비용, 그리고 버그들에 대해 다룹니다. 코딩한 것보다 버그가 더 많았습니다.
왜 단순히 LLM에게 물어볼 수 없는가
LLM은 체스에 취약합니다. 보드판을 놓치고, 존재하지 않는 말을 움직이며, 자신이 계산해보지 않은 포지션을 자신 있게 평가합니다. 코치 입장에서는 이것이 침묵보다 더 나쁩니다. 1300 레벨의 플레이어는 'f3의 나이트가 비숍에 의해 방어된다'라는 말이 거짓이라는 것을 알아차리지 못하고 잘못된 개념을 기억하게 됩니다.
그래서 한 가지 변하지 않는 규칙이 생겼습니다:
답변에 나오는 모든 수, 라인, 평가는 엔진에서 나온다. 모델은 오직 설명만 한다.
아키텍처
Browser Server (Java / Spring)
─────── ──────────────────────
Stockfish WASM (Web Worker) Pool of native Stockfish (3 processes)
...
스톡피시(Stockfish)는 두 곳에서 실행됩니다. 즉각적인 느낌을 주고 비용이 들지 않아야 하는 모든 경우—사용자가 스톡피시를 상대로 플레이할 때의 코치 움직임이나 게임 중 자신의 수를 판단하는 경우—에는 브라우저 (Web Worker 내 WASM)에서 작동합니다. 반면, 서버에서는 네이티브 프로세스 풀로 작동합니다. 처음 버전에서는 질문당 하나의 스톡피시 인스턴스를 생성했는데, 시작하는 데 절반 초가 걸렸습니다. 이는 괜찮아 보였지만, 갑자기 10개의 질문이 들어오자 작은 인스턴스가 메모리를 다 써버려 전체 사이트가 다운되었습니다. 이제는 3개(각 64 MB 해시)의 풀을 사용하고, 20초의 대기열 타임아웃과, 동일한 오프닝 포지션에 대한 질문이 끊임없이 들어오기 때문에 3,000개의 포지션을 담는 LRU 캐시를 사용합니다.
모델에 포지션이 아닌 사실을 제공하기
제가 처음 만든 버전에서는 FEN(Forsyth-Edwards Notation)을 보내고 "왜 이 수가 나쁜지 설명해 달라"고 요청했습니다. 그러면 모델은 r1bqkb1r/pppp1ppp/2n2n2/...를 보고 지어내기 시작했습니다. 한 번은 6...Nd4가 "f3의 기물과 e2의 비숍을 공격한다"고 말했지만, 실제로는 e2에 여왕이 있었습니다.
이제 모델은 미리 계산된 사실들을 받습니다:
{
"current": {
"move": "14...Bxe4",
...
두 가지 알아야 할 중요한 교훈이 있습니다:
- 평가(Evaluations)는 숫자가 아닌 단어로 들어갑니다. 단순히
+0.2만 주었을 때, 모델은 가끔 그 값을 반대편으로 뒤집었습니다. 제가 로그에서 가장 좋아했던 문구는 "백에게 +0.2인데, 이는 명백히 흑에게 더 좋은 수이다."였습니다. - 실수의 원인은 코드를 통해 찾아야 합니다. 엔진의 주 변이(principal variation)를 따라가면서 (체크메이트, 놓친 체크메이트, 물질 손실, 포크, 핀 등) 그 이유를 파악해야 합니다. 모델의 역할은 1300 실력의 플레이어가 기억할 수 있도록 그것을 말로 표현하는 것입니다.
플레이어가 "만약 내가 룩으로 잡으면 어떨까?"라고 물어보면, 모델에게는 도구(tool)가 있습니다:
{
"name": "analyze",
"input_schema": {
...
서버는 실제 보드 위에서 수(최대 12 플라이스)를 두어 Stockfish를 실행하고, 평가치(eval), 6플라이스의 최적 라인, 그리고 마지막 수가 공격하는 대상을 반환합니다. 불법 수는 다음과 같은 메모와 함께 오류를 반환합니다: "왜 안 되는지 추측하지 말고, 단지 그 수 자체가 거기서 불가능하다고만 말하세요." 이 메모가 존재하는 이유는 라이브 게임에서 analyze 기능이 한때 플레이어의 수 이전 포지션으로 기본 설정되었기 때문에, 완벽하게 합법적인 수가 "불법"으로 반환되었고 코치가 자신 있게 설명했기 때문입니다: "c3는 당신의 나이트에 의해 잡혔습니다."
모든 답변 검증하기
사실 정보와 도구가 있음에도 불구하고, 모델은 때때로 존재하지 않는 수를 언급했습니다. 따라서 플레이어가 보기 전에 모든 답변이 확인됩니다. 어디에서든 나타나는 모든 수(게임, 엔진 라인, analyze 결과)는 allowed 세트에 저장되며, 답변에서 나오는 모든 수는 이 목록을 거칩니다:
// 러시아어 기물 문자도 포함 (Кр, Ф, Л, С, К): 모델이 "Фxg5"를 작성하며 한 번에 통째로 지어낸 라인을 검사 과정에서 빠뜨렸습니다.
private static final Pattern MOVE = Pattern.compile(
...
네, 모델은 한때 러시아어 표기법으로 완전히 지어낸 라인 전체를 작성했고, 영어 전용 정규 표현식(regex)이 이를 통과시켰습니다.
검사 항목은 네 가지입니다: 지어낸 수, 알려진 어떤 라인과도 일치하지 않는 번호가 매겨진 라인, "c4의 비숍이 f7을 공격한다"와 같은 단어로 된 주장(보드에서 확인), 그리고 8개 언어로 이름 붙여진 수들입니다. 이 중 하나라도 발견되면 클라이언트에게 retract 이벤트가 발생합니다(화면에 이미 스트리밍되던 텍스트가 지워짐), 그리고 모델은 다음을 받습니다:
check.append("당신의 답변은 사실 정보나 어떤 analyze 결과에도 없는 수를 언급했습니다: ")
.append(String.join(", ", invented))
.append(". 이 수들을 analyze 도구로 확인하거나 아예 빼세요. 그런 다음 다시 답변하세요.");
재생성 기회는 단 한 번만 주어지며, 깨끗한 답변만이 캐시됩니다.
단점: 체크가 때때로 진실을 처벌합니다. 조용한 수(move)를 두자, 모델은 "Ne5가 Nxd3를 위협한다"고 작성했습니다. 이는 실제 위협이었지만, Nxd3는 사실(facts)에 포함되어 있지 않았기 때문에 플래그가 지정되었고 두 번째 답변이 더 나빴습니다. 해결책은 느슨한 체크가 아니라 더 많은 사실(threatens_next, takes_away)을 추가하는 것이었습니다.
사고 모델 및 max_tokens
Claude 5는 답변하기 전에 생각하며, 사고 과정(thinking)이 max_tokens를 소모합니다. 저는 1400으로 설정했는데 ("120단어에 충분함") 답변들이 중간 단어에서 잘리기 시작했습니다. 어려운 질문의 경우, 모델은 45초 동안 생각한 후 아무것도 반환하지 않았습니다.
// Claude 5 모델은 답변하기 전에 생각하며, 이 사고 과정이 max_tokens를 소모합니다: 1400 기준
// 약 450글자로 된 답변이 중간 단어에서 잘렸습니다.
static final int MAX_TOKENS = 4000;
...
| 노력(effort) | 질문 1 | 질문 2 | 질문 3 |
|---|---|---|---|
| default | 7.5 s | 10.4 s | 53 s, 빈 답변 |
low | 12 s | 5.5 s | 8 s |
답변은 SSE를 통해 스트리밍됩니다 (text / retract / done); 첫 단어는 2.5~4초 만에 도착합니다. 시스템 프롬프트(버그에서 비롯된 25가지 번호 규칙)는 cache_control: ephemeral을 가진 캐시 블록과 질문별 <facts> 블록으로 분할됩니다.
LLM이 제 오래된 코드에서 발견한 버그들
코치가 거짓말하지 않도록 가르치는 동안, 제가 의존했던 게임 리뷰가 1년 동안 거짓말을 해왔다는 것을 알게 되었습니다:
} else if (lastCp != null) {
// UCI 점수는 움직이는 측에 상대적입니다
out.cp = whiteToMove ? lastCp : -lastCp;
오래된 코드는 UCI 점수를 백(White)의 것으로 읽었기 때문에, 평가값(eval)이 매 짝수 ply마다 부호가 바뀌었고 사이트의 모든 평가 그래프 절반은 거꾸로 되어 있었습니다. 체크메이트 점수는 완전히 누락되었습니다. 수순당 시간은 플레이된 모든 수에 대해 0으로 저장되어 있었습니다. 이제 경기별 정확도는 Lichess의 공식을 사용하며 조화 평균(단순 평균을 사용했을 경우, 패배한 게임에 대해 "23번의 정확한 수와 3번의 블런더 = 83%"라는 결과가 나왔습니다).
언제 정확히 블런더를 하나요?
가장 유용한 기능은 가장 화려하지 않은 것이었습니다. 지난 30경기 동안 코드는 습관적인 실수가 기준선보다 더 자주 발생하는 상황을 찾습니다:
double share = (double) inHabit / habitTotal; // 실수 중의 공유 비율
double base = (double) inAll / allTotal; // 모든 수 중의 공유 비율
double lift = share / base;
...
'당신의 블런더 대부분은 오프닝에서 발생한다'는 말은 당신의 수 대부분이 오프닝에 있을 때 아무 의미가 없습니다. 두 임계값을 모두 통과하는 사실만이 모델에 도달하여 문장으로 만듭니다. 저 자신의 결과: 제 블런더의 66%는 제가 무언가를 잡았을 때 발생하며, 기준선은 29%입니다. 잡기만 하고 기분이 좋다고 생각하며 보는 것을 멈추세요.
음성: 22개 언어와 퀸 문제
ElevenLabs. 사이트의 22개 언어를 모두 지원하는 것은 v4, v4 Turbo, 그리고 v3뿐입니다 (Multilingual v2는 베ଙ୍갈어, 페르시아어, 히브리어, 베트남어가 부족합니다). 실시간 대화에는 eleven_v4_turbo를 사용하며, 33개 강의 과정은 미리 eleven_v4로 음성 녹음되고, 각 텍스트는 한 번만 비용을 지불하면 됩니다. 모든 것은 캐싱에 달려 있습니다: 짧은 스톡 구문은 R2에서 sha256(voice|model|text) 아래 영구적으로 존재하며, 강의 오디오는 immutable 플래그를 사용하여 sha256(voice|model|lang|text) 아래 저장됩니다. 세마포어는 동시 요청을 5개로 제한합니다 (플랜의 한계). 이것이 없었다면 코치는 최고조에 달했을 때 문장 중간에 침묵했습니다.
표기법은 단어가 되어야 합니다: TTS는 'Nf3'를 글자로 읽지만, 코치는 '나이트 f3'라고 말합니다. 러시아어의 경우: `
저는 귀로 러시아어, 우크라이나어, 영어를 확인할 수 있습니다. 이 코치는 또한 힌디어, 한국어, 페르시아어, 타갈로그어를 구사하며, 지금쯤 원어민 벵골어 화자가 제 코치가 나이트 포크(knight fork)를 설명하는 것을 듣고 조용히 웃고 있을 거라고 확신합니다. 만약 이 언어들 중 하나를 하신다면, 댓글로 어떤 소리가 나는지 알려주세요.
음성-텍스트 변환은 무료입니다: 브라우저의 Web Speech API (Firefox에서는 없어 마이크 버튼이 숨겨져 있음)와 Android 앱의 시스템 인식기를 사용합니다. 왜냐하면 Android WebView에는 Web Speech API가 아예 없기 때문입니다. 타갈로그어는 tl이 아니라 fil-PH 로케일을 필요로 합니다. 그 문제 때문에 저에게 밤 한 번을 잃게 했습니다.
기물을 놓지 않는 스파링 파트너
코치와 플레이할 때, 약한 코치는 부주의해서는 안 됩니다. 브라우저의 Stockfish는 상위 6개 수(MultiPV 6)를 계산하며, 코치는 레벨에 따라 120 / 80 / 45 / 25 / 0 센티폰(centipawns) 손실 범위 내에서 무작위로 선택합니다. 기물 하나는 300점 이상 가치가 있으므로, 이 범위 안에 들어오는 일은 없습니다.
당신의 수를 판단하기 위해, 그것은 같은 MultiPV 검색 내부의 최고의 수와 비교됩니다. 깊이 12에서 수행된 두 개의 별도 검색은 반 병(half a pawn) 정도의 노이즈 차이를 보이며, 이는 일반적인 7.O-O를
코치는 democraticchess.com에서 사용해 보실 수 있습니다. 비회원은 가입하지 않고도 짧은 레슨을 한 번 받을 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기