iOS

HTTP 캐싱(Etag & max-age) 그리고 iOS에서는?

2료일 2025. 10. 1. 01:24

왜 캐싱이 필요할까?

대부분의 앱은 서버와 HTTP 통신으로 데이터를 가져와요.

그런데 이 과정에는 생각보다 큰 비용이 들어가죠.

  • 클라이언트(앱) → 네트워크 왕복 시간이 걸림
  • 서버 → 요청을 처리하느라 부하가 증가함
  • 사용자 → 데이터 사용량과 배터리를 소모함

특히 이런 상황을 생각해봅시다.

👉방금 받아온 데이터랑, 지금 또 요청하려는 데이터가 똑같다면?

 

업데이트된 정보도 없는데, 또 네트워크를 타는 건 이건 명백한 낭비죠입니다.

이 문제를 해결하는 방법이 바로 HTTP 캐싱이에요.

캐시는 어디에 저장되나?

캐시는 어디에 저장되나?

중간 서버에 캐시를 둔 구조

  • CloudFront, Cloudflare 같은 CDN
  • Nginx, Varnish 같은 프록시

요청이 캐시에 있으면 원 서버까지 안 가도 되지만, 여전히 네트워크는 타요.

 

클라이언트 내부에 캐시를 둔 구조

  • iOS에서는 URLCache가 대표적
  • 캐시가 있으면 네트워크 통신 자체를 아예 피할 수 있음

캐시 유효성, 어떻게 판단할까?

HTTP는 이를 위해 크게 두 가지 방식을 제공해요.

max-age: 시간 기반 캐싱

Cache-Control: max-age=60

이거 많이 보셨죠? "이 데이터는 60초 동안 유효합니다"라는 의미예요.

그럼 s-maxage는?

Cache-Control: max-age=300, s-maxage=60

여기서 s-maxage의 's'는 'shared cache', 즉 공유 캐시를 뜻해요

"공유 캐시가 뭔데?" =  (위에서 본 중간서버)

 

그럼 iOS 개발자인 나한테는?

하지만 iOS 앱 개발에서는 s-maxage는 큰 의미가 없어요.

우리가 주로 신경 써야 하는 건 max-age + URLCache 조합이죠.

let config = URLSessionConfiguration.default
// 이미 기본적으로 서버 헤더에 맞춰 캐싱이 동작함

우리가 별도로 설정하지 않아도, URLSession은 서버의 Cache-Control 헤더를 보고 알아서 캐싱을 처리합니다.

캐시 vs 노캐시, 실제로 얼마나 차이날까?

final class NetworkClientTests: XCTestCase {
    private var cachedSUT: NetworkClient!
    private var noCacheSUT: NetworkClient!
    
    override func setUp() {
	//캐시 활성화
        let cachedConfig = URLSessionConfiguration.default
        cachedConfig.requestCachePolicy = .useProtocolCachePolicy

        // 캐시 비활성화
        let noCacheConfig = URLSessionConfiguration.default
        noCacheConfig.requestCachePolicy = .reloadIgnoringLocalCacheData
    }
}

성능 테스트 실행

각각 50번씩 API를 호출해서 평균 응답 시간을 측정했어요

func test_캐시_50번_응답시간_측정() {
    let option = XCTMeasureOptions()
    option.iterationCount = 50
    
    measure(options: option) {
        let expectation = XCTestExpectation(description: "캐시 응답시간")
        Task {
            let _: [IssueDTO] = try await cachedSUT.perform(FetchIssuesEndpoint())
            expectation.fulfill()
        }
        wait(for: [expectation], timeout: 10.0)
    }
}

결과는??

노캐시 (매번 네트워크 요청)

평균: 0.571초
최소: 0.434초
최대: 1.204초
총 시간: 30.036초

캐시 사용

평균: 0.002초 (0.571초 → 0.002초)
최소: 0.001초
최대: 0.006초
총 시간: 0.805초 (30.036초 → 0.805초)

👉 약 285배 빠른 응답 속도를 보여줬습니다.

여기서 의문이 들 수 있어요.

"캐시가 이렇게 좋으면, max-age를 엄청 길게 잡으면 되는 거 아냐?"

예를 들어 max-age=86400 (하루)로 설정하면, 하루 동안은 서버에 요청도 안 가니까 엄청 빠르겠죠?

근데 문제는 그 하루 동안 데이터가 바뀌면 사용자는 구버전을 보게 된다는거죠

  • 짧게 잡으면 → 네트워크 비용 증가, 배터리 소모
  • 길게 잡으면 → 업데이트 반영이 느림

"둘 다 만족시킬 방법은 없을까?"

ETag가 해결해드립니다🚐


ETag를 쓰면 이 문제를 깔끔하게 해결할 수 있어요.

동작 과정

  1. 첫 요청 시 서버가 ETag를 내려줘요.
  2. 다음 요청 시 앱이 If-None-Match 헤더로 보내요.
  3. 앱이 저장해둔 ETag를 헤더에 실어 보내면, 서버는 현재 데이터의 ETag와 비교해서:
    • 같으면 → 304 응답 (본문 없이 헤더만 보냄)
    • 다르면 → 200 응답 (새 데이터 + 새 ETag)

즉, 네트워크 요청 자체는 가지만 데이터 전송량을 크게 줄일 수 있는 거죠.


iOS에서 ETag를 직접 구현해보자

1. 어떤 저장소를 사용할까?

저장소 장점 단점 적합한경우
메모리 (변수) 빠름 앱 종료시 사라짐 임시 데이터
UserDefaults 앱 재실행 후에도 유지 약간의 I/O 대부분의 경우
Keychain 보안 느림, 복잡 민감한 데이터
DB (Core Data, Realm) 복잡한 쿼리 가능 과도한 설계 대량의 ETag 관리

따라서, UserDefaults에 URL을 키로 하여 ETag를 저장하는 방식이 대부분의 경우에 가장 효율적이에요

 

2. ETag는 어떻게 저장할까?

여러 API의 ETag를 구분하려면 URL을 키로 쓰는 딕셔너리 구조가 가장 적합해요.

각 URL마다 다른 ETag를 저장해두면 조회하고 갱신하기 쉽죠

// URL을 키로 사용
{
  "https://api.example.com/users/123": "user-v5",
  "https://api.example.com/posts": "post-v12",
  "https://api.example.com/comments/456": "comment-v3"
}

 

3. 간단한 구현 예시

ETag 저장소를 만들고, 요청에 실어 보내고, 응답을 처리하는 과정이에요.

// ETag 저장
struct ETagStorage: ETagStorable {
    private let storageKey = "app.etags"
    
    func getETag(for url: URL) -> String? {
        let stored = UserDefaults.standard.dictionary(forKey: storageKey) as? [String: String]
        return stored?[url.absoluteString]
    }
    
    func storeETag(_ etag: String, for url: URL) {
        var stored = UserDefaults.standard.dictionary(forKey: storageKey) as? [String: String] ?? [:]
        stored[url.absoluteString] = etag
        UserDefaults.standard.set(stored, forKey: storageKey)
    }
}

// 요청에 ETag 추가 (GET일 때만)
struct URLRequestBuilder {
    static func build(from url: URL, method: String = "GET", etagStorage: ETagStorable) -> URLRequest {
        var request = URLRequest(url: url)
        request.httpMethod = method
        
        if method == "GET", let storedETag = etagStorage.getETag(for: url) {
            request.setValue(storedETag, forHTTPHeaderField: "If-None-Match")
        }
        
        return request
    }
}

 

근데 그거 아세요? 위의 과정을 직접 할 필요가 없습니다:)

지금까지 제가 직접 만든 ETag 관리 로직은 학습용으로는 의미가 있었지만, 실전에서는 쓸 이유가 없어요.

왜냐하면 URLSession이 이미 ETag를 자동으로 관리해주거든요.

useProtocolCachePolicy로 기본 캐시 정책만 따르도록 설정하면, URLSession이 알아서:

  • 요청마다 캐시 데이터와 ETag를 비교하고
  • 필요시 If-None-Match 헤더를 추가하고
  • 304 응답에 따른 캐싱 동작까지 모두 처리해요

하지만 정말 그럴까요? 이걸 검증하기 위해 실제 HTTP 트래픽을 분석해봤어요.

검증의 어려움 ㅠㅠ

URLSession이 ETag를 자동으로 관리한다는 걸 검증하려고 URLRequest를 출력해봤는데요.

실제로 전송되는 헤더는 확인할 수 없었어요.

왜냐하면 URLSession이 내부에서 헤더를 추가하거든요. 요청 객체만으로는 실제 네트워크 동작을 검증할 수 없었죠.

그래서 Proxyman을 사용해서 실제 HTTP 트래픽을 분석하기로 했어요.

URLRequest.CachePolicy별 동작 검증

실험을 위해 세 가지 다른 CachePolicy를 테스트했어요.

1. reloadIgnoringLocalAndRemoteCacheData

의미: 로컬 및 원격 캐시를 모두 무시하고 항상 서버에서 새로운 데이터를 가져옵니다.

Request Header:

Accept: application/...*>>
Authorization: Bearer ***
Cache-Control: no-cache
Content-Type: application/json
Host: api.example.com

결과:

  • Cache-Control: no-cache 헤더가 자동으로 추가됨
  • 매번 200 응답과 함께 전체 데이터를 받음

 If-None-Match 헤더 없음

동작: 캐시를 완전히 무시하고 매번 서버에서 새로운 데이터를 가져옴

2. reloadIgnoringLocalCacheData

의미: 로컬 캐시만 무시하고, 서버의 캐시 검증은 수행합니다.

Request Header:

Accept: application/v...*>>
Authorization: Bearer ***
Content-Type: application/json
Host: api.example.com

결과:

  • Cache-Control: no-cache 헤더가 없음
  • 매번 서버에 요청을 보냄

 If-None-Match 헤더 없음 (로컬 캐시를 무시하므로)

동작: 로컬 캐시는 안 쓰지만, 서버의 캐시 정책은 따라요.

3. useProtocolCachePolicy 

의미: HTTP 프로토콜의 캐시 정책을 그대로 따름 서버의 Cache-Control ETag 헤더를 기반으로 자동으로 캐싱을 관리해요

첫 번째 요청 (캐시 없음)

Request Header:

Accept: application/예시
Authorization: Bearer ***
Content-Type: application/json
Host: api.example.com

Response Header:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60, s-maxage=60
ETag: W/"6e0643d55a6172499b7b92a1409c800a8d75c471974076bf81b33094b175ec0d"
Content-Type: application/json; charset=utf-8

결과:

  • 200 응답과 함께 데이터 수신
  • 서버가 ETag와 Cache-Control: max-age=60 제공
  • URLSession이 자동으로 응답을 캐시에 저장

두 번째 요청 (max-age 이내)

동작:

  • URLSession이 로컬 캐시에서 직접 데이터 반환
  • 응답 시간: ~1ms (거의 즉시)

❌ 네트워크 요청 전송하지 않음

중요: max-age=60 이내에는 서버에 아예 요청을 보내지 않고 로컬 캐시를 써요.

onAppear에서 매번 API를 호출하게 만들었는데, Proxyman에서는 60초 이내에 네트워크 요청이 전혀 발생하지 않는 걸 확인할 수 있었어요.

세 번째 요청 (max-age 초과 후)

Request Header:

Accept: application/예시
Authorization: Bearer ***
Content-Type: application/json
Host: api.example.com
If-None-Match: W/"6e0643d55a6172499b7b92a1409c800a8d75c471974076bf81b33094b175ec0d"

Response:

HTTP/1.1 304 Not Modified
ETag: W/"6e0643d55a6172499b7b92a1409c800a8d75c471974076bf81b33094b175ec0d"

결과:

  • If-None-Match 헤더가 자동으로 추가됨 (수동 추가 불필요!)
  • 서버가 304 응답 (데이터 변경 없음)
  • URLSession이 자동으로 캐시된 데이터 반환
  • 📦 전송 데이터: ~0 bytes (헤더만 전송)

결론: 직접 구현은 필요 없다

URLSession이 자동으로 관리하는 것들

  • Cache-Control 파싱 및 max-age 준수
  • ETag 저장 및 관리
  • If-None-Match 헤더 자동 추가
  • 304 응답 처리 및 캐시된 데이터 반환
  • 캐시 만료 시점 자동 계산

개발자가 해야 할 것 = request.cachePolicy = .useProtocolCachePolicy 설정

 

ETag의 한계

그런데 여기서 한 가지 더 짚고 넘어가야 할 게 있어요.

ETag는 언제 서버에 전달될까요?

정답은: 캐시가 만료됐을 때만이에요.

max-age가 남아있으면, URLCache가 알아서 캐시를 반환하고 서버에 요청조차 안 가요. ETag를 보낼 기회가 없는 거죠.

결국 iOS에서 캐싱 전략은:

  • max-age + URLCache로 빠른 응답을
  • ETag로 데이터 전송량을 줄이도록

이 둘을 조합하는 게 가장 현실적인 해법이에요.

상황에 맞춰 캐시 정책을 잘 설계하는 것이 앱의 성능과 서버 비용을 동시에 잡는 길이겠죠.

결국 캐싱 전략은 백엔드와 함께 설계해야 하는 영역이네요

(아닌가 백엔드가 그냥 담당하고 프론트는 네~ 하고 하는 역할인가..? 이건 현업에 가게 된다면 글을 수정해볼게요)

 



참고자료

- https://thorsten-stark.de/posts/Reduce-network-traffic/