창 너머의 visionOS 공간 패턴
visionOS에 출시되는 앱 대부분은 Apple의 “Designed for iPad” 호환 경로를 통해 이 플랫폼에 올라옵니다. 기존 iPad 바이너리가 3D 공간에 떠 있는 평면 패널로 실행되고, 개발자는 visionOS 고유의 경험을 만드는 대신 체크박스 하나를 켤 뿐입니다. 사용자 입장에서는 이 경로도 나쁘지 않습니다. 앱은 어쨌든 동작하니까요. 하지만 플랫폼의 값어치를 제대로 살리지는 못합니다. visionOS의 고유한 표면은 iPad SDK에는 없는 세 가지 표현 방식(Window, Volume, Immersive Space)과 구조적인 UI 기본 요소(Ornament, Attachment)를 개발자에게 내어 줍니다.4 이를 받아들인 앱은 네이티브처럼 느껴지고, 그러지 않은 앱은 ‘Vision 위의 iPad’로 읽힙니다.
이 글은 Apple 공식 문서를 기준으로 공간 어휘를 하나씩 짚어 봅니다. visionOS 입문서가 아니라 “플랫폼이 SwiftUI 앱에 실제로 무엇을 제공하는가”라는 관점입니다. 시리즈의 RealityKit과 공간적 사고 모델 글이 3D 콘텐츠 계층을 다룬다면, 이 글은 그 콘텐츠를 담는 SwiftUI 표면을 다룹니다.
한눈에 보기
- visionOS 앱은 세 가지 scene 타입을 조합합니다.
WindowGroup(Window),.windowStyle(.volumetric)을 적용한WindowGroup(Volume), 그리고ImmersiveSpace(Immersive Space)입니다1. - Window는 2D 평면이고, Volume은 경계가 정해진 3D 영역이며, Immersive Space는 사용자를 둘러쌉니다. 규칙도 각각 다릅니다. Volume은 생성된 뒤 크기가 고정되고, Immersive Space는 명시적인 열기와 닫기가 필요하며, Window가 iPad와 가장 비슷하게 동작합니다.
- 몰입 방식은 세 가지입니다.
.mixed(콘텐츠가 실제 방과 공존),.full(방을 가상 환경으로 대체),.progressive(주변 시야로 현실 감각을 유지하는 중간 단계)2. - Ornament는 Window와 평행하면서 z축상 앞쪽에 놓이는 UI 평면입니다. visionOS는 툴바와 탭 바를 이 방식으로 구현합니다3. Attachment는 RealityView의 3D 콘텐츠 안에 SwiftUI 뷰를 심어 주는 장치로, 평면 UI와 공간 기하 사이를 잇는 다리입니다.
- “패널 앱” 안티패턴은 Volume도, Space도, Ornament도 쓰지 않은 채 iPad UI를 Window 하나로 내보내는 방식입니다. 사용자는 앱을 쓸 수 있지만, 플랫폼의 진짜 값어치는 아무도 가져가지 않습니다.
세 가지 scene 타입
visionOS 앱의 App 본문은 세 부류의 scene을 조합해 구성됩니다. 각각은 사용자에게 서로 다른 심상 모델을 만듭니다.
Window: 2D 평면
WindowGroup은 기본적으로 visionOS의 유리 프레임을 두른 2D Window를 만듭니다. Window는 공간 안에 배치되고(시스템이 사용자가 바라보는 방향 앞쪽에 놓습니다), 사용자는 표준 시스템 제스처로 이를 옮기거나 크기를 바꿉니다. SwiftUI 관점에서 Window는 macOS 창에 대응하는 visionOS 쪽 짝입니다. 깊이를 인식하는 유리 재질을 두른 평면 콘텐츠 표면이지요.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
기본 Window는 콘텐츠 주변에 유리 재질을 두릅니다. 완전히 투명한 표면이 필요한 앱은 .windowStyle(.plain)을 씁니다.
WindowGroup {
ContentView()
}
.windowStyle(.plain)
plain 스타일 Window는 시스템 유리 프레임을 잃습니다. 콘텐츠 자체가 시각적 컨테이너 역할을 할 때만 쓰고, 그 밖의 경우에는 기본값이 정답입니다.
Volume: 경계가 있는 3D 영역
Volume은 깊이를 가진 콘텐츠를 담는 3D 영역입니다. 모델 하나, 여러 오브젝트로 이뤄진 장면, 세 번째 축이 있을 때 이득을 보는 UI 같은 것들이지요. volume scene 역시 WindowGroup이며, 스타일만 다릅니다.
WindowGroup(id: "globe") {
GlobeView()
}
.windowStyle(.volumetric)
.defaultSize(width: 0.6, height: 0.6, depth: 0.6, in: .meters)
.defaultSize(width:height:depth:in:) 수정자는 volume의 경계를 실제 단위(미터)로 지정합니다. 기본값에서는 열리는 순간 경계가 고정되어, 사용자는 volume을 옮길 수는 있어도 크기를 바꾸지는 못합니다. visionOS 2부터는 사용자가 크기를 조절할 수 있는 volume이 필요한 앱을 위해 .windowResizability(.contentSize)와 관련 API를 통한 선택적 경로가 추가됐지만, 여전히 크기 고정 기본값이 가장 흔합니다. 여기서 따라오는 결론은 이렇습니다. 개발자가 명시적으로 허용하지 않는 한 대부분의 volume은 크기를 바꿀 수 없으므로, 기본 크기를 신중하게 골라야 합니다.
Volume이 어울리는 앱은 공간적 경계 자체가 경험의 일부인 경우입니다. 사용자가 주위를 걸어 다니며 보는 가상 조각상, 실제 벽에 고정된 줄자, 깊이별로 표적이 배치된 운동 장면 같은 것들이지요. 단지 더 넓은 캔버스가 필요할 뿐이라면 Volume에서 얻을 것이 없습니다. 그럴 때는 더 큰 Window가 정답입니다.
Immersive Space: 둘러싸는 공간
ImmersiveSpace는 사용자 주변 환경 전체를 차지하는 scene입니다.5 Shared Space에서 다른 앱과 나란히 보이는 Window나 Volume과 달리, Immersive Space는 사용자의 주변을 통째로 가져가고 다른 앱의 창을 동시에 쓰지 못하게 막습니다.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
ImmersiveSpace(id: "training") {
TrainingScene()
}
.immersionStyle(selection: .constant(.mixed), in: .mixed, .progressive, .full)
}
}
.immersionStyle(...) 수정자는 경험의 단계를 고릅니다.
.mixed. 가상 콘텐츠가 실제 방과 나란히 나타납니다. 사용자가 두 맥락 모두에서 이득을 보는 앱에 씁니다..progressive. Digital Crown으로 몰입 정도를 올리고 내리는 부분 몰입입니다. 중앙 시야는 가상이지만, 사용자는 주변 시야로 방을 계속 인지합니다..full. 방이 가상 환경으로 대체됩니다. 명상, 훈련 시뮬레이션, 게임처럼 완전한 몰입이 필요한 경험에 씁니다.
Immersive Space를 여는 일은 명시적입니다. 앱이 space의 id와 함께 @Environment(\.openImmersiveSpace)를 호출하면, 전환 애니메이션과 충돌하는 space를 닫는 처리는 시스템이 맡습니다.
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
Button("Start Session") {
Task {
await openImmersiveSpace(id: "training")
}
}
앱당 한 번에 활성화할 수 있는 Immersive Space는 하나뿐입니다. Space 사이를 전환하려면, 예를 들어 .mixed에서 .full로 넘어가려면 기존 Space를 명시적으로 닫고 새 Space를 열어야 합니다.
Ornament: Window를 둘러싼 UI 평면
Ornament는 Window의 가장자리에 붙는 SwiftUI 뷰로, z축상 Window 평면보다 살짝 앞에 놓입니다. visionOS는 툴바, 탭 바, 보조 컨트롤을 이 방식으로 구현합니다. 시스템 앱 곳곳에서 ornament를 볼 수 있습니다. TV 앱의 재생 컨트롤, Music의 세그먼트 컨트롤, Mail의 툴바가 그렇습니다.
ContentView()
.ornament(
attachmentAnchor: .scene(.bottom),
contentAlignment: .center
) {
HStack {
Button("Previous", systemImage: "backward.fill") { ... }
Button("Play", systemImage: "play.fill") { ... }
Button("Next", systemImage: "forward.fill") { ... }
}
.padding()
.glassBackgroundEffect()
}
attachmentAnchor: 매개변수는 ornament가 Window를 기준으로 어디에 놓일지 지정합니다. .scene(.top), .scene(.bottom), .scene(.leading), .scene(.trailing)이 있습니다. ornament의 시각적 처리는 개발자 몫이며, .glassBackgroundEffect()를 쓰면 Window 프레임과 어울리는 visionOS 고유의 유리 재질이 만들어집니다.
Ornament는 visionOS에서 실제로 존재하는 문제를 풉니다. 컨트롤을 Window 안에 넣으면 콘텐츠가 비좁아지고, 별도의 Window로 빼면 사용자가 시선을 다시 옮겨야 합니다. ornament는 사용자의 주변 시야에 떠 있어 시선으로 겨냥할 수 있으면서도, 중앙 시야를 놓고 본문 콘텐츠와 경쟁하지 않습니다.
RealityView의 Attachment: 3D 공간 안의 SwiftUI
3D 장면 안에 SwiftUI 뷰가 필요할 때, 예컨대 3D 모델에 붙는 라벨, 가상 오브젝트 옆에 떠 있는 버튼, 실제 표면에 고정된 측정값 표시 같은 경우에는 RealityView의 attachment 메커니즘이 다리 역할을 합니다.
RealityView { content, attachments in
let model = ModelEntity(...)
content.add(model)
if let label = attachments.entity(for: "label") {
label.position = [0, 0.5, 0]
model.addChild(label)
}
} attachments: {
Attachment(id: "label") {
Text("Vintage Globe, 1872")
.padding()
.glassBackgroundEffect()
}
}
attachments: 클로저는 안정적인 식별자를 가진 SwiftUI 뷰를 선언합니다. 메인 RealityView 클로저 안에서 attachments.entity(for:)를 호출하면 그 뷰를 3D Entity로 받아 장면의 좌표 공간에 배치할 수 있습니다. 이 뷰는 3D 장면 안에서 텍스처가 입혀진 평면으로 렌더링되면서도 SwiftUI의 갱신 주기에 그대로 참여합니다. 상태가 바뀌면 뷰가 다시 그려집니다.
움직이는 오브젝트를 따라다니는 라벨, 측정 주석, 맥락에 따라 나타나는 버튼처럼 공간 안에 놓이는 UI라면 모두 이 메커니즘이 정답입니다. SwiftUI 뷰를 작성하는 방식은 달라지지 않고, 3D 배치만 RealityView 계층에서 이뤄집니다.
“패널 앱” 안티패턴
visionOS 출시에서 가장 흔한 실수는 패널 앱입니다. “Designed for iPad” 호환으로 visionOS에 올라온 iPad 앱이 Volume도, Immersive Space도, Ornament도 없이 Window 하나로 출시되는 경우지요. 앱은 동작하지만, 플랫폼을 자기 것으로 만들지는 못합니다.
패널 앱임을 알려 주는 신호가 셋 있습니다.
Window scene 하나뿐. .windowStyle(.volumetric)도 없고, 선언된 ImmersiveSpace도 없습니다. 앱은 그저 평면 하나이고, 그게 전부입니다.
ornament 미적용. 탭 바가 Window 바깥이 아니라 콘텐츠 안쪽에 자리합니다. 같은 콘텐츠 밀도라도 visionOS 네이티브 앱보다 훨씬 답답해 보입니다.
공간에서만 가능한 기능 부재. 앱이 세 번째 축을 어디에도 쓰지 않습니다. Volume 속 3D 모델도, Space 속 환경 장면도, attachment로 z축에 배치한 UI도 없습니다. iPad에서 하던 일을 그대로 하되, 공중에 떠 있을 뿐입니다.
패널 앱이 실패작이라는 뜻은 아닙니다. 채팅 앱, 메모 앱, 설정 유틸리티처럼 공간 컴퓨팅에서 얻을 것이 없는 범주라면 오히려 옳은 선택입니다. 진짜 실패는 패널 앱을 내놓고 거기에 visionOS 네이티브라는 권위까지 주장하는 일입니다. 시리즈의 Apple 플랫폼 매트릭스 글은 어떤 플랫폼에 들어갈지가 제품 결정이라고 말합니다. visionOS라면 그 결정은 “이 앱이 공간이라는 표면을 가질 자격이 있는가, 아니면 패널로 충분한가”입니다.
자주 나오는 실패
visionOS UX를 망치는 패턴 셋입니다.
사실은 깊이 여백만 붙인 2D 콘텐츠인 Volume. Volume을 가득 채우지만 내부에는 평면만 그리는 “3D” UI는 공간을 낭비합니다. Volume은 3D 콘텐츠를 위한 것이고, 평면 콘텐츠의 자리는 Window입니다.
사용 맥락과 싸우는 몰입 방식. .full 몰입만 제공하는 명상 앱은 잠깐 쓰려는 사용자까지 자기 환경 밖으로 끌어냅니다. .mixed만 제공하는 훈련 앱은 온전히 집중해야 하는 운동에 한참 모자랍니다. 몰입 방식은 사용자가 실제로 앱을 쓰는 상황에 맞춰야 합니다.
콘텐츠와 경쟁하는 ornament. ornament는 설계상 주변부에 있는 요소입니다. 깜빡이는 색이나 움직이는 애니메이션처럼 중앙의 주의를 요구하는 ornament는 존재 이유를 스스로 무너뜨립니다. ornament는 안정적이고 흘깃 보고 쓸 수 있는 컨트롤에 사용하세요.
이 패턴이 visionOS 앱에 뜻하는 것
가져갈 것은 셋입니다.
-
쉬운 쪽이 아니라 사용자의 심상 모델에 맞춰 scene 타입을 고르세요. 항목이 나열된 평면 목록은 Window입니다. 사용자가 들여다보는 3D 모델은 Volume입니다. 둘러싸는 환경은 Immersive Space입니다. 이들을 한 앱 안에서 섞는 것, 즉 필요할 때 Volume을 여는 Window나 Window의 버튼에서 진입하는 Immersive Space가 visionOS 고유의 패턴입니다.
-
툴바와 보조 UI에는 ornament를 채택하세요. visionOS는 “이 UI는 보조적이다”라는 사실을 ornament로 전달합니다. 툴바를 Window 콘텐츠 안에 넣으면 ‘Vision 위의 iPad’로 읽힙니다. 적용 비용은 작고 시각적 차이는 큽니다.
-
RealityView 안의 공간 UI에는 attachment를 쓰세요. 3D 오브젝트에 붙는 라벨, 가상 콘텐츠 옆의 버튼, 맥락에 따른 수치 표시가 여기에 해당합니다. SwiftUI와 3D 공간 사이의 다리는 이미 놓여 있습니다. 실패는 그 다리를 쓰지 않고 임시방편으로 3D 텍스트를 직접 렌더링하는 데서 나옵니다.
Apple 생태계 시리즈 전체는 다음과 같습니다. 타입이 있는 App Intents, MCP 서버, 라우팅 문제, Foundation Models, 런타임과 도구 LLM의 구분, 세 가지 표면, 단일 진실 공급원 패턴, 두 개의 MCP 서버, Apple 개발을 위한 훅, Live Activities, watchOS 런타임, SwiftUI의 속살, RealityKit의 공간적 사고 모델, SwiftData 스키마 규율, Liquid Glass 패턴, 멀티 플랫폼 출시, 플랫폼 매트릭스, Vision 프레임워크, Symbol Effects, Core ML 온디바이스 추론, Writing Tools API, Swift Testing, Privacy Manifest 깊이 읽기, 플랫폼 기능으로서의 접근성, SF Pro 타이포그래피 시스템, 제가 쓰지 않기로 한 것들. 허브는 Apple 생태계 시리즈에 있습니다. iOS와 AI 에이전트를 함께 다루는 더 넓은 맥락은 iOS 에이전트 개발 가이드를 참고하세요.
자주 묻는 질문
Volume과 Immersive Space는 무엇이 다른가요?
Volume은 다른 앱과 함께 Shared Space에 놓이는, 경계가 있는 3D 영역입니다. 사용자는 그 주위를 걸어 다닐 수 있고, 시스템이 테두리를 잡아 주며, 다른 앱의 Window도 그대로 보입니다. Immersive Space는 사용자를 둘러싸고 환경을 통째로 가져가며, 다른 앱을 동시에 쓰지 못하게 합니다. Volume은 “이 3D 사물을 보세요”이고, Space는 “이 환경 안에 있으세요”입니다.
Volume을 동시에 여러 개 열 수 있나요?
가능합니다. .volumetric을 적용한 WindowGroup scene을 여러 개 동시에 열 수 있고, 각각 자기 크기와 콘텐츠를 가집니다. 시스템이 공간 안에서 각각을 독립적으로 배치합니다.
Immersive Space를 동시에 여러 개 열 수 있나요?
불가능합니다. 앱당 한 번에 활성화되는 Immersive Space는 하나뿐입니다. Space를 전환하려면 @Environment(\.openImmersiveSpace)와 @Environment(\.dismissImmersiveSpace)를 통해 현재 Space를 명시적으로 닫고 새 Space를 열어야 합니다.
Volume 크기는 정말 바꿀 수 없나요?
기본값에서는 Volume의 경계가 열리는 순간 고정됩니다. visionOS HIG의 설명은 이렇습니다. Volume은 의도된 경계를 가진 특정 3D 콘텐츠를 표현하며, 사용자가 임의로 크기를 바꾸면 콘텐츠가 의도한 축척이 왜곡된다는 것입니다. visionOS 2부터는 .windowResizability(.contentSize)와 관련 API를 통해 크기 조절이 가능한 volume을 개발자가 선택할 수 있게 됐으므로, 사용자가 크기를 바꿀 수 있는 공간 컨테이너가 필요한 앱은 그렇게 요청하면 됩니다. 다만 대부분의 volume은 고정 크기 기본값으로 출시되며, 가상 조각상이나 실제 크기를 가진 모델처럼 축척이 중요한 콘텐츠에는 HIG도 여전히 이 기본값을 권장합니다.
visionOS Window에 탭 바를 어떻게 추가하나요?
콘텐츠 안쪽 탭이라면 Window 안에서 TabView를 쓰면 됩니다. iPad 방식의 패턴이지요. visionOS 고유의 주변부 탭 UI를 원한다면 버튼 행을 직접 구성한 ornament를 쓰세요. Apple의 자체 앱(Music, Mail)이 택한 쪽이 ornament이고, visionOS 사용자에게 가장 네이티브하게 느껴지는 쪽도 이쪽입니다.
RealityView의 attachment가 핸드 트래킹과 상호작용할 수 있나요?
가능합니다. attachment는 일단 배치되고 나면 3D 엔티티이며, 다른 RealityKit 엔티티와 동일한 제스처 및 히트 테스트 체계에 참여합니다. 탭, 드래그, 호버 제스처는 SwiftUI의 표준 제스처 수정자로 붙습니다. 핸드 트래킹 연동 패턴은 시리즈의 RealityKit 글에서 다룹니다.
참고 자료
-
Apple Developer: Meet SwiftUI for spatial computing (WWDC 2023 세션 10109). WindowGroup, volumetric WindowGroup, ImmersiveSpace를 visionOS의 세 가지 scene 타입으로 소개한 세션입니다. ↩
-
Apple Developer Documentation:
ImmersionStyle. 세 가지 몰입 방식(.mixed,.progressive,.full)과.immersionStyle(selection:in:)수정자 API 문서입니다. ↩ -
Apple Developer Documentation:
ornament(visibility:attachmentAnchor:contentAlignment:ornament:). 지정한 앵커 위치에 ornament UI 평면을 Window에 추가하는 SwiftUI 뷰 수정자입니다. ↩ -
Apple Developer: Go beyond the window with SwiftUI (WWDC 2023 세션 10111). Volume, Immersive Space, 그리고 visionOS에서 평면 패널 UI를 넘어서기 위한 패턴을 다룬 세션입니다. ↩
-
Apple Developer Documentation: Creating an immersive space in visionOS with SwiftUI. immersive space를 정의하고 여는 전 과정을 다룬 가이드입니다. ↩