비동기 요청-회신 패턴

Azure
Azure Logic Apps

백 엔드 처리는 비동기적이어야 하지만 프런트 엔드에는 여전히 명확한 응답이 필요한 프런트 엔드 호스트에서 백 엔드 처리를 분리합니다.

컨텍스트 및 문제점

최신 애플리케이션 개발에서는 클라이언트 애플리케이션(종종 웹 클라이언트(브라우저)에서 실행되는 코드)이 비즈니스 논리를 제공하고 기능을 작성하기 위해 원격 API에 의존하는 것이 정상입니다. 이러한 API는 애플리케이션과 직접 관련되거나 타사에서 제공하는 공유 서비스일 수 있습니다. 일반적으로 이러한 API 호출은 HTTP(S) 프로토콜을 통해 수행되며 REST 의미 체계를 따릅니다.

대부분의 경우 클라이언트 애플리케이션에 대한 API는 100ms 이하의 순서로 신속하게 응답하도록 설계되었습니다. 다음을 비롯한 많은 요인이 응답 대기 시간에 영향을 줄 수 있습니다.

  • 애플리케이션의 호스팅 스택
  • 보안 구성 요소
  • 호출자와 백 엔드의 상대적 지리적 위치
  • 네트워크 인프라
  • 현재 부하
  • 요청 페이로드의 크기
  • 큐 처리 길이
  • 백 엔드가 요청을 처리하는 시간

이러한 요소들이 응답 대기 시간을 추가할 수 있습니다. 일부는 백 엔드를 확장하여 완화할 수 있습니다. 네트워크 인프라와 같은 다른 인프라는 대부분 애플리케이션 개발자가 제어할 수 없습니다. 대부분의 API는 응답이 동일한 연결을 통해 다시 도착할 수 있을 만큼 신속하게 응답할 수 있습니다. 애플리케이션 코드는 비차단 방식으로 동기 API 호출을 수행하여 비동기 처리의 형태를 제공할 수 있으며, 이는 I/O 바인딩된 작업에 권장됩니다.

그러나 일부 시나리오에서는 백 엔드에서 수행하는 작업이 몇 초 단위로 장기 실행되거나 몇 분 또는 몇 시간 안에 실행되는 백그라운드 프로세스일 수 있습니다. 이 경우 요청에 응답하기 전에 작업이 완료되기를 기다리는 것은 불가능합니다. 이 상황은 동기 요청-회신 패턴에 대한 잠재적인 문제입니다.

일부 아키텍처는 메시지 브로커를 사용하여 요청 및 응답 단계를 구분하여 이 문제를 해결합니다. 이러한 분리는 종종 큐 기반 부하 평준화 패턴을 사용하여 수행됩니다. 이러한 분리를 통해 클라이언트 프로세스와 백 엔드 API의 크기를 독립적으로 조정할 수 있습니다. 그러나 이 단계를 비동기화해야 하므로 클라이언트에 성공 알림이 필요할 때 이러한 분리는 더욱 복잡해집니다.

클라이언트 애플리케이션에 대해 논의된 많은 동일한 고려 사항은 분산 시스템의 서버 간 REST API 호출(예: 마이크로 서비스 아키텍처)에도 적용됩니다.

해결 방법

이 문제에 대한 한 가지 해결 방법은 HTTP 폴링을 사용하는 것입니다. 폴링은 콜백 엔드포인트를 제공하거나 장기 실행 연결을 사용하기 어려울 수 있으므로 클라이언트 쪽 코드에 유용합니다. 콜백이 가능한 경우에도 필요한 추가 라이브러리 및 서비스가 너무 복잡해질 수 있습니다.

  • 클라이언트 애플리케이션은 API에 동기 호출을 수행하여 백 엔드에서 장기 실행 작업을 트리거합니다.

  • API는 가능한 한 빨리 동기적으로 응답합니다. 처리를 위해 요청이 수신되었음을 인정하는 HTTP 202(수락됨) 상태 코드를 반환합니다.

    참고

    API는 장기 실행 프로세스를 시작하기 전에 수행할 요청과 작업의 유효성을 모두 검사해야 합니다. 요청이 유효하지 않으면 HTTP 400(잘못된 요청)과 같은 오류 코드로 즉시 회신합니다.

  • 응답은 클라이언트가 폴링하여 장기 실행 작업의 결과를 확인할 수 있는 엔드포인트를 가리키는 위치 참조를 보유합니다.

  • API는 메시지 큐와 같은 다른 구성 요소로 처리를 오프로드합니다.

  • 상태 엔드포인트에 대한 모든 호출 성공에 대해 HTTP 200을 반환합니다. 작업이 보류 중인 동안 상태 엔드포인트는 작업이 아직 진행 중임을 나타내는 리소스를 반환합니다. 작업이 완료되면 상태 엔드포인트는 완료를 나타내는 리소스를 반환하거나 다른 리소스 URL로 리디렉션할 수 있습니다. 예를 들어 비동기 작업에서 새 리소스를 만드는 경우 상태 엔드포인트는 해당 리소스의 URL로 리디렉션됩니다.

다음 다이어그램에서는 일반적인 흐름을 보여 줍니다.

요청 및 비동기 HTTP 요청에 대한 응답 흐름

  1. 클라이언트는 요청을 보내고 HTTP 202(수락됨) 응답을 받습니다.
  2. 클라이언트에서 HTTP GET 요청을 시작 엔드포인트에 보냅니다. 작업이 아직 보류 중이므로 이 호출은 HTTP 200을 반환합니다.
  3. 어느 시점에서 작업이 완료되고 상태 엔드포인트가 리소스로 리디렉션되는 302(발견됨)를 반환합니다.
  4. 클라이언트는 지정된 URL에서 리소스를 가져옵니다.

문제 및 고려 사항

  • HTTP를 통해 이 패턴을 구현할 수 있는 여러 가지 방법이 있으며 모든 업스트림 서비스에 동일한 의미 체계가 있는 것은 아닙니다. 예를 들어 대부분의 서비스는 원격 프로세스가 완료되지 않은 경우 GET 메서드에서 HTTP 202 응답을 반환하지 않습니다. 순수 REST 의미 체계에 따라 HTTP 404(찾을 수 없음)를 반환해야 합니다. 이 응답은 호출 결과가 아직 없는 것을 고려할 때 의미가 있습니다.

  • HTTP 202 응답은 클라이언트가 응답을 폴링해야 하는 위치와 빈도를 나타내야 합니다. 다음과 같은 추가 헤더가 있어야 합니다.

    헤더 설명 참고
    위치 클라이언트가 응답 상태를 폴링해야 하는 URL 이 URL은 이 위치에 액세스 제어가 필요한 경우 발레 키 패턴이 적절한 SAS 토큰일 수 있습니다. 응답 폴링을 다른 백 엔드로 오프로드해야 하는 경우에도 발레 키 패턴이 유효합니다.
    Retry-After 처리가 완료되는 예상 시간 이 헤더는 폴링 클라이언트가 재시도로 백 엔드를 압도하지 않도록 설계되었습니다.
  • 사용되는 기본 서비스에 따라 응답 헤더 또는 페이로드를 조작하려면 처리 프록시 또는 외관을 사용해야 할 수 있습니다.

  • 완료 시 상태 엔드포인트가 리디렉션되는 경우 지원하는 정확한 의미 체계에 따라 HTTP 302 또는 HTTP 303이 적절한 반환 코드입니다.

  • 처리에 성공하면 Location 헤더로 지정된 리소스는 200(OK), 201(생성됨) 또는 204(콘텐츠 없음)와 같은 적절한 HTTP 응답 코드를 반환해야 합니다.

  • 처리하는 동안 오류가 발생하는 경우 Location 헤더에 설명된 리소스 URL에 오류를 보관하고 해당 리소스에서 클라이언트에 적절한 응답 코드를 반환하는 것이 좋습니다(4xx 코드).

  • 모든 솔루션이 동일한 방식으로 이 패턴을 구현하는 것은 아니며 일부 서비스에는 추가 또는 대체 헤더가 포함됩니다. 예를 들어 Azure Resource Manager는 이 패턴의 수정된 변형을 사용합니다. 자세한 내용은 Azure Resource Manager 비동기 작업을 참조하세요.

  • 레거시 클라이언트는 이 패턴을 지원하지 않을 수 있습니다. 이 경우 원래 클라이언트에서 비동기 처리를 숨기려면 비동기 API 위에 외관을 배치해야 할 수 있습니다. 예를 들어 Azure Logic Apps는 이 패턴을 기본적으로 비동기 API와 동기 호출을 하는 클라이언트 간의 통합 계층으로 사용할 수 있습니다. 웹후크 동작 패턴으로 장기 실행 작업 수행을 참조하세요.

  • 일부 시나리오에서는 클라이언트가 장기 실행 요청을 취소할 수 있는 방법을 제공할 수 있습니다. 이 경우 백 엔드 서비스는 어떤 형태의 취소 명령을 지원해야 합니다.

이 패턴을 사용해야 하는 경우

다음 경우에 이 패턴을 사용합니다.

  • 브라우저 애플리케이션과 같이 콜백 엔드포인트를 제공하기 어렵거나 장기 실행 연결을 사용하면 복잡성이 너무 커지는 클라이언트 쪽 코드

  • HTTP 프로토콜만 사용할 수 있고 클라이언트 쪽의 방화벽 제한으로 인해 반환 서비스가 콜백을 실행할 수 없는 서비스 호출

  • WebSocket 또는 웹후크와 같은 최신 콜백 기술을 지원하지 않는 레거시 아키텍처와 통합해야 하는 서비스 호출

다음 경우에는 이 패턴이 적합하지 않습니다.

  • 대신 비동기 알림용으로 빌드된 서비스(예: Azure Event Grid)를 사용할 수 있는 경우
  • 응답은 클라이언트에 실시간으로 스트리밍되어야 하는 경우
  • 클라이언트는 많은 결과를 수집해야 하며 이러한 결과의 대기 시간이 중요한 경우 대신 Service Bus 패턴을 고려하는 경우
  • WebSocket 또는 SignalR과 같은 서버 쪽 영구 네트워크 연결을 사용할 수 있습니다. 이러한 서비스를 사용하여 호출자에게 결과를 알릴 수 있습니다.
  • 네트워크 디자인을 사용하면 비동기 콜백 또는 웹후크를 수신하는 포트를 열 수 있습니다.

워크로드 디자인

설계자는 Azure Well-Architected Framework 핵심 요소에서 다루는 목표와 원칙을 해결하기 위해 워크로드 디자인에 비동기 요청-회신 패턴을 사용하는 방법을 평가해야 합니다. 예시:

핵심 요소 이 패턴이 핵심 목표를 지원하는 방법
성능 효율성은 크기 조정, 데이터, 코드의 최적화를 통해 워크로드가 수요를 효율적으로 충족하는 데 도움이 됩니다. 즉각적인 답변이 필요하지 않은 프로세스에 대한 상호 작용의 요청 및 회신 단계를 분리하면 시스템의 응답성과 확장성이 향상됩니다. 비동기 appproach로 서버 쪽에서 동시성을 최대화하고 용량이 허용하는 대로 작업을 완료하도록 예약할 수 있습니다.

- PE:05 크기 조정 및 분할
- PE:07 코드 및 인프라

디자인 결정과 마찬가지로 이 패턴으로 도입될 수 있는 다른 핵심 요소의 목표에 대한 절충을 고려합니다.

예시

다음 코드에서는 Azure Functions를 사용하여 이 패턴을 구현하는 애플리케이션에서 발췌한 항목을 보여 줍니다. 솔루션에는 다음 세 가지 함수가 있습니다.

  • 비동기 API 엔드포인트
  • 상태 엔드포인트
  • 큐에 대기 중인 작업 항목을 가져와서 실행하는 백 엔드 함수

Functions의 비동기 요청 응답 패턴의 구조 이미지

GitHub 로고 이 샘플은 GitHub에서 사용할 수 있습니다.

AsyncProcessingWorkAcceptor 함수

AsyncProcessingWorkAcceptor 함수는 클라이언트 애플리케이션에서 작업을 수락하고 처리를 위해 큐에 배치하는 엔드포인트를 구현합니다.

  • 이 함수는 요청 ID를 생성하여 큐 메시지에 메타데이터로 추가합니다.
  • HTTP 응답에는 상태 엔드포인트를 가리키는 위치 헤더가 포함됩니다. 요청 ID는 URL 경로의 일부입니다.
public static class AsyncProcessingWorkAcceptor
{
    [FunctionName("AsyncProcessingWorkAcceptor")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] CustomerPOCO customer,
        [ServiceBus("outqueue", Connection = "ServiceBusConnectionAppSetting")] IAsyncCollector<ServiceBusMessage> OutMessages,
        ILogger log)
    {
        if (String.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
        {
            return new BadRequestResult();
        }

        string reqid = Guid.NewGuid().ToString();

        string rqs = $"http://{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);

        await OutMessages.AddAsync(message);

        return new AcceptedResult(rqs, $"Request Accepted for Processing{Environment.NewLine}ProxyStatus: {rqs}");
    }
}

AsyncProcessingBackgroundWorker 함수

AsyncProcessingBackgroundWorker 함수는 큐에서 작업을 선택하고, 메시지 페이로드에 따라 일부 작업을 수행하고, 결과를 스토리지 계정에 씁니다.

public static class AsyncProcessingBackgroundWorker
{
    [FunctionName("AsyncProcessingBackgroundWorker")]
    public static async Task RunAsync(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnectionAppSetting")] BinaryData customer,
        IDictionary<string, object> applicationProperties,
        [Blob("data", FileAccess.ReadWrite, Connection = "StorageConnectionAppSetting")] BlobContainerClient inputContainer,
        ILogger log)
    {
        // Perform an actual action against the blob data source for the async readers to be able to check against.
        // This is where your actual service worker processing will be performed

        var id = applicationProperties["RequestGUID"] as string;

        BlobClient blob = inputContainer.GetBlobClient($"{id}.blobdata");

        // Now write the results to blob storage.
        await blob.UploadAsync(customer);
    }
}

AsyncOperationStatusChecker 함수

AsyncOperationStatusChecker 함수는 상태 엔드포인트를 구현합니다. 이 함수는 먼저 요청이 완료되었는지 여부를 확인합니다.

  • 요청이 완료되면 함수는 발레 키를 응답에 반환하거나 호출을 발레 키 URL로 즉시 리디렉션합니다.
  • 요청이 아직 보류 중인 경우 현재 상태를 포함하여 200 코드를 반환해야 합니다.
public static class AsyncOperationStatusChecker
{
    [FunctionName("AsyncOperationStatusChecker")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
        [Blob("data/{thisGuid}.blobdata", FileAccess.Read, Connection = "StorageConnectionAppSetting")] BlockBlobClient inputBlob, string thisGUID,
        ILogger log)
    {

        OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
        OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");

        log.LogInformation($"C# HTTP trigger function processed a request for status on {thisGUID} - OnComplete {OnComplete} - OnPending {OnPending}");

        // Check to see if the blob is present
        if (await inputBlob.ExistsAsync())
        {
            // If it's present, depending on the value of the optional "OnComplete" parameter choose what to do.
            return await OnCompleted(OnComplete, inputBlob, thisGUID);
        }
        else
        {
            // If it's NOT present, then we need to back off. Depending on the value of the optional "OnPending" parameter, choose what to do.
            string rqs = $"http://{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 backoff period hits one minute.
                        int backoff = 250;

                        while (!await inputBlob.ExistsAsync() && backoff < 64000)
                        {
                            log.LogInformation($"Synchronous mode {thisGUID}.blob - retrying in {backoff} ms");
                            backoff = backoff * 2;
                            await Task.Delay(backoff);
                        }

                        if (await inputBlob.ExistsAsync())
                        {
                            log.LogInformation($"Synchronous Redirect mode {thisGUID}.blob - completed after {backoff} ms");
                            return await OnCompleted(OnComplete, inputBlob, thisGUID);
                        }
                        else
                        {
                            log.LogInformation($"Synchronous mode {thisGUID}.blob - NOT FOUND after timeout {backoff} ms");
                            return new NotFoundResult();
                        }
                    }

                default:
                    {
                        throw new InvalidOperationException($"Unexpected value: {OnPending}");
                    }
            }
        }
    }

    private static async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
    {
        switch (OnComplete)
        {
            case OnCompleteEnum.Redirect:
                {
                    // Redirect to the SAS URI to blob storage

                    return new RedirectResult(inputBlob.GenerateSASURI());
                }

            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
}

다음 단계

이 패턴의 구현과 관련된 정보는 다음과 같습니다.