오랜만에 UIKit을 다시 써보면서 새로운 개념들도 알아가고 있고 소마에서 배웠던 기술들도 적용해보고 있습니다. 이러한 것들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
로깅도 많이 알려진 개념 중 하나입니다. 간단하게 말하자면 특정 이벤트에 대한 기록이라고 할 수 있는 로그를 저장하는 것입니다.
그렇다면 여러가지 의문점이 있을 수 있는데, 크게 아래와 같을 것입니다.
참고로 모바일 앱을 기준으로 설명드리고자 합니다.
로그를 남기는 대상은 사실 도메인에 따라 정의하기 나름이라고 생각합니다. 하지만 주로 앱에서 일어나는 상호작용 관련 이벤트에 대해 로그를 남긴다고 알고 있습니다.
상호작용 이벤트라는 것은 사용자가 특정 버튼을 클릭하는 동작일 수도 있고, 더 나아가 특정 화면이 나타난다거나, 특정 API가 호출된다거나 하는 것도 포함될 수 있습니다.
로그를 남기는 이유에는 여러가지가 있을 수 있습니다.
우선 로그를 통해 사용자들의 행동 패턴을 파악하고 앱을 개선할 수 있습니다.
예를 들어, A화면에서 B화면으로 이동할 수 있고, 해당 회사는 B화면을 정말 열심히 만들었다고 가정하겠습니다. 다만 로그를 보니 사용자들이 실제로는 A화면에만 많이 머무르는 것입니다. 그렇다면 B화면으로 이동하는 버튼을 크게 만드는 등 앱을 개선할 수 있을 것입니다.
또한, 에러가 발생했을 경우, 어떠한 경로를 통해 에러가 났는지 파악하고 해결하는데 도움이 될 수 있씁니다.
예를 들어, 많은 사람들이 에러가 난 것을 발견했는데, 해당 사람들의 로그를 보니 공통적으로 B -> A -> B 화면으로 이동한 것이었습니다. 그렇다면 이러한 순서로 화면 이동했을 때 영향을 주는 코드를 집중적으로 디버깅함으로써 에러 해결에 도움이 될 수 있습니다.
마지막으로 MAU(Monthly Active Users)와 같은 특정 지표를 계산해낼 수도 있습니다.
예를 들어, 어떤 앱에서 "사용자가 Active 하다" 라는 것은 한달에 B 화면을 다섯번 이상 방문했을 때라는 기준을 세웠다고 가정하겠습니다. 그렇다면 로그를 통해 Active한 유저와 InActive한 유저를 구분하고 MAU를 계산해낼 수도 있습니다.
단순히 "이벤트가 발생했다!" 라는 것을 기록하는 것만으로는 로깅의 목적들을 달성하기 어려울 수 있습니다.
앞서 로깅이 앱을 개선하는데 도움이 될 수 있다고 했으며, 이것에 대한 예시로 B화면이 나타나는 빈도가 적은 것을 파악하고, B화면 이동 버튼을 크게 만들었다고 했습니다.
이어서 얘기하자면, 그럼에도 여전히 몇몇 사용자들은 B화면에 방문하지 않는 것을 발견했습니다. 알고 보니 이동 버튼 근처에 광고 블럭이 있었는데, 광고 내용이 강렬했기 때문이었습니다.
쓰다 보니 예시가 조금 이상해진 것 같긴한데, 결론은 해당 이벤트를 발생시키는 것에 영향을 줄 수 있는 요소들도 모두 기록해야 한다는 것입니다. 위 예시같은 경우는 B화면 이동 이벤트와 함께 광고 내용도 함께 로그로 저장해야할 것입니다.
이렇게 동작에 영향을 줄 수 있는 요소에는 아래와 같은 것들이 있습니다.
실제로 코드로는 어떻게 구현했는지 설명드리겠습니다. 프로젝트 전체 보기
참고로 클린아키텍처를 사용했습니다.
우선 로그 정보를 담을 수 있는 구조체를 만들어야겠죠?
저같은 경우 로그에 담을 정보를 공통요소와 개별요소로 구분하고 프로토콜을 통해 이를 정의하도록 했습니다. 공통요소는 각 스키마 구조체에 프로퍼티로 정의되어있고, 개별요소는 Array<(String, String)> 에 원하는 만큼 담았습니다.
(App Version, OS 등은 다른 곳에 정의되어있습니다.)
공통요소
- Time, App Version, OS, Log Version, Event, View
개별요소
- 상품 탭 이벤트: Product Name, Product Price, Product Position, Product Index
개별요소는 앞서 말했듯 해당 화면에서 이벤트에 영향을 줄 수 있는 요소이며, 상품을 탭할 때는 상품 이름, 가격, 위치가 영향을 준다고 생각했습니다.
// 로그 스키마들이 준수하는 프로토콜
public protocol LoggingSchemeVO {
// --- 모든 로그에 공통적으로 들어갈 요소 (값은 다를 수 있음) ---
var logVersion: Float { get }
var eventName: String { get }
var screenName: String { get }
// --- 로그마다 특화된 값을 저장해놓는 배열 (딕셔너리도 가능, 하지만 그렇게 하면 순서 보장 안됨) ---
var logData: Array<(String, String)> { get set }
}
// 특정 상품 클릭 이벤트에 대한 로그 스키마
struct ShoppingProductTapped: LoggingSchemeVO {
let logVersion: Float = 1.0
let eventName: String = "Product Tapped"
let screenName: String = "ShoppingList"
var logData: Array<(String, String)> = []
private init(productName: String?, productPrice: Int?, productPosition: String?, productIndex: Int?) {
if let productName = productName {
logData.append(("productName", productName))
}
if let productPrice = productPrice {
logData.append(("productPrice", String(productPrice)))
}
if let productPosition = productPosition {
logData.append(("productPosition", productPosition))
}
if let productIndex = productIndex {
logData.append(("productIndex", String(productIndex)))
}
}
final class Builder {
private var productName: String?
private var productPrice: Int?
private var productPosition: String?
private var productIndex: Int?
func setProductName(_ productName: String) -> Builder {
self.productName = productName
return self
}
func setProductPrice(_ productPrice: Int) -> Builder {
self.productPrice = productPrice
return self
}
func setProductPosition(_ productPosition: String) -> Builder {
self.productPosition = productPosition
return self
}
func setProductIndex(_ productIndex: Int) -> Builder {
self.productIndex = productIndex
return self
}
func build() -> LoggingSchemeVO {
return ShoppingProductTapped(
productName: productName,
productPrice: productPrice,
productPosition: productPosition,
productIndex: productIndex
)
}
}
}
// 실제 로그 객체를 만들어 내는 곳
final public class DefaultLoggingUsecase: LoggingUsecase {
...
public func loggingProductTapped(productName: String, productPrice: Int, productPosition: String, productIndex: Int) {
let scheme = ShoppingProductTapped.Builder()
.setProductName(productName)
.setProductPrice(productPrice)
.setProductPosition(productPosition)
.setProductIndex(productIndex)
.build()
loggingRepository.shotLog(scheme)
}
}
특이한 점은 Builder 패턴을 사용했다는 것인데, 사실 그냥 단순한 구조체로 해도 크게 상관은 없습니다. 다만 저는 로그에 담기는 정보는 자주 바뀔 수 있는 것이라고 생각했고, 만약 빌더 패턴으로 구현한다면 사용되는 프로퍼티를 쉽게 바꿀 수 있습니다.
// 만약 Name과 Price만으로도 정보가 충분해졌다면, 이렇게만 바꾸면 됩니다.
let scheme = ShoppingProductTapped.Builder()
.setProductName(productName)
.setProductPrice(productPrice)
.build()
Builder 패턴에 대한 글은 아니기에 자세한 설명은 넘어가겠습니다.
보통은 로그를 JSON 형태로 서버로 보내서 저장하는데, 해당 프로젝트는 서버가 없었기에 로컬 CSV 파일에 저장하게 됐습니다. 그래서 우선 CSV 파일에 저장할 수 있는 형태로 변환하는 함수를 구현하고 Repository에서 호출했습니다.
public struct LoggingSchemeDTO {
let scheme: String
}
extension LoggingSchemeVO {
// CSV 파일에 저장할 수 있는 형태로 바꾸기
func toDTO() -> LoggingSchemeDTO {
// 일련의 과정의 통해 "1, 17.0.1, 1.0, Product Tapped, ..." 형태로 변환
...
return LoggingSchemeDTO(scheme: logString)
}
}
그러고 나서 아래 Logger의 shotLog 함수를 호출하기만 하면 CSV 파일에 저장할 수 있습니다.
아까 스키마에서 안보이던 프로퍼티들은 여기에 있는데, App Version, OS는 앱이 초기화되면 모든 곳에 동일하게 적용되므로, Logger가 생성될 때 값을 가져왔습니다. 그리고 Time은 로그를 쏘는 시점에 계산해야하므로 shotLog 함수 안에서 현재 시간을 가져왔습니다.
shotLog 함수는 복잡해보이지만 결국엔 단순히 CSV 파일에 데이터를 기록하는 로직입니다.
final class Logger {
// --- 모든 로그에 공통적으로 들어갈 요소 (값도 모두 똑같음) ---
private let appVersion: String = {
if let info = Bundle.main.infoDictionary,
let buildNumber = info["CFBundleVersion"] as? String {
return buildNumber
}
return "nil"
}()
private let os: String = {
let os = ProcessInfo.processInfo.operatingSystemVersion
return String(os.majorVersion) + "." + String(os.minorVersion) + "." + String(os.patchVersion)
}()
// --- CSV 파일에 로그 저장할 때 필요한 기타 변수들 ---
private let csvFileName = "shoppingSearchAppLog.csv"
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return dateFormatter
}()
private let fileManager = FileManager.default
private var filePath: URL {
let documentPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
let filePath = documentPath.appendingPathComponent(csvFileName)
return filePath
}
func shotLog(_ scheme: String) {
let time = dateFormatter.string(from: Date())
let schemeWithAppInfo = "\(time),\(appVersion),\(os)," + scheme
if fileManager.fileExists(atPath: filePath.path) {
// 파일이 이미 존재한다면 기존 텍스트의 다음줄부터 작성하기
guard let log = schemeWithAppInfo.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: filePath) {
handle.seekToEndOfFile()
handle.write(log)
handle.closeFile()
}
} else {
// 파일이 없다면 헤더 포함해서 새로 만들기
let header = "Time,AppVersion,OS,LogVersion,Event,View,Others\n"
guard let log = (header + schemeWithAppInfo).data(using: .utf8) else { return }
try? log.write(to: filePath)
}
}
}
이상으로 Logging에 대해 알아봤습니다. 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊
모든 포스팅 잘 읽었습니다! 너무 유용해서 두고두고 읽겠습니다.. 감사합니다🥹