MemoryCache를 사용한 .NET의 캐싱 | Steven Giesel

Caching in .NET with MemoryCache (steven-giesel.com)를 번역하였습니다.

이 블로그 게시물에서는 데이터베이스에서 항목을 '캐시’하는 방법에 대해 설명합니다. 애초에 왜 이렇게 해야 하는지, 그리고 이를 달성하는 방법에 대해 이야기하겠습니다.

또한 몇 가지 의미와 '캐시 무효화’가 무엇인지에 대해서도 설명합니다.

캐싱이란 무엇이며 캐싱을 하는 이유는 무엇인가요?

처음부터 시작하겠습니다. 캐싱을 하는 이유는 무엇일까요? 쉬운 대답은 애플리케이션의 성능을 향상시키기 위해서입니다. 캐싱이란 데이터를 메모리에 저장하여 더 빠르게 액세스할 수 있도록 하는 것입니다. 예를 들어, 데이터에 액세스하기 위해 일반적으로 유선을 통해 왕복해야 하는 데이터베이스가 있는 경우 RAM에서 개체를 검색하기만 하면 됩니다. 전반적인 목표는 성능과 확장성을 높이는 것입니다.

캐싱으로 유명한 redis와 같은 대형 프로젝트가 많이 있지만, 간단한 경우에는 종종 과장된 솔루션이 될 수 있습니다. 그래서 이번 글에서는 .NET을 위한 간단한 캐싱 솔루션인 IMemoryCache에 대해 살펴보겠습니다.

ASP.NET Core를 사용하는 경우 이미 IMemoryCache를 사용할 수 있습니다. 다른 경우에는 다음을 통해 패키지를 설치할 수 있습니다.

dotnet add package Microsoft.Extensions.Caching.Memory

그리고 종속성을 추가합니다.

services.AddMemoryCache();

이제 준비가 완료되었습니다. 다시 말하지만, ASP.NET Core의 경우 이러한 단계를 수행할 필요가 없습니다. 일반적인 흐름은 다음과 같습니다: 캐시에 항목을 요청하고 항목이 있으면 반환합니다. 그렇지 않은 경우 데이터베이스 또는 웹 API 호출과 같은 “실제” 스토리지 공급자로부터 항목을 검색한 다음 캐시에 저장합니다. 다음에 캐시에 항목을 요청할 때 다시 기본 제공업체로 이동하지 않고 캐시에서 직접 반환할 수 있습니다.

메모리 캐시는 기본적으로 Dictionary<TKey, TValue>입니다. 따라서 캐시 내부의 값에 고유하게 매핑되는 문자열과 같은 키를 정의합니다. 물론 조금 더 복잡하지만 지금은 아주 멋진 사전이라고 가정해 봅시다. 따라서 우리가 해야 할 한 가지 작업은 항목에 대한 고유 키를 찾는 것입니다. 데이터베이스에서 키로 블로그 게시물을 검색하는 예제를 보여드리겠습니다. 아이디는 완벽한 키가 됩니다!

사용법

쉬운 부분입니다. 인터페이스를 생성자 종속성으로 추가하기만 하면 됩니다.

public class BlogController : Controller
{
    private readonly IBlogRepository _blogRepository;
    private readonly IMemoryCache _memoryCache;

    public BlogController(IBlogRepository blogRepository, IMemoryCache memoryCache)
    {
        _blogRepository = blogRepository;
        _memoryCache = memoryCache;
    }
}

특정 아이디에 대한 블로그 게시물을 검색하는 경로가 있다면 어떤 모습일까요?

public async Task<IActionResult> GetBlogPost(int id)
{
    // Cache key
    var cacheKey = $"BlogPost_{id}";

    // Check if the cache contains the blog post
    if (!_memoryCache.TryGetValue(cacheKey, out BlogPost blogPost))
    {
        // Retrieve the blog post from the repository
        blogPost = await _blogRepository.GetBlogPostByIdAsync(id);

        // Save the blog post in the cache
        _memoryCache.Set(cacheKey, blogPost);
    }

    return Ok(blogPost);
}

완벽합니다! 그게 거의 전부입니다! 코드는 위의 시퀀스 다이어그램과 똑같습니다. 블로그 blogpost라는 단어와 주어진 아이디가 포함된 문자열로 키를 생성합니다. 전체 애플리케이션에 IMemoryCache 인스턴스는 하나뿐이므로 고유한 키가 중요하다는 점을 기억하세요! 여기서 한 가지 중요한 점은 현재 설정에서는 캐시 항목이 무기한 유효하다는 것입니다! 이는 오래된 데이터로 이어질 수 있습니다! 이 문제는 나중에 처리하겠습니다.

캐시 항목 삭제하기

하지만 항목을 무효화해야 하는 경우에는 어떻게 해야 할까요? 예를 들어 블로그 게시물을 업데이트하는 경우 캐시 항목도 지워야 사용자가 가장 최신의 상태를 볼 수 있습니다.

[HttpPost]
public async Task<IActionResult> UpdateBlogPost(BlogPost updatedBlogPost)
{
    // Update the blog post in the repository
    await _blogRepository.UpdateBlogPostAsync(updatedBlogPost);

    // Clear the cache entry for the updated blog post
    _memoryCache.Remove($"BlogPost_{updatedBlogPost.Id}");

    // Redirect to the updated blog post's page
    return Ok();
}

만료 기간

전달할 수 있는 메서드는 MemoryCacheEntryOptions입니다. 또한 캐시 항목이 얼마나 오래 유효한지 알 수 있는 SetSlidingExpiration이라는 메서드도 있습니다. 슬라이딩 만료 옆에는 절대 만료(SetAbsoluteExpiration을 통해)가 있습니다. 이 둘의 차이점은 무엇일까요?

슬라이딩 만료 기간:

슬라이딩 만료 기간에서 캐시 항목은 지정된 기간 동안 사용하지 않으면 만료됩니다. 항목에 액세스할 때마다 만료 시간이 재설정됩니다. 이 방식은 자주 액세스하는 항목이 캐시에 더 오래 남아 있어 성능이 향상되므로 유용합니다. 그러나 액세스 빈도가 낮은 항목은 캐시에서 제거되어 더 관련성이 높은 다른 데이터를 위한 메모리를 확보할 수 있습니다.

절대 만료 기간:

절대 만료 기간을 사용하면 캐시 항목은 액세스 여부에 관계없이 고정된 시점에 만료됩니다. 이 접근 방식은 데이터가 지정된 기간보다 오래 캐시에 남아 있지 않도록 보장하므로 주기적으로 변경되거나 업데이트 일정이 알려진 데이터를 처리할 때 유용합니다. 오래된 데이터를 제거하고 필요할 때 새로운 데이터로 교체하여 데이터 정확성을 유지하는 데 도움이 됩니다.

모든 데이터 제거

캐시의 전체 또는 일부를 한 번에 무효화하는 옵션도 있습니다. 이를 위해 CancellationToken을 활용할 수 있습니다. Cancel 을 호출하면 해당 토큰에 연결된 모든 항목이 무효화됩니다.

public class BlogController : Controller
{
    private readonly IBlogRepository _blogRepository;
    private readonly IMemoryCache _memoryCache;
    private CancellationToken resetToken = new();

그리고 토큰을 항목에 추가합니다.

public async Task<IActionResult> GetBlogPost(int id)
{
    // Cache key
    var cacheKey = $"BlogPost_{id}";

    // Check if the cache contains the blog post
    if (!_memoryCache.TryGetValue(cacheKey, out BlogPost blogPost))
    {
        // Retrieve the blog post from the repository
        blogPost = await _blogRepository.GetBlogPostByIdAsync(id);

       var cacheEntryOptions = new MemoryCacheEntryOptions().AddExpirationToken(new CancellationChangeToken(resetToken));

        // Save the blog post in the cache
        _memoryCache.Set(cacheKey, blogPost, cacheEntryOptions);
    }

    return Ok(blogPost);
}

이제 resetToken.Cancel을 호출하면 해당 토큰과 관련된 모든 항목이 취소됩니다. 이제 캐싱의 어려운 부분인 캐시 무효화를 살펴봅시다.

캐시 무효화

캐시 무효화는 캐시에서 오래된 데이터를 제거하거나 업데이트하여 애플리케이션이 정확한 최신 정보로 작동할 수 있도록 하는 프로세스입니다. 캐시 무효화의 복잡성은 데이터 일관성과 성능 개선 사이의 균형을 유지해야 하기 때문에 발생합니다.

“컴퓨터 과학에서 어려운 일은 캐시 무효화와 이름 지정 두 가지뿐이다.”*라는 필 칼튼의 유명한 명언은 캐시 무효화와 관련된 문제를 강조합니다. 캐싱 및 캐시 무효화의 주요 문제는 다음과 같습니다.

  • 데이터 일관성: 기본 데이터 소스에서 데이터가 업데이트되면 데이터 일관성을 유지하기 위해 캐시된 데이터를 업데이트하거나 제거하는 것이 중요합니다. 그렇지 않으면 애플리케이션이 오래된 데이터를 사용하게 되어 부정확한 결과나 예기치 않은 동작이 발생할 수 있습니다.
  • 캐시 무효화 전략: 애플리케이션마다 요구 사항이 다르기 때문에 올바른 캐시 무효화 전략을 선택하는 것이 중요합니다. 전략에는 시간 기반 만료(절대 또는 슬라이딩), 이벤트 기반 만료 또는 수동 무효화 등이 있습니다. 애플리케이션의 데이터 액세스 패턴과 업데이트 빈도에 대한 철저한 이해가 필요하므로 적절한 전략을 선택하는 것은 어려울 수 있습니다.
  • 캐시 쓰레싱: 캐시된 데이터가 자주 무효화되면 캐시가 새로운 데이터로 계속 채워졌다가 무효화되는 캐시 쓰레싱이 발생하여 캐싱의 이점이 감소할 수 있습니다. 최적의 성능을 위해서는 캐시 스래싱 시나리오를 식별하고 해결하는 것이 필수적입니다.

따라서 이러한 모든 요소 간의 균형을 맞추는 것은 어려울 수 있으며 코드가 매우 복잡해질 수 있습니다. 즉, 캐싱이 그만한 가치가 있는지 컨텍스트와 설정에서 확인해야 합니다.

지금 읽고 있는 것과 같은 블로그가 있다고 가정해 보겠습니다. 이 웹사이트에 대한 액세스의 99%는 읽기 전용입니다. 저는 제 블로그에서 해당 캐싱을 사용합니다. 캐시를 업데이트해야 하는 시나리오(기본적으로 블로그 글을 업데이트할 때)를 감독하는 것은 쉽습니다. 쓰기 시나리오가 많을수록 더 어려워 보입니다!

결론

캐싱은 자주 요청되는 데이터에 액세스하는 데 필요한 시간과 리소스를 줄여 애플리케이션 성능을 향상시키는 강력한 기술입니다. 그러나 캐싱의 이점을 최대한 활용하려면 데이터 일관성 및 올바른 전략 선택과 같은 캐시 무효화와 관련된 문제를 해결해야 합니다. 개발자는 애플리케이션의 데이터 액세스 패턴을 이해하고 캐싱 전략을 신중하게 설계함으로써 성능 개선과 데이터 정확성 사이의 균형을 유지하여 보다 효율적이고 응답성이 뛰어난 애플리케이션을 개발할 수 있습니다.


6개의 좋아요