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

2025. 10. 1. 01:24·iOS

왜 캐싱이 필요할까?

대부분의 앱은 서버와 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/

 

 

 

'iOS' 카테고리의 다른 글

UIKit / SwiftUI에서는 무슨 디자인 패턴을 사용해야할까?  (0) 2025.10.17
URLSession 한 줄 뒤에 숨겨진 것들: DNS부터 전자기파까지  (2) 2025.10.11
URLSession에 대한 에브리띵  (0) 2025.09.27
스크롤뷰는 어떻게 스크롤될까? 🤔 UIView 렌더링부터 시작하는 완벽 분석  (0) 2025.09.26
데이터 보관은 어떻게이루어질까(직렬화: NSCoding부터 Codable까지)  (0) 2025.09.09
'iOS' 카테고리의 다른 글
  • UIKit / SwiftUI에서는 무슨 디자인 패턴을 사용해야할까?
  • URLSession 한 줄 뒤에 숨겨진 것들: DNS부터 전자기파까지
  • URLSession에 대한 에브리띵
  • 스크롤뷰는 어떻게 스크롤될까? 🤔 UIView 렌더링부터 시작하는 완벽 분석
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (133) N
      • SWIFT개발일지 (28)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (42) N
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (2)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
HTTP 캐싱(Etag & max-age) 그리고 iOS에서는?
상단으로

티스토리툴바