[Refactoring] 달리는 동안 레일을 다시 까는 법: 3만 개 셀의 실시간(2Hz) 스프레드시트 최적화

2025. 8. 21. 19:09🍏/Project

들어가기 앞서..

이번 글에서는 현업에서 최대 96명의 선수와 327개의 운동 지표(Metric)를 표시하는 실시간 스프레드시트를 구현하면서 겪었던 성능 문제와, 왜 결국 UICollectionView를 선택하게 되었는지, 그리고 어떤 방식으로 최적화를 진행했는지를 정리해보려고 합니다.

UICollectionView는 iOS 6부터 존재해 온 API로, 어느덧 10년이 훌쩍 넘었습니다. SwiftUI가 등장한 이후에는 자연스럽게 이런 이야기를 자주 듣게 됩니다.

“간단하게 구현할 건데 뭐, SwiftUI로 쓰지 뭐. 이젠 UICollectionView는 안 써도 되지 않을까?”

일반적인 리스트나 그리드라면 SwiftUI의 List, LazyVStack, LazyVGrid만으로도 충분한 경우가 많습니다. 하지만 실시간 데이터와 수만 개의 셀, 그리고 복잡한 스크롤 동작이 동시에 요구되는 상황이라면 이야기가 조금 달라질 겁니다.

저 역시 SwiftUI만으로 충분하다고 생각했습니다.

최대 31,392개의 셀이 2Hz로 갱신되고, 상단 두 행과 첫 번째 열까지 고정해야 하는 화면을 마주하기 전까지는요.

 


 

문제의 발단

실시간 리포트의 요구사항

이번에 최적화를 진행하게 된 화면은 아래와 같은 전형적인 Freeze Panes(틀 고정) 형태의 스프레드시트입니다.

요구사항은 단순해 보이지만 실제 구현은 생각보다 까다롭습니다.

  • 상단 Header와 Team Average는 항상 화면 상단에 고정되어 있어야 합니다.
  • Header와 Average row는 Horizontal scroll과 함께 움직여야 합니다.
  • 좌측 Player 영역은 수평 스크롤과 관계없이 항상 보여야 합니다.
  • 나머지 영역은 가로·세로 모두 스크롤이 가능해야 합니다.
  • 여기에 최대 2Hz로 들어오는 실시간 데이터를 끊김 없이 반영해야 합니다.

 


 

기존 구조 — SwiftUI와 UIKit을 오가는 3중 구조

 제가 처음 이 화면을 리팩토링 했을 당시 화면의 대부분이 SwiftUI로 작성되어 있었습니다. 따라서 기존 구조를 크게 변경하기보다는 ScrollView, VStack, HStack을 조합하고, 고정 영역과 스크롤 영역을 동기화하기 위해 UIScrollView를 래핑한 SyncScrollView를 사용하는 방향으로 구현했습니다.

각 선수의 데이터가 최대 2Hz로 갱신되기 때문에 초기 구현에서도 UI 갱신 주기를 조절해 연산량을 줄였습니다. 약간의 표시 지연을 허용하는 대신 스크롤이 끊기지 않도록 한 타협이었고, 당시 데이터 규모에서는 비교적 안정적으로 동작했습니다.

하지만 화면 규모가 커질수록 이 구조는 점점 한계를 드러내기 시작합니다.

겉으로 보기에는 평범한 SwiftUI 화면처럼 보이지만, 실제로는 상당한 수의 View가 생성되고 있었습니다. 결정적인 문제는 셀 수였습니다.

96 Players × 327 Metrics = 31,392 개의 SwiftUI 뷰

총 31,392개의 SwiftUI View가 화면 표시 여부와 관계없이 View 계층에 존재하고 있었습니다.

수직 방향만 고려한다면 LazyVStack을 적용해 화면에 보이는 Player Row만 지연 생성하는 방법을 생각해 볼 수 있습니다. 하지만 이 화면은 단순한 세로 리스트가 아니라, 실시간 데이터가 갱신되는 2차원 스프레드시트였습니다.

LazyVStack을 적용하더라도 Player Row가 생성되는 순간 해당 Row 내부의 327개 Metric View는 모두 생성됩니다. 즉, 수직 방향의 View 생성 비용은 줄일 수 있어도 수평 방향까지 함께 가상화할 수는 없었습니다.

또한 실제 적용 과정에서는 Row의 높이와 표시 상태가 스크롤 중 동적으로 계산되면서, 스크롤 위치가 순간적으로 보정되거나 튀는 현상이 발생했습니다. 상단 고정 영역과 데이터 영역의 offset을 동기화해야 하는 구조에서는 이러한 위치 변화가 더욱 눈에 띄었습니다.

또 다른 성능 문제는 스크롤 동기화 방식에 있었습니다.

가로 스크롤이 발생할 때마다 $scrollOffsetX 바인딩 값이 변경되었고, 이 값이 SwiftUI 계층으로 전달되면서 SyncScrollView 내부 UIHostingController의 View가 다시 평가되었습니다.

Scroll → contentOffset 변경 → SwiftUI body 재평가 → Layout 계산 → Diff → Render

이 과정이 스크롤 중 반복되면서, 화면에는 일부 셀만 보이더라도 화면 밖에 존재하는 수백 개 컬럼의 레이아웃 계산까지 계속 발생했습니다.

결국 문제는 SwiftUI 자체가 아니라, 수만 개의 View를 상시 유지한 상태에서 UIKit과 SwiftUI 사이의 스크롤 상태를 지속적으로 동기화해야 했던 구조에 있었습니다.


 

Instruments로 확인한 실제 병목

코드 구조만으로 원인을 추측하지 않고, Instruments의 Time Profiler를 통해 스크롤과 실시간 데이터 갱신 과정에서 메인 스레드가 어디에 시간을 사용하는지 확인했습니다.

Before 측정에서는 다음과 같은 SwiftUI View Graph 관련 호출이 상위 비용으로 나타났습니다.

  • _UIHostingView.beginTransaction
  • ViewGraphRootValueUpdater
  • GraphHost.flushTransactions
  • AG::Subgraph::update
  • ForEachChild.updateValue
  • MetricRowView.body
  • Metric 값의 문자열 변환

이 호출들은 화면에 픽셀을 그리는 최종 렌더링 단계보다 앞에서 발생합니다. 상태 변경을 SwiftUI 계층에 전달하고, 어떤 View가 달라졌는지 확인하며, body와 레이아웃을 다시 계산하는 과정입니다.

특히 가로 스크롤 offset이 Binding을 통해 전달될 때마다 이 경로가 반복됐습니다. 화면에 보이지 않는 Metric View도 View Graph에 남아 있었기 때문에, 실제 표시 영역보다 훨씬 큰 범위를 계속 확인해야 했습니다.

 

Before — 96 Players × 327 Metrics = 31,392 개의 SwiftUI 뷰

이 측정을 통해 최적화의 목표를 정할 수 있었습니다.
측정 결과 메인 스레드에서 다수의 Hang이 발생했고, SwiftUI View Graph 갱신과 ForEach 순회가 주요 CPU 비용으로 나타났습니다.
렌더링 자체를 없애는 것이 아니라, 렌더링에 도달하기 전에 발생하는 불필요한 View 계산과 갱신 범위를 줄이는 것이었습니다.

 


 

왜 UICollectionView였는가

행과 열을 함께 가상화하기

기존 구조의 문제는 SwiftUI 자체가 느리다는 데 있지 않았습니다. 화면에 보이지 않는 셀까지 포함한 전체 표를 View Graph에 올려두고, 스크롤이나 데이터 변경이 발생할 때 그 그래프의 상당 부분을 다시 확인해야 한다는 점이 문제였습니다.

이 화면에서 필요했던 조건은 명확했습니다.

  • 세로 방향으로 Player Row를 가상화해야 한다.
  • 가로 방향으로 Metric Column을 가상화해야 한다.
  • 상단 Header와 Average Row는 세로 스크롤에서 고정되어야 한다.
  • Player Name Column은 가로 스크롤에서 고정되어야 한다.
  • 실시간 데이터가 변경되면 관련된 셀만 갱신해야 한다.

기존 구조에서 LazyVStack을 적용하면 화면 밖에 있는 선수 행의 생성을 늦출 수는 있었습니다. 하지만 화면에 들어온 행 내부는 HStack과 ForEach로 구성되어 있어, 해당 선수의 327개 Metric View가 함께 생성됐습니다.

따라서 세로 방향의 지연 생성만으로는 충분하지 않았습니다. 선수 행과 Metric 열을 모두 고려해, 개별 셀 단위로 화면 표시 여부를 판단하는 2차원 가상화가 필요했습니다.

이 요구사항에는 UICollectionView의 셀 재사용 구조와 Custom Layout이 적합했습니다.

UICollectionView로 전환한 이유는 UIKit이 언제나 SwiftUI보다 빠르기 때문이 아닙니다. 이 화면에서 필요한 가상화 단위가 행 전체가 아니라 개별 셀이었기 때문입니다.

 

하나의 UICollectionView로 표현한 표

Section 0       헤더 행
Section 1       평균 행
Section 2...    선수별 데이터 행

Item 0          선수 이름 열
Item 1...       Metric 값 열

새로운 구조에서 위와 같이 표 전체를 하나의 UICollectionView로 표현했습니다. 논리적으로는 여전히 최대 96명 × 327개의 Metric을 가진 큰 표입니다. 하지만 모든 위치에 실제 UICollectionViewCell이 생성되는 것은 아닙니다.


After — 화면에 보이는 약 17 Players × 10 Metrics, 약 170 여개의 Cell

변경 후에는 ForEachChild.updateValue()와 MetricRowView.body가 주요 Call Tree에서 사라졌습니다. 대신 MetricsAverageViewModel.recompute()와 averageValue(for:in:)가 상위 비용으로 나타나면서, 병목이 UI 그래프 갱신에서 실제 평균값 계산으로 이동한 것을 확인할 수 있었습니다.

측정 환경에서 화면에 보이는 범위는 약 17행 × 10열이었고, 실제로 생성·재사용되는 셀은 약 170개 수준이었습니다.

데이터 개수는 그대로였습니다. 달라진 것은 전체 데이터를 한 번에 UI 객체로 만들지 않고, 현재 화면에 필요한 범위만 셀로 생성해 유지한다는 점이었습니다.

 


UICollectionView로 구현하기

Custom Layout으로 구현한 Freeze Panes

기존 화면은 Header, Average, Metric 영역에 각각 가로 UIScrollView를 두고, scrollOffsetX를 SwiftUI Binding으로 공유하는 구조였습니다.

스크롤이 발생할 때마다 대략 다음 경로가 반복됐습니다.

UIScrollView contentOffset 변경
→ Binding 갱신
→ SwiftUI 상태 전파
→ body 및 View Graph 갱신
→ 다른 ScrollView의 offset 반영

화면의 이동 거리를 맞추기 위해 선언형 상태 갱신 경로를 계속 통과하고 있었던 셈입니다.

새 구조에서는 Header, Average, Player Name, Metric 영역을 하나의 UICollectionView에 배치했습니다. 가로·세로 스크롤을 하나의 컬렉션뷰가 담당하므로, 여러 스크롤뷰의 offset을 동기화할 필요가 없어졌습니다.

 

고정 행과 고정 열의 위치 보정

Freeze Panes는 UICollectionViewLayout이 각 셀의 레이아웃 속성을 계산할 때 구현했습니다.

if isFrozenColumn {
    frame.origin.x = contentOffset.x
}

if isFrozenRow {
    frame.origin.y = contentOffset.y + (section == 0 ? 0 : headerRowHeight)
}

일반 셀은 원래 콘텐츠 좌표를 사용합니다. 반면 Player Name 열은 현재 contentOffset.x에 맞춰 이동시켜 화면 왼쪽에 고정합니다.

Header와 Average 행은 contentOffset.y를 기준으로 화면 상단에 고정합니다. Average 행에는 Header 높이를 추가해 두 행이 겹치지 않고 차례로 배치되도록 했습니다.

좌상단 셀은 고정 행과 고정 열 조건을 모두 적용받습니다. 셀이 겹치는 영역의 표시 순서는 zIndex로 구분했습니다.

attributes.zIndex =
    (isFrozenColumn ? 10 : 0)
    + (isFrozenRow ? 20 : 0)

이 방식의 핵심은 스크롤 상태를 별도의 SwiftUI 상태로 전달하지 않는다는 것입니다. 현재 UICollectionView의 contentOffset만으로 고정 셀의 위치를 계산합니다.

 

화면에 필요한 Layout Attributes만 반환하기

Custom Layout은 전체 셀의 기본 frame을 계산해 UICollectionViewLayoutAttributes로 캐시합니다. 하지만 layoutAttributesForElements(in:)에서는 모든 attributes를 반환하지 않습니다.

일반 행과 열은 UICollectionView가 요청한 rect를 벗어나면 결과에서 제외합니다.

if !isFrozenRow {
    if rowFrame.maxY < rect.minY { continue }
    if rowFrame.minY > rect.maxY { break }
}

if !isFrozenColumn {
    if cellFrame.maxX < rect.minX || cellFrame.minX > rect.maxX {
        continue
    }
}

고정 행은 세로 방향 검사를 생략하고, 고정 열은 가로 방향 검사를 생략합니다. 다만 반대 방향으로 화면 밖에 있는 셀까지 모두 반환하지는 않습니다.

고정 행 → 세로 검사 생략, 가로 검사 적용
고정 열 → 가로 검사 생략, 세로 검사 적용

전체 셀의 레이아웃 정보는 캐시하지만, 모든 위치에 실제 UICollectionViewCell을 생성하는 것은 아닙니다. 레이아웃 정보와 실제 View 인스턴스의 생성 범위를 분리한 것입니다.

결과적으로 Freeze Panes를 유지하면서도 기존 Status Table에서 사용하던 다음 구조를 제거할 수 있었습니다.

  • Header용 가로 SyncScrollView
  • Average용 가로 SyncScrollView
  • Metric 데이터용 가로 SyncScrollView
  • 스크롤뷰 사이의 scrollOffsetX 바인딩
  • offset 변경으로 발생하던 연쇄적인 SwiftUI View Graph 갱신

SwiftUI와 UIKit을 연결하는 UIViewControllerRepresentable은 여전히 남아 있습니다. 하지만 여러 스크롤뷰의 상태를 SwiftUI Binding으로 동기화하던 구조는 사라졌습니다. 하나의 UICollectionView가 스크롤을 담당하고, Custom Layout은 고정 행과 열의 위치만 보정합니다.


셀 재사용으로 View 수 제한하기

UICollectionView에서는 화면의 역할에 따라 셀을 여섯 종류로 나눴습니다.

  • 좌상단 Player Header 셀
  • Metric Header 셀
  • Average Label 셀
  • Average Value 셀
  • Player Name 셀
  • Metric Value 셀

데이터 소스는 전체 선수와 Metric에 해당하는 IndexPath를 표현하지만, 모든 위치에 실제 셀을 생성하지는 않습니다. 현재 화면에 필요한 셀을 reuse queue에서 가져와 사용합니다.

let cell = collectionView.dequeueReusableCell(
    withReuseIdentifier: SpreadsheetMetricValueCell.reuseID,
    for: indexPath
)

화면 밖으로 나간 셀은 재사용 가능한 상태가 되고, 새로운 위치가 화면에 들어오면 같은 셀 인스턴스에 새로운 데이터를 설정합니다.

기존 구조에서는 최대 31,392개의 Metric View가 View Graph에 참여했습니다. UICollectionView 구조에서는 전체 데이터는 그대로 유지하되, 실제 View 인스턴스는 현재 화면에 필요한 범위만 생성하고 재사용합니다.



동일한 값의 중복 설정 방지

셀 재사용과 별개로, Metric 값 셀 내부에서도 불필요한 UI 갱신을 줄였습니다.

각 셀은 마지막으로 표시한 값과 목표값, 빈 셀 여부를 캐시합니다. configure()가 다시 호출되더라도 이전 상태와 같다면 이후 작업을 수행하지 않습니다.

guard isBlank != cachedIsBlank
        || rawBits != cachedRawBits
        || goalBits != cachedGoalBits else {
    return
}

값이 달라진 경우에만 다음 작업을 수행합니다.

  • Metric 값의 문자열 변환
  • UILabel의 텍스트 설정
  • 폰트와 글자 색상 설정
  • 목표값이 있는 경우 NSAttributedString 생성

셀이 다른 IndexPath에서 재사용될 때는 prepareForReuse()에서 캐시를 초기화합니다.

override func prepareForReuse() {
    super.prepareForReuse()

    cachedRawBits = .max
    cachedGoalBits = .max
    cachedIsBlank = false
}

두 최적화는 서로 다른 비용을 줄입니다.

셀 재사용
→ 생성하고 유지하는 View 인스턴스 수 제한

셀 내부 상태 캐시
→ 동일한 값의 문자열 변환과 UI 속성 설정 생략

결과적으로 전체 데이터를 UI 객체로 유지하지 않으면서, 현재 화면에 존재하는 셀에서도 값이 달라졌을 때만 실제 표시 내용을 갱신하도록 범위를 줄였습니다.

 

실시간 데이터는 필요한 셀만 갱신하기

화면에 생성하는 셀 수를 줄였더라도 실시간 데이터가 들어올 때마다 reloadData()를 호출하면 갱신 범위가 다시 커집니다.
이를 피하기 위해 데이터 변경을 표의 구조가 달라지는 경우와 셀의 값만 달라지는 경우로 나눠 처리했습니다.

 

구조가 변경된 경우

Metric 목록이나 선수 목록이 변경되면 section과 item 구성이 달라질 수 있습니다. 이 경우에는 전체 데이터를 다시 로드하고 선수별 구독을 설정합니다.

if metricsChanged || playersChanged {
    collectionView.reloadData()
    bindPlayers()
    averageViewModel.bind(...)
}

선수의 참가 또는 이탈은 2초 간격으로 확인하며, 실제 목록이 달라졌을 때만 다시 로드하였습니다.
또한 스크롤 중에는 구조 변경을 적용하지 않도록 방어 로직도 두었습니다.

정렬처럼 선수의 section 순서 자체가 달라지는 경우에도 전체 데이터를 다시 로드합니다. 

 

선수의 실시간 값이 변경된 경우

선수별 displayMetric 변경은 각 PlayerManager의 publisher를 구독합니다. 값이 변경되면 해당 선수가 위치한 section만 갱신 대상으로 삼습니다.

다만 현재 구현은 reloadSections를 호출하지 않습니다. 그 선수 행의 Metric index path를 순회한 뒤, 실제 화면에 존재하는 셀만 찾아 직접 configure 합니다.

for metricIndex in metrics.indices {
    let indexPath = IndexPath(
        item: metricIndex + 1,
        section: playerSection
    )

    guard let cell = collectionView.cellForItem(at: indexPath)
            as? SpreadsheetMetricValueCell else {
        continue
    }

    cell.configure(...)
}

 

cellForItem(at:)는 현재 컬렉션뷰에 생성되어 있는 셀만 반환합니다. 화면 밖의 셀은 nil을 반환하므로 실제 configure()는 화면에 존재하는 Metric 셀에만 호출됩니다.

따라서 한 선수의 값이 변경됐다고 해서 327개의 셀 View를 다시 그리는 것은 아닙니다. Metric 목록은 순회하지만, 실제 UI configure는 화면에 보이는 해당 선수의 셀에만 수행됩니다.

 

평균값이 변경된 경우

평균은 여러 선수의 값에 의존하므로 개별 선수 셀과 갱신 전략을 다르게 취했습니다.

각 PlayerManager의 변경 이벤트를 하나의 trigger로 모으고, 100ms debounce를 적용한 뒤 전체 Metric 평균을 다시 계산하였습니다.

trigger
    .debounce(for: .milliseconds(100), scheduler: RunLoop.main)
    .sink { [weak self] in
        self?.recompute()
    }

계산이 완료되면 평균 행인 section 1만 다시 로드합니다.

collectionView.reloadSections(IndexSet(integer: 1))

 

갱신 범위를 지나치게 줄이면서 생긴 문제

처음에는 변경을 ‘표의 구조가 달라지는 경우’와 ‘실시간 값만 달라지는 경우’로 나눴습니다. 하지만 기능이 추가되면서 이 구분만으로는 부족하다는 것을 알게 되었습니다.

플레이어 셀 내부에서 상태값을 변경하는 벤칭이라는 기능이 있는데, 일시정지 상태가 변경되면 Metric 셀뿐 아니라 item 0의 선수명 셀과 행 배경도 함께 바뀌어야 합니다. 기존 실시간 갱신 코드는 item 1부터 시작하는 Metric 셀만 직접 갱신했기 때문에, 선수명 셀은 화면 밖으로 나갔다가 재사용되거나 별도로 reload 되기 전까지 이전 상태를 유지했습니다.

Value값을 추적하여 색상을 변경하는 Goal 기능도 비슷했습니다. 선수와 Metric 목록이 같더라도 세션이나 Goal 유형이 변경되면 셀에 표시되는 목표값과 색상이 달라집니다. 그러나 기존 조건은 선수와 Metric 목록만 비교했기 때문에 전체 갱신이 발생하지 않았습니다.

이를 해결하기 위해 선수와 Metric 목록뿐 아니라 세션과 Goal 유형의 변경도 전체 갱신 조건에 포함했습니다.

let configChanged =
    viewType != lastViewType ||
    goalType != lastGoalType

if metricsChanged || playersChanged || configChanged {
    collectionView.reloadData()
    bindPlayers()
    averageViewModel.bind(...)
}

벤칭 상태가 변경된 경우에는 선수명 셀을 포함한 해당 행을 갱신하도록 수정했습니다.

 

결국 변경 유형을 다음과 같이 다시 나눴습니다.

변경 유형갱신 범위
실시간 Metric 변경 현재 생성된 해당 선수 셀
벤칭 상태 변경 선수명 셀을 포함한 해당 행
평균값 변경 Average section
세션·Goal 유형 변경 전체 데이터
선수·Metric 구성 및 정렬 변경 전체 데이터

초기에는 최적화에 집중한 나머지 “어떻게 적게 갱신할 것인가”를 먼저 생각했고, 하나의 상태 변화가 어떤 셀들에 영향을 주는지 충분히 분류하지 못했습니다. reloadData()를 줄이는 것 자체가 목표가 되어서는 안 됐습니다. 중요한 것은 변경의 영향 범위를 정확히 파악한 뒤, 그 범위만큼은 빠짐없이 갱신하는 것이었습니다.

핵심은 모든 변경을 reloadData()로 처리하지 않는 것입니다. 동시에 전체 reload를 피하기 위해 필요한 갱신까지 누락해서도 안 됩니다. 변경이 영향을 주는 범위를 먼저 정의하고, 셀 직접 갱신과 부분 reload, 전체 reload 중 적절한 방식을 선택해야 했습니다.

 


결론

프레임워크보다 갱신의 경계가 중요했습니다

이번 개선을 “UICollectionView가 SwiftUI보다 빠르다”로 정리하고 싶지는 않습니다.

기존 화면은 최대 96명의 선수와 327개의 Metric을 실시간으로 보여주기 위해 최대 31,392개의 Metric View를 유지하고 있었습니다. 여기에 양방향 스크롤과 Freeze Panes를 구현하기 위한 offset 동기화까지 더해지면서, SwiftUI View Graph 갱신과 ForEach 순회가 메인 스레드의 주요 비용으로 나타났습니다.

이를 하나의 UICollectionView와 Custom Layout으로 변경했습니다. 행과 열을 함께 가상화하고 셀을 재사용하면서, 실제 UI에는 화면에 보이는 약 170개의 셀만 유지했습니다. 고정 행과 열은 UICollectionView의 contentOffset을 기준으로 Layout Attributes를 보정해 구현했고, 여러 ScrollView의 offset을 SwiftUI 상태로 동기화하던 구조도 제거했습니다.

실시간 데이터는 모든 셀을 다시 로드하지 않고, 변경된 선수의 현재 화면에 존재하는 셀만 직접 갱신했습니다. 평균값은 Average section만 다시 로드하고, 선수나 Metric 구성처럼 표의 구조가 달라지는 경우에만 전체 데이터를 다시 구성했습니다.

Instruments에서도 변화가 확인됐습니다. 기존의 ForEachChild.updateValue()와 MetricRowView.body 중심의 비용은 줄었고, 주요 비용은 UI 갱신에서 실제 평균값 계산으로 이동했습니다.

하지만 갱신 범위를 줄이는 과정에서 새로운 오류도 발생했습니다. 벤칭 상태는 Metric 셀뿐 아니라 선수명 셀과 행 배경에도 영향을 주었고, 세션이나 Goal 유형은 데이터 구조가 같더라도 셀의 표시 내용을 변경했습니다. 이를 단순한 값 변경으로 처리하면서 일부 셀이 이전 상태를 유지했습니다.

결국 변경은 영향 범위에 따라 구분해야 했습니다.

실시간 값 변경
→ 현재 생성된 관련 셀만 직접 갱신

일부 UI 상태 변경
→ 영향을 받는 행이나 section만 reload

데이터 구조·정렬·표시 기준 변경
→ 전체 reloadData

이번 경험을 통해 얻은 결론은 reloadData()를 무조건 피해야 한다는 것도, UICollectionView가 SwiftUI보다 항상 빠르다는 것도 아닙니다.

너무 넓게 갱신하면 성능이 떨어지고, 너무 좁게 갱신하면 화면의 정합성이 깨집니다. 최적화는 갱신을 무작정 줄이는 작업이 아니라 상태 변화의 영향 범위를 정확히 파악하고, 필요한 범위만큼은 빠짐없이 갱신하는 과정이었습니다.

결국 성능을 바꾼 것은 프레임워크의 이름이 아니라

화면에 필요한 것만 만들고, 상태 변화가 영향을 주는 곳은 빠짐없이 갱신하도록 경계를 다시 설계한 것.
이번 최적화는 이 경계를 다시 정의하는 과정이었습니다.

 


느낀 점

프로젝트 대부분을 SwiftUI로 구현하고 있었고, 기존 리포트 영역조차 ScrollView와 SwiftUI의 Stack의 조합으로 구현되어 있었기 때문에, 이번 화면 역시 SwiftUI 내부에서 구조를 개선하면 충분히 해결할 수 있을 것이라고 생각했습니다. SwiftUI의 특성상 코드 양이 압도적으로 적고, 가독성도 무척이나 좋아서 쓰지 않을 이유가 없었습니다. 

하지만 Instruments로 실제 동작을 확인하면서 문제는 단순히 SwiftUI가 느리다는 데 있지 않다는 것을 알게 되었습니다. 수만 개의 View를 유지한 상태에서 스크롤 offset과 실시간 데이터 변경을 계속 전달하던 구조가 더 큰 원인이었습니다.

한편으로는 성능 개선에 집중한 나머지 기존 기능의 영향 범위를 충분히 고려하지 못했습니다. 화면을 적게 갱신하는 것이 항상 좋은 최적화라고 생각했지만, 내부 기능에서 상태 갱신이 누락되는 오류를 겪으면서 생각이 바뀌었습니다. 전체 reload가 필요한 상황까지 억지로 부분 갱신으로 처리하면 성능보다 더 중요한 화면의 정확성과 기능 안정성을 잃을 수 있었습니다.

이번 경험을 통해 성능 최적화는 단순히 View와 연산 횟수를 줄이는 작업이 아니라는 것을 느꼈습니다. 먼저 상태 변화가 화면의 어디까지 영향을 주는지 이해하고, 그에 맞는 갱신 방법을 선택하는 과정이 더 중요했습니다.

또한 적어도 이번과 같은 화면에서는 아직은 SwiftUI가 UICollectionView를 완전히 대체할 수는 없다라는 점도 인상적이었습니다. 둘 중 무엇이 더 좋은지를 구분하기보다, 데이터 규모와 갱신 빈도, 스크롤 방식에 따라 적합한 도구를 선택해야 한다는 것을 배울 수 있었습니다. 프레임워크보다 문제를 먼저 이해하고, 상황에 맞는 구조를 설계하고 기술을 선택해야 한 다는 것이 더 중요해지는 순간이었습니다. 

앞으로는 성능 문제가 발생했을 때 막연한 추측으로 구조를 변경하기보다 Instruments를 통해 병목을 먼저 확인하려고 합니다. 최적화 이후에도 성능 수치만 확인하지 않고, 기존 기능과 화면 상태가 정확하게 유지되는지 함께 검증하는 습관을 가져가려고 합니다.

이번 글은 UICollectionView를 적용한 기록이기도 하지만, 성능과 정확성 사이에서 적절한 갱신 범위를 찾아간 시행착오의 기록이기도 합니다.

긴 글 읽어주셔서 감사합니다. 🙇‍♂️