만유인력 — N체 문제를 브라우저에서
별·행성·위성을 직접 배치하고 관측하는 중력 시뮬레이션 — N체 문제와 수치 적분을 Canvas 2D로 구현한 기록
시작 — 왜 N체 문제인가
두 천체의 중력 운동은 케플러 법칙으로 정확하게 풀 수 있습니다. 그런데 천체가 세 개 이상이 되는 순간, 해석적 해(analytic solution)가 존재하지 않습니다. 이것이 바로 '3체 문제'이며, 뉴턴이 달의 운동을 계산하며 처음 부딪혔던 벽입니다.
해석적 해가 없다는 뜻은 공식 하나로 임의의 미래 상태를 계산할 수 없다는 의미입니다. 대신 매우 짧은 시간 간격으로 힘을 적분해 나가는 수치 적분(numerical integration) 방식으로 근사치를 구합니다. 브라우저에서 초당 60번 실행되는 RAF 루프가 그 역할을 맡습니다.
천체 정의와 타입 시스템
시뮬레이션에는 세 종류의 천체가 등장합니다. 질량 차이가 중력의 크기를 결정하고, 시각적으로도 크기와 색상으로 구분됩니다.
const BODY_DEF = {
star: { mass: 500, radius: 16, color: '#fef08a', glow: 'rgba(254,240,138,0.95)' },
planet: { mass: 80, radius: 9, color: '#60a5fa' },
moon: { mass: 15, radius: 5, color: '#c4b5fd' },
};
별(star)의 질량은 행성의 약 6배, 행성은 위성의 약 5배입니다. 실제 태양계에서 태양이 목성보다 약 1000배 무겁다는 사실과 비교하면 훨씬 압축된 수치이지만, 시뮬레이션 화면 안에서 흥미로운 궤도를 보여주기에는 이 비율이 가장 자연스럽게 느껴졌습니다.
중력 계산: 매 프레임, 모든 쌍에 F = G·m1·m2 / r²
핵심 루프는 단순합니다. N개의 천체가 있을 때, 모든 쌍(i, j)에 대해 중력을 계산하고 속도 벡터를 누적합니다.
for (let i = 0; i < bodies.length; i++) {
let vx = 0,
vy = 0;
for (let j = 0; j < bodies.length; j++) {
if (i === j) continue;
const b = bodies[j];
const dx = b.x - bodies[i].x;
const dy = b.y - bodies[i].y;
const distSq = dx * dx + dy * dy;
const dist = Math.sqrt(distSq);
const angle = Math.atan2(dy, dx);
const force = (G * b.mass) / distSq;
vx += Math.cos(angle) * force;
vy += Math.sin(angle) * force;
}
bodies[i].vx += vx;
bodies[i].vy += vy;
}
계산 복잡도는 O(N²)입니다. 천체 수가 적기 때문에(최대 10개 내외) 최적화 없이도 충분히 60fps를 유지합니다.
안정 궤도: 초기 속도를 어떻게 정할까
수치 적분 시뮬레이션에서 가장 중요한 것은 초기 조건입니다. 행성을 별 주변에 원형 궤도로 올려놓으려면 정확한 초기 속도가 필요합니다.
원형 궤도의 조건은 원심력과 중력이 균형을 이루는 것입니다:
// v_circular = sqrt(G * M / r)
const v = Math.sqrt((G * centralMass) / dist);
// 속도 방향은 중심 방향과 수직 (90도 회전)
body.vx = -Math.sin(angle) * v + central.vx;
body.vy = Math.cos(angle) * v + central.vy;
중심 천체의 현재 속도(central.vx, central.vy)를 더하는 부분이 핵심입니다. 바이너리 스타처럼 중심이 움직이는 시스템에서는 이를 빠뜨리면 궤도가 즉시 붕괴됩니다.
사전 정의된 세 가지 시나리오(태양계형, 바이너리 스타, 트리플 시스템)는 모두 이 공식으로 초기 속도를 계산해 배치합니다.
에너지 발산 방지: 최소 거리 클램프
두 천체가 매우 가까워지면 r²이 0에 가까워지며 힘이 무한대로 발산합니다. 실제 물리에서는 충돌이 일어나겠지만, 수치 시뮬레이션에서는 한 프레임 만에 천체가 우주 밖으로 날아가는 현상이 발생합니다.
이를 방지하기 위해 최소 거리 클램프를 적용합니다:
const MIN_DIST_SQ = 25 * 25; // 25px 이하에서는 힘 계산 제한
const distSq = Math.max(dx * dx + dy * dy, MIN_DIST_SQ);
물리적으로는 "천체가 합쳐졌다"고 해석할 수 있습니다. 시각적으로는 자연스러운 통과처럼 보입니다.
궤도 궤적(Trail): 링 버퍼 방식
각 천체가 지나온 경로를 화면에 표시하면 궤도의 형태가 한눈에 들어옵니다. 이를 위해 각 천체마다 링 버퍼(ring buffer)로 최근 위치를 저장합니다.
interface Body {
// ...
trail: Float32Array; // [x0, y0, x1, y1, ...]
trailHead: number; // 현재 쓰기 위치
trailLen: number; // 실제 저장된 개수
}
// 매 프레임 위치 기록
body.trail[body.trailHead * 2] = body.x;
body.trail[body.trailHead * 2 + 1] = body.y;
body.trailHead = (body.trailHead + 1) % TRAIL_MAX;
if (body.trailLen < TRAIL_MAX) body.trailLen++;
Float32Array를 사용하면 일반 배열 대비 메모리 할당이 안정적이고 GC 압박도 적습니다. 궤적 렌더링은 알파값을 시간에 따라 감쇄시켜 오래된 위치일수록 흐리게 표현합니다.
캔버스 성능 — 4K 해상도 캡
N체 시뮬레이션은 천체 수가 적어 계산 자체는 가볍지만, 궤적 Trail을 Float32Array로 수백 포인트씩 유지하면서 매 프레임 렌더링하면 픽셀 처리량이 상당합니다. 4K 디스플레이에서 devicePixelRatio가 높아지면 캔버스 버퍼가 불필요하게 커져 GPU 부하가 증가했습니다.
MAX_AREA = 1920 × 1080 기준으로 캔버스 버퍼 크기를 제한하는 캡을 적용했습니다. 궤도 궤적의 시각적 품질은 유지하면서, 4K 모니터에서도 60fps를 안정적으로 유지합니다.
마치며
수치 적분을 구현하고 나서 처음으로 행성이 별 주위를 타원 궤도로 도는 장면을 보았을 때, 생각보다 오래 화면을 바라봤습니다. F = G·m1·m2 / r² 이 한 줄짜리 공식이 이 모든 움직임을 만들어낸다는 사실이 새삼 경이로웠습니다.
바이너리 스타 시나리오에서 두 별이 서로를 잡아당기며 공전하고, 그 사이를 행성이 복잡한 로제트 궤도로 지나가는 장면은 지금도 볼 때마다 흥미롭습니다. 뉴턴의 법칙 세 줄로 만들어지는 카오스입니다.