nika-blog

Angular 서비스 속도개선 : lazy-loading 본문

performance

Angular 서비스 속도개선 : lazy-loading

nika0 2022. 11. 13. 21:25

이 포스팅은 재직하던 회사 인사팀의 요청으로 작성한 회사 기술 블로그에 게재된 글의 초안입니다. 

 

<목차>

lazy-loading이란

lazy-loading은 브라우저가 요구 사항에 따라 필요한 구성 요소 또는 모듈만 로드하는 기술입니다. url과 관계없이 모든 모듈이 다운로드되지 않아서 성능에 이점이 있습니다.

 

장점

  • 필요한 부분만 로딩하므로 초기 로딩의 부하를 분산할 수 있습니다.
  • 더 이상 부모 모듈이 자식 모듈을 호출하지 않아도 됩니다. 즉, 느슨한 연결로 구성하므로 서로 독립적으로 구성할 수 있으며 쉽게 붙이거나 뗄 수 있도록 구성됩니다.

단점

  • 첫 로딩 시 한꺼번에 저장해두는 방식 보다 늦게 로딩 되므로 즉시 전환보다는 약간의 딜레이가 생깁니다.

 

lazy-loading 이 필요했던 이유

서비스 퀄리티

빠른 속도로 성장하는 스타트업에서는 속도까지 생각하면서 기능 개발하기가 쉽지 않습니다.

어느 순간 서비스에 속도가 느리다는 VOC 가 들어오면, 서비스 퀄리티에 고민하게 됩니다.

저희 서비스도 모든 코드를 번들링한 js 파일을 한번에 불러오기 때문에 사용자가 처음 서비스에 접속했을 때 로딩속도가 무척 느렸습니다. 

 

js bundle

자바스크립트 프로젝트를 만들 때, 코드를 번들로 묶어서 만듭니다.

서비스에 기능이 추가될수록 전달해야하는 파일의 크기도 커지고, 브라우저가 파싱해야 하는 정보도 많아져서 속도가 점점 느려집니다. 

 

lazy-loading

개발자의 목표는 유저가 지금 당장 필요한 정보에 우선순위를 두어 순서대로 로딩하는 것입니다. 

필요하지 않는 정보들까지 받아오느라 속도가 느려지면, 유저가 떠날 수도 있습니다. 

 


구현 방법

webpack

lazy-loading 을 하기 위해서는 하나의 큰 번들을 code-spliting 과정을 통해 여러개의 작은 번들로 쪼개고, 필요한 시점에 로드하면 됩니다. 

Webpack으로 코드 스플리팅을 할 수 있는 방법은 여러가지이지만, 그 중 하나인 dynamic import의 간단한 예시를 보겠습니다.

https://webpack.kr/guides/code-splitting/

 

Code Splitting | 웹팩

웹팩은 모듈 번들러입니다. 주요 목적은 브라우저에서 사용할 수 있도록 JavaScript 파일을 번들로 묶는 것이지만, 리소스나 애셋을 변환하고 번들링 또는 패키징할 수도 있습니다.

webpack.kr

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // 에러가 있는 작업을 수행합니다.
    // 예를 들어, 모든 네트워크 에러가 발생할 경우 요청을 재시도할 수 있습니다.
  });

 

위 코드는 원래 방식대로 import를 하지 않고 원하는 함수 형태로 써서 필요할 때에만 import를 실행한 뒤, 돌아오는 promise에서 모듈을 실행할 수 있도록 만듭니다.

만약 DynamicComponent 라는 컴포넌트가 로딩하는데에 오랜 시간이 걸린다면, 유저가 사용하기 전 까지는 import를 안해주는게 낫겠죠.이러한 dynamic import를 Angular 에서 활용해보겠습니다.

 

angular : module-routing 구현

https://angular.io/guide/lazy-loading-ngmodules

 

Angular

 

angular.io

angular 에서 ngModules 는 기본적으로 서비스 진입시 바로 로드됩니다. 

이때 lazy-loading 을 이용하여, 필요에 따라 ngModules 를 로드하면서 초기 번들 사이즈를 줄일 수 있습니다. 

 

example : item.module lazy-loading 처리

// app.module.ts
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent,
    ...
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
// app-routing.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { MainComponent } from './main.component';

const routes : Routes = [
  { path : '', component : MainComponent  },
  {
    path: 'items',
    // lazy-loading 적용
    loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)
  }
];

@NgModule({
  imports: [ RouterModule.forRoot(routes)],
  exports: [ RouterModule ]
})

export class AppRoutingModule { }
// item.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { ItemRoutingModule } from './item0routing.module';
import { ItemMainComponent } from './item.component';

@NgModule({
  exports: [
    ItemMainComponent
  ],
  declarations: [    
    ItemMainComponent
  ],
  imports: [
    CommonModule,
    ItemRoutingModule
  ],
  providers: []

})

export class MaterialModule { }
// item-routing.ts>

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { ItemComponent } from './item.component';

const itmeRoutes: Routes = [
  { path: '', component: ItemComponent }
];

@NgModule({
    imports: [ RouterModule.forChild(itmeRoutes)],
    exports: [ RouterModule ]
  })

  export class ItemRoutingModule { }

 

 

주요 버그 내용

  1. 불러와지지 않은 모듈에 포함된 컴포넌트를 사용하는 로직이 다른 모듈에 있는 경우
    lazy-loading 처리되어 불러오지 못한 모듈에 포함된 컴포넌트를 모달로 띄우는 로직이 있는 경우, 에러가 발생합니다. 
    모듈화를 할때, 해당 컴포넌트를 import 해서 사용하는 로직이 확인이 필요합니다. 

    예를 들어, 홈 탭과 커머스 탭으로 관심사를 분리하여 모듈화, lazy-loading 처리를 진행했다고 가정하겠습니다. 
    홈 탭에서 사용자가 커머스 탭으로 한번도 클릭하지 않은 상태라면 아직 커머스 모듈이 불러와지기 전입니다. 
    홈 화면 배너 등에서 커머스 모듈에 포함된 컴포넌트를 라우팅 이동없이 사용하는 로직이 있다면 에러가 발생합니다. 
    컴파일 단계나 런타임 단계에서 에러로 잡히지 않고 해당 진입점에서 재현했을 때만 에러가 발생하기 때문에 QA 엔지니어의 도움을 받아서 에러 진입점을 리포트 받은 후 해결했습니다. 

  2. console의 pipe error
    angular 의 pipe 를 모듈화하는 작업도 함께 진행했는데요, pipe 를 사용하고 있는지 전역으로 확인하기는 어려운 문제가 있었습니다. 그래서 pipe 를 사용하고 있는 모듈에서 import 가 되지 않아 에러가 나는 경우가 있었습니다. 

  3. 해쉬라우터 페이지 뒤로가기 home 으로 이동
    이건 저희 서비스에 한정된 문제일수도 있습니다만, 기존에 페이지 라우팅 사용없이 모달로 띄워지는 컴포넌트들이 많았습니다. 
    해시 라우터에서 뒤로가기 로직을 modal을 끄는 방식으로 설정해 두어서, 탭 단위로 모듈화 되면서 url 변경이 발생해서 버그가 발생했습니다. 뒤로가기 함수에 history 관리를 추가함으로써 해결했습니다. 

현실적인 문제

첫번째, 화면이 페이지로 분리되어 있지 않음.

속도 개선에 대한 작업이 우선순위가 높지 않았기 때문에 대부분의 기능 개발은 개발공수를 최소한으로 이루어졌습니다. 

저희 서비스의 가장 큰 문제는 탭 이동을 제외하고 각 탭에서 열리는 모든 페이지가 모달로 개발되어 있다는 점입니다. 

페이지 단위로 모듈화를 시켜, lazy-loading 처리를 해야했지만 모달의 기본 레이아웃과 기능을 사용하지 않고 페이지로 변경하는 것은 리소스가 많이 소요되는 일이었습니다. 

 

두번째, 컴포넌트를 모듈화하면서 의존성 문제 발생

서비스는 하나의 app-module 로 이루어져 있었습니다.

app-module에서 각 컴포넌트를 분리하여 모듈화하고, 필요한 기능들도 별도 모듈로 만들어서 의존성을 주입해야 했습니다. 

app-module 에 import 한 모듈은 lazy-loading 처리를 해도 첫 진입시에 즉시 로드되기 때문에 공통으로 사용되는 컴포넌트, 파이프 등도 모듈화를 시키고 각 기능에 문제를 발생시킬 수 있는 사이드 이펙트도 고려해야 했습니다. 

 


해결방법

첫번째, 탭 단위로 모듈화

속도 개선 작업 전 서비스는 하나의 appModule, 채팅모듈 2개 모듈밖에 없었습니다. 

탭 단위로 모듈화한다면, 진입점에 따라 필요한 탭의 모듈만 먼저 로드하고 탭 이동시에 다른 모듈을 불러옴으로써 첫 로딩속도를 개선할 수 있었습니다. 

화면단위로 작업하면, 성능은 향상될 수 있으나 모달을 페이지화 하는데 개발공수가 너무 많이 들어가기 때문에 탭단위로 진행하고 성능 지표의 변화 양상을 지켜보기로 했습니다. 

전 후 모듈 사이즈 및 개수 비교

저희 서비스는 nextjs 로 기술스택을 변경할 계획이 있어서 페이지단위까지 lazy-loading 처리를 진행하지는 않았는데요, 논의과정에서는 어떻게 모달로 연결된 컴포넌트들의 의존성을 끊어내고 lazy-loading 처리할지 논의가 많이 되었습니다. 

 

routing으로 바꾸기 힘든 부분은 bootstrap에서 제공하는 ngbModal을 사용하는 방법을 처음생각했는데, 중첩 모달의 경우 마지막 단계 모달까지 ngbModal로 변경해주어야 각 번들에서 분리되는 한계가 있었습니다.

 

routing 처리하기 힘든 부분은 비동기 lazy-loading 컴포넌트 import 함수 구현를 적용하거나, ngIf 로 처리할 수도 있을 것 같습니다. 

https://dev.to/kzagoris/lazy-load-a-component-in-angular-without-routing-2024

 

Lazy-load a component in Angular without routing

One of the most desirable features in Angular is to lazy load a component the time you need it. This...

dev.to

 

두번째, 탭 단위 컴포넌트와 필수 컴포넌트 및 기능은 모듈화하고 전수 QA 진행.

탭 단위 컴포넌트화를 진행하게 되면 각 탭에서 사용하는 공통 컴포넌트와 파이프들은 필수적으로 모듈화가 되어야 했기 때문에 app-modules 에서 관심사를 분리하여 모듈화를 진행하였습니다. 

서비스 기능에 이슈가 있을 수 있어서 전수 QA 를 진행하였습니다. 

 

 

배포 후 발생한 문제 

chunk-error

탭 별로 번들이 새로 생성되면서 배포 후에 발생한 문제가 있었습니다. 

main bundle 이 version1 이고, version2가 배포되면서 앱엔진에서 마이그레이션을 version2로 진행하게 되면 version1의 main-bundle에서 아직 다른 js-bundle를 불러오지 않았을 경우 chunk-error 가 발생합니다. 

서비스를 나갔다 오면 다시 새로운 main-bundle을 요청하기 때문에,

해당 버그를 경험하는 사용자는 탭 이동 없이 배포중 앱을 사용하고 있다가 배포 완료 후 탭을 이동한 경우입니다. 

 

https://velog.io/@goon126/%EC%B2%AD%ED%81%AC-%EC%97%90%EB%9F%AC

 

[React] 청크 로드 에러 해결하기 (Loading chunk failed)

지난시간에는 코드스플리팅에 대해서 설명했습니다. 허나 무작정 해당 기술을 적용하게되면 이전글에서 설명했다시피 문제가 발생합니다. 바로 배포에서 문제가 발생하는데요. 해서 해당 이슈

velog.io

 

해결방법

해당 버그 해결방법에 대해 논의한 결과 아래 2가지 방법으로 처리하기로 했습니다. 

 

1. 직전 배포 chunk-file 유지

-> Jenkins 에서 script 를 수정하여 제공되는 gcp 앱 버전에 직전 배포 chunk-file 과 현재 chunk-file을 모두 제공하기로 했습니다. 

2. chunk-error 발생하면 해당 화면에서 새로고침 처리 : global-exeption 이용

1에서 chunk-error 거의 다 해결될 것으로 생각하고 있지만 혹시 몰라서 global-exeption으로 서비스에서 chunk-error 가 발생했을 때, 새로고침 처리를 통해 현재 배포된 main-bundle 을 사용할 수 있도록 처리하였습니다. 

 

https://pkief.medium.com/global-error-handling-in-angular-ea395ce174b1

 

Global Error Handling in Angular

Learn how to automatically catch all errors in a web application written in Angular and process them accordingly

pkief.medium.com

 

service 재생성

이미 서비스가 providedIn: 'root' 로 앱 레벨에서 생성되었지만, lazy-load 되는 모듈에서 providers 로 제공할 경우 새로 생성되어 이전의 서비스를 덮어버리는 문제였습니다. 해결 방법은 앱 단위로 사용되는 서비스들은 providers 로 제공하지 않고, providedIn: 'root' 로 제공하면 됩니다.

service가 다시 생성되면 동작을 하는 콜백을 지워버리는 등의 문제가 발생할 수 있습니다. navigation service라면, 뒤로가기 로직에 버그가 발생할 수 있겠습니다.

 

결과

  • main-bundle size 는 22.7% 감소
  • FCP 21.6% 감소 : sentry 기준

webpack bundle 크기 분석 도구 : npm: webpack-bundle-analyzer

분석 기준 : parsed size : 웹팩이 트리셰이킹을 마친 결과물

 

이번에 서비스의 코드를 모듈화하고 lazy-loading 처리하면서 모듈화의 중요성에 대해서 다시금 깨달았습니다.

제가 입사하자마자 리드분께 드렸던 말이 '어려운 일을 하고싶습니다.' 였습니다. 역시 신규입사자의 열정일까요? 그렇게 속도개선 프록젝트를 인수인계 맡아, 마무리하게되었는데요, 리소스 때문에 타협한 부분도 있지만 bundle 사이즈와 속도는 증가한 것이 지표로 확인할 수 있어서 보람을 느꼈습니다. 여담으로, 오히려 lazy-loading 처리에 집중하느라 서비스 첫 진입시 로드되는 아주 큰 이미지(무려 2MB)에는 관심을 기울이지 못했는데 다음에 이런 프로젝트를 진행하겠된다면 이미지 먼저 해결하고 가는것도 적은 리소스를 좋은 성과를 낼 수 있을 것 같다는 생각이 드네요. 

 

다음 속도개선 프로젝트로는 cloud-funtion을 이용하여 img 를 webp 형식으로 모두 변경해서 관리 및 제공할 수 있는 기능을 개발하고 싶습니다. 커머스나 프로그램 등 이미지를 많이 사용하는 서비스가 저희도 있는데 이미지 최적화만 진행해도 성능이 많이 좋아질 것 같아요. 

 

저희 개발팀은 문제가 발생했을 때, 잘잘못을 가리기보다 원인을 찾고 해결하는 데에 중점을 둡니다. 버그 등 어려운 문제가 발생하고나 기술 관련 고민이 있을때, 언제든지 동료들과 논의하고 조언을 구할 수 있습니다. 이번 속도개선 프로젝트는 거의 모든 파일에 변경사항이 발생했다고 해도 과언이아니었는데요, 전수 QA 를 진행하면서 진입점까지 상세하게 잡아주신 QA 엔지니어분과 항상 자신의 일처럼 고민해주셨던 챕터 분들께 정말 감사드립니다. 속도 개선 미팅을 진행하면서 많은 조언을 해주신 챕터리드분, CTO 님께도 정말 감사합니다. 많이 고민하고 성장할 수 있는 프로젝트를 하고 이렇게 글을 쓸 수 있어서 기쁘네요.

'performance' 카테고리의 다른 글

Web 서비스 속도개선  (2) 2025.01.30