포스트

MapKit (31)

iOS 16 Update

15에 이어 16도 적용해보도록 한다.

Warning 해결하기

Image

이렇게 Warning이 발생하는데

1
Main actor-isolated instance method 'locationManager(_:didUpdateLocations:)' cannot satisfy nonisolated requirement

didUpdateLocations, didFailWithErrornonisolated requirement를 충족하지 못한다고 한다.

CLLocationManagerDelegate 프로토콜은 백그라운드를 포함한 모든 스레드에서 메서드를 호출하려 하는데, 뷰모델은 메인 스레드(@MainActor) 전용 구역으로 격리되어 있어 서로 규칙이 충돌한것

해결 방법은 우선

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class LocationMapViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
    // 생략
    @MainActor
    func getLocations(for locationManager: LocationManager) {
        // 생략
    }

    @MainActor
    func getCheckedInCounts() {
        // 생략
    }

    @MainActor
    @ViewBuilder func createLocationDetailView(for location: DDGLocation, in dynamicTypeSize: DynamicTypeSize) -> some View {
        // 생략
    }
}

@MainActor를 지워주고, 필요한 부분에 @MainActor 개별로 달아주는것이다.

특히 getLocations, getCheckedInCounts 는 UI와도 관련이 되어있기에 MainActor가 필요하다.

그러면 갑자기 이런생각이 들수도있는데 Class를 MainActor로 기존처럼 감싼 상태에서 어디서 본게 있어서 nonisolated 를 앞에 달아주면 되지 않을까라는 생각은 하지말자.

이전글에 적긴 했는데 그거와는 개념이 다르다.

actor?

이전에 정리를 하긴했는데 그래도 여기서도 한번 더 언급이 되면 좋을듯 해서 적어본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import Foundation
import _Concurrency
import PlaygroundSupport 

// 플레이그라운드가 비동기 작업이 끝날 때까지 강제 종료되지 않도록 설정
PlaygroundPage.current.needsIndefiniteExecution = true 

// ==========================================
// 1. Class (티켓 10장, 유저 10명)
// ==========================================
class UnsafeTicketCounter {
    var remainingTickets: Int = 10
    
    func buyTicket(userName: String) {
        if remainingTickets > 0 {
            // 딜레이를 0.1초로 주어 충돌을 확실하게 유도
            Thread.sleep(forTimeInterval: 0.1)
            remainingTickets -= 1
            print("❌ [Class] \(userName)님 예매 성공! 남은 티켓: \(remainingTickets)")
        }
    }
}

// ==========================================
// 2. Actor (티켓 10장, 유저 10명)
// ==========================================
actor SafeTicketCounter {
    var remainingTickets: Int = 10
    
    func buyTicket(userName: String) {
        if remainingTickets > 0 {
            Thread.sleep(forTimeInterval: 0.1)
            remainingTickets -= 1
            print("✅ [Actor] \(userName)님 예매 성공! 남은 티켓: \(remainingTickets)")
        }
    }
    
    nonisolated func getEventName() -> String {
        return "2026 해롤드 단독 콘서트"
    }
}

// ==========================================
// 🚀 플레이그라운드 실행 테스트
// ==========================================

let unsafeCounter = UnsafeTicketCounter()
let safeCounter = SafeTicketCounter()

print("🎉 행사명: \(safeCounter.getEventName())\n") 

Task {
    print("--- 🚨 1. Class 테스트 시작 ---")
    // 10명이 동시에 접근
    for i in 1...10 {
        DispatchQueue.global().async {
            unsafeCounter.buyTicket(userName: "유저\(i)")
        }
    }
    
    // Class 출력이 끝날 때까지 2초 대기
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    
    print("\n--- 🛡️ 2. Actor 테스트 시작 ---")
    // 10명이 동시에 접근
    for i in 1...10 {
        Task {
            await safeCounter.buyTicket(userName: "유저\(i)")
        }
    }
}

AI를 통해 예시 코드를 하나 만들어 달라고 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
🎉 행사명: 2026 해롤드 단독 콘서트

--- 🚨 1. Class 테스트 시작 ---
❌ [Class] 유저2님 예매 성공! 남은 티켓: 9
❌ [Class] 유저4님 예매 성공! 남은 티켓: 8
❌ [Class] 유저1님 예매 성공! 남은 티켓: 7
❌ [Class] 유저3님 예매 성공! 남은 티켓: 6
❌ [Class] 유저9님 예매 성공! 남은 티켓: 1
❌ [Class] 유저8님 예매 성공! 남은 티켓: 3
❌ [Class] 유저6님 예매 성공! 남은 티켓: 3
❌ [Class] 유저5님 예매 성공! 남은 티켓: 1
❌ [Class] 유저10님 예매 성공! 남은 티켓: 0
❌ [Class] 유저7님 예매 성공! 남은 티켓: 3

--- 🛡️ 2. Actor 테스트 시작 ---
✅ [Actor] 유저1님 예매 성공! 남은 티켓: 9
✅ [Actor] 유저2님 예매 성공! 남은 티켓: 8
✅ [Actor] 유저3님 예매 성공! 남은 티켓: 7
✅ [Actor] 유저4님 예매 성공! 남은 티켓: 6
✅ [Actor] 유저5님 예매 성공! 남은 티켓: 5
✅ [Actor] 유저6님 예매 성공! 남은 티켓: 4
✅ [Actor] 유저7님 예매 성공! 남은 티켓: 3
✅ [Actor] 유저8님 예매 성공! 남은 티켓: 2
✅ [Actor] 유저9님 예매 성공! 남은 티켓: 1
✅ [Actor] 유저10님 예매 성공! 남은 티켓: 0

실제로 출력하면 이렇게되는데 결과를 놓고 본다면 두 개념의 차이가 극명하게 드러난다.

  • Class의 한계 (전산 사고 발생): 여러 유저가 동시에 접근했을 때 남은 티켓이 3 -> 1 -> 0 -> 3 처럼 뒤죽박죽 꼬여버리는 이른바 데이터 경쟁(Data Race)이 발생한다.

  • Actor의 본질 (직렬화, Serial): 은행 앱의 출금이나 콘서트 예매처럼 남은 잔량이 중요한 시스템에서는, 밖에서 아무리 수만 명이 동시다발적(Concurrent)으로 접근해도 시스템 내부에서는 무조건 "한 줄로 서서 순서대로(직렬, Serial) 처리"해야 한다. 즉, 비동기 환경 속에서 내부 데이터를 안전하게 보호하기 위해 스스로를 고립(Isolate)시켜 동기성을 강제하는 것이 Actor를 사용하는 가장 큰 이유다.

그렇다면 nonisolated는 왜 하나의 클래스(또는 Actor) 안에 같이 두는 걸까?

바로 통일성(응집도)과 효율성의 타협이다.

예를 들어 한 사람이 여러 장의 티켓을 살 때 적용되는 ‘할인율’, 혹은 은행의 ‘출금 수수료’나 ‘행사 이름(getEventName)’ 같은 데이터는 내부 상태(티켓 잔량, 계좌 잔액)를 변화시키지 않는 고정된 값이다.

이런 단순한 값을 읽어오기 위해 굳이 Actor의 긴 대기열(await)에서 줄을 설 필요는 없다.

하지만 성격상 해당 Actor 내부에 두는 것이 논리적으로 맞기 때문에, "이 데이터는 내부 상태를 건드리지 않으니 줄 서지 말고 바로바로 가져가!"라고 일종의 프리패스(Free-pass) 예외를 허용해 주는 것, 그것이 바로 nonisolated를 사용하는 진짜 이유다.

정리한 사진도 참고

Image

아래 이해를 돕기위한 시뮬레이터를 통해 시각적으로 한번 봐두는것도 좋을듯?

guard let 코드 점검

1
2
3
4
5
6
7
8
// before
guard let cursor = cursor else {
    return checkedInProfiles
}
// after
guard let cursor else {
    return checkedInProfiles
}

불필요하게 = 을 써서 중복 적용하는 부분을 지워준다.

그리고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// DDGLocation
// Before
var squareImage: UIImage {
    guard let asset = squareAsset else { return PlaceholderImage.square }
    return asset.convertToUIImage(in: .square)
}

var bannerImage: UIImage {
    guard let asset = bannerAsset else { return PlaceholderImage.banner }
    return asset.convertToUIImage(in: .banner)
}
// After
var squareImage: UIImage {
    guard let squareAsset else { return PlaceholderImage.square }
    return squareAsset.convertToUIImage(in: .square)
}


var bannerImage: UIImage {
    guard let bannerAsset else { return PlaceholderImage.banner }
    return bannerAsset.convertToUIImage(in: .banner)
}

이건 사실

1
2
let squareAsset: CKAsset!
let bannerAsset: CKAsset!

이렇게 CKAsset으로 타입이 설정되어있고 강재로 Unwrapping을 해두었지만 그래도 optional이긴 하다.

그래서 이부분을 굳이 asset이라는 변수를 써서 리턴을 할필요가 없기에 하나로 바꿔준것

1
2
3
4
5
6
7
8
9
10
11
// DDGProfile
// Before
var avatarImage: UIImage {
    guard let avatar = avatar else { return PlaceholderImage.avatar }
    return avatar.convertToUIImage(in: .square)
}
// After
var avatarImage: UIImage {
    guard let avatar else { return PlaceholderImage.avatar }
    return avatar.convertToUIImage(in: .square)
}

여기도 굳이 avatar = avatar가 필요 없으므로 하나로 통일

1
2
3
4
5
6
7
8
9
10
11
// ProfileViewModel
// Before
guard let profileRecord = existingProfileRecord else {
    alertItem = AlertContext.unableToGetProfile
    return
}
// After
guard let existingProfileRecord else {
    alertItem = AlertContext.unableToGetProfile
    return
}

이후 profileRecord 사용한 것들을 전부 existingProfileRecord로 고쳐준다.

If let, Guard let Article참고

Gradient 적용

1
2
3
4
5
6
7
8
9
// DDGAnnotation
// Before
MapBalloon()
    .frame(width: 100, height: 70)
    .foregroundColor(.brandPrimary)
// After
MapBalloon()
    .fill(Color.brandPrimary.gradient)
    .frame(width: 100, height: 70)

Image

크게 차이를 못느끼겠다.

NavigationStack Docs

Data-Driven Navigation API

Apple이 iOS 16에서 NavigationStack을 발표하며 가장 강조한 키워드는 바로 Data-Driven(데이터 기반)이다.

과거의 NavigationView는 전형적인 View-Driven(뷰 중심) 방식이었다. 버튼을 누르면 “저 뷰(View)로 이동해라”와 같이 뷰와 목적지를 강하게 결합해 두는 형태였다. 하지만 NavigationStack은 주도권을 뷰가 아닌 ‘데이터(Data)’에게 넘겼다.

  • View-Driven (과거): “이 버튼을 누르면 LocationDetailView를 화면에 띄운다.” (목적지 View를 코드에 직접 명시)

  • Data-Driven (현재): “이 버튼을 누르면 DDGLocation이라는 데이터(값)를 스택에 던진다. 어떤 뷰를 띄울지는 중앙 안내소(.navigationDestination)가 데이터를 보고 알아서 결정한다.”

화면을 이동시킨다는 행위가 사실상 배열(path)에 데이터를 추가하거나 삭제하는 행위로 완전히 치환된 것이다.

이 덕분에 화면을 3~4단계 깊이 들어갔다가 한 번에 맨 처음 화면으로 돌아가거나(path.removeAll()), 푸시 알림을 눌러 특정 화면으로 한 번에 점프하는 딥링크(path = [A, B, C]) 작업이 배열 데이터 조작 하나만으로 손쉽게 가능해졌다.

시뮬레이터는 아래 참고

💡 시뮬레이터 핵심 요약 결국 이 두 시뮬레이터가 보여주는 NavigationStack의 핵심은 딱 하나다. 복잡했던 화면(UI) 이동 관리가 '데이터 배열(path) 조작'으로 완전히 대체되었다는 것

과거처럼 목적지 뷰를 직접 호출하는 대신, 다음 화면을 그릴 순수 데이터(값)만 배열에 차곡차곡 쌓는다.

화면 이동 로직이 데이터로 완전히 분리된 이 구조 덕분에, 복잡한 뷰 계층을 신경 쓸 필요 없이 배열을 비워 홈 화면으로 즉시 돌아가거나(removeAll()), 배열에 여러 값을 한 번에 주입해 단숨에 3단계 딥링크 점프를 만들어낼 수 있다.

코드 변경 및 정리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// before
NavigationView {
    List {
        ForEach(locationManager.locations) { location in
            NavigationLink(destination: viewModel.createLocationDetailView(for: location, in: dynamicTypeSize)) {
                // 생략
            }
        }
    }
    .navigationTitle("Grub Spots")
    .listStyle(.plain)
    .task {
        await viewModel.getCheckedInProfilesDictionary()
    }
    .refreshable {
        await viewModel.getCheckedInProfilesDictionary()
    }
    .alert(item: $viewModel.alertItem, content: {
        $0.alert
    })
}
// after
NavigationStack {
    List {
        ForEach(locationManager.locations) { location in
            NavigationLink(value: location) {
                // 생략
            }
        }
    }
    .navigationTitle("Grub Spots")
                .navigationDestination(for: DDGLocation.self, destination: { location in
                viewModel.createLocationDetailView(for: location, in: dynamicTypeSize)
            })
    .listStyle(.plain)
    .task {
        await viewModel.getCheckedInProfilesDictionary()
    }
    .refreshable {
        await viewModel.getCheckedInProfilesDictionary()
    }
    .alert(item: $viewModel.alertItem, content: {
        $0.alert
    })
}

이때 NavigationLink의 value값은

1
NavigationLink(value: <#T##Hashable?#>, label: <#T##() -> View#>)

이렇게 Hashable이어야 하므로 DDGLocation에가서 Hashable프로토콜을 채택해준다.

1
2
3
struct DDGLocation: Identifiable, Hashable {
    // 생략
}

그리고 나머지 NavigationView로 되어있는것들을 모두 NavigationStack으로 바꿔주면 된다.

Gradients & Shadows

1
2
3
4
5
6
7
8
9
10
11
// LocationActionButton
// Before
Circle()
    .foregroundStyle(color)
    .frame(width: 60, height: 60)

// After
Circle()
    .fill(Color.brandPrimary.gradient
    .shadow(.inner(color: .black.opacity(0.5), radius: 5)))
    .frame(width: 60, height: 60)

Image


또는

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Before
Circle()
    .frame(width: 60, height: 60)

Image(systemName: imageName)
    .resizable()
    .scaledToFit() // 빼먹어서 추가. 여기선 영향x
    .foregroundStyle(.white)
    .frame(width: 22, height: 22)
// After
Circle()
    .frame(width: 60, height: 60)

Image(systemName: imageName)
    .resizable()
    .scaledToFit()
    .foregroundStyle(.white.shadow(.drop(color: .black.opacity(0.5), radius: 3)))
    .frame(width: 22, height: 22)

Image

이건 취향이니 알아서 하면 될듯?

단순 색상보다 Gradient와 Inner Shadow를 더해 버튼의 물리적 입체감을 강조하고 싶다면 사용하면 좋을 것 같다.

Multiline TextField

기존에는 height가 100으로 고정된 TextEditor였는데 이부분을 동적으로 height를 조정하도록 바꿔본다.

1
TextField(<#T##titleKey: LocalizedStringKey##LocalizedStringKey#>, text: <#T##Binding<String>#>, axis: <#T##Axis#>)

axis가 있는 TextEditor를 사용한다.

Image

이렇게 동적으로 관리가 된다.


Github: Dub-Dub-Grub Repository

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.