코드 연습: Functions를 사용하는 서버리스 애플리케이션

Azure Event Hubs
Azure 기능

서버리스 모델은 기본 컴퓨팅 인프라의 코드를 추상화하므로 개발자는 광범위한 설정 없이 비즈니스 논리에 집중할 수 있습니다. 서버리스 코드는 코드 실행 리소스와 기간에 대해서만 지불하기 때문에 비용을 절감합니다.

서버리스 이벤트 기반 모델은 특정 이벤트가 정의된 작업을 트리거하는 상황에 적합합니다. 예를 들어 들어오는 디바이스 메시지를 수신하면 나중에 사용할 수 있도록 스토리지가 트리거되거나 데이터베이스 업데이트가 추가 처리를 트리거합니다.

Azure에서 Azure 서버리스 기술을 탐색하는 데 도움이 되도록 Microsoft는 Azure Functions를 사용하는 서버리스 애플리케이션을 개발하고 테스트했습니다. 이 문서에서는 서버리스 Functions 솔루션에 대한 코드를 살펴보고 디자인 결정, 구현 세부 정보 및 발생할 수 있는 몇 가지 "gotchas"에 대해 설명합니다.

솔루션 살펴보기

두 부분으로 구성된 솔루션은 가상 드론 배달 시스템을 설명합니다. 드론은 진행 중인 상태를 클라우드로 보내서 이 메시지를 나중에 사용하기 위해 저장합니다. 웹앱을 사용하면 사용자가 메시지를 검색하여 디바이스의 최신 상태를 가져올 수 있습니다.

GitHub에서 이 솔루션에 대한 코드를 다운로드할 수 있습니다.

이 연습에서는 다음 기술에 기본적으로 익숙하다고 가정합니다.

Functions 또는 Event Hubs의 전문가일 필요는 없지만, 해당 기능에 대해 개괄적으로 이해하고 있어야 합니다. 시작하는 데 유용한 몇 가지 리소스는 다음과 같습니다.

시나리오 이해

Diagram of the functional blocks

Fabrikam은 드론 배달 서비스를 위해 드론 수송단을 관리합니다. 애플리케이션을 구성하는 두 가지 주요 기능 영역은 다음과 같습니다.

  • 이벤트 수집. 드론은 비행 중에 상태 메시지를 클라우드 엔드포인트에 보냅니다. 애플리케이션은 이러한 메시지를 수집하여 처리하고, 결과를 백 엔드 데이터베이스(Azure Cosmos DB)에 씁니다. 디바이스는 메시지를 프로토콜 버퍼(protobuf) 형식으로 보냅니다. protobuf는 효율적이고 자체 설명적 직렬화 형식입니다.

    이러한 메시지에는 부분 업데이트가 포함되어 있습니다. 각 드론은 모든 상태 필드가 포함된 '키 프레임' 메시지를 고정 간격으로 보냅니다. 상태 메시지에는 키 프레임 간에 마지막 메시지 이후 변경된 필드만 포함됩니다. 이러한 동작은 대역폭과 전원을 절약해야 하는 많은 IoT 디바이스에서 일반적입니다.

  • 웹앱. 사용자는 웹 애플리케이션을 통해 디바이스를 검색하고 마지막으로 알려진 디바이스의 상태를 쿼리할 수 있습니다. 사용자는 애플리케이션에 로그인하고 Microsoft Entra ID로 인증해야 합니다. 애플리케이션은 앱에 액세스할 수 있는 권한이 있는 사용자의 요청만 허용합니다.

다음은 쿼리의 결과를 보여 주는 웹앱의 스크린샷입니다.

Screenshot of client app

애플리케이션 설계

Fabrikam은 Azure Functions를 사용하여 애플리케이션 비즈니스 논리를 구현하기로 결정했습니다. Azure Functions는 "FaaS(Functions as a Service)"의 예입니다. 이 컴퓨팅 모델에서 함수는 클라우드에 배포되어 호스팅 환경에서 실행되는 코드의 조각입니다. 이 호스팅 환경은 코드를 실행하는 서버를 완전히 추상화합니다.

서버리스 방식을 선택해야 하는 이유는?

Functions를 사용하는 서버리스 아키텍처는 이벤트 구동 아키텍처의 한 예입니다. 함수 코드는 함수 외부의 일부 이벤트(여기서는 드론의 메시지 또는 클라이언트 애플리케이션의 HTTP 요청)에서 트리거됩니다. 함수 앱을 사용하면 트리거에 대한 코드를 작성할 필요가 없습니다. 트리거에 응답하여 실행되는 코드만 작성합니다. 즉, 메시징과 같은 인프라 관련 문제를 처리하기 위해 많은 코드를 작성하는 대신 비즈니스 논리에만 집중할 수 있습니다.

또한 서버리스 아키텍처를 사용하는 경우 다음과 같은 몇 가지 운영상의 이점도 있습니다.

  • 서버를 관리할 필요가 없습니다.
  • 컴퓨팅 리소스가 필요에 따라 동적으로 할당됩니다.
  • 코드를 실행하는 데 사용된 컴퓨팅 리소스에 대한 요금만 부과됩니다.
  • 컴퓨팅 리소스가 트래픽에 기반한 요청에 따라 크기 조정됩니다.

아키텍처

다음 다이어그램에서는 애플리케이션에 대한 개괄적인 아키텍처를 보여 줍니다.

Diagram showing the high-level architecture of the serverless Functions application.

하나의 데이터 흐름에서 화살표는 디바이스에서 Event Hubs로 이동하고 함수 앱을 트리거하는 메시지를 표시합니다. 앱에서 한 화살표는 스토리지 큐로 이동하는 배달 못한 편지 메시지를 표시하고 다른 화살표는 Azure Cosmos DB에 쓰는 것을 보여 줍니다. 다른 데이터 흐름에서 화살표는 CDN을 통해 Blob Storage 정적 웹 호스팅에서 정적 파일을 가져오는 클라이언트 웹앱을 보여 줍니다. 다른 화살표는 API Management를 통과하는 클라이언트 HTTP 요청을 보여줍니다. API Management에서 하나의 화살표는 Azure Cosmos DB에서 데이터를 트리거하고 읽는 함수 앱을 보여줍니다. 또 다른 화살표는 Microsoft Entra ID를 통한 인증을 보여줍니다. 또한 사용자가 Microsoft Entra ID에 로그인합니다.

이벤트 수집:

  1. 드론 메시지는 Azure Event Hubs에서 수집됩니다.
  2. Event Hubs는 메시지 데이터를 포함한 이벤트 스트림을 생성합니다.
  3. 이러한 이벤트는 Azure Functions 앱을 트리거하여 처리합니다.
  4. 결과는 Azure Cosmos DB에 저장됩니다.

웹앱:

  1. 정적 파일은 Blob Storage의 CDN에서 처리됩니다.
  2. 사용자가 Microsoft Entra ID를 사용하여 웹앱에 로그인합니다.
  3. Azure API Management는 REST API 엔드포인트를 공개하는 게이트웨이로 작동합니다.
  4. 클라이언트의 HTTP 요청은 Azure Cosmos DB에서 읽은 Azure Functions 앱을 트리거하고 결과를 반환합니다.

이 애플리케이션은 위에서 설명한 두 가지 기능 블록에 해당하는 다음 두 개의 참조 아키텍처를 기반으로 합니다.

이러한 문서에서는 개괄적 아키텍처, 솔루션에 사용되는 Azure 서비스 및 확장성, 보안 및 안정성에 대한 고려 사항에 대해 자세히 알아볼 수 있습니다.

드론 원격 분석 함수

먼저 Event Hubs에서 드론 메시지를 처리하는 함수부터 살펴보겠습니다. 이 함수는 RawTelemetryFunction이라는 클래스에 정의되어 있습니다.

namespace DroneTelemetryFunctionApp
{
    public class RawTelemetryFunction
    {
        private readonly ITelemetryProcessor telemetryProcessor;
        private readonly IStateChangeProcessor stateChangeProcessor;
        private readonly TelemetryClient telemetryClient;

        public RawTelemetryFunction(ITelemetryProcessor telemetryProcessor, IStateChangeProcessor stateChangeProcessor, TelemetryClient telemetryClient)
        {
            this.telemetryProcessor = telemetryProcessor;
            this.stateChangeProcessor = stateChangeProcessor;
            this.telemetryClient = telemetryClient;
        }
    }
    ...
}

이 클래스에는 종속성 주입을 사용하여 생성자에 삽입되는 몇 가지 종속성이 있습니다.

  • ITelemetryProcessorIStateChangeProcessor 인터페이스는 두 가지 도우미 개체를 정의합니다. 앞으로 살펴보겠지만 이러한 개체는 대부분의 작업을 수행합니다.

  • TelemetryClient는 Application Insights SDK의 일부입니다. 사용자 지정 애플리케이션 메트릭을 Application Insights에 보내는 데 사용됩니다.

나중에 종속성 주입을 구성하는 방법을 살펴보겠습니다. 지금은 이러한 종속성이 존재한다고 가정하겠습니다.

Event Hubs 트리거 구성

함수의 논리는 RunAsync라는 비동기 메서드로 구현됩니다. 메서드 서명은 다음과 같습니다.

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
    ILogger logger)
{
    // implementation goes here
}

메서드에서 사용하는 매개 변수는 다음과 같습니다.

  • messages는 이벤트 허브 메시지의 배열입니다.
  • deadLetterMessages는 배달 못한 편지 메시지를 저장하는 데 사용되는 Azure Storage Queue입니다.
  • logging은 애플리케이션 로그를 기록하는 로깅 인터페이스를 제공합니다. 이러한 로그는 Azure Monitor로 보내집니다.

messages 매개 변수의 EventHubTrigger 특성은 트리거를 구성합니다. 특성의 속성은 이벤트 허브 이름, 연결 문자열 및 소비자 그룹을 지정합니다. (소비자 그룹은 Event Hubs 이벤트 스트림의 격리된 보기입니다. 이 추상화는 동일한 이벤트 허브의 여러 소비자를 허용합니다.)

특성 속성 중 일부에 있는 백분율 기호(%)에 주의하세요. 이러한 기호는 속성에서 앱 설정의 이름을 지정하고 실제 값은 런타임에 해당 앱 설정에서 가져온다는 것을 나타냅니다. 그렇지 않고 백분율 기호가 없으면 속성에서 리터럴 값을 제공합니다.

Connection 속성은 예외입니다. 이 속성은 항상 앱 설정 이름을 지정하며, 결코 리터럴 값이 아니므로 백분율 기호가 필요하지 않습니다. 이렇게 구분하는 이유는 연결 문자열이 소스 코드에 체크 인하면 안 되는 비밀이기 때문입니다.

다른 두 속성(이벤트 허브 이름 및 소비자 그룹)은 연결 문자열처럼 중요한 데이터가 아니지만 하드 코딩하는 대신 앱 설정에 배치하는 것이 좋습니다. 이렇게 하면 앱을 다시 컴파일하지 않고도 업데이트할 수 있습니다.

이 트리거 구성에 대한 자세한 내용은 Azure Functions에 Azure Event Hubs 바인딩 사용을 참조하세요.

메시지 처리 논리

메시지의 일괄 처리를 처리하는 RawTelemetryFunction.RunAsync 메서드의 구현은 다음과 같습니다.

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
    ILogger logger)
{
    telemetryClient.GetMetric("EventHubMessageBatchSize").TrackValue(messages.Length);

    foreach (var message in messages)
    {
        DeviceState deviceState = null;

        try
        {
            deviceState = telemetryProcessor.Deserialize(message.Body.Array, logger);

            try
            {
                await stateChangeProcessor.UpdateState(deviceState, logger);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error updating status document", deviceState);
                await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message, DeviceState = deviceState });
            }
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
            await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
        }
    }
}

함수가 호출되면 messages 매개 변수에는 이벤트 허브의 메시지 배열이 포함됩니다. 일반적으로 메시지를 일괄 처리하면 메시지를 한 번에 하나씩 읽는 것보다 더 나은 성능을 얻을 수 있습니다. 그러나 복원력이 있는 함수이고 오류 및 예외를 정상적으로 처리하는지 확인해야 합니다. 그렇지 않으면 함수에서 일괄 처리 중에 처리되지 않은 예외를 throw하는 경우 나머지 메시지가 손실될 수 있습니다. 이 고려 사항은 오류 처리 섹션에서 자세히 설명합니다.

그러나 예외 처리를 무시하는 경우 각 메시지에 대한 처리 논리는 다음과 같이 간단합니다.

  1. 디바이스 상태 변경이 포함된 메시지를 역직렬화하는 ITelemetryProcessor.Deserialize를 호출합니다.
  2. 상태 변경을 처리하는 IStateChangeProcessor.UpdateState를 호출합니다.

이 두 가지 메서드를 자세히 살펴보기 위해 Deserialize 메서드부터 시작하겠습니다.

Deserialize 메서드

TelemetryProcess.Deserialize 메서드는 메시지 페이로드가 포함된 바이트 배열을 사용합니다. 이 페이로드를 역직렬화하고, 드론의 상태를 나타내는 DeviceState 개체를 반환합니다. 상태는 마지막으로 알려진 상태의 델타만 포함된 부분 업데이트를 나타낼 수 있습니다. 따라서 메서드는 역직렬화된 페이로드에서 null 필드를 처리해야 합니다.

public class TelemetryProcessor : ITelemetryProcessor
{
    private readonly ITelemetrySerializer<DroneState> serializer;

    public TelemetryProcessor(ITelemetrySerializer<DroneState> serializer)
    {
        this.serializer = serializer;
    }

    public DeviceState Deserialize(byte[] payload, ILogger log)
    {
        DroneState restored = serializer.Deserialize(payload);

        log.LogInformation("Deserialize message for device ID {DeviceId}", restored.DeviceId);

        var deviceState = new DeviceState();
        deviceState.DeviceId = restored.DeviceId;

        if (restored.Battery != null)
        {
            deviceState.Battery = restored.Battery;
        }
        if (restored.FlightMode != null)
        {
            deviceState.FlightMode = (int)restored.FlightMode;
        }
        if (restored.Position != null)
        {
            deviceState.Latitude = restored.Position.Value.Latitude;
            deviceState.Longitude = restored.Position.Value.Longitude;
            deviceState.Altitude = restored.Position.Value.Altitude;
        }
        if (restored.Health != null)
        {
            deviceState.AccelerometerOK = restored.Health.Value.AccelerometerOK;
            deviceState.GyrometerOK = restored.Health.Value.GyrometerOK;
            deviceState.MagnetometerOK = restored.Health.Value.MagnetometerOK;
        }
        return deviceState;
    }
}

이 메서드는 다른 도우미 인터페이스인 ITelemetrySerializer<T>를 사용하여 원시 메시지를 역직렬화합니다. 그런 다음, 결과를 더 쉽게 사용할 수 있는 POCO 모델로 변환합니다. 이 설계는 직렬화 구현 세부 정보에서 처리 논리를 격리하는 데 도움이 됩니다. ITelemetrySerializer<T> 인터페이스는 디바이스 시뮬레이터에서 시뮬레이션된 디바이스 이벤트를 생성하고 Event Hubs로 보내는 데 사용되는 공유 라이브러리에 정의되어 있습니다.

using System;

namespace Serverless.Serialization
{
    public interface ITelemetrySerializer<T>
    {
        T Deserialize(byte[] message);

        ArraySegment<byte> Serialize(T message);
    }
}

UpdateState 메서드

StateChangeProcessor.UpdateState 메서드는 상태 변경 내용을 적용합니다. 각 드론에 대해 마지막으로 알려진 상태는 JSON 문서로 Azure Cosmos DB에 저장됩니다. 드론에서 부분 업데이트를 보내므로 업데이트를 가져오면 애플리케이션에서 단순히 해당 문서를 덮어쓸 수는 없습니다. 대신 이전 상태를 가져와서 필드를 병합한 다음, upsert 작업을 수행해야 합니다.

public class StateChangeProcessor : IStateChangeProcessor
{
    private IDocumentClient client;
    private readonly string cosmosDBDatabase;
    private readonly string cosmosDBCollection;

    public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
    {
        this.client = client;
        this.cosmosDBDatabase = options.Value.COSMOSDB_DATABASE_NAME;
        this.cosmosDBCollection = options.Value.COSMOSDB_DATABASE_COL;
    }

    public async Task<ResourceResponse<Document>> UpdateState(DeviceState source, ILogger log)
    {
        log.LogInformation("Processing change message for device ID {DeviceId}", source.DeviceId);

        DeviceState target = null;

        try
        {
            var response = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(cosmosDBDatabase, cosmosDBCollection, source.DeviceId),
                                                            new RequestOptions { PartitionKey = new PartitionKey(source.DeviceId) });

            target = (DeviceState)(dynamic)response.Resource;

            // Merge properties
            target.Battery = source.Battery ?? target.Battery;
            target.FlightMode = source.FlightMode ?? target.FlightMode;
            target.Latitude = source.Latitude ?? target.Latitude;
            target.Longitude = source.Longitude ?? target.Longitude;
            target.Altitude = source.Altitude ?? target.Altitude;
            target.AccelerometerOK = source.AccelerometerOK ?? target.AccelerometerOK;
            target.GyrometerOK = source.GyrometerOK ?? target.GyrometerOK;
            target.MagnetometerOK = source.MagnetometerOK ?? target.MagnetometerOK;
        }
        catch (DocumentClientException ex)
        {
            if (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
            {
                target = source;
            }
        }

        var collectionLink = UriFactory.CreateDocumentCollectionUri(cosmosDBDatabase, cosmosDBCollection);
        return await client.UpsertDocumentAsync(collectionLink, target);
    }
}

이 코드는 IDocumentClient 인터페이스를 사용하여 Azure Cosmos DB에서 문서를 가져옵니다. 문서가 있으면 새 상태 값이 기존 문서로 병합됩니다. 그렇지 않으면 새 문서가 만들어집니다. 두 경우는 모두 UpsertDocumentAsync 메서드에서 처리됩니다.

이 코드는 문서가 이미 있고 병합될 수 있는 경우에 맞게 최적화되어 있습니다. 지정된 드론의 첫 번째 원격 분석 메시지에는 해당 드론에 대한 문서가 없으므로 ReadDocumentAsync 메서드에서 예외를 throw합니다. 첫 번째 메시지가 표시되면 문서를 사용할 수 있습니다.

이 클래스는 종속성 주입을 사용하여 Azure Cosmos DB에는 IDocumentClient를 삽입하고, 구성 설정에는 IOptions<T>를 삽입합니다. 나중에 종속성 주입을 설정하는 방법에 대해 알아보겠습니다.

참고

Azure Functions는 Azure Cosmos DB에 대한 출력 바인딩을 지원합니다. 함수 앱에서는 이 바인딩을 통해 코드 없이 문서를 Azure Cosmos DB에 쓸 수 있습니다. 그러나 사용자 지정 upsert 논리가 필요하므로 이 특정 시나리오에서는 출력 바인딩이 작동하지 않습니다.

오류 처리

앞에서 설명한 대로 RawTelemetryFunction 함수 앱은 루프의 메시지 일괄 처리를 처리합니다. 즉, 함수에서 예외를 정상적으로 처리하고 나머지 일괄 처리를 계속 처리해야 합니다. 그렇지 않으면 메시지가 삭제될 수 있습니다.

메시지를 처리할 때 예외가 발생하면 함수에서 메시지를 배달 못한 편지 큐에 넣습니다.

catch (Exception ex)
{
    logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
    await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
}

배달 못한 편지 큐는 스토리지 큐에 대한 출력 바인딩을 사용하여 정의됩니다.

[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]  // App setting that holds the connection string
public async Task RunAsync(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
    [Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,  // output binding
    ILogger logger)

여기서 Queue 특성은 출력 바인딩을 지정하고, StorageAccount 특성은 스토리지 계정에 대한 연결 문자열이 포함된 앱 설정의 이름을 지정합니다.

배포 팁: 스토리지 계정을 만드는 Resource Manager 템플릿에서는 앱 설정을 연결 문자열로 자동으로 채울 수 있습니다. 팁은 listKeys 함수를 사용하는 것입니다.

큐에 대한 스토리지 계정을 만드는 템플릿의 섹션은 다음과 같습니다.

    {
        "name": "[variables('droneTelemetryDeadLetterStorageQueueAccountName')]",
        "type": "Microsoft.Storage/storageAccounts",
        "location": "[resourceGroup().location]",
        "apiVersion": "2017-10-01",
        "sku": {
            "name": "[parameters('storageAccountType')]"
        },

함수 앱을 만드는 템플릿의 섹션은 다음과 같습니다.


    {
        "apiVersion": "2015-08-01",
        "type": "Microsoft.Web/sites",
        "name": "[variables('droneTelemetryFunctionAppName')]",
        "location": "[resourceGroup().location]",
        "tags": {
            "displayName": "Drone Telemetry Function App"
        },
        "kind": "functionapp",
        "dependsOn": [
            "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
            ...
        ],
        "properties": {
            "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
            "siteConfig": {
                "appSettings": [
                    {
                        "name": "DeadLetterStorage",
                        "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('droneTelemetryDeadLetterStorageQueueAccountName'), ';AccountKey=', listKeys(variables('droneTelemetryDeadLetterStorageQueueAccountId'),'2015-05-01-preview').key1)]"
                    },
                    ...

이 섹션에서는 listKeys 함수를 사용하여 값이 채워지는 DeadLetterStorage라는 앱 설정을 정의합니다. 함수 앱 리소스를 스토리지 계정 리소스에 종속시켜야 합니다(dependsOn 요소 참조). 이렇게 하면 스토리지 계정이 먼저 만들어지고 연결 문자열을 사용할 수 있습니다.

종속성 주입 설정

다음 코드에서는 RawTelemetryFunction 함수에 대한 종속성 주입을 설정합니다.

[assembly: FunctionsStartup(typeof(DroneTelemetryFunctionApp.Startup))]

namespace DroneTelemetryFunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddOptions<StateChangeProcessorOptions>()
                .Configure<IConfiguration>((configSection, configuration) =>
                {
                    configuration.Bind(configSection);
                });

            builder.Services.AddTransient<ITelemetrySerializer<DroneState>, TelemetrySerializer<DroneState>>();
            builder.Services.AddTransient<ITelemetryProcessor, TelemetryProcessor>();
            builder.Services.AddTransient<IStateChangeProcessor, StateChangeProcessor>();

            builder.Services.AddSingleton<IDocumentClient>(ctx => {
                var config = ctx.GetService<IConfiguration>();
                var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
                var cosmosDBKey = config.GetValue<string>("CosmosDBKey");
                return new DocumentClient(new Uri(cosmosDBEndpoint), cosmosDBKey);
            });
        }
    }
}

.NET용으로 작성된 Azure Functions는 ASP.NET Core 종속성 주입 프레임워크를 사용할 수 있습니다. 기본 개념은 어셈블리에 대한 시작 메서드를 선언하는 것입니다. 이 메서드는 DI 종속성을 선언하는 데 사용되는 IFunctionsHostBuilder 인터페이스를 사용합니다. 이 작업은 Services 개체의 Add* 메서드를 호출하여 수행합니다. 종속성을 추가하는 경우 해당 종속성의 수명을 지정합니다.

  • Transient 개체는 요청될 때마다 만들어집니다.
  • Scoped 개체는 함수를 실행할 때마다 한 번 만들어집니다.
  • Singleton 개체는 함수 호스트의 수명 기간 내에 함수 실행 간에 다시 사용됩니다.

이 예제에서 TelemetryProcessorStateChangeProcessor 개체는 일시적으로 선언됩니다. 이 방법은 간단한 상태 비저장 서비스에 적합합니다. 반면 DocumentClient 클래스는 최상의 성능을 위해 싱글톤이어야 합니다. 자세한 내용은 Azure Cosmos DB 및 .NET에 대한 성능 팁을 참조하세요.

RawTelemetryFunction 코드를 다시 참조하면 DI 설정 코드에 나타나지 않는 다른 종속성, 즉 애플리케이션 메트릭을 기록하는 데 사용되는 TelemetryClient 클래스가 표시됩니다. Functions 런타임에서 이 클래스를 DI 컨테이너에 자동으로 등록하므로 명시적으로 등록할 필요가 없습니다.

Azure Functions의 DI에 대한 자세한 내용은 다음 문서를 참조하세요.

DI에 구성 설정 전달

일부 구성 값을 사용하여 개체를 초기화해야 하는 경우도 있습니다. 일반적으로 이러한 설정은 앱 설정 또는 Azure Key Vault(비밀의 경우)에서 제공되어야 합니다.

이 애플리케이션에는 두 가지 예제가 있습니다. 먼저 DocumentClient 클래스는 Azure Cosmos DB 서비스 엔드포인트와 키를 가져옵니다. 이 개체의 경우 애플리케이션은 DI 컨테이너에서 호출할 람다를 등록합니다. 이 람다는 IConfiguration 인터페이스를 사용하여 구성 값을 읽습니다.

builder.Services.AddSingleton<IDocumentClient>(ctx => {
    var config = ctx.GetService<IConfiguration>();
    var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
    var cosmosDBKey = config.GetValue<string>("CosmosDBKey");
    return new DocumentClient(new Uri(cosmosDBEndpoint), cosmosDBKey);
});

두 번째 예제는 StateChangeProcessor 클래스입니다. 이 개체의 경우 Options 패턴이라는 방법을 사용합니다. 작동 방식은 다음과 같습니다.

  1. 구성 설정이 포함된 T 클래스를 정의합니다. 이 경우 Azure Cosmos DB 데이터베이스 이름 및 컬렉션 이름입니다.

    public class StateChangeProcessorOptions
    {
        public string COSMOSDB_DATABASE_NAME { get; set; }
        public string COSMOSDB_DATABASE_COL { get; set; }
    }
    
  2. T 클래스를 DI에 대한 Options 클래스로 추가합니다.

    builder.Services.AddOptions<StateChangeProcessorOptions>()
        .Configure<IConfiguration>((configSection, configuration) =>
        {
            configuration.Bind(configSection);
        });
    
  3. 구성하는 클래스의 생성자에 IOptions<T> 매개 변수를 포함시킵니다.

    public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
    

DI 시스템은 자동으로 Options 클래스를 구성 값으로 채우고 이를 생성자에 전달합니다.

이 방법에는 다음과 같은 몇 가지 장점이 있습니다.

  • 구성 값의 원본에서 클래스를 분리합니다.
  • 환경 변수 또는 JSON 구성 파일과 같은 다양한 구성 원본을 쉽게 설정합니다.
  • 단위 테스트를 간소화합니다.
  • 오류가 발생할 가능성이 스칼라 값을 전달하는 것보다 낮은 강력한 형식의 Options 클래스를 사용합니다.

GetStatus 함수

이 솔루션의 다른 Functions 앱은 간단한 REST API를 구현하여 마지막으로 알려진 드론 상태를 가져옵니다. 이 함수는 GetStatusFunction이라는 클래스에 정의되어 있습니다. 이 함수에 대한 전체 코드는 다음과 같습니다.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using System.Threading.Tasks;

namespace DroneStatusFunctionApp
{
    public static class GetStatusFunction
    {
        public const string GetDeviceStatusRoleName = "GetStatus";

        [FunctionName("GetStatusFunction")]
        public static IActionResult Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)]HttpRequest req,
            [CosmosDB(
                databaseName: "%COSMOSDB_DATABASE_NAME%",
                collectionName: "%COSMOSDB_DATABASE_COL%",
                ConnectionStringSetting = "COSMOSDB_CONNECTION_STRING",
                Id = "{Query.deviceId}",
                PartitionKey = "{Query.deviceId}")] dynamic deviceStatus,
            ClaimsPrincipal principal,
            ILogger log)
        {
            log.LogInformation("Processing GetStatus request.");

            if (!principal.IsAuthorizedByRoles(new[] { GetDeviceStatusRoleName }, log))
            {
                return new UnauthorizedResult();
            }

            string deviceId = req.Query["deviceId"];
            if (deviceId == null)
            {
                return new BadRequestObjectResult("Missing DeviceId");
            }

            if (deviceStatus == null)
            {
                return new NotFoundResult();
            }
            else
            {
                return new OkObjectResult(deviceStatus);
            }
        }
    }
}

이 함수는 HTTP 트리거를 사용하여 HTTP GET 요청을 처리합니다. 함수에서 Azure Cosmos DB 입력 바인딩을 사용하여 요청된 문서를 가져옵니다. 한 가지 고려 사항은 함수 내에서 권한 부여 논리를 수행하기 전에 먼저 이 바인딩이 실행된다는 것입니다. 권한이 없는 사용자가 문서를 요청하더라도 함수 바인딩에서 여전히 문서를 가져옵니다. 그런 다음, 권한 부여 코드에서 401을 반환하므로 사용자는 해당 문서를 볼 수 없습니다. 이 동작이 허용되는지 여부는 요구 사항에 따라 달라질 수 있습니다. 예를 들어 이 방법은 중요한 데이터에 대한 데이터 액세스를 감사하는 것을 어렵게 만들 수 있습니다.

인증 및 권한 부여

웹앱은 Microsoft Entra ID를 사용하여 사용자를 인증합니다. 앱은 브라우저에서 실행되는 SPA(단일 페이지 애플리케이션)이므로 다음과 같은 암시적 허용 흐름이 적합합니다.

  1. 웹앱은 사용자를 ID 공급자(이 경우 Microsoft Entra ID)로 리디렉션합니다.
  2. 사용자가 자신의 자격 증명을 입력합니다.
  3. ID 공급자가 액세스 토큰을 사용하여 웹앱으로 다시 리디렉션합니다.
  4. 웹앱에서 요청을 웹 API에 보내고 액세스 토큰을 권한 부여 헤더에 포함시킵니다.

Implicit flow diagram

함수 애플리케이션은 코드가 0인 사용자를 인증하도록 구성할 수 있습니다. 자세한 내용은 Azure App Service에서 인증 및 권한 부여를 참조하세요.

반면 권한 부여에는 일반적으로 일부 비즈니스 논리가 필요합니다. Microsoft Entra ID는 클레임 기반 인증을 지원합니다. 이 모델에서 사용자의 ID는 ID 공급자에서 제공하는 클레임 세트로 표시됩니다. 클레임은 이름 또는 이메일 주소와 같이 사용자에 대한 정보 부분일 수 있습니다.

액세스 토큰에는 사용자 클레임의 하위 세트가 포함됩니다. 이 중에는 사용자가 할당된 애플리케이션 역할이 있습니다.

함수의 principal 매개 변수는 액세스 토큰의 클레임이 포함된 ClaimsPrincipal 개체입니다. 각 클레임은 클레임 유형 및 클레임 값의 키/값 쌍입니다. 애플리케이션은 이러한 정보를 사용하여 요청에 대한 권한을 부여합니다.

다음 확장 메서드는 ClaimsPrincipal 개체에 역할 세트가 포함되어 있는지 여부를 테스트합니다. 지정된 역할 중 하나가 없으면 false를 반환합니다. 이 메서드에서 false를 반환하면 함수는 HTTP 401(권한 없음)을 반환합니다.

namespace DroneStatusFunctionApp
{
    public static class ClaimsPrincipalAuthorizationExtensions
    {
        public static bool IsAuthorizedByRoles(
            this ClaimsPrincipal principal,
            string[] roles,
            ILogger log)
        {
            var principalRoles = new HashSet<string>(principal.Claims.Where(kvp => kvp.Type == "roles").Select(kvp => kvp.Value));
            var missingRoles = roles.Where(r => !principalRoles.Contains(r)).ToArray();
            if (missingRoles.Length > 0)
            {
                log.LogWarning("The principal does not have the required {roles}", string.Join(", ", missingRoles));
                return false;
            }

            return true;
        }
    }
}

이 애플리케이션의 인증 및 권한 부여에 대한 자세한 내용은 참조 아키텍처의 보안 고려 사항 섹션을 참조하세요.

다음 단계

이 참조 솔루션의 작동 방식을 파악한 후 유사한 솔루션에 대한 모범 사례 및 권장 사항을 알아봅니다.

Azure Functions는 하나의 Azure 컴퓨팅 옵션일 뿐입니다. 컴퓨팅 기술 선택에 대한 도움말은 애플리케이션에 대한 Azure 컴퓨팅 서비스 선택을 참조하세요.