서론
Jeongfisher는 메모리 캐시와 디스크 캐시를 사용합니다.
이번 포스팅에서는 두 개의 캐시를 구현하면서 고민한 내용을 적어보려고 해요.
특히 메모리 캐시를 구현할 때 기초적인 내용을 깊게 고민할 수 있었습니다.
메모리 캐시 구현 이유
메모리 캐시는 NSCache를 사용하는 대신 직접 구현했습니다.
메모리 캐시 구현 과정을 직접 고민하고 싶었기 때문입니다.
NSCache를 사용하면 물론 편하겠지만...
학습 측면에서는 직접 고민하는 게 더 의미있을 것이라 생각했어요.
그래서 직접 구현하기로 결심했습니다.
추가로 NSCache를 사용할 때 NS 타입을 사용하는 게 불편했습니다.
NSCache를 사용할 때마다 불만이었던 거 같습니다 ㅋㅋ
이왕 직접 만드는거 Swift 타입을 key로 사용할 수 있게 구현해보았습니다.
메모리 캐시 기본 로직
기술적 고민을 작성하기 전에 기본적인 로직을 말씀드리겠습니다.
초기화(init)
JFMemoryCache를 생성할 때 메모리 캐시 용량, 최대 캐시 데이터 크기, 캐시 정책을 입력 받습니다.
기본 값은 아래와 같습니다.
- 메모리 캐시 용량 : 8192MB(NSCache와 동일)
- 최대 캐시 데이터 크기 : 제한 없음
- 캐시 정책 : LRU
init을 할 때 프로젝트 크기에 따라 메모리 캐시 용량과 최대 데이터 크기를 조절하여 메모리 효율을 높일 수 있습니다.
데이터 저장
데이터 저장은 saveCache 메서드를 이용합니다.
로직은 다음과 같습니다.
- 캐싱된 데이터가 있는지 확인
- 캐싱 데이터가 없는 경우
- 남은 cache cost 체크
- cost가 부족하다면 새로운 데이터 추가가 가능할 때까지 노드 delete
- 링크드 리스트에 새로운 노드 추가
- 딕셔너리에 데이터 추가
- cache cost 추가
- 남은 cache cost 체크
- 캐싱 데이터가 있고, overwrite인 경우 (기본)
- cache cost에서 기존 데이터 cost 제거
- 남은 cache cost 체크
- cost가 부족하다면 새로운 데이터 추가가 가능할 때까지 노드 delete
- 링크드리스트의 노드 삭제 후 다시 추가
- 캐싱 데이터가 있고, overwrite를 하지 않는 경우
- saveError 발생
데이터 읽기
데이터는 getData 메서드로 읽을 수 있습니다.
- 캐싱된 데이터가 있는지 확인
- 데이터가 없다면 nil 반환
- 데이터가 있다면 링크드 리스트 노드 삭제 후 다시 추가
노드를 삭제 후 다시 추가하는 이유는 hit 주기로 노드를 재정렬하기 위해서입니다.
hit이 발생하면 노드 맨 앞으로 이동시켜서 cost가 부족할 때 삭제되지 않도록 구현했습니다.
기술적 고민
메모리 캐시를 구현하는 과정에서 고민한 내용들입니다.
개선점이 보인다면 꼭! 댓글로 말씀 부탁드립니다 ㅎㅎ
자료구조 선택
먼저 어떤 자료구조로 메모리 캐시를 구현해야할지 고민했습니다.
사용하는 자료구조는 NSCache를 많이 참고했습니다.
https://jeong9216.tistory.com/551에서 NSCache를 분석한 경험이 큰 도움이 되었어요.
배열과 링크드 리스트
데이터 관리 자료구조로 가장 먼저 생각나는건 배열(Array)이었어요.
하지만 배열은 비효율적이라는 걸 금새 깨달았습니다.
배열은 원소 재배치 오버헤드가 발생하기 때문입니다.
메모리 캐시에서는 Hit 데이터를 맨 앞으로 이동시킵니다. (LRU 기준)
이때 배열은 기존 원소를 모두 한 칸씩 뒤로 이동시켜야 합니다.
이 과정에서 원소 재배치 오버헤드가 발생하고, 시간 효율이 나빠집니다.
Hit 데이터를 맨 뒤로 보내도 동일합니다.
뒤에 넣는 경우에는 cost가 부족해졌을 때 앞의 원소를 삭제하므로 원소 재배치 오버헤드가 발생합니다.
이 문제를 해결하기 위해 배열이 아닌 링크드 리스트를 선택했습니다.
탐색은 느리지만 원소 재배치가 자유로운 자료구조죠.
또한 원소 삭제를 효율적으로 하기 위해 양방향 링크드 리스트로 구현했습니다.
단방향인 경우 맨 뒤의 원소를 삭제할 때 맨 앞부터 순차적으로 이동해야 합니다.
양방향 링크드 리스트는 tail을 이용해 바로 접근할 수 있기 때문에 매우 효율적이죠.
딕셔너리(Dictionary)
방금 말했듯이 링크드 리스트는 탐색이 느립니다.
메모리 캐시는 데이터를 빠르게 읽어야 하기 때문에 이는 심각한 문제였습니다.
그래서 딕셔너리를 도입하기로 결정했습니다.
딕셔너리를 사용하여 getData를 상수 시간복잡도로 완료할 수 있었습니다.
동시성 문제
딕셔너리를 사용하면서 한 가지 문제가 생겼습니다.
바로 동시성 문제입니다.
딕셔너리는 Thread safe 하지 않습니다.
그래서 같은 키에 여러 thread가 동시에 접근하면 런타임 에러가 발생합니다.
이를 해결하기 위해 두 가지 방법을 고민했습니다.
첫 번째는 DispatchQueue barrier를 사용하는 방법입니다.
결론부터 말씀드리면 DispatchQueue는 빠르게 기각되었습니다.
DispatchQueue를 사용하면 리턴이 있는 메서드에서 completionHandler를 사용해야 했기 때문입니다.
getData 메서드 등 리턴이 있는 메서드가 많았기 때문에 코드 복잡도가 높아질 것이라 판단하여 기각했습니다.
두 번째는 NSLock입니다.
클래식하면서 강력한 방법이고 Jeongfisher도 이를 사용해 동시성 문제를 해결했습니다.
NSLock을 사용할 때도 lock 범위를 고민했는데요.
이건 코드 리뷰를 통해 개선할 수 있었습니다.
처음에는 lock 효율을 위해 좁은 범위로 lock과 unlock을 수행했다면,
"lock은 안정성이 최우선이다"라는 리뷰 후에는 defer를 활용해 메서드 단위로 lock을 수행했습니다.
덕분에 딕셔너리를 thread safe하게 사용할 수 있게 되었습니다.
캐시 정책
캐시 정책은 빠르게 작성해보겠습니다 ㅎㅎ;
NSCache는 LRU, LFU를 혼합해서 사용합니다.
참조한 지 가장 오래된 객체 중에서 참조 횟수가 평균 참조 횟수보다 적은 객체를 삭제하는 방식입니다.
JFMemoryCache에서도 이를 채택할까 고민을 했는데요...
학습 목적이라면 단일 정책으로 직접 구현해보는게 좋지 않을까? 생각했습니다.
그래서 현재는 LRU, LFU를 단일 정책으로 구현했고,
NSCache처럼 혼합 정책과 Custom 정책을 지원하는게 목표입니다.
Custom 정책은 Bool 타입 반환 클로저를 전달 받아서 구현하려고 하는데 잘 될지는 모르겠군요 ㅎㅎ;;
디스크 캐시
디스크 캐시는 생각보다 단순했습니다.
FileManager 자체가 Thread Safe하므로 동시성 문제도 발생하지 않았구요.
그래서 디스크 캐시 개념에 좀 더 집중해서 "디스크 캐시의 장점을 극대화하려면 어떻게 해야할까?" 고민했습니다.
이런 고민을 다른 사람에게 말해보니 ETag를 활용해보는 걸 추천해주셔서 적용해보았습니다.
ETag를 활용하면 서버의 데이터가 변경되었는지 체크할 수 있습니다.
디스크 캐시는 파일 생성일을 기준으로 캐시 만료를 계산하고 있는데요.
ETag가 동일하다면 만료일을 갱신해서 디스크 캐시에 최대한 오래 보관할 수 있도록 구현했습니다.
물론 ETag를 지원하지 않거나 사용하지 않고 싶다면 옵션으로 비활성할 수도 있고요!
마무리
이렇게 메모리 캐시와 디스크 캐시를 구현하면서 고민한 내용을 작성해보았습니다.
익숙한 개념이었기 때문에 금방 끝나지 않을까? 생각했는데 역시 만만치 않았네요 ㅠㅠ;;
그래도 기초적인 내용을 고민할 수 있어서 소중한 경험이었습니다.
감사합니다.
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.