비동기 작업 캐시 하기
들어가며
안녕하세요 Noah입니다.
SOPT라는 동아리에서 대학생들이 모여 만들게 된 앱이 공개된지 얼마되지 않았음에도 불구하고 감사하게도 많은 회원 수를 유치하게 되었습니다.
서비스 소개 페이지 : https://havit.app/
또 그만큼 돈으로 살 수 없는 소중한 피드백과, 사용자의 사용경험을 들어볼 수 있었는데요,
오늘은 사용자의 사용 경험을 높이기 위해 했던 경험 한 가지를 공유하고자 합니다.
내가 만든 앱을 누군가가 사용해 본다는 건 정말 특별한 경험이었습니다 ☺️
HAVIT 앱은 인터넷에서 찾은 유용한 웹페이지, 블로그, 기사, 이미지, 동영상 등을 저장하고, 필요할 때 언제든지 쉽게 찾아볼 수 있는 콘텐츠 아카이빙 서비스를 제공합니다.
어느날 받게 된 피드백
사용자는 원하는 콘텐츠를 저장하면서 해당 콘텐츠와 관련된 카테고리를 설정하여 자신만의 개인적인 콘텐츠 라이브러리를 구축할 수 있습니다.
먼저 앱을 처음 보시는 분들을 위해 "공유하기 프로세스"의 사이클을 보도록 하겠습니다.
공유하기 -> Share Extension 진입 -> 카테고리 선택 -> 콘텐츠 저장 (콘텐츠 미리보기 제공) -> 완료 버튼 터치 -> 저장
사용자는 위의 프로세스를 거쳐 콘텐츠를 저장하게 됩니다.
앱을 출시한 지 얼마되지 않아 사용자로부터 피드백을 받게 됩니다.
"콘텐츠가 가끔 중복으로 저장돼요!"
문제 원인 찾기
문제를 해결하기 위해 먼저 문제가 어디서 발생하는지 찾기 위한 방법을 고민하기 시작했습니다.
콘텐츠가 중복되는 현상은 네트워크 요청 과정 중 동시성 문제와 관련이 있다고 판단하여, instruments에서 Swift Concurrency profiling template을 이용해 콘텐츠를 생성하는 동작을 다양하게 시도해보았습니다.
콘텐츠를 생성하는 동작을 여러 조건으로 시도해보던 중 콘텐츠 저장 버튼을 여러 번 터치 시 아래의 레코드를 얻을 수 있었습니다.
생성된 Task들의 상태를 보니, 중복된 비동기 작업 호출로 인한 Continuation 상태의 task들이 작업이 완료되기 이전에 연속해서 생겨나는 것을 확인할 수 있었습니다.
사실,
"중복 요청 방지? Combine 혹은 RxSwift 의 throttle, debounce과 같은 operator로 입력 event를 제어하면 되는 것 아닌가?" 라는 생각을 가지고,
앱을 출시하기 이전에 throttle operator를 이용해 짧은 시간 내에 여러 번 발생하는 중복 요청에 대해 방지를 해놓았다고 생각했기 때문에 이러한 문제가 생기리라고 예상하지 못하였습니다.
Combine 혹은 RxSwift의 throttle, debounce를 사용하게 될 경우 아래의 문서의 내용에서 알 수 있듯 time interval에 의존하게 됩니다.
"Publishes either the most-recent or first element published by the upstream publisher in the specified time interval."
- Apple Documentation -
"Returns an Observable that emits the first and the latest item emitted by the source Observable during sequential time windows of a specified duration."
- ReactiveX / RxSwift -
하지만 네트워크 요청과 같은 비동기 작업은 시간(time interval)에 의존하는 작업이 아닙니다.
따라서 throttle과 같이 특정 time interval, duration에 의존하여 입력 event를 제어하는 방법은 제외해야겠다고 판단하였습니다.
따라서 시간에 의존하지 않고, 비동기 작업에 대한 중복 요청을 방지할 수 있는 방법을 생각해 보기 시작했습니다.
비동기 작업에 관하여
그렇다면 먼저 비동기 작업은 뭘까요?
“비동기 작업”은 나중에 알 수 없는 시간에 완료될 수 있는 작업입니다.
그렇기 때문에
- 해당 비동기 코드는 실행과 동시에 바로 완료되는 것도 아니고,
- 호출 스레드의 코드 실행을 막지도 않습니다 (non-block)
네트워크 작업은 비동기로 처리하는 대표적인 작업입니다.
그렇다면 왜 대부분 네트워크 작업을 비동기로 처리해야 할까요?
먼저 메모리 계층구조를 살펴봅시다.
메모리의 계층구조의 위에 있으면 있을수록 명령을 처리하는 CPU와 가까우며, 그만큼 데이터에 접근하는 속도 역시 빠릅니다. 반대로 계층구조의 밑으로 가면 갈수록 명령을 처리하는 CPU와 멀어지며, 그만큼 데이터에 접근하는 속도 역시 느려집니다.
네트워크를 통해 어떠한 데이터를 가져오거나 보내는 것은 현재 컴퓨터(로컬 디바이스)에 있는 데이터가 아닌, 네트워크 통신을 통해 다른 컴퓨터에 있는 데이터를 가져오는 것이므로 그만큼 데이터에 접근하는 속도 역시 디바이스 내에 있는 데이터에 접근하는 것보다는 느릴 것입니다.
만약 네트워크 통신을 통해 가져온 데이터를 이용해서 작업을 하는 코드를 메인 스레드에서 동기적으로 구성한다면 Remote Storage에 있는 데이터를 가져올 때까지 사용자의 인터렉션에 프로그램이 반응하지 못하게 될 것입니다. 그리고 이는 좋지 못한 사용자 경험으로 이어질 것입니다.
"100 ms is the threshold for delays in direct user interaction. If a delay in user interaction becomes longer than 100 ms, it starts to become noticeable and causes a hang. A shorter delay is rarely noticeable."
- Apple Developer Documentation
Apple의 문서에 따르면 100ms보다 지연시간이 길어지면 눈에 띄기 시작하며 버벅임이 발생한다고 합니다.
좋은 사용자 경험을 제공하기 위해선 메인 스레드에서 동기적으로 작성하는 함수의 경우 최대 100ms 이내에 실행되어야 한다는 중요한 힌트를 위 문서를 통해 얻을 수 있습니다.
따라서 네트워크 요청 작업은 100ms 이내에 실행될 것이라는 보장이 없기 때문에 (사용자 네트워크 환경, 원격 서버 환경과 같은 여러 불확실한 환경적 요인 포함) 비동기적으로 처리하여 요청에 대한 응답이 올 때까지 동기적으로 기다리는 것이 아닌, 다른 작업을 수행할 수 있도록 해주어야 합니다.
앞서 말했듯 네트워크 요청과 같은 비동기 작업은 시간(time interval)에 의존하는 작업이 아닙니다.
알 수 없는 시간에 완료될 수 있는 작업이기 때문에 이를 비동기적으로 처리하여
메인 스레드를 block 하지 않고, 계속해서 사용자와 인터렉션 할 수 있게 해주어야 합니다.
따라서 위의 공유하기 프로세스 사이클에서 보았던 "콘텐츠 저장 "네트워크 요청 작업 역시 비동기 작업으로 구성하게 되었습니다.
문제 해결 아이디어
다시 돌아와서 현재 우리는 "콘텐츠가 가끔 중복으로 저장돼요!"라는 문제를 해결하려고 하고 있습니다.
이 문제는 시간에 의존하지 않는 작업인 비동기 작업을 throttle과 같이 특정 time interval, duration에 의존하여 입력 event를 제어하는 operator를 사용했기 때문에 발생했습니다.
그렇다면 어떻게 해결할 수 있을까요?
처음에 생각했던 방식은 flag 변수를 두고 비동기 작업이 완료될 때까지 새로운 비동기 작업 요청을 막는 것이었습니다.
하지만 조금 더 생각해 보면 변수는 스레드 세이프하지 않습니다.
만약 A 스레드에서 비동기 작업을 요청한 뒤에 B 스레드에서 비동기 작업을 요청한다면 flag 변수라는 공유 데이터에 접근하는 상황에서 race condition이 발생해 중복된 비동기 작업이 요청될 수 있습니다.
물론 flag 변수에 접근하는 코드 역시 mutual exclusion을 보장하기 위해 NSLock, Serial Queue등 여러 동기화 방법을 사용하여 보완할 수 있지만 flag 변수라는 관리 포인트가 늘어나 복잡도가 올라갈 것 같습니다.
이러한 복잡도를 해결하기 위해 캐시를 고려하기 시작했습니다.
캐시는 동일한 요청에 대해 이전에 요청한 결과를 재사용함으로써 중복 요청을 방지하는 문제를 해결하기 위해 필요한 특성을 가지고 있습니다.
또한 부가적으로 대부분의 캐싱 시스템은 내부적으로 동기화 문제를 처리하는 메커니즘이 구현되어 있어 개발자가 직접 동기화 로직을 신경 쓸 필요가 없게 되어 앞서 전술한 flag 변수를 사용했을 때 스레드 동기화로 인한 복잡도가 올라가는 것을 피할 수 있습니다.
혹시 몰라 Swift Foundation framework의 NSCache 구현 부를 살펴본 결과 NSLock을 이용한 동기화 메커니즘이 구현되어 있음을 확인할 수 있었습니다.
따라서 비동기 작업을 중복으로 요청하지 않도록 하게 하기 위해 캐시의 특성을 이용하여 비동기 작업의 단위인 Swift Concurrency의 Task 를 캐시 하여 문제를 해결하는 것으로 결정하게 되었습니다.
이제 콘텐츠를 저장하는 요청에 대한 작업을 캐시 하여 문제를 해결해 봅시다.
비동기 작업 캐시 하기
요구사항은 다음과 같습니다.
- 콘텐츠를 저장하는 작업이 진행 중일 경우 캐시 된 작업을 반환한다.
- 콘텐츠를 저장하는 작업이 완료되었을 경우 완료된 결과를 반환한다.
위의 요구사항으로부터 두 가지의 상태를 가진다고 가정할 수 있습니다.
- 작업이 진행 중인 상태
- 작업이 완료된 상태
Swift의 enum은 위의 상태를 표현하기에 자연스러운 것 같습니다.
그렇다면 작업은 어떻게 표현할 수 있을까요?
Swift Concurrency의 Task는 비동기적으로 실행할 수 있는 작업의 단위를 의미합니다.
Task와 enum을 이용해 위 내용을 코드로 바꿔봅시다.
작업이 요청(request)되었지만, 아직 완료되지 않은 Task의 경우 중복 네트워크 요청 방지를 위해 inProgress 케이스를 사용하며, 작업이 완료된 경우 ready 케이스를 사용할 예정입니다.
이제 위 enum을 캐시 해보겠습니다.
캐시 항목이 영속성을 갖지 않기 원하므로 디스크 캐시가 아닌 메모리 캐시로 구현하기 위해 NSCache 를 이용해 구현해 보도록 하겠습니다.
위 선언 부를 보면 알 수 있듯 NSCache는 reference type만을 저장할 수 있기 때문에 enum을 캐시 할 수 없습니다.
따라서 enum을 보유하는 클래스를 만들어 이 클래스의 인스턴스를 캐시에 삽입하도록 하겠습니다.
final 키워드와 모든 프로퍼티를 let으로 선언하여, 스레드 간에 상태의 변경 없이 해당 인스턴스를 안전하게 전달할 수 있습니다.
Task를 캐시하는데 필요한 타입을 만들어 보겠습니다.
Swift의 서브스크립트를 이용해 복잡도를 숨기고, 캐시에 접근하는 코드를 간결하게 만들어 보도록 하겠습니다.
세터(setter)에서 만약 값이 nil이 들어온다면, 해당 key에 대한 캐시를 제거해 딕셔너리와 비슷한 동작을 하도록 합니다.
NSCache는 스레드 세이프 합니다.
이말인즉슨, 여러 스레드에서 데이터에 액세스해도 race condition이 발생하지 않습니다.
그러나 여러 스레드에서 접근할 때 비동기 작업 요청을 한 번만 실행하도록 해야 합니다.
하지만 NSCache의 스레드 세이프는 이를 보장하기에 충분하지 않습니다.
멀티 스레드 코드의 일반적인 문제는 예측할 수 없는 코드의 실행순서입니다.
첫 번째 요청에 대해 비동기 작업을 대기하면 이전 작업이 완료되기 전에 새 요청이 실행됩니다.
우리가 하려고 하는 바는 다른 네트워크 요청을 시작하지 않고, 이미 진행 중인 요청 작업을 기다리는 것입니다.
actor type을 사용해 이 문제를 해결해 보도록 하겠습니다.
actor type을 사용하면 동시 접근하는 것으로부터 캐시를 보호할 수 있습니다.
하지만 단순히 actor type을 사용하는 것 역시 문제를 해결할 수 없습니다.
actor는 가변상태에 대한 독점적인 액세스를 보장하는데 왜 문제를 해결할 수 없을까요?
바로 actor reenterancy와 관련된 특성 때문인데요, actor는 actor의 Mailbox에서 한번에 하나의 메시지를 처리합니다. 하지만, actor는 해당 메시지를 전부 완료 하는 것을 보장하지는 않습니다.
await 함수를 호출하면 실행이 잠재적으로 suspend되고, actor는 비동기 작업이 완료되기 전에 새로운 함수를 호출 할 수 있게 됩니다.
이러한 actor reentrerancy라는 특성이 현재 작업에서는 어떤 문제를 불러일으킬 수 있을까요?
actor내부에서 특정 URL에 대한 비동기 작업 요청을 한 후 await로 인한 잠재적인 suspend상태에서(작업이 완료되지 않은 상태) 동일한 URL에 대해 비동기작업을 actor에 하게되면 캐시에는 아직 해당 값이 기록되어 있지 않으므로 중복으로 비동기 작업을 요청하는 결과를 낳게 될 것 입니다.
따라서 캐시에 값이 있는지 확인하고, 요청 작업 실행을 시작할 때 캐시된 값을 찾아 추가 비동기 작업 요청을 하지 않도록하여 문제를 해결해보겠습니다.
테스트 코드 작성 및 결과 검증
이제 요구사항대로 제대로 작동하는지 테스트 코드를 통해 검증해 보도록 하겠습니다.
우리의 요구사항은 비동기 작업을 동시에 두 개를 요청했을 때 작업이 한 번만 이루어져야 합니다.
이 요구사항을 토대로 테스트 코드를 작성해 보도록 하겠습니다.
테스트에 통과했습니다!
다음은 instruments를 이용해 앞서 살펴본 바와 같이 다시 한번 문제 상황을 재연해 문제를 해결했는지 확인해보도록 하겠습니다.
instruments를 이용해 검증한 결과 역시 비동기 작업을 중복해서 요청을 하고있지 않다는 것을 확인할 수 있었습니다.
이로써 검증까지 마쳤습니다.
문제를 해결하며 고민했던 내용들이 여러분들에게 도움이 되는 글이 되었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다.
아직 모르는 것이 많고 알아가는 과정입니다. 잘못된 것이 있다면 댓글로 남겨주신다면 감사하겠습니다! 😊