업데이트

+ 24.06.20 일자로 올리브영 공식 앱에 해당 문제를 개선한 업데이트가 반영됨

 

연관된 글

올리브영 iOS 앱 구조 개선 프로젝트 버전 1.0: https://gowild.tistory.com/71


출처: CJ 뉴스룸 이미지 라이브러리

0. 서론

 

0.0. 서론의 서론

올리브영 1차 면접을 보고 왔다.

잠깐의 대화만으로도 면접관 분께서는 나의 개선 프로젝트의 핵심을 간파했다.

마치 막힌 혈이 뚫리는 듯한 기분이었다. 많은 것을 얻을 수 있었다.

 

올리브영 앱 개선 프로젝트 2.0 버전을 만들어야겠다는 판단이 섰다.

아래는 약 2주 간의 시행착오를 담았다.

 

0.1. 다루는 내용

  • 하이브리드 앱의 웹 ↔ 앱 통신 방식: `WKScriptMessageHandler`, `evaluateJavascript`, `Promise`
  • 올리브영몰의 렌더링 방식: `SPA & MPA`, `CSR & SSR`, `Next.js`
  • WKWebView 환경에서의 UI Test: `XCUIApplication`, `XCUIElement`, `coordinate`

 

1. 개선한 것

1.1. 사용자 관점

1.1.1. 모든 페이지를 UINavigationController로 관리

1.1.1.1. 요약

(왼: 현재 올영 앱 / 중: 1.0버전 개선 앱 / 오: 2.0버전 개선 앱, 모든 페이지를 UNC로)

 

최초 버전과 다르게 SPA로 동작하는 페이지까지 UINavigationController로 관리할 수 있게 되었다.

  • 홈 탭 → 퀵 메뉴 → W케어, 건강템찾기, 라이브
  • 홈 탭의 네비게이션 바 → 검색 버튼
  • 매거진 탭, LUXE EDIT 탭
    • 프로젝트 진행 초기에는 SPA라고 메모해두었다.
    • 그러나 어느 순간부터 MPA로 바뀌었다...?
    • 하위 페이지에서 뒤로 가기 했을 때, 항상 최상단 페이지로 가는 문제도, 애매한 위치로 이동하는 문제로 바뀌었다.
    • SPA라고 메모한 것이 부정확할 수도 있다. 당시에는 SPA, MPA에 대한 지식이 부족했기 때문.
  • 위를 포함하여 독립적으로 존재하는 모든 페이지

 

1.1.1.2. 구현

아래에서 자세히 설명

 

 

1.1.2. 하단 탭 바의 각 탭을 별개의 UINavigationController로 관리

1.1.2.1. 요약

왼: 현재 올리브영몰 앱(탭마다 새로고침, 하나의 페이지로 인식) / 오: 개선된 앱 (탭마다 개별 UINavigationController로 관리)

 

총 5개의 UINavigationController를 사용하여, 탭마다 각자의 상태를 유지할 수 있게 되었다.

 

현재 올리브영 앱은 각 탭이 하나의 페이지로 취급되어 히스토리 스택(WKBackForwardList로 추정)에 쌓인다.

이것은 다음의 문제를 발생시킨다.

 

1. 히스토리 스택에 너무 많은 페이지(+탭)이 쌓인다.

  • 앱을 사용하다보면, 뒤로 가기 제스처로 루트 페이지에 도달하기가 사실상 불가능하다.
  • 그래서 하위 페이지의 네비게이션 바에 홈 버튼을 제공하나 보다. 홈에 도달하기가 어려우니까.
  • 이렇게 되면 메모리 문제가 있을 수도 있다.
  • 무엇보다 그냥 불편하다.

2. 탭 간을 오가며 정보를 탐색하는 행위가 불가능하다.

  • 특정 탭으로 이동할 때마다, 해당 탭이 새로고침이 되어서 이전 정보(입력값, 위치 정보)가 모두 사라진다.

 

1.1.2.2. 구현

하단 탭 바에 5개의 아이템을 설정

 

`SceneDelegate`에서 5개의 아이템을 생성해준다.

각 아이템은 각각의 `UINavigationController`를 갖는다.

따라서 상태를 각각 관리할 수 있다.

 

순서는 올리브영몰의 탭 바와 동일하게 맞추고,

아래 코드로 최초에 홈 탭이 보이도록 설정했다.

`tabBarController.selectedViewController = homeNavController`

 

 

1.2. 개발자 관점

1.2.1. 올리브영몰의 렌더링 방식 파악

1.2.1.1. 배경

올리브영몰은 페이지마다 렌더링 방식이 다르다.

웹뷰와 네이티브를 결합한 하이브리드 앱인 것도 있고,
과거 올리브네트웍스에서 작성한 코드를 내재화 하는 과도기에 있기 때문이기도 하다.

 

그래서 하나의 함수로는 모든 네비게이션 요청을 일관되게 처리할 수 없었다.

1.1.1.1.에서 다뤘던 페이지를 처리하기가 쉽지 않았다.

 

1.2.1.2. 고민했던 지점

MPA처럼 동작하는 대부분의 페이지는 내부적으로 저 코드를 실행하더라는 것. SPA가 호출하는 함수는 우측의 href 정보도 안 알려준다.

 

FE 지식이 부족한 나로서는 이 부분에서 가장 고전했다.

  • 1차 당황: 메인 탭 → 퀵메뉴 → W케어 버튼의 <a 태그>에 href 속성값이 없었을 때
  • 2차 당황: 해당 <a 태그>에 기본 네비게이션 요청을 막고, url을 가져오는 eventListener를 붙였는데, 동작하지 않았을 때
  • 3차 당황: 해당 <a 태그>가 호출하는 `xmlHttpRequest`, `fetch api`에도 목적지 URL에 대한 정보가 없었을 때

 

루트 페이지에 있는 __NEXT_DATA__
잘 보다보면 W케어의 목적지 URL 정보가 있다.

 

4일 정도 고민하다가 … 로켓펀치의 취준컴퍼니에서 진행하는 멘토링을 신청했다.

  1. 3년차 FE 개발자 분에게 고민 중인 내용을 문서로 전달드렸다.
  2. 루트 페이지의 body 태그 안에 `id = “__NEXT_DATA__”` 스크립트가 있는데, 그것으로 W케어의 네비게이션 요청과 목적지 URL 정보를 조작한다는 것을 알게 되었다.
  3. __NEXT_DATA__는 Next.js에서 사용되는 글로벌 변수로, 서버 측에서 렌더링된 페이지의 초기 데이터와 상태를 클라이언트 측으로 전달하는 역할을 한다.
  4. 결론적으로 W케어는 Next.js로 렌더링이 되고 있다는 단서를 얻었다.

그 분에게는 별 거 아닐 수도 있지만, 나에게는 중요한 실마리가 되었다.

다시 한번 감사한 마음을 품어본다.

 

이후에는 주요 페이지, 특징이 있는 페이지 16개를 개발자 도구로 뜯어보며 렌더링 방식을 분석했다.

 

 

1.2.1.3. 배운 것

  • SPA vs MPA
  • CSR vs SSR vs SSG

키워드에 대한 자세한 개념은 다른 좋은 글이 많이 있다.

여기에서는 현재 페이지가 위 키워드 중에 어떤 것을 채택했는지, 개발자 도구를 판단하는 법을 배웠다.

 

+ 올리브영몰(https://m.oliveyoung.co.kr/m/mtn)을 기준으로 진행한다.

++ 여기에서 소개한 방법이 절대적이지 않을 수 있다. 어디까지나 내부 코드에 접근이 불가능한 상황에서 판단했기 때문이다.

 

SPA vs MPA

왼: SPA / 오: MPA

 

SPA와 MPA는 개발자도구의 Network 패널을 보면 구분하기 쉽다.

 

SPA는 페이지 로드가 발생하지 않는다.

API로 JSON을 응답 받아서 (이것을 AJAX라고 하는 듯) 화면을 구성한다.

 

MPA는 페이지 로드가 발생한다.

Network 패널이 새로고침 되면서 HTML 문서를 새롭게 생성하여 페이지를 구성한다.

 

정리하면 올리브영몰의 기본적인 기능은 대부분 MPA로 제작되었다.

신규 기능(W케어, 라이브 등)은 SPA를 적용하고 있는 듯하다.

 

 

CSR vs SSR vs SSG

 

역사

왼: 채용 설명회 올리브영몰 프론트엔드 기술 소개 / 오: 올영 테크 블로그 프론트엔드 신규 아키텍처 소개

 

먼저 올리브영몰의 기술 스택부터 살펴보자.

 

올리브네트웍스가 개발하던 시절에는 기본적인 SSR 혹은 React를 단독으로 CSR로 구성했을 것이다. (추정)

이후 TTV 타이밍을 앞당기고, SEO를 위해 Next.js을 도입했다고 한다.

 

알아본 바, Next.js는 기본적으로 SSR과 SSG를 중심으로 설계되었지만, CSR도 지원한다.

Next.js에서의 CSR은 클라이언트 사이드 네비게이션, 하이드레이션 기능을 제공한다.

하이드레이션은 서버에서 미리 렌더링한 HTML을, 클라이언트에서 React 컴포넌트로 페이지를 인터랙티브하게 만드는 과정이다.

이때 React Query를 사용한다. 데이터 fetch의 과정을 단순화한다.

 

결론적으로 올리브영몰은 대부분의 페이지를 CSR과 SSR을 혼합하여 구성한 듯하다.

 

 

CSR vs SSR vs SSG 판단

 

1. HTML 소스 확인, 페이지 로드 이후 페이지 소스 뷰어 (cmd + U)

  • CSR: 매우 간단
  • SSR: 초기 HTML이 거의 완전한 상태
  • SSG: 초기 HTML이 완전한 상태

2. Networks 패널

  • CSR:
    • 초기 HTML 응답이 없거나 최소한
    • 자바스크립트로 데이터를 fetch 한 뒤 렌더링
  • SSR:
    • 초기 HTML이 서버에서 완전히 렌더링되어 클라이언트에 전달
    • 이후 자바스크립트 파일이 로드되고, 페이지 하이드레이션
  • SSG: 
    • 초기 HTML이 완전히 렌더링된 콘텐츠를 포함

3. 자바스크립트 비활성화

  • CSR: 대부분 콘텐츠가 표시되지 않거나 빈 페이지
  • SSR: 기본적인 구조 혹은 스켈레톤 구조까지는 표시
  • SSG: 모두 표시

 

Next.js 적용 여부 판단

화면을 구성하는 주요 요청의 경로에 _next가 포함되어 있다면 next.js로 추정할 수 있다. (퀵메뉴 -> 라이브 페이지)

 

Next.js + SPA인 경우, 네비게이션을 처리하기 위한 별도의 함수를 적용해야 하므로 알아둘 필요가 있다.

다음과 같은 근거로 판단했다.

  • HTML 소스: `<script id="__NEXT_DATA__">`와 같은 Next.js 특유의 태그
  • Network 패널: `_next` 경로로 시작하는 파일 요청

 

1.2.1.4. 올리브영몰 주요 페이지 렌더링 방식

홈 탭 / 올리브영몰 루트 페이지

1. 홈 탭 > 퀵메뉴

  • W케어
    • SPA: 페이지 로드X, json으로 페이지 구성
    • SSR → CSR: `NonMemberDashboard` 요소와 스켈레톤 구조 SSR → 나머지 콘텐츠는 CSR
    • Next.js: 홈 탭에서 `NEXT_DATA`로 처리, json 요청의 경로에 `_next` 포함
  • 건강템찾기
    • SPA: 페이지 로드X, json으로 페이지 구성
    • SSG: 자바스크립트 사용을 중지해도 완전한 페이지를 로드함. 동적으로 변경될 요소가 없음.
    • Next.js: 홈 탭에서 `NEXT_DATA`로 처리, json 요청의 경로에 `_next` 포함
  • 라이브
    • SPA: 페이지 로드X, json으로 페이지 구성
    • SSR → CSR: 스켈레톤 구조만 SSR → 나머지 콘텐츠는 CSR
    • Next.js: 홈 탭에서 `NEXT_DATA`로 처리, json 요청의 경로에 `_next` 포함
  • 나머지 퀵메뉴
    • MPA: 전체 페이지를 새롭게 로드
    • SSR → CSR: 페이지마다 다르지만, 큰 틀과 고정적인 콘텐츠는 SSR → 가변적인 콘텐츠는 CSR
    • Next.js: 홈 탭에서 `NEXT_DATA`로 처리

 

2. 홈 탭 > 탭 리스트

  • 매거진 → 상세 페이지
    • MPA: 전체 페이지를 새롭게 로드
      (프로젝트 초기에는 SPA라고 메모를 남겨놨는데, 최근에 바뀌었나 싶다.)
      (위치를 잃고 최상단으로 가는 문제도, 그보다는 조금 아래인 곳으로 가게 되었다..?)
    • SSR → CSR: 메인 콘텐츠와 하단 상품 추천 대부분을 SSR → 최하단 `캐치키워드를 더 보고 싶다면` 부분은 CSR?
    • Next.js: 응답 받은 HTML 파일에 __NEXT_DATA__를 통해 데이터를 요청하는 부분이 있음.
  • 나머지 탭 → 상세 페이지
    • MPA: 전체 페이지를 새롭게 로드
    • SSR & CSR: 매거진 형식의 상세 페이지는 SSR. 제품 형식의 상세 페이지는 ‘상품설명’까지는 SSR, 나머지는 CSR
    • Next.js: 확실하지 않으나 사용하지 않는 듯하다.

 

왼: 셔터 탭 루트 페이지 / 오: 게시글 + 댓글

 

3. 셔터 탭

  • MPA: 전체 페이지를 새롭게 로드
  • SSR + CSR: 스켈레톤 구조 SSR → 내부 콘텐츠 CSR
  • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

4. 셔터 탭 게시글

  • MPA: 전체 페이지를 새롭게 로드
  • SSR / CSR: 게시글은 SSR / 댓글은 CSR
  • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

 

히스토리 탭

 

5. 히스토리 탭

  • MPA: 전체 페이지를 새롭게 로드
  • SSR + CSR: 스켈레톤 구조 SSR → 내부 콘텐츠 CSR
  • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

6. 히스토리 탭 → 하위 페이지

  • `제품 상세 페이지`와 동일하다 (하단 참고)

 

 

왼: 마이페이지 탭 / 중: CJ ONE 포인트 페이지 / 오: 주문 배송 조회 페이지

 

7. 마이 탭

  • MPA: 전체 페이지를 새롭게 로드
  • SSR + CSR: 전체 구조 SSR → 닉네임 등 유저 데이터는 CSR

8. 마이 탭 → 하위 페이지: CJ포인트 & 주문배송조회 & 배송지 관리 등

  1. MPA: 전체 페이지를 새롭게 로드
  2. SSR: 자바스크립트 사용을 중지해도 완전한 페이지를 로드함. 동적으로 변경될 요소가 있으나, 유저 별로 캐싱해두는 듯. 거의 SSG와 동작이 비슷하나, Next.js를 사용하지 않는 것 같아 SSR이라고 판단.
  3. Next.js: 사용하지 않는 듯

 

 

 

카테고리 탭

9. 카테고리 탭 → 메인 (카테고리 탭)

  • MPA: 전체 페이지를 새롭게 로드
  • SSR: 페이지의 모든 콘텐츠를 next.js를 통해 생성. CSR 요소는 거의 없는 듯하다.
  • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

 

헬스 탭 / 올리브영몰의 app in app

 

10. 헬스 탭 (app in app)

  • 퀵 메뉴 QuickMenu & 나에게 맞는 건강기능식품 찾기 FoodSearch
    • SPA: 페이지 로드X, json으로 페이지 구성
    • SSR / CSR: 게시글은 SSR / 하단 상품 추천은 CSR
    • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js
  • 오늘의 특가로 건강하게 TodaySpecial 포함한 하단
    • 매거진 형식의 상세 페이지는 SSR. 제품 형식의 상세 페이지는 ‘상품설명’까지는 SSR, 나머지는 CSR

 

제품 상세 페이지 (왼: 메인 / 중: 리뷰 탭 / 오: 리뷰 상세 페이지)

 

11. 제품 상세 페이지 → 메인

  • MPA: 전체 페이지를 새롭게 로드
  • SSR / CSR: ‘상품설명’까지는 SSR / 하단 상품 추천부터는 CSR
  • Next.js: 확실하지 않으나, 사용하지 않는 듯.

12. 제품 상세 페이지 메인 → 리뷰 탭 & 리뷰 상세 페이지

  • SPA: 페이지 로드X, json으로 페이지 구성
  • CSR: SSR로 추정되는 요소가 없음.
  • Next.js: 사용하지 않는 듯.

 

 

왼: 검색 페이지 / 오: 검색 결과 페이지

 

13. 기타 페이지 → 검색

  • MPA: 전체 페이지를 새롭게 로드
  • SSR / CSR: 검색바는 SSR / 추천 키워드 및 급상승 검색어는 CSR
  • Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

14. 홈 탭 / 셔터 탭 → 검색

  • SPA: 페이지 로드X, json으로 페이지 구성
  • SSR / CSR: `기타 페이지 → 검색` 참고
  • Next.js: `기타 페이지 → 검색` 참고

15. 검색 결과 페이지

  1. MPA: 전체 페이지를 새롭게 로드
  2. SSR → CSR: 스켈레톤 구조까지 SSR → 내부 콘텐츠는 CSR
  3. Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js

 

 

장바구니 페이지 (왼: 일반 배송 / 중: 오늘드림 / 후: 픽업)

 

16. 장바구니 → 일반 배송 탭 & 오늘드림 & 픽업 탭

  • MPA: 전체 페이지를 새롭게 로드
  • SSR → CSR: 대부분 SSR → 이미지 사진, 금액 계산 결과 등은 CSR
  • Next.js: 사용하지 않는 듯

 

 

1.2.2. 통신 방식을 messageHandler 중심으로 변경

1.2.2.1. 요약

기존에는 `WKNavigationDelegate`로 웹에서 발생하는 navigation 요청을 가로채서 다뤘다.

개선 버전에서는 총 3가지의 방법으로 웹 ↔ 앱 통신을 한다.

 

여전히 `WKNavigationDelegate`도 사용한다.

네비게이션을 처리해야 하는 나의 프로젝트에 가장 적합한 방식이다.

 

1. 웹 → 앱

  1. 웹의 자바스크립트에 삽입된 `window.webkit.messageHandlers` 메서드를 호출하면, 앱으로 메시지를 보낸다.
  2. 앱의 `WKScriptMessageHandler`를 구현하여 `userContentController` 메서드에서 `WKScriptMessage` 타입의 메시지를 수신 후, 이름에 따라 처리한다.
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print("message.name: \(message.name)")
        print("message.body: \(message.body)")
        
        switch message.name {
        case "navigationBarButtonHandler":
            if let messageBody = message.body as? [String: Any],
               let action = messageBody["action"] as? String,
               action == "navigate",
               let urlString = messageBody["url"] as? String,
               let url = URL(string: urlString) {

                let newVC = GenericViewController(url: url)
                self.navigationController?.pushViewController(newVC, animated: true)
            }

 

message.name을 기준으로 자바스크립트에서 보낸 messageHandler를 인식할 수 있다.

메시지를 수신한 뒤에 적절한 처리를 하면 된다.

위의 경우에는 url을 viewController에 담아서 현재 navigationController로 push 해주었다.

 

 

2. 앱 → 웹

  1. 앱에서 작성한 JavaScript 코드를 웹에서 실행한다.
    • `WKUserScript`: 웹 페이지 로드 시점에 자동으로 실행된다.
    • `evaluateJavascript`: 로드된 이후 특정 시점에 즉시 실행한다.
// WKUserScript
...
func configureWebView() {
        let contentController = WKUserContentController()
        contentController.add(self, name: "navigationBarButtonHandler")

        let navigationBarButtonJsCode = """
        window.addEventListener('load', function() { // 페이지가 모두 로드된 이후 함수 실행
            function addClickListenerToLinks() {
                var searchButtonClass = 'SearchButton_search-button__R_86C'; // 다루고자 하는 요소의 클래스명
                var basketButtonClass = 'BasketButton_basket-button___xAaP'; // 다루고자 하는 요소의 클래스명
        
                var links = document.getElementsByTagName('a'); // 페이지 내 모든 <a> 태그를 가져옴
                Array.prototype.forEach.call(links, function(link) { // 모든 <a> 태그 요소에 반복 작업 수행
                    var linkClass = link.className;
        
                    if (!link.getAttribute('data-event-attached') &&
                        (linkClass.includes(searchButtonClass) ||
                         linkClass.includes(basketButtonClass))) {
                        link.addEventListener('click', function(event) {
                            event.preventDefault(); // 기본 네비게이션 방지
                            var url = event.currentTarget.href; // 클릭된 링크의 URL을 가져옴
                            window.webkit.messageHandlers.navigationBarButtonHandler.postMessage({ // iOS 앱으로 메시지 전송
                                action: 'navigate', // 메시지 내용
                                url: url	// 메시지 내용
                            });
                        });
                        link.setAttribute('data-event-attached', 'true'); // 이벤트 리스너가 추가 되었음을 표시. 중복 추가를 방지.
                    }
                });
            }
        
            addClickListenerToLinks(); // 함수 호출
        });
        """

...
let navigationBarButtonUserScript = WKUserScript(source: navigationBarButtonJsCode, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
contentController.addUserScript(navigationBarButtonUserScript) // 작성한 함수를 contentController에 추가
        
let webConfiguration = WKWebViewConfiguration()
webConfiguration.userContentController = contentController // webView 속성을 설정하는 부분에 커스텀한 contentController 추가

 

1.1.1.1에서 설명한 대부분의 페이지는 이 방식과 `WKNavigationDelegate`, 두 가지로 구현이 가능하다.

 

그러나 특정 페이지의 경우 네비게이션 요청이 발생하지도 않고, 목적지 URL 값을 가져올 수도 없어서 곤란했다.

전술한대로 아주 긴 시간을 삽질한 끝에 `Next.js로 제작된 앱에서 SPA처럼 동작하는 페이지`가 문제라는 것을 알게 되었다.

 

chatGPT의 힘을 빌려 window.next로 네비게이션 이동을 방지하고, 목적지 URL을 얻어낼 수 있었다.

 

// WKUserScript: Next.js + SPA

let nextJsNavigationHandler = """
    (function() {
        const handleRouteChange = (url, { shallow }) => {
            if (shallow) return;

            const fullUrl = 'https://m.oliveyoung.co.kr' + url; // 루트 도메인 이후 부분만 얻을 수 있어서, prefix를 붙여줬다.
            window.webkit.messageHandlers.nextJSNavigationHandler.postMessage({
                action: 'navigate',
                url: fullUrl
            });

            // 기본 네비게이션 이동 방지
            window.next.router.events.emit('routeChangeError');
            throw 'routeChange aborted.';
        };
		
        // Next.js의 라우터 이벤트를 감지하는 부분. chatGPT가 작성해주었다.
        if (window.next && window.next.router) {
            window.next.router.events.on('routeChangeStart', (url, options) => {
                handleRouteChange(url, options);
            });

            window.addEventListener('beforeunload', function() {
                window.next.router.events.off('routeChangeStart', handleRouteChange);
            });
        }
    })();
"""

 

위 코드를 통해 1.1.1.1.에서 문제가 되었던 페이지들을 순조롭게 처리할 수 있었다.

정말 어찌나 기쁘던지 ...

 

// evaluateJavascript()

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.evaluateJavaScript("document.body.style.backgroundColor = 'lightblue';") { (result, error) in
        if let error = error {
            print("배경색 변경 실패: \(error.localizedDescription)")
        } else {
            print("배경색 변경 성공")
        }
    }
}

 

두 가지 방법의 가장 큰 차이는 실행되는 타이밍이다.

페이지 로드 시점에, 어떠한 요소에 선제적으로 적용하고 싶은 경우 'WKUserScript'가 적절하다.

그 외에 경우에는 대부분 `evaluateJavascript` 함수를 쓰는 듯하다.

 

 

3. 웹 → 앱 → 웹

  1. `Promise` 객체를 활용하면 된다.
  2. 웹에서 `Promise` 객체를 반환하는 함수를 실행한다. 함수 내부의 `webkit.messageHandlers`가 앱으로 메시지를 전송한다.
  3. 앱의 `WKScriptMessageHandler`가 메시지를 수신 후, `userContentController` 메서드에서 메시지의 이름에 따라 처리한다.
  4. 메시지 처리 후, 결과를 포함한 JS 코드를 `evaluateJavaScript` 메서드를 통해 다시 웹으로 전송한다.
  5. 웹은 앱에서 온 응답을 받아 Promise 객체를 resolve하거나 reject한다.
// iOS 앱에서 작성하는 JavaScript 부분

let promiseJsCode = """
window.addEventListener('load', function() {
    function callAppMethod() {
        return new Promise((resolve, reject) => { // Promise 객체를 반환
            window.webkit.messageHandlers.webToAppHandler.postMessage('requestData'); // webToAppHandler라는 이름으로 메시지 보냄
            window.handleAppResponse = function(response) {
                if (response) {
                    resolve(response); // resolve 되면 응답을 받아옴
                } else {
                    reject('No response from app');
                }
            }
        });
    }

    callAppMethod().then(response => {
        console.log('Received response from app:', response); // 여기서 받은 데이터를 처리 가능. DOM 요소 조작 가능.
    }).catch(error => {
        console.error('Error:', error);
    });
});
"""
// Web -> iOS 앱으로 온 메시지를 처리하는 부분

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print("GenericViewController | userContentController | didReceive message")
        print("message.name: \(message.name)")
        print("message.body: \(message.body)")
        
        switch message.name {
        case "webToAppHandler":
            if let messageBody = message.body as? String, messageBody == "requestData" {
                var responseMessage: String // 여기에 메시지를 담아 전송
                
                // 특정 case에 따른 메시지 반환 예시
                switch AppState.shared.initialLoadCompleted {
                case true:
                    responseMessage = "Response for true"
                case false:
                    responseMessage = "Response for false"
                }
                
                let jsCode = "handleAppResponse('\(responseMessage)');"
                webView.evaluateJavaScript(jsCode, completionHandler: nil)
            }
        ...

웹 -> 앱 -> 웹을 거쳐서 웹 콘솔로 수신한 메시지 "Response for false"

 

양방향 통신은 일반적으로 복잡한 문제에 주로 쓰이는 듯하다.

  • 사용자 인증
  • 위치 정보 요청
  • 결제 처리
  • 푸시 알림 설정

나의 프로젝트의 경우에는 단방향 통신으로도 문제를 해결할 수 있었기에 적용하지는 않았다.

다만 1차 면접 과정에서 피드백을 받았던 부분이기에, 현업에서 자주 쓰이겠구나 싶어서 예제로 진행해보았다.


1.2.3. UI Test 코드 작성 및 테스트 진행

UI Test를 자동화하여 진행 중인 모습

1.2.3.1. 배경

WKWebView의 콘텐츠는 기본적으로 네이티브 뷰 계층 구조에 노출되지 않기 때문에, 일반적인 UI Test의 적용이 어려웠다.

  • 요소 선택의 기준이 되는 `.accessbilityIdentifier`를 설정할 수 없다.
  • Recording이 정상적으로 작동하지 않는다.

이런 이유로 1.0 버전에서는 수동으로 테스트를 진행했지만,
이번에는 자동화된 UI Test를 진행할 수 있었다.

 

다만 내부 코드에 접근할 수 없는 이유로 제한된 해결책을 적용했다.

 

1.2.3.2. 해결

 

1.2.3.3. 구현

extension XCTestCase {
    func tapCoordinateByAbsolute(in context: XCUIElement, x: CGFloat, y: CGFloat) {
        let normalizedX = x / context.frame.width
        let normalizedY = y / context.frame.height
        let coordinate = context.coordinate(withNormalizedOffset: CGVector(dx: normalizedX, dy: normalizedY))
        coordinate.tap()
    } // 전체 화면 좌표의 절댓값을 기준으로 요소 찾아서 탭
    
    func tapCoordinateByRelative(in context: XCUIElement, x: CGFloat, y: CGFloat) {
        let coordinate = context.coordinate(withNormalizedOffset: CGVector(dx: x, dy: y))
        coordinate.tap()
    } // 현대 화면 좌표의 상대값을 기준으로 요소 찾아서 탭
    
    func scrollToElement(in context: XCUIElement, targetText: String) {
        let elementToScrollTo = context.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] %@", targetText)).firstMatch

        // 스크롤하여 요소가 보이게 하기
        let maxScrolls = 10
        var count = 0
        
        while !elementToScrollTo.exists && count < maxScrolls {
            context.swipeUp()
            count += 1
        }
    } // 특정 text를 가진 요소까지 스크롤하기 + 최대 스크롤 횟수 제한
    
    func doScroll(in context: XCUIElement, startDy: CGFloat, endDy: CGFloat) {
        // 스크롤 길이를 더 세밀하게 조절
        let smallStartCoordinate = context.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: startDy))
        let smallEndCoordinate = context.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: endDy))
        smallStartCoordinate.press(forDuration: 0.1, thenDragTo: smallEndCoordinate)
    } // coordinate.press를 이용하게 세밀하게 스크롤

    
    func tapButtonByText(in context: XCUIElement, text: String) {
        context.buttons[text].tap()
    } // 특정 text를 가진 button 요소를 DOM 구조에서 찾아서 탭
    
    func tapLinkByText(in context: XCUIElement, text: String) {
        context.links[text].tap()
    } // 특정 text를 가진 a 태그 요소를 DOM 구조에서 찾아서 탭
    
    func waitForNewPage(in context: XCUIElement) {
        XCTAssertTrue(context.waitForExistence(timeout: 20), "웹뷰가 존재하지 않습니다.")
    } // 액션을 실행한 뒤, 페이지 로드가 완료될 때까지 기다리기
    
    func goBack(in context: XCUIApplication) {
        context.navigationBars.buttons.element(boundBy: 0).tap()
    } // 뒤로 가기 액션 실행
}

 

XCUIElement와 coordinate를 이용하여 화면의 요소를 조작할 수 있도록 커스텀 함수를 작성했다.

테스트 시나리오를 3개로 나누어 각각을 적절하게 구현했다.

 

 

2. 테스트 진행 및 분석

2.1. 분석 도구

(좌: Xcode의 Instruments, 우: Charles Proxy)

 

버전 1.0과 동일하다.

 

분석 도구로는 Xcode 내장 성능 분석 도구인 Instruments와 HTTP 네트워크 트래픽 분석 도구인 Charles Proxy를 이용했다.

Instruments의 기본 기능인 HTTP Traffic을 사용하지 않은 이유는, WKWebView에서 발생하는 HTTP 정보를 확인할 수 없었기 때문이다. (애플 개발자 포럼 공식 답변: https://forums.developer.apple.com/forums/thread/699936)

 

Instruments 참고: https://developer.apple.com/videos/play/wwdc2019/411/

Charles Proxy 설정: https://techblog.gccompany.co.kr/charles-proxy-소개-4c4a3bbc8994

 

2.2. 분석 항목

분석 목적은 앱 사용성 개선 정도의 측정하기 위해 `네트워크 사용량`, `메모리 사용량`을 기준으로 했다.

`CPU 사용량`은 Instruments에서 수집했으나, 유의미한 차이는 없어서 분석은 진행하지 않았다.

  • 네트워크 사용량: Charles Proxy의 Requests, Responses, Combined
  • 메모리 사용량: Instruments의 Allocations

2.3. 분석 플로우

다양한 시나리오를 검증할 수 있는 3가지 케이스를 선정했다.

  1. 메인 홈Main (기존 앱의 홈 탭에서 선택 정보가 사라지는 것에 주목)
    1. `카테고리 랭킹`  `클렌징 탭` 선택
    2. 해당 탭에서 `1~3위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
    3. `메인 홈`으로
  2. 셔터Shutter (계층적 네비게이션에 View를 여러 개 쌓았을 때 메모리 사용량을 측정하기 위함)
    1. `셔터 탭`으로 이동
    2. `첫 번째 셔터 게시글` 조회
    3. 해당 셔터에서 사용한 `첫 번째 상품 조회`
    4. `리뷰 탭` 선택
    5. `이 상품을 태그한 셔터 게시물` → `첫 번째 셔터 게시글` 조회
    6. 해당 셔터에서 사용한 `두 번째 상품 조회`
    7. `리뷰 탭` 선택
    8. `이 상품을 태그한 셔터 게시물` → `두 번째 셔터 게시글` 조회
    9. 뒤로 가기 → 뒤로 가기 → 뒤로 가기  뒤로 가기 → `셔터 탭`으로
  3. 검색Search (기존 앱의 검색 페이지에서는 선택 정보가 사라지지 않는 것에 주목)
    1. 검색 페이지로 이동
    2. `브링그린` 검색
    3. 해당 페이지에서 `1~3위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
    4. 뒤로 가기 → `메인 홈`으로

 

2.4. 분석 방법

테스트 시나리오 별로 자동화 된 UI Test를 진행

 

2.5. 분석 결과

2.5.0. 전제 조건

  • 모두 iPhone 14 모델 실제 기기에 빌드하여 테스트했다.
  • 2.3.에서 정의한 3가지 분석 플로우을 기준으로 했다.
  • 이전 버전에 비교했을 때 개선 버전을 기준으로 작성했다.
  • 이전 버전은 Xcode에서 WKWebView에 올리브영몰 도메인을 연결하여 띄우기만 했다.
  • 개선 버전은 위에서 작성한 모든 내용을 반영했다.

2.5.1. 선요약

  • 메모리 사용량: 1번과 3번 플로우에서는 유사했다. 다만 2번 플로우에서는 깊이가 깊어질수록 사용량이 증가했다. 반면 view를 stack에서 제거함에 따라 메모리 관리가 잘 되고 있음을 확인할 수 있었다.
  • 네트워크 사용량: 대단히 효율적이었다. Requests는 34.8%, Responses는 31.4%, Combined(종합)는 32.4% 적은 사용량을 보였다.

 

2.5.2. 결과 데이터

2.5.2.1. Charles Proxy (네트워크 사용량)

► 주요 항목 설명

더보기

네트워크 속도에 영향을 끼치는 항목들

  1. DNS Time (DNS 시간):
    • DNS 조회에 소요되는 시간.
    • 네트워크 속도에 직접적인 영향을 미치며, DNS 시간이 길어지면 웹 페이지 로딩 시간이 길어질 수 있음.
  2. Connect Time (연결 시간):
    • 서버와의 연결 설정에 소요되는 시간.
    • 연결 시간이 길어지면, 초기 연결 설정에 시간이 많이 걸려 전체 응답 속도가 느려질 수 있음.
  3. TLS Handshake Time (TLS 핸드셰이크 시간):
    • TLS 핸드셰이크를 완료하는 데 소요되는 시간.
    • 보안 연결을 설정하는 과정이기 때문에, 핸드셰이크 시간이 길어지면 데이터 전송 속도가 느려질 수 있음.
  4. Latency (대기 시간):
    • 서버로부터 첫 번째 바이트를 받기까지의 시간.
    • Latency가 높으면 네트워크 반응 속도가 느려질 수 있음.
  5. Speed (속도):
    • 데이터 전송 속도를 바이트/초(B/s) 단위로 나타낸 것

데이터 사용량에 영향을 끼치는 항목들

  1. Requests (요청 데이터 크기):
    • 클라이언트에서 서버로 전송된 총 요청 데이터의 크기.
    • 요청 데이터 크기가 크면 네트워크 사용량이 증가.
  2. Responses (응답 데이터 크기):
    • 서버에서 클라이언트로 전송된 총 응답 데이터의 크기.
    • 응답 데이터 크기가 크면 네트워크 사용량이 증가.
  3. Combined (전체 데이터 크기):
    • 요청과 응답 데이터의 총합.

 

1번 플로우 (메인)

항목 기존(Ori) 개선(Comp) 개선 퍼센티지
Protocols HTTP/1.1 HTTP/1.1 -
Requests Completed 44 49 11.36%
DNS 3 6 100.00%
Connects 43 50 16.28%
TLS Handshakes 41 49 19.51%
DNS Time 39ms 84ms 115.38%
Connect Time 1.44s 2.08s 44.44%
TLS Handshake Time 3.58s 4.82s 34.64%
Latency 0ms 0ms 0.00%
Speed 1.88 KB/s 1.92 KB/s 2.13%
Request Size 1,001.92 KB 712.76 KB -28.85%
Response Size 13.54 MB 9.34 MB -31.00%
Combined Size 14.52 MB 10.04 MB -30.86%

 

 

2번 플로우 (셔터)

항목 기존(Ori) 개선(Comp) 개선 퍼센티지
Protocols HTTP/1.1 HTTP/1.1 -
Requests Completed 51 34 -33.33%
DNS 12 7 -41.67%
Connects 52 54 3.85%
TLS Handshakes 51 44 -13.73%
DNS Time 199ms 75ms -62.31%
Connect Time 1.82s 2.57s 41.21%
TLS Handshake Time 5.52s 4.20s -23.91%
Latency 0ms 0ms 0.00%
Speed 232 B/s 186 B/s -19.83%
Request Size 1.68 MB 992.94 KB -41.22%
Response Size 1.99 MB 1.26 MB -36.68%
Combined Size 3.66 MB 2.23 MB -39.07%

 

 

3번 플로우 (검색)

항목 기존(Ori) 개선(Comp) 개선 퍼센티지
Protocols HTTP/1.1 HTTP/1.1 -
Requests Completed 43 18 -58.14%
DNS 10 6 -40.00%
Connects 44 46 4.55%
TLS Handshakes 44 40 -9.09%
DNS Time 198ms 70ms -64.65%
Connect Time 2.97s 1.66s -44.11%
TLS Handshake Time 5.77s 3.75s -35.01%
Latency 0ms 0ms 0.00%
Speed 173 B/s 182 B/s 5.20%
Request Size 1.22 MB 825.88 KB -33.10%
Response Size 1.36 MB 975.33 KB -28.27%
Combined Size 2.58 MB 1.76 MB -31.78%

 

 

종합 (1 + 2 + 3)

항목 기존(Ori) 총합 개선(Comp) 총합 개선 퍼센티지 평균
Protocols HTTP/1.1 HTTP/1.1 -
Requests Completed 138 101 -26.81%
DNS 25 19 -24.00%
Connects 139 150 7.91%
TLS Handshakes 136 133 -2.21%
DNS Time 436ms 229ms -47.48%
Connect Time 6.23s 6.31s 1.29%
TLS Handshake Time 14.87s 12.37s -16.82%
Latency 0ms 0ms 0.00%
Speed 406.88 KB/s 369.92 KB/s -9.09%
Request Size 3,882.84 KB 2,531.58 KB -34.79%
Response Size 16.89 MB 11.58 MB -31.40%
Combined Size 20.76 MB 14.03 MB -32.42%

 

 

2.5.2.2. Xcode Instruments (메모리 사용량)

1번 플로우 (메인)

1번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

 

2번 플로우 (셔터)

2번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

UINavigationController를 적용한 개선 버전에서는 view를 stack에서 제거함에 따라 메모리가 감소하는 것이 명백했다.

올리브영몰의 기존 버전의 경우 뒤로 가기를 실행해도 메모리가 감소하지 않았다.

 

현재 메모리 관리가 정상적으로 되지 않는다고 추측할 수 있겠으나,

올리브영몰 앱이 아닌, 올리브영몰 도메인 주소를 가져와 WKWebView로 띄운 커스텀 앱이라는 점,

올리브영몰의 내부 코드에 접근할 수 없다는 점,

위를 고려하여 확신을 할 수는 없겠다.

 

 

3번 플로우 (검색)

2번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

3. 한계 및 개선 방안

3.1. 한계

  • 내부 코드에 접근할 수 없어 완벽한 UI Test를 진행하지 못한 점이 아쉽다.
  • UINavigationController에서 viewController가 다수 쌓였을 때 캐싱 전략까지 나아가지 못 했다.
  • Promise 객체를 적용한 양방향 통신을 적극 활용하지 못 했다.

3.2. 개선 방안

  • WKWebView 환경에서 DOM 구조에 접근하는 방법을 더 고민할 것
  • 스택 구조가 깊어지는 발생하는 메모리 문제를 해결할 것
  • 양방향 통신을 적용할 만한 기능을 고려하고, 도입할 것

 

4. 결론

  1. UINavigationController를 적용하면 특정 플로우에서 네트워크 사용량을 32.4% 절감할 수 있다.
  2. Safari 개발자 도구 사용 방법, 올리브영몰 주요 페이지의 렌더링 방식, 웹과 앱의 통신 방식, UI Test 코드 작성 방식에 대해 깊이 이해할 수 있었다.

 

참고

https://oliveyoung.tech/blog/2023-01-04/live-frontend/

https://oliveyoung.tech/blog/2023-10-30/wcare-tdd-development/

https://oliveyoung.tech/blog/2022-12-09/increase-performance-oliveyoung-mainpage/

https://www.youtube.com/live/jbICJFT8oGI?si=ZuQL1P_3dZomuVXJ

https://zeddios.tistory.com/1064

https://masilotti.com/ui-testing-cheat-sheet/

https://pilot34.medium.com/work-with-sfsafariviewcontroller-or-wkwebview-in-xcode-ui-tests-8b14fd281a1f

https://forums.developer.apple.com/forums/thread/699936

https://developer.apple.com/videos/play/wwdc2019/411/

https://techblog.gccompany.co.kr/charles-proxy-소개-4c4a3bbc8994

업데이트

+ 24.06.20 일자로 올리브영 공식 앱에 해당 문제를 개선한 업데이트가 반영됨


클렌징 카테고리의 1~3위를 조회하는 플로우 (좌: 현재 올리브영 앱 / 우: UINavigationController를 적용한 개선 앱)

0. 개요

평소처럼 올리브영 앱을 사용하다가 문득 불편함을 느꼈다.

올리브영 앱의 콘텐츠 부분은 웹뷰로 제작되어, 뷰를 이동할 때마다 새로운 페이지를 로드하는 방식으로 동작한다.

이 과정에서 선택했던 옵션 혹은 탐색 중이던 스크롤 위치를 잃기도 했다.

 

UINavigationController가 떠올랐다.

stack 구조의 계층적 네비게이션을 제공하면, 그만큼 데이터 요청이 줄어들지 않을까? 사용성도 좋아지지 않을까?

안 그래도 많은 이미지를 보여주는 H&B 서비스인데, 비용도 줄어들지 않을까?

그래서 만들어보기로 했다.

 

1. 준비

1.1. 기술 스택 파악

카테고리 기술스택 나의 경험 참고 자료
언어 Swift, Objective-C O, X  
라이브러리(UI) UIKit X  
라이브러리 Cocoa Touch X https://babbab2.tistory.com/51
개발 도구 Xcode O  
동시성 RxSwift, ReactorKit X https://oliveyoung.tech/blog/2023-05-20/OliveYoung-iOS-ReactorKit/,
뷰 방식 WKWebView X https://hilily.tistory.com/78, https://velog.io/@gnwjd309/iOS-WKWebView, https://thoonk.tistory.com/87
구조 MVVM, MVVM-C O  
데이터베이스 Core Data(?) O  
품질 관리 도구 SwiftLint O  

 

지피지기면 백전불태이다.

구현에 앞서 올리브영 앱에서 사용 중인 기술 스택을 파악하기로 했다.

공식 홈페이지, 공식 유튜브, 테크 블로그, 신입 채용 공고, 경력자 채용 공고, 링크드인에서 자료를 수집했다.

 

이번 프로젝트에서 가장 중요한 웹뷰는 `WKWebView`를 사용하여 구현한 것으로 추정된다. (링크드인 경력 채용 참고)

 

 

1.2. 웹뷰 구현 방식

iOS 웹뷰 구현 도구 (출처: http://developer.outbrain.com)

iOS에서 웹뷰 구현 방식에는 3가지 방법이 있다.

  • UIWebView
  • WKWebView
  • SFSafariViewController

UIWebView

iOS 2.0에 도입되었다.

iOS 8.0 이하 버전을 지원이 가능하다.

그러나 deprecated 되었다.

WKWebView

iOS 8.0에 도입되었다.

WebKit 프레임워크의 일부이다.

웹 페이지에서 할당하는 메모리는 앱 메모리와 별도의 스레드에서 관리한다는 특징이 있다.

메모리 문제에 대해 앱 안정성이 높아진다.

UI 커스터마이징이 비교적 자유롭다.

SFSafariViewController

iOS 9.0에 도입되었다.

내장 Safari 브라우저와 같은 사용자 경험을 제공한다.

UI 커스터마이징이 제한적이다.

 

1.3. 웹 ↔ 앱 통신 방식

웹은 js로 작성된 로직을 바탕으로 HTTP에 따른 요청과 응답을 서버와 주고 받는다.

iOS 앱은 (MVVM 구조의 경우) Swift로 작성된 로직을 바탕으로 ViewModel이 Model과 View 사이를 중개하며 데이터를 주고 받는다.

 

그렇다면 웹과 앱 사이는 어떻게 통신할까?

더군다나 현재로써는 올리브영의 웹과 앱 내부 코드에 접근할 수 없는 상황이다.

이번 프로젝트 진행을 위해 핵심적인 부분이다.

 

1.3.1. 웹 → 네이티브 앱

`WKScriptMessageHandler` 프로토콜을 사용하면 JavaScript에서 네이티브 앱으로 메시지를 보낼 수 있다.

이 프로토콜을 채택한 객체를 WKWebView의 `WKUserContentController`에 등록해야 한다.

// iOS 네이티브 앱
import WebKit

class ViewController: UIViewController, WKScriptMessageHandler {

    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let contentController = WKUserContentController()
        contentController.add(self, name: "jsHandler")

        let config = WKWebViewConfiguration()
        config.userContentController = contentController

        webView = WKWebView(frame: self.view.frame, configuration: config)
        self.view.addSubview(webView)

        if let url = URL(string: "https://example.com") {
            webView.load(URLRequest(url: url))
        }
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "jsHandler", let messageBody = message.body as? String {
            print("웹으로부터 받은 메시지: \(messageBody)")
            // 메시지 처리
        }
    }
}

 

설정 방법은 아래와 같다.

  1. WKUserContentController 생성
  2. WKScriptMessageHandler 프로토콜 구현
  3. WKWebViewConfiguration에 WKUserContentController 설정
  4. WKWebView 초기화
// 웹 자바스크립트
<body>
    <h1>웹에서 네이티브 앱으로</h1>
    <button onclick="sendMessageToNative()">Send Message</button>

    <script>
        function sendMessageToNative() {
            window.webkit.messageHandlers.jsHandler.postMessage("웹에서 보내는 메시지");
        }
    </script>
</body>

 

웹에서는 `webkit.messageHandlers.설정name.메서드`을 통해서 네이티브 앱으로 메시지를 보낼 수 있다.

 

1.3.2. 네이티브 앱 → 웹

WKWebView의 `evaluateJavaScript` 메서드를 사용하면 네이티브 코드에서 JavaScript 코드를 실행할 수 있다.

`evaluateJavaScript`는 클로저를 통해 실행 결과나 에러를 반환하므로, 적절한 처리가 필요하다.

// iOS 네이티브 앱
let clickButtonScript = "document.getElementById('myButton').click();"

webView.evaluateJavaScript(clickButtonScript) { (result, error) in
    if let error = error {
        print("에러: \(error)")
    } else {
        print("버튼 클릭 성공")
    }
}

 

위와 같이 자바스크립트 내 특정 버튼을 클릭하는 것이 가능하다.

 

// iOS 네이티브 앱
let changeColorScript = "document.body.style.backgroundColor = 'lightblue';"

webView.evaluateJavaScript(changeColorScript) { (result, error) in
    if let error = error {
        print("에러: \(error)")
    } else {
        print("배경색 변경 성공")
    }
}

 

위와 같이 DOM을 조작하는 것도 가능하다.

 

지금까지 웹과 앱의 통신 방식을 알아봤다.

이제 통신을 통해 얻은 데이터를, 어떻게 stack 구조의 계층적 네비게이션에 담아낼까 고민해봐야 한다.

오늘의 주인공인 `UINavigationController`와 `WKWebView의 네비게이션 제어 방법`에 대해서 알아보자.

 

1.4. UINavigationController

1.4.1. 정의

정의 (출처: https://developer.apple.com/documentation/uikit/uinavigationcontroller)

 

공식 문서에 따르면 UINavigationController는 stack 구조의 계층적 네비게이션을 정의하는 뷰 컨트롤러 컨테이너이다.

 

1.4.2. 동작 방식

 

계층적 네비게이션 (출처: https://developer.apple.com/documentation/uikit/uinavigationcontroller)

 

위 그림은 동작 방식을 표현한 이미지이다.

대부분의 iOS 네이티브 앱에 적용된 네비게이션 방식이다.

 

네비게이션을 통해 탐색을 할 때, 현재 View 위에 다음 View가 쌓이는 방식으로 동작한다.

따라서 `뒤로 가기 == 쌓여있는 View 제거하기`이므로, 이때 새롭게 페이지를 로드할 필요가 없다.

 

1.4.3. 요소

UINavigationController의 요소 (출처: https://tong94.tistory.com/26)

 

  • viewControllers
    • viewControllers는 여러 개의 viewController를 관리할 수 있는 container viewController이다. viewControllers의 타입은 [viewController]라고도 할 수 있겠다. 해당 배열은 push, pop 메서드로 관리된다.
  • navigationBar
    • 타이틀, 뒤로가기, 설정 등 UI 요소들이 배치되는 상단 바이다.
  • toolbar
    • 공유하기 등 UI 요소들이 배치되는 하단 바이다. 기본적으로 UINavigationController에서는 숨김 처리되어 있다.
  • delegate
    • 특정 Event에서 사용할 수 있는 delegate가 선언되어 있다. 특정 viewController를 보이게 하거나, 이동 간 애니메이션을 설정하기 위해 사용한다.

 

1.5. WKWebView의 네비게이션 제어 방법

1.5.0. 발견

1.3.에서 웹과 앱의 통신 방식에 대해서 알아보았지만, 한계가 존재한다.

올리브영의 내부 코드를 모르는 입장에서, 원하는 요소를 정확하게 타겟하여 통신하는 것이 쉽지 않았다.

 

다행히 WKWebView는 웹 페이지의 로드 및 네비게이션을 제어하기 위해 `WKNavigationDelegate` 프로토콜을 제공한다.

이 프로토콜은 웹 뷰에서 일어나는 다양한 네비게이션 이벤트에 대한 콜백 메서드를 정의한다.

내부 코드를 모르더라도, 네비게이션 이벤트가 발생할 때마다 UINavigationController에 viewController를 추가 혹은 제거하는 것이 가능하다.

 

1.5.1. 종류

1. 네비게이션 정책 결정 `webView(_:decidePolicyFor:decisionHandler:):`

// iOS 네이티브 앱
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if let url = navigationAction.request.url {
        print("URL 요청 시도: \(url)")
        
        // 특정 URL에 대해 로드를 취소하거나 허용할 수 있습니다.
        if url.absoluteString.contains("restricted") {
            decisionHandler(.cancel) // 요청 취소
            return
        }
    }
    decisionHandler(.allow) // 요청 진행
}

 

이 메서드는 웹뷰가 새로운 URL을 로드하려고 할 때 호출된다.

여기서 로드할지 말지를 결정할 수 있다.

 

이번 프로젝트에서 가장 핵심적인 메서드이다.

 

 

2. 네비게이션 시작 시 호출 `webView(_:didStartProvisionalNavigation:):`

// iOS 네이티브 앱
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
    print("로딩 시작: \(webView.url?.absoluteString ?? "")")
}

 

웹뷰가 콘텐츠 로드를 시작했을 때 호출된다.

 

 

3. 서버 리디렉션 응답을 받았을 때 호출 `webView(_:didReceiveServerRedirectForProvisionalNavigation:):`

// iOS 네이티브 앱
func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
    print("리디렉션 응답 받음")
}

 

서버에서 리디렉션 응답을 받았을 때 호출된다.

 

 

4. 네비게이션 완료 시 호출 `webView(_:didFinish:):`

// iOS 네이티브 앱
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    print("로딩 완료: \(webView.url?.absoluteString ?? "")")
}

 

웹뷰가 컨텐츠 로드를 완료했을 때 호출된다.

 

 

이번 프로젝트에서는 사용자의 이벤트로 발생하는 네비게이션 요청 URL을 `webView(_:decidePolicyFor:decisionHandler:):`로 처리하여 UINavigationController에 추가하거나, 새롭게 로드하는 방식으로 구현했다.

 

1.6. 분석

1.6.1. 분석 도구

(좌: Xcode의 Instruments, 우: Charles Proxy)

 

분석 도구로는 Xcode 내장 성능 분석 도구인 `Instruments`와 HTTP 네트워크 트래픽 분석 도구인 `Charles Proxy`를 이용했다.

Instruments의 기본 기능인 `HTTP Traffic`을 사용하지 않은 이유는, WKWebView에서 발생하는 HTTP 정보를 확인할 수 없었기 때문이다. (애플 개발자 포럼 공식 답변: https://forums.developer.apple.com/forums/thread/699936)

 

Instruments 참고: https://developer.apple.com/videos/play/wwdc2019/411/

Charles Proxy 설정: https://techblog.gccompany.co.kr/charles-proxy-소개-4c4a3bbc8994

 

 

1.6.2. 분석 항목

분석 목적은 앱 사용성 개선 정도의 측정으로, `CPU 사용량`, `메모리 사용량`, `네트워크 사용량`을 기준으로 했다.

  • CPU 사용량: Instruments의 `Time Profiler`
  • 메모리 사용량: Instruments의 `Allocations`
  • 네트워크 사용량: Charles Proxy의 `Requests`, `Responses`, `Combined`

 

1.6.3. 분석 플로우

다양한 시나리오를 검증할 수 있는 3가지 케이스를 선정했다.

내부 JS 동작이 아닌 URL 중심으로 화면의 전환이 발생하며, 사용자의 일반적인 시나리오를 기준으로 했다.

  1. 메인 홈Main (기존 앱의 홈 탭에서 선택 정보가 사라지는 것에 주목)
    1. `카테고리 랭킹` → `클렌징` 탭 선택
    2. 해당 탭에서 `1~5위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
    3. `메인 홈`으로
  2. 검색Search (기존 앱의 검색 페이지에서 선택 정보가 사라지지 않는 것에 주목)
    1. `검색 페이지`로 이동
    2. `틴트` 검색
    3. 해당 페이지에서 `1~4위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
    4. `검색 페이지`로
  3. 셔터Shutter (계층적 네비게이션에 View를 여러 개 쌓았을 때 메모리 사용량을 측정하기 위함)
    1. `카테고리 랭킹` → `클렌징` 탭 선택
    2. `1위` 상품 `조회`
    3. `리뷰` 탭 선택
    4. `이 상품을 태그한 셔터 게시물` → `첫 번째 셔터` 조회
    5. 해당 셔터에서 사용한 `첫 번째 상품` 조회
    6. `리뷰` 탭 선택
    7. `이 상품을 태그한 셔터 게시물` → `첫 번째 셔터` 조회
    8. 뒤로 가기 → 뒤로 가기 → 뒤로 가기 → `메인 홈`으로

 

1.6.4. 분석 방법

최초에는 Xcode의 `UITests`을 사용하고자 했다.

 

그러나 WKWebView로 모바일 올리브영몰 도메인(https://m.oliveyoung.co.kr/m/mtn?menu=home)을 띄울 경우, 웹 요소의 Identifier가 None으로 설정되어 있어 어려움을 겪었다.

 

또한 Xcode의 Record 기능 또한 WKWebView 환경에서 정상적으로 기록되지 않는 문제가 있었다.

 

그리하여 정해진 플로우에 맞춰 수동으로 테스트를 진행했다.

대신 오차가 발생할 수 있으므로 전체 플로우 수행에 소요되는 시간은 분석 항목에서 제외했다.

 

 

2. 구현

2.1. 앱 아키텍처

앱 아키텍처

 

  • BaseWebViewController (기본 클래스)
    • GenericWebViewControllerViewController의 공통 기능을 정의한다.
    • 뷰를 초기화하고 설정하며, 웹 페이지를 로드하는 기본 메서드들을 포함한다.
    • 네비게이션을 제어하고, 특정 URL을 차단하거나 새로 고침할지를 결정한다.
      • 제어: `WKNavigationAction`을 감지하면 로직에 따라 URL을 차단하거나, 새로 고침하거나, stack에 추가한다.
      • 차단: 모든 URL을 받아서 처리하기 때문에 불필요한 요청을 차단한다. (예: `about:blank`, `gum.criteo.com/`, ...)
      • 새로 고침: 하단 탭바, 검색창, 장바구니로 이동하는 경우 새롭게 페이지를 로드한다.
  • ViewController (초기 로드용 서브클래스)
    • BaseWebViewController를 상속받아 앱이 처음 실행되었을 때 로드되는 초기 뷰 컨트롤러.
    • viewDidLoad 메서드에서 초기 URL을 로드한다.
  • GenericWebViewController (특정 URL 로드용 서브클래스)
    • BaseWebViewController를 상속받아 특정 URL을 로드하는 뷰 컨트롤러.
    • 다른 뷰 컨트롤러(ViewController)에서 새로운 URL을 로드하기 위해 계층적 네비게이션에 추가될 때 사용한다.

 

2.2. 어려웠던 문제

2.2.1. 네비게이션 바

현재 올리브영 앱은 콘텐츠 부분은 WKWebView로, 상단 네비게이션 바와 하단 탭 바는 네이티브로 구현되어 있다.

따라서 UINavigationController를 적용했을 때 기존 네비게이션 바는 동작하지 않았다.

 

고민하다가 기존 네비게이션 바 위에 UINavigationController 전용 네비게이션 바를 추가했다.

미관을 다소 해쳤지만 정상 동작했다.

 

2.2.2. 무한 루프

이번에 알게 된 사실이다.

모바일 올리브영몰에서 URL 요청을 보내면, 무수한 리디렉팅 요청과 내부 삽입된 URL 요청이 쏟아진다.

실제로 시뮬레이터를 실행시키고 잠시 화장실에 다녀왔는데, 모바일 올리브영몰로부터 접속 차단을 당한 경험이 있다.

UINavigationController의 stack에 뷰컨트롤러가 300개 가까이 쌓여있었다.

 

`decidePolicyFor` 메서드로 네비게이션 정책을 정하는 과정에서 `lastLoadedURL` 변수를 설정하여, 동일한 URL을 반복하여 요청받는 경우 해당 요청을 취소할 수 있도록 개선했다.

 

2.2.3. JS 내부적으로 호출하는 요청

JS는 사용자가 요청한 URL 뿐만 아니라, 특정 이벤트를 트리거로 내부적으로 실행되는 요청이 많았다.

예컨대 라이브 탭에 접속하면 비디오가 자동 실행된다.

 

이 요청을 처리하려면, 웹 ↔ 앱 통신이 이루어져야 한다.

그러나 해당 프로젝트는 URL 요청만을 다루고 있었기에, 이러한 문제가 발생하는 경우는 분석 플로우에서 제외했다.

 

 

3. 데이터 분석 결과

3.0. 선요약

(모두 iPhone 14 모델 실제 기기에 빌드하여 테스트했다.)

(1.6.3.에서 정의한 3가지 분석 플로우을 기준으로 했다.)

(이전 버전에 비교했을 때 개선 버전을 기준으로 작성했다.)

  • CPU 사용량: 대체로 비슷한 양상을 보였다.
  • 메모리 사용량: 1번과 2번 플로우에서는 효율적이었다. 다만 3번 플로우에서는 메모리 사용량이 2배 많았다. 
  • 네트워크 사용량: 대단히 효율적이었다. `Requests`는 64.7%, `Responses`는 20.6%, `Combined(종합)`는 33.5% 적은 사용량을 보였다.

 

3.1. 데이터

3.1.1. Charles Proxy (네트워크 사용량)

► 주요 항목 설명

더보기

네트워크 속도에 영향을 끼치는 항목들

  1. DNS Time (DNS 시간):
    • DNS 조회에 소요되는 시간.
    • 네트워크 속도에 직접적인 영향을 미치며, DNS 시간이 길어지면 웹 페이지 로딩 시간이 길어질 수 있음.
  2. Connect Time (연결 시간):
    • 서버와의 연결 설정에 소요되는 시간.
    • 연결 시간이 길어지면, 초기 연결 설정에 시간이 많이 걸려 전체 응답 속도가 느려질 수 있음.
  3. TLS Handshake Time (TLS 핸드셰이크 시간):
    • TLS 핸드셰이크를 완료하는 데 소요되는 시간.
    • 보안 연결을 설정하는 과정이기 때문에, 핸드셰이크 시간이 길어지면 데이터 전송 속도가 느려질 수 있음.
  4. Latency (대기 시간):
    • 서버로부터 첫 번째 바이트를 받기까지의 시간.
    • Latency가 높으면 네트워크 반응 속도가 느려질 수 있음.
  5. Speed (속도):
    • 데이터 전송 속도를 바이트/초(B/s) 단위로 나타낸 것

데이터 사용량에 영향을 끼치는 항목들

  1. Requests (요청 데이터 크기):
    • 클라이언트에서 서버로 전송된 총 요청 데이터의 크기.
    • 요청 데이터 크기가 크면 네트워크 사용량이 증가.
  2. Responses (응답 데이터 크기):
    • 서버에서 클라이언트로 전송된 총 응답 데이터의 크기.
    • 응답 데이터 크기가 크면 네트워크 사용량이 증가.
  3. Combined (전체 데이터 크기):
    • 요청과 응답 데이터의 총합.

 

1번 플로우 (메인)

Category
이전(메인) 개선(메인) Difference (ms/s/%)
Protocols HTTP/1.1 HTTP/1.1 -
Completed 30 5 -
Incomplete 58 31 -
Failed 1 0 -
Blocked 0 0 -
DNS Requests 42 9 -
Connects 88 36 -
TLS Handshakes 76 32 -
Kept Alive 0 0 -
Start 5/19/24 11:23:46 5/19/24 11:29:10 -
End 5/19/24 11:24:24 5/19/24 11:29:28 -
Timespan 37.92 s 17.44 s -
Requests / sec 0.79 0.29 -
Duration 22h 42m 9h 33m Error
DNS Time 804 ms 111 ms ~86.19% faster
Connect Time 5.62 s 888 ms ~84.20% faster
TLS Handshake Time 8.29 s 1.75 s ~78.89% faster
Latency 0 ms 0 ms -
Speed 84 B/s 224 B/s ~166.67% faster
Request Speed - - -
Response Speed - - -
Requests Size 923.73 KB 455.91 KB ~50.66% less
Responses Size 5.61 MB 6.90 MB ~23.00% more
Combined Size 6.51 MB 7.34 MB ~12.74% more
Compression - - -

 

 

2번 플로우 (검색)

Category 이전(검색) 개선(검색) Difference (ms/s/%)
Protocols HTTP/1.1 HTTP/1.1 -
Requests      
Completed 16 4 -
Incomplete 62 34 -
Failed 0 0 -
Blocked 0 0 -
DNS 31 7 -
Connects 78 38 -
TLS Handshakes 74 29 -
Kept Alive 0 0 -
Timing      
Start 5/19/24 11:23:46 5/19/24 11:31:04 -
End 5/19/24 11:25:54 5/19/24 11:31:17 -
Timespan 2 m 7 s 13.73 s -
Requests / sec 0.13 0.29 -
Duration 13 h 7 m 4 h 56 m Error
DNS Time 898 ms 132 ms ~85.30% faster
Connect Time 3.56 s 900 ms ~74.72% faster
TLS Handshake Time 6.43 s 1.81 s ~71.84% faster
Latency 0 ms 0 ms -
Speed 99 B/s 80 B/s ~19.19% slower
Request Speed - - -
Response Speed - - -
Size      
Requests 1.64 MB 402.71 KB ~75.45% less
Responses 2.84 MB 993.78 KB ~65.01% less
Combined 4.47 MB 1.36 MB ~69.56% less
Compression - - -

 

 

3번 플로우 (셔터)

Category 이전(셔터) 개선(셔터) Difference (ms/s/%)
Protocols HTTP/1.1 HTTP/1.1 -
Requests      
Completed 20 14 -
Incomplete 51 30 -
Failed 0 2 -
Blocked 0 0 -
DNS 29 14 -
Connects 71 44 -
TLS Handshakes 70 42 -
Kept Alive 0 0 -
Timing      
Start 5/19/24 11:23:46 5/19/24 11:31:04 -
End 5/19/24 11:26:49 5/19/24 11:32:14 -
Timespan 3 m 3 s 1 m 9 s -
Requests / sec 0.11 0.20 -
Duration 19 h 49 m 8 h 26 m Error
DNS Time 579 ms 249 ms ~57.00% faster
Connect Time 2.66 s 940 ms ~64.66% faster
TLS Handshake Time 5.61 s 2.23 s ~60.25% faster
Latency 0 ms 0 ms -
Speed 81 B/s 78 B/s ~3.70% slower
Request Speed - - -
Response Speed - - -
Size      
Requests 2.16 MB 807.56 KB ~63.78% less
Responses 3.35 MB 1.48 MB ~55.82% less
Combined 5.51 MB 2.27 MB ~58.81% less
Compression - - -

 

 

종합 (1 + 2 + 3)

Category 이전 개선 Difference (%)
Completed 22.00 7.67 65.15
Incomplete 57.00 31.67 44.44
Failed 0.33 0.67 -100.00
DNS 34.00 10.00 70.59
Connects 79.00 39.33 50.21
TLS Handshakes 73.33 34.33 53.18
DNS Time (ms) 760.33 164.00 78.43
Connect Time (s) 3.95 0.91 76.96
TLS Handshake Time (s) 6.78 1.93 71.52
Speed (B/s) 88.00 127.33 -44.70
Requests (MB) 1.57 0.56 64.73
Responses (MB) 3.93 3.12 20.56
Combined (MB) 5.50 3.66 33.47

 

 

3.1.2. Xcode Instruments (CPU 사용량, 메모리 사용량)

► 주요 항목 설명

더보기

Time Profiler → CPU Usage → Weight: 총 시간 중 해당 함수가 차지하는 비율. Weight가 높을수록 CPU 사용량이 많음.

Allocations → Total Bytes:  프로그램 실행 동안 총 할당된 메모리의 양

1번 플로우 (메인)

1번 플로우 CPU 사용량(Time Profiler) / (좌: 이전, 우: 개선)
1번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

2번 플로우 (검색)

2번 플로우 CPU 사용량(Time Profiler) / (좌: 이전, 우: 개선)
2번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

3번 플로우 (셔터)

3번 플로우 CPU 사용량(Time Profiler) / (좌: 이전, 우: 개선)
3번 플로우 메모리 사용량(Allocations) / (좌: 이전, 우: 개선)

 

 

4. 한계 및 개선 방안

4.1. 한계

  • 내부 코드에 접근할 수 없어 자동화 된 UITests를 진행하지 못한 점이 아쉽다. 따라서 오차가 있을 수 있다.
  • Xcode Instruments 분석 결과의 경우 실제 올리브영 앱의 내부 코드와 캐싱 방법에 따라 결과가 크게 달라질 가능성이 있다.
  • 3번 플로우에서 스택 구조가 깊어짐에 따라 발생하는 메모리 문제 해결까지는 나아가지 못 했다.
  • 제한된 일정으로 일부 리디렉팅 문제 등이 잔존해있다.
  • 한정된 플로우만을 이용해서 측정했다.

4.2. 개선 방안

  • 자동화된 UITests를 진행할 것
  • 스택 구조가 깊어지는 경우 발생하는 메모리 문제를 해결할 것
  • 웹 ↔ 앱 통신 방식으로 UINavigationController를 관리할 것
  • 다양한 플로우로 테스트할 것

 

5. 결론

  1. UINavigationController를 적용하면 특정 플로우에서 네트워크 사용량을 33.5% 절감할 수 있다.
  2. 올리브영 앱 내부 구조, WKWebView, UINavigationController, 웹과 네이티브 앱의 통신 방식을 깊이 이해할 수 있었다.

참고

https://babbab2.tistory.com/51

https://hilily.tistory.com/78

https://velog.io/@gnwjd309/iOS-WKWebView

https://thoonk.tistory.com/87

https://forums.developer.apple.com/forums/thread/699936

https://developer.apple.com/videos/play/wwdc2019/411/

https://techblog.gccompany.co.kr/charles-proxy-소개-4c4a3bbc8994

+ Recent posts