업데이트
+ 24.06.20 일자로 올리브영 공식 앱에 해당 문제를 개선한 업데이트가 반영됨
연관된 글
올리브영 iOS 앱 구조 개선 프로젝트 버전 1.0: https://gowild.tistory.com/71
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. 요약
최초 버전과 다르게 SPA로 동작하는 페이지까지 UINavigationController로 관리할 수 있게 되었다.
- 홈 탭 → 퀵 메뉴 → W케어, 건강템찾기, 라이브
- 홈 탭의 네비게이션 바 → 검색 버튼
매거진 탭, LUXE EDIT 탭- 프로젝트 진행 초기에는 SPA라고 메모해두었다.
- 그러나 어느 순간부터 MPA로 바뀌었다...?
- 하위 페이지에서 뒤로 가기 했을 때, 항상 최상단 페이지로 가는 문제도, 애매한 위치로 이동하는 문제로 바뀌었다.
- SPA라고 메모한 것이 부정확할 수도 있다. 당시에는 SPA, MPA에 대한 지식이 부족했기 때문.
- 위를 포함하여 독립적으로 존재하는 모든 페이지
1.1.1.2. 구현
아래에서 자세히 설명
1.1.2. 하단 탭 바의 각 탭을 별개의 UINavigationController로 관리
1.1.2.1. 요약
총 5개의 UINavigationController를 사용하여, 탭마다 각자의 상태를 유지할 수 있게 되었다.
현재 올리브영 앱은 각 탭이 하나의 페이지로 취급되어 히스토리 스택(WKBackForwardList로 추정)에 쌓인다.
이것은 다음의 문제를 발생시킨다.
1. 히스토리 스택에 너무 많은 페이지(+탭)이 쌓인다.
- 앱을 사용하다보면, 뒤로 가기 제스처로 루트 페이지에 도달하기가 사실상 불가능하다.
- 그래서 하위 페이지의 네비게이션 바에 홈 버튼을 제공하나 보다. 홈에 도달하기가 어려우니까.
- 이렇게 되면 메모리 문제가 있을 수도 있다.
- 무엇보다 그냥 불편하다.
2. 탭 간을 오가며 정보를 탐색하는 행위가 불가능하다.
- 특정 탭으로 이동할 때마다, 해당 탭이 새로고침이 되어서 이전 정보(입력값, 위치 정보)가 모두 사라진다.
1.1.2.2. 구현
`SceneDelegate`에서 5개의 아이템을 생성해준다.
각 아이템은 각각의 `UINavigationController`를 갖는다.
따라서 상태를 각각 관리할 수 있다.
순서는 올리브영몰의 탭 바와 동일하게 맞추고,
아래 코드로 최초에 홈 탭이 보이도록 설정했다.
`tabBarController.selectedViewController = homeNavController`
1.2. 개발자 관점
1.2.1. 올리브영몰의 렌더링 방식 파악
1.2.1.1. 배경
올리브영몰은 페이지마다 렌더링 방식이 다르다.
웹뷰와 네이티브를 결합한 하이브리드 앱인 것도 있고,
과거 올리브네트웍스에서 작성한 코드를 내재화 하는 과도기에 있기 때문이기도 하다.
그래서 하나의 함수로는 모든 네비게이션 요청을 일관되게 처리할 수 없었다.
1.1.1.1.에서 다뤘던 페이지를 처리하기가 쉽지 않았다.
1.2.1.2. 고민했던 지점
FE 지식이 부족한 나로서는 이 부분에서 가장 고전했다.
- 1차 당황: 메인 탭 → 퀵메뉴 → W케어 버튼의 <a 태그>에 href 속성값이 없었을 때
- 2차 당황: 해당 <a 태그>에 기본 네비게이션 요청을 막고, url을 가져오는 eventListener를 붙였는데, 동작하지 않았을 때
- 3차 당황: 해당 <a 태그>가 호출하는 `xmlHttpRequest`, `fetch api`에도 목적지 URL에 대한 정보가 없었을 때
4일 정도 고민하다가 … 로켓펀치의 취준컴퍼니에서 진행하는 멘토링을 신청했다.
- 3년차 FE 개발자 분에게 고민 중인 내용을 문서로 전달드렸다.
- 루트 페이지의 body 태그 안에 `id = “__NEXT_DATA__”` 스크립트가 있는데, 그것으로 W케어의 네비게이션 요청과 목적지 URL 정보를 조작한다는 것을 알게 되었다.
- __NEXT_DATA__는 Next.js에서 사용되는 글로벌 변수로, 서버 측에서 렌더링된 페이지의 초기 데이터와 상태를 클라이언트 측으로 전달하는 역할을 한다.
- 결론적으로 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는 개발자도구의 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.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: 전체 페이지를 새롭게 로드
- 나머지 탭 → 상세 페이지
- 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. 히스토리 탭 → 하위 페이지
- `제품 상세 페이지`와 동일하다 (하단 참고)
7. 마이 탭
- MPA: 전체 페이지를 새롭게 로드
- SSR + CSR: 전체 구조 SSR → 닉네임 등 유저 데이터는 CSR
8. 마이 탭 → 하위 페이지: CJ포인트 & 주문배송조회 & 배송지 관리 등
- MPA: 전체 페이지를 새롭게 로드
- SSR: 자바스크립트 사용을 중지해도 완전한 페이지를 로드함. 동적으로 변경될 요소가 있으나, 유저 별로 캐싱해두는 듯. 거의 SSG와 동작이 비슷하나, Next.js를 사용하지 않는 것 같아 SSR이라고 판단.
- Next.js: 사용하지 않는 듯
9. 카테고리 탭 → 메인 (카테고리 탭)
- MPA: 전체 페이지를 새롭게 로드
- SSR: 페이지의 모든 콘텐츠를 next.js를 통해 생성. CSR 요소는 거의 없는 듯하다.
- Next.js: 응답 받은 html 문서의 X-Powered-By 속성값이 Next.js
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. 검색 결과 페이지
- MPA: 전체 페이지를 새롭게 로드
- SSR → CSR: 스켈레톤 구조까지 SSR → 내부 콘텐츠는 CSR
- 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. 웹 → 앱
- 웹의 자바스크립트에 삽입된 `window.webkit.messageHandlers` 메서드를 호출하면, 앱으로 메시지를 보낸다.
- 앱의 `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. 앱 → 웹
- 앱에서 작성한 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. 웹 → 앱 → 웹
- `Promise` 객체를 활용하면 된다.
- 웹에서 `Promise` 객체를 반환하는 함수를 실행한다. 함수 내부의 `webkit.messageHandlers`가 앱으로 메시지를 전송한다.
- 앱의 `WKScriptMessageHandler`가 메시지를 수신 후, `userContentController` 메서드에서 메시지의 이름에 따라 처리한다.
- 메시지 처리 후, 결과를 포함한 JS 코드를 `evaluateJavaScript` 메서드를 통해 다시 웹으로 전송한다.
- 웹은 앱에서 온 응답을 받아 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)
}
...
양방향 통신은 일반적으로 복잡한 문제에 주로 쓰이는 듯하다.
- 사용자 인증
- 위치 정보 요청
- 결제 처리
- 푸시 알림 설정
나의 프로젝트의 경우에는 단방향 통신으로도 문제를 해결할 수 있었기에 적용하지는 않았다.
다만 1차 면접 과정에서 피드백을 받았던 부분이기에, 현업에서 자주 쓰이겠구나 싶어서 예제로 진행해보았다.
1.2.3. UI Test 코드 작성 및 테스트 진행
1.2.3.1. 배경
WKWebView의 콘텐츠는 기본적으로 네이티브 뷰 계층 구조에 노출되지 않기 때문에, 일반적인 UI Test의 적용이 어려웠다.
- 요소 선택의 기준이 되는 `.accessbilityIdentifier`를 설정할 수 없다.
- Recording이 정상적으로 작동하지 않는다.
이런 이유로 1.0 버전에서는 수동으로 테스트를 진행했지만,
이번에는 자동화된 UI Test를 진행할 수 있었다.
다만 내부 코드에 접근할 수 없는 이유로 제한된 해결책을 적용했다.
1.2.3.2. 해결
- 웹뷰 환경에서 요소를 선택할 수 있는 경우, XCUIElement 메서드 적용
- XCUIApplication / XCUIElement / XCUIElementQuery: https://zeddios.tistory.com/1064
- XCUIElement 조작 cheat sheet: https://masilotti.com/ui-testing-cheat-sheet/
- coordinates를 사용하여 좌표값을 기준으로 조작
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. 분석 도구
버전 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가지 케이스를 선정했다.
- 메인 홈Main (기존 앱의 홈 탭에서 선택 정보가 사라지는 것에 주목)
- `카테고리 랭킹` → `클렌징 탭` 선택
- 해당 탭에서 `1~3위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
- `메인 홈`으로
- 셔터Shutter (계층적 네비게이션에 View를 여러 개 쌓았을 때 메모리 사용량을 측정하기 위함)
- `셔터 탭`으로 이동
- `첫 번째 셔터 게시글` 조회
- 해당 셔터에서 사용한 `첫 번째 상품 조회`
- `리뷰 탭` 선택
- `이 상품을 태그한 셔터 게시물` → `첫 번째 셔터 게시글` 조회
- 해당 셔터에서 사용한 `두 번째 상품 조회`
- `리뷰 탭` 선택
- `이 상품을 태그한 셔터 게시물` → `두 번째 셔터 게시글` 조회
- 뒤로 가기 → 뒤로 가기 → 뒤로 가기 → 뒤로 가기 → `셔터 탭`으로
- 검색Search (기존 앱의 검색 페이지에서는 선택 정보가 사라지지 않는 것에 주목)
- 검색 페이지로 이동
- `브링그린` 검색
- 해당 페이지에서 `1~3위` 상품 조회 → 뒤로 가기 → 조회 → ... (반복)
- 뒤로 가기 → `메인 홈`으로
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 (네트워크 사용량)
► 주요 항목 설명
네트워크 속도에 영향을 끼치는 항목들
- DNS Time (DNS 시간):
- DNS 조회에 소요되는 시간.
- 네트워크 속도에 직접적인 영향을 미치며, DNS 시간이 길어지면 웹 페이지 로딩 시간이 길어질 수 있음.
- Connect Time (연결 시간):
- 서버와의 연결 설정에 소요되는 시간.
- 연결 시간이 길어지면, 초기 연결 설정에 시간이 많이 걸려 전체 응답 속도가 느려질 수 있음.
- TLS Handshake Time (TLS 핸드셰이크 시간):
- TLS 핸드셰이크를 완료하는 데 소요되는 시간.
- 보안 연결을 설정하는 과정이기 때문에, 핸드셰이크 시간이 길어지면 데이터 전송 속도가 느려질 수 있음.
- Latency (대기 시간):
- 서버로부터 첫 번째 바이트를 받기까지의 시간.
- Latency가 높으면 네트워크 반응 속도가 느려질 수 있음.
- Speed (속도):
- 데이터 전송 속도를 바이트/초(B/s) 단위로 나타낸 것
데이터 사용량에 영향을 끼치는 항목들
- Requests (요청 데이터 크기):
- 클라이언트에서 서버로 전송된 총 요청 데이터의 크기.
- 요청 데이터 크기가 크면 네트워크 사용량이 증가.
- Responses (응답 데이터 크기):
- 서버에서 클라이언트로 전송된 총 응답 데이터의 크기.
- 응답 데이터 크기가 크면 네트워크 사용량이 증가.
- 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번 플로우 (메인)
2번 플로우 (셔터)
UINavigationController를 적용한 개선 버전에서는 view를 stack에서 제거함에 따라 메모리가 감소하는 것이 명백했다.
올리브영몰의 기존 버전의 경우 뒤로 가기를 실행해도 메모리가 감소하지 않았다.
현재 메모리 관리가 정상적으로 되지 않는다고 추측할 수 있겠으나,
올리브영몰 앱이 아닌, 올리브영몰 도메인 주소를 가져와 WKWebView로 띄운 커스텀 앱이라는 점,
올리브영몰의 내부 코드에 접근할 수 없다는 점,
위를 고려하여 확신을 할 수는 없겠다.
3번 플로우 (검색)
3. 한계 및 개선 방안
3.1. 한계
- 내부 코드에 접근할 수 없어 완벽한 UI Test를 진행하지 못한 점이 아쉽다.
- UINavigationController에서 viewController가 다수 쌓였을 때 캐싱 전략까지 나아가지 못 했다.
- Promise 객체를 적용한 양방향 통신을 적극 활용하지 못 했다.
3.2. 개선 방안
- WKWebView 환경에서 DOM 구조에 접근하는 방법을 더 고민할 것
- 스택 구조가 깊어지는 발생하는 메모리 문제를 해결할 것
- 양방향 통신을 적용할 만한 기능을 고려하고, 도입할 것
4. 결론
- UINavigationController를 적용하면 특정 플로우에서 네트워크 사용량을 32.4% 절감할 수 있다.
- 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://forums.developer.apple.com/forums/thread/699936
https://developer.apple.com/videos/play/wwdc2019/411/
https://techblog.gccompany.co.kr/charles-proxy-소개-4c4a3bbc8994
'Dev > Project' 카테고리의 다른 글
올리브영 iOS 앱 구조 개선 프로젝트 + UINavigationController (0) | 2024.05.20 |
---|