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)
이 저장소가 대체 무엇인가.
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 생태계 초기 진입 + Docker Desktop 무거움 대비 포지션.
Apple의 `container` CLI는 아직 생태계 초기 단계다. CLI만 존재하고 공식 GUI가 없는 상태에서, Contained는 가장 이른 시점에 나온 SwiftUI 네이티브 GUI 중 하나로 자리를 잡았다. Docker Desktop은 Electron 기반(정확히는 자체 프레임워크지만 무겁다는 평가가 널리 퍼져있다)이라 메모리 사용량이 크고, macOS 네이티브 느낌이 부족하다는 불만이 꾸준히 제기돼왔다. Contained는 100% SwiftUI로 macOS 디자인 언어(Glass 효과, 툴바, 메뉴바 확장 등)를 그대로 따르면서, 어도비 앱들처럼 백그라운드에서 무거운 헬퍼 프로세스를 상시 구동하지 않는다.
| 항목 | Docker Desktop | Contained |
|---|---|---|
| 기반 런타임 | 자체 리눅스 VM + dockerd | Apple `container` CLI (Apple Silicon 전용 경량 VM) |
| UI 기술 | 독자 프레임워크(무겁다는 평 다수) | 100% SwiftUI (AppKit은 터미널 뷰 한 곳만) |
| CLI 투명성 | GUI 뒤에 명령어 숨김 | 실행 전 항상 "Reveal CLI"로 실제 명령어 노출 |
| 재시작 정책 | Docker 엔진 네이티브 지원 | CLI엔 없음 → 앱이 폴링으로 직접 구현(RestartWatchdog) |
| 라이선스/과금 | 기업 유료 정책 있음 | PolyForm Noncommercial (개인 무료, 상업 금지) |
| 플랫폼 | Win/Mac/Linux | macOS 26+ Apple Silicon 전용 |
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` 커맨드 팔레트로 이미지 검색부터 로그 확인까지 키보드만으로 처리할 수 있다.
SwiftUI/AppKit, Apple container CLI, Process 래핑, 빌드 툴체인 각각 상세.
| 라이브러리 | 역할 | 비고 |
|---|---|---|
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` CLI | Contained는 이 CLI를 서브프로세스로 호출만 함 — 직접 컨테이너 런타임을 구현하지 않음 |
| 도구 | 역할 |
|---|---|
| Swift 6.2 / SPM | 주 언어 및 빌드 시스템. `Package.swift`로 타겟 2개(실행형 + 코어 라이브러리) 정의 |
scripts/bundle.sh | SPM 빌드 산출물을 `.app` 번들 형태로 패키징 |
scripts/make-dmg.sh | 배포용 `.dmg` 이미지 생성 (채널별 배경 이미지 포함) |
scripts/appcast.sh | Sparkle용 `appcast.xml` 생성/갱신 |
| GitHub Actions | `.github/workflows/{nightly,beta,stable,pr}.yml` — 채널별 자동 빌드·서명·배포 |
Contained.entitlements | 앱 권한 설정 — 샌드박스 미적용(표준 경로 파일시스템 직접 탐색 필요하기 때문) |
SwiftUI 뷰 ↔ CLI 래퍼(Process) ↔ container 런타임 흐름 + 핵심 설계 패턴.
`Package.swift`가 타겟을 두 개로 분리한다. ContainedCore는 SwiftUI 의존성이 전혀 없는 순수 로직(모델, CLI argv 빌더, JSON 디코더)이라 단위 테스트가 쉽다. Contained는 SwiftUI 앱 셸(뷰, `@Observable` 스토어, 디자인 시스템)이다. 실제로 `Tests/ContainedCoreTests`가 `Tests/ContainedAppTests`와 분리돼 있어, 로직 테스트는 UI 없이 빠르게 돌릴 수 있다.
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]
}
}
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() }
}
}
}
`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] = [:]
}
앱 전체에서 유일하게 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()
}
샌드박스가 적용되지 않은 앱이므로, `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.")
}
}
Swift 소스 / 뷰 / 모델 구성 — 약 170개 파일.
이 저장소에서 무엇을 배울 수 있는가.
Contained는 "GUI가 CLI를 대체하는 게 아니라 보조한다"는 철학을 코드로 구현한 좋은 사례다. ContainerCommands(순수 argv 빌더) → CommandRunner(실행) → ContainerClient(디코딩)의 3계층 분리를 그대로 따라 하면, 어떤 CLI 도구든 안전하게 GUI로 감쌀 수 있다.
배울 것: 순수 함수로 명령어 인자 배열 만들기(테스트 용이), "실행 전 미리보기" 기능을 위한 계층 분리, CLI 출력(JSON) 디코딩과 UI 모델 분리.
CommandRunner.swift는 Foundation의 `Process`와 `Pipe`를 다루는 실전 패턴을 보여준다. stdout/stderr를 동시에 읽어야 교착을 막을 수 있다는 것, stdin으로 비밀번호를 흘려보내 argv 노출을 피하는 것, 스트리밍 중 Task 취소 시 자식 프로세스를 확실히 종료(`terminate()`)하는 것까지 — 프로덕션 수준의 서브프로세스 관리 코드다.
배울 것: `Process.standardOutput/standardError` 파이프 연결, `withCheckedThrowingContinuation`으로 콜백 API를 async/await로 브릿지, `AsyncThrowingStream`과 `onTermination`으로 리소스 정리 보장.
`ObservableObject` + `@Published` 구식 패턴 대신 Swift 최신 `@Observable` 매크로를 전역적으로 사용한다. `AppModel`, `ContainersStore` 등 모든 스토어가 `@MainActor @Observable final class`로 선언되고, `.environment(app)`으로 SwiftUI 뷰 트리에 주입된다.
배울 것: `@Observable`과 `ObservableObject`의 차이(세밀한 프로퍼티 단위 추적, 보일러플레이트 감소), `@MainActor` 격리로 UI 업데이트 스레드 안전성 보장, environment 기반 의존성 주입 패턴.
순수 SwiftUI 앱이라도 완전한 터미널 에뮬레이션처럼 SwiftUI가 못 하는 기능은 성숙한 AppKit 라이브러리(SwiftTerm)를 브릿지해서 해결한다. `makeNSView`/`updateNSView`/`dismantleNSView` 생명주기를 정확히 구현하면 SwiftUI와 AppKit을 매끄럽게 공존시킬 수 있다.
배울 것: `NSViewRepresentable` 프로토콜 구현, Coordinator 패턴으로 델리게이트 연결, 뷰 해제 시 리소스(자식 프로세스) 정리 타이밍.
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) 빌드 파이프라인 구성, 코드 서명 없이/있이의 배포 차이.
`ContainedCore`(순수 로직)와 `Contained`(SwiftUI 셸)의 타겟 분리는 "테스트하기 쉬운 코드"를 설계하는 원칙을 보여준다. `Tests/ContainedCoreTests`의 `Fixtures`에는 실제 `container list --format json` 응답을 흉내낸 JSON 픽스처가 있어, CLI 실행 없이도 디코딩 로직을 검증할 수 있다.
배울 것: 비즈니스 로직과 UI 프레임워크 의존성 분리, JSON 픽스처 기반 디코더 테스트, 멀티 타겟 SPM 패키지 설계.
실행 환경 · 설치 조건.
| 항목 | 요구사항 |
|---|---|
| OS | macOS 26 이상 (Tahoe 이후) |
| CPU | Apple 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 |
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로 설치했든 공식 인스톨러로 설치했든 대부분 자동 인식된다.
난이도별 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" 상태로 넘어가는지, 컨테이너 카드가 그리드에 나타나는지 확인.
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 매핑 관계를 이해한다.
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를 동시에 읽지 않으면 어떤 상황에서 교착이 발생하는지 직접 재현해본다(큰 출력을 만드는 명령어로 테스트).
`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` 초과 시 재시작을 포기하고 상태를 사용자에게 알리는지 확인한다.
Swift/SwiftUI · 컨테이너 · macOS 시스템 프로그래밍 이해를 위한 주차별 학습 경로.
목표: Swift 문법(옵셔널, 구조체 vs 클래스, 프로토콜), SwiftUI로 간단한 화면 만들기.
자료: Apple 공식 "The Swift Programming Language", "Introducing SwiftUI" 튜토리얼(developer.apple.com).
실습: 카드 그리드 UI를 SwiftUI `LazyVGrid`로 만들어보기. Contained의 `ContainersGridView` 구조 참고.
목표: `@Observable` 매크로의 동작 원리, environment 기반 의존성 주입 이해.
자료: WWDC "Discover Observation in SwiftUI" 세션, Apple Observation 프레임워크 문서.
실습: `AppModel`과 유사한 전역 상태 스토어를 만들어 여러 뷰에서 공유해보기.
목표: `Process`, `Pipe`, `FileHandle`로 외부 CLI를 실행하고 출력을 캡처하는 법.
자료: Apple Foundation `Process` 클래스 문서, "Swift에서 셸 명령어 실행하기" 관련 블로그 글들.
실습: `CommandRunner.swift`를 그대로 따라 작성해보고, 교착 상황을 의도적으로 재현 후 해결한다.
목표: 컨테이너와 VM의 차이, Apple의 경량 VM 기반 컨테이너 런타임이 Docker와 어떻게 다른지 이해.
자료: github.com/apple/container README 및 아키텍처 문서, Docker 공식 문서의 컨테이너 vs VM 설명.
실습: `container` CLI만으로 (GUI 없이) 이미지 pull → run → exec → logs 전체 흐름을 손으로 실행해보고, Contained가 이를 어떻게 GUI로 매핑했는지 소스와 대조한다.
목표: SwiftUI에서 AppKit 뷰를 통합하는 방법, 생명주기 관리.
자료: Apple `NSViewRepresentable` 문서, SwiftTerm 저장소(migueldeicaza/SwiftTerm) README.
실습: `TerminalTab.swift`를 참고해 간단한 텍스트 뷰를 AppKit `NSTextView`로 브릿지해보기.
목표: SPM 프로젝트를 `.app`/`.dmg`로 패키징하고 Sparkle로 자동 업데이트를 붙이는 전체 흐름.
자료: Sparkle 공식 문서(sparkle-project.org), `scripts/bundle.sh`·`scripts/make-dmg.sh`·`scripts/appcast.sh` 소스 정독.
실습: 자신의 SPM 실행형 프로젝트를 번들링해 `.app`으로 만들고, GitHub Actions로 릴리스 자동화 워크플로우를 구성해본다.
이 저장소를 이해하는 데 필요한 핵심 용어.
더 깊이 파고들기 위한 공식 자료.