안녕하세요! 피피아노입니다 🎵
이번 포스팅에서는 제가 프로젝트를 진행하면서 발생한 문제와 해결 과정을 공유해보려고 합니다.
그럼 바로 시작하겠습니다!
프로젝트 소개
우선 프로젝트에 대해서 간단하게 소개해보자면 인생네컷처럼 내 앨범에서 사진을 골라서 네 컷 사진을 만들 수 있는 앱을 개발하고 있었습니다.
사용자가 최대 4장의 사진을 선택하고, 원하는 사진을 삭제한 후, 새로운 사진을 추가할 수 있는 기능도 추가로 구현했습니다.
기본 구조는 아래처럼 설계를 했습니다.
- ContentView: 사진 선택 및 관리(네컷 이미지를 만드는 View)
- FourCutFrameModel: 프레임 데이터 모델
- PhotoModel: 개별 사진 데이터
문제 발견 과정
앱의 기본 기능은 완성하였지만 기능을 더 확장하고 싶다는 생각이 들었습니다. 완전 프로젝트 초기에는 큰 프로젝트도 아니었고 사용되는 기능도 별로 없어서 View안에 로직들도 함께 담았지만 기능을 추가하려고 하니 의존도와 복잡도가 증가하면서 앱의 속도도 느려지고 컴파일 속도도 느려져서 MVVM 디자인패턴을 도입하기로 하였습니다.
그리고 이 MVVM 디자인패턴을 도입하고 나서 다시 QA 테스트 중 다음과 같은 상황이 발생하였습니다.
예상 시나리오
- 4장 사진 서택 -> [사진1, 사진2, 사진3, 사진4]
- 1번, 4번 사진 삭제 -> [nil, 사진2, 사진3, nil]
- 1장 새 사진 추가 -> [새사진, 사진2, 사진3, nil]
실제 발생한 현상
- 4장 사진 서택 -> [사진1, 사진2, 사진3, 사진4]
- 1번, 4번 사진 삭제 -> [nil, 사진2, 사진3, nil]
- 1장 새 사진 추가 -> [새사진, 사진2, 사진3, 기존 사진4 복구]
이런 식으로 마지막 배열은 nil이 나와야 하는데 처음에 넣었던 사진4가 복구되는 문제였습니다.
가설 세우기
문제 해결을 하기 위해 해당 문제가 발생되는 가설을 세워보았습니다.
- UI 렌더링 문제: SwiftUI View 업데이트 지연?
- 데이터 모델 문제: FourCutFrameModel에서 상태 동기화 실패?
- 로직 문제: loadTransferable() 함수에서 인덱스 처리 오류?
이렇게 3가지의 가설을 세우고 각각의 코드에 디버깅 로그를 추가하면서 디버깅을 시작하였습니다.
문제 원인 추적
func loadTransferable() async {
print("=== 새 사진 추가 시작 ===")
print("선택된 사진 개수: \(selectedPhotos.count)")
// 현재 상태 확인
let currentState = displayedImages.enumerated().map { index, image in
return "\(index): \(image == nil ? "nil" : "image")"
}.joined(separator: ", ")
print("현재 상태: [\(currentState)]")
// 빈 자리 찾기
let emptyIndices = displayedImages.enumerated().compactMap { index, image in
image == nil ? index : nil
}
print("빈 자리 인덱스들: \(emptyIndices)")
for (index, photoItem) in selectedPhotos.prefix(4).enumerated() {
print("처리 중인 사진 인덱스: \(index)")
do {
if let imageData = try await photoItem.loadTransferable(type: Data.self),
let uiImage = UIImage(data: imageData) {
let photo = PhotoModel(uiImage: uiImage)
print("사진을 \(index)번 자리에 배치 예정")
frameModel.setPhoto(photo, at: index) // 의심 지점!
// 배치 후 상태 확인
let afterState = frameModel.displayedImages.enumerated().map { idx, img in
return "\(idx): \(img == nil ? "nil" : "image")"
}.joined(separator: ", ")
print("배치 후 상태: [\(afterState)]")
}
} catch {
print("이미지 로드 실패: \(error)")
}
}
selectedPhotos.removeAll()
self.displayedImages = frameModel.displayedImages
print("=== 최종 결과 ===")
let finalState = displayedImages.enumerated().map { index, image in
return "\(index): \(image == nil ? "nil" : "image")"
}.joined(separator: ", ")
print("최종 상태: [\(finalState)]")
}
로그를 추가하고 분석을 해보니 새 사진을 1장만 추가했는데 인덱스 0번에 배치하는 순간 인덱스 3번도 함께 채워지는 로그를 확인하였습니다.
문제는 frameModel.setPhoto(photo, at: index)에서 발생하고 있었습니다.
모델 내부 분석
기존 loadTransferable() 메서드를 분석해 보니 여러 문제점을 발견했습니다.
// 문제가 있던 기존 코드
func loadTransferable() async {
var newImages: [Image] = []
// 1. 새로운 이미지들을 임시 배열에 저장
for photoItem in selectedPhotos {
// ... 이미지 로드 로직
newImages.append(image)
}
// 2. displayedImages를 복사하여 작업
var updatedImages = displayedImages
// 3. 빈 자리를 찾아서 새 이미지 배치
var emptyIndices: [Int] = []
for i in 0..<4 {
if updatedImages[i] == nil {
emptyIndices.append(i)
}
}
// 4. frameModel 동기화
frameModel.setImages(updatedImages)
}
핵심 문제점
1. 이중 상태 관리
displayedImages와 frameModel.photos가 별도로 관리되어 동기화에 문제가 발생하였습니다.
2. 복잡한 로직 처리
MVVM 디자인패턴으로 변경하면서 여러 가지 코드가 꼬일 때 제가 로직을 추가했던 것이 여기에서 발목을 잡고 있었습니다.
3. 상태 불일치
frameModel 업데이트와 displayedImages 업데이트가 따로 이루어져서 일관성이 부족한 문제도 존재했습니다.
해결 방안 설계
문제의 근본적인 원인은 상태가 2군데에서 관리되고 있다는 점이었습니다. 이러한 문제를 해결하기 위해 다음과 같은 해결책을 세웠습니다.
1. 이중 상태 관리 통합
frameModel을 유일한 데이터 소스로 설정
2. 간단한 로직으로 변경
복잡한 빈자리 찾기 로직 제거(불필요한 코드)
3. 순차적 배치
새로운 사진들을 순서대로 배치하는 로직 채택
func loadTransferable() async {
// 1. frameModel에 직접 PhotoModel 객체 저장
for (index, photoItem) in selectedPhotos.prefix(4).enumerated() {
do {
if let imageData = try await photoItem.loadTransferable(type: Data.self),
let uiImage = UIImage(data: imageData) {
let photo = PhotoModel(uiImage: uiImage)
frameModel.setPhoto(photo, at: index) // 직접 frameModel 업데이트
}
} catch {
print("이미지 로드 실패: \(error)")
}
}
selectedPhotos.removeAll()
self.displayedImages = frameModel.displayedImages // frameModel에서 UI 상태 동기화
}
개선된 점
- 버그 해결: 위에서 나타난 오류가 해결됨
- 일관성: 상태가 항상 일정하게 유지됨
- 유지 보수성: 코드가 간단해짐
- 성능: 불필요한 배열 복사와 같은 복잡한 로직 제거로 성능 향상
마무리
이번 트러블슈팅을 통해 SwiftUI는 여러 곳에서 같은 데이터를 관리하면 동기화 문제가 발생할 수 있다는 것을 확실하게 느꼈고 Single Source of Truth 원칙을 지키는 것이 중요하다는 것을 다시 알게 되었습니다.
비슷한 문제로 고민하시는 분들이 있다면 이 글이 조금이나마 도움이 되면 좋겠습니다!
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > SwiftUI' 카테고리의 다른 글
[SwiftUI] 앱에 Face ID 잠금 기능 적용하기 (8) | 2025.07.08 |
---|---|
[SwiftUI] CADisplayLink를 활용한 부드러운 ProgressView 애니메이션 구현 트러블슈팅 (6) | 2025.06.06 |
[SwiftUI] NavigationStack 사용 시 화면 전환 안 되는 문제와 title 깨짐 문제 트러블슈팅 (0) | 2025.06.03 |
[SwiftUI] SwiftUI로 카메라 기능 구현하기 (2) | 2025.03.01 |
[SwiftUI] ProgressView 생성하기 (15) | 2025.01.15 |