[iOS] 해리포터 책 시리즈 앱 프로젝트

iosSwiftUIKit
avatar
2025.04.04
·
13 min read

프로젝트 개요

  • 프로젝트명: 로직에 UI를 더하다 (해리포터 책 시리즈 앱 프로젝트)

  • 개발 기간: 2025.04.01 ~ 2025.04.05

  • 사용 기술: Swift, UIKit, SnapKit

  • 주요 학습 목표: UIKit, SnapKit, MVC, MVVM, Clean Architecture

설계 과정

요구사항 분석

Level 1: 책 제목 및 시리즈 순서 구현

  • JSON 데이터에서 전체 시리즈 중에 한 권의 책 제목과 시리즈 순서를 표시

  • 제목과 순서는 각각 다른 스타일을 가지며, AutoLayout을 사용해 배치

  • 시리즈 순서 버튼을 원형으로 설정

Level 2: 책 정보 영역 구현

  • JSON 데이터를 파싱해 책 표지, 제목, 저자, 출간일, 페이지 수 정보를 표시

  • JSON 데이터 로드 실패 시 사용자에게 Alert 창으로 알림 표시

  • 출간일 데이터 포맷 변환 (June 26, 1997)

Level 3: 헌정사와 요약 구현

  • 각 영역에 타이틀과 내용 표시

  • AutoLayout으로 간격과 정렬 조정

Level 4: 목차 구현

  • 구조적으로 목차 표시

  • 스크롤 가능한 영역과 화면 상단의 고정 View 분리

Level 5: 요약 더보기/접기 기능 구현

  • 글자 수 450자를 기준으로 요약 내용을 접거나 펼침

  • 더보기/접기의 마지막 상태를 저장해 앱 재실행 시에도 상태 유지

Level 6: 시리즈 전체 데이터 구현

  • 시리즈 순서 버튼 클릭 시 JSON 데이터를 로드하여 화면 갱신

  • 각 시리즈 권별로 더보기/접기 상태를 독립적으로 유지

Level 7: 다양한 디바이스 지원 및 화면 회전 대응 구현

  • iOS 16과 호환 가능한 iPhone 모델 지원

  • Portrait / Landscape 모드 대응

  • 노치 및 다이나믹 아일랜드 영역을 고려해 화면 배치 조정

  • AutoLayout 경고 메시지가 발생하지 않도록 제약 조건 확인

프로젝트 구조 설계

프로젝트 요구사항은 총 7단계로 구성되어 있으며, 요구사항의 복잡성과 확장 가능성을 고려한 아키텍처가 필요하다고 판단했다.

  1. MVC는 ViewController에 로직이 집중될 것 같았고, 더보기/접기 및 시리즈 데이터 업데이트 같은 복잡한 상태 관리가 어려울 것 같았다.

  2. MVVM은 더보기/접기 및 시리즈 데이터 업데이트를 ViewModel에서 처리하면 코드가 깔끔하고 관리가 수월하겠지만, Level 1 ~ 2의 간단한 요구사항에서 오히려 복잡할 것 같았다.

또한, 이전 콘솔 기반 숫자 야구 프로젝트에서 도입했던 Clean Architecture와 Coordinator의 도입도 고려했는데,

  1. Clean Architecture를 도입하게 되면 비즈니스 로직과 UI를 명확히 분리하여 유지보수성과 확장성은 좋겠지만, 비즈니스 로직이 단순하기 때문에 처음부터 도입하기엔 오버 엔지니어링으로 느껴졌다.

  2. 그리고 화면 전환 없이 한 화면에서 데이터를 업데이트하고 표시하는 수준이기 때문에 Coordinator는 필요가 없다고 판단했다.

이 프로젝트 자체가 요구사항이 명확히 정의되어 있기 때문에 Top-Down 방식이 더 적합하다고 생각해서 전체 아키텍처를 설계 후 개발을 진행하고자 했다. 하지만, 이 방식은 이전 콘솔 기반 숫자 야구 프로젝트에서 적용해봤기에 이번에는 '이전 Level의 요구사항을 수행해야 다음 Level의 요구사항이 주어진다'고 가정하고 Bottom-Up 방식으로 진행해보는 것도 좋을 것 같았다.

결과적으로, MVC, MVVM 그리고 Clean Architecture를 단계적으로 적용하였다.

Leve 1 ~ 4: MVC와 간소화된 Clean Architecture

초기 요구사항과 기본 UI를 빠르게 구현하기 위해 MVC 패턴과 Clean Architecture를 최소한으로 도입하여 핵심 데이터를 관리하는 구조를 설정했다.

  • Model: 데이터를 구조화하기 위한 모델 정의

  • Service: API를 통해(실제로는 JSON 파일이지만..) 데이터를 가져오는 역할을 분리하여 비즈니스 로직과 데이터 접근 최소화

  • UseCaes: 생략

  • Controller: UI와 데이터 간 상호작용 관리

HarryPotterSeries
├── Controller
│   └── MainViewController.swift
├── Model
│   ├── Book.swift
│   ├── BookAPIResponse.swift
│   ├── BookData.swift
│   └── Chapter.swift
├── Resource
│   ├── Assets.xcassets
│   ├── data.json
│   ├── Info.plist
│   └── LaunchScreen.storyboard
├── Service
│   ├── BookAPIError.swift
│   └── BookAPIService.swift
├── Util
│   ├── Constant
│   │   ├── Alert.swift
│   │   ├── Component.swift
│   │   ├── DataFile.swift
│   │   ├── Font.swift
│   │   ├── Layout.swift
│   │   ├── Logging.swift
│   │   └── StringKey.swift
│   ├── AppLogger.swift
│   └── DateFormatterManager.swift
├── View
│   ├── BookDedicationView.swift
│   ├── BookDetailView.swift
│   ├── BookSummaryView.swift
│   ├── BookTitleLabel.swift
│   ├── ChapterView.swift
│   ├── MainView.swift
│   └── SeriesOrderButton.swift
├── AppDelegate.swift
└── SceneDelegate.swift

Level 5: MVVM과 간소화된 Clean Architecture

MVVM 패턴을 적용해 접기/더보기 상태를 포함한 데이터 로직과 UI를 분리하였다.

  • Entity: 기존 데이터 구조 유지

  • Repository: 데이터 접근 로직 분리

  • UseCase: 생략

  • ViewModel: UI와 데이터 간 상호작용 담당

HarryPotterSeries
├── Data
│   ├── Helper
│   │   └── DateFormatterHelper.swift
│   ├── Mapper
│   │   └── BookMapper.swift
│   ├── Model
│   │   ├── Attributes.swift
│   │   ├── BookResponse.swift
│   │   ├── Chapter.swift
│   │   └── Datum.swift
│   ├── Repository
│   │   ├── DefaultBookRepository.swift
│   │   └── DefaultExpandedStatesRepository.swift
│   ├── Source
│   │   ├── Local
│   │   │   └── ExpandedStatesDataSource.swift
│   │   └── Remote
│   │       ├── BookDataSource.swift
│   │       └── BookError.swift
│   └── DataConstanat.swift
├── Domain
│   ├── Entity
│   │   └── Book.swift
│   └── Repository
│       ├── BookRepository.swift
│       └── ExpandedStatesRepository.swift
├── Resource
│   ├── Assets.xcassets
│   ├── data.json
│   ├── Info.plist
│   └── LaunchScreen.storyboard
├── Util
│   ├── AlertManager.swift
│   ├── AppLogger.swift
│   └── LoggingConstant.swift
├── View
│   ├── Main
│   │   ├── SubView
│   │   │   ├── BookDedicationView.swift
│   │   │   ├── BookDetailView.swift
│   │   │   ├── BookSummaryView.swift
│   │   │   ├── BookTitleLabel.swift
│   │   │   ├── ChapterView.swift
│   │   │   ├── SeriesOrderButton.swift
│   │   │   └── SummaryToggleButton.swift
│   │   ├── MainView.swift
│   │   └── MainViewController.swift
│   └── UIConstant.swift
├── ViewModel
│   └── Main
│      └── MainViewModel.swift
├── AppDelegate.swift
└── SceneDelegate.swift

Level 6 ~ 7: MVVM과 Clean Architecture

추가 기능 구현을 위해 Clean Architecture를 확장하였다. 비즈니스 로직을 UseCase로 분리하고, DI Container를 도입해 결합도를 낮췄다.

  • Entity: 비즈니스 로직에서 사용할 데이터를 구조화하기 위한 모델 정의

  • Repository: Data Source와의 상호작용 담당

  • UseCase: 비즈니스 로직 분리

  • DIContainer: 의존성 담당

  • ViewModel: UI와 데이터간 상호작용 담당

HarryPotterSeries
├── Application
│   ├── DIContainer
│   │   ├── AppDIContainer.swift
│   │   ├── DataSourceDIContainer.swift
│   │   ├── RepositoryDIContainer.swift
│   │   └── UseCaseDIContainer.swift
│   ├── AppDelegate.swift
│   └── SceneDelegate.swift
├── Data
│   ├── Helper
│   │   └── DateFormatterHelper.swift
│   ├── Mapper
│   │   └── BookMapper.swift
│   ├── Model
│   │   ├── Attributes.swift
│   │   ├── BookResponse.swift
│   │   ├── Chapter.swift
│   │   └── Datum.swift
│   ├── Repository
│   │   ├── DefaultBookRepository.swift
│   │   └── DefaultExpandedStatesRepository.swift
│   ├── Source
│   │   ├── Local
│   │   │   └── ExpandedStatesDataSource.swift
│   │   └── Remote
│   │       ├── BookDataSource.swift
│   │       └── BookDataSourceError.swift
│   ├── UseCase
│   │   ├── DefaultBookSummaryUseCase.swift
│   │   ├── DefaultFetchBooksUseCase.swift
│   │   └── DefaultManageExpandedStatesUseCase.swift
│   └── DataConstanat.swift
├── Domain
│   ├── Entity
│   │   └── Book.swift
│   ├── Repository
│   │   ├── BookRepository.swift
│   │   └── ExpandedStatesRepository.swift
│   └── UseCase
│       ├── BookSummaryUseCase.swift
│       ├── FetchBooksUseCase.swift
│       └── ManageExpandedStatesUseCase.swift
├── Presentation
│   ├── Main
│   │   ├── Component
│   │   │   ├── BookTitleLabel.swift
│   │   │   ├── SeriesOrderButton.swift
│   │   │   └── SummaryToggleButton.swift
│   │   ├── Extension
│   │   │   ├── UILabel+Extension.swift
│   │   │   ├── UIStackView+Extension.swift
│   │   ├── SubView
│   │   │   ├── BookDedicationView.swift
│   │   │   ├── BookDetailView.swift
│   │   │   ├── BookSummaryView.swift
│   │   │   └── ChapterView.swift
│   │   ├── MainViewController.swift
│   │   └── MainViewModel.swift
│   └── UIConstant.swift
├── Resource
│   ├── Assets.xcassets
│   ├── data.json
│   ├── Info.plist
│   └── LaunchScreen.storyboard
├── Util
    ├── AlertManager.swift
    ├── AppLogger.swift
    └── LoggingConstant.swift

이슈 및 해결 방안

UIScrollView 제약조건 설정 문제

  • 문제 상황:

    1. 내부 뷰가 화면 밖을 넘어가는 이슈 발생

    2. 세로 스크롤만 가능해야 하지만, 가로 스크롤도 가능해짐

4529
  • 원인 추론:

    1. 내부 뷰들의 제약 조건이 잘못되었을 가능성

      safeAreaLayoutGuide 또는 Superview를 기준으로 horizontalEdges에 맞춰져 있음

    2. UIScrollViewhorizontalEdges 제약이 잘못되었을 가능성

      Superview를 기준으로 horizontalEdges에 맞춰져 있음

    3. 내부 뷰의 width 제약이 없어서 내부 콘텐츠의 크기에 따라 UIScrollView가 커졌을 가능성

      edges 제약은 위치를 설정하는 것이지, 크기를 제한하는 것이 아님
      즉, 내부 콘텐츠의 크기가 커지면 UIScrollView도 커질수 있음

  • 해결 방안: width 제약조건을 추가하여 동일한 너비를 갖도록 강제함

  • 결과 확인: 내부 뷰가 화면 밖을 넘어가지 않고, 세로 스크롤만 가능해짐

배운점

  • SnapKit을 사용하여 Auto Layout을 적용하는 방법

  • 에러 핸들링

  • 메모리 누수 방지를 위해 클로저 내에서 약한 참조를 사용하는 방법

  • Custom View를 만들어 재사용성을 높이는 방법

  • 버튼 액션 설정

  • 확장

향후 목표

  • 반응형 프로그래밍 공부하고 적용해보기 (사용해보려 했지만, 아직 이해를 못 했다)

  • Unit Test.... (이전 프로젝트에서도 향후 목표였지만 못 했다)

짧은 소감

Bottom-Up이라는 컨셉을 잡고 진행하다보니, 중간에 프로젝트 구조를 변경하면서 기존 코드들이 새로운 코드로 추적되면서 커밋되었다. 이로 인해 PR에서 변경 사항이 과도하게 많아져 팀원들이 코드 리뷰를 할 때 힘들었을 것 같다. (나는 재밌었지만..)

이 자리를 빌려, 다시 한 번 사과의 말씀을 전합니다.

4527

프로젝트 코드는 GitHub에서 확인할 수 있습니다!







- 컬렉션 아티클