본문 바로가기
프로젝트/수수-경조사비 관리 서비스

[수수] iOS에서 에러 Log를 Discord + FireBase로 전달(Discord를 통한 Log 저장 방식 공유)

by 마라민초닭발로제 2024. 9. 26.

 

 

SUSU를 개발하던 도중 한 이메일이 도착했습니다. 메일은 정확한 버그에 대해서 설명을 해주셨습니다. 검색 기능이 동작하지 않는 버그였는데, 버그를 재현 할 수 없었습니다. 즉 어떤 방식으로 왜 버그가 발생했는지에 대해서 명확한 분석을 하지 못하였습니다. 그래서 이를 해결하고자 Log를 외부 서버로 저장하자 라는 생각을 하였습니다. 

 

그래서 서버쪽에 버그를 아카이브 하는건 어떨지에 대해서 물어봤습니다. 당연히 API만들 어줄 것으로 생각했지만, 더 좋은 해결 방법을 얻을 수 있었습니다. 바로 업무에 사용하는 메신저 플래폼을 활용하여 버그를 아카이브하는 방법을 선택했습니다. 

 

 

1. 통합 로깅 시스템 구축

개발자들은 각 Object혹은 function에 적절한 do try catch 를 활용하여 Error를 로직에 따라 처리합니다. Swift의 경우 Error가 발생할 경우 Error에 관한 Message Description을 받을 수 있습니다. Error가 될 객체에 `LocalizedError`를 채택하여 ErrorDescription을 정의할 수 있습니다.

그러면 수 많은 do {} catch {} 문장에서 어떻게 Error관리 시스템에 "나 에러가 발생했어"라는 신호를 던질 수 있는 방법은 무엇일까요? 많은 방법이 있겠지만(Combine, Stream, NotificationCenter...), 가장 적절하다고 생각한 방식은 NotificationCenter였습니다. 이렇게 생각한 근거는 두가지 입니다. 

- Combine이나 Steram의 경우 "사용하는 곳 에서 필요한 의존성을 채택" 해야한다. NotificationCenter를 활용하면 NotificationName만으로 의존성 채택을 하지 않아 의존성을 떨어뜨릴 수 있다.

- NotificationCenter를 활용하면 여러 군데에서 Notification을 Subscribe할 수 있습니다. 만약 combine을 활용할 경우 보다 관리가 편해집니다. 

 

프로젝트에서는 CoreLayers라는 의존성이 있는데 대부분의 모듈들이 이를 채택하고 있습니다. 그중 앱 전역으로 사용되는 Notification.Name을 모아둔 파일이 있습니다.  이를 활용하여 logError라는 Notification을 만들어 줍니다. 

public enum SSNotificationName {
	// code ... 

  public static let logout = Notification.Name("logout")

  public static let goMyPageEditMyProfile = Notification.Name("goMyPageEditMyProfile")
  public static let goEditProfile = Notification.Name("goEditProfile")

  public static let showDefaultNetworkErrorAlert = Notification.Name("ShowDefaultNetworkErrorAlert")
  // ✅ Error를 처리하는 Notification Name
  public static let logError = Notification.Name("ErrorLog")
}

 

 

그리고 어플리케이션 전역으로 Notification을 관리하면 됩니다. 실제 작성한 FirebaseAnalytics를 활용한 ErrorLogging 코드 입니다. 

public final class FireBaseErrorHandler {
  public static let shared = FireBaseErrorHandler()
  var subscription: AnyCancellable?
  private init() {}

  public func registerFirebaseLogSystem() {
    subscription = NotificationCenter.default.publisher(for: SSNotificationName.logError)
      .sink { @Sendable errorObjectOutput in
        let errorObject = errorObjectOutput.object as? String
        Self.sendErrorToFirebase(errorMessage: errorObject)
      }
  }

  public func removeDiscordLogSystem() {
    subscription = nil
  }

  @Sendable private static func sendErrorToFirebase(errorMessage message: String?) {
    guard let message else {
      return
    }
    Analytics.logEvent("iOS Error", parameters: ["description": message])
  }
}

 

마찬가지로 Discord Error Handle 코드 입니다. 

public final class DiscordErrorHandler {
  public static let shared = DiscordErrorHandler()
  var subscription: AnyCancellable?
  private init() {}

  public func registerDiscordLogSystem() {
    subscription = NotificationCenter.default.publisher(for: SSNotificationName.logError)
      .sink { @Sendable errorObjectOutput in
        let errorObject = errorObjectOutput.object as? String
        Self.sendErrorToDiscord(errorMessage: errorObject)
      }
  }

  public func removeDiscordLogSystem() {
    subscription = nil
  }

  @Sendable private static func sendErrorToDiscord(errorMessage message: String?) {
     //... call discord API code 
   }
}

 

 

둘 코드에서 볼 수 있듯이 Error를 핸들하기 위해서 특정 NotificationName을 sink하는 형태로 작성했습니다. 이렇게 한다면 Error를 호출 할 때 간편하게 로깅 할 수 있게 됩니다. 

 

 

2. 통합 catch - handler 정의 

만약 우리가 저 코드를 사용할 때 Error가 발생한다면 NotificationCenter를 통해서 에러를 전달해야 합니다. 이 방법은 모든 Error핸들링할 때 같은 코드를 작성해야한다는 단점이 존재합니다. 만약 까먹고 작성하지 않는다면 Error를 로깅하지 않는 do catch문이 완성되는 잠재적 위험도 존재하게 됩니다. 

func task1() {
  do {
    try someTask()
  }catch {
    NotificationCenter.default.post(name: SSNotificationName.logError, object: error.localizedDescription)
  }

}

func task2() {
  do {
    let decoder = JSONDecoder()
    let responseData = try await networkProvider(.requestID)
    let id = try decoder.decode(Int64.self, from: decoder)
    // code...
  }catch {
    NotificationCenter.default.post(name: SSNotificationName.logError, object: error.localizedDescription)
    // Error Handling Code
  }
}

 

 

이를 해결하고자 custom do catch문을 고안하게 되었습니다. Custom Do catch문을 활용하여 자동으로 NotificationCenter가 불리게 만들 수 있습니다. 

func customDo(
  _ tryBlock: () throws -> Void,
  catch handler: (Error) -> Void
) {
  do {
    try tryBlock()
  }catch {
    NotificationCenter.default.post(name: SSNotificationName.logError, object: error.localizedDescription)
    handler(error)
  }
}

func customDo(
  _ tryBlock: () async throws -> Void,
  catch handler: (Error) -> Void
) async {
  do {
    try await tryBlock()
  }catch {
    NotificationCenter.default.post(name: SSNotificationName.logError, object: error.localizedDescription)
    handler(error)
  }
}

// 1️⃣ Example with function 
func task1() async {
  await customDo {
    // some custom try code
    try await Task.sleep(nanoseconds: 100)
  } catch: { error in
    // some error Handle Code...
  }
}

// 2️⃣ Example with async function 
func task2() {
  customDo {
    // some custom try code
  } catch: { error in
    // some error Handle Code...
  }
}

 

 

 

이로서 customDo Catch를 활용하여 통합 에러 로깅 환경을 구성했습니다. 문제가 발생할 경우에 FireBase와 Discord에 Logging 되게 만듬으로서 SUSU 어플리케이션의 유지보수성을 높이게 되었습니다. 

 

3. Discord를 활용한 Logging 방법 상세하게! (부록)

디스코드 웹훅을 활용하여 로깅방법에 대한 트러블 슈팅 공유 섹션입니다. 일단 Discord웹 훅을 만들어 줍니다. 

 

 

웹 훅이 완성됬다면 웹 훅을 작동시키기 위한 URL을 복사합니다. 그 URL주소를 통해서 웹훅이 디스코드 채널에 메시지를 작성하게 할 것입니다. 만약 요청하는 URL을 Secure하게 보관하고 싶다면 XCConfing를 통해서 보관하는 것도 방법도 존재합니다. 실제 이를 코드로 작성하면 다음과 같습니다.  

 

 

 @Sendable private static func sendErrorToDiscord(errorMessage message: String?) {
    guard let message else {
      return
    }
    guard
      let webhookURLString = Bundle(for: self).infoDictionary?["DISCORD_WEB_HOOK_URL"] as? String,
      let webhookURL = URL(string: webhookURLString)
    else {
      os_log("Discord Web Hook URL이 잘못되었습니다. 확인해주세요")
      return
    }

 

그리고 디스코드에게 메시지를 보내려고 하는데 Message 개수에 제한이 있습니다. 2000자 제한이기 때문에 사전에 2000자를 넘는지를 확인하고 메시지를 여러번 나눠서 보내게 만들어줘야 합니다. 

 

 

이를 위해서 String Extension을 만들고 Messages라는 변수를 통해서 쪼개줍니다. 

private extension String {
  func splitByLength(_ length: Int) -> [String] {
    var result: [String] = []
    var currentIndex = startIndex

    while currentIndex < endIndex {
      let nextIndex = index(currentIndex, offsetBy: length, limitedBy: endIndex) ?? endIndex
      result.append(String(self[currentIndex ..< nextIndex]))
      currentIndex = nextIndex
    }

    return result
  }
}

 

 

쪼개진 messages를 순차적으로 메시지를 보내개 만들어 줍니다. 

 @Sendable private static func sendErrorToDiscord(errorMessage message: String?) {
    guard let message else {
      return
    }
    guard
      let webhookURLString = Bundle(for: self).infoDictionary?["DISCORD_WEB_HOOK_URL"] as? String,
      let webhookURL = URL(string: webhookURLString)
    else {
      os_log("Discord Web Hook URL이 잘못되었습니다. 확인해주세요")
      return
    }
    let messages = message.splitByLength(1800)

  // ✅ New Task를 통한 메시지 순차 보내는 로직 작성 
    Task {
      do {
        for message in messages {
          try await sendDiscordMessage(message, url: webhookURL) // ✅
        }
        os_log("Success to send discord message")
      } catch {
        os_log("Fail to send discord message")
      }
    }
  }
  
@Sendable private static func sendDiscordMessage(_ message: String, url: URL) async throws {
    let payload: [String: Any] = ["content": message]
    guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
      os_log("Json 직렬화가 불가능 합니다. 확인해주세요")
      return
    }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = jsonData
    _ = try await URLSession.shared.data(for: request)
  }

 

 

4. 실제 적용 화면

Discord 웹훅 로깅으로 iOS에러를 쌓을 수 있게 되었습니다. 빨리 버전을 업데이트 해서 사용자에게 더 나은 서비를 제공하고 싶습니다. 궁금한 점 있으면 문의 주세요!

 

5. SUSU Logging Data Flow 

1. AppDelegate에서 Logging System Register

2. try catch block은 항상 TCA - run 에서 발생함. 이를통제하고자 Custom Error Handler을 적용한 ssRun을 활용(TCA의 run 객체 대체)

3. ssRun에서 Notification 호출 

4. Error Discord 전달