Code splitting을 쉽게 하기 위해 만든 grow-loader

안녕하세요, 저는 LINE MANGA팀의 자바 스크립트 개발자, @sunderls입니다. 일본에서는 LINE으로 만화를 볼 수 있다는 것, 알고 계셨나요? 혹시 여러분은 LINE MANGA1) 서비스를 이용해 보셨나요? 예전에 블로그를 통해서도 나누었듯이(LINE MANGA: Page Stack을 이용해서 페이지 전환 처리하기), LINE MANGA는 웹 기반으로 구현되어 있습니다. 이 서비스는 LINE 앱 안에서 구동되는 서비스이기 때문에, LINE 앱을 이용할 때와 마찬가지로 편안하고 매끄러운 UX를 제공하고자 저희는 많은 노력을 쏟았습니다. 우리는 우리의 목표를 달성하고자 code-splitting을 도입하였는데, 이 글을 통해 code-splitting을 손쉽게 적용할 수 있도록 개발한, LINE의 오픈 소스 프로젝트인 grow-loader를 여러분께 소개하고자 합니다.

코드를 분리하려고 했던 이유

우리 서비스의 자바 스크립트 코드는 단 하나의 번들로 빌드됩니다. 서비스 규모가 자라면 페이지 개수도 많아지고, 복잡한 기능이 추가되고, 이에 따라 번들은 점점 커집니다. 앞으로의 대처를 진지하게 고민한 결과, 우리는 늦기 전에 code-splitting을 도입하기로 결정하였습니다.

HOC 솔루션을 사용하지 않은 이유

저희가 처음에 시도한 것은 react-loadable 같은 일반적인 HOC (Higher Order Component) 솔루션을 도입하는 것이었습니다만, 다음과 같은 심각한 문제들과 맞딱드리게 되었습니다.

  • 페이지 전환이 빠르지 않음

    이 문제는 어쩌면 매우 당연한 현상일지도 모르겠습니다. HOC 솔루션은 유저 액션이 발생하면 컴포넌트 로딩을 지연시킵니다. 그리고 페이지 전환은 모든 컴포넌트가 로딩된 후에야 발생합니다. 즉, 컴포넌트 로딩 지연으로 인해 페이지 전환은 더 이상 바로 수행되지 않습니다. LINE MANGA 서비스에 있어서 페이지 전환이 느리다는 것은? 매우 심각한 문제입니다.

  • 프리로딩이 도움이 되기는 하나, 페이지가 길면 무용지물

    현 페이지를 기준으로 다음 페이지가 될 가능성이 있는 모든 페이지를 유저의 액션이 발생하기 전, 미리 프리로딩할 수 있습니다. 프리로딩이 도움이 될 때가 있기는 합니다만, LINE MANGA 서비스는 수많은 페이지로 구성되어 있습니다. 우리의 서비스에 프리로딩을 적용한다면, 프리로딩을 관리하기가 무척 어려워집니다. 제 견해로, 전혀 접근하지 않을 페이지마저도 미리 로딩해 놓는 것은 code-splitting의 목표에 반한다고 생각합니다. 생각해 보면, 우리 서비스의 페이지는 대부분 길이가 매우 길기 때문에 애초에 프리로딩이 도움이 되지 않기도 합니다. 왜냐하면, 프리로딩은 DOM을 렌더링하는 시간에 영향을 주지 않기 때문입니다.

  • 공용 로딩 인디케이터 추가? 우리 서비스는 로딩 인디케이터를 사용하지 않습니다

    우리 서비스는 공용 로딩 인디케이터를 사용하는 대신, placeholder 컴포넌트를 이미 사용하고 있었습니다. 우리는 placeholder를 별도로 구별하여 구현하는 대신 더미 데이터를 채운 placeholder 컴포넌트를 페이지 안에 포함시켰습니다. 따라서, placeholder 컴포넌트를 별도의 파일로 분리해 내는 것은 어렵습니다.

  • 자체 후크와 잘 동작하지 못한 HOC 솔루션

    페이지 전환이 순간적으로 일어나게 하기 위해 우리는 우리의 페이지를 스택(Page Stack)으로 관리하도록 하고, componentDidHide와 같은 자체 제작 라이프사이클 후크를 추가했습니다. Code-splitting을 위해 HOC 솔루션을 도입한다면, 수정해야 하는 코드가 너무 많습니다. 시도를 해 보기는 했는데요, 깔끔하게 포기했습니다.

앞서 말씀드렸듯이, 우리는 매끄러운 UX를 절대 포기할 수 없습니다!

그래서 우리는?

HOC 솔루션을 과감히 포기한 후, 우리가 해결해야 하는 문제에 대해 다시 한 번 곰곰히 생각해 보았습니다.

  • Placeholder 컴포넌트를 엔트리 번들에 포함시키면, 우리는 매끄러운 UX를 유지할 수 있다.
  • Placeholder 컴포넌트를 지금 구현된 대로 두고 나머지 부분들을 다른 파일로 분리하면 길이가 긴 페이지는 두 단계 렌더링을 거치게 되므로 보다 빠르게 렌더링된다.
  • 어쩌면 코드를 자동으로 분리해 내는 웹팩 로더를 만들 수 있지 않을까?

꼬리에 꼬리를 무는 생각 끝에 grow-loader가 탄생하게 되었습니다.

grow-loader란?

우리가 자체 제작한 웹팩 로더인 grow-loader를 사용하면, @grow라는 데코레이터만 추가해서 메서드가 동적으로 임포트되게 할 수 있습니다. 다음의 예를 한 번 볼까요? methodToGrow() 메서드와 methodToGrowAndBind() 메서드에 각각 @grow 데코레이터가 추가된 것을 보실 수 있습니다.

class SampleClass {

    @grow
    methodToGrow() {
        // ...
    }

    @grow
    methodToGrowAndBind = () => {
        // ...
    }

    methodToBeBundled(){
        // ...
    }
}

grow-loader가 구동되면, 위 코드의 SampleClass 클래스는 다음의 변화를 겪게 됩니다.

  • SampleClass 클래스에는 기본적으로 methodToGrow() 메서드와 methodToGrowAndBind() 메서드가 없습니다.
  • SampleClass 클래스는 새로운 메서드인 grow() 메서드를 가지게 됩니다. 이 grow() 메서드가 바로 methodToGrow() 메서드와 methodToGrowAndBind() 메서드를 SampleClass 클래스로 로딩하는 역할을 합니다.

grow-loader를 사용하는 예제 코드를 한번 보시겠습니다.

const sample = new SampleClass();
console.assert(a.methodToGrow === undefined);
console.assert(a.methodToGrowAndBind === undefined);

sample.grow().then(() => {
    sample.methodToGrow();
    sample.methodToGrowAndBind();
});

grow-loader를 React와 함께 이용하기

다음과 같이 LongPage라는 클래스가 있다고 가정해 보겠습니다. 그리고 이 클래스에는 분리해 내고 싶은 메서드가 있습니다.

export default class LongPage extends React.Component {

    methodToGrow() {
        // ..
    }

    methodToGrowAndBind = () => {
        // ..
    }

    methodToBeBundled(){
        // ...
    }

    render(){
        return <div>
            this is a very long page
        </div>
    }
}

먼저, 우리 서비스의 모든 페이지를 위해 공용 컴포넌트를 다음과 같이 생성합니다.

class GrowablePage extends React.Component {
    // Load split methods at componentDidMount
    componentDidMount() {
        if (this.grow) {
            this.grow().then(() => {
                this.hasGrown = true;
                this.forceUpdate();
            });
        }
    }
}

다음으로, 앞서 소개드렸던 render() 메서드를 코드 상 나누는 작업을 진행합니다. render() 메서드는 복잡하면서도 긴 페이지를 렌더링하는 메서드입니다. 이 메서드를 동명의 render() 메서드와 renderMore()메서드, 두 개의 메서드로 나눕니다. 이에 따라, render() 메서드는 이전보다 훨씬 간결해지고, 원래 render() 메서드가 렌더링하던 페이지 중 번들에 포함되지 않아도 되는 페이지를 renderMore() 메서드가 렌더링하게 됩니다. 즉, renderMore() 메서드는 번들에 포함되지 않고 동적으로 임포트되어도 되기 때문에, `@grow` 데코레이터를 추가해 줍니다.

export default class LongPage extends GrowablePage {

    @grow
    methodToGrow() {
        // ...
    }

    @grow
    methodToGrowAndBind = () => {
        // ...
    }

    methodToBeBundled(){
        // ...
    }

    @grow
    renderMore() {
        return <div>
            this is below the first view
        </div>
    }

    render(){
        return <div>
            this is basic part
            { this.hasGrown ? this.renderMore() : null}
        </div>
    }
}

grow-loader 적용 후기

grow-loader의 영향 범위는 코드 자체이고, 파일 크기를 감소시키는 것은 grow-loader를 어떻게 사용하느냐에 따라 좌우됩니다. 앞선 예제에서 보셨듯이, render() 메서드가 수행하던 코드를 renderMore() 메서드로 옮기면 옮길수록, 더 나아가 더 많은 메서드에 `@grow` 데코레이터를 추가할수록, 빌드되는 번들의 크기는 줄어듭니다.

LINE MANGA의 번들 크기는 grow-loader를 통해 15% 감소되었습니다. 로딩 시간에는 어떤 영향이 있었을까요? HOC 솔루션은 컴포넌트 로딩 자체를 지연시키기 때문에 일반적인 HOC 솔루션과 grow-loader를 비교하는 것은 어렵습니다. 하지만, 우리는 grow-loader를 이용하여 최상위 페이지의 로딩 시간(constructor() 메서드와 componentDidMount() 메서드가 호출되는 시간 차이)가 40% 단축됨을 확인했습니다. 이러한 결과를 바탕으로 우리의 작업이 성공적이었다고 판단하게 되었습니다.

grow-loader는 이제 GitHub에서 직접 만나보실 수 있으니, 한번 이용해 보시는 것은 어떨까요? 혹시 버그를 발견하거나, 좋은 아이디어가 있으시면 저희에게 꼭! 알려주시길 바랍니다.

1) LINE MANGA는 일본에서만 제공되는 서비스입니다.

Related Post