ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Crash 감지하고 다루기
    iOS 2025. 3. 29. 09:16

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

    이번 포스팅에서는 뭔가 개념적으로 각잡힌 정보 전달의 목적보다 제가 삽질하면서 그냥 코드로 나타내본 내용을 전달해볼까해요!

     

    주제는, iOS App Crash를 감지하고 이를 다루기 위한 방법이라고 볼 수 있습니다.

    앞서 말했듯이 삽질 코드라서 부족한 부분이 있을수도 있고 논리적으로 안맞는 부분도 있을 수 있지만 그래도 주제 자체에서는 삽질을 완료하고 검증한 상태라 볼 수 있어요 😃

     

    그럼 바로 들어가볼까요?

     


    Crash?

    앱에서는 의도치 않은 crash가 충분히 발생할 수도 있죠.

    메모리 부족 이슈라던지, 뷰 바인딩이 제대로 안되어 있다던지 아니면 의도적으로 fatalError 등 크래쉬를 내는 환경이라던지 너무 무수히도 많은 앱 크래쉬 현상이 발생할 수 있습니다.

     

    물론, 이를 최대한 막는 방법으로 앱을 개발해야 하겠지만, 사실상 디바이스의 문제로 발생할 수도 있는 크래쉬를 우리가 다 컨트롤할 수는 없잖아요?

     

    그렇기에, 크래쉬가 났을때 어떻게 대처할까도 한번 생각해봐야 합니다 🤔

     

    상황을 하나 가정해볼께요.

     

    앱이 처음 런치될때 어떠한 로컬 스토리지 값을 이용하여 로직을 수행한다고 해볼께요.

    로직이 수행되어야 다음 화면으로 넘어갈 수 있다고 가정합니다.

    그런데 이 값 자체에 뭔가 문제가 있어 런치 후 로직을 탈 수 없는 환경이라면 사실상 해당 사용자는 앱을 삭제하고 다시 재설치 하기 전까지 계속 앱 사용에 지장이 있을거에요.

    왜냐면 계속 크래쉬가 나는 환경일테니까요 😭

     

    그랬을때 이 크래쉬를 감지해서 어떠한 일련의 작업을 해준다면 문제를 발생시키지 않겠죠?

     

    그것에 대해 다뤄보려고 하는겁니다 😁

     

    또한, 어떤 일련의 작업을 안해주더라도 크래쉬를 감지하고 로그를 저장하고 다루는것도 꼭 필요하다고 생각해요.

     

    그럼 이제 설명은 끝났고 코드로만 얘기해볼께요!

     


    Implementation Code

    먼저 크래쉬를 다룰 CrashHandler를 만들어보겠습니다 🙋🏻

     


    CrashHandler

    import Foundation
    
    class CrashHandler {
      static let shared = CrashHandler()
      
      private init() {}
      
      // static 함수로 예외 처리기 정의
      private static var exceptionHandler: @convention(c) (NSException) -> Void = { exception in
        // CrashHandler 인스턴스에서 실제 처리를 담당
        CrashHandler.shared.handleException(exception)
      }
      
      func setup() {
        // 이전 핸들러 저장
        let previousHandler = NSGetUncaughtExceptionHandler()
        
        // C 스타일의 핸들러 등록
        NSSetUncaughtExceptionHandler(CrashHandler.exceptionHandler)
        
        // 이전 핸들러 호출
        if let previous = previousHandler {
          // 예외를 기존 핸들러에 전달
          CrashHandler.shared.handlePreviousHandler(previous)
        }
        
        // Signal 핸들러 등록
        setupSignalHandler()
      }
      
      private func handleException(_ exception: NSException) {
        // 예외 정보 수집
        let name = exception.name
        let reason = exception.reason ?? "알 수 없는 이유"
        let callStack = exception.callStackSymbols
        
        // 로그 작성
        let logMessage = """
              ===== 처리되지 않은 예외 발생 =====
              시간: \(Date())
              이름: \(name)
              이유: \(reason)
              호출 스택:
              \(callStack.joined(separator: "\n"))
              ============================
              """
        
        // 로그 파일에 저장
        saveLogToFile(log: logMessage)
        
        // 앱 크래시 플래그 설정 (다음 실행 시 확인용)
        UserDefaults.standard.set(true, forKey: "AppCrashed")
        UserDefaults.standard.set(logMessage, forKey: "LastCrashLog")
      }
      
      private func handlePreviousHandler(_ previousHandler: @escaping (NSException) -> Void) {
          // 이전 핸들러 호출을 위한 예외 생성
          let exception = NSException(name: .genericException, reason: "Previous Handler Call", userInfo: nil)
          
          // 이전 핸들러 호출
          previousHandler(exception)
      }
    
      private func setupSignalHandler() {
        // Signal 핸들러 설정
        signal(SIGABRT) { _ in CrashHandler.shared.handleSignal(signal: SIGABRT) }
        signal(SIGILL) { _ in CrashHandler.shared.handleSignal(signal: SIGILL) }
        signal(SIGSEGV) { _ in CrashHandler.shared.handleSignal(signal: SIGSEGV) }
      }
    
      private func handleSignal(signal: Int32) {
        let signalName: String
        switch signal {
          case SIGABRT: signalName = "SIGABRT"
          case SIGILL: signalName = "SIGILL"
          case SIGSEGV: signalName = "SIGSEGV"
          default: signalName = "알 수 없는 신호 (\(signal))"
        }
        
        let logMessage = """
            ===== 신호 처리 =====
            시간: \(Date())
            신호: \(signalName)
            ============================
            """
        
        saveLogToFile(log: logMessage)
        
        // 앱 크래시 플래그 설정
        UserDefaults.standard.set(true, forKey: "AppCrashed")
        UserDefaults.standard.set(logMessage, forKey: "LastCrashLog")
      }
      
      func saveLogToFile(log: String) {
        guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
          return
        }
        
        let logFileURL = documentsDirectory.appendingPathComponent("crash_logs.txt")
        
        if var existingLogs = try? String(contentsOf: logFileURL, encoding: .utf8) {
          existingLogs.append("\n\n\(log)")
          try? existingLogs.write(to: logFileURL, atomically: true, encoding: .utf8)
        } else {
          try? log.write(to: logFileURL, atomically: true, encoding: .utf8)
        }
      }
      
      func readCrashLogs() -> String {
        guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
          return "문서 디렉토리를 찾을 수 없습니다"
        }
        
        let logFileURL = documentsDirectory.appendingPathComponent("crash_logs.txt")
        do {
          let logs = try String(contentsOf: logFileURL, encoding: .utf8)
          return logs.isEmpty ? "로그 파일이 비어 있습니다" : logs
        } catch {
          return "로그 파일을 읽을 수 없습니다: \(error.localizedDescription)"
        }
      }
      
      // 이전 크래시가 있었는지 확인
      func checkForPreviousCrash() -> String? {
        if UserDefaults.standard.bool(forKey: "AppCrashed") {
          UserDefaults.standard.set(false, forKey: "AppCrashed")
          return UserDefaults.standard.string(forKey: "LastCrashLog")
        }
        return nil
      }
    }

     

    코드가 길긴하지만, 자세히 뜯어보면 크게 어려울건 없습니다.

    메서드별로 어떤 역할을 하는지 정도로 설명해볼께요ㅎㅎ

     

    exceptionHandler는 정적 예외 처리기의 역할로 C 스타일의 예외 처리기를 정의해요.

    옵젝씨 타임에서 우리는 크래쉬를 다뤄야하니까요.

    예외가 발생하면 handleException을 호출해 예외를 처리하게 됩니다.

    즉, 이 녀석이 크래쉬를 감지하고 처리하죠.

     

    setup은 예외 처리기를 설정해주는 메서드에요.

    이전 예외 처리기를 저장하고 새로운 C 스타일의 예외 처리기를 등록해요.

     

    handleException에서는 처리되지 않은 예외가 발생하면 호출되는 함수가 됩니다.

    예외 정보들을 수집하고 이를 로그 파일에 저장한 후에 앱 크래시 플래그를 설정해 앱이 다음 실행 시 크래시가 발생했다는걸 알 수 있도록 해주죠.

     

    handlePreviousHandler 메서드에서는 이전 설정된 예외 처리기를 호출하는 함수죠.

    새로운 예외를 생성해서 이전 핸들러에 전달해줍니다.

    예외 처리 체인을 유지하는데 유용하죠.

     

    setupSignalHandler 메서드에선 앱에서 발생할 수 있는 신호에 대한 핸들러를 설정합니다.

    각 신호가 발생하면 handleSignal 메서드가 호출됩니다.

     

    handleSignal 메서드에서는 신호가 발생하면 호출됩니다.

    해당 신호에 대한 로그 메시지를 생성해서 로그 파일에 저장하고 앱 크래시 플래그를 설정해요.

     

    saveLogToFile 메서드에서는 로그 파일을 저장해줘요.

    예외나 신호 처리 시 생성된 로그 메시지를 디바이스 문서 디렉토리에 있는 crash_logs.txt 파일에 저장해줍니다.

    로그가 이미 존재해서 새로운 로그를 덧붙이고 존재하지 않으면 새 파일을 생성합니다.

     

    readCrashLogs 메서드는 crash_logs.txt 파일에서 크래시 로그를 읽어 반환하는 함수입니다.

    파일이 비어 있거나 오류가 발생하면 적절한 메시지를 반환해요.

     

    checkForPrevious 메서드에선 이전 크래시를 확인합니다.

    앱이 이전에 크래시가 발생했는지 확인하고 발생했다면 저장된 마지막 크래시 로그를 반환해요.

    UserDefaults 저장된 AppCrashed 플래그를 사용해 이전 크래시 여부를 확인하고 로그를 반환한 후 플래그를 초기화해요.

     

    자 그럼 이제 이걸 얹어볼까요?

     

    뷰를 먼저 생성해볼께요.

     


    ContentView

    import SwiftUI
    
    struct ContentView: View {
      @State private var showingLogAlert = false
      @State private var logContent = ""
      
      var body: some View {
        NavigationView {
          VStack(spacing: 20) {
            Text("크래시 테스트 앱")
              .font(.largeTitle)
              .padding()
            
            Group {
              CrashButton(
                title: "1. Objective-C 예외 발생",
                description: "NSException을 직접 발생시킵니다",
                color: .blue
              ) {
                triggerObjectiveCException()
              }
              
              CrashButton(
                title: "2. 배열 범위 초과",
                description: "존재하지 않는 배열 인덱스에 접근합니다",
                color: .red
              ) {
                triggerArrayOutOfBounds()
              }
              
              CrashButton(
                title: "3. Fatal Error",
                description: "Swift fatalError()를 호출합니다",
                color: .orange
              ) {
                triggerFatalError()
              }
              
              CrashButton(
                title: "4. 강제 언래핑 실패",
                description: "nil 값을 강제 언래핑합니다",
                color: .purple
              ) {
                triggerNilUnwrapping()
              }
              
              CrashButton(
                title: "5. 0으로 나누기",
                description: "0으로 나누는 연산을 수행합니다",
                color: .green
              ) {
                triggerDivideByZero()
              }
            }
            
            Button("로그 파일 확인하기") {
              logContent = CrashHandler.shared.readCrashLogs()
              showingLogAlert = true
            }
            .padding()
            .background(Color.gray.opacity(0.2))
            .cornerRadius(10)
            .padding(.top, 20)
          }
          .padding()
          .navigationTitle("크래시 테스트")
          .alert("크래시 로그", isPresented: $showingLogAlert) {
            Button("확인", role: .cancel) {}
          } message: {
            Text(logContent)
          }
        }
      }
      
      // 다양한 크래시 유발 함수
      private func triggerObjectiveCException() {
        let exception = NSException(
          name: NSExceptionName("TestException"),
          reason: "의도적으로 발생시킨 Objective-C 예외",
          userInfo: ["source": "ContentView.triggerObjectiveCException"]
        )
        exception.raise() // 예외 발생
      }
      
      private func triggerArrayOutOfBounds() {
        let array = [1, 2, 3]
        let _ = array[10] // 존재하지 않는 인덱스 접근
      }
      
      private func triggerFatalError() {
        fatalError("의도적인 fatal error 발생")
      }
      
      private func triggerNilUnwrapping() {
        let nilValue: String? = nil
        let _ = nilValue! // 강제 언래핑 실패
      }
      
      private func triggerDivideByZero() {
        0 / 100
      }
    }
    
    // MARK: - 크래시 버튼 컴포넌트
    struct CrashButton: View {
      let title: String
      let description: String
      let color: Color
      let action: () -> Void
      
      var body: some View {
        Button(action: action) {
          VStack(alignment: .leading, spacing: 5) {
            Text(title)
              .font(.headline)
            
            Text(description)
              .font(.caption)
              .opacity(0.7)
          }
          .frame(maxWidth: .infinity, alignment: .leading)
          .padding()
          .background(color.opacity(0.2))
          .cornerRadius(10)
        }
      }
    }
    
    // MARK: - 크래시 보고서 뷰
    struct CrashReportView: View {
      let crashLog: String
      @Binding var showingCrashReport: Bool
      
      var body: some View {
        NavigationView {
          VStack(spacing: 16) {
            Image(systemName: "exclamationmark.triangle.fill")
              .font(.system(size: 50))
              .foregroundColor(.red)
              .padding(.top, 20)
            
            Text("이전 실행에서 앱 크래시가 감지되었습니다")
              .font(.headline)
              .multilineTextAlignment(.center)
            
            ScrollView {
              Text(crashLog)
                .font(.system(.body, design: .monospaced))
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(maxHeight: .infinity)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
            
            Button("확인") {
              showingCrashReport = false
            }
            .padding()
            .frame(maxWidth: .infinity)
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(10)
            .padding(.bottom, 20)
          }
          .padding()
          .navigationTitle("크래시 보고서")
          .navigationBarItems(trailing: Button("닫기") {
            showingCrashReport = false
          })
        }
      }
    }

     

    뷰 설명은 크게 안해도 될것 같아요!

    두가지만 보면 될것 같습니다.

    각 크래시가 날 수 있는 5가지의 테스트 케이스를 만들고 해당 버튼을 클릭하면 크래시를 일으켜요.

    그리고 이전 크래시가 어떤것이 있었는지 시트 및 얼럿으로 확인해볼 수 있습니다.

     

    이제 App 호출부로 가볼까요?

     


    App

    import SwiftUI
    
    @main
    struct crashTestApp: App {
      @State private var showingCrashReport = false
      @State private var lastCrashLog = ""
      
      init() {
        // 예외 핸들러 설정
        CrashHandler.shared.setup()
        
        // 이전 크래시 확인
        if let crashLog = CrashHandler.shared.checkForPreviousCrash() {
          lastCrashLog = crashLog
          showingCrashReport = true
        }
      }
      
      var body: some Scene {
        WindowGroup {
          ContentView()
            .sheet(isPresented: $showingCrashReport) {
              CrashReportView(crashLog: lastCrashLog, showingCrashReport: $showingCrashReport)
            }
        }
      }
    }

     

    앱 실행 시 CrashHandler를 설정하고 이전 크래시를 확인하죠.

     

    그럼 이제 한번 실행해볼까요?

     

     

    최초 실행 시 이렇게 크래시 기록이 없었으니 5가지 테스트 케이스가 나타납니다.

    이제 제가 1번 상황을 누르고 크래시를 발생시켰습니다.

     

    그러고 다시 실행한다면?

     

     

     

    이렇게 뷰 진입 시 크래시가 있었다는걸 감지하고 알려주게 됩니다.

    내용 자체는 시점 문제로 테스트 시에는 안보이지만 이게 주요한 확인 사항은 아니여서 넘어갑니다 😄

     

    그리고, 시트를 닫고 로그 파일 확인을 위해 버튼을 눌러도 아래와 같이 나타나죠!

     

     

    이전 어떤 크래시가 있었는지 확인해볼 수 있게 됩니다.

     


    Conclusion

    이렇게 크래시 자체를 감지하고 적절히 다루는 방법을 알아봤습니다!

    해당 코드를 그대로 가져다 사용하면 각자 프로젝트에서 맞지 않는 부분도 있을 수 있으니, 이번엔 코드 이해보다 흐름이나 어떤걸 사용해야될지를 기준으로 해당 포스팅을 접근하면 좋을것 같아요ㅎㅎ

     

    결국 우리는 예기치 못한 크래시를 단순하게 인지하는것이 아닌 후속으로 어떤 작업을 해줘야 할지를 고민하는것이 필요합니다.

    'iOS' 카테고리의 다른 글

    iOS에서 서버 과부하 감지 및 API 호출 최적화  (0) 2025.03.15
    Server-Driven UI  (0) 2025.03.07
    Factory Pattern  (0) 2025.03.03
    카카오톡 공유하기 (메시지 템플릿)  (36) 2024.12.05
    Bring your app to Siri (feat. WWDC 2024)  (7) 2024.10.07
Designed by Tistory.