지루한 CS 공부를 즐거운 습관으로 바꾸는 게이미피케이션 플랫폼
Funda는 CS 학습을 게임처럼 지속할 수 있게 만드는 게이미피케이션 학습 플랫폼입니다. 로드맵 기반 학습, 다양한 퀴즈 타입, 실시간 배틀, 주간 랭킹, AI 학습 보조까지 하나의 흐름으로 연결하는 팀 프로젝트였으며, 저는 프론트엔드 전반에서 인터랙션 중심의 UI 구현을 주도했습니다.
프로젝트의 핵심 가치가 '재미있는 학습 경험'이었기 때문에, 프론트엔드는 단순히 데이터를 화면에 그리는 역할이 아니었습니다. 사용자가 문제를 풀면서 무엇을 느끼는지, 결과를 받는 순간 어떤 보상감을 갖는지, 그리고 내일 다시 돌아올 이유가 있는지가 모두 프론트엔드와 직결되어 있었습니다.
Funda의 3D 캐릭터는 서비스의 첫 인상이자 학습 중 감정 표현을 담당하는 인터랙션 오브젝트였습니다. 시선 추적, 표정 변화, 클릭 반응이 살아 있어야 캐릭터가 "장식"이 아닌 "존재감"을 가질 수 있었습니다.
Rigified Bone은 웹 환경에서 실시간 제어가 불가능한 구조였습니다. 이를 그대로 사용하면 애니메이션 제어 자체가 막히는 상황이었습니다. 본 구조 분석 → 가중치 매핑 → 애니메이션 전이 검증 → WebGL Uniform 추적 → 최종 렌더링 점검의 5단계 디버깅으로 연산량이 많은 데이터를 웹 런타임에서 처리하려 한 것이 구조적 원인임을 특정했습니다.
반복 재생되는 idle·walk 같은 정적 애니메이션 데이터는 Blender에서 미리 베이킹해 GLB에 포함했습니다. 런타임에 bone matrix를 계산할 필요가 없어 CPU 부담을 크게 줄였습니다.
시선 추적(look-at), 클릭 반응, 조명, 그림자처럼 사용자 입력에 즉시 반응해야 하는 인터랙션만 런타임에서 처리하도록 분리했습니다. SkeletonUtils.clone()으로 캐릭터 인스턴스를 분리해 각 화면에서 독립적인 애니메이션 상태를 유지했습니다.
캐릭터는 안내·축하·결과 등 여러 화면에서 재사용되기 때문에, 인스턴싱 기법으로 동일 지오메트리의 중복 렌더링 없이 GPU 부하를 분산했습니다.
링크 정보를 불러오는 중...
이 구조를 바탕으로 사용자가 컨트롤러로 캐릭터의 애니메이션과 표정(Shape Key)을 직접 실시간 제어하는 체험 페이지도 구현했습니다. 캐릭터를 단순 장식이 아닌 서비스 브랜딩 요소이자 학습 체험 인터페이스로서 이중 역할을 수행하게 한 결과입니다.
더 디테일한 회고글은 링크 정보를 불러오는 중... 링크에서 보실 수 있습니다.
Funda는 객관식, OX, 매칭, 코드 분석 등 다양한 퀴즈 타입을 제공합니다. 각 타입은 단순 포맷 나열이 아니라 서로 다른 학습 목표를 전달합니다. 객관식은 핵심 개념 인지, OX는 오개념 교정, 코드 분석은 실행 흐름 추론, 매칭은 개념 간 관계 이해를 목표로 합니다.
| 객관식 (MCQ) | O/X 퀴즈 |
|---|---|
| 가장 표준적인 개념 체크 방식 | 흔히 헷갈리는 오개념(Anti-pattern) 바로잡기 |
![]() | ![]() |
| 매칭 (Matching) | 코드 분석 |
|---|---|
| 개념과 정의, 혹은 기술 간의 관계 연결 | 실제 코드 스니펫의 실행 결과 및 로직 추론 |
![]() | ![]() |
그 중 매칭 퀴즈에서 "무엇을 해야 할지 모르겠다"는 피드백이 반복됐습니다. 사용자가 관계를 머릿속으로만 유지하면 인지 부하가 커지고 학습 흐름이 금방 끊겼습니다.

양쪽 선택지를 클릭하면 SVG Path를 동적으로 생성해 연결 상태를 즉시 시각화했습니다. ResizeObserver로 화면 크기가 바뀌어도 연결선을 다시 계산하고, 정답 제출 후에는 선의 색과 스타일로 정답/오답 여부를 즉각 구분했습니다.
사용자 클릭 → pairs 상태 업데이트
→ DOM 위치 읽기 (getBoundingClientRect)
→ SVG Path 좌표 계산
→ 연결선 렌더링개선 후 직관성 관련 피드백이 0건에 수렴하여 핵심 학습 플로우의 이탈 요인을 제거했습니다.
학습 서비스에서 결과 화면은 단순 요약표가 아닙니다. "이번 학습이 저장되었고 의미 있는 성과가 남았다"는 감각을 짧게 압축해서 전달하는 장치입니다.
상황별로 다른 보상 경험을 설계했습니다.
![]() | ![]() | ![]() |
|---|
XP, 성공률, 소요 시간 카드가 왼쪽부터 순차 등장합니다. Framer Motion으로 등장 타이밍을 맞추고, 카드가 나타나는 순간마다 ding 사운드를 순차 재생해 보상감을 강화했습니다.
당일 첫 번째 풀이라면 Streak 화면으로 분기됩니다. 큰 숫자와 불꽃 아이콘, 주간 체크 상태, 다음 날 학습 유도 문구로 구성해 "연속 학습 기록을 이어간다"는 맥락을 전달했습니다.
복습 모드의 마지막 문제를 끝냈을 때는 일반 결과 대신 별도 이펙트 화면으로 분기됩니다. 체크 배지, 파동, 별 파티클이 짧게 등장한 뒤 학습 화면으로 복귀합니다.
결과 순간에만 짧고 정확하게 연출을 사용했기 때문에 산만해지지 않았습니다.
| 배틀 로비 | 문제 풀이 |
|---|---|
| 정답 확인 | 결과 화면 |
|---|---|
실시간 배틀은 단순히 점수를 동기화하는 기능이 아니었습니다. 대기실, 초대 링크, 시작 카운트다운, 실시간 점수, 결과 공개까지 서로 다른 상태가 라우트와 UI에 동시에 반영되어야 했고, 잘못된 진입(초대 토큰 없음, 이미 시작된 방)도 처리해야 했습니다.
소켓 인프라와 배틀 도메인 흐름을 분리하는 구조를 설계했습니다. 실시간 상태 변화가 컴포넌트 곳곳에 흩어지지 않도록 4개 계층으로 나누었습니다.
배틀 관련 전역 상태는 battleStore로 분리해 소켓 이벤트와 UI 렌더링 사이의 결합을 줄였습니다. 배틀 규칙이 바뀌거나 상태 전이가 추가되어도 각 계층의 책임이 명확해 수정 범위를 예측할 수 있었습니다.
서비스 런칭을 위해 6,000개 규모의 퀴즈 데이터셋이 필요했지만 수동 제작 시 수일 이상 소요될 상황이었습니다.
self-hosted n8n(Docker) + Google Sheets(단일 진실 원본) + Gemini 기반 자동화 파이프라인을 직접 설계했습니다. JSON/JSONL/CSV 병렬 출력과 Docker 병렬 실행 구조로 처리 속도를 극대화했습니다.

LLM 연속 루프 호출 시 Gemini Rate Limit과 스키마 파싱 오류가 급증했습니다. 배치 간격 지연 로직과 중간 저장 로직을 구성하여 안정성을 확보했습니다.
API 키 3개를 순환 사용해 분당 처리량을 3배로 늘려 처리 시간도 최적화하였습니다.
결과적으로 6,000개 퀴즈를 30분 내에 생성해 수일의 수동 작업을 자동화했습니다. 팀 리소스를 핵심 기능 개발에 집중할 수 있었습니다.
Funda는 비로그인 사용자도 먼저 학습을 체험하고 필요할 때 로그인으로 자연스럽게 이어지는 구조를 선택했습니다. 이 흐름 전체를 코드 기반 라우팅 위에서 통제했습니다.
, , 와 , 를 조합해 로그인 여부와 권한에 따라 접근 경로를 명확히 나누었습니다. 가드와 loader를 route 정의 안에 명시한 이유는, 서비스가 커질수록 접근 정책이 UI 코드 바깥에서도 읽혀야 유지보수가 가능하기 때문입니다.
브라우저 저장과 서버 임시 저장을 나눈 이유는 역할이 다르기 때문이었습니다.
브라우저 저장은 사용자가 로그인하지 않은 상태에서도 학습 맥락이 끊기지 않게 만들기 위한 장치였습니다. 마지막으로 보던 위치와 field별 마지막 완료 유닛, guest 풀이 진행 상태를 남겨 두면 새로고침하거나 다시 들어왔을 때도 바로 이어서 학습할 수 있습니다. 사용자가 느끼는 장점은 "처음부터 다시 찾아가지 않아도 된다"는 즉시성입니다.
반면 Redis는 계정이 없는 상태에서도 step 완료 기록과 heart 상태를 서버 기준으로 잠시 보관하기 위한 계층이었습니다. 이렇게 두면 로그인 순간 client_id 기준 데이터를 사용자 계정으로 병합할 수 있고, 단순 UI 상태가 아니라 서비스 규칙과 연결되는 값들을 서버 쪽에서 이어받을 수 있습니다. 사용자가 느끼는 장점은 "체험 중 쌓은 진행이 로그인 때문에 사라지지 않는다"는 연속성입니다.
즉, 브라우저는 화면의 문맥을 붙잡고, Redis는 계정 전환 직전의 진행 데이터를 붙잡는 역할을 맡았습니다. 스케치북에는 구도와 메모를 남기고, 작업실 테이블에는 본 작업에 필요한 재료를 따로 정리해 두는 방식에 가깝습니다.
![]() | ![]() | ![]() |
|---|
SM-2 기반 SRS가 복습 타이밍을 계산한다면, 정기 학습 알림 이메일은 그 시점을 실제 행동으로 연결하는 장치입니다. 로그인 화면의 수신 동의 → 설정 화면의 토글 → 메일 본문의 수신 거부 페이지까지 하나의 흐름으로 완성했습니다.
이메일은 전체 발송이 아닌 '가입 5일 경과', '연속 학습(Streak) 끊김', '최근 14일 내 로그인', '마지막 발송 후 48시간 경과' 조건을 쿼리로 필터링하여, 넛지(Nudge)가 꼭 필요한 순간에만 메일이 도달하도록 했습니다.
대량 발송 시 Gmail SMTP 서버의 초당 발송 제한에 걸리는 문제를 막기 위해, 배치 루프 내에 지연(Delay) 로직을 추가하여 초당 5통 이하의 발송 속도를 애플리케이션 레벨에서 제어했으며 고정된 포맷이 주는 기계적인 느낌을 탈피하고자, 유저 이름(displayName)과 다채로운 본문 문구를 무작위로 조합 발송해 오픈율을 높였습니다.
마지막으로 URL 조작을 통한 악의적인 타인 구독 해지를 막기 위해 7일 만료의 일회성 JWT를 발급해 링크에 포함시켰으며, @Throttle로 API 호출을 분당 5회로 제한해 무차별 대입 공격을 방어했습니다.

프로필 페이지는 단순 사용자 정보 화면이 아니라, 퀴즈 풀이 기록을 학습 동기로 다시 번역하는 개인 대시보드 역할을 했습니다. 연간 잔디, 최근 7일 학습 시간 그래프, 최근 7일 분야별 문제 풀이 그래프로 "얼마나 했는가"뿐 아니라 "언제 했는가", "어느 분야를 했는가"까지 한눈에 읽히게 만드는 것이 목표였습니다.
학습 기록은 하루 단위로 보여야 하는데, 서버 기준 날짜와 사용자 로컬 날짜가 어긋나면 "어제 푼 문제가 오늘로 찍히는" 오류가 바로 드러납니다. 으로 사용자 시간대를 읽어 헤더로 서버에 전달하고, 서버가 반환한 날짜를 , , 로 잔디·툴팁·축 라벨에 일관되게 연결했습니다.

단순 div 그리드가 아니라 월 라벨, 요일 라벨, level 기반 색상 강도, hover/focus 툴팁을 포함한 학습 히트맵으로 설계했습니다. 기록이 없는 셀은 인터랙션에 반응하지 않고, 실제 기록이 있는 셀만 버튼처럼 동작하도록 해 정보 밀도는 높이되 인터랙션은 과하지 않게 유지했습니다.

학습 시간 그래프와 분야별 학습량 그래프 모두 SVG 위에 Y축 눈금, grid line, 부드러운 곡선 path, area gradient, hover 포인트, popover를 직접 구현했습니다. 차트 라이브러리보다 제품 톤에 맞는 세밀한 제어가 가능했습니다.
Funda를 통해 인터랙션은 시각적 장식이 아니라 사용자가 서비스를 계속 사용하게 만드는 설계의 일부라는 것을 깨달았습니다.
3D 캐릭터, 매칭 퀴즈, 실시간 배틀처럼 눈에 보이는 구현도 다뤘지만, 동시에 각각의 인터랙션이 왜 그 방식이어야 했는지, 어떤 문제를 풀기 위해 그렇게 설계했는지를 중심에 두었습니다. 좋은 인터랙션은 화려한 모션 하나로 끝나지 않고, 사용자가 다음 행동을 자연스럽게 이해하고 다시 돌아오게 만드는 흐름 위에 있다고 생각합니다.
GuestGuardLoginGuardAdminGuardguestLoaderprotectedLoaderIntl.DateTimeFormat().resolvedOptions().timeZonex-time-zonenormalizeDateKeyformatDateKeyLocalformatDateDisplayNameLocalconst resolveTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
async getProfileStreaks(userId: number): Promise<ProfileStreakDay[]> {
return apiFetch.get(`/profiles/${userId}/streaks`, {
headers: { 'x-time-zone': resolveTimeZone() },
});
}const resolveLevel = (count: number) => {
if (count <= 0) return 0;
if (count <= 10) return 1;
if (count <= 30) return 2;
if (count <= 60) return 3;
return 4;
};
// 기록이 있는 셀만 버튼으로 동작
const isInteractive = !isPlaceholder && solvedCount > 0;<span
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
aria-label={
isInteractive
? `${formatDateDisplayNameLocal(dayDate)} ${solvedCount}개`
: undefined
}
/>const createSmoothPath = (points: { x: number; y: number }[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
return points.reduce((acc, point, index, array) => {
if (index === 0) return `M ${point.x} ${point.y}`;
const previous = array[index - 1];
const cp1 = controlPoint(previous, array[index - 2], point, false);
const cp2 = controlPoint(point, previous, array[index + 1], true);
return `${acc} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${point.x} ${point.y}`;
}, '');
};<path d={group.areaPath} fill={`url(#gradient-${index})`} />
<path d={group.linePath} fill="none" stroke={group.color} strokeWidth="2.5" />
<circle
cx={point.x} cy={point.y} r="10" fill="transparent"
role="button" tabIndex={0}
aria-label={`${point.label}: ${point.tooltipFormatter(point.value)}`}
/>