RSS
프론트엔드

코드 분할과 지연 로딩: 기능을 지우지 않고 첫 화면을 가볍게 하는 법

작성자
듀오랩스 대표·10분 읽기

「체스, 지뢰찾기, 스티커 메모를 빼면 앱이 가벼워지고 빨라질까?」 웹 데스크톱에 메모 앱을 하나 더 붙인 뒤 이 질문이 나왔습니다. 기능이 늘수록 첫 화면이 무거워지는 건 당연해 보였고, 가장 쉬운 답은 덜 쓰는 앱을 지우는 것이었습니다.

저는 지우지 않았습니다. 대신 그 앱들의 코드를 언제 받는지를 바꿨습니다. 이 글은 그 과정에서 실제로 손이 갔던 부분의 기록입니다.

브라우저 안의 데스크톱과 늘어나는 앱

duolabs-os는 브라우저 안에서 도는 웹 데스크톱입니다. 앱 목록과 Dock이 있고, 창을 옮기고 크기를 바꿀 수 있습니다. 앱 대부분은 다른 서비스를 창으로 여는 것이지만, 체스와 지뢰찾기처럼 제 몸으로 도는 앱도 있습니다.

최근에 윈도우의 스티커 메모를 닮은 메모 앱을 더했습니다. 메모를 여러 장 띄우고 색을 바꾸거나 접을 수 있고, 내용과 위치와 크기를 브라우저의 localStorage에 저장합니다. 새로고침해도 열려 있던 메모가 제자리에 다시 뜹니다.

앱을 열지 않아도 받고 있던 코드

변경 전에는 데스크톱 컴포넌트가 세 앱을 정적으로 import하고 있었습니다.

// Desktop.tsx (변경 전)
import ChessWindow from "./ChessWindow";
import MinesweeperWindow from "./MinesweeperWindow";
import StickyNotes from "./StickyNotes";

이렇게 쓰면 번들러는 세 앱의 화면과 로직을 데스크톱과 같은 덩어리로 묶습니다. 체스를 한 번도 열지 않는 방문자도 체스 코드를 받고, 브라우저는 그것을 파싱합니다.

여기서 한 가지는 먼저 짚어 두었습니다. 코드를 받는 것과 기능이 도는 것은 다릅니다. 체스 AI는 창을 열 때 만드는 Web Worker 안에서 계산합니다. 체스를 열지 않았는데 뒤에서 계속 수를 읽고 있는 상황은 아니었습니다. 그러니 문제는 CPU를 계속 쓰는 것이 아니라, 첫 접속 때 쓰지도 않을 코드를 내려받고 처리하는 비용이었습니다. 앱을 지워서 없앨 비용이 아니라 뒤로 미룰 수 있는 비용이었던 셈입니다.

코드 분할과 지연 로딩은 같은 말이 아닙니다

두 말은 자주 섞여 쓰이지만 하는 일이 다릅니다.

하는 일 이것만 하면
코드 분할 앱 코드를 별도 JavaScript 청크로 나눈다 파일은 나뉘었는데 처음에 전부 받을 수도 있습니다
지연 로딩 그 청크를 필요한 시점까지 받지 않는다 나뉜 파일이 있어야 미룰 수 있습니다

번들러는 동적 import()를 만나면 그 뒤의 모듈을 별도 청크로 떼어 냅니다. 그 import()를 사용자가 앱을 실행하는 순간에 부르면, 그때 비로소 청크를 받습니다. 이번 작업은 둘을 함께 쓴 것입니다.

// 변경 후: 체스를 실행할 때 부른다
const module = await import("./ChessWindow");

바뀐 흐름은 이렇습니다.

데스크톱 접속
  → 앱 이름·아이콘·실행을 맡는 작은 로더만 준비
  → 사용자가 체스 실행
  → 체스 청크 다운로드
  → 체스 화면 표시
  → 닫았다 다시 열면 이미 받은 코드를 재사용

실제 로더(LazyDesktopApps.tsx)는 앱마다 import 경로를 문자열 그대로 적어 둡니다. 경로를 변수로 조립하면 번들러가 어떤 파일을 떼어야 할지 알 수 없기 때문입니다.

const LOADERS = {
  chess: () => import("./ChessWindow"),
  mines: () => import("./MinesweeperWindow"),
  "sticky-notes": () => import("./StickyNotes"),
};

여기까지는 교과서대로입니다. 손이 간 곳은 그 다음이었습니다.

들을 사람이 아직 없는 실행 요청

이 데스크톱의 앱은 chess:open 같은 브라우저 이벤트를 받고 창을 엽니다. Dock을 누르든, 앱 보기에서 고르든, /chess 주소로 들어오든 결국 이 이벤트 하나가 창을 띄웁니다. 그 이벤트를 듣는 리스너는 체스 컴포넌트 안에 있었습니다.

지연 로딩으로 바꾸면 여기서 순서가 꼬입니다. 사용자가 체스를 누르는 순간에는 체스 컴포넌트가 아직 없습니다. 이벤트는 날아가는데 들을 사람이 없습니다.

가장 먼저 떠오르는 방법은 코드를 다 받은 뒤 같은 이벤트를 한 번 더 보내는 것입니다. 저는 이 방법을 쓰지 않았습니다. 다운로드가 끝난 시점과 컴포넌트가 마운트되어 useEffect로 리스너를 거는 시점은 다릅니다. 이벤트를 다시 쏘는 순간에 리스너가 아직 안 걸려 있으면 요청은 조용히 사라집니다. 어떤 기기에서는 되고 어떤 기기에서는 안 되는 버그가 되기 쉬운 모양입니다.

그래서 이벤트는 작은 로더가 대신 받습니다. 로더는 앱마다 실행 요청 횟수를 세어 두고, 코드가 도착해 컴포넌트를 그릴 때 그 숫자를 launchRequest prop으로 넘깁니다. 앱은 이 숫자가 바뀌면 창을 엽니다. prop은 마운트와 동시에 이미 들어 있으므로, 리스너를 언제 거느냐와 상관없이 요청이 앱에 닿습니다. 이미 열린 앱을 다시 실행하면 숫자가 하나 올라가고, 같은 경로로 전달됩니다.

메모는 클릭이 아니라 저장된 상태로 판단

체스와 지뢰찾기는 「실행하면 받는다」로 충분했습니다. 메모는 그렇지 않았습니다. 새로고침해도 열려 있던 메모가 다시 떠야 하는데, 사용자는 아무것도 누르지 않았기 때문입니다.

그래서 로더가 처음 뜰 때 메모 앱의 저장값을 먼저 봅니다. 메모 코드를 받지 않고도 볼 수 있게, 저장 키 하나만 담은 작은 파일(stickyNotesStorage.ts)을 로더와 메모 앱이 함께 읽습니다. 판단은 세 갈래입니다.

저장된 상태 메모 코드
처음 방문했거나 저장된 메모가 없음 받지 않음
열려 있던 메모가 하나라도 있음 바로 받아서 자동 복원
닫힌 메모만 있음 사용자가 메모 앱을 실행할 때 받음

다른 탭에서 메모를 열면 storage 이벤트로 같은 판단을 한 번 더 합니다.

이 부분을 짜면서 기준이 바뀌었습니다. 지연 로딩을 「클릭했는가」로 생각하고 시작했는데, 실제 기준은 「지금 이 화면에 이 기능이 필요한가」였습니다. 메모가 화면에 떠 있어야 하는 사람에게는 첫 화면부터 필요한 코드입니다.

첫 실행의 대기와 실패 복구

지연 로딩은 비용을 없애지 않습니다. 첫 화면에서 치르던 값을 그 앱을 처음 여는 순간으로 옮길 뿐입니다. 그래서 받는 동안에는 「불러오는 중」 표시를 띄우고, 실패하면 다시 시도 버튼을 보여 주도록 했습니다.

예상하지 못한 것은 재시도였습니다. 프로덕션 빌드에서 청크 다운로드를 일부러 실패시킨 뒤, 같은 import()를 다시 부르게 해 봤습니다. 그런데 이 환경에서는 한 번 실패한 동적 import의 상태가 런타임에 남아 있어서, 다시 불러도 복구되지 않았습니다. 버튼을 눌러도 같은 실패가 반복됐습니다.

그래서 재시도 버튼은 import를 다시 부르지 않고, 그 앱의 주소(/ko/chess 같은 딥링크)로 문서를 새로 엽니다. 새 문서에서는 런타임이 처음부터 시작하므로 청크를 새로 받고, 주소가 그 앱을 가리키므로 새로고침 뒤에도 사용자가 열려던 앱이 그대로 뜹니다. 메모 내용은 브라우저 저장소에 있으므로 이 과정에서 사라지지 않습니다.

이 동작이 모든 번들러와 브라우저에서 똑같이 일어나는지는 확인하지 않았습니다. 제가 본 것은 이 프로젝트의 빌드 환경에서의 결과입니다. 다만 「실패한 import를 다시 부르면 되겠지」라는 가정은 한 번은 직접 깨 보고 넘어가는 편이 안전하다고 생각합니다.

네트워크 요청으로 확인한 것

개발 서버에서 창이 열리는 것만 보고 끝내지 않았습니다. 개발 모드는 번들 구조가 프로덕션과 달라서, 청크가 실제로 나뉘었는지는 프로덕션 빌드를 띄워 네트워크 요청으로 봐야 알 수 있습니다. 확인한 것은 다음과 같습니다.

  • 첫 접속 때 세 앱의 청크가 요청되지 않음
  • 앱을 실행하면 그 앱의 청크만 요청됨
  • 같은 페이지에서 닫았다 다시 열면 청크를 다시 요청하지 않음
  • 열려 있던 메모는 새로고침 뒤 자동으로 복원되고, 닫힌 메모만 있으면 메모 청크를 받지 않음
  • 다운로드를 일부러 실패시킨 뒤 재시도로 복구됨

메모 앱 자체의 저장·복원·색 바꾸기·이동·크기 조절·접기·삭제 취소·다른 탭 동기화와 모바일 화면도 다시 확인했습니다. 타입 검사와 프로덕션 빌드는 통과했고, 데스크톱과 앱 주소 페이지가 빌드 때 정적으로 만들어지는 것도 그대로였습니다.

하지만 몇 % 빨라졌는지는 재지 않았습니다. 확인한 것은 세 앱의 코드가 첫 다운로드에서 빠졌다는 사실까지입니다. 로딩 시간이나 Lighthouse 점수가 얼마나 달라졌는지는 측정하지 않았으니, 이 글에도 그 숫자는 없습니다.

어디까지 나눌지 정하는 기준

이번에 나눈 것은 체스, 지뢰찾기, 스티커 메모 세 앱뿐입니다. 데스크톱의 모든 앱을 이렇게 바꾸지는 않았습니다.

잘게 나눌수록 좋은 것은 아닙니다. 청크 하나하나가 요청이고, 첫 실행마다 대기가 생깁니다. Dock이나 창 시스템처럼 첫 화면에 반드시 필요한 것까지 나누면 오히려 화면이 늦게 섭니다. 제가 쓴 기준은 두 가지였습니다. 첫 화면에 없어도 되는가, 그리고 코드를 떼어 낼 만큼 덩어리가 큰가. 세 앱은 둘 다 해당됐습니다. 다른 서비스를 창으로 여는 앱은 이미 iframe 주소 하나라서 나눌 코드가 거의 없습니다.

이 방법은 Next.js 전용 기술도 아닙니다. 동적 import()는 JavaScript 문법이고, 그것을 보고 청크를 나누는 것은 번들러가 하는 일입니다. React의 lazy나 Next.js의 next/dynamic도 결국 같은 원리 위에 있습니다. 이번에는 실행 요청을 prop으로 넘기고 메모의 복원 조건을 직접 판단해야 해서, 그 위의 편의 기능 대신 import()를 직접 불렀습니다.

「이 기능을 빼면 빨라질까?」라는 질문을 다시 받는다면, 저는 빼기 전에 그 기능의 값을 언제 치르고 있는지부터 보겠습니다. 첫 화면에서 모든 방문자가 치르던 값을 그 기능을 쓰는 사람이 쓰는 순간에 치르게 옮길 수 있다면, 기능을 지울 이유가 하나 줄어듭니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.