브라우저가 죽었다 - 유전자 계산기와 Web Worker
러스트 농사 유전자 조합 계산하다가 브라우저 멈춤 이야기. Web Worker로 해결한 삽질기.
발단: "완벽한 클론을 찾아줘"
러스트 농사 시스템에는 유전자가 있다. 식물마다 6개 슬롯에 G(성장), Y(수확), H(체력), W(물), X(빈칸) 유전자가 들어간다.
내 감자: [G][Y][X][W][H][Y]
좋은 클론은 GGGYYY(성장3 + 수확3) 같은 조합. 근데 이걸 얻으려면 식물끼리 교배해야 한다.
"이 10개 클론으로 GGGYYY 만들 수 있어?" "어떤 순서로 교배해야 빨라?"
디스코드에서 이런 질문이 계속 왔다. 손으로 계산하기엔 조합이 너무 많았다.
1차 시도: 그냥 for문 돌리면 되겠지
function findBestBreeding(clones: Clone[], target: Gene[]) {
const results: BreedingResult[] = [];
// 모든 클론 쌍 조합
for (let i = 0; i < clones.length; i++) {
for (let j = i + 1; j < clones.length; j++) {
// 이 둘을 교배하면?
const offspring = breed(clones[i], clones[j]);
const score = calculateScore(offspring, target);
results.push({ parent1: clones[i], parent2: clones[j], score });
}
}
return results.sort((a, b) => b.score - a.score);
}
클론 10개면 조합이 45개. 간단하네.
근데 문제가 있었다.
교배 결과도 다시 교배할 수 있다. 자손끼리, 자손과 부모끼리... 세대를 거듭하면 경우의 수가 폭발한다.
1세대: 45가지 조합
2세대: 45 + 새로운 클론 포함해서 수백 가지
3세대: 수천 가지
그리고 각 교배마다 자손의 유전자가 확률적으로 결정된다. 부모가 [G][Y]고 [G][G]면 자손 첫 슬롯은 100% G지만, 둘째 슬롯은 50% Y, 50% G.
모든 확률 경우의 수까지 계산하면 경우의 수가 기하급수적으로 증가한다.
브라우저 사망 사건
// 순진한 완전 탐색
function findOptimalPath(clones: Clone[], target: Gene[], maxGen: number) {
const results: BreedingPath[] = [];
function explore(current: Clone[], generation: number, path: BreedingResult[]) {
if (generation > maxGen) return;
for (let i = 0; i < current.length; i++) {
for (let j = i + 1; j < current.length; j++) {
// 가능한 모든 자손 (확률 분기 포함)
const possibleOffspring = getAllPossibleOffspring(current[i], current[j]);
for (const offspring of possibleOffspring) {
const newPath = [...path, { parent1: current[i], parent2: current[j], offspring }];
if (matchesTarget(offspring, target)) {
results.push({ steps: newPath, generations: generation });
} else {
explore([...current, offspring], generation + 1, newPath);
}
}
}
}
}
explore(clones, 1, []);
return results;
}
클론 8개, 목표 GGGYYY, 최대 3세대.
실행 결과: 브라우저 탭이 "응답 없음"
Chrome 작업 관리자 열어보니 메모리 2GB 먹고 있었다. CPU 100%.
"자바스크립트 싱글 스레드 한계에 부딪힘"
메인 스레드에서 무거운 계산 돌리면 UI가 멈춘다. 버튼도 안 눌리고, 스크롤도 안 되고.
Web Worker 도입
해결책: 계산을 별도 스레드로 분리
// geneticsWorker.ts
self.onmessage = (e: MessageEvent) => {
const { clones, target, maxGenerations } = e.data;
// 무거운 계산은 여기서
const result = findOptimalPath(clones, target, maxGenerations);
// 완료되면 메인 스레드로 전송
self.postMessage({ type: 'complete', result });
};
// GeneticsCalculator.tsx
const [isCalculating, setIsCalculating] = useState(false);
const [progress, setProgress] = useState(0);
const workerRef = useRef<Worker | null>(null);
const startCalculation = () => {
setIsCalculating(true);
setProgress(0);
const worker = new Worker(
new URL('../workers/geneticsWorker.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
setProgress(e.data.percent);
} else if (e.data.type === 'complete') {
setResults(e.data.result);
setIsCalculating(false);
worker.terminate();
}
};
worker.postMessage({ clones, target, maxGenerations: 3 });
workerRef.current = worker;
};
// 취소 기능
const cancelCalculation = () => {
workerRef.current?.terminate();
setIsCalculating(false);
};
이제 계산 중에도 UI가 안 멈춘다. 프로그레스 바도 보여줄 수 있다.
알고리즘 최적화: 안 해도 되는 계산은 안 한다
Web Worker로 UI는 살렸는데, 계산 자체가 여전히 느렸다. 클론 10개면 결과 나오는데 30초.
가지치기 (Pruning)
function explore(current: Clone[], generation: number, path: BreedingResult[], bestScore: number) {
// 현재까지 최고 점수보다 가능성 없으면 탐색 중단
const maxPossibleScore = calculateMaxPotential(current, target);
if (maxPossibleScore < bestScore) {
return; // 이 경로는 버림
}
// ... 탐색 계속
}
"이 조합으로는 절대 목표에 도달 못 함"이면 더 탐색할 필요 없다.
중복 상태 제거
const visited = new Set<string>();
function explore(current: Clone[], ...) {
// 클론 목록을 정렬해서 문자열로 변환 (상태 해시)
const stateKey = current
.map(c => c.genes.join(''))
.sort()
.join('|');
if (visited.has(stateKey)) {
return; // 이미 탐색한 상태
}
visited.add(stateKey);
// ...
}
[A, B, C]와 [B, A, C]는 같은 상태. 중복 탐색 방지.
우선순위 탐색
// 목표에 가까운 조합부터 먼저 탐색
const pairs = getAllPairs(current)
.map(([c1, c2]) => ({
c1, c2,
potential: estimatePotential(c1, c2, target)
}))
.sort((a, b) => b.potential - a.potential);
for (const { c1, c2 } of pairs) {
explore(...);
if (foundOptimal) break; // 찾으면 즉시 종료
}
유망한 경로부터 탐색하면 최적해를 빨리 찾을 수 있다.
결과: 30초 → 2초
확률 계산 단순화
원래 모든 확률 분기를 다 계산하려고 했다.
부모1: [G][Y]
부모2: [H][Y]
자손: [G or H][Y] → 2가지 경우
6슬롯 전부 다르면: 2^6 = 64가지 경우
이건 너무 많다. 그래서 기대값 기반 점수로 바꿨다.
function calculateExpectedScore(parent1: Gene[], parent2: Gene[], target: Gene[]): number {
let score = 0;
for (let i = 0; i < 6; i++) {
if (parent1[i] === parent2[i]) {
// 100% 확정
score += parent1[i] === target[i] ? 1 : 0;
} else {
// 50:50 확률
const p1Match = parent1[i] === target[i] ? 0.5 : 0;
const p2Match = parent2[i] === target[i] ? 0.5 : 0;
score += p1Match + p2Match;
}
}
return score; // 0~6 사이
}
정확한 확률 분포 대신 기대값만 계산. 유저에게는 "평균적으로 이 정도 맞을 거다"라고 보여줌.
OCR 스캔: 스크린샷만 찍으면 자동 인식
유전자 직접 입력하기 귀찮다. 게임 스크린샷 찍으면 자동으로 인식되면 좋겠다.
Tesseract.js 도입
import Tesseract from 'tesseract.js';
async function scanGenes(imageFile: File): Promise<Gene[]> {
const result = await Tesseract.recognize(imageFile, 'eng', {
tessedit_char_whitelist: 'GYHWX',
});
const text = result.data.text.replace(/\s/g, '').toUpperCase();
// "GYHWXY" 같은 문자열 추출
return text.split('').filter(c => 'GYHWX'.includes(c)) as Gene[];
}
문제: 정확도가 낮았다. G를 C로, Y를 V로 인식하는 경우가 많음.
전처리 추가
async function preprocessImage(file: File): Promise<Blob> {
const img = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// 그레이스케일 변환
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
// 이진화 (명암 대비 강화)
const binary = gray > 128 ? 255 : 0;
data[i] = data[i + 1] = data[i + 2] = binary;
}
ctx.putImageData(imageData, 0, 0);
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), 'image/png');
});
}
결과: 정확도 70% → 90%
후처리 보정
function correctOCRResult(text: string): Gene[] {
const corrections: Record<string, Gene> = {
'C': 'G', // C → G
'V': 'Y', // V → Y
'I': 'H', // I → H
'0': 'O', // 숫자 0 → X로 처리 안 함
// ...
};
return text
.split('')
.map(c => corrections[c] || c)
.filter(c => 'GYHWX'.includes(c)) as Gene[];
}
흔히 잘못 인식되는 패턴 수동 보정.
최종 정확도: ~95%
실패한 시도들
1. 유전 알고리즘(GA)으로 최적화
최적 경로 찾는 데 유전 알고리즘 써봤다. 아이러니하게도 유전자 계산기에 유전 알고리즘.
// 염색체 = 교배 순서
type Chromosome = number[]; // [0, 2, 1, 3, ...] = 클론 0과 2 교배, 그 결과와 1 교배, ...
function evolve(population: Chromosome[], generations: number) {
for (let gen = 0; gen < generations; gen++) {
// 적합도 평가
const fitness = population.map(c => evaluateFitness(c));
// 선택, 교차, 변이
population = nextGeneration(population, fitness);
}
return getBest(population);
}
결과: 완전 탐색보다 느리고, 최적해 보장도 안 됨.
교배 가능한 조합 수가 적어서 (최대 수십 개) GA의 장점이 없었다. 그냥 가지치기 + 우선순위 탐색이 더 빠름.
2. 서버 사이드 계산
계산이 무거우니 서버에서 하고 결과만 받으면?
포기 이유:
- 서버 비용 증가
- 네트워크 지연
- 오프라인에서 사용 불가
Web Worker로 충분히 빨라서 클라이언트에서 처리.
현재 기능
- 클론 입력: 직접 타이핑 또는 OCR 스캔
- 목표 설정: GGGYYY, YYYYYY 등 원하는 조합 선택
- 최적 경로 계산: 최소 세대로 목표 달성하는 교배 순서
- 확률 표시: 각 단계의 성공 확률
사용 통계
| 항목 | 수치 | |------|------| | 월 계산 횟수 | ~3,000회 | | 평균 클론 수 | 6개 | | 평균 계산 시간 | 1.8초 | | OCR 사용률 | 약 20% |
OCR 쓰는 사람이 의외로 적다. 클론 6개 정도면 그냥 타이핑하는 게 더 빠르다고 느끼는 듯.
배운 것
- 무거운 계산은 Web Worker로: 메인 스레드 블로킹은 UX 재앙
- 가지치기가 핵심: 알고리즘 복잡도보다 탐색 공간 줄이기가 중요
- OCR은 전처리가 80%: Tesseract 자체보다 이미지 전처리 품질이 정확도 좌우
- 유저는 단순함을 원함: 복잡한 확률 분포보다 "기대값" 하나가 더 직관적
코드 일부
// 핵심 교배 로직
export function breedClones(parent1: Gene[], parent2: Gene[]): {
offspring: Gene[];
certainty: number;
} {
const offspring: Gene[] = [];
let certainSlots = 0;
for (let i = 0; i < 6; i++) {
if (parent1[i] === parent2[i]) {
offspring.push(parent1[i]);
certainSlots++;
} else {
// 둘 중 랜덤 (실제로는 기대값 계산에 사용)
offspring.push(Math.random() < 0.5 ? parent1[i] : parent2[i]);
}
}
return {
offspring,
certainty: certainSlots / 6
};
}
다음 편에서는 레이드 계산기 이야기. 유황 2000개로 뭘 부술 수 있는지 계산하는 건 의외로 어렵다.
질문 있으면 디스코드로: softopia