단어장 앱을 만든 열흘 — 한 값이 세 가지를 재고 있었다

작성 · 수정


워킹홀리데이 첫 해에 쓰는 일본어 단어 1,497개와 문장 192개를 다섯 번 돌려 외우는 앱을 열흘 동안 만들었다. 계정도 서버도 없고 기록은 기기 안에만 남는다. 만드는 동안 고친 것들은 대부분 기능이 모자라서가 아니라, 한 값이 여러 가지를 겸하고 있거나 실패가 조용히 삼켜져서 생긴 문제였다. 그 열흘에서 남길 만한 것을 적는다.

같은 엔진에서 앱 셋이 나온다 — 시고또(직장) · 바이또(워홀) · 레포또(유학). 이번 글의 주인공인 바이또는 그 둘째다. 엔진은 한 줄도 다르지 않고, 데이터와 껍데기만 갈아 끼운다.

단어문장카드
시고또1,5021851,687
바이또1,4971921,689
레포또1,5161951,711

첫 커밋 2026-09-07, 열흘 동안 커밋 160개. 엔진 코드 7,574줄, 팩 데이터 16,095줄, 구운 음성 13,053개(185MB). 세션 시험 193개가 배포 전마다 돈다.

복제 가능성은 세 번째에서 증명된다

처음 만든 것은 직장 일본어 앱이었다. 그런데 단어장 앱의 뼈대 — 카드를 섞어 내고, 회독을 세고, 소리를 붙이고, 진도를 굴리는 일 — 은 주제와 상관이 없다. 바뀌는 것은 데이터와 껍데기뿐이었다.

그래서 팩 구조로 갈랐다. NEXT_PUBLIC_PACK이 어느 앱인지 정하고, @pack/* 별칭이 그 팩의 데이터만 번들에 넣는다. 다른 팩의 단어는 한 줄도 안 실린다.

바이또는 그 위에 얹은 둘째 팩이었고, 레포또가 셋째였다. 엔진의 구멍은 셋째에서 드러났다. 아이콘 굽기·OG 이미지·음성 시험 스크립트가 팩 별칭 로더 없이 돌고 있었고 한 스크립트는 「장면이 둘 이상 있다」를 전제하고 있었다. 팩이 둘일 때는 셋 다 우연히 돌아갔다. 두 번째는 우연히 성공할 수 있다. 복제 가능성은 세 번째에서 증명된다.

「다섯 번 돌린다」를 어떻게 셀 것인가

이 앱의 전부는 사실 규칙 하나다. 正 한 획이 회독 하나 — 같은 표현을 다시 만나는 한 번이고, 다섯 획이면 그 표현은 끝. 그런데 이 단순한 규칙이 열흘 내내 가장 많이 고쳐진 자리였다.

처음에는 한 번에 맞힌 것만 한 획으로 쳤다. 되물어 맞힌 카드는 안 쳤다. 엄격해 보였지만 카드 25장을 한 묶음으로 내는 구조에서 그중 한 장을 되물으면 그 묶음의 층을 못 넘어서 같은 묶음을 서너 번 돌 때까지 다음이 잠긴 채였다. 마무리한 사람을 붙잡는 일이다. 고친 뒤로는 끝까지 돌면 모두 한 획이다. 한 번에 맞혔는지는 결과 화면의 〇·△가 따로 말하고, 거기서 줄을 눌러 그 한 획을 뺄 수도 있다.

묶음 하나를 다섯 번 채우고 넘어가지는 않는다. 층으로 돈다. 1회독으로 줄 전체를 한 바퀴 돌고 그다음 2회독으로 다시 한 바퀴. 뭉쳐 외우는 것보다 흩어 외우는 쪽이 오래 남고, 무엇보다 첫 묶음에서만 199장을 다섯 번 외우는 일이 없다.

회독마다 묻는 방식도 바뀐다. 1·2회독은 일본어→뜻, 3회독은 뜻→일본어, 4·5회독은 예문에서 그 말만 빈칸으로 가린다. 같은 화면을 다섯 번 보면 세 번째부터는 뜻이 아니라 화면을 외운다.

다 외운 뒤가 이 앱의 구멍이었다. 기억이 빠지기 시작하는 건 정확히 그 시점부터다. 다섯 획을 채운 카드가 20장 모이면 「유지」 줄이 서고 FSRS-6이 카드마다 오늘 기억하고 있을 확률을 계산해 목표(90%) 아래로 떨어진 것부터 낸다. 맞히면 다음은 더 멀리, 틀리면 곧 다시.

한 숫자가 세 가지 일을 겸하고 있었다

마지막 날에야 알았다. counts(카드별 회독) 하나로 진도(다음에 무엇을 할까) · 해금(어디에 다시 들어갈 수 있나) · 유지(다 외운 것)를 전부 재고 있었다.

그래서 유지 모드에서 한 장 틀려 회독이 내려가면 이미 열려 있던 묶음이 도로 잠겼다. 실제 데이터로 돌려 보니 2회독 시점에 60묶음 중 56개가 다시 잠기는 계산이 나왔다.

셋을 갈랐다 — 진도는 rounds(묶음을 끝까지 돈 횟수), 해금은 seen(그 묶음의 카드를 하나도 빠짐없이 만났나), 유지는 그대로 회독. 한 값이 세 가지를 재고 있으면 셋 다 조금씩 틀린다. 어느 하나도 명백히 고장 나 보이지 않는다는 점이 이런 종류의 버그가 오래 사는 이유다.

서버가 없다는 결정이 나머지를 다 정했다

계정 없음, 서버 없음, 완전한 정적 사이트. 기록은 localStorage에만 남는다. 이 하나로 정해진 것들이 있다.

  • 운영비 0원, 백업 의무 0. 남의 학습 기록을 갖고 있지 않으니 지킬 것도 없다
  • 열자마자 시작. 가입 화면이 없다
  • 대신 기기를 옮기면 기록이 안 따라온다 → 설정에 글자로 복사하는 백업·복원을 뒀다
  • 개인정보 처리방침이 짧아진다 — 수집하지 않는 것은 쓸 것도 없다

음성은 미리 굽는다. 13,053개, 185MB. 기계 음성으로 즉석에서 읽히지 않는 이유는 첫인상이 로봇 목소리가 되기 때문이다. 묶음을 열 때 받아 기기에 남으므로 한 번 연 묶음은 네트워크 없이도 소리가 난다.

한 색이 다섯 가지를 뜻하면 아무것도 안 뜻한다

세 앱은 글꼴·종이·괘선·강조색이 다르다. 바이또는 구인 프리페이퍼의 문법이다 — 따뜻한 흰 종이, 굵은 획, 먹으로 그은 괘선, 모서리를 거의 깎지 않은 상자. 시고또는 명조와 화지, 레포또는 원고지 칸과 고딕. 팩이 정하는 값은 열대여섯 개뿐이다(theme.ts). 색 여덟, 굵기 셋, 모서리 둘, 테두리 굵기, 낙관 글자, 아이콘 글꼴. 화면 코드는 그 값만 읽는다.

마지막 작업이 색 정리였는데, 세어 보니 점찍는 색이 넷이고 열두 자리에서 서로 다른 뜻으로 쓰이고 있었다. 특히 팩 강조색 하나가 「끝냈다 · 잘했다 · 주의 · 여기 · 위험」을 다 졌다. 레포또에서는 다 채운 正도 초록이고 「다시」 표찰도 초록이었다.

둘로 갈랐다.

색뜻쓰는 곳
인주 #fe6180사람이 남긴 자국오늘 도장, 담아 둔 것, 지금 서 있는 자리
팩 강조색다 끝냈다다섯 획, 막대 100%, 졸업 합격

주의와 위험은 색에서 뺐다 — 「다시」는 곁글색이고, 지우기는 두 번 눌러야 한다. 그러면서 하나가 드러났다. 「틀림」 단추가 인주와 사실상 같은 분홍이어서, 카드 화면에 담아 둔 리본과 틀림 단추가 같은 색으로 같이 서 있었다. 주황으로 옮겼다. 앞 절의 counts와 같은 이야기다. 한 색이 다섯 가지를 뜻하면 아무것도 안 뜻한다.

증상은 사람이 말하고, 원인은 재서 찾는다

9월 15일 하루에만 커밋 44개가 들어갔다. 대부분 직접 써 보다 걸린 것이다.

증상 (쓰다가 발견)실제 원인
「26장 중 0장」이 안 움직인다그 숫자가 다른 걸 세고 있었다 — 5회독 완료 장수
옆으로 밀어도 채점이 안 된다touch-action: pan-y라 브라우저가 세로 끌기로 가져갔다
북마크가 아이콘에서만 눌린다rounded-full이 네 구석을 원 밖으로 밀어 상자의 21%가 죽어 있었다
설정이 내려갈 것처럼 생겼는데 안 내려간다손잡이가 표시일 뿐이었다 → 말한 대로 하게 고쳤다

네 가지 모두 짚은 층과 원인의 층이 달랐다. 증상은 사람이 말하고, 원인은 재서 찾는다.

아무도 볼 수 없던 것

배포 확인을 하다가 /sw.js가 세 앱 전부 404인 것을 찾았다. 서비스 워커 등록이 .catch(() => {})에 삼켜져 운영에서 오프라인 재생도 자동 갱신도 한 번도 돈 적이 없었다. 화면은 멀쩡했고 로컬 시험은 전부 통과하고 있었다.

원인은 빌드 순서였다. Vercel의 Next 프리셋은 out/을 배포에 쓰지 않고 정적 파일을 public/에서 올린다. 그런데 서비스 워커는 next build 뒤에 out/으로만 찍고 있었다. npm run sw를 빌드 앞으로 옮기고 public/에 찍는 것으로 끝났다.

여기서 두 가지가 겹쳤다. 하나는 로컬에서 띄워 검사하던 out/이 배포되는 물건이 아니었다는 것. 다른 하나는 같은 함정에 걸린 게 두 번째였다는 것이다 — 나흘 전엔 소리 폴더로 같은 일을 겪고 public/ 쪽을 고쳤는데, 그때 증상만 고치고 원인은 안 고쳤다.

글을 걷어냈다

마지막 날, 화면의 글을 통째로 줄였다. 사용법이 열한 줄 스물여섯 문단이었는데 여섯 줄과 짧은 항목 다섯으로 줄였다. 규칙은 하나도 빼지 않았다. 걷어낸 것은 둘이다.

  • 화면을 보면 아는 것 — 이레 도장 설명, 예순 날 격자 설명
  • 해 보면 아는 것 — 「밀다가 가운데서 놓으면 제자리로 돌아온다」

홈에서도 같은 것을 걷었다. 「한 번에 50장씩 돌며 뺀다」 「잊을 때가 된 것부터」 「이제부터는 잊지 않는 게 일이다」 — 전부 앱이 자기 사정을 설명하는 말이었다. 줄 이름과 숫자가 이미 하는 말이다. 소개에서는 「순서는 앱이 정한다」를 걷었다. 사용자가 알 바가 아니다.

남길 만한 것

  • 한 값이 여러 가지를 재고 있으면 전부 조금씩 틀린다. 어느 하나도 명백히 고장 나 보이지 않는다
  • 색은 뜻마다 하나씩. 다섯 가지를 겸한 색은 아무 신호도 아니다
  • 복제 가능성은 두 번째가 아니라 세 번째에서 증명된다
  • 조용히 실패하는 코드는 없는 기능과 같다. .catch(() => {}) 하나가 기능 하나를 통째로 지웠다
  • 증상을 고치는 것과 원인을 고치는 것은 다르다. 원인을 두면 나흘 뒤 다른 얼굴로 돌아온다
  • 로컬 시험은 배포를 증명하지 않는다. 검사하는 산출물이 배포되는 산출물인지부터 확인해야 한다
  • 읽는 사람은 앱의 사정을 안 궁금해한다. 규칙과 그 규칙을 그렇게 정한 이유를 같은 무게로 적으면 무엇을 기억해야 하는지가 흐려진다

지금은 웹 세 주소를 운영 중이다(shigoto.papercut.kr · baito.papercut.kr · repoto.papercut.kr). iOS는 Capacitor로 껍데기를 만들어 두었고 제출은 아직이다.