달 위상 — 수식 하나로 오늘의 달을 그리기
29.53일 삭망 주기 공식으로 오늘의 달 위상을 계산하고 Canvas로 렌더링하는 과정 — 외부 API 없이 순수 수학으로
시작 — API 없이 달을 계산할 수 있을까
달 위상을 표시하는 앱을 만들려고 할 때 가장 먼저 든 생각은 "어떤 API를 써야 할까"였습니다. 그런데 찾아보니 달의 위상은 외부 데이터 없이 순수한 수학 공식으로 계산할 수 있었습니다.
달의 삭망 주기는 29.53058867일입니다. 알려진 신월(삭) 기준점 하나와 오늘 날짜만 있으면 현재 위상을 구할 수 있습니다.
위상 계산 — 삭망 주기 공식
2000년 1월 6일 18시 14분 UTC는 정확히 신월이었던 시각으로 잘 알려져 있습니다. 이 기준점에서 현재까지 경과한 일수를 삭망 주기로 나누면 현재 위상을 얻을 수 있습니다.
const LUNAR_CYCLE = 29.53058867;
function getMoonPhase(date: Date): number {
const KNOWN_NEW_MOON = new Date('2000-01-06T18:14:00Z');
const days = (date.getTime() - KNOWN_NEW_MOON.getTime()) / 86400000;
const phase = ((days % LUNAR_CYCLE) + LUNAR_CYCLE) % LUNAR_CYCLE;
return phase / LUNAR_CYCLE; // 0~1 (0=신월, 0.5=보름달)
}
음수 나머지를 방지하기 위해 % LUNAR_CYCLE + LUNAR_CYCLE) % LUNAR_CYCLE 패턴을 사용했습니다. 과거 날짜를 조회하면 days가 음수가 될 수 있기 때문입니다.
조도 계산과 8단계 위상 판별
위상 값(0~1)에서 조도(illumination)를 구하는 공식은 다음과 같습니다.
function getIllumination(phase: number): number {
return (1 - Math.cos(phase * 2 * Math.PI)) / 2;
}
0(신월)에서 0.5(보름달)까지 0에서 1로 올라갔다가, 0.5에서 1(다음 신월)까지 다시 0으로 내려옵니다.
8단계 위상 판별은 위상 값의 구간으로 분류합니다.
function getPhaseName(phase: number): PhaseName {
if (phase < 0.0625 || phase >= 0.9375) return 'new'; // 삭월
if (phase < 0.1875) return 'waxing-crescent'; // 초승달
if (phase < 0.3125) return 'first-quarter'; // 상현달
if (phase < 0.4375) return 'waxing-gibbous'; // 상현망간
if (phase < 0.5625) return 'full'; // 보름달
if (phase < 0.6875) return 'waning-gibbous'; // 하현망간
if (phase < 0.8125) return 'last-quarter'; // 하현달
return 'waning-crescent'; // 그믐달
}
Canvas 달 렌더링 — 타원호로 음영 경계 그리기
달을 Canvas로 그리는 핵심은 밝은 면과 어두운 면의 경계를 자연스럽게 표현하는 것입니다.
구현 방식은 다음과 같습니다. 원 전체를 어두운 색으로 채운 뒤, 밝은 면을 덮어 그립니다. 밝은 면의 경계는 반원과 타원호의 조합으로 구성됩니다.
// p = 0~1 위상 값, r = 달 반경
// 상현(p < 0.5): 오른쪽이 밝음 / 하현(p >= 0.5): 왼쪽이 밝음
const isWaxing = phase < 0.5;
const localPhase = isWaxing ? phase : 1 - phase;
// 타원 반경: 신월에 r, 보름달에 r, 경계는 0에서 왕복
const ellipseRx = r * Math.abs(1 - localPhase * 4);
const flattenSide = localPhase < 0.25 ? -1 : 1; // 타원 방향 (오목/볼록)
밝은 면의 윤곽은 반원(반달의 직선 경계)과 타원호(위상에 따라 오목하거나 볼록한 곡선)를 clip 영역으로 조합해 그립니다.
미니 달력 — MiniMoon 컴포넌트
당월 30일간의 달 위상을 작은 아이콘으로 나열하는 미니 달력을 만들었습니다. 각 날짜에 해당하는 위상을 계산해 16×16 크기의 오프스크린 캔버스에 미리 그려둡니다.
const miniMoons = useMemo(() => {
return Array.from({ length: daysInMonth }, (_, i) => {
const date = new Date(year, month, i + 1);
return getMoonPhase(date);
});
}, [year, month]);
각 MiniMoon은 drawMoon 함수를 16px 스케일로 호출해 렌더링합니다. 오늘 날짜는 테두리로 강조하고, 선택된 날짜는 백그라운드를 밝게 표시합니다.
다음 위상까지 D-day
현재 위상에서 다음 주요 위상(신월·상현·보름달·하현)까지 남은 일수를 계산해 표시합니다.
function getDaysToNextPhase(currentPhase: number): { name: PhaseName; days: number } {
const targets = [0, 0.25, 0.5, 0.75, 1.0];
const next = targets.find(t => t > currentPhase) ?? 1.0;
const days = (next - currentPhase) * LUNAR_CYCLE;
return { name: getPhaseName(next % 1), days: Math.ceil(days) };
}
마치며
달의 모양을 결정하는 것이 단 하나의 숫자 — 삭망 주기 내 위치 — 라는 사실이 흥미롭습니다. 29.53일이라는 주기와 기준점 하나로 수천 년 치의 달 모양을 계산할 수 있습니다.
외부 API 하나 없이 날짜 계산만으로 오늘 밤 하늘의 달이 화면에 나타났을 때, 수학이 현실과 맞닿는 순간을 느꼈습니다.