2024년 5월에 진행한 작업을 2026년 5월에 정리한 기록입니다.
Voca Study의 학습 화면은 여러 단계로 이어집니다. 사용자는 단어를 보고, 뜻을 확인하고, 예문을 읽고, 동의어와 반의어로 의미를 확장한 뒤, 마지막에 빈칸 퀴즈로 확인합니다. 이 글에서는 그 흐름을 순서대로 따라가며 각 화면에서 어떤 기준으로 UI를 나누고, 그 아래의 상태를 어떻게 설계했는지 정리합니다.
진행률 표시
학습 흐름이 여러 단계로 이어지기 때문에, 화면은 사용자가 지금 어디까지 왔는지 계속 알려줘야 합니다.
처음에는 단순한 숫자 진행률만 생각했습니다.
예를 들어 3 / 10처럼 현재 단계와 전체 단계를 보여주는 방식입니다.
하지만 이 방식만으로는 사용자가 지금 학습의 어느 구간에 있는지 직관적으로 알기 어렵습니다.
특히 주차별 학습처럼 여러 단계가 반복되는 구조에서는 숫자보다 시각적인 구간 표시가 더 잘 맞았습니다.
그래서 상단에 얇은 progress segment를 두고, 현재 단계만 강조하는 방식으로 구성했습니다. 각 단계는 완료 여부와 현재 활성 상태만 알면 됩니다.
type StudyStep = {
id: string;
title: string;
completed: boolean;
active: boolean;
};
진행률 UI는 이 데이터를 받아 segment 색상과 길이를 결정합니다.
function ProgressSegments({ steps }: { steps: StudyStep[] }) {
return (
<div className="progress">
{steps.map((step) => (
<span
key={step.id}
className={step.active || step.completed ? "is-active" : "is-idle"}
/>
))}
</div>
);
}
사용자는 지금 단계가 앞쪽인지, 중간인지, 거의 끝났는지 바로 알 수 있습니다. 현재 단계만 강하게 강조하고 나머지는 조용하게 두었고, 진행 상태는 UI 전용 상태로 단순하게 관리했습니다.
주차, 단계 제목, 진행률의 역할 분리
상단에는 Week 01 같은 큰 흐름과 현재 단계 제목을 함께 보여주었습니다.
둘을 하나의 문자열로 합치지 않고 분리한 이유는 역할이 다르기 때문입니다.
- Week: 학습 묶음의 기준
- Step title: 현재 해야 할 작업
- Progress segment: 전체 흐름에서의 위치
세 가지를 따로 두어서 단계가 바뀌어도 상단 영역은 그대로 유지됩니다.
예를 들어 Expand the Meaning, Fill in the Blanks 같은 단계 제목이 바뀌어도 주차 정보와 진행률은 그대로 유지됩니다.
이 구조를 잡아두었기 때문에 이후 예문, 의미 확장, 퀴즈 화면이 추가되어도 상단 진행률은 같은 방식으로 재사용할 수 있었습니다.
타이머는 보조 정보로
학습 화면에는 타이머도 함께 필요했습니다. 다만 타이머를 너무 크게 보여주면 학습 내용보다 시간이 더 중요해 보입니다. 그래서 우측 상단에 작게 배치하고, 진행률과 같은 줄에서 보조 정보처럼 보이게 했습니다.
타이머는 사용자의 긴장감을 만들 수 있으므로 시각적으로는 절제하는 편이 낫다고 봤습니다. 학습의 중심은 단어와 예문이고, 타이머는 현재 세션을 인지하게 해주는 보조 정보입니다. 타이머를 상태 측면에서 어떻게 다뤘는지는 마지막 세션 구조에서 다시 다룹니다.
정의와 예문 노출 순서
첫 학습 단계에서는 단어, 뜻, 예문, 부가 설명을 모두 보여줄 수 있습니다. 하지만 정보를 한 화면에 한 번에 다 보여주면 사용자는 읽기만 하고 넘어가기 쉽습니다. 그래서 단어를 크게 보여주고, 정의는 버튼을 눌렀을 때 확인할 수 있게 했습니다. 예문은 그 아래에 배치하되, 핵심 단어가 문장 안에서 어떻게 쓰이는지 강조했습니다.
정의는 사용자가 원할 때 펼치기
정의를 처음부터 보여주면 사용자는 단어를 보자마자 의미를 확인합니다. 이 방식은 편하지만, 기억을 떠올리는 시간이 줄어듭니다.
그래서 Click for definition처럼 사용자가 한 번 멈추고 눌러보게 만들었습니다.
작은 클릭이지만 학습 흐름에서는 차이가 있습니다.
상태도 단순해서, 정의를 열었는지 여부만 관리하면 됩니다.
type DefinitionState = {
revealed: boolean;
};
{definition.revealed ? (
<p>{word.definition}</p>
) : (
<button onClick={revealDefinition}>Click for definition</button>
)}
이 구조는 복잡하지 않지만, 사용자가 단어를 먼저 보고 스스로 떠올릴 여지를 만들어줍니다.
예문은 항상 문맥으로 보여주기
정의와 달리 예문은 학습 흐름에서 바로 보이게 두었습니다. 단어의 의미는 사전식 설명보다 문장 안에서 더 잘 이해되는 경우가 많기 때문입니다. 예문에서는 학습 대상 단어를 강조했습니다.
function HighlightedExample({ sentence, word }: Props) {
return sentence.split(word).map((part, index, list) => (
<Fragment key={index}>
{part}
{index < list.length - 1 && <mark>{word}</mark>}
</Fragment>
));
}
실제 구현에서는 대소문자나 파생형까지 고려해야 하지만, 여기서는 문장 전체를 숨기지 않았습니다. 정의는 기억을 떠올리기 위해 숨길 수 있지만, 예문은 의미를 추론하는 재료이기 때문입니다.
시선 순서에 맞춘 화면 밀도
단어 자체는 가장 크게 보여주고, 정의 버튼은 그보다 작게 두었습니다. 예문 영역은 선으로 구분해 학습 대상 단어와 문맥을 분리했습니다. 이렇게 한 이유는 사용자의 시선 순서를 고정하기 위해서입니다.
- 단어를 먼저 본다.
- 의미를 떠올린다.
- 필요하면 정의를 확인한다.
- 예문으로 실제 사용 맥락을 본다.
동의어와 반의어 화면
영단어를 외울 때 단어 하나와 뜻 하나만 연결하면 기억이 얕게 남기 쉽습니다. 특히 추상적인 단어는 한국어 뜻 하나로는 뉘앙스를 충분히 설명하기 어렵습니다. 그래서 의미를 확장하는 단계에서는 중심 단어를 가운데에 두고, 왼쪽에는 비슷한 의미, 오른쪽에는 반대 의미를 배치했습니다.
중심 단어를 고정하기
동의어와 반의어가 추가되면 화면에 정보가 많아지고, 사용자는 어떤 단어를 기준으로 보고 있는지 헷갈릴 수 있습니다. 그래서 중심 단어는 화면 중앙에 크게 고정하고, 동의어와 반의어는 중심 단어를 설명하는 보조 정보로 배치했습니다.
type MeaningExpansion = {
target: WordItem;
synonym: WordItem;
antonym: WordItem;
};
데이터 구조도 target을 기준으로 두었습니다. 동의어와 반의어는 각각 별도 카드처럼 보이지만, 실제로는 target을 설명하기 위한 관계 데이터입니다.
좌우 대비와 라벨
동의어와 반의어는 단순 목록보다 대비 구조가 잘 맞았습니다. 왼쪽은 밝은 배경, 오른쪽은 어두운 배경처럼 시각적 대비를 주면 의미의 방향이 분리되어 보입니다.
다만 색상 자체가 의미를 대신하게 만들면 안 됩니다.
그래서 Synonym, Antonym 라벨을 함께 두고, 각 단어의 예문까지 표시했습니다.
<section>
<MeaningCard label="Synonym" word={synonym} />
<TargetWord word={target} />
<MeaningCard label="Antonym" word={antonym} />
</section>
이 구조에서는 중심 단어와 관계 단어가 시각적으로 분리됩니다.
관계 단어에도 예문을, 강조 색은 적게
앞 단계의 예문 강조 원칙은 동의어와 반의어 카드에도 그대로 적용했습니다. 단어만 나열하면 실제 쓰임이 약해지기 때문에 각 카드에 예문을 넣고, 해당 단어를 강조해 문장 안에서 어떤 역할을 하는지 보이게 했습니다.
강조 색상은 전체 UI의 포인트 컬러와 맞췄습니다. 새로운 색상을 계속 추가하면 학습 화면보다 장식이 먼저 보이기 쉽습니다. 강조 색은 포인트 컬러 하나만 썼습니다.
이 화면은 빈칸 퀴즈로 넘어가기 전에 의미를 넓히는 중간 단계 역할을 했습니다.
빈칸 퀴즈: 선택, 제출, 피드백 상태 나누기
흐름의 마지막은 빈칸 채우기 퀴즈입니다. 문장에 빈칸이 있고, 아래 선택지 중 하나를 고르면 되니 보기에는 단순합니다. 하지만 실제 상태를 보면 단순하지 않습니다.
- 사용자가 아직 선택하지 않은 상태
- 선택했지만 제출하지 않은 상태
- 정답인 상태
- 오답인 상태
- 정답을 보여주는 상태
- 다음 문제로 넘어갈 수 있는 상태
이 상태들을 하나의 boolean으로 처리하면 금방 꼬입니다.
예를 들어 isCorrect만 있으면 아직 제출하지 않은 상태와 오답 상태를 구분하기 어렵습니다.
퀴즈 진행 상태를 단계로 나누기
먼저 퀴즈의 진행 상태를 명확히 나눴습니다.

type QuizStatus = "idle" | "selected" | "submitted";
type QuizState = {
selectedChoiceId: string | null;
status: QuizStatus;
};
선택 여부와 제출 여부를 분리하면 UI 조건이 단순해집니다. 선택지만 고른 상태에서는 강조만 보여주고, 제출된 뒤에야 정답과 오답 피드백을 보여줄 수 있습니다.
정답은 제출 이후에만 계산하기
선택지 데이터에 정답 여부를 직접 넣을 수도 있습니다. 하지만 UI 렌더링에서 모든 선택지가 자기 정답 여부를 알고 있으면, 제출 전에도 정답 상태가 새어 나올 수 있습니다. 그래서 문제 데이터는 정답 id를 별도로 갖게 했습니다.
type QuizQuestion = {
id: string;
sentenceBeforeBlank: string;
sentenceAfterBlank: string;
answerChoiceId: string;
choices: Array<{
id: string;
label: string;
}>;
};
렌더링할 때는 현재 제출 여부를 먼저 확인합니다.
function getChoiceState(choiceId: string, state: QuizState, question: QuizQuestion) {
if (state.status !== "submitted") {
return state.selectedChoiceId === choiceId ? "selected" : "idle";
}
if (choiceId === question.answerChoiceId) return "correct";
if (choiceId === state.selectedChoiceId) return "wrong";
return "idle";
}
이렇게 하면 제출 전에는 정답이 노출되지 않습니다. 사용자는 먼저 선택하고, 제출 후에 피드백을 받습니다. 선택지 하나하나도 정답, 오답, 선택, idle 중 하나의 명확한 상태를 갖게 됩니다.
선택한 단어를 빈칸에 반영하기
선택한 단어는 빈칸 위치에도 반영했습니다. 빈칸 자체를 input처럼 만들 수도 있지만, 선택지 기반 문제라면 실제 입력 필드가 아니어도 됩니다. 선택한 값이 있으면 빈칸에 표시하고, 없으면 빈 상태를 유지합니다.
<span className={selected ? "blank is-filled" : "blank"}>
{selected?.label}
</span>
이 방식은 사용자가 선택한 답이 문장 안에서 어떻게 들어가는지 바로 확인할 수 있다는 장점이 있습니다.
문제 수가 늘어나도 퀴즈 컴포넌트는 그대로 썼습니다.
새 문제로 넘어갈 때는 selectedChoiceId와 status만 초기화하면 됩니다.
타이머와 문제 진행을 함께 다루는 세션 구조
여기까지의 단계를 하나의 세션으로 묶어 보면, 학습 화면에는 여러 상태가 동시에 움직입니다. 현재 주차, 현재 단계, 현재 단어, 퀴즈 진행 상태, 타이머가 함께 바뀝니다.
처음에는 화면 컴포넌트 안에서 필요한 상태를 각각 관리해도 충분해 보였습니다. 하지만 단계가 늘어나면 어떤 상태가 세션 전체에 속하고, 어떤 상태가 현재 문제에만 속하는지 구분하기 어려워집니다.
예를 들어 다음 단어로 넘어갈 때는 퀴즈 선택 상태를 초기화해야 하지만, 타이머는 유지해야 합니다. 반대로 세션을 종료하면 타이머와 진행 상태를 모두 초기화해야 합니다. 이 경계를 정하지 않으면 상태 초기화 누락이 생기기 쉽습니다.
세션 상태와 문제 상태 분리
그래서 상태를 두 층으로 나눴습니다.
type StudySessionState = {
weekId: string;
stepIndex: number;
wordIndex: number;
elapsedSeconds: number;
status: "running" | "paused" | "completed";
};
type QuestionState = {
selectedChoiceId: string | null;
submitted: boolean;
};
세션 상태는 학습 전체가 진행되는 동안 유지됩니다. 문제 상태는 현재 문제 안에서만 의미가 있습니다. 이렇게 나누면 다음 문제로 넘어갈 때 어떤 값을 초기화해야 하는지 명확해집니다.
function goToNextQuestion() {
setSession((session) => ({
...session,
wordIndex: session.wordIndex + 1,
}));
resetQuestionState();
}
타이머는 세션 상태에 남아 있고, 문제 선택 상태만 초기화됩니다.
타이머는 화면 렌더링과 분리
타이머는 매초 바뀌기 때문에 불필요한 렌더링을 만들 수 있습니다. 규모가 작은 화면에서는 큰 문제가 아니지만, 학습 화면처럼 여러 컴포넌트가 붙어 있으면 타이머 변경이 전체 UI를 자주 갱신하게 됩니다.
그래서 타이머 값은 세션에 속하되, 타이머 컴포넌트가 필요한 값만 구독하도록 구성하는 편이 좋았습니다.
상태 관리 라이브러리를 쓰는 경우에도 전체 세션 객체를 통째로 구독하기보다 elapsedSeconds만 선택해서 썼습니다.
const elapsedSeconds = useStudySession((state) => state.elapsedSeconds);
이렇게 하면 문제 선택 상태가 바뀌어도 타이머 컴포넌트는 불필요하게 복잡해지지 않습니다.
단계 이동은 세션 안의 상태 전환
단계 이동은 페이지 이동 대신 세션 상태 값을 바꾸는 방식으로 처리했습니다. 다음 단계로 이동할 때는 앞에서 살펴본 각 단계에서만 쓰던 상태를 정리해야 합니다.
- 정의 보기 단계: reveal 상태 초기화
- 의미 확장 단계: 현재 관계 단어 초기화
- 퀴즈 단계: 선택지와 제출 상태 초기화
- 세션 종료: 타이머와 인덱스 초기화
이 규칙을 명시하면 "다음 화면으로 넘어갔는데 이전 선택이 남아 있는 문제"를 줄일 수 있습니다.
정리
Voca Study의 학습 흐름은 단어를 먼저 보고, 의미를 확장하고, 마지막에 문제로 확인하는 순서로 이어집니다. 각 단계를 만들면서 정리한 기준은 다음과 같습니다.
- 진행률은 숫자 대신 단계형 segment로 보여주고, 주차·단계 제목·진행률을 따로 둔다.
- 단어는 화면의 가장 강한 요소로 두고, 정의는 사용자가 원할 때 펼치게 하며, 예문은 의미를 추론하는 재료이므로 바로 제공한다.
- 동의어와 반의어는 target을 기준으로 한 관계 데이터로 보고, 좌우 대비를 쓰되 라벨과 예문으로 의미를 보강한다.
- 예문 안의 핵심 단어는 강조하되, 포인트 컬러는 적게 사용해 학습 내용이 먼저 보이게 한다.
- 퀴즈는 선택 상태와 제출 상태를 분리하고, 정답 여부는 제출 이후에만 계산한다.
- 세션 상태와 문제 상태는 수명이 다르므로 분리하고, 타이머 컴포넌트는 필요한 값만 구독한다.
- 단계 이동 시에는 이전 단계의 임시 상태를 정리하고, 세션 종료 시에는 타이머와 인덱스를 함께 초기화한다.