H-8: 국제화 ② — 글자만 바꾸면 되는 게 아닙니다
목차 65
안녕하세요, 홍순구 튜터입니다.
지난 시간에 우리 앱을 한국어·영어·일본어 세 벌로 만들었는데, 주소 첫 칸이 언어가 됐고 언어별로 화면이 한 벌씩 미리 그려져서 잘 돌아갑니다.
영어로 들어가봤어요. /en/jaehoon을 열었더니 이렇게 나왔습니다.
1 posts · 1240 followers
(2026년 8월 22일 오후 2:32 기준)
영어 화면인데 이상한 게 세 가지 보입니다.
하나, 1 posts입니다. 하나인데 복수형이에요. 둘, 1240입니다. 영어권 사람이 기대하는 건 1,240 이죠. 셋, 시각이 한국어로 찍혀 있습니다. 주소는 /en인데요.
지난 시간에 우리가 옮긴 것은 구조였습니다. 언어를 어디서 알아낼지 정하고, 주소를 다시 짜고, 앱이 죽은 걸 되살렸죠. 글자는 머리말 세 칸과 탭 제목 네 칸, 딱 그만큼만 옮겼습니다.
오늘은 그 나머지를 하는데, 하다 보면 알게 되실 거예요. 번역은 글자를 바꾸는 일이 아닙니다. 숫자에도, 날짜에도, 줄이 바뀌는 방식에도, 심지어 글이 흘러가는 방향에도 언어가 들어 있습니다.
오늘의 여정
1. 번역이 어디까지 따라오나 브라우저까지 따라온다 — 필요 없는 것까지
2. 숫자에 자릿점이 없다 {count} 와 {count, number} 는 다른 것
3. 하나일 때와 여럿일 때 "1 posts" 를 언어가 고치게 한다
4. 날짜는 진짜로 갈린다 하드코딩된 ko-KR 을 걷어낸다
5. 글자 길이가 화면을 깨뜨린다 같은 머리말이 언어마다 다른 높이
6. 줄은 아무 데서나 안 끊긴다 낱말 한가운데가 잘리는 이유
7. 언어를 바꾸는 버튼 보던 화면은 그대로 두고
8. 네 번째 언어, 뒤집힌 화면 아랍어와 좌우
💡 오늘 수업의 핵심 — "언어는 글자에만 들어 있는 게 아닙니다"
🎯 학습 목표
- 서버에서 읽은 번역문이 브라우저까지 얼마나 따라가는지 바이트로 재고, 그 양을 우리가 정합니다.
- 숫자·날짜·복수형처럼 글자가 아닌 것들을 언어에 맡깁니다.
- 글자 길이와 줄바꿈 규칙이 화면을 어떻게 깨뜨리는지 재보고 고칩니다.
- 글이 오른쪽에서 시작하는 언어를 들이고, 좌우가 뒤집혀도 안 깨지는 방식으로 여백을 씁니다.
Step 1: 번역이 어디까지 따라오나
먼저 물어볼 것
지난 시간에 우리가 쓴 도구는 서버에서 번역 파일을 읽어서, 브라우저가 번역 파일을 따로 내려받지 않습니다. 이게 이 도구를 쓰는 큰 이유 중 하나인데, 우리 머리말은 브라우저에서 도는 컴포넌트죠. 지금 어느 주소에 있는지 알아야 하니까요. 그 머리말이 홈, 탐색 같은 글자를 쓰는데, 이 글자는 어떻게 브라우저까지 갈까요? 서버에서만 읽는다면서요.
재봅시다.
서버가 브라우저로 보내는 것
서버가 화면을 그려서 보낼 때, HTML만 보내는 게 아닙니다. 브라우저에서 돌 컴포넌트들이 이어서 일할 수 있도록 자료 꾸러미를 같이 보내요. 그 꾸러미가 HTML 안에 <script>로 들어 있습니다.
그 안을 열어봤습니다.
"messages": {
"Nav": { "brand": "인스타그램 클론", "home": "홈", "explore": "탐색" },
"Meta": { "title": "인스타그램 클론",
"description": "Next.js App Router 로 다시 짓는 인스타그램",
"profileTitle": "@{username} · 인스타그램 클론",
"followers": "팔로워 {count}명" }
}
Nav가 있는 건 이해가 됩니다. 머리말이 브라우저에서 도니까 그 글자가 따라와야 하는데, Meta가 왜 여기 있을까요.
Meta.profileTitle과 Meta.followers는 프로필 화면의 탭 제목을 만들 때 씁니다. 그건 서버에서만 하는 일이에요. 브라우저에서 도는 컴포넌트는 이 칸을 한 글자도 안 읽습니다. 게다가 지금 이건 홈 화면입니다. 프로필 화면도 아니에요.
안 쓰는 칸도 값을 치르는지 확인합니다
의심이 들면 재보는 게 맞습니다. 번역 파일에 아무도 안 쓰는 칸을 스무 개 넣어봤어요. 회원가입 화면에서 쓸 문구라고 치고, 다시 빌드해서 홈 화면을 열어봤습니다.
| 홈 화면 HTML | 꾸러미 속 번역문 | 회원가입 문구가 홈에 | |
|---|---|---|---|
| 원래 | 43,237 B | 307 B | 0건 |
| 안 쓰는 20칸 추가 | 44,840 B | 1,810 B | 20건 |
회원가입 문구 스무 개가 홈 화면 HTML 안에 그대로 실려 나가는데, 홈 화면은 그 글자를 한 개도 안 보여주는데도 그렇습니다.
브라우저로 넘어가는 양이 쓰는 만큼이 아니라 파일 전체라서, 번역을 열심히 할수록 모든 화면이 무거워져요.
넘길 것을 우리가 고릅니다
고치는 방법은 한 줄이라서, 브라우저로 넘길 칸을 적어주면 됩니다.
// apps/web-next/app/[locale]/layout.tsx
// 이 언어의 번역문 전체를 서버에서 읽는다. 여기까지는 서버 안의 일이다.
const messages = await getMessages();
// ...
{/* 브라우저로 «넘겨보낼» 칸을 여기서 고른다.
안 고르면 이 언어의 번역문이 통째로 넘어간다 — 서버만 쓰는 칸까지 같이. */}
<NextIntlClientProvider messages={{ Nav: messages.Nav }}>
messages는 서버에서 전부 읽습니다. 탭 제목을 만들려면 Meta도 필요하기 때문인데, 브라우저로 건네줄 때는 필요한 칸만 골라 건넵니다.
재봤습니다.
| 꾸러미 속 번역문 | Meta가 실려 있나 |
|
|---|---|---|
| 고치기 전 | 307 B | 있음 |
| 고친 뒤 | 88 B | 없음 |
/en은 83 B, /ja는 111 B가 됐어요. 머리말 세 칸과 탭 제목은 세 언어 모두 그대로 나옵니다. 잃은 기능이 없습니다.
한 가지 짚고 싶은 게 있어요. 고친 뒤(88 B)가 아무것도 안 넣었을 때(307 B)보다 작습니다. 우리 앱은 지난 시간부터 이미 Meta를 헛되이 보내고 있었던 거예요. 오늘 발견한 게 아니라 오늘 재본 겁니다.
🙋 학생 질문 — "그럼 처음부터 필요한 칸만 넘기게 만들어두면 되지 않나요?"
좋은 지적인데, 도구 입장에서 생각해보면 그러기가 어려워요.
브라우저에서 도는 컴포넌트가 어떤 칸을 읽을지는 그려봐야 압니다.. 조건에 따라 다른 칸을 읽을 수도 있어서, 도구는 안전한 쪽을 고릅니다. 전부 보내면 적어도 없어서 못 찾는 일은 안 생기니까요.
대신 "내가 아는데"라고 말할 방법을 열어둔 겁니다. 우리가 방금 쓴 게 그거인데, 이 선택에는 대가가 따라옵니다. 오늘 Step 7에서 그 청구서를 받게 되실 거예요.
💡 한 줄 정리
브라우저로 넘어가는 번역문은 «쓰는 만큼» 이 아니라 «파일 전체» 다. 넘길 칸을 직접 고르면 307 B가 88 B로 줄지만, 대신 새 칸이 필요해질 때마다 손으로 추가해야 한다.
Step 2: 숫자에 자릿점이 없다
1240과 1,240
오프닝에서 본 1240부터 봅시다. 번역 파일에는 이렇게 적혀 있었어요.
"followers": "팔로워 {count}명"
{count} 자리에 숫자를 넣어달라는 뜻이고, 넣어주긴 하는데 넣기만 해요.
{count} → 팔로워 1240명
{count, number} → 팔로워 1,240명
{count}는 값을 그대로 끼워 넣습니다. {count, number}라고 적어야 "이건 숫자다, 숫자답게 찍어라"가 됩니다. 한 단어 차이인데 결과가 다르죠.
그런데 예상이 하나 빗나갑니다
여기서 저는 "언어마다 숫자 쓰는 법이 다르니까 언어에 맡겨야 한다"고 말하려 했는데, 세 언어로 찍어봤어요.
1240 → ko 1,240
en 1,240
ja 1,240
셋이 똑같습니다.
한국어·영어·일본어는 천 단위를 쉼표로 끊는 게 같아서, 우리 앱에서 숫자의 문제는 "언어마다 다르다"가 아니라 "자릿점이 아예 없다"였던 겁니다. 세 언어 모두에서 똑같이 틀리고 있었어요.
언어마다 다르다는 건 사실이지만, 그걸 보려면 다른 언어가 필요해요.
1240 → de 1.240 ← 독일어는 점으로 끊고
en 1,240 ← 영어는 쉼표로 끊는다
독일어는 쉼표와 점의 역할이 영어와 정반대입니다. 독일어 사용자가 1,240을 보면 1.24로 읽어요. 우리가 지원하는 언어에 독일어가 없어서 안 보였을 뿐이지, 규칙 자체는 진짜입니다.
이건 기억해둘 만합니다. 우리가 지원하는 언어들이 우연히 비슷하면, 진짜 문제가 안 보입니다.
프로필 화면을 옮깁니다
프로필 화면의 숫자들을 번역 파일로 옮기면서 number를 붙였습니다.
// apps/web-next/messages/ko.json
"Profile": {
"stats": "게시물 {posts, number} · 팔로워 {followers, number}",
"likes": "좋아요 {count, number}"
}
// apps/web-next/messages/en.json
"Profile": {
"stats": "{posts, number} posts · {followers, number} followers",
"likes": "{count, number} likes"
}
화면 쪽은 이렇게 바뀌었어요.
// apps/web-next/app/[locale]/[username]/page.tsx
// 이 화면의 번역 칸을 연다. 서버에서 도는 조각이라 기다렸다 받는다.
const t = await getTranslations('Profile');
// ...
<p className="mb-4 text-sm text-black/60">
{t('stats', { posts: posts.length, followers: profile.followerCount })}
</p>
세 언어 모두 자릿점이 찍힙니다.
/ko 게시물 1 · 팔로워 1,240
/en 1 posts · 1,240 followers
/ja 投稿 1 · フォロワー 1,240
탭 제목도 같이 고쳐졌어요. 지난 시간에 1240으로 나오던 게 1,240이 됐습니다.
🙋 학생 질문 — "화면 코드에서 숫자를 직접 찍으면 안 되나요?"
됩니다. 브라우저에는 숫자를 언어에 맞게 찍어주는 기능이 있어서 코드에서 바로 부를 수도 있지만, 두 가지를 잃습니다.
하나, 언어를 또 손으로 적게 됩니다. 그 기능을 부르려면 어느 언어인지 알려줘야 하는데, 그러면 화면마다 언어 이름이 하나씩 늘어납니다. 바로 다음 단계에서 우리가 걷어낼 것이 정확히 그 모양이에요.
둘, 숫자가 문장 어디에 놓일지를 번역가가 못 정합니다. 코드에서 숫자를 찍어 글자에 이어 붙이면 그 순서를 코드가 정해버려요. 언어에 따라 숫자가 문장 앞에 오기도 하고 뒤에 오기도 하는데요.
번역 파일에 {followers, number}라고 적어두면 둘 다 풀립니다. 어느 언어인지는 요청이 정하고, 숫자가 어디에 놓일지는 번역가가 문장을 쓰면서 정해요.
영어를 다시 보면 1 posts가 아직 그대로인데, 자릿점을 고치니까 이게 더 눈에 띄네요. 다음에서 다룹니다.
💡 한 줄 정리
{count}는 값을 그대로 끼워 넣을 뿐이고, {count, number} 라야 숫자답게 찍힌다. 지원하는 언어들이 우연히 비슷하면 이런 결함은 세 언어에서 똑같이 안 보인다.
Step 3: 하나일 때와 여럿일 때
한국어로 개발하면 안 보이는 문제
1 posts는 영어 문법이 틀린 겁니다. 하나면 1 post여야 해요.
이 문제가 왜 지금까지 안 보였을까요. 한국어에는 이런 구분이 없기 때문입니다. 게시물 1개 든 게시물 5개 든 개는 그대로예요.
언어마다 "갈래"가 몇 개인지 세어봤습니다.
언어 0 1 2 5 11 갈래 수
────────────────────────────────────────────────
ko other other other other other 1
ja other other other other other 1
en other one other other other 2
ar zero one two few many 6
한국어와 일본어는 한 갈래라서, 이 문제가 존재한다는 것 자체를 모르고 지나갑니다. 영어에서 처음 둘로 갈라지고요. 아랍어는 여섯 갈래예요. 아랍어는 오늘 마지막에 만납니다.
갈래를 언어에게 맡깁니다
우리가 if (count === 1) 같은 걸 쓰면 안 됩니다. 그건 영어 규칙이거든요. 아랍어에 가면 안 맞아요.
대신 번역 파일에 갈래를 적어둡니다.
// apps/web-next/messages/en.json
"Profile": {
"stats": "{posts, plural, one {# post} other {# posts}} · {followers, plural, one {# follower} other {# followers}}",
"likes": "{count, plural, one {# like} other {# likes}}"
}
// apps/web-next/messages/ko.json
"Profile": {
"stats": "게시물 {posts, plural, other {#개}} · 팔로워 {followers, plural, other {#명}}",
"likes": "좋아요 {count, plural, other {#}}"
}
한국어는 other 하나만 적습니다. 갈래가 하나니까요. 영어는 one과 other 둘을 적고요.
#는 "여기에 그 숫자"라는 뜻입니다.
자릿점이 안 죽었는지 확인합니다
여기서 한 가지 걱정이 생깁니다. 방금 Step 2에서 {count, number}로 자릿점을 얻었는데, 지금 그걸 {count, plural, ...}로 바꿔 썼거든요. number라는 말이 사라졌어요.
자릿점이 같이 사라진 건 아닐까요?
넣기 전에 재봤습니다.
1 → 1 post
2 → 2 posts
1240 → 1,240 posts
살아 있습니다. #는 복수형 안에서도 숫자답게 찍혀요. Step 2에서 얻은 것을 Step 3이 되팔지 않았습니다.
이런 확인은 습관으로 두시면 좋아요. 방금 얻은 것 위에 새 걸 쌓을 때, 밑에 있던 게 그대로인지 보는 겁니다. 안 보고 넘어가면 조용히 잃습니다.
실제 화면
/en jaehoon 1 post · 1,240 followers
/en minji 1 post · 8,500 followers
1 post는 하나짜리 갈래, 1,240 followers는 여럿 갈래입니다. 한 줄 안에 두 갈래가 같이 나왔네요.
🙋 학생 질문 — "번역가한테 이런 문법을 알려줘야 하나요?"
실무에서 자주 부딪히는 문제입니다.
번역을 맡기는 도구들은 대개 이 문법을 이해합니다. 번역가에게는 "하나일 때" 칸과 "여럿일 때" 칸이 따로 보이고, 그 언어에 필요한 갈래만 나타나요. 한국어 번역가에게는 칸이 하나만 보이고, 아랍어 번역가에게는 여섯 개가 보이는데, 갈래가 몇 개인지는 언어가 정하고 도구가 알고 있습니다. 우리가 결정할 일이 아니에요.
우리가 할 일은 하나입니다. 문장을 통째로 넘기지 말고, 숫자가 들어가는 곳을 표시해서 넘기는 것요.
💡 한 줄 정리
갈래가 몇 개인지는 언어가 정한다 — 한국어 1개, 영어 2개, 아랍어 6개. 우리가 if (count === 1)로 세면 영어 규칙을 다른 언어에 강요하게 된다.
Step 4: 날짜는 진짜로 갈린다
주소는 영어인데 시각은 한국어
오프닝의 세 번째 이상한 점입니다. 프로필 화면 코드를 열어보니 이렇게 되어 있었어요.
// 지난 시간까지의 코드
({new Date(profile.countedAt).toLocaleTimeString('ko-KR')} 기준)
'ko-KR'이 손으로 적혀 있습니다. 그러니 /en이든 /ja 든 항상 한국어로 찍힙니다.
지난 시간에 우리가 구조만 옮기고 지나간 빚이에요. 이런 건 눈에 잘 안 띕니다. 화면이 안 깨지거든요. 그냥 조금 이상할 뿐이죠.
날짜는 숫자와 다릅니다
Step 2에서 숫자는 세 언어가 똑같았죠. 날짜는 다릅니다.
같은 시각을 세 언어로
ko 2026년 8월 22일 오후 2:32
en August 22, 2026 at 2:32 PM
ja 2026年8月22日 14:32
순서도, 구분 기호도, 오전·오후를 쓰는지도 다 다릅니다. 일본어는 24시간제로 찍네요. 이건 우리가 규칙을 외워서 만들 수 있는 게 아니에요.
언어를 안 적는 방식으로 바꿉니다
// apps/web-next/app/[locale]/[username]/page.tsx
// 날짜·시각·숫자를 이 요청의 언어로 찍어주는 도구. 언어 이름을 손으로 안 적는다.
const format = await getFormatter();
// ...
<span className="ml-2 text-black/40">
{t('countedAt', {
time: format.dateTime(new Date(profile.countedAt), {
dateStyle: 'long',
timeStyle: 'short',
}),
})}
</span>
dateStyle과 timeStyle로 어느 정도로 자세히를 말할 뿐, 어떤 모양인지는 안 정합니다. 그건 언어가 정해요.
괄호와 "기준" 같은 말도 언어마다 다르니 번역 파일로 옮겼습니다.
ko "countedAt": "({time} 기준)"
en "countedAt": "(as of {time})"
ja "countedAt": "({time} 時点)"
실제 화면입니다.
/ko (2026년 8월 22일 오후 2:32 기준)
/en (as of August 22, 2026 at 2:32 PM)
/ja (2026年8月22日 14:32 時点)
⚠️ 언어와 시간대는 다른 축입니다
위 세 줄을 자세히 보시면 시각이 전부 2:32입니다. 영어로 봐도 서울 시각이에요.
당연합니다. 우리 설정에 시간대가 서울로 적혀 있으니까요. 그게 맞을 때가 많은데, 이 숫자는 "우리 서버가 언제 집계했나" 니까요. 보는 사람이 어디 있든 집계 시각은 하나지만, 이건 결정입니다. "이 시각을 누구 기준으로 보여줄 것인가"를 정해야 하고, 그 답은 그 숫자가 무엇이냐에 따라 달라요.
- 서버가 집계한 시각 → 한 시간대로 고정하고 그걸 밝힌다
- 사용자가 댓글을 단 시각 → 보는 사람의 시간대로 바꿔주는 게 낫다
언어를 바꿨다고 시간대가 따라오지 않습니다. 영어를 쓰는 사람이 서울에 살 수도 있고, 한국어를 쓰는 사람이 뉴욕에 있을 수도 있으니까요.
🙋 학생 질문 — "시간대도 언어처럼 알아서 정해지게 하면 안 되나요?"
언어와 시간대는 우리가 알아내는 방법부터 다릅니다.
언어는 주소에 적혀 있어요. /en/jaehoon이면 영어죠. 서버가 요청을 받는 순간 이미 압니다.
시간대는 주소에 없는데, 이 화면은 서버가 그립니다. 서버는 요청을 받았을 뿐, 그 사람이 서울에 있는지 뉴욕에 있는지는 안 받아서, 알아서 맞추려면 브라우저가 알아낸 뒤 서버에 알려주거나 서버는 기준 시각만 보내고 브라우저가 다시 찍어야 합니다. 그 방법을 고민하기 전에 정할 게 있는데, 위에서 본 것처럼 이 시각을 누구 기준으로 보여줄지는 그 숫자가 무엇이냐에 달렸어요. 서버가 집계한 시각이라면 보는 사람 시간대로 바꾸는 게 오히려 틀린 답이라서, 순서가 이렇습니다. 할 수 있느냐를 묻기 전에 무엇이 맞느냐를 먼저 정해요.
💡 한 줄 정리
날짜는 숫자와 달라서 순서·구분 기호·시간제가 언어마다 진짜로 갈린다. 언어 이름을 손으로 적지 말고 맡기되, 시간대는 언어에 딸려 오는 게 아니라 따로 내리는 결정이다.
Step 5: 글자 길이가 화면을 깨뜨린다
같은 머리말, 다른 높이
글자 자체를 봅시다. 머리말을 세 언어로 열어놓고 높이를 재봤어요.
머리말 높이 (폭 768px 기준)
ko ██████████ 102px
en ████████████ 122px
ja ██████████ 102px
영어가 20px 더 높습니다. 왜일까요. 안을 들여다봤어요.
언어 브랜드 이름 홈 탐색
────────────────────────────────────────
ko 74px / 2줄 13px/1줄 20px / 2줄
en 70px / 2줄 41px/1줄 49px / 1줄
ja 85px / 2줄 23px/3줄 31px / 2줄
두 줄, 세 줄이 보이시죠. 머리말 한 줄에 들어가야 할 것들이 글자 단위로 줄바꿈되고 있습니다.
영어에서 특히 심했어요. Home과 Explore가 홈·탐색보다 넓다 보니, 옆에 있던 "글자 크기" 버튼이 밀려서 네 글자가 세로로 한 줄씩 쌓였습니다.
영어 화면의 "글자 크기" 버튼
글
자
크
기
이건 오늘 만든 문제가 아닙니다
먼저 분명히 해둘 게 있어요. 이 깨짐은 오늘 우리가 만든 게 아닙니다. 한국어에서도 이미 깨져 있었어요. 브랜드 이름이 두 줄이었고 로그인 칸은 세 줄이었죠.
머리말에 넣은 것이 많아서 폭이 모자랐던 것인데, 언어를 바꾸니까 어느 것이 눌리는지가 달라져서 눈에 띄었어요.
국제화 작업을 하면 이런 일이 자주 생깁니다. 원래 아슬아슬하던 것이 언어가 바뀌면서 무너지는 거죠. 국제화가 문제를 만든 게 아니라 드러낸 겁니다.
칸이 눌리지 않게 합니다
왜 글자가 쪼개졌을까요. 머리말이 가로로 늘어선 배치인데, 폭이 모자라니까 각 칸을 제 너비보다 좁게 눌러버린 겁니다. 눌린 칸 안에서 글자가 줄바꿈된 거고요.
고침은 한 단어입니다. 폭이 모자라면 칸을 누르지 말고 다음 줄로 내려보내라 고 하면 돼요.
// apps/web-next/app/components/HeaderNav.tsx
// flex-wrap 이 없으면 칸들이 제 너비보다 좁게 눌려서 «글자» 가 줄바꿈된다.
// 줄이 모자라면 글자를 쪼개지 말고 칸 단위로 다음 줄에 내려보낸다.
<nav className="mx-auto flex max-w-3xl flex-wrap items-center gap-4 p-4 text-sm">
다시 재봤습니다.
| 고치기 전 | 고친 뒤 | |
|---|---|---|
| 영어 머리말 높이 | 122px | 107px |
| 일본어 머리말 높이 | 102px | 107px |
| 한 칸이 차지한 최대 줄 수 | 4줄 | 1줄 |
모든 칸이 한 줄이 됐습니다. 로그인 폼은 통째로 다음 줄로 내려갔어요. 글자가 쪼개지는 대신 덩어리가 이동한 겁니다.
일본어는 오히려 5px 높아졌는데, 이건 나빠진 게 아닙니다. 글자를 세로로 쌓아서 억지로 낮췄던 걸 정상적인 두 줄로 편 결과예요.
🙋 학생 질문 — "언어마다 글자가 얼마나 길어지는지 미리 알 수는 없나요?"
경향은 알려져 있습니다. 같은 뜻을 적을 때 한국어·중국어·일본어가 짧은 편이고, 영어는 그보다 길어요. 독일어처럼 낱말을 이어 붙여 쓰는 언어는 더 길어지기도 하고요. 짧은 글일수록 차이가 크게 벌어지는데, 오늘 우리가 만난 건 그 경향 때문이 아니었어요. 머리말이 이미 폭이 모자란 상태였고, 언어가 바뀌면서 어느 칸이 눌리는지만 달라진 겁니다. 한국어에서도 브랜드 이름은 두 줄이었으니까, 길이를 외워두는 것보다 이쪽이 낫습니다. 가장 긴 언어를 가정하고 배치를 짜는 것, 그리고 길이가 얼마든 안 깨지는 방식으로 고치는 것요.
오늘 우리가 고친 한 단어가 그런 종류입니다. 어떤 언어가 새로 들어와도 칸을 안 누르니까, 그 언어의 글자가 얼마나 긴지 미리 몰라도 됩니다.
💡 한 줄 정리
국제화는 깨짐을 만든 게 아니라 드러냈다. 폭이 모자랄 때 칸을 누르는 대신 다음 줄로 내려보내면, 어느 언어가 들어와도 글자가 쪼개지지 않는다.
Step 6: 줄은 아무 데서나 안 끊긴다
Step 5가 증상을 지웠습니다
솔직하게 말씀드릴 게 있어요. 원래 이번 단계는 "일본어는 띄어쓰기가 없어서 줄바꿈이 이상하다"를 머리말로 보여드리려고 했습니다. 실제로 고치기 전 일본어 머리말이 이랬거든요.
インスタグラ ← "인스타그램" 이 여기서 잘렸다
ムクローン
Step 5에서 칸이 안 눌리게 하자 이 증상이 같이 사라졌습니다. 글자에 자리가 생기니까 쪼갤 이유가 없어진 거라서, 여기서는 확인할 수 있는 게 없어졌습니다. 우리 앱의 게시물 본문은 사용자가 쓴 한국어 글이라 번역하지 않으니까요.
증상이 사라졌다고 원인이 사라진 건 아닙니다. 자리가 넉넉할 때만 안 보이는 것이라서, 폭을 고정한 칸을 따로 만들어서 재봤습니다.
어디서 끊기는지 봅니다
폭 120px 짜리 칸에 같은 뜻의 문장을 세 언어로 넣고, 줄이 어디서 바뀌는지 그대로 뽑았습니다.
영어
Welcome to the
Instagram Clone ← 띄어쓰기에서만 끊긴다. 낱말은 온전하다.
한국어
인스타그램 클론에
오신 것을 환영합니
다 ← "환영합니 / 다" 낱말 한가운데가 잘렸다
일본어
インスタグラムク
ローンへようこそ ← "クローン" 한가운데가 잘렸다
여기서 짚고 싶은 게 있어요. 한국어도 잘립니다.
저는 이걸 "일본어 문제"로 알고 있었는데 재보니 아니었습니다. 한국어에도 띄어쓰기가 있지만 브라우저는 한국어·일본어·중국어에서 기본적으로 글자 단위로 끊어요. 띄어쓰기를 안 기다립니다.
영어만 낱말을 지킵니다.
언어마다 다른 규칙을 줍니다
한국어는 띄어쓰기가 있으니 "거기서만 끊어라" 고 하면 됩니다.
한국어에 keep-all 을 준 뒤
인스타그램 클론에
오신 것을
환영합니다 ← 낱말이 온전해졌다
일본어에도 같은 걸 주면 될까요. 해봤습니다.
일본어에 keep-all 을 주면
今日の東京の天気はとても良いので散歩に行きます
└─ 칸 폭 140px · 실제 너비 322px ─┘
→ 182px 넘쳐서 밖으로 삐져나간다
같은 속성이 한 언어를 고치고 다른 언어를 깨뜨립니다.
당연합니다. 일본어에는 띄어쓰기가 없어요. "띄어쓰기에서만 끊어라"라고 하면 끊을 곳이 없으니 한 줄로 쭉 뻗어버리는 겁니다.
일본어에는 다른 걸 줍니다. 문장을 의미 덩어리로 나눠서 그 경계에서 끊게 하는 거예요.
일본어에 auto-phrase 를 준 뒤
今日の東京の天気は
とても良いので散歩に
行きます ← 덩어리 경계에서 끊긴다
언어를 보고 붙게 합니다
두 규칙을 화면마다 손으로 적으면 금방 어긋납니다. 지난 시간에 우리가 <html>에 언어를 적어뒀으니, 그걸 보고 붙게 하면 돼요.
/* apps/web-next/app/globals.css */
/* 한국어: 띄어쓰기가 있으니 «거기서만» 끊게 한다. */
:lang(ko) {
word-break: keep-all;
}
/* 일본어: 띄어쓰기가 없어서 keep-all 을 주면 한 줄이 통째로 넘쳐버린다.
대신 문절(文節) 경계를 브라우저가 찾아 끊게 한다. */
:lang(ja) {
word-break: auto-phrase;
}
브라우저가 실제로 뭘 적용했는지 확인해봤습니다. 선언만 보고 넘어가면 안 되니까요.
/ko → keep-all
/ja → auto-phrase
/en → normal ← 규칙을 안 줬으니 기본값. 영어는 이미 낱말을 지킨다.
⚠️ 이 중 하나는 크롬에서만 됩니다
auto-phrase는 크롬 계열 브라우저에서만 동작하고, 다른 브라우저는 이 줄을 모릅니다.
문제가 될까요? 안 됩니다. CSS는 모르는 값을 만나면 그 줄을 그냥 무시해요. 화면이 깨지지 않습니다. 그 브라우저에서는 지금처럼 글자 단위로 끊길 뿐이에요.
이런 걸 "있으면 더 좋고 없어도 되는" 개선이라고 봅니다. 반대로 keep-all은 오래전부터 모든 브라우저가 지원하니 마음 놓고 써도 됩니다.
이 둘을 구분하는 게 중요해요. 없으면 깨지는 것과 없으면 조금 덜 예쁜 것은 다르게 다뤄야 하니까요.
🙋 학생 질문 — "그럼 한국어에도 auto-phrase를 주면 더 좋지 않나요?"
그럴 것 같은데 안 하는 게 낫습니다. 두 규칙의 성격이 다르거든요.
한국어는 keep-all만으로 낱말이 온전해지는 걸 위에서 확인했고, keep-all은 오래전부터 모든 브라우저가 지원해요.
auto-phrase는 크롬 계열에서만 됩니다. 이걸로 한국어를 고치면 다른 브라우저를 쓰는 한국어 사용자는 오늘 이전 상태 그대로예요. 모든 브라우저에서 되는 해법이 이미 있는데, 한 브라우저에만 있는 것으로 바꿀 이유가 없습니다.
일본어에 auto-phrase를 준 건 사정이 달라서예요. 일본어에는 모든 브라우저에서 되는 해법이 없습니다. keep-all을 주면 넘치고, 아무것도 안 주면 글자 단위로 끊기니까, 되는 곳에서만이라도 나아지게 둔 겁니다.
정리하면 이래요. 고를 게 여럿일 때는 더 좋아 보이는 것이 아니라 없을 때 무슨 일이 나는가로 고릅니다.
💡 한 줄 정리
같은 속성이 한 언어를 고치고 다른 언어를 깨뜨린다. 한국어엔 keep-all, 일본어엔 auto-phrase를 :lang()으로 갈라 주되, 앞은 없으면 깨지는 것이고 뒤는 없어도 되는 개선이다.
Step 7: 언어를 바꾸는 버튼
보던 화면은 그대로 두고
지금은 언어를 바꾸려면 주소창에 직접 쳐야 합니다. 버튼을 만들어봅시다.
중요한 건 보던 화면을 잃지 않는 것입니다. /ko/jaehoon을 보다가 일본어로 바꿨는데 홈으로 튕기면 곤란하죠.
지난 시간에 만든 도구가 여기서 일합니다. 우리가 쓰는 usePathname은 언어 칸을 뗀 주소를 돌려줘요. /ko/jaehoon에 있어도 /jaehoon을 줍니다. 그러니 거기에 새 언어만 붙이면 됩니다.
// apps/web-next/app/components/LocaleSwitcher.tsx
'use client';
export function LocaleSwitcher() {
const locale = useLocale();
// 언어 칸이 «빠진» 주소가 온다. /ja/jaehoon 에 있어도 /jaehoon 을 준다.
// 그래서 보던 화면을 그대로 두고 언어만 갈아 끼울 수 있다.
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('LocaleSwitcher');
return (
<select
aria-label={t('label')}
value={locale}
onChange={(event) => {
router.replace(pathname, { locale: event.target.value });
}}
className="rounded border border-black/15 px-2 py-1"
>
{routing.locales.map((code) => (
<option key={code} value={code}>{nativeName(code)}</option>
))}
</select>
);
}
목록의 이름을 무슨 말로 적을까요
작지만 중요한 결정이 하나 있습니다. 목록에 "일본어"라고 적을까요, "日本語"라고 적을까요?
한국어 화면이니까 "일본어"가 맞아 보이지만, 생각해보세요. 그 목록을 여는 사람은 지금 한국어를 못 읽어서 여는 겁니다. "일본어"라고 적혀 있으면 자기가 찾는 게 어느 것인지 알 수가 없어서, 각 언어를 그 언어 자신의 말로 적습니다.
// 목록에 뜨는 이름은 «그 언어 자신의 말» 로 적는다.
// 한국어 화면이라고 "일본어" 라고 쓰면, 일본어 쓰는 사람은 그 글자를 못 읽는다.
function nativeName(code: string) {
return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
}
이름을 우리가 적어두지 않아도 됩니다. 브라우저가 알고 있어요.
ko → 한국어
en → English
ja → 日本語
⚠️ Step 1의 청구서가 여기서 옵니다
버튼을 붙이고 빌드했더니 이런 게 쏟아졌습니다.
MISSING_MESSAGE: LocaleSwitcher (ko)
MISSING_MESSAGE: LocaleSwitcher (en)
MISSING_MESSAGE: LocaleSwitcher (ja)
... 모두 36건
"번역 칸이 없다"는 뜻인데, 저는 세 언어 파일에 LocaleSwitcher.label을 분명히 넣었어요. 파일을 열어보면 있습니다.
없는 게 아니라 안 넘어간 겁니다.
Step 1에서 우리가 이렇게 적었죠.
<NextIntlClientProvider messages={{ Nav: messages.Nav }}>
브라우저로 Nav만 넘긴다고 했는데, 이 버튼은 브라우저에서 돌면서 LocaleSwitcher 칸을 읽으려고 합니다. 서버에는 있지만 브라우저에는 안 갔어요.
이게 Step 1에서 우리가 한 선택의 대가입니다. 넘길 것을 고른다는 건, 새로 필요해지면 추가해야 한다는 뜻이에요.
// apps/web-next/app/[locale]/layout.tsx
<NextIntlClientProvider
messages={{ Nav: messages.Nav, LocaleSwitcher: messages.LocaleSwitcher }}
>
이러니 36건이 0건이 됐습니다.
💡 그런데 빌드는 통과했습니다
여기서 꼭 짚어야 할 게 있어요. 위의 36건이 쏟아지는 동안에도 빌드는 성공했습니다. 종료 코드 0 이었어요.
MISSING_MESSAGE 36건
빌드 결과 성공 (exit 0)
빌드가 초록불인데 화면의 라벨이 세 언어 모두 깨져 있는 상태였던 겁니다.
우리는 이런 모양을 전에도 만났습니다. 보안 설정을 다룰 때 빌드는 멀쩡한데 화면의 스크립트가 하나도 안 실행된 적이 있었고, 지난 시간에는 빌드가 통과했는데 앱의 모든 화면이 안 열린 적이 있었죠.
빌드 성공은 "문법이 맞다"는 뜻이지 "동작한다"는 뜻이 아닙니다. 세 번째 만나는 것이니 이제 기억해두시면 좋겠어요.
확인
/ko/explore → /ja/explore 보던 목록 그대로
/ko/jaehoon → /ja/jaehoon 보던 사람 그대로
바뀐 내용
게시물 1개 · 팔로워 1,240명(2026년 8월 22일 오후 2:43 기준)
投稿 1件 · フォロワー 1,240人(2026年8月22日 14:43 時点)
두 번째 줄 안에 오늘 한 게 거의 다 들어 있습니다. 복수형, 자릿점, 날짜 형식, 그리고 화면을 유지한 언어 전환까지요.
🙋 학생 질문 — "코드에 replace라고 쓰셨는데, 그냥 이동하는 것과 뭐가 다른가요?"
방문 기록을 남기느냐 아니냐가 다릅니다.
보통 화면을 옮길 때는 기록을 쌓아요. 그래야 뒤로 가기가 동작하니까요. 그 방식으로 언어를 바꾸면 바꿀 때마다 기록이 하나씩 쌓입니다. 한국어에서 일본어로, 다시 영어로 바꾸면 기록이 셋이 되죠.
그 상태에서 뒤로 가기를 누르면 어떻게 될까요. 이전 화면이 아니라 이전 언어로 갑니다. 같은 프로필 화면을 보면서 언어만 되감기는 거예요. 쓰는 사람 입장에선 뒤로 가기가 고장 난 것처럼 느껴집니다.
replace는 지금 기록을 갈아 끼웁니다. 언어를 몇 번 바꾸든 기록은 하나예요. 뒤로 가기를 누르면 이 화면에 오기 전에 보던 곳으로 갑니다.
이 버튼의 목적이 보던 화면을 잃지 않는 것이었죠. 뒤로 가기도 거기에 들어갑니다. 언어를 바꾸는 건 다른 곳으로 이동이 아니라 같은 곳을 다른 말로 보는 것이니까요.
💡 한 줄 정리
언어 칸이 빠진 주소에 새 언어만 붙이면 보던 화면을 잃지 않는다. 그리고 Step 1에서 넘길 칸을 고른 대가가 여기서 청구된다 — 새로 필요해진 칸은 우리가 직접 추가해야 한다.
Step 8: 네 번째 언어, 그리고 뒤집힌 화면
언어를 하나 더 들입니다
마지막으로 아랍어를 들여봅시다. 지금까지와 다른 성질이 하나 있는 언어예요.
추가는 간단합니다. 목록에 한 줄, 번역 파일 하나.
// apps/web-next/i18n/routing.ts
export const routing = defineRouting({
locales: ['ko', 'en', 'ja', 'ar'],
defaultLocale: 'ko',
});
미리 그려지는 화면이 20개에서 24개로 늘었습니다. 언어 하나에 네 화면씩이니까요.
⚠️ 칸을 덜 채우면 어떻게 되는지 먼저 봅니다
일부러 머리말 세 칸만 채우고 빌드해봤습니다. 나머지는 비워뒀어요.
빌드는 성공했습니다. 그런데 /ar을 열어보니 이랬어요.
브라우저 탭 제목: Meta.title
탭에 우리가 쓴 칸 이름이 그대로 떠 있습니다. 번역문 대신에요. 페이지 설명도 Meta.description이라고 나갔고요.
빌드 기록을 보니 이런 게 76건 있었습니다.
MISSING_MESSAGE: Meta (ar)
MISSING_MESSAGE: LocaleSwitcher (ar)
빌드 결과: 성공 (exit 0)
Step 7에서 본 것과 같은 모양이죠. 언어를 하나 추가하고 칸을 덜 채워도 아무도 안 막아줍니다. 검색 결과에 Meta.title이라고 뜬 뒤에야 알게 되는 거예요.
그래서 칸을 채웠습니다.
아랍어의 복수형 여섯 갈래
Step 3에서 예고한 걸 이제 실물로 봅니다.
// apps/web-next/messages/ar.json
"likes": "{count, plural, zero {لا إعجابات} one {إعجاب واحد} two {إعجابان} few {# إعجابات} many {# إعجابًا} other {# إعجاب}}"
여섯 갈래를 다 적어야 합니다. 실제로 다 쓰이는지 숫자를 바꿔가며 찍어봤어요.
0 → لا إعجابات (zero — 없을 때 전용 표현)
1 → إعجاب واحد (one)
2 → إعجابان (two — 둘일 때 전용)
3 → 3 إعجابات (few)
11 → 11 إعجابًا (many)
1240 → 1,240 إعجابًا (many — 자릿점도 유지)
여섯 갈래가 전부 다르게 나옵니다. 한국어가 한 갈래로 끝내는 것을 아랍어는 여섯으로 나눠요.
만약 우리가 Step 3에서 if (count === 1) 같은 걸로 처리했다면, 여기서 전부 다시 짜야 했을 겁니다.
🙋 학생 질문 — "튜터님이 아랍어를 할 줄 아세요?"
못 합니다. 그리고 이건 중요한 이야기예요.
위의 아랍어는 제가 사전을 찾아 지어낸 것입니다. 문법 갈래가 여섯 개로 갈린다는 구조는 브라우저가 알려준 사실이지만, 각 칸에 들어간 문장은 제가 쓴 거예요. 원어민이 보면 어색한 표현이 있을 수 있습니다.
실무에서는 이렇게 하면 안 됩니다. 번역은 개발자가 짐작해서 쓰는 게 아니라 그 언어를 쓰는 사람이 쓰는 거예요.
그럼 개발자가 할 일은 뭘까요. 번역가가 일할 수 있는 모양을 만들어두는 것입니다. 오늘 우리가 한 게 정확히 그거예요. 글자를 코드에서 꺼내 번역 파일로 옮기고, 숫자 들어갈 곳을 표시하고, 갈래가 필요한 곳을 표시해뒀죠. 이제 번역가에게 이 파일을 건네면 됩니다.
글이 오른쪽에서 시작합니다
아랍어는 오른쪽에서 왼쪽으로 씁니다. 브라우저에게 알려주려면 <html>에 한 칸을 더해야 해요.
그런데 여기서 제가 넘어졌습니다.
⚠️ 넘어진 이야기
방향을 우리가 목록으로 들고 있지 말고 브라우저에게 물어보면 좋겠다고 생각했어요. 실제로 그런 기능이 있습니다. 확인해보니 잘 되길래 코드에 넣었죠.
그리고 빌드가 멈췄습니다.
TypeError: (intermediate value).getTextInfo is not a function
Export encountered an error on /[locale]/[username]/page, exiting the build.
"그런 기능이 없다"는 뜻입니다. 분명히 확인했었는데요.
원인은 제 확인 방법에 있었습니다. 저는 이렇게 확인했어요. "새 이름이 있으면 그걸 쓰고, 없으면 옛 이름을 쓴다" 고요. 결과가 잘 나오길래 통과시켰죠.
그런데 그 확인은 어느 쪽이 동작했는지를 안 남겼습니다. 실제로는 새 이름이 없어서 옛 이름으로 돌아간 거였는데, 저는 결과만 보고 새 이름이 있다고 믿었어요. 그리고 코드에는 새 이름을 적었습니다.
다시 확인해보니 이랬습니다.
새 이름(getTextInfo) 이 함수인가 → undefined 없다
옛 이름(textInfo) 이 있나 → {"direction":"rtl"} 있다
이 기능은 표준으로 확정됐지만 이름이 바뀌는 중입니다. 그래서 실행 환경마다 있는 게 달라요. 우리 환경에는 옛 이름만 있었습니다.
여기서 배울 게 두 가지 있어요.
하나는 확인에 관한 겁니다. 여러 갈래를 두고 확인하면, 결과는 알 수 있어도 어느 길로 갔는지는 모릅니다. 갈래가 있는 확인을 할 때는 어느 쪽이 동작했는지도 같이 남겨야 해요.
둘은 선택에 관한 겁니다. 이름이 바뀌는 중인 기능 위에 앱의 동작을 얹으면 이런 일이 납니다. 우리 언어 목록은 네 줄이에요. 직접 적는 게 쌉니다.
직접 적되, 빠뜨리면 알려주게 합니다
// apps/web-next/i18n/routing.ts
// 글이 어느 쪽에서 시작하는지를 언어 목록 바로 옆에 함께 적는다.
//
// satisfies 를 붙여두면 언어를 하나 추가하고 방향을 안 적었을 때
// 화면이 아니라 «타입» 이 먼저 알려준다.
export const localeDirections = {
ko: 'ltr',
en: 'ltr',
ja: 'ltr',
ar: 'rtl',
} as const satisfies Record<(typeof routing.locales)[number], 'ltr' | 'rtl'>;
satisfies 한 단어가 하는 일이 있습니다. 언어를 추가하고 방향을 안 적으면 타입 검사가 막아요. 일부러 아랍어 방향을 지워봤습니다.
TS1360 Property 'ar' is missing in type ... but required in
type 'Record<"ko" | "en" | "ja" | "ar", "ltr" | "rtl">'
빠뜨린 곳과 쓰는 곳 두 군데에서 잡힙니다. 오늘 우리가 두 번이나 만난 "빌드는 초록인데 화면이 깨진" 상황을, 여기서는 미리 막아둔 거예요.
// apps/web-next/app/[locale]/layout.tsx
// 아랍어는 rtl, 나머지 셋은 ltr. 이 한 칸이 화면 전체의 좌우를 뒤집는다.
<html lang={locale} dir={localeDirections[locale]} className={notoSansKr.className}>
여백이 반대쪽에 붙어야 합니다
dir 한 칸으로 화면이 뒤집힙니다. 머리말이 오른쪽에서 시작하고, 목록이 오른쪽으로 정렬되죠.
그런데 우리가 손으로 적은 여백은 안 따라옵니다. 예를 들어 로그인 칸에 ml-2라고 적어뒀다면, 그건 "왼쪽에 여백"이라는 뜻이에요. 화면이 뒤집혀도 여전히 왼쪽에 붙습니다. 원래 의도는 "앞의 것과 떨어뜨려라" 였는데, 뒤집힌 화면에서 앞은 오른쪽이거든요.
우리 앱에 그런 곳이 몇 군데인지 세어봤습니다.
app/[locale]/[username]/page.tsx ml-2
app/components/SignInFields.tsx ml-2
app/components/SignInForm.tsx ml-2
app/components/CommentSection.tsx mr-1
─────
4곳
네 곳입니다. 생각보다 적죠. 우리가 여백을 대부분 gap으로 줬기 때문이에요.
고침은 이름을 바꾸는 겁니다. "왼쪽" 대신 "시작하는 쪽"이라고 적어요.
ml-2 → ms-2 왼쪽 여백 → 시작하는 쪽 여백
mr-1 → me-1 오른쪽 여백 → 끝나는 쪽 여백
"시작하는 쪽"이 어디인지는 그 언어가 정합니다. 한국어면 왼쪽, 아랍어면 오른쪽이에요. 조건문을 하나도 안 씁니다.
브라우저가 실제로 뭘 적용했는지 확인했습니다.
같은 ms-2가 |
/ko (ltr) |
/ar (rtl) |
|---|---|---|
margin-left |
8px | 0 |
margin-right |
0 | 8px |
같은 한 단어가 언어에 따라 반대쪽에 붙습니다.
한국어 화면 (ltr) — 글이 왼쪽에서 오른쪽으로 흐른다
┌───────────┐
│ 로그인 │
└───────────┘
↑
ms-2 가 준 여백 8px 이 «왼쪽» 에 붙는다
아랍어 화면 (rtl) — 글이 오른쪽에서 왼쪽으로 흐른다
┌───────────┐
│ 로그인 │
└───────────┘
↑
같은 ms-2 인데 여백 8px 이 «오른쪽» 에 붙는다
버튼에 실제로 뜨는 글자는 아랍어(تسجيل)지만, 여기서 보실 것은 글자가 아니라 여백이 어느 쪽에 붙었는지입니다. 우리가 바꾼 건 한 단어뿐이고 붙는 쪽은 언어가 정했어요.
💡 한 줄 정리
dir 한 칸이 화면 전체의 좌우를 뒤집지만, 손으로 적은 왼쪽·오른쪽 여백은 따라오지 않는다. 시작·끝 기준으로 적으면 방향은 언어가 정하고, 빠뜨린 언어는 satisfies가 타입 단계에서 잡는다.
마무리
오늘 한 일을 정리하면 이렇습니다.
1. 브라우저로 넘길 번역 칸을 골랐다 307 B → 88 B
2. 숫자에 자릿점을 붙였다 1240 → 1,240
3. 하나와 여럿을 언어가 정하게 했다 "1 posts" → "1 post"
4. 시각에서 하드코딩된 언어를 걷어냈다 /en 인데 한국어였던 것
5. 칸이 눌려 글자가 쪼개지는 걸 막았다 한 칸 4줄 → 1줄
6. 언어마다 줄 끊는 규칙을 다르게 줬다 낱말 한가운데가 잘리던 것
7. 화면을 유지한 채 언어를 바꾸게 했다 /ko/jaehoon → /ja/jaehoon
8. 네 번째 언어와 뒤집힌 화면을 들였다 물리 여백 4곳 → 논리 여백
지난 시간 처음에 "글자만 바꾸면 될 것 같지만 그렇지 않다" 고 말씀드렸죠. 오늘 그 뒤쪽 절반을 보셨습니다.
오늘 배운 핵심 세 가지
💡 하나 — 오늘 만진 것 중 글자 자체는 오히려 적었습니다. 숫자를 찍는 법, 날짜를 찍는 법, 갈래를 나누는 법, 줄을 끊는 법, 여백이 붙는 쪽. 이것들이 전부 언어에 딸려 있었습니다.
💡 둘 — 오늘 두 번 넘어졌고, 둘 다 재보고 나서야 알았습니다. 한 번은 Step 5의 고침이 Step 6에서 보여드리려던 증상을 지워버린 것이었고, 한 번은 확인을 잘못해서 없는 기능을 코드에 적은 것이었죠. 앞의 것은 고친 다음 원래 되던 걸 다시 안 쟀으면 몰랐을 거고, 뒤의 것은 확인이 어느 길로 갔는지를 안 남겨서 생긴 일이었습니다.
💡 셋 — 지난 시간이 옮긴 것은 구조였고, 오늘은 그 위에 얹히는 세부였습니다. 자릿점 · 복수형 · 줄바꿈 · 방향까지 갖춰야 비로소 "번역했다" 고 말할 수 있습니다.
다음 시간 예고
우리 앱에는 아직 한국어 그대로인 글자가 마흔 개 넘게 남아 있습니다. 다음 시간에는 화면을 여러 팀이 나눠 만드는 이야기로 넘어가는데, 남은 글자들은 오늘 배운 방식 그대로 옮기시면 됩니다.
과제
[구현] 남은 글자를 옮깁니다
우리 앱에는 아직 한국어 그대로인 UI 글자가 있습니다. 세어보니 20개 파일에 47개였어요. (주석을 뺀 문자열과 화면에 그대로 나가는 글자만 셌습니다. 세는 방법에 따라 숫자가 달라질 수 있으니 직접 세보셔도 좋아요.)
그중 게시물 본문과 해시태그 30개는 사용자가 쓴 글이라 번역하지 않습니다. 지난 시간에 가른 그 기준 그대로예요.
- 가장 많이 남은 파일부터 옮겨보세요.
SignInFields.tsx에 5개가 있습니다. - 옮긴 뒤 빌드하고
MISSING_MESSAGE가 나오는지 보세요. 나온다면 왜일까요? Step 7에서 만난 것과 같은 이유일 수 있습니다. app/actions/안의 파일들은 브라우저가 아니라 서버에서만 도는 코드입니다. 여기서는 훅을 쓸 수 없어요. 어떻게 번역 칸을 여실 건가요?
[구현] 언어를 하나 더 넣어보세요
독일어를 넣어보세요. Step 2에서 자릿점 이야기를 하며 잠깐 봤던 그 언어입니다.
- 언어 목록에
de를 넣고 방향을 일부러 안 적어보세요. 무엇이 먼저 알려주나요? 화면인가요, 타입 검사인가요? - 번역 파일을 만들고 팔로워 수를 보세요.
1,240으로 나오나요,1.240으로 나오나요? - 미리 그려지는 화면이 몇 개가 되나요? 24개에서 얼마나 늘었나요?
[탐구] 좁은 화면에서 다시 재보세요
Step 5에서 잰 머리말 높이는 폭이 768px 일 때입니다.
- 브라우저 창을 휴대폰 폭(400px 안팎)으로 줄이고 네 언어를 각각 열어보세요.
- 머리말이 몇 줄이 되나요? 언어마다 다른가요?
flex-wrap을 잠시 지워보고 같은 폭에서 다시 보세요. 무엇이 달라지나요? 확인 뒤 되돌리세요.
[탐구] 고친 것이 다른 것을 되팔지 않았는지 확인하세요
Step 3에서 저는 복수형을 넣기 전에 "자릿점이 안 죽었나"를 먼저 쟀습니다.
- 만약 안 쟀다면 어떻게 됐을지 재현해보세요.
#대신 그냥 숫자를 넣는 방식으로 복수형을 써보고, 1240을 넣으면 무엇이 나오는지 보세요. - 그 상태에서 빌드와 타입 검사는 통과하나요?
- 통과한다면, Step 2에서 얻은 것이 사라진 걸 무엇으로 알아챌 수 있을까요?
생각해볼 주제
1. 우리가 지원하는 언어가 우연히 비슷할 때
Step 2에서 숫자 표기가 세 언어 모두 같았습니다. 그래서 "언어마다 다르다"를 보여드릴 수가 없었어요. 독일어를 빌려와야 했죠.
이건 운이 좋았던 걸까요, 나빴던 걸까요. 세 언어만 지원한다면 자릿점 처리를 대충 해도 아무 문제가 없습니다. 그러다 네 번째 언어가 들어오는 날 한꺼번에 드러나겠죠.
지금 당장 문제가 안 되는 것을 미리 제대로 해둘지, 문제가 될 때 고칠지는 늘 판단입니다. 여러분이라면 이 결정을 무엇으로 내리시겠어요? 그리고 "나중에 고치자"를 골랐다면, 그 나중이 왔다는 걸 어떻게 알아차릴 수 있을까요?
2. 고쳤더니 증거가 사라졌습니다
Step 5에서 칸이 눌리는 걸 고쳤더니, Step 6에서 보여드리려던 일본어 줄바꿈 문제가 같이 사라졌습니다. 증상이 없어졌지만 원인은 그대로 있었어요. 자리가 넉넉할 때만 안 보일 뿐이었죠.
이런 일은 버그를 고칠 때도 자주 생깁니다. 증상이 사라져서 고쳐진 줄 알았는데, 사실은 조건이 바뀌어서 안 보이게 된 것뿐인 경우요.
둘을 어떻게 구별할 수 있을까요? "정말 고쳐졌다"와 "지금은 안 보인다"를 가르는 데 무엇이 필요할지 이야기해봅시다.
3. 빌드가 초록불인데 화면이 깨져 있을 때
오늘 두 번 만났습니다. 번역 칸을 안 넘겨서 라벨이 깨졌을 때 36건, 아랍어 칸을 덜 채워서 탭 제목이 Meta.title로 떴을 때 76건. 둘 다 빌드는 성공이었어요.
그런데 마지막에 우리는 이걸 막는 방법을 하나 썼습니다. satisfies로 방향을 빠뜨리면 타입이 잡게 한 거죠. 같은 방식으로 번역 칸이 비었을 때도 막을 수 있을까요?
막을 수 있다면 어디까지 막는 게 좋을까요. 모든 언어의 모든 칸이 채워져야만 빌드가 되게 하면 안전하겠지만, 번역이 도착하기 전에는 아무것도 배포할 수 없게 됩니다. 이 사이 어디에 선을 그으시겠어요?
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 직접 해보신 뒤에 펼쳐서 맞춰보세요.
배점 100점 — 과제 1~4 각 25점
🎯 [과제 1 예시답안] 남은 글자를 옮깁니다
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 다섯 글자를 번역 파일로 옮겼다 | 6 | SignIn 칸이 지원 언어 전부에 있다 |
| 같은 글자를 두 곳에서 재사용했다 | 3 | 안내 글자와 낭독기 이름이 칸 하나를 쓴다 |
| 브라우저로 넘길 칸에 추가했다 | 6 | 안 하면 로그인 폼이 사라진다 |
| 빌드만 보고 넘어가지 않았다 | 7 | 서버를 띄워 실제 화면을 확인했다 |
app/actions/의 두 가지 제약을 짚었다 |
3 | 훅 못 씀 + 언어를 따로 받아야 함 |
| 합계 | 25 |
무엇을 옮겼나
SignInFields.tsx에 있던 다섯 개를 옮겼습니다. 하나 짚어둘 게 있어요. 아이디와 비밀번호는 화면에 두 번씩 쓰입니다. 눈에 보이는 안내 글자로 한 번, 화면 낭독기가 읽어주는 이름으로 한 번요. 번역 칸은 하나만 만들고 두 곳에서 씁니다.
// apps/web-next/messages/en.json
"SignIn": {
"username": "Username",
"password": "Password",
"pending": "Checking…",
"submit": "Sign in",
"github": "Continue with GitHub"
}
// apps/web-next/app/components/SignInFields.tsx
const t = useTranslations('SignIn');
// ...
<input
name="username"
aria-label={t('username')}
placeholder={t('username')}
autoComplete="username"
className="w-24 rounded border border-black/15 px-2 py-1"
/>
⚠️ 여기서 오늘 가장 무서운 걸 만났습니다
Step 7에서 우리는 "번역 칸을 안 넘기면 빌드 기록에 MISSING_MESSAGE가 뜬다"를 봤는데, 이번에도 빌드해보면 알겠거니 했어요.
빌드가 조용했습니다.
빌드 결과 성공 (exit 0)
MISSING_MESSAGE 0건
그래서 넘어갔다면 큰일 날 뻔했습니다. 서버를 띄우고 화면을 열어보니 이랬거든요.
요청을 처리하는 동안: MISSING_MESSAGE: SignIn (en) 7번
화면에 뜬 로그인 칸: 아무것도 없음
칸 이름이 대신 뜬 것도 아니고 로그인 폼 자체가 통째로 사라졌습니다. 아이디 칸도, 비밀번호 칸도, 버튼도요.
왜 빌드가 못 잡았을까요
로그인 칸은 지금 로그인했는지 아닌지를 봐야 합니다. 그건 요청이 와야 아는 값이라 미리 그려두지 못해요. 그래서 빌드는 이 부분을 아예 안 그려봅니다. 안 그려보니 없는 칸을 찾을 일도 없었던 거죠.
Step 7의 언어 전환 버튼은 미리 그려지는 곳에 있어서 빌드가 잡았습니다. 같은 실수인데 한쪽은 잡히고 한쪽은 안 잡혔어요.
정리하면 이렇습니다.
| 미리 그려지는 부분 | 요청 때 그려지는 부분 | |
|---|---|---|
| 번역 칸이 없을 때 | 빌드 기록에 뜬다 | 빌드는 조용하다 |
| 언제 알게 되나 | 빌드할 때 | 사용자가 열었을 때 |
고침은 Step 7과 같습니다. 브라우저로 넘길 칸에 하나 더 적으면 돼요.
// apps/web-next/app/[locale]/layout.tsx
<NextIntlClientProvider
messages={{
Nav: messages.Nav,
LocaleSwitcher: messages.LocaleSwitcher,
SignIn: messages.SignIn,
}}
>
고친 뒤 확인했습니다.
요청 처리 중 MISSING_MESSAGE 0건
Username 2건 ← 안내 글자 + 낭독기용 이름
Password 2건
Sign in 1건
Continue with GitHub 1건
브라우저로 넘어간 칸 Nav · LocaleSwitcher · SignIn
app/actions/는 어떻게 하나요
여기 있는 파일들은 브라우저가 아니라 서버에서만 도는 코드입니다. 훅을 쓸 수 없어요. 훅은 화면을 그리는 도중에만 부를 수 있는데, 이 코드는 화면을 그리는 게 아니라 요청을 처리하니까요.
지난 시간에 탭 제목을 번역할 때 만난 것과 같은 상황이라, 그때 쓴 방법을 그대로 쓰면 됩니다. 기다렸다 받는 쪽을 쓰는 것인데, 여기엔 문제가 하나 더 있습니다. 이 코드가 지금 무슨 언어인지 모른다는 거예요. 화면을 그리는 코드는 주소에서 언어를 받지만, 요청을 처리하는 코드는 그 주소를 안 거칠 수도 있습니다.
언어를 어떻게 전달할지 정해야 하는데, 폼에 숨은 칸으로 실어 보내는 방법도 있고, 서버가 요청 정보에서 읽는 방법도 있습니다. 어느 쪽이든 결정을 해야 한다는 게 핵심이에요.
🎯 [과제 2 예시답안] 언어를 하나 더 넣어보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 방향을 빠뜨렸을 때 타입이 먼저 잡는 걸 확인했다 | 8 | 화면에서 발견했다고 적었으면 감점 |
| 두 곳에서 잡힌다는 걸 봤다 | 3 | 목록 + 쓰는 곳 |
독일어에서 1.240을 실제로 확인했다 |
8 | 앱 화면에서 봤는지 |
| 페이지 수 증가를 직접 셌다 | 4 | 24 → 28 |
| 곱셈으로 늘어난다는 걸 짚었다 | 2 | 언어 × 화면 |
| 합계 | 25 |
① 방향을 안 적으면 무엇이 먼저 알려주나
목록에 de만 넣고 방향은 일부러 비워둔 채 타입 검사를 돌렸습니다.
i18n/routing.ts
TS1360 Property 'de' is missing in type
'{ ko: "ltr"; en: "ltr"; ja: "ltr"; ar: "rtl"; }'
but required in type 'Record<"ko"|"en"|"ja"|"ar"|"de", "ltr"|"rtl">'
app/[locale]/layout.tsx
TS7053 Property 'de' does not exist on type ...
화면이 아니라 타입 검사가 먼저 잡습니다. 그것도 두 군데에서요. 빠뜨린 곳(목록)과 그걸 쓰는 곳(화면 껍데기) 양쪽에서 알려줍니다.
오늘 우리는 "빌드는 초록인데 화면이 깨진" 상황을 두 번 만났죠. 여기서는 그게 안 일어납니다. satisfies 한 단어가 미리 막아둔 덕분이에요.
② 독일어 자릿점
Step 2에서 "독일어는 점으로 끊는다" 고 말씀드렸는데, 그때는 우리 앱 밖에서 확인한 것이었고 이번엔 앱 안에서 봅니다.
/en/jaehoon 1,240 followers
/de/jaehoon 1.240 Follower ← 점으로 끊긴다
우리가 코드에서 한 일은 없습니다. 번역 파일에 {followers, plural, ...}라고 적었을 뿐이고, 쉼표냐 점이냐는 언어가 정했어요.
Step 2에서 세 언어가 똑같이 나와서 확인할 수 없었던 것이 네 번째 언어에서 드러났습니다. 규칙이 없었던 게 아니라 우리 언어들이 우연히 같았던 것이죠.
🙋 학생 질문 — "독일어 복수형은 왜 one과 other가 같나요?"
Follower는 독일어에서 단수와 복수가 같은 낱말이라서, 두 갈래를 적었지만 내용이 같아요.
그럼 갈래를 하나로 합치면 안 될까요? 안 하는 게 좋습니다. 독일어는 문법적으로 두 갈래를 가진 언어이고, 다른 낱말(Beitrag / Beiträge)은 실제로 갈라지거든요. 낱말 하나가 우연히 같다고 구조를 없애면, 나중에 그 칸의 문장이 바뀔 때 다시 만들어야 합니다.
③ 미리 그려지는 화면 수
3개국어(ko/en/ja) 20 페이지
4개국어(+ar) 24 페이지
5개국어(+de) 28 페이지
언어 하나에 네 페이지씩 늘어납니다. 우리 앱에 미리 그릴 수 있는 화면이 네 종류이기 때문이에요.
이 숫자는 곱셈으로 늘어납니다. 화면이 늘어도 늘고 언어가 늘어도 늘어요. 지난 시간 마무리에서 "어느 지점부터는 이 판단이 뒤집힐 것 같다" 고 했던 게 이 이야기입니다.
🎯 [과제 3 예시답안] 좁은 화면에서 다시 재보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 실제 폭을 확인하고 쟀다 | 6 | 창만 줄이고 안 확인했으면 감점 |
| 네 언어를 각각 쟀다 | 6 | 한 언어만 재고 결론 내렸으면 감점 |
flex-wrap을 지운 상태와 견줬다 |
7 | 되돌렸는지도 확인 |
| 언어마다 눌리는 칸이 다른 걸 짚었다 | 4 | |
| 언어와 무관한 방식이 낫다는 결론 | 2 | |
| 합계 | 25 |
폭이 줄면 무엇이 달라지나
Step 5에서 잰 것은 머리말 폭이 768px 일 때였습니다. 휴대폰 폭에서는 다릅니다.
측정하실 때 한 가지 주의하실 게 있어요. 브라우저 창을 줄였는데 실제로 안 줄어드는 경우가 있습니다. 개발자 도구의 기기 모드를 쓰거나, 줄인 뒤 실제 폭을 확인하고 재세요. 저도 이걸 확인 안 하고 잴 뻔했습니다.
확인하는 법 — 개발자 도구 콘솔에서
window.innerWidth
flex-wrap을 지우면
Step 5의 한 단어를 지우고 같은 폭에서 다시 보면, 칸들이 다시 눌리고 눌린 칸 안에서 글자가 쪼개져요.
flex-wrap 이 있을 때 flex-wrap 이 없을 때
[브랜드] [홈] [탐색] [브 ] [홈] [탐]
[검색] [언어] [글자크기] [랜 ] [색]
[로그인 폼 ─────────] [드 ]
← 칸이 눌려 글자가 세로로 쌓인다
칸 단위로 줄이 바뀐다
폭이 좁을수록 차이가 큽니다. 768px 에서는 한 칸이 최대 네 줄이었는데, 더 좁아지면 더 늘어나요.
언어마다 다른가
다릅니다. 그리고 어느 칸이 눌리는지가 언어마다 달라요.
Step 5에서 봤듯 영어는 Home·Explore가 한글보다 넓어서 옆의 버튼을 밀어냅니다. 일본어는 브랜드 이름이 가장 넓고요. 아랍어에서는 눌리는 방향이 반대가 됩니다.
여기서 얻을 게 있어요. 한 언어에서 잘 나온다고 다 잘 나오는 게 아닙니다. 언어를 늘릴수록 확인해야 할 조합이 늘어나서, 폭이 모자랄 때 "글자를 쪼개지 말고 칸을 내려보내라" 같은, 언어와 무관하게 동작하는 방식을 쓰는 게 낫습니다.
🎯 [과제 4 예시답안] 고친 것이 다른 것을 되팔지 않았는지 확인하세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
{n}으로 바꿔 자릿점이 사라지는 걸 재현했다 |
8 | 그럴듯하게 추측만 했으면 감점 |
| 복수형 자체는 멀쩡하다는 걸 구분했다 | 4 | 갈래는 맞고 자릿점만 틀리다 |
| 빌드·타입 검사가 통과하는 걸 확인했다 | 5 | |
| 알아챌 방법을 구체적으로 제시했다 | 6 | "잘 보자" 는 감점, 방법이어야 함 |
| 네 자리 이상 숫자로 확인해야 함을 짚었다 | 2 | |
| 합계 | 25 |
재현해봅니다
Step 3에서 복수형을 쓸 때 #를 썼습니다. 대신 이름을 그대로 넣으면 어떻게 될까요.
숫자 # {n} {n, number}
─────────────────────────────────────────────────
1 1 post 1 post 1 post
1240 1,240 posts 1240 posts 1,240 posts
└─ 자릿점이 사라졌다
{n}을 쓰면 Step 2에서 얻은 자릿점이 조용히 사라집니다. 복수형 갈래는 제대로 동작해요. 1 post와 1240 posts로 잘 갈립니다. 오직 자릿점만 없어져요.
빌드와 타입 검사는 통과합니다
빌드 성공
타입 검사 통과
화면 1240 posts ← 틀렸다
당연합니다. 번역 파일은 그냥 글자예요. 안에 {n}이라고 썼는지 #라고 썼는지는 타입 검사가 볼 수 있는 게 아닙니다. 문법도 둘 다 맞고요.
그럼 무엇으로 알아챌 수 있을까요
세 가지를 생각해볼 수 있습니다.
첫째, 얻었을 때 그 값을 적어둡니다. Step 2에서 1,240을 얻었다면 그 숫자를 어딘가에 남겨둡니다. 나중에 다시 잴 때 견줄 게 있어야 하니까요. 기억에 의존하면 1240을 보고도 이상한 줄 모릅니다.
둘째, 자릿점이 생기는 크기의 숫자로 확인합니다. 1 post만 보고 있으면 영영 모릅니다. 자릿점은 네 자리부터 보이니까 확인할 때 그런 숫자를 써야 해요. 우리 앱의 minji는 팔로워가 8,500이라 이 확인에 쓸 수 있습니다.
셋째, 얻은 것 위에 새 걸 쌓을 때만 확인하면 됩니다. 매번 전부 다시 재는 건 현실적이지 않지만, "방금 고친 것과 같은 곳을 또 건드릴 때"는 신호로 삼을 만합니다. Step 3은 Step 2가 고친 바로 그 문장을 다시 썼거든요.
💡 이게 왜 무서운가
오늘 우리는 "빌드는 초록인데 화면이 깨진" 상황을 여러 번 만났는데, 그때는 적어도 뭔가 이상하다는 신호가 있었어요. 기록에 오류가 찍히거나 화면에 칸 이름이 떴죠.
이번 건 다릅니다. 오류도 없고 화면도 그럴듯해요. 1240 posts는 그냥 숫자가 좀 커 보일 뿐입니다. 아무도 이상하다고 안 해요.
조용히 깨지는 것 중에서도 가장 조용한 종류입니다.
생각해볼 주제
🤔 [생각해볼 주제 1] 우리가 지원하는 언어가 우연히 비슷할 때
문제 상황 요약
Step 2에서 한국어·영어·일본어의 숫자 표기가 전부 같아서, "언어마다 다르다"를 보여줄 수가 없었고 독일어를 빌려와야 했죠. 세 언어만 지원한다면 자릿점을 대충 처리해도 아무 문제가 없습니다.
튜터의 가이드 및 해설
이 판단을 "미리 할까 나중에 할까"로 놓으면 답이 잘 안 나옵니다. 대신 비용의 모양으로 보면 갈라져요.
지금 제대로 하는 비용은 작고 예측 가능합니다. {count}를 {count, number}로 고치는 것, 그게 다예요. 몇 분이면 끝나고 위험도 없습니다.
나중에 고치는 비용은 크고 예측이 안 됩니다. 언어를 추가하는 날, 이미 수백 개로 늘어난 번역 칸을 전부 뒤져야 해요. 게다가 그날은 대개 바쁜 날입니다. 새 시장에 나가기로 한 날이니까요.
즉 이건 "언제 할까"가 아니라 "싼 값에 살까 비싼 값에 살까"의 문제입니다. 값이 이렇게 차이 나면 미리 하는 게 거의 항상 맞아요.
다만 반대 방향도 있습니다. 미리 하는 비용이 작지 않은 경우요. 예를 들어 "언젠가 오른쪽에서 왼쪽으로 쓰는 언어가 올지 모르니 지금 모든 여백을 논리 속성으로 바꾸자"는 판단은 다릅니다. 우리는 네 곳이라 쌌지만, 수천 곳인 앱이라면 그렇지 않아서, 기준은 이렇게 잡습니다. 지금 하는 비용이 작고, 나중 비용이 크게 흩어지면 미리 한다. 둘 다 크면 그때는 "그 언어가 정말 올 것인가"를 따져야 하고요.
"나중이 왔다는 걸 어떻게 아느냐"는 질문에는 답이 하나 있습니다. 우리가 오늘 한 것처럼 아직 지원하지 않는 언어로 한 번 찍어보는 것입니다. 독일어를 실제로 넣지 않고도 숫자 하나만 독일어로 찍어보면 5초 만에 알 수 있었어요.
🎯 면접관을 홀리는 핵심 멘트
"지원 언어가 셋인데 셋 다 숫자 표기가 같아서, 자릿점 버그가 세 언어에서 똑같이 안 보이고 있었습니다. 지원하지 않는 언어로 한 번 찍어보니 바로 드러났고요. 테스트에 없는 조건을 하나 넣어보는 게, 있는 조건을 열 번 더 도는 것보다 빨랐습니다."
🤔 [생각해볼 주제 2] 고쳤더니 증거가 사라졌습니다
문제 상황 요약
Step 5에서 칸이 눌리는 걸 고쳤더니, Step 6에서 보여드리려던 일본어 줄바꿈 문제가 같이 사라졌습니다. 증상은 없어졌지만 원인은 그대로였어요. 자리가 넉넉할 때만 안 보이는 것이었죠.
튜터의 가이드 및 해설
"고쳐졌다"와 "지금은 안 보인다"를 가르는 건 원인을 말할 수 있느냐입니다.
증상만 보고 있으면 둘을 구별할 수 없어요. 화면이 멀쩡하다는 사실은 양쪽 모두에서 똑같이 관찰되니까요. 구별하려면 "왜 그랬는지"를 알아야 하고, 그걸 알면 그 원인을 다시 만들어낼 수 있습니다.
오늘 제가 한 게 그거예요. 일본어가 쪼개진 이유가 "칸이 좁아서" 였다면, 좁은 칸을 일부러 만들면 다시 나와야 합니다. 만들어봤더니 나와서, 원인이 그대로 있다는 걸 알았습니다.
이걸 뒤집으면 확인 방법이 됩니다. 고쳤다고 생각한 뒤, 그 증상을 일부러 다시 만들어보세요. 안 나오면 진짜 고친 겁니다. 나오면 조건이 바뀐 것뿐이에요.
실무에서 이게 특히 중요한 곳이 있습니다. 타이밍에 의존하는 버그요. "가끔 실패한다" 던 것이 로그를 추가하니 안 나타나는 경우가 있는데, 대개 고쳐진 게 아니라 로그가 시간을 조금 늦춰서 안 보이게 된 겁니다. 로그를 빼면 돌아오는데, 저는 버그를 고칠 때 순서를 이렇게 둡니다. 먼저 마음대로 재현할 수 있게 만들고, 그다음에 고칩니다. 재현이 안 되는 상태에서 고치면 고쳐졌는지 알 방법이 없거든요.
🎯 면접관을 홀리는 핵심 멘트
"증상이 사라진 것과 원인이 사라진 것은 다릅니다. 저는 고친 뒤에 그 증상을 일부러 다시 만들어봅니다. 안 나오면 고친 거고, 나오면 조건이 바뀌어서 가려진 것뿐이니까요. 그래서 고치기 전에 재현부터 확실히 잡아둡니다."
🤔 [생각해볼 주제 3] 빌드가 초록불인데 화면이 깨져 있을 때
문제 상황 요약
오늘 두 번 만났습니다. 번역 칸을 안 넘겨서 라벨이 깨졌을 때, 아랍어 칸을 덜 채워서 탭 제목이 칸 이름으로 떴을 때요. 과제 1에서는 한 걸음 더 나갔죠. 빌드 기록조차 조용한데 화면에서 로그인 폼이 통째로 사라진 겁니다.
그런데 마지막에 우리는 이걸 막는 방법을 하나 썼어요. 방향을 빠뜨리면 타입이 잡게 한 겁니다.
튜터의 가이드 및 해설
먼저 왜 한쪽은 막을 수 있고 한쪽은 어려운지 봅시다.
방향은 목록이 닫혀 있습니다. 언어가 넷이면 방향도 넷이어야 하고, 그 대응은 코드를 안 돌려봐도 알 수 있어서, 타입이 잡습니다.
번역 칸은 다릅니다. 어떤 칸이 필요한지는 화면을 그려봐야 알아요. 조건에 따라 다른 칸을 읽을 수도 있어서, 같은 방식으로는 막기 어렵습니다.
그럼 포기할까요. 아니요, 다른 층에서 막습니다. 몇 가지가 있어요.
타입으로 막기. 번역 파일에서 칸 이름 목록을 뽑아내면, 없는 칸을 부를 때 타입 검사가 잡을 수 있습니다. 우리가 쓰는 도구도 이걸 지원하지만, 이건 "이름을 잘못 썼다"를 잡는 것이지 "번역이 비었다"를 잡는 건 아닙니다.
검사 도구로 막기. 기준 언어에 있는 칸이 다른 언어에도 다 있는지 훑는 것은 간단합니다. 파일 두 개를 비교하면 되니까요. 이건 빌드와 별개로 돌릴 수 있습니다.
그런데 어디까지 막아야 할까요. 여기가 진짜 질문입니다.
"모든 언어의 모든 칸이 채워져야 배포 가능"으로 잡으면 안전해 보입니다. 실제로는 팀이 멈춰요. 새 기능을 만들 때마다 다섯 언어 번역이 다 도착할 때까지 아무것도 못 내보내니까, 사람들은 가짜 번역으로 칸을 채웁니다. 영어를 복사해 넣어두는 거죠. 검사는 통과하고 사용자는 영어를 봅니다. 막으려던 게 그대로 일어나는 셈이죠.
그래서 저는 이렇게 나눕니다.
- 기준 언어(우리는 한국어)는 비면 막는다. 이건 우리가 쓰는 거라 핑계가 없습니다.
- 나머지 언어는 비어도 막지 않되, 몇 개가 비었는지 보이게 한다. 배포는 되지만 아무도 모르는 상태로 두지는 않는 거예요.
- 비었을 때 칸 이름 대신 기준 언어를 보여준다.
Meta.title보다는 한국어 제목이 낫습니다.
핵심은 막는 것과 보이게 하는 것을 구분하는 겁니다. 다 막으면 우회하고, 안 보이게 두면 모릅니다.
🎯 면접관을 홀리는 핵심 멘트
"빌드 성공은 문법이 맞다는 뜻이지 동작한다는 뜻이 아닙니다. 특히 요청 때 그려지는 부분은 빌드가 아예 안 그려보기 때문에 검사에서 통째로 빠집니다. 그래서 검사를 강하게 만들 때는 우회로가 생기는지를 같이 봅니다. 모든 언어 번역을 강제하면 사람들이 영어를 복사해 채우거든요. 막을 것과 보이게 할 것을 나누는 게 검사 설계의 핵심이라고 봅니다."