정확하지만 표정이 없는 화면
Utakoto 의 학습 화면은 단어장 복습, 듣고 고르기, 오늘의 단어 세 갈래입니다. 채점도 복습 주기도 제대로 돌아가는데, 10월 26일에 intent 를 쓰면서 제가 문제로 적은 건 다른 쪽이었습니다. 맞혀도 틀려도 돌아오는 것이 색 바뀐 테두리와 토스트 한 줄뿐이라, 하루 몇 분씩 반복하는 화면이 "검사지"처럼 느껴진다는 것. 그리고 앱에 얼굴이 없다는 것입니다. 다시 열고 싶게 만드는 정서적인 고리가 비어 있었습니다.
스토어에 올라가 있는 단어 학습 화면입니다. 카세트 캐릭터가 들어가기 전의 모습이고, 이번 변경은 이 플래시카드와 4지선다 자리에 들어갑니다.
출발은 다른 앱의 화면이었습니다. 깔끔하고 귀엽다고 느낀 레퍼런스가 있었는데, 거기 나오는 "큰 동그라미에 눈" 캐릭터를 그대로 가져오면 베낀 것처럼 보입니다. 그래서 intent 에 조건을 하나 붙였습니다. 캐릭터는 우타코토라는 이름, 노래(uta)와 말(koto)에서 나와야 한다. 그렇게 고른 게 카세트테이프입니다. 코랄 몸통에 크림색 라벨 UTA·KOTO, 두 릴이 눈이 됩니다. 시안 몇 벌을 거쳐 이 모양으로 정했고, 같은 날 작업에 들어갔습니다.
표정은 기본·기뻐요·듣는 중·앗 네 가지입니다. 틀렸을 때의 앗에는 "혼내지 않는다"고 따로 적어 두었습니다.
무엇을 바꿨나: 표정은 순수 함수, 움직임은 상수
답이 표정이 되는 규칙은 react-native 를 import 하지 않는 파일 하나에 모았습니다. jest 에서 그대로 돌리기 위해서입니다.
export type CassetteMood = 'idle' | 'happy' | 'listening' | 'oops';
/** 4지선다·플래시카드 공통: quality ≥ 3 이 맞힘(QUALITY_QUIZ_CORRECT·QUALITY_KNOWN). */
export function moodForQuality(quality: number): CassetteMood {
return quality >= 3 ? 'happy' : 'oops';
}
/** 반응 표정이 기본으로 돌아가기까지 — 결과 토스트(1.6초)와 같이 사라진다. */
export const REACTION_MS = 1600;
움직임은 처음부터 지어내지 않고 kinetics 프리셋 이름과 값을 그대로 옮겼습니다. 가만히 있을 때 둥실 뜨는 Float Bob(4초, 위로 12px), 맞히면 출렁이는 Jelly Wobble(0.8초), 틀리면 좌우로 흔들리는 Error Shake(0.45초), 듣는 중의 Orbit Spinner(한 바퀴 0.8초), 처음 나타날 때 아래에서 솟는 Toast Stack, 말풍선이 튀어나오는 Tag Input pop. 눈 깜빡임만은 맞는 프리셋이 없어서 직접 정했습니다. 닫는 데 70ms, 뜨는 데 90ms, 2.8~4.6초 간격입니다.
/** Jelly Wobble — 0.8s cubic-bezier(0.34,1.56,0.64,1). [scaleX, scaleY, 구간 ms]. */
export const JELLY_BEZIER = [0.34, 1.56, 0.64, 1] as const;
export const JELLY_WOBBLE: readonly (readonly [number, number, number])[] = [
[1.14, 0.86, 200],
[0.9, 1.1, 160],
[1.05, 0.95, 160],
[1, 1, 280],
];
/** Error Shake(shake-x) — 0.45s cubic-bezier(.36,.07,.19,.97), 10% 마다 translateX. */
export const ERROR_SHAKE_X = [-1, 2, -4, 4, -4, 4, -4, 2, -1, 0] as const;
export const ERROR_SHAKE_STEP_MS = 45;
테스트는 이 값들이 프리셋 길이(800ms·450ms)로 끝나는지를 봅니다. 다만 실제 컴포넌트에서는 흔들기 값에 2를 곱한 뒤 캐릭터 폭에 비례해 줄여서 씁니다. 왜 두 배인지는 기록에 이유가 남아 있지 않습니다.
화면에 놓는 방식은 자리마다 다릅니다. 단어장 플래시카드에서는 카드 테두리를 걷어 "한 화면 한 단어"로 비우고, 폭 220 짜리 카세트를 답 버튼 바 뒤 바닥에 깔아 위쪽 100pt 정도만 보이게 했습니다. 카세트를 카드가 아니라 세션 화면이 들고 있는 게 요점입니다. 카드가 바뀌어도 카세트는 그대로라 반응이 끊기지 않습니다. 결과 문구("「…」 학습중 · 완료까지 N번")도 화면 위 토스트 대신 카세트 위 말풍선으로 옮겼고, 문구와 1.6초는 토스트와 같습니다.
{/* 카세트가 있으면 결과는 위 토스트 대신 카세트의 말풍선으로 */}
{dock ? null : toast}
{modeSheet}
{dock ? (
<CassetteDock
mood={mascot.mood}
pulse={mascot.pulse}
bubble={feedback ? { id: feedback.id, text: feedbackCopy(feedback).text } : null}
/>
) : null}
pulse 는 연달아 맞혀 표정 값이 happy 그대로일 때도 매번 출렁이게 하려는 카운터입니다. 듣고 고르기에서는 처음 3초 동안 릴이 돌고, 고른 뒤 정답·오답에 따라 반응합니다.
왜 이렇게 했나
가장 먼저 정한 건 그리는 방법입니다. 이 앱에는 react-native-svg 가 설치돼 있지 않습니다. 넣으면 네이티브 의존이 늘어 스토어 빌드를 새로 내야 합니다. 그래서 시안 좌표(300×190)를 그대로 쓰되 View 와 Reanimated 만으로 몸통·라벨·창·릴·나사를 하나씩 겹쳐 그렸고, 그 덕에 이번 변경은 OTA 로 낼 수 있었습니다.
두 번째는 답 흐름을 건드리지 않는다는 제약입니다. 채점·복습 주기·포인트·계측 경로는 그대로 두고, 캐릭터는 결과를 보여 주기만 합니다. 플래시카드는 다음 카드로 바로 넘어가고 반응은 세션 화면이 이어서 그립니다. 캐릭터 때문에 한 문제라도 늦어지면 안 된다는 판단이었습니다.
세 번째는 어디서 빼느냐입니다. 창 높이가 700pt 보다 낮은 화면(iPhone SE 같은)에서는 플래시카드의 카세트를 아예 그리지 않고 토스트도 원래 자리로 돌립니다. 귀여운 것보다 뜻과 예문이 들어갈 자리가 먼저입니다. 퀴즈처럼 문항마다 새로 그려지는 자리에서는 등장 움직임(Toast Stack)을 끕니다. 문제마다 아래에서 솟으면 산만하기 때문입니다. 접근성 설정의 "동작 줄이기"에서는 둥실·출렁·흔들·회전·깜빡임을 모두 끄고 투명도만 남겼고, 캐릭터는 장식이라 스크린리더에서 숨깁니다.
범위는 도중에 넓어졌습니다. 처음엔 세 화면만 넣었는데, 첫 미션 퀴즈를 열어 보니 캐릭터가 한 번도 안 나왔습니다. 그래서 4지선다 퀴즈와 가사 빈칸, 코스 단계(첫 미션 포함), 이어서 히라가나·가타카나·한자 퀴즈와 쓰기 드릴까지 넣었습니다. 쓰기 드릴에서는 획을 틀릴 때마다 앗, 글자를 완성하면 기뻐요입니다. 반대로 세션 결과의 도장 화면은 그대로 두었습니다. 습자지 같은 분위기로 끝맺는 기존 설계를 지키기 위해서였습니다.
확인한 것과 남은 것
plan 에 남은 검증은 tsc 0, jest 714개 통과입니다. 시뮬레이터 확인은 이틀에 나눠 적혀 있습니다. 10월 26일에는 iPhone 17 시뮬레이터에서 표정 4종과 움직임, 뜻이 열린 플래시카드에서 예문과 카세트가 겹치지 않는지, 다크·라이트를, 실제 세션으로 플래시카드·퀴즈와 한자·히라가나 쓰기를 봤습니다. 27일에는 남은 오늘의 단어, 가나·한자 4지선다, Error Shake(30fps 녹화에서 약 0.45초 좌우), 동작 줄이기를 켠 상태를 확인했습니다.
이 과정에서 두 가지를 같이 고쳤습니다. 하나는 딥링크로 가나·한자 세션이 첫 화면으로 열렸을 때, ✕ 를 눌러도 router.back() 이 경고만 내고 아무 데도 가지 않던 것입니다.
function leaveSession() {
if (router.canGoBack()) router.back();
else router.replace('/(tabs)/learn' as never);
}
다른 하나는 오늘의 단어입니다(e2ccef9). 채점하면 뜻 카드가 펼쳐지는데, 그러면 iPhone 17 에서도 말풍선·카세트·"이 곡 보기 →" 버튼이 화면 아래로 밀려났습니다. 반응을 만들어 놓고 정작 그 반응이 안 보이는 상태였습니다. 공용 Screen 컴포넌트가 안쪽 ScrollView 의 ref 를 받을 수 있게 열고, 채점 뒤 끝까지 내렸습니다.
// 채점하면 뜻 카드가 펼쳐져 카세트 반응·다음 행동이 화면 아래로 밀린다(iPhone 17 에서도) — 끝까지 내려 보여 준다.
useEffect(() => {
if (!isGraded) return;
const t = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: !reduceMotion }), 80);
return () => clearTimeout(t);
}, [isGraded, reduceMotion]);
동작 줄이기를 켠 사람에게는 애니메이션 없이 바로 내립니다. 그리고 10월 27일, main 의 이 커밋으로 OTA c91fe0e4(runtime 1.11.0)를 냈습니다.
남은 건 세 가지입니다. 판정은 출시 2주 뒤인 11월 9일로 잡았습니다. 세션 완료 비율과 오늘의 단어 채점 비율이 10월 12~25일 기준보다 떨어지지 않는지를 기존 이벤트로 봅니다. intent 의 Open questions 도 그대로입니다. 캐릭터 이름은 아직 없고, 정해지면 말풍선이나 온보딩에 쓸 수 있습니다. 실기기에서 둥실(-12pt)과 릴 회전(0.8초)이 산만하지 않은지는 시뮬레이터로만 봤고 실기기 기록은 아직 없습니다.
Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.