ASP.NET Core 고성능 웹 API 구축을 위한 핵심 전략 4가지

현대 소프트웨어 아키텍처에서 웹 API는 다양한 클라이언트와 시스템 간의 데이터 통신을 담당하는 핵심 요소입니다. 백엔드 개발자라면 사용자에게 빠르고 안정적인 경험을 제공하기 위해 고성능 API를 구축하는 것을 목표로 해야 합니다. 다음은 ASP.NET Core 환경에서 웹 API의 성능을 최적화하는 데 도움이 될 수 있는 네 가지 실용적인 기법입니다.

1. 대량 데이터 전송 시 페이징(Pagination) 활용

API를 통해 한 번에 너무 많은 데이터를 전송하는 것은 심각한 성능 저하를 유발할 수 있습니다. 이는 네트워크 지연, 서버 메모리 과부하, 그리고 응답 시간 증가로 이어질 수 있습니다. 이러한 병목 현상을 완화하기 위해 페이징 기법을 적용하는 것이 좋습니다. 페이징을 사용하면 클라이언트가 필요한 데이터의 특정 부분(페이지)만 요청하여 받아볼 수 있으므로, 서버와 네트워크의 부담을 줄이고 응답성을 향상시킬 수 있습니다.

아래 예시는 ASP.NET Core에서 쿼리 파라미터를 통해 페이지 번호와 페이지 크기를 받아와 특정 조건에 맞는 로그 데이터를 페이징 처리하여 반환하는 방법을 보여줍니다.


[HttpGet("api/system-logs/paged-critical")]
public async Task<ActionResult<List<SystemLog>>> GetCriticalSystemLogs(
    [FromQuery] int page = 1,
    [FromQuery] int size = 10)
{
    if (page < 1 || size < 1)
    {
        return BadRequest("페이지 번호와 크기는 1 이상이어야 합니다.");
    }

    var criticalLogs = await _dbContext.SystemLogs
        .Where(log => log.Level == "Critical")
        .OrderByDescending(log => log.Timestamp)
        .Skip((page - 1) * size)
        .Take(size)
        .ToListAsync();

    return Ok(criticalLogs);
}

2. 네트워크 왕복(Roundtrip) 횟수 최소화

네트워크 왕복 횟수를 줄이는 것은 API 성능을 향상시키는 중요한 방법 중 하나입니다. 이는 여러 개의 작은 API 호출을 통해 필요한 데이터를 조립하는 대신, 한 번의 호출로 필요한 모든 데이터를 가져오도록 설계해야 함을 의미합니다. 각 네트워크 왕복은 지연 시간과 오버헤드를 발생시키므로, 이를 최소화하는 것이 전체적인 응답 속도에 큰 영향을 미칩니다.

권장하지 않는 방식 (여러 번의 호출):

다음은 특정 범주의 상품 ID를 가져오는 API와, 이 ID 목록을 기반으로 해당 상품과 관련된 주문 기록을 조회하는 두 개의 개별 API 엔드포인트 예시입니다. 이 방식은 클라이언트가 두 번의 네트워크 호출을 수행해야 하므로 비효율적입니다.


// (1) 특정 카테고리 상품 ID를 가져오는 엔드포인트
[HttpGet("api/products/category-ids/{category}")]
public async Task<ActionResult<List<int>>> GetProductIdsByCategory(string category)
{
    var productIds = await _dbContext.Products
        .AsNoTracking()
        .Where(p => p.Category == category)
        .Select(p => p.ProductId)
        .ToListAsync();
    return Ok(productIds);
}

// (2) 특정 상품 ID로 주문 기록을 가져오는 엔드포인트
[HttpGet("api/orders/by-product-list")]
public async Task<ActionResult<List<OrderItem>>> GetOrdersForProductIds([FromQuery] List<int> productIds)
{
    if (productIds == null || !productIds.Any())
    {
        return BadRequest("조회할 상품 ID 목록이 필요합니다.");
    }
    var orders = await _dbContext.OrderItems
        .AsNoTracking()
        .Where(item => productIds.Contains(item.ProductId))
        .Select(item => new OrderItem
        {
            ItemId = item.ItemId,
            ProductId = item.ProductId,
            Quantity = item.Quantity
        })
        .ToListAsync();
    return Ok(orders);
}

권장하는 방식 (단일 호출로 최적화):

대신, 관련된 데이터를 하나의 API 호출 내에서 처리하도록 설계하여 네트워크 왕복 횟수를 줄일 수 있습니다. 이 예시는 특정 카테고리의 모든 상품을 조회하고, 그 상품들과 관련된 모든 주문 항목을 한 번의 API 호출로 반환합니다.


[HttpGet("api/products-and-related-orders/{category}")]
public async Task<ActionResult<List<OrderItem>>> GetProductsWithOrdersByCategory(string category)
{
    var products = await _dbContext.Products
        .AsNoTracking()
        .Where(p => p.Category == category)
        .ToListAsync();

    var productIds = products.Select(p => p.ProductId).ToList();
    
    // 이제 해당 상품 ID들을 사용하여 모든 관련 주문 항목을 조회
    var relatedOrderItems = await _dbContext.OrderItems
        .AsNoTracking()
        .Where(item => productIds.Contains(item.ProductId))
        .ToListAsync();

    return Ok(relatedOrderItems);
}

3. 비동기 메서드에 `async Task` 사용

ASP.NET Core 컨트롤러 액션 메서드에서 비동기 작업을 처리할 때, 항상 `async Task` 또는 `async Task`를 사용해야 합니다. `async void`는 특수한 경우(예: 이벤트 핸들러)를 제외하고는 웹 API의 비동기 작업에 사용해서는 안 됩니다. `async void`를 사용하면 요청 파이프라인이 비동기 메서드의 완료를 기다리지 않고 응답을 보낼 수 있어, 예외 처리 문제, 예상치 못한 동작, 서버 리소스 낭비 등의 문제가 발생할 수 있습니다.

권장하지 않는 방식 (`async void`):

다음은 `async void`를 사용하여 고객 삭제 작업을 수행하는 예시입니다. 이 경우, `RemoveCustomer` 메서드의 비동기 작업이 완전히 완료되기 전에 HTTP 응답이 전송될 수 있으며, `Response.WriteAsync`가 실행되지 않거나 예외가 제대로 처리되지 않을 위험이 있습니다.


[HttpDelete("api/customer-mgmt/void/{id}")]
public async void RemoveCustomer([FromRoute] int id)
{
    var customer = await _dbContext.Customers.FirstOrDefaultAsync(c => c.CustomerId == id);
    if (customer != null)
    {
        _dbContext.Customers.Remove(customer);
        await _dbContext.SaveChangesAsync();
        // 이 메시지가 클라이언트에게 전달되지 않을 수 있습니다.
        await Response.WriteAsync($"고객 ID {id}의 기록이 성공적으로 삭제되었습니다.");
    }
}

권장하는 방식 (`async Task`):

`async Task`를 사용하면 ASP.NET Core 런타임이 비동기 메서드의 완료를 명확하게 기다리므로, 작업이 완전히 끝난 후 적절한 HTTP 응답을 반환할 수 있습니다. 이는 안정적인 동작과 일관된 예외 처리를 보장합니다.


[HttpDelete("api/customer-mgmt/task/{id}")]
public async Task<IActionResult> DeleteCustomerRecord([FromRoute] int id)
{
    var customer = await _dbContext.Customers.FirstOrDefaultAsync(c => c.CustomerId == id);
    if (customer == null)
    {
        return NotFound($"고객 ID {id}를 찾을 수 없습니다.");
    }

    _dbContext.Customers.Remove(customer);
    await _dbContext.SaveChangesAsync();

    // 표준 API 응답: 성공적인 삭제를 위해 204 No Content 반환
    return NoContent();
}

4. 자주 접근하는 데이터에 캐싱(Caching) 적용

캐싱은 데이터베이스나 외부 서비스에 대한 반복적인 접근을 줄여 애플리케이션의 성능을 크게 향상시키는 효과적인 기법입니다. 데이터가 처음 요청될 때 데이터베이스에서 가져와 캐시에 저장하고, 이후 요청부터는 캐시된 데이터를 즉시 반환하여 응답 시간을 단축하고 백엔드 리소스 부하를 줄일 수 있습니다. 캐싱은 데이터가 자주 변경되지 않거나 일정 시간 동안 유효한 경우에 특히 유용합니다.

ASP.NET Core는 크게 두 가지 주요 캐싱 유형을 지원합니다: 인메모리(In-Memory) 캐시와 분산(Distributed) 캐시.

1. 인메모리 캐시 (In-Memory Cache)

인메모리 캐시는 웹 서버의 메모리에 데이터를 저장하는 방식입니다. `IMemoryCache` 인터페이스를 통해 제공되며, 단일 서버 환경에서 쉽게 구현하고 사용할 수 있습니다.


private readonly IMemoryCache _memoryCache; // DI를 통해 주입받는다고 가정

// ... 생성자 ...

[HttpGet("api/reports/cached-user-activity")]
public async Task<ActionResult<List<UserActivity>>> GetCachedUserActivity()
{
    const string CacheKey = "recent_user_activity_list";
    List<UserActivity>? userActivities;

    // 캐시에 데이터가 있는지 확인
    if (!_memoryCache.TryGetValue(CacheKey, out userActivities))
    {
        // 캐시에 없는 경우, 데이터베이스에서 조회
        userActivities = await _dbContext.UserActivities
            .Where(ua => ua.ActionType == "View" || ua.ActionType == "Edit")
            .OrderByDescending(ua => ua.ActivityDate)
            .Take(100) // 최근 100개의 활동만 캐시
            .ToListAsync();

        // 캐시 옵션 설정 (예: 30분 후 만료)
        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .SetAbsoluteExpiration(TimeSpan.FromMinutes(30));

        // 데이터를 캐시에 저장
        _memoryCache.Set(CacheKey, userActivities, cacheEntryOptions);
    }

    return Ok(userActivities);
}

2. 분산 캐시 (Distributed Cache)

분산 캐시는 여러 서버 인스턴스 간에 캐시를 공유할 수 있도록 해줍니다. 이는 마이크로서비스 아키텍처나 부하 분산 환경에서 특히 유용합니다. ASP.NET Core는 `IDistributedCache` 인터페이스를 통해 분산 캐시를 지원하며, Redis, SQL Server, NCache 등 다양한 구현체를 활용할 수 있습니다. 분산 캐시를 사용하려면 별도의 캐싱 서비스(예: Redis 서버)가 필요합니다.


using System.Text.Json; // JSON 직렬화/역직렬화에 필요

private readonly IDistributedCache _distributedCache; // DI를 통해 주입받는다고 가정

// ... 생성자 ...

[HttpGet("api/analytics/cached-dashboard-metrics")]
public async Task<ActionResult<List<DashboardMetric>>> GetDashboardMetricsWithDistributedCache()
{
    const string CacheKey = "dashboard_metrics_summary";
    List<DashboardMetric>? metrics = null;
    string? cachedDataJson = await _distributedCache.GetStringAsync(CacheKey);

    if (!string.IsNullOrEmpty(cachedDataJson))
    {
        metrics = JsonSerializer.Deserialize<List<DashboardMetric>>(cachedDataJson);
    }

    if (metrics == null)
    {
        // 캐시 데이터가 없거나 만료된 경우, 데이터베이스에서 조회
        metrics = await _dbContext.DashboardMetrics
            .Where(m => m.MetricType == "Revenue" || m.MetricType == "Users")
            .OrderByDescending(m => m.ReportDate)
            .Take(15) // 최근 15일치 데이터
            .ToListAsync();

        if (metrics.Any())
        {
            var jsonString = JsonSerializer.Serialize(metrics);
            await _distributedCache.SetStringAsync(CacheKey, jsonString, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) // 1시간 후 만료
            });
        }
    }

    return Ok(metrics);
}

태그: ASP.NET Core Web API performance optimization pagination Caching

11월 15일 23:17에 게시됨