TrendShift 딥다이브 · 2026-07-01

contained-app 딥다이브
— Apple Container CLI를 감싸는 네이티브 macOS GUI

Contained는 Apple의 container CLI(Apple Silicon 위에서 경량 리눅스 컨테이너를 돌리는 macOS 네이티브 런타임)를 위한 SwiftUI 네이티브 컨트롤 서피스다. 컨테이너·이미지·볼륨·네트워크·레지스트리를 GUI로 관리하면서도, 실행 전 항상 실제 container 명령어를 미리 보여줘 "명령줄을 숨기지 않는다." Swift 6.2 + SwiftUI + SwiftTerm(터미널) + Sparkle(자동업데이트) + Yams(Compose YAML 파싱)로 만든 순수 Swift Package Manager 프로젝트, Xcode 프로젝트 파일조차 없다. `container` CLI엔 없는 앱 관리형 재시작 정책·헬스체크까지 직접 구현했다. (저장소: tdeverx/contained-app · Swift · v1.0.0 · PolyForm Noncommercial)
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

이 저장소가 대체 무엇인가.

핵심 메시지

"Docker Desktop의 macOS 네이티브 대안 — 단, 엔진은 Apple 것을 그대로 쓴다."

Contained는 컨테이너 런타임 자체를 만든 게 아니다. Apple이 2026년에 공개한 container CLI(github.com/apple/container — Apple Silicon 전용 경량 VM 기반 리눅스 컨테이너 런타임)를 명령줄 그대로 감싸는 SwiftUI 앱이다. 컨테이너 목록을 카드로 보여주고, 로그를 실시간 스트리밍하고, 터미널 탭에서 container exec로 직접 쉘에 들어가고, 이미지를 빌드·푸시하는 모든 작업을 GUI 클릭 몇 번으로 처리한다.

Docker Desktop을 써봤다면: Docker Desktop과 똑같은 일상 워크플로우(컨테이너 카드, 이미지 관리, 로그 뷰어) + "지금 실행될 정확한 CLI 명령어 미리보기" + 앱이 직접 구현한 재시작 정책이라고 이해하면 된다. Docker 데몬 대신 Apple의 VM 기반 런타임이 뒤에서 돌아간다는 점이 근본적으로 다르다.

용어
Apple Container CLI
Apple이 2026년 공개한 오픈소스 프로젝트(github.com/apple/container). Docker처럼 컨테이너를 실행하지만, 각 컨테이너를 매우 가벼운 전용 VM(Virtualization.framework 기반) 안에서 돌려 리눅스 커널 격리를 확보한다. Apple Silicon Mac에서만 동작하며, `container` 명령어로 조작한다. Contained는 이 CLI의 GUI 프론트엔드다.

2왜 주목받는가

Apple Container 생태계 초기 진입 + Docker Desktop 무거움 대비 포지션.

Apple의 `container` CLI는 아직 생태계 초기 단계다. CLI만 존재하고 공식 GUI가 없는 상태에서, Contained는 가장 이른 시점에 나온 SwiftUI 네이티브 GUI 중 하나로 자리를 잡았다. Docker Desktop은 Electron 기반(정확히는 자체 프레임워크지만 무겁다는 평가가 널리 퍼져있다)이라 메모리 사용량이 크고, macOS 네이티브 느낌이 부족하다는 불만이 꾸준히 제기돼왔다. Contained는 100% SwiftUI로 macOS 디자인 언어(Glass 효과, 툴바, 메뉴바 확장 등)를 그대로 따르면서, 어도비 앱들처럼 백그라운드에서 무거운 헬퍼 프로세스를 상시 구동하지 않는다.

Docker Desktop 대비 위치 비교

항목Docker DesktopContained
기반 런타임자체 리눅스 VM + dockerdApple `container` CLI (Apple Silicon 전용 경량 VM)
UI 기술독자 프레임워크(무겁다는 평 다수)100% SwiftUI (AppKit은 터미널 뷰 한 곳만)
CLI 투명성GUI 뒤에 명령어 숨김실행 전 항상 "Reveal CLI"로 실제 명령어 노출
재시작 정책Docker 엔진 네이티브 지원CLI엔 없음 → 앱이 폴링으로 직접 구현(RestartWatchdog)
라이선스/과금기업 유료 정책 있음PolyForm Noncommercial (개인 무료, 상업 금지)
플랫폼Win/Mac/LinuxmacOS 26+ Apple Silicon 전용
Contained의 핵심 차별점 세 가지
① CLI를 숨기지 않음 ② 순수 SwiftUI 네이티브 ③ 앱이 재시작 정책을 대신 구현

Docker Desktop은 GUI가 CLI를 완전히 감춘다. Contained는 반대로 "GUI로 조작하되, 실제 실행될 명령어를 항상 보여준다"는 철학을 택했다. 또한 `container` CLI 자체엔 Docker의 `--restart always` 같은 정책이 없는데, Contained가 앱 레벨에서 상태를 폴링하며 크래시한 컨테이너를 지수 백오프로 재시작하는 워치독을 직접 구현했다 — CLI의 기능 공백을 GUI 레이어에서 메운 좋은 사례다.

이런 상황을 상상해봐

터미널에서 container run --detach --publish 8080:80 nginx를 매번 손으로 치는 대신, Contained에서 "Run" 폼에 포트와 볼륨을 입력하고 "Reveal CLI" 버튼을 누르면 정확히 어떤 명령어가 실행될지 먼저 보여준다. 확인하고 실행하면 카드가 그리드에 나타나고, 크래시하면 라벨에 재시작 정책이 걸려있는 경우 앱이 알아서 재시작을 시도한다.

메뉴바 아이콘에서 컨테이너 상태를 바로 확인할 수 있고, `⌘K` 커맨드 팔레트로 이미지 검색부터 로그 확인까지 키보드만으로 처리할 수 있다.

3기술 스택 전체 지도

SwiftUI/AppKit, Apple container CLI, Process 래핑, 빌드 툴체인 각각 상세.

핵심 Swift 의존성 (Package.swift 기반)

라이브러리역할비고
SwiftTerm 1.2.0+VT100/xterm 터미널 에뮬레이터 + PTY 호스트AppKit 기반. 앱에서 유일하게 AppKit에 직접 닿는 지점(`NSViewRepresentable`로 브릿지)
Yams 5.0.0+순수 Swift YAML 파서`compose.yaml` 임포트 기능에 사용
Sparkle 2.6.0+macOS 사실상 표준 자동 업데이트 프레임워크Stable/Beta/Nightly 3채널 appcast.xml 배포

플랫폼 / 언어 계층

계층기술설명
UI 프레임워크SwiftUI (macOS 26 타겟)뷰 76개 파일, 앱 전체 사실상 100% SwiftUI
상태 관리`@Observable` 매크로구형 `ObservableObject` 대신 Swift 최신 관찰 패턴 사용
영속화SwiftData실행 히스토리·활동 로그 저장(Swift Charts로 시각화)
동시성Swift Concurrency (`async/await`, `@MainActor`)`withCheckedThrowingContinuation`으로 Process 콜백을 async로 브릿지
빌드 시스템Swift Package Manager (SPM)Xcode 프로젝트 파일(.xcodeproj) 자체가 없음 — `swift build`로 빌드
백엔드(런타임)Apple `container` CLIContained는 이 CLI를 서브프로세스로 호출만 함 — 직접 컨테이너 런타임을 구현하지 않음

빌드 & 배포 도구

도구역할
Swift 6.2 / SPM주 언어 및 빌드 시스템. `Package.swift`로 타겟 2개(실행형 + 코어 라이브러리) 정의
scripts/bundle.shSPM 빌드 산출물을 `.app` 번들 형태로 패키징
scripts/make-dmg.sh배포용 `.dmg` 이미지 생성 (채널별 배경 이미지 포함)
scripts/appcast.shSparkle용 `appcast.xml` 생성/갱신
GitHub Actions`.github/workflows/{nightly,beta,stable,pr}.yml` — 채널별 자동 빌드·서명·배포
Contained.entitlements앱 권한 설정 — 샌드박스 미적용(표준 경로 파일시스템 직접 탐색 필요하기 때문)
용어
Swift Package Manager (SPM) 전용 프로젝트
Xcode의 `.xcodeproj`/`.xcworkspace` 없이 `Package.swift` 매니페스트만으로 앱을 정의·빌드하는 방식. 커맨드라인에서 `swift build`, `swift test`로 완결되며, CI에서 Xcode GUI 없이도 빌드 가능하다는 장점이 있다. Contained는 실행형 타겟(Contained)과 순수 로직 라이브러리 타겟(ContainedCore)을 분리해 테스트 용이성을 높였다.

4아키텍처 심화 분석

SwiftUI 뷰 ↔ CLI 래퍼(Process) ↔ container 런타임 흐름 + 핵심 설계 패턴.

전체 시스템 구조도

┌──────────────────────────────────────────────────────────────┐ │ SwiftUI Views (Features/*) │ │ ContainersGridView · ImageStyleButton · RunSpecForm 등 │ │ 76개 뷰 파일 — 앱 로직의 프레젠테이션 계층 │ └────────────────────────────┬───────────────────────────────────┘ │ .environment(app) 로 주입된 │ @Observable 스토어 참조 ┌──────────▼──────────────────┐ │ @Observable Stores │ │ (Sources/Contained/Stores) │ │ AppModel · ContainersStore │ │ RestartWatchdog · Settings │ └──────────┬───────────────────┘ │ 타입 안전 메서드 호출 ┌──────────▼───────────────────┐ │ ContainerClient (파사드) │ │ (Sources/ContainedCore) │ │ list() / logs() / run() ... │ └──────────┬───────────────────┘ │ argv 배열 생성 ┌──────────▼───────────────────┐ │ ContainerCommands (순수 함수) │ │ ["list","--format","json"] │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ CommandRunner │ │ Process + Pipe 실행 │ │ stdout/stderr 동시 읽기 │ │ AsyncThrowingStream 스트리밍 │ └──────────┬───────────────────┘ │ /usr/local/bin/container │ 또는 /opt/homebrew/bin/container ┌──────────▼───────────────────┐ │ Apple `container` CLI 데몬 │ │ (경량 VM 기반 리눅스 런타임) │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ │ 실제 컨테이너 프로세스들 │ │ [ nginx ] [ alpine ] ... │ └────────────────────────────────┘ JSON 응답(--format json) → ContainedCore Models → 디코딩 → UI 갱신

핵심 설계 패턴 4가지

패턴 1

2-타겟 클린 아키텍처 — ContainedCore vs Contained

`Package.swift`가 타겟을 두 개로 분리한다. ContainedCore는 SwiftUI 의존성이 전혀 없는 순수 로직(모델, CLI argv 빌더, JSON 디코더)이라 단위 테스트가 쉽다. Contained는 SwiftUI 앱 셸(뷰, `@Observable` 스토어, 디자인 시스템)이다. 실제로 `Tests/ContainedCoreTests`가 `Tests/ContainedAppTests`와 분리돼 있어, 로직 테스트는 UI 없이 빠르게 돌릴 수 있다.

패턴 2

CLI 3계층 분리 — Commands / Runner / Client

ContainerCommands(순수 argv 문자열 배열 생성, side-effect 없음) → CommandRunner(실제 `Process` 실행) → ContainerClient(타입 디코딩 파사드)로 나뉜다. 이 분리 덕분에 "어떤 명령어가 만들어지는가"를 CLI 실행 없이 테스트할 수 있고, "Reveal CLI" 기능(실행 전 명령어 미리보기)도 `ContainerCommands`만 호출해서 구현 가능하다.

// ContainerCommands.swift — 순수 argv 빌더
public enum ContainerCommands {
    static let jsonFormat = ["--format", "json"]

    public static func list(all: Bool) -> [String] {
        var args = ["list"]
        if all { args.append("--all") }
        return args + jsonFormat
    }

    // 비밀번호는 argv가 아닌 stdin으로 — 프로세스 목록에 노출 안 됨
    public static func registryLogin(server: String, username: String) -> [String] {
        ["registry", "login", "--username", username,
         "--password-stdin", server]
    }
}
패턴 3

Process/Pipe 래핑 — 교착 방지 + 스트리밍

CommandRunner는 `Process`와 `Pipe`로 CLI를 실행하되, stdout/stderr를 동시에 읽어 한쪽 파이프 버퍼가 가득 차서 교착(deadlock)되는 걸 막는다. 로그 tail이나 이미지 pull 진행률처럼 실시간 스트림이 필요한 경우엔 `AsyncThrowingStream`으로 감싸고, 소비 측 Task가 취소되면 `onTermination`에서 자식 프로세스를 `terminate()`해 좀비 프로세스를 방지한다.

// CommandRunner.swift 핵심 구조 (요약)
public final class CommandRunner: CommandRunning {
    public func run(_ arguments: [String], stdin: Data?) async throws -> Data {
        try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let process = Process()
                process.executableURL = self.executableURL
                process.arguments = arguments
                let outPipe = Pipe(); let errPipe = Pipe()
                process.standardOutput = outPipe
                process.standardError = errPipe
                try? process.run()
                // stdout/stderr 동시 읽기 → 교착 방지
                process.waitUntilExit()
                continuation.resume(returning: /* 읽은 데이터 */ Data())
            }
        }
    }

    // 스트리밍: 취소 시 자식 프로세스 자동 종료
    public func stream(_ arguments: [String]) -> AsyncThrowingStream<String, Error> {
        AsyncThrowingStream { continuation in
            continuation.onTermination = { _ in process.terminate() }
        }
    }
}
패턴 4

앱 관리형 기능 — RestartWatchdog으로 CLI 공백 메우기

`container` CLI엔 Docker의 `--restart always` 같은 네이티브 재시작 정책이 없다. Contained는 매 새로고침(refresh) 주기마다 컨테이너 상태를 이전 상태와 diff해서, `running → stopped`로 전환됐고 `contained.restart` 라벨이 붙은 컨테이너를 감지하면 `start` 명령을 재실행한다. 재시도는 최대 5회, 지수 백오프로 제한해 크래시 루프를 방지한다.

// RestartWatchdog.swift — 핵심 아이디어
/// container CLI엔 네이티브 --restart가 없으므로, 매 refresh마다
/// 상태를 diff해서 running→stopped 전환 + contained.restart 라벨이
/// always/on-failure인 컨테이너에 한해 start를 재발행한다.
@MainActor
final class RestartWatchdog {
    private let maxRetries = 5
    private var lastState: [String: RuntimeStatus] = [:]
    private var attempts: [String: Int] = [:]
    private var nextEligible: [String: Date] = [:]
}

인터랙티브 터미널 통합 (SwiftTerm + AppKit 브릿지)

앱 전체에서 유일하게 AppKit에 직접 닿는 부분이 터미널 탭이다. SwiftTerm의 `LocalProcessTerminalView`(AppKit 뷰)를 `NSViewRepresentable`로 감싸 SwiftUI 트리에 끼워넣고, `container exec --interactive --tty <id> <shell>`을 그대로 실행한다.

// TerminalTab.swift — NSViewRepresentable로 AppKit 뷰 브릿지
func makeNSView(context: Context) -> LocalProcessTerminalView {
    let view = LocalProcessTerminalView(frame: .zero)
    var env = ProcessInfo.processInfo.environment
    env["TERM"] = "xterm-256color"
    view.startProcess(
        executable: executableURL.path,
        args: ["exec", "--interactive", "--tty", containerID, shell],
        environment: env.map { "\(\$0.key)=\(\$0.value)" })
    return view
}

// 뷰가 사라지면 exec 자식 프로세스도 함께 종료
static func dismantleNSView(_ nsView: LocalProcessTerminalView, coordinator: Coordinator) {
    nsView.terminate()
}

CLI 바이너리 탐색 & 버전 검증

샌드박스가 적용되지 않은 앱이므로, `CLILocator`가 표준 설치 경로(`/usr/local/bin/container`, `/opt/homebrew/bin/container`)를 직접 파일시스템 검사로 찾는다. 부트스트랩 시퀀스에서 `container --version`을 실행해 `1.0.` 접두사 버전만 지원하도록 검증한다.

// CLILocator.swift
public enum CLILocator {
    public static let defaultCandidates = [
        "/usr/local/bin/container",
        "/opt/homebrew/bin/container",
    ]
    public static func isSupported(_ version: String?) -> Bool {
        guard let version else { return false }
        return version.hasPrefix("1.0.")
    }
}

5디렉토리 구조 해부

Swift 소스 / 뷰 / 모델 구성 — 약 170개 파일.

contained-app/ ├── Package.swift # SPM 매니페스트 (Xcode 프로젝트 없음) ├── Package.resolved # 의존성 버전 잠금 ├── VERSION # 현재 버전 (1.0.0) ├── appcast.xml # Sparkle 자동업데이트 피드 ├── CHANGELOG.md / NOTICE / LICENSE │ ├── Sources/ │ ├── ContainedCore/ # ★ 순수 로직 (SwiftUI 의존 X, 테스트 용이) │ │ ├── Models/ # Container, ImageResource, VolumeResource, │ │ │ # NetworkResource, HealthCheck, RestartPolicy, │ │ │ # ComposeProject, SystemInfo 등 │ │ └── Services/ # CLILocator, CommandRunner, ContainerClient, │ │ # ContainerCommands, CommandError, │ │ # RegistryManifestClient │ │ │ └── Contained/ # ★ SwiftUI 앱 셸 │ ├── ContainedApp.swift # @main 진입점 — AppModel 생성, environment 주입 │ ├── Stores/ # AppModel, ContainersStore, RestartWatchdog, │ │ # RefreshCoordinator, SettingsStore │ ├── Features/ │ │ ├── Containers/ # 카드·상세탭(Overview/Logs/Terminal/Stats/ │ │ │ # History/Files/Inspect)·실행폼·Compose임포트 │ │ ├── Images/ # 빌드 워크스페이스, Docker Hub 검색 │ │ ├── Registries/ # 레지스트리 로그인 시트 │ │ ├── System/ # 시스템 df, 볼륨/네트워크 카드 │ │ ├── Settings/ # 채널별(About/Runtime/Registries 등) 설정 탭 │ │ ├── MenuBar/ # 메뉴바 확장 콘텐츠 │ │ ├── Onboarding/ # 부트스트랩(CLI 미설치 등) 안내 뷰 │ │ └── Palette/ # ⌘K 커맨드 팔레트 │ ├── Navigation/ # RootView, AppToolbar, UIState, ClassicShell │ ├── History/ # SwiftData 기반 활동 로그 + Swift Charts │ ├── Migration/ # 앱 상태 마이그레이션(버전 업그레이드/다운그레이드) │ ├── DesignSystem/ # 27개 파일 — Glass 효과, 버튼, 테마, 토큰 │ └── Support/ # 로깅, 포매팅, 헬스모니터, Sparkle 업데이터 컨트롤러 │ ├── Tests/ │ ├── ContainedCoreTests/ # CommandTests, DecodingTests, Fixtures(JSON) │ └── ContainedAppTests/ # RunSpecTests, MigrationTests, PaletteSearchTests │ ├── Resources/ # 채널별(beta/nightly/stable) 아이콘, DMG 배경 ├── docs/wiki/ # Architecture, Installation, Features-* 등 문서 └── scripts/ # bundle.sh, make-dmg.sh, appcast.sh, release.sh
용어
app-managed 라벨 (contained.* 네임스페이스)
Contained가 컨테이너 라벨에 `contained.tint`, `contained.icon`, `contained.nickname`, `contained.restart` 같은 자체 키를 심어서 개인화·재시작 정책 상태를 저장하는 방식. 일반 UI 커스터마이징(색상·아이콘)은 로컬 전용으로 유지하려 하지만, 재시작 정책처럼 "기능적으로 동작해야 하는" 라벨은 실제로 컨테이너 자체에 왕복 저장돼야 하므로 예외적으로 CLI 라벨에 반영된다.

6학습 포인트 (기술별)

이 저장소에서 무엇을 배울 수 있는가.

1. SwiftUI로 시스템 CLI를 감싸는 법

Contained는 "GUI가 CLI를 대체하는 게 아니라 보조한다"는 철학을 코드로 구현한 좋은 사례다. ContainerCommands(순수 argv 빌더) → CommandRunner(실행) → ContainerClient(디코딩)의 3계층 분리를 그대로 따라 하면, 어떤 CLI 도구든 안전하게 GUI로 감쌀 수 있다.

배울 것: 순수 함수로 명령어 인자 배열 만들기(테스트 용이), "실행 전 미리보기" 기능을 위한 계층 분리, CLI 출력(JSON) 디코딩과 UI 모델 분리.

2. Process / Pipe — Swift에서 서브프로세스 다루기

CommandRunner.swift는 Foundation의 `Process`와 `Pipe`를 다루는 실전 패턴을 보여준다. stdout/stderr를 동시에 읽어야 교착을 막을 수 있다는 것, stdin으로 비밀번호를 흘려보내 argv 노출을 피하는 것, 스트리밍 중 Task 취소 시 자식 프로세스를 확실히 종료(`terminate()`)하는 것까지 — 프로덕션 수준의 서브프로세스 관리 코드다.

배울 것: `Process.standardOutput/standardError` 파이프 연결, `withCheckedThrowingContinuation`으로 콜백 API를 async/await로 브릿지, `AsyncThrowingStream`과 `onTermination`으로 리소스 정리 보장.

3. @Observable — Swift 최신 상태 관리 패턴

`ObservableObject` + `@Published` 구식 패턴 대신 Swift 최신 `@Observable` 매크로를 전역적으로 사용한다. `AppModel`, `ContainersStore` 등 모든 스토어가 `@MainActor @Observable final class`로 선언되고, `.environment(app)`으로 SwiftUI 뷰 트리에 주입된다.

배울 것: `@Observable`과 `ObservableObject`의 차이(세밀한 프로퍼티 단위 추적, 보일러플레이트 감소), `@MainActor` 격리로 UI 업데이트 스레드 안전성 보장, environment 기반 의존성 주입 패턴.

4. NSViewRepresentable — SwiftUI에서 AppKit 뷰 통합

순수 SwiftUI 앱이라도 완전한 터미널 에뮬레이션처럼 SwiftUI가 못 하는 기능은 성숙한 AppKit 라이브러리(SwiftTerm)를 브릿지해서 해결한다. `makeNSView`/`updateNSView`/`dismantleNSView` 생명주기를 정확히 구현하면 SwiftUI와 AppKit을 매끄럽게 공존시킬 수 있다.

배울 것: `NSViewRepresentable` 프로토콜 구현, Coordinator 패턴으로 델리게이트 연결, 뷰 해제 시 리소스(자식 프로세스) 정리 타이밍.

5. macOS 앱 배포 파이프라인 (SPM → .app → .dmg)

Xcode 프로젝트 없이 SPM만으로 배포 가능한 `.app` 번들을 만드는 전체 파이프라인을 갖추고 있다. `scripts/bundle.sh`가 `swift build` 산출물을 번들링하고, `scripts/make-dmg.sh`가 채널별(stable/beta/nightly) 배경 이미지가 다른 DMG를 생성하며, Sparkle이 `appcast.xml`로 자동 업데이트를 제공한다.

배울 것: SPM 실행형 타겟을 macOS `.app` 번들로 패키징하는 절차, Sparkle 프레임워크로 자동 업데이트 구현, GitHub Actions로 3채널(Stable/Beta/Nightly) 빌드 파이프라인 구성, 코드 서명 없이/있이의 배포 차이.

6. 상태-런타임 분리와 테스트 용이성

`ContainedCore`(순수 로직)와 `Contained`(SwiftUI 셸)의 타겟 분리는 "테스트하기 쉬운 코드"를 설계하는 원칙을 보여준다. `Tests/ContainedCoreTests`의 `Fixtures`에는 실제 `container list --format json` 응답을 흉내낸 JSON 픽스처가 있어, CLI 실행 없이도 디코딩 로직을 검증할 수 있다.

배울 것: 비즈니스 로직과 UI 프레임워크 의존성 분리, JSON 픽스처 기반 디코더 테스트, 멀티 타겟 SPM 패키지 설계.

7하드웨어 / 시스템 요구사항

실행 환경 · 설치 조건.

항목요구사항
OSmacOS 26 이상 (Tahoe 이후)
CPUApple Silicon 전용 (Intel Mac 미지원)
필수 사전 설치Apple `container` CLI 1.0.0 이 `PATH`에 있어야 함 (표준 경로: /usr/local/bin 또는 /opt/homebrew/bin)
개발 환경Xcode 26 / Swift 6.2+ (로컬 빌드 시)
앱 상태Pre-1.0, 활발히 다듬는 중 (스크린샷 캡션 원문: "Pre-1.0 and actively polishing")
라이선스PolyForm Noncommercial License 1.0.0 — 비상업적 사용만 허용, 포크 시 이름/아이콘 변경 필요
배포 채널Stable / Beta / Nightly 3채널, 기본값은 Nightly
현실적 주의사항
Apple container CLI가 먼저 설치돼 있어야 앱이 동작한다

Contained는 GUI일 뿐, 컨테이너 런타임 자체를 포함하지 않는다. Apple의 `container` CLI(github.com/apple/container)를 먼저 설치하지 않으면 앱 실행 시 "CLI Missing" 부트스트랩 화면만 보게 된다. 또한 CLI 버전이 `1.0.x`가 아니면 "unsupported" 상태로 안내된다 — 코드에서 `hasPrefix("1.0.")`로 엄격히 체크한다.

운영 특성
샌드박스 미적용 — 표준 경로를 직접 파일시스템 탐색

App Sandbox가 적용되지 않은 앱이라, `CLILocator`가 `/usr/local/bin/container`, `/opt/homebrew/bin/container` 같은 표준 설치 경로를 직접 검사해 바이너리를 찾는다. Homebrew로 설치했든 공식 인스톨러로 설치했든 대부분 자동 인식된다.

8직접 해볼 수 있는 실습 과제

난이도별 4개.

난이도: 입문

실습 1 — Apple container CLI 설치 후 Contained로 첫 컨테이너 실행1/4

Apple Silicon Mac(macOS 26+)에서 Apple의 `container` CLI를 먼저 설치하고, Contained 앱을 빌드/실행해 alpine 이미지를 pull한 뒤 컨테이너를 실행해본다.

# 1) Apple container CLI 설치 (공식 저장소 릴리스 참고)
container --version    # 1.0.x 확인

# 2) Contained 클론 & 빌드
git clone https://github.com/tdeverx/contained-app
cd contained-app
swift build
./scripts/bundle.sh debug
open Contained.app

확인 포인트: 앱 실행 시 부트스트랩 화면이 "ready" 상태로 넘어가는지, 컨테이너 카드가 그리드에 나타나는지 확인.

난이도: 초급

실습 2 — "Reveal CLI"로 실제 명령어 비교해보기2/4

Run 폼에서 포트 매핑·볼륨·환경변수를 채운 뒤 "Reveal CLI" 버튼으로 미리보기 명령어를 확인하고, 터미널에서 똑같은 명령어를 직접 쳐서 결과가 동일한지 비교한다.

# 예상되는 미리보기 명령어 형태
container run --detach --name my-nginx \
  --publish 8080:80 \
  --volume ~/data:/usr/share/nginx/html \
  --env FOO=bar \
  nginx

확인 포인트: `RunSpec.arguments()`가 만든 argv와 실제 터미널 명령어가 정확히 일치하는지, GUI 폼 필드와 CLI 플래그의 1:1 매핑 관계를 이해한다.

난이도: 중급

실습 3 — 나만의 CLI 래퍼 앱 만들기 (CommandRunner 패턴 재사용)3/4

Contained의 `CommandRunner`/`ContainerCommands` 계층 분리를 참고해, 다른 CLI 도구(예: `git`, `docker`, 자신이 만든 스크립트)를 감싸는 미니 SwiftUI 앱을 만들어본다.

// 순수 argv 빌더 (테스트 가능)
enum GitCommands {
    static func status() -> [String] { ["status", "--short"] }
    static func log(count: Int) -> [String] { ["log", "-\(count)", "--oneline"] }
}

// 실제 실행은 CommandRunner가 담당 (Contained 코드 참고)

확인 포인트: stdout/stderr를 동시에 읽지 않으면 어떤 상황에서 교착이 발생하는지 직접 재현해본다(큰 출력을 만드는 명령어로 테스트).

난이도: 고급

실습 4 — 나만의 재시작 워치독 구현하기4/4

`RestartWatchdog.swift`를 참고해, 특정 라벨이 붙은 컨테이너가 크래시하면 최대 N회까지 지수 백오프로 재시작하는 로직을 직접 구현해본다. Contained 소스의 `Tests/ContainedAppTests`에서 관련 테스트가 있는지 찾아 패턴을 검증한다.

@MainActor
final class MyWatchdog {
    private let maxRetries = 5
    private var attempts: [String: Int] = [:]
    private var nextEligible: [String: Date] = [:]

    func checkAndRestart(previous: [String: RuntimeStatus],
                          current: [String: RuntimeStatus]) async {
        // running → stopped 전환 감지 후 지수 백오프로 재시작
    }
}

확인 포인트: 백오프 시간이 지수적으로 늘어나는지, `maxRetries` 초과 시 재시작을 포기하고 상태를 사용자에게 알리는지 확인한다.

9관련 기술 심화 학습 로드맵

Swift/SwiftUI · 컨테이너 · macOS 시스템 프로그래밍 이해를 위한 주차별 학습 경로.

1~2주차 — Swift 기초 + SwiftUI 입문

목표: Swift 문법(옵셔널, 구조체 vs 클래스, 프로토콜), SwiftUI로 간단한 화면 만들기.

자료: Apple 공식 "The Swift Programming Language", "Introducing SwiftUI" 튜토리얼(developer.apple.com).

실습: 카드 그리드 UI를 SwiftUI `LazyVGrid`로 만들어보기. Contained의 `ContainersGridView` 구조 참고.

3주차 — @Observable과 상태 관리

목표: `@Observable` 매크로의 동작 원리, environment 기반 의존성 주입 이해.

자료: WWDC "Discover Observation in SwiftUI" 세션, Apple Observation 프레임워크 문서.

실습: `AppModel`과 유사한 전역 상태 스토어를 만들어 여러 뷰에서 공유해보기.

4주차 — Foundation Process/Pipe와 서브프로세스

목표: `Process`, `Pipe`, `FileHandle`로 외부 CLI를 실행하고 출력을 캡처하는 법.

자료: Apple Foundation `Process` 클래스 문서, "Swift에서 셸 명령어 실행하기" 관련 블로그 글들.

실습: `CommandRunner.swift`를 그대로 따라 작성해보고, 교착 상황을 의도적으로 재현 후 해결한다.

5주차 — 컨테이너 런타임 개념 (Docker vs Apple container)

목표: 컨테이너와 VM의 차이, Apple의 경량 VM 기반 컨테이너 런타임이 Docker와 어떻게 다른지 이해.

자료: github.com/apple/container README 및 아키텍처 문서, Docker 공식 문서의 컨테이너 vs VM 설명.

실습: `container` CLI만으로 (GUI 없이) 이미지 pull → run → exec → logs 전체 흐름을 손으로 실행해보고, Contained가 이를 어떻게 GUI로 매핑했는지 소스와 대조한다.

6주차 — NSViewRepresentable과 AppKit 브릿징

목표: SwiftUI에서 AppKit 뷰를 통합하는 방법, 생명주기 관리.

자료: Apple `NSViewRepresentable` 문서, SwiftTerm 저장소(migueldeicaza/SwiftTerm) README.

실습: `TerminalTab.swift`를 참고해 간단한 텍스트 뷰를 AppKit `NSTextView`로 브릿지해보기.

7~8주차 — macOS 앱 배포 파이프라인

목표: SPM 프로젝트를 `.app`/`.dmg`로 패키징하고 Sparkle로 자동 업데이트를 붙이는 전체 흐름.

자료: Sparkle 공식 문서(sparkle-project.org), `scripts/bundle.sh`·`scripts/make-dmg.sh`·`scripts/appcast.sh` 소스 정독.

실습: 자신의 SPM 실행형 프로젝트를 번들링해 `.app`으로 만들고, GitHub Actions로 릴리스 자동화 워크플로우를 구성해본다.

10핵심 키워드 사전

이 저장소를 이해하는 데 필요한 핵심 용어.

SwiftUI
SwiftUI
Apple의 선언형(declarative) UI 프레임워크. "화면이 어떻게 그려져야 하는지"를 명령형으로 일일이 지시하는 대신, 상태에 따라 뷰가 어떻게 보여야 하는지를 선언하면 프레임워크가 알아서 갱신한다. Contained는 앱 전체가 사실상 100% SwiftUI로 작성됐다.
Apple Container CLI
Apple `container` CLI
Apple이 공개한 오픈소스 컨테이너 런타임 CLI(github.com/apple/container). Apple Silicon Mac에서 각 컨테이너를 경량 전용 VM(Virtualization.framework 기반)으로 격리 실행한다. Docker와 유사한 명령어 체계(`run`, `list`, `logs`, `exec` 등)를 갖는다.
Process / 파이프
Process & Pipe (Foundation)
Swift Foundation이 제공하는 서브프로세스 실행 API. `Process`로 외부 실행파일을 띄우고, `Pipe`로 그 표준입출력을 연결해 데이터를 주고받는다. Contained의 `CommandRunner`가 이 API로 Apple `container` CLI를 호출한다.
MVVM
MVVM (Model-View-ViewModel)
UI(View)와 비즈니스 로직(ViewModel/상태 스토어)을 분리하는 아키텍처 패턴. Contained에서는 `@Observable` 스토어(AppModel, ContainersStore 등)가 사실상 ViewModel 역할을 하고, `ContainedCore`의 Model들이 Model 계층, SwiftUI 뷰들이 View 계층에 해당한다.
@Observable
@Observable 매크로
Swift의 최신 상태 관찰 매크로(구 `ObservableObject`+`@Published`를 대체). 클래스에 붙이면 프로퍼티 단위로 세밀하게 변경을 추적해, 실제로 사용된 프로퍼티가 바뀔 때만 관련 뷰를 다시 그린다. Contained의 모든 스토어가 `@MainActor @Observable final class`로 선언된다.
컨테이너 런타임
컨테이너 런타임 (Container Runtime)
이미지를 실제로 실행하고 격리된 프로세스(컨테이너)를 관리하는 소프트웨어 계층. Docker는 dockerd/containerd를 쓰고, Apple `container`는 Virtualization.framework 기반 경량 VM으로 격리를 구현한다. Contained는 런타임을 만들지 않고 Apple 런타임의 CLI를 호출만 한다.
SPM
Swift Package Manager (SPM)
Swift 공식 빌드/의존성 관리 도구. `Package.swift` 매니페스트 하나로 타겟·의존성·리소스를 정의한다. Contained는 Xcode 프로젝트 파일 없이 SPM만으로 앱 전체를 빌드한다.
NSViewRepresentable
NSViewRepresentable
SwiftUI 뷰 트리 안에 AppKit(`NSView` 기반) 뷰를 끼워넣기 위한 프로토콜. `makeNSView`/`updateNSView`/`dismantleNSView` 세 메서드를 구현하면 된다. Contained는 이걸로 SwiftTerm의 터미널 뷰를 SwiftUI 안에 통합한다.
AsyncThrowingStream
AsyncThrowingStream
Swift Concurrency에서 비동기적으로 연속된 값을 방출하는(에러 가능) 스트림 타입. `for await` 구문으로 소비한다. Contained는 로그 tail이나 이미지 pull 진행률처럼 계속 흘러나오는 CLI 출력을 이 타입으로 감싸 UI에 스트리밍한다.
Sparkle
Sparkle 프레임워크
macOS 앱의 사실상 표준 자동 업데이트 프레임워크. `appcast.xml`이라는 RSS 유사 피드를 읽어 새 버전 유무를 확인하고 다운로드·설치를 자동화한다. Contained는 Stable/Beta/Nightly 3개 채널을 각각 다른 appcast 피드로 운영한다.

11참고 링크

더 깊이 파고들기 위한 공식 자료.