블로그로 돌아가기
Web Worker알고리즘최적화OCR러스트

브라우저가 죽었다 - 유전자 계산기와 Web Worker

러스트 농사 유전자 조합 계산하다가 브라우저 멈춤 이야기. Web Worker로 해결한 삽질기.

Softopia
2024년 11월 28일
8 min read

발단: "완벽한 클론을 찾아줘"

러스트 농사 시스템에는 유전자가 있다. 식물마다 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로 충분히 빨라서 클라이언트에서 처리.


현재 기능

  1. 클론 입력: 직접 타이핑 또는 OCR 스캔
  2. 목표 설정: GGGYYY, YYYYYY 등 원하는 조합 선택
  3. 최적 경로 계산: 최소 세대로 목표 달성하는 교배 순서
  4. 확률 표시: 각 단계의 성공 확률

사용 통계

| 항목 | 수치 | |------|------| | 월 계산 횟수 | ~3,000회 | | 평균 클론 수 | 6개 | | 평균 계산 시간 | 1.8초 | | OCR 사용률 | 약 20% |

OCR 쓰는 사람이 의외로 적다. 클론 6개 정도면 그냥 타이핑하는 게 더 빠르다고 느끼는 듯.


배운 것

  1. 무거운 계산은 Web Worker로: 메인 스레드 블로킹은 UX 재앙
  2. 가지치기가 핵심: 알고리즘 복잡도보다 탐색 공간 줄이기가 중요
  3. OCR은 전처리가 80%: Tesseract 자체보다 이미지 전처리 품질이 정확도 좌우
  4. 유저는 단순함을 원함: 복잡한 확률 분포보다 "기대값" 하나가 더 직관적

코드 일부

// 핵심 교배 로직
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