맵 타일에 캐싱을 적용한 이유
MapKit 기반 UI를 구성하면서 MKTileOverlay를 사용해 커스텀 타일을 렌더링했는데, 실제 사용성과 QA 과정에서 몇 가지 명확한 문제가 드러났습니다. 이를 개선하기 위해 타일 캐싱을 적용하게 되었고, 그 이유는 다음과 같습니다.
1. 네트워크가 없을 때 "빈 지도"가 노출되는 문제

맵 타일은 기본적으로 서버에서 이미지를 받아와 렌더링하는 구조입니다. 따라서 네트워크 연결이 끊기면 타일을 가져올 수 없고, 그 결과 지도 영역이 비어 보이거나 깨진 형태로 노출됩니다.
물론 Flyleaf는 Firebase, 검색 API 등 외부 네트워크 의존도가 높은 구조이기 때문에 네트워크가 완전히 끊긴 상황에서는 앱 사용 자체가 제한되는 것이 사실입니다.
그럼에도 불구하고 아래와 이유로 캐싱이 필요하다고 판단했습니다.
- 홈 화면은 앱 진입 시 가장 먼저 노출되는 화면
- 사용자가 "지도 상태"만이라도 확인하고 싶어할 가능성 존재
- 최소한 최근에 본 지도라도 유지하는 것이 UX 측면에서 자연스럽다고 판단
2. 줌/지도 이동 시 발생하는 타일 깜빡임(플리커 문제)
두 번째 이유는 더 직접적이고, 실제 QA 및 피드백에서 가장 많이 지적된 문제였습니다.
맵을 이동하거나 줌 할때마다 새로운 영역에 필요한 타일을 서버에서 요청하고, 해당 타일이 도착하면 화면에 렌더링되는 구조이기 때문에 이 과정에서 타일이 교체되며 깜빡이는 현상(플리커)가 발생합니다.
서버 응답 확인
캐싱을 바로 구현하기 전에, 먼저 타일 서버가 캐싱에 필요한 응답 헤더를 제대로 내려주는지 확인했습니다. 아무리 클라이언트에서 캐싱을 구현해도 서버가 Cache-Control 헤더를 올바르게 내려주지 않으면 의미가 없기 때문입니다.
curl -I "https://a.basemaps.cartocdn.com/dark_nolabels/5/27/13.png"

확인한 응답에서 주목할 부분은 세 가지였습니다.
1. 캐싱 가능 여부
cache-control: public, max-age=15552000
public은 클라이언트와 중간 프록시 모두 캐싱 가능하다는 의미이고, 'max-age=15552000`은 약 180일간 유효하다는 뜻입니다. 따라서 서버가 캐싱을 명시적으로 허용하고 있었습니다.
2. CDN 캐시 상태
x-cache: HIT, HIT, HIT
x-cache-hits: 5, 6, 1
서버 단에서도 이미 CDN 캐시가 동작 중이었습니다.
3. 응답 속도
x-timer: S1774788499.804033, VS0, VE1
응답 속도도 1ms 수준으로 매우 빠른 상태였습니다.
캐싱을 적용해 봅시다
URLCache.shared 초기화
먼저 앱 시작 시점에 URLCache.shared를 초기화했습니다.
// SceneDelegate
URLCache.shared = URLCache(
memoryCapacity: 30 * 1024 * 1024, // 30MB
diskCapacity: 300 * 1024 * 1024 // 300MB
)
맵타일 내부에 독립적인 URLCache 인스턴스를 만들지 않고 URLCache.share를 사용한 이유는, VC 생명주기와 무관하게 앱 전역에서 캐시를 공유하기 위함입니다. 홈 화면을 나갔다 다시 돌아와도 캐시가 유지됩니다.
캐시 크기는 타일 하나의 평균 크기가 2~3KB인 점을 기준으로 계산했습니다. 메모리 30MB이면 약 10,000 ~ 15,000개의 타일을 만들 수 있고, 한 화면에 표시되는 타일이 약 50개인 점을 감안하면 충분한 수준이라고 판단했습니다. 디스크 300MB는 Library/Caches 디렉토리에 저장되며 앱 번들 용량과 무관하고, iOS가 저장 공간 부족 시 정리해줍니다.
메모리와 디스크 캐시를 둘 다 사용한 이유는 역할이 다르기 떄문입니다.
1. 메모리 캐시 -> 속도
지도 스크롤 중 동일한 타일 반복 요청 시 디스크 I/O 없이 즉시 반환
2. 디스크 캐시 -> 영속성
앱 재시작 후에도 이전에 받은 타일을 서버 재요청 없이 사용 가능
CachedMapTileOverlay 구현
final class CachedMapTileOverlay: MKTileOverlay {
private let session: URLSession
override init(urlTemplate: String?) {
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache.shared
configuration.requestCachePolicy = .returnCacheDataElseLoad
self.session = URLSession(configuration: configuration)
super.init(urlTemplate: urlTemplate)
}
override func loadTile(
at path: MKTileOverlayPath,
result: @escaping (Data?, Error?) -> Void
) {
let request = URLRequest(url: url(forTilePath: path))
session.dataTask(with: request) { data, _, error in
result(data, error)
}.resume()
}
}
핵심은 URLSessionConfiguration에 두 가지를 설정한 것입니다.
1. urlCache = URLCache.shared
SceneDelegate에서 설정한 캐시를 그대로 사용
2. requestCachePolicy = .returnCacheDataElseLoad
캐시가 있으면 서버 요청 없이 즉시 반환, 없으면 서버에서 받아서 캐시에 저장
setuoMapView() 교체
func setupMapView() {
mapView.delegate = self
mapView.addSubview(gradientOverlayView)
let tileOverlay = CachedMapTileOverlay(urlTemplate: MapTile.darkNolabels)
tileOverlay.canReplaceMapContent = true
mapView.addOverlay(tileOverlay, level: .aboveRoads)
}
캐시 동작 검증
구현 후 실제로 캐시가 동작하는지 검증하기 위해 loadTile 내부에 로그를 추가 후 검증했습니다.
if URLCache.shared.cachedResponse(for: request) != nil {
print("캐시 HIT z:\(path.z) x:\(path.x) y:\(path.y)")
} else {
print("캐시 MISS z:\(path.z) x:\(path.x) y:\(path.y)")
}
결과를 확인해 보니 한 번 받은 타일은 이후 동일 영역에서 재방문 시 전부 HIT가 찍히는 것을 확인했습니다. 네트워크를 끊고 테스트했을 때도 캐시된 타일은 정상적으로 표시되는 것을 확인했습니다.

결과 및 한계
해결된 것
1. 네트워크 미연결 시 캐시된 타일은 정상 표시
2. 이미 본 영역 재방문 시 서버 요청 없이 즉시 렌더링
해결되지 않은 것 - 플리커 현상
사실 캐싱을 도입한 이유 중 가장 큰 이유가 줌/이동 시 발생하는 플리커 현상을 개선하기 위해서였습니다.
그러나 캐싱 적용 이후에도 지도 이동 시에는 플리커가 눈에 띄게 줄어들었지만, 줌 시에는 여전히 플리커가 발생했습니다.
즉, 캐싱은 일부 상황에서는 효과가 있었지만, 문제의 핵심은 해결하지는 못했습니다.
플리커의 원인을 단순히 "네트워크 지연"으로 가정하고 접근했습니다. 하지만, 네트워크 응답 헤더를 분석해보니
- 응답 속도는 1ms 수준
- 캐시 HIT도 정상적으로 발생
즉, 타일을 가져오는 속도는 문제가 아니었습니다.
다음으로 MapKit 레벨에서 원인을 찾기 위해 여러 설정을 변경해보았습니다.
1. mapType 변경
hybridFlyover -> standard, satelliteFlyover
Flyover 모드가 상대적으로 렌더링 비용이 크기 때문에 렌더링 부하로 인한 플리커 가능성을 검증하기 위함이었으나, 유의미한 차이는 없었습니다.
2. titleOverlay.canReplaceMapContent 변경
true -> 기본 맵을 대체
false -> 기본 맵 위에 오버레이
타일 교체 타이밍 문제 또는 렌더링 레이어 충돌 가능성을 줄이기 위함이었으나, 유의미한 차이는 없었습니다.
문제를 다시 분석하면서 "지도 이동에서는 왜 개선됐을까?"에 집중하여 분석해보았습니다. 지도 이동은 같은 줌 레벨(z축)에서 x/y타일만추가로 로드되는 구조입니다. 이미 캐싱된 타일이 재사용되면서 자연스럽게 이어지는 렌더링이 가능합니다. 그래서 플리커가 개선된 것으로 확인됩니다.
하지만 줌은 완전히 다른 동작을 하니다. 줌이 발생하면 z레벨이 변경됨 -> 기존 타일 세트 제거 -> 새로운 z레벨의 타일이 다시 그려짐.
이 과정에서 기존 타일이 먼저 사라지고, 새 타일이 올라오기 전까지 짧은 공백이 발생함.
이 공백이 바로 플리커의 본질적인 원인이라고 생각합니다.
캐싱으로 해결하는 것은 네트워크 요청 감소, 타일 로딩 속도 개선과 같은 것입니다. 하지만 타일 레벨 변경 시 발생하는 렌더링 교체 자체는 해결이 불가능하다고 생각합니다. 타일이 캐싱에 있어도 기존 타일을 유지하면서 전환하는 기능은 MKTileOverlay에서 지원하지 않기 때문입니다.
근본적인 해결을 위해서는 MapLibre와 같은 커스텀 맵 라이브러리 도입이 필요하며, 홈지도는 가장 중요한 기능이기 때문에 UX 개선을 위해 MapLibre 도입을 검토하고 있습니다.
'Flyleaf - 독서를 여행처럼 > 리팩토링' 카테고리의 다른 글
| [리팩토링] 01. AppCoordinator의 비대함 문제를 해결해봅시다 (feat. Route) (0) | 2026.03.26 |
|---|