Skip to main content

실전 프로젝트로 앱 완성하기

Views 4약 10분생성형 AI 활용
Summary

이제는 하나의 앱으로 묶어 볼 차례다

지금까지 우리는 iOS 앱 개발에 필요한 여러 조각을 배웠다. Swift 문법, SwiftUI 화면 구성, 상태 관리, 화면 전환, 목록, 로컬 저장소, 네트워크 통신, 비동기 처리, 아키텍처, 테스트, 성능 최적화까지 살펴보았다.

하지만 지식은 따로 떨어져 있을 때보다 하나의 앱 안에서 함께 움직일 때 더 잘 이해된다. 화면에서 버튼을 누르고, 상태가 바뀌고, 데이터를 저장하고, 비동기 작업의 결과를 화면에 반영하는 흐름을 직접 만들어 보면 각 개념의 역할이 분명해진다.

이번 장에서는 작은 실전 프로젝트를 완성하는 과정을 따라간다. 목표는 화려한 앱을 만드는 것이 아니다. 실제 앱에 필요한 기본 구조를 끝까지 연결하는 것이다.

우리가 만들 앱은 하루 기록 앱이다. 사용자는 오늘의 기록을 작성하고, 저장된 기록을 목록에서 확인하며, 짧은 오늘의 문장을 함께 볼 수 있다.

이 앱에는 다음 요소가 들어간다.

  • SwiftUI 기반 화면

  • 상태 관리와 화면 전환

  • SwiftData를 이용한 로컬 저장

  • 비동기 서비스 호출

  • MVVM 기반 코드 구조

  • 로딩, 오류, 빈 데이터 상태 처리

  • 리팩터링을 통한 코드 정리

작지만 실제 앱의 뼈대는 모두 갖춘 프로젝트다.

실전 프로젝트로 앱 완성하기

아이디어를 앱 구조로 바꾸기

앱을 만들 때 가장 먼저 할 일은 코드를 쓰는 것이 아니다. 아이디어를 기능, 화면, 데이터로 나누는 일이다.

"하루 기록 앱"이라는 아이디어는 아직 추상적이다. 이를 개발 가능한 형태로 바꾸면 다음과 같다.

기능 목록 정리

먼저 사용자가 할 수 있는 일을 적는다.

  • 오늘의 기록 작성하기

  • 작성한 기록 목록 보기

  • 기록 상세 내용 보기

  • 기록 삭제하기

  • 오늘의 문장 불러오기

  • 데이터가 없을 때 안내 문구 보여 주기

  • 오늘의 문장을 불러오지 못했을 때 다시 시도하기

기능 목록은 앱의 범위를 정해 준다. 초반부터 기능을 너무 많이 넣으면 앱이 완성되기 전에 구조가 무너진다. 실전 프로젝트에서는 "작지만 끝까지 완성할 수 있는 범위"가 중요하다.

이번 앱에서는 로그인, 이미지 업로드, 푸시 알림 같은 기능은 넣지 않는다. 대신 기본 흐름을 안정적으로 만드는 데 집중한다.

화면 흐름 그리기

다음은 화면 흐름이다.

기록 목록 화면
 ├─ 새 기록 작성 화면
 ├─ 기록 상세 화면
 │   └─ 기록 삭제
 └─ 오늘의 문장 새로고침

이 정도만 정리해도 어떤 SwiftUI 화면이 필요한지 보인다.

  • HomeView: 기록 목록과 오늘의 문장을 보여 주는 메인 화면

  • MemoEditorView: 새 기록을 작성하는 화면

  • MemoDetailView: 저장된 기록의 상세 화면

  • EmptyStateView: 기록이 없을 때 보여 줄 공통 화면

화면을 먼저 나누면 View가 너무 커지는 것을 막을 수 있다.

데이터 모델 설계하기

앱에서 다룰 데이터는 크게 두 가지다.

첫 번째는 사용자가 직접 작성하는 기록이다. 이 데이터는 앱 안에 저장되어야 한다.

두 번째는 오늘의 문장이다. 실제 서비스라면 서버 API에서 가져올 수 있지만, 이 실전 프로젝트에서는 먼저 실행 가능한 예제가 되도록 Mock 서비스로 시작한다. 나중에 실제 API가 생기면 같은 프로토콜을 따르는 네트워크 서비스로 교체하면 된다.

기록 모델

SwiftData를 사용한다면 기록 모델을 다음처럼 정의할 수 있다.

import Foundation
import SwiftData

@Model
final class DailyMemo {
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String, createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }
}

모델은 복잡할 필요가 없다. 처음에는 앱이 실제로 사용하는 정보만 담는 것이 좋다.

여기서는 제목, 본문, 작성일만 저장한다. 나중에 필요하면 기분, 태그, 즐겨찾기 여부 같은 속성을 추가할 수 있다.

오늘의 문장 모델

오늘의 문장은 앱 내부에서 다음처럼 다룬다.

struct Quote: Equatable {
    let text: String
    let author: String
}

실제 API와 연결한다면 서버 응답 전용 모델도 따로 둘 수 있다.

struct QuoteResponse: Decodable {
    let text: String
    let author: String?

    func toDomain() -> Quote {
        Quote(
            text: text,
            author: author ?? "알 수 없음"
        )
    }
}

QuoteResponse는 서버 응답을 해석하기 위한 모델이고, Quote는 앱 내부에서 사용하는 모델이다. 둘을 분리하면 서버 응답 구조가 바뀌어도 화면 코드 전체가 함께 흔들리지 않는다.

프로젝트 파일 구조 잡기

작은 앱이라도 파일 구조를 정리해 두면 나중에 코드를 찾기 쉽다.

예를 들어 다음처럼 구성할 수 있다.

DailyMemoApp
 ├─ App
 │   └─ DailyMemoApp.swift
 ├─ Models
 │   ├─ DailyMemo.swift
 │   └─ Quote.swift
 ├─ Services
 │   └─ QuoteService.swift
 ├─ ViewModels
 │   └─ HomeViewModel.swift
 ├─ Views
 │   ├─ HomeView.swift
 │   ├─ MemoEditorView.swift
 │   ├─ MemoDetailView.swift
 │   └─ EmptyStateView.swift
 └─ Utilities
     └─ DateFormatter+.swift

폴더 이름은 정답이 아니다. 중요한 것은 기준이다.

  • Models: 앱에서 사용하는 데이터 구조

  • Services: 네트워크 요청이나 외부 데이터 제공 코드

  • ViewModels: 화면 상태와 사용자 동작 처리

  • Views: SwiftUI 화면

  • Utilities: 날짜 포맷 등 보조 기능

처음부터 너무 많은 계층을 만들 필요는 없다. 하지만 화면 코드 안에 네트워크 요청, 저장소 처리, 날짜 포맷, 오류 메시지 생성을 모두 넣으면 금방 읽기 어려워진다.

실전 앱에서는 "화면은 화면답게, 데이터 처리는 데이터 처리답게" 나누는 습관이 중요하다.

앱 시작 지점에 저장소 연결하기

SwiftData 모델을 사용하려면 앱 시작 지점에서 모델 컨테이너를 연결해야 한다.

import SwiftUI
import SwiftData

@main
struct DailyMemoApp: App {
    var body: some Scene {
        WindowGroup {
            HomeView()
        }
        .modelContainer(for: DailyMemo.self)
    }
}

이 설정이 있어야 SwiftUI 화면 안에서 @Query, @Environment(.modelContext) 등을 사용할 수 있다.

간단한 앱에서는 View에서 바로 modelContext를 사용해도 된다. 다만 프로젝트가 커질수록 저장 로직을 Repository로 분리하는 편이 좋다.

로딩, 오류, 성공 상태 표현하기

비동기 작업은 항상 성공하지 않는다. 네트워크가 끊길 수도 있고, 서버 응답이 늦을 수도 있으며, 학습용 Mock 서비스에서도 실패 상황을 일부러 만들어 볼 수 있다.

따라서 화면에는 최소한 세 가지 상태가 필요하다.

  • 로딩 중

  • 성공

  • 실패

이를 enum으로 표현하면 깔끔하다.

enum LoadState<Value> {
    case idle
    case loading
    case success(Value)
    case failure(String)
}

상태를 하나의 타입으로 묶으면 isLoading, errorMessage, quote 같은 여러 값이 서로 모순되는 일을 줄일 수 있다.

오늘의 문장 서비스 만들기

먼저 서비스를 프로토콜로 정의한다.

protocol QuoteServiceProtocol {
    func fetchTodayQuote() async throws -> Quote
}

이제 학습용으로 바로 실행할 수 있는 Mock 서비스를 만든다.

struct MockQuoteService: QuoteServiceProtocol {
    func fetchTodayQuote() async throws -> Quote {
        try await Task.sleep(for: .milliseconds(500))

        return Quote(
            text: "Small steps make a big difference.",
            author: "Unknown"
        )
    }
}

이 방식의 장점은 앱을 만드는 동안 실제 서버가 없어도 전체 흐름을 완성할 수 있다는 점이다. 화면은 로딩 상태를 보여 주고, 잠시 뒤 성공 상태로 바뀐다. 즉, 네트워크가 있는 앱과 거의 같은 사용자 경험을 연습할 수 있다.

실제 API가 있다면 같은 프로토콜을 따르는 구현체로 교체하면 된다.

struct RemoteQuoteService: QuoteServiceProtocol {
    let url: URL

    func fetchTodayQuote() async throws -> Quote {
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              (200..<300).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }

        let decoded = try JSONDecoder().decode(QuoteResponse.self, from: data)
        return decoded.toDomain()
    }
}

주의할 점은 예제 주소를 그대로 실제 서비스처럼 사용하지 않는 것이다. example.com 같은 주소는 설명용 주소일 뿐이다. 실제 앱에서는 본인이 사용할 API의 문서에 맞춰 URL, 응답 모델, 인증 방식, 오류 처리를 맞춰야 한다.

HomeViewModel 만들기

오늘의 문장 상태는 ViewModel에서 관리한다.

import Foundation

@MainActor
final class HomeViewModel: ObservableObject {
    @Published private(set) var quoteState: LoadState<Quote> = .idle

    private let quoteService: QuoteServiceProtocol

    init(quoteService: QuoteServiceProtocol = MockQuoteService()) {
        self.quoteService = quoteService
    }

    func loadQuoteIfNeeded() async {
        if case .success = quoteState { return }
        await loadQuote()
    }

    func loadQuote() async {
        quoteState = .loading

        do {
            let quote = try await quoteService.fetchTodayQuote()
            quoteState = .success(quote)
        } catch is CancellationError {
            quoteState = .idle
        } catch {
            quoteState = .failure("오늘의 문장을 불러오지 못했습니다.")
        }
    }
}

@MainActor는 이 ViewModel의 상태 변경이 메인 스레드에서 일어나도록 보장한다. SwiftUI 화면 상태는 메인 스레드에서 바뀌어야 안전하다.

private(set)을 붙이면 외부에서는 값을 읽을 수 있지만 직접 바꿀 수 없다. 상태 변경은 ViewModel의 메서드를 통해서만 일어난다.

또한 QuoteServiceProtocol을 사용했기 때문에 나중에 MockQuoteService를 RemoteQuoteService로 바꾸기 쉽다. 이것이 앞에서 배운 의존성 주입의 실제 활용이다.

메인 화면 만들기

메인 화면은 앱의 중심이다. 여기에서 오늘의 문장, 기록 목록, 새 기록 작성 버튼을 보여 준다.

import SwiftUI
import SwiftData

struct HomeView: View {
    @Environment(.modelContext) private var modelContext
    @Query(sort: DailyMemo.createdAt, order: .reverse) private var memos: [DailyMemo]

    @State private var isShowingEditor = false
    @StateObject private var viewModel = HomeViewModel()

    var body: some View {
        NavigationStack {
            List {
                quoteSection
                memoSection
            }
            .navigationTitle("하루 기록")
            .toolbar {
                Button {
                    isShowingEditor = true
                } label: {
                    Image(systemName: "plus")
                }
                .accessibilityLabel("새 기록 작성")
            }
            .sheet(isPresented: $isShowingEditor) {
                MemoEditorView()
            }
            .task {
                await viewModel.loadQuoteIfNeeded()
            }
        }
    }
}

이 코드에는 여러 개념이 함께 들어 있다.

  • NavigationStack은 목록에서 상세 화면으로 이동하기 위한 구조다.

  • @Query는 SwiftData에 저장된 기록을 조회한다.

  • @State는 작성 화면 표시 여부를 관리한다.

  • @StateObject는 화면 상태를 담당하는 ViewModel을 보관한다.

  • .task는 화면이 나타났을 때 비동기 작업을 실행한다.

처음 보면 많은 기능이 한 번에 들어 있는 것처럼 보인다. 하지만 각각의 역할을 나누어 보면 어렵지 않다.

오늘의 문장 섹션 만들기

quoteState에 따라 화면을 다르게 보여 줄 수 있다.

private extension HomeView {
    var quoteSection: some View {
        Section("오늘의 문장") {
            switch viewModel.quoteState {
            case .idle, .loading:
                HStack(spacing: 12) {
                    ProgressView()
                    Text("문장을 불러오는 중입니다")
                        .foregroundStyle(.secondary)
                }

            case .success(let quote):
                VStack(alignment: .leading, spacing: 8) {
                    Text(quote.text)
                        .font(.headline)

                    Text("— (quote.author)")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .padding(.vertical, 4)

            case .failure(let message):
                VStack(alignment: .leading, spacing: 8) {
                    Text(message)
                        .foregroundStyle(.secondary)

                    Button("다시 시도") {
                        Task {
                            await viewModel.loadQuote()
                        }
                    }
                }
            }
        }
    }
}

이렇게 상태별 화면을 명확히 나누면 사용자는 앱이 멈춘 것인지, 데이터를 불러오는 중인지, 문제가 생긴 것인지 알 수 있다.

실제 앱에서 좋은 사용자 경험은 거창한 애니메이션보다 이런 기본 상태 처리에서 시작된다.

기록 목록과 빈 상태 만들기

기록 목록은 저장된 데이터가 있을 때와 없을 때를 나누어야 한다.

private extension HomeView {
    var memoSection: some View {
        Section("나의 기록") {
            if memos.isEmpty {
                EmptyStateView(
                    title: "아직 기록이 없습니다",
                    message: "오른쪽 위 + 버튼을 눌러 오늘의 기록을 남겨 보세요."
                )
            } else {
                ForEach(memos) { memo in
                    NavigationLink {
                        MemoDetailView(memo: memo)
                    } label: {
                        VStack(alignment: .leading, spacing: 6) {
                            Text(memo.title)
                                .font(.headline)

                            Text(memo.createdAt.formatted(date: .abbreviated, time: .shortened))
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                        .padding(.vertical, 4)
                    }
                }
                .onDelete(perform: deleteMemos)
            }
        }
    }

    func deleteMemos(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(memos[index])
        }
    }
}

빈 상태는 단순히 "데이터 없음"을 보여 주는 화면이 아니다. 사용자가 다음에 무엇을 하면 되는지 알려 주는 안내다.

struct EmptyStateView: View {
    let title: String
    let message: String

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)

            Text(message)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .padding(.vertical, 12)
    }
}

빈 상태, 로딩 상태, 오류 상태는 앱의 완성도를 크게 좌우한다. 데이터가 있는 정상 상황만 구현하면 앱은 쉽게 불안정해 보인다.

작성 화면 구현하기

새 기록 작성 화면에서는 제목과 본문을 입력받는다. 입력값은 화면 내부 상태로 관리한다.

import SwiftUI
import SwiftData

struct MemoEditorView: View {
    @Environment(.dismiss) private var dismiss
    @Environment(.modelContext) private var modelContext

    @State private var title = ""
    @State private var bodyText = ""

    private var canSave: Bool {
        !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
        !bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("제목") {
                    TextField("오늘을 한 줄로 표현해 보세요", text: $title)
                }

                Section("내용") {
                    TextEditor(text: $bodyText)
                        .frame(minHeight: 180)
                }
            }
            .navigationTitle("새 기록")
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("취소") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("저장") {
                        saveMemo()
                    }
                    .disabled(!canSave)
                }
            }
        }
    }

    private func saveMemo() {
        let memo = DailyMemo(
            title: title.trimmingCharacters(in: .whitespacesAndNewlines),
            body: bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
        )

        modelContext.insert(memo)
        dismiss()
    }
}

이 화면에서 눈여겨볼 점은 저장 버튼의 활성화 조건이다.

사용자가 빈 제목이나 빈 본문을 저장하지 못하도록 canSave를 만들었다. 이런 작은 검증이 데이터 품질을 높인다.

입력 화면에서는 다음 원칙을 기억하자.

  • 입력값은 @State로 관리한다.

  • 저장 가능한 조건을 계산 속성으로 분리한다.

  • 저장 후 화면을 닫는다.

  • 불완전한 데이터를 저장하지 않는다.

상세 화면과 삭제 흐름 만들기

상세 화면은 저장된 기록을 보여 주고 삭제할 수 있게 한다.

import SwiftUI
import SwiftData

struct MemoDetailView: View {
    @Environment(.dismiss) private var dismiss
    @Environment(.modelContext) private var modelContext

    let memo: DailyMemo

    @State private var isShowingDeleteAlert = false

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(memo.title)
                    .font(.largeTitle.bold())

                Text(memo.createdAt.formatted(date: .long, time: .shortened))
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                Divider()

                Text(memo.body)
                    .font(.body)
                    .lineSpacing(4)
            }
            .padding()
        }
        .navigationTitle("기록 상세")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            Button(role: .destructive) {
                isShowingDeleteAlert = true
            } label: {
                Image(systemName: "trash")
            }
            .accessibilityLabel("기록 삭제")
        }
        .alert("기록을 삭제할까요?", isPresented: $isShowingDeleteAlert) {
            Button("삭제", role: .destructive) {
                deleteMemo()
            }
            Button("취소", role: .cancel) { }
        } message: {
            Text("삭제한 기록은 되돌릴 수 없습니다.")
        }
    }

    private func deleteMemo() {
        modelContext.delete(memo)
        dismiss()
    }
}

삭제처럼 되돌리기 어려운 동작은 바로 실행하지 않고 확인 절차를 두는 것이 좋다. SwiftUI에서는 alert를 사용해 간단히 구현할 수 있다.

MVVM으로 책임 나누기

지금까지의 코드는 충분히 작기 때문에 View 안에서 저장 로직을 직접 처리해도 큰 문제는 없다. 하지만 앱이 커지면 View가 너무 많은 일을 하게 된다.

MVVM에서는 역할을 다음처럼 나눈다.

  • Model: 데이터 구조

  • View: 화면 표시와 사용자 입력 전달

  • ViewModel: 화면 상태 관리와 동작 처리

  • Service/Repository: 네트워크와 저장소 접근

중요한 기준은 "View가 판단을 많이 하기 시작하면 ViewModel로 옮긴다"는 것이다.

예를 들어 오늘의 문장을 불러오는 작업은 이미 HomeViewModel이 맡고 있다. 화면은 quoteState를 보고 표시만 한다.

저장소도 더 분리하고 싶다면 Repository를 만들 수 있다.

protocol MemoRepositoryProtocol {
    func add(title: String, body: String)
    func delete(_ memo: DailyMemo)
}

final class MemoRepository: MemoRepositoryProtocol {
    private let modelContext: ModelContext

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    func add(title: String, body: String) {
        let memo = DailyMemo(title: title, body: body)
        modelContext.insert(memo)
    }

    func delete(_ memo: DailyMemo) {
        modelContext.delete(memo)
    }
}

다만 모든 기능을 처음부터 Repository, UseCase, Coordinator로 나눌 필요는 없다. 추상화는 코드가 복잡해졌을 때 도입해도 늦지 않다.

좋은 구조는 파일 수가 많은 구조가 아니다. 변경하기 쉬운 구조다.

실제 API로 교체할 때 확인할 것

Mock 서비스로 앱 흐름을 완성했다면, 실제 API를 붙일 때는 다음을 확인한다.

  • API 주소가 실제로 열리는가?

  • 응답 JSON의 키와 Swift 모델의 프로퍼티가 맞는가?

  • 날짜나 숫자 타입이 예상과 같은가?

  • 인증 토큰이 필요하다면 어디에서 안전하게 관리할 것인가?

  • 상태 코드가 200번대가 아닐 때 어떤 메시지를 보여 줄 것인가?

  • 네트워크 실패와 빈 데이터 상태를 구분하고 있는가?

Mock에서 실제 API로 넘어갈 때 화면 코드를 거의 바꾸지 않는 것이 좋은 구조다. HomeViewModel이 QuoteServiceProtocol만 알고 있다면, 서비스 구현체만 바꾸면 된다.

let remoteService = RemoteQuoteService(
    url: URL(string: "https://your-api.example/today-quote")!
)
let viewModel = HomeViewModel(quoteService: remoteService)

실제 프로젝트에서는 앱 시작 지점이나 의존성 조립 위치에서 이런 객체를 만들면 된다.

실사용 시나리오 점검하기

앱이 완성되었다고 느껴질 때는 정상 흐름만 확인하기 쉽다. 하지만 실제 사용자는 다양한 상황에서 앱을 사용한다.

다음 시나리오를 확인해 보자.

첫 실행

앱을 처음 설치하면 저장된 기록이 없다. 이때 빈 목록만 보여 주면 사용자는 무엇을 해야 할지 모를 수 있다.

따라서 빈 상태 문구가 필요하다.

아직 기록이 없습니다.
+ 버튼을 눌러 오늘의 기록을 남겨 보세요.

오늘의 문장을 불러오는 중

오늘의 문장이 바로 나타나지 않을 수 있다. 이때 ProgressView를 보여 주면 앱이 작업 중임을 알릴 수 있다.

오늘의 문장 불러오기에 실패한 경우

오류 메시지와 다시 시도 버튼을 제공한다.

오늘의 문장을 불러오지 못했습니다.
[다시 시도]

오류 문구는 개발자 중심이 아니라 사용자 중심이어야 한다. DecodingError.dataCorrupted 같은 메시지를 그대로 보여 주면 안 된다.

입력값이 부족한 경우

제목이나 본문이 비어 있으면 저장 버튼을 비활성화한다. 사용자가 잘못된 동작을 한 뒤 오류를 보는 것보다, 처음부터 잘못된 동작을 하지 못하게 하는 편이 낫다.

삭제하는 경우

삭제 전 확인 알림을 보여 준다. 특히 사용자가 작성한 데이터는 쉽게 삭제되면 안 된다.

이런 시나리오를 점검하면 앱은 "예제 코드"에서 "사용 가능한 앱"으로 가까워진다.

리팩터링으로 코드 품질 높이기

기능이 동작한다고 해서 작업이 끝난 것은 아니다. 이제 코드를 다시 읽어야 한다.

리팩터링은 동작을 바꾸지 않고 코드 구조를 개선하는 일이다.

다음 질문을 던져 보자.

  • 같은 코드가 반복되고 있지 않은가?

  • View 하나가 너무 많은 일을 하고 있지 않은가?

  • 네트워크 오류 메시지가 여러 곳에 흩어져 있지 않은가?

  • 날짜 포맷 코드가 화면마다 반복되고 있지 않은가?

  • 테스트하기 어려운 코드가 있는가?

예를 들어 날짜 표시가 여러 화면에서 반복된다면 확장으로 분리할 수 있다.

extension Date {
    var memoDisplayText: String {
        formatted(date: .abbreviated, time: .shortened)
    }
}

그러면 화면 코드는 더 읽기 쉬워진다.

Text(memo.createdAt.memoDisplayText)

버튼 스타일이나 빈 상태 화면도 반복된다면 공통 View로 분리한다.

단, 리팩터링을 할 때 주의할 점이 있다. 너무 이른 추상화는 오히려 코드를 어렵게 만든다.

두 번 반복되었다고 바로 공통화할 필요는 없다. 세 번째 같은 패턴이 나타날 때 분리해도 충분하다.

사용자 경험 다듬기

기능이 완성되면 사용자의 흐름을 기준으로 다시 앱을 살펴본다.

사용자는 앱을 켜고 무엇을 먼저 보게 되는가? 새 기록을 작성하려면 버튼을 쉽게 찾을 수 있는가? 저장 후 목록이 자연스럽게 갱신되는가? 오류가 났을 때 다음 행동을 알 수 있는가?

작은 개선만으로도 앱의 느낌은 크게 달라진다.

예를 들어 다음을 적용할 수 있다.

  • 새 기록 작성 버튼을 툴바에 배치한다.

  • 아이콘만 있는 버튼에는 접근성 라벨을 붙인다.

  • 기록이 없을 때 명확한 안내를 보여 준다.

  • 저장 버튼은 입력이 완료되었을 때만 활성화한다.

  • 긴 본문은 ScrollView에서 편하게 읽게 한다.

  • 삭제 전 확인 알림을 제공한다.

  • 오류에는 재시도 버튼을 제공한다.

디자인은 색을 많이 쓰는 것이 아니다. 사용자가 망설이지 않게 만드는 것이다.

프로젝트 완성 체크리스트

마지막으로 앱을 완성했다고 말하기 전에 다음 항목을 확인하자.

  • 앱이 처음 실행될 때 오류 없이 열린다.

  • 기록이 없을 때 빈 상태가 표시된다.

  • 새 기록을 작성하고 저장할 수 있다.

  • 저장된 기록이 목록에 표시된다.

  • 기록을 선택하면 상세 화면으로 이동한다.

  • 기록을 삭제할 수 있다.

  • 삭제 전 확인 알림이 표시된다.

  • 오늘의 문장이 로딩 후 표시된다.

  • 오늘의 문장 실패 상태와 재시도 버튼을 확인했다.

  • View, ViewModel, Model, Service의 역할이 지나치게 섞여 있지 않다.

  • 중복 코드가 과하지 않다.

  • 실제 기기 또는 시뮬레이터에서 주요 흐름을 직접 확인했다.

체크리스트는 단순하지만 강력하다. 개발자는 자신이 만든 앱의 정상 흐름을 이미 알고 있기 때문에 문제를 놓치기 쉽다. 항목을 하나씩 확인하면 빠진 부분을 발견할 수 있다.

작은 앱을 끝까지 만드는 힘

이번 장에서 만든 앱은 거대한 서비스가 아니다. 하지만 iOS 앱 개발의 중요한 흐름을 모두 담고 있다.

아이디어를 기능으로 나누고, 화면 흐름을 만들고, 데이터 모델을 정의했다. SwiftUI로 화면을 구성하고, 상태에 따라 UI를 바꾸었다. SwiftData로 로컬 데이터를 저장하고, 비동기 서비스로 오늘의 문장을 불러왔다. MVVM 구조로 책임을 나누고, 로딩·오류·빈 상태까지 구현했다. 마지막으로 리팩터링을 통해 코드 품질과 사용자 경험을 점검했다.

이 과정을 한 번 끝까지 경험하면 다음 앱을 만들 때 훨씬 덜 막힌다. 새로운 기능을 만나도 "이건 화면 상태 문제인지, 데이터 저장 문제인지, 네트워크 문제인지, 구조 분리 문제인지" 구분할 수 있게 된다.

앱 개발 실력은 문법을 많이 아는 것만으로 늘지 않는다. 작은 앱이라도 끝까지 완성하고, 다시 고치고, 더 나은 구조로 다듬는 과정에서 자란다.

이제 우리는 앱을 만들었다. 다음 단계는 이 앱을 사용자에게 전달하는 일이다. 배포를 준비하고, 출시 후 문제를 관찰하며, 계속 개선하는 과정으로 넘어가 보자.

책 목록

처음부터 출시까지 배우는 iOS 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기