프로젝트 개요
프로젝트명: 로직에 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단계로 구성되어 있으며, 요구사항의 복잡성과 확장 가능성을 고려한 아키텍처가 필요하다고 판단했다.
MVC는 ViewController에 로직이 집중될 것 같았고, 더보기/접기 및 시리즈 데이터 업데이트 같은 복잡한 상태 관리가 어려울 것 같았다.
MVVM은 더보기/접기 및 시리즈 데이터 업데이트를 ViewModel에서 처리하면 코드가 깔끔하고 관리가 수월하겠지만, Level 1 ~ 2의 간단한 요구사항에서 오히려 복잡할 것 같았다.
또한, 이전 콘솔 기반 숫자 야구 프로젝트에서 도입했던 Clean Architecture와 Coordinator의 도입도 고려했는데,
Clean Architecture를 도입하게 되면 비즈니스 로직과 UI를 명확히 분리하여 유지보수성과 확장성은 좋겠지만, 비즈니스 로직이 단순하기 때문에 처음부터 도입하기엔 오버 엔지니어링으로 느껴졌다.
그리고 화면 전환 없이 한 화면에서 데이터를 업데이트하고 표시하는 수준이기 때문에 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
제약조건 설정 문제
문제 상황:
내부 뷰가 화면 밖을 넘어가는 이슈 발생
세로 스크롤만 가능해야 하지만, 가로 스크롤도 가능해짐

원인 추론:
내부 뷰들의 제약 조건이 잘못되었을 가능성
→
safeAreaLayoutGuide
또는Superview
를 기준으로horizontalEdges
에 맞춰져 있음UIScrollView
의horizontalEdges
제약이 잘못되었을 가능성→
Superview
를 기준으로horizontalEdges
에 맞춰져 있음내부 뷰의
width
제약이 없어서 내부 콘텐츠의 크기에 따라UIScrollView
가 커졌을 가능성→
edges
제약은 위치를 설정하는 것이지, 크기를 제한하는 것이 아님
즉, 내부 콘텐츠의 크기가 커지면UIScrollView
도 커질수 있음
해결 방안:
width
제약조건을 추가하여 동일한 너비를 갖도록 강제함결과 확인: 내부 뷰가 화면 밖을 넘어가지 않고, 세로 스크롤만 가능해짐
배운점
SnapKit을 사용하여 Auto Layout을 적용하는 방법
에러 핸들링
메모리 누수 방지를 위해 클로저 내에서 약한 참조를 사용하는 방법
Custom View를 만들어 재사용성을 높이는 방법
버튼 액션 설정
확장
향후 목표
반응형 프로그래밍 공부하고 적용해보기 (사용해보려 했지만, 아직 이해를 못 했다)
Unit Test.... (이전 프로젝트에서도 향후 목표였지만 못 했다)
짧은 소감
Bottom-Up이라는 컨셉을 잡고 진행하다보니, 중간에 프로젝트 구조를 변경하면서 기존 코드들이 새로운 코드로 추적되면서 커밋되었다. 이로 인해 PR에서 변경 사항이 과도하게 많아져 팀원들이 코드 리뷰를 할 때 힘들었을 것 같다. (나는 재밌었지만..)
이 자리를 빌려, 다시 한 번 사과의 말씀을 전합니다.

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