버스하냥 서비스 UX 개선하기
Last updated: Dec 5, 2022
버스하냥 서비스 개발을 총괄하면서 UX를 개선하기 위한 여러가지 노력들을 적어보고자 한다.

웹 서비스에서 속도는 생명이라고 생각한다. 구글의 연구결과에 따르면 모바일 웹 페이지 로딩 속도가 증가함에 따라 사용자의 이탈률이 엄청나게 증가하는 걸 볼 수 있다. 버스하냥 서비스는 한양대학교 ERICA 캠퍼스의 셔틀버스 시간표를 볼 수 있는 간편한 웹 서비스로, 사용자에게 빠르고 간편하게 제공되어야 한다.
버스하냥의 접속 속도를 향상시키기 위해 다음과 같은 최적화를 진행하였다.
버스하냥은 웹 서비스이지만 앱의 장점도 살리고자 PWA 설치를 지원한다. PWA에서 가장 핵심 부품인 Service Worker의 대표적인 기능 중에 하나가 바로 캐싱이다. 서버에서 제공되는 파일을 로컬에 캐싱 해두어 로딩 속도를 크게 개선해 주는 역할을 한다. 또한 PWA를 설치해야만 캐싱이 되는 것도 아니므로 서비스를 여러 번 이용하는 사용자들 입장에서는 데이터 절약 및 속도 향상을 기대할 수 있다.
캐싱을 했을 때의 단점도 물론 존재한다. 캐싱 된 파일들로 인해 서비스 업데이트 버전 배포 후 사용자들에게 즉각적으로 업데이트가 반영이 안될 수 있다. 이런 경우에는 Service Worker 쪽에서 캐시가 자동으로 업데이트될 때까지 기다려야 한다.
버스하냥 서비스는 Vite
를 이용하여 제작되었다. Vite
에는 vite-plugin-pwa
라는 플러그인이 존재하는데, Service Worker 및 manifest 파일을 자동으로 생성해 준다.
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
// Service worker setting
injectRegister: 'auto', // Service worker mode. This decides the injection method.
registerType: 'autoUpdate', // New version update mode.
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], // File regex to cache
},
}),
],
})
vite.config.ts
에서 다음과 같이 설정해 주면 완성이다. Default 값과 거의 동일하다. 하지만 폰트 파일 및 아이콘 리소스들도 캐싱을 하고 싶어서 확장자 패턴을 추가해 주었다.

이제 서비스에 접속을 해보면 Size 탭에서 용량이 측정되지 않고 (ServiceWorker)
라고 뜨면서 정상적으로 로컬에 캐싱 됨을 확인할 수 있다. 또한 이미지 하단에 있는 전송량을 보면 실제로 전송된 용량이 리소스 용량에 비해 압도적으로 적은 걸 볼 수 있다.
버스하냥 서비스는 Pretendard
라는 오픈소스 라이선스를 가지고 있는 폰트를 사용한다. 해당 폰트를 사용하게 되면, 웹에서 woff2라는 압축된 폰트 형식으로 제공하게 되는데, 해당 글꼴 용량이 2.2MB이다. 물론 인터넷이 빠른 세상이라 2.2MB는 금방 로딩하지만, 인터넷이 혹시나 느린 경우에는 폰트가 느리게 로딩되어 폰트 적용 전 글씨가 화면에 렌더링 된 후 폰트가 로딩이 끝나면 그제야 폰트가 변경되는 이상한 현상을 겪을 수 있다. (참고) 물론 Service Worker를 사용하기 때문에 캐싱 하여 다음번에 접속하면 큰 문제는 없으나, 기본적으로 우리 서비스를 접속하기 위한 페이지 리소스 용량 자체를 줄이고 싶어졌다.
Pretendard 자체에서 Dynamic subset을 제공하고 있었기에, 적용은 어렵지 않았다.
@import url("https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.5/static/pretendard-dynamic-subset.css");
일반 폰트 대신 subset 폰트를 불러오면 끝이다. Dynamic Subset Font가 적용이 되면 페이지를 로드할 때 다음과 같이 폰트 파일들이 쪼개져서 로딩 됨을 확인할 수 있다. 결과적으로 초기에 불러오는 폰트의 용량을 거의 2MB 가까이 줄였다.

버스하냥 서비스에서 적용한 기술 중 가장 최근에 나온 기술이다. 해당 라이브러리는 웹에 있는 서드파티 스크립트를 메인 쓰레드에서 로딩시키지 않고 Service Worker 내부에서 돌아가게끔 만들어준다. 무거운 서드파티 스크립트가 있다면 메인 페이지를 로딩하는 쓰레드에서 벗어나 따로 로딩을 하기 때문에 보다 쾌적하게 웹사이트를 로딩 할 수 있다. (참고)
대표적인 서드파티 스크립트로는 구글 애널리틱스와 페이스북 픽셀이 있다. 버스하냥 서비스는 구글 애널리틱스를 사용하기 때문에, 해당 스크립트를 따로 웹 페이지 헤더에서 로딩시키지 않고 Partytime을 이용하여 실행하기로 했다.
공식 홈페이지 가이드라인을 읽어보면, 각 프론트엔드 라이브러리마다 어떻게 적용해야 되는지 나와있다. 버스하냥 서비스는 React를 이용하기 때문에 다음과 같이 적용하였다.
패키지 추가
yarn add @builder.io/partytown
React 쪽 메인 컴포넌트에 Partytown 컴포넌트 추가 (예시)
import { Partytown } from '@builder.io/partytown/react'; export function Head() { return ( <> <Partytown debug={false} forward={['dataLayer.push']} /> // dataLayer.push -> GTAG only </> ); }
스크립트를 Partytown에 집어넣기
<script src="https://www.googletagmanager.com/gtag/js?id=YOUR_GTAG_HERE" type="text/partytown"></script> <script type="text/partytown"> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'YOUR_GTAG_HERE'); </script>
Script type이 표준 규격이 아니라 IDE에서도 linting이 되지 않는다. 그래도 걱정하지 말자.
빌드 시 특정 디렉터리에 Partytown의 빌드 된 결과물을 옮기게끔 설정
import { partytownVite } from '@builder.io/partytown/utils' import path from 'path' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ partytownVite({ dest: path.join(__dirname, 'dist', '~partytown'), }), ], publicDir: './public', })
이 단계까지 했으면 적용은 끝이다.
물론 해당 라이브러리가 장점만 있는 건 아니다. 개발자 도구에서 네트워크 탭을 보면 무수히 많은 proxytown
request들이 보일 것이다. 이것은 Partytown과 Service Worker 간에 통신하는 request이므로, 로컬에서 통신하는 거라 실제 성능에는 영향을 끼치지 않는다. (참고)
버스하냥에는 페이지가 메인 페이지, 전체 시간표 페이지 총 2개밖에 없다. 하지만 메인 페이지를 로딩할 때 전체 페이지도 같이 로딩할 필요가 있을까? 아쉽게도 그냥 Route 안에 전체 시간표 컴포넌트를 넣으면 메인 페이지가 로딩될 때 Route 안에 있는 전체 시간표 컴포넌트도 같이 로딩된다. 이렇게 불필요한 코드를 한꺼번에 로딩하는걸 줄이기 위해 React.lazy와 Suspense를 이용하여 코드를 분할할 수 있다.
기존 코드를 살펴보자면 다음과 같다.
<Route path="/all" element={<FullTime />} />
그냥 Route를 사용하면 해당 코드가 있는 typescript와 해당 Route 안에 있는 컴포넌트도 같이 들어가서 로딩한다.
import React, { lazy, Suspense } from 'react'
import { Route } from 'react-router-dom'
const FullTime = lazy(() => import('./app/components/FullTime'))
<Route
path="/all"
element={
<Suspense fallback={<div />}>
<FullTime />
</Suspense>
}
/>
기존 코드를 다음과 같이 바꾸면 코드 분할이 완료된다. 전체 시간표 페이지를 눌렀을 때 typescript가 따로 로딩 되는 것을 개발자 도구의 네트워크 탭에서 확인할 수 있다.

https://blog.cloudflare.com/even-faster-connection-establishment-with-quic-0-rtt-resumption/
2008년도에 나온 규격인 TLS 1.2에서는 안전한 연결을 위해 TCP와 TLS가 Handshaking 하는 과정에서 필요한 서로 간의 통신 횟수가 많았다. 하지만 2018년 8월에 새로 나온 TLS 1.3에서는 기존의 TCP 대신 QUIC라는 새로운 프로토콜을 이용하여 이 Handshaking 과정을 대폭 축소시켰다.

https://blog.cloudflare.com/the-road-to-quic/
하지만 여기서 끝이 아니다. 사용자가 재접속을 했을 경우에 저장해둔 PSK를 애플리케이션 데이터와 같이 보내버리면 별도의 추가 Handshake 필요 없이 서버에서 암호화된 데이터를 다시 받을 수 있다. Round trip 횟수가 HTTP와 다를 게 없으므로 이를 통해 암호화 연결 속도를 향상시키고, 리소스 절약이 가능하다.

https://blog.cloudflare.com/even-faster-connection-establishment-with-quic-0-rtt-resumption/
0-RTT를 사용하면 replay attack
이라는 네트워크 공격에 취약해지지만, 버스하냥이 이용하는 API에서 서버의 값을 직접적으로 바꾸는 API는 없고 단순히 읽는 API만 있기 때문에 안심하고 0-RTT를 활성화하였다.

TLS 1.3을 적용한 버스하냥

실제 사용자의 웹페이지 접속 경험 결과 통계
PageSpeed Insights에서 실제 사용자들의 경험을 확인한 결과이다. 코어 웹 바이탈 평가에서 꽤 준수한 평가를 받았음을 볼 수 있다.

외부에 공개된 서비스인만큼 검색엔진에서 검색했을 때도 잘 나와야 한다. 핵심 키워드만을 등록해서 검색엔진에 노출시키는 방법은 구글에서 지원하지 않으므로, 검색엔진들에 잘 노출되게끔 SEO 최적화를 진행했다. 최적화는 Chrome 개발자 도구에 내장된 Lighthouse를 참고하였다.
Lighthouse는 개발자 도구를 열면 바로 이용 가능하다. Lighthouse에서 안내하는 최적화 내용들은 다음과 같다.

처음에는 해당 안 되는 항목들이 몇 개 있었으나 밑에 적을 예정인 항목들을 해결하다 보니 전부 해결되었다.
버스하냥 서비스는 셔틀버스 시간표를 보여주는 서비스라, 사실상 크롤링 할 수 있는 내용이 별로 없다. 하지만 검색엔진에 서비스에 사용된 아이콘이나 이미지들이 이미지 탭에 나열되어 있는 건 원하지 않는다. 따라서 이미지들은 수집을 하지 말라고 크롤러들에게 알려줄 robots.txt
를 작성했다.
User-agent: *
Allow: /
Disallow: /*.png$
Disallow: /*.svg$
Disallow: /*.jpg$
Disallow: /*.jpeg$
robots.txt는 따로 빌드 할 필요 없이 빌드 디렉터리 root에 존재하면 된다. 따라서 빌드 결과물에 해당 파일을 포함시켜준다.
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
// Deployment
includeAssets: [
'favicon.svg',
'favicon.ico',
'robots.txt',
'safari-pinned-tab.svg',
],
}),
],
publicDir: './public',
})
검색엔진이 사이트에 관한 정보를 가져가는 부분이 바로 메타 태그다. 검색 엔진의 크롤러들은 웹페이지의 <head>
에 있는 메타 태그를 읽음으로써 사이트 크롤링을 한다. 상당히 많은 메타 태그들이 있지만, 메타 태그를 전부 넣지는 않고 필요하다고 생각된 태그들만 추가하였다.
검색엔진 필수 태그 제외하고 가장 대표적이고 많이 쓰이는 태그가 Opengraph 태그이다. Opengraph는 메타 태그 종류 중에 하나이며 SNS 공유, 특히 페이스북에 최적화되어있는 태그이다. Opengraph 태그에 대한 자세한 정보는 여기서 확인 가능하다.
리뉴얼되기 이전 버스하냥 서비스의 애널리틱스에서 referral link를 분석해 본 결과, SNS에서 공유되는 현상은 매우 드물었다. 특히 요즘 대학생들은 페이스북을 잘 안 한다…
그나마 홍보를 시작한 Everytime의 유입이 많은데, 여기는 링크 미리 보기가 없다. 따라서 차라리 전 국민이 사용하는 카카오의 Opengraph 규격을 일부 사용하기로 했다.

카카오톡의 가이드라인을 살펴보면, 유일하게 독특한 부분이 바로 Opengraph image이다. 여기 카카오 devtalk를 확인해 보면, Opengraph 스크랩 이미지의 권장 크기는 800px * 800px, 정사각형이다. 해당 규격으로 Opengraph image를 넣으면, 카카오톡 또는 카카오스토리에서 링크를 공유했을 때 자동으로 crop되어 깔끔하게 보인다고 한다. 따라서 해당 가이드라인을 따라 image 태그를 삽입하였고, 테스트해 본 결과 깔끔하게 잘 나왔다.

적용하던 중 OG 캐시를 초기화해도 적용이 안되는 버그가 있어 엄청난 삽질을 했는데, 카카오는 공유된 링크 사이트의 Opengraph image 태그를 직접적으로 읽지 않고 Opengraph url 태그에 있는 값(링크)의 Opengraph image를 읽어온다.
마지막으로 PWA이다. PWA 또한 Lighthouse에서 분석이 가능하다. PWA가 무엇인지와 PWA 지원 조건에 대해서는 이전 게시글에서 설명하였으니, 해당 게시글에서는 생략한다.

여기서 아까 위에서 추가하지 못한 메타 태그를 마저 추가한다.

순서대로 모바일 테스트, 데스크톱 테스트
비록 인터넷 및 컴퓨터 환경에 따라 점수 편차가 나지만, 결과적으로 Lighthouse에서 아주 높은 점수를 기록했다.
하지만 이번에는 개발자 입장에서 본 사용자 편의성과 성능만을 고려하여 수정되었다. 아직 리뉴얼 버전이 배포된 지 얼마 안 되었기 때문에, 추후에 데이터를 수집하고 사용자들의 의견을 수렴하여 UX를 더 개선하고 싶다.