1.0x
> cd ..
NiVoca (Legacy)

NiVoca (Legacy)

2026-02-22 ~ 2026-08-06

Intro

NiVoca는 퀴즈, OCR 단어 인식 등 다양한 기능을 담은 일본어 단어장 앱입니다.

이 앱을 만든 계기는 좀 더 일본어 단어를 쉽게 입력하여 단어를 추가하고 싶다는 나의 편의를 위해서 입니다.
한국인 입장에서 스마트폰을 사용할 때, 보통 한/영 키보드를 사용하는데, 여기서 일본어 키보드가 추가되면 일반적인 입력케이스에서 벗어나게 되어 사용하기에 불편해지는 문제가 있었습니다.
그래서 처음에는 단어 입력란 input에 focus하면 자동으로 ime가 변경되도록 하는 안을 생각했으나, 그건 사용자의 기기를 임의 조작하는 문제로 해당 기능이 막힌지 꽤 되어 어쩔 수 없이 단념하고, 머리 속에서 잠깐 지워뒀습니다.

최근에 AI의 발달로 바이브 코딩이 유행하여 앱/웹 서비스를 만들기 굉장히 간단해져, 머리 속에 묵혀뒀던 내가 원하는 일본어 단어장 앱을 바이브 코딩으로 만들어볼까 하는 생각이 문득 들어 개발을 시작하게 되었습니다.

최초의 니즈, ime 임의로 변경하는 건 여전히 불가능하여, 설계안을 ai한테 질문하니 waragana라고 영어를 자동으로 히라가나로 변환해주는 라이브러리가 있다고 하여, 이 정도면 타협할만하다 생각하여 개발을 진행하게 되었습니다.

개발하다보니, 이미지 인식을 통해 단어도 추가하고 싶고, 최근에 나온 Gemma4 활용하여 온다바이스 LLM을 통해 학습 기능도 붙이는 등 최초 기획에 비해 여러 기능이 포함된 제가 생각하기에는 활용도가 높은 앱이 되었다고 생각합니다.

개발을 진행하다보니 Web / ReactNative 만으로는 앱의 native 기능을 활용하기에 한계가 있다는 것을 느껴 (특히 on device LLM 활용) Flutter로 전면 리팩토링하기로 마음 먹고, Web, React native 버전은 아카이브로 남기기로 했습니다.

그래서 현 리포는 여러 credential key, ui 디자인 기록 정도를 제외하고 public으로 전환했습니다.
바이브 코딩으로 개발된 거라 퀄리티는 보장을 못하지만, 핵심 기능은 잘 돌아가도록 설정해두었으니 참고가 되었으면 합니다.

프로젝트

사이트 링크

기술 스택

영역사용 기술
프레임워크Next.js 16 (App Router, Turbopack)
UIReact 19, Tailwind CSS 4, shadcn/ui (Radix)
상태 관리Zustand (인증·스캔), 나머지는 로컬 state
데이터Supabase (로그인)
간격 반복FSRS (ts-fsrs)
OCRTesseract.js, LLM Vision, 온디바이스 Gemma (LiteRT-LM)
네이티브 셸Expo 55 + React Native WebView
테스트Vitest, Playwright
배포GitHub Actions → Docker → Nginx (자체 리눅스 서버) → CloudFlare
패키지 매니저Bun workspaces (모노레포)

모노레포 구성은 다음과 같습니다.

nihongo-vocabook/
├── apps/web/ # Next.js PWA (본체)
├── apps/mobile/ # Expo iOS 앱 (WebView + 네이티브 기능)
└── supabase/ # 마이그레이션 / 시드

개발 과정

초기 기술 선정

  • Next.js + PWA
    • 앱 개발은 7년 전에 flutter로 잠깐 개발한 것 뿐이기에 익숙한 웹으로 진행.
    • 앱과 같은 사용성은 PWA를 활용하는 것으로 대체 
  • Supabase
    • 개인 프로젝트에서 인증 + DB + RLS를 한 번에 해결
    • 간단하게 개발하는 것이 목적, 그리고 백엔드 단을 크게 신경안써도 되기에 채택
  • Bun
    • node , pnpm 조합보다 빠르다고 하기에 써봄.
    • 라이브러리 모두 최신 것을 씀, + 개인 프로젝트이기 때문에 버전 제약이 없어 신기술 채택.

초기 화면은 이랬습니다. 사전 검색 결과를 고르면 단어 · 읽기 · 뜻이 채워지고, 알파벳으로 친 입력이 waragana를 거쳐 히라가나로 들어갑니다.

단어 추가 (사전 검색)이미지 OCR로 단어 추가
초기 UI - 사전 검색 결과와 단어 추가 폼초기 UI - 이미지 OCR로 단어를 추가하는 화면

초기 설계

  1. 비로그인 상태에서도 사용 가능
  2. 단어 / 단어장 / 퀴즈 / 설정 부로 IA 구성
  3. 단어 외우는 과정은 flip으로 “단어만 보기” / “단어 + 뜻으로 보기” 
  4. 퀴즈 설정 
  5. 초기는 srs 
  6. 이상해서 ai와 토의 끝에  fsrs를 구성을 커스텀하여 활용, (그대로 쓰려니 내가 구현한 ui.ux와 적합하지 않았음)
  7. waragana를 통해 알파벳으로 히라가나 입력 
  8. 단어는 오픈 소스에 있는 일본어/영문 의 데이터를 가져와 LLM(claude sonnet) 을 통한 한글 번역 진행
  9. 단어 검색은 먼저 db에 저장된 사전 검색, 없으면 Jisho api 활용, jisho 에서도 검출 안되면 llm 활용하여 단어 검색, 여기도 없으면 결과 없음으로  (여기서의 llm은 gpt5)
  10. DB, 인증 쪽은 Supabase로 전담 활용

다만 1번(비로그인 사용)은 나중에 철회함.
초기엔 게스트용 저장소를 IndexedDB(Dexie)로 따로 만들어서, 로그인 여부에 따라 Repository 구현체를 갈아끼우는 구조였음.
근데 게스트 데이터 → 로그인 후 서버 데이터로 넘기는 마이그레이션까지 챙기려니 상태 관리가 감당이 안 됐음.
결국 게스트 저장을 통째로 걷어내고, 게스트는 모드는 폐기

퀴즈 / 간격 반복

단어를 담는 쪽보다 오히려 여기에 시간을 더 썼음.

SM-2 → FSRS 교체

  • 처음엔 Anki 계열의 고전 알고리즘(SM-2)으로 직접 짰는데, 복습 간격이 감각적으로 안 맞았음
  • ts-fsrs로 갈아탐. 알고리즘 파라미터는 기본값 그대로 쓰고, 커스텀은 그 위 UI/UX 층에서만 함
  • 전환 흔적이 아직 남아있음 — study_progress.ease_factor는 SM-2 시절 필드라 FSRS는 안 쓰는데 컬럼은 그대로 살아있음

평가 4단계 + 다음 간격 미리보기

  • Again / Hard / Good / Easy 4버튼
  • 누르기 전에 각 버튼이 다음 복습을 언제로 잡는지 미리 보여줌 (“Easy = 20일 뒤” 식)
  • 등급을 감으로 찍지 않게 하려는 의도

카드 두 종류

  • 단어 카드 — flip. 방향은 단어 → 뜻 / 뜻 → 단어 / 랜덤 중 선택
  • 예문 빈칸 카드 — 예문에서 대상 단어를 ____로 가리고 3지선다 (오답 2개는 다른 단어에서 뽑음). 기본 비율 30%, 설정에서 조절

빈칸을 어디에 뚫을지가 생각보다 까다로웠음. 사전형(食べる)이 예문에선 활용형(食べます)으로 나오기 때문.

  • 사전형이 문장에 그대로 있으면 그걸 사용
  • 없으면 뒤쪽 히라가나를 한 글자씩 떼면서, 한자가 남아있는 조각을 찾음 (食べる食べ)
  • 한자가 아예 없는 단어(する, できる)는 포기하고 그 카드를 버림

오늘 뭘 낼지 고르는 기준

우선순위 가중치 × JLPT 레벨 근접도 × 연체 배수 를 곱해서 점수순 정렬.

  • 우선순위: 높음 1.0 / 보통 0.7 / 낮음 0.4
  • 연체 배수: 안 밀렸으면 0.8, 3일 이내 1.0, 일주일 넘게 밀리면 1.5

쓰다 보니 점수가 같은 단어끼리는 매번 같은 순서로만 뽑히는 게 걸렸음.

  • 최종 점수에 ±15% 지터를 곱해 동점을 랜덤으로 깨도록 함
  • 우선순위 · 연체로 벌어지는 차이(보통 1.4~3배)가 지터보다 커서, 급한 카드가 뒤로 밀리진 않음
  • 신규 카드는 아예 입력순으로 편향돼 있어서, DB에서 랜덤으로 뽑아오는 RPC를 따로 팜

자동 마스터 / leech

  • Easy 연속 2회 → 자동으로 「암기 완료」 처리하고 큐에서 제외
  • 반대로 계속 틀리는 단어는 leech로 표시 (기본 8회, 설정 가능)

세션 복원

  • 퀴즈 도중에 앱을 나가도 이어서 할 수 있어야 했음
  • localStorage에는 단어 ID 순서 + 현재 위치만 저장. 예문 / 오답 보기는 무거워서 저장 안 하고 불러올 때 다시 생성
  • 날짜가 바뀌면 폐기

정답률은 가중 평균

맞췄다 / 틀렸다 이분법이 아니라 “얼마나 확실히 아는가”로 집계.

AgainHardGoodEasy세션 중 마스터
0205080100

그 외 일일 목표(기본 20단어), JLPT / 우선순위 필터, 학습 리마인더 알림, 성취 22종(단어 수 · 연속 학습일 · 누적 복습 수 · 무결점 세션 등)을 설정에서 조절할 수 있게 함.

단어 카드 (flip)예문 빈칸 카드 (3지선다)
퀴즈 - 단어 카드와 Again/Hard/Good/Easy 평가 버튼퀴즈 - 예문에서 대상 단어를 가린 빈칸 채우기 카드

기능 확장

  1. On device LLM(Gemma4)를 활용한 OCR 강화 AI와의 일본어 학습 기능
  2. 각 단어의 한자 클릭하면 각 한자의 훈/독에 따른 뜻과 요미가나
  3. 각 단어의 예문 제공
  4. 한자와 예문 데이터는 llm(claude sonnet) 생성으로 처리 
  5. ui/ux 전면 개편. 

단어 상세에서 한자를 누르면 그 한자의 음독 / 훈독과 뜻이 그 자리에서 펼쳐집니다. 예문에는 후리가나와 한국어 해석을 같이 붙였습니다.

단어 상세예문 · 한자 음독/훈독
단어 상세 - 뜻, JLPT 난이도, 우선순위, 학습 현황단어 상세 - 예문과 한자를 눌러 펼친 음독 / 훈독 팝오버

UI는 한 번에 갈아엎은 게 아니라 계속 손봤음. 그래서 이 글에 실린 스크린샷도 찍힌 시점이 제각각이라 조금씩 다름.

  • 하단 탭: 「암기 완료」 자리를 「어시스턴트」가 가져감. 온디바이스 LLM을 붙이면서 교체
  • 탭 바: 화면 바닥에 붙은 평평한 바 → 액티브 탭 pill → 바 전체가 떠 있는 캡슐 형태
  • 단어 상세: 난이도 · 우선순위 · 학습 현황을 한 화면에 드러내고, 구독 단어장 배지 추가

로고도 이때 같이 갈아엎었습니다.

초기 로고개편 후
초기 로고개편 후 로고 (가로형)

React Native 및 Expo 도입

  1. web 환경으로는 메모리가 1gb로 제한되어 있어 on device llm을 활용하기에 적절하지 않았기 때문에 전환
  2. next.js 개발 산출물을 재활용하느기에는 RN이 적합
  3. 쉽게 앱스토어 배포할 수 있는 expo도 사용해보기로 함.

정확히는 화면을 RN으로 다시 짠 게 아니라, 기존 Next.js 웹을 WebView로 감싼 셸 구조. 화면과 로직은 웹 산출물을 그대로 쓰고, 네이티브가 필요한 것(카메라 / 알림 / 온디바이스 LLM 추론)만 브리지로 뚫어서 내보냄.

[ Next.js (WebView) ] ←→ bridge ←→ [ Expo Native ]
화면 · 로직 카메라 / 알림 / on-device LLM

현 프로젝트 Freeze 및 Flutter 전환

  • RN의 불편한 사용성:
    • 바이브 코딩으로 최적화를 제대로 안해서 그런 것 같긴 한데 묘하게 버벅거리는 부분이 많았음
    • js -> swift의 층이 하나 더 있어 native 기능을 온전히 활용하기 힘듦
  • On deivce LLM 활용 제한
    • native가 아니기 때문에 묘하게 안되는 부분이 많았음, 원래 iphone15pro 정도면 gemma4 e4b 를 사용할 수 있는 메모리가 있음에도 e4b를 쓰면 앱이 튕김
    • e2b는 모델이 작아서 계속 이상한 대답함.  프롬프트를 1천자 이상 넣으면 제대로 동작 안함. function calling 제대로 수행을 못함.
  • Expo 불편함
    • free 플랜으로는 빌드 제약이 있음  => 사이드 프로젝트로 하는거라 유료 플랜 구독은 좀 부담스러움.
      • 빌드 우선 순위에 밀려, 1시간 뒤에 빌드 한다던지 하는..

위 이유로 현 프로젝트는 archive하고, flutter로 전면 전환.

flutter로 전환한 이유는 ios/android 동시 지원, 크로스 플랫폼이지만 native에 가까움.

Public Archive 처리

현 프로젝트는 private로 두기엔 좀 아까워서 블로그 글이나 써볼까 해서 민감 요소만 빼고 public archive로 두기로 함. 
“_docs”, “_note” 폴더 등을 보면 내가 이 프로젝트를 진행하면서 ai와 논의 햇던 것, 계획 세웠던 것이 다 있음.   

배포 환경

블로그와 마찬가지로 개인 리눅스 서버에 self-hosted runner를 두고 배포

  • 빌드는 GitHub 호스티드 러너에서 수행 → 아티팩트 업로드
  • 배포는 self-hosted 러너가 아티팩트를 받아 Docker 이미지 빌드 후 컨테이너 교체
  • Nginx 리버스 프록시 + Cloudflare 도메인 연결

회고

잘한 것

  • 바이브 코딩을 통한 빠른 기능 개발 => OCR 방식 교체, 게스트 모드 철회, RN 전환 전부 “해보고 판단”이 가능한 속도였기에 결정이 안 밀렸음.
  • 사전 데이터를 오픈소스 + LLM 번역으로 채운 것. 개인 프로젝트에서 수만 단어를 손으로 채울 방법은 없었음.
  • 어쨌든 앱스토어 심사까지 통과시켜 출시한 것. 사이드 프로젝트는 보통 로컬에서 도는 데까지 하고 멈추는데, 스토어 등록 · 심사 · 배포 파이프라인까지 한 번 굴려본 건 그대로 남음.

아쉬운 것

  • 온디바이스 LLM을 기능의 전제로 깔아버린 것. 모델이 되는지부터 확인하고 기능을 설계했어야 했는데 순서가 반대였음. 결국 e4b는 메모리로 튕기고 e2b는 품질이 안 나와서, 이미 만든 기능을 모델 수준에 맞춰 깎아내는 작업을 반복함.
  • 게스트 모드를 IndexedDB까지 다 만들고 나서 철회한 것. 착수 전에 상태 관리 복잡도를 가늠 못 했음.
  • Supabase 트리거 / RPC에 도메인 로직을 일부 흘려보낸 것. 당장은 편했지만, 백엔드를 직접 운영하기로 하면서 그대로 이전 비용이 됨.
  • 바이브 코딩의 대가. “동작한다”와 “검증했다”를 구분하는 규율이 늦게 잡혔음.

온디바이스 LLM이 실제로 어떻게 안 됐는지는 화면에 그대로 남아있습니다. 입력이 조금만 길어져도 토큰 한도(2048)를 넘겨 끊기고, 세션 자체가 안 열리는 경우도 잦았습니다.

토큰 한도 초과 · 세션 생성 실패function calling 시도
온디바이스 LLM - 입력 토큰 2630이 한도 2048을 넘겨 실패한 화면온디바이스 LLM - function calling 시도 화면

“동작한다”와 “검증했다”의 차이도 이런 식으로 드러났습니다. 한자 상세 패널이 평가 버튼을 덮어버려서, 화면상으로는 멀쩡한데 정작 답을 고를 수 없었습니다.

응답 실패패널이 평가 버튼을 가린 화면
온디바이스 LLM 응답 실패 화면퀴즈 화면에서 한자 상세 패널이 평가 버튼을 덮어버린 화면