iOS, SwiftUI Guide (AI generated)
제가 예전에 공부 내역을 정리한 포스팅을 GPT 4.5 Deep Research로 내용을 정리해달라 한 문서입니다.
목차Permalink
-
Swift 문법 기초 및 고급 개념 – 변수, 상수, 자료형, 옵셔널, 제어 흐름, 함수/클로저, 구조체와 클래스, 열거형, 프로토콜/익스텐션, 제네릭, 오류 처리 등의 언어 문법
-
SwiftUI 기초 및 주요 뷰 컴포넌트 – 선언적 UI 개념 소개, Text·Image·Button 등 기본 뷰와 뷰 조합, 뷰 수정자 사용 방법
-
뷰 레이아웃 시스템 – Stack을 이용한 배치, Alignment 정렬, .frame과 Offset/Position 차이, PreferenceKey를 통한 레이아웃 정보 전달
-
애니메이션 – 암시적/명시적 애니메이션 차이, withAnimation 사용, 전환(Transition) 효과 적용, AnimatableModifier와 고급 애니메이션
-
상태 관리 – @State와 @Binding, @ObservedObject·@StateObject와 ObservableObject, @EnvironmentObject를 통한 상태 공유 패턴
-
Environment와 프로퍼티 래퍼 – @Environment로 시스템 환경 값 사용, 커스텀 EnvironmentKey, 프로퍼티 래퍼 개념과 활용 (예: @AppStorage, @SceneStorage 등)
-
내비게이션 – NavigationStack/NavigationLink를 통한 화면 전환, 새로운 NavigationPath API, 모달 시트(.sheet) 사용
-
UIKit 연동 – UIViewRepresentable/UIViewControllerRepresentable로 UIKit 뷰 사용, UIHostingController로 SwiftUI 뷰를 UIKit에 삽입, Coordinator 활용
-
데이터 저장: AppStorage, SceneStorage, UserDefaults – @AppStorage와 @SceneStorage로 간단한 영구 저장/복원, UserDefaults 직접 사용 비교
-
동시성(Concurrency) – GCD와 RunLoop, Swift 5.5의 async/await 및 Task, Actor를 통한 데이터 경쟁 방지
-
Combine 프레임워크 – Publisher/Subscriber 개념, 연산자 체인, Subject와 Scheduler, 새로운 async/await와의 관계
1. Swift 문법: 기초부터 고급까지Permalink
변수와 상수Permalink
Swift에서 변수는 var
키워드로 선언하며, 상수는 let
키워드로 선언합니다. 변수는 값을 변경할 수 있지만 상수는 한 번 값을 설정하면 변경할 수 없습니다. Swift는 타입 추론(type inference)을 지원하여 초기 값에 따라 변수의 타입을 자동으로 결정합니다. 예를 들어:
var greeting = "Hello" // 타입 추론으로 greeting은 String 타입이 됩니다
let year: Int = 2025 // year은 Int 타입의 상수로 값 2025를 갖습니다
// year = 2026 // 오류: 상수의 값은 변경할 수 없음
위 코드에서 greeting
은 타입을 명시하지 않았지만 문자열로 초기화했기 때문에 컴파일러가 String
타입으로 추론합니다. Swift는 강한 타입 안전성(type safety)을 갖춘 언어로, 한 번 정해진 타입에는 다른 타입의 값을 잘못 전달할 수 없도록 컴파일 단계에서 검증합니다. 예를 들어 welcomeMessage
가 String
으로 선언되었다면, 실수로 Int
값을 대입하면 컴파일 오류가 발생합니다.
자료형과 타입 안전성Permalink
Swift는 기본 자료형으로 Int
, Double
, Bool
, String
등을 제공합니다. 모든 변수는 반드시 특정 타입을 갖으며, 선언 시에 명시적으로 타입을 지정하거나, 앞서 설명한 타입 추론을 통해 결정됩니다. 타입 안전성 덕분에, 정수형 변수에 문자열이 들어가는 등의 잘못을 방지할 수 있습니다. 또한 Swift는 타입 추론으로 불필요한 타입 표기를 줄여주지만, 여전히 코드의 타입 일관성을 엄격하게 유지합니다.
var message: String // 타입 선언만 하고 초기화하지 않은 상태 (현재 값 없음)
message = "안녕하세요" // 나중에 String 값을 할당
// message = 123 // 오류: message는 String 타입이라 숫자를 할당할 수 없음
옵셔널(Optional)과 안전한 값 처리Permalink
Swift의 옵셔널(Optional)은 값이 있을 수도 있고 없을 수도 있음을 표현하는 타입입니다. 옵셔널은 일반 타입에 ?
를 붙여서 표현합니다 (예: String?
는 “String 또는 nil”을 의미). 옵셔널을 사용하면 nil (값이 없음)을 안전하게 처리할 수 있으며, Swift는 옵셔널이 아닌 타입에는 nil을 직접 대입할 수 없게 하여 런타임 오류를 방지합니다.
옵셔널 변수를 사용할 때는 값을 꺼내기 위해 언래핑(unwrapping)이 필요합니다. 가장 흔한 방법은 if-let 구문을 사용하는 옵셔널 바인딩(optional binding)입니다:
var username: String? = nil
if let name = username {
// name은 옵셔널이 아닌 String으로 안전하게 언래핑되었음
print("사용자 이름: \(name)")
} else {
print("사용자 이름이 설정되지 않았습니다.")
}
위 코드에서 username
이 nil
인 경우 else 블록이 실행되고, 값이 있다면 name
상수에 그 값을 풀어서 사용할 수 있습니다. 이처럼 옵셔널을 통해 “값이 존재하면 처리하고, 없으면 다른 동작을 한다”는 로직을 명확하게 작성할 수 있습니다. 옵셔널은 열거형으로 구현되어 Optional.some(값)
또는 Optional.none(nil)
의 형태를 가지며, “값이 있다(x와 같다)” 또는 “값이 전혀 없다”라는 두 가지 상태를 표현합니다.
또 다른 언래핑 방법으로 강제 언래핑(forced unwrapping) !
연산자가 있지만, 이는 옵셔널에 값이 없을 경우 런타임 오류를 발생시키므로 필요할 때만 신중히 사용해야 합니다.
제어 흐름 (if, guard, switch, 반복문)Permalink
Swift는 익숙한 제어문을 제공하며, 표현식의 결과에 따라 코드 흐름을 제어합니다.
-
if
문: 조건이true
일 때 블록을 실행합니다. 조건은 반드시Bool
타입이어야 하며, 암시적 형 변환이 없으므로(someNumber != 0)
처럼 명시적으로 작성해야 합니다. -
guard
문: 특정 조건이 참임을 보장하지 못하면 함수에서 이탈(Early Exit)시키는 문법입니다. 주로 함수 시작 부분에서 조건 검증 후 아니면return
/throw
등을 수행합니다.guard condition else { ... }
형태로 사용합니다. -
switch
문: 다양한 경우(case)에 대해 패턴 매칭을 수행합니다. C나 Java와 달리 switch의 case에는 암시적인 fallthrough(다음 case로 계속 진행)가 없으며, 하나의 case 실행이 끝나면 switch문이 종료됩니다. 필요하다면fallthrough
키워드로 명시 가능합니다. 또한 정수뿐만 아니라 문자열, 열거형, 조건 패턴 등 매우 다양한 패턴을 매칭할 수 있어 강력합니다.
예시:
let score = 87
switch score {
case 0:
print("점수 0점")
case 1..<60:
print("낙제") // 1부터 59까지
case 60..<90:
print("합격") // 60부터 89까지 실행 (87이라 이 블록 실행)
case 90...100:
print("우수")
default:
print("유효하지 않은 점수")
}
- 반복문:
for-in
루프는 시퀀스(예: 배열, 범위 등)를 순회하며,while
과repeat-while
은 조건에 따라 반복합니다. Swift에서는 C 스타일의for(;;)
루프는 지원하지 않습니다.
let names = ["Alice", "Bob", "Charlie"]
for name in names {
print("이름: \(name)")
}
함수와 클로저 (Closures)Permalink
Swift에서 함수(function)는 일급 객체로, 변수처럼 전달하거나 중첩 정의할 수 있습니다. 함수는 func
키워드로 정의하며 매개변수 타입과 반환 타입을 명시합니다. 또한 매개변수 이름을 외부에서 사용할 레이블과 내부에서 사용할 이름 두 가지로 지정할 수 있습니다 (예: func greet(to name: String)
에서 외부 인자명은 to
, 내부에서는 name
으로 사용).
예시 함수:
func add(a: Int, b: Int) -> Int {
return a + b
}
print( add(a: 3, b: 5) ) // 8 출력
외부 인자 이름을 사용하기 싫다면 _
를 써서 생략할 수도 있습니다 (예: func add(_ a: Int, _ b: Int) -> Int
).
클로저(Closure)는 함수의 형태를 띤 익명 함수로, 실행 가능한 코드 블록을 의미합니다. Swift의 클로저는 함수형 프로그래밍 스타일을 쉽게 구현할 수 있게 해주며, 특히 컬렉션의 고차 함수(map, filter, reduce 등)와 함께 자주 사용됩니다. 클로저는 { (매개변수) -> 반환타입 in 실행코드 }
형태로 작성됩니다. 예를 들어 배열의 각 요소를 2배로 만드는 클로저 활용:
let numbers = [1, 2, 3]
let doubled = numbers.map { (num: Int) -> Int in
return num * 2
}
print(doubled) // [2, 4, 6]
위 예에서 map
함수에 전달된 클로저는 num
을 받아 num * 2
를 반환합니다. Swift의 타입 추론으로 매개변수와 반환 타입을 생략할 수도 있고, $0
와 같은 축약 인자를 사용할 수도 있습니다. 위 클로저는 { $0 * 2 }
로 더욱 간결하게 쓸 수 있습니다.
클로저는 값을 캡처(capture) 할 수 있어서, 클로저 정의 시점에 존재하는 외부 변수/상수에 접근하고 저장할 수도 있습니다. 이 특성은 콜백이나 비동기 작업 후 실행할 코드 블록을 작성할 때 유용합니다.
구조체와 클래스 (값 타입 vs 참조 타입)Permalink
Swift에는 구조체(struct)와 클래스(class) 두 가지 사용자 정의 데이터 타입이 있습니다. 문법적으로 유사하지만 중요한 차이가 있는데, 구조체는 값 타입(Value Type)이고 클래스는 참조 타입(Reference Type)이라는 점입니다.
-
구조체 (Struct): 값 타입이라서 인스턴스를 변수에 할당하거나 함수에 전달할 때 복사가 일어납니다. 따라서 하나의 구조체 인스턴스를 복사한 두 변수는 서로 독립적인 메모리를 가집니다. Swift의 기본 타입들(Int, String 등)은 모두 구조체로 구현되어 값 타입의 특성을 가집니다.
-
클래스 (Class): 참조 타입이라서 인스턴스를 여러 변수가 가리킬 때 모두 동일한 객체를 참조합니다. 하나의 변수가 인스턴스의 속성을 변경하면 다른 참조들도 그 변경을 볼 수 있습니다. 또한 클래스는 상속(inheritance)을 지원하고, deinit 등 구조체에는 없는 기능을 제공합니다.
예를 들어 아래 코드에서 구조체와 클래스의 동작 차이를 살펴볼 수 있습니다:
struct Point { var x: Int, y: Int }
class Person { var age: Int = 0 }
var pt1 = Point(x: 1, y: 2)
var pt2 = pt1 // 구조체 값 복사
pt2.x = 10
print(pt1.x) // 1 – pt1은 영향을 받지 않음 (값 복사 되었기 때문)
let personA = Person()
personA.age = 25
let personB = personA // 클래스 인스턴스 참조 복사
personB.age = 30
print(personA.age) // 30 – personA와 personB가 같은 객체를 가리킴
위에서 pt2
는 pt1
의 복사본이므로 pt2.x
를 변경해도 pt1.x
는 그대로입니다. 하지만 personA
와 personB
는 동일한 Person
인스턴스를 가리키므로 personB
를 통해 나이를 변경하면 personA.age
값도 바뀌게 됩니다.
구조체와 클래스 모두 속성(properties)과 메서드(methods)를 정의할 수 있고, 인스턴스를 통해 점(.
) 문법으로 접근합니다. 구조체는 상속이 불가능하지만, 대신 Swift의 프로토콜을 통해 기능을 추가할 수 있습니다. 클래스는 다른 클래스를 상속할 수 있으며, 레퍼런스 카운팅을 통한 메모리 관리(ARC)가 적용됩니다.
열거형 (Enumerations)과 연관 값Permalink
Swift의 열거형(enum)은 관련된 값들의 그룹을 타입으로 정의하며, 각 열거 case가 raw value(원시값)를 가지거나 또는 연관 값(Associated Value)을 가질 수 있습니다. 열거형은 값 타입입니다.
예시:
enum Direction {
case north
case south
case east
case west
}
let dir: Direction = .east
enum Result {
case success(data: String)
case failure(errorCode: Int)
}
let result = Result.success(data: "정상 완료")
Direction
열거형은 단순히 4방향을 case로 갖고, Result
열거형은 연관 값으로 성공 시 문자열 데이터, 실패 시 에러 코드를 함께 저장합니다.
열거형은 switch
문과 함께 사용하면 각 case별로 분기 처리가 쉬워지며, 연관 값도 패턴 매칭으로 꺼낼 수 있습니다:
switch result {
case .success(let data):
print("성공: \(data)")
case .failure(let code):
print("실패: 오류 코드 \(code)")
}
프로토콜과 익스텐션 (Protocols & Extensions)Permalink
프로토콜(Protocol)은 특정 기능을 수행하기 위한 메서드, 프로퍼티 등의 요구사항을 청사진(blueprint)으로 정의하는 타입입니다. 클래스, 구조체, 열거형은 프로토콜을 채택(conform)하여 프로토콜이 요구하는 기능을 구현할 수 있습니다. 프로토콜은 다중 채택이 가능하여, 하나의 타입이 여러 프로토콜의 요구사항을 모두 구현하도록 할 수 있습니다. Swift 표준 라이브러리에서도 Equatable
, Codable
, Sequence
등 다양한 프로토콜을 제공하며, 이를 채택하면 해당 기능(예: Equatable 채택 시 ==
비교 지원)을 쉽게 추가할 수 있습니다.
예를 들어, 커스텀 타입에 CustomStringConvertible
프로토콜을 채택하면 description
프로퍼티를 구현하여 객체를 문자열로 표현할 수 있습니다:
struct Person: CustomStringConvertible {
var name: String
var description: String {
return "이름: \(name)"
}
}
print(Person(name: "홍길동"))
// 출력: 이름: 홍길동
위에서 Person
은 CustomStringConvertible
프로토콜을 채택하여 description을 구현했고, print
로 출력할 때 해당 description 문자열을 사용합니다.
익스텐션(Extension)은 기존 타입(구조체, 클래스, 열거형, 프로토콜 등)에 새로운 기능을 추가하는 기능입니다. 소스 코드를 수정할 수 없는 타입(예: Int, String 같은 기본 타입)에도 메서드나 계산 프로퍼티 등을 확장할 수 있습니다. 익스텐션은 Objective-C의 category와 유사한 개념이지만, 저장 프로퍼티를 추가할 수 없고 계산 프로퍼티나 메서드, 생성자 등을 추가할 수 있습니다.
예를 들어, Int
타입에 제곱 기능을 추가하는 익스텐션:
extension Int {
var squared: Int {
return self * self
}
}
print(5.squared) // 25
익스텐션을 통해 Int 타입에 squared
라는 계산 프로퍼티를 추가했고, 이를 통해 정수의 제곱 값을 손쉽게 구할 수 있습니다.
프로토콜과 익스텐션을 조합하면 프로토콜 지향 프로그래밍 패러다임을 활용할 수 있습니다. 즉, 프로토콜로 인터페이스를 정의하고 익스텐션으로 기본 구현을 제공하여 코드 재사용과 추상화를 용이하게 합니다.
제네릭(Generics)과 타입 파라미터Permalink
제네릭(Generics)은 재사용 가능한 유연한 함수와 타입을 작성할 수 있게 해주는 기능입니다. 동일한 로직을 가지면서도 다양한 타입에 대해 동작할 수 있도록, 함수나 타입을 타입 파라미터로 일반화합니다. 예를 들어, 배열의 요소를 뒤집는 함수를 제네릭으로 만든다면, Int 배열이든 String 배열이든 같은 함수가 동작하도록 구현할 수 있습니다.
아래는 제네릭 함수를 이용해 배열의 첫 요소를 찾아 반환하는 예시입니다:
func firstElement<T>(of array: [T]) -> T? {
return array.isEmpty ? nil : array[0]
}
let intArray = [1, 2, 3]
let strArray = ["A", "B", "C"]
print(firstElement(of: intArray)) // 출력: Optional(1)
print(firstElement(of: strArray)) // 출력: Optional("A")
firstElement(of:)
함수는 타입 매개변수 T
를 사용하여, T
의 배열을 받아 T
타입의 옵셔널을 반환합니다. 호출 시에 intArray
를 넣으면 T
가 Int
로, strArray
를 넣으면 T
가 String
으로 각각 구체화되어 작동합니다. 이처럼 제네릭 함수는 코드를 중복 작성하지 않고도 여러 타입에 활용할 수 있습니다.
제네릭은 타입 제약(type constraint)을 지정하여 타입 파라미터가 가져야 할 조건을 줄 수 있습니다. 예를 들어 Equatable 프로토콜을 준수하는 타입만 받아서 동작하는 제네릭 함수:
func findIndex<T: Equatable>(of value: T, in array: [T]) -> Int? {
for (index, element) in array.enumerated() {
if element == value {
return index
}
}
return nil
}
print(findIndex(of: 3, in: [1,2,3,4])) // 출력: Optional(2)
print(findIndex(of: "B", in: ["A","B","C"])) // 출력: Optional(1)
위 함수는 T
가 Equatable을 준수함을 요구하므로, ==
비교를 안전하게 수행할 수 있습니다. 제네릭을 사용하면 함수, 구조체, 열거형, 클래스 모두에 타입에 독립적인 유연성을 부여할 수 있고, 중복 코드를 줄이며 타입 안정성을 유지할 수 있습니다.
오류 처리 (Error Handling)Permalink
Swift는 오류 상황을 처리하기 위해 throw
-try
-catch
구문을 제공합니다. 오류를 던질 수 있는 함수는 throws
키워드를 명시하고, 오류 타입은 Error
프로토콜을 준수하는 타입(주로 열거형)으로 정의합니다.
예를 들어, 간단한 네트워크 요청 결과를 모의하여 오류 처리를 구현해봅시다:
enum NetworkError: Error {
case badURL
case timeout
}
func fetchData(from url: String) throws -> String {
guard url.hasPrefix("https://") else {
throw NetworkError.badURL // URL 형식이 잘못되면 오류 던지기
}
// ... (데이터 가져오는 로직)
return "성공적으로 가져왔습니다"
}
do {
let data = try fetchData(from: "invalid_url")
print(data)
} catch NetworkError.badURL {
print("잘못된 URL 형식입니다.")
} catch {
print("알 수 없는 오류: \(error)")
}
위 코드에서 fetchData
함수는 URL이 “https://”로 시작하지 않으면 NetworkError.badURL
오류를 throw
합니다. 이 함수를 호출할 때는 try
키워드를 사용해야 하며, 오류가 발생하면 catch
구문으로 흐름이 이동합니다. catch NetworkError.badURL
처럼 특정 오류에 대한 분기 처리도 가능하며, 마지막 catch
는 그 외의 모든 오류를 받아옵니다.
이와 같이 Swift의 오류 처리는 런타임 오류(crash)를 방지하고, 오류 상황에 대한 대응 로직을 명확하게 작성할 수 있도록 도와줍니다. 또한 try?
(오류 발생 시 nil 반환)나 try!
(오류 무시, 실패 시 런타임 오류) 등의 문법도 제공하지만, 상황에 맞게 신중히 사용해야 합니다.
정리Permalink
Swift의 문법은 안전성과 강력함을 모두 갖추고 있습니다. 변수와 상수를 통해 값 변경 여부를 명확히 구분하고, 옵셔널로 nil 가능성을 표현하며, 타입 안전성으로 잘못된 타입 사용을 컴파일 단계에서 방지합니다. 제어 흐름과 함수/클로저 문법은 직관적이면서도 강력한 표현력을 제공하고, 구조체와 클래스를 통해 값 타입과 참조 타입을 구분하여 효율적인 메모리 사용과 설계를 할 수 있습니다. 열거형으로 관련된 값의 집합을 정의하고, 프로토콜과 익스텐션으로 유연하게 다형성과 기능 확장을 구현합니다. 제네릭으로 코드의 재사용성을 높이고, 오류 처리로 안전한 실행 흐름을 보장합니다. 이러한 Swift 문법 요소들은 최신 Swift 버전에서도 지속적으로 개선되고 있으며, 개발자가 안전하고 간결한 코드를 작성할 수 있도록 돕습니다.
2. SwiftUI 기초 및 주요 뷰 컴포넌트Permalink
SwiftUI 소개 (선언적 UI와 구조)Permalink
SwiftUI는 Apple이 2019년에 도입한 선언적(declarative) UI 프레임워크입니다. UIKit/AppKit 등의 기존 프레임워크가 명령형(imperative)으로 UI를 구성하는 반면, SwiftUI에서는 어떤 UI를 원하는지 선언적으로 작성하면 프레임워크가 화면을 구성하고 상태 변화를 반영합니다. 예를 들어 “이런 텍스트와 버튼을 화면에 그리고, 상태가 변경되면 UI를 새로고침하라”고 선언만 하면, 실제 변경 감지나 그에 따른 UI 업데이트는 SwiftUI가 자동으로 처리해줍니다. SwiftUI는 iOS, macOS, watchOS, tvOS는 물론 visionOS까지 다양한 Apple 플랫폼에서 공통으로 사용할 수 있는 프레임워크이며, 코드 한 벌로 여러 플랫폼의 UI를 구현할 수 있습니다.
SwiftUI의 뷰(View)는 구조체(struct)로 구현되며, View
프로토콜을 채택합니다. 각 SwiftUI 뷰는 다른 뷰를 조합하여 자기 자신의 화면 구성을 정의하며, 결국 가장 작은 구성 단위인 Text, Image 등의 기본 뷰부터 시작해 조합과 중첩을 통해 복잡한 UI를 만들어냅니다. 모든 SwiftUI 뷰는 body
라는 연산 프로퍼티를 가지고 있으며, 이 안에 some View
를 반환함으로써 화면에 보여줄 뷰 계층을 선언합니다.
SwiftUI에서는 데이터와 UI의 동기화가 자동으로 이루어집니다. 상태가 바뀌면 해당 상태를 사용하는 모든 뷰들이 자동으로 다시 그려지므로, 개발자는 UI 업데이트를 직접 호출하지 않고 상태만 관리하면 됩니다. 이러한 특성 덕분에 UI의 선언적 구성과 상태에 따른 자동 업데이트가 가능하며, 코드의 양이 줄고 실수도 감소합니다.
주요 뷰 요소: Text, Image, Button 등Permalink
SwiftUI는 풍부한 기본 UI 컴포넌트들을 제공합니다. 가장 자주 쓰이는 몇 가지를 소개합니다:
-
Text – 화면에 문자열 텍스트를 표시하는 뷰입니다. 글자 크기(
.font
), 색상(.foregroundColor
), 굵기(.bold()
등) 등의 수정자(Modifier)를 체인으로 적용하여 텍스트 스타일을 바꿀 수 있습니다. -
Image – 이미지 또는 SF Symbols 아이콘을 표시하는 뷰입니다. 앱 자산에 넣은 이미지 또는 시스템 아이콘(
Image(systemName: "symbol_name")
)을 표시할 수 있고,.resizable()
,.aspectRatio()
등의 수정자로 크기와 비율을 조정합니다. -
Button – 터치(또는 클릭) 가능한 버튼 컴포넌트입니다.
Button("레이블") { 액션 }
형태로 사용하며, 버튼이 눌렸을 때 실행할 코드를 클로저로 제공합니다. 버튼의 레이블은 간단히 문자열이나 SF Symbol 이름으로 지정하거나,Button { } label: { 구성뷰 }
구문을 사용해 아이콘+텍스트 조합 등 커스텀 뷰로 구성할 수도 있습니다. -
Toggle – 스위치(On/Off) 형태의 입력 컨트롤로, Bool 값에 바인딩되어 토글 상태를 UI와 동기화합니다.
-
TextField – 한 줄짜리 텍스트 입력 필드로, 사용자로부터 문자열 입력을 받을 때 사용합니다. 플레이스홀더와 바인딩, 키보드 타입 등을 설정할 수 있습니다.
-
Slider – 연속적인 값 범위에서 숫자 값을 선택하는 슬라이더 컨트롤입니다. 최소/최대 범위와 현재 값을 바인딩으로 연결하여 사용합니다.
-
List – 컬렉션 데이터를 손쉽게 목록 UI로 표현하는 뷰입니다.
List
내에 반복문 (ForEach)을 사용하여 여러 행을 동적으로 생성할 수 있습니다. (예: 연락처 목록 등)
이 외에도 Color(색상 뷰), Spacer(빈 간격을 채워 레이아웃 조절) 등 다양한 기본 제공 뷰들이 있습니다. 이러한 기본 뷰들을 조합하여 화면을 구성하게 됩니다.
뷰 수정자(Modifier)와 구성 방법Permalink
SwiftUI의 강력한 기능 중 하나는 뷰 수정자(modifier)입니다. 수정자는 뷰를 반환하는 메서드로서, 어떤 뷰에 호출하면 새로운 변형된 뷰를 만들어줍니다. 예를 들어 .padding()
수정자를 호출하면 해당 뷰에 여백을 주는 새 뷰를 반환하고, .background(Color.gray)
를 호출하면 해당 뷰를 회색 배경 위에 그려주는 새 뷰를 반환합니다. 수정자는 체인 형태로 여러 개를 연결해서 쓸 수 있으며, 적용 순서대로 처리됩니다.
예:
Text("Hello, SwiftUI!")
.font(.headline)
.foregroundColor(.blue)
.padding()
.background(Color.yellow)
위 코드에서는 "Hello, SwiftUI!"
텍스트에 헤드라인 폰트 적용 → 파란색 글자색 적용 → 내부 여백 패딩 적용 → 배경을 노란색으로 적용 순으로 수정자가 씌워집니다. 이처럼 수정자를 사용하면 뷰의 레이아웃, 스타일, 동작을 쉽게 조정할 수 있습니다.
또한 SwiftUI에서는 뷰들을 컨테이너 뷰 안에 넣어 배치합니다. 대표적인 컨테이너로 HStack, VStack, ZStack이 있습니다:
-
HStack
은 수평으로 뷰들을 배열하는 컨테이너로, 내부의 뷰들을 가로로 나란히 배치합니다. -
VStack
은 세로로 쌓는 컨테이너로, 내부의 뷰들을 위에서 아래로 배치합니다. -
ZStack
은 겹쳐서 배치하는 컨테이너로, 내부의 뷰들을 동일 좌표 공간에 쌓아올려서 (z축 방향으로) 배치합니다.
이러한 스택과 패딩/정렬 등의 수정자를 함께 사용하면 복잡한 레이아웃도 간결하게 표현할 수 있습니다. (레이아웃 관련 내용은 다음 장에서 더 자세히 다룹니다.)
예제: SwiftUI 뷰 구성하기Permalink
아래 예제는 SwiftUI에서 기본 컴포넌트들을 조합하여 간단한 뷰를 구성하는 코드입니다. VStack
을 사용해 수직으로 Text, Image, Button을 배치하고 각각에 수정자를 적용했습니다:
struct ContentView: View {
var body: some View {
VStack { // 수직 스택으로 하위 뷰들을 배열
Text("Hello, World!") // 텍스트 표시
.font(.title) // 제목 폰트 스타일 적용
.foregroundColor(.purple) // 텍스트 색상을 보라색으로
Image(systemName: "star.fill") // SF Symbols의 별 모양 아이콘
.font(.system(size: 50)) // 아이콘 크기를 50으로 설정
.foregroundColor(.yellow) // 아이콘 색상을 노란색으로
.padding() // 아이콘 주위에 기본 여백 추가
Button("Tap me") { // "Tap me" 레이블의 버튼
print("Button tapped!") // 버튼 눌렀을 때의 액션
}
.buttonStyle(.bordered) // 버튼 스타일 (테두리있는 기본 스타일) 적용
.tint(.orange) // 버튼 틴트색을 주황색으로 지정
}
.padding() // VStack 전체에 패딩 적용
}
}
코드 설명: ContentView
구조체는 View
프로토콜을 따르며, body
에서 VStack
을 반환하여 그 안에 세 가지 하위 뷰를 넣었습니다. 첫 번째는 "Hello, World!"
텍스트로, 큰 제목 폰트와 보라색 색상을 적용했습니다. 두 번째는 시스템 아이콘인 별 모양(star.fill
) Image로, 크기를 크게 키우고 노란색으로 칠한 뒤 약간의 패딩을 주었습니다. 세 번째는 "Tap me"
라벨을 가진 Button으로, 누르면 콘솔에 메시지를 출력하는 동작을 지정했습니다. 이 버튼에는 테두리가 있는 스타일과 주황색 틴트색을 적용하여 기본 모양을 꾸몄습니다. 모든 요소는 VStack
에 의해 세로로 정렬되고, VStack 자체에 .padding()
이 있어 화면 가장자리와 내용 사이에 여백을 주었습니다.
이 코드의 미리보기(Preview)를 실행하면 텍스트, 아이콘, 버튼이 위에서 아래로 배치된 화면을 볼 수 있고, 버튼을 누를 때마다 Xcode 콘솔에 “Button tapped!”이 출력됩니다.
정리Permalink
SwiftUI에서는 선언적인 방식으로 UI를 구성하고, 상태 변화에 따라 자동으로 UI를 업데이트하는 현대적인 방법론을 제공합니다. Text, Image, Button과 같은 기본 뷰 컴포넌트들을 제공하여 빠르게 UI를 구성할 수 있고, HStack/VStack 등의 레이아웃 컨테이너와 수정자를 활용해 디자인과 배치를 세부 조정할 수 있습니다. SwiftUI의 뷰는 구조체이며, 작고 독립적인 뷰를 조합해 복잡한 화면을 만들 수 있어 재사용성과 모듈화에 용이합니다. 최신 SwiftUI 버전에서는 리스트, 그리드(Grid), 내비게이션 스택(NavigationStack) 등 고수준 컴포넌트도 강화되어, 적은 코드로 풍부한 UI를 구현할 수 있습니다. SwiftUI의 이러한 특성은 UI 개발 생산성을 높이고, 코드의 간결함과 유지보수성을 크게 향상시킵니다.
3. 뷰 레이아웃 시스템 (Stacks, Alignment, Offset/Position, Preferences 등)Permalink
Stack을 통한 뷰 배치 (HStack, VStack, ZStack)Permalink
SwiftUI에서는 개별 뷰들을 화면에 배치하기 위해 스택(Stack) 컨테이너를 활용합니다. 가장 기본적인 세 가지 스택은:
-
HStack (Horizontal Stack): 수평으로 뷰들을 나란히 배치하는 컨테이너입니다. 예를 들어 가로로 아이콘과 텍스트를 옆에 붙여 보여주고 싶을 때 사용합니다.
-
VStack (Vertical Stack): 세로로 뷰들을 쌓아서 배치하는 컨테이너입니다. 예를 들어 위에서 아래로 여러 텍스트를 나열하거나, 이미지 위에 텍스트를 놓는 등 수직 방향 배치에 활용합니다.
-
ZStack: 동일 좌표 공간에 뷰들을 겹쳐 배치하는 컨테이너입니다. 먼저 선언한 뷰가 아래에 깔리고 나중에 선언한 뷰가 위에 쌓입니다. 예를 들어 이미지 위에 텍스트 워터마크를 겹쳐 표시할 때 사용할 수 있습니다.
예시: 두 개의 원(Circle)과 텍스트를 서로 다른 스택으로 배치:
HStack {
Circle().fill(Color.red).frame(width: 50, height: 50)
Circle().fill(Color.blue).frame(width: 50, height: 50)
}
위 코드는 빨간색 원과 파란색 원을 가로로 나란히 배치합니다.
스택은 초기화 시 alignment(정렬)과 spacing(간격)을 파라미터로 받아서 내부 뷰들의 정렬 방식과 간격을 조절할 수 있습니다. 예를 들어 VStack(alignment: .leading, spacing: 10)
은 모든 자식 뷰들을 왼쪽 정렬하고, 10포인트 간격으로 세로 배치합니다. alignment 기본값은 .center(중앙정렬), spacing 기본값은 0입니다.
또한 스택 안에 또 다른 스택을 중첩시켜 복잡한 레이아웃을 만들 수 있습니다. 예를 들어 HStack 안에 VStack 두 개를 넣으면 행과 열이 결합된 그리드 비슷한 배치도 가능합니다.
정렬(Alignment)과 프레임(Frame) 조정Permalink
개별 뷰 혹은 스택 내에서 정렬(Alignment)을 제어하여 원하는 배치 위치를 지정할 수 있습니다. Alignment는 부모 컨테이너 (예: HStack, VStack, ZStack, 혹은 .frame 수정자 등) 기준으로 자식을 어떻게 정렬할지 결정합니다.
수평/수직 스택의 정렬:
-
HStack은 수직축 정렬 옵션을 가집니다 (예:
.top
,.center
,.bottom
등). HStack의 alignment를.top
으로 주면 가장 위를 기준으로 아이템들이 정렬됩니다. -
VStack은 수평축 정렬 옵션을 가집니다 (예:
.leading
,.center
,.trailing
). VStack의 alignment를.trailing
으로 주면 오른쪽 끝에 맞춰 정렬됩니다. -
ZStack은 both axes (수평+수직) 정렬 옵션을 가집니다 (예:
.topLeading
,.center
,.bottomTrailing
등). ZStack 기본 alignment는.center
이며, 이를 변경하면 모든 자식의 기준점이 달라집니다.
Frame과 AlignmentGuide:
모든 뷰에 사용할 수 있는 .frame(width:height:alignment:)
수정자를 통해 뷰의 고정 크기를 지정하고, 그 내부에서의 콘텐츠 정렬을 설정할 수 있습니다. 예를 들어 Text("Hello") .frame(width:100, height:100, alignment: .bottomTrailing)
은 100x100 사각형 프레임 내에서 텍스트를 오른쪽 아래에 정렬시킵니다.
SwiftUI에서는 커스텀 정렬을 위해 alignmentGuide도 제공됩니다. alignmentGuide를 이용하면 자식 뷰 각각에 대해 별도로 정렬 위치를 계산해 줄 수 있지만, 일반적인 경우보다는 특수한 레이아웃 상황에서 사용됩니다. (예: 여러 개의 뷰를 Baseline 등 특정 기준선에 맞추는 경우)
예시: 아래 코드는 HStack 내에서 텍스트와 원의 상대적 정렬 차이를 보여줍니다.
HStack(alignment: .bottom) {
Text("SwiftUI")
.font(.largeTitle)
Circle()
.fill(Color.green)
.frame(width: 40, height: 40)
}
여기서 HStack의 alignment를 .bottom
으로 했으므로, 텍스트의 baseline과 원의 아래쪽 테두리가 기준선에 맞춰 함께 정렬됩니다.
Offset vs Position 차이Permalink
SwiftUI에서 뷰의 위치를 조정하는 두 가지 주요 수정자가 offset
과 position
입니다. 겉보기에는 둘 다 뷰를 이동시키는 용도이지만, 동작 방식에 차이가 있습니다:
-
.offset(x: y:)
: 뷰의 기본 배치 위치로부터 X, Y만큼 상대적으로 이동시킵니다. offset을 적용해도 원래 뷰의 레이아웃 영역은 그대로 유지되기 때문에, 다른 뷰들의 배치에는 영향이 없습니다. 쉽게 말해, 뷰를 화면에 그릴 때만 살짝 옮겨 놓는 효과이며, 레이아웃 상에서는 아직 그 뷰가 원래 자리 차지를 하고 있습니다. -
.position(x: y:)
: 뷰의 부모 좌표 공간에서의 절대 좌표를 지정합니다. position을 사용하면 해당 뷰는 정확히 지정한 (x,y) 위치에 놓이고, 원래 부모에서 차지하던 자리에서 완전히 분리됩니다. 따라서 position을 쓰면 뷰가 절대 배치되므로, 다른 뷰들은 그 공간이 비었다고 판단하여 레이아웃을 다시 채우게 됩니다.
간단히 비교하면, position은 뷰를 절대 좌표에 놓고, offset은 현재 위치에서 상대 이동하는 방식입니다. 둘을 동시에 쓰는 것도 가능한데, 이 경우 position으로 1차 배치한 뒤 offset으로 추가 이동을 적용하게 됩니다.
예시:
ZStack {
Color.yellow.frame(width: 150, height: 150)
Text("Offset").offset(x: 20, y: 20).background(Color.red)
Text("Position").position(x: 130, y: 130).background(Color.blue)
}
위 ZStack 안에 노란색 사각형(background)과 두 텍스트를 넣었습니다. “Offset” 텍스트는 기본 중앙 위치에서 (20,20) 만큼 이동하였지만 여전히 ZStack의 중앙에 배치된 영역을 차지하고 있고, “Position” 텍스트는 절대 좌표 (130, 130)에 배치되어 노란색 박스의 오른쪽 하단 근처로 이동했습니다. 또한 “Position” 텍스트는 절대 배치로 인해 원래 중앙에 있던 자리에는 더 이상 공간을 차지하지 않으므로, 다른 뷰 배치에 영향이 없습니다.
offset
은 애니메이션 등에서 뷰의 위치만 살짝 옮기되 레이아웃 영향은 없도록 할 때 유용하고, position
은 그래프나 캔버스같이 절대 좌표 배치가 필요한 상황에 사용됩니다.
PreferenceKey를 활용한 뷰 간 정보전달Permalink
SwiftUI의 뷰 계층은 일반적으로 단방향 데이터 흐름(상위 -> 하위)입니다. 상위 뷰의 상태가 하위 뷰로 전달되어 화면을 구성하는 형태죠. 그런데 때로는 하위 뷰가 상위 뷰에게 정보를 주어 레이아웃이나 다른 동작에 활용해야 하는 경우가 있습니다. 예를 들어, 여러 자식 뷰들의 크기를 파악해서 부모 뷰의 크기를 동적으로 조절하거나, 스크롤 목록 내 특정 셀의 위치를 부모에서 알아야 하는 상황 등이 그러합니다.
이럴 때 사용하는 것이 PreferenceKey입니다. PreferenceKey는 프로토콜로, 자식 뷰들이 값(Preference)을 설정하면 상위 뷰에서 그 값을 읽어오도록 도와주는 메커니즘입니다. 즉, 뷰 계층의 아래 -> 위 방향으로 데이터 전달을 가능케 합니다.
PreferenceKey 사용 방법 요약:
-
PreferenceKey 프로토콜을 준수하는 타입을 정의합니다 (associatedtype Value와 정적 defaultValue, reduce 구현).
-
자식 뷰에서
.preference(key: MyPreferenceKey.self, value: someValue)
수정자를 사용해 값 설정. -
상위 뷰에서
.onPreferenceChange(MyPreferenceKey.self) { value in ... }
를 사용해 전달된 값을 받아서 활용.
예를 들어, 각 자식 뷰의 너비를 알아내 부모에서 최대 너비를 알고 싶다면:
-
struct WidthKey: PreferenceKey { static var defaultValue: CGFloat = 0; static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } }
와 같이 PreferenceKey를 정의합니다 (여러 자식 값 중 최대값을 선택하는 reduce). -
자식 뷰들에서
background
나overlay
로 GeometryReader를 사용하여 자신의 크기를 측정하고,.preference(key: WidthKey.self, value: measuredWidth)
로 각 너비를 부모에게 보냅니다. -
부모 뷰에서
.onPreferenceChange(WidthKey.self) { maxWidth in ... }
로 자식 중 최대 너비를 받아서 레이아웃을 조정할 수 있습니다.
이런 과정은 SwiftUI에서 다소 복잡한 편이지만, 하위 뷰의 레이아웃 정보나 상태를 상위로 올리는 유일한 수단이라는 점에서 중요합니다. PreferenceKey를 이용하면 뷰 계층 구조를 깨트리지 않고도 필요한 정보 전달을 할 수 있습니다.
예시 시나리오: 탭 뷰(TabView)의 하위 탭들이 스크롤 가능 한정된 너비로 표시될 때, 현재 선택된 탭에 밑줄을 그리기 위해 해당 탭 뷰의 위치나 크기를 상위 컨테이너에서 알아내는 경우 등이 PreferenceKey 활용 사례입니다. (자식 탭 뷰가 자신의 프레임 정보를 Preference로 올리고, 부모 탭 바 뷰가 그 정보를 받아 밑줄 Indicator 위치를 결정)
정리Permalink
SwiftUI의 레이아웃 시스템은 간결함과 유연함을 목표로 디자인되었습니다. HStack/VStack/ZStack을 통해 직관적으로 뷰를 가로, 세로, 겹쳐 배치할 수 있고, alignment 매개변수와 frame/offset/position 수정자를 조합하여 정교한 위치 조정이 가능합니다. 특히 offset
과 position
의 차이를 이해하면, UI 요소를 원하는 곳에 배치하면서도 전체 레이아웃 흐름을 유지할 수 있습니다.
또한 PreferenceKey를 사용한 하위->상위 데이터 전달 기법은 레이아웃 계산이나 상위 뷰의 동적 조정을 가능하게 해줍니다. 이는 SwiftUI의 선언적 구조를 해치지 않으면서도 복잡한 UI 요구사항 (예: 자식 크기에 따른 부모 크기 결정 등)을 해결할 수 있는 고급 도구입니다. 최신 SwiftUI에서는 그리드(Grid)나 Custom Layout 프로토콜 등 레이아웃을 위한 새로운 도구들도 도입되어, 개발자가 보다 직관적으로 원하는 배치를 구현할 수 있게 발전하고 있습니다. 전체적으로 SwiftUI의 레이아웃 시스템은 적은 코드로도 다양한 UI 배치를 표현할 수 있으며, 필요한 경우 세밀한 제어도 할 수 있도록 설계되어 있습니다.
4. 애니메이션 (암시적, 명시적, Transition, 커스텀 모디파이어)Permalink
암시적 애니메이션 (Implicit Animations)Permalink
SwiftUI의 암시적 애니메이션은 특정 상태 변화를 자동으로 부드럽게 연결해주는 애니메이션입니다. 개발자가 애니메이션 동작을 일일이 지시하지 않아도, .animation
수정자 등을 통해 상태 변화 시 자동으로 애니메이션이 적용되게 할 수 있습니다. 예를 들어 크기나 색상 등의 속성이 변할 때 SwiftUI가 그 변화를 감지하고 지정된 애니메이션 효과로 변화를 이어줍니다.
암시적 애니메이션을 사용하는 방법:
-
.animation()
수정자: 뷰에 이 수정자를 붙이고 애니메이션 효과 (예:.easeIn
,.spring()
등)와 옵션을 지정하면, 해당 뷰의 상태가 변할 때 알아서 애니메이션됩니다. 이 수정자는 iOS 15부터animation(_:value:)
형태로 개선되어, 감지할 값(value)을 함께 명시합니다. -
암시적 애니메이션 프로퍼티: SwiftUI의 일부 뷰나 메서드는 내부적으로 상태 변화에 암시적 애니메이션을 적용합니다. (예: Toggle를 빠르게 토글하면 슬라이더가 부드럽게 이동하는 등 기본값으로 애니메이션됨)
예시:
@State private var angle = 0.0
// ...
Image(systemName: "arrow.3.trianglepath")
.rotationEffect(.degrees(angle))
.animation(.easeInOut(duration: 1), value: angle)
위 코드에서 angle
값이 변하면 해당 Image 뷰의 회전 각도가 1초 동안 easeInOut 곡선을 따라 부드럽게 변경됩니다. 즉, angle
의 변경이 즉시 UI에 반영되는 대신, 지정한 애니메이션으로 연결됩니다. 이처럼 암시적 애니메이션은 “상태만 바꾸면 애니메이션은 SwiftUI가 알아서 처리”하는 간편한 방식입니다. 다만 암시적 애니메이션은 프레임워크에 제어권이 있으므로, 동작을 세밀하게 조정하기는 제한적이며 SwiftUI 버전에 따라 미묘하게 동작이 달라질 수 있습니다.
명시적 애니메이션 (Explicit Animations)Permalink
명시적 애니메이션은 개발자가 특정 동작을 애니메이션으로 감싸 직접 실행을 제어하는 방식입니다. SwiftUI에서는 withAnimation
함수를 사용하여 코드 블록 내 상태 변화를 애니메이션으로 처리할 수 있습니다. 명시적 애니메이션은 타이밍이나 애니메이션 종류를 코드에서 명확히 지정하기 때문에 더 세밀한 제어가 가능합니다.
사용 방법:
withAnimation(Animation.spring(response: 0.5, dampingFraction: 0.6)) {
isShown.toggle()
}
위와 같이 withAnimation
에 원하는 애니메이션 객체를 전달하고 상태 변화를 일으키면(isShown.toggle()
), 해당 변화가 지정된 스프링 애니메이션을 통해 진행됩니다. 명시적 애니메이션을 쓰면 어떤 변화에 애니메이션을 적용할지, 언제 실행할지 개발자가 결정할 수 있으므로, 연속된 여러 변화 중 일부만 애니메이션하거나 사용자 인터랙션에 따라 조건부로 애니메이션을 적용하는 등이 가능합니다.
또한 명시적 애니메이션은 결과를 상수로 받아 추후 제어(예: 취소)도 가능합니다:
let animation = withAnimation(.easeIn) {
value = 100
}
// 필요시 animation.cancel() 가능 (AnyCancellable 반환인 경우)
하지만 일반적으로 SwiftUI에서는 withAnimation
을 호출하는 순간 즉시 애니메이션이 수행되며, 반환값을 쓰지 않아도 됩니다.
암시적 vs 명시적 정리: 암시적 애니메이션은 뷰 단위로 선언하여 상태 변화마다 자동 적용되며, 명시적 애니메이션은 특정 코드 블록을 직접 애니메이션으로 감싸는 방식입니다. 간단한 UI 변화를 빠르게 처리할 때는 암시적 방식을, 보다 복잡한 타이밍 제어나 여러 상태 변화를 동기화해야 할 때는 명시적 방식을 선택하면 됩니다.
뷰 전환(Transition) 효과Permalink
전환(Transition)은 뷰가 삽입되거나 제거될 때 적용되는 특수한 애니메이션 효과입니다. SwiftUI에서 뷰를 조건에 따라 if
/else
로 표시하거나, 리스트에서 추가/삭제할 때, 해당 뷰의 출현/소멸 과정에 전환 효과를 줄 수 있습니다.
전환 효과를 지정하려면 .transition(_:)
수정자를 사용합니다. SwiftUI가 제공하는 기본 전환(AnyTransition) 유형은 다음과 같습니다:
-
.opacity
: 페이드 인/아웃 (투명도 변화) 전환. -
.slide
: 뷰가 한쪽에서 미끄러져 들어오고 나갈 때는 밀려나가는 전환. -
.scale
: 뷰의 크기를 0에서 100%로 또는 반대로 변화시키는 전환. -
.move(edge:)
: 지정한 방향으로부터 이동하여 나타나고 사라지는 전환. -
이 외에
.asymmetric(insertion: , removal: )
를 사용해 나타날 때와 사라질 때 서로 다른 전환을 지정할 수도 있습니다.
전환은 뷰가 실제 뷰 계층에 추가/제거될 때만 작동합니다. SwiftUI에서는 상태 변화와 if
조건을 통해 뷰를 삽입/삭제하므로, withAnimation
블록 안에서 상태를 변경하면 그 때 전환 애니메이션이 재생됩니다. (혹은 상위 뷰에 .animation
을 걸어두어도 가능함)
예시: 버튼을 눌러 상세 정보를 토글 표시하며 슬라이드 전환 효과를 주는 코드:
@State private var showDetails = false
VStack {
Button("토글 상세보기") {
withAnimation(.easeInOut) {
showDetails.toggle()
}
}
if showDetails {
Text("여기에 상세 정보가 나타납니다.")
.padding()
.background(Color.yellow)
.transition(.slide) // 슬라이드 전환 효과
}
}
위 코드에서 showDetails
가 false
에서 true
로 바뀔 때 Text 뷰가 아래에서 위로 슬라이드되며 등장하고, 반대로 false
로 바뀌면 위에서 아래로 슬라이드되어 사라집니다. withAnimation
으로 감쌌기 때문에 전환이 애니메이션과 함께 실행된 것입니다.
전환 효과를 커스텀하고 싶다면 AnyTransition.extension으로 직접 만들 수 있습니다. 뷰의 특정 조합으로 나타날/사라질 상태를 정의하거나, .modifier
기반 전환을 구현해 복잡한 전환도 가능합니다. 하지만 일반적인 UI에서는 기본 제공 전환만으로도 충분히 다양한 연출이 가능합니다.
커스텀 애니메이션과 AnimatableModifierPermalink
SwiftUI의 기본 애니메이션으로 다루기 어려운 경우, 예를 들어 커스텀 Gradient 효과나 복잡한 도형의 그리기 진행 애니메이션 등이 필요할 때는 AnimatableModifier 또는 withAnimation
과 Animatable
프로토콜을 활용한 기법이 있습니다.
-
AnimatableModifier: ViewModifier 프로토콜의 확장으로,
AnimatableModifier
를 채택하면 animatableData라는 연산 프로퍼티를 통해 하나 이상의 애니메이션 가능한 값을 정의할 수 있습니다. SwiftUI는 animatableData가 변경될 때 Modifier의body
를 중간값으로 여러 번 호출하며, 자연스러운 애니메이션을 진행합니다. 이를 이용해 직접 커스텀 속성(예: 그래프의 퍼센티지 등)을 중간값으로 보간하며 애니메이션시키는 것이 가능합니다. -
Shape의 Animatable: SwiftUI의 Shape 프로토콜도
Animatable
을 준수하는데, 도형의 특정 속성(예: 각도, 크기 등)을 animatableData로 노출하면 Shape 자체도 상태 변화 시 부드럽게 변형할 수 있습니다. (예: PieChart 모양을 그리는 Shape에서 종료 각도를 animatableData로 두면, 퍼센트 변화에 따라 원형 차트가 부드럽게 그려지는 효과)
예시: 커스텀 AnimatableModifier를 이용해 색상의 HueRotation 애니메이션 만들기 (SwiftUI는 Color 자체 애니메이션을 제공하지만 개념 예시):
struct HueRotateModifier: AnimatableModifier {
var angle: Double // 현재 색상 각도 (0~360)
var animatableData: Double {
get { angle }
set { angle = newValue }
}
func body(content: Content) -> some View {
content.hueRotation(.degrees(angle))
}
}
// 사용:
// .modifier(HueRotateModifier(angle: targetAngle))
위 Modifier는 angle
값이 바뀔 때마다 content 뷰에 그 각도의 hueRotation을 적용합니다. SwiftUI가 animatableData를 보간하여 호출해주므로, angle 변화가 자연스럽게 이어진 채 색조 회전 애니메이션이 일어납니다.
또 다른 예로, matchedGeometryEffect도 고급 애니메이션 기법 중 하나입니다. 두 개의 뷰를 같은 식별자로 연결해주면, 한 뷰에서 다른 뷰로의 전환 시 위치나 크기 변화가 중간 애니메이션으로 매끄럽게 이어지게 할 수 있습니다. 이 역시 Hero 애니메이션 같은 효과를 구현할 때 유용한 도구입니다.
정리Permalink
SwiftUI의 애니메이션 시스템은 선언적 UI 철학에 맞게 단순한 사용법으로 강력한 효과를 낼 수 있도록 디자인되었습니다. 암시적 애니메이션은 상태 변화에 대해 자동으로 부드러운 전환을 제공하며, 명시적 애니메이션은 withAnimation
을 통해 개발자가 원하는 시점에 원하는 애니메이션을 적용하게 합니다. 또한 뷰의 전환(Transition) 효과를 사용하면 뷰 추가/삭제 시 멋진 등장/퇴장 연출을 손쉽게 구현할 수 있습니다.
더 나아가 AnimatableModifier나 Shape의 애니메이션 등을 통해 기본 제공되지 않는 특수한 애니메이션도 만들 수 있으며, matchedGeometryEffect 같은 기능으로 화면 간의 부드러운 객체 이동 효과도 표현할 수 있습니다. 이러한 도구들을 적절히 활용하면 적은 코드로도 풍부한 애니메이션을 구현할 수 있으며, UIKit 시절보다 훨씬 쉬운 방법으로 일관성 있는 모션 디자인을 적용할 수 있습니다.
5. State Management (상태 관리: @State, @Binding, @ObservedObject 등)Permalink
@State: 뷰 전용 상태 저장Permalink
@State 프로퍼티 래퍼는 뷰 내부의 상태 값을 저장하고 관리하는 데 사용됩니다. @State
로 선언된 변수는 뷰의 단일 출처(Single Source of Truth)로서, 해당 값이 변경되면 SwiftUI가 자동으로 뷰의 body
를 재계산하여 UI를 업데이트합니다. 주로 간단한 값 타입(Bool, Int, String 등)에 사용하며, 그 뷰에서만 참조되고 변경되는 데이터에 적합합니다.
특징:
-
@State 변수는 뷰의 생명주기와 함께 합니다. 뷰가 생성될 때 초기화되고, 뷰가 사라지면 함께 메모리에서 해제됩니다.
-
@State는 구조체 내부에서 값이 변경될 때 뷰를 무효화(invalidate)하여 다시 그리도록 트리거합니다.
-
뷰 외부에서는 직접 접근할 수 없고, 해당 뷰가 제공하는 인터페이스(예: Binding)로만 접근해야 합니다.
예를 들어 간단한 토글 스위치의 상태:
struct SettingsView: View {
@State private var isOn = false // Toggle의 상태 값
var body: some View {
Toggle("기능 활성화", isOn: $isOn)
}
}
위 코드에서 isOn
은 @State로 선언되어 Toggle과 바인딩되어 있습니다. 사용자가 토글을 on/off 하면 isOn
값이 변경되고, SwiftUI는 SettingsView의 UI를 갱신합니다.
💡 Note: @State는 뷰 내부의 private한 상태에 사용되며, 다른 뷰로 직접 전달되지 않습니다. 다른 뷰와 상태를 공유하려면 Binding이나 ObservableObject를 사용해야 합니다.
@Binding: 상위 상태의 바인딩 (양방향 연결)Permalink
@Binding 프로퍼티 래퍼는 다른 뷰의 상태를 가리키는 참조입니다. Binding은 두 뷰 간에 하나의 상태 값을 공유하면서, 양방향으로 값이 반영되도록 합니다. 주로 부모 뷰의 @State를 자식 뷰에 전달할 때 사용합니다.
특징:
-
Binding은 자체 저장공간이 없으며, 실제 값은 다른 곳(@State 등)에 저장되어 있습니다.
-
Binding 변수를 변경하면 원본 상태(@State 등)가 변경되고, 그에 따라 원본을 소유한 뷰가 업데이트됩니다.
-
@State 변수 앞에
$
를 붙이면 Binding<타입>으로 변환됩니다. (`$isOn`은 `Binding` 타입)
예를 들어 위 SettingsView의 Toggle은 $isOn
을 통해 Binding을 전달받았습니다. Toggle 내부에서는 자신의 상태를 가지지 않고, Binding으로 연결된 외부 isOn
값을 변경합니다.
Binding 사용 예:
struct ParentView: View {
@State private var username = ""
var body: some View {
VStack {
Text("입력한 이름: \(username)")
ChildView(name: $username) // 자식에게 바인딩 전달
}
}
}
struct ChildView: View {
@Binding var name: String // 부모의 상태를 바인딩으로 받음
var body: some View {
TextField("이름 입력", text: $name)
.textFieldStyle(.roundedBorder)
}
}
ParentView의 username
상태를 ChildView에 $username
으로 바인딩 넘겼습니다. ChildView에서 TextField에 연결된 name
바인딩을 통해 부모의 상태를 직접 수정할 수 있고, 부모의 Text 또한 즉시 업데이트됩니다.
Binding은 값의 원본이 자신이 아닌 경우에 사용하며, SwiftUI에서 “내부에서 관리하는 값인가? → State, 외부에서 전달받은 값인가? → Binding”으로 구분할 수 있습니다.
ObservableObject와 @ObservedObject, @StateObjectPermalink
더 복잡한 상태나 여러 뷰에서 공유해야 하는 상태는 클래스 타입의 ObservableObject로 관리합니다. ObservableObject는 Combine 프레임워크의 Publisher처럼 동작하여 변경 시 신호를 보내고, SwiftUI 뷰들은 이를 감지하여 업데이트됩니다.
ObservableObject:
-
ObservableObject
프로토콜을 채택한 클래스. 내부에 @Published 프로퍼티를 갖고 있으며, 그 프로퍼티가 변경되면objectWillChange
라는 Publisher를 통해 알림을 보냅니다. -
주로 뷰 모델(ViewModel)이나 공유 상태 객체에 사용합니다.
이 ObservableObject를 뷰에서 사용하기 위해 두 가지 프로퍼티 래퍼가 있습니다:
-
@ObservedObject – 뷰가 외부에서 전달받은 ObservableObject를 관찰할 때 사용합니다. ObservableObject 내부의 @Published 프로퍼티가 바뀌면 뷰가 업데이트됩니다. 단, ObservedObject로 선언하면 해당 뷰는 그 객체의 소유자가 아니며, SwiftUI는 객체의 생명주기를 관리하지 않습니다. 따라서 뷰가 새로 생성되면 @ObservedObject는 외부에서 전달된 객체를 그대로 사용하지만, 만약 부모에서 새로운 객체 인스턴스를 매번 넘긴다면 뷰도 그에 맞춰 갱신됩니다 (주의 필요).
-
@StateObject – SwiftUI iOS 14부터 도입된 래퍼로, 뷰가 직접 객체의 생명주기를 관리할 때 사용합니다. 보통 해당 뷰 내부에서 ObservableObject를 새로 생성하는 경우에 쓰입니다. StateObject로 선언하면 뷰가 초기 생성될 때 한 번만 객체를 만들고, 뷰가 다시 그려질 때는 기존 객체를 유지합니다. 뷰가 없어질 때 객체도 해제됩니다.
언제 ObservedObject vs StateObject? 간단히 요약하면:
-
외부에서 이미 생성되어 전달되는 ObservableObject라면 @ObservedObject 사용 (뷰는 객체를 소유하지 않음).
-
뷰 자체에서 새로운 ObservableObject를 생성하여 소유해야 한다면 @StateObject 사용 (뷰가 객체의 인스턴스를 관리).
예:
class CounterModel: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@ObservedObject var counter: CounterModel // 외부에서 주입된 모델 관찰
var body: some View {
Button("Count: \(counter.count)") {
counter.count += 1
}
}
}
CounterView는 외부에서 CounterModel
객체를 받아와 관찰합니다. 버튼을 누르면 count가 증가하고 뷰 텍스트가 갱신됩니다.
한편, 만약 CounterView 내부에서 자체적으로 CounterModel을 생성한다면 @StateObject를 써야 합니다:
struct SelfContainedCounterView: View {
@StateObject private var counter = CounterModel() // 뷰가 직접 모델 생성 및 소유
var body: some View {
Button("Count: \(counter.count)") {
counter.count += 1
}
}
}
이렇게 하면 SelfContainedCounterView가 생성될 때 CounterModel이 한 번 만들어지고, 이후 뷰가 업데이트되어도 동일한 counter 객체를 사용합니다. (뷰가 제거됐다가 다시 생성되면 새로운 객체 생성)
@EnvironmentObject: 환경을 통한 상태 공유Permalink
@EnvironmentObject는 ObservableObject를 환경(Environment)에 주입하여 여러 하위 뷰에서 쉽게 접근하도록 하는 방법입니다. 보통 앱 전체에서 공유되는 상태나, 깊은 뷰 계층에 걸쳐 전달해야 하는 객체를 환경에 넣고 @EnvironmentObject로 꺼내 씁니다.
사용법:
-
먼저 상위 뷰(주로 App 또는 Scene 단계)에서
.environmentObject(someObject)
수정자를 사용해 ObservableObject 인스턴스를 넣습니다. -
하위 뷰들에서는
@EnvironmentObject var someObject: ObjectType
으로 선언만 하면, 상위에서 제공된 객체를 자동으로 주입받습니다. 만약 해당 객체가 환경에 없으면 런타임 에러가 발생하므로, App 구성 시 반드시 주입해야 합니다.
예:
class UserSettings: ObservableObject {
@Published var nickname: String = "Guest"
}
@main
struct MyApp: App {
@StateObject private var settings = UserSettings()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(settings)
}
}
}
struct ContentView: View {
@EnvironmentObject var settings: UserSettings
var body: some View {
Text("Hello, \(settings.nickname)")
}
}
위에서 MyApp은 UserSettings 객체를 생성하고 ContentView 이하 모든 뷰에 환경객체로 공급합니다. ContentView나 그 하위에서는 @EnvironmentObject var settings
로 접근하여 데이터를 사용/수정할 수 있으며, 변경 시 자동으로 UI가 갱신됩니다.
EnvironmentObject는 전역 싱글톤처럼 남용하면 구조 파악이 어려워질 수 있으니, 필요할 때 효율적으로 사용하는 것이 좋습니다 (예: 사용자 설정, 테마, 공용 데이터 등).
정리Permalink
SwiftUI에서는 상태의 흐름을 명확히 하여, 데이터 변경이 UI에 자동 반영되도록 다양한 프로퍼티 래퍼를 제공합니다. @State는 해당 뷰 내부의 상태를 간단히 관리하고, @Binding은 그 상태를 자식 뷰와 공유하여 양방향 업데이트를 가능케 합니다. 더 큰 범위의 상태 관리를 위해 ObservableObject 패턴을 활용하며, 이를 뷰에 적용할 때 @StateObject (소유 및 생성)와 @ObservedObject(외부 주입 객체 관찰)을 구분하여 사용합니다. 마지막으로 @EnvironmentObject를 통해 앱 전역 또는 넓은 범위의 상태를 손쉽게 주입/공유할 수 있습니다.
이러한 상태 관리 기법을 조합하면, SwiftUI 앱 내에서 데이터의 단일 출처를 유지하면서도 계층 구조를 따라 데이터를 읽고 쓸 수 있습니다. 중요한 것은 어떤 뷰가 그 데이터의 소유자인지 명확히 구분하는 것입니다. 소유 뷰에서는 @State/@StateObject로 상태를 가지고, 다른 뷰들은 Binding이나 ObservedObject로 참조하는 패턴을 사용하면, 데이터 흐름을 추적하기 쉽고 예측 가능한 UI 업데이트를 구현할 수 있습니다.
6. Environment 및 PropertyWrapperPermalink
SwiftUI의 Environment 값 사용하기Permalink
SwiftUI는 Environment를 통해 뷰 계층 전반에 걸쳐 공유되는 설정 값들을 전달합니다. Environment에는 시스템에서 제공하는 값 (예: 색상 모드, 지역(locale), 접근성 설정 등)과 개발자가 주입한 값(EnvironmentObject 등)이 포함됩니다.
@Environment 프로퍼티 래퍼를 사용하면 현재 뷰의 환경에서 특정 키의 값을 쉽게 꺼내 쓸 수 있습니다. 예를 들어:
@Environment(\.colorScheme) var colorScheme: ColorScheme
@Environment(\.locale) var locale: Locale
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
-
colorScheme
은 현재 다크모드/라이트모드 여부를 알려주는 환경 값입니다 (.dark
또는.light
). -
locale
은 앱의 현재 Locale 정보를 제공합니다. -
presentationMode
는 해당 뷰를 모달 등으로 띄운 상위 Presentation 구조를 제어할 수 있는 바인딩으로,presentationMode.wrappedValue.dismiss()
를 호출해 뷰를 닫을 수 있습니다.
이처럼 환경 값(Environment Values)은 미리 정의된 키 경로(EnvironmentValues
구조체의 프로퍼티)를 통해 접근하며, SwiftUI가 시스템이나 상위 View/Scene에서 적절한 값을 제공해줍니다.
또한 개발자가 특정 환경 값을 뷰 계층에 적용하려면 .environment(\.키, 값)
수정자를 사용할 수 있습니다. 예를 들어 특정 뷰 이하 모두를 강제로 다크 모드로 표시하려면 .environment(\.colorScheme, .dark)
를 해당 뷰에 적용할 수 있습니다.
환경 값은 상위에서 하위로 기본 전달되므로, 깊은 하위 뷰에서도 상위에서 설정한 환경을 그대로 읽을 수 있습니다. (단, 특정 하위에서 재정의하지 않는 한)
커스텀 EnvironmentKey와 환경 값 전달Permalink
SwiftUI는 기본 제공 환경 값 외에도, 개발자가 커스텀 EnvironmentKey를 정의하여 자신만의 환경 값을 주입/사용할 수 있게 해줍니다. 이를 활용하면 전역 싱글톤 없이도 넓은 범위에 공통 데이터를 전달할 수 있습니다.
커스텀 환경 값 생성 방법:
-
EnvironmentKey
프로토콜을 준수하는 타입을 정의합니다.defaultValue
를 제공해야 합니다. -
EnvironmentValues
에 해당 키에 대한 연산 프로퍼티를 확장하여 추가합니다. -
상위 뷰에서
.environment(\.<key>, value)
로 값 주입, 하위 뷰에서@Environment(\.<key>)
로 사용.
예를 들어, 테마(글자 크기 등) 정보를 환경으로 전달:
struct Theme {
var fontSize: CGFloat
}
// 1. 키 정의
private struct ThemeKey: EnvironmentKey {
static let defaultValue: Theme = Theme(fontSize: 14)
}
// 2. EnvironmentValues 확장
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// 3. 상위에서 값 지정
// someView.environment(\.theme, Theme(fontSize: 20))
// 4. 하위에서 사용
struct ContentView: View {
@Environment(\.theme) var theme
var body: some View {
Text("Hello")
.font(.system(size: theme.fontSize))
}
}
위 코드에서 ThemeKey
를 정의하고 EnvironmentValues에 theme
프로퍼티를 추가했습니다. 이제 .environment(\.theme, ...)
를 통해 하위 뷰들에 Theme 값을 전달하고, 하위에서는 @Environment로 그 값을 받아 사용할 수 있습니다. 만약 상위에서 .environment(\.theme, ...)
를 지정하지 않으면 defaultValue가 사용됩니다.
이런 커스텀 Environment 값은 글로벌하게 접근될 필요는 없지만, 여러 뷰에 걸쳐 공유되어야 하는 설정(예: 사용자 설정, 테마, 공용 스타일 등)을 전달하는 데 유용합니다.
프로퍼티 래퍼(Property Wrapper)의 개념과 SwiftUI에서의 활용Permalink
프로퍼티 래퍼(Property Wrapper)는 Swift 언어 기능으로, 프로퍼티의 get/set 동작에 추가 로직을 첨부하여 재사용할 수 있는 래퍼 타입입니다. SwiftUI에서 사용되는 @State, @Binding, @ObservedObject, @Environment 등도 모두 프로퍼티 래퍼로 구현되어 있습니다. 프로퍼티 래퍼는 @propertyWrapper
로 선언하며, wrappedValue
라는 프로퍼티를 통해 원본 값에 접근/설정하도록 만듭니다.
SwiftUI 외에도, 개발자가 반복되는 프로퍼티 패턴을 추상화할 때 직접 프로퍼티 래퍼를 만들 수 있습니다. 예를 들어, 값 설정 시 특정 범위를 넘어가지 않도록 제한하는 래퍼나, 값 변경 시 로깅을 남기는 래퍼 등을 구현할 수 있습니다.
직접 프로퍼티 래퍼 만들기 (간단 예시)Permalink
간단한 프로퍼티 래퍼 예시: 값이 0~100로 항상 클램핑(범위 제한)되도록 하는 래퍼를 만들어보겠습니다.
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue initialValue: Value, _ range: ClosedRange<Value>) {
self.range = range
// 초기값을 범위에 맞게 조정
self.value = min(max(initialValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set {
// 새 값이 범위를 벗어나면 한계값으로 보정
value = min(max(newValue, range.lowerBound), range.upperBound)
}
}
}
사용:
struct Player {
@Clamped(0...100) var health: Int = 100
}
var player = Player()
player.health = 150
print(player.health) // 100으로 클램핑되어 출력됨
위 @Clamped 프로퍼티 래퍼는 health 값을 0에서 100 사이로 유지합니다. 150을 설정해도 wrappedValue setter에서 100으로 보정하므로 벗어난 값이 들어가지 않습니다.
이렇듯 프로퍼티 래퍼는 중복 코드를 줄이고, 프로퍼티의 부가 기능(검증, 변환 등)을 캡슐화하는 데 유용합니다. SwiftUI에서 상태 관리 관련 래퍼들이 이러한 개념으로 구현되어 있으며, Combine 및 Swift Concurrency에서도 유사한 패턴이 활용됩니다.
정리Permalink
SwiftUI의 Environment는 뷰 계층 전체에 걸쳐 공통된 정보를 전달하는 강력한 수단입니다. 기본 제공 환경 값(@Environment(.))을 통해 시스템 환경이나 앱 전역 설정을 쉽게 참조할 수 있고, 커스텀 EnvironmentKey를 정의하면 개발자 정의 데이터를 깨끗하게 전파할 수 있습니다.
한편, Swift 언어의 프로퍼티 래퍼 기능은 SwiftUI의 선언적 문법을 가능케 한 핵심 요소로, 반복적인 프로퍼티 제어 로직을 재사용 가능한 형태로 캡슐화합니다. SwiftUI의 @State, @Binding 등은 모두 프로퍼티 래퍼로 구현되어 뷰의 상태를 관찰하고 UI를 업데이트하는 역할을 합니다. 개발자도 상황에 따라 직접 프로퍼티 래퍼를 만들어 활용할 수 있으며, 이를 통해 코드의 의도를 명확히 드러내고 중복을 줄이는 효과를 얻습니다.
요약하면, Environment와 프로퍼티 래퍼를 적절히 활용하면 상태와 설정의 관리 범위를 명확히 하면서, SwiftUI 앱을 모듈화하고 읽기 쉽게 구성할 수 있습니다.
7. Navigation (내비게이션)Permalink
NavigationStack과 NavigationViewPermalink
SwiftUI에서 계층적인 화면 이동(즉, 화면을 눌러 다음 화면으로 넘어가는 UI)은 NavigationStack을 사용하여 구현합니다 (iOS 16 이전에는 NavigationView). NavigationStack은 UIKit의 UINavigationController 역할을 대체하며, 여러 뷰를 쌓아(push) 나가는 내비게이션 구조를 만듭니다.
사용법:
NavigationStack {
// 내비게이션 가능한 콘텐츠 뷰들
}
이 블록 안에 NavigationLink 등을 배치하면, 상단에 자동으로 내비게이션 바(Navigation Bar)가 생기고, 하위 화면 이동 시 백버튼 등이 표시됩니다. NavigationView
도 유사하게 동작하지만, iOS 16부터는 NavigationStack
으로 명칭이 변경되고 기능이 확장되었습니다.
NavigationView vs NavigationStack:
-
NavigationView (iOS 13~15): Push-pop 내비게이션 지원. 하지만 프로그램적인 제어가 어렵고, 두 개 이상의 NavigationLink를 한 화면에서 동시 활성화 등의 고급 동작에 제약이 있었습니다.
-
NavigationStack (iOS 16~): 제네릭 타입
NavigationStack<Path, Content>
형태로 경로(Path)를 추적할 수 있으며, new API인 NavigationLink(value:)와navigationDestination
을 활용해 데이터 중심의 내비게이션 구현이 가능합니다. 또한 iPad/Mac에서 사이드바+디테일 형식은 NavigationSplitView로 분리되어 제공됩니다.
간단한 경우 기존 NavigationView와 NavigationStack의 사용법은 거의 같지만, 최신 API에 익숙해지는 것이 좋습니다.
NavigationLink를 통한 화면 이동Permalink
NavigationLink는 버튼처럼 터치되었을 때 새로운 화면을 내비게이션 스택에 Push해주는 뷰입니다. 사용자가 NavigationLink를 탭하면 지정된 대상(destination) 뷰가 내비게이션 스택에 추가되어 표시됩니다.
기본적인 사용법 두 가지:
- destination 클로저를 사용하는 방법 (전통적):
NavigationStack {
NavigationLink(destination: DetailView()) {
Text("Go to Detail")
}
.navigationTitle("Home")
}
여기서는 “Go to Detail” 버튼을 누르면 DetailView로 이동합니다. navigationTitle
수정자를 사용해 내비게이션 바 타이틀을 설정했습니다.
- value 기반 새로운 방법 (iOS 16+):
NavigationStack {
List(items) { item in
NavigationLink(item.name, value: item)
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
.navigationTitle("Items")
}
이 패턴에서는 NavigationLink에 destination 대신 value: item
을 주고, NavigationStack 밖에서 .navigationDestination(for: Item.self)
를 통해 해당 타입의 화면을 정의합니다. 이렇게 하면 프로그래밍적으로 NavigationStack
의 경로(NavigationPath)에 Item을 추가하거나 제거하여 내비게이션을 제어할 수 있습니다. 또한 List와 함께 쓰면 목록의 선택이 NavigationLink의 동작으로 자동 연결되어 편리합니다.
NavigationLink는 label을 커스터마이즈할 수 있기 때문에, 단순 텍스트뿐 아니라 HStack 등 복잡한 뷰를 넣어도 됩니다. 또한 NavigationLink를 사용하지 않고도 NavigationStack
의 path
바인딩을 직접 조작해 화면을 전환할 수도 있지만, 일반적인 경우 NavigationLink로 충분합니다.
새로운 NavigationStack API (NavigationPath, navigationDestination)Permalink
앞서 언급한 NavigationPath는 내비게이션 스택의 히스토리를 데이터로 관리하는 타입입니다. 예를 들어:
@State private var path: NavigationPath = NavigationPath()
NavigationStack(path: $path) {
VStack {
Button("Go to A") { path.append("ScreenA") }
Button("Go to B") { path.append("ScreenB") }
}
.navigationDestination(for: String.self) { value in
if value == "ScreenA" {
ScreenAView()
} else if value == "ScreenB" {
ScreenBView()
}
}
}
위 코드에서 버튼을 누르면 path 배열에 해당 식별자(String)가 append되고, navigationDestination
블록에서 매칭되는 화면이 Push됩니다. 이처럼 코드로 자유롭게 Push/Pop이 가능하며, path.removeLast()
나 path.removeLast(path.count)
로 여러 화면을 한번에 Pop하거나 초기 상태로 복귀시킬 수 있습니다.
이 새로운 API는 상태 기반 내비게이션을 가능하게 하여, 깊숙한 뷰에서 발생한 이벤트에 따라 중간 경로를 건너뛰고 특정 화면으로 이동하거나, 딥링크(deeplink) 처리를 쉽게 해줍니다. 다만 단순한 마스터-디테일 구조에서는 굳이 NavigationPath를 사용할 필요 없이 기본 NavigationLink로 충분합니다.
Modal 내비게이션 (Sheet, FullScreenCover)Permalink
NavigationStack을 통한 push 방식 외에도, 모달 시트 형태의 화면 전환도 SwiftUI에서 제공합니다:
-
.sheet(isPresented: $flag)
– 작은 모달 시트 (iOS에서는 기본적으로 카드 형태로 화면 일부만 덮음) -
.fullScreenCover(isPresented: $flag)
– 전체 화면을 덮는 모달 (전통적인 modalPresentationStyle.fullScreen과 유사)
이들은 NavigationStack과 별개로 동작하지만, 모달 내부에서도 필요하면 NavigationStack을 사용할 수 있습니다.
예:
.sheet(isPresented: $showSettings) {
SettingsView()
}
showSettings
가 true 되면 SettingsView가 모달로 올라옵니다. 모달을 닫을 때는 showSettings = false
로 상태를 바꾸면 됩니다 (또는 Environment의 presentationMode를 사용할 수도 있음).
정리Permalink
SwiftUI의 내비게이션은 NavigationStack을 중심으로 이루어지며, NavigationLink를 사용해 사용자가 새로운 화면으로 이동하도록 구현합니다. iOS 16부터 도입된 새로운 내비게이션 API들은 데이터를 기반으로 내비게이션 상태를 관리할 수 있게 해주어, 더욱 유연한 화면 전환 (예: 딥링크 처리, 프로그래밍적 내비게이션)이 가능합니다.
일반적인 앱에서는 간단한 NavigationLink와 NavigationStack 구성만으로 대부분의 내비게이션 요구사항을 충족할 수 있습니다. 추가로 모달 표시가 필요하면 .sheet
나 .fullScreenCover
를 조합하여 사용합니다. 중요한 것은 SwiftUI 내비게이션이 상태 변화에 반응하는 방식이라는 점으로, 화면 이동 역시 상태(@State
혹은 @StateObject
등)의 변화로 트리거된다는 개념을 이해하면 더욱 직관적으로 설계할 수 있습니다.
SwiftUI 내비게이션은 과거 UIKit 대비 코드량이 적고 선언적으로 화면 흐름을 표현할 수 있다는 장점이 있지만, 여전히 복잡한 내비게이션 시나리오에서 주의가 필요합니다 (예: 한 화면에서 여러 NavigationLink 동시 활성화, NavigationSplitView 구성 등). 최신 버전의 SwiftUI에서는 이러한 문제점들이 개선되고 있으므로, 최신 API에 맞춰 코드를 구성하는 것이 권장됩니다.
8. UIKit 연동 (SwiftUI ↔ UIKit Integration)Permalink
SwiftUI에서 UIKit 뷰 사용하기: UIViewRepresentablePermalink
SwiftUI는 대부분의 UI를 커버하지만, 간혹 UIKit의 UIView를 SwiftUI 화면에 포함해야 하는 경우가 있습니다. 예를 들어 SwiftUI에 직접 제공되지 않는 특수한 UIKit 컨트롤이나, 기존 UIKit 커스텀 뷰를 재사용하려는 경우입니다. 이때 UIViewRepresentable 프로토콜을 활용하면 SwiftUI View로 UIKit UIView를 래핑할 수 있습니다.
UIViewRepresentable 구현 기본 구조:
struct MyUIKitViewRepresentable: UIViewRepresentable {
// 1. 표현하려는 UIView의 타입 지정
typealias UIViewType = UISwitch // 예: UISwitch를 SwiftUI에서 사용
// 2. UIView 생성
func makeUIView(context: Context) -> UISwitch {
let uiView = UISwitch()
// 필요한 초기 설정
return uiView
}
// 3. SwiftUI 상태 변화에 따라 UIView 업데이트
func updateUIView(_ uiView: UISwitch, context: Context) {
// SwiftUI -> UIKit 상태 전달 (예: uiView.isOn = someState)
}
// 4. (옵션) Coordinator 생성 - SwiftUI <-> UIKit 상호 작용 담당
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator {
// UIKit의 delegate 등을 처리할 중간 객체
}
}
기본적으로 makeUIView
에서 UIKit 뷰를 생성하고, updateUIView
에서 SwiftUI 상태에 따른 뷰의 속성을 업데이트합니다. SwiftUI가 이 두 메서드를 적절한 시점에 호출하여 UIView의 라이프사이클을 관리합니다.
예시: SwiftUI에서 MapKit의 MKMapView 사용하기 (간단 버전):
struct MapView: UIViewRepresentable {
@Binding var centerCoordinate: CLLocationCoordinate2D // SwiftUI <-> UIKit 공유 상태
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator // Coordinator를 delegate로 설정
return map
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// 필요 시 SwiftUI 상태 변화에 따른 지도 업데이트
}
func makeCoordinator() -> MapViewCoordinator {
MapViewCoordinator(self)
}
class MapViewCoordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
// MKMapViewDelegate 메서드 예: 지도 중심이 움직였을 때 Binding 업데이트
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
parent.centerCoordinate = mapView.centerCoordinate
}
}
}
위 예에서 SwiftUI MapView
는 UIKit의 MKMapView를 래핑합니다. Coordinator를 통해 MKMapViewDelegate 이벤트를 받아, SwiftUI의 상태 (centerCoordinate
)를 업데이트하고 있습니다. 이렇게 하면 SwiftUI와 UIKit 간에 데이터가 양방향으로 연동됩니다.
UIViewControllerRepresentable 사용Permalink
전체 뷰 컨트롤러(UIViewController)를 SwiftUI에 넣어야 하는 경우는 UIViewControllerRepresentable 프로토콜을 사용합니다. 구조는 UIViewRepresentable과 유사하지만 뷰 대신 뷰컨트롤러를 생성하고 관리합니다.
예를 들어 UIImagePickerController(사진 선택) UIKit 컨트롤러를 SwiftUI에서 모달로 쓰고 싶다면:
struct ImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
@Binding var selectedImage: UIImage?
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var parent: ImagePicker
init(_ parent: ImagePicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
이렇게 구현한 후 .sheet
등을 통해 ImagePicker를 표시하면, 사용자가 사진을 고른 뒤 selectedImage
바인딩에 결과가 저장되고, 모달이 닫히도록 처리됩니다.
UIViewControllerRepresentable은 makeUIViewController/updateUIViewController를 제공하며, Coordinator를 활용해 UIKit의 delegate 패턴을 처리하는 식은 UIViewRepresentable과 동일합니다.
UIKit에서 SwiftUI 뷰 사용하기: UIHostingControllerPermalink
반대로 UIKit 기반 프로젝트에 SwiftUI 뷰를 삽입할 수도 있습니다. 이때 사용하는 것이 UIHostingController<Content>
입니다. UIHostingController는 UIViewController처럼 동작하며, SwiftUI의 View를 하나 받아 그 콘텐츠를 화면에 표시합니다.
예:
let swiftUIView = ContentView() // SwiftUI 뷰 생성
let hostingController = UIHostingController(rootView: swiftUIView)
// 이제 hostingController를 UIKit의 뷰 컨트롤러 계층에 추가 (present 또는 push 등)
navigationController.pushViewController(hostingController, animated: true)
스토리보드에서도 HostingController를 활용할 수 있으며, Interface Builder에서 UIHostingController
객체를 놓고 Module/Class를 지정한 후, 연결된 SwiftUI 뷰를 설정해주는 식으로 사용합니다.
이를 통해 기존 UIKit 프로젝트에 점진적으로 SwiftUI 뷰를 도입하거나, UIKit 화면 일부를 SwiftUI로 작성한 컴포넌트로 교체할 수 있습니다.
코디네이터(Coordinator)를 통한 상호작용 처리Permalink
위 예시들에서 등장한 Coordinator는 SwiftUI와 UIKit 사이의 중계 역할을 합니다. 대표적으로:
-
UIKit 뷰/뷰컨트롤러의 Delegate, DataSource 등을 구현하여 이벤트를 받아 SwiftUI 상태나 동작으로 전달.
-
SwiftUI에서 UIKit으로 액션을 보낼 필요가 있을 때 (필요 시 Context를 통해 UIViewRepresentable에서 UIKit뷰로 직접 접근하여 조작하기도 하지만, 복잡한 경우 Coordinator 활용).
Coordinator는 UIViewRepresentable/UIViewControllerRepresentable 프로토콜의 기본 연장선으로, makeCoordinator()를 구현하면 SwiftUI가 해당 Coordinator 객체의 생명주기도 관리합니다 (뷰 생성 시 1회 생성, 필요없어지면 해제).
정리하면 Coordinator를 통해 두 UI 프레임워크 간의 의사소통을 담당하는 객체를 둠으로써, SwiftUI와 UIKit 코드를 깔끔하게 분리하면서도 필요한 상호작용은 가능하게 해줍니다.
정리Permalink
SwiftUI와 UIKit은 상호 보완적으로 함께 사용 가능합니다. UIViewRepresentable과 UIViewControllerRepresentable 프로토콜을 사용하면 SwiftUI 화면 안에 UIKit 구성 요소를 넣을 수 있으며, 이로써 기존 UIKit 자산 재사용이나 SwiftUI에서 부족한 기능 보완이 가능합니다. 반대로 UIHostingController
를 활용하면 UIKit 기반 앱에도 SwiftUI 뷰를 손쉽게 포함시킬 수 있어, 점진적인 마이그레이션이나 혼합 개발이 용이합니다.
개발자는 이러한 연동 기법을 통해 두 프레임워크의 강점을 모두 활용할 수 있습니다. 예를 들어, SwiftUI로 전체 UI를 구축하면서도, 지도, 카메라, 웹 뷰 등 복잡한 뷰는 UIKit으로 래핑하여 활용하거나, 기존 UIKit ViewController를 유지한 채 신규 화면만 SwiftUI로 작성하는 등 다양한 전략을 취할 수 있습니다. SwiftUI-UIKit 통합은 약간의 보일러플레이트 코드가 필요하지만, Coordinator 패턴 등을 통해 구조적으로 관리할 수 있으며, 장기적으로는 SwiftUI 생태계가 확대됨에 따라 이러한 연동 필요성은 점차 줄어들 것으로 기대됩니다.
9. AppStorage, SceneStorage, UserDefaultsPermalink
UserDefaults와 SwiftUIPermalink
UserDefaults는 앱 내에 간단한 설정값이나 사용자 데이터를 영구 저장하기 위한 iOS의 기본 저장소입니다. 키-값 쌍으로 데이터를 저장하며, 앱 재시작 후에도 유지됩니다. 과거 UIKit에서는 UserDefaults.standard.set()
등으로 값을 저장하고 불러왔지만, SwiftUI에서는 프로퍼티 래퍼를 통해 UserDefaults와 뷰 상태를 바로 연결하는 기능을 제공합니다.
SwiftUI에서 UserDefaults를 직접 사용할 수도 있지만, @AppStorage 등을 쓰면 훨씬 간결하게 상태와 저장을 동기화할 수 있습니다.
@AppStorage: 사용자 기본값 간편 연결Permalink
@AppStorage 프로퍼티 래퍼는 특정 키의 UserDefaults 값을 자동으로 조회 및 저장해주는 역할을 합니다. 마치 @State처럼 사용할 수 있지만, 실제 저장은 UserDefaults에 이루어지므로 앱 재시작 후에도 유지됩니다.
사용법:
@AppStorage("username") var username: String = "Guest"
위처럼 선언하면, UserDefaults.standard
에서 "username"
키로 값을 관리합니다. 초기 기본값 “Guest”가 UserDefaults에 없을 때 사용되며, 한번 저장되면 이후부터는 영구 저장된 값이 사용됩니다. 이 변수를 변경하면 UserDefaults에 바로 기록되고, 반대로 UserDefaults에 변경이 생기면 (@AppStorage를 통해) 뷰에도 업데이트가 반영됩니다.
주의점:
-
@AppStorage는 기본적으로
UserDefaults.standard
를 사용하며, 앱 그룹 등을 지정하고 싶다면UserDefaults(suiteName:)
를 파라미터로 제공할 수 있습니다. -
저장된 데이터는 앱 삭제 시 제거되고, 앱 업데이트나 재설치 후에는 초기값으로 시작합니다 (iCloud 동기화 등은 별도).
예:
@AppStorage("isDarkMode") var isDarkMode: Bool = false
Toggle("다크 모드", isOn: $isDarkMode)
.onChange(of: isDarkMode) { newValue in
print("DarkMode setting changed to \(newValue)")
}
토글을 조작하면 isDarkMode 값이 바뀌고, 이는 자동으로 UserDefaults에 "isDarkMode"
키로 저장됩니다. 앱을 다시 실행해도 이전에 저장된 설정이 유지됩니다.
@SceneStorage: 장면별 상태 복원Permalink
iOS 13 이후 멀티 윈도우/장면(Scene) 개념이 도입되면서, 각 장면별로 복원해야 할 UI 상태가 있을 수 있습니다. @SceneStorage는 장면 단위의 UserDefaults와 유사하게, 각 Scene별로 상태를 저장하고 복원하는 역할을 합니다. 예를 들어 문서 편집 앱에서 각 창마다 스크롤 위치나 현재 선택된 탭을 기억하도록 할 때 사용합니다.
사용법은 @AppStorage와 유사하지만, 저장 공간이 전체 앱이 아니라 각 장면별로 분리되어 관리된다는 점이 다릅니다:
@SceneStorage("draftText") var draftText: String = ""
이렇게 하면 Scene A와 Scene B 각각에 “draftText” 키가 별도로 존재하여, A 장면에서 작성 중이던 텍스트와 B 장면의 텍스트를 독립적으로 저장/복원합니다. Scene을 닫았다 다시 열면 마지막 저장 값이 복원됩니다.
@SceneStorage는 SwiftUI의 ScenePhase (활성/백그라운드 전환 등) 변화에 맞춰 자동 저장을 시도하며, 시스템이 장면을 종료하기 직전 상태를 기록해둡니다. 너무 큰 데이터를 넣기보다는 (UserDefaults 제한도 있으므로) 수백 바이트~수KB 이내의 가벼운 상태 정보를 저장하는 용도로 적합합니다.
언제 어떤 것을 사용할까Permalink
-
영구적으로 유지해야 하는 사용자 설정 (예: 다크모드 설정, 로그인 이름 등): @AppStorage를 사용하면 편리합니다. UserDefaults를 직접 쓰는 것보다 코드량이 적고, 뷰와 값이 연동되어 UI도 자동 업데이트됩니다.
-
일시적 UI 상태 복원 (예: 텍스트 입력 임시 저장, 스크롤 위치): @SceneStorage가 유용합니다. SceneScope에서 관리되므로 여러 창을 지원하는 앱에서 각각의 상태를 개별 보존할 수 있습니다. 단일 창 앱에서도, 앱을 완전히 종료했다 다시 켤 때 복원되길 원하는 UI 상태에 활용할 수 있습니다.
-
그 외 데이터 영속성 요구사항: @AppStorage와 @SceneStorage는 간단한 경우에 한하며, 복잡한 데이터나 보안이 필요한 데이터는 Keychain, 파일 저장, CoreData/SQLite, iCloud 등 다른 수단을 사용해야 합니다. 또한 @AppStorage로 Codable 구조 등을 저장하려면 Base64 인코딩하거나 별도 처리 필요하므로, 간단한 값 타입 (String, Number, Bool 등)에 주로 사용합니다.
정리Permalink
SwiftUI는 @AppStorage와 @SceneStorage라는 편리한 프로퍼티 래퍼를 제공하여, 소규모의 상태를 영구 저장 또는 장면별 복원할 수 있게 합니다. @AppStorage는 내부적으로 UserDefaults를 사용하므로 영구 저장이 간편해졌고, @SceneStorage는 멀티씬 환경에서 UI 상태 유지에 유용합니다.
이러한 래퍼들은 프로퍼티 래퍼 문법 덕분에 보일러플레이트를 크게 줄여주며, 마치 @State를 쓰듯이 선언만 하면 자동으로 저장/로딩이 이루어집니다. 다만, 저장소의 성격을 이해하고 적절한 용도에만 사용하는 것이 중요합니다 – AppStorage/SceneStorage는 소량의 상태에 적합하며, 복잡한 데이터 관리에는 별도의 영속화 기법을 사용해야 합니다.
결론적으로, SwiftUI 앱에서 사용자의 설정이나 UI 상태를 손쉽게 유지하려면 이 두 래퍼를 적극 활용할 수 있으며, 이를 통해 상태 유지 로직을 최소화하고도 쾌적한 사용자 경험(UX)을 제공할 수 있습니다.
10. Concurrency (동시성: GCD, RunLoop, async/await, Task, Actor 등)Permalink
GCD (Grand Central Dispatch)와 스레드 큐Permalink
Grand Central Dispatch (GCD)는 Apple의 저수준 동시성 프레임워크로, DispatchQueue를 통해 스레드 풀에서 작업을 병렬 또는 비동기로 수행할 수 있게 합니다. GCD를 사용하면 직접 스레드를 만들지 않고, 전역 큐나 커스텀 큐에 작업을 제출하여 비동기 실행을 구현합니다.
예:
DispatchQueue.global(qos: .background).async {
// 백그라운드 큐에서 실행할 작업
let result = heavyCalculation()
DispatchQueue.main.async {
// UI 업데이트는 메인 큐에서
self.label.text = "\(result)"
}
}
위 코드에서 GCD를 사용해 heavyCalculation()
을 백그라운드에서 수행하고, 완료 후 메인 큐로 돌아와 UI를 갱신합니다. GCD는 동시 실행(concurrent queue)과 직렬 실행(serial queue)을 지원하여, 적절한 QoS(Quality of Service)와 함께 작업을 최적화합니다.
또한 GCD에는 DispatchGroup, DispatchWorkItem, DispatchSemaphore 등 다양한 동기화 도구가 있습니다:
-
DispatchGroup: 여러 비동기 작업의 완료를 추적하고 그룹 동작을 수행.
-
DispatchSemaphore: 간단한 락/신호기 메커니즘 제공.
-
DispatchSource: 타이머, 파일 디스크립터 등 이벤트 소스를 처리.
RunLoop 개념Permalink
RunLoop는 스레드와 관련된 이벤트 처리 루프입니다. 메인 RunLoop는 앱 실행 내내 돌면서 사용자 입력, Timer, 시스템 이벤트 등을 받고 처리합니다. UI 이벤트나 NSTimer 등이 특정 RunLoop (주로 메인스레드 RunLoop)에 등록되어 동작합니다.
RunLoop는 특정 스레드에서 들어오는 이벤트를 수신하고 처리하는 루프입니다. 기본적으로 메인 RunLoop는 항상 실행 중이며 시스템의 메시지를 받아 앱에 전달합니다.
개발자가 직접 RunLoop를 다룰 일은 드물지만, 간혹 RunLoop를 수동 제어해야 할 때 (예: Thread를 만들고 RunLoop를 돌려 특정 이벤트를 청취) 사용합니다. SwiftUI에서는 RunLoop.main을 Scheduler로 이용하거나 Combine에서 .receive(on: RunLoop.main)
식으로 쓰기도 합니다.
Swift의 새로운 동시성 (async/await와 Task)Permalink
Swift 5.5부터 언어 차원의 동시성(concurrency) 모델이 도입되어, async/await 키워드를 사용한 구조적 동시성(Structured Concurrency)을 구현할 수 있습니다. 이는 GCD의 추상화된 형태로, 비동기 함수를 작성하고 호출하는 방식이 동기 코드처럼 순차적으로 보이지만, 실제로는 비동기로 실행됩니다.
async/await:
-
async
함수는 suspending function으로, 내부에await
가능한 비동기 작업을 수행합니다. -
함수를 호출하는 쪽에서는
await
키워드로 그 비동기 작업의 완료를 기다립니다.
예:
func fetchData() async throws -> Data {
let url = URL(string: "https://example.com/api")!
let (data, response) = try await URLSession.shared.data(from: url)
return data
}
Task {
do {
let data = try await fetchData()
print("Data size: \(data.count)")
} catch {
print("Error:", error)
}
}
위 코드에서 fetchData()
는 async 함수로 URLSession의 비동기 data 작업을 await
하여 결과를 반환합니다. Task
내부에서는 해당 async 함수를 호출할 때 await
를 사용하며, 에러 처리를 위해 do/try/await
조합을 사용합니다. Task { }
로 감싼 부분은 새로운 동시 실행 컨텍스트로 수행되며, 이는 GCD 글로벌 큐에서 실행되는 것과 유사하지만, Structured Concurrency에 의해 부모 태스크와 연계된 형태로 관리됩니다.
Task & TaskGroup:
-
Task { }
를 쓰면 명시적으로 새로운 태스크를 시작합니다 (이는 기본적으로 detached가 아니며, 호출부의 local context를 상속받습니다). -
Task.detached { }
는 부모 컨텍스트와 상관없이 완전히 독립적인 태스크를 생성합니다. -
TaskGroup
을 사용하면 여러 하위 태스크를 병렬 실행하고, 그 결과를 수집하거나 태스크 간 동기화를 손쉽게 구현할 수 있습니다 (예:await group.next()
로 완료된 순서대로 결과 처리).
MainActor:
Swift Concurrency에서는 메인쓰레드에서 실행해야 할 코드를 위해 @MainActor
를 사용합니다. @MainActor
가 적용된 함수/프로퍼티는 항상 메인 스레드에서 실행되며, UI 업데이트 관련 async 함수를 만들 때 이용합니다 (예: @MainActor func updateUI() { ... }
).
Actor를 통한 상태 보호Permalink
Actor는 Swift 5.5의 동시성 모델에서 제공하는 참조 타입 격리 메커니즘입니다. Actor는 자신 내부의 상태를 보호하여 한 번에 하나의 태스크만 접근하게 함으로써 데이터 레이스(data race)를 원천 차단합니다. 쉽게 말해, 클래스로 선언하되 actor
키워드를 사용하면, 그 인스턴스의 mutable 상태는 Actor의 ‘순서보장 큐’를 통해 직렬화된 접근이 이루어집니다.
예:
actor Counter {
private var value: Int = 0
func increment() {
value += 1
}
func getValue() -> Int {
return value
}
}
Counter actor의 value
는 동시에 둘 이상의 태스크가 수정할 수 없습니다. await counter.increment()
를 여러 태스크에서 호출해도 Actor는 한 번에 하나씩 호출을 처리합니다. Actor 내부에서는 별도 동기화 코드 없이도 상태 보호가 되므로, 전통적인 mutex 락 등을 대체합니다.
Actor 간 메시지는 await
로 주고받으며, Actor 내부에서 외부와 상호작용하지 않는 함수는 nonisolated
키워드를 붙여 actor 격리에서 예외적으로 빠져나올 수도 있습니다. 또한 MainActor
도 global actor의 한 종류로, 사실상 main thread에서 작업하는 Actor로 볼 수 있습니다.
정리 및 권장 패턴Permalink
동시성 처리는 Swift 언어의 지원으로 크게 변화하고 있습니다. 기존에는 GCD와 콜백, Combine, OperationQueue 등을 이용했지만, 이제는 async/await과 Actor를 사용하는 것이 권장됩니다. 이러한 새로운 모델은 코드 가독성을 높이고 런타임 오류(데이터 레이스 등)를 방지하는 데 효과적입니다.
-
UI 업데이트는 항상 메인 쓰레드(MainActor)에서! Swift Concurrency에서는 자동으로 MainActor로 전환하거나,
DispatchQueue.main.async
대신await MainActor.run { }
같은 패턴을 사용할 수 있습니다. -
백그라운드 작업은 Task로 분리하거나
Task.detached
를 사용하되, 너무 많은 태스크 남발은 피하고 필요한 구조 내에서 실행합니다 (예: TaskGroup으로 child tasks 관리). -
기존 GCD 기반 코드와 혼용할 때는 주의가 필요합니다. 예를 들어
DispatchQueue.async
안에서await
를 직접 호출할 수는 없으므로, bridging이 필요할 경우Task { await ... }
형태로 감싸야 합니다. -
RunLoop를 직접 다룰 일은 거의 없지만, 이해해두면 Timer나 입력 처리의 동작 원리를 파악하는 데 도움이 됩니다.
마지막으로, Swift의 새로운 동시성 도입으로 코드의 비동기 흐름이 동기 코드처럼 간결하게 표현될 수 있게 되었고, 이는 앱의 유지보수성과 안정성을 높입니다. 앱 개발 시 GCD나 콜백 패턴보다 async/await 패턴을 우선적으로 고려하고, 공유 자원 접근에는 Actor를 활용하는 것이 미래 지향적인 선택입니다.
11. Combine (Publisher, Subscriber, Subject, Scheduler 등)Permalink
Combine 소개와 개념Permalink
Combine은 Apple이 iOS 13(2019년)에 도입한 반응형 프로그래밍(Reactive Programming) 프레임워크입니다. 시간에 따라 발생하는 값의 흐름(이벤트 스트림)을 처리하고, 가공하고, 다른 관심 객체에 전달하는 것을 표준화합니다. Combine은 Publisher-Subscriber 패턴을 기반으로 동작하며, RxSwift 등의 기존 ReactiveX 라이브러리와 유사한 개념을 표준 프레임워크로 제공한다는 점에서 큰 의미가 있었습니다.
Combine의 핵심 용어:
-
Publisher: 시간에 따라 일련의 값을 내보내는 주체입니다. 예를 들어
Timer.publish(every: 1.0, ...)
는 매 1초마다 정수를 내보내는 Publisher입니다. Publisher는 Output 타입과 Failure(Error) 타입을 제네릭으로 가지며 (Publisher<Output, Failure>
). -
Subscriber: Publisher로부터 값을 받아 처리하는 객체입니다. 일반적으로 Combine에서는 Subscriber를 직접 구현하기보다는
sink(receiveValue:)
등의 편의 클로저를 사용하거나,assign(to:on:)
으로 객체의 프로퍼티에 바인딩하는 식으로 값을 소비합니다.
Combine의 동작 원리:
-
Publisher와 Subscriber를 구독(subscription)으로 연결합니다.
-
Subscriber는 Publisher에게 수요(demand)를 요청하고, Publisher는 그에 맞춰 데이터를 전달합니다 (이는 Backpressure 조절 메커니즘).
-
데이터 전달은 Publisher -> Subscriber 방향으로 이뤄지며, 완료(completion)되거나 에러가 발생하면 스트림이 종료됩니다.
Publisher와 Subscriber의 관계Permalink
Combine에서 Publisher와 Subscriber는 1:N 또는 N:1 관계로 구성할 수 있지만, 일반적으로 한 Publisher를 다수 Subscriber가 구독할 수 있습니다 (멀티캐스트의 경우 Subject나 multicast 연산자를 사용).
예시: 간단한 Publisher 생성과 구독:
let publisher = [1, 2, 3].publisher // Array를 Publisher로 변환
let subscription = publisher
.map { $0 * 2 }
.sink(
receiveCompletion: { completion in
print("완료: \(completion)")
},
receiveValue: { value in
print("값 수신: \(value)")
}
)
// 출력: 값 수신: 2, 값 수신: 4, 값 수신: 6, 완료: finished
[1,2,3].publisher
는 순차적으로 1,2,3을 내보내는 Publisher입니다. 여기에 .map
연산자를 체인하여 값을 2배로 만들었고, sink
로 구독하여 값을 출력했습니다. sink는 Subscriber를 반환하는데 (AnyCancellable
타입) 이는 필요한 경우 subscription.cancel()
로 취소(cancellation)할 수 있습니다.
Operator 체이닝 (맵, 필터, 결합 등)Permalink
Combine의 강력함은 다양한 연산자(Operator)들을 제공한다는 점입니다. 연산자는 Publisher 프로토콜의 extension으로 구현되어, Publisher 스트림을 가공하거나 다른 스트림과 합성할 수 있습니다. 몇 가지 주요 연산자:
-
Transform 계열:
map
(값 변환),tryMap
(오류 던질 수 있는 map),flatMap
(Publisher의 값을 또 다른 Publisher로 변환 후 flatten),scan
(이전 결과와 현재 값을 조합하여 축적). -
Filter 계열:
filter
(조건에 맞는 값만 통과),removeDuplicates
(연속 중복 값 제거),first
/last
(첫 또는 마지막 값을 한 번만 출력). -
Combine 계열:
merge
(여러 Publisher의 이벤트를 하나로 병합),combineLatest
(여러 Publisher의 최신 값들을 튜플로 묶어 출력),zip
(각 Publisher의 같은 순번 값들을 튜플로 짝지어 출력). -
Time 계열:
debounce
(일정 기간 새로운 이벤트가 없을 때 해당 이벤트 출력),throttle
(일정 기간 내 첫 이벤트만 출력하거나 최신 이벤트 출력),delay
(지정 시간 지연 후 이벤트 전달). -
Error 처리:
catch
(오류 발생 시 다른 Publisher로 대체),retry
(오류 발생 시 지정 횟수만큼 재시도).
이런 연산자들을 체인(chain) 형태로 연결하여 데이터 흐름을 선언적으로 구성할 수 있습니다. 예를 들어, 사용자의 검색 입력에 따라 네트워크 요청을 하되, 입력이 끝난 후 0.5초 동안 추가 입력이 없을 때만 요청하고, 같은 검색어에 대해 중복 요청을 제거하는 흐름을 Combine으로 표현하면:
textField.textPublisher // UITextField의 텍스트 Publisher (CombineCocoa 등 활용)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { query in Network.fetchResults(query: query) }
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Error: \(error)")
}
}, receiveValue: { results in
self.results = results
})
.store(in: &cancellables)
이처럼 연산자를 잘 조합하면 복잡한 비동기 로직도 선언적으로 간결하게 구현할 수 있습니다.
Subject와 실전 활용Permalink
Subject는 Publisher이면서 Subscriber인 특수한 타입입니다. 외부에서 Subject에 값을 send()
하면 그것을 Publisher처럼 구독자들에게 전달합니다. Combine에는 두 가지 주요 Subject가 있습니다:
-
PassthroughSubject – 초기 값이 없고,
send
할 때마다 들어온 값을 그대로 내보냅니다. -
CurrentValueSubject – 현재 값을 하나 저장하고 있으며, 새로운 구독자에게 즉시 현재 값을 내보냅니다. 값이 변할 때마다 (send) 최신 값을 저장하고 내보냅니다.
Subject는 Combine에서 브리지(bridge) 역할로 많이 쓰입니다. 예를 들어 Delegate 패턴으로 제공되는 UIKit 이벤트를 Combine 스트림으로 변환할 때, Delegate 메서드 안에서 Subject.send(event)로 Combine 파이프라인에 이벤트를 흘려보낼 수 있습니다.
또한 Publisher들이 output을 여러 Subscriber에게 공유해야 할 때, multicast
연산자나 share()
메서드를 사용하거나, Subject를 중계기로 활용할 수 있습니다. (예: 하나의 네트워크 응답을 여러 뷰모델이 받아 처리해야 한다면, 해당 Publisher에 multicast로 Subject를 붙여 공유)
Scheduler (스케줄러) – 스레드 제어Permalink
Combine의 Scheduler는 어느 쓰레드/큐에서 작업을 수행할지 지정하는 프로토콜입니다. 대표적으로:
-
DispatchQueue.main
(메인 큐, 메인 스레드),DispatchQueue.global()
(백그라운드 글로벌 큐) -
RunLoop.main
(메인 런루프),RunLoop.current
(현재 스레드 런루프) -
ImmediateScheduler
(현재 실행중인 스레드에서 즉시 실행) 등
연산자 중 subscribe(on:)
과 receive(on:)
을 사용해:
-
subscribe(on:)
– 업스트림 구독을 시작하는 시점의 스케줄러를 결정 (예: 네트워크 Publisher를 백그라운드 스레드에서 시작). -
receive(on:)
– 다운스트림(결과 처리)을 전달받을 스케줄러를 결정 (보통 메인스레드 UI 업데이트를 위해 사용).
예: .subscribe(on: DispatchQueue.global()).receive(on: DispatchQueue.main)
형태로 많이 사용됩니다. 이는 백그라운드에서 작업하다가 결과만 메인 쓰레드에서 받겠다는 뜻입니다.
async/await와의 비교 및 결합Permalink
Swift 5.5의 async/await 등장으로 Combine과 겹치는 부분이 많아졌습니다. Combine은 Pull 모델(Subscriber가 request)인 반면, Swift의 AsyncSequence는 Push 모델(for await in
으로 소비)로 개념적으로 차이가 있습니다. 둘 다 시간에 따라 값이 들어오는 것을 처리한다는 점에서 유사하지만, Combine은 연산자와 backpressure, 스케줄러 개념이 있고, async/await은 코드가 간결해지는 장점이 있습니다.
Apple은 공식 문서에서 “Combine의 Publisher와 Swift 표준 라이브러리의 AsyncSequence는 역할은 비슷하지만 구별된다”고 설명합니다:
“Combine의 Publisher는 Swift 표준 라이브러리의 AsyncSequence와 유사한 역할을 하지만, 접근 방식이 다릅니다. Publisher와 AsyncSequence 모두 시간에 따라 요소를 생산하지만, Combine은 Subscriber가 요청하는 풀(pull) 모델을 사용하고, Swift Concurrency는 for-await-in 문법으로 요소를 펼쳐 소비합니다. 두 API 모두 map, filter와 같은 변형을 제공하지만, Combine만이 시간 기반 연산자(예: debounce, throttle)와 결합 연산자(예: merge, combineLatest)를 제공합니다.”
실제로 Swift 5.7부터 Combine의 Publisher
는 values
라는 프로퍼티를 제공하여 이를 AsyncSequence로 사용할 수 있고, 반대로 AsyncStream 등을 통해 async/await 코드에서 Combine의 Publisher를 생성할 수도 있습니다. 즉, 두 방식은 상황에 따라 상호 보완적으로 활용 가능합니다.
결합 예시: Combine의 @Published 프로퍼티를 async/await으로 처리하기:
for await value in viewModel.$text.values {
print("New text:", value)
}
위 코드는 Combine의 Published 스트림(viewModel.$text
)을 AsyncSequence로 변환하여 for await
문으로 값들을 처리한 예시입니다. Combine의 stream을 동시성 코드에서 소비할 수 있게 해주므로, Combine과 async/await를 동시에 사용할 때 유용합니다.
정리Permalink
Combine은 SwiftUI와 함께 등장하여 데이터 흐름과 비동기 이벤트 처리에 대한 선언적 접근을 가능하게 한 프레임워크입니다. 다양한 Publisher 연산자를 통해 복잡한 이벤트 처리를 구성할 수 있고, UIKit/SwiftUI와도 긴밀히 연계됩니다 (예: @Published + @ObservedObject로 ViewModel 상태를 View에 전달). Swift Concurrency의 등장으로 일부분 기능이 중복되지만, 타이머 제어, 스트림 결합, 연속 이벤트 처리 등 Combine만의 장점이 여전히 존재합니다.
실무에서는 간단한 비동기 로직은 async/await으로 처리하고, 다중 값의 스트림(예: 텍스트필드 입력 변화, NotificationCenter 이벤트 등)은 Combine으로 처리하는 식으로 두 기술을 혼용할 수 있습니다. 중요한 것은 두 가지 모두 상황에 맞게 활용하여, 반응형(Reactive)이고 안정적인 앱 구조를 설계하는 것입니다. 앞으로도 Combine과 async/await는 서로 보완적인 도구로서 Swift 개발자의 중요한 무기가 될 것입니다.
각 주제에 대해 핵심 개념과 예제를 다루었습니다. 이 가이드를 통해 Swift와 SwiftUI의 기본부터 고급까지 내용을 정리하고, 실제 코드로 실습하며 이해도를 높일 수 있을 것입니다. Happy Coding!
Comments