DeveloperAcademy-POSTECH / DeveloperAcademy-POSTECH/swift-style-guide

[내용 추가 및 수정] <SwiftUI 스타일 가이드>

Open
#81 0 comments 0 reactions 1 assignee Claimed by @KhoraLee View on GitHub
Dominant language
No language data
Stars
272
Forks
41
PR merge metrics
No merged PRs in 30d

Description

#80 이슈를 포함해서 애플이 공개한 예제 코드들을 바탕으로 Claude(+GPT)로 분석한 결과입니다.
전부 추가하진 않더라도 일부 내용을 swift-style-guide에 추가하는게 좋을것 같습니다.

이하 AI가 분석한 결과 전문
---

# Apple 공개 샘플에서 유추한 SwiftUI 코드 스타일 가이드

이 가이드는 Apple이 2019년부터 2025년까지 공개한 5개 sample 프로젝트와 WWDC25 세션/Group Lab 정리본을 종합·검증한 결과입니다.
Apple이 SwiftUI 코드 포맷/분해 방식에 대한 별도 공식 스타일 가이드를 공개하지 않은 만큼, 그들의 코드 자체에서 일관된 패턴을 추출하는 방식으로 작성했습니다.

---

## 1. 분석 대상

**Sample 프로젝트 (5개)**

| 시기 | 프로젝트 | 출처 |
|---|---|---|
| WWDC19/20 | Landmarks (오리지널) | https://github.com/apple-sample-code/SwiftUI-Tutorials |
| WWDC22 | Food Truck | https://github.com/apple/sample-food-truck |
| WWDC22 | NavigationCookbook | https://github.com/apple-sample-code/BringingRobustNavigationStructureToYourSwiftUIApp |
| WWDC23 | Backyard Birds | https://github.com/apple/sample-backyard-birds |
| WWDC25 | **Landmarks (Liquid Glass 신규)** | https://developer.apple.com/documentation/SwiftUI/Landmarks-Building-an-app-with-Liquid-Glass |

**WWDC25 세션/메모**

- Build a SwiftUI app with the new design (Session 323) — https://developer.apple.com/videos/play/wwdc2025/323/
- SwiftUI Group Lab 참석자 정리본 (비공식) — https://gist.github.com/samhenrigold/1e05a0dfa83ca953e7b99219e6bd9615

> Group Lab은 비공식 정리본입니다. 본 가이드에서는 *"Group Lab 메모 기준"*으로 인용하며, Apple 공식 문서와 동급으로 취급하지 않습니다.

---

## 2. 시기별 스타일 진화 — 변한 것과 유지된 것

여러 시기의 코드를 가로질러 봤을 때 Apple SwiftUI 작성 스타일은 놀랄 만큼 일관됩니다. 변한 것은 사용하는 *API*뿐이고, 코드를 *어떻게 배치하고 분해하는지*에 대한 원칙은 거의 그대로입니다.

### 2.1 가장 단순한 케이스 — `ContentView` (WWDC19)

`apple-sample-code/SwiftUI-Tutorials/1 Creating and Combining Views/.../ContentView.swift`

```swift
struct ContentView: View {
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: 300)

CircleImage()
.offset(x: 0, y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}
```

여기서 이미 Apple의 원형 패턴이 다 들어 있습니다. 형제 view들 사이에 빈 줄 1개로 시각적 단락 구분, modifier는 한 줄에 하나로 점(`.`)으로 시작, 부모 `VStack`의 `.padding()`은 닫는 중괄호 다음에 붙임. 안쪽 `VStack(alignment: .leading)`은 *다른 정렬이 필요해서* 존재하는 거지, 단순한 그룹핑 용도가 아닙니다.

### 2.2 본격 분해 패턴 — `RecipeDetail.swift` (WWDC22)

`BringingRobustNavigationStructureToYourSwiftUIApp/.../RecipeDetail.swift`. view decomposition의 정수.

```swift
private struct Content: View {
var recipe: Recipe
var dataModel = DataModel.shared
var relatedLink: (Recipe) -> Link

var body: some View {
ScrollView {
ViewThatFits(in: .horizontal) {
wideDetails
narrowDetails
}
.padding()
}
.navigationTitle(recipe.name)
}

var wideDetails: some View {
VStack(alignment: .leading) {
title
HStack(alignment: .top) {
image
ingredients
Spacer()
}
relatedRecipes
}
}

var narrowDetails: some View { ... }
var title: some View { ... }
var image: some View { ... }

@ViewBuilder
var ingredients: some View { ... }

@ViewBuilder
var relatedRecipes: some View { ... }
}
```

`body`는 "ViewThatFits 안에 wide/narrow가 있다"는 선언문이고, 각 섹션은 한 가지 책임만 가진 `var`. wide/narrow가 같은 building block을 *다르게 조합*한다는 점이 핵심 — **재배치(layout)와 재료(content)의 분리**.

### 2.3 상태 관리의 중심축 이동 — Backyard Birds (WWDC23)

WWDC23부터 `@Observable` 매크로와 SwiftData가 도입되며 Apple 샘플의 상태 관리 중심축이 이동합니다. `ObservableObject`가 사라진 건 아니고 backward compatibility나 Combine 기반 코드에서는 여전히 쓰이지만, 새 샘플의 기본은 `@Observable`로 옮겨갑니다.

```swift
// 이전 (~2022)
class FoodTruckModel: ObservableObject {
@Published public var orders: [Order] = []
}
struct OrdersView: View {
@ObservedObject var model: FoodTruckModel
}
// 주입: .environmentObject(model)

// 이후 (2023+)
@Observable
final class ModelData {
var landmarks: [Landmark] = []
var earnedBadges: [Badge] = []
}
struct BadgesView: View {
@Environment(ModelData.self) private var modelData
}
// 주입: .environment(modelData)
```

`@Observable`의 가장 큰 이득은 **fine-grained invalidation** — view body에서 *실제로 읽은* 프로퍼티에 대해서만 invalidate가 발생합니다.

### 2.4 최신 — WWDC25 Landmarks (Liquid Glass)

```swift
private struct ShowsBadgesViewModifier: ViewModifier {
func body(content: Content) -> some View {
ZStack {
content
HStack {
Spacer()
VStack {
Spacer()
BadgesView()
.padding()
}
}
}
}
}

extension View {
func showsBadges() -> some View {
modifier(ShowsBadgesViewModifier())
}
}
```

```swift
GlassEffectContainer(spacing: Constants.badgeGlassSpacing) {
VStack(alignment: .center, spacing: Constants.badgeButtonTopSpacing) {
if isExpanded {
VStack(spacing: Constants.badgeSpacing) {
ForEach(modelData.earnedBadges) {
BadgeLabel(badge: $0)
// Adds Liquid Glass to the badge.
.glassEffect(.regular, in: .rect(cornerRadius: Constants.badgeCornerRadius))
// Adds an identifier to the badge for animation.
.glassEffectID($0.id, in: namespace)
}
}
}

Button { withAnimation { isExpanded.toggle() } } label: { ... }
.buttonStyle(.glass)
#if os(macOS)
.tint(.clear)
#endif
.glassEffectID("togglebutton", in: namespace)
}
.frame(width: Constants.badgeFrameWidth)
}
```

2025년에 새로 보이는 패턴: `Constants` namespace로 매직 넘버 제거, modifier 위에 의도(intent) 주석, modifier chain 안에 `#if os(macOS)` 인라인, `private struct ViewModifier` + `View extension`으로 메서드 노출.

---

## 3. 12가지 핵심 원칙

### 원칙 1 — 파일은 같은 골격을 따른다

```swift
/*
See LICENSE folder for this sample's licensing information.

Abstract:
A view showing the details for a landmark.
*/

import SwiftUI

struct LandmarkDetail: View {
// 1. 외부 의존성 (@Query, @Environment)
// 2. let/var 입력 (init parameter)
// 3. @State (보통 private)

// 4. computed property

var body: some View { ... }

// 5. 추출된 var someView: some View
}

#Preview { ... }
```

이 샘플들에서는 `// MARK:`보다 빈 줄과 좋은 이름으로 구조를 드러내는 경향이 강합니다.

### 원칙 2 — 프로퍼티 정렬에 위계가 있다

```swift
public struct BackyardList: View {
@Query(sort: \Backyard.creationDate) // 1. 데이터 의존성
private var backyards: [Backyard]

@Environment(\.passIDs.group) private var passGroupID // 2. environment
let isSubscribed: Bool // 3. 외부에서 받은 입력
let onOfferSelection: () -> Void
let backyardLimit: Int

@State private var offerWasDismissed = false // 4. 내부 상태
@State private var showingNewBackyardForm = false
```

내부 상태는 원칙적으로 `@State private var`로 둡니다. Apple 샘플 대부분도 이 패턴을 따릅니다. 일부 샘플(Food Truck `DetailColumn`)에 `private`이 빠진 사례가 있지만, 이는 스타일 원칙이라기보다는 샘플 코드상의 누락으로 보는 편이 자연스럽습니다 — `@State`는 본질적으로 view 내부 구현 상태이고, 외부에서 접근할 이유가 거의 없습니다.

bool 플래그는 영어 문장처럼: `showingProfile`, `isExpanded`, `pulseOrderText`.

### 원칙 3 — `body`는 짧고, 깊이는 ~3단계까지

stack 중첩 처리의 핵심입니다. Apple은 inline 중첩이 3단계를 넘어가기 전에 거의 항상 다음 중 하나를 합니다.

1. **computed property로 추출** (`var someView: some View`)
2. **별도 `struct`로 분리** (자체 상태가 있거나 재사용 가능할 때)
3. **modifier로 변환** (`.background { }` / `.overlay { }`)

WWDC22 `TruckWeatherCard`가 좋은 예 — `body`는 단 6줄이고, 60줄짜리 복잡한 차트 빌더는 별도 `var chart` 안에 들어가 있습니다.

```swift
var body: some View {
VStack {
CardNavigationHeader(...) {
Label("Forecast", systemImage: "cloud.sun")
}

chart
.frame(minHeight: 180)
}
.padding(10)
.background()
.task { ... }
}

var chart: some View {
Chart { ... } // 60줄짜리 복잡한 차트 빌더
}
```

### 원칙 4 — 안쪽 stack은 이유가 있어야 존재한다

Apple은 "그룹으로 묶기 위해" stack을 중첩하지 않습니다. 안쪽 stack이 존재하는 이유는 대체로 다음 중 하나.

| 이유 | 예시 |
|---|---|
| 다른 `alignment`가 필요 | 바깥은 `VStack`, 안은 `VStack(alignment: .leading)` |
| 다른 `spacing`이 필요 | `HStack(spacing: 25)` |
| 다른 `padding`이 필요 | 안쪽 stack에 `.padding()` |
| 자체 modifier 그룹이 필요 | 안쪽만 `.background()` 적용 |

이 중 어느 것에도 해당 안 되면 stack 추가 대신 별도 view로 분리하거나 평평하게 둡니다.

### 원칙 5 — `Spacer()`와 `.frame(maxWidth:)`는 의도가 다른 도구

이 둘은 자주 혼동되지만 Apple 코드에서는 **명확히 다른 의도**로 쓰입니다.

**`Spacer()` — 양 끝으로 밀어내기 (push apart)**

```swift
HStack(alignment: .top) {
Text(landmark.park).font(.subheadline)
Spacer()
Text(landmark.state).font(.subheadline)
}
```

**`.frame(maxWidth: .infinity)` — 폭 채우기 (fill width)**

```swift
// Food Truck OrdersTable.swift
Text(order.id)
.frame(maxWidth: .infinity, alignment: .leading)

// Food Truck CityView.swift
ForecastView(...)
.frame(maxWidth: .infinity)
```

Food Truck 한 프로젝트에서만 `.frame(maxWidth: .infinity)`가 20군데 이상 등장합니다. 둘 다 자주 쓰이며, **의도가 다릅니다**.

**모서리 정렬은 nested stack + Spacer 조합** — 이건 Apple의 시그니처 패턴:

```swift
HStack {
Spacer()
VStack {
Spacer()
actualContent
}
}
```

WWDC25 Landmarks의 `ShowsBadgesViewModifier`가 정확히 이 패턴을 씁니다.

### 원칙 6 — 단일 배경/오버레이는 modifier로, 의미 있는 겹침은 ZStack으로

진짜로 여러 컨텐츠가 의미 있게 겹쳐야 할 때만 `ZStack`을 씁니다. 단일 컨텐츠에 배경/테두리/오버레이를 추가하는 건 modifier로:

```swift
.background {
BackyardSkyView(timeInterval: backyard.timeIntervalOffset)
}
.overlay {
ContainerRelativeShape()
.strokeBorder(.separator, lineWidth: 0.5)
}
.clipShape(.containerRelative)
```

현대 SwiftUI 코드에서는 `.background { }`, `.overlay { }`처럼 trailing closure 형태를 선호하지만, 의도에 따라 다른 형태도 자주 등장합니다.

```swift
.background(Color.red) // single color
.background(in: .circle) // shape만 (default material)
.background(style, in: shape) // style + shape
.background { CustomView() } // closure로 view 생성
.background(alignment: .top) { ... } // alignment + closure
```

API 오버로드 자체가 다양해서, "trailing closure가 무조건 옳다"가 아니라 *표현하려는 의도에 가장 적합한 형태*를 고릅니다.

### 원칙 7 — Modifier chain은 의미 단위로 그룹핑

Apple 코드에서 modifier 순서는 *고정 규칙*이 아니지만 **의미 단위 그룹핑** 경향이 있습니다.
아래는 이상적인 그룹핑 예시입니다. 실제 코드는 modifier의 의미, 적용 범위, 가독성에 따라 더 유연하게 배치됩니다.

```swift
view
// 레이아웃 그룹
.frame()
.padding()

// 외관 그룹
.background()
.foregroundStyle()
.font()
.clipShape()

// 인터랙션/라이프사이클 그룹
.onTapGesture { }
.onAppear { }
.task { }
.onChange(of:) { }

// navigation/표시 그룹
.navigationTitle()
.toolbar { }
.sheet(...) { }
```

그래서 실제 Apple 코드에서는 이 순서가 자주 뒤섞입니다. 예를 들어 Food Truck `OrderDetailView`는 `.navigationTitle` → `.sheet` → `.onChange` → `.toolbar` 순서로 navigation modifier가 먼저 나옵니다.

```swift
// Food Truck OrderDetailView.swift
List { ... }
.navigationTitle(order.id) // navigation 먼저
.sheet(isPresented: $presentingCompletionSheet) { ... }
.onChange(of: order.status) { ... }
.toolbar { ... }
```

핵심: 절대적 순서가 아니라 **레이아웃 → 외관 → 동작/표시**의 *대략적 흐름*을 따르되 readable하게 묶는다는 것.

### 원칙 8 — System-provided 컴포넌트를 먼저 쓴다

WWDC25 Session 323의 핵심 메시지입니다.

> "The best way to adopt the new design is to use standard app structures, toolbars, search placements, and controls."

Apple 스타일의 핵심 중 하나는 **custom UI를 먼저 만들지 않는다**는 것입니다. `NavigationSplitView`, `TabView`, `ToolbarItem`, `Button`, `Label`, `searchable`, `Form`, `List`, `Section`, `Menu` 같은 system-provided structure/control을 먼저 채택해야 합니다. 새 디자인 시스템(Liquid Glass)의 상당 부분은 표준 컨트롤을 쓰면 자동으로 따라옵니다.

custom Liquid Glass 요소(`glassEffect`, `GlassEffectContainer`)는 표준 컴포넌트로 표현이 안 될 때 *보조*로만 사용합니다. WWDC25 Lab의 Anna도 동일하게 권고:

> "I would recommend taking a look at if you're able to use the standard system controls for what you're trying to build... If you are looking to do something that is way more custom, then I would take a look at the new glass APIs."

### 원칙 9 — 상태 관리는 Observation + Environment 주입을 기본값으로 둔다 (iOS 17+)

`@Observable` 기반 reference model을 다룰 때의 매핑입니다. `ObservableObject`를 계속 쓰는 타입이라면 여전히 `@StateObject`/`@ObservedObject`/`@EnvironmentObject`가 맞습니다.

| 구버전 ObservableObject 패턴 | iOS 17+ Observation 패턴 |
|---|---|
| `class Foo: ObservableObject` + `@Published var` | `@Observable class Foo` + 그냥 stored property |
| `@StateObject var foo` | `@State private var foo` |
| `@EnvironmentObject var foo: Foo` | `@Environment(Foo.self) private var foo` |
| `.environmentObject(foo)` | `.environment(foo)` |
| `@ObservedObject var foo: Foo` | 그냥 `var foo: Foo` |

`@Observable` 객체를 단순히 읽거나 메서드를 호출할 때는 그냥 `var`/`let` 또는 `@Environment(Type.self)`로 충분합니다. Binding이 필요할 때만 `@Bindable`을 추가합니다.

**자식 view가 model을 직접 받아서 binding을 만들 때**
```swift
struct LandmarkEditor: View {
@Bindable var landmark: Landmark

var body: some View {
TextField("Name", text: $landmark.name)
}
}
```

**Environment model에서 binding을 만들 때**
`@Environment`로 받은 값 자체에는 바로 `$model.property`를 만들 수 없으므로, body 안에서 `@Bindable` shadow copy를 만듭니다.
```swift
struct ProfileEditor: View {
@Environment(ModelData.self) private var modelData

var body: some View {
@Bindable var modelData = modelData

TextField("Name", text: $modelData.profile.name)
}
}
```
이 패턴은 `@Environment(Type.self)`와 `@Bindable`을 함께 쓸 때 중요합니다. 즉, `@Environment`는 dependency lookup이고, `@Bindable`은 그 observable object에서 binding projection을 꺼내기 위한 도구입니다.

**Binding이 필요 없는 경우에는 @Bindable을 붙이지 않습니다.**
```swift
struct BadgeList: View {
@Environment(ModelData.self) private var modelData

var body: some View {
ForEach(modelData.earnedBadges) { badge in
BadgeLabel(badge: badge)
}
}
}
```

**`shared` singleton에 대한 nuance** — WWDC25 Lab에서 Nick이 `public static let shared`를 피하라고 한 건 맞지만, 이는 **앱의 핵심 mutable model**에 대한 권고입니다. Apple 샘플 자체는 다음과 같이 구분됩니다:

```swift
// framework-provided singleton — 자연스럽게 사용
WeatherService.shared.weather(...)

// app-defined service/actor singleton — 신중히 사용
StoreActor.shared.subscriptionController // (실제로 stateful)

// 샘플/튜토리얼 편의 — production app에서는 environment 주입 권장
DataModel.shared
```

정리하면: **앱의 핵심 mutable model은 environment 주입을 우선한다. framework-provided singleton은 자연스럽게 사용하고, app-defined shared service/actor는 테스트 가능성과 교체 가능성을 해치지 않는 범위에서 제한적으로 사용한다**.

### 원칙 10 — `var` 추출 vs `private struct` 분리는 state isolation으로 결정

view 조각이 자체 상태(`@State`, `@Namespace`, `@FocusState`)를 가져야 하는가가 기준입니다.

**기준 A — `var someName: some View`로 추출**: 자체 상태 없이 부모의 데이터를 *조합/표시*만 할 때

```swift
struct TruckWeatherCard: View {
@State private var forecast: TruckWeatherForecast = placeholderForecast

var body: some View {
VStack {
CardNavigationHeader(...) { ... }
chart
.frame(minHeight: 180)
}
}

var chart: some View {
Chart { ... } // 60줄이지만 자체 상태 없음
}
}
```

**기준 B — `private struct`로 분리**: 자체 `@State`/`@Namespace`/`@FocusState`가 필요할 때

```swift
struct HikeView: View {
@State private var showDetail = false // 자체 상태
var body: some View { ... }
}

// 또는 같은 파일 내부에 helper로:
struct RecipeDetail: View {
var body: some View {
ZStack {
if let recipe = recipe {
Content(recipe: recipe, ...) // private struct 분리
} else {
Text("Choose a recipe")
}
}
}
}

private struct Content: View {
var body: some View { ... }
var wideDetails: some View { ... }
var narrowDetails: some View { ... }
}
```

**왜 중요한가** — SwiftUI는 view tree에서 각 `struct View`를 개별 노드로 다룹니다. 자체 `@State`를 가지려면 그 노드가 identity를 가져야 하고, 그러려면 `struct`여야 합니다. struct로 분리하면 state identity와 관찰 범위가 더 명확해지고, SwiftUI가 변경 범위를 좁혀 최적화할 여지가 생깁니다. 다만 struct 분리 자체가 항상 body 재평가 생략을 보장하는 것은 아닙니다 — 실제 evaluation은 SwiftUI 내부 최적화와 데이터 의존성에 따라 달라집니다.

### 원칙 11 — Preview는 모델 측 factory로 mock 데이터 주입

```swift
// 데이터 모델 측
@Observable
final class ModelData {
static var preview: ModelData {
let data = ModelData()
data.landmarks = Landmark.previewSamples
data.earnedBadges = Badge.previewSamples
return data
}
}

extension Landmark {
static let preview = Landmark(name: "Mount Fuji", ...)
static let previewSamples = [Landmark.preview, ...]
}

// 여러 상태를 한 화면에서 비교
#Preview("Empty") {
BadgesView()
.environment(ModelData())
}

#Preview("With badges") {
BadgesView()
.environment(ModelData.preview)
}

#Preview("Landscape", traits: .landscapeLeft) {
LandmarkDetailView(landmark: .preview)
.environment(ModelData.preview)
}
```

view 안에 mock 데이터를 하드코딩하지 않고 model 측에 factory를 둡니다. WWDC25 Lab의 Anna가 강조한 부분:

> "Play with your previews... you can change the device, change the orientation, change the Dynamic Type size."

### 원칙 12 — `body`에서 expensive work 금지

WWDC25 Lab에서 Nick이 강조한 성능 원칙입니다.

> "Avoid expensive work in view bodies. The instrument can help reveal when maybe you're doing that when you didn't even realize."

`body`는 *선언적 outline*으로 유지하고, 다음과 같은 작업은 절대 들어가지 않게 합니다.

- 이미지 디코딩 / 다운스케일
- 무거운 계산이나 정렬
- 네트워크 요청 트리거 / 요청 생성에 필요한 무거운 작업
- 큰 컬렉션의 반복적인 filter/sort/map, 특히 body 호출마다 결과가 달라지거나 비용이 큰 변환

이런 작업은 model 또는 `.task { }` 안으로 옮깁니다. `body` 자체는 SwiftUI가 매우 자주 호출하므로, 거기서 expensive work를 하면 scrolling jank의 직접적 원인이 됩니다.

WWDC25 Lab에서 Nick이 추천한 디버깅 팁 — **disco ball 기법**:

```swift
// 의심 view에 무작위 배경색을 깔아본다
.background(Color(red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)))

// 스크롤 시 화면이 디스코볼처럼 깜빡거리면 over-invalidation 발생 중
```

---

## 4. Liquid Glass 마이그레이션 시 추가 체크사항

WWDC25 Session 323에서 Apple이 명시적으로 권고한 Liquid Glass 적용 시 주의사항입니다.

**`tint`는 의미 전달용으로만**

새 디자인 시스템은 monochrome 렌더링을 우선합니다. tint를 단순 시각 효과나 브랜딩으로 쓰지 말고, 의미 있는 action, state, next step을 전달할 때만 사용합니다. `tint(.red)`로 strokes/highlight를 잔뜩 뿌리던 이전 스타일은 새 디자인과 충돌합니다.

**기존 배경/darkening 효과 audit**

iOS 26/macOS Tahoe에서 system toolbar/sheet/sidebar는 자체 glass 처리와 scroll-edge effect를 합니다. 이전에 직접 넣어둔 다음 요소들은 새 시스템과 충돌하므로 **제거 가능 여부를 audit**하세요.

- toolbar 뒤의 darkening overlay
- sheet의 `presentationBackground` custom 설정
- 브랜딩용 navigation bar tinting (대신 콘텐츠 영역 자체에 색을 두면 glass가 그것을 반영)
- scroll view content 위쪽의 fade gradient

**Glass 위에 Glass 금지**

Toolbar 같은 시스템 glass 위에 또 glass가 깔리면 시각적으로 깨집니다. `GlassEffectContainer`로 묶고, scrolling content가 toolbar와 겹칠 가능성을 미리 점검합니다.

---

## 5. WWDC25 Group Lab 메모 — 안티패턴 정리

> 출처: https://gist.github.com/samhenrigold/1e05a0dfa83ca953e7b99219e6bd9615 (참석자 정리본, 비공식)

**조건부 modifier 피하기 (Taylor)** — `if`로 modifier를 붙였다 떼는 건 view identity를 바꿔서 state/animation이 reset됩니다.

```swift
// ❌
if isHighlighted { text.foregroundColor(.red) } else { text }

// ✅
text.foregroundColor(isHighlighted ? .red : .primary)
text.opacity(condition ? 1 : 0) // inert variant
```

**`NavigationStack`을 `if`로 바꾸지 않기 (Nick, Anna)**

```swift
// ❌
if isLoggedIn {
NavigationStack { HomeView() }
} else {
LoginView()
}

// ✅
NavigationStack {
if isLoggedIn { HomeView() } else { LoginView() }
}
```

**`@Observable`은 작은 단위로 (Taylor)** — 큰 struct 하나로 들고 다니지 말고 작은 조각으로 나눠야 fine-grained invalidation의 이득을 봅니다.

**Custom control은 `accessibilityRepresentation { }` (Sommer)**

**디버깅: `Self._printChanges()` (Anna)**

```swift
var body: some View {
let _ = Self._printChanges()
// ...
}
```

**ForEach는 일관된 개수의 view 반환 (Nick)** — 여러 item을 한 row로 보이게 하려면 `VStack`으로 감싸기.

---

## 6. 명명 규칙

| 종류 | Apple 스타일 | 흔한 실수 |
|---|---|---|
| View struct | `LandmarkDetail`, `CategoryRow`, `BackyardSupplyGauge` | `LandmarkDetailView`, `CategoryRowView` |
| 추출된 view 프로퍼티 | `var profileButton`, `var chart`, `var ingredients` | `var profileButtonView`, `var makeChart()` |
| Bool 상태 | `showingProfile`, `isExpanded`, `pulseOrderText` | `profileShown`, `expandFlag` |
| Preview | `#Preview { }` (Xcode 15+) / `_Previews` (구) | `LandmarkDetailPreview` |
| ViewModifier 타입 | `ShowsBadgesViewModifier` | `BadgeAdder`, `BadgeStyle` |
| Modifier 메서드 | `.showsBadges()`, `.backyardViewportContent(.floor)` | `.applyBadges()`, `.setBackyardContent()` |
| Constants | `Constants.badgeCornerRadius` | raw `16.0` |

`View` 접미사를 안 붙이는 게 가장 두드러진 차이입니다 — Apple은 SwiftUI struct의 타입이 이미 `View`임을 알기에 이름에 다시 적지 않습니다. 단, 데이터 모델과 이름 충돌이 날 때만 예외(`MapView` 등).

ViewModifier 타입은 “무엇을 하는 modifier인가”가 드러나도록 `...ViewModifier` 형태로 두고, 외부 사용자는 `View` extension 메서드로 읽게 만듭니다.
```swift
private struct ShowsBadgesViewModifier: ViewModifier { ... }

extension View {
func showsBadges() -> some View {
modifier(ShowsBadgesViewModifier())
}
}
```

---

## 7. 실전 적용 체크리스트

**파일 단위로**

- 파일 맨 위에 `Abstract: ...` 한 문장 주석을 적기
- 한 파일에 한 메인 `struct`, 그리고 `#Preview`. 보조용 작은 struct는 같은 파일 OK
- `import`는 최소한으로

**프로퍼티 정렬**

- 외부 의존성 → 외부 입력(let) → 내부 상태(`@State private`)
- `@State`는 기본적으로 `private`으로 선언
- 외부에서 읽히거나 변경되어야 하는 상태라면 `@State`가 아니라 입력값, `@Binding`, observable model, environment 주입이 맞는지 먼저 의심
- bool은 영어 문장처럼 (`showingFoo`, `isBarExpanded`)

**상태 관리 (iOS 17+, `@Observable` 기반)**

- 모델 클래스에 `@Observable` 매크로 (새로 작성하는 코드)
- `@StateObject` → `@State` (단, 그 모델이 `@Observable` 기반일 때만)
- `@EnvironmentObject` → `@Environment(Type.self) private var`
- `.environmentObject()` → `.environment()`
- binding 필요하면 `@Bindable`
- 핵심 mutable model에 `public static let shared` 피하기 — environment 주입
- framework-provided singleton(`WeatherService.shared` 등)은 자연스럽게 사용
- app-defined shared actor/service는 테스트 가능성과 교체 가능성을 해치지 않는 범위에서

**body 작성**

- 팀 컨벤션으로 `body`가 30줄 넘기 시작하면 추출을 검토 (Apple 규칙은 아님 — 실용 기준)
- 형제 view 사이에 빈 줄 1개
- modifier는 한 줄에 하나
- 부모 stack의 modifier는 닫는 괄호 *다음*에
- **expensive work 절대 금지** — 이미지 디코딩, 무거운 계산, 정렬은 model이나 `.task`로 옮김

**view 분해 (`var` vs `struct`)**

- 자체 상태(`@State`/`@Namespace`/`@FocusState`) 없으면 `var someName: some View`
- 자체 상태 있으면 `private struct` 또는 별도 파일 `struct`
- 다른 곳에서 재사용하면 별도 파일 `struct`
- modifier로 감싸고 싶으면 `struct ...: ViewModifier` + `extension View`

**stack 중첩**

- inline stack 깊이는 3단계까지가 한계선
- 안쪽 stack은 다른 alignment/spacing/padding/modifier가 필요할 때만
- 단순한 그룹핑이면 stack 대신 별도 View로 분리
- **`Spacer()`** = 양 끝 정렬 (push apart)
- **`.frame(maxWidth: .infinity)`** = 폭 채우기 (fill width). 둘은 의도가 다름
- 모서리 정렬은 nested stack + Spacer 조합 (`HStack { Spacer(); VStack { Spacer(); content } }`)
- 단일 view 위에 배경/테두리는 `.background { }` / `.overlay { }`
- `ZStack`은 진짜로 여러 컨텐츠가 의미 있게 겹칠 때만

**modifier chain**

- 의미 단위 그룹핑: 레이아웃 → 외관 → 동작/라이프사이클 → navigation/표시
- 절대적 순서는 없음. readable하게 묶기
- 매직 넘버는 `Constants` namespace로 묶어서
- 의도가 자명하지 않은 modifier는 위에 한 줄 주석
- 플랫폼 분기는 `#if os(macOS)`를 chain 안에 인라인

**조건부 처리**

- 조건부 modifier 대신 inert variant 사용 (`opacity`, `tint(condition ? .red : .primary)` 등)
- `NavigationStack`이나 컨테이너 자체를 `if`로 바꾸지 않기 — 안의 컨텐츠를 바꾸기

**컴포넌트 선택**

- **System-provided가 항상 먼저** — `NavigationSplitView`, `TabView`, `ToolbarItem`, `Button`, `searchable`, `Form`, `List`, `Section`, `Menu`
- Custom UI는 표준으로 표현 안 될 때만 보조 수단
- iOS 26/macOS Tahoe에서는 `tint`를 의미 전달용으로만 사용
- 새 디자인 시스템 도입 시 기존 toolbar 배경/sheet background/scroll fade 등을 audit하고 제거

**Preview**

- 모델에 `static var preview` factory 만들기
- view 안에 mock 데이터 하드코딩 금지
- `#Preview("이름")`으로 여러 상태를 한 번에 비교
- 다양한 환경(`landscape`, `Dynamic Type`)을 trait으로 prefab

**명명**

- View struct 이름에 `View` 접미사 안 붙이기
- 추출된 view 프로퍼티는 명사 (`chart`, `header`, `ingredients`)
- 커스텀 modifier는 `View` extension으로 메서드 형태로

---

## 8. 마무리

Apple SwiftUI 스타일의 본질은 한 줄로 — **"`body`가 화면의 outline처럼 읽히도록 만들고, 디테일은 아래로 위임한다"**입니다.

위임 대상은 세 가지: (1) computed property로 추출된 sub-view, (2) 별도 struct로 분리된 child view, (3) view modifier. 각각 어디에 쓰는지의 기준은 **state isolation의 필요성**으로 결정됩니다 — 자체 상태가 없으면 `var`, 있으면 `struct`, view 자체 변형이면 `ViewModifier`.

WWDC19 Landmarks와 WWDC25 Liquid Glass Landmarks를 나란히 놓고 보면 사용한 API의 절반 이상이 바뀌었습니다 (`@EnvironmentObject` → `@Observable`+`@Environment`, `.background(SomeView())` → `.background { }`, `PreviewProvider` → `#Preview`, raw 숫자 → `Constants` namespace). 그러나 코드를 어떻게 분해하고 어떻게 들여쓰는지의 원칙은 거의 그대로입니다. 이게 Apple이 명시적 스타일 가이드 없이도 일관된 코드를 유지하는 이유라고 봅니다.

위 12가지 원칙 중에서도 다음 5가지를 코드 리뷰 기준으로 삼으면 가장 즉각적인 효과를 볼 수 있습니다.

- **원칙 3** (`body` 짧게 + 3단계 깊이 한계)
- **원칙 4** (안쪽 stack의 존재 이유)
- **원칙 8** (System-provided 먼저)
- **원칙 10** (`var` vs `struct`의 state isolation 기준)
- **원칙 12** (body에 expensive work 금지)

---

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.