.NET에서 캐싱

이 문서에서는 다양한 캐싱 메커니즘에 대해 알아봅니다. 캐싱은 중간 계층에 데이터를 저장하여 후속 데이터 검색을 더 빠르게 만드는 동작입니다. 개념적으로 캐싱은 성능 최적화 전략 및 디자인 고려 사항에 속합니다. 캐싱은 자주 변경되지 않거나 검색 비용이 많이 드는 데이터를 쉽게 사용할 수 있도록 하여 앱 성능을 크게 향상시킬 수 있습니다. 이 문서에서는 두 가지 기본 형식의 캐싱을 소개하고 그 두 가지 캐싱 모두에 대한 샘플 소스 코드를 제공합니다.

Important

.NET에는 두 개의 MemoryCache 클래스가 있는데, 하나는 System.Runtime.Caching 네임스페이스에 있고 다른 하나는 Microsoft.Extensions.Caching 네임스페이스에 있습니다.

이 문서에서는 캐싱에 중점을 두지만 System.Runtime.Caching NuGet 패키지는 포함하지 않습니다. MemoryCache에 대한 모든 참조는 Microsoft.Extensions.Caching 네임스페이스 내에 있습니다.

모든 Microsoft.Extensions.* 패키지에는 DI(종속성 주입)가 준비되어 있으며 IMemoryCacheIDistributedCache 인터페이스를 모두 서비스로 사용할 수 있습니다.

메모리 내 캐싱

이 섹션에서는 Microsoft.Extensions.Caching.Memory 패키지에 대해 알아봅니다. IMemoryCache의 현재 구현은 ConcurrentDictionary<TKey,TValue> 주변의 래퍼이며 기능이 풍부한 API를 노출합니다. 캐시 내의 항목은 ICacheEntry로 표시되며 모든 object일 수 있습니다. 메모리 내 캐시 솔루션은 캐시된 모든 데이터가 앱 프로세스의 메모리를 임대하는 단일 서버에서 실행되는 앱에 적합합니다.

다중 서버 캐싱 시나리오의 경우 메모리 내 캐싱의 대안으로 분산 캐싱 접근 방식을 고려합니다.

메모리 내 캐싱 API

캐시의 소비자는 슬라이딩 및 절대 만료를 모두 제어할 수 있습니다.

만료를 설정하면 만료 시간 할당 내에 액세스하지 않을 경우 캐시의 항목이 제거됩니다. 소비자는 MemoryCacheEntryOptions를 통해 캐시 항목을 제어하는 추가 옵션을 사용할 수 있습니다. 각 ICacheEntryMemoryCacheEntryOptions와 쌍을 이루어 IChangeToken으로 만료 제거 기능을 표시하고 CacheItemPriority로 우선 순위 설정을 표시하며 ICacheEntry.Size를 제어합니다. 다음 확장 메서드를 고려합니다.

메모리 내 캐시 예제

기본 IMemoryCache 구현을 사용하려면 AddMemoryCache 확장 메서드를 호출하여 DI와 함께 필요한 모든 서비스를 등록합니다. 다음 코드 샘플에서는 제네릭 호스트를 사용하여 DI 기능을 노출합니다.

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

.NET 워크로드에 따라 IMemoryCache(예: 생성자 주입)에 다르게 액세스할 수 있습니다. 이 샘플에서는 host에서 IServiceProvider 인스턴스를 사용하고 일반 GetRequiredService<T>(IServiceProvider) 확장 메서드를 호출합니다.

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

메모리 내 캐싱 서비스를 등록하고 DI를 통해 확인하면 캐싱을 시작할 준비가 된 것입니다. 이 샘플에서는 영어 알파벳 'A'에서 'Z'까지 문자를 반복합니다. record AlphabetLetter 형식은 문자에 대한 참조를 갖고 있으며, 메시지를 생성합니다.

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

file 액세스 한정자는 AlphabetLetter 형식에서 사용되며, Program.cs 파일 내에서 정의되고 액세스됩니다. 자세한 내용은 파일(C# 참조)을 참조하세요. 전체 소스 코드를 보려면 Program.cs 섹션을 참조하세요.

샘플에는 알파벳 문자를 반복하는 도우미 함수가 포함되어 있습니다.

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

위의 C# 코드에서:

  • Func<char, Task> asyncFunc는 현재 letter를 전달하면서 각 반복에서 대기합니다.
  • 모든 문자가 처리되면 콘솔에 빈 줄이 기록됩니다.

캐시에 항목을 추가하려면 Create 또는 Set API 중 하나를 호출합니다.

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

위의 C# 코드에서:

  • addLettersToCacheTask 변수가 IterateAlphabetAsync에 위임되고 대기합니다.
  • Func<char, Task> asyncFunc는 람다 식을 사용하여 인수 처리됩니다.
  • MemoryCacheEntryOptions는 현재를 기준으로 한 절대 만료를 사용하여 인스턴스화됩니다.
  • 사후 제거 콜백이 등록됩니다.
  • AlphabetLetter 개체가 인스턴스화되고 letteroptions와 함께 Set으로 전달됩니다.
  • 문자는 콘솔에 캐시되는 것으로 기록됩니다.
  • 마지막으로 Task.Delay가 반환됩니다.

알파벳의 각 문자에 대해 캐시 항목은 만료 및 제거 후 콜백으로 작성됩니다.

제거 후 콜백은 제거된 값의 세부 정보를 콘솔에 씁니다.

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

이제 캐시가 채워졌으므로 IterateAlphabetAsync에 대한 다른 호출이 대기되지만 이번에는 IMemoryCache.TryGetValue을 호출합니다.

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

cacheletter 키가 포함되어 있고 valueAlphabetLetter의 인스턴스인 경우, 캐시는 콘솔에 기록됩니다. letter 키가 캐시에 없을 때 캐시는 제거되었으며 제거 후 콜백이 호출되었습니다.

추가 확장 메서드

IMemoryCache는 비동기 GetOrCreateAsync를 포함해 많은 편리성 기반 확장 메서드가 함께 제공됩니다.

모든 요소 결합

전체 샘플 앱 소스 코드는 최상위 프로그램이며 다음의 두 가지 NuGet 패키지가 필요합니다.

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

const int MillisecondsDelayAfterAdd = 50;
const int MillisecondsAbsoluteExpiration = 750;

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

await host.RunAsync();

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

캐시된 항목의 만료 및 제거에 대한 동작 변경 사항을 관찰하려면 MillisecondsDelayAfterAddMillisecondsAbsoluteExpiration 값을 자유롭게 조정하세요. 다음은 이 코드를 실행한 샘플 출력입니다. .NET 이벤트의 비결정적 특성으로 인해 출력이 다를 수 있습니다.

A was cached.
B was cached.
C was cached.
D was cached.
E was cached.
F was cached.
G was cached.
H was cached.
I was cached.
J was cached.
K was cached.
L was cached.
M was cached.
N was cached.
O was cached.
P was cached.
Q was cached.
R was cached.
S was cached.
T was cached.
U was cached.
V was cached.
W was cached.
X was cached.
Y was cached.
Z was cached.

A was evicted for Expired.
C was evicted for Expired.
B was evicted for Expired.
E was evicted for Expired.
D was evicted for Expired.
F was evicted for Expired.
H was evicted for Expired.
K was evicted for Expired.
L was evicted for Expired.
J was evicted for Expired.
G was evicted for Expired.
M was evicted for Expired.
N was evicted for Expired.
I was evicted for Expired.
P was evicted for Expired.
R was evicted for Expired.
O was evicted for Expired.
Q was evicted for Expired.
S is still in cache. The 'S' character is the 19 letter in the English alphabet.
T is still in cache. The 'T' character is the 20 letter in the English alphabet.
U is still in cache. The 'U' character is the 21 letter in the English alphabet.
V is still in cache. The 'V' character is the 22 letter in the English alphabet.
W is still in cache. The 'W' character is the 23 letter in the English alphabet.
X is still in cache. The 'X' character is the 24 letter in the English alphabet.
Y is still in cache. The 'Y' character is the 25 letter in the English alphabet.
Z is still in cache. The 'Z' character is the 26 letter in the English alphabet.

절대 만료(MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow)가 설정되므로 캐시된 모든 항목이 결국 제거됩니다.

작업자 서비스 캐싱

데이터를 캐시하는 일반적인 전략 중 하나는 사용 중인 데이터 서비스와는 별개로 캐시를 업데이트하는 것입니다. BackgroundService는 다른 애플리케이션 코드와는 별개로(또는 백그라운드에서) 실행되기 때문에 작업자 서비스 템플릿은 좋은 예에 속합니다. IHostedService의 구현을 호스팅하는 애플리케이션이 실행되기 시작하면 해당 구현(이 경우 BackgroundService 또는 ‘작업자’)은 동일한 프로세스에서 실행되기 시작합니다. 이러한 호스트된 서비스는 AddHostedService<THostedService>(IServiceCollection) 확장 메서드를 통해 DI에 싱글톤으로 등록됩니다. 그 밖의 서비스는 서비스 수명에 관계없이 DI에 등록할 수 있습니다.

Important

서비스 수명을 이해하는 것은 매우 중요합니다. 모든 메모리 내 캐싱 서비스를 등록하기 위해 AddMemoryCache를 호출하면 서비스가 싱글톤으로 등록됩니다.

사진 서비스 시나리오

HTTP를 통해 액세스할 수 있는 타사 API를 사용하는 사진 서비스를 개발하고 있다고 가정합니다. 이 사진 데이터는 자주 변경되지 않지만 많은 데이터가 있습니다. 각 사진에는 다음과 같이 간단한 record가 표시됩니다.

namespace CachingExamples.Memory;

public readonly record struct Photo(
    int AlbumId,
    int Id,
    string Title,
    string Url,
    string ThumbnailUrl);

다음 예제에서는 DI에 등록되는 여러 서비스를 확인할 수 있습니다. 각 서비스에는 단일 책임이 있습니다.

using CachingExamples.Memory;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<CacheWorker>();
builder.Services.AddHostedService<CacheWorker>();
builder.Services.AddScoped<PhotoService>();
builder.Services.AddSingleton(typeof(CacheSignal<>));

using IHost host = builder.Build();

await host.StartAsync();

위의 C# 코드에서:

PhotoService는 지정된 조건(또는 filter)과 일치하는 사진을 얻는 역할을 담당합니다.

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class PhotoService(
        IMemoryCache cache,
        CacheSignal<Photo> cacheSignal,
        ILogger<PhotoService> logger)
{
    public async IAsyncEnumerable<Photo> GetPhotosAsync(Func<Photo, bool>? filter = default)
    {
        try
        {
            await cacheSignal.WaitAsync();

            Photo[] photos =
                (await cache.GetOrCreateAsync(
                    "Photos", _ =>
                    {
                        logger.LogWarning("This should never happen!");

                        return Task.FromResult(Array.Empty<Photo>());
                    }))!;

            // If no filter is provided, use a pass-thru.
            filter ??= _ => true;

            foreach (Photo photo in photos)
            {
                if (!default(Photo).Equals(photo) && filter(photo))
                {
                    yield return photo;
                }
            }
        }
        finally
        {
            cacheSignal.Release();
        }
    }
}

위의 C# 코드에서:

  • 생성자에는 IMemoryCache, CacheSignal<Photo>ILogger가 필요합니다.
  • GetPhotosAsync 메서드는 다음 작업을 수행합니다.
    • Func<Photo, bool> filter 매개 변수를 정의하고 IAsyncEnumerable<Photo>를 반환합니다.
    • _cacheSignal.WaitAsync()를 호출하여 해제될 때까지 기다립니다. 이렇게 하면 캐시에 액세스하기 전에 캐시가 채워집니다.
    • _cache.GetOrCreateAsync()를 호출하여 캐시에 있는 모든 사진을 비동기적으로 받습니다.
    • factory 인수는 경고를 기록하고 빈 사진 배열을 반환합니다. 이렇게 하면 안 됩니다.
    • 캐시의 각 사진이 yield return을 통해 반복, 필터링 및 구체화됩니다.
    • 마지막으로 캐시 신호가 다시 설정됩니다.

이 서비스의 소비자는 GetPhotosAsync 메서드를 자유롭게 호출하고 그에 따라 사진을 처리할 수 있습니다. 캐시에 사진이 포함되어 있기 때문에 HttpClient는 필요하지 않습니다.

비동기 신호는 일반 형식의 제한된 싱글톤 내에서 캡슐화된 SemaphoreSlim 인스턴스를 기반으로 합니다. CacheSignal<T>SemaphoreSlim의 인스턴스를 사용합니다.

namespace CachingExamples.Memory;

public sealed class CacheSignal<T>
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    /// <summary>
    /// Exposes a <see cref="Task"/> that represents the asynchronous wait operation.
    /// When signaled (consumer calls <see cref="Release"/>), the 
    /// <see cref="Task.Status"/> is set as <see cref="TaskStatus.RanToCompletion"/>.
    /// </summary>
    public Task WaitAsync() => _semaphore.WaitAsync();

    /// <summary>
    /// Exposes the ability to signal the release of the <see cref="WaitAsync"/>'s operation.
    /// Callers who were waiting, will be able to continue.
    /// </summary>
    public void Release() => _semaphore.Release();
}

위의 C# 코드에서 데코레이터 패턴은 SemaphoreSlim의 인스턴스를 래핑하는 데 사용됩니다. CacheSignal<T>는 싱글톤으로 등록되기 때문에 제네릭 형식(이 경우 Photo)이 적용된 모든 서비스 수명에 걸쳐 사용할 수 있습니다. 캐시의 시드 신호를 전달하는 역할을 담당합니다.

CacheWorkerBackgroundService의 하위 클래스입니다.

using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class CacheWorker(
    ILogger<CacheWorker> logger,
    HttpClient httpClient,
    CacheSignal<Photo> cacheSignal,
    IMemoryCache cache) : BackgroundService
{
    private readonly TimeSpan _updateInterval = TimeSpan.FromHours(3);

    private bool _isCacheInitialized = false;

    private const string Url = "https://jsonplaceholder.typicode.com/photos";

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await cacheSignal.WaitAsync();
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Updating cache.");

            try
            {
                Photo[]? photos =
                    await httpClient.GetFromJsonAsync<Photo[]>(
                        Url, stoppingToken);

                if (photos is { Length: > 0 })
                {
                    cache.Set("Photos", photos);
                    logger.LogInformation(
                        "Cache updated with {Count:#,#} photos.", photos.Length);
                }
                else
                {
                    logger.LogWarning(
                        "Unable to fetch photos to update cache.");
                }
            }
            finally
            {
                if (!_isCacheInitialized)
                {
                    cacheSignal.Release();
                    _isCacheInitialized = true;
                }
            }

            try
            {
                logger.LogInformation(
                    "Will attempt to update the cache in {Hours} hours from now.",
                    _updateInterval.Hours);

                await Task.Delay(_updateInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                logger.LogWarning("Cancellation acknowledged: shutting down.");
                break;
            }
        }
    }
}

위의 C# 코드에서:

  • 생성자에는 ILogger, HttpClientIMemoryCache가 필요합니다.
  • _updateInterval은 3시간 동안 정의됩니다.
  • ExecuteAsync 메서드는 다음 작업을 수행합니다.
    • 앱이 실행되는 동안 반복됩니다.
    • "https://jsonplaceholder.typicode.com/photos"에 HTTP 요청을 보내고 응답을 Photo 개체의 배열로 매핑합니다.
    • 사진 배열은 "Photos" 키 아래의 IMemoryCache에 배치됩니다.
    • _cacheSignal.Release()가 호출되어 신호를 기다리고 있던 모든 소비자를 해제합니다.
    • 업데이트 간격이 지정된 경우 Task.Delay에 대한 호출은 대기됩니다.
    • 3시간 동안 지연된 후 캐시가 다시 업데이트됩니다.

동일한 프로세스의 소비자는 IMemoryCache에 사진을 요청할 수 있지만 CacheWorker는 캐시 업데이트를 담당합니다.

분산 캐싱

일부 시나리오에서는 여러 앱 서버의 경우와 같이 분산 캐시가 필요합니다. 분산 캐시는 메모리 내 캐싱 방법보다 더 높은 확장을 지원합니다. 분산 캐시를 사용하면 캐시 메모리가 외부 프로세스로 오프로드되며 다만 추가 네트워크 I/O가 필요하고 약간의 대기 시간이 (명목인 경우에도) 발생합니다.

분산 캐싱 추상화는 Microsoft.Extensions.Caching.Memory NuGet 패키지의 일부에 속하며, AddDistributedMemoryCache 확장 메서드도 있습니다.

주의

AddDistributedMemoryCache는 개발 및/또는 테스트 시나리오에서만 사용해야 하며 실행 가능한 프로덕션 구현에 속하지 않습니다.

다음 패키지에서 사용 가능한 IDistributedCache의 구현을 고려하세요.

분산 캐싱 API

분산 캐싱 API는 메모리 내 캐싱 API보다 약간 더 단순합니다. 키-값 쌍은 약간 더 기본적인 형식입니다. 메모리 내 캐싱 키는 object를 기반으로 하는 반면 분산 키는 string에 속합니다. 메모리 내 캐싱을 사용할 경우 값은 강력한 형식의 일반 값이 될 수 있지만 분산된 캐싱의 값은 byte[]로 유지됩니다. 다양한 구현이 강력한 형식의 일반 값을 노출하지 않는 것은 아니며 다만 구현의 세부 정보를 나타냅니다.

값 만들기

분산 캐시에서 값을 만들려면 set API 중 하나를 호출합니다.

메모리 내 캐시 예제의 AlphabetLetter 레코드를 사용하여 개체를 JSON으로 직렬화한 다음 string을 다음과 같이 byte[]로 인코딩할 수 있습니다.

DistributedCacheEntryOptions options = new()
{
    AbsoluteExpirationRelativeToNow =
        TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
};

AlphabetLetter alphabetLetter = new(letter);
string json = JsonSerializer.Serialize(alphabetLetter);
byte[] bytes = Encoding.UTF8.GetBytes(json);

await cache.SetAsync(letter.ToString(), bytes, options);

메모리 내 캐싱과 마찬가지로 캐시 항목은 캐시 내에서 존재를 미세 조정하는 데 도움이 되는 옵션을 포함할 수 있습니다(이 경우 DistributedCacheEntryOptions).

확장 메서드 만들기

값을 만들기 위한 다양한 편의 기반 확장 메서드가 있습니다 .이 메서드를 통해 개체의 string 표현을 byte[]로 인코딩하는 것을 방지할 수 있습니다.

값 읽기

분산 캐시에서 값을 읽으려면 get API 중 하나를 호출합니다.

AlphabetLetter? alphabetLetter = null;
byte[]? bytes = await cache.GetAsync(letter.ToString());
if (bytes is { Length: > 0 })
{
    string json = Encoding.UTF8.GetString(bytes);
    alphabetLetter = JsonSerializer.Deserialize<AlphabetLetter>(json);
}

캐시 항목을 캐시에서 읽은 후에는 byte[]에서 UTF8로 인코딩된 string 표현을 가져올 수 있습니다.

확장 메서드 읽기

값을 읽기 위한 다양한 편의 기반 확장 메서드가 있습니다 .이 메서드는 byte[]를 개체의 string 표현으로 디코딩하는 것을 방지하는 데 도움이 됩니다.

값 업데이트

단일 API 호출을 사용하여 분산 캐시의 값을 업데이트하는 방법은 없지만, 새로 고침 API 중 하나를 사용하여 값의 상대 만료가 다시 설정되도록 할 수 있습니다.

실제 값을 업데이트해야 하는 경우 값을 삭제한 후 다시 추가해야 합니다.

값 삭제

분산 캐시에서 값을 삭제하려면 remove API 중 하나를 호출합니다.

앞서 언급한 API의 동기 버전이 있지만 분산 캐시의 구현이 네트워크 I/O를 기반으로 한다는 사실을 고려하시기 바랍니다. 이러한 이유로 비동기 API를 아예 사용하지 않는 것보다는 자주 사용하는 것이 더 좋습니다.

참고 항목