target 이상이면서 가장 작은 조합을 구하고, 조합의 합과 target의 차이를 return 하라.
접근
N = 20이므로, 백트래킹으로 풀이하면 2 ** 20 ~= 1,000,000 정도일 것이다. 총합이 target 이상이 되면, target과의 차이를 현재 result 값과 비교하여 갱신한다.
모든 원소에 대하여 (추가하거나 / 추가하지 않거나)의 경우를 고려하고, 모든 원소를 탐색하면 result를 갱신한다.
코드
def solve(i, total):
global n, target, res
if i == n:
if total >= target:
res = min(res, total - target)
return
solve(i + 1, total + heights[i])
solve(i + 1, total)
T = int(input())
for tc in range(T):
n, target = map(int, input().split())
heights = list(map(int, input().split()))
res = 1e9
solve(0, 0)
print(f'#{tc + 1} {res}')
간단한 코드이지만, 2 * n개의 경우의 수를 고려하는 백트래킹 문제의 뼈대 코드인 듯하다.
시작점부터 도착점까지 가장 빠른 길을 찾아야 한다. -> BFS 섬(1)은 지나갈 수 없다. 소용돌이(2)는 지나갈 수 없다. 다만 한 번 통과한 경우 지나갈 수 있다. 소용돌이(2)는 0초부터 시작해서 2초 동안 유지되고 1초 동안 잠잠해지는 패턴을 반복한다.
접근
BFS 탐색을 하다가 소용돌이를 만났을 때는 판단을 해야 한다. 방문한 위치인 경우 -> 지날 수 있다 방문하지 않은 위치인 경우 time % 3 == 2인 경우, 지날 수 있다 이때 2를 0으로 바꿔야 한다. 한 번 방문한 뒤에는 언제든 지나갈 수 있기 때문에. 나머지의 경우, 지날 수 없다.
BFS 탐색을 하는 경우 큐에는 (x, y, time)이 담겨야 한다. 그래야 최단 경로를 판단할 수 있기 때문에 visited 배열은 (x, y, time)의 원소가 완벽하게 같은 경우 걸러낸다.
4방향 탐색이 아니라, 가만히 있기를 포함한 5방향 탐색이기 때문에, 방문 여부를 결정하기 위해 time이 필요하다.
코드
from collections import deque
dx = [1, -1, 0, 0, 0]
dy = [0, 0, 1, -1, 0]
def bfs(a, b, time):
q = deque()
q.append((a, b, time))
visited = []
visited.append((a, b, time))
while q:
x, y, t = q.popleft()
if x == ex and y == ey:
return t
for i in range(4):
nx = x + dx[i]
ny = y + dy[i]
if nx < 0 or nx >= n or ny < 0 or ny >= n:
continue
if matrix[nx][ny] == 1:
continue
if matrix[nx][ny] == 2:
if t % 3 == 2:
matrix[nx][ny] = 0
q.append((nx, ny, t + 1))
visited.append((nx, ny, t + 1))
else:
continue
if (nx, ny, t + 1) not in visited:
q.append((nx, ny, t + 1))
visited.append((nx, ny, t + 1))
testCase = int(input())
for tc in range(testCase):
n = int(input())
matrix = [list(map(int, input().split())) for _ in range(n)]
sx, sy = map(int, input().split())
ex, ey = map(int, input().split())
result = bfs(sx, sy, 0)
print(f"#{tc + 1} {result}")
swea에서 해당 문제의 python 제출을 허용하지 않아, 정확한 정답 여부는 알 수 없다.
코테에서 유사한 문제를 풀었던 경험이 있어 어렵지 않게 풀었다.
"제자리에 있기"를 탐색 방향에 포함하는 것이 까다로운데, 시간까지 포함한 visited 배열로 관리하면 풀이할 수 있다.
from collections import deque
dx = [1, 1, -1, -1, 1, -1, 0, 0]
dy = [1, -1, 1, -1, 0, 0, 1, -1]
def openMatrix(x, y):
tmp = 0
for i in range(8):
nx = x + dx[i]
ny = y + dy[i]
if nx < 0 or nx >= n or ny < 0 or ny >= n:
continue
if matrix[nx][ny] == "*":
tmp += 1
matrix[x][y] = tmp
def clickZeroElement(a, b):
q = deque()
q.append([a, b])
matrix[a][b] = "*"
while q:
x, y = q.popleft()
for i in range(8):
nx = x + dx[i]
ny = y + dy[i]
if nx < 0 or nx >= n or ny < 0 or ny >= n:
continue
if matrix[nx][ny] == 0:
q.append([nx, ny])
matrix[nx][ny] = "*"
elif matrix[nx][ny] != "*":
matrix[nx][ny] = "*"
testCase = int(input())
for tc in range(testCase):
n = int(input())
matrix = [list(input()) for _ in range(n)]
total = 0
for i in range(n):
for j in range(n):
if matrix[i][j] == ".":
openMatrix(i, j)
for i in range(n):
for j in range(n):
if matrix[i][j] == 0:
clickZeroElement(i, j)
total += 1
for i in range(n):
for j in range(n):
if matrix[i][j] != "*":
total += 1
print(f"#{tc + 1} {total}")
전형적인 삼성 스타일의 문제인 듯하다.
그래도 아주 어렵지는 않다.
클릭 수가 최소가 되게 하는 방법을 고민하다보면, 완전 탐색으로는 불가능하다는 것을 알게 된다.
A non-empty array A consisting of N integers is given.
A pair of integers (P, Q), such that 0 ≤ P ≤ Q < N, is called a slice of array A.
The sum of a slice (P, Q) is the total of A[P] + A[P+1] + ... + A[Q].
Write a function:
def solution(A)
that, given an array A consisting of N integers, returns the maximum sum of any slice of A.
For example, given array A such that:
A[0] = 3 A[1] = 2 A[2] = -6
A[3] = 4 A[4] = 0
the function should return 5 because:
(3, 4) is a slice of A that has sum 4,
(2, 2) is a slice of A that has sum −6,
(0, 1) is a slice of A that has sum 5,
no other slice of A has sum greater than (0, 1).
Write an efficient algorithm for the following assumptions:
N is an integer within the range [1..1,000,000];
each element of array A is an integer within the range [−1,000,000..1,000,000];
the result will be an integer within the range [−2,147,483,648..2,147,483,647].
문제 분석
Maximum slice 유형.
번역을 한다면 '배열에서 최대가 되는 부분합' 정도가 되겠다.
배열의 길이가 1,000,000 이하이므로 반드시 O(N) 이하의 시간복잡도로 풀어야 함.
배열의 원소의 범위는 [-1,000,000 ... -1,000,000] 이다.
Codility의 교육 자료에 있는 O(N)으로 풀이하려 했으나, 추가적으로 원소가 음수인 경우를 처리해야 한다.
풀이
def solution(A):
max_ending = max_slice = A[0]
for i in range(1, len(A)):
max_ending = max(A[i], max_ending + A[i])
max_slice = max(max_ending, max_slice)
return max_slice
max_ending과 max_slice를 명확히 하고 넘어가야 이 간단한 코드를 이해할 수 있다.
max_ending: 현재 인덱스까지의 부분 배열중에서 가장 큰 합
max_slice: 현재까지 찾은 모든 부분 배열 중에서 가장 큰 합
좀 더 설명해 보자면,
max_ending은 반복문을 실행하는 각 단계에서, 현재 부분 배열(지금 배열이 최선이라고 가정하고 계산 중인)의 최대 합을 계산한다.
max_slice은 모든 max_ending을 비교하여(실제로는 모든 값을 나열하고 비교하지 않는다. 하지만 모든 max_ending은 max_slice에 게 도전하게 된다.) 최대 합을 계산한다.
한 줄씩 이해해 보자.
`max_ending = max_slice = A[0]`
초기화 하는 부분이다.
배열의 첫 번째 원소로 초기화 한다.
0으로 초기화를 하면, 모든 원소가 음수인 경우 0을 반환하는 문제가 생긴다.
`for i in range(1, len(A)):`
첫 번째 원소로 초기화 했으니, 첫 번째 원소는 건너뛴다.
`max_ending = max(A[i], max_ending + A[i])`
가장 고민을 많이 한 부분이다.
max_ending을 갱신할 때 무엇과 비교해야 하는가?
max() 함수의 두 번째 인자는 현재 부분 배열에 새로운 값을 더한 것이다. 자연스럽다.
def solution(A):
result = 0
min_value = 200001
for i in range(len(A)):
if min_value < A[i]: # 0 <= P <= Q < N일 때, A[Q] - A[P]가 양수인 경우만 판단. 음수는 어차피 0으로 반환해야 함.
result = max(result, A[i] - min_value)
min_value = min(min_value, A[i]) # 계산 이후 최솟값 갱신.
return result
++ 여기에서 소개한 방법이 절대적이지 않을 수 있다. 어디까지나 내부 코드에 접근이 불가능한 상황에서 판단했기 때문이다.
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포인트 & 주문배송조회 & 배송지 관리 등
MPA: 전체 페이지를 새롭게 로드
SSR: 자바스크립트 사용을 중지해도 완전한 페이지를 로드함. 동적으로 변경될 요소가 있으나, 유저 별로 캐싱해두는 듯. 거의 SSG와 동작이 비슷하나, Next.js를 사용하지 않는 것 같아 SSR이라고 판단.
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. 검색 결과 페이지
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)
}
...
웹 -> 앱 -> 웹을 거쳐서 웹 콘솔로 수신한 메시지 "Response for false"
양방향 통신은 일반적으로 복잡한 문제에 주로 쓰이는 듯하다.
사용자 인증
위치 정보 요청
결제 처리
푸시 알림 설정
나의 프로젝트의 경우에는 단방향 통신으로도 문제를 해결할 수 있었기에 적용하지는 않았다.
다만 1차 면접 과정에서 피드백을 받았던 부분이기에, 현업에서 자주 쓰이겠구나 싶어서 예제로 진행해보았다.
1.2.3. UI Test 코드 작성 및 테스트 진행
UI Test를 자동화하여 진행 중인 모습
1.2.3.1. 배경
WKWebView의 콘텐츠는 기본적으로 네이티브 뷰 계층 구조에 노출되지 않기 때문에, 일반적인 UI Test의 적용이 어려웠다.
요소 선택의 기준이 되는 `.accessbilityIdentifier`를 설정할 수 없다.
Recording이 정상적으로 작동하지 않는다.
이런 이유로 1.0 버전에서는 수동으로 테스트를 진행했지만, 이번에는 자동화된 UI Test를 진행할 수 있었다.
import UIKit
import WebKit
class ViewController: UIViewController {
var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
// WKWebViewConfiguration 생성 및 설정
let webConfiguration = WKWebViewConfiguration()
// WKWebView 초기화
webView = WKWebView(frame: .zero, configuration: webConfiguration)
// Safari 웹 검사기로 분석 가능하도록 설정
webView.isInspectable = true
// 웹 뷰를 뷰 컨트롤러의 뷰에 추가
view.addSubview(webView)
// 웹 뷰의 오토레이아웃 설정
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
webView.topAnchor.constraint(equalTo: view.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// 웹 페이지 로드
if let url = URL(string: "https://www.example.com") {
let request = URLRequest(url: url)
webView.load(request)
}
}
}
extension ViewController: WKNavigationDelegate, WKUIDelegate {
// 필요한 델리게이트 메소드 구현
}
생성된 webView의 인스턴스에 `.isInpectable = true`로 설정한다.
이것을 설정하지 않으면 웹 검사기가 앱을 감지하지 못 한다.
2.2. Xcode 빌드 설정
실행 중인 앱을 선택 → `Edit Scheme...` 선택
`Run`→ `Info` → `Build Configuration` → `Debug` 선택
네비게이션을 통해 탐색을 할 때, 현재 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:):`