현대 소프트웨어 아키텍처에서 웹 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`):
다음은 `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);
}