iOS/App

Scrumdinger 개발 08 : Handling errors

태애니 2025. 4. 8. 19:27
728x90

 

 

전 회사에서 웹을 할 때 계시던 차장님이 초반 Spring 공부 가이드를 알려주시면서 강조해주셨던 부분이 있다.

에러처리를 반드시 꼼꼼히 공부하라는 말씀이셨다.

사용자 입장에서 없는 서비스나, 제공이 미숙한 서비스보다는 에러가 나는게 제일 크리티컬한 문제라고 강조해주셨던 기억이 있다.

 

그래서 테스팅이나 에러처리 과정이 꼼꼼해야하는데

사실 제일 어렵다...ㅎ...ㅠ 

내가 할땐 다 잘된단말이지?(?)

크로스 체크가 필요한 이유..

 

아무튼, 예기치 않은 에러등의 발생 시 그것을 핸들링하는 방법을 Swift 에서 어떻게 하는지 공부해보도록...하겠다...!👍

 

 

근데 갑자기 왜 이렇게 일기식으로 포스팅을 하고 있는 건지 모르겠음.

 

 

Add an error wrapper structure 

 
일단 Error 가 났을 시 띄워줄 가이딩 메세지 등을 구조화하는 Model 를 만들어준다
import Foundation

struct ErrorWrapper: Identifiable {
    let id: UUID
    let error: Error
    let guidance: String
    
    init(id: UUID = UUID(), error: Error, guidance: String) {
        self.id = id
        self.error = error
        self.guidance = guidance
    }
}



 

해당 메세지를 띄울 View 도 만들어주는데, 

예시에서는 modal 로 띄울 수 있도록 해두었다.

dismiss 하여 유저가 모달 창을 닫을 수 있게까지 해둔 코드는 아래와 같다.

 

import SwiftUI

struct ErrorView: View {
    let errorWrapper: ErrorWrapper
    
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        NavigationStack {
            VStack {
                Spacer()
                Text("ERROR")
                    .font(.title)
                    .padding()
                Text(errorWrapper.error.localizedDescription)
                    .font(.headline)
                Text(errorWrapper.guidance)
                    .font(.caption)
                    .padding()
                Spacer()
            }
            .padding()
            .background(.ultraThinMaterial)
            .cornerRadius(16)
            .padding()
            .toolbar{
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("닫기") {
                        dismiss()
                    }
                }
            }
        }
    }
}

#Preview {
    ErrorView(errorWrapper: ErrorWrapper(
        error: SampleError.errorRequired,
        guidance: "앱을 재시작해주세요."
    ))
}

enum SampleError: Error {
    case errorRequired
}

 

 

Error 가 날 수 있는 곳 (저장/삭제/업데이트 등 데이터 처리 시의 throw 상황) 함수에 throw 를 추가한다.

 

 

  1. @State private var errorWrapper: ErrorWrapper? 추가
  2. saveEdits() 메서드에 throws 붙이기
  3. 호출하는 곳에서 do-catch 문으로 오류 처리하고 errorWrapper에 값 할당
  4. .sheet(item: $errorWrapper)로 ErrorView 표시

 

 

DetailEditView 에서는

 

private func saveEdits() throws { //throws 추가
        scrum.title = title
        scrum.lengthInMinutesAsDouble = lengthInMinutesAsDouble
        scrum.attendees = attendees
        scrum.theme = theme
        
        if isCreatingScrum {
            context.insert(scrum)
        }
        
        
        try? context.save()
    }

 

전체코드

import SwiftUI
import ThemeKit
import SwiftData

struct DetailEditView: View {
    //    @Binding var scrum: DailyScrum // 상위 뷰에서 받는 값
    //    @State private var scrum = DailyScrum.emptyScrum
    let scrum: DailyScrum //swiftData
    
    //    let saveEdits: (DailyScrum) -> Void // user가 Done 버튼을 눌렀을 때 저장/수정이 일어나도록 하는 closure
    
    @State private var attendeeName = "" // input 받을 참석자명
    // 입력 필드 상태 분리
    @State private var title: String
    @State private var lengthInMinutesAsDouble: Double
    @State private var attendees: [Attendee]
    @State private var theme: Theme
    
    @State private var errorWrapper: ErrorWrapper?// error
    
    @Environment(\.dismiss) private var dismiss
    // @Environment(\.dismiss) private var dismiss 을 이용하여 DismissAction 인스턴스를 호출시킨다.
    @Environment(\.modelContext) private var context
    
    private let isCreatingScrum: Bool
    
    // 생성자 정의
    // DailyScrum 값을 넘겨 받으면 이를 분해해서 입력 필드를 입력해줄 수 있도록 한다.
    init(scrum: DailyScrum?) {
        let scrumToEdit: DailyScrum
        if let scrum {
            scrumToEdit = scrum
            isCreatingScrum = false
        } else {
            scrumToEdit = DailyScrum(title: "", attendees: [], lengthInMinutes: 5, theme: .sky)
            isCreatingScrum = true
        }
        
        
        self.scrum = scrumToEdit
        self.title = scrumToEdit.title
        self.lengthInMinutesAsDouble = scrumToEdit.lengthInMinutesAsDouble
        self.attendees = scrumToEdit.attendees
        self.theme = scrumToEdit.theme
    }
    
    var body: some View {
        Form {
            Section(header: Text("Meeting Info")) {
                TextField("Title", text: $title)
                /**
                 TextField takes a binding to a String. You can use the $ syntax to create a binding to scrum.title. The current view manages the state of the data property.
                 */
                HStack {
                    Slider(value: $lengthInMinutesAsDouble, in: 5...30, step: 1) {
                        Text("Length")
                        //the slider and the label stay in sync.
                    }
                    .accessibilityValue("\(String(format: "%.0f", lengthInMinutesAsDouble)) minutes") // 접근성
                    Spacer()
                    Text("\(String(format: "%.0f", lengthInMinutesAsDouble)) minutes")
                        .accessibilityHidden(true)
                }
                ThemePicker(selection: $theme)
            }
            Section(header: Text("참석자 목록")) {
                ForEach(attendees) { attendee in
                    Text(attendee.name)
                }
                .onDelete { indices in
                    attendees.remove(atOffsets: indices)
                    //onDelete when the user swipes to delete a row
                }
                
                HStack {
                    TextField("새로운 참석자", text: $attendeeName)
                    Button(action: {
                        withAnimation {
                            //setting the value to the empty string also clears the contents of the text field.
                            //                            let attendee = DailyScrum.Attendee(name: attendeeName)
                            //                            scrum.attendees.append(attendee)
                            
                            // swift data 사용
                            let attendee = Attendee(name: attendeeName)
                            attendees.append(attendee)
                            
                            // binding to attendeeName, setting the value to the empty string also clears the contents of the text field
                            // animation block 안에서 처리!
                            attendeeName = ""
                        }
                    }) {
                        Image(systemName: "plus.circle.fill")
                            .accessibilityLabel("참석자 추가")
                    }
                    .disabled(attendeeName.isEmpty)
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") {
                    dismiss()
                }
            }
            ToolbarItem(placement: .confirmationAction) {
                Button("Done") {
                    //                    saveEdits()
                    //                    dismiss()
                    do {
                        try saveEdits()
                        dismiss()
                    } catch {
                        errorWrapper = ErrorWrapper(error: error, guidance: "Daily scrum was not recorded. Try again later.")
                        
                    }
                }
            }
        }
        .sheet(item: $errorWrapper) {
            dismiss()
        } content: { wrapper in
            ErrorView(errorWrapper: wrapper)
        }
    }
    
    private func saveEdits() throws {
        scrum.title = title
        scrum.lengthInMinutesAsDouble = lengthInMinutesAsDouble
        scrum.attendees = attendees
        scrum.theme = theme
        
        if isCreatingScrum {
            context.insert(scrum)
        }
        
        
        try? context.save()
    }
}

#Preview {
    //    @Previewable @State var scrum = DailyScrum.sampleData[0]
    //    DetailEditView(scrum: $scrum, saveEdits: { _ in })
    
    @Previewable @Query(sort: \DailyScrum.title) var scrums: [DailyScrum]
    DetailEditView(scrum: scrums[0])
}

 

 

 

DetailView

import SwiftUI
import SwiftData

struct DetailView: View {
    //    let scrum: DailyScrum
    //    @Binding var scrum: DailyScrum // binding 받아서 보여준다.
    let scrum: DailyScrum // swiftData
    
    @State private var isPresentingEditView = false //add a Boolean @State property named isPresentingEditView.
    
    
    //    @State private var editingScrum = DailyScrum.emptyScrum
    
    
    @State private var errorWrapper: ErrorWrapper? // error
    
    var body: some View {
        List {
            Section(header: Text("Meeting Info")) {
                //                Label("Start Meeting", systemImage: "timer")
                //                    .font(.headline)
                //                    .foregroundColor(.accentColor)
                
                // 3depth
                NavigationLink(destination: MeetingView(scrum: scrum, errorWrapper: $errorWrapper)) { //errorWrapper 추가
                    Label("Start Meeting", systemImage: "timer")
                        .font(.headline)
                        .foregroundColor(.accentColor)
                }
                
                HStack {
                    Label("Length", systemImage: "clock")
                    Spacer()
                    Text("\(scrum.lengthInMinutes) minutes")
                }
                .accessibilityElement(children: .combine)
                /**
                 VoiceOver 사용 시 설정
                 
                 .accessibilityElement
                 .accessibilityLabel("내 맘대로 정의") // 사용자가 정의한 라벨 적용
                 SwiftUI에서 접근성(VoiceOver 등)이 특정 UI 요소를 어떻게 인식할지 정의
                 
                 .ignore : 접근성 요소로 인식하지 않음
                 .combine : 하나의 요소로 읽음
                 .contain : 개별로 읽음
                 */
                
                HStack {
                    Label("Theme", systemImage: "paintpalette")
                    Spacer()
                    Text(scrum.theme.name)
                        .padding(4)
                        .foregroundColor(scrum.theme.accentColor)
                        .background(scrum.theme.mainColor)
                        .cornerRadius(4)
                }
                .accessibilityElement(children: .combine)
            }
            
            // 참석자
            Section(header: Text("Attendees")) {
                ForEach(scrum.attendees) { attendee in
                    Label(attendee.name, systemImage: "person")
                }
            }
            
            // history
            Section(header: Text("회의내역")) {
                if scrum.history.isEmpty {
                    Label("회의 내역이 없습니다.", systemImage: "calendar.badge.exclamationmark")
                        .foregroundColor(.gray)
                }
                
                ForEach(scrum.history) { history in
                    HStack {
                        Image(systemName: "calendart")
                        Text(history.date, style: .date)
                    }
                }
            }
        }
        .navigationTitle(scrum.title)
        .toolbar {
            Button("수정") {
                isPresentingEditView = true
            }
        }
        .sheet(isPresented: $isPresentingEditView) {
            NavigationStack{
                //                DetailEditView(scrum: $editingScrum, saveEdits: { dailyScrum in
                //                    scrum = editingScrum
                //                })
                
                DetailEditView(scrum: scrum)
                    .navigationTitle(scrum.title)
                //                DetailEditView(scrum: $editingScrum)// 3depth
                //                // sheet 에 보여질 내용들을 아래에 구성한다
                //                    .navigationTitle(scrum.title)
                //                    .toolbar{
                //                        ToolbarItem(placement: .cancellationAction) {
                //                            Button("취소") {
                //                                isPresentingEditView = false
                //                            }
                //                        }
                //                        ToolbarItem(placement: .confirmationAction) {
                //                            Button("저장") {
                //                                isPresentingEditView = false
                //                            }
                //                        }
                //                    }
            }
        }
        .sheet(item: $errorWrapper, onDismiss: nil) { wrapper in //error
            ErrorView(errorWrapper: wrapper)
        }
    }
}

#Preview {
    //Wrap DetailView in a NavigationStack to preview navigation elements on the canvas.
    //    NavigationStack {
    //        DetailView(scrum: DailyScrum.sampleData[0])
    //    }
    
    
    //    @Previewable @State var scrum = DailyScrum.sampleData[0]
    //    NavigationStack {
    //        DetailView(scrum: $scrum)
    //    }
    
    @Previewable @Query(sort: \DailyScrum.title) var scrums: [DailyScrum]
    NavigationStack {
        DetailView(scrum: scrums[0])
    }
}

 

 

 

 

modelContainer 를 읽기전용으로 만들어 저장 로직 처리 시 에러가 날 수 있는지 테스트해본다.

import SwiftUI
import SwiftData //swiftData

@main
struct ScrumdingerApp: App {
    //add a private @State property named scrums.
    //    @State private var scrums = DailyScrum.sampleData
    
    var body: some Scene {
        WindowGroup {
            ScrumsView()
        }
        //        .modelContainer(for: DailyScrum.self)
        .modelContainer(try! .init(for: DailyScrum.self, configurations: .init(allowsSave: false))) // error 유도
    }
}

 

 

 

 

 

 

 

전체 분량의 약 1/3 정도 왔다. 아니였다

호ㅏ이링....⭐️

728x90