이 레포가 무엇을 하는 물건인가.
Echo Loop는 듣기 → 정밀히 듣기 → 따라 말하기 → 내 말로 다시 말하기 → 간격 복습을 하나의 자동 진행 루프로 엮습니다. 사용자가 매번 "오늘 뭘, 몇 번, 언제 복습하지?"를 결정하지 않아도 되게 만드는 것이 이 앱의 본질입니다.
대부분의 영어 학습은 자료가 모자라서 실패하지 않습니다. 오늘은 팟캐스트 하나, 내일은 연설 하나 — 자료는 쌓이는데 진짜로 練熟(연습해서 익힘)한 건 거의 없는 상태가 반복됩니다. 들을 땐 이해한 것 같다가 며칠 뒤 잊어버리고, 그 표현을 내 입으로 말하는 건 더더욱 안 됩니다.
Echo Loop는 이 "방법을 몰라서, 그리고 알아도 매 단계 판단하기가 지쳐서 못 하는" 문제를 정조준합니다. 한 단락의 오디오를 골라 시작 버튼을 누르면, 블라인드 리스닝(맨귀로 한 번 통째로 듣기)부터 통관(클리어)까지 매 단계 "지금 뭘 하라"를 직접 띄워 줍니다. 중앙민족대학 외국어대학 양옌(杨艳) 교수(베이징대 영문학 박사)가 방법론을 자문했고, 라이선스는 AGPL-3.0(강한 카피레프트 오픈소스)입니다.
트렌딩 이유 · 경쟁 앱 대비 장점.
기능 하나하나만 보면 다른 앱에도 일부 있습니다. Echo Loop의 진짜 차별점은 "각 단계를 하나로 이어 붙여 자동으로 끝까지 끌고 간다"는 점입니다. 사용자가 방법을 스스로 터득하거나, 반복 횟수를 세거나, 복습 시점을 기억할 필요가 없습니다.
또 하나는 듣기에서 멈추지 않고 '말하기'까지 닫힌 고리로 만든다는 점입니다. 많은 앱이 정밀 듣기·따라 말하기까지는 지원해도, "들은 내용을 내 말로 다시 말하는" 복술(復述, retell) 단계는 빠져 있습니다. 입력(듣기)과 출력(말하기)의 비율까지 통계로 보여 주는 건 그래서 흔치 않습니다.
| 항목 | 흔한 학습앱 | Echo Loop |
|---|---|---|
| 학습 리듬 | 사용자가 직접 통제 | 앱이 단계를 자동 진행 |
| 듣기→말하기 | 대개 듣기·따라읽기까지 | 정밀듣기+따라읽기+복술까지 |
| 긴 문장 | 통째로 제시 | 의미 단위로 끊어 제시 |
| 복습 | 없거나 수동 | 망각 직전 자동 예약 |
| 발음 평가 | 서버형이 많음 | 기기 내(오프라인) 평가 |
| 코드 | 비공개 | 오픈소스(AGPL) |
여기에 에빙하우스 망각곡선에 맞춘 간격 복습이 들어갑니다. 한 자료를 1회 학습 + 7회 복습으로 나누고, 복습 간격을 6시간 → 1일 → 2일 → 4일 → 7일 → 14일 → 28일로 점점 늘립니다. "막 잊으려는 순간"에 다시 만나게 해 장기 기억으로 굳히는 방식입니다.
올바른 방법을 알아도 오래 못 가는 이유 — 막상 실행하면 "오늘 어느 단락을, 몇 번 듣고, 언제 자막을 보고, 어느 문장을 반복하고, 며칠 뒤 복습할지"를 매 순간 스스로 판단해야 합니다. 학습을 시작하기도 전에 결정 부담이 마음의 힘을 다 써버립니다.
"못 알아듣는 것"이 아니라 "다음에 뭘 할지 모르는 것"이 병목이라고 보고, 그 결정들을 앱이 대신 내립니다. 사용자는 앱을 여는 습관만 지키면 되고, 횟수·복습 타이밍·난이도는 시스템이 추진합니다. "한 자료를 제대로 굴리는 게 100개를 흘려듣는 것보다 낫다"가 슬로건입니다.
UI·상태·오디오·음성인식·데이터·인프라 한눈에.
전부 Dart 한 언어로 쓰인 Flutter 앱입니다. 네이티브 코드(Swift·Kotlin·C++)는 5% 미만으로, 주로 iOS/macOS 음성 인식 같은 OS 고유 기능을 호출하는 다리 역할만 합니다.
| 레이어 | 기술 / 버전 | 역할 |
|---|---|---|
| UI 프레임워크 | Flutter + Material 3 | 하나의 코드로 5개 플랫폼 UI 렌더링 |
| 언어/SDK | Dart · Flutter SDK 3.9.2+ | 앱 전체 로직 |
| 상태 관리 | Riverpod 2.6 (코드 생성) | 단방향 데이터 흐름 |
| 오디오 재생 | just_audio · audio_session | 구간 반복·속도 제어 엔진 |
| 음성 합성(TTS) | flutter_tts | 단어·예문 읽어 주기 |
| 음성 인식(ASR) | sherpa_onnx + iOS/macOS 네이티브 | 따라 말하기 채점 (기기 내) |
| 데이터 저장 | Drift(SQLite) · shared_preferences | 학습 진도·수집·캐시 |
| 라우팅 | go_router 17 | 화면 전환·딥링크 |
| 알림 | flutter_local_notifications · timezone | 간격 복습 리마인더 |
| 텍스트 처리 | subtitle(SRT/VTT) · lemmatizerx | 자막 파싱·단어 원형화 |
| 네트워크 | dio · cached_network_image | AI 번역/해설 API·이미지 캐시 |
| 분석/지표 | Firebase Analytics · Umeng · PostHog | 사용 통계(동의 기반) |
| 국제화 | flutter_localizations + ARB | 한국어 관점에선 en/zh 2종 |
| 개발 도구 | build_runner · drift_dev · mocktail | 코드 생성·테스트·정적분석 |
sherpa_onnx로 기기 안에서 처리하므로 인터넷 없이도, 음성을 서버로 보내지 않고도 평가가 됩니다.데이터가 어디서 어디로 흐르는가 — 먼저 전체 그림, 그다음 한 줄기 추적.
이 앱은 계층형(layered) + 단방향 데이터 흐름 구조를 따릅니다. 저장소의 CLAUDE.md(개발 규범 문서)에 원칙이 못 박혀 있는데, 핵심은 "화면은 동작만 던지고, 상태 관리자가 상태를 바꾸고, 화면은 그 상태를 보고 다시 그린다"는 한 방향 사이클입니다. UI에 비즈니스 로직을 섞지 않습니다.
맨 위 Screen은 조립만 합니다. 버튼을 누르면 Provider/Notifier에게 "이거 해"라고 알리고, Provider가 상태를 바꾸면 화면이 그 상태만 보고 다시 그립니다. 실제 오디오 재생·음성 인식·DB 쓰기 같은 무거운 일은 Service / 편성(orchestration) 로직이 도구들을 불러 처리합니다.
해피 패스(정상 경로) 하나만 손으로 따라가 봅니다. 곁가지(에러·예외)는 일부러 생략합니다.
여기서 초보자가 눈여겨볼 "이 프로젝트의 정상적인 모양새"가 둘 있습니다. 첫째, 복잡한 흐름은 화면이 아니라 순수 Dart 클래스로 빼서 테스트 가능하게 만든다는 것(예: SentenceTracker, BookmarkManager). 둘째, 비동기 작업에는 항상 경쟁 상태(race condition) 방어가 들어간다는 것 — 이게 이 레포의 가장 값진 학습 포인트입니다(6장에서 자세히).
어떤 폴더가 무슨 일을 하나.
코드의 본체는 lib/ 안의 Dart 파일들(약 376개 — 비생성 소스 기준 / 432개 이상 — .g.dart 포함)이고, android/ios/macos/windows/linux/는 각 플랫폼의 네이티브 껍데기입니다. 구조는 "페이지 외형이 아니라 역할(기능 도메인)별로 자른다"는 규범을 따릅니다.
| 위치 | 역할 |
|---|---|
lib/providers/ | 상태·비즈니스 로직의 심장. audio_engine(저수준 재생)과 listening_practice(연습 흐름)로 나뉨. |
lib/database/ | Drift 기반 SQLite. daos(읽기/쓰기)·tables(스키마)·migration(버전 업)으로 분리. |
lib/services/ | 저장·자막 파싱·TTS 등 외부 자원을 다루는 서비스층. |
lib/analytics/ | Firebase·Umeng·PostHog 채널 + 동의 관리(consent_manager)·지역 인터셉터. |
ios/Runner/AppDelegate.swift | Dart에서 호출하는 iOS 네이티브 음성인식 핸들러(IOSSpeechPracticeHandler). |
scripts/ | 릴리스 자동화. scripts/lib/build_number.sh가 커밋 수로 versionCode를 생성. |
PLAN.md · TASKS.md | "채팅 기억이 아니라 파일에 결정을 적는다"는 파일 주도 개발의 산물. |
이 레포에서 배울 만한 것 + 어디를 보면 되는지.
이 레포의 가장 큰 매력은 "예쁜 앱"이 아니라 실전 Flutter 앱이 부딪히는 어려운 문제들과 그 해법이 문서로 남아 있다는 점입니다. 특히 비동기 경쟁 상태 3건은 교과서에 안 나오는 실무 함정입니다.
상태 관리는 Riverpod(라이버팟)으로, 그것도 코드 생성 방식을 씁니다. Provider를 손으로 다 적는 대신 어노테이션을 달면 build_runner가 나머지(.g.dart)를 자동으로 만들어 줍니다. 어디를 보면 되나: lib/providers/ 전체와, "Provider는 컴포넌트 개수가 아니라 기능 도메인으로 나눈다"는 CLAUDE.md 규범.
실행 순서는 이렇습니다:
# Provider 코드를 고친 뒤 매번
dart run build_runner build # .g.dart 재생성
Drift(드리프트)는 SQLite를 Dart 타입으로 감싸 주는 ORM입니다. 문자열 SQL을 손으로 쓰다 오타로 깨지는 대신, 컴파일 단계에서 잘못을 잡아 줍니다. tables/(스키마 15개) · daos/(읽기·쓰기 26개) · migration/(버전 업)으로 깔끔히 나뉘어 있어, "스키마를 어떻게 진화시키나"를 배우기 좋은 표본입니다.
따라 말하기 채점에 서버를 쓰지 않고 sherpa_onnx로 기기 안에서 음성을 텍스트로 바꿉니다. ONNX(Open Neural Network Exchange, 표준 신경망 교환 포맷) 런타임으로 학습된 음성 모델을 폰에서 직접 돌리는 방식입니다. 인터넷이 없어도, 목소리를 외부로 보내지 않아도 평가가 됩니다(프라이버시·오프라인 둘 다 이득). iOS/macOS에서는 OS가 가진 SFSpeechRecognizer를 메서드 채널(Method Channel)로 호출해 더 매끄럽게 처리합니다.
CLAUDE.md의 "트러블슈팅" 절에 실제로 겪고 고친 함정 3건이 박제돼 있습니다. 비동기 콜백이 늦게 도착해 새 작업을 망치는, 초급 강의엔 안 나오는 진짜 버그들입니다.
① iOS 음성 인식 — 옛 콜백이 새 세션을 파괴
SFSpeechRecognitionTask.cancel()의 콜백이 비동기라, 새 녹음이 시작된 뒤 옛 콜백이 도착해 새 세션 상태를 망가뜨렸습니다(녹음이 멈추고, 새 인식 작업이 취소됨). 해법은 제너레이션 카운터 — 세션마다 번호를 올리고, 콜백에서 번호가 안 맞으면 그냥 버립니다.
② flutter_tts — 빠른 stop→speak가 완료 신호를 헷갈림
플래시카드를 빠르게 넘길 때 TTS가 3~9밀리초 만에 "끝났다"고 거짓 신고했습니다. stop()의 취소 콜백이 다음 speak()의 완료를 잘못 마감한 탓. 해법은 라이브러리 내부 완료 신호에 기대지 않고 직접 Completer + 시작 핸들러 플래그로 관리하는 것.
③ Android versionCode는 전역 단조 증가여야 함
Android는 버전 이름이 아니라 정수 versionCode만 보고 업/다운그레이드를 판단합니다. minor 버전을 올리며 빌드번호를 1로 되돌렸더니 4→1로 역행해 설치가 막혔습니다. 해법: 태그는 순수 SemVer로, versionCode는 CI가 git rev-list --count(커밋 수)로 자동 생성.
세 사례의 공통점은 모두 "타이밍"입니다. 한 번 천천히 누르면 멀쩡하고, 빠르게 연타하거나 세션을 급히 바꿀 때만 터집니다. 그래서 재현이 어렵고, 로컬에선 안 보이다가 사용자 손에서만 터집니다.
모든 비동기 콜백에 "내가 시작될 때의 신분(번호/토큰/세션ID)"을 들려 보내고, 돌아왔을 때 신분이 유효한지 검사해 옛것은 버리는 습관. Echo Loop는 이 패턴을 음성 인식·TTS·릴리스 번호 세 군데에 일관되게 적용합니다.
오디오가 흐르는 동안 매 프레임 "현재 재생 위치가 몇 번째 문장인지"를 알아내야 합니다. 문장이 수백 개면 처음부터 하나씩 비교하는 건 낭비라, sentence_tracker.dart는 이진 탐색(binary search)으로 단번에 좁힙니다. "사전에서 단어 찾을 때 가운데를 펼쳐 앞/뒤를 가르는" 그 방법입니다 — 500문장도 약 9번 비교로 끝납니다.
학습 과학(망각곡선)을 실제 스케줄러로 옮긴 사례입니다. 통관 시점에 다음 복습 시각(6h→1d→2d→4d→7d→14d→28d)을 계산해 flutter_local_notifications + timezone으로 예약합니다. 시간대(timezone)를 명시적으로 다루는 것도 포인트 — 사용자가 비행기로 시차를 넘어도 복습 시각이 어긋나지 않도록.
개발자로 돌릴 때 / 사용자로 쓸 때 각각 무엇이 필요한가.
순수 소프트웨어 앱이라 특별한 하드웨어는 없지만, 두 입장에서 필요 조건이 다릅니다.
| 구분 | 항목 | 요구사항 |
|---|---|---|
| 개발 | Flutter SDK | 3.9.2 이상 (Dart 3.9.2+) |
| 개발 | 실행 대상 | iOS·Android 시뮬레이터/실기기, 또는 macOS·Windows·Linux 데스크톱 환경 |
| 개발 | 코드 생성 | build_runner(Riverpod·Drift 코드 생성 필수) |
| 사용 | 모바일 | iOS(App Store) / Android(APK 다운로드) |
| 사용 | 데스크톱 | macOS 개발 중 · Windows 계획 · Web 계획 없음 |
| 사용 | 오프라인 | 핵심 학습·기기 내 음성 평가는 오프라인 가능 |
| 사용 | 네트워크 | AI 번역·문장 해설 등 일부 기능은 API 연결 필요 |
빌드 시 API 주소를 주입하는 방식이라(--dart-define), 개발/운영 서버를 빌드 한 줄로 바꿀 수 있습니다:
flutter run -d <ios|android|macos> # 대상 지정 실행
flutter build apk \
--dart-define=API_BASE_URL=https://www.echo-loop.top
난이도별로 손에 익히는 단계.
클론 → 의존성 설치 → 코드 생성 → 실행까지. "복사만 하면 OK"입니다.
git clone https://github.com/echo-loop/Echo-Loop.git
cd Echo-Loop
flutter pub get # 의존성 내려받기
dart run build_runner build # Riverpod·Drift 코드 생성
flutter run -d macos # 또는 ios / android
실행되면 데모 콘텐츠(lib/data/demo_content.dart)로 학습 루프가 어떻게 도는지 먼저 체험해 보세요.
복습 간격(6h→28d)을 정의한 곳을 찾아 한 단계를 짧게(예: 6h→10분) 바꿔 보고, 통관 후 알림이 그 시각에 예약되는지 확인합니다. 학습 과학이 코드의 어느 숫자로 박혀 있는지 감을 잡는 연습입니다.
lib/database/tables/에 컬럼을 하나 더하고, migration/에 버전 업 규칙을 적은 뒤 build_runner를 돌려 봅니다. "스키마를 깨지 않고 진화시키는" 실무 감각을 얻습니다.
음성 인식이나 TTS를 빠르게 stop→start 연타해 경쟁 상태를 일부러 재현해 보고, 제너레이션 카운터(또는 토큰) 검사를 넣어 옛 콜백을 버리도록 고쳐 봅니다. ios/Runner/AppDelegate.swift와 lib/services/tts_service.dart가 정답지입니다.
lib/l10n/에 한국어 ARB 파일을 추가해 메뉴를 우리말로 바꿔 봅니다. 템플릿(app_en.arb)의 키를 그대로 두고 값만 번역 → flutter gen-l10n으로 생성하는 국제화 흐름을 체험합니다.
한 주씩 따라가는 계획.
| 주차 | 주제 | 학습 자료 |
|---|---|---|
| 1주차 | Flutter·Dart 기초, 위젯·레이아웃, 로컬 실행 | flutter.dev 공식 튜토리얼 · 이 레포 클론·실행 |
| 2주차 | Riverpod 상태 관리 + 코드 생성 | riverpod.dev · lib/providers/ 정독 |
| 3주차 | Drift로 SQLite·DAO·마이그레이션 | drift.simonbinder.eu · lib/database/ |
| 4주차 | 오디오(just_audio)·TTS·자막 파싱 | 각 pub.dev 문서 · services/·audio_engine/ |
| 5주차 | 온디바이스 ASR + 메서드 채널(네이티브 연동) | sherpa_onnx 문서 · AppDelegate.swift |
| 6주차 | 비동기 경쟁 상태·테스트·CI 릴리스 | CLAUDE.md 트러블슈팅 · test/ · scripts/ |
본문에 나온 용어 빠른 참조.
| 용어 | 의미 |
|---|---|
| Flutter | 구글의 크로스플랫폼 UI 프레임워크. 한 코드로 iOS·Android·데스크톱·웹 빌드. |
| Dart | Flutter 전용 프로그래밍 언어. 이 레포 코드의 95%. |
| Riverpod | Flutter 상태 관리 라이브러리. 코드 생성으로 보일러플레이트를 줄임. |
| Drift | SQLite를 Dart 타입으로 감싸는 ORM. 컴파일 단계에서 SQL 오류를 잡음. |
| ORM | 객체-관계 매핑. DB 표를 객체로 자동 변환해 주는 다리. |
| ASR | 자동 음성 인식. 말소리 → 텍스트. 따라 말하기 채점의 핵심. |
| sherpa_onnx | ONNX 런타임 기반 온디바이스(기기 내) 음성 인식 라이브러리. |
| ONNX | 표준 신경망 교환 포맷. 학습된 AI 모델을 폰에서 직접 실행. |
| TTS | 음성 합성. 텍스트 → 말소리. 단어·예문 읽어 주기에 사용. |
| Method Channel | Dart ↔ 네이티브(Swift/Kotlin) 코드 간 메시지 통로. |
| 제너레이션 카운터 | 비동기 작업마다 번호를 매겨 옛 콜백을 버리는 경쟁 상태 방어 패턴. |
| 경쟁 상태 | 여러 비동기 작업의 타이밍이 엉켜 생기는 버그. 연타·세션 전환 때 터짐. |
| 이진 탐색 | 정렬된 데이터를 절반씩 좁혀 찾는 알고리즘. 현재 문장 위치 계산에 사용. |
| 에빙하우스 망각곡선 | 시간이 지날수록 기억이 사라지는 곡선. 간격 복습 일정의 근거. |
| 셰도잉 | 원어민 음성을 거의 동시에 따라 말하는 훈련. |
| 복술(復述) | 들은 내용을 자기 말로 다시 말하는 출력 단계. |
| ARB | Application Resource Bundle. Flutter 다국어 번역 파일 포맷. |
| AGPL-3.0 | 강한 카피레프트 오픈소스 라이선스. 네트워크로 제공해도 소스 공개 의무. |
| go_router | Flutter 선언형 라우팅 라이브러리. 화면 전환·딥링크 처리. |
| build_runner | 어노테이션을 보고 .g.dart 같은 코드를 자동 생성하는 도구. |