ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Swift로 효율적인 디버그 로깅 시스템 구축하기
    Swift 2025. 1. 6. 18:49

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 앱 개발 환경에서 효율적인 디버그 로깅 시스템 구축을 주제로 포스팅하려 합니다 🙋🏻

     

    개발 과정에서 로깅(logging)은 앱의 동작을 이해하고 디버깅하는데 필수적인 도구라 볼 수 있어요.

    그렇기에 이번 포스팅에서 Swift로 구현된 효율적인 디버그 로깅 시스템 구축을 해볼까 합니다!

     


    Swift로 효율적인 디버그 로깅 시스템 구축하기

    우선 해당 포스팅에서 가장 큰 주축이 되는 두 클래스 구현을 설계합니다.

     

    1️⃣ SwiftLog - 실제 로그 파일 관리와 쓰기를 담당하는 클래스

    2️⃣ DebugLogger - 로그 메시지 포맷팅과 로깅 인터페이스를 제공하는 클래스

     

    해당 클래스들로 만들어진 시스템은 다음과 같은 주요 특징을 가지게 됩니다.

     

    1️⃣ 파일 기반 로깅

    2️⃣ 로그 레벨 구분 (debug, warn, error)

    3️⃣ 자동 로그 로테이션

    4️⃣ 스레드 안전성

    5️⃣ 컴파일 타임 최적화

     

    이런 로깅 시스템을 구축하면 추후, 개발자뿐 아니라 QA 단계에서도 해당 로그 파일을 통해 이슈 제보를 하고 개발자는 그걸 통해 더 명확히 디버깅을 할 수 있는 장점이 있습니다 😃

     

    그럼 클래스들을 하나씩 알아볼까요?

     


    SwiftLog

    먼저 가장 기초가 되는 실제 로그 파일을 관리하고 로그를 쓰고 자동으로 용량에 따라 로그를 삭제하는 등의 클래스인 SwiftLog가 있습니다.

    해당 타입의 구현체는 daltoniam님이 만드신 SwiftLog 라이브러리가 존재하는데 심플하게 잘 구현되어 있어 이걸 활용해 봤습니다.

    라이브러리를 채택하기 보다 간단한 코드들이기에 해당 필요한 부분만 발췌해서 커스텀하게 적용시켜 봤어요!

     

     

     

    SwiftLog/Log.swift at master · daltoniam/SwiftLog

    Simple and easy logging in Swift. Contribute to daltoniam/SwiftLog development by creating an account on GitHub.

    github.com

     

    그럼 코드를 볼까요?

     

    import Foundation
    
    ///The log class containing all the needed methods
    open class SwiftLog {
      
      ///The max size a log file can be in Kilobytes. Default is 1024 (1 MB)
      open var maxFileSize: UInt64 = 10240
      
      ///The max number of log file that will be stored. Once this point is reached, the oldest file is deleted.
      open var maxFileCount = 8
      
      ///The directory in which the log files will be written
      open var directory = SwiftLog.defaultDirectory() {
        didSet {
          directory = NSString(string: directory).expandingTildeInPath
          
          let fileManager = FileManager.default
          if !fileManager.fileExists(atPath: directory) {
            do {
              try fileManager.createDirectory(atPath: directory, withIntermediateDirectories: true, attributes: nil)
            } catch {
              NSLog("Couldn't create directory at \(directory)")
            }
          }
        }
      }
      
      open var currentPath: String {
        return "\(directory)/\(logName(0))"
      }
      
      ///The name of the log files
      open var name = "logfile"
      
      ///Whether or not logging also prints to the console
      open var printToConsole = false
      
      ///logging singleton
      open class var logger: SwiftLog {
        struct Static {
          static let instance: SwiftLog = SwiftLog()
        }
        return Static.instance
      }
      
      ///write content to the current log file.
      open func write(_ text: String) {
        let path = currentPath
        let fileManager = FileManager.default
        if !fileManager.fileExists(atPath: path) {
          do {
            try "".write(toFile: path, atomically: true, encoding: String.Encoding.utf8)
          } catch _ {
          }
        }
        if let fileHandle = FileHandle(forWritingAtPath: path) {
          let writeText = "[\(text)\n"
          fileHandle.seekToEndOfFile()
          // swiftlint:disable force_unwrapping
          fileHandle.write(writeText.data(using: String.Encoding.utf8)!)
          // swiftlint:enable force_unwrapping
          fileHandle.closeFile()
          if printToConsole {
            print(writeText, terminator: "")
          }
          cleanup()
        }
      }
      ///do the checks and cleanup
      func cleanup() {
        let path = "\(directory)/\(logName(0))"
        let size = fileSize(path)
        let maxSize: UInt64 = maxFileSize * 1024
        if size > 0 && size >= maxSize && maxSize > 0 && maxFileCount > 0 {
          rename(0)
          //delete the oldest file
          let deletePath = "\(directory)/\(logName(maxFileCount))"
          let fileManager = FileManager.default
          do {
            try fileManager.removeItem(atPath: deletePath)
          } catch _ {
          }
        }
      }
      
      ///check the size of a file
      func fileSize(_ path: String) -> UInt64 {
        let fileManager = FileManager.default
        let attrs: NSDictionary? = try? fileManager.attributesOfItem(atPath: path) as NSDictionary
        if let dict = attrs {
          return dict.fileSize()
        }
        return 0
      }
      
      ///Recursive method call to rename log files
      func rename(_ index: Int) {
        let fileManager = FileManager.default
        let path = "\(directory)/\(logName(index))"
        let newPath = "\(directory)/\(logName(index + 1))"
        if fileManager.fileExists(atPath: newPath) {
          rename(index + 1)
        }
        do {
          try fileManager.moveItem(atPath: path, toPath: newPath)
        } catch _ {
        }
      }
      
      ///gets the log name
      func logName(_ num: Int) -> String {
        return "\(name)-\(num).log"
      }
      
      ///get the default log directory
      class func defaultDirectory() -> String {
        var path = ""
        let fileManager = FileManager.default
        #if os(iOS)
        let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
        path = "\(paths[0])/Logs"
        #elseif os(macOS)
        let urls = fileManager.urls(for: .libraryDirectory, in: .userDomainMask)
        if let url = urls.last {
          path = "\(url.path)/Logs"
        }
        #endif
        if !fileManager.fileExists(atPath: path) && path != ""  {
          do {
            try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil)
          } catch _ {
          }
        }
        return path
      }
    }

     

    코드를 보면 SwiftLog 클래스를 구현하고 있죠.

    파일 관리를 위해서 최대 파일 크기를 기본으로 10MB로 제한하고 있고 최대 파일 갯수 또한 8개로 제한하도록 명시했어요.

    이 값들은 물론 환경에 맞게 변경할 수 있습니다.

    그리고, 로그들이 쌓여서 최대 파일 갯수나 크기를 넘어서게 되면 이전 오래된 로그 파일들을 지워야 하기에 자동으로 로그 파일을 교체하는 기능도 들어가 있습니다.

    cleanup 메서드를 통해서 로그 파일 크기를 자동으로 관리하고 로그 교체들을 자동화 시키며 오래된 로그를 자동으로 삭제하는 로직을 담고 있습니다.

     

    기본 골자는 FileManager를 이용하여 파일의 이름을 디렉토리와 로그의 인덱스를 가지고 로그 파일을 write하는것이라 볼 수 있습니다.

    iOS와 macOS에 대한 플랫폼별 기본 로그 디렉토리도 설정하며 필요한 디렉토리도 자동으로 생성합니다.

     

    이 SwiftLog 클래스를 이용하여 이제 DebugLogger 클래스를 구축해볼 수 있습니다.

     


    DebugLogger

    코드부터 볼까요?

     

    import Foundation
    
    public let logger = DebugLogger()
    
    public final class DebugLogger: Sendable {
      private static let fileLogQueue = DispatchQueue(label: "com.green.app.file_log_queue")
      
      public init() { }
      
      public func debug(
        _ message: String = "",
        filePath: String = #file,
        funcName: String = #function,
        line: Int = #line
      ) {
        printLog(message, symbol: "🍀", filePath: filePath, funcName: funcName, line: line)
      }
      
      public func warn(
        _ message: String = "",
        filePath: String = #file,
        funcName: String = #function,
        line: Int = #line
      ) {
        printLog(message, symbol: "🚧", filePath: filePath, funcName: funcName, line: line)
      }
      
      public func error(
        _ message: String = "",
        filePath: String = #file,
        funcName: String = #function,
        line: Int = #line
      ) {
        printLog(message, symbol: "❌", filePath: filePath, funcName: funcName, line: line)
      }
      
      private func printLog(
        _ message: String,
        symbol: String,
        filePath: String,
        funcName: String,
        line: Int
      ) {
        #if DEBUG || QA
        let fileName = DebugLogger.sourceFileName(filePath: filePath)
        let formatter = DateFormatter.iso8601
        let str = "\(formatter.string(from: Date())) - [\(symbol)|\(fileName).\(funcName)-\(line)] - \(message)"
        print(str)
        
        DebugLogger.fileLogQueue.async {
          SwiftLog.logger.write(str)
        }
        #endif
      }
      
      public static func sourceFileName(filePath: String) -> String {
        let components = filePath.split(separator: "/")
        guard !components.isEmpty else { return "" }
        
        var fileName = components.last
        fileName = fileName?.split(separator: ".").first
        
        return String(fileName ?? "")
      }
    }

     

    DebugLogger에서 다루는 디버그 종류는 크게 세가지로 구분했습니다.

     

    1️⃣ debug - 일반적인 디버그 정보

    2️⃣ warn - 경고 디버그 정보

    3️⃣ error - 오류 디버그 정보

     

    물론 이 외 프로젝트 목적에 맞게 더 충분히 구분지어 생성할 수 있죠.

     

    기본적으로 모두 디버그 message를 담습니다.

    또한, 해당 디버그 메서드를 호출한 파일의 경로와 메서드 명, 라인 위치 등을 가져가요.

     

    모두 동일한 구조로 printLog를 호출하고 있습니다.

    해당 메서드에서 실제 로그 파일의 내용을 생성해줘요.

    해당 파일명은 날짜와 로그 종류에 따른 심볼 그리고 파일명, 메서드 명, 라인 위치와 마지막으로 로그 메시지를 가지고 파일을 내용을 만듭니다.

    그리고, 우리가 앞서 구현한 SwiftLog를 통해 wirte 시켜주는것이죠.

     

    여기서 하나 알아볼 수 있는것이 있어요!

     

    스레드 안전성을 위해서 디스패치 큐를 이용하여 파일 쓰기 작업의 스레드 안전성을 보장시키고 메인 스레드 블로킹을 방지해줍니다.

    또한, 실제 릴리즈 빌드에서는 로깅 코드를 제외하여 성능을 최적화 시켜줍니다.

    현재의 목적은 DEV, QA 환경에서 테스트 시 로깅을 위함이여서 제거했지만 필요 시 조건부 컴파일을 변경해도 좋습니다.

     

    그럼 실제 어떻게 사용되는지 예시를 볼까요?

     


    실제 로직에서 사용해보기

    func adTapped(_ adItem: AdDisplayItem) {
      let targetUrl = adItem.targetUrl ?? ""
      if let url = URL(string: targetUrl),
         UIApplication.shared.canOpenURL(url)
      {
        UIApplication.shared.open(url)
      } else {
        let logMessage: String = "Open Failed. TargetUrl: \(adItem.targetUrl)"
        
        logger.error(logMessage)
      }
    }

     

    해당 비지니스 로직 코드는 광고를 탭했을때 해당 광고의 URL을 여는 로직입니다.

    이때 URL이 정상적이라면 해당 URL을 열겠지만, URL이 비정상적이라 열 수 없다면 else 구문을 타죠.

    이때 에러에 대해서 로깅을 해줄 수 있습니다.

    단순히 전역 변수인 logger를 통해 error 메서드를 적절히 logMessage를 담아서 호출해주면 끝이죠.

    즉, 사용이 너무 간단합니다 😃

     

    그럼 어떻게 이런 로그 파일들은 기록될까요?

     

     

    이렇게 로그 파일들은 아래 경로에 자동으로 생성됩니다.

    /Users/<username>/Library/Developer/CoreSimulator/Devices/<Device-ID>/data/Containers/Data/Application/<App-UUID>/Documents/Logs/

     

    그리고 해당 로그 파일을 열어보면 아래와 같은 형식으로 로그 정보들이 기입되어 나타납니다.

     

    [2025-01-06T19:17:41.625+09:00 - [🍀|AppDelegate.application(_:willFinishLaunchingWithOptions:)-36] - [LIFE_CYCLE] willFinishLaunchingWithOptions
    [2025-01-06T19:17:41.627+09:00 - [🍀|UIApplication+Extension.clearLaunchScreenCache()-20] - clearLaunchScreenCache
    [2025-01-06T19:17:41.629+09:00 - [🍀|AppDelegate.application(_:didFinishLaunchingWithOptions:)-45] - [LIFE_CYCLE] didFinishLaunchingWithOptions: nil

     

    아주 상세하게 로깅을 심어 놓는다면 플로우에 따라 어떻게 앱을 사용하고 있는지도 명확하게 로깅을 해볼 수 있겠죠?

     


    마무리

    아주 단순한 설계지만 꼭 필요한 로깅 시스템이라고 생각합니다.

    더 효율적인 로깅 시스템 구축을 위해 이 포스팅이 어떻게 보면 초안 정도가 될 수 있을것 같아요!

     


    레퍼런스

     

    SwiftLog/Log.swift at master · daltoniam/SwiftLog

    Simple and easy logging in Swift. Contribute to daltoniam/SwiftLog development by creating an account on GitHub.

    github.com

Designed by Tistory.