LINE MANGA: Page Stack을 이용해서 페이지 전환 처리하기

안녕하세요, 저는 LINE MANGA 개발을 담당하고 있는 자바 스크립트 개발자, @sunderls입니다.

여러분, 혹시 LINE 앱에서 만화를 보실 수 있는 것*, 알고 계셨나요? 금시초문이라면, ‘더 보기’ 버튼을 눌러 LINE MANGA 메뉴를 통해 멋진 만화들을 만끽해 보세요.

아래의 그림은 LINE MANGA 서비스의 화면 중 일부입니다. 페이지 전환이 참 매끄럽지 않나요? 여러분께 살짝 귀띔을 해드리자면, LINE MANGA 서비스는 웹 기반으로 개발했습니다. 저희가 보기에는 페이지 전환이 네이티브 앱 못지 않다고 생각하는데, 여러분은 어떻게 생각하세요? 이번 글을 통해서 우리가 이런 결과물을 얻기 위해 어떤 노력을 했는지 여러분께 간단히 소개해 드리고자 합니다.

웹을 기반으로 구현하면 발생하는 문제

웹앱이나 웹페이지를 구현할 때 흔히 React나 Vue를 사용합니다. React나 Vue를 이용해서 페이지 전환을 구현한다면, 바로 Router를 사용하면 되겠다는 생각이 떠오르실 텐데요, react-router나 vue-router를 이용하는 것 자체에는 전혀 문제가 없습니다. 다만, 페이지 전환을 매끄럽게 구현하는 데 적합한가는 다른 문제입니다. 아마 여러분도 다음과 같은 문제를 만나게 되시리라 생각합니다

  • 뒤로 가기 버튼을 눌렀을 때 지연 현상 발생

    지연 현상이 발생하는 이유는 바로 router가 DOM을 대체하기 때문입니다. LINE MANGA의 최상위 페이지는 길이와 구조가 모두 상당히 복잡합니다. 때문에, 뒤로 가기 버튼 (<)을 누르면 지연 현상이 더 오랫동안 발생합니다. 사용자들에게 LINE 서비스별로 안정된 UX를 일관되게 제공하는 것은 매우 중요합니다. 뒤로 가기 버튼을 눌렀을 때의 반응은 반드시 신속해야 합니다.

  • 뒤로 가기를 하면, 스크롤했던 위치로 갈 수 없음

    이 문제는 SPA (Single Page Application)에서 상당히 자주 볼 수 있는 문제입니다. 물론 자바 스크립트를 이용하면 유저가 마지막으로 스크롤했던 곳을 저장하고, 다시 그 값을 가지고 오는 것은 가능합니다만, 결코 쉬운 작업은 아닙니다. LINE MANGA는 세로 방향뿐만 아니라, 화살표 아이콘을 눌러 가로 방향으로도 스크롤할 수 있습니다. 이런 다양한 유스케이스들을 모두 지원한다는 것은 현실적으로 어려운 일입니다.

  • 레이지 로딩되는 이미지가 깜박거림

    Placeholder 컴포넌트가 포함된 페이지가 로딩될 때는 화면에 패턴이 나타나는 것을 볼 수 있는데요, 뒤로 가기 버튼을 눌러도 레이지 로딩이 발생하여 화면이 깜박입니다.

이 문제들을 종합하자면 다음과 같습니다. 페이지를 스크롤하거나, 터치하는 등, 유저의 액션으로 인해 DOM이 변경된 상황에서 이전 페이지로 돌아갈 때 DOM을 원래의 상태로 어떻게 복원시킬 수 있는가?

네이티브 앱으로 개발했다면?

LINE MANGA 서비스의 성능을 네이티브 앱의 수준에 맞추기 위해, 우리 서비스를 네이티브 앱으로 구현하면 어떤지 살펴볼 필요가 있었습니다. 저희는 Apple의 UINavigationController를 살펴보았습니다.

출처: https://developer.apple.com/documentation/uikit/uinavigationcontroller

UINavigationController를 사용하면 view controller는 네비게이션 스택에서 관리되고, 필요에 따라 아래의 함수를 이용하여 view contoller를 push하거나 pop합니다.

func pushViewController(_ viewController: UIViewController, animated: Bool)
func popViewController(animated: Bool) -> UIViewController?

그래서 다다른 생각이 이것입니다. ‘우리도 만약 스택을 이용해서 모든 페이지를 관리한다면, 페이지 전환으로 인한 DOM 대체는 발생하지 않을 것이고, 우리의 문제는 해결될 것이다.’ 이에 따라, LINE MANGA에서도 일명 페이지 스택이라는 비슷한 스택을 구현하였습니다.

페이지 스택 구현하기

페이지 스택을 구현하기 위해 다음의 세 가지 컴포넌트를 구현해야 합니다.

  • Container component, <Stack> – 모든 페이지들을 한 곳에 담아두기 위한 컨테이너입니다./li>
  • Page wrapper, <Page> – 각각의 페이지들을 래핑(wrapping)합니다.
  • Functional component, <withStack()> – 각각의 페이지 컴포넌트를 페이지 스택 컴포넌트와 붙이는 역할을 합니다.

이 세 가지 컴포넌트가 어떻게 이용되는지, HTML과 Router를 이용한 JavaScript 구현부를 통해 함께 확인해 보겠습니다.

HTML 구조 정의하기

LINE MANGA를 위한 HTML 코드의 구조를 다음과 같이 정의하였습니다. 각 페이지는 <page> 태그로 구성되며, 모든 페이지는 <PageStack> 컨테이너 안에 포함됩니다. (참고로 말씀드리자면, Modal도 스택으로 구현되었습니다)

<div id="root">
    <PageStack>

        <page>
            <content />
            <mask />
        </page>

        <page>
            <content />
            <mask />
        </page>

        ...
  </PageStack>

  <ModalStack />
</div>

컨테이너 컴포넌트 생성하기( <Stack>)

다음의 코드는 페이지 컴포넌트를 위한 컨테이너, 즉 <stack>을 생성합니다. 또한 유저의 액션을 처리하기 위한 메서드를 제공합니다. 다음의 코드는 여러분께 소개 드리는 목적의 코드이므로, CSS 관련 코드 등 일부 코드가 생략되어 있습니다.

class Stack extends React.PureComponent {
    constructor(props) {
        super(props);

        this.state = {
            stack: []   // This is where each page element is contained
        };

        // Push the first page
        this.state.stack.push(this.getPage(props.location));
    }

    componentStack = [];  // Store the components of each page

    // Wrap a page defined in the react-router with the <Page> component
    getPage(location) {
        return <Page
            onEnter={this.onEnter}
            onEntering={this.onEntering}
            onEntered={this.onEntered}
            onExit={this.onExit}
            onExiting={this.onExiting}
            onExited={this.onExited}
            >
            {
                React.createElement(this.props.appRoute, { location })
            }
        </Page>
    }

    // Update the stack according to the changes in window.location
    componentWillReceiveProps(nextProps) {
        // When the back button is tapped, pop the top page from the stack
        if (nextProps.history.action === 'POP') {
            this.state.stack.pop();
        } else {
            if (nextProps.history.action === 'REPLACE') {
                this.state.stack.pop();
            }
            // When pushing a page, wrap the page and push it as a new page
            this.state.stack.push(this.getPage(nextProps.location));
        }
    }

    // If swiping is supported, take care of the touchstart event in the capture phase
    componentDidMount() {
        if (this.props.swipable) {
            this.slideContainer.addEventListener('touchstart', this.onTouchStart, true);
        }
    }

    // If the screen is swiped from the left side, swipe the page backwards
    onTouchStart = (e) => {
        if (this.touchStartX < 10 && this.state.stack.length > 1) {
            e.preventDefault();
            e.stopPropagation();
            this.slideContainer.addEventListener('touchmove', this.onTouchMove, true);
            this.slideContainer.addEventListener('touchend', this.onTouchEnd, true);
        }
    }

    // Refresh the page’s translateX and mask opacity based on the finger movement
    onTouchMove = (e) => { ... }

    // When a finger leaves the screen, determine whether to return to the previous page
    onTouchEnd = (e) => { ... }

    // Refresh the mask opacity for the following hooks
    onEnter = () => {...}
    onEntering = () => {...}
    onExit = () => {...}
    onExiting = () => {...}

    // When a new page is pushed, trigger the componentDidHide hook on the previous page
    onEntered = (component) => {
        this.componentStack.push(component);
        const prevTopComponent = this.componentStack[this.componentStack.length - 2];
        if (prevTopComponent && prevTopComponent.componentDidHide) {
            prevTopComponent.componentDidHide();
        }
    }

    // When a page is popped, trigger the componentDidTop hook on the previous page.
    onExited = (component) => {
        this.componentStack.splice(this.componentStack.indexOf(component), 1);
        const topComponent = this.componentStack[this.componentStack.length - 1];
        if (topComponent && topComponent.componentDidTop) {
            topComponent.componentDidTop();
        }
    }
    render() {
        return <TransitionGroup>
            { this.state.stack }
        </TransitionGroup>;
    }
}

export default withRouter(Stack);

래퍼 컴포넌트 생성하기(<Page>)

우리는 각각의 페이지에 React 컴포넌트를 생성하지만, 전환에 대한 정보(예. 방향, 속도)를 매 페이지에 정의하고 싶지는 않습니다. 너무 번거로우니까요. 따라서 페이지 전환을 간단하게 구현하도록, 각 페이지를 <Page> 컴포넌트로 감쌉니다. 예를 들어 <A_Sample_Page>라는 페이지가 있다고 한다면, 이 페이지는 <Page><A_Sample_Page></Page>, 이렇게 페이지 컴포넌트로 감싸집니다.

다음의 코드는 페이지 컴포넌트(<Page>)를 생성하는 예제입니다.

// First, define the transition
const Slide = ({ children, ...props }) => <CSSTransition classNames={'slide'}
    {...props}>
    { children }
</CSSTransition>;

export default class Page extends React.Component {
    constructor(props) {
        super(props);
    }

    // Use the context to pass refPage
    getChildContext() {
        return {
            refPage: (c) => {
                this.page = c;
            }
        }
    }

    componentDidEnter = () => {
        if (this.props.onEntered) {
            this.props.onEntered(this);
        }
        if (this.page && this.page.componentDidEnter) {
            this.page.componentDidEnter();
        }
    }

    // Implement in the following hooks as implemented in the componentDidEnter() hook
    componentDidExit = () => {...}
    componentDidTop = () => {...}
    componentDidHide = () => {...}

    render() {
        const props = this.props;
        return <Slide
            {...props}
            onEntered={this.componentDidEnter}
            onExited={this.componentDidExit}
            >
                { props.children }
        </Slide>;
    }
}

Page.childContextTypes = {
    refPage: PropTypes.func
}

함수형 컴포넌트 생성하기(withStack)

각 페이지의 컴포넌트와 페이지 스택을 위한 공용 컴포넌트를 연결하기 위한 함수형 컴포넌트가 필요합니다. 다음의 코드를 통해 함수형 컴포넌트인 withStack을 어떻게 구현했는지 확인해 보세요.

class Wrapper extends React.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return React.createElement(this.props.component, Object.assign({},
            this.props,
            {
                ref: this.context.refPage
            }
        ));
    }
}

Wrapper.contextTypes = {
    refPage: PropTypes.func
};

// With withStack, the refPage in the context is relayed.
export default function withStack(Component) {
    return (props) => {
        return <Wrapper component={Component} {...props}/>;
    };
}

자, 지금까지는 구현에 대한 설명을 드렸습니다. 다음으로는 어떻게 사용하는지를 함께 보겠습니다.

예제 코드

앞서 소개한 페이지 스택을 어떻게 구현했는지 다음의 코드를 통해 확인해 보세요.

// A.js
// This is a sample page
export default withStack(class A extends React.Component {
    constructor(props) {
        super(props);
    }

    // When the page is displayed and the transition ends
    componentDidEnter() {...}
    // When page is hidden, and the transition ends
    componentDidExit() {...}
    // When the page becomes the top page again
    componentDidTop() {...}
    // The page is no longer the top page
    componentDidHide() {...}

    render() {
        return <div> page A </div>;
    }
});

// appRoute.js
export default function AppRoute(props) {
    return <Switch location={props.location} >
            <Route path="/a" component={A}/>
            <Route path="/b" component={B} />
            <Route path="/" component={Top} />
    </Switch>
}

// app.js
class App extends React.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return <Router>
            <PageStack
                swipable={true}
                appRoute={AppRoute}
            />
        </Router>
    }
}

ReactDom.render(<App/>, document.querySelector('#app'));

구현 결과

페이지 스택이 적용된 결과물은 다음과 같습니다. 페이지가 아주 매끄럽게 전환되지 않나요? 페이지를 밀어 넘기는 것도 매끄럽습니다.

페이지를 느리게 넘겨도 매끄럽게 전환됨을 확인하실 수 있습니다.

글을 맺으며

저희는 페이지 전환 개선 외에도 우리의 서비스를 어떻게 개선할 수 있을지, 항상 고민을 많이 하고 있습니다. 이번에 구현한 페이지 스택이 저희에게는 든든한 기반이 되어준 것 같습니다. 우리의 성과에 대해 여러분의 생각은 어떠한지 궁금하네요. 저희 작업이 마음에 드신다면, 지금 바로 LINE MANGA* 서비스를 이용해 보세요!

아, 깜박했네요. LINE 개발자들은 우리의 유저에게 최상의 서비스를 제공하기 위해 최선을 다하고 있습니다. 이를 위해 프론트엔드 개발자도 열심히 찾고 있습니다. 많은 분들의 관심 바랍니다!