네이버 스마트 주문 페이지 성능 개선 경험담 공유

Jiwon Kim
네이버 플레이스 개발 블로그
16 min readAug 22, 2020

안녕하세요. 네이버 Glace 예약&주문 개발팀에서 일하고 있는 새내기 개발자 김지원입니다. :)

지난 6월부터 약 한 달 반간 스마트 주문 페이지 성능을 안정화시키는 경험을 하여 공유드리고자 합니다. 비슷한 작업을 수행하실 때 이 글을 통해 좀 더 빨리 해결 방법을 떠올리시게 되셨으면 좋겠습니다.

목차

목차는 다음과 같습니다.

  1. 서비스 설명
  2. 성능 문제 발생
  3. 문제 원인 파악
  4. 문제 해결
  5. 개선 후 성능 측정 결과 비교
  6. 사용한 성능 측정 도구 및 측정 방법 공유

1. 서비스 설명

들어가기에 앞서 설명에 대한 이해를 돕기 위해 네이버 스마트 주문에서 사용하고 있는 기술 스택과 서비스에 대해 설명드리겠습니다.

기술스택

  • React, TypeScript (Client)
  • Koa, GraphQL (BFF Server)
  • Spring, Java, Kotlin (API Server)
  • MySQL, Elastic Search (DB)

서비스 설명

네이버 스마트 주문은 네이버를 통해 업체를 검색하여 미리 포장 주문을 하거나 매장 내 QR코드를 읽어 줄을 서지 않고 주문할 수 있는 서비스입니다. 현재 스타벅스, KFC, 폴바셋 등 프랜차이즈 매장부터 개인 사업장까지 다양하게 서비스되고 있습니다.

네이버 스마트 주문 페이지

스마트 주문은 위 사진처럼 두가지 UI가 존재합니다.

  • ScrollSpy UI: 모든 메뉴 정보 초기 렌더링
  • Slider UI: 각 슬라이드마다 카테고리 메뉴들 존재

초기 문서를 서빙할 때 SSR(Server Side Rendering)을 활용합니다. ReactDOM.RenderToString을 사용하여 Document 생성 및 서빙하고, 이후 브라우저단에서 hydrate, 일부 지연되어도 되는 데이터 요청 및 처리합니다.

2. 성능 문제 발생

지난 5월 말 스마트 주문 페이지가 느리다는 제보를 받고 얼마나 느린지 측정해봤습니다. 여러 번 측정한 뒤 평균이 되는 데이터를 캡쳐했습니다.

측정에 사용한 도구는 Chrome > 개발자도구 > Performance입니다.

Stage 환경

측정 결과 다음과 같은 문제가 있음을 확인할 수 있었습니다.

문서 응답 시간

Document를 응답받는 시간입니다. 평균적으로 3초 중반을 형성했습니다.

초기 스크립트 처리 완료 시점

초기 스크립트 처리가 완료되어야 유저의 액션에 대한 응답을 할 수 있기 때문에 중요한 지표입니다. (TTI: Time To Interaction)
측정 결과 5.2초에 완료되었고, 문서 응답 시간을 제외하면 문서 응답 후 1.7초 뒤 스레드가 유저 액션을 처리할 수 있었습니다.

스펙 추가

다음 두가지 스펙이 성능 개선 도중에 추가되었습니다.

  • 개인화 (헤더에 최근 주문 이력, 최근 주문 메뉴, 프로모션 데이터 노출)
  • ScrollSpy UI 추가 (다수의 메뉴를 한 화면에 모두 그림)

3. 문제 원인 파악

측정된 성능을 바탕으로 어떤 문제가 있는지 확인해봤습니다.

SSR시점 과다, 중복 호출

스마트 주문은 각 카테고리별로 메뉴 API를 호출합니다. (인기 메뉴, 커피, 디저트, 상품 등..)
스와이프 동작을 통해 다음 카테고리를 확인할 수 있는 UI 임에도 화면에 보이지 않는 카테고리까지 호출하는 것을 확인할 수 있었습니다

인기 메뉴 카테고리만 우선적으로 필요합니다.

만약 해당 페이지에 카테고리가 6개 있다고 하면, 6개 카테고리에 해당하는 메뉴 전부 SSR 시점에 불러오고 있었습니다.

GET /options?scheduleId=1000&categoryId=1000 (214ms)
GET /options?scheduleId=1001&categoryId=1001 (239ms)
GET /options?scheduleId=1002&categoryId=1002 (164ms)
GET /options?scheduleId=1003&categoryId=1003 (165ms)
GET /options?scheduleId=1004&categoryId=1004 (131ms)
GET /options?scheduleId=1005&categoryId=1005 (227ms)

또한 이 모든 메뉴 API들이 직렬로 호출되고 있었습니다. 즉 이전 메뉴 요청에 대한 응답이 없으면 다음 메뉴를 요청하지 않습니다.

이 외에 사용하지 않는 데이터를 호출하거나, 같은 데이터를 3번 중복 호출하는 문제를 찾을 수 있었습니다.

# /user 3번 호출, /business 3번 호출, /codes 사용하지 않지만 호출GET /codes (51ms)
GET /user (159ms)
GET /user (175ms)
GET /waiting-status (50ms)
GET /businesses/300000?lang=ko (57ms)
GET /businesses/300000?lang=ko&projections=foo (69ms)
GET /user (151ms)
GET /businesses/300000?lang=ko&projections=foo,bar (70ms)
GET /businesses/300000/agencies (33ms)

직렬 호출

많은 API 호출이 SSR 시점에 직렬로 호출되고 있었습니다. 즉 각 호출마다 블로킹이 걸려 이전 응답이 와야 요청할 수 있었습니다.

붉은색 호출만 병렬로 호출되는 부분입니다.

느린 API 응답

하기 두 API가 Elastic Search 조회 및 데이터 검증 작업을 수행하면서 느린 응답 속도를 기록했습니다.

  • /api/옵션 카테고리
    DB에서 데이터를 확인한 뒤 인기메뉴로 노출할 수 있는 메뉴가 있는지 검증, 데이터를 응답
  • /api/메뉴
    주문수, 별점 정보를 얻어오기 위해 Elastic Search 추가 조회

스크립트 처리 시간

보이지 않는 영역의 데이터까지 미리 받아오고 렌더링 했기 때문에 스크립트 처리 시간이 길었습니다. 또한 단일 메뉴 컴포넌트당 처리하는 로직들이 무거웠습니다.

4. 문제 해결

사용자가 처음 보는 화면에 필요한 데이터만 호출

SSR 시점 과다, 중복 호출 문제를 해결하기 위해 유저가 처음 보는 화면의 데이터만 SSR 시점에 받아오고 렌더링 하도록 처리했습니다.

# AS-IS
GET /options?scheduleId=1000&categoryId=1000 (214ms)
GET /options?scheduleId=1001&categoryId=1001 (239ms)
GET /options?scheduleId=1002&categoryId=1002 (164ms)
GET /options?scheduleId=1003&categoryId=1003 (165ms)
GET /options?scheduleId=1004&categoryId=1004 (131ms)
GET /options?scheduleId=1005&categoryId=1005 (227ms)
# TO-BE
GET /options?scheduleId=1000&categoryId=1000 (127ms)

서로 의존이 없는 데이터는 병렬 호출

API간 의존이 없는 경우는 병렬로 호출하도록 처리했습니다. 상위 개선되지 않은 호출들 제외하고 호출 단위가 11개에서 5개로 개선되었습니다.

3개, 6개의 API를 병렬로 한꺼번에 요청

중복호출 제거

반복되는 API 호출을 제거했습니다. SSR 시점에 아래 두가지 이유로 몇몇 API를 중복 호출하고 있었습니다.

스마트 주문에서는 GraphQL을 사용하고 있으며 GraphQL 쿼리를 나타냅니다.

같은 쿼리지만 필드가 다르거나 추가되는 경우 요청
같은 쿼리이고 필드도 같지만 데이터 응답을 받기 전

따라서 쿼리를 단일 Document로 작성해둔 뒤 동일 참조의 쿼리를 재사용하도록 하여 중복요청을 보내지 않도록 처리했습니다.

// 동일한 참조의 쿼리를 재사용하여 중복요청 제거export const UserDocument = gql`
query user {
id
name
nickname
email
phone
isAdult
bookingCount
isMembershipUser
// …
}
`

이처럼 쿼리 재사용, 불필요한 쿼리 제거를 통해 다음과 같이 API 호출량을 줄였습니다.

# AS-IS
GET /codes (51ms)
GET /user (159ms)
GET /user (175ms)
GET /waiting-status (50ms)
GET /businesses/300000?lang=ko (57ms)
GET /businesses/300000?lang=ko&projections=foo (69ms)
GET /user (151ms)
GET /businesses/300000?lang=ko&projections=foo,bar (70ms)
GET /businesses/300000/agencies (33ms)
# TO-BE
GET /user (175ms)
GET /waiting-status (50ms)
GET /businesses/300000?lang=ko (57ms)
GET /businesses/300000?lang=ko&projections=foo,bar (70ms)
GET /businesses/300000/agencies (33ms)

오래 걸리는 API는 지연호출 혹은 클라이언트에서 처리

[느린 API 응답] 에서 언급한 느린 API에 대해 다음과 같이 처리했습니다.

1. /api/옵션 카테고리

  • AS-IS
    API 서버에서 인기 메뉴 카테고리가 존재하는지 검증 (DB조회)
  • TO-BE
    어차피 전체 메뉴 정보를 받아오기 때문에 받아온 메뉴 정보들을 클라이언트에서 조합하여 존재 여부 검증

2. /api/메뉴

  • AS-IS
    SSR 시점에 특정 메뉴들의 주문수, 평점 ES 조회
  • TO-BE
    SSR시점에는 주문수, 평점 데이터를 조회하지 않고 메뉴만 조회. 클라이언트 측에서 주문수, 평점 정보 지연 요청
    * pros) 문서 응답 시간 단축, 피로도 감소
    * cons) 주문수, 별점 지연 노출로 UI 변경

4.4. 스크립트 처리 시간 단축

메뉴가 많을수록 연산이 증가하기 때문에 각 메뉴 컴포넌트에서 무거운 로직들을 제거했습니다.

Intl.NumberFormat 의 인스턴스 캐싱

단일 메뉴 컴포넌트에서는 가격을 각 국가의 포맷에 맞춰 표시하기 위해 렌더링마다 Intl.NumberFormat의 인스턴스를 생성했습니다. ScrollSpy UI 같이 메뉴를 많이 표시하는 UI의 경우 생성자 호출이 빈번해지기 때문에 생성한 인스턴스를 캐싱하여 사용하도록 했습니다.

아래는 인스턴스를 매 렌더링마다 생성했을 경우와 캐싱 해두고 사용하는 것을 비교한 테스트 코드입니다.

// 함수에서 생성자를 통해 인스턴스 생성
function useConstructor(price) {
const formatter = new Intl.NumberFormat(‘ko’)
return formatter.format(price)
}
// 상위 스코프에 캐싱해두고 사용
const fomatter = new Intl.NumberFormat(‘ko’)
function useCaching(price) {
return fomatter.format(price)
}
// 테스트 함수
function test(cb, time) {
console.time(cb.name)
for (let i = 0; i < time; i++) {
cb(10000)
}
console.timeEnd(cb.name)
}
// 테스트 실행 (Chrome)
// 메뉴 100개, 초기 렌더링 21 => 총 2100회
test(useConstructor, 2100) // useConstructor: 60.779052734375 ms
test(useCaching, 2100) // useCaching: 0.986083984375ms

useXXX 커스텀 훅을 상위 컴포넌트에서 실행

React의 useXXX 커스텀 훅 내 로직들 모두 렌더링마다 호출되기 때문에 최대한 상위에서 받아올 수 있도록 수정했습니다.

ex) useRouter, useI18n, useLanguage, useOrder

사용자가 보는 부분만 렌더링

[사용자가 처음 보는 화면에 필요한 데이터만 호출] 처리로 사용자가 보는 부분만 데이터를 가지게 되면서 렌더링도 사용자가 보는 부분만 렌더링 됩니다.

상위 컴포넌트 렌더링 최소화

React에서는 상위 컴포넌트가 렌더링 되는 경우 별다른 처리가 없다면 하위 컴포넌트가 같이 렌더링 되기 때문에 불필요한 렌더링을 막기 위해 상위 컴포넌트의 불필요한 리렌더링 로직을 제거했습니다.

추가로 React는 성능 최적화를 위해 React.memo라는 함수를 제공합니다. 실제로 테스트 효과는 있었지만 의존성 관리로 인해 디버깅 및 관리가 까다로울 것으로 생각되었고, 이 함수를 적용할 만큼 성능이 안좋지 않아서 사용하지 않았습니다. 정말 스크립트 처리 성능이 나오지 않을 때 고려해보시면 좋을 것 같습니다.

5. 개선 후 성능 측정 결과 비교

다음과 같은 개선 결과를 얻을 수 있었습니다.

  • 문서 응답 시간 2.2초 개선
  • 문서가 응답된 시점부터 초기 스크립트 처리가 완료되는 시점까지 0.8초 개선
  • 전체적으로 3.2초 개선
해당 스크린샷은 Production 환경입니다.

6. 사용한 성능 측정 도구 및 측정 방법 공유

6.1. 성능 측정 도구

성능 측정에 다음 도구들을 사용했습니다. 많은 분들이 사용해 보셨을 거라 생각합니다. :)

Chrome canary

개발자용 나이틀리 빌드입니다. 저는 Lighthouse 최신버전이 Chrome canary 에서 사용 가능하여 사용했습니다.
여러 최신 기능을 사용할 수 있기 때문에 사용을 고려해봐도 좋을 것 같습니다. :)

#Chrome canary

Chrome Performance

개발자 도구 > Performance 입니다. 스크립트 처리의 병목구간을 확인하는데 주로 사용했습니다.

개인적으로 참고했던 지표는 다음과 같습니다.

  1. 무거운 연산 표시

해당 그래프는 브라우저가 어떤 연산 작업을 했는지를 나타냅니다. 제가 표시해놓은 빨간 표시는 50ms 이상 작업을 하는 경우 표시됩니다.

크롬에서는 유저의 동작에 대해 100ms 내로 페인팅이 되는 것을 권장합니다. #web.dev

2. Bottom-Up, Call Tree

task를 클릭하거나 범위를 설정하면 하단 Bottom Up, Call Tree를 통해 어떤 작업이 시간이 오래 걸렸는지 확인할 수 있습니다.

우측 파일명 링크를 클릭하면 아래와 같은 화면이 노출됩니다.

위 사진처럼 라인별로 소요된 시간을 확인할 수 있고, 개선 포인트를 잡을 수 있습니다.

3. Snapshot, Timeline

React나 Angular 같이 *CSR을 주로 하는 *SPA는 실제 onLoad 이벤트 발생으로만 페이지 로드의 정도를 판단할 수 없습니다. Performance의 Snapshot을 추적하면서 실제 어느 시점에 페이지가 노출되는지 확인할 수 있습니다.

여기서 렌더링은 사용자가 시각적으로 확인할 수 있는 시점을 의미합니다. :)

CSR: Client Side Rendering
SPA: Single Page Application

4. Network, CPU 설정

일부러 하드웨어, 네트워크 환경을 좋지 않게 설정하여 성능을 측정할 수 있습니다.

Lighthouse

PWA, SEO, 접근성 등을 체크할 때도 사용하지만, 악조건의 하드웨어 및 네트워크환경 등에서 어떤 성능을 가지고 있는지 확인할 수 있습니다.

측정을 완료하면 노출되는 각 지표는 각각 다음과 같은 의미를 가집니다.

  1. Total Blocking Time(TBT)
    페이지가 로딩되는 동안 유저 입력에 대한 반응이 얼마나 좋은가를 나타냅니다. 수치는 입력에 대한 반응이 느려질 정도로 스레드가 멈추는 시간을 누적한 값입니다. (스레드의 50ms가 넘는 작업의 시간을 누적합니다.)
  2. Largest Contentful Paint(LCP)
    페이지가 로딩되는 동안 가장 큰 영역이 렌더링 되는 시점입니다. 보통 img나 video입니다.
  3. Cumulative Layout Shift(CLS)
    레이아웃이 화면에서 얼마나 많이 움직이는지를 숫자로 표현한 지표입니다. UX와 관련이 깊습니다.
  4. First Contentful Paint(FCP)
    처음으로 이미지나 텍스트가 그려지는 시점입니다.
  5. Speed Index(SI)
    페이지의 내용이 얼마나 빨리 표시되는지 입니다.
  6. Time to Interactive(TTI)
    초기 스크립트처리가 완료되어 사용자의 인터랙션이 가능한 순간입니다.

6.2. 성능 측정 방법

크게 문서 로드 시점과 초기 스크립트 처리 시간을 기준으로 측정했습니다.

문서 로드

여러 번 측정하다 보니 다음과 같은 사실을 알게 되었습니다.

  • 빠른 시간 내에 새로고침(Refresh) 하는 경우 API 서버의 캐시 때문에 API 응답이 빨라지면서 문서 응답이 빨라졌습니다.
  • 적은 횟수로 측정하는 경우 가끔 튀어서 느린 케이스나 해당 문서를 요청한 다른 이용자 때문에 응답이 빨라지는 경우가 있어 여러 번 측정한 뒤 평균을 구해야 했습니다.

알게된 사실을 기반으로 다음과 같은 방법으로 문서 로드 시간을 측정했습니다.

  • 30초 단위로 40–50회 문서 요청, 응답시간 평균값 계산.
  • 여러번 반복적으로 요청해야하기 때문에 puppeteer로 해당 작업 자동화.

문서 로드 ~ 초기 스크립트 처리 완료 시간

초기 스크립트 처리 시간은 하드웨어의 여러 환경 및 브라우저의 성능에 따라 달라집니다. 때문에 다음 사항을 고려하여 처리했습니다.

  • js, css 파일 등이 캐싱 되기 때문에 캐시 기능을 껐습니다.
  • Extension이 설치되어 있는 경우 스크립트 처리 시간을 늘리기 때문에 비활성 하거나 시크릿 모드를 사용했습니다.

지표는 Performance 도구를 사용해서 (문서 로드 시간 ~ 초기 스크립트 처리 완료 시간)을 보고 브라우저 측 성능을 확인했습니다.

--

--