왜 Hindsight가 우리에게 글로벌 연구 맥락을 재고하게 만들었는가
I’m happy to translate the article for you, but I’ll need the text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line and all formatting exactly as you requested.
단일 컨텍스트의 문제
우리는 모든 것을 하나의 React 컨텍스트에 넣었습니다 — streak, topic strengths, mistakes, quiz history, retry state, exam date, weekly scores — 그리고 오랫동안 이것이 좋은 아키텍처처럼 느껴졌습니다. 모든 학습 상태를 한 곳에. 깔끔한 import. prop‑drilling 없음.
그런 다음 Hindsight를 사용해 시스템 동작을 추적하기 시작했고, 우리가 다음과 같은 단일 구조를 만들었다는 것을 깨달았습니다:
- 매 퀴즈 답변마다 전체 앱을 다시 렌더링했습니다.
- 매 세션 후에 마스터된 토픽을 조용히 잘못 계산했습니다.
- 관련된 상태 조각들을 연결하려 할 때 stale‑closure 버그가 발생하지 않도록 구조적으로 불가능하게 만들었습니다.
컨텍스트: 무엇을 포함하고 있는가
src/context/StudyContext.tsx는 전체 앱의 핵심입니다. 학습 데이터를 표시하는 모든 페이지는 이 컨텍스트에서 읽고, 학습 데이터를 생성하는 모든 페이지는 여기서 씁니다. 아래는 관리되는 전체 형태입니다:
interface StudyState {
// Data
streak: number;
topicsMastered: number;
weakTopicsCount: number;
totalQuizzesTaken: number;
totalCorrect: number;
totalQuestions: number;
quizHistory: QuizResult[];
mistakes: MistakeEntry[];
topicStrengths: TopicStrength[];
weeklyScores: { day: string; score: number; quizzes: number }[];
examDate: Date;
// Actions
addQuizResult: (result: QuizResult, newMistakes: MistakeEntry[]) => void;
markMistakeReviewed: (id: number) => void;
retryMistake: (id: number) => void;
retryingMistakeId: number | null;
clearRetry: () => void;
}
총 14개의 필드와 5개의 액션이 있습니다. 프로바이더는 src/main.tsx에서 전체 앱을 감싸고 있기 때문에 이 14개 필드 중 하나라도 변경될 때마다 컨텍스트를 사용하는 모든 컴포넌트가 다시 렌더링됩니다.
학생이 퀴즈 질문에 답하면 addQuizResult가 호출되어 다음을 업데이트합니다:
streaktotalQuizzesTakentotalCorrecttotalQuestionsquizHistorymistakestopicStrengthstopicsMasteredweeklyScores
— 하나의 함수 호출에서 아홉 개의 상태가 업데이트되며, 이는 대시보드, 퀴즈, 실수, 플래너 페이지 전반에 걸쳐 모든 소비자 컴포넌트가 동시에 다시 렌더링될 수 있음을 의미합니다.
모놀리식 컨텍스트가 만든 버그
addQuizResult 내부에서 토픽 강도를 업데이트하고, 그 강도에서 마스터리 개수를 도출합니다:
const addQuizResult = useCallback(
(result: QuizResult, newMistakes: MistakeEntry[]) => {
// ... other updates ...
// Correct: functional updater reads fresh state
setTopicStrengths((ts) =>
ts.map((t) => {
if (result.weakTopics.includes(t.topic)) {
return { ...t, strength: Math.max(10, t.strength - 8) };
}
return { ...t, strength: Math.min(100, t.strength + 2) };
})
);
// Bug: reads topicStrengths from stale closure
setTopicsMastered((prev) => {
const newStrengths = topicStrengths.map((t) =>
result.weakTopics.includes(t.topic) ? t.strength - 8 : t.strength + 2
);
return newStrengths.filter((s) => s >= 80).length;
});
},
[topicStrengths] // topicStrengths in deps — but still stale inside the callback
);
- 첫 번째
setTopicStrengths는 functional updater 형태(ts => …)를 사용해 최신 상태를 읽습니다. - 두 번째
setTopicsMastered는 클로저에 직접 잡혀 있는topicStrengths를 읽습니다.
React는 상태 업데이트를 배치하기 때문에 setTopicStrengths가 플러시되지 않은 상태에서 setTopicsMastered가 실행됩니다. 결과적으로 마스터리 개수는 이전 퀴즈의 강도에서 항상 계산됩니다. 각 퀴즈가 끝난 뒤 대시보드의 토픽 히트맵은 올바르게 업데이트되지만 “Topics Mastered” 카운터는 한 퀴즈씩 뒤처집니다.
뒤돌아보면 컨텍스트 설계에 대해 알 수 있는 점
컨텍스트가 어떻게 보여야 하는가
// Remove stored state
// const [topicsMastered, setTopicsMastered] = useState(8);
// Derive it instead
const topicsMastered = topicStrengths.filter((t) => t.strength >= 80).length;
한 줄. 동기화 문제가 없습니다. topicStrengths와 항상 일치하는 값이며 렌더링마다 계산됩니다.
같은 패턴이 이미 weakTopicsCount에 존재합니다:
const weakTopicsCount = topicStrengths.filter((t) => t.strength < 50).length;
topicsMastered도 그 패턴을 따라야 합니다. 파생된 값을 별도로 저장하면 일관성이 깨져 버그를 숨길 수 있습니다.
제안된 컨텍스트 분리
| Domain | Holds |
|---|---|
| QuizContext | quizHistory, totalQuizzesTaken, totalCorrect, totalQuestions, weeklyScores |
| StudyProgressContext | streak, topicStrengths, examDate |
| MistakeContext | mistakes, retryingMistakeId + actions (markMistakeReviewed, retryMistake, clearRetry) |
현재 규모에서는 성능 최적화가 아니라 명확성 최적화입니다. 실수 데이터만 필요한 컴포넌트가 스트릭이 바뀔 때 다시 렌더링되지 않아야 하고, 토픽 강도만 필요한 컴포넌트가 재시도 상태에 접근하지 않아야 합니다. 더 좁은 컨텍스트 소비자는 의존성을 명시적으로 만들고, 콜백의 의존성 배열이 작아져 감사가 쉬워지므로 오래된 클로저 버그가 발생하기 어려워집니다.
더 넓은 패턴 및 교훈
-
파생된 상태는 저장된 상태가 되어서는 안 된다.
다른 상태로부터 값을 계산할 수 있다면 계산하십시오. 파생된 값을 저장하면 결국 위배되는 동기화 의무가 생깁니다.topicsMastered와weakTopicsCount는 모두 매 렌더마다topicStrengths에서 파생되어야 합니다. -
useCallback의존성 배열은 문제 감지기이다.
콜백 자체가 업데이트하는 상태 조각이 의존성 배열에 포함되어 있다면, 함께 업데이트되어야 할 두 요소가 분리되어 있다는 경고로 받아들여야 합니다.addQuizResult에서 발생한 stale‑closure 버그는setTopicStrengths를 호출하는 함수의 의존성 배열에[topicStrengths]가 포함된 것이 신호였습니다. -
단일 컨텍스트는 암묵적인 의존성을 숨긴다.
모든 것이 하나의 컨텍스트에 존재할 때,topicStrengths와topicsMastered사이의 관계는 구조적으로 보이지 않습니다—그들은 평평한 객체의 두 필드에 불과합니다. 도메인‑별 컨텍스트로 분리하면 각 필드를 어느 컨텍스트가 소유할지 결정하게 되며, 파생 관계가 명시적으로 드러납니다.
가능한 곳에서는 파생시키고, 도메인별로 컨텍스트를 분리하며, 콜백의 의존성을 최소화함으로써, 우리는 stale‑closure 버그를 제거하고, 불필요한 재렌더링을 줄이며, 앱의 데이터 흐름을 훨씬 이해하고 유지 관리하기 쉽게 만듭니다.
세션 간 관찰
세션 간 관찰은 단일 렌더 테스트가 놓치는 것을 포착합니다. 마스터리‑카운터 버그는 단일 렌더에서는 올바르게 보였기 때문에 모든 수동 테스트를 통과했습니다. 오직 세션을 넘어서야 비로소 한 퀴즈 지연이 드러났습니다.
이것이 Hindsight의 종단 추적이 이 종류의 버그에 적합한 도구인 이유이며, 더 나은 단위 테스트가 아니라는 이유입니다.