Amazon Web Services 한국 블로그

AWS Lambda로 진화형 아키텍처 개발하기

민첩성(Agility)을 통해 필요에 따라 새로운 기능을 추가하거나 새로운 인프라를 도입하여 워크 로드를 빠르게 발전시킬 수 있습니다. 코드 기반에서 민첩성을 달성하기 위한 주요 특성은 느슨하게 결합된(loosely coupled) 컴포넌트와 강력한 캡슐화(encapsulation)입니다.

느슨한 결합은 테스트 커버리지를 개선하고 원자적(Atomic) 리팩토링을 실행하는 데 도움이 될 수 있습니다. 캡슐화를 사용하면 구현 논리를 노출하지 않고 서비스와 상호 작용하는 데 필요한 것만 노출합니다.

진화형 아키텍처를 사용하면 설계 시점에 민첩성을 달성하는 데 도움이 될 수 있습니다. “Building Evolutionary Architectures”라는 책에서 이 아키텍처는 “다양한 관점에서 유도된 점진적인 변경을 지원하는” 아키텍처로 정의됩니다.

이 글은 모듈 방식으로 AWS Lambda 함수에 대한 코드를 구성하는 방법에 중점을 둡니다. 헥사고날 아키텍처 패턴이 제공하는 진화하는 측면을 수용하고 다양한 사용 사례에 적용하는 방법을 보여줍니다.

포트와 어댑터 적용하기

헥사고날 아키텍처는 포트 및 어댑터 아키텍처라고도 합니다. 도메인 로직을 캡슐화하고 인프라 또는 클라이언트 요청과 같은 다른 세부 구현 정보를 분리하는 데 사용되는 아키텍처 패턴입니다.

  1. 도메인 로직: 애플리케이션이 수행해야 하는 작업을 나타내며 외부 세계와의 상호작용을 추상화합니다.
  2. 포트: 도메인 로직을 통해(왼쪽의) 프라이머리 액터들이 애플리케이션과 상호작용할 수 있는 방법을 제공합니다. 도메인 로직은 필요할 때 세컨더리 액터(오른쪽)와 상호 작용하기 위해 포트를 사용합니다.
  3. 어댑터: 한 인터페이스를 다른 인터페이스로 변환하기 위한 설계 패턴입니다. 어댑터는 프라이머리 혹은 세컨더리 액터와 상호작용하기 위한 로직을 래핑(wrap) 합니다.
  4. 프라이머리 액터: 웹 훅, UI 요청 또는 테스트 스크립트와 같은 시스템의 사용자입니다.
  5. 세컨더리 액터: 애플리케이션에서 사용되는 서비스로서 보통 외부 시스템을 의미합니다. 예를 들면, 저장소 역할을 하는 데이터베이스 메시지 수신 처리를 담당하는 메시지 큐 등이 있습니다.

Lambda 함수와 헥사고날 아키텍처

Lambda 함수는 특정 작업을 수행하는 컴퓨팅 로직의 단위입니다. 예를 들어 Lambda 함수는 Amazon Kinesis 스트림의 데이터를 조작하거나 Amazon SQS 대기열의 메시지를 처리할 수 있습니다.

Lambda 함수에서 헥사고날 아키텍처는 새로운 비즈니스 요구 사항을 구현하고 워크 로드의 민첩성을 개선하는 데 도움이 될 수 있습니다. 이 접근 방식은 문제를 분리하고 인프라에서 도메인 로직을 분리하는 데 도움이 될 수 있습니다. 개발팀은 새로운 기능의 구현을 단순화하고 여러 개발자 사이의 작업을 병렬화할 수도 있습니다.

다음 예제에서는 Stock 가치를 반환하는 서비스를 소개합니다. 이 서비스는 대시보드에 정보를 표시하는 프런트엔드 애플리케이션을 위해 다양한 통화(currency)를 지원합니다. 통화 간의 Stock 가치 변환은 실시간으로 이루어집니다. 서비스는 클라이언트가 요청할 때마다 환율을 검색해야 합니다.

이 서비스의 아키텍처는 REST API를 노출하는 Amazon API Gateway 엔드 포인트를 사용합니다. 클라이언트가 API를 호출하면 Lambda 함수를 트리거 합니다. 그러면 DynamoDB 테이블에서 Stock 값을 가져오고 서드파티 엔드 포인트에서 통화 정보를 가져옵니다. 도메인 로직은 클라이언트 요청에 응답하기 전에 환율 정보를 사용하여 Stock 가치를 다른 통화로 변환합니다.

전체 예제는 AWS GitHub sample repository에서 확인할 수 있습니다. 이 서비스의 아키텍처는 다음과 같습니다.

  1. 클라이언트는 Lambda 함수를 호출하는 API Gateway 엔드 포인트에 요청을 보냅니다:
  2. 기본 어댑터가 요청을 수신하고 Stock ID를 캡처하여 포트로 전달합니다:
    exports.lambdaHandler = async (event) => {
        try{
        // retrieve the stockID from the request
            const stockID = event.pathParameters.StockID;
        // pass the stockID to the port
            const response = await getStocksRequest(stockID);
            return response
        } 
    };
  3. 포트는 도메인 로직과 통신하기 위한 인터페이스입니다. 어댑터와 도메인 로직 간의 분리를 적용할 수 있습니다. 이 접근 방식을 사용하면 코드 베이스의 다른 부분에 영향을 주지 않고 인프라와 도메인 로직을 개별적으로 변경하고 테스트할 수 있습니다:
    const retrieveStock = async (stockID) => {
        try{
        //use the port “stock” to access the domain logic
            const stockWithCurrencies = await stock.retrieveStockValues(stockID)
            return stockWithCurrencies;
        }
    }
  4. Stock ID를 전달하는 포트는 도메인 로직 진입점을 호출합니다. 도메인 로직은 DynamoDB 테이블에서 Stock의 가치를 가져온 다음 환율을 요청합니다. 포트를 통해 기본 어댑터에 계산된 값을 반환합니다. 포트는 외부 세계와의 인터페이스이기 때문에 도메인 로직은 항상 포트를 사용하여 어댑터와 상호 작용합니다:
    const CURRENCIES = [“USD”, “CAD”, “AUD”]
    const retrieveStockValues = async (stockID) => {
    try {
    //retrieve the stock value from DynamoDB using a port
            const stockValue = await Repository.getStockData(stockID);
    //fetch the currencies value using a port
            const currencyList = await Currency.getCurrenciesData(CURRENCIES);
    //calculate the stock value in different currencies
            const stockWithCurrencies = {
                stock: stockValue.STOCK_ID,
                values: {
                    "EUR": stockValue.VALUE
                }
            };
            for(const currency in currencyList.rates){
                stockWithCurrencies.values[currency] =  (stockValue.VALUE * currencyList.rates[currency]).toFixed(2)
            }
    // return the final computation to the port
            return stockWithCurrencies;
        }
    }

도메인 로직이 DynamoDB 테이블과 상호 작용하는 방식은 다음과 같습니다.

how domain logic interacts with DynamoDB

  1. 도메인 로직은 데이터베이스와 상호 작용하기 위해 리포지토리 포트를 사용합니다. 도메인과 어댑터 간에 직접 연결되지 않습니다:
    const getStockData = async (stockID) => {
        try{
    //the domain logic pass the request to fetch the stock ID value to this port
            const data = await getStockValue(stockID);
            return data.Item;
        }
    }
  2. 보조 어댑터는 DynamoDB 테이블에서 항목을 읽기 위한 로직을 캡슐화합니다. DynamoDB와 상호 작용하기 위한 모든 로직은 이 모듈에 캡슐화되어 있습니다:
    const getStockValue = async (stockID) => {
        let params = {
            TableName : DB_TABLE,
            Key:{
                'STOCK_ID': stockID
            }
        }
        try {
            const stockData = await documentClient.get(params).promise()
            return stockData
        }
    }

도메인 로직은 서드파티 서비스에서 환율을 가져오기 위해 어댑터를 사용합니다. 그런 다음 데이터를 처리하고 클라이언트 요청에 응답합니다.

how domain logic interacts with API

  1. 비즈니스 로직의 두 번째 작업은 환율을 검색하는 것입니다. 도메인 로직은 어댑터로 요청을 전달하는 프록시 역할을 하는 포트를 통해 작업을 요청합니다:
    const getCurrenciesData = async (currencies) => {
        try{
            const data = await getCurrencies(currencies);
            return data
        } 
    }
  2. 통화(currency) 서비스 어댑터는 서드파티 엔드 포인트에서 데이터를 가져오고 결과를 도메인 로직에 반환합니다.
    const getCurrencies = async (currencies) => {
        try{        
            const res = await axios.get(`http://api.mycurrency.io?symbols=${currencies.toString()}`)
            return res.data
        } 
    }

이처럼 지금까지 살펴본 8단계는 Lambda 함수 코드에 어떠한 방법으로 헥사고날 아키텍처를 사용하여 코드를 구조화할 수 있는지 보여줍니다.

캐시 레이어 추가하기

이 시나리오에서 프로덕션 Stock 서비스는 하루 동안 트래픽 급증을 경험합니다. 환율에 대한 외부 엔드 포인트는 이러한 트래픽 급증 수준을 지원할 수 없습니다. 이 문제를 해결하기 위해 Redis 클러스터를 사용하여 Amazon ElastiCache로 캐싱 전략을 구현할 수 있습니다. 이 접근 방식은 트래픽을 외부 서비스로 오프로드 하기 위해 cache-aside 패턴을 사용합니다.

일반적으로 코드 베이스에서 문제를 분리하지 않고 이러한 변경을 구현하기 위해 코드를 발전시키는 것은 어려울 수 있습니다. 그러나 이 예에서는 외부 서비스와 상호 작용하는 어댑터가 있습니다. 따라서 구현을 변경하여 cache-aside 패턴을 추가하고 나머지 애플리케이션과 동일한 API 계약을 유지할 수 있습니다:

const getCurrencies = async (currencies) => {
    try{        
// Check the exchange rates are available in the Redis cluster
        let res = await asyncClient.get("CURRENCIES");
        if(res){
// If present, return the value retrieved from Redis
            return JSON.parse(res);
        }
// Otherwise, fetch the data from the external service
        const getCurr = await axios.get(`http://api.mycurrency.io?symbols=${currencies.toString()}`)
// Store the new values in the Redis cluster with an expired time of 20 seconds
        await asyncClient.set("CURRENCIES", JSON.stringify(getCurr.data), "ex", 20);
// Return the data to the port
        return getCurr.data
    } 
}

이것은 어댑터에만 영향을 주는 간단한 변경입니다. 어댑터와 상호 작용하는 도메인 논리 및 포트는 그대로 유지되며 동일한 API 계약을 유지합니다. 이 아키텍처에서 제공하는 캡슐화는 코드 기반을 발전시키는 데 도움이 됩니다. 또한 어댑터만 수정된다는 점을 고려하여 많은 테스트를 그대로 유지할 수 있습니다.

도메인 로직을 컨테이너에서 Lambda 함수로 이동하기

이 예에서 이 워크 로드를 작업하는 팀은 원래 Amazon ECS와 AWS Fargate를 사용하여 컨테이너 내부의 모든 기능을 래핑(wrapping) 합니다. 개발자는 Stock 값에 대한 검색을 제공하기 위한 GET 메서드 경로를 정의합니다.

// This web application uses the Fastify framework 
  fastify.get('/stock/:StockID', async (request, reply) => {
    try{
        const stockID = request.params.StockID;
        const response = await getStocksRequest(stockID);
        return response
    } 
})

이 경우 경로의 진입점은 Lambda 함수와 정확히 동일합니다. 헥사고날 아키텍처가 제공하는 특성 덕분에 팀은 코드 베이스의 다른 어떤 것도 변경할 필요가 없습니다.

이 패턴을 사용하면 컨테이너 또는 가상 머신에서 여러 Lambda 함수로 코드를 보다 쉽게 리팩토링할 수 있습니다. 또한 다른 솔루션에서는 더 어려울 수 있는 높은 수준의 코드 이식성을 제공합니다.

장점과 단점

다른 모든 패턴과 마찬가지로 헥사고날 아키텍처를 사용하면 장점과 단점이 있습니다.

주요 이점은 다음과 같습니다:

  • 도메인 로직은 도메인 외부 세계에 구애받지 않고 독립적입니다.
  • 관심사의 분리(separation of concerns)는 코드 테스트 가능성을 높입니다.
  • 워크 로드의 기술적 부채를 줄이는 데 도움이 될 수 있습니다.

단점은 다음과 같습니다:

  • 패턴에는 시간의 선행 투자가 필요합니다.
  • 도메인 로직 구현은 독단적인 방식을 제시하지 않습니다.

Lambda 함수 개발에 이 아키텍처를 사용해야 하는지는 애플리케이션의 요구 사항에 따라 다릅니다. 워크 로드가 진화함에 따라 아키텍처에 대한 추가 구현 노력은 가치가 있을 수 있습니다.

이 패턴은 제공된 관심사의 캡슐화 및 분리로 인해 코드 테스트 가능성을 개선하는 데 도움이 될 수 있습니다. 이 접근 방식은 코드 마이그레이션과 같은 프로젝트에도 유용할 수 있으며 Lambda 이외의 컴퓨팅 솔루션에도 사용할 수 있습니다.

마무리

이 글은 헥사고날 아키텍처를 사용하여 워크 로드를 발전시키는 방법을 보여줍니다. 이글을 통해서 새로운 기능을 추가하거나, 기본 인프라를 변경하거나, 다른 컴퓨팅 솔루션 간에 코드 베이스를 이식하는 방법을 설명하였습니다. 이를 가능하게 하는 핵심은 느슨한 결합(loose coupling)과 강력한 캡슐화(strong encapsulation)입니다.

헥사고날 아키텍처 및 유사한 패턴에 대해 자세히 알아보려면 다음을 참고하실 수 있습니다:

더 많은 서버리스 학습 리소스를 살펴보려면 Serverless Land를 방문하세요.

– Luca Mezzalira, AWS 미디어 및 엔터테인먼트의 수석 솔루션 아키텍트

이 글은 AWS Compute 블로그의 Developing evolutionary architecture with AWS Lambda 한국어 번역본으로 주성식 AWS  솔루션 아키텍트가 번역 및 검수하였습니다.