몬테카를로 시뮬레이션 - 랜덤으로 복잡한 문제 풀기

원의 넓이를 구할 때, π(파이)를 어떻게 계산하는지 궁금해 본 적 있나요?

저는 처음에 “수학 공식으로 정확히 계산하는 것 아니야?”라고 생각했습니다. 하지만 랜덤한 점을 던져서 π를 근사할 수 있다는 것을 알고 깜짝 놀랐습니다!

// ❌ 복잡한 수식으로 계산
const pi = calculatePiWithComplexMath();

// ✅ 랜덤 샘플링으로 근사
const pi = estimatePiWithMonteCarloSimulation(); // 약 3.14159...

몬테카를로 시뮬레이션은 1940년대 맨해튼 프로젝트에서 처음 사용되었습니다. 물리학자들이 원자폭탄 개발 중 중성자 확산 문제를 풀 때, 너무 복잡해서 직접 계산할 수 없었습니다. 그래서 수학자 스타니스와프 울람(Stanisław Ulam)과 존 폰 노이만(John von Neumann)은 “무작위 샘플링”이라는 아이디어를 떠올렸고, 이를 당시 유명했던 모나코의 몬테카를로 카지노의 이름을 따서 “Monte Carlo Method”라고 명명했습니다.

핵심 아이디어는 간단합니다: 랜덤하게 많은 샘플을 생성하고, 그 결과를 관찰해서 확률이나 기댓값을 추정하는 것입니다. 수학적으로 정확한 답을 구하기 어려울 때, “충분히 많은 시도”를 통해 실용적인 근사값을 얻는 방법이죠.

왜 몬테카를로 시뮬레이션을 배워야 할까요?

1. 복잡한 문제를 단순하게 접근

// 복잡한 적분 문제를 수식 없이 해결
// 예: 불규칙한 도형의 넓이 구하기

function estimateArea(shape) {
  let insideCount = 0;
  const totalSamples = 1000000;

  for (let i = 0; i < totalSamples; i++) {
    const x = Math.random();
    const y = Math.random();

    if (shape.contains(x, y)) {
      insideCount++;
    }
  }

  return insideCount / totalSamples;
}

// 샘플이 많을수록 정확한 결과!

2. 실생활의 불확실성 모델링

// 주식 가격 시뮬레이션
function simulateStockPrice(initialPrice, days, volatility) {
  let price = initialPrice;
  const prices = [price];

  for (let i = 0; i < days; i++) {
    // 랜덤한 변화율 (-volatility ~ +volatility)
    const change = (Math.random() - 0.5) * 2 * volatility;
    price = price * (1 + change);
    prices.push(price);
  }

  return prices;
}

// 100번 시뮬레이션 -> 가능한 미래 시나리오 분석

3. 다양한 분야에서 활용

실제로 몬테카를로 시뮬레이션은 이런 곳에서 사용될 수 있습니다.

  • 금융: 옵션 가격 결정, 포트폴리오 리스크 분석 (블랙-숄즈 모델)
  • 물리학: 입자 운동, 분자 시뮬레이션
  • 게임: 게임 AI의 의사결정 (예: 알파고의 MCTS)
  • 머신러닝: 베이지안 추론, 강화학습
  • 공학: 품질 관리, 신뢰성 분석
  • 보험: 위험 평가, 보험료 책정

먼저, 기초부터 이해하기

몬테카를로 시뮬레이션을 이해하려면, 먼저 “확률”“기댓값”이라는 개념을 알아야 합니다.

확률의 기본

확률은 “어떤 사건이 일어날 가능성”을 0과 1 사이의 숫자로 표현한 것입니다.

확률 = 원하는 결과가 나온 횟수 / 전체 시도 횟수

예를 들어 동전을 던져서 앞면이 나올 확률은 1/2 (50%)입니다.

// 동전 던지기 시뮬레이션
function flipCoin() {
  return Math.random() < 0.5 ? '앞면' : '뒷면';
}

// 10,000번 던지면?
let heads = 0;
for (let i = 0; i < 10000; i++) {
  if (flipCoin() === '앞면') heads++;
}

console.log(heads / 10000); // 약 0.5 (50%)

핵심: 시도 횟수가 많을수록 이론적 확률에 가까워집니다. 이것을 “큰 수의 법칙(Law of Large Numbers)”이라고 합니다.

랜덤 샘플링이란?

샘플링은 “전체 중에서 일부를 뽑는 것”을 의미합니다. 몬테카를로에서는 무작위로 샘플을 뽑습니다.

// 0과 1 사이의 랜덤한 숫자
const randomSample = Math.random(); // 예: 0.742...

// 두 개의 랜덤한 좌표 (x, y)
const point = {
  x: Math.random(),
  y: Math.random()
};

이런 랜덤 샘플을 수천, 수만, 수백만 개 생성하면, 전체 공간의 성질을 파악할 수 있습니다.

원주율(π) 추정하기 - 가장 유명한 예제

가장 직관적이고 유명한 몬테카를로 시뮬레이션 예제는 π를 추정하는 것입니다.

아이디어

정사각형 안에 사분원(quarter circle)이 있다고 생각해보세요:

    1 ┌─────────────┐
      │      ⚫⚫⚫  │
      │    ⚫⚫⚫⚫⚫ │
      │   ⚫⚫⚫⚫⚫⚫│  ⚫ = 원 안에 있는 점
  y   │  ⚫⚫⚫⚫⚫⚫ │  ⚪ = 원 밖에 있는 점
      │ ⚫⚫⚫⚫⚫⚪⚪│
      │ ⚫⚫⚫⚪⚪⚪ │
      │ ⚫⚪⚪⚪⚪  │
    0 └─────────────┘
      0            1  x

사분원의 넓이 = π * r² / 4 = π / 4  (r = 1)
정사각형의 넓이 = 1 * 1 = 1

비율 = (원 안의 점 개수) / (전체 점 개수) ≈ π / 4
따라서, π ≈ 4 * (원 안의 점 개수) / (전체 점 개수)

구현

function estimatePi(numSamples) {
  let insideCircle = 0;

  for (let i = 0; i < numSamples; i++) {
    // 0과 1 사이의 랜덤 좌표
    const x = Math.random();
    const y = Math.random();

    // 원점으로부터의 거리
    const distance = Math.sqrt(x * x + y * y);

    // 반지름 1인 사분원 안에 있는지 확인
    if (distance <= 1) {
      insideCircle++;
    }
  }

  // π 추정
  const piEstimate = 4 * insideCircle / numSamples;
  return piEstimate;
}

// 실행
console.log(estimatePi(1000));      // 약 3.1~3.2
console.log(estimatePi(10000));     // 약 3.13~3.15
console.log(estimatePi(100000));    // 약 3.141~3.142
console.log(estimatePi(1000000));   // 약 3.1415~3.1416
console.log(Math.PI);               // 정확한 값: 3.141592653589793

관찰 포인트:

  • 샘플이 1,000개일 때: 오차 약 ±0.05
  • 샘플이 10,000개일 때: 오차 약 ±0.01
  • 샘플이 1,000,000개일 때: 오차 약 ±0.001

샘플 개수가 10배 증가하면, 오차는 약 √10 ≈ 3.16배 감소합니다.

시각화로 이해하기

코드를 실행하면서 점들이 어떻게 분포하는지 생각해보세요:

function visualizePiEstimation(numSamples) {
  const points = [];
  let insideCount = 0;

  for (let i = 0; i < numSamples; i++) {
    const x = Math.random();
    const y = Math.random();
    const distance = Math.sqrt(x * x + y * y);
    const inside = distance <= 1;

    if (inside) insideCount++;

    points.push({
      x,
      y,
      inside,
      // 현재까지의 π 추정값
      currentEstimate: 4 * insideCount / (i + 1)
    });
  }

  return points;
}

const simulation = visualizePiEstimation(1000);

// 처음 10개 샘플의 π 추정값
simulation.slice(0, 10).forEach((p, i) => {
  console.log(`${i + 1}번째 샘플 후 π ≈ ${p.currentEstimate.toFixed(4)}`);
});

// 마지막 π 추정값
console.log(`최종 추정: π ≈ ${simulation[simulation.length - 1].currentEstimate.toFixed(4)}`);

출력 예시:

1번째 샘플 후 π ≈ 4.0000  (편차가 큼!)
2번째 샘플 후 π ≈ 4.0000
3번째 샘플 후 π ≈ 2.6667
10번째 샘플 후 π ≈ 3.2000
100번째 샘플 후 π ≈ 3.1600
1000번째 샘플 후 π ≈ 3.1440  (점점 수렴!)
최종 추정: π ≈ 3.1440

초기에는 값이 크게 흔들리다가, 샘플이 늘어날수록 안정적으로 π에 가까워지는 것을 볼 수 있습니다.

실전 예제 1: 주사위 게임 시뮬레이션

두 개의 주사위를 던져서 합이 7이 나올 확률을 몬테카를로로 추정해봅시다.

이론적 확률

두 주사위의 합이 7이 되는 경우:
(1,6), (2,5), (3,4), (4,3), (5,2), (6,1) = 6가지

전체 경우의 수: 6 × 6 = 36가지

확률 = 6/36 = 1/6 ≈ 0.1667 (약 16.67%)

시뮬레이션으로 확인

function rollDice() {
  return Math.floor(Math.random() * 6) + 1; // 1~6
}

function simulateDiceGame(numTrials) {
  let sumIsSeven = 0;

  for (let i = 0; i < numTrials; i++) {
    const dice1 = rollDice();
    const dice2 = rollDice();

    if (dice1 + dice2 === 7) {
      sumIsSeven++;
    }
  }

  const probability = sumIsSeven / numTrials;
  return probability;
}

console.log(simulateDiceGame(1000));     // 약 0.16~0.17
console.log(simulateDiceGame(10000));    // 약 0.166~0.167
console.log(simulateDiceGame(100000));   // 약 0.1666~0.1668
console.log(1/6);                        // 정확한 값: 0.16666...

배울 점: 간단한 확률 문제라도 시뮬레이션으로 검증할 수 있습니다. 특히 복잡한 규칙이 있는 게임에서는 이론적 계산보다 시뮬레이션이 더 쉬울 수 있습니다.

실전 예제 2: 불규칙한 도형의 넓이

수학 공식으로 구하기 어려운 복잡한 도형의 넓이를 추정해봅시다.

// 복잡한 도형: x² + y² < 0.5이고 y > x인 영역
function isInsideComplexShape(x, y) {
  return (x * x + y * y < 0.5) && (y > x);
}

function estimateAreaOfComplexShape(numSamples) {
  let insideCount = 0;

  // 0~1 범위의 정사각형(넓이 = 1) 안에서 샘플링
  for (let i = 0; i < numSamples; i++) {
    const x = Math.random();
    const y = Math.random();

    if (isInsideComplexShape(x, y)) {
      insideCount++;
    }
  }

  // 정사각형의 넓이(1) × 비율
  const area = insideCount / numSamples;
  return area;
}

console.log(estimateAreaOfComplexShape(100000));  // 약 0.196

시각화:

1  ┌─────────────┐
   │    ▓▓▓      │
   │   ▓▓▓▓▓     │  ▓ = 도형 안
y  │  ▓▓▓▓▓▓     │
   │ ▓▓▓▓▓▓      │
   │  ◆◆◆       │  ◆ = y ≤ x 영역
0  └─────────────┘
   0            1  x

이런 방법으로 아무리 복잡한 도형이라도 넓이를 추정할 수 있습니다!

실전 예제 3: 기댓값 계산

카지노 게임에서 평균적으로 얼마를 딸 수 있는지(또는 잃는지) 계산해봅시다.

룰렛 게임

  • 빨간색에 $10 베팅
  • 빨간색이 나오면 $10 수익 (확률 18/38 ≈ 47.4%)
  • 검은색이나 초록색이 나오면 $10 손실
function playRoulette() {
  const random = Math.random();

  // 빨간색: 18/38 확률
  if (random < 18/38) {
    return 10;  // 수익
  } else {
    return -10; // 손실
  }
}

function simulateRouletteExpectedValue(numGames) {
  let totalProfit = 0;

  for (let i = 0; i < numGames; i++) {
    totalProfit += playRoulette();
  }

  // 게임당 평균 수익
  const expectedValue = totalProfit / numGames;
  return expectedValue;
}

console.log(simulateRouletteExpectedValue(10000));   // 약 -0.5
console.log(simulateRouletteExpectedValue(100000));  // 약 -0.53
console.log(simulateRouletteExpectedValue(1000000)); // 약 -0.526

// 이론적 기댓값: (18/38) × 10 + (20/38) × (-10) ≈ -0.526

결론: 평균적으로 게임당 약 $0.53를 잃습니다. 카지노가 돈을 버는 이유죠!

⚠️ 흔한 실수와 함정

몬테카를로 시뮬레이션을 사용할 때 주의해야 할 점들이 있습니다.

함정 1: 샘플 개수가 너무 적음

// ❌ 샘플이 너무 적어서 부정확
const pi1 = estimatePi(10);      // 2.8 또는 3.6 같은 엉뚱한 값
const pi2 = estimatePi(100);     // 여전히 오차가 큼

// ✅ 충분한 샘플로 정확도 향상
const pi3 = estimatePi(100000);  // 3.14... (신뢰할 만함)

가이드라인:

  • 빠른 테스트: 1,000 ~ 10,000 샘플
  • 실용적 정확도: 100,000 ~ 1,000,000 샘플
  • 높은 정확도: 10,000,000+ 샘플

함정 2: 랜덤이 진짜 랜덤이 아님

JavaScript의 Math.random()의사 난수(pseudo-random)입니다. 진짜 무작위가 아니라 알고리즘으로 생성된 것이죠.

// ❌ 같은 패턴이 반복될 수 있음 (매우 드물지만)
// Math.random()은 내부적으로 seed 기반

// ✅ 대부분의 경우 Math.random()으로 충분
// ✅ 보안이 중요하면 crypto.getRandomValues() 사용
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
const secureRandom = randomBytes[0] / (0xffffffff + 1);

함정 3: 수렴 속도를 과대평가

몬테카를로의 수렴 속도는 O(1/√n)입니다. 즉, 정확도를 10배 높이려면 샘플을 100배 늘려야 합니다!

// 오차를 1/10로 줄이려면?
// 샘플을 100배 늘려야 함

estimatePi(1000);      // 오차 ± 0.05
estimatePi(100000);    // 오차 ± 0.005 (100배 샘플, 10배 정확)
estimatePi(10000000);  // 오차 ± 0.0005 (10,000배 샘플, 100배 정확)

교훈: 몬테카를로는 강력하지만, 매우 높은 정확도가 필요하면 비효율적일 수 있습니다.

함정 4: 모든 문제에 적용 가능하다고 착각

// ❌ 이렇게 간단한 문제는 직접 계산이 나음
function addTwoNumbers(a, b) {
  // 몬테카를로로 덧셈을 할 필요가 없습니다!
  return a + b;
}

// ✅ 복잡하거나 해석적 해가 없을 때만 사용
// - 고차원 적분
// - 복잡한 확률 분포
// - 시스템 시뮬레이션

좋은 예 vs 나쁜 예

✅ 좋은 사용 사례

// 1. 복잡한 확률 문제
function simulatePokerWinRate(hand, opponents, numTrials) {
  let wins = 0;

  for (let i = 0; i < numTrials; i++) {
    // 랜덤하게 카드 배분하고 승패 판정
    if (playPokerRound(hand, opponents)) {
      wins++;
    }
  }

  return wins / numTrials;
}

// 2. 다변수 최적화
function findOptimalPortfolio(stocks, budget, riskTolerance) {
  let bestPortfolio = null;
  let bestReturn = -Infinity;

  for (let i = 0; i < 10000; i++) {
    // 랜덤하게 포트폴리오 구성
    const portfolio = generateRandomPortfolio(stocks, budget);
    const expectedReturn = simulatePortfolioReturn(portfolio);

    if (expectedReturn > bestReturn &&
        portfolioRisk(portfolio) <= riskTolerance) {
      bestReturn = expectedReturn;
      bestPortfolio = portfolio;
    }
  }

  return bestPortfolio;
}

❌ 나쁜 사용 사례

// ❌ 간단한 평균 계산에 몬테카를로 불필요
function averageOfArray(arr) {
  let sum = 0;
  for (let i = 0; i < 1000; i++) {
    const randomIndex = Math.floor(Math.random() * arr.length);
    sum += arr[randomIndex];
  }
  return sum / 1000;
}

// ✅ 직접 계산이 정확하고 빠름
function averageOfArray(arr) {
  return arr.reduce((sum, val) => sum + val, 0) / arr.length;
}

실무에서 사용할 때 팁

1. 진행 상황 표시

시뮬레이션이 오래 걸릴 때는 진행 상황을 보여주세요:

function estimatePiWithProgress(numSamples) {
  let insideCircle = 0;
  const milestones = [0.1, 0.25, 0.5, 0.75, 1.0];
  let nextMilestone = 0;

  for (let i = 0; i < numSamples; i++) {
    const x = Math.random();
    const y = Math.random();

    if (Math.sqrt(x * x + y * y) <= 1) {
      insideCircle++;
    }

    // 진행 상황 출력
    const progress = (i + 1) / numSamples;
    if (progress >= milestones[nextMilestone]) {
      const currentEstimate = 4 * insideCircle / (i + 1);
      console.log(`${(progress * 100).toFixed(0)}% 완료 - 현재 π ≈ ${currentEstimate.toFixed(5)}`);
      nextMilestone++;
    }
  }

  return 4 * insideCircle / numSamples;
}

estimatePiWithProgress(1000000);
// 출력:
// 10% 완료 - 현재 π ≈ 3.14080
// 25% 완료 - 현재 π ≈ 3.14272
// 50% 완료 - 현재 π ≈ 3.14156
// 75% 완료 - 현재 π ≈ 3.14165
// 100% 완료 - 현재 π ≈ 3.14159

2. 결과에 신뢰구간 제공

function estimatePiWithConfidence(numSamples, numRuns) {
  const estimates = [];

  for (let run = 0; run < numRuns; run++) {
    estimates.push(estimatePi(numSamples));
  }

  // 평균과 표준편차
  const mean = estimates.reduce((sum, val) => sum + val, 0) / numRuns;
  const variance = estimates.reduce((sum, val) => sum + (val - mean) ** 2, 0) / numRuns;
  const stdDev = Math.sqrt(variance);

  return {
    estimate: mean,
    stdDev: stdDev,
    // 95% 신뢰구간 (정규분포 가정)
    confidenceInterval: [mean - 1.96 * stdDev, mean + 1.96 * stdDev]
  };
}

const result = estimatePiWithConfidence(10000, 100);
console.log(`π ≈ ${result.estimate.toFixed(5)} ± ${result.stdDev.toFixed(5)}`);
console.log(`95% 신뢰구간: [${result.confidenceInterval[0].toFixed(5)}, ${result.confidenceInterval[1].toFixed(5)}]`);
// 출력 예:
// π ≈ 3.14152 ± 0.01623
// 95% 신뢰구간: [3.10971, 3.17333]

3. 재현 가능한 시뮬레이션

디버깅이나 테스트를 위해 시드를 고정할 수 있습니다:

// 간단한 시드 가능 난수 생성기
class SeededRandom {
  constructor(seed) {
    this.seed = seed;
  }

  random() {
    // Mulberry32 알고리즘
    let t = this.seed += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  }
}

// 같은 시드 = 같은 결과
const rng1 = new SeededRandom(12345);
console.log(rng1.random()); // 항상 0.2785...

const rng2 = new SeededRandom(12345);
console.log(rng2.random()); // 항상 0.2785... (재현 가능!)

더 나아가기

몬테카를로 시뮬레이션의 고급 주제들:

1. 분산 감소 기법 (Variance Reduction)

같은 샘플 개수로 더 정확한 결과를 얻는 기법들:

  • 중요도 샘플링 (Importance Sampling): 중요한 영역을 더 많이 샘플링
  • 층화 샘플링 (Stratified Sampling): 공간을 균등하게 나눠서 샘플링
  • 대조 변수법 (Control Variates): 알려진 값과의 차이를 이용

2. 마르코프 체인 몬테카를로 (MCMC)

복잡한 확률 분포에서 샘플링하는 기법:

  • Metropolis-Hastings 알고리즘
  • Gibbs Sampling

머신러닝의 베이지안 추론에서 핵심적으로 사용됩니다.

3. 몬테카를로 트리 탐색 (MCTS)

게임 AI에서 최적의 수를 찾는 알고리즘. 알파고가 사용한 핵심 기법입니다!

정리하면

몬테카를로 시뮬레이션은:

장점:

  • 복잡한 문제를 단순하게 접근
  • 구현이 직관적이고 쉬움
  • 다차원 문제에 효과적
  • 실제 불확실성을 모델링

단점:

  • 정확한 답이 아닌 근사값
  • 높은 정확도에는 많은 샘플 필요
  • 수렴이 느림 (O(1/√n))
  • 진짜 난수가 아닐 수 있음

언제 사용?

  • 수학적으로 풀기 어려운 문제
  • 확률이 개입된 복잡한 시스템
  • 여러 시나리오를 비교하고 싶을 때

언제 피해야?

  • 간단하게 직접 계산 가능할 때
  • 매우 높은 정확도가 필요할 때
  • 실시간 성능이 중요할 때

연습 문제

직접 구현해보면서 이해를 깊게 만들어보세요!

문제 1: 버핏 바늘 (Buffon’s Needle)

바닥에 평행선이 그어져 있고, 바늘을 떨어뜨렸을 때 선을 가로지를 확률을 구하세요. (힌트: 이것도 π와 관련이 있습니다!)

문제 2: 생일 문제

몇 명이 모이면 같은 생일을 가진 사람이 있을 확률이 50%를 넘을까요? 시뮬레이션으로 확인해보세요.

문제 3: 주차 시뮬레이션

길이 L인 주차 공간에 길이 d인 차들이 랜덤하게 주차합니다. 평균 몇 대나 주차할 수 있을까요?

문제 4: 주식 포트폴리오

3개 주식이 있고, 각각 다른 기대수익률과 변동성을 가집니다. 몬테카를로로 1년 후 포트폴리오 가치 분포를 시뮬레이션하세요.

참고 자료


몬테카를로 시뮬레이션은 “불확실성”을 다루는 강력한 도구입니다. 완벽한 답을 구할 수 없을 때, “충분히 좋은 답”을 빠르게 찾을 수 있게 해주죠. 실제 프로젝트에서 확률적 문제를 만나면, 몬테카를로를 떠올려보세요!

댓글