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 를 추가한다.
- @State private var errorWrapper: ErrorWrapper? 추가
- saveEdits() 메서드에 throws 붙이기
- 호출하는 곳에서 do-catch 문으로 오류 처리하고 errorWrapper에 값 할당
- .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