[리팩토링] 02. MKTileOverlay에 캐싱을 적용해봅시다.

2026. 3. 29. 23:15·Flyleaf - 독서를 여행처럼/리팩토링
728x90

맵 타일에 캐싱을 적용한 이유

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
'Flyleaf - 독서를 여행처럼/리팩토링' 카테고리의 다른 글
  • [리팩토링] 01. AppCoordinator의 비대함 문제를 해결해봅시다 (feat. Route)
여성일
여성일
  • 여성일
    성일노트
    여성일
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Flyleaf - 독서를 여행처럼
        • 리팩토링
        • 트러블슈팅
        • 개발일지
      • 탭탭 - TapTap
        • 리팩토링
        • 트러블슈팅
        • 개발일지
      • 애플 디벨로퍼 아카데미
        • 챌린지 회고
        • 하루의 날씨
      • Swift Student Challenge 202..
      • AI를 잘쓰는 개발자가 될래요
      • 우리 같이 협업하자
      • 사카마카 (살까말까 고민 될 때는 사카마카)
      • Book2OnNon (모바일 서재)
      • 바꿔조 (환율 계산기)
      • iOS
        • iOS
        • Vapor
        • Design Pattern
        • CoreData
        • Tuist
        • RxSwift
        • ReactorKit
        • TCA
      • Swift
        • Swift 기본기
        • UIkit
        • SwiftUI
      • UX, 사용성
      • 원티드 프리온보딩 챌린지 iOS 과정
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.6
    여성일
    [리팩토링] 02. MKTileOverlay에 캐싱을 적용해봅시다.
    상단으로

    티스토리툴바