왜 캐싱이 필요할까?
대부분의 앱은 서버와 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를 쓰면 이 문제를 깔끔하게 해결할 수 있어요.
동작 과정
- 첫 요청 시 서버가 ETag를 내려줘요.
- 다음 요청 시 앱이 If-None-Match 헤더로 보내요.
- 앱이 저장해둔 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 헤더 없음
동작: 캐시를 완전히 무시하고 매번 서버에서 새로운 데이터를 가져옴

의미: 로컬 캐시만 무시하고, 서버의 캐시 검증은 수행합니다.
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 |