Outbox 아키텍처: 메시지를 놓치지 않기 위한 설계

이 페이지는 일본어 원문을 기계 번역한 것입니다. 일본어 원문 보기

과제: 전송 즉시 클리어해도 메시지를 놓치지 않으려면

SimpleMemo에서는 사용자가 전송 버튼을 탭하는 순간 UI가 초기화되고 바로 다음 메모를 작성하기 시작할 수 있습니다. 이것이 Captio 스타일의 근간을 이루는 UX입니다.

하지만 실제 네트워크는 300ms~2초의 지연이 있으며, 실패도 발생하고, 원래 오프라인일 수도 있습니다. 많은 앱은 네트워크 응답을 기다린 후 UI를 클리어합니다. 안전하지만, UX는 나쁩니다. 반대로 즉시 클리어하면 메시지를 잃을 위험이 있습니다.

Outbox패턴은 이 상반되는 요구사항을 동시에 해결합니다. 전송 탭 순간에 메시지를 로컬 암호화 스토리지(Outbox)에 영구 저장하고, UI를 즉시 해제한 다음 백그라운드에서 네트워크 전송을 수행합니다. 전송에 성공하면 Outbox에서 삭제하고, 실패하면 자동으로 재시도합니다. 이로 인해 전송되지 않은 메시지를 단말기에 남겨 재전송하면서 체감 레이턴시가 제로에 가까워집니다.

Outbox 패턴의 설계 흐름

아래는 전송 버튼을 탭한 후 메시지가 전송될 때까지의 전체 흐름입니다.

  1. 전송 탭 → 먼저 Outbox에 AES-GCM을 암호화하여 영구 저장
    사용자가 전송 버튼을 탭한 순간, 메시지는 AES-GCM 256bit으로 암호화되어 디바이스의 로컬 스토리지에 영구 저장됩니다. 이 단계가 완료될 때까지 UI 클리어를 수행하지 않습니다. 즉, Outbox 저장이 누수를 방지하는 최초의 방파제가 됩니다.
  2. UI를 즉시 클리어하고(사용자는 다음 메모를 작성하기 시작할 수 있습니다)
    Outbox 저장이 성공한 시점에 텍스트 뷰를 클리어하고 전송 애니메이션을 재생합니다. 사용자 입장에서는 “전송했다”와 동의어입니다. 네트워크 응답을 전혀 기다리고 있지 않습니다. Send-to-Reset의 목표는 대기감을 느끼지 않는 짧은 시간(애니메이션 포함 0.25초)입니다.
  3. 백그라운드에서 Relay API를 통해 메일 전송
    UI 클리어와 병행하여, SendManager가 Cloudflare Workers의 Relay API에 HTTP 요청을 전송합니다. 이 처리는 메인 스레드를 차단하지 않는 완전 비동기입니다.
  4. 성공하면 Outbox에서 삭제
    Relay API에서 200 응답을 수신하면 해당 메시지를 Outbox에서 안전하게 삭제합니다. 이것으로 메시지의 라이프사이클이 완료됩니다.
  5. 실패하면 자동으로 다시 전송
    네트워크 오류나 서버 오류인 경우, 전송 대기 상태로 두고, 회선이 복구되었을 때·앱을 열었을 때·백그라운드 처리(BGTaskScheduler). 실행 시점은 iOS이 정하는 것을 계기로 다시 전송합니다. 자동 재전송에는 횟수 제한이 있습니다.
  6. 오프라인인 경우 NWPathMonitor로 회선 복귀를 감지하고 자동 재전송
    전송 시점에 오프라인인 경우, Network.framework의 NWPathMonitor가 회선 상태를 모니터링하고, Wi-Fi 및 셀룰러의 복귀를 감지하면 Outbox의 미송신 메시지를 자동으로 다시 전송합니다(위의 5와 마찬가지로 자동 재전송에는 횟수 제한이 있습니다).

SendManager의 코드

SendManager의 send() 함수는 Outbox 패턴의 진입점입니다. 먼저 Outbox에 저장하고, 네트워크 연결이 없으면 대기열을 하며, 연결이 있으면 즉시 전송을 실행합니다.

// 保存完了と送信結果を分ける説明用の簡略API
@MainActor
func send(message: String, onSaved: () -> Void,
          completion: @escaping (SendResult) -> Void) {
    // まずOutboxに保存(クラッシュや圏外で失わないように)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // 同期保存に成功した場合だけ、メインスレッドでUIへ通知する
    onSaved()

    // ネットワーク接続がない場合はキューに入れたことを通知
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // 送信処理を実行
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

중요한 포인트는 OutboxManager.shared.add(body:)이 동기식으로 영구 스토리지에 기록하는 것입니다. 이 호출이 성공한 시점에, 메시지는 단말기에 안전하게 저장되어 있습니다. 네트워크 전송이 어떤 결과가 나오더라도, 메시지는 Outbox에 계속 남아 있습니다.

AES-GCM 암호화 구현

Outbox에 저장되는 메시지는 사용자의 개인 메모입니다. 비록 로컬 스토리지라고는 하지만, 평문으로 저장할 수는 없습니다. Apple CryptoKit의 AES-GCM를 사용하고, 암호화와 관련해서는 외부 SDK를 전혀 사용하지 않는 구현으로 하고 있습니다.

  • Apple CryptoKit — iOS 13 이후에서 사용 가능합니다. 암호화 처리에 외부 SDK를 거치지 않습니다(Apple 정품만).
  • 256-bit대칭키 — 키체인에 저장합니다.
  • 인증 포함 암호화 — AES-GCM은 데이터의 기밀성과 완전성을 동시에 보장합니다. 변조된 데이터는 복호할 때 감지됩니다.
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

키는 처음 필요할 때 자동으로 생성되어 키체인에 저장됩니다. 저장 설정은 “이 기기에서만 최초 잠금 해제 후에 읽을 수 있다”(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)이며, 다른 앱에서는 읽을 수 없고, 백업에서 다른 기기로 이동할 일도 없습니다. 재부팅 후 한 번 잠금을 해제하면, 잠금 중의 백그라운드 처리에서도 읽을 수 있습니다. 참고로, 위의 코드 예시는 기사를 작성한 시점의 간략 버전이며, 현재 코드에서는 키체인을 읽는 데 실패했을 때 “키가 없다”고 처리하여 새로운 키를 만들지 않습니다.

데이터 경로를 Apple 순정만으로 구성하는 의미

Outbox 아키텍처에서 사용하는 프레임워크는 모두 Apple 네이티브입니다.

  • CryptoKit — AES-GCM 암호화. 서드파티 암호화 라이브러리는 필요하지 않습니다.
  • Network.framework — NWPathMonitor에 의한 회선 상태 모니터링. Reachability 라이브러리는 필요하지 않습니다.
  • BackgroundTasks — BGTaskScheduler에 의한 백그라운드 재시도.

전송 대기 경로에 외부 라이브러리가 없다는 것은 다음과 같은 의미가 있습니다.

  • 외부 업데이트에 좌우되지 않음 — 전송 대기 중인 처리가 서드파티 라이브러리 업데이트나 제공 종료로 인해 작동하지 않게 되는 일은 없습니다.
  • 보안 — 전송 대기 중인 메모의 본문에 접촉하는 처리(암호화·저장·전송)에 제3자 코드가 들어가지 않습니다.

참고로, 앱 전체에서는 외부 SDK(Google 로그인, Firebase, AppsFlyer)도 사용하고 있습니다(프라이버시 퍼스트 설계를 참조). 여기서 말하는 “Apple 순정만”은 전송 대기 데이터의 경로에 관한 이야기입니다.

UI 클리어와 네트워크 전송의 분리

Outbox 패턴의 진정한 가치는 UI 레이어와 네트워크 레이어의 완전한 분리에 있습니다. 전송 버튼의 탭 핸들러를 살펴보겠습니다.

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    SendManager.shared.send(message: message, onSaved: { [weak self] in
        // 端末への保存成功後にだけ消す。ネットワーク応答は待たない。
        self?.performSendAnimation { [weak self] in
            guard let self = self else { return }
            // アニメーション中に編集された入力は残す。
            if self.textView.text == message {
                self.clearTextView()
            }
            PerformanceLogger.shared.endSendToReset()
        }
    }) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

이 간략한 예제에서는 기기에 저장하는 데 성공했을 때만 onSaved을 호출하고, 그때부터 애니메이션을 실행하고 입력을 지웁니다. 저장에 실패하면 입력을 남겨 둡니다. completion은 전송 결과나 대기열 등록을 알리는 별도의 알림이며, UI를 비우는 작업은 네트워크 응답을 기다리지 않습니다.

Send-to-Reset의 목표는기다림을 느끼지 못하는 짧음. 애니메이션을 포함해도 0.25초. 사용자는 전송 버튼을 탭한 직후에 다음 메모를 작성하기 시작할 수 있습니다. 이것이 Captio 스타일의 UX입니다.

자주 묻는 질문

Q. 오프라인으로 전송한 메모는 어떻게 되나요?

Outbox에 암호화되어 저장되고, 회선 복구(NWPathMonitor로 감지)·앱을 열었을 때·백그라운드 처리를 계기로 자동으로 다시 전송됩니다. 자동 재전송에는 횟수 제한이 있으며, 한도에 도달한 메모는 “기록”에서 길게 눌러 “재전송”을 선택합니다.

Q. 앱을 닫아도 메모가 사라지지 않나요?

Outbox은 영구 스토리지입니다. 앱을 종료해도, iPhone을 재시작해도 남습니다. 다음 앱 실행 시 또는 백그라운드 작업에 의해 자동으로 재시도됩니다.

Q. 왜 AES-GCM을 선택하셨습니까?

Apple CryptoKit이 네이티브로 지원하고 있으며, 인증된 암호화(Authenticated Encryption)를 통해 변조 감지도 가능합니다. 암호화 처리에 외부 라이브러리가 필요하지 않은 점도 장점입니다.

Q. 전송 실패 시 재시도 횟수는?

자동으로 다시 전송합니다. 계기는 회선이 복구되었을 때, 앱을 열었을 때, 백그라운드 처리(BGTaskScheduler)입니다. 실행 시간은 iOS이 결정합니다. 보내지 못한 메모는 전송 대기 중에서 삭제하지 않고 다시 보내지만, 자동 재전송에는 횟수 제한이 있습니다.

관련 기사

참고문헌