티스토리 뷰
안녕하세요 지마켓 Mobile Application Team 강수진입니다.
오늘은 iOS에서 특정 이벤트에 대한 URL 요청이 정상적으로 이루어졌는지 확인하는 방법에 대해 알아보겠습니다.
들어가기 전에
모든 서비스에서 광고는 중요합니다. 왜냐하면 수익과 직결되기 때문이죠 💵💵
지마켓도 곳곳에 다양한 유형의 광고가 포함되어 있는데요! 일례로 사용자가 광고 상품을 클릭하면, 해당 이벤트가 광고 처리 시스템으로 전송되어 광고가 집계되고, 이에 따라 비용이 청구될 수 있습니다.
요구 사항
광고 트래킹은 수익과 직결되기 때문에 문제가 생기면 최우선 순위로 대응해야 하는 이슈 중 하나입니다.
그런데 코드 수정을 하다가 기존의 트래킹 코드가 동작을 안 하는 상황이 발생한다면요..?? 심지어 이런 데이터 트래킹의 이슈는 일반적인 QA로 발견되기 어려운데요...? 벌써 아찔하죠..? 🫠🫠
그렇다면 배포 전 테스트를 통해 '광고 트래킹 이벤트가 발생했을때, 해당 URL 요청을 제대로 보내고 있는지' 확인할 수 없을까요??
이번 프로젝트는 이러한 필요성으로부터 시작됩니다.
어떻게 하면 좋을까?
앞서 말했듯 '광고 이벤트가 발생하면, 광고 처리 시스템 URL 로 트래킹을 보낸다' 이것이 광고 시스템 처리에 대한 기본 전제입니다.
하지만 트리거 이벤트가 발생했을때 진짜로 광고 URL 요청을 보냈는지 확신할 수 있나요? 정말요?? 이 사실을 어떻게 검증할 수 있을까요?
기존에 트래킹 이벤트를 아래와 같이 sendTracking 함수에서 처리한다고 할때
protocol AdTracker {
func sendTracking()
}
struct AdTrackingClient: AdTracker {
func sendTracking() {
// URL Request 보냄
}
}
단순히 Fake 객체를 만들고, sendTracking 함수에서는 isTrackingSent 플래그를 변경하는 방식으로 트래킹이 되었는지 검증하면 될까요?
struct FakeAdTrackingClient: AdTracker {
var isTrackingSent = false
func sendTracking() {
isTrackingSent = true
}
}
이와 같은 방식은 sendTracking 함수가 호출되었는지 여부만 확인할 수 있을 뿐, 실제로 URLRequest를 통해 네트워크 요청이 이루어졌는지는 확인할 수 없습니다. 실제 AdTrackingClientAdTrackingClient의 sendTracking 함수에서는 print("hello world")만 구현되어 있을지도 모르는 일이죠!
따라서 저희는 실제 URL 요청을 캐치할 수 있는 proxy 를 만들어서 이벤트를 가로채기로 했습니다. 그리고 여기서 가로챈 URL 정보는 저장소에 별도로 저장해 둡니다.
광고 트래킹 이벤트를 트리거 시킬 때 URL 요청을 제대로 보낸다면, 해당 URL 정보는 proxy에 의해 저장소에 저장될 겁니다.
그럼 우리는 이벤트 트리거 후 URL 이 저장소에 있냐 / 아니냐에 따라서 이벤트에 따른 URL 요청 여부를 판단할 수 있습니다.
URL 요청 가로채기
그럼 먼저 URL 요청을 가로채는 proxy를 만들 거다!라고 했을 때, iOS에서는 실제로 어떻게 구현을 할 수 있을까요?
바로 URLProtocol 을 사용하면 됩니다. URLProtocol 은 네트워크 연결을 열고 요청을 작성하며 응답을 읽어오는 기본 작업을 수행합니다.
URLProtocol은 URL 로딩 시스템에 대한 확장성을 제공하기 위해 서브클래싱할 수 있도록 설계되었는데요, HTTPS와 같이 일반적인 프로토콜을 위한 서브클래스는 기본으로 제공됩니다.
하지만 우리가 원하는 건 일반적인 네트워크 요청이 아니죠! 따라서 URLProtocol 를 상속받는 subclass를 정의하고, startLoading() 등의 함수를 override 하여 요청을 가로챈 후 필요한 처리 및 임의의 응답을 반환할 수 있습니다.
final class CustomURLProtocol: URLProtocol {
// 요청을 시작할 때 호출되는 메서드
override func startLoading() {
// ✅ 이곳에서 요청을 가로채고, 저장소에 URL 저장 할 것
// URLProtocolClient 프로토콜을 통해 시스템에 진행 상황을 전달 가능
}
}
동작에 대한 좀 더 자세한 내용은 [WWDC18] Testing Tips & Tricks 영상, URLProtocol 문서, URL Loading System 문서를 참고하세요.
다시 본론으로 돌아와서, 우리가 원하는 동작을 달성하기 위해 아래 코드는 요청을 시작할 때 호출되는 메서드인 startLoading() 을 override 하여
- 저장소에 요청 URL을 저장하고
- 임의의 응답을 반환
했습니다.
// CustomURLProtocol.swift
// 요청을 시작할 때 호출되는 메서드
override func startLoading() {
guard let url = request.url else { return }
do {
// URL을 텍스트로 변환하여 저장소에 기록
try AdTrackingURLStore.writeDataToSharedFolder(text: url.absoluteString)
} catch {
// URL 기록에 실패했을 경우 오류 메시지 출력
print(error.localizedDescription)
}
// 네트워크 요청을 모방하여 임의의 응답을 생성
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
// 응답 객체를 클라이언트에게 전달
self.client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
// 요청 완료를 클라이언트에게 알림
self.client?.urlProtocolDidFinishLoading(self)
}
이 외에도 URLProtocol을 상속받는 subclass에서는 startLoading 외에도 필수로 구현해야 하는 몇 가지 함수가 있기 때문에 추가로 구현해 주었습니다.
final class CustomURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override func startLoading() {
....
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func stopLoading() {
}
}
이렇게 정의한 CustomURLProtocol 는 URLSessionConfiguration의 protocolClasses에 설정하면 됩니다. 물론 광고 트래킹을 보내는 용도의 URLSession 이 따로 정의되어있기 때문에 아래와 같은 코드 세팅이 가능했습니다.
let sessionConfiguration = URLSessionConfiguration.ephemeral
if isTest {
// 테스트 환경일때 CustomURLProtocol 에서 요청을 가로채서 처리
sessionConfiguration.protocolClasses = [CustomURLProtocol.self]
}
// 광고 트래킹 URLSession
let adTrackingSession = URLSession(configuration: sessionConfiguration)
그럼 아래와 같이 프록시 역할을 위해 정의된 CustomURLProtocol 에서는 URL 요청을 가로채고, 저장소에 URL을 저장합니다.
저장된 URL 불러오기 (feat. 어떤 저장소를 사용할까?)
이제 우리는 저장소에 광고 URL 이 제대로 저장되어 있는지 확인함으로써, 광고 이벤트에 따른 URL 요청이 보내졌는지 확인할 수 있습니다.
즉, CustomURLProtocol 에서 요청을 가로채고 URL을 저장해 두었다면
// CustomURLProtocol.swift
try AdTrackingURLStore.writeDataToSharedFolder(text: url.absoluteString) //저장소에 URL 저장
테스트 코드에서는 광고 트래킹 이벤트를 발생시킨 후, 아래와 같이 저장소에 원하는 URL 존재하는지 확인함으로써 URL 요청 여부를 판단할 수 있습니다.
// AdTrackingUITest.swift
let textCount = try AdTrackingURLStore.numberOfTextInSharedFolder(text: expectedURL) // 저장소에 특정 URL 존재 여부 확인
XCTAssertEqual(textCount, 1)
하지만 여기서 근본적인 의문이 드는데요,, 과연 우리는'어떤 저장소'를 사용하고 있느냐 대한 문제입니다.
iOS에서 데이터를 저장하는 방법은 여러 가지가 있습니다.
처음에는 단순히 아래와 같은 방식으로 하나의 객체에 url 저장소를 만든 후 (App target에서) 쓰기 / (UI Test target 에서) 읽기를 시도했습니다.
// App & UI Test Target 모두 포함
class AdTrackingURLStore {
static let shared: AdTrackingURLStore()
var storedUrls: [String] = []
}
// App Target
class ViewController: UIViewController {
func clickAdButton() {
AdTrackingURLStore.shared.storedUrls.append("adTrackingUrl")
}
}
// UI test target
class AdTrackingTest: XCTestCase {
func test_장바구니_클릭시_광고_클릭_url_저장() {
// 장바구니 클릭 tap
let isUrlStored = AdTrackingURLStore.shared.storedUrls.contains("adTrackingUrl")
XCTAssertTrue(isUrlStored)
}
}
하지만 UI Test Target에서 제대로 값을 불러오지 않는 문제가 발생하는데요! 그 이유는 User Interface Testing에서 엿볼 수 있듯, UI TestCode는 별도의 프로세스로 실행되기 때문입니다.
class AdTrackingTest: XCTestCase {
func test_장바구니_클릭시_광고_클릭_url_저장() {
// 장바구니 클릭 tap
let isUrlStored = AdTrackingURLStore.shared.storedUrls.contains("adTrackingUrl") // 💥 false
XCTAssertTrue(isUrlStored)
}
}
따라서 App 과 UI Test Target 양쪽에서 공통적으로 사용하는 데이터 저장소가 필요했습니다.
그리고 이를 File System을 통해 해결하기로 했는데요, SIMULATOR_SHARED_RESOURCES_DIRECTORY라는 환경 변수를 통해, 시뮬레이터의 공유 리소스 디렉터리 Path 얻고 접근할 수 있습니다. 이런 식으로 말이죠!
let simulatorSharedDir = ProcessInfo.processInfo.environment["SIMULATOR_SHARED_RESOURCES_DIRECTORY"]
// output
/Users/계정이름/Library/Developer/XCTestDevices/시뮬레이터-UDID/data
이렇게 가져온 공유 폴더 경로에 파일 경로까지 (ex. storedUrls.txt) 추가해 주면!? 우리가 read / write 할 파일의 경로가 완성됩니다.
private static func fileURL() throws -> URL {
// 공유 폴더의 URL을 가져옴
let folderURL = try sharedFolderURL()
// 파일 이름을 추가하여 최종 파일 경로 생성
let fileName = "storedUrls.txt"
return folderURL.appendingPathComponent(fileName)
}
그럼 아래와 같이 생성되어 있는 걸 확인할 수 있습니다.
/Users/계정이름/Library/Developer/XCTestDevices/시뮬레이터-UDID/data
물론 공유폴더/storedUrls.txt 처럼 폴더 하위에 바로 txt 파일을 생성하지 않고, 공유폴더/Library/Caches/ 와 같이 path를 더 붙임으로써 캐시 디렉토리 하위로 파일 경로를 지정할 수도 있습니다.
어찌 됐건 저장소를 시뮬레이터의 공유 폴더 하위 파일로 설정했기 때문에, App / UI Test 어느 타겟에서나 접근 가능해졌죠?!
해당 파일에 접근하여 읽고 쓸 수 있도록 AdTrackingURLStore 에는 아래와 같은 함수들이 포함되어있습니다.
참고로, 단순히 저장소 내 URL의 포함 여부를 확인하는 것이 아니라 URL의 개수를 반환하는 함수를 사용하는 이유는, 광고가 중복 적재되지 않아야 하는 상황에서 중복 적재되거나, 모듈 재노출 시 다시 적재되어야 하는 상황에서 적재되지 않는 등 에러 상황을 판단하기 위함입니다.
struct AdTrackingURLStore {
private static func fileURL() throws -> URL {
// 시뮬레이터 환경 변수에서 공유 리소스 디렉토리 경로를 가져오고, 공유 폴더 경로와 파일 이름 추가하여 최종 파일 경로 생성
}
static func writeDataToSharedFolder(text: String) throws {
// 파일에 입력된 text 쓰기
}
static func numberOfTextInSharedFolder(text: String) throws -> Int {
// 파일에서 데이터 읽어와서 특정 텍스트가 있는지 확인
}
static func clearFileInSharedFolder() throws {
// 파일 내용 삭제
}
}
광고 이벤트 트리거 하기
이제 광고 이벤트가 전송될 때, 해당 요청을 가로채어 저장소에 저장하고, 읽어낼 준비까지 모두 완료되었습니다.
그럼 이 모든 과정의 시작인, 광고 이벤트의 전송을 어떻게 해야 할까요? 개발자가 일일이 버튼 클릭, 스크롤 등의 트래킹 시나리오를 재현한 뒤, 저장소에 URL 이 제대로 기록되었나 확인해야 할까요?
사람이 일일이 테스트하는 방법은 당연히 말도 안 됩니다 🤢
잠깐 위에서도 언급했지만, 우리는 UITest를 통해 사용자의 광고 이벤트를 트리거하고, URL 요청을 잘 보냈는지 검증할 것입니다.
여기서 UnitTest가 아닌 UITest를 선택한 이유는, 실제 사용자 환경에서 광고 이벤트가 발생하는 흐름을 보다 정확하게 재현하고자 했기 때문입니다. 단순히 clickButton 과 같은 함수 호출을 검증하는 것이 아니라, 앱과 사용자가 상호작용하는 과정에서 이벤트가 정상적으로 처리되고, 필요한 URL 요청이 제대로 전송되는지를 확인하려는 의도입니다.
UITest 작성을 시작하기 전에 알아야 할 가장 중요한 점은 바로, UITest에서는 UI 요소를 찾기 위해 accessibilityIdentifier 를 사용하기 때문에, 상호 작용하려는 요소에 반드시 해당 값이 설정되어 있어야 한다는 것입니다.
지마켓 서비스도 접근성을 꾸준히 대응하고 있지만, 이를 위해 주로 accessibilityLabel 이 설정되어 있고, accessibilityIdentifier는 되어있지 않은 상황이었습니다.
따라서 큰 틀에서의 컨벤션은 아래와 같이 정하고 실제 테스트 코드 작성 시 어려움이 생긴다면 수정해 나가기로 결정했습니다.
- 자신의 identifier를 초기화 함수 등에서 자체적으로 설정할 수 있다면 타입 명을 따라간다 (ex. MyItemCell)
- UIView 등 기본 타입으로 정의되어 있다면 property 명을 따라간다 (ex. defaultBackgroundView)
- ViewController 기준으로 enum을 나눠 정의하되 공통적인 요소가 있다면 Common에 선언한다
이제 UITest에서는 설정한 identifier와 타입을 조합해 상위 요소부터 상호 작용하고자 하는 요소까지 찾아 들어가면 됩니다.
let element = app.otherElements[viewControllerIdentifier].collectionViews.cells[cellIdentifier].buttons[cartButtonIdentifier]
참고로 자주 element와 상호작용하기 위해 자주 사용한 함수로는 waitForExistence(timeout:), tap(), swipeLeft(), typeText() 등이 있는데요, 더 많은 내용은 XCUIElement 를 참고하세요.
이제 아래와 같이 자동으로 요소를 찾아서 클릭하고, 동작에 따라 url 이 잘 저장되었는지 검증하는 코드를 작성할 수 있습니다.
func test_MyCell에서_장바구니_버튼_클릭시_광고_url을_호출한다() throws {
// given
// ... 검색 결과 페이지까지 이동...
let clickExpectedURL = "광고 처리 시스템 url"
// when
let element = app.otherElements[viewControllerIdentifier].collectionViews.cells[cellIdentifier].buttons[cartButtonIdentifier]
element.tap()
// then
do {
let textCount = try AdTrackingURLStore.numberOfTextInSharedFolder(text: clickExpectedURL)
XCTAssertTrue(textCount == 1, "\(expectedURL) URL이 \(textCount)개 존재합니다.")
} catch {
XCTFail("URL을 찾을 수 없습니다.")
}
}
광고 데이터 세팅하기
이제 거의 다 왔습니다!
마지막으로 저희가 고민해야 할 사항은 테스트 시 어떤 데이터를 사용할 거냐,, 에 대한 부분입니다.
🙋🏻♀️ : 귀찮은데 그냥 이대로 돌리면 안돼여?
👩🏻💻 : 흠,, 그렇다는 건 네트워크를 통해 데이터를 동적으로 받아온다는 얘기인데,, 그럼 이런 상황에선 어떻게 하실 건가요?
- 네트워크 상황이 좋지 않아서 데이터를 불러오는데 시간이 오래 걸리고, 결과적으로 테스트 실행이 느려지거나 실패하는 경우는요?
- 상황에 따라 테스트가 필요한 모듈이 노출되지 않을 수도 있는데요?
- 동일한 모듈이라 하더라도, 일반 상품인지 / 광고 상품인지에 따라, 광고 처리 시스템 URL의 포함 여부가 달라질 수 있잖아요? 이때 URL 기록 결과로 성공 여부를 판단하고 있는 현재 상황에서는, 모듈에서 트래킹 이벤트를 처리하지 않아 실패할 가능성이 있을 뿐만 아니라, API에서 값이 전달되지 않아 테스트가 실패할 위험도 존재합니다. 즉, 상황에 따라 동일 모듈에 대한 테스트 결과가 달라집니다.
- 특정 모듈의 트래킹 이벤트 여부를 정확히 판단하기 위해, 단순히 테스트 성공 여부를 트래킹 이벤트 전송 여부로 판단하지 않고, 어떤 URL로 요청을 보냈는지의 값을 확인하고 있습니다. 그러나 데이터가 동적으로 들어오면 어떤 URL 값을 성공 기준으로 봐야 하는지 알 수 없습니다.
🤦🏻♀️ : 아 알겠어여;;; Fake 객체 만들어서 쓰면 되잖아요 ㅜ 근데..........어떻게 해요? ㅎㅎ
여기서 Protocol과 의존성 주입의 중요성이 등장합니다.
다음과 같이 protocol에 수행해야 할 동작을 명시하고, 네트워크 요청을 통해 결과를 받아오는 구현체와 JSON 파일을 로딩하여 결과를 받아오는 구현체를 각각 구분하여 작성할 수 있습니다.
protocol SearchResultRepository {
func fetchItemList(keyword: String) async -> [Item]
}
class SearchResultRepositoryImp: SearchResultRepository {
func fetchItemList(keyword: String) async -> [Item] {
// 네트워크 요청
}
}
class FakeSearchResultRepository: SearchResultRepository {
func fetchItemList(keyword: String) async -> [Item] {
// Items.json 로딩해서 리턴
}
}
이제 protocol을 사용하면, 함수를 호출하는 쪽에서 코드 변경 없이, 실제 환경과 UITest 환경에 따라 주입된 Concrete type의 동작을 사용할 수 있습니다.
private let searchResultRepository: any SearchResultRepository
init(searchResultRepository: any SearchResultRepository) {
self.searchResultRepository = searchResultRepository
}
func search(keyword: String) async {
let items = await searchResultRepository.fetchItemList(keyword: keyword)
}
물론, 실제로는 모든 코드가 예시처럼 정석적으로 분리되어 있진 않았지만, 주입을 통해 구현체를 교체한다는 큰 개념은 차용했습니다.
이제 모든 게 해결된 걸까요? 일반적인 상황에서는 그럴 수 있겠지만, 저희의 경우 테스트 케이스마다 테스트할 모듈을 다르게 세팅해야 했습니다. 즉, 상황에 따라 사용해야 하는 JSON 파일이 각기 달랐습니다.
class MockSearchResultRepository: SearchResultRepository {
func fetchItemList(keyword: String) async -> [Item] {
// case 1. A 모듈.json 로딩해서 리턴
// case 2. B 모듈.json 로딩해서 리턴
// case 3. C 모듈.json 로딩해서 리턴
}
}
이를 해결하기 위해 XCUIApplication의 launchEnvironment 프로퍼티를 사용했는데요! 얘가 뭔고,, 하니 말 그대로 앱 실행 시에 전달되는 환경 변수로 key - value 쌍을 지정할 수 있습니다. 이렇게 말이죠!
// UITest target
class AdTrackingTest: XCTestCase {
func test_모듈_A_장바구니_클릭시_광고_클릭_url_저장() {
// ✅ 1. 세팅할 Fake resonse 지정
let environmentKey = "FakeSearchResultResponse"
app.launchEnvironment[environmentKey] = "ModuleA.json"
// 2. 장바구니 클릭 tap
// 3. url 저장 검증
}
}
그럼 앱에서는 processInfo 에서 동일한 key 값을 이용해 값을 불러올 수 있습니다.
// App target
let environmentKey = "FakeSearchResultResponse"
let fakeResponseFileName = ProcessInfo.processInfo.environment[environmentKey] // "ModuleA.json"
물론 실제 코드는 좀 더 리팩토링 되어있긴 하지만,, 큰 개념은 아시겠죠?!
이제 테스트 코드를 돌려보면~~?! 완성되었습니다~
마무리
지금까지 iOS에서 특정 이벤트에 대한 URL 요청이 정상적으로 이루어졌는지 확인하는 방법에 대해 알아보았는데요!
전체적인 구조와 각 단계에서 사용된 주요 개념은 아래 그림과 같이 정리할 수 있습니다.
- 먼저, 테스트 환경에 따라서 세팅된 가짜 json 데이터가 로드됩니다
- 다음으로 UITest를 통해서 버튼 클릭과 같은, 사용자의 광고 이벤트를 트리거합니다. 이렇게 URL 요청이 나가면,
- proxy 가 이를 가로채고
- 공유 디렉토리 저장소에 URL을 저장합니다
- 마지막으로, 이 URL 이 저장소에 있냐 아니냐에 따라서 이벤트에 따른 URL 요청 여부를 판단합니다.
여기까지 읽으신 분들 중에 혹시 '🤔 엥,, 테스트 그렇게 하는 거 아닌데용,,' 하는 분들 계신가요?! 그렇다면 언제든 댓글로 의견 나눠주시면 압도적 감사,, 하겠습니다 ㅎㅎ
그럼 테스트와 함께 행복한 2025년 되세요! 그럼 20000! 🎄🎄🎄
'Mobile' 카테고리의 다른 글
jcenter, 이제 문 닫습니다 (3) | 2024.07.17 |
---|---|
statements 가 있는 switch/when 구문 deep dive (feat. bytecode) (0) | 2023.10.25 |
Xcode 15 의 iOS 17 빌드에서 User Agent 가 원하는 값으로 설정되지 않을때 (feat. iOS 버전별 WebKit 버전과 작업 내역 확인하기) (2) | 2023.10.19 |
OS 10 이하에서 Backstack 에 Activity 가 중첩되어 쌓이는 이슈 (0) | 2023.07.05 |
DiffUtil 이해하기 (0) | 2023.05.17 |