이 글은 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. 브라우저 WASMC. 앱 내장
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 수의 무작위성을 높여 기력을 크게 낮춘다” 고 적혀 있습니다.

그런데 이 장치에는 서비스 관점의 함정이 둘 있습니다.

  1. 필승 수순은 여전히 찾습니다. STRENGTH 0 에서도 VCF 잎 탐색과 즉승 판정은 그대로 돌아서, Part 4 의 렌쥬 국면에서 5번 모두 7수 필승을 찾았습니다. 초보자는 수를 조금 흘리다가 갑자기 한 번도 틀리지 않는 연속 공격 을 당하게 됩니다. 사람의 실력 차이와는 다른 모양입니다.
  2. 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 에는 그 뒤로 큰 변경이 계속 쌓였습니다. 서비스라면 다음을 하나의 묶음으로 고정 합니다.

  1. 엔진 커밋 (또는 릴리스 태그)
  2. 가중치 저장소 커밋
  3. 설정 파일 (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)#

라이선스 자료#

배경 자료#