사주고사 제작기 10. useMemo 자정 버그와 visibilitychange 해결기
사주 챗봇 결과에서 일곱 섹션 중 두 개만 빈 텍스트로 나왔다. 그걸 쫓다가 더 큰 버그를 찾았다.
클라이언트에서 계산하는 것만 비어 있었다
| 섹션 | 생성 주체 | 표시 |
|---|---|---|
| 종합운 | AI | 정상 |
| 성격 | AI | 정상 |
| 직업·재물 | AI | 정상 |
| 대인관계 | AI | 정상 |
| 오늘의 운세 | 클라이언트 계산 | 빈 텍스트 |
| 올해 운세 | 클라이언트 계산 | 빈 텍스트 |
| 조언 | AI | 정상 |
AI가 만드는 다섯은 멀쩡하고 클라이언트에서 계산하는 둘만 비었다. 경계가 정확히 갈렸다.
앞서 운세를 평생 안 바뀌는 것과 날짜마다 바뀌는 것으로 나눠뒀다. 서버는 앞의 다섯 섹션만 내려주고, 만세력 십신 계산으로 만드는 뒤의 둘은 클라이언트가 붙인다.
const interpretation = useMemo(
() => composeInterpretation(permanent, pillars),
[permanent, pillars], // ← 문제의 의존성 배열
);빈 텍스트를 쫓다가 다른 걸 발견했다
Node.js에서 직접 돌려보니 운세 텍스트가 정상으로 나왔다. manseryeok과 josa도 클라이언트 번들에 잘 포함돼 있었다. 브라우저에서만 실패하는데, computeTemporalFortune에 에러 처리가 하나도 없어서 예외가 나도 뭐가 터졌는지 알 방법이 없었다.
원인을 찾는 과정에서 의존성 배열에 날짜가 없다는 걸 봤다. 이쪽이 더 심각했다.
computeTemporalFortune 안에서 Date.now()를 부르긴 한다. 그런데 useMemo는 의존성이 그대로면 함수를 아예 실행하지 않고 이전 결과를 돌려준다. 함수 내부에서 무엇을 읽든 상관이 없다. 날짜가 의존성에 없으니 영원히 재계산되지 않는다.
useMemo의 계산 함수가 순수하지 않으면, 즉 인자 말고 바깥 상태를 읽으면 그 상태를 의존성에 넣어야 한다. 시간이 딱 그런 경우다.
날짜를 값으로 만들어 의존성에 넣었다
날짜를 문자열 키로 만들고, 탭에 돌아왔을 때 바뀌었는지 확인하는 훅을 짰다.
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;
}UTC에 9시간을 더하고 getUTC* 계열로 읽는 방식을 썼다. 브라우저의 로컬 시간대를 쓰면 해외에서 접속한 사용자에게 다른 날짜가 나온다. 운세는 한국 날짜 기준이어야 한다.
prev === next 비교가 중요하다. 이게 없으면 탭을 오갈 때마다 같은 값으로 setState가 불려서 매번 리렌더가 난다. 같은 값을 반환하면 React가 리렌더를 건너뛴다.
setTimeout으로 자정까지 시간을 재는 방법도 생각했는데 안 썼다. 브라우저는 백그라운드 탭의 타이머를 1분 이상 늦출 수 있고, 노트북이 절전으로 들어가면 타이머가 아예 멈춘다. 자정에 정확히 깨어날 거라는 보장이 없다.
visibilitychange는 사용자가 실제로 화면을 볼 때만 확인한다. 폴링도 타이머도 필요 없고, 화면을 안 보는 동안에는 아무 일도 안 한다.
이제 이 키를 의존성에 넣는다.
function ComposedReadingSections({ permanent, pillars }) {
const dateKey = useKstDateKey();
const interpretation = useMemo(
() => composeInterpretation(permanent, pillars),
[permanent, pillars, dateKey], // dateKey 추가
);
return <SajuReadingSections interpretation={interpretation} />;
}dateKey를 계산 함수 안에서 쓰지는 않는다. 날짜가 바뀌었다는 신호로만 쓴다.
조용히 실패하던 걸 드러나게 했다
composeInterpretation을 try-catch로 감쌌다.
function composeInterpretation(permanent, pillars): SajuInterpretation {
try {
const temporal = computeTemporalFortune(pillars);
return { ...permanent, ...temporal };
} catch (err) {
console.error('[사주] 오늘/올해 운세 계산 실패:', err);
return {
...permanent,
dailyFortune: '운세 계산에 실패했습니다. 새로고침 후 다시 시도해주세요.',
yearFortune: '운세 계산에 실패했습니다. 새로고침 후 다시 시도해주세요.',
};
}
}useMemo 안에서 예외가 나면 렌더링 중 예외라 컴포넌트가 통째로 죽는다. 에러 바운더리가 없으면 화면이 빈 채로 남고 콘솔에도 원인이 안 남는다. 잡아서 로그를 남기고 나머지 다섯 섹션이라도 보이게 했다.
막힌 지점과 원인
| 문제 | 원인 | 해결 |
|---|---|---|
| 자정이 지나도 어제 운세가 표시됨 | useMemo 의존성에 날짜가 없어 재계산이 안 됨 | useKstDateKey로 날짜 키를 만들어 의존성에 추가 |
| 타이머로는 자정을 못 잡음 | 백그라운드 탭 스로틀링, 절전 시 타이머 정지 | visibilitychange로 복귀 시점에 확인 |
| 해외 접속 시 날짜가 어긋남 | 브라우저 로컬 시간대 사용 | UTC에 9시간을 더해 한국 날짜로 고정 |
| 운세 계산 실패 시 원인 불명 | useMemo 안에 에러 처리 없음 | try-catch로 폴백 메시지와 콘솔 로그 |
빈 텍스트의 정확한 원인은 아직 못 찾았다. 지금은 폴백 메시지로 막아둔 상태라 다시 나면 콘솔에 뭐라도 남는다.
패널을 열어둔 채로 자정을 넘기는 경우는 여전히 안 잡힌다. 탭을 떠났다가 돌아와야 갱신된다.