본문 바로가기
Swift

[Swift] ETag를 활용한 캐시 유효성 검증 ( 서버 비용 줄이기)

by Jimmy_iOS 2023. 11. 12.

JimFit 앱을 개발하면서 운동 DB를 관리하는 데 많은 고민을 했습니다.

로컬 디바이스에서 관리하기 위해 Realm 형태의 DB를 생성하기로 선택했습니다.

이로 인해 AppBundle에서는 읽기가 가능하지만 쓰기에 제약이 있어 Realm 파일에 접근하는 데 문제가 발생했습니다. 이 문제는 다음과 같은 방법으로 해결할 수 있었습니다.

[Swift] Realm 파일을 프로젝트 내부에 삽입하는 방법 및 사용하기 (파일 접근 에러)
요약실제 기기에서는 번들에 있는 Realm 파일에 접근할 수 없어서 파일 접근 에러가 발생합니다. 이를 해결하기 위해 앱이 실행 중일 때 Realm 파일을 앱의 documentDirectory로 복사하여 사용하는 방법을 사용할 수 있습니다. 이렇게 하면 파일에 대한 읽기 및 쓰기 권한이 부여되어 Realm 데이터베이스를 사용할 수 있습니다. 문제의 시작운동 기록 앱을 개발할 때, 운동 데이터베이스(DB)를 생성하여 앱에 삽입하여 사용하려고 시도해 보았습니다. Realm 파일을 만들어 프로젝트에 포함시킨 후 Bundle에 넣어 사용하면 되지 않을까 생각하여,Realm 파일을 Bundle에 넣었습니다. Bundle에 있는 파일을 사용하기 위해서는 다음과 같이 진행해야 합니다: App Targets → Bui..
https://jimmy-ios.tistory.com/34

하지만 로컬 디바이스에서 DB를 관리하면 데이터 수정에 제약 사항이 생깁니다.

DB를 수정하고 업데이트하기 위해서는 앱스토어 심사를 받아야 하고,

변경된 데이터를 사용자가 이용하려면 앱을 업데이트해야 합니다.

이와 같은 문제를 해결하기 위해 데이터베이스(DB)를 서버로 이전하여 저장하는 방법을 선택했습니다.

Realm 파일 형태로 데이터를 주고받으면 Realm으로 Wrapping된 데이터를 추가로 받아야 하는

비효율이 발생하기 때문에 Raw 데이터인 JSON을 사용하여 서버에 저장했습니다.

하지만 이 방법에도 한 가지 문제점이 있습니다.

유저가 최신 운동 데이터를 유지하기 위해서는 앱을 실행할 때마다 서버에 데이터를 요청해야 합니다.

유저가 이미 최신 데이터를 가지고 있음에도 불구하고 매번 서버에 데이터를 요청하는 비효율이 발생합니다.

이 비효율을 해결하기 위해 서버의 부하를 줄일 수 있는 방법들 중 ETag 기반 캐싱을 구현하는 방법을 알아보겠습니다.

ETag 이해하기

ETag는 Entity Tag의 약자로, 웹 서버에서 리소스의 새로운 버전 여부를 판단하기 위한 메커니즘입니다.

이는 특정 버전의 리소스에 할당된 고유한 식별자입니다.

ETag를 비교함으로써 클라이언트는 로컬 복사본이 최신 상태인지 확인하거나 서버에서 새로운 버전을 가져와야 하는지 결정할 수 있습니다.

HTTPHeader

Postman을 사용해 서버에 데이터를 요청하고 받은 응답의 HTTPHeader를 살펴보면

위와 같이 Etag 값으로 Hash 값이 온 것을 확인할 수 있습니다.

Etag값을 저장해 뒀다가 다음번 요청시 HTTP 헤더의 "If-None-Match"를 사용해 서버 데이터의 Etag 값과 디바이스의 Etag 값이 일치하는지 확인해 응답을 받습니다.

GET 요청 HTTP 헤더

응답 HTTP 바디

서버의 ETag와 요청한 ETag의 값이 일치하면 Status Code로 304 Not Modified(수정되지 않음)를 받아볼 수 있습니다.

서버의 ETag와 요청한 ETag의 값이 일치하지 않으면 StatusCode로 200(수정됨) OK와 함께 HTTP 바디값을 받을 수 있습니다.

ETag 기반 캐싱 구현하기

네트워킹 코드에서 ETag 기반 캐싱을 구현하기 위해 다음 단계를 따를 수 있습니다:

  1. 서버 API에서 ETag 확인 및 데이터 가져오기 엔드포인트를 정의합니다.
  1. Router 프로토콜을 준수하는 열거형 FireStoreRouter를 생성합니다. 이 열거형은 checkETag 케이스에 대한 HTTP 메서드 및 ETag 헤더와 같은 헤더를 지정합니다.
enum FireStoreRouter: Router {
    case checkETag
    case fetchData

    var baseURL: URL? {
        return URL(string: APIKEY.ExerciseDataURL)
    }

    var method: HTTPMethod {
        switch self {
        case .checkETag:
            return .get
        case .fetchData:
            return .get
        }
    }

    var headers: HTTPHeaders? {
        switch self {
        case .checkETag:
            return ["If-None-Match": UM.ETag]
        case .fetchData:
            return nil
        }
    }

    // ...
}

FireStorageService

  1. FireStorageService 클래스에 checkETagFromFireStore 메서드를 구현합니다.
  1. 이 메서드는 서버에 ETag를 확인하기 위한 요청을 보냅니다.
  1. HTTP 헤더의 "If-None-Match"를 사용해 서버 데이터의 Etag 값과 디바이스의 Etag 값이 일치하는지 확인해 응답을 받습니다.
  1. 응답 상태 코드가 304(수정되지 않음)가 아닌 경우, 새로운 버전의 리소스가 있음을 나타내므로 fetchDataFromFireStore 메서드를 호출해 서버에서 데이터를 받아옵니다.
  1. 그렇지 않은 경우에는 UM.finishedLaunch와 같은 적절한 플래그를 설정하여 로컬 복사본이 최신 상태임을 나타냅니다.
final class FireStorageService {
		// ...
    func checkETagFromFireStore() {
        
        AF.request(FireStoreRouter.checkETag).response { response in
            switch response.result {
            case .success(_):
                if response.response?.statusCode != 304 {
                    self.fetchDataFromFireStore()
                } else {
                    UM.finishedLaunch = true
                }
            case .failure(_):
                UM.finishedLaunch = true
            }
        }
    }
		// ...
}
  1. 데이터를 가져오기 위해 fetchDataFromFireStore 메서드를 구현합니다. 이 메서드는 서버에서 데이터를 가져오기 위한 요청을 보냅니다. 요청이 성공한 경우, JSONDecoder를 사용하여 응답 데이터를 파싱하고, 이를 로컬 데이터베이스인 Realm에 저장합니다. 또한, 미래의 요청을 위해 ETag 값을 업데이트합니다.
private func fetchDataFromFireStore()  {
    AF.request(FireStoreRouter.fetchData).response { response in
        switch response.result {
        case .success(let data):
            guard let data else { return }
            self.parseExerciseData(jsonData: data)
            UM.ETag = response.response?.allHeaderFields["Etag"] as? String ?? ""
        case .failure(_):
            UM.finishedLaunch = true
        }
    }
}
  1. parseExerciseData 메서드에서 JSONDecoder를 사용하여 가져온 운동 데이터를 파싱합니다.
  1. 파싱이 완료되면, realmManager 인스턴스를 사용하여 운동 목록을 로컬 Realm 데이터베이스에 저장합니다.
private func parseExerciseData(jsonData: Data) {
    do {
        let jsonDecoder = JSONDecoder()
        let exercises = try jsonDecoder.decode([Exercise].self, from: jsonData)
        // Realm에 저장
        realmManager.saveNewRealm(exercises: exercises)
        realmManager.copyLikeAndRemoveOldRealm()
    } catch {
        // Error handling
    }
}

이러한 단계를 구현함으로써 서버에서 최초로 데이터를 받아올 때 Etag를 저장하고, 앱을 실행할 때마다 서버에 저장된 Etag와 디바이스의 Etag를 비교하여 일치하지 않을 때에만 데이터를 불러와 호출 대비 데이터 사용량을 약 520% 줄일 수 있었습니다.

결론

네트워킹 코드에서 ETag 기반 캐싱을 구현함으로써 서버 부하를 크게 감소시키고 전체적인 성능을 향상시킬 수 있습니다.

ETag를 활용함으로써 클라이언트는 필요한 경우에만 업데이트된 리소스를 가져오도록 하여 불필요한 네트워크 요청을 줄이고 사용자 경험을 개선할 수 있습니다.


Uploaded by N2T