Skip to content

[#317] Canvas에서 메인스레드가 아닌 스레드에서 이미지를 비동기적으로 가져오도록 개선한다#318

Merged
opficdev merged 5 commits intodevelopfrom
refactor/#317-async-image-fetch
Mar 15, 2026
Merged

[#317] Canvas에서 메인스레드가 아닌 스레드에서 이미지를 비동기적으로 가져오도록 개선한다#318
opficdev merged 5 commits intodevelopfrom
refactor/#317-async-image-fetch

Conversation

@opficdev
Copy link
Copy Markdown
Collaborator

@opficdev opficdev commented Mar 9, 2026

📝 작업 내용

📌 요약

  • Photo가 직접 이미지 데이터를 읽지 않도록 정리하고, 이미지 로딩 책임을 PhotoImageLoader로 일원화했습니다.
  • PhotoFramePreview가 필요한 이미지를 비동기로 불러오도록 개선했습니다.
  • PhotoComposer.render를 비동기 처리로 전환해 렌더링 전에 이미지를 미리 로드하고, 결과 화면에서도 해당 흐름을 반영했습니다.

🔍 상세

  • Photo.imageData 계산 프로퍼티를 제거해 모델이 파일 I/O를 직접 수행하지 않도록 수정했습니다.
  • PhotoImageLoader를 추가해 단일 이미지와 다중 이미지 로딩을 한 곳에서 처리하도록 구성했습니다.
  • PhotoFramePreview[URL: UIImage] 상태를 사용해 이미 로드된 이미지를 재사용하고, 없는 이미지만 .task에서 비동기로 불러오도록 변경했습니다.
  • PhotoComposer는 합성 렌더링 전에 필요한 이미지를 먼저 로드한 뒤 PhotoFramePreview에 전달하도록 수정했습니다.
  • ResultView는 렌더링 결과 생성 시 await PhotoComposer.render(with:)를 호출하도록 변경했습니다.

💬 리뷰 노트

Instruments 시도

  • Instruments를 사용하여 확실하게 메인스레드가 아닌 곳에서 로드하는 것을 확인하려고 시도했습니다

아래 두 이미지는 10장의 이미지를 받아온 후, 6장의 이미지를 선택하는 것을 녹화(?) 한 결과입니다
오히려 개선 쪽에서는 백그라운드 스레드에서의 동작이 미미한 형태로 나왔습니다
기존 쪽에서 백그라운드에서 동작하는 활동을 확인했지만, 이미지 로드는 아니었던거로 확인했습니다

테스트_동기 테스트_비동기
기존 개선
  • 하지만 SwiftUI 앱이라 그런지 PhotoImageLoader.loadImage(from: ) 메서드를 찾아낼 수가 없었습니다

print문으로 메인 스레드에서 동작하는지 확인

  • Thread.isMainThread 를 통해 해당 코드가 메인스레드에서 동작하는지를 확인했습니다
  • 결과는 모두 false로 출력되었으나 instruments로 안찍히는게 찜찜하게 남았습니다
2026-03-09.3.19.00.mov

📸 영상 / 이미지 (Optional)

정상 작동 확인 및 메모리 사용량 확인

  • 기존 리팩토링 문서와 비교했을때, 선택한 이미지가 플레이스홀더로만 남아있던 문제가 해결되었습니다.
2026-03-09.2.21.19.mov
2026-03-09.10.45.52.mov
정상 액션 확인 메모리 사용량

@opficdev opficdev self-assigned this Mar 9, 2026
@opficdev opficdev added the 🔨 refactor 코드 리팩토링 (기능 변화 없이 구조 개선, 불필요한 import 제거) label Mar 9, 2026
Copy link
Copy Markdown
Collaborator

@YunDaeHyeon YunDaeHyeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📄 PR Code Review Report

🔍 Summary

이번 PR은 이미지 로딩 및 렌더링 과정을 동기 방식에서 비동기 방식으로 전환하여 앱의 반응성과 안정성을 크게 향상시키는 중요한 변경 사항을 포함합니다. 특히, 메인 스레드를 블로킹할 수 있는 동기 파일 I/O를 제거하고, 전용 PhotoImageLoader를 통해 백그라운드에서 이미지를 로드하도록 개선한 점이 긍정적입니다. 전반적으로 코드의 견고함과 성능이 개선되었습니다.

⚠️ Key Issues

  1. 동시성 비효율성 (PhotoImageLoader.loadImages): PhotoImageLoader.loadImages 함수는 여러 이미지를 순차적으로 로드하도록 구현되었습니다. 각 이미지를 await loadImage(from: photo.url) 호출로 개별적으로 비동기 처리하지만, for 루프 내에서 순차적으로 기다리므로, 여러 이미지가 있다면 총 로딩 시간이 각 이미지 로딩 시간의 합이 됩니다. 이는 불필요한 직렬화로 이어져 전체 이미지 로딩 성능을 저하시킬 수 있으며, 특히 렌더링에 필요한 이미지 수가 많을 때 사용자 경험에 영향을 줄 수 있습니다.

🛠 Improvement Suggestions

  1. PhotoImageLoader.loadImages 병렬 처리: 위 'Key Issues'에서 언급했듯이, PhotoImageLoader.loadImages 내부에서 await withTaskGroup 또는 async let을 사용하여 여러 이미지를 병렬로 로드하도록 변경하면 전체 로딩 시간을 단축할 수 있습니다. 이는 특히 이미지 수가 많을 때 렌더링 성능에 크게 기여할 수 있습니다.
  2. PhotoFramePreview의 synchronizePhotoImages에서 로깅 강화: 현재 loadImage 실패 시 continue하여 다음 이미지로 넘어갑니다. 이는 기능적으로 올바르지만, 어떤 이미지 로딩이 실패했는지 알기 어렵습니다. 디버깅 및 잠재적인 문제 진단을 위해 이미지 로딩 실패 시 로그를 남기는 것을 고려해볼 수 있습니다.

✅ Positive Observations

  1. 메인 스레드 블로킹 문제 해결: Photo 모델에서 imageData 계산 속성을 제거하고 이미지 로딩을 PhotoImageLoader를 통해 비동기로 처리하도록 변경한 점은 매우 긍정적입니다. 이는 UI 스레드 블로킹을 방지하고 앱의 반응성을 크게 향상시킵니다.
  2. 적절한 Swift Concurrency 도입: async/awaitTask.detached, .task 수정자를 적절하게 활용하여 비동기 작업을 안전하고 효율적으로 관리하고 있습니다. 특히 Task.isCancelled 체크를 통해 작업 취소 가능성을 고려한 점은 매우 좋습니다.
  3. UI 렌더링과 데이터 로딩 분리: PhotoComposerPhotoImageLoader를 통해 이미지를 미리 로드한 후 PhotoFramePreview에 전달하도록 구조화하여, 렌더링 시점에 이미지가 모두 준비되도록 한 점은 효율적이고 안정적인 UI 렌더링을 보장합니다.
  4. UIImage(contentsOfFile:) 및 경로 처리: PhotoImageLoader에서 UIImage(contentsOfFile: photoUrl.path(percentEncoded: false))를 사용하여 로컬 파일에서 이미지를 로드하는 효율적인 방법을 택했고, percentEncoded: false 옵션으로 URL 경로의 인코딩 문제를 방지한 점은 세심한 처리입니다.
  5. 뷰 초기화 시 @State 초기화: PhotoFramePreviewinit에서 외부 photoImages를 받아 _photoImagesByURL = State(initialValue: ...) 방식으로 @State를 초기화한 방식은 매우 올바르고 안전한 SwiftUI 패턴입니다.

Copy link
Copy Markdown
Collaborator

@GRJeon GRJeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지 로딩을 일원화 하셨다고 하셨는데 기존에 사용하던 LocalAsyncImage는 이번 변경 범위 외인가요? AI리뷰 중 비동기 문제에 대한 의견도 궁금합니다.

@opficdev
Copy link
Copy Markdown
Collaborator Author

opficdev commented Mar 9, 2026

이미지 로딩을 일원화 하셨다고 하셨는데 기존에 사용하던 LocalAsyncImage는 이번 변경 범위 외인가요? AI리뷰 중 비동기 문제에 대한 의견도 궁금합니다.

@GRJeon
말씀하신대로 LocalAsyncImage는 이번 리팩토링에서 제외되는 범위입니다. 그 이유는 LocalAsyncImage를 UI 컴포넌트라고 생각해서 이번 작업과는 별개 요소라고 생각한 것과 더불어 이번 리팩토링에서 사용하지 않았기 때문입니다

AI 리뷰 중 이미지 병렬 로드는 마지막 영상에서 보듯이 모든 이미지가 바로 나와서 생각하지 못한 부분입니다. 오래 걸리지 않을 작업같아서 곧바로 해보겠습니다 👍

  • 2026-03-09 18:04 개선 완료-

@opficdev opficdev requested a review from GRJeon March 9, 2026 09:03
Copy link
Copy Markdown
Collaborator

@sangYuLv sangYuLv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메인 스레드에서 이미지를 로딩해서 찝찝했던 부분을 개선해주셨군요 👍👍

struct PhotoFramePreview: View {
let information: PhotoInformation
private let hasPreloadedPhotoImages: Bool
@State private var photoImagesByURL: [URL: UIImage]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PhotoCompositionView에서 PhotoFramePreview가 만들어지는 형태를 보면, 사진이 선택될 때마다 (상태가 변할 때마다) 새로운 PhotoFramePreview가 만들어지고 있습니다.
그래서 시도하신 캐싱 로직은 수행되지 않는 상태인 듯 합니다...!
해당 흐름 다시 한 번 확인부탁드립니다!

PhotoFramePreview(
    information: PhotoInformation(
        layout: store.state.selectedLayout,
        frame: store.state.selectedFrame,
        photos: store.state.selectedPhotos
    )
)

더해서 캐싱을 적용한다면, 이미지를 렌더링하는 CPU 사용률은 줄어들겠지만 UIImage 저장으로 인해 메모리 사용량은 늘어날 듯 한데요,
개선 이전에도, 개선 후에도 사용 측면에서는 버벅임이 없지만 이런 사용량 변화에 대한 분석도 같이 공유해주시면 좋을 듯 합니다.

Copy link
Copy Markdown
Collaborator Author

@opficdev opficdev Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sangYuLv

피드백 감사합니다!

  1. PhotoFramePreview

아시겠지만 SwiftUI는 상태가 변경되면 그 뷰를 완전히 새로 그리지 않고 재계산을 통해 알아서 뷰를 재사용하는 형태입니다. 특히 PhotoFramePreview에서 자주 변경이 되는 selectedPhotos 가 채택한 Photo 타입은 Identifiable을 채택함으로서 뷰 계산에 영향을 주고 있어서 selectedPhotos가 변경된다고 PhotoFramePreview가 완전히 다시 그려진다고 보기엔 좀 어려울것 같습니다. 아래 영상은 selectedPhotos가 변경되는 것과 별개로 PhotoFramePreview의 onAppear가 최초 한번만 뜨는 것을 확인할 수 있는 영상입니다

2026-03-13.3.54.12.mov
  1. 메모리 사용 및 캐싱

UIImage를 저장하는 딕셔너리(photoImagesByURL) 가 실제로 PhotoFramePreview에서의 메모리 캐싱 역할을 하고 있습니다. PhotoFramePreview를 설명한 대로, 뷰가 재계산되는 것이니 캐싱도 정상적으로 되는것을 확인할 수 있습니다. (아래 이미지 참고)

image

다만 변경 전과 변경 후에 대해 명확하게 설명하지 않는 것은 PR 영상과 기존 리팩토링 문서 아래쪽의 첫번째 영상과 비교가 가능하다고 생각하여 작성하지 않았는데 작성하는게 오히려 나았을 것 같네요. 요약 정리만 하자면, 개선 전은 최대 410MB, 개선 후는 최대 390MB 사용량을 보여 메모리 사용 개선이 조금 있었다고 말씀드릴 수 있겠습니다!

@opficdev opficdev requested a review from sangYuLv March 13, 2026 09:22
Copy link
Copy Markdown
Collaborator

@sangYuLv sangYuLv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사진 선택 상태가 바인딩 되는 형태가 아니고, PhotoFramePreview의 초기화 매개변수로 전달되어서 그 상태가 바뀔 때마다 새로 생긴다고 생각했었습니다.
PhotoInformationstruct다보니 캐싱이 잘 동작하고 있는 것인지 의아했었네요
사실 아직도 어떻게 상태 변경이 반영되는지 이해가 되진 않지만, 확인해주셔서 감사합니다!

지금과 같은 상태 변경으로 CPU 사용률이 줄고 메모리 사용량이 늘 것이라고 예측했는데 오히려 메모리 사용이 줄었다는 것도 신기하네요
시간이 되신다면 여러 사진을 선택했다가 적은 사진을 선택하는 상황도 개선 전/후로 비교해보시면 좋을 것 같아요!
(저한테 증명해달라는 말은 아닙니다 😅)

제가 지금 가지는 의문들은 따로 학습하면서 파헤칠게요!
수고 많으셨습니다 👍

@opficdev opficdev merged commit 6f9fbd4 into develop Mar 15, 2026
2 checks passed
@opficdev opficdev deleted the refactor/#317-async-image-fetch branch March 15, 2026 13:03
@opficdev opficdev changed the title Refactor/#317 async image fetch [#317] Canvas에서 메인스레드가 아닌 스레드에서 이미지를 비동기적으로 가져오도록 개선한다 Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔨 refactor 코드 리팩토링 (기능 변화 없이 구조 개선, 불필요한 import 제거)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Canvas에서 메인스레드가 아닌 스레드에서 이미지를 비동기적으로 가져오도록 개선한다

4 participants