Layout shift와 CLS
현상 — 글을 읽는데 위쪽 이미지·광고가 늦게 뜨면서 본문이 갑자기 아래로 밀린다. 버튼을 누르려는 순간 버튼이 밀려 엉뚱한 걸 누르게 된다.
용어
- layout shift — 이미 화면에 보이던 요소가 예고 없이 위치를 옮기는 현상 하나하나.
- CLS (Cumulative Layout Shift) — 페이지가 살아 있는 동안 생긴 layout shift를 점수로 매긴 지표. Core Web Vitals 세 지표 중 하나. 0.1 이하면 “좋음”이다.
- 클릭·키 입력 직후(0.5초 이내)에 생긴 이동은 사용자가 예상한 변화로 보고 CLS에 넣지 않는다. 아코디언을 눌러 펼친 것은 layout shift로 치지 않는다.
원인 — 공통점은 자리를 미리 잡아두지 않은 요소가 나중에 끼어드는 것이다. 식당에서 일행 자리를 안 맡아두면 늦게 온 사람이 끼어 앉을 때 모두가 한 칸씩 밀려나는 것과 같다.
width/height(또는 aspect-ratio)를 지정하지 않은 이미지·영상·iframe
- 늦게 삽입되는 배너·광고·쿠키 안내
- 웹폰트가 늦게 로드돼 글자 폭이 바뀌면서 줄바꿈이 달라짐
지시 예문
- “히어로 이미지에 width/height를 지정해서 로드 전에 공간을 예약하고, CLS를 없애줘.”
- “상단 공지 배너가 늦게 들어와 본문을 밀어내. 배너 자리를 min-height로 미리 잡아줘.”
- “웹폰트 교체 때 layout shift가 생겨. 폴백 폰트 크기를 맞춰서 줄바꿈이 안 바뀌게 해줘.”
LCP
현상 — 페이지에 들어가면 헤더·메뉴는 금방 뜨는데, 정작 화면 한가운데 큰 대표 이미지(또는 큰 제목)가 한참 뒤에 나타난다. “페이지가 느리다”는 체감의 대부분이 이것이다.
용어
- LCP (Largest Contentful Paint) — 첫 화면(viewport) 안에서 가장 큰 이미지나 텍스트 덩어리가 그려진 시점. 페이지 로드 시작부터 잰다. Core Web Vitals 세 지표 중 하나이며 2.5초 이하면 “좋음”이다.
- 후보:
<img>, 영상 poster, CSS background-image, 큰 텍스트 블록(제목·문단). 대개 히어로 이미지가 LCP 요소가 된다.
- 식당에 비유하면 반찬이 먼저 깔리는 건 상관없고, 메인 요리가 나온 시각을 재는 것이다.
원인
- 서버 응답 자체가 느림 (HTML이 늦게 옴)
- 렌더링을 막는 CSS·JS가 먼저 다 받아져야 그림을 그리기 시작함
- 히어로 이미지가 너무 무겁거나(원본 PNG 등) 늦게 발견됨
- 히어로 이미지에
loading="lazy"를 걸어 오히려 늦게 받음 — 흔한 실수
- 클라이언트 렌더링(CSR)이라 JS 실행이 끝나야 본문이 생김
지시 예문
- “히어로 이미지가 LCP 요소야. lazy loading 빼고
fetchpriority="high"로 우선 로드해줘.”
- “LCP 이미지를 WebP/AVIF로 바꾸고 화면 크기에 맞는 사이즈만 받게 해줘.”
- “LCP가 3초 넘게 나와. 원인이 서버 응답인지, 이미지인지, 렌더 블로킹 리소스인지 먼저 분석해줘.”
INP
현상 — 버튼을 눌렀는데 0.5초쯤 아무 반응이 없다가 뒤늦게 바뀐다. 입력창에 타이핑하면 글자가 한 박자 늦게 찍힌다. 그래서 같은 버튼을 두세 번 누르게 된다.
용어
- INP (Interaction to Next Paint) — 클릭·탭·키 입력 후 화면에 다음 변화가 그려지기까지 걸린 시간. 스크롤·hover는 재지 않는다. 페이지에서 일어난 상호작용 중 거의 가장 느린 것을 대표값으로 쓴다. Core Web Vitals 세 지표 중 하나이며 200ms 이하면 “좋음”, 500ms 초과면 “나쁨”이다. 2024년 3월에 FID를 대체했다.
- main thread — 브라우저가 JS 실행, 레이아웃 계산, 화면 그리기를 한 줄로 처리하는 작업 통로. 은행 창구 직원이 한 명뿐인 것과 같다. 앞 손님 업무가 길면 뒤 손님(클릭)은 기다린다.
- long task — main thread를 50ms 넘게 붙잡는 작업. INP를 망치는 주범.
원인
- 클릭 핸들러 안에서 무거운 계산·대량 데이터 정렬을 바로 실행
- 상태 하나 바꿨는데 화면 전체가 다시 렌더링됨
- 수천 개 행을 한 번에 DOM에 그리는 긴 목록
- 광고·분석 같은 서드파티 스크립트가 main thread를 점유
지시 예문
- “필터 버튼 누르면 반응이 늦어. 버튼 상태부터 즉시 바꾸고 무거운 필터링은 그다음에 처리해서 INP를 줄여줘.”
- “검색 입력할 때마다 목록 전체가 다시 렌더링돼. 입력은 debounce하고 목록은 virtualization 적용해줘.”
- “INP가 나빠. Performance 패널 기준으로 long task를 찾아서 쪼개줘.”
Stacking context
현상 — 모달·드롭다운에 z-index: 9999를 줬는데도 헤더나 다른 카드 아래에 깔린다. 숫자를 더 키워도 그대로다.
용어
- stacking context(쌓임 맥락) — z-index 순서를 따지는 독립된 층 묶음. z-index는 같은 묶음 안에서만 비교된다. 묶음 안의 자식이 9999여도, 묶음 자체가 다른 묶음보다 아래면 통째로 아래에 깔린다.
- 비유: 스테이플러로 찍은 종이 묶음. 묶음 안에서 종이 순서는 마음대로 바꿀 수 있지만, 묶음 안의 한 장만 빼서 다른 묶음 위로 올릴 수는 없다.
- 새 묶음을 만드는 속성 (부모에 이게 있으면 의심):
position: relative/absolute + z-index 값 지정
position: fixed/sticky
transform, filter, opacity 1 미만, will-change
- flex/grid 자식에
z-index 지정
isolation: isolate (묶음을 일부러 만들 때 쓰는 속성)
원인 — 애니메이션용 transform, 페이드용 opacity 같은 겉보기와 무관한 속성이 부모에 걸려 몰래 새 묶음이 생김. 그래서 z-index만 보고는 원인을 못 찾는다.
지시 예문
- “모달이 헤더 아래 깔려. z-index 올리지 말고, 부모 중 stacking context를 만드는 요소부터 찾아줘.”
- “모달을 portal로 body 바로 아래에 렌더링해서 부모 stacking context에서 빼줘.”
- “z-index 값이 여기저기 9999로 난무해. 레이어 순서를 토큰으로 정리해줘 (dropdown < sticky header < modal < toast).”
Race condition
현상 — 검색어를 “react” → “redux”로 빠르게 바꿨는데, 화면에 “react” 결과가 남는다. 탭을 빠르게 A → B로 눌렀는데 A 탭 데이터가 B 탭에 뜬다. 가끔만 생기고 재현이 잘 안 된다.
용어
- race condition(경쟁 상태) — 여러 작업이 동시에 진행될 때, 끝나는 순서에 따라 결과가 달라지는 버그. 웹에서는 “먼저 보낸 요청이 늦게 도착해 최신 결과를 덮어쓰는” 형태가 가장 흔하다.
- 비유: 배달 앱에서 주소를 A → B로 바꿨는데, 서버에 “A로 변경” 처리가 늦게 끝나 최종 주소가 A로 남는 것. 보낸 순서 ≠ 도착 순서.
- stale response — 이미 쓸모없어진(낡은) 요청의 응답. race condition을 막는 핵심은 이걸 버리는 것이다.
- 네트워크가 빠른 개발 환경에선 잘 안 보이고, 느린 모바일 환경에서 터진다. 그래서 “가끔 이상해요”라는 제보로 들어온다.
원인 — 요청을 보낼 때마다 응답이 오면 무조건 화면에 반영하도록 짜서, 응답이 오는 순서를 보장하지 않음.
해결 방향
- 새 요청을 보낼 때 이전 요청을 취소 (
AbortController)
- 응답이 와도 최신 요청의 응답인지 확인하고 아니면 무시
- 데이터 패칭 라이브러리(TanStack Query, SWR)는 이 처리를 기본으로 해준다
지시 예문
- “검색어를 빠르게 바꾸면 이전 결과가 남아. race condition 같으니, 새 요청 보낼 때 이전 요청은 AbortController로 취소해줘.”
- “탭 전환 시 stale response가 화면을 덮어써. 현재 탭과 다른 응답은 무시하게 해줘.”
- “개발자 도구에서 네트워크를 Slow 4G로 두고 race condition 재현되는지 확인해줘.”
Hydration과 hydration mismatch
현상 — 새로고침 직후 콘솔에 “Hydration failed” / “Text content does not match” 경고가 뜨고, 처음 보인 텍스트가 잠깐 뒤 다른 값으로 바뀐다. 심하면 그 영역이 통째로 다시 그려지며 깜빡인다.
용어
- SSR (Server-Side Rendering) — 서버가 HTML을 완성해서 보냄. 빨리 보이지만, 이 상태의 HTML은 버튼을 눌러도 반응하지 않는 “그림”이다.
- CSR (Client-Side Rendering) — 빈 HTML을 받고 브라우저의 JS가 화면을 만듦.
- hydration — SSR로 받은 HTML에 JS가 이벤트·상태를 붙여 살아 움직이게 만드는 과정. 정상 동작의 이름이다. 마네킹(HTML)에 사람(JS)이 들어가 움직이는 것.
- hydration mismatch(hydration error) — hydration 도중 “서버가 그린 HTML”과 “클라이언트가 같은 코드로 다시 계산한 결과”가 다른 것. 버그의 이름은 이쪽이다. 마네킹 자세와 들어간 사람 자세가 안 맞는 셈.
원인 — 서버와 브라우저에서 같은 코드가 다른 값을 내는 경우
- 실행 시점·환경 차이:
new Date(), Math.random(), 서버와 사용자 타임존 차이
- 브라우저에만 있는 값:
window, localStorage, 화면 너비
- 잘못된 HTML 중첩:
<p> 안에 <div>, <a> 안에 <a> — 브라우저가 HTML을 고쳐 읽어 구조가 달라짐
- 브라우저 확장 프로그램(번역기, 비밀번호 관리자)이 DOM을 먼저 건드림
지시 예문
- “날짜 표시에서 hydration mismatch가 나. 서버에선 자리만 그리고, 실제 시간은 마운트 후 클라이언트에서 채워줘.”
- “localStorage 값으로 테마를 정하다가 hydration error가 나. 서버와 첫 렌더 결과가 같도록 고쳐줘.”
- “hydration error 원인이 잘못된 HTML 중첩인지 먼저 확인해줘 (p 안의 div 등).”
Virtualization
현상 — 주문 내역·로그·채팅처럼 행이 수천 개인 목록에 들어가면 첫 화면이 늦게 뜨고, 스크롤이 버벅이며, 필터를 눌러도 반응이 굼뜨다(INP 악화).
용어
- virtualization(가상화, windowing) — 목록 전체를 DOM에 그리지 않고 지금 화면에 보이는 행 + 약간의 여유분만 그리는 기법. 스크롤하면 화면 밖으로 나간 행을 지우고 새로 보일 행을 그린다. 전체 높이만큼의 빈 공간은 유지해서 스크롤바는 정상처럼 보인다.
- 비유: 영화 세트장. 거리 전체를 짓지 않고 카메라에 잡히는 건물 앞면만 세운다. 카메라가 움직이면 그쪽 세트만 새로 세운다.
- overscan — 빠르게 스크롤할 때 빈칸이 보이지 않도록 화면 위아래에 미리 그려두는 여유 행 수.
- 필요한 이유는 행 크기가 아니라 타이밍 차이다. 스크롤 자체는 브라우저가 main thread와 별도로 즉시 움직이지만, 새 행을 그리는 건 JS(main thread)라 한 박자 늦다. 그 틈에 아직 안 그린 영역이 드러나 흰 빈칸이 번쩍인다. overscan은 미리 그려둔 행으로 이 틈을 메운다.
- 트레이드오프: 크게 잡을수록 빈칸은 줄지만 그리는 행이 늘어 virtualization 효과가 줄어든다. 보통 화면 위아래로 몇 행 정도.
- 세트장 비유로는, 카메라가 빠르게 돌면 세트 짓는 속도가 못 따라가니 옆 건물 몇 채를 미리 세워두는 것.
- 대표 라이브러리: TanStack Virtual, react-window, react-virtuoso
부작용 — 지시할 때 같이 챙길 것
- 브라우저 찾기(Ctrl+F)로 화면 밖 행을 못 찾는다 — 아직 DOM에 없으니까
- 행 높이가 제각각이면 스크롤 위치가 튀거나 계산이 복잡해진다
- 스크린리더가 전체 개수를 모를 수 있다
대안 — 수백 개 수준이면 virtualization까지 필요 없을 수 있다. 페이지네이션, 무한 스크롤, CSS content-visibility: auto가 더 단순한 선택지다.
지시 예문
- “주문 목록이 3천 행이라 스크롤이 버벅여. virtualization 적용해줘. 행 높이는 고정 64px이야.”
- “채팅 목록에 virtualization 넣어줘. 메시지 길이에 따라 높이가 달라서 가변 높이를 지원해야 해.”
- “목록이 200개 정도인데 virtualization이 꼭 필요한지, content-visibility로 충분한지 먼저 판단해줘.”
FOUC · FOUT · FOIT
현상 — 페이지가 뜨는 순간 잠깐 이상한 모습이 보였다가 정상으로 바뀐다. 스타일 없는 맨 HTML이 번쩍이거나, 글꼴이 바뀌며 글자가 출렁이거나, 다크 모드인데 흰 화면이 번쩍인다.
용어 — 전부 “Flash of ~”(~가 번쩍임)의 약자다.
- FOUC (Flash of Unstyled Content) — CSS가 적용되기 전의 맨 HTML이 잠깐 보임. CSS를 늦게 붙이거나 JS로 스타일을 주입할 때 생긴다.
- FOUT (Flash of Unstyled Text) — 웹폰트가 오기 전 대체 글꼴로 먼저 보였다가 바뀜. 글자 폭이 달라 줄바꿈이 바뀌면 layout shift(CLS)로 이어진다.
- FOIT (Flash of Invisible Text) — 웹폰트가 올 때까지 글자가 아예 안 보임. 버튼·제목 자리가 비어 보인다.
- FOUT와 FOIT는
font-display 속성으로 고른다. swap이면 FOUT(일단 보여줌), block이면 FOIT(기다림). 보통 FOUT가 낫다 — 안 보이는 것보다 다른 글꼴이라도 보이는 게 낫기 때문.
- 다크 모드 깜빡임(flash of incorrect theme) — FOUC의 친척. 저장된 테마를 JS가 늦게 읽어서, 첫 화면이 기본(라이트) 테마로 그려졌다가 다크로 바뀐다.
원인 — 공통점은 첫 화면을 그리는 시점에 필요한 정보(CSS·폰트·테마)가 아직 없음.
외울 것 — 약자 셋을 다 외울 필요는 없다. 지시할 때는 “flash” + 무엇이 번쩍이는지만 말하면 AI가 약자로 바꿔 알아듣는다. 약자는 AI 답변·문서를 읽을 때 알아보는 정도면 충분하다.
- 스타일 없는 화면이 flash → FOUC
- 다른 글꼴이 flash → FOUT
- 글자가 안 보이다 나타남 → FOIT (이건 “번쩍”보다 “빈칸”이라 따로 말해야 한다)
- 테마가 flash → 다크 모드 깜빡임
지시 예문
- “새로고침할 때 다크 모드인데 흰 화면이 번쩍여. 테마를 <head>의 인라인 스크립트에서 첫 paint 전에 적용해줘.”
- “웹폰트 로드 때 FOUT로 줄바꿈이 바뀌어. 대체 글꼴 크기를 웹폰트에 맞춰서(size-adjust) layout shift를 없애줘.”
- “제목 글자가 1~2초 안 보이다 나타나. FOIT 같으니 font-display: swap으로 바꾸고 폰트를 preload해줘.”
현상
- 모달 안의 목록을 끝까지 스크롤했는데, 계속 굴리니 뒤 배경 페이지가 대신 스크롤된다.
- 모달을 열면 뒤 페이지가 옆으로 살짝 움찔한다.
용어
- scroll chaining — 안쪽 스크롤 영역이 끝에 닿으면 남은 스크롤이 바깥(부모·페이지)으로 넘어가는 브라우저 기본 동작. 버그가 아니라 기본값이라서 막아야 한다.
- CSS
overscroll-behavior: contain을 안쪽 영역에 주면 넘어가지 않는다.
- 비유: 위층 욕조가 넘치면 아래층으로 물이 샌다.
contain은 욕조 둘레에 턱을 세우는 것.
- scroll lock(body scroll lock) — 모달·드로어가 열려 있는 동안 뒤 페이지 스크롤을 아예 막는 것. 보통
body에 overflow: hidden을 건다. 모바일 Safari는 동작이 달라 실제 기기에서 따로 확인해야 한다.
- scrollbar shift — scroll lock으로 스크롤바가 사라지면 그 폭(데스크톱 기준 보통 15px 안팎)만큼 페이지가 넓어지면서 내용이 옆으로 밀리는 현상. 옆으로 움찔하는 원인이 이것이다. layout shift의 일종.
scrollbar-gutter: stable로 스크롤바 자리를 항상 비워두거나, 사라진 폭만큼 padding-right를 보충해 막는다.
원인 정리
- 배경이 스크롤됨 → scroll chaining (또는 scroll lock 없음)
- 모달 열 때 옆으로 움찔 → scroll lock이 만든 scrollbar shift
지시 예문
- “모달 안 목록 끝에서 배경이 스크롤돼. 목록에 overscroll-behavior: contain 걸고, 모달 열린 동안 body scroll lock 해줘.”
- “모달 열 때 페이지가 옆으로 움찔해. scroll lock 때문에 생긴 scrollbar shift니까 scrollbar-gutter: stable로 막아줘.”
텍스트 넘침과 줄바꿈
현상
- 한글 문장이 “안녕하세 / 요”처럼 단어 중간에서 끊겨 줄바꿈된다.
- 긴 URL·영문 단어가 카드 밖으로 삐져나간다.
- 제목이 길어 카드 높이가 제각각이 된다. 말줄임(…)을 걸었는데 flex 안에서는 안 먹는다.
용어
- truncate(말줄임, ellipsis) — 한 줄을 넘는 텍스트를
…로 자르기. overflow: hidden + white-space: nowrap + text-overflow: ellipsis 세 개가 함께 있어야 동작한다. Tailwind에서는 truncate.
- line clamp — 정해진 여러 줄까지만 보여주고
…로 자르기. “2줄까지만” 같은 요구. Tailwind에서는 line-clamp-2.
word-break: keep-all — 한글·중국어·일본어(CJK)는 기본값이 글자 단위 줄바꿈이라 단어가 쪼개진다. keep-all은 영어처럼 띄어쓰기 단위로만 끊는다. 한국어 서비스에서 거의 기본으로 건다.
overflow-wrap: anywhere(또는 break-word) — 띄어쓰기 없는 긴 URL·영문을 필요하면 중간에서라도 끊어 영역 밖으로 나가지 않게 함. keep-all과 함께 쓰면 “평소엔 단어 단위, 너무 길면 강제로 끊기”가 된다.
min-width: 0 — flex 자식은 기본적으로 내용보다 작아지지 않으려 한다(min-width: auto). 그래서 flex 안에서는 truncate가 안 먹고 부모를 밀어낸다. 자식에 min-width: 0(Tailwind min-w-0)을 주면 줄어들 수 있게 되어 말줄임이 동작한다.
지시 예문
- “본문 한글이 단어 중간에서 끊겨. word-break: keep-all 적용하고, 긴 URL은 overflow-wrap: anywhere로 넘치지 않게 해줘.”
- “카드 제목은 2줄 line clamp, 설명은 1줄 truncate로 맞춰서 카드 높이를 통일해줘.”
- “flex 행 안의 파일명에 truncate가 안 먹고 버튼을 밀어내. 파일명 쪽에 min-width: 0 줘줘.”
Skeleton · spinner · optimistic UI
현상
- 데이터를 불러오는 동안 화면이 텅 비었다가 한 번에 뜬다. 또는 뜨면서 내용이 밀린다.
- 응답이 금방 오는데도 스피너가 0.1초 번쩍이고 사라져 오히려 거슬린다.
- 좋아요를 누르면 서버 응답을 기다리느라 하트가 한 박자 늦게 채워진다.
용어 — 모두 “기다림을 어떻게 보여줄까”에 대한 선택지다.
- spinner — 빙글빙글 도는 표시. 얼마나 걸릴지 모를 때, 버튼·작은 영역의 짧은 작업에 쓴다.
- progress bar — 진행률 막대. 얼마나 남았는지 알 수 있을 때(파일 업로드 등) 쓴다.
- skeleton — 실제 콘텐츠 모양을 회색 상자로 미리 그려둔 자리표시. 목록·카드·프로필처럼 화면 영역을 채우는 로딩에 쓴다. 체감 대기가 줄고, 실제 콘텐츠와 크기를 맞추면 layout shift(CLS)도 막는다.
- optimistic UI(낙관적 업데이트) — 서버 응답을 기다리지 않고 성공했다고 가정하고 화면부터 바꾸는 방식. 실패하면 원래대로 되돌리고(rollback) 알린다.
- 비유: 카카오톡 메시지. 보내자마자 말풍선이 뜨고, 전송 실패하면 그제야 빨간 느낌표가 붙는다.
- 좋아요·북마크·체크박스처럼 거의 실패하지 않고 되돌리기 쉬운 동작에만 쓴다. 결제·삭제처럼 실패하면 곤란한 동작엔 쓰지 않는다.
- spinner 지연 표시 — 응답이 빠르면 스피너를 아예 안 보여주도록 일정 시간(예: 300ms) 뒤에만 띄우는 요령. 번쩍임을 막는다.
지시 예문
- “상품 목록 로딩 중에 화면이 비어. 카드 크기와 같은 skeleton으로 바꿔서 CLS도 같이 막아줘.”
- “좋아요 버튼을 optimistic UI로 바꿔줘. 요청 실패 시 rollback하고 토스트로 알려줘.”
- “저장 버튼 스피너가 번쩍여. 300ms 넘게 걸릴 때만 스피너를 보여줘.”
Cache와 cache invalidation
현상
- 글을 수정·삭제했는데 목록으로 돌아가면 예전 내용이 그대로 보인다. 새로고침해야 바뀐다.
- 새로 배포했는데 내 브라우저에서만 옛 화면이 보인다.
- 로컬에선 바로 반영되는데 배포 환경에선 몇 분 뒤에야 반영된다.
용어
- cache(캐시) — 한 번 받은 결과를 저장해두고 다음엔 다시 묻지 않고 꺼내 쓰는 것. 빠르지만 원본이 바뀌어도 모른다.
- 비유: 휴대폰 연락처. 친구가 번호를 바꿔도 내 폰엔 옛 번호가 남아 있다.
- stale data — 캐시에 남은 낡은 데이터. 앞서 배운 stale response와 같은 “stale(상한)”이다.
- cache invalidation(캐시 무효화) — “이 캐시는 이제 낡았으니 다음엔 새로 받아와”라고 표시하는 것. 데이터를 바꾼 직후 관련 캐시를 무효화해야 화면이 맞는다.
- revalidate — 캐시를 새 데이터로 다시 채우는 것. Next.js의
revalidatePath·revalidateTag, TanStack Query의 invalidateQueries가 이 역할이다.
- 캐시는 여러 층에 있다 — 어느 층 문제인지 짚어야 지시가 정확해진다.
- 브라우저 캐시: 내 브라우저만 옛것을 봄 → 강력 새로고침(Cmd+Shift+R)으로 확인
- CDN 캐시: 배포 후 모든 사용자가 한동안 옛것을 봄
- 서버 데이터 캐시: Next.js 등 프레임워크가 서버에서 보관한 결과
- 클라이언트 데이터 캐시: TanStack Query·SWR이 브라우저 메모리에 보관한 결과
원인 — 데이터를 바꾸는 코드(mutation)는 있는데, 그 데이터를 보여주는 캐시를 무효화하는 코드가 없음.
지시 예문
- “글 수정 후 목록에 옛 제목이 보여. 수정 성공 시 목록 쿼리를 invalidate해줘.”
- “Next.js server action에서 글 삭제 후 /posts 페이지가 갱신 안 돼. revalidatePath로 캐시 무효화해줘.”
- “배포 후 일부 사용자만 옛 화면을 봐. 브라우저 캐시인지 CDN 캐시인지 먼저 구분해줘.”
CORS
현상 — 프론트에서 API를 호출하면 콘솔에 빨간 에러가 뜬다: “Access to fetch at … has been blocked by CORS policy”. 그런데 같은 주소를 Postman·curl로 부르면 멀쩡히 응답이 온다.
용어
- origin(출처) —
프로토콜 + 도메인 + 포트 세 개의 묶음. 하나라도 다르면 다른 출처다. localhost:3000과 localhost:8000도 서로 다른 출처.
- same-origin policy(동일 출처 정책) — 브라우저는 기본적으로 다른 출처의 응답을 JS가 읽지 못하게 막는다. 악성 사이트가 내 로그인 정보로 은행 API를 몰래 읽는 걸 막기 위한 보안 장치.
- CORS (Cross-Origin Resource Sharing) — 서버가 응답 헤더(
Access-Control-Allow-Origin)로 “이 출처는 읽어도 돼”라고 허락해주는 규칙. 허락이 없으면 브라우저가 응답을 버린다.
- 비유: 아파트 경비실. 방문객(다른 출처)은 입주민(서버)이 방문 명단에 올려야 들어갈 수 있고, 막는 건 경비(브라우저)다. Postman엔 경비가 없어서 그냥 통과한다.
- preflight — JSON 전송·커스텀 헤더·PUT/DELETE 같은 요청 전에 브라우저가
OPTIONS 요청으로 먼저 “보내도 되나요?” 묻는 것. 서버가 OPTIONS에 제대로 답하지 않으면 본 요청은 아예 나가지 않는다.
원인 — 서버가 허락 헤더를 안 보냄. 그래서 고칠 곳은 프론트가 아니라 서버(또는 프록시)다.
- 흔한 잘못된 처방: 프론트에서
mode: 'no-cors' → 에러는 사라지지만 응답 내용을 못 읽는다.
- 쿠키를 함께 보낼 때는
Access-Control-Allow-Origin: *(전부 허용)가 안 된다. 출처를 정확히 적어야 한다.
지시 예문
- “localhost:3000에서 API(localhost:8000) 호출 시 CORS 에러가 나. 서버에서 3000 출처를 허용하고 preflight(OPTIONS)도 처리해줘.”
- “CORS 에러를 프론트에서 우회하지 말고, Next.js rewrites로 같은 출처 프록시를 만들어줘.”
- “쿠키 인증 요청에서만 CORS 에러가 나. credentials 설정과 Allow-Origin이 *인지 확인해줘.”
Re-render와 무한 루프
현상
- 화면이 멈추고 콘솔에 “Too many re-renders” 또는 “Maximum update depth exceeded” 에러가 뜬다.
- 네트워크 탭에 같은 API 요청이 끝없이 찍힌다.
- 입력창 한 글자 칠 때마다 상관없는 영역까지 버벅인다.
용어 (React 기준)
- render / re-render — 컴포넌트 함수를 다시 실행해 화면을 새로 계산하는 것. state·props가 바뀌거나, 부모가 re-render되면 일어난다.
- 불필요한 re-render — 바뀐 게 없는데 다시 계산되는 것. 많아지면 INP가 나빠진다.
- 무한 루프(infinite re-render) — re-render가 state를 바꾸고, 그게 또 re-render를 부르는 순환. React가 일정 횟수에서 강제로 멈추며 위 에러를 낸다.
- dependency array(의존성 배열) —
useEffect(fn, [a, b])의 [a, b]. “a나 b가 바뀌면 fn을 다시 실행해”라는 뜻.
- 참조 동일성(referential equality) — 객체·배열·함수는 내용이 같아도 새로 만들면 다른 것으로 취급된다(
{} !== {}). 컴포넌트 안에서 만든 객체는 render마다 새로 만들어진다.
- 비유: 같은 내용으로 명함을 새로 인쇄해도 “새 명함”이다. React는 내용이 아니라 “같은 종이인가”를 본다.
원인 — 무한 루프의 전형
useEffect의 의존성 배열에 render마다 새로 만들어지는 객체·함수가 들어감
- 그래서 매 render마다 “바뀌었다”고 판단해 effect 실행 → effect 안에서
setState → re-render → 1로 돌아감
지시 예문
- “목록 API가 무한으로 호출돼. useEffect 의존성 배열에 매 render마다 새로 생기는 객체가 있는지 확인하고 고쳐줘.”
- “Maximum update depth exceeded 에러가 나. setState가 render나 effect에서 순환하는 지점을 찾아줘.”
- “입력할 때 사이드바까지 re-render돼. React DevTools로 불필요한 re-render를 찾아서 줄여줘.”
모바일 viewport: 100vh 문제와 safe area
현상
- 데스크톱에선 화면에 딱 맞던 풀스크린 영역이, 모바일에선 하단이 잘려 고정 버튼이 주소창 뒤로 숨는다.
- 스크롤하면 주소창이 접히면서 레이아웃이 들썩인다.
- 아이폰에서 하단 고정 버튼이 홈 인디케이터(하단 막대)와 겹치거나, 상단 내용이 노치에 가린다.
용어
- viewport — 브라우저에서 실제로 페이지가 보이는 창 영역.
100vh 문제 — 모바일 브라우저는 주소창·툴바가 나타났다 사라지는데, vh는 툴바가 사라진 가장 큰 상태 기준이다. 그래서 툴바가 보일 땐 100vh가 실제 보이는 높이보다 커서 아래가 잘린다.
- 새 viewport 단위 — 이 문제를 풀려고 생긴 단위들:
svh (small) — 툴바가 보일 때 높이. 절대 안 잘림. 기본 선택.
lvh (large) — 툴바가 숨었을 때 높이. 기존 vh와 같다.
dvh (dynamic) — 툴바 상태에 따라 실시간으로 변함. 딱 맞지만 스크롤 중 레이아웃이 들썩일 수 있다.
- safe area — 노치·둥근 모서리·홈 인디케이터를 피한, 내용을 안전하게 놓을 수 있는 영역. CSS
env(safe-area-inset-bottom) 등으로 그 여백 크기를 받는다. viewport meta에 viewport-fit=cover가 있어야 값이 들어온다.
- 비유: 액자 유리 가장자리를 덮는 테두리(매트). 그림을 그 안쪽에 둬야 안 가린다.
지시 예문
- “모바일에서 풀스크린 섹션 하단이 잘려. 100vh를 100svh로 바꿔줘.”
- “하단 고정 결제 버튼이 아이폰 홈 인디케이터와 겹쳐. padding-bottom에 env(safe-area-inset-bottom)을 더해줘.”
- “dvh 때문에 스크롤 중 레이아웃이 들썩여. 들썩임이 없어야 하는 영역은 svh로 바꿔줘.”
인터랙션 상태: hover · active · focus-visible
현상
- 모바일에서 버튼을 탭한 뒤 hover 색이 그대로 남아 선택된 것처럼 보인다.
- 버튼을 마우스로 클릭하면 파란 테두리가 생겨서 거슬린다. 그래서 없앴더니 키보드(Tab)로 이동할 때 지금 어디에 있는지 안 보인다.
- 버튼을 눌러도 눌렸다는 느낌이 없다.
용어 — 요소가 사용자 동작에 따라 가지는 상태(state). 디자인 시안에서도 같은 이름으로 부른다.
- hover — 마우스를 올려둔 상태.
- active(pressed) — 누르고 있는 순간. 살짝 어두워지거나 작아지면 “눌렸다”는 피드백이 된다.
- focus — 키보드 입력을 받을 대상으로 선택된 상태. 클릭·Tab 모두로 생긴다.
- focus-visible — focus 중 키보드로 이동했을 때만 표시하는 상태. 마우스 클릭엔 테두리(focus ring)를 숨기고 Tab 이동엔 보여준다.
- disabled — 비활성. 흐리게 + 클릭 불가.
- sticky hover — 터치 기기엔 마우스가 없는데, 탭하면 브라우저가 hover를 흉내 내고 다른 곳을 탭할 때까지 유지하는 현상.
@media (hover: hover) 안에서만 hover 스타일을 주면 막힌다. (Tailwind v4의 hover:는 기본으로 이렇게 동작한다.)
원인
- sticky hover → hover 스타일을 터치 기기 구분 없이 줌
- 파란 테두리 문제 →
:focus에 스타일을 줘서 클릭에도 보임. outline: none으로 통째로 지우면 키보드 사용자가 길을 잃는다(접근성 문제).
지시 예문
- “모바일에서 탭 후 hover 색이 남아. hover 스타일을 hover 가능한 기기에서만 적용해줘.”
- “클릭할 때 focus ring이 거슬려. outline 지우지 말고 focus-visible일 때만 보이게 바꿔줘.”
- “버튼에 active 상태가 없어서 눌린 느낌이 안 나. 누를 때 살짝 작아지게(scale 0.97) 해줘.”
환경 변수(env)
현상
- 로컬에선 잘 되는데 배포하면 API 호출이 실패한다. 로그를 보면 키 값이
undefined.
.env 값을 바꿨는데 반영이 안 된다.
- 브라우저에서
process.env.API_KEY를 읽으면 undefined가 나온다.
용어
- 환경 변수(environment variable, env) — API 키·DB 주소처럼 코드 밖에 두는 설정값. 같은 코드를 로컬·스테이징·프로덕션에서 다른 값으로 돌리기 위해 쓴다.
.env / .env.local — 로컬용 환경 변수 파일. 비밀값이 들어가므로 git에 올리지 않는다. 그래서 배포 환경(Vercel 등)에는 따로 등록해야 한다 — “로컬에선 되는데 배포하면 안 됨”의 1순위 원인.
- public 접두사 — 브라우저 코드에서 읽을 수 있는 변수는 이름에 표시를 붙인다. Next.js는
NEXT_PUBLIC_, Vite는 VITE_. 접두사가 없으면 서버에서만 읽힌다.
- build time 주입 — public 변수는 빌드할 때 값이 코드에 박힌다. 그래서 값을 바꾸면 dev 서버 재시작·재배포(재빌드)를 해야 반영된다.
- secret 노출 — public 접두사를 붙인 값은 누구나 브라우저 개발자 도구로 볼 수 있다. 결제·DB·OpenAI 같은 비밀 키에 붙이면 키가 유출된다.
- 비유: public 변수는 명함에 인쇄한 정보다. 전화번호는 괜찮지만 금고 비밀번호를 인쇄하면 안 된다.
지시 예문
- “배포 환경에서만 API 키가 undefined야. Vercel에 환경 변수가 등록됐는지, 이름이 로컬과 같은지 확인해줘.”
- “이 키가 NEXT_PUBLIC_으로 브라우저에 노출되고 있어. 서버(API route)에서만 쓰도록 옮기고 접두사 빼줘.”
- “env를 바꿨는데 반영이 안 돼. build time 주입 변수인지 확인하고 재빌드가 필요한지 알려줘.”
현상
- 목록에서 필터·정렬·페이지를 골라뒀는데 새로고침하면 초기화된다. 링크를 공유해도 상대는 기본 화면을 본다.
- 상세 페이지에서 뒤로가기하면 목록이 맨 위로 올라가 있다. 무한 스크롤로 한참 내려갔던 위치가 사라진다.
- 검색창에 글자 칠 때마다 방문 기록이 쌓여서, 뒤로가기를 열 번 눌러야 이전 페이지로 간다.
용어
- URL 상태(URL state) — 화면 상태를 메모리(
useState)가 아니라 주소에 적어두는 것. 새로고침·공유·뒤로가기에 살아남는다. 쇼핑몰 필터처럼 “이 화면을 다시 보고 싶은” 상태에 쓴다.
- 비유: 메모리 상태는 머릿속 기억, URL 상태는 포스트잇. 잠들면(새로고침) 기억은 날아가지만 포스트잇은 남고, 남에게 건네줄 수도 있다.
- query string(search params) — 주소의
? 뒤 부분. /products?category=shoes&sort=price&page=2. 필터·정렬·페이지·검색어를 담는 자리.
- path params — 주소 경로 자체에 들어간 값.
/products/123의 123. “무엇을 보는가(대상)”는 path, “어떻게 보는가(조건)”는 query로 나누는 게 관례.
- push vs replace — 주소를 바꿀 때 방문 기록에 새 칸을 추가(push)할지, 현재 칸을 덮어쓸(replace)지. 검색어 입력·슬라이더처럼 자주 바뀌는 값은 replace로 해야 뒤로가기가 꼬이지 않는다.
- scroll restoration — 뒤로가기 때 이전 스크롤 위치를 되돌려주는 것. 브라우저가 기본으로 해주지만, 데이터를 뒤늦게 불러오는 목록(특히 무한 스크롤)은 복원 시점에 내용이 아직 없어서 실패한다.
지시 예문
- “상품 목록의 필터·정렬·페이지를 query string으로 옮겨서 새로고침·공유해도 유지되게 해줘.”
- “검색어 입력 시 URL을 replace로 바꿔서 방문 기록이 쌓이지 않게 해줘.”
- “상세에서 뒤로가기하면 무한 스크롤 목록이 맨 위로 가. 불러온 데이터를 캐시해서 scroll restoration이 되게 해줘.”
Semantic HTML과 접근성(a11y)
현상
- 클릭은 되는데 Tab으로 이동이 안 되고 Enter로 눌리지 않는 버튼이 있다.
- 링크처럼 생긴 메뉴를 Cmd+클릭해도 새 탭으로 안 열린다. 우클릭에 “새 탭에서 열기”가 없다.
- 체크박스 옆 글자를 눌러도 체크가 안 된다. 작은 체크박스만 정확히 눌러야 한다.
- 아이콘만 있는 버튼을 스크린리더가 그냥 “버튼”이라고만 읽는다.
용어
- semantic HTML(의미 있는 HTML) — 모양이 아니라 역할에 맞는 태그를 쓰는 것.
<button>, <a>, <nav>, <main>, <label> 등. 역할에 맞는 태그를 쓰면 키보드 조작·스크린리더·검색엔진 지원이 공짜로 따라온다.
- 반대말 격인 div soup — 전부
<div onClick>으로 만든 상태. 보기엔 같아도 위 기능이 전부 빠진다.
- 별도 언어가 아니다. semantic HTML도 그냥 HTML이다. 같은 HTML 안에서 태그를 고르는 방식을 가리키는 말이다.
- 비유: 워드에서 제목을 만들 때 “글자 크게 + 굵게”로 꾸미는 것과 “제목 1” 스타일을 적용하는 것. 화면엔 똑같이 보이지만, 목차 자동 생성·탐색 창은 “제목 1”을 쓴 쪽에서만 동작한다. 워드가 “이게 제목이다”를 알기 때문이다. 브라우저도 같다 —
<div>를 버튼처럼 꾸미면 모양만 버튼이고, <button>이어야 브라우저가 “이건 버튼”임을 알고 키보드·스크린리더 기능을 붙여준다.
- 예:
<!-- 겉모습만 버튼: Tab·Enter 안 됨, 스크린리더는 "그룹" 정도로 읽음 -->
<div class="btn" onclick="save()">저장</div>
<!-- 역할까지 버튼: Tab·Enter·Space 자동 지원, "저장, 버튼"으로 읽음 -->
<button class="btn" onclick="save()">저장</button>
- button vs a —
<a href>는 어딘가로 이동(주소가 바뀜), <button>은 동작 실행(저장·열기·삭제). 이동인데 button을 쓰면 새 탭 열기가 안 되고, 동작인데 a를 쓰면 이상한 기록이 남는다.
- label — 입력창·체크박스에 붙는 이름표.
<label>로 연결하면 글자를 눌러도 해당 입력이 선택되고(터치 영역 확대), 스크린리더가 이름을 읽는다.
- aria-label — 눈에 보이는 글자가 없는 요소(아이콘 버튼)에 스크린리더용 이름을 붙이는 속성.
<button aria-label="닫기">✕</button>.
- alt — 이미지의 대체 텍스트. 내용 있는 이미지는 설명을, 장식용 이미지는
alt=""(빈 값)를 준다.
- a11y — accessibility의 줄임말. a와 y 사이 글자가 11개라서.
지시 예문
- “div onClick으로 만든 버튼들을 button 태그로 바꿔서 키보드로도 누를 수 있게 해줘.”
- “사이드바 메뉴가 button이라 새 탭으로 안 열려. 페이지 이동이니까 a(Link)로 바꿔줘.”
- “아이콘 전용 버튼에 aria-label 붙이고, 체크박스는 label로 감싸서 글자를 눌러도 체크되게 해줘.”
Bundle과 code splitting
현상
- 첫 페이지 하나 여는 데 JS를 몇 MB씩 받는다. 느린 폰에서 화면은 떴는데 한동안 눌러도 반응이 없다.
- 차트 라이브러리 하나 추가했더니 모든 페이지가 느려졌다.
- 날짜 함수 하나 쓰려고 라이브러리를 넣었는데 용량이 크게 늘었다.
용어
- bundle(번들) — 여러 JS 파일과 라이브러리를 브라우저용으로 묶어놓은 결과 파일. 빌드 도구(webpack, Turbopack, Vite)가 만든다.
- bundle size — 그 묶음의 용량. 크면 받는 시간 + 해석·실행 시간이 늘어 LCP·INP가 함께 나빠진다. 느린 폰일수록 실행 시간이 더 아프다.
- code splitting — 하나의 큰 묶음을 여러 조각으로 나눠 필요할 때 받게 하는 것. Next.js 등은 페이지 단위로 자동으로 나눈다.
- 비유: 이삿짐. 전부 한 트럭에 실어 다 내려야 입주할 수 있는 대신, 당장 필요한 짐만 먼저 들이고 나머지는 나중에 받는 것.
- dynamic import(lazy loading 컴포넌트) — 특정 컴포넌트를 실제로 필요해질 때 불러오는 것. 모달 안의 에디터, 탭 뒤의 차트처럼 첫 화면에 안 보이는 무거운 것에 쓴다.
next/dynamic, React.lazy.
- tree shaking — 라이브러리에서 실제로 쓴 부분만 남기고 나머지를 빌드에서 털어내는 것. 라이브러리 구조·import 방식에 따라 안 될 때가 있다.
- bundle analyzer — 번들 안에 무엇이 얼마나 차지하는지 지도처럼 보여주는 도구. 추측 대신 이걸로 범인을 찾는다.
지시 예문
- “bundle analyzer로 번들에서 가장 큰 라이브러리 5개를 찾아줘.”
- “차트 컴포넌트가 대시보드 탭에서만 쓰여. dynamic import로 바꿔서 다른 페이지 번들에서 빼줘.”
- “날짜 처리에 쓰는 라이브러리가 너무 커. tree shaking 되는 가벼운 대안이나 내장 Intl API로 바꿔줘.”
Open Graph와 링크 미리보기
현상
- 카카오톡·슬랙에 링크를 붙여넣었는데 미리보기 카드가 안 뜨거나, 제목만 덩그러니 나온다.
- 모든 페이지 링크가 똑같은 사이트 기본 이미지·제목으로 뜬다.
- 이미지를 바꿨는데 카톡에선 계속 예전 미리보기가 나온다.
용어
- Open Graph(OG) 태그 — 링크 미리보기 카드에 쓸 정보를
<head>에 적어두는 <meta> 태그 묶음. 페이스북이 만든 규격인데 카카오톡·슬랙·디스코드 등이 공통으로 읽는다.
og:title 제목 / og:description 설명 / og:image 썸네일 / og:url 대표 주소
- X(트위터)는
twitter:card 등을 함께 본다. 큰 이미지 카드는 summary_large_image.
- OG image — 미리보기 썸네일. 보통 1200×630 비율로 만든다. 주소는
https://부터 시작하는 절대 경로여야 한다(/og.png처럼 쓰면 못 읽는 곳이 많다).
- 크롤러(crawler) — 미리보기를 만들려고 링크를 대신 방문하는 봇. 대부분 JS를 실행하지 않고 처음 받은 HTML만 읽는다. 그래서 CSR로 JS가 뒤늦게 넣은 메타 태그는 못 본다(SSR 레슨과 연결).
- 비유: 책 표지만 보고 소개 카드를 쓰는 사서. 책을 펴서 읽지 않으니 표지(처음 HTML)에 정보가 없으면 빈 카드가 된다.
- 미리보기 캐시 — 카카오톡·페이스북은 한 번 만든 미리보기를 한동안 저장해 둔다. 바꿔도 바로 반영 안 되는 이유. 카카오는 Kakao Developers의 공유 디버거, 페이스북은 Sharing Debugger로 캐시를 지울 수 있다(Cache와 cache invalidation 레슨과 같은 원리).
지시 예문
- “게시글 상세 페이지마다 og:title·og:description·og:image를 글 내용으로 넣어줘. 서버에서 렌더링되는 HTML에 들어가야 해.”
- “og:image가 상대 경로라 카톡에서 안 떠. 절대 URL로 바꿔줘.”
- “글마다 제목이 들어간 1200×630 OG 이미지를 자동 생성해줘.”
Empty state · error state · error boundary
현상
- 검색 결과가 없을 때 아무것도 안 나오는 흰 화면이라, 로딩 중인지 결과가 없는 건지 모른다.
- API가 실패하면 로딩 스피너가 영원히 돈다.
- 위젯 하나에서 에러가 났는데 페이지 전체가 하얗게 날아간다.
- 폼 검증 에러를 토스트로 띄워서, 어느 칸이 틀렸는지 모른다.
용어
- UI 상태 4종 — 데이터를 보여주는 화면은 최소 네 가지 상태를 설계해야 한다. 시안에 “성공” 화면만 있고 나머지가 빠지는 게 흔한 구멍이다.
- loading(불러오는 중) — skeleton·spinner 레슨
- empty(데이터 없음)
- error(실패)
- success(정상)
- empty state — 결과가 0개일 때의 화면. “없음”만 말하지 말고 이유 + 다음 행동을 준다. “아직 작성한 글이 없어요 → [첫 글 쓰기]”, “검색 결과가 없어요 → 필터 초기화”.
- error state — 실패했을 때의 화면. 무슨 일인지 + 재시도 버튼. 스피너가 영원히 도는 건 error state가 없다는 신호.
- white screen(흰 화면) — 렌더링 중 처리되지 않은 에러 하나 때문에 React 앱 전체가 사라지는 현상.
- error boundary — 에러를 그 구역 안에서 가두는 울타리 컴포넌트. 안쪽에서 터지면 그 부분만 대체 화면(“문제가 생겼어요 [다시 시도]”)으로 바꾸고 나머지 페이지는 살린다. Next.js에서는 경로별
error.tsx가 이 역할.
- 비유: 아파트 방화문. 한 집에 불이 나도 방화문이 번지는 걸 막아 건물 전체가 타지 않는다.
- 한계: 클릭 핸들러 안이나 비동기 코드의 에러는 못 잡는다. 그건 try/catch로 따로 처리.
- inline error vs toast — 입력 칸 바로 아래 붙는 에러(inline)는 어디가 틀렸는지 알려야 할 때, 잠깐 떴다 사라지는 토스트는 저장 완료·전송 실패 같은 결과 알림에 쓴다. 폼 검증 에러를 토스트로 띄우면 사용자가 위치를 못 찾는다.
지시 예문
- “주문 목록에 loading·empty·error 상태가 다 있는지 확인하고, 빠진 걸 만들어줘. empty엔 ‘쇼핑하러 가기’ 버튼.”
- “차트 위젯 에러로 대시보드 전체가 흰 화면이 돼. 위젯별로 error boundary를 씌워서 그 위젯만 대체 화면을 보여줘.”
- “회원가입 폼 검증 에러를 토스트 말고 각 입력 칸 아래 inline error로 바꿔줘.”
인증: 쿠키 · 세션 · 토큰
현상
- 로그인했는데 새로고침하면 로그아웃된다.
- 한참 쓰다 보면 갑자기 로그인 화면으로 튕긴다. 작성 중이던 글이 날아간다.
- 로그인은 됐는데 관리자 페이지에서 “권한 없음”이 뜬다.
용어
- authentication(인증) vs authorization(인가) — 인증은 “누구냐”(로그인), 인가는 “이걸 해도 되냐”(권한). HTTP 상태 코드로는 401 = 누군지 모름(로그인 필요), 403 = 누군지는 아는데 권한 없음.
- cookie(쿠키) — 브라우저가 사이트별로 저장해두고, 그 사이트에 요청할 때마다 자동으로 같이 보내는 작은 값. 로그인 상태를 실어 나르는 수단.
- session(세션) — 로그인 상태를 서버가 장부에 기록하고, 브라우저 쿠키엔 장부 번호(session ID)만 두는 방식.
- 비유: 호텔 카드키. 카드엔 번호만 있고, 어느 방을 열 수 있는지는 프런트 장부가 안다. 프런트에서 장부를 지우면 카드는 즉시 무효.
- token(토큰, 흔히 JWT) — 로그인 정보를 위조 방지 서명과 함께 토큰 자체에 담아 브라우저가 들고 다니는 방식. 서버가 장부를 안 봐도 된다.
- 비유: 놀이공원 손목밴드. 밴드만 보고 입장시킨다. 대신 중간에 무효화하기 어려워서 유효기간을 짧게 잡는다.
- access token / refresh token — access token은 수명이 짧은(예: 15분) 출입증, refresh token은 출입증을 새로 발급받는 교환권. 갑자기 튕기는 건 대개 access token 만료 후 갱신(refresh)이 안 돼서다.
- httpOnly 쿠키 — JS가 읽을 수 없는 쿠키. 토큰을
localStorage에 두면 악성 스크립트(XSS)가 훔쳐갈 수 있어서, 민감한 토큰은 httpOnly 쿠키에 두는 게 보통 더 안전하다.
한 장면으로 묶은 비유: 헬스장
- 핵심: 쿠키는 세션·토큰과 같은 층의 선택지가 아니다. 세션·토큰은 “무엇을 들고 다니나”, 쿠키는 “어디에 넣고 다니나”다.
| 웹 |
헬스장 |
| session |
회원카드에 번호만 있고, 카운터 PC 장부에서 조회해 입장. 해지하면 즉시 막힘. 대신 매번 장부 조회가 필요 |
| token(JWT) |
회원권 자체에 이름·만료일·위조 방지 도장이 찍힘. 직원은 도장만 보고 통과. 장부 조회는 필요 없지만, 잃어버려도 회수가 어려워 짧게 발급 |
| access / refresh token |
매일 새로 받는 1일권과, 1일권을 재발급받는 정기권 |
| cookie |
카드를 넣는 목걸이 카드지갑. 입구를 지날 때마다 리더기에 자동으로 찍힘. 세션 번호도 토큰도 여기 넣을 수 있다 |
| localStorage |
가방 속. 입구마다 직접 꺼내 보여줘야 하고(JS가 요청에 직접 붙임), 소매치기(XSS)가 가방을 뒤질 수 있다 |
| httpOnly 쿠키 |
손으로 못 여는 목걸이 지갑. 입구 리더기만 읽을 수 있어 소매치기가 못 꺼낸다 |
원인
- 새로고침 로그아웃 → 로그인 상태를 메모리(state)에만 둠 (URL 상태 레슨의 “머릿속 기억”과 같은 문제)
- 갑자기 튕김 → 토큰 만료 후 자동 갱신 없음
- 권한 없음 → 인증은 됐지만 인가(역할 확인)에서 막힘
지시 예문
- “새로고침하면 로그아웃돼. 로그인 상태를 어디에 저장하는지 확인하고 httpOnly 쿠키 기반으로 유지되게 해줘.”
- “access token 만료되면 바로 튕겨. 401이 오면 refresh token으로 갱신 후 원래 요청을 재시도하게 해줘.”
- “로그인 안 한 경우는 401로 로그인 페이지에, 권한 없는 경우는 403으로 안내 페이지에 보내줘.”
실시간 갱신: polling · SSE · WebSocket
현상
- 새 알림·새 댓글이 새로고침해야 보인다.
- 채팅 메시지가 몇 초씩 늦게 뜬다. 서버 요청 로그가 사용자 수에 비해 지나치게 많다.
- AI 답변이 한 번에 뜨지 않고 글자가 타자 치듯 흘러나오게 하고 싶다.
- 폰을 잠갔다 켜면 실시간 갱신이 멈춰 있다.
용어 — 서버의 새 소식을 화면에 반영하는 세 가지 방식. 비유는 택배 도착 확인.
- polling — 브라우저가 정해진 간격마다 “새거 있어?” 하고 묻는 방식. 구현이 제일 쉽다. 대신 간격만큼 늦고, 새 소식이 없어도 계속 요청한다.
- 비유: 5분마다 현관문을 열어 택배 왔나 확인하기.
- SSE (Server-Sent Events) — 연결을 열어두고 서버가 브라우저 쪽으로만 소식을 밀어주는 방식(단방향). 알림, 진행률, AI 답변 스트리밍(글자가 흘러나오는 효과)에 잘 맞는다.
- 비유: 택배 기사가 도착하면 문자를 보내준다. 내가 기사에게 말을 걸 수는 없다.
- WebSocket — 연결을 열어두고 양쪽이 아무 때나 주고받는 방식(양방향). 채팅, 공동 편집, 게임처럼 서로 빈번히 말하는 경우.
- streaming(스트리밍) — 응답을 다 만든 뒤 한 번에 보내지 않고 만들어지는 대로 조금씩 보내는 것. SSE가 대표 수단.
- reconnect(재연결) — SSE·WebSocket은 연결이 끊길 수 있다(지하철, 폰 잠금, 서버 재시작). 끊기면 다시 연결하고 놓친 소식을 채워 넣는 처리가 필요하다.
고르는 기준
- 몇 분 늦어도 됨, 단순하게 → polling
- 서버 → 화면 한 방향이면 충분 → SSE
- 양방향으로 자주 주고받음 → WebSocket
지시 예문
- “알림 개수를 30초마다 polling으로 갱신해줘. 탭이 안 보일 땐 멈추게 해줘.”
- “AI 답변을 SSE로 스트리밍해서 글자가 순서대로 나타나게 해줘.”
- “채팅 WebSocket이 폰 잠금 후 끊긴 채로 있어. 다시 화면이 보이면 reconnect하고 그 사이 메시지를 불러와줘.”
웹 보안 기초: XSS · CSRF · injection
현상 — 겉으로는 멀쩡해서 “현상”으로 드러나지 않는 게 특징이다. 대신 이런 코드를 보면 의심한다.
- 댓글·게시글을 HTML 그대로 화면에 넣는 코드(
dangerouslySetInnerHTML, innerHTML)
- 로그인 쿠키만 확인하고 삭제·송금·정보 변경을 처리하는 API
- 검색어·ID 같은 입력값을 문자열로 이어 붙여 만든 DB 쿼리
용어
- XSS (Cross-Site Scripting) — 공격자가 댓글 등에 스크립트를 심어, 그 글을 본 다른 사용자의 브라우저에서 실행되게 하는 공격. 쿠키·토큰 탈취, 대신 글쓰기 등이 가능해진다. (인증 레슨에서 localStorage 토큰이 위험하다고 한 이유.)
- 비유: 게시판 전단지에 “이걸 읽은 사람은 지갑을 꺼내 여기 두시오”라고 적었더니, 읽은 사람이 그대로 따르는 것. 브라우저는 페이지에 있는 스크립트를 의심 없이 실행한다.
- React는 기본적으로 텍스트를 안전하게 바꿔(escape) 넣어서 막아준다. HTML을 직접 넣는 순간 이 보호가 꺼진다.
- 막는 법: 사용자 HTML은 sanitize(소독)해서 넣기(DOMPurify 등), 가능하면 HTML 대신 텍스트로 렌더링.
- CSRF (Cross-Site Request Forgery) — 다른 사이트가 사용자의 브라우저를 시켜 내 사이트로 요청을 보내게 하는 공격. 쿠키는 자동으로 따라가기 때문에(헬스장 목걸이 지갑) 서버는 본인 요청으로 착각한다.
- 비유: 누가 위조한 심부름 쪽지를 들고 헬스장에 가도, 목걸이 카드가 자동으로 찍혀 통과되는 것.
- 막는 법: 쿠키에
SameSite 설정(다른 사이트발 요청엔 쿠키를 안 보냄), CSRF 토큰, GET 요청으로 데이터를 바꾸지 않기.
- injection(SQL injection 등) — 입력값이 명령의 일부로 해석되게 만드는 공격. 검색창에
' OR 1=1 -- 같은 걸 넣어 쿼리 조건을 무력화한다.
- 막는 법: 입력값을 쿼리에 직접 이어 붙이지 말고 parameterized query(매개변수화 쿼리)나 ORM을 쓴다.
- 공통 원칙 — 사용자 입력은 전부 믿지 않는다. 화면에 넣을 땐 escape/sanitize, DB에 넣을 땐 parameterize, 상태 변경 요청은 출처 확인.
지시 예문
- “댓글을 dangerouslySetInnerHTML로 렌더링하고 있어. XSS 위험이니 DOMPurify로 sanitize하거나 텍스트로 렌더링해줘.”
- “인증 쿠키에 SameSite=Lax와 httpOnly, Secure가 설정돼 있는지 확인해줘. CSRF 대비야.”
- “검색 쿼리를 문자열로 이어 붙이고 있어. SQL injection 막게 parameterized query로 바꿔줘.”
- “이 API 전체를 XSS·CSRF·injection 관점에서 점검해줘.”
시간대(timezone)와 날짜 버그
현상
- 로컬에선 시간이 맞는데 배포하면 9시간 차이가 난다.
- 오늘 날짜를 저장했는데 아침 9시 전에만 어제 날짜로 들어간다.
- 생일·마감일이 해외 사용자에게 하루 앞당겨 보인다.
용어
- UTC — 세계 기준 시각. 한국 시간(KST)은 UTC+9. 서버(Vercel·AWS 등)는 보통 UTC로 돈다. 로컬 개발 PC는 KST라서, “로컬은 맞는데 배포하면 9시간 차이”가 생긴다.
- timestamp(시점) — “지구상 모두에게 같은 한 순간”. 저장은 UTC로, 보여줄 때만 사용자 시간대로 변환하는 게 원칙.
- 비유: 국제 화상회의 시각. 회의는 한 순간이지만 서울은 저녁 9시, 런던은 낮 12시로 각자 달리 표기된다. 장부엔 기준 시각 하나만 적고, 초대장에 각자 시간으로 바꿔 보여준다.
- date-only(날짜만) — 생일·마감일·공휴일처럼 시간대가 없어야 하는 날짜. 이걸 timestamp로 저장하면 시간대 변환 때 하루가 밀린다.
2026-10-08 문자열 그대로 다루는 게 안전하다.
- ISO 8601 —
2026-10-08T21:00:00+09:00 같은 표준 표기. 끝의 +09:00(offset)이나 Z(=UTC)가 어느 시간대 기준인지 알려준다. 이게 빠진 날짜 문자열은 해석이 환경마다 달라 위험하다.
- 흔한 함정:
new Date().toISOString().slice(0, 10)로 “오늘 날짜”를 만들면 UTC 기준 날짜가 나온다. 한국 아침 9시 전엔 UTC가 아직 어제라서 어제 날짜가 된다.
- 서버와 브라우저의 시간대가 달라 렌더링 결과가 다르면 hydration mismatch로도 이어진다(6번 레슨).
지시 예문
- “배포 후 시간이 9시간 어긋나. DB엔 UTC로 저장하고, 화면에 보여줄 때만 Asia/Seoul로 변환해줘.”
- “오늘 날짜를 toISOString으로 만들어서 오전 9시 전엔 어제로 저장돼. 사용자 시간대 기준 날짜로 바꿔줘.”
- “생일은 시간대 없는 date-only 값이야. timestamp로 저장하지 말고 YYYY-MM-DD 문자열로 다뤄줘.”
중복 제출과 멱등성(idempotency)
현상
- 결제 버튼을 빠르게 두 번 눌렀더니 주문이 두 건 생겼다.
- 느린 네트워크에서 “저장”을 눌렀는데 반응이 없어 다시 눌렀더니 같은 글이 두 개 올라갔다.
- 회원가입을 두 번 눌러 같은 이메일 계정이 두 개 생겼다.
용어
- double submit(중복 제출) — 같은 요청이 의도치 않게 두 번 이상 서버에 도착하는 것. 더블클릭, 재시도, 새로고침, 여러 탭 등 원인이 다양하다.
- 멱등성(idempotency) — 같은 요청을 여러 번 보내도 한 번 보낸 것과 결과가 같은 성질.
- 비유: 엘리베이터 호출 버튼은 열 번 눌러도 한 번 부른 것과 같다(멱등). 자판기 버튼은 두 번 누르면 음료가 두 개 나온다(비멱등). 주문·결제 버튼은 자판기처럼 동작하면 안 된다.
- HTTP 관례: 조회(GET)·덮어쓰기(PUT)·삭제(DELETE)는 멱등, 생성(POST)은 비멱등. 그래서 문제는 대개 POST에서 터진다.
- 반대말은 따로 없고 non-idempotent(비멱등)라고 부른다.
- 멱등은 서버 상태가 같다는 뜻이지 응답이 같다는 뜻이 아니다. 같은 글을 두 번 DELETE하면 두 번째 응답은 “없음(404)”이지만 결과 상태(글 없음)는 같으니 멱등이다.
- 헷갈리는 짝 safe(안전) — 서버 상태를 아예 안 바꾸는 것. GET은 safe이면서 멱등, DELETE는 상태를 바꾸니 safe는 아니지만 멱등.
- idempotency key(멱등 키) — 한 번의 사용자 동작마다 고유한 번호를 만들어 요청에 붙이고, 서버는 이미 처리한 번호면 다시 처리하지 않고 이전 결과를 돌려주는 방식. 결제 API(Stripe 등)가 쓰는 표준적인 방법.
- 비유: 번호표. 같은 번호표로 두 번 와도 창구는 한 번만 처리한다.
- 방어는 층층이 — 하나만으론 부족하다.
- 화면: 요청 중엔 버튼 disabled + 로딩 표시 (더블클릭 차단)
- 서버: idempotency key로 같은 동작 중복 처리 차단 (재시도·여러 탭 대비)
- DB: unique 제약(예: 이메일 중복 불가)으로 최후 방어
지시 예문
- “주문 버튼을 빨리 두 번 누르면 주문이 두 건 생겨. 요청 중엔 버튼을 disabled 처리하고, 서버에도 idempotency key로 중복 처리를 막아줘.”
- “회원가입 중복 계정이 생겨. users.email에 unique 제약을 걸고, 중복이면 친절한 에러를 보여줘.”
- “이 API들이 재시도돼도 안전한지(멱등한지) 점검해줘.”
HTTP 상태 코드
현상 — 개발자 도구 Network 탭이나 에러 로그에 404, 500, 504 같은 숫자가 찍힌다. 이 숫자만 정확히 전달해도 AI가 원인 범위를 절반 이상 좁힌다.
용어 — 서버가 응답마다 붙이는 세 자리 결과 번호. 첫 자리가 큰 분류다.
- 식당 비유: 2xx는 주문대로 나옴, 3xx는 “저쪽 창구로 가세요”, 4xx는 손님 쪽 문제(주문서 오류·회원 아님), 5xx는 식당 쪽 문제(주방 사고).
| 코드 |
뜻 |
흔한 상황 |
| 200 OK |
성공 |
일반 조회 |
| 201 Created |
새로 만들어짐 |
글 작성·회원가입 성공 |
| 204 No Content |
성공했는데 돌려줄 내용 없음 |
삭제 성공 |
| 301 / 308 |
영구 이동 |
주소 변경. 검색엔진도 새 주소로 옮김 |
| 302 / 307 |
임시 이동 |
로그인 후 원래 페이지로 보내기 |
| 304 Not Modified |
바뀐 게 없으니 캐시 써라 |
브라우저 캐시 재사용(캐시 레슨) |
| 400 Bad Request |
요청 형식이 틀림 |
필수 값 누락, JSON 깨짐 |
| 401 Unauthorized |
누군지 모름 |
로그인 필요·토큰 만료(인증 레슨) |
| 403 Forbidden |
누군지 아는데 권한 없음 |
일반 회원이 관리자 페이지 접근 |
| 404 Not Found |
없음 |
잘못된 주소, 삭제된 글 |
| 409 Conflict |
현재 상태와 충돌 |
이미 있는 이메일로 가입(멱등성 레슨) |
| 422 Unprocessable |
형식은 맞는데 값이 규칙 위반 |
비밀번호 너무 짧음 |
| 429 Too Many Requests |
너무 자주 요청함 |
rate limit(요청 횟수 제한)에 걸림 |
| 500 Internal Server Error |
서버 코드가 터짐 |
처리 안 된 예외, 버그 |
| 502 Bad Gateway |
중간 서버가 뒤쪽 서버에게 이상한 응답을 받음 |
백엔드 서버 다운 |
| 503 Service Unavailable |
지금 처리 불가 |
과부하, 점검 중 |
| 504 Gateway Timeout |
뒤쪽 서버가 너무 늦음 |
서버리스 함수 실행 시간 초과, 느린 DB 쿼리 |
지시할 때 요령
- “에러 나” 대신 코드 + 어떤 요청에서를 말한다. 4xx면 요청·권한 쪽, 5xx면 서버 쪽부터 보게 된다.
- 반대로 API를 만들 때도 알맞은 코드로 응답하게 시키면, 화면에서 상황별 처리(로그인 이동, 권한 안내, 재시도)가 쉬워진다.
지시 예문
- “주문 생성 POST가 배포 환경에서만 504야. 함수 실행 시간 초과인지, DB 쿼리가 느린 건지 확인해줘.”
- “검색 API가 가끔 429를 받아. rate limit 기준을 확인하고, 화면에선 잠시 후 재시도하게 해줘.”
- “회원가입 API가 모든 실패를 500으로 응답해. 입력 오류는 422, 이메일 중복은 409로 나눠줘.”
현상
- 무한 스크롤로 내리는데 같은 글이 두 번 나온다. 또는 어떤 글이 건너뛰어져 아예 안 보인다.
- 피드 뒤쪽 페이지로 갈수록 로딩이 점점 느려진다.
- 무한 스크롤 페이지에서 푸터(회사 정보·약관 링크)에 영영 도달할 수 없다.
용어
- pagination(페이지네이션) — 많은 데이터를 나눠서 조금씩 가져오는 것. 화면 방식과 서버에서 자르는 방식을 따로 고른다.
- offset pagination — “앞에서 20개 건너뛰고 10개 줘” 방식(
page=3, offset=20). 페이지 번호로 바로 점프할 수 있어 단순하다.
- 약점 ①: 보는 도중에 맨 앞에 새 글이 추가되면 전체가 한 칸씩 밀려서, 다음 페이지에 이미 본 글이 또 나온다(삭제되면 반대로 하나를 건너뜀).
- 약점 ②: 뒤로 갈수록 DB가 건너뛸 행을 다 세야 해서 느려진다.
- 비유: “앞에서 21번째 사람부터 10명”으로 줄을 끊는 것. 누가 앞에 새치기하면 순번이 다 밀린다.
- cursor pagination — “마지막으로 본 항목(cursor) 다음부터 10개 줘” 방식(
after=글ID). 앞에 새 글이 생겨도 기준점이 고정이라 중복·누락이 없고 깊이 가도 빠르다. 대신 “5페이지로 바로 가기”는 어렵다.
- 비유: 책갈피. 앞에 페이지가 추가돼도 책갈피 꽂은 자리부터 이어 읽는다.
- 화면 방식 셋
- 페이지 번호 — 위치 공유·특정 페이지 이동이 필요할 때(검색 결과, 관리자 표). offset과 잘 맞는다.
- load more(더 보기 버튼) — 사용자가 원할 때만 더 불러온다. 푸터에 도달 가능.
- infinite scroll(무한 스크롤) — 끝에 닿으면 자동으로 불러온다. 피드·SNS에 맞고 cursor와 잘 맞는다. 푸터 접근 불가·scroll restoration(18번 레슨) 문제를 함께 챙겨야 한다.
지시 예문
- “피드 무한 스크롤에서 글이 중복으로 떠. offset 방식이라 새 글이 끼면 밀리는 것 같아. cursor pagination으로 바꿔줘.”
- “관리자 주문 표는 페이지 번호로 이동해야 하니 offset pagination 유지하고, 페이지를 URL query에 넣어줘.”
- “상품 목록이 무한 스크롤이라 푸터에 못 가. load more 버튼 방식으로 바꿔줘.”
반응형: breakpoint · mobile-first · container query
현상
- 태블릿 폭에서만 레이아웃이 어중간하게 깨진다(카드가 너무 좁거나 텍스트가 겹침).
- Tailwind에
sm:hidden을 줬는데 폰에서 안 숨고 오히려 큰 화면에서 숨는다.
- 같은 카드 컴포넌트가 본문에선 멀쩡한데 좁은 사이드바에 넣으면 찌그러진다.
용어
- 반응형(responsive) — 화면 폭에 따라 레이아웃이 알맞게 바뀌는 것.
- breakpoint — 레이아웃이 바뀌는 기준 폭. Tailwind 기본값:
sm 640px, md 768px, lg 1024px, xl 1280px, 2xl 1536px.
- 비유: 옷 사이즈 S·M·L의 경계. 몸(화면)이 경계를 넘으면 다른 사이즈(레이아웃)를 입힌다.
- media query — “화면 폭이 ~이상이면 이 스타일” 같은 조건문(
@media (min-width: 768px)). 화면(viewport) 전체 폭 기준.
- mobile-first — 작은 화면을 기본으로 짜고, 큰 화면용 스타일을 덧붙여 가는 방식. Tailwind가 이 방식이라
md:flex는 “768px 이상에서 flex”라는 뜻이다.
- 흔한 착각:
sm:을 “폰에서”로 읽는 것. 실제론 “640px 이상에서“다. 폰 전용 스타일은 접두사 없이 기본값으로 쓰고, 큰 화면에서 덮어쓴다.
- container query — 화면 폭이 아니라 컴포넌트가 들어간 부모 칸의 폭을 기준으로 바뀌는 것(
@container). 같은 카드가 넓은 본문에선 가로형, 좁은 사이드바에선 세로형으로 알아서 바뀐다. Tailwind v4는 @container·@md: 등을 기본 지원.
- 비유: media query는 방 크기를 보고 가구 배치를 정하고, container query는 가구가 들어간 칸의 크기를 보고 접을지 펼칠지 정한다.
- fluid(유동형) — breakpoint에서 뚝뚝 바뀌지 않고 폭에 따라 연속적으로 커지고 작아지게 하는 것. 글자 크기에
clamp(최소, 선호, 최대)를 자주 쓴다.
지시 예문
- “모바일 기본 스타일 먼저 짜고, md 이상에서만 2단 레이아웃으로 바꿔줘 (mobile-first).”
- “sm:hidden이 폰에서 안 숨어. 폰에서만 숨기려면 기본은 hidden, sm 이상에서 보이게 바꿔줘.”
- “상품 카드가 사이드바에 들어가면 찌그러져. 화면 폭 말고 container query로 부모 폭에 맞춰 세로형으로 바뀌게 해줘.”
- “768~1024px 사이에서 카드 그리드가 어중간해. 그 구간 breakpoint에서 열 개수를 조정해줘.”
상태 관리: single source of truth · prop drilling · 전역 상태
현상
- 장바구니에 상품을 담았는데 헤더의 장바구니 숫자는 그대로다. 새로고침해야 맞는다.
- 프로필 이름을 바꿨는데 사이드바엔 옛 이름이 남아 있다.
- 값 하나를 깊은 자식에게 주려고 중간 컴포넌트 다섯 개가 쓰지도 않는 props를 그냥 전달만 하고 있다.
용어
- state(상태) — 화면이 기억하는 바뀌는 값(입력값, 열림 여부, 장바구니 목록 등).
- single source of truth(단일 진실 공급원) — 같은 데이터는 한 곳에만 두고 모두 거기서 읽는 원칙. 같은 값을 두 곳에 복사해 따로 들고 있으면, 한쪽만 바뀌어 서로 어긋난다(위 현상 1·2의 원인).
- 비유: 팀 일정표를 각자 수첩에 베껴 쓰면 누군가 수정할 때 수첩마다 달라진다. 공유 캘린더 하나만 보면 어긋날 일이 없다.
- lifting state up(상태 끌어올리기) — 여러 컴포넌트가 같은 값을 써야 하면, 그 값을 공통 부모로 올려 한 곳에서 관리하고 내려주는 것.
- prop drilling — 끌어올린 값을 깊은 자식까지 보내려고 중간 단계마다 props로 계속 넘기는 상태. 몇 단계면 괜찮지만, 깊어지면 수정이 번거롭다.
- 비유: 회사 공지를 사장 → 본부장 → 팀장 → 파트장 → 사원 순서로 말로 전달하는 것. 단체 공지 게시판(전역 상태)이 있으면 바로 볼 수 있다.
- 전역 상태(global state) — 앱 어디서든 바로 읽고 쓰는 상태 저장소. React Context, Zustand, Jotai 등. 로그인 사용자·테마·장바구니처럼 여러 곳에서 쓰는 값에 쓴다. 아무거나 넣으면 오히려 re-render가 늘어난다(14번 레슨).
- server state vs client state — 서버에서 온 데이터(글 목록, 프로필)는 server state, 화면에서만 쓰는 값(모달 열림, 입력 중 텍스트)은 client state. server state는 전역 상태에 복사해두지 말고 TanStack Query·SWR 같은 데이터 패칭 도구의 캐시를 단일 진실 공급원으로 쓰는 게 요즘 관례(12번 레슨의 invalidation과 연결).
지시 예문
- “장바구니 담기 후 헤더 숫자가 안 바뀌어. 장바구니 데이터가 두 군데 따로 있는지 확인하고 single source of truth로 합쳐줘.”
- “user 정보가 다섯 단계 prop drilling되고 있어. Context(또는 Zustand)로 옮겨줘.”
- “서버에서 받은 글 목록을 전역 store에 복사해서 쓰고 있어. TanStack Query 캐시를 직접 쓰고, 수정 후엔 invalidate하게 바꿔줘.”
폼 입력: IME composition · validation 타이밍
현상
- 채팅창에 한글을 치고 Enter를 누르면 메시지가 두 번 전송되거나, 마지막 글자가 다음 입력에 한 번 더 붙는다(“안녕” 전송 후 입력창에 “녕”이 남음).
- 이메일 칸에 첫 글자를 치자마자 “올바른 이메일이 아닙니다” 빨간 에러가 뜬다.
- 콘솔에 “A component is changing an uncontrolled input to be controlled” 경고가 뜬다.
용어
- IME(입력기) — 한글·일본어·중국어처럼 여러 키를 조합해 한 글자를 만드는 입력 장치(소프트웨어).
- composition(조합 중) — “ㅎ→하→한”처럼 글자가 아직 완성되지 않은 상태. 이 상태에서 Enter를 누르면 브라우저는 “조합 완료”와 “Enter 키” 이벤트를 둘 다 보낸다. 핸들러가 이를 구분하지 않으면 두 번 처리된다.
- 이 현상 자체에 공식 이름은 없다. 보통 “IME composition 문제“(영어권 “IME composition Enter bug”), 국내에선 “한글 마지막 글자 중복 버그”, “isComposing 이슈”로 통한다. 지시할 땐 “IME composition 문제” + 현상 묘사 + “한글 입력에서”를 같이 말하면 정확하다.
- 막는 법: Enter 처리 전에
isComposing(조합 중 여부)을 확인해 조합 중이면 무시한다. 영어로만 테스트하면 절대 발견되지 않는 한국어 서비스 단골 버그.
- 비유: 도장을 아직 찍는 중인데 서류를 가져가 버리는 것. 도장이 다 찍힌 다음(조합 완료)에 가져가야 한다.
- validation 타이밍 — 입력값 검사를 언제 보여줄지.
- onChange(칠 때마다) — 다 치기도 전에 혼나서 불쾌하다.
- onBlur(칸을 벗어날 때) — 다 쓴 뒤 확인. 보통 첫 검사는 이 시점이 좋다.
- onSubmit(제출할 때) — 늦게 알려 고칠 게 몰려 있다.
- 관례: 처음엔 blur에서 검사하고, 한 번 에러가 뜬 칸은 고치는 즉시(onChange) 에러가 사라지게 한다. “칭찬은 빨리, 지적은 늦게”.
- controlled vs uncontrolled input — 입력값을 React state가 쥐고 있으면 controlled, DOM(입력창 자신)이 쥐고 있으면 uncontrolled. 위 경고는 초기값이
undefined(uncontrolled로 시작)였다가 나중에 값이 생겨(controlled) 바뀐 것이 원인 — 초기값을 ''로 주면 사라진다.
지시 예문
- “채팅 입력에서 한글 치고 Enter 누르면 두 번 전송돼. keydown에서 isComposing일 땐 무시하게 해줘.”
- “회원가입 폼 에러가 타이핑하자마자 떠. 처음엔 blur 때 검사하고, 에러가 뜬 뒤엔 onChange로 바로 사라지게 해줘.”
- “uncontrolled에서 controlled로 바뀐다는 경고가 나. 초기값이 undefined인 input을 찾아서 빈 문자열로 초기화해줘.”
배포: build · 환경 · preview · rollback
현상
- 로컬
dev에선 잘 되는데 배포하면 빌드가 실패한다.
- 배포는 성공했는데 특정 페이지에서만 실서비스 에러가 난다. 급하게 고칠 시간이 없다.
- 기능을 실서비스에 올리기 전에 팀원에게 미리 보여주고 싶다.
용어
- build(빌드) — 작성한 코드를 실서비스에서 돌릴 수 있는 형태로 변환·압축·검사하는 단계. 번들(20번 레슨)도 여기서 만들어진다.
- build error vs runtime error — 빌드 단계에서 막히면 배포 자체가 안 되고(build log를 본다), 배포 후 실행 중에 터지면 사용자가 에러를 본다(runtime log를 본다). 어느 쪽인지 먼저 말해야 AI가 엉뚱한 로그를 뒤지지 않는다.
- dev 모드는 빨리 띄우려고 검사를 느슨하게 하지만, build는 타입 에러·린트 에러까지 엄격하게 잡는다. 그래서 “dev에선 되는데 build는 실패”가 흔하다.
- 대소문자 함정: macOS는 파일명 대소문자를 구분하지 않고, 배포 서버(Linux)는 구분한다.
import Button from './button'인데 파일이 Button.tsx면 내 맥에선 되고 배포에선 실패한다.
- 환경(environment) — 같은 코드를 돌리는 장소의 구분.
- local/development — 내 컴퓨터
- preview/staging — 실서비스 직전 리허설 무대. 실제와 비슷한 조건에서 미리 확인
- production — 실제 사용자가 쓰는 본 무대
- preview deployment — 브랜치·PR마다 임시 주소로 자동 배포되는 것(Vercel·Netlify 기본 기능). 링크만 공유하면 실서비스를 건드리지 않고 미리 보여줄 수 있다.
- rollback(롤백) — 문제가 생긴 배포를 직전의 정상 배포로 되돌리는 것. 원인을 고치는 동안 우선 서비스를 살린다. Vercel은 클릭 한 번으로 이전 배포로 즉시 되돌릴 수 있다.
- 비유: 공연 중 새 무대장치가 고장 나면, 고치는 동안 어제 쓰던 무대장치로 바로 바꿔 끼우는 것.
- CI/CD — 코드를 올릴 때마다 자동으로 검사·빌드(CI)하고 자동으로 배포(CD)하는 파이프라인. GitHub Actions 등. “빌드 실패를 배포 전에 미리 잡는 문지기”.
지시 예문
- “Vercel 배포에서 build error가 나. build log 기준으로 원인 찾아줘. 로컬 dev에선 됐어.”
- “로컬은 되는데 배포 빌드만 Module not found야. import 경로와 실제 파일명의 대소문자가 다른지 확인해줘.”
- “방금 배포 후 결제 페이지에 runtime error가 나. 우선 직전 배포로 rollback하고, runtime log로 원인 분석해줘.”
- “PR 올릴 때마다 타입 체크와 빌드를 CI에서 돌리게 GitHub Actions 설정해줘.”
반응형 이미지: srcset · sizes · DPR · art direction
현상
- 폰에서 썸네일 하나 보려고 4000px 원본 사진(수 MB)을 받는다. LCP가 느리다.
- 아이콘·로고가 맥북·아이폰에서만 흐릿하게 보인다.
- 가로로 긴 배너가 폰에선 너무 작아져 안의 글씨를 못 읽는다.
용어
- DPR(device pixel ratio) — CSS 1px에 실제 화면 점(물리 픽셀)이 몇 개 들어가는지. 레티나 화면은 2~3. 그래서 화면에 200px로 보일 이미지도 레티나에선 400~600px 원본이 있어야 선명하다. 딱 200px 이미지를 쓰면 늘려 그려서 흐릿해진다.
- srcset — 같은 이미지의 여러 크기 버전 목록을 브라우저에 주고, 브라우저가 화면 크기·DPR에 맞는 걸 골라 받게 하는 속성.
- 비유: 옷가게에 S·M·L을 다 걸어두면 손님(브라우저)이 자기 사이즈를 집어 가는 것. 원본 하나만 있으면 모두가 XXL을 받아 간다.
- sizes — “이 이미지는 화면에서 대략 이 폭으로 표시될 거야“라고 브라우저에 미리 알려주는 힌트. 예:
(max-width: 768px) 100vw, 33vw = 모바일에선 화면 전체 폭, 그 외엔 1/3 폭.
- 이게 없으면 브라우저는 “화면 전체 폭”으로 가정해 필요 이상으로 큰 버전을 받는다. srcset만 있고 sizes가 없는 게 흔한 실수. Next.js
<Image>도 fill이나 반응형 폭일 땐 sizes를 직접 줘야 한다.
- art direction — 크기만 줄이는 게 아니라 화면에 따라 다른 구도의 이미지를 보여주는 것. 폰엔 세로로 크롭한 배너, 데스크톱엔 가로 배너.
<picture> + <source media="...">로 한다.
- 이미지 포맷 — WebP·AVIF는 JPEG·PNG보다 같은 화질에서 훨씬 가볍다(LCP 레슨).
지시 예문
- “상품 썸네일이 원본을 그대로 받아. srcset으로 여러 크기를 만들고, 그리드에서 실제 표시 폭에 맞게 sizes를 지정해줘.”
- “로고가 레티나에서 흐릿해. 2x 해상도 이미지를 쓰거나 SVG로 바꿔줘.”
- “메인 배너가 모바일에서 글씨가 안 보여. picture 태그로 모바일용 세로 배너를 따로 쓰는 art direction 적용해줘.”
SEO 기초: index · robots · sitemap · canonical
현상
- 사이트를 연 지 한참 됐는데 구글에 검색해도 안 나온다.
- 검색 결과에 페이지 제목이 전부 “React App” 같은 똑같은 문구로 뜬다.
- 같은 글이
?utm_source=... 붙은 주소 등 여러 주소로 중복 검색된다.
용어
- SEO(Search Engine Optimization) — 검색 결과에 잘 나오고, 잘 보이게 만드는 작업.
- crawl → index → rank — 검색엔진 봇이 페이지를 방문(crawl)하고, 검색 대상 목록에 등록(index)하고, 검색어마다 순위(rank)를 매긴다. “검색에 안 나옴”은 대개 index 단계 문제다.
- 비유: 도서관. 사서가 책을 발견하고(crawl), 목록에 등록하고(index), 질문받으면 어떤 책을 먼저 추천할지 정한다(rank). 목록에 없으면 아무리 좋은 책도 추천될 수 없다.
- title · meta description — 검색 결과에 보이는 제목과 요약문. 페이지마다 달라야 한다. 링크 미리보기의 OG 태그(21번 레슨)와 비슷하지만 대상이 검색엔진이다.
- robots.txt — 사이트 입구에 붙이는 “여긴 들어오지 마세요” 안내문. 봇의 방문(crawl)을 막는다.
- noindex — 페이지 안에 붙이는 “목록에 올리지 마세요” 표시. 등록(index)을 막는다. 스테이징용
noindex나 robots.txt의 Disallow: /가 실서비스에 그대로 딸려 가서 검색에서 통째로 사라지는 사고가 흔하다.
- sitemap.xml — 사이트의 페이지 주소를 정리한 목차. 봇에게 “이 페이지들 있어요”라고 알려준다.
- canonical — 같은 내용이 여러 주소로 열릴 때 “이 주소가 원본이에요“라고 지정하는 태그. 중복 페이지끼리 점수가 나뉘는 걸 막는다.
- Google Search Console — 내 사이트가 구글에 등록됐는지, 왜 안 됐는지 확인하는 공식 도구. 추측하지 말고 여기서 확인한다.
- 본문이 CSR로만 그려지면 봇이 내용을 늦게 보거나 놓칠 수 있다. 중요한 페이지는 SSR(6번 레슨)이 안전하다.
지시 예문
- “페이지마다 title과 meta description을 내용에 맞게 다르게 넣어줘. 지금은 전부 같아.”
- “실서비스가 검색에 안 나와. robots.txt와 noindex가 production에 걸려 있는지 확인해줘.”
- “sitemap.xml을 자동 생성하고, 쿼리 파라미터 붙은 주소는 canonical로 원래 주소를 가리키게 해줘.”
현상 — AI에게 “안 돼”, “이상해”라고만 말하면 추측으로 코드를 고치다 엉뚱한 곳을 망친다. 개발자 도구(Chrome: Cmd+Option+I)에서 어느 탭의 무엇을 봤는지 전달하면 원인 범위가 확 좁혀진다.
용어 — 탭별로 “무엇을 확인하는 곳”인지
- Console — JS 에러·경고가 찍히는 곳. 빨간 에러 문구 전체 + 파일명:줄번호를 그대로 복사해 준다. 첫 번째 에러가 원인이고 나머지는 연쇄 반응인 경우가 많다.
- Network — 브라우저가 서버와 주고받은 모든 요청 목록.
- status — 상태 코드(28번 레슨). 빨간 줄부터 본다.
- Payload — 내가 보낸 데이터. “값이 안 바뀐다”면 애초에 제대로 보냈는지 여기서 확인.
- Response / Preview — 서버가 돌려준 데이터. 화면이 이상한지, 데이터부터 이상한지 가른다.
- Timing — 어디서 오래 걸렸는지(서버 대기 vs 다운로드).
- Disable cache — 캐시를 끄고 새로 받기(12번 레슨 캐시 문제 확인용).
- Throttling — 느린 네트워크 흉내(Slow 4G). race condition(5번)·로딩 상태(11번) 재현용.
- Elements — 실제 화면의 HTML 구조와 적용된 CSS. 스타일이 왜 안 먹는지(취소선 그어진 규칙 = 다른 규칙에 덮임), stacking context(4번)를 만든 부모가 누군지 찾는다.
- Application — 쿠키·localStorage 저장 내용 확인(23번 레슨 인증 문제).
- Performance — 시간 흐름에 따라 무엇이 main thread를 잡았는지 기록. long task(3번 INP) 찾기.
- Lighthouse — LCP·CLS 등 성능·접근성·SEO 점수 리포트를 자동으로 만들어 준다.
- Device toolbar(Cmd+Shift+M) — 모바일 화면 크기 흉내(15·30번 레슨 확인용). 실제 기기의 Safari 동작까지 같지는 않다.
전달 공식 — “어디서(탭) + 무엇을 봤다(값) + 기대는 이랬다”
- 비유: 병원에서 “아파요” 대신 “어제부터 오른쪽 아랫배가, 누르면 더 아파요”라고 말하는 것. 위치·증상·시점이 있으면 진단이 빠르다.
지시 예문
- “저장 버튼 누르면 Network에서 PATCH /api/profile이 422야. Payload엔 name이 빈 문자열로 가. 입력값이 요청에 안 담기는 원인 찾아줘.”
- “Console에 ‘Cannot read properties of undefined (reading map)’ at ProductList.tsx:42. 데이터 로딩 전에 map을 도는 것 같아.”
- “Elements에서 보니 버튼 color 규칙에 취소선이 있어. 더 우선순위 높은 규칙이 덮는 것 같으니 찾아줘.”
CSS 우선순위: cascade · specificity · !important
현상
- 버튼 색을 바꾸는 CSS를 분명 썼는데 적용이 안 된다. Elements 탭에서 보면 내 규칙에 취소선이 그어져 있다.
- AI가 고치다가
!important를 덕지덕지 붙여서, 나중엔 무엇도 덮어쓸 수 없게 됐다.
- Tailwind로
className="p-2 p-4"처럼 겹쳐 줬는데, 뒤에 쓴 p-4가 아니라 엉뚱한 쪽이 적용된다.
용어
- cascade(캐스케이드) — 한 요소에 여러 CSS 규칙이 겹칠 때 어느 것이 이길지 정하는 규칙 체계. CSS의 C가 이것(Cascading Style Sheets). 판정 순서는 대략: ①
!important 여부 → ② specificity(구체성) → ③ 나중에 선언된 것.
- specificity(구체성, 명시도) — 선택자가 얼마나 구체적으로 대상을 찍었는지 점수. 높을수록 이긴다.
- inline
style="" > #id > .class·[속성]·:hover > 태그명
.card .title(클래스 2개)이 .title(클래스 1개)을 이긴다. 같은 점수면 CSS 파일에서 나중에 나온 쪽이 이긴다.
- 비유: 회사 지시. 직급 높은 사람(구체적인 선택자)의 지시가 우선이고, 같은 직급이면 나중에 내린 지시가 우선.
!important는 사장 특명.
!important — 우선순위를 강제로 맨 위로 올리는 표시. 한 번 쓰면 그걸 덮으려고 또 !important를 써야 해서 군비 경쟁이 된다. 원인(더 구체적인 규칙)을 찾아 고치는 게 정석.
- Tailwind 클래스 충돌 — Tailwind에서
className 안의 쓴 순서는 우선순위와 무관하다. 실제 CSS 파일 안에서의 순서로 결정된다. 그래서 p-2 p-4처럼 같은 속성을 겹쳐 주면 결과를 예측할 수 없다. 컴포넌트에 바깥에서 클래스를 덮어쓸 땐 tailwind-merge(shadcn의 cn() 함수)로 충돌을 정리한다.
- inheritance(상속) —
color·font 같은 글자 속성은 부모 값이 자식에게 내려온다. margin·padding·border는 내려오지 않는다. “부모에 색을 줬는데 왜 이 링크만 파랗지?” → 링크에 브라우저 기본 색이 따로 있어서 상속을 덮은 것.
지시 예문
- “버튼 색 규칙이 Elements에서 취소선 그어져 있어. 어떤 규칙이 specificity로 이기는지 찾아서, !important 없이 고쳐줘.”
- “프로젝트에 !important가 너무 많아. 꼭 필요한 것만 남기고 specificity를 정리해줘.”
- “Button에 바깥에서 준 className이 기본 패딩을 못 덮어. cn()(tailwind-merge)으로 클래스 병합하게 바꿔줘.”
레이아웃: flex · grid · 축 · gap
현상
- “가운데 정렬해줘”라고 했는데 가로로만 가운데가 되고 세로는 위에 붙어 있다.
- 카드 사이 간격을 margin으로 줬더니 맨 끝 카드에도 여백이 남아 정렬이 어긋난다.
- 화면 폭에 따라 카드가 3개, 2개, 1개로 알아서 줄바꿈되게 하고 싶은데 breakpoint를 일일이 잡고 있다.
용어
- flex(flexbox) — 자식들을 한 줄(가로 또는 세로)로 늘어놓는 배치. 내비게이션 바, 버튼 묶음, 아이콘+텍스트처럼 한 방향 정렬에 쓴다.
- grid — 자식들을 행과 열(바둑판)에 배치. 카드 목록, 대시보드처럼 두 방향을 동시에 맞출 때 쓴다.
- 비유: flex는 한 줄 서기, grid는 극장 좌석표.
- main axis / cross axis(주축 / 교차축) — flex에서 자식이 늘어선 방향이 주축, 그와 수직인 방향이 교차축. 가로 배치(
flex-direction: row)면 주축=가로, 교차축=세로.
justify-content — 주축 방향 정렬 (가로 배치면 좌우)
align-items — 교차축 방향 정렬 (가로 배치면 위아래)
- 그래서 “완전 가운데”는 둘 다
center. flex-direction: column으로 바꾸면 두 속성이 맡는 방향이 서로 뒤바뀐다 — 헷갈림의 주범.
- gap — 자식들 사이에만 간격을 주는 속성. margin과 달리 바깥 끝에는 여백이 생기지 않는다. 간격은 margin 대신 gap이 기본.
space-between — 양 끝 자식을 가장자리에 붙이고 남은 공간을 사이에 고르게 나눔. “로고는 왼쪽, 메뉴는 오른쪽” 헤더의 단골.
flex: 1 / flex-grow — 남는 공간을 이 자식이 차지하게 함. “검색창이 남은 폭을 다 쓰게”.
repeat(auto-fit, minmax(240px, 1fr)) — grid에서 “카드 최소 240px을 지키면서 들어가는 만큼 열을 자동으로” 만드는 주문. breakpoint 없이 3열→2열→1열이 알아서 된다.
지시 예문
- “로그인 박스를 화면 정중앙에 놔줘. 부모를 flex로 하고 justify-content·align-items 둘 다 center, 높이는 100svh.”
- “카드 간격을 margin 대신 gap 16px로 바꿔서 끝 여백 없애줘.”
- “상품 카드를 grid repeat(auto-fit, minmax(240px, 1fr))로 바꿔서 breakpoint 없이 열 개수가 자동 조절되게 해줘.”
- “헤더에서 로고는 왼쪽, 메뉴는 오른쪽 끝으로. space-between 써줘.”
Position: relative · absolute · fixed · sticky
현상
- 스크롤하면 상단에 붙어야 할 헤더가
position: sticky인데 그냥 같이 올라가 버린다.
- 배지(알림 숫자)를 카드 오른쪽 위에
absolute로 놨더니 화면 맨 오른쪽 위로 날아간다.
- 화면 하단에 고정한
fixed 버튼이 모달 안에서는 화면이 아니라 모달 기준으로 붙는다.
- 요소를
absolute로 바꿨더니 아래 요소들이 위로 올라와 겹친다.
용어
- normal flow(일반 흐름) — 요소들이 위에서 아래로 차례대로 자리를 차지하며 쌓이는 기본 배치.
position: static(기본값)이 이 상태다.
- relative — 원래 자리를 지키면서 살짝 옮길 수 있음. 더 중요한 역할은 absolute 자식의 기준점이 되는 것.
- absolute — normal flow에서 빠져나와(자리를 비움) 가장 가까운 positioned 조상(static이 아닌 조상)을 기준으로 위치를 잡는다. 그런 조상이 없으면 페이지 전체가 기준이 되어 엉뚱한 곳으로 날아간다.
- 비유: 벽에 붙인 포스트잇. 어느 벽(기준 조상)에 붙일지 정하지 않으면 건물 외벽에 붙어 버린다. 부모에
relative를 주는 게 “이 벽에 붙여”라는 뜻.
- 자리를 비우기 때문에 아래 요소들이 올라와 겹친다.
- fixed — 화면(viewport)에 못 박힌 듯 고정. 스크롤해도 그 자리.
- 함정: 조상에
transform·filter가 있으면 화면 대신 그 조상이 기준이 된다. 애니메이션 걸린 모달 안의 fixed가 이상한 이유(4번 stacking context 레슨과 같은 범인).
- sticky — 평소엔 normal flow처럼 있다가, 스크롤로 지정 위치(
top: 0 등)에 닿으면 부모 영역 안에서 붙어 있는다. 부모가 화면 밖으로 지나가면 같이 떠난다.
- 안 될 때 점검 3가지:
top(또는 bottom) 값을 줬는가 — 없으면 붙을 위치를 모른다
- 조상 중
overflow: hidden/auto/scroll이 있는가 — 그 조상 안에서만 sticky가 동작해서 화면 기준으로는 안 붙는 것처럼 보인다(가장 흔한 원인)
- 부모 높이가 sticky 요소와 같지 않은가 — 붙어 있을 여유 공간이 없다
지시 예문
- “테이블 헤더가 sticky인데 안 붙어. top 값, 조상의 overflow, 부모 높이를 순서대로 확인해줘.”
- “알림 배지가 화면 구석으로 날아가. 카드에 relative를 주고 배지를 그 기준 absolute로 오른쪽 위에 붙여줘.”
- “모달 안의 fixed 하단 버튼이 화면 기준으로 안 붙어. 조상에 transform이 있는지 확인하고, 없애거나 portal로 빼줘.”
다국어: i18n · locale · RTL
현상
- 서비스를 영어로 내려는데 화면 문구가 코드 곳곳에 한국어로 박혀 있어 하나하나 찾아야 한다.
- 독일어로 바꿨더니 버튼 글자가 길어져서 넘치거나 두 줄이 된다.
- 해외 사용자에게 가격이
1000000처럼 쉼표 없이, 날짜가 10/11/2026처럼 월/일이 헷갈리게 보인다.
- 아랍어로 바꿨더니 글자는 오른쪽→왼쪽인데 아이콘·여백은 그대로라 어색하다.
용어
- i18n(internationalization, 국제화) — 여러 언어·지역을 받아들일 수 있게 구조를 만드는 것. i와 n 사이 글자가 18개. 번역 자체가 아니라 “번역을 끼울 수 있는 틀”이다.
- l10n(localization, 현지화) — 그 틀에 특정 언어·문화를 실제로 채워 넣는 것(번역, 통화, 이미지).
- hardcoded string — 화면 문구를 코드에 직접 적어둔 것. i18n의 첫 작업은 이걸 번역 키(
t('cart.empty'))로 바꿔 번역 파일로 빼내는 것.
- locale(로케일) — 언어 + 지역 조합.
ko-KR, en-US, de-DE. 같은 영어라도 en-US(10/11/2026 = 10월 11일)와 en-GB(10/11/2026 = 11월 10일)가 다르다.
- 숫자·통화·날짜는 직접 문자열을 조립하지 말고 브라우저 내장
Intl API로 locale에 맞춰 포맷한다. 1,000,000원 / 1.000.000 € / $1,000,000.
- pluralization(복수형 처리) — “1 item / 2 items”처럼 수에 따라 형태가 바뀌는 것. 한국어엔 거의 없지만 러시아어·아랍어는 형태가 여러 개라
${n}개 식 조립으로는 안 된다. i18n 라이브러리의 복수형 기능을 쓴다.
- text expansion(텍스트 팽창) — 번역하면 글이 길어지는 현상. 독일어는 영어보다 꽤 길어지는 경우가 많다. 버튼·탭에 고정 폭을 주면 깨진다 — 10번 레슨(텍스트 넘침)과 연결.
- RTL(right-to-left) — 아랍어·히브리어처럼 오른쪽에서 왼쪽으로 쓰는 언어.
dir="rtl"을 주고, 여백·정렬을 margin-left 대신 margin-inline-start 같은 논리 속성으로 쓰면 방향이 자동으로 뒤집힌다. “뒤로가기” 화살표 같은 방향성 아이콘도 좌우를 뒤집는다.
지시 예문
- “화면 문구가 하드코딩돼 있어. next-intl로 i18n 구조 잡고, 문자열을 번역 키로 빼줘. 우선 ko·en.”
- “가격·날짜를 문자열로 조립하고 있어. Intl.NumberFormat·DateTimeFormat으로 사용자 locale에 맞게 포맷해줘.”
- “아랍어 RTL 대응해줘. dir=rtl 적용하고, 좌우 margin·padding을 논리 속성(inline-start/end)으로 바꿔줘.”