lingdojo/kana-dojo)는 히라가나·가타카나·한자(JLPT N5~N1)·어휘를 게임처럼 외우게 해 주는 웹 학습 플랫폼이다(kanadojo.com). 듀오링고의 "재미"와 몽키타입(Monkeytype)의 미니멀·커스터마이즈 감성을 합쳐, 100가지가 넘는 테마와 28종 일본어 폰트로 화면을 꾸미며 공부한다. 기술적으로는 Next.js 16(App Router) + React 19 + TypeScript strict 위에 Zustand 상태관리 · Feature 기반 아키텍처 · next-intl 다국어를 얹은, 실전 규모의 프론트엔드 교과서다. (저장소: lingdojo/kana-dojo · TypeScript · Vercel OSS 후원 · AGPL-3.0 · TrendShift 6위)이 레포가 무엇을 하는 물건인가.
외국어를 배울 때 가장 지루한 구간이 '글자 외우기'다. 히라가나·가타카나 46자씩, 거기에 탁음·요음까지 수백 개를 무작정 반복해야 한다. KanaDojo는 이 반복을 '문제 풀기 게임'으로 바꾼다. 글자가 뜨면 보기에서 고르거나(객관식) 직접 로마자를 입력하고(주관식), 맞히면 콤보·연속 정답(streak)이 쌓이고 통계와 업적이 기록된다. 몽키타입에서 타자를 치듯, 듀오링고에서 레슨을 깨듯, '한 판 더'를 부르는 일본어 글자 도장이다.
KanaDojo(저장소명 lingdojo/kana-dojo, 서비스명 かな道場)는 세 개의 도장(Dojo)으로 나뉜다. Kana 도장은 히라가나·가타카나, Kanji 도장은 JLPT N5~N1 한자, Vocabulary 도장은 어휘를 다룬다. 각 도장에서 글자/단어 묶음을 고른 뒤 게임 모드를 선택하면 훈련이 시작된다. 회원가입·서버 계정이 필요 없고, 진행 상황과 설정은 모두 브라우저(localStorage)에 저장되는 100% 클라이언트 학습 도구다.
차별점은 '학습'보다 '경험'에 있다. 4가지 게임 모드(Pick·Reverse-Pick·Input·Reverse-Input)에 더해 시간제한 Blitz, 목숨이 걸린 Gauntlet, 정신없는 CrazyMode까지 있고, 100+ 테마 · 28종 일본어 폰트 · 80+ 업적으로 자기 화면을 끝없이 꾸밀 수 있다. 코드 관점에서는 "학습 콘텐츠 데이터 + 게임 엔진 + 극한의 커스터마이즈 UI"를 깔끔한 레이어로 쌓아 올린 모던 Next.js 앱이다.
트렌딩 이유 · 경쟁 대비 장점.
일본어 학습 앱은 듀오링고·Anki·WaniKani 등 강자가 즐비하다. 그런데도 이 레포가 TrendShift 상위에 오른 이유는 세 가지다. 첫째, Vercel OSS 후원 + "good first issue" 친화라 오픈소스에 처음 기여하려는 개발자들의 유입 거점이 됐다. 둘째, 코드가 최신 스택(Next.js 16 · React 19 · Tailwind 4)의 살아있는 예제라 프론트엔드 학습 교재로 가치가 크다. 셋째, 몽키타입식 극한 커스터마이즈가 "공부 앱인데 꾸미는 재미가 있다"는 입소문을 만든다.
| 비교 항목 | 일반 학습 앱 | KanaDojo |
|---|---|---|
| 소스 공개 | 대부분 비공개 | 전체 오픈소스(AGPL-3.0) — 학습·자체 호스팅 가능 |
| 계정·서버 | 로그인·클라우드 동기화 필수 | 계정 불필요 · 진행상황 100% 로컬 저장 |
| 커스터마이즈 | 제한적 테마 | 100+ 테마 · 28 폰트 · 배경 월페이퍼 |
| 게임성 | 고정된 레슨 흐름 | Pick·Input·Blitz·Gauntlet 등 다중 모드 |
| 기술 스택 | 다양/구버전 혼재 | Next.js 16 + React 19 최신 표준 |
가나·한자 암기는 본질적으로 단순 반복이라 금세 질린다. 시판 앱들은 학습 흐름이 고정돼 있고 화면도 바꿀 수 없어, '내 취향대로 공부하는 맛'이 없다. 게다가 진도가 회사 서버에 묶여 있어 계정·구독·프라이버시 문제가 따라붙는다.
같은 반복을 콤보·정답률·업적이 쌓이는 게임 루프로 바꿔 몰입을 만든다. 100+ 테마와 일본어 폰트로 화면을 자기 것으로 꾸미게 해 '공부를 켜고 싶게' 만들고, 모든 데이터를 브라우저에 저장해 계정 없이 즉시 시작하고 프라이버시도 지킨다. 코드까지 열려 있어 마음에 안 들면 직접 고칠 수 있다.
'일본어 학습 게임'을 떠받치는 기술들.
KanaDojo는 백엔드 서버가 거의 없는 프론트엔드 집약형 100% TypeScript 앱이다. Next.js 16 App Router로 라우팅·SSR·SEO를 처리하고, 상태는 Zustand(localStorage 영속화)로 관리한다. 일본어 텍스트 처리는 전용 라이브러리(kuroshiro·wanakana)에 맡기고, 사전 데이터는 SQLite를 브라우저에서 직접 굴린다.
| 레이어 | 기술 | 역할 |
|---|---|---|
| 프레임워크 | Next.js 16.2 (App Router · Turbopack) | 라우팅 · SSR · [locale] 다국어 라우트 · SEO/사이트맵 |
| 언어·런타임 | React 19 + TypeScript(strict) | 함수형 컴포넌트 · 엄격한 타입 · Node 20+ |
| 상태관리 | Zustand 5 + persist | 기능별 스토어 · localStorage/localforage 영속화 |
| 스타일 | Tailwind CSS 4 + shadcn/ui(Radix) | 유틸리티 CSS · 접근성 컴포넌트 · cn() 조건부 클래스 |
| 애니메이션 | Framer Motion 12 + canvas-confetti | 화면 전환·피드백 모션 · 정답 축포 효과 |
| 다국어 | next-intl 4 | 네임스페이스 기반 i18n · [locale] 미들웨어 라우팅 |
| 일본어 처리 | kuroshiro + kuromoji · wanakana | 한자→읽기 변환·토큰화 · 가나↔로마자 변환 |
| 사전 데이터 | better-sqlite3 + sql.js(WASM) | JMdict·KANJIDIC 사전을 SQLite로 · 브라우저 내 질의 |
| 차트·표 | Recharts 3 | 학습 통계·진도 시각화 |
| 마크다운 | next-mdx-remote · react-markdown · shiki | 블로그·아카데미 콘텐츠 렌더 · 코드 하이라이트 |
| 난수·무작위 | random-js | 편향 없는 문제 출제(글자 무작위 선택) |
| 분석·모니터링 | PostHog + Vercel Analytics/Speed Insights | 사용 행동 분석 · 성능 지표 · 에러 로깅 |
| 테스트 | Vitest 3 + Playwright + fast-check | 유닛(jsdom) · E2E 브라우저 · 속성 기반 테스트 |
| 컴포넌트 카탈로그 | Storybook 10 + a11y 애드온 | UI 컴포넌트 격리 개발 · 접근성 검사 |
| 품질 자동화 | ESLint 9 · Prettier · Husky · lint-staged | 커밋 훅 · 스테이징 파일 자동 검사 · 컨벤셔널 커밋 |
| 배포·인프라 | Vercel + Docker(멀티스테이지) | kanadojo.com 호스팅 · 자체 호스팅용 standalone 이미지 |
ka를 か로 바꾸거나 정답 비교에 쓴다. kuroshiro는 형태소 분석기 kuromoji를 등에 업고 한자가 섞인 문장을 읽는 법(후리가나)으로 풀어 준다. 일본어 앱의 '엔진오일' 같은 도구들.better-sqlite3는 빌드/스크립트 단계의 Node용, sql.js는 런타임 브라우저용이다.시스템 구조도 · 핵심 설계 패턴.
KanaDojo의 핵심 설계는 Feature 기반(Feature-Sliced 유사) 레이어 아키텍처다. 코드를 "컴포넌트/스토어/데이터" 같은 기술 종류로 나누지 않고, "Kana/Kanji/Vocabulary/Progress" 같은 기능 단위로 자른다. 그리고 레이어를 위→아래 한 방향으로만 의존하게 강제해, 기능끼리 서로의 내부를 침범하지 못하게 한다.
백화점의 층 구조를 떠올리면 된다. 1층은 입구·안내(app), 위층은 매장 묶음(widgets), 각 매장(features)은 자기 상품·창고·계산대를 스스로 갖춘다. 공용 화장실·엘리베이터는 공용시설(shared), 건물 전기·수도 같은 기반은 지하 설비(core)다. 신발 매장이 옷 매장의 창고를 함부로 뒤지지 못하듯, 기능은 다른 기능의 내부를 직접 import하지 못한다.
각 기능 폴더에는 facade/ 또는 index.ts라는 '정문'이 있다. 바깥에서는 이 정문이 내보낸 것만 쓸 수 있고, 폴더 안쪽의 컴포넌트·스토어 파일을 직접 가져다 쓰면 안 된다. 예를 들어 Kana 기능은 features/Kana/facade/에서 useKanaContent·useKanaSelection만 공개하고, 내부의 useKanaStore나 게임 컴포넌트는 숨긴다. 덕분에 내부 구현을 바꿔도 바깥 코드가 깨지지 않는다.
// entities/kana/index.ts — 도메인의 공개 입구(정문)
export { kana } from '@/features/Kana/data/kana';
export type { KanaGroup } from '@/features/Kana/data/kana';
export { flattenKanaGroups } from '@/features/Kana/lib/flattenKanaGroup';
export { getSelectionLabels } from './selection';
// 바깥(widgets·app)은 이 파일만 import → 내부 파일 경로는 자유롭게 변경 가능
전역 상태를 거대한 하나의 store에 몰지 않고, 기능마다 작은 store를 둔다. useKanaStore(선택한 글자 묶음·게임 모드), useStatsStore(통계·업적), usePreferencesStore·useCustomThemeStore(테마·폰트·월페이퍼)처럼 책임이 분리돼 있다. 그리고 통계·설정처럼 오래 남겨야 하는 상태는 persist 미들웨어로 localStorage에 자동 저장한다.
// features/Progress/store/useStatsStore.ts (요지)
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useStatsStore = create(
persist(
(set) => ({
allTime: { totalCorrect: 0, bestStreak: 0 /* … */ },
recordAnswer: (ok) => set(/* 정답/오답 집계 */),
}),
{ name: 'stats', storage: createJSONStorage(() => localStorage) } // ← 브라우저에 영속
)
);
거대한 store 하나는 '모든 물건을 한 서랍에 몰아넣은 책상'과 같아서, 뭐 하나 꺼내려면 전부 들썩인다. 기능별 store는 '용도별로 라벨 붙인 여러 서랍'이다. Kana 서랍을 열어도 Stats 서랍은 그대로다. persist는 그 서랍을 퇴근할 때 자동으로 잠가 두는 자물쇠라, 다음 날(새 방문) 열면 어제 그대로다.
가나·한자·어휘는 features/*/data/에 순수 데이터(JSON/TS 배열)로 담겨 있고, "다음 문제를 무엇으로 낼까"는 lib/의 순수 함수가 결정한다. 부작용(화면 그리기·저장)과 계산(출제·정답 판정)을 분리했기 때문에, 출제 로직만 떼어 내 단위 테스트할 수 있다(실제로 generateKanaQuestions·isKanaInputAnswerCorrect 테스트가 있다).
// features/Kana/lib/generateKanaQuestions.ts — 편향 없는 무작위 출제
import { Random } from 'random-js';
const random = new Random();
export function generateKanaQuestion(selected: KanaCharacter[]): KanaCharacter {
if (selected.length === 0) throw new Error('No kana selected');
const i = random.integer(0, selected.length - 1); // random-js: 균일 분포
return selected[i];
}
주요 폴더·파일의 역할.
주목할 점 몇 가지. app/[locale]/의 대괄호는 Next.js의 동적 라우트로, URL의 언어 코드(en·es)를 변수처럼 받는다. train/ 페이지에서 게임 모드(Pick·Input 등)는 URL 세그먼트가 아닌 클라이언트 상태로 처리하고, 시간제한 모드는 train/timed/ 서브라우트를 별도로 사용한다. proxy.ts는 예전 Next.js의 middleware.ts에 해당하는 파일(최신 명명 규약)로, 들어오는 요청의 언어를 감지해 알맞은 로케일 경로로 보내는 관문 역할을 한다.
또 하나, scripts/가 유난히 풍부하다. 번역 키 누락 검사(i18n:validate), 타입 자동 생성, Anki 변환, 월페이퍼 이미지 처리, SEO 헬스체크, 번들 크기 점검까지 — "손으로 반복할 일은 전부 스크립트로"라는 성숙한 OSS 운영 문화를 보여 준다.
이 레포에서 무엇을, 어떻게 배울 수 있나.
app/[locale]/ + next-intl + proxy.ts 조합으로 다국어를 구현했다. core/i18n/routing.ts에 localePrefix: 'never'가 설정돼 있어 URL에 /en/·/ko/ 접두사가 붙지 않는다 — 로케일은 쿠키·Accept-Language 헤더로 감지·저장되고, 사용자 URL은 /kana/ 형태 그대로다. app/[locale]/은 Next.js 내부 라우팅 구조용이며 URL에는 직접 노출되지 않는다. 페이지는 locale을 받아 알맞은 번역을 렌더한다.
실습 아이디어: 한국어 로케일(ko)을 추가해 보라. core/i18n/routing.ts의 locales 배열에 'ko'를 넣고 locales/ko.json을 만든 뒤 npm run i18n:check로 누락 키를 잡아내는 흐름을 경험할 수 있다.
Redux의 보일러플레이트 없이 create() 한 번으로 store를 만들고, persist 미들웨어로 새로고침에도 살아남는 상태를 구현하는 법을 배운다. 기능별로 store를 쪼개 책임을 분리하는 실전 패턴이 그대로 들어 있다.
실습 아이디어: useKanaStore를 읽고, "마지막으로 고른 게임 모드"를 persist로 저장하도록 바꿔, 재방문 시 직전 모드가 복원되게 만들어 보라.
출제(generateKanaQuestion)와 채점(isKanaInputAnswerCorrect)을 화면과 분리된 순수 함수로 두면, 가짜 입력을 넣어 결과만 검증하는 단위 테스트가 가능하다. random-js로 균일 분포 난수를 쓰고, fast-check로 속성 기반 테스트까지 거는 모범 사례를 볼 수 있다.
실습 아이디어: wanakana로 "사용자가 친 로마자"와 "정답 가나"를 비교하는 채점 함수를 작성하고, ka/ka-·대소문자 등 엣지 케이스 테스트를 추가해 보라.
방대한 일본어 사전을 SQLite 파일로 담아 sql.js(WebAssembly)로 브라우저에서 질의하는 구조는, "백엔드 없이도 무거운 데이터를 다루는" 정적 웹앱 설계의 핵심 기법이다. 빌드 시엔 better-sqlite3(Node), 런타임엔 sql.js(브라우저)로 역할을 나눈 점도 배울 만하다.
실습 아이디어: 작은 단어 테이블을 SQLite로 만들어 sql.js로 로드하고, 입력어로 LIKE 검색해 결과를 화면에 띄우는 미니 사전을 구현해 보라.
커밋 직전 자동 검사(Husky+lint-staged), 컴포넌트 격리 개발(Storybook+a11y), Vitest+Playwright 다층 테스트, 번들 크기 모니터링까지 — 혼자 만든 토이가 아니라 '여러 명이 기여하는 제품'을 운영하는 표준 셋업을 통째로 학습할 수 있다.
실습 아이디어: "good first issue" 라벨이 붙은 이슈를 하나 골라, CONTRIBUTING 가이드대로 포크→수정→npm run check→PR까지 오픈소스 기여 전 과정을 밟아 보라.
직접 돌려 보려면 무엇이 필요한가.
| 항목 | 요구사항 | 비고 |
|---|---|---|
| Node.js | 20 이상 | Dockerfile 기준 node:20-alpine |
| 패키지 매니저 | npm (package-lock.json 제공) | npm ci로 재현 가능 설치 |
| OS | Windows / macOS / Linux / WSL | CLAUDE.md에 셸별 명령 구분 안내 |
| 실행(개발) | npm install && npm run dev | Turbopack 개발 서버 · localhost:3000 |
| 검증 | npm run check | tsc + ESLint (빌드는 검증용으로 쓰지 말라고 명시) |
| Docker | docker compose up (dev) | 프로덕션은 멀티스테이지 Dockerfile |
| 브라우저(이용자) | 최신 크롬·사파리·파폭 | WASM(sql.js)·localStorage 지원 필요 |
이용자 입장에선 설치가 전혀 필요 없다. kanadojo.com에 접속만 하면 되고, 모든 진행상황은 브라우저에 저장되므로 계정도 필요 없다. 개발자로서 코드를 돌려 보려면 Node 20과 위 명령이면 충분하다.
난이도별로 손을 움직여 보자.
① 로컬 실행 + 테마 하나 추가. 레포를 클론해 npm run dev로 띄운 뒤, features/Preferences/data/themes/themeDefinitions.ts에 내 색조합 테마를 하나 추가하고 화면에서 선택해 본다. "데이터를 바꾸면 UI가 바뀐다"는 데이터 주도 설계를 체감하는 과제.
② 가나 데이터 구조 분석. features/Kana/data/kana.ts와 lib/flattenKanaGroup.ts를 읽고, '그룹(행) → 개별 글자'로 펼치는 흐름을 그림으로 정리한다. 그런 다음 탁음·요음 한 그룹이 빠졌다고 가정하고 데이터에 추가해 본다.
③ 채점 함수에 테스트 추가. features/Kana/lib/isKanaInputAnswerCorrect.ts를 읽고, 장음(ー)·n받침(ん)·대소문자 같은 엣지 케이스에 대한 Vitest 케이스를 직접 작성한다. npm run test:watch로 빨강→초록을 만든다.
④ 새 게임 모드 한 판 만들기. 기존 Pick/Input을 참고해 "음성 듣고 고르기" 같은 새 모드의 뼈대를 features/Kana/components/Game/에 추가하고, train/ 페이지의 클라이언트 상태와 연결한다. Facade·store 규칙을 지키며 기능을 끼워 넣는 연습.
⑤ 한국어 로케일 풀스택 적용. routing.ts에 ko 추가 → locales/ko.json 작성 → i18n:check로 누락 키 0 만들기 → 미들웨어 동작 확인 → 메타데이터/사이트맵까지 한국어가 반영되는지 점검한다. 다국어 시스템 전체를 관통하는 종합 과제.
4주에 걸쳐 이 레포를 '내 것'으로.
| 주차 | 주제 | 학습 내용 · 산출물 |
|---|---|---|
| 1주차 | Next.js App Router 기초 | 서버/클라이언트 컴포넌트, 동적 라우트([locale])·정적 서브라우트(train/timed/), 레이아웃·로딩. → 미니 라우팅 앱 1개 |
| 2주차 | Zustand + 영속화 | create·셀렉터·persist·기능별 store 분리. → 카운터를 새로고침에도 유지되게 만들기 |
| 3주차 | 게임 로직 + 테스트 | 순수 함수 출제/채점, random-js·wanakana, Vitest·fast-check. → 가나 퀴즈 핵심 로직 + 테스트 셋 |
| 4주차 | 아키텍처 + OSS 운영 | Feature 레이어·Facade, Storybook, Husky/lint-staged, Docker. → 본 레포에 PR 1건 오픈 |
처음부터 코드 전체를 이해하려 하지 말 것. "한 글자 문제가 화면에 뜨고 → 내가 답하고 → 통계가 1 늘어나는" 한 줄기 흐름을 골라, 그 흐름이 지나는 파일만(page → widget → feature → store) 순서대로 따라가는 게 가장 빠르다. 나머지 기능은 같은 패턴의 반복이다.
한 줄로 빠르게 짚는 용어.
| 용어 | 의미 |
|---|---|
| Kana(가나) | 일본어 표음문자. 히라가나·가타카나 두 벌. |
| JLPT N5~N1 | 일본어능력시험 등급. N5 입문 ~ N1 최상급. |
| App Router | Next.js의 폴더=라우트 방식. 서버 컴포넌트 기본. |
동적 라우트 [x] | URL 일부를 변수로 받는 폴더. 예: [locale]. 게임 모드는 동적 라우트가 아닌 클라이언트 상태로 처리. |
| Zustand | 훅 기반 경량 전역 상태관리 라이브러리. |
| persist | store를 localStorage에 자동 저장하는 미들웨어. |
| next-intl | 네임스페이스 기반 Next.js 다국어 라이브러리. |
| Facade | 기능 폴더의 '정문'. 공개 API만 내보내 내부를 숨김. |
| Feature 기반 구조 | 코드를 기술 종류가 아닌 기능 단위로 자르는 아키텍처. |
| wanakana | 로마자↔가나 변환 라이브러리. 입력·채점에 사용. |
| kuroshiro / kuromoji | 일본어 형태소 분석·읽기 변환(후리가나) 도구. |
| sql.js | WASM으로 브라우저에서 도는 SQLite. 사전 검색에 사용. |
| shadcn/ui | Radix 기반 복붙형 React 컴포넌트 모음. |
| Storybook | UI 컴포넌트를 화면과 분리해 개발·문서화하는 도구. |
| Husky / lint-staged | git 커밋 훅으로 스테이징 파일을 자동 검사. |
| Turbopack | Next.js의 차세대 번들러. 빠른 개발 서버. |
| AGPL-3.0 | 네트워크 사용 시에도 소스 공개를 요구하는 강한 카피레프트 라이선스. |