블로그JavaScript로 사주팔자 계산기 만들기 — 천간지지부터 오행 분석까지

JavaScript로 사주팔자 계산기 만들기 — 천간지지부터 오행 분석까지

사주팔자를 JavaScript로 구현한다고 하면 대부분 “그걸 코드로?” 하는 반응이다. 나도 처음엔 그랬다. 그런데 막상 파고 들어보면, 사주 계산은 생각보다 체계적인 수학 로직이다. 천간 10개, 지지 12개를 조합하고, 오행의 상생상극 관계를 매핑하고, 60갑자를 순환시키는 구조. 결국 모듈러 연산과 해시맵 탐색의 연속이다.

이 글에서는 실제로 워드프레스 플러그인으로 동작하는 사주팔자 계산기를 만들면서 겪은 과정을 코드와 함께 정리한다. 단순 이론이 아니라, 실제 배포 중인 코드 기반이다.

천간과 지지: 데이터 모델링부터

사주 계산의 기본 단위는 천간(天干) 10개와 지지(地支) 12개다. 이걸 어떤 자료구조로 잡느냐가 이후 모든 계산의 기반이 된다. 나는 오행(element), 음양(yin), 한자 표기를 포함한 객체 배열로 정의했다.

// 천간 (10 Heavenly Stems)
const STEMS = [
    { name: '갑', hanja: '甲', element: 'wood', yin: false },
    { name: '을', hanja: '乙', element: 'wood', yin: true },
    { name: '병', hanja: '丙', element: 'fire', yin: false },
    { name: '정', hanja: '丁', element: 'fire', yin: true },
    { name: '무', hanja: '戊', element: 'earth', yin: false },
    { name: '기', hanja: '己', element: 'earth', yin: true },
    { name: '경', hanja: '庚', element: 'metal', yin: false },
    { name: '신', hanja: '辛', element: 'metal', yin: true },
    { name: '임', hanja: '壬', element: 'water', yin: false },
    { name: '계', hanja: '癸', element: 'water', yin: true }
];

// 지지 (12 Earthly Branches)
const BRANCHES = [
    { name: '자', hanja: '子', element: 'water', animal: '쥐', yin: false },
    { name: '축', hanja: '丑', element: 'earth', animal: '소', yin: true },
    { name: '인', hanja: '寅', element: 'wood', animal: '호랑이', yin: false },
    // ... 12개 전체
    { name: '해', hanja: '亥', element: 'water', animal: '돼지', yin: true }
];

포인트는 element 필드다. 천간과 지지 모두 오행 속성을 갖고 있는데, 나중에 오행 분석에서 이 값을 기준으로 집계한다. yin 필드는 십신 계산에서 같은 음양인지 판별할 때 쓴다. 처음에 단순 문자열 배열로 시작했다가, 십신 계산을 구현하면서 객체로 리팩토링하게 됐다.

오행 관계: 상생과 상극의 해시맵

사주 해석의 근간인 오행의 상생상극 관계를 4개의 해시맵으로 표현한다. 나중에 십신이나 용신 분석에서 “나를 생하는 오행이 뭐지?” 같은 질문에 O(1)로 답할 수 있다.

const ELEMENTS = {
    wood: { name: '목', hanja: '木', color: '#4CAF50', kr: '나무' },
    fire: { name: '화', hanja: '火', color: '#f44336', kr: '불' },
    earth: { name: '토', hanja: '土', color: '#FFC107', kr: '흙' },
    metal: { name: '금', hanja: '金', color: '#E0E0E0', kr: '쇠' },
    water: { name: '수', hanja: '水', color: '#2196F3', kr: '물' }
};

// 상생: A가 B를 생한다
const GENERATES = { wood: 'fire', fire: 'earth', earth: 'metal', metal: 'water', water: 'wood' };
// 상극: A가 B를 극한다
const OVERCOMES = { wood: 'earth', earth: 'water', water: 'fire', fire: 'metal', metal: 'wood' };
// 역상생: 누가 나를 생하는가
const GENERATED_BY = { fire: 'wood', earth: 'fire', metal: 'earth', water: 'metal', wood: 'water' };
// 역상극: 누가 나를 극하는가
const OVERCOME_BY = { earth: 'wood', water: 'earth', fire: 'water', metal: 'fire', wood: 'metal' };

ELEMENTS 객체에 color 필드를 넣은 건, 오행 분석 차트를 그릴 때 각 오행 색상으로 표시하기 위해서다. 목=초록, 화=빨강, 토=노랑, 금=은색, 수=파랑. 동양 전통의 오방색이다.

연주 계산: 모듈러 연산의 시작

사주의 네 기둥 중 첫 번째인 연주(年柱)는 태어난 해에서 천간과 지지를 구한다. 기원전 4년이 갑자년이라는 기준점을 잡으면, 나머지 연산만으로 구할 수 있다.

function calcYearPillar(year) {
    var stemIdx = ((year - 4) % 10 + 10) % 10;
    var branchIdx = ((year - 4) % 12 + 12) % 12;
    return { stem: stemIdx, branch: branchIdx };
}

((year - 4) % 10 + 10) % 10 형태의 이중 모듈러 연산은 자바스크립트에서 음수 모듈러를 양수로 변환하기 위한 패턴이다. Python에서는 -1 % 109를 반환하지만, JS에서는 -1을 반환한다. 기원전 연도를 다루려면 이 처리가 필수다.

월주 계산: 년간에 따른 시작점 매핑

월주 계산은 연주보다 까다롭다. 지지는 월(月)에 대응하지만, 천간은 연간(年干)에 따라 시작점이 달라진다. 이게 소위 “갑기년 병인월 시작” 규칙이다.

// 년간 index를 5로 모듈러 -> 월 천간 시작점 매핑
const MONTH_STEM_START = [2, 4, 6, 8, 0];
// 갑(0)/기(5) -> 병(2)부터, 을(1)/경(6) -> 무(4)부터, ...

function calcMonthPillar(yearStemIdx, month) {
    var branchIdx = (month + 1) % 12;
    var startStem = MONTH_STEM_START[yearStemIdx % 5];
    var stemIdx = (startStem + (month - 1)) % 10;
    return { stem: stemIdx, branch: branchIdx };
}

핵심은 yearStemIdx % 5다. 천간 10개를 5쌍으로 묶는데, 갑과 기가 같은 그룹이고, 을과 경이 같은 그룹이다. 이 패턴을 인덱스 5로 모듈러 연산해서 매핑 테이블에 접근한다. 시주 계산도 동일한 패턴으로 일간 기준이다.

일주 계산: 기준일로부터의 일수 차이

일주(日柱)는 가장 정확한 기준점이 필요하다. 특정 날짜의 간지를 알고 있으면, 거기서부터 일수 차이를 계산해서 천간/지지를 구할 수 있다.

function calcDayPillar(year, month, day) {
    // 기준: 2000년 1월 7일 = 갑자일(甲子日)
    var refDate = new Date(2000, 0, 7);
    var targetDate = new Date(year, month - 1, day);
    var diffDays = Math.round((targetDate - refDate) / (1000 * 60 * 60 * 24));
    var stemIdx = ((diffDays % 10) + 10) % 10;
    var branchIdx = ((diffDays % 12) + 12) % 12;
    return { stem: stemIdx, branch: branchIdx };
}

2000년 1월 7일이 갑자일이라는 걸 기준점으로 잡았다. Math.round를 쓴 이유는 서머타임(DST) 경계에서 밀리초 나눗셈 결과에 소수점이 나올 수 있기 때문이다. 한국은 서머타임을 시행하지 않지만, Date 객체는 시스템 타임존 영향을 받으므로 round가 floor보다 안전하다.

시주 계산과 전체 사주 조합

const HOUR_STEM_START = [0, 2, 4, 6, 8];

function calcHourPillar(dayStemIdx, hourIdx) {
    var branchIdx = hourIdx;
    var startStem = HOUR_STEM_START[dayStemIdx % 5];
    var stemIdx = (startStem + hourIdx) % 10;
    return { stem: stemIdx, branch: branchIdx };
}

function calculateSaju(year, month, day, hourIdx) {
    var yearPillar = calcYearPillar(year);
    var monthPillar = calcMonthPillar(yearPillar.stem, month);
    var dayPillar = calcDayPillar(year, month, day);
    var hourPillar = calcHourPillar(dayPillar.stem, hourIdx);

    var pillars = [yearPillar, monthPillar, dayPillar, hourPillar];
    var elements = calcElements(pillars);

    return {
        year: yearPillar, month: monthPillar,
        day: dayPillar, hour: hourPillar,
        pillars: pillars, elements: elements,
        dayStem: dayPillar.stem, dayBranch: dayPillar.branch
    };
}

이 calculateSaju 함수의 반환값이 이후 모든 분석의 입력이 된다. dayStem은 사주에서 가장 중요한 “일간”으로, 본인을 나타내는 기준점이다. 모든 십신, 격국, 용신 분석이 이 일간 중심으로 돌아간다.

오행 분석: 8글자에서 5원소 분포 추출

function calcElements(pillars) {
    var counts = { wood: 0, fire: 0, earth: 0, metal: 0, water: 0 };
    pillars.forEach(function(p) {
        counts[STEMS[p.stem].element]++;
        counts[BRANCHES[p.branch].element]++;
    });
    return counts;
}

4개 기둥 x 2(천간+지지) = 총 8글자에서 각 오행의 개수를 센다. 이 분포가 사주 해석의 핵심이다. 목(木)이 3개이고 금(金)이 0개면, 목 과다 + 금 결핍 사주가 된다. 이 분포로 강약 판별과 용신 추출이 이루어진다.

십신 계산: 사주 해석의 꽃

십신(十神)은 일간을 기준으로, 나머지 7글자 각각이 나와 어떤 관계인지를 분류한다. 총 10가지: 비견, 겁재, 식신, 상관, 편재, 정재, 편관, 정관, 편인, 정인.

function getTenGod(dayStemIdx, otherStemIdx) {
    var dayElem = STEMS[dayStemIdx].element;
    var dayYin = STEMS[dayStemIdx].yin;
    var otherElem = STEMS[otherStemIdx].element;
    var otherYin = STEMS[otherStemIdx].yin;
    var sameYinYang = (dayYin === otherYin);

    if (dayElem === otherElem)
        return sameYinYang ? 'bigyeon' : 'geopjae';
    if (GENERATES[dayElem] === otherElem)
        return sameYinYang ? 'siksin' : 'sanggwan';
    if (OVERCOMES[dayElem] === otherElem)
        return sameYinYang ? 'pyeonjae' : 'jeongjae';
    if (OVERCOME_BY[dayElem] === otherElem)
        return sameYinYang ? 'pyeongwan' : 'jeonggwan';
    if (GENERATED_BY[dayElem] === otherElem)
        return sameYinYang ? 'pyeonin' : 'jeongin';
    return 'bigyeon';
}

이 함수가 사주 분석의 핵심 로직이다. 두 축으로 판별한다: (1) 오행 관계가 뭐냐 (2) 음양이 같냐 다르냐. 같은 오행이면 비견/겁재, 내가 생하는 오행이면 식신/상관. 음양이 같으면 “편(偏)”, 다르면 “정(正)”이 붙는 규칙을 코드로 풀면 위처럼 깔끔해진다.

지지의 십신은 지장간(地藏干)이라는 개념이 추가된다. 지지 안에 숨어있는 천간이 1~3개씩 있다:

const HIDDEN_STEMS = {
    0:  [9],        // 자(子): 계(癸)
    1:  [5, 9, 7],  // 축(丑): 기(己), 계(癸), 신(辛)
    2:  [0, 2, 4],  // 인(寅): 갑(甲), 병(丙), 무(戊)
    3:  [1],        // 묘(卯): 을(乙)
    4:  [4, 1, 9],  // 진(辰): 무(戊), 을(乙), 계(癸)
    5:  [2, 4, 6],  // 사(巳): 병(丙), 무(戊), 경(庚)
    6:  [3, 5],     // 오(午): 정(丁), 기(己)
    7:  [5, 3, 1],  // 미(未): 기(己), 정(丁), 을(乙)
    8:  [6, 8, 4],  // 신(申): 경(庚), 임(壬), 무(戊)
    9:  [7],        // 유(酉): 신(辛)
    10: [4, 7, 3],  // 술(戌): 무(戊), 신(辛), 정(丁)
    11: [8, 0]      // 해(亥): 임(壬), 갑(甲)
};

대운 계산: 인생의 10년 주기 흐름

대운은 10년 단위로 바뀌는 인생의 큰 흐름이다. 월주를 기준으로 순행/역행 방향이 결정되는데, 연간의 음양과 성별의 조합으로 정해진다.

function calcMajorLuck(yearStemIdx, monthStemIdx, monthBranchIdx, gender, birthYear, birthMonth, birthDay) {
    var yearYin = STEMS[yearStemIdx].yin;
    var isMale = (gender === 'male');
    // 순행: (남자+양년) 또는 (여자+음년)
    var forward = (isMale && !yearYin) || (!isMale && yearYin);

    var startAge = approximateStartAge(birthYear, birthMonth, birthDay, forward);

    var periods = [];
    for (var i = 1; i <= 10; i++) {
        var offset = forward ? i : -i;
        var stem = ((monthStemIdx + offset) % 10 + 10) % 10;
        var branch = ((monthBranchIdx + offset) % 12 + 12) % 12;
        var age = startAge + (i - 1) * 10;
        periods.push({ stem: stem, branch: branch, startAge: age, endAge: age + 9 });
    }
    return { forward: forward, startAge: startAge, periods: periods };
}

대운의 시작 나이를 계산하는 함수가 핵심이다. 생일에서 가장 가까운 절기(節氣)까지의 일수를 구하고, 3일을 1년으로 환산한다. 절기 날짜를 하드코딩한 근사 테이블을 사용했는데, 입춘(2월 4일경), 경칩(3월 6일경), 청명(4월 5일경) 등 12개 절기의 평균 날짜를 넣어둔다. 정밀한 천문학적 계산은 프론트엔드에서 하기엔 과하고, 근사치로도 전문가 결과와 1년 이내 오차를 보인다.

용신 분석: 사주 균형의 핵심 알고리즘

용신(用神)은 사주에서 부족한 오행을 보충하거나 과한 오행을 억제하는 핵심 개념이다.

function analyzeYongsin(saju) {
    var dayElem = STEMS[saju.dayStem].element;
    var elements = saju.elements;

    // 일간 강약 판별: 나와 같은 오행 + 나를 생하는 오행
    var myStrength = elements[dayElem] + elements[GENERATED_BY[dayElem]];
    var totalCount = Object.values(elements).reduce((a, b) => a + b, 0);
    var isStrong = myStrength > (totalCount / 2);

    if (isStrong) {
        // 강한 일간 -> 설기시킬 식상이 용신
        return { yongsinElem: GENERATES[dayElem], isStrong: true };
    } else {
        // 약한 일간 -> 생해줄 인성이 용신
        return { yongsinElem: GENERATED_BY[dayElem], isStrong: false };
    }
}

"나의 오행 + 나를 생하는 오행"이 전체의 절반을 넘으면 강한 일간. 단순하지만 실용적인 기준이다. 실제 사주 이론의 억부법(抑扶法)과 부합한다. 용신 결과에서 행운의 방위(목=동, 화=남, 토=중앙, 금=서, 수=북), 색상, 계절, 숫자까지 해시맵으로 매핑해서 추천한다.

격국 분석: 월지의 본기를 기준으로

function analyzePattern(saju) {
    var dayStemIdx = saju.dayStem;
    var monthBranchIdx = saju.month.branch;
    var hiddenStems = HIDDEN_STEMS[monthBranchIdx];
    var mainHidden = hiddenStems[0]; // 본기
    var tenGodKey = getTenGod(dayStemIdx, mainHidden);
    // tenGodKey에 따라 '식신격', '정관격' 등 격국 결정
}

HIDDEN_STEMS 테이블의 0번 인덱스(본기)를 꺼내서 일간과 십신 관계를 구한 다음, 격국 이름에 매핑한다. 각 격국(비견격, 겁재격, 식신격, 상관격, 편재격, 정재격, 편관격, 정관격, 편인격, 정인격) 마다 100~200자의 해석 텍스트를 작성해뒀다.

워드프레스 플러그인 구조

JS 계산 로직을 워드프레스 플러그인으로 감쌌다. 구조는 심플하다.

// shortcode가 있는 페이지에서만 에셋 로드
function nalkkul_saju_enqueue_assets() {
    global $post;
    if (is_a($post, 'WP_Post') && has_shortcode($post->post_content, 'nalkkul_saju')) {
        wp_enqueue_script('nalkkul-saju-script', NALKKUL_SAJU_URL . 'assets/saju.js',
            array('jquery'), NALKKUL_SAJU_VERSION, true);
        wp_localize_script('nalkkul-saju-script', 'nalkkul_saju', array(
            'ajaxUrl'    => admin_url('admin-ajax.php'),
            'nonce'      => wp_create_nonce('nalkkul_saju_nonce'),
            'isLoggedIn' => is_user_logged_in() ? 1 : 0,
        ));
    }
}
add_action('wp_enqueue_scripts', 'nalkkul_saju_enqueue_assets');

has_shortcode()로 현재 페이지에 shortcode가 있을 때만 에셋을 로드한다. 사주 JS가 1200줄이 넘어서, 모든 페이지에서 불필요하게 로드되면 성능에 직접적 영향이 간다. wp_localize_script로 PHP 변수(AJAX URL, nonce, 로그인 상태)를 JS 전역 객체로 전달한다.

회원/비회원 기록 관리

// 활성화 시 전용 테이블 생성
function nalkkul_saju_activate() {
    global $wpdb;
    $table_name = $wpdb->prefix . 'saju_history';
    $sql = "CREATE TABLE $table_name (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        user_id bigint(20) unsigned NOT NULL,
        name varchar(100) NOT NULL,
        birth_date varchar(20) NOT NULL,
        birth_hour varchar(10) NOT NULL,
        gender varchar(10) NOT NULL,
        result_json longtext NOT NULL,
        created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY user_id (user_id)
    ) $charset_collate;";
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta($sql);
}
register_activation_hook(__FILE__, 'nalkkul_saju_activate');

// AJAX 저장 핸들러 (로그인 사용자만)
function nalkkul_saju_save_reading() {
    check_ajax_referer('nalkkul_saju_nonce', 'nonce');
    if (!is_user_logged_in()) wp_send_json_error('로그인이 필요합니다.');

    global $wpdb;
    $wpdb->insert($wpdb->prefix . 'saju_history', array(
        'user_id'     => get_current_user_id(),
        'name'        => sanitize_text_field($_POST['name']),
        'birth_date'  => sanitize_text_field($_POST['birth_date']),
        'result_json' => wp_unslash($_POST['result_json']),
    ));
    wp_send_json_success(array('id' => $wpdb->insert_id));
}
add_action('wp_ajax_nalkkul_saju_save', 'nalkkul_saju_save_reading');

회원은 result_json에 전체 분석 결과를 JSON으로 저장해서 프로필 페이지에서 과거 기록을 다시 볼 수 있다. 비회원은 sessionStorage에 임시 저장 — 탭을 닫으면 사라지고, 회원가입 유도에도 쓰인다.

프리미엄 기능의 블러 처리

무료 사용자에게 기본 분석만 보여주고, 상세 분석(올해의 신수, 대운 상세, 심화 성격)은 CSS blur로 가린다.

.premium-locked {
    filter: blur(5px);
    user-select: none;
    pointer-events: none;
}
.premium-overlay {
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    z-index: 10;
}

실제 데이터를 렌더링한 다음 블러를 건다. 빈 영역에 블러를 걸면 의미가 없다. 사용자가 "실제 내용이 있구나" 느껴야 전환율이 올라간다.

겪은 문제들

JS Date 객체의 타임존 함정. 일주 계산에서 Date 객체는 로컬 타임존을 따른다. 서버와 사용자 타임존이 다르면 일수 차이가 1일 틀어질 수 있다. 양쪽 모두 로컬 시간 기준으로 상대 차이만 구하는 방식으로 해결했고, Math.round가 Math.floor보다 안전하다.

절기 기반 월 vs 음력 월. 사주의 월주는 음력이 아니라 절기 기준이다. 입춘(2월 4일경)이 1월의 시작. 매년 정확한 절기 날짜가 미세하게 다르기 때문에 프론트엔드에서는 근사 고정 날짜를 사용했다. 전문 앱이라면 NASA 천문 데이터 기반 정밀 계산이 필요하다.

하드코딩 테이블의 크기. 지장간, 납음(60갑자 x 오행), 십이운성 시작점 등은 전부 테이블 참조다. 납음 테이블 60개 엔트리, 지장간 12개, 십이운성 10개 — 합쳐도 수 KB 수준이라 서버 API 대신 JS 하드코딩이 맞다. 사주 데이터는 절대 변하지 않으므로.

마무리

전체 아키텍처를 정리하면: nalkkul-saju.php(shortcode, AJAX, DB), assets/saju.js(1200줄+ 계산 로직), assets/saju.css(UI), templates/saju-page.php(HTML 템플릿). 계산은 전부 클라이언트에서 이루어지고, 서버는 저장/조회만 담당한다. 외부 API 의존이 없어 오프라인 동작도 가능하다. 사주팔자를 코드로 풀어보면, 수천 년 동양 철학이 모듈러 연산 + 해시맵 참조 + 조건 분기라는 걸 알게 된다. 도메인 지식 습득이 코딩 자체보다 어려웠다.

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬