이 글에서 다루는 것
며칠에 걸쳐 만난 문제들이 결국 같은 질문 하나로 모였다. 중간에 낀 계층이 무엇을 그대로 통과시키고, 무엇을 자기 마음대로 추측하는가. 특정 제품·수치는 빼고 어디서나 통하는 일반 개념만 정리한다.
오늘 다룬 것
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 무한 리다이렉트 | 자기 자신으로 튕기는 경로를 살리고 싶었다 | curl로 업스트림 응답을 직접 확인해 원인 확정 | rewrite가 3xx를 통과시킨다는 걸 확인, 목적지 교체로 해결 |
| 한글 조기 확정 | 조합 중 저장되는 값을 막고 싶었다 | 조합 상태를 추적해 커밋을 조합 종료 뒤로 미룸 | 브라우저별 이벤트 순서와 무관하게 동작 |
| 번들 축소 | 라우트별 JS 전송량을 줄이고 싶었다 | provider와 헤더 인증 UI의 클라이언트 경계를 안쪽으로 이동 | 코드를 지우지 않고 경계만 옮겨 감축 |
| 이미지 비율 | 잘려 보이는 스크린샷을 고치고 싶었다 | 빌드 시 원본 치수를 주입하고 폴백은 유지 | 레이아웃 이동과 잘림이 동시에 해소 |
1. rewrite는 리다이렉트가 아니라 프록시다
어떤 상황이었나. 어떤 경로에 들어가면 페이지가 안 뜨고 브라우저가 "리다이렉트가 너무 많습니다"로 포기했다. 정작 앱 코드에는 그 경로를 어디로 보내라는 리다이렉트가 한 줄도 없었다.
핵심 개념. redirect는 브라우저에게 "저기로 다시 가라"고 3xx를 주는 것이고 주소창이 바뀐다. rewrite는 주소창을 그대로 두고 서버가 대신 받아다 주는 프록시다. 함정은 rewrite가 업스트림 응답을 가공하지 않는다는 점이다 — 업스트림이 3xx를 주면 그 3xx가 그대로 브라우저에 전달된다. 많은 SaaS는 중복 URL을 막으려고 테넌트 주소를 고객의 대표 도메인으로 308 이동시키는데, 그 대표 도메인이 다시 rewrite로 SaaS를 가리키면 왕복이 끝나지 않는다.
한 일. 추측 대신 curl -I --max-redirs 0 <목적지>로 업스트림 상태 코드와 location을 직접 찍었다. 테넌트 주소는 308로 내 도메인을 가리켰고, 벤더가 커스텀 도메인용으로 제공하는 프록시 전용 호스트는 200이었다. 목적지를 그쪽으로 바꿔 해결했다. 결정적 단서는 같은 설정 파일에서 이미 멀쩡히 동작하던 형제 규칙이 처음부터 그 호스트를 쓰고 있었다는 점이다. 받은 설정 스니펫은 목적지 한 줄만 채택했다 — 통째로 붙였다면 기존 캐치올 rewrite와 이미지·번들 설정이 사라져 사이트 전체가 죽는다.
교훈. 프록시를 붙이기 전에 목적지가 200을 주는지 먼저 확인한다. 그리고 깨진 규칙 옆의 멀쩡한 형제 규칙을 읽는다 — 정답이 이미 같은 파일에 있는 경우가 흔하다.
2. 한글 입력 버그는 값이 아니라 확정 시점의 문제다
어떤 상황이었나. 값을 손으로 고치는 입력 필드에서, 한글을 치다가 다른 곳을 클릭하거나 Enter를 누르면 마지막 글자가 완성되기 전 값이 저장됐다.
핵심 개념. 한글·일본어·중국어는 여러 키 입력이 모여 한 글자가 되는 조합 구간이 있다. 이 구간이 끝나기 전에 blur나 Enter가 커밋(확정 저장)을 실행하면 화면에 보이는 값과 저장된 값이 달라진다. 더 성가신 건 compositionend·keydown·blur의 도착 순서가 브라우저마다 다르다는 점이다. 한 환경 기준으로 순서를 가정해 고치면 다른 환경에서 그대로 재발한다.
한 일. 리치 텍스트 에디터를 도입하는 선택지를 버리고 native textarea를 유지했다. 값이 단순 문자열인데 에디터를 얹으면 IME 문제는 남고 층만 늘어난다. 조합 시작·종료 신호로 "지금 조합 중인가"를 상태로 들고, 조합 중 blur는 즉시 저장하지 않고 예약해 두었다가 조합이 끝난 뒤 저장하게 했다. Enter도 조합 중이면 무시. 값을 문자열로 다듬는 방식(중복 글자 제거 등)은 넣지 않았다 — 사용자가 진짜로 같은 글자를 두 번 친 입력까지 훼손한다.
교훈. 이벤트 순서를 가정하지 말고 상태로 판단한다. 그리고 경계 문제를 값 문제로 오진하지 않는다.
3. 번들 축소는 코드를 지우는 게 아니라 경계를 옮기는 일이다
어떤 상황이었나. 목록·상세처럼 상호작용이 적은 라우트까지 공통 provider와 헤더 때문에 같은 양의 JS를 받고 있었다.
핵심 개념. "use client"는 파일 하나를 클라이언트로 만드는 표시가 아니라, 그 파일에서 시작하는 import 그래프 전체를 브라우저로 보내는 경계다. 무거운 라이브러리 하나를 무심코 끌어오면 그 경계 아래 모든 페이지가 비용을 나눠 진다. provider를 최상위에 두는 것도 마찬가지다 — 편의지 기본값이 아니다.
한 일. 데이터 캐시 provider를 실제로 쓰는 무한 목록 기능 쪽으로 내려보내고, 헤더에서 로그인 상태에 따라 달라지는 부분만 별도 클라이언트 조각으로 떼어 냈다. 무거운 관계 그래프는 필요할 때 불러오는 로더를 통하게 했다. 검증은 유닛 테스트와 기존 E2E로 잡았다.
교훈. 경계는 "상호작용이 실제로 필요한 가장 안쪽"에 그린다. 쓰는 라우트로 내려보내면 나머지 라우트가 공짜로 가벼워진다.
4. 비율은 추측하지 말고 원본에서 알아낸다
어떤 상황이었나. 본문에 넣은 세로로 긴 스크린샷이 잘려 보였다. 레이아웃 흔들림을 막으려고 넣어 둔 고정 비율 폴백이 이번엔 콘텐츠를 훼손한 것이다.
핵심 개념. 치수가 없는 이미지는 로드 전 높이가 0이라 늦게 도착하면 아래 내용을 밀어낸다(누적 레이아웃 이동). 흔한 대응인 aspect-ratio: 16/9는 흔들림은 막지만 비율을 추측하므로 세로 이미지를 잘라 먹는다. 원본 파일이 로컬에 있다면 추측할 이유가 없다 — 빌드 시점에 파일을 열어 실제 치수를 읽으면 런타임 비용도 네트워크 요청도 0이다.
한 일. 마크다운을 HTML로 바꾸는 단계에 끼어들어 로컬 이미지의 실제 치수를 width/height로 주입하는 플러그인을 넣었다. 외부 URL은 건드리지 않는다. 스타일은 치수가 있는 이미지만 높이를 자동으로 두어 원본 비율로 그리고, 치수를 못 얻은 이미지는 기존 크롭 폴백을 그대로 남겼다. 단위 테스트로 "로컬엔 붙고 외부는 그대로"를 고정했다.
교훈. 새 경로를 넣을 때 기존 폴백을 지우지 않는다. 그리고 빌드 타임에 할 수 있는 계산을 사용자 기기로 미루지 않는다.
5. 곁가지 — exit 0인데 아무 일도 안 하는 CLI
전역 설치한 CLI가 종료 코드 0에 출력도 결과물도 없이 끝났다. 원인은 npm 전역 bin이 심볼릭 링크라 "지금 직접 실행됐나"를 판별하는 경로 비교식 가드가 조용히 거짓이 된 것이었다. 실경로로 직접 실행하니 정상 동작했다. 실패 신호가 하나도 없는 이 실패 모드가 가장 위험하다.