데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      기술 한 스푼으로 웹앱 사용자 경험 개선하기

      chamming2 24.11.28
      1,263 14 1
      DEVOTEE 요약
      SK플래닛의 UPTN Station 블록체인 플랫폼에서 사용자 경험 개선을 위해 스켈레톤 UI를 도입하고 Tanstack-Query를 활용하여 데이터 페칭 및 상태 관리를 간소화하였습니다. 스켈레톤 UI는 페이지 로딩 시점의 정보를 미리 예측할 수 있게 하여 사용자 경험을 개선했으며, 다양한 조건에서 최적의 경험을 제공하기 위해 페이드 효과 등 다른 UI 기술도 활용하였습니다. 또한, 새로운 기술 도입을 통해 UX와 서버 상태 관리의 효율성을 높였음을 설명하며, 보다 나은 웹 프론트엔드 개발을 목표로 하고 있습니다.

      안녕하세요, SK플래닛에서 운영하는 블록체인 플랫폼 UPTN Station의 프론트엔드 개발을 담당하는 김찬민이라고 합니다.

      저는 입사하기 전부터 "나부터 사용하고 싶고, 네이티브 이상으로 부드러운 웹앱" 을 만들고 싶다는 꿈을 갖고 있었는데요,

      이를 이루기 위해서는 작은 디테일들을 잡아내는 것이 중요하다고 생각했습니다.

      약 1년간의 시간을 돌아보며, 어떤 시도를 통해 사용자 경험을 개선했는지에 대한 이야기를 사진과 함께 가볍게 풀어보려 합니다.


      스피너, 어떻게 개선할 수 있을까?

      1년 전 UPTN Station을 처음 맡게 되었을 때의 기억이 아직도 생생한데요,

      이 서비스가 과연 어떤 앱인지 파악하기 위해 직접 써보던 중 화면을 가득 채우는 로딩 스피너가 제일 먼저 눈에 들어왔습니다.

      • AS-IS (1년 전)

      2.gif

      스피너는 페이지가 데이터를 불러오는 중임을 표시하는 효과적인 수단이지만,

      사용자에게 어떤 화면이 나타날지를 미리 알려주지 않고 유저의 탐색 경험을 가로막는다는 점에서 좋지 않은 UI라고 생각했습니다.

      덧붙여 스피너를 표시하는 것은 PHP나 JSP 등 템플릿 엔진을 기반으로 한 고전적인 홈페이지에서 데이터를 불러오는 경험과 유사하다고 느꼈는데,

      우리는 SPA 기반의 웹뷰 어플리케이션을 개발하고 있는 만큼 이보다 나은 UX를 제공할 수 있어야 한다고 생각했습니다.

      • TO-BE (현재)

      3.gif

      동일한 화면에 스켈레톤을 적용해본 모습입니다.

      단순히 로딩 중임을 알리는 스피너와 달리 스켈레톤 UI는 페이지 레이아웃과 구조를 미리 보여주어, 사용자가 어떤 정보를 보게 될지 미리 예측할 수 있도록 해줍니다.

      또한 FCP를 최소화하기 위해 SSR을 도입하는 것처럼 스켈레톤 UI는 비록 실제 로딩 시간은 동일하더라도 사용자가 더 빠르게 콘텐츠를 접하고 있다는 인식을 줄 수 있다는 효과가 있습니다.


      FCP 벤치마크


      onMounted(() => {
        new PerformanceObserver((entryList) => {
          for (const entry of entryList.getEntriesByName("first-contentful-paint")) {
            console.log("FCP candidate:", entry.startTime, entry);
          }
        }).observe({ type: "paint", buffered: true });
      });
      // 로딩 스피너 적용 시
      // FCP candidate: 699.6000000238419
      {
        name: 'first-contentful-paint',
        entryType: 'paint',
        startTime: 699.6000000238419, // 400 ~ 900ms 사이의 값
        duration: 0
      }
      
      // 스켈레톤 적용 시
      // FCP candidate: 204.69999998807907
      {
        name: 'first-contentful-paint',
        entryType: 'paint',
        startTime: 204.69999998807907, // 200ms ~ 260ms 사이의 값
        duration: 0
      }


      스켈레톤을 어떻게 잘 도입할 수 있을까?

      하지만 스켈레톤이 언제나 로딩 경험을 향상시키는 은탄환이 되어 준 것은 아니었습니다.

      • 중간 버전 (스켈레톤 도입 초기)

      4.gif

      높이가 고정되지 않은 UI와 컨텐츠의 길이를 예측할 수 없는 상황이 겹쳐 레이아웃 시프트가 발생할 수밖에 없는 상황도 있었고,

      쿼리 개수가 적어 빠르게 로딩되는 페이지에서는 오히려 스켈레톤이 화면을 갑작스럽게 변화시킨다는 느낌을 주게 되었습니다.

      • 현재 (스켈레톤 대신 페이지 페이드 효과 적용)

      6.gif

      그래서 모든 스피너를 일괄적으로 스켈레톤으로 교체하기보다는 발생하는 네트워크 지연을 측정해,

      비교적 짧은 지연이 예상되는 페이지에는 레이아웃 시프트를 발동할 염려가 없는 스피너, fade 효과 등을 대신 채택했습니다.

      📄 이런 결정에는 카카오페이의 무조건 스켈레톤 화면을 보여주는게 사용자 경험에 도움이 될까요? 라는 글이 많은 도움이 되었습니다.

      한편, 스켈레톤이 필요할 때마다 마크업을 하는 것은 생각보다 많은 시간이 소요될 수도 있습니다.

      // CoinDetailSkeleton.vue
      
      <div class="skeleton__coin-detail">
        <div class="skeleton__name-wrapper">
          <div class="skeleton__name"></div>
          <div class="skeleton__chain"></div>
        </div>
        <div class="skeleton__amount"></div>
        <!-- ... -->
      </div>
      .skeleton__coin-list {
        animation: skeleton infinite;
      }
      
      .skeleton__name-wrapper {
        display: flex;
        margin-bottom: 0.8rem;
      }
      
      .skeleton__name {
        height: 8rem;
        width: 20rem;
      }
      
      .skeleton__chain {
        height: 8rem;
        width: 12rem;
      }
      
      .skeleton__amount {
        width: 34rem;
        height: 12rem;
      }
      
      /* ... 스켈레톤 스타일 코드 */

      초기에는 위와 같은 형태로 스켈레톤을 페이지마다 구현하기도 했었는데요,

      한정된 개발기간 내에 최대한의 생산성을 발휘하기 위해서는 마크업을 보다 쉽고 빠르게 마칠 수 있는 방법이 필요했습니다.

      // CoinDetailSkeleton.vue
      
      <div>
        <Skeleton width="200" height="80" />
        <Spacing size="8" />
        <Row>
          <Skeleton width="340" height="120" />
          <Spacing size="8" direction="row" />
          <Skeleton width="120" height="80" />
        </Row>
      </div>

      저희는 공통으로 사용되는 애니메이션이 포함된 스켈레톤을 컴포넌트로 만들고,

      UI의 레이아웃을 제어하는 <Row>, <Column>, <Spacing> 등의 컴포넌트를 적절히 섞어 사용함으로써

      스켈레톤 구현에 페이지당 약 5 ~ 10분 내외의 최소한의 시간만을 들이고 서비스 개발에 집중할 수 있었습니다.


      <Transition> 컴포넌트 활용하기

      두 번째로는 조건부 렌더링과 관련된 이야기를 잠깐 다뤄보고 싶었습니다.

      <!-- React -->
      <div>
        { service?.name ?
        <p>{service?.name}</p>
        :
        <p>로딩 중...</p>
        }
      </div>
      
      <!-- Vue -->
      <div>
        <p v-if="service?.name">{{ service?.name }}</p>
        <p v-else>로딩 중...</p>
      </div>

      7.gif


      FE 개발자라면 한번쯤은 조건부 렌더링 로직을 작성하는 것이 익숙할 텐데요, 상태에 따라 UI가 전환되는 과정을 조금 더 부드럽게 다듬어볼 수는 없을까요?

      <div>
        <Transition name="fade">
          <TransferListSkeleton v-if="transferQuery.isLoading" />
          <TransferList v-else :data="transferQuery.data" />
        </Transition>
      </div>
      /* [사용자 지정 애니메이션]-[enter|leave]-[active|from|to] 의 형식에 맞게 애니메이션을 정의합니다. */
      /* 전이되는 클래스명에 대한 설명은 아래 공식 문서 링크를 참고해 주세요. */
      /* https://vuejs.org/guide/built-ins/transition#css-based-transitions */
      
      .fade-leave-active {
        transition: opacity 0.2s ease;
      }
      
      .fade-enter-from,
      .fade-leave-to {
        opacity: 0;
      }

      React에서는 React-Transition-Group, Framer-motion 등의 라이브러리를 통해 enter (DOM에 추가) - exit (DOM에서 제거) 상태에 적용할 효과를 정의할 수 있지만,

      Vue에서는 <Transition> 이라는 내장 컴포넌트로 조건부 렌더링을 적용할 요소를 감싸는 것만으로도 간단히 동일한 로직을 구현할 수 있습니다.

      • AS-IS

      8.gif

      • TO-BE

      9.gif

      <Transition> 과 관련된 한 가지 팁을 남겨보자면, 기본적으로는 A 상태에서 B 상태로 전환되어도

      <Transition> 의 영향을 받는 하위 컴포넌트들은 애니메이션이 종료되기 전까지 DOM에 유지됩니다.

      <div>
        <Transition name="fade" mode="out-in">
          <!-- mode="out-in" 이 설정되면 스켈레톤이 DOM에서 제거된 후 <TransferList> 가 렌더링되는 것이 보장된다. -->
          <TransferListSkeleton v-if="transferQuery.isLoading" />
          <TransferList v-else :data="transferQuery.data" />
        </Transition>
      </div>

      즉 기본적으로는 트랜지션 애니메이션이 종료되기 전까지 DOM에 로딩 UI와 완료 UI가 동시에 존재할 수 있는데요,

      mode 속성을 조작해 UI가 전환되는 시점을 섬세히 제어할 수 있습니다.

      이처럼 <Transition> 을 활용하면 외부 라이브러리를 사용하지 않고도 쉽고 미려한 enter-exit 전환 애니메이션을 부여할 수 있어,

      Vue를 다루고 계신다면 적극적으로 활용해보시는 것을 권해드리고 싶습니다.


      Tanstack-Query 도입하기

      마지막으로 이제는 너무나 유명해진 Tanstack-Query(TQ로 줄여 부르겠습니다) 를 도입한 후기를 간략히 소개해보려 합니다.

      onMounted(() => {
        handleFetchCoinList();
      });
      
      const handleFetchCoinList = async () => {
        await useCoinDetail().load({ ...params });
        // Do something...
      };

      TQ를 도입하기 전까지는 마운트 시점에 데이터를 페칭하고, 필요한 경우에는 Pinia 스토어를 활용해 페칭한 데이터를 전역으로 관리하고 있었습니다.

      • AS-IS (1년 전)

      11.gif

      이는 가장 기본적이면서 확실한 데이터 페칭 방법이기도 하지만 사용자는 페이지를 이동할 때마다 데이터를 새로 불러옴에 따른 지연과 로딩 UI를 반복적으로 만나야 했고,

      서버 상태를 관리하기 위해 Pinia 스토어를 관리하는 로직을 별도로 작성해야 하는 점은 개발자에게도 쉽지 않아 개선이 필요하다고 생각했습니다.

      • TO-BE (현재)

      12.gif

      // AS-IS
      onMounted(() => {
        if (!coinStore.$state.coinList.length) {
          handleFetchCoinList();
        }
      });
      
      const handleFetchCoinList = async () => {
        const { isLoading, coinList } = await useCoinDetail().load({ ...params });
        isLoading.value = isLoading;
        coinStore.setCoinList(coinList);
      };
      // TO-BE
      import { keepPreviousData } from "@tanstack/vue-query";
      
      const { isLoading, data: coinList } = useQuery({
        queryKey: [WALLET_COIN_LIST_API],
        queryFn: () => useCoinDetail().load({ ...params }),
        // 페이지 이동 과정에서 데이터가 갱신되어도, 데이터가 심리스하게 교체됩니다.
        placeholderData: keepPreviousData,
      });

      약 3만 명의 유저가 꾸준히 사용하는 라이브 서비스에 새로운 기술을 도입한다는 시도가 설레면서도 걱정스러웠던 경험도 있었는데요,

      결과는 기대했던 것보다 훨씬 성공적이었습니다.

      여러 글에서 이미 다뤘겠지만 TQ를 도입한 이후 저희도 isLoading 등 페칭 상태를 관리하는 로직을 작성하는 난이도가 크게 낮아졌고,

      무엇보다 UX를 크게 끌어올리면서도 서버 상태 관리 난이도가 크게 높아지지 않은 점에서 만족스러운 결정이었던 것 같습니다.

      한편으로는 TQ를 더 잘 다루기 위해 어떻게 해야 에러 핸들링을 더 멋지게 할 수 있을지에 대한 고민을 계속하고 있습니다.

      const { error: queryAError } = useQuery();
      const { error: queryBError } = useQuery();
      const { error: queryCError } = useQuery();
      
      watch(
        () => [queryAError, queryBError, queryCError],
        () => {
          // 에러 핸들링 로직이 너무 길어진다... 😭
        }
      );


      마치며

      이번 글을 통해 제가 보낸 한 해를 돌아보며 비교적 간단한 방법으로 UX를 개선하는 방법들을 소개해 보았는데요,

      내년에는 더 멋진 서비스와 새로운 방법으로 다시 만나뵐 수 있길 바라겠습니다.

      많은 분들께서 웹 어플리케이션은 네이티브에 비해 부드럽지 못하다고 생각하실 수도 있겠지만,

      이를 따라잡고 네이티브와의 차이를 느끼지 못할 정도의 경험을 만들어내는 것이 웹 프론트엔드 개발자의 궁극적인 목표라고 생각합니다.

      마지막으로 UPTN Station에 많은 응원을 부탁드리며, 끝까지 읽어주신 모든 분들께 감사의 말씀을 전하고 싶습니다. 🙂

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      chamming2 님의 최신 블로그

      더보기
      동영상 기고하기