진행 중 · 2026년 5월 23일
Mimi
YouTube 영상 아무거나 간격반복 영어 쉐도잉으로 바꾸고, 저절로는 안 나오는 문장 패턴·콜로케이션·전치사를 매일 드릴로 굳히는 풀스택 영어 학습 앱. Java/Spring + Next.js, 1인 개발.
8,892 LOC · 49 endpoints · 121 tests · multi-tenant · AWS ECS Fargate
- 역할
- Solo (Claude Code 페어 프로그래밍)
- 스택
- Java 21 · Spring Boot 3.3 · PostgreSQL 16 · Next.js 16 · TypeScript · Gemini / Claude · AWS ECS

영어 문서는 하루 종일 읽는데 막상 말하거나 쓰려면 굳는 개발자를 위한 풀스택 영어 학습 앱. Java 21 / Spring Boot API + Next.js / TypeScript 프론트엔드, 현재 iOS App Store에 Mimi — English Shadowing으로 출시되어 있다. 핵심은 기능 수가 아니라 마찰 제거 — 이미 소비하는 입력(YouTube)을 피하던 출력 연습으로 바꾼다.
서로 먹여주는 두 반쪽
- YouTube 쉐도잉. URL 붙여넣고 자막 구간을 클립하면, LLM 한 번 호출로 번역 + 직독직해(단어별) + 어휘 + 전치사별 노트 + 연습 시나리오가 돌아와 Postgres JSONB에 캐싱 — 공부할 때마다 API 재과금 없음. SM-2 간격반복이 클립마다 일정을 잡고, 복습은 reveal / write(토큰 단위 diff) / scenario 모드.
- Practice hub. 매일 Leitner 간격 드릴 — 문장 패턴 82개, 단어+전치사 콜로케이션 101개, 애니메이션 전치사 다이어그램, AI 작문 첨삭, 그리고 본인 오답 데이터로 만든 약점 대시보드.
엔지니어링 하이라이트
- 트랜잭션 안전 비동기 AI 파이프라인. 클립 생성이 도메인 이벤트를 발행하고, 분석은
@TransactionalEventListener(AFTER_COMMIT)+@Async로 백그라운드 스레드에서 — 수 초짜리 LLM 호출이 DB 트랜잭션 바깥에서 돌아 커넥션 풀을 붙들지 않음. 트랜잭션은PENDING → READY / FAILED라는 작은 쓰기뿐. 일시적 실패(429 / 5xx / timeout)는 재시도, 영구 실패는 빠르게 실패. - 환경변수로 갈리는 가장자리. 같은 코드가 로컬에서도 실서버에서도 돌고 env var 하나로 전환 — AI 프로바이더(Gemini ↔ Claude)는
@ConditionalOnProperty로 호출부 변경 0, 녹음 저장(로컬 디스크 ↔ S3 / R2)도 같은 방식, 바이트는 항상 백엔드를 통해 스트림(공개 URL 아님). - 채점 모양에 맞춘 SRS 2개. 클립 복습은 SM-2(4단계), 이진 드릴(알았음 / 다시)은 Leitner 박스 — 2개 버튼에 SM-2의 0~5 스케일을 욱여넣는 건 틀린 도구라서. 둘 다 I/O 없는 순수 함수.
- 한계비용 $0 + 남용 방지. 클립당 LLM 호출 1회, 캐싱. 돈 드는 유일한 엔드포인트(AI 작문 첨삭)는 사용자별 rate-limit, 가입/로그인의 IP별 limiter와 분리.
- 멀티테넌트 안전. stateless JWT에 token-version 클레임(비번 변경 시 이전 토큰 폐기), 쿼리 레벨 사용자 격리(
findByIdAndUserId— 남의 id는 404), BCrypt, 접근 제어된 녹음.
스택 & 인프라
Java 21 · Spring Boot 3.3 · PostgreSQL 16(Flyway, JSONB, raw-SQL CHECK) · Next.js 16 / React 19 / TypeScript strict · Tailwind 4 · shadcn/ui. 관측성: Micrometer + Actuator → Prometheus, MDC 요청/사용자 로깅. Docker(멀티스테이지, ffmpeg + yt-dlp); GitHub Actions CI; AWS ECS Fargate + RDS로 keyless OIDC 배포; 프론트는 Vercel. Testcontainers(실제 Postgres) + Vitest + Playwright(14 spec).
아직 안 하는 것
- 1인, AI 보조 — 제품 방향, 아키텍처(프로바이더 추상화, 트랜잭션 안전 비동기 파이프라인, SRS 2분할), 프롬프트 설계, 콘텐츠 정확도 감수는 내가; Claude Code가 페어.
- rate-limit과 idempotency가 인메모리 — 단일 인스턴스엔 맞지만 다중 인스턴스는 durable 저장소(Redis) 필요.
ja/zh/es는 스캐폴딩만(영문 문자열),en·ko만 완역.- AWS 인프라는 문서화된 런북 + task definition이지 아직 Terraform 아님.
- 자막 추출에
yt-dlp사용 — YouTube ToS 회색지대라 개인용은 괜찮지만 상업 전엔 재검토.
프로젝트 로그
이 프로젝트를 만들면서 남긴 트러블슈팅 · 회고 · 업데이트의 시간순 기록.
2026년 7월 20일 주2 개 · 2 기술 회고
PAY-1 — free/pro 한 칸을 capability 테이블로 교체하고 AI 비용 구멍을 막다
기술 회고2026년 7월 20일 · 3 분
user_entitlements 복합 PK + AccessPolicy 중앙 판정으로 SHADOW/AI 접근을 분리하고, 클립 자동분석·regenerate·유료 벤더 경로의 게이트 구멍을 봉합했다. 193개 테스트 green, 적대 리뷰 10건 반영.
PAY-2 — RevenueCat 연동에서 적대 리뷰가 진짜 경쟁 조건과 보안 오탐지를 잡다
기술 회고2026년 7월 20일 · 4 분
216개 테스트가 green인 상태에서 3-렌즈 적대 리뷰가 신규 entitlement 행의 실제 경쟁 조건과 sandbox 오탐지 버그(유료 고객이 프로덕션에서 안 보이게 됨)를 찾아 원자적 SQL upsert로 재설계했다.
2026년 7월 13일 주16 개 · 5 업데이트 · 2 트러블슈팅 · 3 기술 회고 · 6 사용성 회고
AI 스파링을 앱의 중심으로 — 독립 중앙 탭 승격 (R2)
업데이트2026년 7월 14일 · 1 분
묻혀있던 스파링을 최상위 중앙 탭으로 올렸다. 'live' 코랄색의 부양 마이크 버튼 — 나머지 파란 앱과 다른 색·다른 자리로, 제품의 해자(말하기)가 가장 눈에 띄는 픽셀을 갖게 했다. Codex R2 트랙이 미산출이라 직접 구현.
U1 mastery 데이터 레이어와 스파링 리포트 클라이언트
업데이트2026년 7월 14일 · 2 분
8개 정적 SRS 팩의 mastery를 한 selector로 집계하고 F1 스파링 리포트 API를 shared core에 연결했다. 오늘 reps를 주간 합계로 오인하지 않도록 표시 계약도 분리했다.
U3 세션 goal-gradient와 peak-end
사용성 회고2026년 7월 14일 · 4 분
드릴에 후퇴하지 않는 진행바와 막판 남은 카드 강조를 추가하고, 낙관 채점 뒤 드릴·스파링을 공용 peak-end 요약으로 마무리했다. 공유 SRS 갱신이 세션을 재마운트하던 경합은 세션 snapshot으로 분리했다.
U4 팩별 진행과 조용한 마일스톤
업데이트2026년 7월 14일 · 3 분
Practice 팩 카드에 U1 mastery 진행을 연결하고, 사용자별 로컬 receipt로 마스터·스트릭·첫 스파링 축하를 한 번만 큐잉한다.
공용 API 요청에 15초 deadline 추가
트러블슈팅2026년 7월 13일 · 1 분
공용 fetch client에 일반 요청 15초, FormData 60초 상한을 추가하고 caller 취소와 timeout을 구분했다. 공용 client를 우회하는 legacy 음성 업로드는 이번 범위에 포함되지 않는다.
신규 사용자를 무료 학습 루프로 안내
사용성 회고2026년 7월 13일 · 4 분
빈 Home의 일반 import CTA를 shadow, 기기 내 dictation, review로 이어지는 우선순위 경로로 바꾸고 초대 전용 AI 범위를 분명히 했다.
모바일 저장 실패를 보이게 — 카드 진행 전에 서버 응답 기다리기
기술 회고2026년 7월 13일 · 3 분
clip·영상 삭제·review·drill grade의 조용한 mutation 실패를 localized Alert로 드러내고, grade는 저장 성공 뒤에만 다음 카드로 진행하도록 바꿨다.
모바일 다크 입력창과 눌림 피드백 — 터치가 보이게
기술 회고2026년 7월 13일 · 3 분
지정된 12개 입력창을 theme 색으로 바꾸고, Track D 허용 화면의 Pressable 49개에 공용 opacity와 Android ripple을 연결했다.
R3 Home 우선순위와 Practice 팩 그리드
사용성 회고2026년 7월 13일 · 4 분
Home 첫 화면을 streak와 one-tap Sparring 중심으로 재구성하고, Practice를 팩별 count·due 메타가 있는 균일 그리드로 바꿨다.
R1 의미 토큰과 공용 Pressable 기반
기술 회고2026년 7월 13일 · 3 분
기존 색 값을 primary/live/mint 의미로 고정하고, light/dark 공용 Card·Chip·PrimaryButton·TalkButton과 press/focus helper를 추가했다.
Review·Me·drill 채점 표면을 공용 토큰으로 통일
사용성 회고2026년 7월 13일 · 2 분
Review 카드 flip과 semantic grade 색을 추가하고, Me 섹션과 두 drill runner를 R1 Card·Chip·Button 계약으로 통일했다.
저장 세션을 네이티브 splash 아래에서 조용히 검증
트러블슈팅2026년 7월 13일 · 2 분
SecureStore 실패와 만료 토큰이 부팅을 멈추거나 Home을 잠깐 노출하지 않도록, 저장 토큰을 bounded me 요청으로 먼저 확인하고 401 fallback을 합쳤다.
스파링 연결에 끝을 만들다 — 12초 상한, 취소, 복구 문구
사용성 회고2026년 7월 13일 · 2 분
Realtime mint 또는 WebView 연결이 끝나지 않던 경로에 12초 상한과 Cancel을 추가했다. 내부 오류 원문은 로그에만 남기고 화면은 재시도 가능한 한국어·영어 안내로 통일했다.
AI 403을 초대제 안내와 무료 연습 복귀로 바꾸기
사용성 회고2026년 7월 13일 · 2 분
AI 사용 권한이 없는 계정에서 서버 403 원문 대신 초대제 설명을 보여 주고, 비용 없는 Practice 흐름으로 돌아갈 수 있게 했다.
스파링에 신규 학습 표현 모드 — 낮은 box부터 입으로 꺼내기
업데이트2026년 7월 13일 · 2 분
복습일이 아직 오지 않은 낮은-box 표현을 우선 주입하고, 부족한 자리는 아직 본 적 없는 카드로 채우는 스파링 모드를 추가했다. 기존 오늘 복습 선택은 바꾸지 않았다.
기본 동사 스파링을 한 축으로 — 동사별·파티클·전치사별 하위 범위
업데이트2026년 7월 13일 · 3 분
기본 동사 스파링에서 put/take/get 같은 동사 그룹이나 to/in/with/up/out 같은 파티클·전치사 하나만 골라 대화 타깃을 만들 수 있게 했다. 선택 범위 밖 카드는 보충하지 않는다.
2026년 7월 6일 주2 개 · 1 업데이트 · 1 사용성 회고
대화가 어색한 이유 — AI에게 '목록 다 쓰지 마라'를 가르치다
사용성 회고2026년 7월 12일 · 2 분
스파링이 오늘 복습 카드를 섞어 6개 주입하니 운동·코딩 표현이 한 대화에 뒤섞여 어색했다. '전부 소화'가 아니라 '한 흐름에 붙는 부분집합만 쓰고 나머지는 이월'로 프롬프트를 바꿔 자연스러움을 회복. 근본 해결(주제 스코프 세션)은 별도 설계 중.
주제 골라 스파링 — 팩 하나로 대화를 한 결로 묶기 (③)
업데이트2026년 7월 12일 · 2 분
스파링 타깃이 주제를 넘나들어 어색하던 문제(① 프롬프트로 완화)를 근본적으로: 세션 시작에 주제(팩)를 고르면 타깃 6개가 그 도메인 안에서만 뽑힌다. 클라이언트 전용 변경이라 백엔드·런타임 비용 0.
2026년 6월 1일 주8 개 · 6 기술 회고 · 2 사용성 회고
모바일 사용성 갈아엎기 — 영상을 버리지 않고 '내 영상'으로 쌓는 TubeShad식 플로우
사용성 회고2026년 6월 7일 · 4 분
import하면 클립 하나 만들고 영상을 버리던 모바일 플로우를, 영상을 저장해 언제든 다시 열어 전체 자막을 보고 아무 줄이나 쉐도잉하는 구조로 바꿨다. 근본 원인은 '내가 import한 영상' 개념의 부재였다.
자막 캐시를 1000만 명 규모로 — 전역 공유 캐시의 두 빈틈(경합 insert + 무한 재긁기)
기술 회고2026년 6월 7일 · 5 분
1000만 명까지 가는 설계에서 '실시간 스크래핑'은 비용·차단·법적으로 불가능하다. 답은 캐싱. videos 테이블은 이미 전역 공유 캐시였지만, 동시 insert 경합과 UNAVAILABLE 무한 재긁기 두 빈틈이 스케일에서 터진다.
쉐도잉 루프 고도화 — 한 줄 반복 / A-B 구간 / 자동 다음 줄을 폴링 하나로
사용성 회고2026년 6월 7일 · 3 분
import한 영상 화면에서 TubeShad급 쉐도잉을 만들었다. 한 줄 반복, A-B 구간 반복, 줄별 N회 자동 진행 + 0.5~1.5배속을, 단일 폴링 루프에 모드 분기로 통합했다.
우리가 서울에 올린 AWS 49개 — 실제 배포된 리소스 전체 딥 정리
기술 회고2026년 6월 4일 · 10 분
terraform state에 실제로 박힌 49개 리소스(+ 데이터소스 7개)를 층별로 하나도 안 빼고 푼다. 각 리소스가 무엇이고 왜 있고 어디에 연결되는지.
YouTube 자막 가져오기 7가지 방안 — 하나도 모르는 사람을 위한 딥 해설
기술 회고2026년 6월 4일 · 9 분
레지덴셜 프록시, CORS 프록시, 브라우저 확장, 큐레이션... 용어를 하나도 모를 때 각 방안이 정확히 무엇이고 왜 되거나 안 되는지를 바닥부터 푼다.
YouTube 자막 — 검증된 방법 랭킹 + 폴백 런북 (이게 깨지면 어디로 가나)
기술 회고2026년 6월 4일 · 5 분
7개 방법을 적대적으로 실측 검증한 뒤 'bgutil POToken 사이드카'를 채택. 깨졌을 때 갈아탈 폴백 순서와 각 대안의 트레이드오프를 런북으로 남긴다.
YouTube 자막의 진짜 벽은 IP가 아니라 POToken이었다 — 그리고 WebView 우회
기술 회고2026년 6월 4일 · 4 분
AWS 배포 후 깨진 YouTube import. '데이터센터 IP 차단'인 줄 알았는데, 가정 IP에서도 raw fetch는 빈 응답이었다. 진짜 벽은 POToken(BotGuard)이었고, yt-dlp만 통과한다. 폰은 yt-dlp를 못 돌리니 — 진짜 브라우저인 WebView로 우회한다.
YouTube 자막을 어디서 가져올 것인가 — 서버 vs 클라이언트, 웹 vs 모바일 (CORS의 벽)
기술 회고2026년 6월 4일 · 7 분
AWS 배포 직후 YouTube 임포트가 깨졌다. yt-dlp가 데이터센터 IP에서 차단된 것. '그럼 클라이언트에서 가져오면?'을 끝까지 파보니, 웹앱은 CORS 때문에 사실상 불가능하고 모바일 네이티브만 가능하다는 비대칭이 드러났다. 7개 방안을 차원별로 비교하고 Mimi의 전략을 정한다.