오목 엔진 Rapfi 해부 Part 5: Rapfi 로 오목·렌쥬 서비스를 만들 때
이 글은 Claude Opus 5.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
시리즈의 마지막 편입니다. Part 4 에서 Rapfi 를 명령 단위로 돌려 봤으니, 이번에는 이것을 서비스의 엔진 으로 쓸 때 부딪히는 문제들을 정리합니다. 온라인 대국 사이트의 AI 상대, 기보 분석 기능, 퍼즐(필승 수순 찾기) 앱 같은 것을 염두에 둡니다.
이 글의 코드 인용은 master 커밋 3c94c2a (2026-07-23) 기준이고, 실측은 Apple M1 Max 에서 했습니다.
1. 어디서 돌릴 것인가#
가장 먼저 정할 것은 엔진의 위치입니다. 이 결정이 라이선스 의무, 비용, 공정성을 한꺼번에 정합니다.
flowchart LR
U["사용자"] --> A["A. 서버 실행<br/>네이티브 프로세스"]
U --> B["B. 브라우저 실행<br/>WASM (Gomocalc 방식)"]
U --> C["C. 앱 내장<br/>모바일·데스크톱"]
A --> A1["소스 공개 의무 없음<br/>계산 비용은 서버 부담"]
B --> B1["배포에 해당<br/>계산 비용은 사용자 기기"]
C --> C1["배포에 해당<br/>스토어 약관 검토 필요"]
style A fill:#90EE90,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#FF9999,color:#000000
| 항목 | A. 서버 | B. 브라우저 WASM | C. 앱 내장 |
|---|---|---|---|
| GPLv3 소스 제공 의무 | 없음 | 있음 | 있음 |
| 계산 비용 | 서버가 부담 | 사용자 기기 | 사용자 기기 |
| 응답 속도 | 네트워크 왕복 추가 | 기기 성능에 좌우 | 기기 성능에 좌우 |
| 대국 중 엔진 부정사용 방지 | 엔진이 서버에만 있음 | 사용자가 엔진을 손에 쥠 | 사용자가 엔진을 손에 쥠 |
| 초기 다운로드 | 없음 | 가중치 약 40MB | 앱 용량에 포함 |
부정사용 문제는 대인 대국 서비스라면 중요합니다. 브라우저나 앱에 엔진을 넣으면 사용자가 그 엔진으로 사람과의 대국에서 수를 물어볼 수 있습니다. 엔진을 서버에만 두어도 이 문제가 사라지지는 않지만(Rapfi 는 누구나 받을 수 있으므로), 서비스가 부정사용을 쉽게 만들지는 않게 됩니다.
2. 라이선스#
이 절은 법률 자문이 아닙니다. 상업 서비스라면 반드시 전문가의 검토를 받으십시오. 아래는 라이선스 원문과 FSF 의 공식 FAQ 가 말하는 범위입니다.
2.1 세 저장소, 세 라이선스#
| 대상 | 라이선스 | 의미 |
|---|---|---|
| 엔진 코드 (rapfi) | GPLv3 | 배포하면 대응 소스 전체를 GPLv3 로 제공해야 합니다 |
| 신경망 가중치 (rapfi-networks) | CC0 1.0 | 사실상 퍼블릭 도메인입니다. 제약이 없습니다 |
| 웹앱 Gomocalc (gomoku-calculator) | 라이선스 파일 없음 | 소스는 공개되어 있지만 재사용 허락이 명시되지 않았습니다. 기본 저작권이 적용되므로 코드를 가져다 쓰려면 저자의 허락이 필요합니다 |
Gomocalc 는 참고 구조로는 훌륭하지만 코드를 복사해 오면 안 됩니다. 구조를 보고 직접 구현하십시오.
2.2 무엇이 “배포” 인가#
GPLv3 의 의무는 프로그램을 남에게 전달(convey) 할 때 생깁니다. §0 의 정의가 핵심입니다.
“Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.” (복사본을 넘기지 않고 네트워크로 사용자와 상호작용만 하는 것은 전달이 아니다.)
그래서 배치에 따라 의무가 갈립니다.
- A. 서버 실행: 사용자는 엔진의 수만 받고 엔진 복사본은 받지 않습니다. GPL FAQ 도 수정한 GPL 프로그램을 웹사이트에서 돌리는 회사는 “수정한 소스를 공개할 필요가 없다” 고 답합니다. 엔진을 고쳐 써도 공개 의무가 없습니다. (이것은 GPL 이기 때문이고, AGPL 이었다면 달랐을 것입니다.)
- B. 브라우저 WASM: 같은 FAQ 항목은 웹사이트가 “방문자에게 전달되는 GPL 프로그램(흔히 JavaScript)” 을 포함하면 그 프로그램의 소스를 GPL 조건으로 공개해야 한다고 명시합니다. WASM 파일을 내려보내는 것은 목적 코드를 전달하는 것입니다. 그 WASM 을 만든 정확한 소스 를 제공해야 하고, 엔진을 고쳤다면 고친 소스도 GPLv3 로 공개해야 합니다. 소스는 GitHub 같은 다른 곳에 두고 안내해도 됩니다(§6(d)).
- C. 앱 내장: 앱에 실행 파일이나 라이브러리를 넣어 배포하는 것도 전달입니다. 여기에 모바일 스토어 문제가 더해집니다. FSF 는 2010년에 애플 App Store 약관이 GPLv2 와 충돌한다고 밝혔고, 실제로 GNU Go 가 스토어에서 내려갔습니다. 현재 스토어 약관이 GPLv3 와 충돌하는지는 1차 자료로 확인하지 못했습니다. 이 경로를 택한다면 법률 검토가 필수입니다.
2.3 내 코드가 GPL 에 물드는가#
서비스 코드 전체를 GPL 로 공개해야 하는지는 엔진과 어떻게 연결하느냐 에 달려 있습니다. GPL FAQ 의 “mere aggregation” 항목은 이렇게 말합니다.
- 파이프, 소켓, 명령행 인자는 “보통 서로 다른 두 프로그램 사이의 통신 수단” 입니다.
- 다만 복잡한 내부 자료구조를 주고받을 만큼 통신이 긴밀하면 하나의 프로그램으로 볼 근거가 될 수 있습니다.
- 같은 실행 파일로 링크하거나 주소 공간을 공유하면 거의 확실히 하나의 프로그램입니다.
Piskvork 프로토콜은 stdin/stdout 으로 주고받는 짧은 텍스트 명령 이므로 “별개의 프로그램” 쪽 패턴에 들어맞습니다. 반대로 Rapfi 의 C++ 코드를 내 서버나 내 WASM 모듈에 직접 링크 하면 결합 저작물이 됩니다. 엔진은 별도 프로세스(또는 별도 Web Worker)로 두고 텍스트 프로토콜로만 대화하는 것 이 라이선스 측면에서 가장 깔끔한 구조입니다. 이 판단도 해석이지 판결이 아니라는 점은 다시 적어 둡니다.
3. 프로세스 모델#
3.1 프로토콜은 상태를 가진다#
Piskvork 프로토콜은 프로세스 하나가 대국 하나를 기억하는 구조입니다. 판 크기, 룰, 시간 설정, 그리고 지금까지의 수가 엔진 프로세스 안에 있습니다. HTTP 처럼 요청마다 독립적이지 않습니다. 서비스는 둘 중 하나를 고릅니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 대국당 프로세스 1개 | 치환표와 폰더링(상대 차례에 미리 생각하기)을 대국 내내 활용 | 동시 대국 수만큼 프로세스와 메모리가 필요. 대국이 끝나지 않고 방치되면 자원이 샘 |
프로세스 풀 + 매번 BOARD 로 국면 재구성 | 프로세스 수를 CPU 코어 수에 맞춰 고정할 수 있음. 요청이 독립적이라 수평 확장이 쉬움 | 앞 수의 탐색 결과를 거의 재사용하지 못함 |
대부분의 서비스에는 풀 방식 이 맞습니다. BOARD … DONE 은 전체 수순을 한 번에 보내므로 요청 하나로 완결됩니다. 풀에서 꺼낸 프로세스를 다른 대국에 쓸 때는 다음을 지킵니다.
- 룰이나 판 크기가 바뀌면
START n과INFO rule을 다시 보냅니다. Rapfi 는 룰이 바뀌면 치환표를 스스로 비웁니다 (command/gomocup.cpp:236-240). - 같은 룰이라도 다른 대국의 치환표가 남아 있으므로, 결과가 앞 요청에 영향받는 것이 싫다면
YXHASHCLEAR로 비웁니다. RESTART는 같은 프로세스로 새 대국을 시작하는 Piskvork 명령입니다. 실측에서RESTART→OK→BEGIN→7,7이 정상 동작했습니다.
3.2 시간 제한과 멈춤#
INFO timeout_turn은 엔진의 목표 입니다. Rapfi 는 여기서 30ms 를 뺀 값을 상한으로 쓰므로(Part 4 3절) 대개 제한 안에 답합니다.- 그래도 서비스는 엔진을 믿지 말고 자기 마감 시간 을 따로 둡니다. 마감이 지나면
YXSTOP을 보냅니다. 실측에서 한 수 30초를 준 뒤 0.9초에YXSTOP을 보내자, 엔진은 그때까지의 최선수를 곧바로 답했습니다. YXSTOP에도 답이 없으면 프로세스를 죽이고 새로 띄웁니다. Piskvork 명세도 매니저가END뒤 약 1초 안에 끝나지 않는 엔진을 죽일 수 있다고 적습니다.- 엔진도 죽습니다. Rapfi 는 Gomocup 2023 표준 리그에서 27번 크래시 가 나 순위에서 빠진 적이 있습니다. 크래시한 프로세스를 감지해 교체하고, 그 요청을 다시 시도하는 로직이 있어야 합니다.
3.3 Go 래퍼#
위 원칙을 담은 최소한의 Go 래퍼입니다. 실제로 빌드해서 실행했습니다.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"os/exec"
"regexp"
"strings"
"time"
)
// Move is a Piskvork coordinate: 0-based x (column) and y (row).
type Move struct{ X, Y int }
// Engine drives one pbrain-rapfi process over the Piskvork protocol.
// It is not safe for concurrent use; give each game its own Engine or
// guard it with a pool.
type Engine struct {
cmd *exec.Cmd
stdin io.WriteCloser
lines chan string
}
var moveRe = regexp.MustCompile(`^(\d+),(\d+)$`)
// Start launches the engine in dir, where config.toml and the weights live.
func Start(dir string) (*Engine, error) {
cmd := exec.Command("./pbrain-rapfi")
cmd.Dir = dir
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
e := &Engine{cmd: cmd, stdin: stdin, lines: make(chan string, 1024)}
go func() {
sc := bufio.NewScanner(stdout)
for sc.Scan() {
e.lines <- sc.Text()
}
close(e.lines)
}()
return e, nil
}
func (e *Engine) send(lines ...string) error {
_, err := io.WriteString(e.stdin, strings.Join(lines, "\n")+"\n")
return err
}
// readUntil returns the first line matching match. Other lines go to onInfo.
func (e *Engine) readUntil(ctx context.Context, match func(string) bool, onInfo func(string)) (string, error) {
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case l, ok := <-e.lines:
switch {
case !ok:
return "", errors.New("engine exited")
case match(l):
return l, nil
case strings.HasPrefix(l, "ERROR"):
return "", fmt.Errorf("engine: %s", l)
case onInfo != nil:
onInfo(l)
}
}
}
}
// NewGame resets the engine. rule: 0 freestyle, 1 standard, 4 renju.
func (e *Engine) NewGame(ctx context.Context, size, rule int, turn time.Duration) error {
if err := e.send(fmt.Sprintf("START %d", size)); err != nil {
return err
}
if _, err := e.readUntil(ctx, func(l string) bool { return l == "OK" }, nil); err != nil {
return err
}
return e.send(
fmt.Sprintf("INFO rule %d", rule),
"INFO timeout_match 0",
fmt.Sprintf("INFO timeout_turn %d", turn.Milliseconds()),
)
}
// BestMove sends the whole game with BOARD and waits for the reply.
// history starts with black's first move; the engine plays the side to move.
// If ctx expires first, it asks the engine to stop and takes the move found so far.
func (e *Engine) BestMove(ctx context.Context, history []Move, onInfo func(string)) (Move, error) {
lines := []string{"BOARD"}
toMove := len(history) % 2
for i, m := range history {
side := 2 // opponent's stone
if i%2 == toMove {
side = 1 // engine's own stone
}
lines = append(lines, fmt.Sprintf("%d,%d,%d", m.X, m.Y, side))
}
lines = append(lines, "DONE")
if err := e.send(lines...); err != nil {
return Move{}, err
}
l, err := e.readUntil(ctx, moveRe.MatchString, onInfo)
if errors.Is(err, context.DeadlineExceeded) {
// Out of time: YXSTOP makes the engine answer with its current best move.
e.send("YXSTOP")
grace, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
l, err = e.readUntil(grace, moveRe.MatchString, onInfo)
}
if err != nil {
return Move{}, err
}
var m Move
fmt.Sscanf(l, "%d,%d", &m.X, &m.Y)
return m, nil
}
// Close ends the engine, killing it if it does not exit within a second.
func (e *Engine) Close() error {
e.send("END")
done := make(chan error, 1)
go func() { done <- e.cmd.Wait() }()
select {
case err := <-done:
return err
case <-time.After(time.Second):
e.cmd.Process.Kill()
return <-done
}
}
func main() {
eng, err := Start("../run")
if err != nil {
log.Fatal(err)
}
defer eng.Close()
ctx := context.Background()
if err := eng.NewGame(ctx, 15, 4, 10*time.Second); err != nil {
log.Fatal(err)
}
// Kagetsu (h8, h9, i9), white to move. The engine may think for 10s,
// but the service only waits 1.5s.
history := []Move{{7, 7}, {7, 8}, {8, 8}}
deadline, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
start := time.Now()
var last string
m, err := eng.BestMove(deadline, history, func(l string) { last = l })
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\nmove %d,%d after %v\n", last, m.X, m.Y, time.Since(start).Round(10*time.Millisecond))
}
MESSAGE Speed 296K | Depth 18-31 | Eval -441 | Node 427K | Time 1442ms
move 6,6 after 1.5s
엔진에게는 한 수 10초를 줬지만 서비스는 1.5초에 마감했고, YXSTOP 경로를 타서 그때까지의 최선수 6,6 을 받았습니다. 실제 서비스라면 여기에 몇 가지를 더합니다.
- 풀:
Engine여러 개를 채널에 담아 두고 요청마다 하나를 빌려 씁니다.Engine하나를 두 고루틴이 동시에 쓰면 응답이 섞입니다. - 교체:
engine exited나YXSTOP무응답 오류가 나면 그Engine을Close하고 새로Start해서 풀에 돌려놓습니다. - 입력 검증: 사용자가 보낸 좌표가 판 안인지, 빈칸인지, 렌쥬 흑 금수가 아닌지를 엔진에 보내기 전에 서비스가 확인합니다. 금수 확인에는
YXBOARD+YXSHOWFORBID를 쓸 수 있지만, 규칙 판정은 서비스가 직접 구현해 두는 편이 엔진 장애와 무관하게 동작합니다.
3.4 로그 파싱#
엔진의 MESSAGE 줄은 분석 기능의 재료입니다. 기본 출력은 사람이 읽기 좋은 형식이지만, 설정 파일의 message_mode 를 "ucilike" 로 바꾸면 키-값 쌍 형식으로 바뀝니다 (config.cpp:242-251). 花月 국면에서 실제로 바꿔 본 출력입니다.
MESSAGE depth 15-29 ev -352 n 103K n/ms 255 tm 406 pv G7 I10 I8 G10 H10 H11 G12 I12 J13 ...
MESSAGE depth 16-25 ev -352 n 114K n/ms 257 tm 444 pv G7 I10 I8 G10 H10 H11 G12 I12 J13 ...
6,6
depth, ev(평가), n(노드), tm(ms), pv 가 이름표를 달고 나오므로 파싱할 계획이라면 이쪽이 안정적입니다.
4. 룰과 판 크기#
4.1 신경망이 지원하는 판은 좁다#
Part 3 에서 본 것처럼 가중치 파일마다 지원하는 룰과 판 크기가 정해져 있습니다. 배포 가중치(mix9svq) 기준으로 이렇습니다.
| 룰 | 신경망이 동작하는 판 | 그 밖의 판 |
|---|---|---|
| 자유룰 | 13~22줄 | 고전 평가만 사용 |
| 표준 (정확히 다섯) | 15줄만 | 고전 평가만 사용 |
| 렌쥬 | 15줄만 | 고전 평가만 사용 |
신경망을 쓸 수 없으면 엔진은 오류 없이 조용히 고전 평가로 둡니다 (eval/evalconfig.cpp:100-103). 한국에서 흔한 19줄 판 오목을 렌쥬 룰로 서비스하면 엔진이 크게 약해지는데, 서비스 쪽에서는 알아차리기 어렵습니다. 룰과 판 크기 조합을 15줄 렌쥬, 15줄 표준, 13~22줄 자유룰 로 제한하거나, 그 밖의 조합은 “약한 AI” 로 명시하는 편이 정직합니다.
4.2 렌쥬 오프닝 규정은 서비스의 몫#
Rapfi 가 아는 오프닝 규정은 자유 오프닝, SWAP1, SWAP2 뿐입니다 (core/types.h:266-278). INFO rule 로는 SWAP1·SWAP2 를 자유룰과만 조합할 수 있고(값 5, 6), SWAP2BOARD 명령은 현재 룰을 유지한 채 스왑2 로 동작합니다.
렌쥬 입문 Part 2 에서 본 Soosõrv-8, Taraguchi-10, 야마구치 룰 같은 렌쥬 대회 규정은 소스에 전혀 없습니다. 26주형 중 하나로 시작하기, 5수째 후보 여러 개 제시하기, 색 교환 같은 절차는 서비스가 직접 진행하고, 엔진에는 각 단계에서 필요한 질문만 합니다.
- “이 5수 후보들 중 백에게 가장 좋은 것은?” → 후보마다 국면을 만들어 평가를 받거나,
YXBLOCK으로 나머지 칸을 막고 탐색시킵니다. - “흑 백 어느 쪽을 고를까?” → 그 국면의 평가 부호를 봅니다.
- “균형 잡힌 오프닝을 제시하라” →
YXBALANCEONE/YXBALANCETWO가 평가값이 목표에 가장 가까운 수(쌍)를 찾습니다.
Caro 룰은 지원하지 않습니다.
5. 난이도 설계#
5.1 STRENGTH 가 하는 일과 하지 않는 일#
INFO STRENGTH 0..100 은 Part 4 에서 실측한 대로 두 가지를 합니다.
- 주 탐색의 깊이 상한 을 4~16 사이로 제한합니다.
- 상위 2~6개 후보 중에서 평가 차이와 난수 를 섞어 하나를 고릅니다.
Gomocalc 는 이것을 “Handicap” 슬라이더로 보여 주고 STRENGTH = 100 − Handicap 으로 보냅니다. 소개 페이지에는 “Handicap 은 AI 수의 무작위성을 높여 기력을 크게 낮춘다” 고 적혀 있습니다.
그런데 이 장치에는 서비스 관점의 함정이 둘 있습니다.
- 필승 수순은 여전히 찾습니다. STRENGTH 0 에서도 VCF 잎 탐색과 즉승 판정은 그대로 돌아서, Part 4 의 렌쥬 국면에서 5번 모두 7수 필승을 찾았습니다. 초보자는 수를 조금 흘리다가 갑자기 한 번도 틀리지 않는 연속 공격 을 당하게 됩니다. 사람의 실력 차이와는 다른 모양입니다.
- MCTS 에는 적용되지 않습니다.
strengthLevel은 알파-베타 탐색기에서만 쓰입니다.SEARCH_TYPE mcts로 바꾸면 STRENGTH 는 무시됩니다.
5.2 더 사람다운 약한 AI#
사람처럼 약한 AI 를 원한다면 엔진 옵션 하나로는 부족하고 서비스 쪽 설계가 필요합니다. 선택지는 다음과 같습니다.
| 방법 | 효과 | 비고 |
|---|---|---|
YXNBEST n 으로 후보와 평가를 받고, 서비스가 확률적으로 차선을 고름 | 실수의 크기와 빈도를 서비스가 조절 | 가장 유연합니다. 엔진 수정이 필요 없습니다 |
| 초보 단계에서는 “상대 사를 못 보고 지나치기” 같은 실수 유형을 직접 주입 | 사람의 전형적인 실수를 흉내 | 규칙 판정 로직이 서비스에 있어야 합니다 |
INFO max_node, max_depth, 짧은 timeout_turn | 전반적으로 얕게 읽음 | 무작위성은 늘지 않습니다 |
신경망 없는 고전 설정 (gomocalc-classical*.toml) 을 RELOADCONFIG 로 적용 | 평가 자체를 약하게 | 가중치 저장소에 예제 설정이 있습니다 |
| 엔진 소스를 고쳐 VCF 를 끄거나 제한 | 필승 수순 탐지를 약하게 | 고친 엔진을 브라우저·앱으로 배포하면 GPLv3 로 소스 공개 |
참고로 바둑의 KataGo 는 사람 기보로 학습한 “human SL” 모델로 특정 급수 사람처럼 두게 할 수 있습니다. 이 시리즈를 조사한 범위에서는 오목용으로 공개된 비슷한 모델을 찾지 못했습니다.
6. 자원과 비용#
6.1 CPU#
Rapfi 는 CPU 엔진이고 비용은 한 수에 주는 시간 × 스레드 수 로 거의 결정됩니다. 실측값을 모으면 이렇습니다 (M1 Max).
| 조건 | 속도 |
|---|---|
bench, 1스레드 | 약 22만 노드/초 |
| 花月 국면, 1스레드, 1초 | 약 26~27만 노드/초 |
| 花月 국면, 4스레드, 5초 | 약 160만 노드/초 |
| 花月 국면, MCTS, 1스레드 | 약 6~7만 방문/초 |
용량을 가늠하는 계산 예를 들면, 한 수 1초·1스레드로 AI 가 대국당 30수를 둔다면 대국 하나에 CPU 30초가 듭니다. 코어 하나로 한 시간에 약 120대국입니다. 사람이 생각하는 시간 동안은 CPU 를 쓰지 않으므로 실제 동시 대국 수는 이보다 훨씬 많이 받을 수 있습니다(폰더링을 끈 경우). 이 수치는 계산 예일 뿐이니 목표 기력에 맞는 시간을 정한 뒤 직접 부하 시험을 하십시오.
Gomocup 은 엔진당 CPU 코어 1개 로 대회를 치르고 Rapfi 는 그 조건에서 최상위입니다. 다시 말해 코어 1개, 한 수 수 초면 이미 사람이 이기기 어려운 강도 이므로, 대부분의 서비스에서 스레드를 늘릴 필요는 크지 않습니다.
6.2 메모리#
- 예제 설정의 치환표 기본값은 32MiB 입니다 (
default_tt_size_kb = 32768).INFO hash_size(KiB) 나INFO max_memory(바이트, 0 이면 350MB) 로 바꿉니다. - 프로세스마다 치환표를 따로 가지므로 풀 크기 × 치환표 크기 가 메모리 예산입니다. 여기에 가중치(룰마다 압축 상태로 약 10MB)가 더해집니다.
- MCTS 는 메모리가 곧 사고 시간입니다. 탐색 그래프가 메모리 예산을 다 쓰면 시간이 남아도 탐색을 끝냅니다 (
search/mcts/search.cpp:1063-1066). 실측에서 기본 32MiB 로는 3초를 줘도 1.1초 만에 멈췄고, 512MiB 를 주자 목표 시간을 다 썼습니다. 알파-베타는 치환표가 차도 오래된 항목을 덮어쓰며 계속 탐색합니다.
7. 브라우저(WASM)로 갈 때#
B 배치를 택했다면 Gomocalc 의 구조가 좋은 참고서입니다(코드가 아니라 구조를 참고하십시오).
- 빌드 변형: Rapfi 는 Emscripten 으로 빌드하며 README 에 절차가 있습니다. 싱글/멀티스레드와 SIMD 지원 여부에 따라 여러 변형을 만들고, Gomocalc 는
wasm-feature-detect로 브라우저 기능을 확인해 맞는 것을 고릅니다. - 멀티스레드 조건: WASM 스레드는
SharedArrayBuffer가 필요하고, 이것은 페이지가 교차 출처 격리(cross-origin isolation) 상태일 때만 쓸 수 있습니다.Cross-Origin-Opener-Policy: same-origin과Cross-Origin-Embedder-Policy: require-corp헤더를 내려야 합니다. 이 헤더를 켜면 CORP/CORS 를 지원하지 않는 외부 광고, 분석 스크립트, 임베드가 깨질 수 있습니다. 수익 모델이 광고라면 미리 확인해야 합니다. - 싱글스레드의 멈춤 문제: 싱글스레드 빌드는 생각하는 동안 명령을 읽지 못해
YXSTOP이 통하지 않습니다. Gomocalc 는 엔진을 Web Worker 에 넣고, 멈출 때는 워커를 통째로 종료하고 다시 띄웁니다. - 다운로드 크기: 2026-09-24 에 gomocalc.com 의 파일을 확인해 보니 WASM 본체(
rapfi-multi-simd128.wasm)는 약 1.1MB, 가중치 묶음(rapfi.data)은 약 40MB 였습니다. Gomocalc 는 서비스 워커로 캐시하고, 서비스 워커가 없는 브라우저에는 신경망 없는 가벼운 엔진을 줍니다. - 속도: WASM 과 네이티브의 속도 차이를 잰 1차 자료는 찾지 못했습니다. Gomocalc 소개 페이지는 “네이티브에 가까운 성능” 이라고만 적습니다. 목표 기기에서 직접 재십시오.
8. 버전 고정#
Rapfi 는 공식 릴리스가 드물고(마지막이 2025-06-15), master 에는 그 뒤로 큰 변경이 계속 쌓였습니다. 서비스라면 다음을 하나의 묶음으로 고정 합니다.
- 엔진 커밋 (또는 릴리스 태그)
- 가중치 저장소 커밋
- 설정 파일 (
config.toml)
셋을 따로 올리면 안 되는 이유가 있습니다.
- 설정 파일에는
[requirement] min_version = [0,43,1]같은 요구 버전이 있고, 엔진이 이보다 낮으면 “config requires newer version of rapfi” 오류로 설정을 읽지 않습니다 (config.cpp:204-210). - 가중치 저장소 main 은 2026-07-08 에 고전 평가 모델을 새 16패턴 배치로 변환했고, 엔진 쪽 패턴 체계도 같은 달에 바뀌었습니다. 릴리스 바이너리와 최신 가중치 저장소의 조합이 호환되는지는 확인하지 않았습니다. 같은 시점의 조합을 쓰는 것이 안전합니다.
- 갱신할 때는
pbrain-rapfi bench의 마지막Hash값을 기록해 두십시오. 같은 빌드·같은 가중치면 같은 값이 나오므로 배포 환경이 의도한 조합인지 확인하는 서명으로 쓸 수 있습니다. 새 조합이 정말 더 강한지는 이전 조합과 대국시켜 확인합니다.
README 가 링크하는 wiki 의 Protocol 문서는 2026-09-24 현재 열리지 않습니다. 명령과 INFO 키의 정본은 command/gomocup.cpp 이므로, 버전을 올릴 때는 이 파일의 변경 이력을 함께 보십시오.
9. 체크리스트#
| 영역 | 확인할 것 |
|---|---|
| 배치 | 서버 / 브라우저 / 앱 중 어디서 돌리는가. 대인 대국이라면 부정사용 가능성까지 고려했는가 |
| 라이선스 | 브라우저·앱이면 대응 소스를 제공하는가. 엔진을 링크하지 않고 별도 프로세스로 두었는가. Gomocalc 코드를 복사하지 않았는가 |
| 프로세스 | 풀 크기, 요청별 마감 시간, YXSTOP → kill 순서의 복구, 크래시 교체가 있는가 |
| 규칙 | 사용자 입력(범위·빈칸·금수)을 엔진 전에 검증하는가. 렌쥬 오프닝 규정을 서비스가 진행하는가 |
| 판 크기 | 신경망이 지원하는 룰·판 조합(15줄 렌쥬·표준, 13~22줄 자유룰)인가 |
| 난이도 | STRENGTH 만으로 충분한가. 약한 단계에서도 VCF 필승을 찾는다는 점을 감안했는가 |
| 자원 | 풀 크기 × 치환표 메모리를 계산했는가. MCTS 를 쓴다면 메모리를 넉넉히 줬는가 |
| 브라우저 | COOP/COEP 헤더가 광고·임베드와 충돌하지 않는가. 40MB 가중치를 캐시하는가 |
| 버전 | 엔진·가중치·설정을 한 묶음으로 고정하고 bench 해시를 기록했는가 |
시리즈를 마치며#
다섯 편에 걸쳐 Rapfi 를 봤습니다. 오목은 이미 풀린 게임이지만, 그 풀이를 실전 속도로 재현하는 일은 여전히 공학입니다. Rapfi 는 오목의 정의를 표로 굽고, KataGomo 가 GPU 로 얻은 지식을 초경량 신경망으로 증류하고, Stockfish 의 탐색 뼈대에 VCF 를 이식해서 CPU 한 코어로 대회 정상에 올랐습니다. 그리고 이 모든 것이 GPLv3 소스와 CC0 가중치로 열려 있습니다.
서비스를 만드는 사람에게 이 개방성은 기회이면서 숙제입니다. 엔진은 가져다 쓸 수 있지만, 룰 진행, 사람다운 난이도, 프로세스 관리, 라이선스 준수는 여전히 만드는 사람의 몫입니다.
References#
1차 출처 (2025~2026)#
- Rapfi 저장소 (master
3c94c2a, 2026-07-23): https://github.com/dhbloo/rapfi- 프로토콜:
Rapfi/command/gomocup.cpp - 설정과 버전 요구:
Rapfi/config.cpp - 기력 조절:
Rapfi/search/skill.h,Rapfi/search/ab/search.cpp - MCTS 메모리 예산:
Rapfi/search/mcts/search.cpp - 가중치 선택과 판 크기:
Rapfi/eval/evalconfig.cpp
- 프로토콜:
- rapfi-networks (CC0, 예제 설정): https://github.com/dhbloo/rapfi-networks
- gomoku-calculator (Gomocalc 소스, 라이선스 파일 없음): https://github.com/dhbloo/gomoku-calculator
- Gomocalc: https://www.gomocalc.com
- Gomocup 2026 결과: https://gomocup.org/results/gomocup-result-2026
- KataGo README (human SL 모델): https://github.com/lightvector/KataGo
- MDN, SharedArrayBuffer (보안 요구사항): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
- MDN, Cross-Origin-Embedder-Policy: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy
라이선스 자료#
- GNU General Public License v3: https://www.gnu.org/licenses/gpl-3.0.html
- GPL FAQ, 수정 버전을 공개하지 않고 웹사이트에서 쓰는 경우: https://www.gnu.org/licenses/gpl-faq.html#UnreleasedMods
- GPL FAQ, mere aggregation 과 결합 저작물: https://www.gnu.org/licenses/gpl-faq.html#MereAggregation
- GPL FAQ, 소스를 다른 서버에 두는 경우: https://www.gnu.org/licenses/gpl-faq.html#SourceAndBinaryOnDifferentSites
- FSF, “GPL Enforcement in Apple’s App Store” (2010, GPLv2 사례): https://www.fsf.org/news/2010-05-app-store-compliance
배경 자료#
- Piskvork 프로토콜 명세 (최종 갱신 2023-03-20): https://plastovicka.github.io/protocl2en.htm
- Emscripten, Pthreads support: https://emscripten.org/docs/porting/pthreads.html
- Gomocup 2023 결과 (Rapfi 표준 리그 크래시): https://gomocup.org/results/gomocup-result-2023