Return...

다섯 개의 화면을 아우르는 선(禪) 명상 및 집중 타이머: iPhone, iPad, Apple Watch, Apple TV, Mac.

2026년 4월 21일 출시. 단일 코드베이스. 아랍어와 히브리어를 포함한 27개 언어. 네 가지 테마, 세 가지 종, 제로 애널리틱스. 여기서부터는 이것이 어떻게 만들어졌는지에 대한 이야기입니다: 기술적 선택, 디자인 트레이드오프, 그리고 수백 개의 AI 생성 물방울을 하나로 다듬어 낸 길고 조용한 과정.

유니버설

하나의 코드베이스, 다섯 개의 화면.

Return은 단일 Xcode 프로젝트에서 모든 Apple 화면 등급을 가로질러 실행되는, 제가 출시한 첫 번째 앱입니다: iPhone, iPad, Apple Watch, Apple TV, Mac. 57개의 Swift 파일, 약 12,700 줄의 코드, 그리고 외부 의존성은 제로. 순수한 SwiftUI, AVFoundation, HealthKit, ActivityKit, WidgetKit로 구성되어 있습니다.

이걸 하는 순진한 방법은 플랫폼별 차이마다 #if 분기가 있는 하나의 유니버설 TimerManager를 만드는 것입니다. 저는 그렇게 하지 않았습니다. Return은 세 개의 타이머 클래스(iOS와 macOS의 TimerManager, tvOS의 TVTimerManager, watchOS의 WatchTimerManager)를 출시하며, 이들은 상태 시맨틱은 공유하되 각 플랫폼이 실제로 잘하는 것을 존중합니다. Live Activities는 iOS에서만. HealthKit은 API가 존재하는 곳에서만. 확장 런타임 세션은 Watch에서만. 각 매니저는 하나의 다형 클래스보다 더 짧고 더 정직합니다.

Fire 테마로 iPhone 17 Pro Max에서 실행 중인 Return
iPhone
Fire 테마로 macOS에서 실행 중인 Return
Mac
Fire 테마로 Apple Watch Series 11에서 실행 중인 Return
Watch
Fire 테마로 Apple TV에서 실행 중인 Return
Apple TV
Fire 테마로 iPad Pro 13인치에서 실행 중인 Return
iPad

중요한 곳에서만 공유.

단일 Shared/ 폴더는 모든 타깃이 합의해야 하는 조각들을 담고 있습니다: MeditationSession 데이터 모델, SessionStore iCloud 래퍼, 그리고 SessionHistoryView. 설정은 App Group(group.com.941apps.Return)을 통해 Watch와 폰 사이에서 동기화됩니다. 나머지는 의도적으로 플랫폼별로 다릅니다.

가장 명확한 예시는 세션이 이미 HealthKit에 기록되었는지를 결정하는 한 줄입니다. iPhone은 직접 쓰기 때문에, 세션이 종료되는 순간 "synced"는 true입니다. Mac과 TV는 HealthKit에 전혀 쓸 수 없으므로, iPhone이 나중에 보류 중인 세션을 가져올 때까지 "synced"는 false입니다. 같은 의도, 반대의 불리언, 하나의 #if:

Swift · TimerManager.swift:120-138
/// Save session to SessionStore for cross-device sync and HealthKit syncing
private func saveSessionToStore(startTime: Date, endTime: Date) {
    // On iOS: if healthKitEnabled, we save directly to HealthKit, so mark as synced
    // On Mac: if healthKitEnabled, we want to sync to iPhone, so mark as NOT synced
    #if os(iOS)
    let alreadySynced = settings.healthKitEnabled
    #else
    let alreadySynced = !settings.healthKitEnabled
    #endif

    let session = MeditationSession(
        startDate: startTime,
        endDate: endTime,
        sourceDevice: .current,
        syncedToHealthKit: alreadySynced
    )

    SessionStore.shared.addSession(session)
}

저는 그 패턴으로 끊임없이 돌아옵니다: 의도를 여전히 읽을 수 있게 만드는 가장 적은 줄. 같은 불리언이 플랫폼마다 다른 의미를 가질 때는, 다른 불리언으로 쓰세요. #if가 문서의 일부가 됩니다.

로컬라이즈드

27개 언어, 그리고 우측에서 좌측(RTL) 지원.

Return은 제가 관심 있는 모든 언어로 출시한 첫 번째 Apple 앱입니다. 아랍어와 히브리어를 포함해 27개 로케일이 완전한 검토 과정을 거쳤습니다. 모든 것이 하나의 Localizable.xcstrings 파일에 들어 있는데, 들리는 것만큼 영웅적이지는 않습니다. 문자열을 손으로 직접 쓰는 것을 그만두기로 동의하기만 하면, Xcode가 대부분의 일을 해 줍니다.

Return 홈 화면, Water 테마, 영어
English홈 · Water
Return 홈 화면, Fire 테마, 일본어
日本語홈 · Fire
Return 홈 화면, Forest 테마, 중국어 간체
简体中文홈 · Forest
Return 설정 화면, 독일어
Deutsch설정
Return HealthKit 권한 화면, 한국어
한국어HealthKit

RTL은 맞서 싸우기를 그만두면 공짜로 얻는 승리입니다.

SwiftUI는 .leading.trailing을 고정된 방향인 .left.right가 아니라 시맨틱한 방향으로 취급합니다. 화면을 시맨틱 방향으로 한 번 레이아웃하면, 같은 화면이 전용 코드 경로 없이 아랍어, 히브리어, 페르시아어, 또는 우르두어에서 자동으로 미러링됩니다. 설정 라벨이 뒤집히고, 뒤로 가기 셰브런이 반전되며, 스위치 위치가 뒤바뀝니다. 테마 아이콘(물방울, 불꽃, 잎)은 그대로 있습니다. 저는 이 동작을 위해 RTL 코드를 한 줄도 쓰지 않았습니다.

Return 홈 화면, Forest 테마, 영어, 좌에서 우 레이아웃
영어 · LTR
Return 홈 화면, Forest 테마, 아랍어, 우에서 좌 레이아웃
아랍어 · RTL
Return 홈 화면, Forest 테마, 히브리어, 우에서 좌 레이아웃
히브리어 · RTL

출시하면서 잡은 한 가지 예외: SwiftUI는 Text 뷰에도 레이아웃 방향을 적용하기 때문에, 아랍어와 히브리어 스크린샷의 첫 버전에서는 타이머가 "20:00" 대신 "00:02"로 읽혔습니다 — 라틴 숫자가 우에서 좌로 배치된 것이죠. 시간이나 숫자 콘텐츠를 담는 모든 Text 뷰에 .environment(\.layoutDirection, .leftToRight) 수식자를 하나 붙이면 해결됩니다. 위의 스크린샷들은 그 수식자가 적용된 채로 출시되는 릴리스에서 가져온 것입니다.

스크린샷 세트는 fastlane이 서로 다른 -AppleLanguages 인자로 같은 UI 테스트를 실행해 생성했습니다. 앱 자체의 effectiveLocale 패턴이 그 플래그를 읽고, 뷰 계층을 재구축한 뒤 결과를 캡처합니다. 하나의 헬퍼, 27개 로케일, 네 개의 기기 등급이, 모두 하룻밤 실행으로 완료되었습니다.

Swift · ReturnWatchApp.swift:92-111
/// The locale to use for the app - either user-selected or system default
/// In snapshot mode, always use system language (set by -AppleLanguages)
/// to allow screenshot generation for different locales
private var effectiveLocale: Locale {
    if isSnapshotMode || appLanguage.isEmpty {
        if let preferredLanguage = Locale.preferredLanguages.first {
            return Locale(identifier: preferredLanguage)
        }
        return .current
    }
    return Locale(identifier: appLanguage)
}

var body: some Scene {
    WindowGroup {
        WatchContentView()
            .preferredColorScheme(.dark)
            .environment(\.locale, effectiveLocale)
            .id(appLanguage) // Force rebuild when locale changes
    }
}

.id(appLanguage)가 제 역할을 하는 디테일입니다. 그것이 없으면 SwiftUI는 이전 뷰 계층을 캐시하고, 런타임에 언어를 바꿔도 문자열이 새로고침되지 않습니다. 그것이 있으면 전체 트리가 폐기되고 재구축되며, 모든 것이 로컬라이즈된 문자열을 자동으로 다시 읽습니다. 한 줄, 버그 한 범주 삭제.

HealthKit

Mindful Minutes, 드디어.

Apple의 네이티브 Watch 마음챙김 앱은 내장된 돌아보기와 호흡 세션을 5분으로 제한합니다. HealthKit API 자체에는 그런 제한이 없습니다. 종료일이 시작일보다 뒤인 HKCategorySample이라면 기꺼이 받아들입니다. 제한은 시스템이 아니라 UI에 있습니다. Return은 모든 기기에 5분에서 60분까지의 선택기를 두고, 당신이 실제로 앉은 만큼을 기록합니다.

Swift · HealthKitManager.swift:92-103
/// Save a mindful session with the given start and end time
func saveMindfulSession(start: Date, end: Date) async -> Bool {
    guard isAvailable else { return false }

    // Don't save if end is before or equal to start
    guard end > start else { return false }

    let sample = HKCategorySample(
        type: mindfulType,
        value: HKCategoryValue.notApplicable.rawValue,
        start: start,
        end: end
    )
    ...
}

유일한 검증은 end > start입니다. HealthKit 자체가 검증하는 전부입니다. Apple의 API는 언제나 45분짜리 명상을 기록할 의사가 있었습니다. 그것을 요청할 버튼이 없었을 뿐이죠.

세 기기에 HealthKit 없이 이루는 크로스 디바이스.

Mac과 Apple TV에는 HealthKit이 아예 없습니다. 뻔한 반응은 "그럼 거기서는 세션을 기록하지 말자"입니다. 덜 뻔하지만 올바른 반응은, 어쨌든 iCloud Key-Value Store에 기록해 두고, 폰이 다음에 깨어날 때 가져가게 하는 것입니다. Return의 SessionStore가 공유 저장소이고, MeditationSession.syncedToHealthKit이 보류 플래그이며, HealthKitManager.syncPendingSessions()는 iOS 앱이 포그라운드로 복귀할 때마다 실행됩니다.

SessionStore
iCloud Key-Value Store
보류 중인 세션
iPhone이 HealthKit에 기록 ♥
한 달 동안 20분 평균을 보여주는 Apple Health Mindful Minutes 막대 차트
Apple Health Mindful Minutes, 막대 뷰. Apple 자체의 마음챙김 앱은 5분 돌아보기 세션에서 멈춥니다. 기저의 저장소는 거기에 무엇을 쓰는지 개의치 않습니다.
지난 4주 동안 18일의 수행을 보여주는 Apple Health Mindful Minutes 캘린더
같은 데이터, 캘린더 뷰: 지난 4주 중 18일, 모든 세션이 Return에서 기록됨.
20분 명상 세션 목록을 보여주는 Return 세션 기록 화면
Return 자체의 세션 기록. 모든 기기가 기여하고, 모든 세션은 출처 마커를 담고 있습니다.

이것은 Apple이 직접 출시해야 한다고 제가 생각하는 부분입니다: Mac에서 명상하고 싶을 때 폰이 활성화되어 있을 필요가 없는, 제대로 된 크로스 플랫폼 Mindful Minutes 기록기. 그들이 그럴 때까지, Return이 합니다.

제너레이티브

물은 어디서 왔는가.

네 가지 테마. 네 가지 앰비언트 루프. 세 가지 종. 모두 생성되었고, 대부분은 버려졌습니다. 영상은 Midjourney, 오디오는 ElevenLabs이지만, 중요한 작업은 프롬프팅이 아니었습니다. 편집이었습니다. 200개의 물방울 그리드를 바라보며 보이는 이음매 없이 깔끔하게 루프되는 하나를 고르는 일. 사찰 종의 40가지 변주를 듣다가 적절한 어택과 적절한 디케이를 가졌으면서도 폰 알림처럼 들리지 않는 하나를 찾는 일.

Midjourney 컨택트 시트: 수백 가지 물방울 변주, 몇 개는 하트와 재생 삼각형으로 표시됨
Water · 128개 표시
Midjourney 컨택트 시트: 수십 가지 불 변주
Fire · 96개 표시
Midjourney 컨택트 시트: 나무 캐노피와 잎 변주
Forest · 60개 표시
Midjourney 컨택트 시트: 출시되지 않은 구름과 하늘 탐색
미출시 탐색 · 128개 표시

모든 타일이 하나의 생성물입니다. 하트는 1차 통과를 살아남은 것들. 재생 삼각형은 제가 영상으로 가져간 것들. 네 개의 테마가 출시되었습니다. 나머지는 모두 그리드에 남았고, 바로 그것이 이 과정의 핵심입니다: 비율이 중요합니다.

종들은 오디오에서 같은 궤적을 따랐습니다. 프롬프트, 듣고, 다듬고, 다시 프롬프트. 저는 세 개를 남겼습니다: Singing Bowl, Temple Bell, Soft Chime. 각각은 인위적으로 들리는 것을 멈출 때까지 반복되었습니다.

총 생성 횟수를 헤아리는 척은 하지 않겠습니다. 테마당 수백 개는 정직한 숫자입니다. 수양은 프롬프트에 있지 않습니다. 그냥 괜찮은 모든 것을 버리고, 20분 동안 조용히 앉아 있을 때 결코 눈에 띄는 것이 되지 않고 타이머 뒤에 머물 수 있는 것들만 남기는 데 있습니다.

수행

왜 스승이 아니라 타이머인가.

이 부분은 개인적입니다. 저는 이미 명상 수행이 있고, 방해되지 않고 물러서 있는 타이머를 찾을 수 없었기 때문에 Return을 만들었습니다. 제가 앉는 것은 무술적 흐름 속의 일본 선(禪)입니다: Takuan, Yagyu, Musashi, Dogen, Hakuin. 큰 앱들이 출시하는 치료적 마음챙김이 아닙니다. 다른 의도, 다른 결.

한 주를 따라 순환하는 것들:

  • Susokukan(수식관, 호흡 세기). 호흡에 맞춰 하나에서 열까지 세고, 숫자를 놓칠 때마다 하나로 돌아갑니다. 기반. 집중, joriki, 먼저.
  • Shikantaza(지관타좌, 오직 앉기). 대상 없음. 세지 않고, 질문 없이, 시각화 없이. 고착하지 않는 마음. Dogen의 중심적인 zazen 형태이자 제가 실제로 원하는 상태에 가장 가까운 형식적 근사입니다.
  • Koan(공안). 주로 Joshu의 Mu. 생각으로 해결할 수 없는 질문, 생각이 포기할 때까지 붙잡는 것.
  • Maranasati(죽음 관조). Hagakure의 프레이밍. 아껴서 사용. 생존은 마음을 조이지만, 이것은 그것을 뚫고 지나갑니다.
  • Isshin(일심). Takuan과 Yagyu의 영역: 이완되어 있으면서도 전념하고, 안정되어 있으면서도 움직이는. 방석과 그다음에 올 것 사이의 다리.
  • 통합의 날들. 감사, 자비, 법맥. Jihi. Katsujinken: 죽이는 칼이 아니라 살리는 칼. 보통 토요일에.
  • Sakki(살기, 적의에 대한 자각). 모든 세션 끝에 5분의 열린 장(場) 경청을 덧붙입니다. shikantaza를 방석에서 들어내어 일상적 환경에서 압력 테스트를 합니다.

순환은 경직되어 있지 않습니다. 안정이 필요할 때는 호흡 세기. 뚫고 나가야 할 때는 공안. 열림 속에서 쉬어야 할 때는 shikantaza. 판돈이 분명해져야 할 때는 죽음 관조. 다양성은 훈련에 속합니다.

Return이 타이머인 이유는 제 폰에 스승이 필요하지 않기 때문입니다. 저는 시계를 대신 들어줘서 제가 그럴 필요가 없게 해 주고, 제가 존중하는 종으로 시작과 끝을 표시해 주며, 그 사이에는 물러서 있는 무언가가 필요합니다. 이미 수행이 있다면, 아마 당신도 그런 것을 원할 겁니다. 이제 막 시작했다면, 방 안에 있는 스승을 찾으세요. 그리고 돌아오세요.

절제

Return에 없는 것.

Return은 Calm이 아닙니다. Headspace도 아닙니다. 영국식 내레이터가 당신을 바디 스캔으로 부드럽게 이끄는 일이 없습니다. 연속 기록을 축하하는 카툰 아바타도 없습니다. 새 가이드 프로그램을 해제하는 구독도 없습니다. Return은 타이머입니다. 발상은 이것입니다: 이미 수행이 있다면, 앱 안에 스승이 필요 없습니다. 시간을 대신 잡아 주고 물러서 있는 도구가 필요합니다.

  • 가이드 음성 또는 내레이션 없음
  • 연속 기록, 점수, 게이미피케이션 없음
  • 구독 또는 앱 내 구매 없음
  • 광고, 절대 없음
  • 애널리틱스 없음; 앱은 아무것도 추적하지 않습니다
  • 소셜 로그인 또는 공유 없음
  • 잔소리 화면 없음, 콜드 스타트 모달 없음
  • IAP 플로우의 다크 패턴 없음, 왜냐하면 IAP 플로우 자체가 없기 때문입니다

Return에 있는 것, 의도적으로 작게 유지됩니다: 네 가지 반복 모드(한 번, 멈출 때까지, 시간까지, N회 반복), 사이클 간 2초의 호흡 멈춤, 각 전환마다 1~3회 종 울림, 세 가지 종 선택지, 네 가지 테마, HealthKit 옵트인, 그리고 언어 선택기. 이것이 제품의 전부입니다.

이렇게 엄격한 것의 비용은 설정 모델에서 드러납니다. 사용자를 향한 모든 기본 설정은 UI 검증이 아니라 속성 자체에 의해 유효 범위로 고정됩니다. 조심하지 않으면 UI 검증은 또 다른 다크 패턴입니다. bellRepeatCount 게터는 1, 2, 3 외에는 무엇도 반환할 수 없습니다. 기저의 @AppStorage에 0이나 47을 쓰면 조용히 허용 범위로 다시 고정됩니다.

Swift · Settings.swift:74-81
@ObservationIgnored
@AppStorage("bellRepeatCount") private var _bellRepeatCount = 1

/// Validated bell repeat count (1-3)
var bellRepeatCount: Int {
    get { max(1, min(3, _bellRepeatCount)) }
    set { _bellRepeatCount = max(1, min(3, newValue)) }
}

Return은 $2.99입니다. 한 번 지불하면 그것이 당신의 것입니다. 지원할 서버 비용도 없고, 갱신할 구독도 없으며, 당신이 하는 일을 지켜보는 애널리틱스 파이프라인도 없습니다. 제품이 곧 제품입니다. 제가 왜 이런 방식으로 계속 앱을 만드는지에 대한 더 긴 버전을 원한다면, Minimum Worthy ProductThe Steve Test을 읽어 보세요. 짧은 버전은 이 섹션에 있습니다.

Return.

iPhone, iPad, Apple Watch, Apple TV, Mac용으로 App Store에서 지금 이용 가능합니다.