Amazon Web Services 한국 블로그

고성능 애플리케이션 제작을 위한 AWS C++ SDK 커스터마이징

게임과 같은 고성능 애플리케이션 제작의 경우, 가상머신(VM)기반의 언어 사용시 성능상 제약이 있기에 C++와 같은 네이티브 바이너리를 직접 생성하는 언어를 쓰게 됩니다. C++의 경우 가비지 컬렉션(Garbage Collection)기능이 없는 언어이기 때문에 사용자가 직접 메모리 관리를 해주어야 합니다. 빈번히 메모리를 할당(new)하고 해제(delete)하는 일은 실행 시간 비용 측면에서도 상당히 비싼 편에 속합니다.

그래서, 고성능을 요구하는 게임 애플리케이션의 경우, 빈번한 메모리 할당과 파편화(memory fragmentation) 문제를 막기 위해서 메모리 풀링(pooling)을 주로 사용하게 됩니다. 이러한 기능을 제공하는 잘 알려진 tcmalloc이나 jemalloc과 같은 범용 메모리 관리자를 사용하는 경우가 많지만, 게임 엔진이나 서버 프레임워크의 경우에는 자체적으로 메모리 관리자를 제공하는 경우가 많습니다. 이러한 환경에서 AWS C++ SDK를 사용하기 위해서는 별도의 커스터마이징 작업이 필요합니다. 그래서, 지난 글에 이어 이번에는 AWS C++ SDK를 사용자의 상황에 맞게 커스터마이징하는 방법에 대해 다루겠습니다.

AWS C++ SDK는 메모리 관리자뿐만 아니라 HTTP 클라이언트, 작업 실행용 스레드(worker thread), 로그 에이전트(log agent)등 여러 부분에 대해 직접 커스터마이징 할 수 있는 인터페이스를 제공합니다.  이번 글에서는 커스텀 메모리 관리자 구현 방법 및 작업 실행을 위한 커스텀 스레드풀(worker thread pool)을 사용하는 방법에 대해 설명 드리겠습니다.

1. 커스텀 메모리 관리자 사용을 위한 AWS C++ SDK 빌드
커스텀 메모리 관리자를 사용하기 위하여 AWS C++ SDK를 동적 라이브러리 형태(DLL)로 빌드해 보도록 하겠습니다.  시작하기 전에 AWS C++ SDK의 코드를 최신 상태로 업데이트 하시기 바랍니다. 이후 AWS C++ SDK의 루트 폴더로 이동한 후, 빌드의 결과물이 생성될 폴더를 하나 만들고 cmake 명령을 통하여 Visual Studio용 솔루션 환경을 생성합니다. 동적 라이브러리 형태로 빌드하기 위해 이번에는 STATIC_LINKING 옵션을 주지 않습니다. 대신, 커스텀 메모리 관리자 사용을 알려주기 위하여CUSTOM_MEMORY_MANAGEMENT옵션을 주었습니다.

PS>  md sdk-dynamic-64
PS>  cd sdk-dynamic-64
PS> cmake .. -G "Visual Studio 14 2015 Win64" -DCUSTOM_MEMORY_MANAGEMENT=1 

위의 명령이 완료되면 sdk-dynamic-64 폴더 내에 AWS C++ SDK 빌드를 위한 Visual Studio 솔루션 파일이 생성되어 있을 것입니다. 솔루션 파일을 열고 빌드를 하기 전에, 커스텀 메모리 매니저를 사용하기 위해서 전처리 선언을 하나 추가해주어야 합니다. AWSMemory.cpp 파일에 대해 Property Pages를 열고 전처리 선언 부분에서USE_AWS_MEMORY_MANAGEMENT를 추가한 후에 빌드를 하시기 바랍니다. 빌드가 완료되면 해당 폴더 하위에 동적 라이브러리의 형태로 AWS C++ SDK가 생성이 됩니다. 생성된 DLL 파일들(예: aws-cpp-sdk-core.dll 등)은 추후에 애플리케이션 실행 시 필요하기에 따로 복사해 두시기 바랍니다.

<< AWSMemory.cpp에서 USE_AWS_MEMORY_MANAGEMENT 선언 추가 >>

2.커스텀 메모리 관리자를 사용하는 애플리케이션 작성
앞 단계에서 빌드한 AWS C++ SDK 동적 라이브러리를 사용하는 애플리케이션(ddb-sample: DynamoDB에 테이블을 생성하고 아이템을 대량으로 추가하고 읽어오는 프로그램)을 작성해보도록 하겠습니다.  프로그램 본체에 해당하는 main.cpp를 작성하면 다음과 같습니다. (동작 과정에 대한 자세한 내용은 주석을 참고)

#include <aws/core/Aws.h>
#include <aws/core/client/AsyncCallerContext.h>
#include <aws/core/client/ClientConfiguration.h>
#include <aws/core/client/CoreErrors.h>
#include <aws/core/http/HttpTypes.h>
#include <aws/core/utils/Outcome.h>
#include <aws/core/utils/ratelimiter/DefaultRateLimiter.h>
#include <aws/dynamodb/DynamoDBClient.h>
#include <aws/dynamodb/DynamoDBErrors.h>
#include <aws/dynamodb/model/CreateTableRequest.h>
#include <aws/dynamodb/model/DeleteTableRequest.h>
#include <aws/dynamodb/model/DescribeTableRequest.h>
#include <aws/dynamodb/model/ListTablesRequest.h>
#include <aws/dynamodb/model/UpdateTableRequest.h>
#include <aws/dynamodb/model/PutItemRequest.h>
#include <aws/dynamodb/model/GetItemRequest.h>
#include <aws/dynamodb/model/ScanRequest.h>
#include <aws/dynamodb/model/UpdateItemRequest.h>
#include <aws/dynamodb/model/DeleteItemRequest.h>
#include <aws/core/utils/threading/Executor.h>

// 커스텀 메모리 관리자
#include "MyCustomMemoryManager.h"

using namespace Aws;
using namespace Aws::Auth;
using namespace Aws::Http;
using namespace Aws::Client;
using namespace Aws::DynamoDB;
using namespace Aws::DynamoDB::Model;


// 생성할 테이블 이름 및 파티션 키 설정
static const char* HASH_KEY_NAME = "HashKey1";
static const char* SIMPLE_TABLE = "TestSimpleTable1";
static const char* ALLOCATION_TAG = "DynamoDbTest";

class DynamoDbTest
{
public:

	// 테스트용 클라이언트 생성
	void SetUpTest()
	{
		auto limiter = Aws::MakeShared<Aws::Utils::RateLimits::DefaultRateLimiter<>>(ALLOCATION_TAG, 200000);
	
		ClientConfiguration config;
		config.scheme = Scheme::HTTPS;
		config.connectTimeoutMs = 30000;
		config.requestTimeoutMs = 30000;
		config.readRateLimiter = limiter;
		config.writeRateLimiter = limiter;
		config.region = Region::AP_NORTHEAST_2;

		m_client = Aws::MakeShared<DynamoDBClient>(ALLOCATION_TAG, config);
	}
	
	// 테이블 생성 테스트
	void CreateTableTest()
	{
		CreateTableRequest createTableRequest;
		AttributeDefinition hashKey;
		hashKey.SetAttributeName(HASH_KEY_NAME);
		hashKey.SetAttributeType(ScalarAttributeType::S);
		createTableRequest.AddAttributeDefinitions(hashKey);
		KeySchemaElement hashKeySchemaElement;
		hashKeySchemaElement.WithAttributeName(HASH_KEY_NAME).WithKeyType(KeyType::HASH);
		createTableRequest.AddKeySchema(hashKeySchemaElement);
		ProvisionedThroughput provisionedThroughput;
		provisionedThroughput.SetReadCapacityUnits(10);
		provisionedThroughput.SetWriteCapacityUnits(10);
		createTableRequest.WithProvisionedThroughput(provisionedThroughput);
		createTableRequest.WithTableName(SIMPLE_TABLE);

		CreateTableOutcome createTableOutcome = m_client->CreateTable(createTableRequest);
		if (createTableOutcome.IsSuccess())
		{
			printf("[OK] CreateTable: %s\n", createTableOutcome.GetResult().GetTableDescription().GetTableName().c_str());
		}
		else
		{
			assert(createTableOutcome.GetError().GetErrorType() == DynamoDBErrors::RESOURCE_IN_USE);
		}

		// 테이블이 생성될 때까지 기다림
		DescribeTableRequest describeTableRequest;
		describeTableRequest.SetTableName(SIMPLE_TABLE);

		DescribeTableOutcome outcome = m_client->DescribeTable(describeTableRequest);

		while (true)
		{
			assert(outcome.IsSuccess());
			if (outcome.GetResult().GetTable().GetTableStatus() == TableStatus::ACTIVE)
			{
				break;
			}
			else
			{
				std::this_thread::sleep_for(std::chrono::seconds(1));
			}

			outcome = m_client->DescribeTable(describeTableRequest);
		}
	}

	// 테이블 삭제 테스트
	void DeleteTableTest()
	{
		DeleteTableRequest deleteTableRequest;
		deleteTableRequest.SetTableName(SIMPLE_TABLE);

		DeleteTableOutcome deleteTableOutcome = m_client->DeleteTable(deleteTableRequest);

		if (!deleteTableOutcome.IsSuccess())
		{
			assert(DynamoDBErrors::RESOURCE_NOT_FOUND == deleteTableOutcome.GetError().GetErrorType());
		}
		else
		{
			printf("[OK] DeleteTable: %s\n", deleteTableOutcome.GetResult().GetTableDescription().GetTableName().c_str());
		}
	}

	// 테이블에 아이템을 집어넣고 읽기 테스트 (비동기 방식으로)
	void PutAndGetItemAsyncTest()
	{
		auto putItemHandler = std::bind(&DynamoDbTest::PutItemOutcomeReceived, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4);
		auto getItemHandler = std::bind(&DynamoDbTest::GetItemOutcomeReceived, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4);

		/// 50개의 아이템을 비동기로 생성 요청
		Aws::String testValueColumnName = "TestValue";
		Aws::StringStream ss;

		for (int i = 0; i < 50; ++i)
		{
			ss << HASH_KEY_NAME << i;
			PutItemRequest putItemRequest;
			putItemRequest.SetTableName(SIMPLE_TABLE);
			AttributeValue hashKeyAttribute;
			hashKeyAttribute.SetS(ss.str());
			ss.str("");
			putItemRequest.AddItem(HASH_KEY_NAME, hashKeyAttribute);
			AttributeValue testValueAttribute;
			ss << testValueColumnName << i; 
                        testValueAttribute.SetS(ss.str()); 
                        putItemRequest.AddItem(testValueColumnName, testValueAttribute); 
                        ss.str(""); 
                        m_client->PutItemAsync(putItemRequest, putItemHandler);
		}

		// 모든 작업이 완료될 때까지 기다림
		std::unique_lock putItemResultLock(putItemResultMutex);
		putItemResultSemaphore.wait(putItemResultLock);

		// 기록된 아이템들을 다시 읽어와보기
		for (int i = 0; i < 50; ++i)
		{
			GetItemRequest getItemRequest;
			ss << HASH_KEY_NAME << i;
			AttributeValue hashKey;
			hashKey.SetS(ss.str());
			getItemRequest.AddKey(HASH_KEY_NAME, hashKey);
			getItemRequest.SetTableName(SIMPLE_TABLE);

			Aws::Vector attributesToGet;
			attributesToGet.push_back(HASH_KEY_NAME);
			attributesToGet.push_back(testValueColumnName);
			ss.str("");
			m_client->GetItemAsync(getItemRequest, getItemHandler);
		}

		std::unique_lock getItemResultLock(getItemResultMutex);
		getItemResultSemaphore.wait(getItemResultLock);

		for (int i = 0; i < 50; ++i)
		{
			GetItemOutcome outcome = getItemResultsFromCallbackTest[i];
			assert(outcome.IsSuccess());

			GetItemResult result = outcome.GetResult();
			auto returnedItemCollection = result.GetItem();

			printf("%s\n", returnedItemCollection[testValueColumnName].GetS().c_str());
		}

	}


public:
	// 아이템 집어넣기에 관한 핸들러
	void PutItemOutcomeReceived(const DynamoDBClient* sender, const PutItemRequest& request, const PutItemOutcome& outcome, const std::shared_ptr<const AsyncCallerContext>& context)
	{
		std::lock_guard locker(putItemResultMutex);
		putItemResultsFromCallbackTest.push_back(outcome);

		if (putItemResultsFromCallbackTest.size() == 50)
		{
			putItemResultSemaphore.notify_all();
		}
	}

	// 아이템 읽어오기에 관한 핸들러
	void GetItemOutcomeReceived(const DynamoDBClient* sender, const GetItemRequest& request, const GetItemOutcome& outcome, const std::shared_ptr<const AsyncCallerContext>& context)
	{
		std::lock_guard locker(getItemResultMutex);
		getItemResultsFromCallbackTest.push_back(outcome);

		if (getItemResultsFromCallbackTest.size() == 50)
		{
			getItemResultSemaphore.notify_all();
		}
	}


private:

	std::shared_ptr<DynamoDBClient> m_client;

	std::mutex putItemResultMutex;
	std::condition_variable putItemResultSemaphore;

	std::mutex getItemResultMutex;
	std::condition_variable getItemResultSemaphore;

	Aws::Vector<PutItemOutcome> putItemResultsFromCallbackTest;
	Aws::Vector<GetItemOutcome> getItemResultsFromCallbackTest;
};


int main()
{
	// 커스텀 메모리 관리자 선언
	MyCustomMemorySystem memorySystem;
	
	// SDK를 사용하기 위한 초기화
	Aws::SDKOptions options;
	options.memoryManagementOptions.memoryManager = &memorySystem;
	options.loggingOptions.logLevel = Aws::Utils::Logging::LogLevel::Info;
	Aws::InitAPI(options);


	// DynamoDB Test Sample
	{
		DynamoDbTest test;
		test.SetUpTest();
		
		// 테이블 생성 테스트 
		test.CreateTableTest();

		// 아이템 put/get 테스트
		test.PutAndGetItemAsyncTest();

		// 테이블 삭제 테스트
		test.DeleteTableTest();
	}


	// SDK에서 사용하는 내부 자원 해제
	Aws::ShutdownAPI(options);
    return 0;  
}

main함수의 첫 부분에서 커스텀 메모리 관리자를 사용하고 있음을 확인할 수 있습니다. 커스텀 메모리 관리자의 구현을 위해서는 AWS C++ SDK의Aws::Utils::Memory::MemorySystemInterface를 상속받아 구현하여야 합니다. 위의 코드에서 사용된 MyCustomMemoryInterface는 다음과 같은 방식으로 구현할 수 있습니다.

// MyCustomMemoryManager.h

#include <aws/core/utils/memory/MemorySystemInterface.h>
#include <aws/core/utils/memory/AWSMemory.h>

class MyCustomMemorySystem : public Aws::Utils::Memory::MemorySystemInterface
{
public:
	MyCustomMemorySystem() {}
	virtual ~MyCustomMemorySystem() {}

	// 커스텀 메모리 매니저 시작과 끝에서 불리는 함수. 필요시 구현할 것
	virtual void Begin() override {}
	virtual void End() override {}

	// 할당(new)시 불리는 AllocateMemory 인터페이스 구현
	virtual void* AllocateMemory(std::size_t blockSize, std::size_t alignment, const char *allocationTag = nullptr) override
	{
		// 여기에 메모리 할당 로직 직접 구현
		// 예: tcmalloc을 사용한다면, tc_malloc(blocksize);
		// 예: UnrealEngine에서 제공하는 할당자를 사용한다면, FMemory::Malloc()

		return _aligned_malloc(blockSize, alignment);
	}

	// 해제(delete)시 불리는 FreeMemory 인터페이스 구현
	virtual void FreeMemory(void* memoryPtr) override
	{
		// 여기에 메모리 해제 로직 직접 구현
		// 예: tcmalloc을 사용한다면, tc_free(memoryPtr)
		// 예: UnrealEngine의 경우는, FMalloc::Free()

		_aligned_free(memoryPtr);
	}

};

이런 방식으로 AWS C++ SDK에서 제공하는 메모리 시스템 인터페이스를 구현(override)하여 사용하면, 시스템 전역적인 기본 메모리 할당자 대신, 여러분이 직접 지정한 커스텀 메모리 관리자를 사용할 수 있습니다.  위의 샘플 코드에서는 직접 AllocateMemory 및 FreeMemory 멤버 함수를 구현한 내용을 담고는 있지는 않습니다만, Windows환경에서 제공하는 API를 이용한 고성능 커스텀 메모리 관리자의 구현 예제는 AWSTestSample의 LockFreeMemorySystem.h/.cpp 파일을 참고하시기 바랍니다.

그런데, 커스텀 메모리 관리자를 사용할 경우 주의할 점이 있습니다. AWS C++ SDK라이브러리로 (API 호출시) 전달되는 STL 객체의 경우, 표준 STL 컨테이너(std::vector 등)를 그대로 사용하면 안된다는 것입니다. 표준 STL 컨테이너는 기본적으로 시스템 전역 할당자(malloc/free)를 사용하기 때문입니다. 그래서, 커스텀 메모리 관리자를 사용하도록 (표준 STL 타입들을 wrapping한) 수정된STL 타입을 제공하고 있습니다. Aws::Vector, Aws::Map, Aws::String 등이 그 예입니다.  자세한 사용 예는 위의 샘플 코드(main.cpp)에서 확인하실 수 있습니다.

3. 애플리케이션을 위한 Visual Studio 솔루션 생성 및 실행
이전 단계에서 작성한 ddb-sample 애플리케이션용 Visual Studio 솔루션을 생성하기 위한 CMakeLists.txt를 작성하면 다음과 같습니다. 지난번 글과는 다르게AWS C++ SDK를 동적 라이브러리 형태로 사용하기 위해서 USE_IMPORT_EXPORT 선언을 추가하였습니다.

cmake_minimum_required(VERSION 2.8)
project(ddb-sample)
find_package(aws-sdk-cpp)
add_definitions(-DUSE_IMPORT_EXPORT)
add_executable(ddb-sample main.cpp MyCustomMemoryManager.h)
target_link_libraries(ddb-sample aws-cpp-sdk-dynamodb)

CMakeLists.txt 작성이 완료되었다면, 아래의 명령을 통하여 ddb-sample 애플리케이션용 Visual Studio 솔루션을 생성합니다.

PS > md ddb-sample
PS > cd ddb-sample
PS >  cmake –Daws-sdk-cpp_DIR=..\sdk-dynamic-64 -G "Visual Studio 14 2015 Win64" 

Ddb-sample 폴더 내에 생성되어 있는 솔루션(.sln)파일을 열어서 빌드를 하고 실행해보시기 바랍니다. (이전AWS C++ SDK 빌드 단계에서 생성된DLL파일이 필요합니다.) 테이블이 생성되고 50개의 아이템이 생성되는 것을 직접 확인하실 수 있습니다. 이 과정에서 커스텀 메모리 매니저의 사용 여부를 알고 싶다면, MyCustomMemorySystem클래스의 AllocateMemory/FreeMemory 멤버함수에 중단점(breakpoint)을 걸어보시기 바랍니다.

4. 커스텀 스레드 풀을 사용하여 애플리케이션 최적화하기
위의 ddb-sample 애플리케이션에서는 DynamoDB에 아이템 생성(put)을 요청할 때, 비동기 방식으로 하게 됩니다.  즉, AWS C++ SDK의 요청 API중 “Async”라는 접미어 (예: DynamoDBClient::GetItemAsync)가 붙은 API를 통해 요청하는 경우는 그 결과가 모두 오기 전까지 기다리지 않습니다. 결과는 콜백 함수(callback handler)를 통해서 받아야 합니다.

이러한 비동기 방식의 요청에 대해서는 내부적으로 Executor(ClientConfiguration::executor를 통해 지정 가능)라는 작업 실행기를 통해서 요청을 수행하게 되고 수행이 완료되면 콜백 함수를 불러(invoke)주게 됩니다. 그런데, 명시적으로 Executor를 따로 지정해주지 않게 되면, 내부적으로 기본 실행기(DefaultExecutor)를 사용하게 됩니다. DefaultExecutor의 구현은 아래와 같은 형태로 되어 있습니다.

bool DefaultExecutor::SubmitToThread(std::function<void()>&&  fx)
{
    std::thread t(fx);
    t.detach();
    return true;
}

보시다시피, 요청마다 일일이 스레드 생성을 통하여 함수를 실행하도록 구현되어 있습니다. 위의 ddb-sample 애플리케이션의 경우에는 DynamoDB에 50개의 아이템 생성을 비동기로 요청하게 되어 있는데, 이 경우에는 50개의 스레드를 생성해서 처리하게 됩니다.  빈번한 메모리 할당 요청과 마찬가지로 빈번한 스레드 생성 요청의 경우도 운영체제 입장에서는 꽤 비싼 것이 사실이고 성능에 감소에 영향을 주게 됩니다.

그래서, AWS C++ SDK는 이러한 Executor를 직접 구현해서 교체(override)할 수 있도록 인터페이스를 제공할 뿐만 아니라 기본적으로 스레드 풀링(thread pooling)을 사용하는 PooledThreadExecutor도 따로 제공해 드립니다. 아래 코드는 ddb-sample 애플리케이션에서 기본 실행기(DefaultExecutor)대신 PooledThreadExecutor를 사용하는 방법을 보여줍니다. SetUpTest멤버 함수의 ClientConfiguration::executor에 새로운 Executor를 지정하기만 하면 됩니다.

// ddb-sample 테스트를 위한 클라이언트 설정 부분
DynamoDbTest::SetUpTest()
{
	ClientConfiguration config;
              // … … …
              // 10개의 스레드를 사용하는 스레드풀 executor 생성후 지정하는 예
	config.executor = Aws::MakeShared<Aws::Utils::Threading::PooledThreadExecutor>(ALLOCATION_TAG, 10);

	m_client = Aws::MakeShared<DynamoDBClient>(ALLOCATION_TAG, config);
}

물론, 사용자의 환경에 따라 직접 구현한 것을 사용하거나 사용중인 프레임워크, 라이브러리 등에서 제공하는 스레드풀을 사용하는 방법도 가능합니다. Windows 환경에서 성능상의 이점을 위하여Executor를 직접 구현하여 사용하는 예는 이곳의 LockFreeExecutor.h/.cpp 파일을 참고하시기 바랍니다.

성능이 크게 문제가 안되거나 가상머신(VM)위에서 동작하는 일반적인 애플리케이션의 경우, 필요한 SDK나 라이브러리를 패키지 관리자로부터 바로 얻어서 기본 설정 그대로 사용할 수 있는 경우가 많습니다. 그러나, C++을 사용하여 애플리케이션을 작성하는 이유의 상당수가 성능 문제인 경우가 많기에 기본 설정 그대로 사용하기 부적합한 경우가 많습니다.  그래서, 이번에 다룬 내용을 토대로 필요한 경우 얼마든지AWS C++ SDK를 사용자의 환경에 맞게 직접 커스터마이징하여 사용하시기 바랍니다.

본 글은 아마존웹서비스 코리아의 솔루션즈 아키텍트가 국내 고객을 위해 전해 드리는 AWS 활용 기술 팁을 보내드리는 코너로서, 이번 글은 구승모 솔루션즈 아키텍트께서 작성해주셨습니다.