앵귤러 중급 강좌 #5 Standalone과 Lazy Loading
앱이 작을 때는 모든 코드를 한 번에 받아도 별 문제가 없습니다. 하지만 화면이 수십 개로 늘어나고 차트 라이브러리, 에디터, 관리자 전용 화면처럼 무거운 의존성이 붙기 시작하면 이야기가 달라집니다. 사용자가 처음 접속해서 보는 화면 하나 띄우자고 어드민 페이지에서나 쓰는 차트 라이브러리까지 모두 내려받게 만드는 건 낭비입니다.
기초 강좌 #6 Router 마지막에 loadComponent로 라우트 하나를 lazy하게 분리하는 패턴을 짧게 보여드렸는데, 이번 글에서는 그 주제를 본격적으로 분해합니다. 그 출발점은 의외로 Standalone 컴포넌트의 imports 배열입니다. “내가 어떤 의존성을 갖는지를 컴포넌트가 직접 안다"는 이 모델이 lazy loading을 지금처럼 깔끔하게 만들어 준 일등 공신이기 때문입니다.
Standalone Components 깊이 #
기초 강좌 #2에서 standalone 컴포넌트의 모양은 살펴봤습니다. 다시 한 번 들여다봅시다.
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AvatarComponent } from '../shared/avatar.component';
@Component({
selector: 'app-user-card',
standalone: true,
imports: [CommonModule, RouterLink, AvatarComponent],
templateUrl: './user-card.component.html',
})
export class UserCardComponent {
// ...
}핵심은 imports 배열입니다. 이 컴포넌트의 템플릿에서 사용할 다른 컴포넌트, 디렉티브, 파이프를 컴포넌트가 직접 선언합니다. NgModule이라는 중간 등록부가 없고, 그래서 컴파일러도 빌드 도구도 의존성 그래프를 명확하게 파악할 수 있습니다.
이게 왜 lazy loading과 관련이 있을까요? 컴파일러가 “이 컴포넌트가 정확히 무엇을 끌고 들어오는지"를 알면, 빌드 시점에 코드 청크를 분할할 때도 어떤 코드가 어디로 가야 하는지 자동으로 결정할 수 있습니다. 우리가 라우트에서 () => import(...) 한 줄만 적어도 청크가 깔끔하게 떨어지는 이유가 여기에 있습니다.
또 한 가지 실용적인 점 — standalone 컴포넌트는 그 자체가 라우트의 단위가 될 수 있습니다. 별도의 모듈로 감싸지 않고도 loadComponent에 그대로 넘길 수 있다는 뜻입니다. 옛날 NgModule 시절에는 페이지 하나 lazy하게 만들려고 전용 모듈과 라우팅 모듈을 같이 만들어야 했던 걸 생각하면 굉장한 단순화입니다.
NgModule 짧은 회고 #
Angular 14 이전에는 모든 컴포넌트, 디렉티브, 파이프가 NgModule이라는 단위에 등록되어 있어야 했습니다. AppModule, SharedModule, UserModule처럼 폴더마다 *.module.ts가 한 개씩 있고, 그 안에 declarations, imports, exports, providers를 빼곡히 채우는 식이었습니다.
@NgModule({
declarations: [UserListComponent, UserDetailComponent],
imports: [CommonModule, RouterModule.forChild(routes)],
providers: [UserService],
})
export class UserModule {}문제는 보일러플레이트가 너무 많고, 같은 컴포넌트를 여러 곳에서 쓰려면 exports를 따로 챙겨줘야 했고, 의존성 흐름이 모듈 사이로 뿌옇게 퍼져서 추적이 어려웠다는 점입니다.
Angular 14에서 standalone이 도입되고 v17에서 기본값이 되면서 NgModule은 사실상 legacy로 밀려났습니다. 새 프로젝트는 모두 standalone이고, 공식 문서,튜토리얼,ng new 결과물도 모두 standalone 기준입니다.
@NgModule, declarations, RouterModule.forRoot(...) 같은 키워드가 보이면 “아, legacy 패턴이구나” 정도로 알아보면 됩니다. 새로 짤 때는 standalone, 기존 코드는 그대로 유지하면서 점진적으로 standalone으로 마이그레이션 — 이게 현재 권장 방향입니다.Eager loading vs Lazy loading #
라우트의 컴포넌트를 라우트 정의에 직접 적으면, 그 컴포넌트는 eager하게 로드됩니다.
import { HomeComponent } from './pages/home.component';
import { AdminComponent } from './pages/admin.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', component: AdminComponent },
];AdminComponent를 import한 시점에 그 코드는 라우트 정의 파일에 묶이고, 라우트 정의는 app.config.ts에 묶이고, 결국 초기 번들에 포함됩니다. 사용자가 /admin에 한 번도 들어가지 않더라도 첫 페이지를 띄울 때 admin 코드까지 다 받게 되는 셈입니다.
Lazy loading은 그 import를 함수 안으로 미루는 기법입니다.
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent),
},
];빌드 도구(esbuild/webpack)는 import('./pages/admin.component') 패턴을 보고 별도 청크로 떼어냅니다. 사용자가 /admin으로 이동하는 순간에 비로소 그 청크가 네트워크로 받아져 실행됩니다.
판단 기준은 단순합니다.
- 자주 보는 화면이고 가벼움 → eager가 낫습니다. 청크가 갈리면 오히려 라우트 전환 때마다 짧은 지연이 생깁니다
- 가끔 들어가는데 무거움 (관리자 페이지, 통계 대시보드, 설정 화면 등) → lazy가 정답입니다
- 로그인 전후 화면이 완전히 다름 → 로그인/회원가입은 eager로, 본 앱은 lazy로 분리하는 패턴이 흔합니다
loadComponent로 라우트 단위 lazy
#
가장 가벼운 분리 단위는 라우트 하나입니다. 위에서 본 패턴이 표준입니다.
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'reports',
loadComponent: () =>
import('./pages/reports/reports.page').then(m => m.ReportsPage),
},
{
path: 'settings',
loadComponent: () =>
import('./pages/settings/settings.page').then(m => m.SettingsPage),
},
];.then(m => m.ReportsPage) 부분이 보일러플레이트처럼 보이지만, 더 짧게 줄일 수도 있습니다. 컴포넌트를 default export로 내보내면 됩니다.
@Component({
selector: 'app-reports',
standalone: true,
template: `<h1>리포트</h1>`,
})
export default class ReportsPage {}{
path: 'reports',
loadComponent: () => import('./pages/reports/reports.page'),
},then 한 단계가 통째로 사라집니다. 다만 default export는 한 파일에 하나만 가능하니, 페이지 컴포넌트처럼 그 파일에서 한 가지만 내보내는 경우에 잘 어울립니다. shared 컴포넌트를 default export로 만들면 다른 곳에서 import할 때 이름을 매번 새로 지어야 해서 오히려 불편합니다.
loadChildren으로 라우트 그룹 lazy
#
페이지 하나가 아니라 여러 페이지가 같은 영역에 묶여 있다면 loadChildren이 더 깔끔합니다. 예를 들어 /admin 아래에 대시보드, 사용자 관리, 설정 같은 화면들이 모여 있다면 그 묶음을 통째로 lazy하게 분리할 수 있습니다.
import { Routes } from '@angular/router';
export const ADMIN_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('./pages/dashboard.page').then(m => m.DashboardPage),
},
{
path: 'users',
loadComponent: () =>
import('./pages/users.page').then(m => m.UsersPage),
},
],
},
];export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];/admin에 처음 들어가는 시점에 admin.routes.ts와 그 안에서 import하는 모든 컴포넌트가 청크로 받아집니다. 라우트 그룹을 한 단위로 다루기 때문에 영역별 폴더 구조와도 자연스럽게 맞아떨어집니다.
Provider도 lazy 구간에만 #
Lazy 구간에서만 쓰는 서비스가 있다면, 그 provider도 lazy 라우트의 providers에 넣어두면 됩니다. 그러면 해당 서비스의 코드도 lazy 청크에 함께 묶여 들어가고, 그 영역에 들어오기 전까지는 메모리에 인스턴스화되지도 않습니다.
export const ADMIN_ROUTES: Routes = [
{
path: '',
providers: [
provideHttpClient(),
AdminAnalyticsService,
{ provide: REPORT_API_BASE, useValue: '/api/admin' },
],
loadComponent: () =>
import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [/* ... */],
},
];라우트 단위 providers는 그 서브트리에 한정된 격리된 인젝터를 만듭니다. 같은 토큰이 루트와 lazy 구간 양쪽에 있을 때, lazy 안에서는 lazy 쪽 인스턴스를 쓰게 됩니다. 영역별로 설정이 다른 HTTP 클라이언트, 분석 서비스 같은 걸 깔끔하게 격리할 수 있습니다.
빌드 분석 #
Lazy 청크가 잘 떨어지고 있는지 확인하려면 production 빌드 결과를 봐야 합니다.
ng build빌드가 끝나면 터미널에 청크별 사이즈가 출력됩니다.
Initial chunk files | Names | Raw size
main.abc123.js | main | 142.5 kB
polyfills.def456.js | polyfills | 33.2 kB
Lazy chunk files | Names | Raw size
chunk-ghi789.js | admin-routes | 78.4 kB
chunk-jkl012.js | reports-page | 24.1 kBInitial에 들어 있는 게 첫 페이지를 띄울 때 받는 코드입니다. 여기에 들어가지 말아야 할 무거운 라이브러리(차트, 에디터, PDF 뷰어 등)가 보이면 lazy로 옮길 후보입니다.
청크 안에 정확히 뭐가 들어 있는지 보려면 source-map-explorer가 편합니다.
npm install -g source-map-explorer
ng build --source-map
source-map-explorer dist/my-app/browser/*.js브라우저에 트리맵이 열리고 어떤 라이브러리가 어느 청크에서 얼마를 차지하는지 한눈에 보입니다. “왜 main 청크에 chart.js가 들어 있지?“라는 식의 디버깅을 할 때 결정적입니다.
Preloading 전략 #
Lazy의 단점은 첫 이동 시점에 짧은 지연이 생긴다는 겁니다. 사용자가 메뉴를 누르고 화면이 뜨기까지 청크를 받는 시간이 추가됩니다. 이걸 preloading으로 완화할 수 있습니다 — 사용자가 메뉴를 누르기 전에 미리 백그라운드에서 받아두는 전략입니다.
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};PreloadAllModules는 이름은 옛 NgModule 시대의 흔적이지만, standalone 시대에도 그대로 동작하는 상수입니다. 의미는 “초기 로드가 끝난 직후, 모든 lazy 청크를 백그라운드로 미리 받아둬"입니다.
전부 미리 받는 게 부담스럽다면 사용자가 idle 상태일 때만 받거나, 특정 라우트만 골라서 받는 커스텀 전략을 만들 수 있습니다.
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PreloadOnIdleStrategy implements PreloadingStrategy {
preload(_route: Route, load: () => Observable<unknown>): Observable<unknown> {
return timer(2000).pipe(switchMap(() => load()));
}
}provideRouter(routes, withPreloading(PreloadOnIdleStrategy)),첫 페이지 렌더 후 2초 뒤부터 lazy 청크들이 조용히 받아집니다. 사용자가 메뉴를 누를 때쯤이면 캐시에서 바로 꺼내 쓰니까 체감 지연이 거의 없습니다. “lazy의 빌드 사이즈 이점 + eager의 즉시성"을 동시에 챙길 수 있는 가성비 좋은 패턴입니다.
흔한 실수 #
Lazy loading을 처음 적용할 때 몇 가지 함정이 있습니다.
1. lazy 구간에서 eager 쪽 큰 컴포넌트를 import
// admin/pages/dashboard.page.ts (lazy 청크)
import { HomeChartComponent } from '../../home/home-chart.component';HomeChartComponent가 main 청크에 들어 있는 무거운 컴포넌트라고 해도, lazy 쪽에서 직접 import하면 빌드 도구가 그걸 lazy 청크에도 함께 끌어옵니다. 결과적으로 같은 코드가 두 청크에 중복으로 들어가 버립니다. 공유해야 한다면 shared 폴더로 빼서 양쪽이 같은 청크 의존성을 갖도록 정리해야 합니다.
2. shared “kitchen sink” 모듈/배열
NgModule 시대에 흔히 만들던 SharedModule을 standalone 시대에도 비슷하게 만들려는 분들이 있습니다. “공통 컴포넌트, 디렉티브, 파이프를 한 배열에 모아놓고 어디서든 imports에 풀어 쓰자"는 발상인데, 이게 lazy 청크 분할을 망치는 주범이 됩니다. 한 컴포넌트만 쓰고 싶어도 그 배열이 끌고 들어오는 모든 의존성이 함께 들어오기 때문입니다. 모던 앵귤러에서는 컴포넌트 단위로 imports를 직접 명시하는 게 정석입니다.
3. providedIn: 'root'로 모든 서비스를 묶기
서비스를 만들 때 무심코 @Injectable({ providedIn: 'root' })만 쓰는 분이 많습니다. 정말 전역에서 쓰는 서비스라면 맞지만, lazy 영역에서만 쓰는 서비스라면 lazy 라우트의 providers에 두는 편이 청크 분리에 유리합니다.
4. tree-shakable하지 않은 라이브러리
이건 사용자 잘못이 아니라 라이브러리 잘못이지만, 어떤 라이브러리는 import 한 줄만 적어도 패키지 전체를 끌고 들어옵니다. source-map-explorer로 확인해보고 사이즈가 비정상적으로 크면 라이브러리 문서에서 “modular import” 가이드를 찾아보세요. 예: import { format } from 'date-fns'처럼 함수 단위로 import해야 하는 경우.
마무리 #
이번 글에서는 모던 앵귤러의 코드 분할 전략을 한 바퀴 돌아봤습니다. 핵심을 정리하면:
- Standalone의 명시적
imports가 코드 분할의 단단한 기반이다 - NgModule은 legacy. 만나면 알아보되, 새로 짤 때는 standalone
- Eager vs Lazy의 판단 기준은 “초기 번들에 넣을 가치가 있는가”
- **
loadComponent**는 라우트 하나, **loadChildren**은 라우트 그룹, **providers**는 영역 격리 - 빌드 분석으로 어떤 코드가 어디 청크에 들어가는지 정기적으로 확인
- Preloading으로 lazy의 지연을 사실상 제거
다음 글인 “앵귤러 중급 강좌 #6 Guards와 Resolver"에서는 Router의 또 다른 한 축 — 라우트에 들어가기 전에 권한을 확인하는 Guards와, 화면을 띄우기 전에 데이터를 미리 받아두는 Resolver를 다루겠습니다. 인증이 붙는 실무 라우팅에서 빠질 수 없는 도구들입니다.