【개발일지 Day1】Captio가 너무 좋아서, 다시 한 번 "그 체험"을 만들고 싶어졌습니다

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

Captio이라는 앱을 알고 계십니까?

메모를 작성하고, 전송 버튼을 누릅니다. 그것만으로도 제 이메일 주소로 메모가 도착합니다. 시작하자마자 키보드가 표시되고, 쓰면 전송하고, 보내면 사라집니다. 불필요한 기능은 전혀 없습니다. 그저 그 정도의 앱이었습니다.

하지만 “그저 그것뿐”이 엄청나게 기분 좋았습니다.

통근 전철 안에서 떠오른 아이디어, 회의 중에 갑자기 떠오른 작업, 잠들기 전 머릿속에 스친 한마디——Captio가 그 모든 것을 즉시 메일함에 전달해 주었습니다. 메모 앱을 열고, 폴더를 선택하고, 제목을 붙이는... 번거로움이 전혀 없습니다. 그 “마찰 제로” 체험은 한 번 알게 되면 놓을 수 없게 되는 것이었습니다.

그 Captio가 서비스를 종료했습니다.

대체 앱을 몇 개 시도했지만, 모두 그 경험과는 거리가 멀었습니다. 시작이 느리고, UI에 불필요한 요소가 있으며, 전송 후에 메모가 남는 것—사소한 차이처럼 보일 수도 있지만, Captio가 만든 경험의 본질은 바로 그런 “소소한 일”의 누적이었습니다.

그래서, 직접 만들기로 했습니다. Captio의 체험을 다시 한 번.

이것은 “SimpleMemo”의 개발 일지 Day1입니다. 이 기사에서는 Captio의 경험을 분해하고, 이를 현대 기술 스택에서 어떻게 재현할지 모든 기술적 판단을 기록합니다.

1. Captio의 “무엇”이 좋았는지——체험을 분해해 보았습니다

Captio의 체험을 언어화하기 위해, 3을 하나의 요소로 분해해 보았습니다.

요소 1:시작 즉시 입력(Instant Launch-to-Input)

Captio를 실행하면 즉시 키보드가 표시되고 텍스트 입력이 가능해집니다. 스플래시 화면 뒤에 홈 화면이 표시되고, 그곳에서 메모 작성 버튼을 눌러......라는 절차가 없습니다. 시작=입력. 이 “시작부터 텍스트 입력이 가능해지기까지의 시간”을 우리는 Time-to-Text이라고 부릅니다. Captio의 그것은 체감으로 0.5초 이하였습니다.

요소 2: 전송하면 바로 사라집니다(Instant Clear After Send)

전송 버튼을 누르면 화면이 즉시 초기화됩니다. 전송 중...」의 진행 표시줄을 바라보며 기다릴 시간이 없습니다. 작성한 메모가 사라진 순간, “아, 보낼 수 있었구나”라는 안도감과 함께 다음 행동으로 옮길 수 있습니다. 이 “전송 버튼을 누른 후 UI가 클리어될 때까지의 시간”을 우리는 Send-to-Reset이라고 부릅니다. 목표는 기다리게 되었다고 느끼기 전에 화면이 비어 있는 것입니다.

요소3:불필요한 것이 전혀 없습니다(Nothing Unnecessary)

폴더 기능, 태그 기능, 리치 텍스트, 마크다운 지원——Captio에는 그 중 어느 것도 없었습니다. 텍스트 입력란과 전송 버튼만 있습니다. 이 청결함이 인지 부하를 제로에 가깝게 하고 있었다.「 무엇을 해야 할지 고민할 필요가 없는 앱. 그것이 Captio이었습니다.

이 3의 요소를 현대의 iOS 개발에서 재현합니다. 그것이 “SimpleMemo”의 미션이다.

2. 아키텍처: 왜 SwiftUI가 아니라 UIKit인가

2026년의 iOS 개발에서 SwiftUI가 아니라 UIKit을 선택하는 것이 이단적으로 보일 수도 있습니다. 하지만, 우리의 최우선 목표는 명확했습니다——Time-to-Text 500ms 이하.

SwiftUI의 `body` 재평가, `StateObject`의 초기화, `@Environment` 해결——이러한 오버헤드는 일반적인 앱 개발에서는 무시할 수 있는 범위이지만, “시작 즉시 입력”을 구현하기 위해서는 무시할 수 없습니다. 1ms에서도 빠르게 텍스트 필드에 포커스를 맞추고 싶습니다.

그곳에서 채택한 것은, Storyboard를 사용하지 않는 UIKit 직접 구축이다.

SceneDelegate에서 window를 생성하고, rootViewController에 직접 ComposeViewController를 설정합니다. Storyboard의 파싱 처리조차 건너뜁니다.

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

그리고 ComposeViewController의 `viewDidAppear`에서 즉시 텍스트 뷰에 포커스를 맞춥니다.

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

이 2개의 코드만으로도 시작부터 키보드 표시까지의 경로가 가장 짧아집니다.

목표는 500ms 이하입니다. 이후 화면 녹화로 측정한 기록(2026년 8월 11일, iPhone 16e·웜 실행)에서는 아이콘을 탭한 뒤 키보드 표시가 완료할 때까지 0.4초였습니다. SwiftUI에서는 `@FocusState`와 body의 재평가 타이밍을 세밀하게 제어하기 어렵고, 이 경로를 좁히는 것이 어렵다고 판단했습니다(양자를 동일한 조건으로 측정하여 비교한 것은 아닙니다).

UIKit은 “오래된” 것일 수도 있습니다. 하지만 “빠릅니다”. 이 1점에서 UIKit은 지금도 가장 좋은 선택입니다.

3. '보내면 사라짐'을 원터치로 구현하는 설계

Captio의 “보내면 사라지는” 경험을 재현하기 위해 가장 중요한 설계 판단이 있습니다. 그것은 전송 애니메이션과 UI 클리어를 네트워크 응답으로부터 완전히 분리하는 것입니다.

일반적인 앱의 전송 흐름은 다음과 같습니다:

  1. 전송 버튼을 누르세요
  2. 로드 인디케이터를 표시
  3. 서버로부터의 응답을 기다립니다
  4. 성공하면 UI를 지우고, 실패하면 오류를 표시합니다

이 흐름에서는 네트워크 지연이 그대로 UI 지연이 됩니다. 우리의 흐름은 다음과 같습니다:

  1. 전송 버튼을 누르세요
  2. 즉시UI를 클리어(애니메이션: 0.25초)
  3. 백그라운드에서 네트워크 전송
  4. 성공→조용히 Outbox에서 삭제 / 실패→백그라운드에서 재시도
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

사용자에게는 전송 버튼을 누른 순간 메모가 “사라져” 다음 메모를 작성할 수 있는 상태가 됩니다. 네트워크의 성공과 실패는 사용자가 눈에 보이지 않는 곳에서 처리됩니다.

「하지만, 전송에 실패하면 어떻게 하시겠습니까?」——그 의문에 답하는 것이 다음 섹션의 Outbox 아키텍처입니다.

4. 누수를 방지하는 Outbox 아키텍처

전송 UI를 네트워크 응답에서 분리한다는 것은, “UI는 클리어되었지만 실제로는 메일 전송에 실패하고 있었다”는 상황이 발생할 수 있다는 뜻입니다. 이는 절대 용납할 수 없습니다. 사용자가 “보냈다”고 생각한 메모가 실제로는 전송되지 않았습니다——이는 메모 앱에 치명적인 신뢰 상실입니다.

그곳에서 채택한 것이 Outbox 패턴이다.

메시지 전송 흐름은 다음과 같습니다:

  1. 전송 버튼 탭:메시지를 AES-GCM 암호화하여 Outbox(로컬 스토리지)에 저장
  2. UI 클리어:performSendAnimation으로 즉시 화면을 클리어
  3. 백그라운드 전송:Outbox에서 메시지를 추출하여 릴레이 API에 전송
  4. 성공:Outbox에서 메시지를 삭제
  5. 실패:전송 대기열에 남겨두고, 나중에 다시 전송
  6. 오프라인:NWPathMonitor로 연결을 감시하고, 복구 시 자동 재전송

중요한 것은, 네트워크 전송 전에 Outbox에 저장하는 순서입니다. 이로 인해 앱이 갑자기 충돌하더라도, 기기의 전원이 꺼져도, Outbox에 기록한 메시지는 남습니다.

Outbox에 저장된 메시지는 CryptoKit을 사용한 AES-GCM 암호화로 보호됩니다. 암호화 키는 Keychain에 저장되며, 앱의 샌드박스 외부에서는 접근할 수 없습니다.

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

또한, 모든 것을 직접 구현했기 때문에 외부 라이브러리에 대한 의존은 제로이다. CryptoKit, NWPath Monitor, URLSession—모두 Apple 순정 프레임워크로 완료됩니다. 서드파티 라이브러리의 버전 관리 및 보안 감사에서 해방되는 것은 소규모 팀에게 큰 이점입니다.

5. Relay API: 메일함의 OAuth 연동에 의존하지 않는 전송

Captio의 전송 메커니즘은 공식적으로 공개되지 않았습니다. 개발사의 종료 공지가 제시한 이유는 앱의 안정성과 안전성을 유지하기 위한 노력을 계속할 수 없게 되었기 때문이며, 전송 방식은 그 이유에 제시되지 않았습니다. 다만 일반적인 논리로, 외부 메일 API에 의존하는 전송은 제공자의 요구 사항 변경 및 접근 제한의 영향을 받기 쉽습니다.

저희는 Cloudflare Workers + Resend API라는 구성을 선택했습니다.

Cloudflare Workers는 엣지 컴퓨팅 기반이며, 전 세계 사용자에게 가장 가까운 데이터 센터에서 코드가 실행됩니다. 일본으로부터의 요청은 일본의 엣지에서 처리되고, 미국으로부터의 요청은 미국의 엣지에서 처리됩니다. 이로 인해 기존의 서버리스(AWS Lambda 등)와 비교하여 콜드 스타트 문제가 거의 해결됩니다.

메일 전송에는 Resend API를 사용합니다. Resend는 개발자를 위한 메일 전송 API입니다.

메일 인증 흐름

사용자가 수신자 이메일 주소를 설정할 때, 6자리 인증 코드를 이용한 이메일 인증을 수행합니다. 이를 통해 제3자의 이메일 주소로의 부정 전송을 방지합니다.

다층 레이트 제한

부정 이용을 방지하기 위해 다층적인 레이트 제한을 구현하고 있습니다.

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 1デバイスあたり1分30回
  devicePerDay: 200,      // 1デバイスあたり1日200回
  ipPerHour: 120,         // 1IPあたり1時間120回
  globalPerDay: 300       // サービス全体で1日300回(調整可能)
};

디바이스 단위, IP 단위, 글로벌 단위의 3 레이어에 레이트 제한을 적용함으로써 단일 디바이스의 폭주와 봇넷 공격을 차단할 수 있습니다.

2026-09 추가:단말기별 상한과 전체 1일 상한은 2026년 6월에 철거되었습니다. 현재 전송 상한은 IP 주소당 1 시간당 상한입니다.

멱등성의 보증

네트워크의 불안정으로 인한 중복 전송을 방지하기 위해 각 메시지에 UUID를 할당하고, Relay API 측에서 중복 검사를 수행합니다. 동일한 UUID 메시지가 재전송된 경우, 2 이후부터는 메일 전송을 수행하지 않고 성공 응답을 반환합니다. 이로 인해 Outbox 패턴의 재시도 메커니즘과 결합해도 사용자가 동일한 메모를 이중으로 수신하지 않습니다.

2026-10 추가:현재 전송용 서버는 중복 확인을 직접 수행하지 않았습니다. 메시지 ID를 Resend에 대한 요청의 멱등 키(Idempotency-Key)로 전달하고, Resend가 동일한 키를 24시간 기억하며, 동일한 내용의 요청에 대해서는 이전과 동일한 응답을 반환합니다. 따라서 24 시간 동안 동일한 메모가 중복 전송되지 않습니다(자세한 내용은 Relay API 설계).

6. 오늘 한 일: 작은 불편함을 하나씩 없애다

Day1의 구현 작업의 대부분은 별 평가 대화 상자를 검토하는 데에 할애했습니다. App Store의 평가는 앱의 생명선이지만, 사용자 경험을 손상시키는 방식으로 평가를 요구해서는 안 됩니다.

표시 조건의 재설계

별 평가 대화 상자의 표시 조건을 다음과 같이 설정했습니다:

  • 100통당 표시:앱을 충분히 사용한 사용자에게만 표시
  • 최소 14일 간격:이전에 표시된 이후 14일 이상 경과한 것
  • 닫은 후에는 30일 동안 표시되지 않습니다:사용자가 닫은 경우, 30일 동안은 다시 표시되지 않습니다

별 아이콘 탭 영역 수정

Apple Human Interface Guidelines에서는 터치 타깃의 크기가 최소 44x44pt가 권장됩니다. 별 아이콘의 탭 영역이 이 기준을 충족하지 못했기 때문에 수정했습니다.

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

별 아이콘의 왜곡 수정

UIStackView의 `.fillEqually` 배포가 원인으로 별 아이콘이 왜곡되어 있던 문제를 수정했습니다. `contentMode`를 `.scaleAspectFit`으로 설정하고, StackView의 distribution을 `.equalSpacing`으로 변경했습니다.

평가 후 흐름

  • 5성:App Store의 리뷰 화면으로 이동(`SKStoreReviewController`)
  • 4성 이하:「감사합니다」의 토스트를 조용히 표시하고 종료합니다. 부정적인 리뷰를 App Store에 유도하지 마십시오.

7. History의 표시 상태 불일치를 해결했습니다

전송 기록 화면(History)에서 어떤 버그를 발견했습니다. 전송이 완료되었음에도 불구하고 상태가 “전송 중”인 채로 변하지 않는 메시지가 있었습니다.

원인을 조사한 결과, 상태 업데이트 로직에 문제가 있는 것으로 확인되었습니다. 기존 구현에서는 메시지의 텍스트 내용을 키로 설정하여 상태를 업데이트했습니다. 하지만 동일한 내용의 메모를 여러 번 전송한 경우, 텍스트 기반 검색에서는 올바른 메시지를 식별할 수 없습니다.

수정은 단순명료했습니다. 텍스트 기반 검색을 ID 기반 상태 업데이트로 변경했습니다. 각 메시지에는 Outbox 저장 시 UUID가 할당되어 있으므로, 이 ID를 사용하여 History의 상태를 업데이트합니다.

// HistoryManager.swift
// Before: テキストベースの検索(バグの原因)
// func updateStatus(forText text: String, status: SendStatus)

// After: IDベースのステータス更新
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

이 수정으로 인해, 동일한 내용의 메모를 몇 번 보내도 각각의 상태가 올바르게 표시되게 되었습니다.

8. 프라이버시에 대한 편집증적인 집착

메모 앱에는 개인 정보가 기록됩니다. 비밀번호, 개인적인 고민, 비즈니스 아이디어—사용자가 무엇을 쓸지는 예측할 수 없습니다. 그렇기 때문에 프라이버시 보호는 “충분”이 아니라 “과도함” 정도가 딱 좋습니다.

앱 스위처에서의 프라이버시 오버레이

iOS의 앱 스위처에서는 앱 화면이 썸네일로 표시됩니다. 앱 스위처 화면에서 메모 내용이 보이지 않도록, 앱이 백그라운드로 이동할 때 프라이버시 오버레이를 표시합니다.

2026-09 추가:현재 앱에서는 이 보호가 활성화되어 있지 않습니다(표시용 처리는 소스에 남아 있지만 호출되지 않았습니다). 표시 중인 메모가 앱 스위처 이미지에 남아 있는 경우가 있습니다. 자세한 내용은 “프라이버시 설계” 기사를 확인해 주세요.

일시 URL 세션

일반적인 URL 세션은 캐시, 쿠키, 인증 정보를 디스크에 저장합니다. 우리는 `URLSessionConfiguration.ephemeral`를 사용함으로써 이러한 정보가 디스크에 전혀 남지 않도록 하고 있습니다. 통신 캐시로서 메모 내용이 스토리지에 남지 않도록 하기 위함이다.

로그의 무해화

개발 중인 디버그 로그에는 주의가 필요합니다. 보낸 메모의 내용을 로그에 출력한다는 겉보기에 무해해 보이는 처리가 보안 취약점이 될 수 있습니다. 우리의 정책은 명확합니다:

  • 메모의 내용은 DEBUG 빌드라 하더라도전혀 로그에 기록하지 않습니다
  • 오류 로그에서는 `localizedDescription`을 사용하지 않고, `type(of: error)`만을 기록
  • 이유:`localizedDescription`에는 사용자 입력이 포함될 가능성이 있기 때문에
// NetworkManager.swift
// BAD: エラーの詳細をログに出力
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: エラーの型のみをログに出力
Logger.error("Send failed: \(type(of: error))")

편집증적으로 보일 수도 있습니다. 하지만 메모 앱을 신뢰하고 사용하도록 하려면 이 정도의 편집증이 필요하다고 생각합니다.

9. 10 언어 지원: 전 세계 Captio 사용자에게 전달하고 싶습니다

Captio는 영어권을 중심으로 전 세계에 사용자가 있었습니다. 그 전 사용자들에게 “SimpleMemo”을 전달하기 위해, 최초 출시부터 10 언어에 대응했습니다.

지원 언어: 일본어, 영어, 스페인어, 프랑스어, 독일어, 이탈리아어, 포르투갈어, 한국어, 중국어(간체), 아라비아어.

2026-09 추가:현재 App Store 표기에서는 지원 언어가 일본어, 영어, 스페인어, 프랑스어, 독일어, 포르투갈어, 러시아어, 중국어(간체), 중국어(번체), 아랍어인 10 언어입니다. 위 목록의 이탈리아어와 한국어는 현재 표기에 포함되지 않습니다.

특히아라비아어의 RTL(Right-to-Left) 대응은 기술적인 도전이었습니다. 텍스트 방향뿐만 아니라 UI 레이아웃 전체를 미러링해야 합니다. Auto Layout의 `leading`/`trailing` 제약을 일관되게 사용함으로써 RTL 환경에서도 자연스러운 레이아웃을 구현하고 있습니다.

앱 내 언어 전환

iOS 16 이후의 앱 내 언어 전환에 더해, 독자적인 언어 전환 메커니즘도 구현하고 있습니다. 언어가 전환된 경우, ViewController를 `cross-dissolve` 트랜지션으로 교체함으로써 원활한 전환 경험을 제공합니다.

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. 오늘의 학습: 제품은 “안심감” 설계입니다

Day1을(를) 끝내고 생각한 것은, 이 제품의 본질은 “안심감” 설계라는 것입니다.

기술적인 수치를 정리해 보겠습니다:

  • Time-to-Text:500ms 이하(목표)
  • Send-to-Reset:기다림을 느끼지 않는 짧음(목표)
  • 메시지 누수:Outbox 패턴으로 방지
  • 암호화:AES-GCM(CryptoKit)
  • 외부 라이브러리 의존:제로

하지만, 이러한 수치들은 모두 “안심감”이라는 하나의 목표로 집약됩니다.

시작이 빠릅니다→'언제든지 바로 쓸 수 있다'는 안심감.
을 보내면 사라집니다 → “처리된” 안심감.
누수 방지 → “전송되지 않은 채 사라지지 않음” 안심감.
암호화 → “아무도 볼 수 없는” 안심감.

Captio가 사랑받은 것은 기능이 뛰어났기 때문이 아니다。「 안심하고 사용할 수 있는 경험을 철저히 다듬어 왔기 때문이다. 그것을 이어받고 싶습니다.

11. 다음 (Day2) 예고

Day2에서는 다음 주제에 착수할 예정입니다:

  • 전송 애니메이션의 세련됨:현재 0.25초 페이드아웃에서 보다 편안한 애니메이션으로
  • History 상태 업데이트 타이밍 최적화:실시간 업데이트 vs 배치 업데이트 비교 검토
  • 성공 토스트가 정말 필요합니까? :전송 성공 피드백에 대해 “표시하지 않음”이라는 선택지 검토

Captio의 “아무것도 없는” 청렴함을 목표로, Day2로 이어집니다.

참고문헌