Stimulus lazyLoad 로 바꿔도 컨트롤러를 다 받는 이유
importmap 의 pin_all_from 은 기본이 preload: true. eager → lazy 한 줄에 preload: false 까지 바꾸고, 이름 · 호출 순서 · 첫 클릭을 확인한다
importmap 으로 Stimulus 를 쓰는 Rails 앱에서 eagerLoadControllersFrom 을 lazyLoadControllersFrom 으로 바꾸려는 사람을 위한 글이다. 다 읽으면 그 한 줄만으로는 다운로드가 안 줄어드는 이유를 알고, 바꾸기 전에 볼 것 여섯 가지를 체크하고 넘어갈 수 있다.
결론부터. pin_all_from 은 기본이 preload: true 다. 그래서 lazy 로 바꿔도 모든 컨트롤러가 <link rel="modulepreload"> 로 HTML 에 찍히고, 브라우저는 그걸 전부 받는다. config/importmap.rb 에 preload: false 까지 같이 넣어야 줄어든다.
로그인 화면이 컨트롤러 131개를 받고 있었다
회사 프로젝트(계약관리 SaaS)에서 메일을 한꺼번에 보낸 직후 ALB 가 502 를 수만 건 냈다. 왜 CPU 21% 에서 죽었는지는 Puma 슬롯 고갈 — CPU 21% 인데 502 가 6만 건 난 이유 에 썼다. 여기서는 그 슬롯을 뭐가 채웠는지 쓴다.
ALB 액세스 로그 약 69만 줄을 경로별로 셌더니 이렇게 나왔다.
/renewals 메일 속 링크 약 8%
/login 미인증이라 302 → 로그인 약 4%
/assets/** JS 모듈 약 88%
로그인 화면인데 조직 관리 · 회계 화면의 컨트롤러 JS 까지 받고 있었다. 정적 파일을 Puma 가 직접 내주는 구성(public_file_server)이라, 그 파일 하나하나가 Puma 슬롯을 하나씩 썼다.
범인은 app/javascript/controllers/index.js 의 한 줄이었다. stimulus-rails 를 설치하면 생기는 기본값 그대로다.
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eager 와 lazy 는 무엇이 다른가
stimulus-rails 1.3.4 의 stimulus-loading.js 를 열어 보면 둘 다 짧다.
eagerLoadControllersFrom 은 페이지의 importmap 에서 controllers/…_controller 로 끝나는 키를 전부 골라 import() 한다. 그 화면에 data-controller 가 하나도 없어도 상관없다.
lazyLoadControllersFrom 은 지금 DOM 에 있는 data-controller 값만 골라 import() 하고, 그 뒤로는 MutationObserver(DOM 이 바뀌면 알려 주는 브라우저 API)로 새로 나타나는 요소를 본다.
여기까지만 보면 한 줄 바꾸면 끝날 것 같다. 처음엔 나도 그렇게 생각했다.
한 줄로는 안 줄었다 — modulepreload
javascript_importmap_tags 는 importmap 을 찍는 것 말고도 일을 하나 더 한다. preload 가 켜진 핀마다 <link rel="modulepreload"> 를 찍는다. 브라우저는 이 링크를 보면 그 모듈을 미리 받는다. 누가 import 하든 말든.
그리고 pin_all_from 의 기본값이 preload: true 다. importmap-rails 2.2.2 에서 컨트롤러 세 개짜리 폴더를 pin_all_from 하고, preload 목록을 직접 뽑아 봤다.
preload: true (기본)
modulepreload: application.js, stimulus-loading.js,
channel_controller.js, file-dropzone_controller.js, hello_controller.js
preload: false
modulepreload: application.js, stimulus-loading.js
importmap 키: 컨트롤러 3개 그대로
lazy 로 바꾸고 이 설정을 그대로 두면, JS 가 import 를 안 해도 HTML 이 「다 받아 둬」 라고 말하고 있는 셈이다. 그래서 고칠 곳은 두 군데다.
# config/importmap.rb
pin_all_from "app/javascript/controllers", under: "controllers", preload: false
preload: false 여도 importmap 키는 남는다. lazy 가 import() 할 주소는 그대로 있으니 동작은 같다. 빠지는 건 미리 받기뿐이다.
바꾸기 전에 볼 것 1 — 이름이 파일명과 정확히 맞는가
eager 는 파일명에서 이름을 만든다. _ 를 - 로, / 를 -- 로. lazy 는 반대로 이름에서 파일명을 만든다. -- 를 / 로, - 를 _ 로.
서로 역변환이라 한쪽으로만 맞는 파일이 생긴다. 파일명에 하이픈이 들어간 file-dropzone_controller.js 가 그렇다. 두 함수를 node 로 그대로 돌려 봤다.
file-dropzone_controller
eager 가 등록하는 이름 file-dropzone → 동작함
lazy 가 찾는 파일 file_dropzone_controller → 없음
eager 일 때는 우연히 돌던 컨트롤러가, lazy 로 바꾸는 순간 아무 말 없이 안 붙는다. 바꾸기 전에 이걸 먼저 찾는다.
find app/javascript/controllers -name '*-*_controller.js'
2 — 실패가 조용하다
lazy 의 import() 가 실패하면 console.error("Failed to autoload controller: …") 한 줄이 전부다. 화면은 멀쩡히 뜨고 그 버튼만 안 먹는다.
특히 배포 직후에 열려 있던 탭이 위험하다. 페이지에 박힌 importmap 은 옛 다이제스트(파일명 뒤에 붙는 내용 해시) 주소를 가리킨다. 새 이미지에 옛 파일이 없으면 모달을 여는 순간 404 다. importmap 태그에는 data-turbo-track="reload" 가 붙어 있어 Turbo 로 다른 화면에 가면 새로 고쳐지지만, 같은 화면에서 모달만 여는 건 그 기회가 없다.
3 — 직접 register 하는 코드보다 뒤에 둔다
application.register("dropzone", …) 처럼 손으로 등록하는 줄이 섞여 있으면, lazyLoadControllersFrom 은 그보다 뒤에 부른다.
stimulus-loading 은 canRegisterController 로 이미 등록된 이름을 거른다. 그런데 lazy 를 먼저 부르면 그 순간엔 아직 등록 전이라 fetch 가 나간다. 받아 온 모듈은 등록할 때 다시 걸러져 버려지니, 요청만 한 번 낭비한다.
4 — 나중에 붙는 요소는 걱정 안 해도 된다
이슈를 처음 적을 때 제일 걱정한 게 이거였다. Turbo Frame 교체, Turbo Stream 의 append, 모달 삽입, JS 로 setAttribute("data-controller", …) 하는 코드.
소스를 보니 MutationObserver 가 childList · subtree 에 더해 attributeFilter: ["data-controller"] 로 속성 변경까지 본다. 넷 다 잡힌다. 다만 메커니즘이 맞다는 것과 실제 화면이 도는 건 다른 얘기라, 그 화면들은 손으로 한 번씩 눌러 봤다.
5 — 첫 클릭이 씹힐 수 있다
lazy 는 요소가 나타난 다음에 import → register → connect() 순서로 간다. data-action 은 connect() 때 걸린다. 그 전에 누른 클릭은 아무 일도 안 일어난다.
캐시가 있으면 수 ms 라 못 느낀다. 캐시가 없으면 네트워크 왕복 한 번이다. 모달이 열리자마자 누르는 버튼, 연타하는 버튼은 캐시를 비우고 눌러 본다.
6 — 요청 수는 「그 화면에서 쓰는 만큼」 으로 준다
lazy 는 요청을 없애는 게 아니다. 화면에 컨트롤러가 20개면 요청 20개다. 그래서 바꾸기 전에 두 가지를 본다. HTTP/2 이상인가, 다이제스트 붙은 파일에 긴 Cache-Control 이 붙는가.
# config/environments/production.rb
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=31536000, immutable"
}
이번 사건은 메일 링크를 누른 사람 · 스캐너가 매번 새 세션으로 와서 캐시가 아예 안 먹었다. 그래서 88% 였다. 근본은 정적 파일을 Puma 가 아니라 CDN 이나 앞단 프록시가 내주는 쪽이다. lazy 는 그때까지 양을 줄이는 수다.
그리고 그 프로젝트는 spec/system 이 0개였다. 위 2 · 4 · 5 는 화면을 눌러 봐야 아는 종류라, E2E 없이 바꾸면 수동 QA 로 막겠다는 결정까지 같이 하는 거다. 어느 화면을 눌러 봤는지 PR 에 적어 두자.
번들러(Vite · esbuild)면 얘기가 다르다
importmap 은 파일 하나가 요청 하나라 eager · lazy 가 곧 요청 수다. 번들러를 쓰면 eager 는 한 번들에 다 들어가 요청은 하나고 크기가 문제가 된다. lazy 는 코드 분할 청크가 된다. Vite 의 import.meta.glob 은 기본이 lazy(함수를 돌려줌)고, { eager: true } 를 주면 정적 import 다.
고르는 기준은 이렇게 잡았다. 컨트롤러가 수십 개 이하고 어느 화면이나 비슷하게 쓰면 eager 그대로 둬도 된다. 화면마다 쓰는 컨트롤러가 갈리고, 로그인 · LP 처럼 로그인 전 페이지가 트래픽을 받으면 lazy + preload: false 다.
오래된 Rails 앱의 프런트 자산이 어디서 얼마나 나가는지, 정적 파일을 Puma 에서 떼어 내는 것까지 같이 볼 사람이 필요하면 negabaro 에 물어봐도 된다.
eager · lazy · preload — 로그인 화면에서 무엇을 받나
| eagerLoad | lazyLoad | lazyLoad + preload: false | |
|---|---|---|---|
| import 하는 것 | importmap 의 컨트롤러 전부 | DOM 의 data-controller 만 | DOM 의 data-controller 만 |
| modulepreload 링크 | 전부 (pin_all_from 기본) | 전부 (그대로) | application · stimulus-loading 만 |
| 로그인 화면이 받는 컨트롤러 | 전부 | 전부 | 그 화면에서 쓰는 것만 |
| 이름 → 파일 | 파일명에서 이름을 만든다 | 이름에서 파일명을 만든다 (- → _) | 같음 |
바꾸기 전 체크리스트
| 확인 | 어떻게 |
|---|---|
| preload: false | config/importmap.rb 의 pin_all_from 에 붙였나 |
| 하이픈 파일명 | find app/javascript/controllers -name '*-*_controller.js' 가 0줄인가 |
| 호출 순서 | lazyLoadControllersFrom 이 손으로 쓴 application.register 보다 뒤인가 |
| 조용한 실패 | 배포 직후 열린 탭 · 모달에서 콘솔에 Failed to autoload controller 가 안 나오나 |
| 첫 클릭 | 캐시를 비우고 모달 직후 버튼 · 연타 버튼을 눌러 봤나 |
| 캐시 · HTTP/2 | 다이제스트 파일에 긴 Cache-Control, HTTP/2 이상인가 |
| E2E | 없으면 손으로 눌러 본 화면을 PR 에 적었나 |
핵심 포인트
controllers/index.js 의 eagerLoadControllersFrom 을 lazyLoadControllersFrom 으로
config/importmap.rb 의 pin_all_from "app/javascript/controllers" 에 preload: false
파일명에 하이픈이 든 컨트롤러를 찾아 밑줄로 바꾼다
lazyLoadControllersFrom 을 손으로 쓴 application.register 보다 뒤로
캐시를 비우고 모달 · Turbo Frame · 연타 버튼을 손으로 눌러 본다
페이지 소스에서 modulepreload 링크가 application · stimulus-loading 만 남았는지 본다