[Day 59] UI 최적화: 컬렉션 뷰 셀의 이미지 마스킹과 렌더링 성능 개선
CALayer 마스킹 최적화와 셀 재사용 시 이미지 할당 전략 개선으로 스크롤 성능을 높인 과정
오늘은 Snowy 프로젝트를 진행하면서 HotelGridCell의 이미지가 살짝 늦게 보이고, 컬렉션 뷰를 빠르게 스크롤할 때 전체적인 움직임도 미세하게 무거워 보이던 문제를 정리해보려고 한다.
처음에는 “이미지가 늦게 뜨는 것 같긴 한데 왜 그런지 모르겠다” 정도로 느껴졌는데, 코드를 하나씩 다시 보니 원인은 생각보다 명확했다. 문제는 단순히 이미지를 넣는 속도 하나가 아니라, 셀 재사용 과정에서 불필요한 작업이 반복되고 있었고, 그 작업들이 스크롤 중 계속 메인 스레드와 렌더링 비용에 영향을 주고 있었다는 점이다.
🧐 문제 상황
컬렉션 뷰를 빠르게 스크롤하면 셀 안의 호텔 이미지가 즉시 나타나지 않고, 아주 짧은 순간 하얀 배경이 먼저 보인 뒤 이미지가 따라오는 것처럼 보였다.
기능적으로는 큰 오류가 아니었다. 앱이 멈추는 것도 아니고, 데이터가 잘못 표시되는 것도 아니었다. 하지만 이런 아주 짧은 지연은 사용자가 느끼기에 “조금 버벅인다”, “앱이 무겁다”는 인상을 줄 수 있다. 특히 이미지가 많은 리스트 화면에서는 이런 작은 차이가 UX에서 꽤 크게 느껴진다.
🔍 원인 분석
이번 문제는 크게 두 가지로 나눠서 볼 수 있었다.
1. layoutSubviews()에서 반복되던 마스킹 연산
KeyFrameView에서는 이미지 뷰의 상단과 하단 코너를 서로 다르게 라운딩하기 위해 UIBezierPath와 CAShapeLayer를 사용하고 있었다.
이 방식 자체는 문제 없지만, 기존 구현에서는 layoutSubviews()가 호출될 때마다 마스크 관련 작업이 다시 실행될 가능성이 있었다. 컬렉션 뷰 셀은 스크롤 중에도 계속 레이아웃 계산이 일어나기 때문에, 여기서 무거운 작업이 반복되면 스크롤 성능에 바로 영향을 준다.
특히 마스크 패스를 만들고, 레이어의 frame과 path를 다시 설정하는 작업은 눈에 보이지는 않지만 비용이 적지 않다. 셀이 많아질수록 이 비용은 누적되고, 결국 이미지가 늦게 나타나는 느낌과 스크롤 저하로 이어질 수 있다.
2. 셀 재사용 시 불필요하게 발생하던 이미지 재할당
두 번째 원인은 HotelGridCell의 prepareForReuse()였다.
기존에는 셀이 재사용될 때마다 기본 이미지를 다시 넣고 있었는데, 이 방식은 얼핏 보면 안전해 보이지만 실제로는 불필요한 렌더링을 계속 유발할 수 있다. 이미 같은 이미지가 들어가 있어도, 재사용 시점마다 다시 할당하면 이미지 뷰 입장에서는 또 한 번 화면을 갱신해야 한다.
즉, 셀이 화면에 다시 등장할 때마다 꼭 필요하지 않은 이미지 설정이 반복되고 있었고, 이 때문에 미세한 깜빡임이나 지연처럼 보이는 현상이 생긴 것이다.
🛠 해결 방법
이번 최적화는 “무조건 빨리 그리게 하자”가 아니라, 필요한 순간에만 작업하도록 바꾸는 방향으로 진행했다.
1. Path Caching 적용
가장 먼저 KeyFrameView에서 마스크 패스를 매번 다시 만들지 않도록 수정했다.
핵심은 bounds가 실제로 바뀌었을 때만 새로운 패스를 계산하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func layoutSubviews() {
super.layoutSubviews()
let currentBounds = bounds
guard currentBounds.width > 0, currentBounds.height > 0 else { return }
if lastMaskBounds != currentBounds {
lastMaskBounds = currentBounds
let newPath = makeMixedRoundedRectPath(in: currentBounds).cgPath
cachedMaskPath = newPath
maskLayer.frame = currentBounds
maskLayer.path = newPath
}
}
이전에는 bounds가 같더라도 maskLayer.frame과 maskLayer.path를 다시 설정하는 흐름이 남아 있었는데, 수정 후에는 그 부분을 제거했다.
즉,
- 크기가 바뀌면 다시 계산하고
- 크기가 그대로면 아무 작업도 하지 않도록 바꾼 것이다.
이 변화 덕분에 스크롤 중 반복 호출되는 layoutSubviews()의 부담을 줄일 수 있었다.
2. Rasterization으로 마스크 결과 캐싱
두 번째로는 마스크 레이어에 shouldRasterize를 적용했다.
1
2
maskLayer.shouldRasterize = true
maskLayer.rasterizationScale = UIScreen.main.scale
이 설정은 복잡한 레이어 결과를 비트맵처럼 캐싱해두고 재사용할 수 있게 도와준다. 즉, 같은 형태를 계속 다시 그리기보다 한 번 만들어둔 결과를 활용하는 방향이다.
물론 shouldRasterize는 모든 상황에서 무조건 좋은 것은 아니다. 내용이 계속 변하는 뷰에는 오히려 역효과가 날 수도 있다. 하지만 이번 케이스처럼 마스크 형태가 비교적 고정적이고, 셀 스크롤 중 같은 형태가 반복적으로 사용되는 구조에서는 효과를 기대할 수 있었다.
3. prepareForReuse()에서 이미지 초기화 제거
다음으로 HotelGridCell에서는 prepareForReuse()에서 매번 기본 이미지를 다시 넣던 코드를 제거했다.
기존에는 셀이 재사용될 때마다 이미지가 다시 세팅되었는데, 이 과정이 스크롤 중 불필요한 화면 갱신을 만들고 있었다.
수정 후에는 재사용 시점에는 텍스트 등 필요한 초기화만 수행하고, 이미지는 무조건 다시 넣지 않도록 바꿨다.
1
2
3
4
5
6
override func prepareForReuse() {
super.prepareForReuse()
disposeBag = DisposeBag()
titleLabel.text = nil
}
이렇게 하면 셀이 재사용된다고 해서 이미지까지 매번 다시 그릴 필요가 없어진다.
4. 필요한 경우에만 이미지 할당
이미지 설정은 configurePrototype()에서 조건부로 처리했다.
1
2
3
if imageBoxView.imageView.image == nil {
imageBoxView.setImage(image: .storeImg2)
}
이 로직의 핵심은 간단하다. 이미지가 아직 없는 첫 구성 시점에는 이미지를 넣고, 이미 들어가 있다면 다시 설정하지 않는 것이다.
코드 한 줄 차이처럼 보이지만, 셀 기반 UI에서는 이런 작은 조건문이 실제 체감 성능에 꽤 큰 영향을 줄 수 있다. 특히 동일한 이미지가 반복적으로 사용되는 프로토타입 셀에서는 더 효과적이었다.
📌 이번 최적화에서 중요했던 포인트
정리해보면 이번 작업은 단순히 “이미지가 늦게 뜬다”는 현상을 고친 것이 아니라, 다음 두 가지 관점에서 구조를 다듬은 작업이었다.
1. 레이아웃 단계에서 불필요한 연산 줄이기
layoutSubviews()는 생각보다 자주 호출된다. 따라서 이 안에서는 꼭 필요한 작업만 해야 한다.
이번처럼 마스크 패스 생성 같은 비용 있는 연산이 들어 있다면,
- 언제 다시 계산해야 하는지 기준을 세우고
- 같은 값이면 재사용하도록 만드는 것이 중요하다.
2. 셀 재사용 시 무조건 초기화하지 않기
재사용 셀은 “초기화 많이 할수록 안전하다”고 생각하기 쉽지만, 실제로는 그 초기화가 성능 저하의 원인이 되기도 한다.
특히 이미지처럼 렌더링 비용이 있는 리소스는,
- 정말 비워야 하는지
- 다시 넣어야 하는지
- 이미 같은 값이 들어 있는지를 구분해서 다루는 편이 훨씬 효율적이다.
📈 결과
최적화 적용 후에는 시뮬레이터와 실기기 모두에서 스크롤이 훨씬 안정적으로 느껴졌다.
가장 크게 체감된 부분은 두 가지였다.
- 이미지가 늦게 보이는 느낌이 거의 사라졌다.
- 빠르게 스크롤해도 셀이 이전보다 훨씬 자연스럽게 붙어서 보였다.
즉, 거창한 기능 추가는 아니었지만 사용자 입장에서 느껴지는 완성도는 분명히 좋아졌다. 이런 부분이 결국 리스트 UI의 품질을 결정한다고 느꼈다.
🤔 이번 작업에서 배운 점
이번 작업을 통해 다시 느낀 것은, 성능 문제는 꼭 거대한 알고리즘 이슈에서만 나오는 것이 아니라는 점이다.
오히려 iOS UI에서는
- 레이어 속성을 어디서 바꾸는지
- 셀 재사용 시 무엇을 초기화하는지
- 같은 값을 왜 다시 넣고 있는지
이런 사소해 보이는 코드들이 모여 실제 체감 성능을 좌우한다.
특히 컬렉션 뷰나 테이블 뷰처럼 재사용이 핵심인 구조에서는, “지금 이 작업이 정말 매번 필요한가?”를 계속 의심해보는 습관이 중요하다는 걸 배웠다.
🐾 오늘의 마무리: 춘식이의 개발 일기
오늘도 오류 내며 자란 춘식이였습니다. 🐾
처음에는 단순히 이미지가 늦게 뜨는 현상 하나를 고치려 했는데, 막상 파고 들어가 보니 레이아웃, 마스킹, 셀 재사용까지 전부 연결되어 있었다.
이럴 때마다 느끼는 건, 좋은 UI는 단순히 예쁘게 만드는 것만으로 완성되지 않는다는 점이다. 사용자가 보기에 자연스럽고 부드럽게 느껴지도록 만드는 것까지 포함해서 진짜 완성도라고 생각한다.
오늘도 작은 최적화 하나를 통해 한 단계 더 배웠다. 앞으로도 이런 미세한 차이를 놓치지 않는 개발자가 되고 싶다.
혹시 이 글을 우연히 보게 되신 분이 있다면, 더 좋은 최적화 방법이나 피드백은 언제든 환영입니다 :)