백 엔드 처리를 비동기적으로 실행해야 하지만 프런트 엔드에 명확한 응답이 필요한 경우 프런트 엔드 호스트에서 백 엔드 처리를 분리합니다.
컨텍스트 및 문제점
최신 애플리케이션 개발에서 클라이언트 애플리케이션은 종종 원격 API를 사용하여 비즈니스 논리를 제공하고 기능을 작성합니다. 많은 애플리케이션이 웹 브라우저에서 코드를 실행하고 다른 환경에서도 클라이언트 코드를 호스트합니다. API는 애플리케이션과 직접 관련되거나 외부 서비스에서 공유 서비스로 작동할 수 있습니다. 대부분의 API 호출은 HTTP 또는 HTTPS를 사용하고 REST 의미 체계를 따릅니다.
대부분의 경우 클라이언트 애플리케이션에 대한 API는 약 100밀리초(밀리초) 이하로 응답합니다. 많은 요소가 응답 대기 시간에 영향을 줄 수 있습니다.
- 애플리케이션의 호스팅 스택
- 보안 구성 요소
- 호출자와 백 엔드의 상대적 지리적 위치
- 네트워크 인프라
- 현재 부하
- 요청 페이로드의 크기
- 처리 큐 길이
- 백 엔드에서 요청을 처리하는 시간
이러한 요소는 응답에 대기 시간을 추가할 수 있습니다. 백 엔드를 확장하여 몇 가지 요소를 완화할 수 있습니다. 네트워크 인프라와 같은 다른 요소는 애플리케이션 개발자가 제어할 수 없습니다. 대부분의 API는 응답이 동일한 연결을 통해 반환되도록 충분히 신속하게 응답합니다. 애플리케이션 코드는 비동기 처리의 모양을 제공하기 위해 비 차단 방식으로 동기 API 호출을 만들 수 있습니다. 입력 및 출력(I/O) 바인딩된 작업에 이 방법을 사용하는 것이 좋습니다.
일부 시나리오에서는 백 엔드가 장기 실행되고 몇 초 정도 걸리는 작업을 수행합니다. 다른 시나리오에서는 백 엔드가 몇 분 동안 또는 연장된 기간 동안 장기 실행 백그라운드 작업을 수행합니다. 이러한 경우 응답을 보내기 전에 작업이 완료되기를 기다릴 수 없습니다. 이 경우 동기 요청-회신 패턴에 문제가 발생할 수 있습니다.
일부 아키텍처는 메시지 브로커를 사용하여 요청 및 응답 단계를 구분하여 이 문제를 해결합니다. 많은 시스템에서 Queue-Based 부하 평준화 패턴을 통해 이러한 분리를 수행합니다. 이러한 분리를 통해 클라이언트 프로세스와 백 엔드 API는 독립적으로 확장할 수 있습니다. 또한 해당 단계가 비동기적이어야 하므로 클라이언트에 성공 알림이 필요한 경우 더욱 복잡해집니다.
클라이언트 애플리케이션에 적용되는 많은 동일한 고려 사항은 마이크로 서비스 아키텍처와 같은 분산 시스템의 서버 간 REST API 호출에도 적용됩니다.
해결 방법
이 문제에 대한 한 가지 해결 방법은 HTTP 폴링을 사용하는 것입니다. 콜백 엔드포인트를 사용할 수 없거나 장기 실행 연결이 너무 복잡할 때 클라이언트 쪽 코드에 폴링이 잘 작동합니다. 콜백이 가능한 경우에도 필요한 추가 라이브러리 및 서비스가 복잡해질 수 있습니다.
다음 단계에서는 솔루션을 설명합니다.
클라이언트 애플리케이션은 API를 동기로 호출하여 백 엔드에서 장기 실행 작업을 트리거합니다.
API는 가능한 한 빨리 동기적으로 응답합니다. 처리 요청을 수신했음을 인정하는 HTTP 202(수락됨) 상태 코드를 반환합니다.
비고
API는 장기 실행 프로세스를 시작하기 전에 수행할 요청 및 작업의 유효성을 검사합니다. 요청이 유효하지 않으면 HTTP 400(잘못된 요청)과 같은 오류 코드로 즉시 회신합니다.
응답에는 클라이언트가 폴링하여 장기 실행 작업의 결과를 확인할 수 있는 엔드포인트를 가리키는 위치 참조가 포함됩니다.
API는 메시지 큐와 같은 다른 구성 요소로 처리를 오프로드합니다.
상태 엔드포인트를 성공적으로 호출하기 위해 엔드포인트는 HTTP 200(확인)을 반환합니다. 작업이 진행 중인 동안 엔드포인트는 해당 상태를 나타내는 리소스를 반환합니다. 작업이 완료되면 엔드포인트는 완료를 나타내는 리소스를 반환하거나 다른 리소스 URL로 리디렉션합니다. 예를 들어 비동기 작업에서 새 리소스를 만드는 경우 상태 엔드포인트는 해당 리소스의 URL로 리디렉션됩니다.
다음 다이어그램은 일반적인 흐름을 보여줍니다.
클라이언트는 요청을 보내고 HTTP 202 응답을 받습니다.
클라이언트에서 HTTP GET 요청을 시작 엔드포인트에 보냅니다. 작업이 보류 중이므로 이 호출은 HTTP 200을 반환합니다.
작업이 완료되고 상태 엔드포인트가 HTTP 302(발견됨)를 반환하여 리소스로 리디렉션합니다.
클라이언트는 지정된 URL에서 리소스를 가져옵니다.
문제 및 고려 사항
이 패턴을 구현하는 방법을 결정할 때 다음 사항을 고려합니다.
HTTP를 통해 이 패턴을 구현하는 여러 가지 방법이 있으며 업스트림 서비스에서 항상 동일한 의미 체계를 사용하지는 않습니다. 예를 들어 대부분의 서비스는 HTTP 202가 아닌 원격 프로세스가 완료되지 않은 경우 GET 메서드에서 HTTP 404(찾을 수 없음)를 반환합니다. 표준 REST 의미 체계에 따르면 HTTP 404는 호출 결과가 아직 존재하지 않기 때문에 올바른 응답입니다.
HTTP 202 응답은 클라이언트가 폴링하는 위치와 빈도를 나타냅니다. 여기에는 다음 헤더가 포함됩니다.
Header 설명 Notes Location클라이언트가 응답 상태에 대해 폴링하는 URL 이 URL은 공유 액세스 서명 토큰일 수 있습니다. 이 위치에 액세스 제어가 필요한 경우 발레 키 패턴 이 잘 작동합니다. 응답 폴링을 다른 백 엔드로 이동해야 하는 경우에도 패턴이 적용됩니다. Retry-After처리가 완료되는 예상 시간 이 헤더는 폴링 클라이언트가 백 엔드에 너무 많은 요청을 보내는 것을 방지합니다. 이 응답을 디자인할 때 예상되는 클라이언트 동작을 고려합니다. 제어하는 클라이언트는 이러한 응답 값을 정확하게 따를 수 있습니다. 코드 없음 또는 Azure Logic Apps 같은 하위 코드 도구를 사용하여 빌드된 클라이언트를 포함하여 다른 사용자가 작성하는 클라이언트는 HTTP 202에 대해 자체 처리를 적용할 수 있습니다.
사용하는 기본 서비스에 따라 처리 프록시를 사용하여 응답 헤더 또는 페이로드를 조정해야 할 수 있습니다.
완료 후 상태 엔드포인트가 리디렉션되는 경우 지원하는 의미 체계에 따라 HTTP 302 또는 HTTP 303(기타 참조) 이 유효한 반환 코드입니다.
서버가 요청을 처리한 후 헤더가 지정하는 리소스는
Location200, 201(생성됨) 또는 204(콘텐츠 없음)와 같은 HTTP 상태 코드를 반환합니다.처리하는 동안 오류가 발생하는 경우 헤더가 지정하는
Location리소스 URL에 오류를 보관하고 해당 리소스에서 오류와 일치하는 4xx 상태 코드를 반환합니다.솔루션이 모두 동일한 방식으로 이 패턴을 구현하지는 않으며 일부 서비스에는 추가 또는 대체 헤더가 포함됩니다. 예를 들어 Azure Resource Manager 이 패턴의 수정된 변형을 사용합니다. 자세한 내용은 Resource Manager 비동기 작업 참조하세요.
레거시 클라이언트는 이 패턴을 지원하지 않을 수 있습니다. 이 경우 원래 클라이언트에서 비동기 처리를 숨기려면 비동기 API에 처리 프록시를 배치해야 할 수 있습니다. 예를 들어 Logic Apps는 이 패턴을 기본적으로 지원하며 비동기 API와 동기 호출을 하는 클라이언트 간의 통합 계층으로 사용할 수 있습니다. 자세한 내용은 웹후크 작업 패턴을 사용하여 장기 실행 작업 수행을 참조하세요.
일부 시나리오에서는 클라이언트가 장기 실행 요청을 취소할 수 있는 방법을 제공할 수 있습니다. 이 경우 백 엔드 서비스는 어떤 형태의 취소 명령을 지원해야 합니다.
이 패턴을 사용하는 경우
다음 경우에 이 패턴을 사용합니다.
브라우저 애플리케이션과 같은 클라이언트 쪽 코드로 작업하면 콜백 엔드포인트를 제공하기 어렵거나 장기 실행 연결로 인해 너무 복잡해집니다.
HTTP 프로토콜만 사용하는 서비스를 호출하고 반환 서비스는 클라이언트 쪽의 방화벽 제한으로 인해 콜백을 보낼 수 없습니다.
WebSocket 또는 웹후크와 같은 최신 콜백 메커니즘을 지원하지 않는 레거시 아키텍처와 통합합니다.
이 패턴은 다음과 같은 경우에 적합하지 않을 수 있습니다.
대신 Azure Event Grid 같은 비동기 알림용으로 빌드된 서비스를 사용할 수 있습니다.
응답은 클라이언트에 실시간으로 스트리밍되어야 하는 경우
클라이언트는 많은 결과를 수집해야 하며 이러한 결과의 대기 시간이 중요합니다. Service Bus 패턴을 대신 고려하세요.
WebSocket 또는 SignalR과 같은 서버 쪽 영구 네트워크 연결을 사용할 수 있습니다. 이러한 연결을 사용하여 호출자에게 결과를 알릴 수 있습니다.
네트워크 디자인은 비동기 콜백 또는 웹후크를 수신하는 열린 포트를 지원합니다.
워크로드 디자인
설계자는 워크로드 디자인에서 비동기 Request-Reply 패턴을 사용하여 Azure Well-Architected Framework 핵심 요소 다루는 목표와 원칙을 해결하는 방법을 평가해야 합니다.
| 핵심 요소 | 이 패턴으로 핵심 목표를 지원하는 방법 |
|---|---|
| 성능 효율성은 크기 조정, 데이터 및 코드의 최적화를 통해 워크로드가 수요를 효율적으로 충족 하는 데 도움이 됩니다. | 즉각적인 응답이 필요하지 않은 프로세스에 대한 요청 및 회신 단계를 분리하여 응답성 및 확장성을 향상시킵니다. 비동기 접근 방식은 동시성을 높이고 용량을 사용할 수 있게 되면 서버 일정이 작동할 수 있도록 합니다. - PE:05 크기 조정 및 분할 - PE:07 코드 및 인프라 |
디자인 결정을 내릴 때, 이 패턴이 도입할 수 있는 다른 기둥의 목표와의 상충 관계를 고려하세요.
예시
다음 코드는 Azure Functions 사용하여 이 패턴을 구현하는 애플리케이션에서 발췌한 내용입니다. 이 솔루션에는 다음 세 가지 함수가 있습니다.
- 비동기 API 엔드포인트
- 상태 엔드포인트
- 대기 중인 작업 항목을 가져와서 실행하는 백 엔드 함수
이 샘플은 GitHub 사용할 수 있습니다.
AsyncProcessingWorkAcceptor 함수
이 함수는 AsyncProcessingWorkAcceptor 클라이언트 애플리케이션의 작업을 수락하고 처리를 위해 큐에 넣기하는 엔드포인트를 구현합니다.
이 함수는 요청 ID를 생성하여 큐 메시지에 메타데이터로 추가합니다.
HTTP 응답에는 상태 엔드포인트를
Location가리키는 헤더가 포함됩니다. 요청 ID가 URL 경로에 나타납니다.
public class AsyncProcessingWorkAcceptor(ServiceBusClient _serviceBusClient)
{
[Function("AsyncProcessingWorkAcceptor")]
public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, [FromBody] CustomerPOCO customer)
{
if (string.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
{
return new BadRequestResult();
}
var reqid = Guid.NewGuid().ToString();
string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
var rqs = $"{scheme}://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{reqid}";
var messagePayload = JsonConvert.SerializeObject(customer);
var message = new ServiceBusMessage(messagePayload);
message.ApplicationProperties.Add("RequestGUID", reqid);
message.ApplicationProperties.Add("RequestSubmittedAt", DateTime.Now);
message.ApplicationProperties.Add("RequestStatusURL", rqs);
var sender = _serviceBusClient.CreateSender("outqueue");
await sender.SendMessageAsync(message);
return new AcceptedResult(rqs, $"Request Accepted for Processing{Environment.NewLine}ProxyStatus: {rqs}");
}
}
AsyncProcessingBackgroundWorker 함수
이 함수는 AsyncProcessingBackgroundWorker 큐에서 작업을 읽고, 메시지 페이로드에 따라 처리하고, 결과를 스토리지 계정에 씁니다.
public class AsyncProcessingBackgroundWorker(BlobContainerClient _blobContainerClient)
{
[Function(nameof(AsyncProcessingBackgroundWorker))]
public async Task Run([ServiceBusTrigger("outqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message)
{
var requestGuid = message.ApplicationProperties["RequestGUID"].ToString();
string blobName = $"{requestGuid}.blobdata";
await _blobContainerClient.CreateIfNotExistsAsync();
var blobClient = _blobContainerClient.GetBlobClient(blobName);
using (MemoryStream memoryStream = new MemoryStream())
using (StreamWriter writer = new StreamWriter(memoryStream))
{
writer.Write(message.Body.ToString());
writer.Flush();
memoryStream.Position = 0;
await blobClient.UploadAsync(memoryStream, overwrite: true);
}
}
}
AsyncOperationStatusChecker 함수
AsyncOperationStatusChecker 함수는 상태 엔드포인트를 구현합니다. 이 함수는 요청의 상태를 확인합니다.
요청이 완료되면 함수는 응답에 발레 키를 반환하거나 호출을 발레 키 URL로 즉시 리디렉션합니다.
요청이 보류 중인 경우 함수는 현재 상태를 포함하는 HTTP 200 코드를 반환합니다.
public class AsyncOperationStatusChecker(ILogger<AsyncOperationStatusChecker> _logger)
{
[Function("AsyncOperationStatusChecker")]
public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
[BlobInput("data/{thisGUID}.blobdata", Connection = "DataStorage")] BlockBlobClient inputBlob, string thisGUID)
{
OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");
_logger.LogInformation($"C# HTTP trigger function processed a request for status on {thisGUID} - OnComplete {OnComplete} - OnPending {OnPending}");
// Check whether the blob exists.
if (await inputBlob.ExistsAsync())
{
// If the blob exists, the function uses the OnComplete parameter to determine the next action.
return await OnCompleted(OnComplete, inputBlob, thisGUID);
}
else
{
// If the blob doesn't exist, the function uses the OnPending parameter to determine the next action.
string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
string rqs = $"{scheme}://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{thisGUID}";
switch (OnPending)
{
case OnPendingEnum.OK:
{
// Return an HTTP 200 status code.
return new OkObjectResult(new { status = "In progress", Location = rqs });
}
case OnPendingEnum.Synchronous:
{
// Back off and retry. Time out if the back-off period reaches one minute.
int backoff = 250;
while (!await inputBlob.ExistsAsync() && backoff < 64000)
{
_logger.LogInformation($"Synchronous mode {thisGUID}.blob - retrying in {backoff} ms");
backoff = backoff * 2;
await Task.Delay(backoff);
}
if (await inputBlob.ExistsAsync())
{
_logger.LogInformation($"Synchronous Redirect mode {thisGUID}.blob - completed after {backoff} ms");
return await OnCompleted(OnComplete, inputBlob, thisGUID);
}
else
{
_logger.LogInformation($"Synchronous mode {thisGUID}.blob - NOT FOUND after timeout {backoff} ms");
return new NotFoundResult();
}
}
default:
{
throw new InvalidOperationException($"Unexpected value: {OnPending}");
}
}
}
}
private async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
{
switch (OnComplete)
{
case OnCompleteEnum.Redirect:
{
// The typical way to generate a shared access signature token in code requires the storage account key.
// If you need to use a managed identity to control access to your storage accounts in code, which is a recommended best practice, you should do so when possible.
// In this scenario, you don't have a storage account key, so you need to find another way to generate the shared access signatures.
// To generate shared access signatures, use a user delegation shared access signature. This approach lets you sign the shared access signature by using Microsoft Entra ID credentials instead of the storage account key.
BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();
var userDelegationKey = await blobServiceClient.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(7));
// Redirect the shared access signature uniform resource identifier (URI) to blob storage.
return new RedirectResult(inputBlob.GenerateSASURI(userDelegationKey));
}
case OnCompleteEnum.Stream:
{
// Download the file and return it directly to the caller.
// For larger files, use a stream to minimize RAM usage.
return new OkObjectResult(await inputBlob.DownloadContentAsync());
}
default:
{
throw new InvalidOperationException($"Unexpected value: {OnComplete}");
}
}
}
}
public enum OnCompleteEnum
{
Redirect,
Stream
}
public enum OnPendingEnum
{
OK,
Synchronous
}
다음 CloudBlockBlobExtensions 클래스는 상태 검사기에서 결과 Blob에 대한 사용자 위임 공유 액세스 서명 URI(Uniform Resource Identifier)를 생성하는 데 사용하는 확장 메서드를 제공합니다.
public static class CloudBlockBlobExtensions
{
public static string GenerateSASURI(this BlockBlobClient inputBlob, UserDelegationKey userDelegationKey)
{
BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();
BlobSasBuilder blobSasBuilder = new BlobSasBuilder()
{
BlobContainerName = inputBlob.BlobContainerName,
BlobName = inputBlob.Name,
Resource = "b",
StartsOn = DateTimeOffset.UtcNow,
ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10)
};
blobSasBuilder.SetPermissions(BlobSasPermissions.Read);
var blobUriBuilder = new BlobUriBuilder(inputBlob.Uri)
{
Sas = blobSasBuilder.ToSasQueryParameters(userDelegationKey, blobServiceClient.AccountName)
};
// Generate the shared access signature on the blob, which sets the constraints directly on the signature.
Uri sasUri = blobUriBuilder.ToUri();
// Return the URI string for the container, including the shared access signature token.
return sasUri.ToString();
}
}