본문으로 건너뛰기

사주고사 제작기 10. useMemo 자정 버그와 visibilitychange 해결기

·7 min read·10 / 12

오늘/올해 운세가 빈 텍스트로 나오는 버그를 추적했고, 자정 이후에도 어제 운세가 표시되는 useMemo 의존성 문제를 visibilitychange 기반 훅으로 해결했다.


1. 증상

사주 챗봇 결과에서 AI가 생성한 5개 섹션(종합운, 성격, 직업/재물, 대인관계, 조언)은 정상 표시되는데, 클라이언트에서 계산하는 2개 섹션(오늘의 운세, 올해 운세)만 빈 텍스트로 나왔다.

✅ 종합운      → "화가 강한 사주로..."
✅ 성격        → "적극적이고 열정적인..."
✅ 직업/재물    → "창의적 분야에서..."
✅ 대인관계     → "사교성이 뛰어나..."
❌ 오늘의 운세  → (빈 텍스트)
❌ 올해 운세    → (빈 텍스트)
✅ 조언        → "수 기운을 보충..."

2. 아키텍처 리마인드

9일차(ba51a11)에서 운세를 **불변(permanent)**과 **가변(temporal)**으로 분리했다.

API 응답 (서버)
  └─ interpretation: { overview, personality, career, relationships, advice }
                      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                      permanent 5섹션 — Gemini AI 생성, 평생 불변
 
클라이언트 계산
  └─ temporal: { dailyFortune, yearFortune }
               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
               만세력 십신 기반, 매일/매년 변경

ComposedReadingSections 컴포넌트에서 useMemo로 합성:

const interpretation = useMemo(
  () => composeInterpretation(permanent, pillars),
  [permanent, pillars],  // ← 문제의 의존성 배열
);

3. 원인 분석

빈 텍스트 문제

Node.js, 빌드, 번들 전부 정상이었다. manseryeok, josa 라이브러리 모두 클라이언트 번들에 포함되어 있고, 직접 실행해도 운세 텍스트가 정상 생성됐다. computeTemporalFortune에 에러 핸들링이 전혀 없어서, 브라우저에서 예외가 발생하면 원인 파악 자체가 불가능한 구조였다.

자정 갱신 문제 (진짜 버그)

분석 중 useMemo 의존성에 날짜가 없다는 근본적 문제를 발견했다.

시나리오:
1. 3/25 오후에 사주 조회 → useMemo가 3/25 운세 계산
2. 탭을 열어둔 채 자정 경과
3. 3/26이 되었지만 permanent/pillars 참조 불변 → useMemo 재계산 안 함
4. 유저는 3/25 운세를 3/26 운세로 착각

computeTemporalFortune 내부에서 Date.now()를 사용하지만, useMemo는 의존성이 바뀌지 않으면 이전 결과를 반환한다. 날짜가 의존성에 없으니 절대 재계산되지 않는다.


4. 해결

4-1. useKstDateKey

visibilitychange 이벤트로 탭 복귀 시 날짜 변경을 감지한다.

function getKstDateKey(): string {
  const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
  return `${kst.getUTCFullYear()}-${kst.getUTCMonth() + 1}-${kst.getUTCDate()}`;
}
 
function useKstDateKey(): string {
  const [dateKey, setDateKey] = useState(getKstDateKey);
 
  useEffect(() => {
    function check() {
      if (document.visibilityState === 'visible') {
        setDateKey(prev => {
          const next = getKstDateKey();
          return prev === next ? prev : next;  // 같으면 리렌더 방지
        });
      }
    }
    document.addEventListener('visibilitychange', check);
    return () => document.removeEventListener('visibilitychange', check);
  }, []);
 
  return dateKey;
}

visibilitychange인가:

  • 폴링/타이머 없이 유저가 실제로 화면을 볼 때만 체크
  • 브라우저 백그라운드 탭에서 setInterval은 스로틀링되는 반면, visibilitychange는 정확히 한 번 발생
  • prev === next 비교로 날짜 같으면 state 변경 없음 → 불필요한 리렌더 제로

setTimeout(ms_until_midnight)가 아닌가:

  • 브라우저는 백그라운드 탭의 타이머를 1분 이상 지연시킬 수 있음
  • 노트북 절전 모드에서 타이머가 정지됨
  • visibilitychange는 이 모든 케이스를 한 번에 커버

4-2. useMemo 의존성에 dateKey 추가

function ComposedReadingSections({ permanent, pillars }) {
  const dateKey = useKstDateKey();
  const interpretation = useMemo(
    () => composeInterpretation(permanent, pillars),
    [permanent, pillars, dateKey],  // dateKey 추가
  );
  return <SajuReadingSections interpretation={interpretation} />;
}

4-3. 에러 폴백 추가

composeInterpretation에 try-catch를 추가해서, computeTemporalFortune이 실패해도 폴백 메시지를 표시하고 콘솔에 에러를 출력하도록 했다.

function composeInterpretation(permanent, pillars): SajuInterpretation {
  try {
    const temporal = computeTemporalFortune(pillars);
    return { ...permanent, ...temporal };
  } catch (err) {
    console.error('[사주] 오늘/올해 운세 계산 실패:', err);
    return {
      ...permanent,
      dailyFortune: '운세 계산에 실패했습니다. 새로고침 후 다시 시도해주세요.',
      yearFortune: '운세 계산에 실패했습니다. 새로고침 후 다시 시도해주세요.',
    };
  }
}

5. 배운 것

  • useMemo에 시간 의존 로직을 넣으면 의존성에 시간 키가 필수. Date.now()가 함수 내부에 있어도 의존성 배열에 없으면 재계산되지 않는다.
  • 에러 핸들링 없는 useMemo는 위험하다. throw 시 컴포넌트 전체가 crash하는데, error boundary 없이는 원인 파악이 불가능하다.
  • visibilitychange는 "유저가 돌아올 때" 패턴의 정답. 폴링, 타이머보다 정확하고 배터리/성능 친화적이다.

6. 남은 과제

  • 빈 텍스트의 정확한 원인은 특정하지 못함 (현재는 폴백 메시지로 방어)
  • 패널을 열어둔 채 자정을 넘기는 극단 케이스는 visibilitychange로 커버 안 됨 (탭을 떠났다 돌아와야 갱신)

커밋 로그

3d8a87b fix: 오늘/올해 운세 자정 갱신 + 계산 실패 폴백