앱 이름은 링링입니다. Xcode 프로젝트·스킴·워크스페이스는
RingRing, 번들 ID는com.coby.ringring, App Group은group.com.coby.ringring입니다. SwiftPM 모듈 이름(RingRingCore등)은 대량 변경을 피해 그대로 둡니다.
좋아하는 일본 아티스트 — 여자·남자 아이돌부터 밴드까지 — 의 노래 가사로 일본어를 공부하는 iPhone / iPad 앱.
노래 한 곡이 학습 한 세트입니다. 가사 줄을 누르면 그 줄이 단어로 쪼개지고, 담은 단어는 실제로 나온 가사를 예문 삼아 간격 반복 복습으로 넘어갑니다.
tuist install # 외부 Swift 패키지는 없지만 Tuist가 먼저 요구합니다
tuist generateXcode 26.6 이상(27 포함), iOS 26 SDK, Swift 6.3 기준입니다.
계정이 없습니다. 권한 요청은 복습 알림을 켤 때의 알림 권한과 가사 카드를 사진 앱에 저장할 때의 사진 추가 권한뿐이고, 둘 다 그 기능을 쓸 때에만 묻습니다. 곡 정보는 Apple의 공개 iTunes 검색 API에서, 영상은 YouTube에서 오므로 시뮬레이터에서도 그대로 동작합니다.
하나만 준비하면 됩니다 — 곡의 영상을 찾는 YouTube Data API 키입니다. Google Cloud Console에서 YouTube Data API v3를 켜고 API 키를 만든 뒤, 프로젝트를 생성할 때 환경 변수로 넘깁니다.
TUIST_YOUTUBE_API_KEY=여기에_키 tuist generate --no-open매번 적기 싫으면 저장소 루트의 .env에 둡니다. .mise.toml이 이 파일을 읽으므로
mise가 활성화된 셸(mise activate)에서는 tuist generate와 Scripts/setup.sh가
그대로 받습니다. .env는 커밋되지 않습니다.
# .env
TUIST_YOUTUBE_API_KEY=여기에_키.env가 없을 때의 동작은 mise 버전에 따라 다를 수 있으니, 키가 없어도 빈 파일
하나는 만들어 두는 편이 안전합니다(touch .env). CI도 그렇게 합니다.
키 없이 생성하면 앱은 그대로 돌지만 영상을 찾을 수 없어 모든 곡을 30초 미리듣기로 재생합니다.
할당량은 기기마다가 아니라 API 키 하나에 걸립니다. 출시 빌드에는 키가 하나 박혀 나가므로, 앱을 설치한 모든 사람이 하루 10,000단위를 나눠 씁니다. 검색 한 번이 100단위라 전체 사용자를 합쳐 하루 검색 약 100회입니다(채널 업로드 목록은 50개에 1단위라 훨씬 쌉니다). 한 번 찾은 영상은 기기에 남아 다시 묻지 않지만, 사용자가 늘면 할당량이 먼저 바닥납니다 — 바닥나면 그날 남은 시간 동안 새 곡은 30초 미리듣기로 재생됩니다.
앱에 들어간 키는 앱 바이너리에서 꺼낼 수 있으므로, Cloud Console에서 반드시 제한을 걸어 둡니다.
- API 제한: YouTube Data API v3만 허용
- 애플리케이션 제한: iOS 앱, 번들 ID
com.coby.ringring(iOS 제한은 요청 헤더로 판별하는 것이라 완전한 방어는 아닙니다). 번들 ID를 바꾸면 여기도 함께 바꿔야 합니다 — 안 맞으면 Google이 403으로 거부하고, 앱은 모든 곡을 미리듣기로 틉니다. 앱은 이 경우를 할당량 소진과 구분해video로그에 fault로 남깁니다. - 할당량 알림: Cloud Monitoring에서 일일 사용량 경보를 걸어 두기
근본적인 해결은 키를 서버에 두고 검색 결과를 캐시해 모든 사용자가 나눠 쓰는 프록시입니다. 아직 없고, 남은 일로 둡니다.
아이돌 ────┬─ 나의 최애 — 그룹 화면의 ♡로 정한 그룹, 왕관 스티커 + 멤버 컬러 탭
├─ 이어서 공부하기 (진도 표시)
└─ 그룹 카드 — 아티스트 사진 위에 그룹 색 (KAWAII LAB. / iLiFE! / =LOVE)
↓ 그룹 선택
그룹의 곡 목록
↓ 곡 선택
내 노래 ───── 내가 연 곡 전부, 최신순. 진도와 함께. 길게 눌러 목록에서 빼기
플레이어 ──┬─ 싱크 가사 + 후리가나 → 줄 탭 → 단어 카드 · 가사 카드 공유
├─ 가사 싱크 조절 (곡별로 저장)
├─ 한 줄 구간 반복
├─ 가사 전체화면 / 가사 크기 4단계
└─ 단어 일괄 담기 · 이 곡의 단어
↓ 내리면
미니플레이어 (탭 바 위) — ⏮ ▶ ⏭, 열었던 목록 안에서 이전/다음 곡
단어장 ────┬─ CSV 내보내기 (Anki·스프레드시트)
├─ 통계 + JLPT 분포 차트
└─ JLPT 필터 + 정렬 → 단어 상세
연습 ──────┬─ 복습 카드 (FSRS)
├─ 어려운 단어 집중 · 랜덤 믹스
├─ 빈칸 채우기 · 뜻 보고 쓰기 · 사지선다
└─ 듣고 받아쓰기
이 앱은 명단에 있는 일본 아티스트 96팀만 다룹니다 — 여자 아이돌, 남자 아이돌, 밴드·아티스트, 보컬로이드, 애니송. 검색이 없는 것이 결함이 아니라 정의입니다 — 좋아하는 그룹이 첫 화면에 있고 두 번 눌러 곡에 닿는 것이 강점입니다. 홈 상단의 장르 칩(전체·여자 아이돌·남자 아이돌·밴드·보컬로이드·애니송)으로 먼저 좁히고, 그 안에서 소속·계열별 섹션으로 나뉩니다.
| 섹션 | 그룹 |
|---|---|
| KAWAII LAB. | FRUITS ZIPPER · CANDY TUNE · SWEET STEADY · CUTIE STREET · MORE STAR |
| =LOVE · ≠ME · ≒JOY | =LOVE · ≠ME · ≒JOY |
| 坂道シリーズ | 乃木坂46 · 櫻坂46 · 日向坂46 · 僕が見たかった青空 |
| 48グループ | AKB48 · SKE48 · NMB48 · HKT48 |
| Hello! Project | モーニング娘。 · アンジュルム · Juice=Juice · つばきファクトリー · BEYOOOOONDS |
| STARDUST | ももいろクローバーZ · 私立恵比寿中学 · 超ときめき♡宣伝部 · いぎなり東北産 |
| 그 밖의 인기 그룹 | iLiFE! · 高嶺のなでしこ · #ババババンビ · ME:I · NiziU · 新しい学校のリーダーズ |
| 남자 아이돌 | Snow Man · SixTONES · なにわ男子 · King & Prince · Hey! Say! JUMP · BE:FIRST · JO1 · INI · Da-iCE · timelesz · Travis Japan · WEST. · Aぇ! group · M!LK · OWV · DXTEEN · MAZZEL · THE RAMPAGE · GENERATIONS · FANTASTICS |
| 밴드 · 아티스트 | YOASOBI · Official髭男dism · Mrs. GREEN APPLE · King Gnu · Vaundy · back number · RADWIMPS · ONE OK ROCK · Ado · あいみょん · ヨルシカ · ずっと真夜中でいいのに。 · SEKAI NO OWARI · 緑黄色社会 · Saucy Dog · マカロニえんぴつ · 結束バンド · スピッツ · BUMP OF CHICKEN · サカナクション · 米津玄師 |
| 보컬로이드 | 初音ミク · DECO*27 · ピノキオピー · Kikuo · wowaka · Neru · Eve · まふまふ · りぶ |
| 애니송 | LiSA · Aimer · ClariS · fripSide · OxT · FLOW · GRANRODEO · JAM Project · 藍井エイル · 春奈るな · 高橋洋子 · 宮野真守 · 水樹奈々 · angela |
카탈로그 ID·공식 YouTube 채널·일본어 별칭은 IdolGroup.all에 있습니다. ID는 이름으로
추측하지 않고 iTunes 조회로, 채널은 YouTube 검색으로 확인한 값입니다. 아이돌 계열엔
공식 채널을 넣어 영상 검색 비용을 아끼고, 남자 아이돌·밴드는 채널 없이 YouTube 검색으로
찾습니다. 곡의 아티스트
표기가 그룹 이름(또는 4자 이상의 별칭)을 포함하면 그 그룹의 곡으로 봅니다 —
「=LOVE & ≠ME」, 「モーニング娘。'17」, 「椎名林檎と新しい学校のリーダーズ」도 제 그룹을
찾습니다.
그룹을 고를 때 가사 확보율을 함께 봤습니다(2026-09 표본: 그룹당 10곡을 LRCLIB에서 찾음). ≒JOY·AKB48 9/10, 乃木坂46·≠ME·NMB48·NiziU 7/10처럼 높은 그룹이 있고, 신생 그룹과 Hello! Project는 낮습니다 — 이런 곡은 가사 붙여넣기로 공부합니다.
그룹이 서른 개를 넘으면서 첫 실행에 그룹 사진을 받을 때 조회 서비스의 분당 한도(약 20회)에 걸립니다. 거절된 그룹은 35초 뒤에 다시 묻고(최대 세 번), 받은 페이지는 기기에 남아 다음 실행부터는 바로 뜹니다.
곡 목록은 iTunes 조회(lookup?id=…&entity=song)에서 옵니다 — 새 싱글이 나오면 앱
업데이트 없이 뜹니다. 이름이 아니라 ID로 찾는 이유는 동명이인입니다. 조회는 모든
녹음을 돌려주므로 Instrumental·off vocal은 빼고, 같은 곡의 여러 판은 하나로 접어
새 것부터 늘어놓습니다.
그룹 카드의 사진은 아티스트의 공개 Apple Music 페이지 og:image에서 읽고(조회 API에는
아티스트 사진이 없습니다), 실패하면 가장 새 앨범 커버가 대신 섭니다. 페이지와 사진
주소는 기기에 남겨 다음 실행의 첫 프레임부터 뜹니다(GroupPageCache, 하루 지나면
뒤에서 갱신). 사진이 아직 안 왔으면 그룹 색 그라데이션이 카드 자체라 깜빡이지 않습니다.
목록과 그룹 화면은 밝고 화사하게, 플레이어와 가사는 다크를 유지합니다. 가사는 어두운 배경에서 읽기가 훨씬 편하고, 이 앱에서 가장 긴 시간이 가사를 읽는 시간입니다.
두 톤은 팔레트 하나가 색상 모드를 따라 바뀌는 것으로 구현됩니다.
JustTheme.Ink와 Surface가 다크·라이트 값을 각각 갖고 그리는 자리에서 해석되므로,
목록 화면은 루트에 .environment(\.colorScheme, .light) 한 줄을 붙이는 것으로
뒤집히고 그 안의 모든 라벨이 따라옵니다. 화면마다 수십 개의 색 참조를 고치는
대신 한 곳을 고쳤습니다.
앱 아이콘은 하트 안의 「歌」에 반짝임 셋입니다(Scripts/make-icon.py). 한자만으로는
일본어를 말하고 아이돌을 말하지 못했고, 하트만으로는 그 반대였습니다. 말풍선 꼬리도
시도했다가 뺐습니다 — 하트는 이미 뾰족한 끝이 있어서 꼬리를 붙이면 끝이 둘이 되고,
작은 크기에서 렌더링 오류처럼 읽혔습니다.
RingRing (앱)
├── RingRingCore 도메인 모델, SwiftData 스키마, FSRS 스케줄러
├── RingRingDesign 팔레트 추출, 메시 배경, 후리가나 조판
├── RingRingMusic iTunes 카탈로그 + YouTube 내장 플레이어
├── RingRingLyrics LRCLIB 클라이언트, LRC 파서
└── RingRingSensei 형태소 분석, 읽기, 사전·문법·번역으로 줄 해석
| 출처 | 비고 | |
|---|---|---|
| 곡·앨범·아트워크 | Apple iTunes 검색 API | 키·계정 불필요 |
| 영상 찾기 | YouTube Data API v3 | API 키 필요. 공식 채널 목록(1단위/50개)을 먼저, 검색(100단위)은 그다음. 결과는 기기에 저장 |
| 재생 | YouTube 내장 플레이어 (MV) | 영상이 없거나 재생 불가면 30초 미리듣기 |
| 가사 | LRCLIB | 무인증, 싱크 LRC |
| 해석 | 번들 사전 + 문법 패턴 + Apple 번역 | 한 곡에 몇 초. 문장 번역만 언어팩 필요 |
| 단어 뜻·읽기 | 번들 사전 | 아래 「번들 사전」 참고 |
| JLPT 등급 | Jonathan Waller의 JLPT 목록 (CC BY) | tanos.co.uk, open-anki-jlpt-decks 경유 |
| 품사 | JMdict (EDRDG, CC BY-SA 4.0) | jmdict-simplified |
| 한자 음훈 | 번들 (1,591자) | 한국어 학습자용 |
두 가지가 어긋날 수 있고 원인이 다릅니다.
미리듣기에서는 가사가 따라가지 않습니다. 영상을 찾지 못한 곡은 30초 클립이고, 그 클립은 곡 중간에서 잘려 나옵니다(미리듣기 첫 1초의 평균 음량이 클립 전체 평균과 0.3~3.5dB 차이뿐 - 곡의 시작이라면 무음에서 출발합니다). Apple은 어디서 잘랐는지 공개하지 않으므로 클립 시간을 곡 시간으로 환산할 방법이 없습니다. 그래서 환산하지 않고, 강조·자동 스크롤·줄 탭 이동·구간 반복을 모두 멈추고 이유를 한 줄로 표시합니다. 가사는 그대로 읽고 눌러 뜻을 볼 수 있습니다.
전곡 재생에서 몇 초 어긋나는 것은 시트가 그렇게 적힌 것입니다. 시트를 만든
사람이 한 줄의 시작을 어디로 잡았는지(첫 박, 첫 음절, 그 앞의 숨)는 데이터에
없고, 대조할 두 번째 출처도 없어서 자동으로는 찾을 수 없습니다. 대신 곡별로
StudySong.lyricsOffset에 저장되는 보정값을 사용자가 정합니다 - 다이얼로
0.1/0.5초씩 밀거나, 지금 들리는 줄을 눌러 한 번에 맞춥니다. 앱이 짚고 있는
줄이 아니라 실제로 들리는 줄을 눌러야 합니다(짚고 있는 줄은 정의상 틀린 줄입니다).
±5초를 넘기면 받지 않습니다 - 그건 타이밍이 아니라 다른 편집본이라는 뜻입니다.
iTunes도 YouTube도 가사를 제공하지 않습니다. 싱크 가사는 LRCLIB에서 받습니다.
가사 저작권은 열린 문제입니다. LRCLIB는 사용자들이 올린 가사를 모은 커뮤니티 데이터베이스이고, 올라온 가사의 이용 허락을 LRCLIB가 받아 둔 것이 아닙니다. 이 앱은 가사를 저장소에 담지 않고 실행 시 받아 기기에만 두지만, 화면에 가사 전문을 표시하는 것은 사실입니다. 출처 표기(LRCLIB)를 화면에 두는지, 권리자 요청 시 내리는 절차를 어떻게 할지는 출시 전에 정해 둘 일입니다 — 스토어 심사나 권리자 신고로 문제가 될 수 있는 부분입니다.
LRCLIB에 처음의 일곱 그룹 곡은 절반쯤 있습니다(2026-09 실측: 216곡 중 99곡). 나머지는 아직 아무도 올리지 않은 신곡·수록곡이라, 찾는 방법을 바꿔도 나오지 않습니다. 있는 것을 놓치지 않는 데 집중합니다.
-
정확 조회(아티스트·제목·앨범·길이) → 구조 검색 → 자유 검색, 표기를 바꾸며 반복합니다. 카탈로그 표기 그대로, 괄호 꼬리를 뗀 제목, 첫 아티스트만, 부제·판 표기를 뗀 제목 (「〜…〜」, 「-TV size-」, 「- Single Version」), 그룹의 일본어 표기 별칭 (
IdolGroup.aliases: フルーツジッパー, イコラブ …), 마지막으로 제목만. 제목만 찾을 때는 길이 ±10초와 가나 비율이 다른 곡을 걸러 줍니다(실측 +4%).카탈로그 표기를 먼저 혼자 묻고, 나머지 표기는 둘씩 동시에 묻습니다. 답은 여전히 표기 순서대로 받습니다 — 뒤 표기의 답은 앞 표기들이 모두 빈손으로 돌아온 뒤에만 씁니다. 하나씩 묻던 때는 LRCLIB에 없는 곡에서 「가사 찾는 중」이 4~8초 걸렸고, 지금은 2초 안팎입니다(
Report/LyricsTimingReport.swift). 한꺼번에 다 묻자 무료 커뮤니티 서버가 가끔 503으로 답해서 둘로 묶었습니다. 첫 표기를 혼자 묻는 이유는 대부분 거기서 찾고, 서버가 내려가 있으면 더 묻기 전에 알 수 있어서입니다. -
길이가 맞는 가사가 없고 다른 판의 일본어 가사(라이브의 원곡, TV 사이즈의 완곡)만 있으면 시간을 떼고 글로만 씁니다 — 줄이 어긋나게 따라가는 것보다, 따라가지 않는 편이 낫고, 없는 것보다는 있는 편이 낫습니다.
-
없다는 답은 사흘 동안 기억합니다(
LyricsMisses). 없는 곡 하나에 요청이 최대 아홉 번 가는데, 전에는 그 곡을 열 때마다 처음부터 다시 물었습니다. LRCLIB는 한 주소가 많이 물으면 429를 주는 무료 서버입니다(실측: 분당 수십 번에서 429·503). 사흘 안에 다시 열면 묻지 않고 바로 「못 찾음」 카드를 보여주고, 카드의 「다시 찾기」는 언제나 묻습니다. 서버가 바빴거나 연결이 끊겨서 실패한 것은 기억하지 않습니다. -
그래도 없으면 직접 붙여넣기. 가사를 못 찾은 카드에서 가사를 붙여 넣으면 그 가사로 공부합니다.
[00:12.34]시간이 있으면 노래를 따라가고, 글만 있으면 줄을 눌러 공부합니다.
번역본을 원문으로 착각하지 않도록 가나가 있는 줄이 절반을 넘는 가사만 받습니다.
곡은 그룹의 뮤직비디오로 재생됩니다. 계정이 필요 없는 유일한 전곡 경로이고, 가사 싱크가 곡 전체를 따라갑니다.
내장 플레이어의 약관을 지킵니다. 영상은 재생 중 화면에 있어야 하므로 플레이어를 접으면 영상이 멈추고, 미니플레이어의 재생 버튼은 플레이어를 다시 열어 이어 봅니다. 가사 전체화면에서도 영상은 위에 남습니다. 오디오만 재생하거나 백그라운드로 보내는 일은 없습니다. 플레이어 자체 컨트롤은 꺼 두고 앱의 재생 버튼·슬라이더가 대신합니다 (한 세트면 충분합니다).
영상은 세 단계로 찾습니다.
-
그룹의 공식 채널. 명단(
IdolGroup.youtubeChannels)에 각 그룹의 채널, 뮤직비디오를 올리는 레이블 채널(KAWAII LAB.), 레이블 음원의 자동 생성 「Topic」 채널을 실어 둡니다. 명단의 모든 아티스트(95팀)에 채널이 있습니다. 전에는 64팀이 비어 있어 그 곡들은 매번 검색(곡당 100단위)으로 갔고, 모든 설치가 나눠 쓰는 하루 할당량이 금방 바닥나 있는 MV도 "못 찾음"이 됐습니다. 채널은 YouTube 검색 화면의 공식 아티스트 배지 (VERIFIED_ARTIST)와 「- トピック」 채널로 골라 손으로 확인했습니다. 곡을 열면 그 채널들의 업로드 목록을 최근 2000개까지 받아(50개에 1단위) 제목으로 맞춥니다. 처음엔 200개만 봤는데, 채널마다 쇼츠·비하인드가 매일 올라와 옛 MV가 밀려 났습니다(실측: 乃木坂46 40곡 중 11곡 → 1000개로 36곡, AKB48 17 → 37). 목록은 기기에 남고 하루 지나면 새로 올라온 페이지만 받아 앞에 붙이므로(보통 한 번의 요청) 신곡의 영상이 나오는 주에 들어옵니다.MV가 아닌 영상에 지지 않게 점수를 매깁니다. 같은 길이로, 그룹 채널에, 제목에 「MV」까지 단 영상이 많습니다 — 안무 영상 「Choreography Video -MV edition-」, 응원법 「Cheering Guide / コール動画」, 메이킹·비하인드·다큐, 조회수 기념 쇼츠. 이것들은 크게 깎아(-8) 그룹 채널 가산점으로도 MV를 못 넘게 합니다. 【MV】·MUSiC CLiP 태그는 접기 전 원문에서 읽고(접으면 괄호가 사라져 이 가산점이 죽어 있었습니다), 감점 단어도 접어서 비교합니다(「コール動画」는 접으면 「コル動画」). 감점은 곡 제목 밖의 글자에만 겁니다 — 「いえなかったことば〜ありがとう〜」가 제 MV를 감사 인사 클립으로 깎던 일이 있었습니다. 【MV】가 붙은 콜 영상(iLiFE!는 MV를 그렇게 올립니다)은 조금만 깎아, 깨끗한 MV보다는 뒤지만 음원보다는 앞섭니다.
길이로 한 번 더 거릅니다. 곡의 절반(최소 30초)보다 짧거나 2.5배보다 긴 영상은 버립니다 — 쇼츠·예고·메들리·공연 전체입니다. 남은 것 중 곡 길이(0.6~1.6배)에 맞는 것을 앞에 둡니다. 채널에 맞는 길이가 하나도 없으면 검색으로 넘어갑니다(전에는 그래도 남은 것을 틀어 14초짜리 쇼츠가 곡으로 재생됐습니다).
제목은 폭(전각·반각)과 장음을 접어서 비교합니다 — 카탈로그는 「LOVEマシ-ン」처럼 하이픈을, 영상은 「LOVEマシーン」처럼 장음을 씁니다. 판 표기(「 -ZZ ver.-」, 「(2024 ver.)」)·멤버 크레딧(「/亀井絵里・…」)·「・・・(略)」로 줄인 긴 제목은 떼어낸 핵심 제목으로도 찾습니다. 일본어는 두 글자(「走れ」)도 제목으로 쓰고, 로마자는 세 글자 미만을 버립니다. 그보다 짧은 제목(「虹」, 「W」)은 영상 제목이 『虹』처럼 괄호로 인용할 때만 맞춥니다.
-
검색. 채널에 없는 곡만
"<아티스트> <곡명> MV"로 검색합니다(100단위). 분류는 가리지 않습니다 — 아이돌 MV는 「음악」이 아니라 「엔터테인먼트」로 올라간 것이 많습니다. 결과 중 제목에 곡명이 있는 것에서 MV·Music Video와 그룹 채널·레이블 채널·Topic 채널을 높이고, Dance Practice·cover·teaser·자막 재업로드를 낮춥니다. 아무것도 없으면 「MV」를 빼고 한 번 더 찾아 라이브·리릭 영상도 받습니다. -
30초 클립. 영상이 하나도 없을 때만.
찾은 영상과 나머지 후보는 기기에 남습니다. 찾는 방법이 나아지면(currentMatcher)
예전에 "못 찾음"으로 남긴 곡은 다시 찾고(YouTube가 임베드를 막은 곡은 그대로), 규칙 3부터는
예전에 고른 영상도 다시 고릅니다 — 기기에 남은 채널 목록으로 고르므로 길이 조회 한 번이면
됩니다. 임베드가 막힌 영상(오류 101/150)과 15초 안에
재생이 시작되지 않는 영상(오류 없이 버퍼링만 도는 경우가 있습니다)은 다음 후보로
넘어가고, 후보가 다 떨어지면 클립으로 갑니다.
| 그룹 | 공식 채널 | 함께 보는 채널 |
|---|---|---|
| FRUITS ZIPPER | UCQG8tNnV4hKetLhMb4MopHQ | KAWAII LAB., Topic |
| CANDY TUNE | UCU0PgOXf0lxzVxN2TLzMJkw | KAWAII LAB., Topic |
| SWEET STEADY | UC5s_kUbxX3P1q6lmDgygD-w | KAWAII LAB., Topic |
| CUTIE STREET | UCEz-AFAg3EUKsxraad1puQA | KAWAII LAB., Topic |
| MORE STAR | UCBkLxz038AbxBA8CMw6o9oA | KAWAII LAB., Topic |
| iLiFE! | UChVflUz2J_jaaqYjXqvpmQA | Topic |
| =LOVE | UCv7VutirxDn3RWIJXI68n_A | — |
KAWAII LAB. 채널은 UCW8Q9LBGGBgK6a-u0C0h95A입니다.
매칭 보고서(Report/VideoMatchReport.swift)가 표본 그룹 14팀의 곡마다 실제로
고르는 영상과 다음 후보를 점수·길이와 함께 찍습니다. 업로드 목록과 길이는 디스크에
캐시하므로 점수 규칙을 바꾼 뒤 다시 돌려도 할당량이 들지 않습니다.
TEST_RUNNER_RINGRING_EVAL_DIR=/tmp/ringring-eval xcodebuild -workspace RingRing.xcworkspace \
-scheme RingRingReport -destination 'platform=iOS Simulator,name=iPhone 17' test \
-only-testing:'RingRingReport/VideoMatchReportSuite/writeVideoMatchReport()'TEST_RUNNER_RINGRING_EVAL_GROUPS(카탈로그 id나 이름, 쉼표 구분)로 그룹을 바꿉니다. 이번
개선의 실측: 413곡 중 149곡의 선택이 바뀌었고, 그중 132곡이 이제 【MV】·MUSiC CLiP·
Official Music Video입니다(전에는 안무 영상·응원법·메이킹·음원·쇼츠).
MV는 음원과 시작이 다를 수 있습니다(인트로 연출). 가사가 어긋나면 플레이어의 싱크 조정으로 맞춥니다.
곡을 열면 그 곡에 딸린 모든 데이터가 기기에 남습니다.
| 데이터 | 위치 | 비고 |
|---|---|---|
| 가사 (LRC) | StudySong.lyricsData |
오프라인 재열람 가능 |
| 줄별 해석 전체 | StudySong.analysisData |
번역 + 단어 + 문법. 곡당 한 번만 만든다 |
| 난이도 히스토그램 | StudySong.levelCounts |
목록에서 해석을 디코드하지 않아도 되도록 비정규화 |
| 단어·복습 일정 | VocabEntry / ReviewState |
|
| 앨범아트 | Caches/Artwork | URL 해시 파일명. 시스템이 회수 가능 |
곡을 처음 열면 전곡 해석이 자동으로 시작됩니다. 기다리는 화면은 없습니다 — 가사가 먼저 뜨고, 해석은 그 뒤에서 한 줄씩 채워지며 가사 위에 진행이 보입니다. 진행분은 채워지는 대로 기록되므로 중간에 닫아도 버려지지 않고, 다시 열면 남은 줄부터 이어서 합니다. 후렴처럼 같은 줄이 반복되면 한 번만 해석하고 나머지 줄에 복사합니다.
자동 해석 시점은 설정에서 고릅니다 — 항상 / 저전력 모드가 아닐 때(기본) / 안 함. 기본값이 저전력 모드를 따르는 이유는, 사용자가 이미 시스템에 에너지 사용 방침을 말해 두었기 때문입니다. 학습 앱이 그걸 앞질러 판단할 이유가 없습니다.
한 줄마다 세 가지를 답합니다 — 단어가 무슨 뜻인지, 어떤 문법을 쓰는지, 줄이 무슨 말인지.
| 답하는 것 | ||
|---|---|---|
| 단어 | 번들 사전 | 오프라인 |
| 문법 | 패턴 매칭 | 오프라인 |
| 문장 | Apple 번역 (직역) | 언어팩 필요 |
한 곡이 몇 초면 끝나고, 어느 기기에서나 같은 답이 나옵니다. 예전에는 온디바이스 언어 모델로 문장을 자연스럽게 옮기는 방식도 있었지만 걷어냈습니다. 최신 기기에서만 돌았고, 한 곡에 몇 분이 걸렸고, 그 답도 결국 같은 사전으로 교정해야 했습니다.
영어 구절은 번역만 합니다. J-POP은 후렴이 통째로 영어인 경우가 흔합니다. 그
줄도 노래의 일부라 뜻은 알려주지만, 거기서 외울 일본어 단어는 없습니다 —
baby에 가나 읽기를 지어 붙인 카드는 복습 일정에 들어가서 계속 돌아옵니다.
가나나 한자가 하나라도 있으면 일본어로 보고(비율이 아닙니다 — 영어 줄에 섞인
일본어 한 단어도 배울 값이 있습니다), 섞인 줄에서는 일본어 단어만 남깁니다.
문법은 찾습니다. 일본어 문법 패턴은 유한하고 잘 정리된 집합이고, 어떤 줄이 「〜てしまう」를 포함하는지 아닌지는 판단이 아니라 사실이며, 그게 무슨 뜻인지는 곡에 따라 달라지지 않습니다. 줄에 맞춘 설명이 없는 대신, 항상 작동하고 항상 같은 말을 합니다. 긴 패턴은 자기가 포함한 짧은 패턴을 가립니다 — 「なきゃ」는 「ない」에 대한 수업이 아닙니다. 한 줄에 최대 3개인데, 짧은 가사 한 줄에 노트 여덟 개는 수업이 아니라 소음입니다.
언어팩이 없으면 번역 칸만 비워 둡니다. 단어와 문법은 그대로 보여주고, 그 줄은 끝난 것으로 치지 않습니다. 설정에서 언어팩을 받고 돌아오면 비어 있던 줄만 다시 채웁니다.
Translation 프레임워크는 뷰 밖에서 init(installedSource:target:)만 쓸 수
있어서, 언어팩 다운로드는 설정 화면의 버튼(.translationTask)에서 시작합니다 —
시스템 프롬프트가 뜨는 일이라 해석 도중이 아니라 사용자가 연 화면에 있어야
맞습니다. 스위치로 끌 수 있고 기본은 켬입니다.
Report/SenseiReport.swift는 테스트가 아니라 측정 장치입니다. 가사 모양의
고정 문장에 실제 해석기를 돌려, 단어·문법·번역이 붙은 줄의 비율, 문법 패턴별
출현 수, 아무것도 못 붙인 일본어 줄을 찍습니다. 실제 가사로 사전 커버리지도 잽니다.
품질은 단언할 수 없지만 이 숫자들은 셀 수 있고, 사전·문법 목록·분절기를 바꿨을 때
두 실행을 비교하는 것이 판단 방법입니다.
별도 타깃(RingRingReport)이고 앱이 호스트입니다. 그래서 실기기에서 돌릴 수
있습니다 — 시뮬레이터는 번역 언어팩을 받지 못하는 경우가 많고, 보고서는 그때의
언어팩 상태를 함께 찍어 번역 없는 실행을 고장으로 오해하지 않게 합니다.
# 시뮬레이터
xcodebuild -workspace RingRing.xcworkspace -scheme RingRingReport \
-destination 'platform=iOS Simulator,name=iPhone 17' test
# 실기기 (언어팩이 진짜인 곳). 기기 잠금을 풀어야 합니다.
xcodebuild -workspace RingRing.xcworkspace -scheme RingRingReport \
-destination 'platform=iOS,name=Coby' -allowProvisioningUpdates test기본 RingRing 스킴의 테스트는 RingRingTests만 돌립니다. 유닛 테스트는 1초 미만이고
매 변경마다 돌리는 것이라, 그 앞에 앱 실행을 얹지 않기 위해 타깃을 나눴습니다.
커버리지 보고서에 **「가나 표기가 한자 표제어로 잡힌 것」**을 셉니다. 가사가 가나로 쓴 말이 한자 단어로 돌아온 경우인데, 옳을 때도(ゆめ → 夢) 있고 그 반대가 문제였습니다 — 부정 어미 「ない」가 119줄에서 「泣く(울다)」로, 「また」가 「待つ」로, 「いい」가 「言う」로 잡혔습니다. 가나 단어를 활용 되돌리기로 다른 가나로 구부린 뒤 읽기 인덱스에서 한자 동사를 찍어 온 것입니다.
세 가지로 막았습니다. 읽기 인덱스는 먼저 들어온 항목 대신 흔한 뜻(손으로 확인한 것, 그중 쉬운 급)을 고릅니다. 가나로 쓴 말은 같은 가나 표제어가 있으면 그것을, 없으면 손으로 확인한 흔한 한자 단어까지만 받고 수입된 희귀 표기로는 넘어가지 않습니다. 그리고 되돌린 후보 중 어미(た·て·ない·ます·よう)가 붙어 있던 것만 읽기로 한자 동사에 닿을 수 있고, 끝 글자 하나를 바꿔 만든 후보는 표기로만 찾습니다 — 어미는 동사가 있었다는 증거고, 끝 글자는 증거가 아닙니다.
측정: 오매칭 227 → 25, 남은 25건은 전부 옳은 매칭입니다. 커버리지는 66% → 63%로 내려갔는데, 빠진 3점이 가짜 적중이었습니다.
Modules/RingRingSensei/Resources/seed-dictionary.json은 생성물입니다.
python3 Scripts/build-dictionary.py층을 순서대로 쌓아 만듭니다 — 뒤 층이 앞 층을 덮지 않습니다.
Scripts/curated.json— 손으로 확인한 항목. 품사·JLPT 등급까지 신뢰합니다.- 가져온 뜻·읽기 — 옆 프로젝트
jlpt-app의 일한 단어 데이터(~6.8천). 원본의lv는 난이도가 아니라 교재 소속 태그라(전 행이n1) 버립니다. 옆 프로젝트가 없으면 직전 빌드의 항목을 그대로 유지합니다. Scripts/corrections.json— 가져온 뜻·읽기의 수정. JMdict 영어 뜻과 대조해 한자어 거짓짝(配信이 「배신」), 다른 읽기의 뜻(角 かく에 「뿔」), 읽기 오타를 고칩니다.Scripts/additions/*.json— 새 단어. 사전에 없던 JLPT 단어와, 어느 목록에도 없지만 아이돌 가사가 기대는 어휘(외래어·의성의태어·팬 용어).Scripts/data/jlpt-levels.tsv— JLPT 등급. 등급이 없는 행에 채웁니다.Scripts/data/jmdict-pos.tsv— JMdict 품사. 품사가 없는 행에 채웁니다.Scripts/extract-jmdict.py로 다시 뽑습니다(JMdict 원본 필요, 저장소엔 없음).
결과가 이전보다 10% 넘게 줄면 쓰기를 거부합니다 — 옆 프로젝트가 사라진 채 빌드하면 어휘 대부분을 잃기 때문입니다. 지금은 약 12,000단어이고, 그중 등급이 붙은 것이 약 7,900개, 품사가 붙은 것이 약 11,600개입니다.
가사 한 줄
├─ CFStringTokenizer 문맥 인식 분절 + 읽기
├─ NLTagger 사전형(lemma) · 품사
├─ 번들 사전 표제어 · 읽기 · 뜻 · 품사 · JLPT 등급
│ (가사에 쓰인 표기를 읽기보다 우선)
├─ 문법 패턴 「〜てしまう」 같은 패턴을 줄에서 찾기
└─ Apple 번역 문장 직역 (언어팩이 있을 때)
번역으로 받아들이는 조건은 기계적으로 검사합니다. 한글이 인용부호 밖 글자의 다수여야 하고, 인용부호 밖에 일본어가 남아 있으면 받지 않습니다. 인용은 괜찮습니다 — 원문이 「さよなら」를 인용하면 번역이 그대로 인용하는 것이 옳고, 영어 후렴을 인용하는 한국어 문장도 옳습니다. 「한글이 하나라도 있으면」으로 두었을 때 「가 fence across from each other.」가 통과했습니다. 영어로 볼 때는 일본어가 남지 않고 로마자가 있으면 받습니다.
밝은 화면(그룹·목록·단어장·연습)은 아이돌 굿즈처럼 꾸밉니다 — 흰 테두리에
인쇄된 핑크 그림자를 단 스티커 카드, 도트 무늬와 반짝이 배경, 마스킹 테이프를
붙인 안내 메모, 누르면 쑥 들어가는 캔디 버튼, 형광펜 밑줄의 섹션 제목, 줄무늬
캔디 진행 바. 전부 RingRingDesign의 공용 부품(Kitsch.swift, JustTheme)에
있어서 화면 코드는 justCard()·justSurface()·.justPrimary만 씁니다.
어두운 플레이어는 무대라서 담백하게 둡니다. 가사를 읽는 곳이라 무늬나 반짝이를 깔지 않고, 지금 부르는 줄에만 펜라이트 같은 핑크 빛을 줍니다. 장식은 VoiceOver에서 숨기고, 동작 줄이기가 켜져 있으면 반짝이도 멈춥니다.
스티커 콘셉트 하나로 움직임도 맞춥니다(KitschMotion.swift). 화면마다 제각각
튀지 않고, 손가락에 같은 방식으로 답하게 하려는 것입니다.
- 누르기 — 스티커 카드(
.buttonStyle(.kitschPress))는 누르는 동안 자기 그림자 위로 눌려 내려앉고, 떼면 살짝 튕기며 올라옵니다. 그림자는 제자리에 두고 카드만 움직여야 "줄어든다"가 아니라 "눌렸다"로 읽힙니다. 가벼운 햅틱이 함께 옵니다. - 등장 — 목록과 그리드는 스티커를 한 장씩 붙이듯 조금씩 비뚤게 튀어 들어옵니다
(
kitschEntrance). 첫 화면분 12장까지만 순서대로, 그 아래는 기다리지 않습니다. - 홀로 반짝임 — 그룹 카드는 트레이딩 카드 호일처럼 몇 초마다 빛 띠가 지나갑니다
(
holoSheen). 카드마다 차례를 두어 한꺼번에 번쩍이지 않습니다. - 반짝이 터짐 — 단어를 담을 때, 퀴즈를 맞힐 때, 퀴즈·복습을 끝낼 때 하트와 별이
튀어나와 떨어집니다(
sparkleBurst). 끝낼 때는 두 번. - 흔들림 — 틀리거나 거의 맞힌 답은 고개를 젓듯 좌우로 흔들립니다(
kitschShake). - 복습 카드 — 뜻을 확인하면 플래시카드처럼 뒤집히고, 등급을 매기면 왼쪽으로
튕겨 나가며 다음 카드가 아래에서 붙습니다(
stickerFlick). - 가사 — 부르는 줄은 옅은 분홍 바탕과 펜라이트 같은 빛으로만 표시하고, 바탕은 그 자리에서 번집니다. 줄 사이를 미끄러지던 강조 막대(matchedGeometryEffect)는 LazyVStack 안에서 SwiftUI 레이아웃을 죽게 해서 걷어냈습니다. 이미 부른 줄은 한 단계 더 흐립니다. 반복 중 표시는 숨 쉬듯 깜빡입니다.
- 기다림 — 가사를 찾는 동안 음표 세 개가 차례로 튀고(
BouncingNotes), 해석 진행은 줄무늬가 흐르는 캔디 막대입니다. 숫자는 굴러서 바뀝니다. - 실행 화면 — 홈의 크림 분홍에 아이콘을 올려, 분홍 앱이 검은 화면에서 시작하지 않게 합니다.
동작 줄이기가 켜져 있으면 전부 멈추거나 투명도 변화만 남습니다. 가사 글자에는 어떤 효과도 겹치지 않습니다.
모든 본문 텍스트가 Dynamic Type을 따르고, 설정을 바꾸면 앱을 다시 열지 않아도
바로 반영됩니다. Font.system(size:)는 시스템 글자 크기 설정을 무시하고
Font.custom(_:size:relativeTo:)는 폰트 파일 이름을 요구하므로, 글꼴 토큰은
Font 값이 아니라 JustFontStyle(크기·굵기·기준 텍스트 스타일)이라는 설명이고,
.font(_:)가 그리는 자리에서 @ScaledMetric으로 크기를 정합니다. 디자인된
포인트 크기를 유지하면서, 실행 중의 설정 변경과 상위 뷰의 .dynamicTypeSize
제한까지 따릅니다. 후리가나 자리의 높이도 같은 방식으로 함께 커집니다.
고정 크기로 남긴 것은 SF Symbol 아이콘과 연속일수 링의 숫자뿐입니다. 둘 다 글자와 함께 커지면 안 되는 고정 지오메트리 안에 있습니다.
가사는 여기에 앱 자체의 4단계 조절이 곱해집니다. 시스템 설정과 앱 설정이 같은 텍스트에 함께 작용하므로, 접근성 크기에서는 가사 크기를 낮춰 쓰면 됩니다.
just://review와 just://words로 화면을 지정할 수 있습니다. 복습 알림과 위젯이
이 링크를 씁니다 — 알림이 앱을 열되 사용자가 마지막에 보던 화면으로 데려가면
그 알림은 실패한 것입니다.
알림은 onOpenURL로 오지 않으므로 NotificationRouter가 대신 받습니다. 앱
시작 시점에 등록하는 이유는, 콜드 스타트에서 탭한 알림이 뷰가 만들어지기 전에
전달되기 때문입니다.
홈 화면 위젯은 앱의 SwiftData를 열지 않습니다. 앱이 App Group 컨테이너에 작은 스냅샷(JSON)을 쓰고 위젯은 그걸 읽습니다 — 위젯이 앱 데이터베이스를 여는 것은 스키마 변경을 따라다녀야 하고 쓰기도 못 하며, 숫자 세 개를 읽자고 기존 사용자 데이터를 마이그레이션할 이유도 없습니다.
App Group group.com.coby.ringring가 필요합니다. 자동 서명이 등록하지 못하면
developer.apple.com > Identifiers > App Groups에서 만들고, App ID의
App Groups 기능에서 연결해 주세요.
옆 프로젝트 mana와 같은 구성입니다.
bash Scripts/setup.sh # tuist install + tuist generate
bundle exec fastlane test # 시뮬레이터 테스트
bundle exec fastlane beta # TestFlight 업로드Tuist 버전은 .mise.toml에 고정합니다. 서명은 자동이고 팀 id는 매니페스트에
있으므로 tuist generate만으로 기기 빌드가 됩니다.
TestFlight는 App Store Connect API 키로 클라우드 서명합니다 — 인증서를 손으로
갱신할 일이 없습니다. 인증 플래그는 xcargs 한 곳에만 둡니다(gym이 archive와
export 양쪽에 그대로 넘기므로 export_xcargs로 중복하면 xcodebuild가 거부합니다).
v* 태그를 밀면 .github/workflows/deploy.yml이 돕니다. 먼저 test.yml의 테스트를
돌리고, 통과해야 업로드합니다. 필요한 GitHub 시크릿은 다음과 같습니다.
| 시크릿 | 쓰이는 곳 |
|---|---|
APPSTORE_KEY_ID / APPSTORE_ISSUER_ID / APPSTORE_PRIVATE_KEY |
App Store Connect API 키 (서명·업로드) |
YOUTUBE_API_KEY |
TUIST_YOUTUBE_API_KEY — 없으면 모든 곡이 미리듣기 |
fastlane beta는 YouTube 키가 비어 있거나, 출시 번들 ID로 보낸 요청을 Google이 거부하면
빌드 전에 멈춥니다(채널 조회 한 번, 할당량 1). 어느 쪽이든 빌드는 성공하고 앱만 조용히
망가지기 때문입니다. 할당량이 바닥난 날과 네트워크 오류는 경고만 하고 넘어갑니다.
버전은 MARKETING_VERSION과 CURRENT_PROJECT_VERSION이 정합니다. Info.plist에
값을 박아두면 fastlane이 넘기는 빌드 번호가 무시되어 업로드가 매번 충돌합니다.
- 마케팅 버전:
TUIST_MARKETING_VERSION환경 변수에서 오고, 없으면1.0.0입니다. 태그 배포는 태그에서 가져옵니다(v1.2.0→1.2.0). 수동 실행은 워크플로 입력marketing_version으로, 로컬에서는bundle exec fastlane beta version:1.2.0으로 넘깁니다. - 빌드 번호: 워크플로 입력
build_number를 준 경우에만 그 값을 쓰고, 아니면 fastlane이 TestFlight의 최신 빌드 번호에 1을 더합니다.
CI는 macos-26 러너와 Xcode 26.6으로 고정합니다. 위의 기준 버전을 올리면 두
워크플로의 xcode-version도 함께 올려 주세요. 액션은 메이저 태그(@v4, @v2)로
고정되어 있으니, 공급망 위험을 더 줄이려면 커밋 SHA로 바꿉니다.
App/Resources/PrivacyInfo.xcprivacy는 필수입니다. 앱이 직접 하는 통신은 iTunes
조회, YouTube(Data API와 내장 플레이어), LRCLIB이고 어느 쪽에도 사용자 식별자를
넘기지 않습니다. 서드파티 SDK·계정·분석 도구가 없으므로 수집 항목
(NSPrivacyCollectedDataTypes)은 비어 있고, 추적(NSPrivacyTracking)은 거짓입니다.
필수 사유 API는 UserDefaults(CA92.1)입니다. RingRingCore·RingRingSensei·RingRingDesign은
동적 프레임워크라 앱 매니페스트가 그 안의 호출을 덮지 못하므로, 각자
Modules/<모듈>/Resources/PrivacyInfo.xcprivacy를 갖습니다(ITMS-91053). 다른
모듈에서 UserDefaults 같은 필수 사유 API를 쓰기 시작하면 그 모듈에도 매니페스트를
추가하고 Project.swift에서 hasResources: true로 연결해야 합니다.
라벨은 매니페스트에서 자동으로 채워지지 않습니다. 출시 전, 그리고 통신하는 곳이 바뀔 때마다 확인합니다.
- Xcode Organizer에서 아카이브의 Privacy Report를 뽑아 수집 항목이 없는지 확인
- 「데이터를 수집하지 않음」으로 답하기 — 가사·단어·복습 기록은 기기에만 남고, 계정·연락처·사용자 콘텐츠를 받지 않음
- 「LRCLIB에 공유」를 켠 사람의 붙여넣은 가사는 공개 데이터베이스인 LRCLIB로 올라감 — 앱이 받지는 않지만, 「기타 사용자 콘텐츠」로 답할지 출시 전에 판단
- 영상은 YouTube 내장 플레이어로 재생하므로, 개인정보 처리방침에 YouTube 이용약관과 Google 개인정보처리방침을 안내
- 예전 빌드에서 광고 SDK 때문에 입력한 라벨이 남아 있으면 지우기
xcodebuild -workspace RingRing.xcworkspace -scheme RingRing \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest' test순수 로직만 덮습니다 — 파싱, 스케줄링, 문자열 처리. 지금까지 실제로 나온 버그가 전부 그 영역이었고, 기기·계정 없이 검증할 수 있는 부분도 거기까지입니다. 테스트는 250개가 넘고, 그중 여러 개는 실제로 겪은 회귀를 그대로 고정해 둔 것입니다: CRLF 가사 분할, 번역본 오선택, 동음이의어 표기 뒤바뀜.
- YouTube 검색 할당량은 키 하나를 모든 설치가 나눠 씁니다. 키를 서버에 두는 프록시(검색 결과 공유 캐시)가 필요합니다.
MIT. 다만 번들 데이터의 출처는 구분해서 봐 주세요.
Scripts/curated.json,Modules/RingRingSensei/Resources/kanji-ko.json(한자 1,591자 음훈), 그리고seed-dictionary.json의 뜻·읽기 대부분은 이 저장소 소유자의 다른 프로젝트에서 가져온 데이터입니다.Scripts/data/jlpt-levels.tsv(JLPT 등급)는 Jonathan Waller의 JLPT 단어 목록(tanos.co.uk, CC BY)에서, open-anki-jlpt-decks를 거쳐 가져왔습니다.Scripts/data/jmdict-pos.tsv(품사)는 JMdict에서 뽑았습니다. JMdict는 Electronic Dictionary Research and Development Group의 자료이고 CC BY-SA 4.0으로 배포됩니다. 이 파생 표도 같은 라이선스를 따릅니다.- 가사는 저장소에 포함되지 않습니다. 실행 시 LRCLIB에서 받아 기기에만 저장됩니다.
- 곡 정보와 아트워크는 Apple의 iTunes 검색 API에서, 영상은 YouTube에서 옵니다. 각각 Apple Media Services 이용약관과 YouTube API Services 약관이 적용됩니다.