목차

  • 캐싱이란
  • 캐싱 유형
    • Local Caches
    • Remote Caches
  • 캐싱 전략
    • Cache Aside(lazy loading)
    • Write Through
    • Write Back
  • 데이터 제거 방식
    • expiration
    • eviction

 

캐싱이란

캐싱이란 자주 사용하는 데이터를 보다 빠르게 액세스 하기 위해, 캐시와 같이 가까운 장소에 임시로 저장해 두는 것을 말한다. cpu 코어와 메모리, 웹 클라이언트와 서버, 서버와 db 등 다양한 곳에서 캐싱 기술이 사용되고 있다.

 

만약 모든 데이터가 동일한 사용 빈도를 띈다면 캐싱이라는 기술이 큰 의미를 갖기 힘들었을 것이다. 하지만 소수의 데이터가 자주 사용되는 경향이 있기 때문에 캐싱이 큰 힘을 발휘하고 있다. 이를 캐시의 지역성이라고 하며 크게 시간 지역성과 공간 지역성으로 나뉜다.

  • 시간 지역성 : 최근에 접근한 데이터에 다시 접근하는 경향. ex) for문의 인덱스
  • 공간 지역성 : 최근에 접근한 데이터의 주변 공간에 다시 접근하는 경향. ex) 배열

실생활에서도 지역성을 쉽게 발견할 수 있다. 인스타그램을 예로 들면, 보통 소수의 사용자들이 많은 팔로워를 보유하고 있다. 또한 예전 게시물보다는 최근 게시물을 소비하는 빈도가 더욱 높기 때문에, 일부 게시물의 사용 빈도가 높다는 사실을 알 수 있다.

 

캐싱 유형

Local caches

로컬 캐시란 애플리케이션 내에 자주 사용되는 데이터를 저장하는 것을 말한다.

 

장점

  • 데이터 검색과 관련된 네트워크 트래픽이 제거된다.

단점

  • 각 서버 간의 로컬 캐시(db 행 데이터, 웹 콘텐츠, 세션 테이터 등)가 공유되지 않는다. 따라서 분산 환경에서 공유 데이터에 대해 문제를 야기할 수 있다.
  • 서버가 중단되면 캐시 데이터가 손실된다.

원격 캐시를 사용하면 로컬 캐시의 단점을 극복할 수 있다.

Remote caches

원격 캐시란 별도의 전용 인스턴스에 캐시 데이터를 저장하는 것을 말한다. 일반적으로 Redis나 Memcached와 같은 key/value 저장소로 구축한다.

 

장점

  • 요청의 평균 latency는 밀리초 단위 미만으로, 디스크 기반 db보다 훨씬 빠르다.
  • 서버 간에 캐시 공유가 가능하기 때문에 분산 환경에 적합하다.

딘점

  • 로컬 캐시보다 느리다.

네트워크 latency가 문제가 되는 경우에는 로컬 캐시와 함께 사용하는 방법을 적용하면 된다. 하지만 복잡하기 때문에 꼭 필요한 경우에만 사용을 고려한다.

 

캐싱 전략

Cache-Aside(Lazy Loading)

가장 일반적으로 사용하는 방식이며, 절차는 다음과 같다.

  1. db를 읽어야 하는 요청이 들어오는 경우, 가장 먼저 캐시에 데이터가 있는지 확인한다.
  2. 이용 가능한 데이터가 존재하면(cache hit) 캐시 데이터를 반환한다.
  3. 이용 가능한 데이터가 존재하지 않으면(cache miss) db를 통해 데이터를 조회한다. 그다음 캐시에 데이터를 저장하고, 데이터를 응답으로 반환한다.

 

장점

  • 애플리케이션이 실제로 요청하는 데이터만 캐싱하기 때문에, 비용 효율적으로 캐시를 활용할 수 있다.
  • 단순한 구현으로 즉각적인 성능 향상을 기대할 수 있다.

단점

  • cache miss가 발생해야 캐시에 데이터를 적재한다. 따라서 최초 요청에 대해 오버헤드가 존재한다.

 

Write-Through

cache miss가 발생한 뒤에 캐시에 적재하는 lazy loading과 달리, DB 변경이 발생하는 경우에 캐시도 함께 최신화하는 방식이다. 하지만 캐시 데이터가 없거나 만료되었을 수도 있기 때문에, 보통 lazy loading 전략과 함께 사용한다.

 

장점

  • 캐시와 db가 동기화되어 있기 때문에, 전반적인 애플리케이션 성능과 사용자 경험이 향상된다.
  • cache-aside에 비해 db 쿼리가 더 적게 수행된다.

단점

  • 자주 요청하지 않는 데이터도 캐싱된다. 따라서 캐시가 더 크고 비용이 많이 든다.

캐싱 전략을 효과적으로 사용하기 위해서는, write-through와 lazy loading 전략을 적절하게 사용해야 한다. 그리고 데이터에 만료 설정을 걸어주어야 한다.

 

Write-Back

데이터를 캐시에만 쓰고, 캐시의 데이터를 일정 주기로 DB에 업데이트하는 방식이다.

 

장점

  • 쓰기가 많은 경우 DB 부하를 줄일 수 있다.

단점

  • 캐시가 DB에 쓰기 전에 장애가 생기면 데이터 유실될 수 있다.

 

데이터 제거 방식(with redis)

expiration

데이터에 ttl을 설정하여 시간 단위로 캐시 데이터의 수명을 제어할 수 있다. 제한 시간을 초과하면 캐시에서 데이터를 삭제하고, 원본 데이터에서 데이터를 새로 가져온다.

참고로 레디스에서는 논리적으로 만료되더라도 메모리에 남아있는 경우가 있다. 다만 존재하지 않는 것으로 간주되며, 해당 메모리는 결국 회수되니 문제 될 건 없다. (참고 링크)

 

TTL을 적용하기 위해서는 다음의 두 가지를 고려한다. 몇 분, 몇 초 단위로 ttl을 적용하더라도, 적절한 ttl은 성능과 사용자 경험에 큰 이점을 준다.

  1. 기반 데이터가 변경되는 정도.
  2. 오래된 데이터가 반환될 수 있는 리스크.

eviction

eviction은 캐시 메모리가 꽉 차거나 maxmemory 설정보다 클 때 발생한다. 가장 마지막에 사용한 키 제거와 같이, 제거 정책을 설정할 수 있다. 절차는 다음과 같다.

  1. 클라이언트가 데이터를 추가하는 command를 실행한다.
  2. redis는 메모리 사용량을 확인하고, maxmemory 제한보다 크다면 정책에 따라 키를 제거한다.
  3. 새로운 command가 실행된다.

 

일반적으로 lru 정책을 가장 많이 사용하며, 정책의 종류는 다음과 같다.

  • noeviction: 메모리 제한에 도달하면 새로운 값들이 저장되지 않는다. 레플리케이션을 사용하는 경우 기본값이다.
  • allkeys-lru: 가장 마지막에 사용한 키를 제거한다.
  • allkeys-lfu: 가장 적게 사용한 키를 제거한다.
  • volatile-lru: expire가 걸려있는 키 중에서, 가장 마지막에 사용한 키를 제거한다.
  • volatile-lfu: expire가 걸려있는 키 중에서, 가장 적게 사용한 키를 제거한다.
  • allkeys-random: 키를 랜덤으로 제거한다.
  • volatile-random: expire가 걸려있는 키 중에서, 랜덤으로 제거한다.
  • volatile-ttl: expire가 걸려있는 키 중에서, ttl이 가장 짧은 키를 제거한다.

 

제거가 발생하는 경우는 일반적으로 확장이 필요하다는 신호이다. 하지만 의도적으로 정책에 따라 키를 관리하고 있다면 무시해도 좋다.

 

 

 

참고 자료

목 사용 팁

1. 비관리 의존성에만 목을 사용한다. 데이터베이스는 관리 의존성이므로 목을 사용하지 않는다.

 

2. 비관리 의존성을 처리하는 코드는 컨트롤러뿐이다. 따라서 단위 테스트에서는 목을 사용하지 않는다.

 

3 시스템의 끝에서 비관리 의존성과의 상호작용을 검증한다. 이를 래핑 하거나 추상화한 인터페이스를 목으로 처리하는 것보다 회귀 방지와 리팩터링 내성이 향상된다.

 

4. 시스템 끝에 있는 클래스에 대해서는 스파이가 목보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.

 

5. 모든 비관리 의존성에 하위 호환성이 동일한 수준으로 필요한 것은 아니다. 로깅과 같이 메시지의 정확한 구조가 중요하지 않고 메시지의 존재 여부와 전달하는 정보만 검증하면, 시스템의 끝에서 비관리 의존성과의 상호 작용을 검증하라는 지침을 무시할 수 있다. 

 

6. 테스트에서 사용된 목의 수는 상관 없다. 비관리 의존성의 수에 따라 달라질 뿐이다.

 

7. 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 작성하자. 기본 타입 대신 해당 어댑터를 목으로 처리한다.

들어가며

테스트할 로직 중간에 데이터베이스가 끼어있는 경우가 많다. 이번 장에서는 단위 테스트와 통합 테스트 각각에서 데이터베이스를 어떻게 다루면 좋을지 학습해 보자.

 

단위 테스트에서의 데이터베이스

먼저 프로세스 외부 의존성의 유형을 짚고 넘어가자. 모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.

  • 관리 의존성 : 외부 의존성이지만 애플리케이션을 통해서만 접근할 수 있다. 데이터베이스가 대표적인 예이며, 외부 시스템은 보통 애플리케이션에서 제공하는 api를 통해서 데이터베이스에 간접 접근한다.
  • 비관리 의존성 : 애플리케이션과의 상호 작용을 외부에서 볼 수 있다. 예를 들어 SMTP 서버와 메시지 버스 등이 있다.

 

 

 

다시 본론으로 돌아오자. 이전 글인 코드 유형과 테스트의 관계 글에서 언급했듯이, 단위 테스트는 도메인 도델과 알고리즘을 대상으로 하고 통합 테스트는 컨트롤러를 대상으로 한다. 만약 도메인 모델과 알고리즘에 데이터베이스라는 외부 의존성이 존재한다면, 리팩터링을 먼저 한 다음에 단위 테스트를 진행해야 한다.

 

물론 스텁을 사용하여 해결할 수는 있다. 하지만 db는 보통 관리 의존성이기 때문에 테스트가 구현 세부 사항에 의존하게 된다. 테스트 용이성과 가독성 측면에서도 좋지 않으므로 이를 지양하자. 예외적으로 db 또는 db의 일부가 비관리 의존성인 경우가 있다. 그럴 때는 목이나 스텁을 사용해도 무방하다.

 

통합 테스트에서의 데이터베이스

이메일이나 푸시와 같은 비관리 의존성은 목으로 대체하고 상호작용을 검증한다. 하지만 관리 의존성을 목으로 대체하면 리팩터링 내성이 저하되고 회귀 방지도 떨어진다. 따라서 데이터베이스를 그대로 테스트하는 것이 바람직하다.

 

데이터베이스는 실제 운영 환경과 동일하게 하는 게 좋다. 인메모리 데이터베이스는 기능적으로 일관성이 없기 때문에 회귀 방지 측면에서 좋지 않다.

 

쓰기는 위험성이 높기 때문에 철저히 테스트해야 한다. 읽기는 보통 위험성이 없기 때문에 복잡하거나 중요한 작업만 테스트하고, 나머지는 무시해도 된다.

 

리포지토리 테스트는 유지비가 높고 회귀 방지가 떨어지기 때문에 이점이 별로 없다. 직접 테스트하기보다는 포괄적인 통합 테스트 스위트의 일부로 취급하라.

'테스트' 카테고리의 다른 글

목 사용 팁  (0) 2023.09.18
코드 유형과 테스트의 관계  (0) 2023.09.17
단위 테스트의 세 가지 스타일  (0) 2023.09.17
테스트 대역(Test Double)이란?  (0) 2023.09.17
좋은 단위 테스트의 4대 요소  (0) 2023.09.17

+ Recent posts