개선된 페이지 스택으로 LINE 증권의 웹 페이지 전환 처리하기

들어가며

안녕하세요! sunderls입니다. 저는 2년 전에 페이지 스택을 주제로 ‘LINE MANGA: Page Stack을 이용해서 페이지 전환 처리하기‘라는 글을 썼고, 현재 LINE 증권에서도 같은 방식으로 프로젝트를 진행하고 있습니다. 그래서 이번 글에서는 페이지 스택이 어떻게 진화했는지, 어떤 과제를 안고 있는지 함께 알아보겠습니다. 

 

개선된 점

먼저 개선된 사항에 대해 공유드리겠습니다. 

 

헤더 parallax 전환

페이지 스택은 네이티브(native) 앱과 같이 페이지 전환을 해도 DOM(Document Object Model)을 삭제하지 않고 순서대로 쌓아 놓는 구조입니다. DOM을 삭제하지 않기 때문에 이전 페이지로 돌아갈 때 순식간에 넘어가므로 깜빡거리는 문제가 해소됩니다. 아래는 예시입니다.

 

 

위 예시를 잘 살펴보면 헤더 부분이 네이티브 앱과 조금 다른데요. 네이티브 앱은 헤더 부분에서 다른 페이지 전환을 사용하는 경우가 많기 때문입니다. 페이지 스택에서도 헤더와 바디를 별개의 컴포넌트로 구현하면 같은 효과를 얻을 수 있습니다. LINE 증권에서는 이런 방법으로 아래와 같이 깔끔하게 페이지 전환을 구현했습니다.

 

 

스와이프 제스처 개선

페이지 스택은 처음부터 스와이프(swipe) 제스처를 지원했습니다. 다만 처음에 깊게 생각하지 않고 구현하는 바람에 손가락의 이동 거리만 보고 내비게이션을 트리거했는데요. 네이티브 앱은 이동 속도까지 보는 경우가 많기 때문에 아래와 같이 개선했습니다. 구현은 간단합니다.

 private onTouchEnd = () => {
   const distance = touch.clientX - touchStartClientX
   const velocity = distance / elapsedTime
   if (distance > distanceThreshhold || velocity > velocityThreshhold) {
      // OK, trigger history.back()
   }
}

 

componentCanSwipe

스와이프 제스처는 잘 개선되었지만 ‘모든 페이지에 스와이프 제스처를 적용하는 것은 좋지 않다’는 문제에 직면했습니다. 예를 들어 여러 페이지를 열어 놓고 있는 사용자의 입력 기능을 떠올려 보겠습니다. 이전 페이지로 돌아갈 때 사용자의 입력이 남아 있는 경우에 확인 다이얼로그를 표시하는 경우가 종종 있지요. 이런 상황에서 스와이프 제스처를 제공하면 사용자 조작이 까다로워집니다. 그래서 각 페이지에서 스와이프 제스처의 적용 여부를 지정할 수 있도록 페이지 스택에 componentCanSwipe를 추가했습니다. 구조는 간단합니다. 스와이프 제스처는 페이지 스택에서 제어하기 때문에, 작동하기 전에 페이지 스택의 가장 상위에 있는 페이지 컴포넌트의 componentCanSwipe를 살펴보고 페이지 전환을 중단할 수 있습니다. 

 // PageStack
canSwipe = () => {
   // 마지막 페이지일 경우 스와이프 제스처는 작동하지 않음
   if (componentStackLength < 2) {
       return false
   }
    
   // 페이지 전환 도중에도 스와이프 제스처를 비활성화
   if (transitionLayerCount > 0) {
      return false
   }
  
 // 가장 상위 페이지의 프로퍼티를 본다
   return topComponent.componentCanSwipe
}
 
 
// Page Component에서는 간단히 스와이프 제스처 적용 여부를 지정 가능
class Page extends React.Component {
   componentCanSwipe = true
   render() {}
} 

 

componentWillExit

이미 눈치채신 분도 계실 텐데요. 앞서 말씀드린 대응책으로 스와이프 제스처는 막을 수 있었지만, 화면 이탈 확인 다이얼로그를 내보내지 않았습니다. 이 문제를 해결하는 방법은 애플리케이션이 SPA(Single Page Application)인지 아닌지에 따라 달라집니다. SPA가 아닌 경우에는 onbeforeunload를 사용해서 간단하게 대응할 수 있습니다. 하지만 SPA라면 조금 복잡해지는데요. React Router에서 <Prompt/>를 제공하고는 있지만 네이티브 UI의 window.confirm과 같은 것 밖에 지원하지 않습니다. 저희는 깔끔한 UI를 포기할 수 없었기 때문에 다른 방법을 찾았습니다. 페이지 스택은 유일하게 히스토리 변화를 관찰하는 컴포넌트이기 때문에 히스토리 변화를 감지하고 자체적으로 확인 UI를 내보낼 수 있습니다. 이를 위해 추가한 것이 componentWillExit입니다. 내용은 대략 다음과 같습니다.

 // PageStack
async componentDidUpdate(prevProps: Props) {
  if (this.props === prevProps) return
 
 
  // POP의 경우
  if (this.props.history.action === 'POP') {
    const lastPoppedLocation = {
      ...prevProps.location
    }
  
    // 가장 상위 페이지의 exit 허용 여부
    if (await topComponent.componetnWillExit()) {
      // 허용하는 경우에는 아무것도 하지 않고 그대로 둬도 됨
    } else {
      // 혹시 허용하지 않는 경우에는 전에 pop된 히스토리를 재구축할 필요가 있음
      // history.push는 호출됨
      // history.push 일련의 동작을 중단하기 위해 이 플래그를 설치함
      this.isPreventingGoBack = true
      prevProps.history.push(lastPoppedLocation)
      return
    }
 
    this.popStack()
  } else {
    // 혹시 이 플래그가 설치된 경우에는
    // 페이지 스택이 넣은 히스토리이므로 무시함
    if (this.isPreventingGoBack) {
      this.isPreventingGoBack = false
      return
    }
    this.pushStack(...)
  }
} 

componentWillExit는 비동기로 동작하므로 어떤 UI에서도 내보낼 수 있습니다. 

 

의문점

위와 같이 페이지 스택을 사용하는 것에 대해 몇 가지 의문이 발생할 수 있습니다. 그에 대해 정리해 보았습니다.

 

페이지 스택이 방대해지는 것은 아닌지?

네. DOM을 삭제하지 않기 때문에 방대해질 수 있습니다. 이 부분은 웹앱의 서비스 로직에 따라 문제로 불거질 수도 있는데요. 네이티브 앱도 같은 문제를 갖고 있지만 OS 레벨에서 잘 대응하고 있습니다. 페이지 스택에서는 슬라이딩 윈도를 구현하면 어느 정도 비슷한 구조를 만들 수 있고, 처음부터 이에 대한 구현이 포함되어 있었습니다. 

페이지 스택은 maxDepth와 componentShouldSleep 혹은 componentShouldAwake 사이의 후크(hook) 처리를 지원합니다. 예를 들어 maxDepth를 초과했을 때 가장 하위에 있는 페이지 컴포넌트의 componentShouldSleep가 호출되는데요. 이곳에 DOM을 삭제하는 처리를 넣을 수 있습니다. 

 // PageStack
async componentDidUpdate(prevProps: Props) {
   if (this.props.history.action === 'POP') {
      if (this.componentStack.length > this.maxDepth) {
          this.componentStack[this.componentStack.length - this.maxDepth].componentShouldAwake()
      }
   } else {
       if (this.componentStack.length > this.maxDepth) {
          this.componentStack[this.componentStack.length - this.maxDepth].componentShouldSleep()
       }
   }
}

 

브라우저의 네이티브 스와이프 제스처와 충돌하지는 않는지?

좋은 질문입니다. 지금까지 페이지 스택은 주로 브라우저가 아닌 웹뷰(webview)를 타깃으로 했는데요. 브라우저 환경에선 네이티브 스와이프 제스처와 내비게이션 버튼이라는 두 가지 벽을 만나게 됩니다. 각각에 대해 아래와 같은 대응 방법을 생각해 봤습니다.

  • 내비게이션 버튼: 페이지 스택은 히스토리 변화를 관찰하고 있기 때문에 뒤로 가기 버튼은 문제없습니다. 다만 앞으로 가기 버튼은 처리하기가 좀 까다로워서, 개인적으로는 더미 히스토리를 넣어 기능 사용을 막는 게 좋을 것 같습니다.
  • 네이티브 스와이프 제스처: 앞으로 가기 버튼보다 네이티브 스와이프 제스처를 이용하는 사용자가 많을 것입니다. 아무 조치도 하지 않으면 페이지 스택의 페이지 전환과 중복되는 문제가 발생합니다.

이 문제를 해결하기 위해 여러 가지 시도를 해봤는데요. 결론적으로 브라우저 환경에서는 페이지 스택의 스와이프 제스처와 페이지 전환을 비활성화하는 것이 가장 적절한 대응이라고 생각합니다. 페이지 스택의 CSS 코드를 수정하면 간단하게 비활성화할 수 있습니다.

페이지 전환이 없는 것이 좀 아쉽지만, 네이티브 스와이프 제스처 덕분에 오히려 보기 좋아졌습니다. 

 

브라우저 환경에서는 페이지 스택을 사용하는 의미가 있는지?

좋은 질문입니다. 앞서 말씀드린 방법으로 대응하면 페이지 스택을 사용했을 때와 사용하지 않았을 때 사이에 큰 변화가 없는 것처럼 보입니다. 하지만 그럼에도 페이지 스택을 사용했을 때 누릴 수 있는 장점이 분명히 존재합니다.

  • ‘뒤로 가기’가 순식간에 실행됩니다. 페이지 스택은 기본적으로 DOM을 삭제하지 않기 때문에 ‘뒤로 가기’ 동작이 다른 모든 방법 중에서 가장 빠릅니다.
  • 깜빡임이 없습니다. 가로 스크롤 위치와 같은 UI의 상태가 자동으로 유지됩니다. 
  • 네이티브 앱과 개념이 유사합니다. 저희는 웹앱을 개발하고 있기 때문에 최대한 네이티브에 근접하게 만들고자 하는데요. 개념이 유사하다는 점은 이와 같은 목적을 쉽게 달성하기 위한 장점이 됩니다.

 

함수형 컴포넌트에서는 작동할 수 있는지?

현재 페이지 스택은 withStack을 통해 페이지 컴포넌트에서 라이프 사이클 메서드를 활성화하고 있지만, 함수형 컴포넌트(Functional Component)에서는 ref를 사용할 수 없으므로 페이지 스택이 작동하지 않습니다. 2년 전에는 크게 문제되지 않았던 부분입니다만, 지금은 후크의 시대라서 대부분이 함수형 컴포넌트이기 때문에 문제가 되었습니다.

함수형 컴포넌트에서 페이지 스택을 어떻게 작동시킬 것인지에 대해선 아직 고민하고 있습니다. 제대로 된 방법을 찾을 때까지, 차선책으로 <PageStackGlue/>를 사용해서 함수형 컴포넌트에서도 페이지 스택을 작동시킬 수 있습니다. 

 // PageStackGlue는 proxy와 같이 lifecycle hook을 제공함
const PageStackGlue = withStack(
  class extends React.PureComponent {
    componentDidTop() {
      if (this.props.componentDidTop) {
        this.props.componentDidTop()
      }
    }
    componentDidEnter() { ... }
 
    componentDidHide() {... }
 
    componentCanSwipe() { ... }
 
    render() {
      return null
    }
  }
)
 
 
// 이렇게 함으로써 함수형 컴포넌트에서도 사용할 수 있음
const A = () => {
   return <div>
    <PageStackGlue componentCanSwipe />
   </div>
} 

 

마치며

저희는 페이지 스택을 적극적으로 이용하고 있습니다. 편리하긴 하지만 분명 번거로운 면도 존재하는데요. 이런 한계를 돌파하는 것이 프런트 엔드 개발의 묘미라고 할 수 있습니다. 앞으로도 LINE 증권에 대한 다양한 이야기를 전해드리겠습니다. 많은 기대 바랍니다.