'Cache-Control'이 필요한 이유





안녕하세요. 드림어스컴퍼니 iOS개발팀 Dons 입니다.

FLO에 적용된 'Cache-Control' 기능을 점검하며 'Cache-Control' 이 무엇인지, 왜 필요한지에 대해 조사한 내용을 공유합니다.




'Cache-Control', 왜 필요한가?

대부분의 Client(앱, 웹 등등)는 Back-end와 HTTP를 통해 통신을 하여, 데이터를 가져오고 이를 사용자에게 UI를 제공합니다. 이 과정을 거치면서 Client는 네트워크를 거치는 시간을 소비하고, Server는 요청을 처리하는데 걸리는 시간 혹은 부하를 소비합니다. 그런데 만약, Client가 이전에 받은 데이터와 새로 요청한 데이터가 같다면 이 과정은 낭비일 수 있습니다. 사용자에게 업데이트된 정보는 없으니까요.


이러한 낭비를 줄이기 위한 해결책으로 사용할 수 있는 방법이 HTTP에서 제공하는 Cache-Control입니다. 적절하게 Cache-Control을 사용함으로써, 상황에 따라 Server는 부하를 줄일 수 있고, Client는 네트워크를 거치는 시간을 아낄 수 있습니다.


(물론, 이외에도 장점은 더 있습니다. 사용자 데이터 사용을 아낄수 있으며, 네트워크 통신에 의한 배터리 소모를 줄일 수 있는 점 등등)


장점을 요약하면, 불필요한 데이터 요청을 피할 수 있습니다. 네트워크를 거치지 않기 때문에, 사용자에게 빠른 응답을 제공할 수 있습니다. Server에 요청이 줄어, 부하를 줄일 수 있습니다. Server와 Client의 거리가 물리적으로 먼 경우, 네트워크를 거치지 않는 장점은 더욱 증가합니다.





Client, Server 그리고 Cache의 배치구조



먼저, 기본적인 Client, Server 구조입니다.




중간에 Cache Server를 배치한 구조입니다. 요청한 데이터가 캐싱이 되어 있는 경우라면, Server까지 갈 필요가 없기 때문에 좀 더 빠르게 데이터를 받을 수 있겠죠. 하지만, Cache Server까지 도달하는 네트워크 통신은 불가피할 겁니다.



아니면, Client 내에 제공된 Cache 매니저가 있다면 이를 사용하는 방법도 있습니다. iOS에서는 이러한 Cache Manager를 제공하고 있습니다. Client 내부에서 Cache를 관리하기에, 캐싱이 되어있는 경우라면 완벽하게 네트워크 통신을 피할 수도 있을 겁니다. (하지만, 캐싱이 되어 있더 라도 항상 요청을 하지 않는 것은 아닙니다. 이에 대해서는, 조금 뒤에 설명하겠습니다.)


물론 이 구조는, 다양하게 조합하여 구성할 수 있겠죠.






Cache 동작 원리

그러면, 어떻게 Cache가 동작하는지 알아보도록 하겠습니다.



  1. Client의 요청이 Cache Server 혹은 로컬 Cache 관리자에 전달됩니다.

  2. 캐시 된 문서가 있는지를 조회합니다.

  3. 존재한다면 문서의 유효성 체크를 하게 되고, 없다면 Server로 요청을 전달합니다.

  4. 해당 문서가 아직 유효하다면, 새로 생성한 Headers와 기존의 Body를 조합합니다.

  5. 유효하지 않다면, Server에 요청을 전달합니다.

  6. Client에 응답을 전달합니다.





Cache 유효성 관리

HTTP는 Cache된 데이터와 Server가 제공하는 데이터 간에 불일치를 최소화하기 위한 방법들을 제공하고 있습니다. 그 방법 중 문서의 유효시간 을 제공하는 방식과, ETag을 사용하여 Server에 유효성 체크를 하도록 요구하는 방식이 있습니다.




문서 유효시간 방식


응답으로 받은 문서의 유효시간이 지나기 전까지, 캐시 된 데이터를 재 사용하는 방식입니다. 유효시간이 지난 경우에는 Server에 유효성을 다시 확인해야 합니다. 유효성 시간이 지났다고 해서, 데이터가 변경되었다는 것은 아닙니다. 한번, 확인할 시간이 되었다는 것이지요.





HTTP/1.1 200 OK 
Date: Fri, 12 Mar 2021 03:52:32 GMT 
Server: WSGIServer/0.2 CPython/3.8.0 
Content-Type: application/json 
Cache-Control: max-age=15 

{"firstName": "\uad11\ubbfc", "lastName": "\uae40"} 


위와 같이 Cache-Control의 max-age 속성을 이용하여 유효시간을 지정합니다. (시간단위는 초)

Cache-Control을 활용한 캐싱 데모를 위해 다음과 같이 환경을 구성하였습니다.


  • Server : Django를 활용한 로컬 서버. 간단하게 예시에 맞춰, 캐시 정책을 구현하였습니다.

  • Client: 버튼을 클릭하면, 로컬 서버에 요청하고 데이터를 Label에 노출하는 매우 간단한 iOS 앱

  • Cache: iOS URL System에서 제공하는, 기본 캐시 매니저


그리고, 요청과 응답의 과정을 Charles(Proxy 앱)을 통해 확인하였습니다.






앱 구동 후, Request 버튼을 눌렀을 때, 요청에 따라 응답을 정상적으로 받는 것을 확인할 수 있습니다.

하지만, 다시 버튼을 눌렀을때 아무런 동 작을 하지 않는 것처럼 보입니다. 영상에 나타나듯, Request 버튼을 반복해서 눌렀을 때, XCode의 콘솔에는 버튼이 눌린 것을 확인할 수 있으나, Charles에서는 요청이 발생되지 않음을 알 수 있고, 일정 시간(15초)이 지난 후, 버튼을 눌렀을 때는 다시 요청이 전달되는 것이 보입니다.


이는, 버튼이 눌렸을 때 iOS 내부의 캐시 매니저를 거치고 있기 때문입니다. Server로부터 받은 max-age=15가 아직 유효하기 때문에 실제 요청을 전달하지 않고, 내부에 캐시 된 데이터를 바로 응답으로 보냈기 때문에 proxy에 잡히지 않는 것입니다. (영상 속 우상단에 띄워져 있는 Charles의 위 패널은 요청에 사용된 Contents를 노출하고, 아래 패널은 응답 Contents를 노출합니다.)

응답을 자세히 보면, 응답 Header에 Cache-Control: max-age=15 가 주어진 것을 볼수 있습니다.




ETag(Entity Tag) 사용 방식


유효시간으로 유효성을 체크하는 것이 충분치 않은 경우들이 있습니다.

예를 들어,


  • 문서가 주기적으로 업데이트가 되지만, 바뀐 내용이 없는 경우

  • 변경된 내용이 있지만, 응답상으로는 의미가 크지 않은 경우

  • 유효한 시간을 정하기 어려운 경우


이와 같은 상황에는, ETag를 사용해서 캐시를 관리할 수 있습니다.





위의 예시 영상을 보면,


  1. 최초 요청 시 응답 코드 200 OK와 함께, 데이터를 받았습니다. 이때, 응답 헤더에는 ETag가 붙어 있습니다.

  2. 다시 요청을 할 때, 이전에 받은 ETag 값이 요청 Header의 If-Not-Match 속성의 값으로 붙게 됩니다.

  3. Server는 받은 ETag를 대조하고, 변경된 사항이 없기 때문에 응답으로 304 Not Modified를 보냅니다. 변경된 것이 없으므로, 캐싱 된 데이터가 사용됩니다.

  4. 다시 한번 요청했을 때는, Server에서 데이터가 변경된 상태이고 그에 따라 ETag 또한 변경되었습니다. 이에, 새로운 데이터와 업데이트 된 ETag를 200 OK 와 함께 응답합니다.




Cache-Control 을 통한 제어

그러면 위와 같은 Cache의 동작을 어떻게 Cache-Control을 통해 제어할 수 있는지 그리고, 그것을 위한 디테일한 장치들을 알아 보겠습니다.



Server


아래는 Server가 Cache 관리를 제한하는 방법입니다.


Cache-Control: no-cache

캐시 관리자에게 캐싱을 허용하나, 사용 전 필수로 Server에 유효성 체크를 해야 합니다.


Cache-Control: no-store

캐싱을 금지합니다.


Cache-Control: max-age=3600

초 단위로 캐시가 유효한 시간을 제공합니다.


Cache-Control: must-revalidate

캐시 관리자에게 내부 유효성 체크를 허용하지 않으며, 항상 Server에 유효성 체크를 하도록 합니다.




Client


아래는 Client가 데이터를 요청 시에 Cache 관련 제어를 하는 방법입니다.


Cache-Control: max-stale

Client가 캐시관리자에게 유효시간이 지난 데이터를 반환 하는것을 허용합니다.


Cache-Control: max-stale=3600

Client가 캐시 관리자에게 제공한 시간 정도만 유효시간이 지난 데이터를 반환하는 것을 허용합니다.


Cache-Control: max-age=3600

캐시 관리자는 제공한 시간보다 오래된 데이터를 반환하지 않도록 요청합니다.


Cache-Control: no-cache

캐시 관리자에게 캐시 된 데이터를 반환하기 전에 Server에 유효성 체크를 하도록 요청합니다.


Cache-Control: no-store

캐시를 하지 않도록 요청합니다.




iOS에 적용

저는 iOS 개발자이기에, 마지막으로 iOS에 기본 캐시 매니저를 어떻게 활용하는지 알아보겠습니다. 근데, 놀랍게도 딱히 언급할 내용이 많지 않습니다. 본인이 iOS 개발 중에 캐시 관련한 작업을 따로 하지 않았다면, 캐시 매니저는 이미 동작하고 있습니다. 물론, Server에서 동작을 위한 적절한 Header를 설정하고 구현을 했다면 말이죠.


몇 가지 요약하자면,

  • URLSession은 default로 URLCache를 공유하고 있습니다.

  • 적절하게 메모리 및 디스크에 캐시를 관리합니다.

  • 물론, 원하면 별도의 URLCache를 생성하여 관리할 수 있습니다.

  • Cache policy를 지정할 수 있어, Client가 각 요청에 따라 다른 캐시 정책을 줄 수 있습니다. (예를 들면, 캐시 된 데이터는 무시하고 항 상 Server에 요청을 한다던가 혹은 캐시된 데이터만 요청하고 없어도 Server에 요청을 하지 않는다던가 하는 식입니다.)

실제로, 위 영상들에서 사용된 코드를 보면 Cache 관련해서 따로 한 작업은 없습니다. 기본 URLSession을 사용해서, 요청하고 응답을 파싱하고 결과를 UI에 노출한 것이 전부입니다.




    private lazy var session: URLSession = {  
        let config = URLSessionConfiguration.default  
        let session = URLSession(configuration: config)  
        return session  
    }()   
    
    func sendRequest() {  
        let url = URL(string: "http://localhost.charlesproxy.com:8000 /cache_sample/")  
        let request = URLRequest(url: url!)  
        session  
            .dataTaskPublisher(for: request)  
            .map(\.data)  
            .decode(type: ContentModel.self, decoder: JSONDecoder())              
            .receive(on: RunLoop.main)  
            .sink(receiveCompletion: { [weak self] completion in  
                guard let self = self else { return }  
                switch completion {  
                case .finished: break  
                case .failure(let error):  
                    self.title = error.localizedDescription  
                }  
             }, receiveValue: { [weak self] (model) in  
                 guard let self = self else { return }  
                 self.title = " \(model.lastName) \(model.firstName)"
             })  
             .store(in: &storage) 
   } 
             

지금까지, Cache-Control의 필요성과 동작원리, 제공된 툴, 그리고 마지막으로 iOS 적용 예시를 알아 보았습니다.

감사합니다.