AWS SDK for C++: S3 멀티파트 업로드, DynamoDB CRUD, Lambda 호출, IAM 자격 증명
들어가며
C++로 서버·데스크톱 앱을 개발하다 보면 클라우드 스토리지·DB·서버리스 함수를 연동해야 하는 순간이 옵니다. REST API를 직접 호출할 수도 있지만, AWS SDK for C++를 쓰면 요청 서명·재시도·에러 처리를 SDK가 담당해 줍니다.
이 글은 S3(PutObject, GetObject, 멀티파트 업로드, ListObjectsV2), DynamoDB(PutItem, GetItem, UpdateItem, Query), Lambda(동기·비동기 Invoke), 그리고 자격 증명 체인을 차례로 다룹니다. AWS SDK for C++는 다른 언어 SDK보다 초기화·종료 규칙과 스트림 기반 API가 까다로워서, 예제마다 실제로 크래시나 조용한 오류를 만드는 지점을 함께 짚습니다.
요구 환경: C++17 이상, AWS SDK for C++ 1.x
AWS SDK 빌드와 초기화
CMake로 AWS SDK 빌드
vcpkg 또는 시스템 패키지로 AWS SDK for C++를 설치합니다.
# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(aws_sdk_demo)
set(CMAKE_CXX_STANDARD 17)
# SDK가 설치한 AWSSDKConfig.cmake를 찾음 (vcpkg·소스 빌드 공통)
find_package(AWSSDK REQUIRED COMPONENTS s3 dynamodb lambda)
add_executable(aws_demo
main.cpp
)
# 요청한 컴포넌트와 그 의존성(core 등)이 이 변수에 모두 담김
target_link_libraries(aws_demo PRIVATE ${AWSSDK_LINK_LIBRARIES})
패키지 이름은 aws-sdk-cpp가 아니라 AWSSDK이고, core는 다른 컴포넌트의 의존성으로 자동 포함됩니다. vcpkg로 설치하려면 필요한 서비스만 기능(feature)으로 고릅니다. 전체 SDK는 서비스가 수백 개라 빌드 시간이 매우 깁니다.
vcpkg install aws-sdk-cpp[s3,dynamodb,lambda]:x64-linux
SDK 초기화 및 ClientConfiguration
모든 AWS API 호출 전에 Aws::InitAPI를 호출하며, 종료 시 Aws::ShutdownAPI를 호출해야 합니다.
#include <aws/core/Aws.h>
#include <aws/core/client/ClientConfiguration.h>
#include <aws/s3/S3Client.h>
#include <iostream>
int main() {
Aws::SDKOptions options;
Aws::InitAPI(options);
// 리전·엔드포인트·재시도 설정
Aws::Client::ClientConfiguration config;
config.region = "ap-northeast-2"; // 서울 리전
config.connectTimeoutMs = 3000;
config.requestTimeoutMs = 30000;
config.maxConnections = 50;
Aws::S3::S3Client s3Client(config);
// ... API 호출 ...
Aws::ShutdownAPI(options);
return 0;
}
ClientConfiguration에서 자주 만지는 옵션:
region: 리전 (예: ap-northeast-2, us-east-1)endpointOverride: 커스텀 엔드포인트 (LocalStack 등)connectTimeoutMs/requestTimeoutMs: 연결·요청 타임아웃 (기본값이 각각 1초·3초로 짧은 편)maxConnections: 연결 풀 크기
이 초기화 코드에는 AWS SDK for C++에서 가장 유명한 함정이 들어 있습니다. s3Client가 main의 지역 변수라서 Aws::ShutdownAPI(options)가 먼저 호출되고, 그 뒤 return 시점에 s3Client의 소멸자가 실행됩니다. 이미 종료된 SDK 내부 자원(HTTP 클라이언트 팩토리, 로깅, 암호 라이브러리)을 소멸자가 건드리면서 종료 시 세그폴트가 나거나 멈출 수 있습니다. 공식 예제가 항상 SDK 사용 코드를 중괄호 블록으로 감싸는 이유가 이것입니다.
Aws::SDKOptions options;
Aws::InitAPI(options);
{
Aws::Client::ClientConfiguration config;
config.region = "ap-northeast-2";
Aws::S3::S3Client s3Client(config);
// ... API 호출 ...
} // 여기서 모든 클라이언트가 먼저 소멸
Aws::ShutdownAPI(options);
같은 이유로 뒤에서 볼 함수 내부 static 클라이언트(싱글톤)도 주의해야 합니다. 정적 객체는 main이 끝난 뒤 소멸하므로 항상 ShutdownAPI 이후에 파괴됩니다. 클라이언트를 전역 수명으로 두고 싶다면 std::unique_ptr로 보관하고 ShutdownAPI 직전에 명시적으로 reset()하는 편이 안전합니다.
ClientConfiguration에 region을 지정하지 않으면 SDK가 환경 변수, 프로파일, 그리고 EC2 메타데이터(IMDS) 순으로 리전을 찾습니다. EC2가 아닌 환경에서 IMDS 조회가 타임아웃될 때까지 기다리느라 클라이언트 생성이 몇 초씩 걸리는 경우가 있어, 로컬 개발이나 컨테이너에서는 리전을 명시하거나 AWS_EC2_METADATA_DISABLED=true를 설정해 두면 이런 지연을 피할 수 있습니다.
S3 파일 저장·다운로드
PutObject: 파일 업로드
#include <aws/core/Aws.h>
#include <aws/core/client/ClientConfiguration.h>
#include <aws/s3/S3Client.h>
#include <aws/s3/model/PutObjectRequest.h>
#include <fstream>
#include <iostream>
bool uploadFileToS3(const std::string& bucket, const std::string& key,
const std::string& filePath,
const Aws::Client::ClientConfiguration& config) {
Aws::S3::S3Client client(config);
auto inputStream = Aws::MakeShared<Aws::FStream>(
"PutObjectStream", filePath.c_str(),
std::ios_base::in | std::ios_base::binary);
if (!*inputStream) {
std::cerr << "파일 열기 실패: " << filePath << std::endl;
return false;
}
Aws::S3::Model::PutObjectRequest request;
request.SetBucket(bucket);
request.SetKey(key);
request.SetBody(inputStream);
auto outcome = client.PutObject(request);
if (!outcome.IsSuccess()) {
std::cerr << "PutObject 실패: " << outcome.GetError().GetMessage()
<< std::endl;
return false;
}
std::cout << "업로드 완료: s3://" << bucket << "/" << key << std::endl;
return true;
}
Aws::FStream으로 파일을 열어 본문으로 넘기면 SDK가 스트림에서 읽어 보내므로 파일 전체를 메모리에 올리지 않습니다. PutObject는 응답이 올 때까지 블록하는 동기 호출이고, 콜백을 받는 비동기 버전은 PutObjectAsync입니다. 단일 PutObject로 올릴 수 있는 객체는 최대 5GB입니다.
이 예제의 uploadFileToS3처럼 함수를 호출할 때마다 S3Client를 새로 만드는 것은 예제를 짧게 하려는 선택일 뿐, 실무에서는 피해야 합니다. 클라이언트를 만들 때마다 HTTP 연결 풀, TLS 세션, 자격 증명 공급자가 새로 초기화되어, 파일을 수천 개 올리는 배치에서는 업로드 자체보다 클라이언트 생성 비용이 더 커집니다. SDK의 서비스 클라이언트는 여러 스레드에서 동시에 호출해도 안전하므로 하나를 만들어 공유하는 것이 기본입니다.
SetBody에 넘기는 스트림은 SDK가 요청 서명(본문 해시 계산)과 재시도 때 처음으로 되감아 다시 읽습니다. 그래서 한 번만 읽을 수 있는 파이프나 소켓 스트림을 그대로 넘기면 재시도 시 빈 본문이 전송될 수 있고, 파일 스트림을 ios_base::binary 없이 열면 Windows에서 줄바꿈 변환으로 내용이 달라져 업로드된 객체가 손상됩니다.
PutObject: 문자열(버퍼) 업로드
bool uploadStringToS3(const std::string& bucket, const std::string& key,
const std::string& content,
const Aws::Client::ClientConfiguration& config) {
Aws::S3::S3Client client(config);
auto stream = Aws::MakeShared<Aws::StringStream>("");
*stream << content;
Aws::S3::Model::PutObjectRequest request;
request.SetBucket(bucket);
request.SetKey(key);
request.SetBody(stream);
auto outcome = client.PutObject(request);
return outcome.IsSuccess();
}
GetObject: 파일 다운로드
#include <aws/s3/model/GetObjectRequest.h>
bool downloadFromS3(const std::string& bucket, const std::string& key,
const std::string& localPath,
const Aws::Client::ClientConfiguration& config) {
Aws::S3::S3Client client(config);
Aws::S3::Model::GetObjectRequest request;
request.SetBucket(bucket);
request.SetKey(key);
auto outcome = client.GetObject(request);
if (!outcome.IsSuccess()) {
std::cerr << "GetObject 실패: " << outcome.GetError().GetMessage()
<< std::endl;
return false;
}
auto& result = outcome.GetResult();
auto& body = result.GetBody();
std::ofstream ofs(localPath, std::ios::binary);
ofs << body.rdbuf();
ofs.close();
std::cout << "다운로드 완료: " << localPath << std::endl;
return true;
}
멀티파트 업로드
파일을 파트로 나눠 올리면 실패한 파트만 다시 보내면 되고 파트를 병렬로 올릴 수도 있습니다. AWS는 대략 100MB 이상의 객체부터 멀티파트 업로드를 권장하며, 5GB를 넘는 객체는 멀티파트로만 올릴 수 있습니다.
#include <aws/s3/model/CreateMultipartUploadRequest.h>
#include <aws/s3/model/UploadPartRequest.h>
#include <aws/s3/model/CompleteMultipartUploadRequest.h>
#include <aws/s3/model/AbortMultipartUploadRequest.h>
#include <fstream>
#include <vector>
struct PartETag {
int partNumber;
Aws::String eTag;
};
bool multipartUpload(const std::string& bucket, const std::string& key,
const std::string& filePath,
const Aws::Client::ClientConfiguration& config,
size_t partSize = 5 * 1024 * 1024) { // 5MB
Aws::S3::S3Client client(config);
std::ifstream file(filePath, std::ios::binary | std::ios::ate);
if (!file) return false;
size_t fileSize = file.tellg();
file.seekg(0);
// 1. 멀티파트 업로드 시작
Aws::S3::Model::CreateMultipartUploadRequest createReq;
createReq.SetBucket(bucket);
createReq.SetKey(key);
auto createOutcome = client.CreateMultipartUpload(createReq);
if (!createOutcome.IsSuccess()) {
std::cerr << "CreateMultipartUpload 실패" << std::endl;
return false;
}
auto uploadId = createOutcome.GetResult().GetUploadId();
// 2. 각 파트 업로드
std::vector<PartETag> parts;
int partNumber = 1;
std::vector<char> buffer(partSize);
while (file.read(buffer.data(), partSize) || file.gcount() > 0) {
size_t bytesRead = file.gcount();
if (bytesRead == 0) break;
auto partStream = Aws::MakeShared<Aws::StringStream>("");
partStream->write(buffer.data(), bytesRead);
partStream->seekg(0);
Aws::S3::Model::UploadPartRequest partReq;
partReq.SetBucket(bucket);
partReq.SetKey(key);
partReq.SetUploadId(uploadId);
partReq.SetPartNumber(partNumber);
partReq.SetBody(partStream);
auto partOutcome = client.UploadPart(partReq);
if (!partOutcome.IsSuccess()) {
client.AbortMultipartUpload(
Aws::S3::Model::AbortMultipartUploadRequest()
.WithBucket(bucket).WithKey(key).WithUploadId(uploadId));
return false;
}
parts.push_back({partNumber, partOutcome.GetResult().GetETag()});
partNumber++;
}
// 3. 멀티파트 업로드 완료
Aws::S3::Model::CompletedMultipartUpload completed;
for (const auto& p : parts) {
Aws::S3::Model::CompletedPart cp;
cp.SetETag(p.eTag);
cp.SetPartNumber(p.partNumber);
completed.AddParts(cp);
}
Aws::S3::Model::CompleteMultipartUploadRequest completeReq;
completeReq.SetBucket(bucket);
completeReq.SetKey(key);
completeReq.SetUploadId(uploadId);
completeReq.WithMultipartUpload(completed);
auto completeOutcome = client.CompleteMultipartUpload(completeReq);
if (!completeOutcome.IsSuccess()) {
std::cerr << "CompleteMultipartUpload 실패" << std::endl;
client.AbortMultipartUpload( // 올린 파트가 과금되며 남지 않도록 정리
Aws::S3::Model::AbortMultipartUploadRequest()
.WithBucket(bucket).WithKey(key).WithUploadId(uploadId));
return false;
}
std::cout << "멀티파트 업로드 완료: " << key << std::endl;
return true;
}
S3 멀티파트 업로드는 마지막 파트를 제외한 각 파트가 최소 5MB여야 하고, 파트는 최대 10,000개까지입니다. 파트 크기를 5MB로 고정하면 10,000파트 제한 때문에 약 48.8GB가 넘는 파일은 올릴 수 없습니다(5MB × 10,000). 파일 크기를 먼저 확인해 max(5MB, fileSize / 10000을 올림한 값)으로 파트 크기를 정하는 것이 안전하고, 네트워크가 안정적이라면 8~16MB 정도로 키우는 편이 요청 수와 오버헤드를 줄입니다.
AbortMultipartUpload를 호출하지 못한 채 프로세스가 죽으면(크래시, 강제 종료), 이미 올린 파트는 보이지 않는 상태로 버킷에 남아 계속 요금이 나갑니다. 콘솔의 객체 목록에도 나오지 않아 모르고 지나치기 쉽습니다. 버킷에 “완료되지 않은 멀티파트 업로드를 N일 후 삭제”(AbortIncompleteMultipartUpload) 수명 주기 규칙을 걸어 두는 것이 사실상 필수입니다. 또 이 코드는 파트를 하나씩 순차로 올리므로 대역폭을 다 쓰지 못합니다. 직접 병렬화하기보다 뒤에서 소개하는 TransferManager가 파트 크기 계산, 병렬 업로드, 재시도, 중단 시 정리를 한꺼번에 처리하므로, 특별한 이유가 없다면 그쪽을 먼저 검토하는 것이 좋습니다.
ListObjectsV2: 객체 목록 조회
ListObjectsV2는 한 번에 최대 1,000개 키만 돌려주므로 아래처럼 ContinuationToken으로 반복해야 합니다. 아래 코드는 중간에 요청이 실패하면 break로 빠져나와 그때까지 모은 일부 목록을 정상 결과처럼 반환한다는 점을 주의하세요. 삭제·동기화처럼 전체 목록이 정확해야 하는 작업이라면 실패를 호출자에게 알려야 합니다.
#include <aws/s3/model/ListObjectsV2Request.h>
std::vector<Aws::String> listS3Objects(const std::string& bucket,
const std::string& prefix,
const Aws::Client::ClientConfiguration& config) {
Aws::S3::S3Client client(config);
std::vector<Aws::String> keys;
Aws::String continuationToken;
do {
Aws::S3::Model::ListObjectsV2Request request;
request.SetBucket(bucket);
if (!prefix.empty()) request.SetPrefix(prefix);
if (!continuationToken.empty()) request.SetContinuationToken(continuationToken);
auto outcome = client.ListObjectsV2(request);
if (!outcome.IsSuccess()) break;
for (const auto& obj : outcome.GetResult().GetContents()) {
keys.push_back(obj.GetKey());
}
continuationToken = outcome.GetResult().GetNextContinuationToken();
} while (!continuationToken.empty());
return keys;
}
DynamoDB NoSQL 연동
PutItem: 아이템 저장
#include <aws/dynamodb/DynamoDBClient.h>
#include <aws/dynamodb/model/PutItemRequest.h>
#include <aws/dynamodb/model/AttributeValue.h>
#include <chrono>
#include <string>
bool putDynamoItem(const std::string& tableName,
const std::string& pkName, const std::string& pkValue,
const std::string& skName, const std::string& skValue,
const Aws::Client::ClientConfiguration& config) {
Aws::DynamoDB::DynamoDBClient client(config);
Aws::DynamoDB::Model::PutItemRequest request;
request.SetTableName(tableName);
request.AddItem(pkName, Aws::DynamoDB::Model::AttributeValue().SetS(pkValue));
request.AddItem(skName, Aws::DynamoDB::Model::AttributeValue().SetS(skValue));
// 추가 속성
request.AddItem("createdAt", Aws::DynamoDB::Model::AttributeValue().SetS(
std::to_string(std::chrono::system_clock::now().time_since_epoch().count())));
auto outcome = client.PutItem(request);
if (!outcome.IsSuccess()) {
std::cerr << "PutItem 실패: " << outcome.GetError().GetMessage() << std::endl;
return false;
}
return true;
}
createdAt에 system_clock::now().time_since_epoch().count()를 문자열로 넣은 부분은 개선할 여지가 있습니다. count()의 단위는 구현마다 다르고(libstdc++는 나노초, libc++는 마이크로초, MSVC는 100나노초 단위), 문자열(S)로 저장하면 자릿수가 다를 때 정렬·범위 조건이 기대대로 동작하지 않습니다. duration_cast<seconds>로 에포크 초를 구해 숫자(N)로 저장하거나, ISO 8601 문자열로 통일하는 편이 쿼리하기 쉽습니다. 에포크 초 숫자 속성은 DynamoDB TTL 기능에도 그대로 쓸 수 있습니다.
PutItem은 같은 키의 아이템이 있으면 통째로 덮어씁니다. 덮어쓰기를 막으려면 SetConditionExpression("attribute_not_exists(#pk)")를 걸고 #pk를 실제 파티션 키 이름으로 매핑하는 ExpressionAttributeNames를 함께 넘긴 뒤, 실패 시 ConditionalCheckFailedException을 처리합니다.
AttributeValue의 주요 setter:
SetS(s): 문자열SetN(n): 숫자 (문자열로 전달, 예:"123")SetB(data): 바이너리SetBool(b): 불리언SetSS(set): 문자열 집합SetM(map): 맵(중첩 문서)
GetItem: 단일 아이템 조회
#include <aws/dynamodb/model/GetItemRequest.h>
#include <optional>
std::optional<Aws::Map<Aws::String, Aws::DynamoDB::Model::AttributeValue>>
getDynamoItem(const std::string& tableName,
const std::string& pkName, const std::string& pkValue,
const std::string& skName, const std::string& skValue,
const Aws::Client::ClientConfiguration& config) {
Aws::DynamoDB::DynamoDBClient client(config);
Aws::DynamoDB::Model::GetItemRequest request;
request.SetTableName(tableName);
request.AddKey(pkName, Aws::DynamoDB::Model::AttributeValue().SetS(pkValue));
request.AddKey(skName, Aws::DynamoDB::Model::AttributeValue().SetS(skValue));
auto outcome = client.GetItem(request);
if (!outcome.IsSuccess()) {
std::cerr << "GetItem 실패: " << outcome.GetError().GetMessage() << std::endl;
return std::nullopt;
}
auto item = outcome.GetResult().GetItem();
if (item.empty()) return std::nullopt;
return item;
}
GetItem의 기본 읽기는 최종적 일관성(eventually consistent)이라, 방금 쓴 아이템을 바로 읽으면 이전 값이 나올 수 있습니다. 쓰기 직후 읽기가 중요하다면 request.SetConsistentRead(true)를 주면 되고, 읽기 용량은 두 배로 소모됩니다.
UpdateItem: 속성 업데이트
#include <aws/dynamodb/model/UpdateItemRequest.h>
bool updateDynamoItem(const std::string& tableName,
const std::string& pkName, const std::string& pkValue,
const std::string& attrName, const std::string& attrValue,
const Aws::Client::ClientConfiguration& config) {
Aws::DynamoDB::DynamoDBClient client(config);
Aws::DynamoDB::Model::UpdateItemRequest request;
request.SetTableName(tableName);
request.AddKey(pkName, Aws::DynamoDB::Model::AttributeValue().SetS(pkValue));
request.SetUpdateExpression("SET #a = :v");
Aws::Map<Aws::String, Aws::String> exprNames;
exprNames["#a"] = attrName;
request.SetExpressionAttributeNames(exprNames);
Aws::Map<Aws::String, Aws::DynamoDB::Model::AttributeValue> exprValues;
exprValues[":v"] = Aws::DynamoDB::Model::AttributeValue().SetS(attrValue);
request.SetExpressionAttributeValues(exprValues);
auto outcome = client.UpdateItem(request);
return outcome.IsSuccess();
}
#a, :v 같은 자리 표시자를 쓰는 이유는 두 가지입니다. :v는 값을 식 문자열과 분리해 타입을 명시하기 위한 것이고, #a는 속성 이름이 status, name, data 같은 DynamoDB 예약어와 겹칠 때 Attribute name is a reserved keyword 오류를 피하기 위한 것입니다. 예약어 목록이 수백 개라서, 속성 이름은 항상 # 별칭으로 넘기는 습관이 편합니다.
예제는 파티션 키만 있는 테이블을 가정합니다. 정렬 키가 있는 테이블이라면 AddKey로 정렬 키까지 넘겨야 하며, 빠뜨리면 ValidationException(키 요소가 스키마와 맞지 않음)이 납니다. 또 UpdateItem은 키에 해당하는 아이템이 없으면 새로 만듭니다(upsert). 존재하는 아이템만 수정하려던 의도라면 SetConditionExpression("attribute_exists(#pk)")를 추가해야, 잘못된 키로 호출했을 때 빈 아이템이 조용히 생기는 일을 막을 수 있습니다.
Query: 파티션 키 기준 조회
#include <aws/dynamodb/model/QueryRequest.h>
std::vector<Aws::Map<Aws::String, Aws::DynamoDB::Model::AttributeValue>>
queryDynamoByPartitionKey(const std::string& tableName,
const std::string& pkName, const std::string& pkValue,
const Aws::Client::ClientConfiguration& config) {
Aws::DynamoDB::DynamoDBClient client(config);
std::vector<Aws::Map<Aws::String, Aws::DynamoDB::Model::AttributeValue>> items;
Aws::DynamoDB::Model::QueryRequest request;
request.SetTableName(tableName);
request.SetKeyConditionExpression("#pk = :pkval");
request.AddExpressionAttributeNames("#pk", pkName);
request.AddExpressionAttributeValues(":pkval",
Aws::DynamoDB::Model::AttributeValue().SetS(pkValue));
Aws::Map<Aws::String, Aws::DynamoDB::Model::AttributeValue> lastEvaluatedKey;
do {
if (!lastEvaluatedKey.empty()) {
request.SetExclusiveStartKey(lastEvaluatedKey);
}
auto outcome = client.Query(request);
if (!outcome.IsSuccess()) break;
for (const auto& item : outcome.GetResult().GetItems()) {
items.push_back(item);
}
lastEvaluatedKey = outcome.GetResult().GetLastEvaluatedKey();
} while (!lastEvaluatedKey.empty());
return items;
}
Query는 한 번에 최대 1MB까지만 읽고 나머지가 있으면 LastEvaluatedKey를 돌려주므로, 위처럼 그 값을 ExclusiveStartKey로 넘겨 반복해야 전체 결과를 얻습니다. ListObjectsV2 예제와 마찬가지로 중간 실패 시 일부 결과만 반환된다는 점은 호출자에게 알려야 합니다.
Lambda 함수 호출
Invoke (동기 호출)
#include <aws/lambda/LambdaClient.h>
#include <aws/lambda/model/InvokeRequest.h>
#include <aws/core/utils/json/JsonSerializer.h>
struct LambdaResponse {
bool success;
std::string payload;
std::string errorMessage;
};
LambdaResponse invokeLambdaSync(const std::string& functionName,
const std::string& payloadJson,
const Aws::Client::ClientConfiguration& config) {
Aws::Lambda::LambdaClient client(config);
Aws::Lambda::Model::InvokeRequest request;
request.SetFunctionName(functionName);
request.SetInvocationType(Aws::Lambda::Model::InvocationType::RequestResponse);
auto payloadStream = Aws::MakeShared<Aws::StringStream>("");
*payloadStream << payloadJson;
request.SetBody(payloadStream);
auto outcome = client.Invoke(request);
LambdaResponse result{};
if (!outcome.IsSuccess()) {
result.success = false;
result.errorMessage = outcome.GetError().GetMessage();
return result;
}
auto& invokeResult = outcome.GetResult();
auto statusCode = invokeResult.GetStatusCode();
// 함수 코드가 예외를 던져도 StatusCode는 200이며, FunctionError에 "Unhandled" 등이 담김
if (!invokeResult.GetFunctionError().empty()) {
result.success = false;
result.errorMessage = "Function error: " + invokeResult.GetFunctionError();
return result;
}
if (statusCode >= 200 && statusCode < 300) {
result.success = true;
auto& body = invokeResult.GetPayload();
std::string payloadStr((std::istreambuf_iterator<char>(body)),
std::istreambuf_iterator<char>());
result.payload = payloadStr;
} else {
result.success = false;
result.errorMessage = "Lambda returned status " + std::to_string(statusCode);
}
return result;
}
InvocationType은 세 가지입니다. RequestResponse는 함수가 끝날 때까지 기다리는 동기 호출이고(함수 실행은 최대 15분, 클라이언트 쪽 requestTimeoutMs는 별도), Event는 큐에 넣고 바로 돌아오는 비동기 호출, DryRun은 권한과 파라미터 검증만 하고 함수는 실행하지 않습니다.
동기 호출에서 가장 자주 놓치는 부분이 함수 오류의 표현 방식입니다. Lambda 함수 코드 안에서 예외가 발생해도 Invoke API 자체는 성공으로 처리되고 HTTP 상태도 200입니다. 대신 FunctionError 필드에 Unhandled가 담기고, 응답 본문에는 {"errorMessage": "...", "errorType": "..."} 형태의 오류 JSON이 들어옵니다. 상태 코드만 보고 성공으로 판단하면 함수가 실패해도 오류 JSON을 정상 결과로 처리하게 되므로, 위처럼 GetFunctionError()를 반드시 확인해야 합니다.
Event 호출은 요청이 Lambda의 내부 큐에 들어가는 즉시 202를 반환하므로, 이 코드의 success는 “큐에 들어갔다”는 뜻일 뿐 함수가 성공했다는 뜻이 아닙니다. 비동기 호출이 실패하면 Lambda가 기본적으로 두 번 재시도하고, 그래도 실패하면 버려지므로 실패 알림이 필요하면 함수에 실패 대상(Destination)이나 DLQ를 설정해야 합니다. 또 재시도 때문에 같은 이벤트가 여러 번 처리될 수 있어 함수는 멱등하게 작성하는 것이 좋습니다. 페이로드 크기 제한은 동기 호출이 비동기 호출보다 넉넉하므로, 큰 데이터는 S3에 올리고 키만 넘기는 방식이 일반적입니다.
Invoke (비동기 Event)
LambdaResponse invokeLambdaAsync(const std::string& functionName,
const std::string& payloadJson,
const Aws::Client::ClientConfiguration& config) {
Aws::Lambda::LambdaClient client(config);
Aws::Lambda::Model::InvokeRequest request;
request.SetFunctionName(functionName);
request.SetInvocationType(Aws::Lambda::Model::InvocationType::Event);
auto payloadStream = Aws::MakeShared<Aws::StringStream>("");
*payloadStream << payloadJson;
request.SetBody(payloadStream);
auto outcome = client.Invoke(request);
LambdaResponse result{};
result.success = outcome.IsSuccess();
if (!outcome.IsSuccess()) {
result.errorMessage = outcome.GetError().GetMessage();
}
return result;
}
IAM 인증 및 Credentials
Credentials 체인 (기본 순서)
AWS SDK의 기본 자격 증명 공급자 체인은 대략 다음 순서로 자격 증명을 찾고, 처음 찾은 것을 씁니다(SDK 버전에 따라 세부 단계가 추가될 수 있습니다):
- 환경 변수:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN(선택) - 공유 설정 파일:
~/.aws/credentials,~/.aws/config의 프로파일 (AWS_PROFILE로 선택) - 웹 ID 토큰(EKS의 IRSA 등), SSO, 외부 프로세스 공급자
- ECS/컨테이너 자격 증명 (Task Role)
- EC2 Instance Profile (EC2 메타데이터)
순서가 중요한 이유는 앞에서 찾으면 뒤는 보지 않기 때문입니다. EC2에 Instance Profile을 붙였는데도 권한 오류가 난다면, 배포 스크립트나 컨테이너 이미지에 남은 AWS_ACCESS_KEY_ID 환경 변수가 우선 적용되고 있는지 먼저 확인합니다. 로컬에서는 되는데 서버에서만 실패하는 문제의 상당수가 이런 “예상과 다른 자격 증명”입니다. 어떤 자격 증명이 쓰이는지는 같은 환경에서 aws sts get-caller-identity를 실행하면 바로 확인할 수 있습니다.
# 환경 변수 설정 예시
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=ap-northeast-2
프로파일 지정
~/.aws/credentials의 특정 프로파일(예: [my-profile])을 쓰려면 프로세스를 시작할 때 AWS_PROFILE을 지정하는 것이 가장 간단합니다. 코드를 바꾸지 않아도 기본 체인이 그 프로파일을 읽습니다.
AWS_PROFILE=my-profile ./aws_demo
EC2/ECS에서 IAM Role
EC2에 Instance Profile을, ECS Task에 Task Role을 붙이면 별도 키 없이 SDK가 자동으로 메타데이터 서비스에서 임시 자격 증명을 가져옵니다. 프로덕션에서는 이 방식을 권장합니다.
NoSuchBucket, SignatureDoesNotMatch, ValidationException: 에러 해결
S3: “NoSuchBucket” / “Access Denied”
NoSuchBucket은 버킷 이름 오타이거나 버킷이 실제로 없는 경우입니다. Access Denied는 대개 IAM 권한 부족인데, S3는 존재하지 않는 객체에 대해서도 s3:ListBucket 권한이 없으면 404 대신 403을 돌려주므로 “객체가 없는 것”과 “권한이 없는 것”이 같은 에러로 보일 수 있습니다. 위 예제들이 쓰는 작업에 필요한 정책은 다음과 같습니다. s3:ListBucket은 버킷 ARN에, 객체 작업은 버킷/* ARN에 걸린다는 점이 자주 틀리는 부분입니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::my-bucket"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
S3: “SignatureDoesNotMatch”
요청 서명에는 시크릿 키뿐 아니라 요청 시각, 리전, 서비스명이 들어가므로 키가 맞아도 서명이 어긋날 수 있습니다. 시크릿 키에 공백이나 줄바꿈이 섞이지 않았는지 확인하고, chrony나 systemd-timesyncd(timedatectl status로 확인)로 서버 시간을 동기화합니다. 서명 시각이 AWS 서버 시각과 약 15분 이상 차이 나면 RequestTimeTooSkewed로 거부됩니다. endpointOverride를 쓴다면 설정한 리전이 엔드포인트의 리전과 같은지도 확인합니다.
DynamoDB: “ValidationException: One or more parameter values were invalid”
AttributeValue 타입이 테이블 키 스키마와 다를 때 가장 흔하게 납니다. 파티션 키가 문자열(S)로 정의된 테이블에 숫자(N)를 넘기면 값이 같아 보여도 거부됩니다.
// 잘못된 예: 파티션 키가 문자열인데 숫자로 전달
request.AddKey("userId", Aws::DynamoDB::Model::AttributeValue().SetN("123")); // 스키마가 S면 SetS
// 올바른 예: 스키마에 맞게
request.AddKey("userId", Aws::DynamoDB::Model::AttributeValue().SetS("user-123"));
request.AddKey("timestamp", Aws::DynamoDB::Model::AttributeValue().SetN("1700000000"));
DynamoDB·Lambda: “ResourceNotFoundException”
테이블이나 함수 이름 오타, 또는 클라이언트의 region이 리소스가 있는 리전과 다른 경우가 대부분입니다. DynamoDB 테이블은 리전마다 따로 존재하므로 같은 이름의 테이블도 다른 리전에서는 찾을 수 없습니다. 테이블을 막 만든 직후라면 상태가 아직 CREATING일 수 있으니 DescribeTable로 ACTIVE가 된 것을 확인한 뒤 사용합니다. Lambda는 함수 이름 대신 my-function:alias나 전체 ARN을 넘길 수도 있습니다.
Lambda 동기 호출 타임아웃
ClientConfiguration의 기본 requestTimeoutMs는 3초라서, 실행에 몇 초 이상 걸리는 함수를 RequestResponse로 호출하면 함수는 계속 돌고 있는데 클라이언트만 먼저 타임아웃으로 실패합니다. 클라이언트 타임아웃은 함수에 설정한 타임아웃보다 길게 잡아야 합니다.
config.requestTimeoutMs = 60000; // 함수 타임아웃 + 네트워크 여유
반대로 함수 자체의 타임아웃(최대 15분)에 걸리면 FunctionError가 채워진 응답이 오므로, 앞의 동기 호출 예제처럼 그 필드를 확인해야 구분할 수 있습니다.
공통: 연결·요청 타임아웃
네트워크 지연, 방화벽, DNS 문제 외에도 위에서 말한 짧은 기본 타임아웃이 원인인 경우가 많습니다. SDK는 재시도 가능한 오류를 기본 재시도 정책으로 몇 차례 다시 시도하므로, 타임아웃 로그가 보이면 한 번의 시도가 아니라 재시도까지 모두 실패한 결과일 수 있습니다.
config.connectTimeoutMs = 5000;
config.requestTimeoutMs = 30000;
클라이언트 재사용·병렬 업로드·Batch API
클라이언트 재사용
서비스 클라이언트는 여러 스레드에서 동시에 호출해도 안전하므로 앱 전체에서 재사용합니다. 단, 아래처럼 함수 내부 static으로 두면 앞에서 설명한 대로 프로그램 종료 시 ShutdownAPI 이후에 소멸하므로, 종료 시 크래시가 나면 이 패턴부터 의심해야 합니다.
class AwsServiceHolder {
public:
static Aws::S3::S3Client& getS3() {
static Aws::S3::S3Client client(getConfig());
return client;
}
static Aws::DynamoDB::DynamoDBClient& getDynamo() {
static Aws::DynamoDB::DynamoDBClient client(getConfig());
return client;
}
private:
static Aws::Client::ClientConfiguration getConfig() {
Aws::Client::ClientConfiguration c;
c.region = "ap-northeast-2";
c.maxConnections = 100;
return c;
}
};
S3 멀티파트 업로드 병렬화
파트는 서로 독립적이므로 병렬로 올릴 수 있습니다. 아래는 구조만 보여 주는 스케치입니다.
// 파트 업로드를 std::async로 병렬 실행 (스케치: uploadPart는 앞의 UploadPart 호출을 감싼 함수)
std::vector<std::future<PartETag>> futures;
for (size_t i = 0; i < numParts; ++i) {
futures.push_back(std::async(std::launch::async, [&, i]() {
return uploadPart(uploadId, i + 1, ...);
}));
}
for (auto& f : futures) {
parts.push_back(f.get());
}
이 스케치는 파트 수만큼 스레드를 만들므로, 1,000파트짜리 파일이면 스레드 1,000개가 동시에 생깁니다. 직접 병렬화한다면 동시 실행 수를 maxConnections 이하로 제한하는 작업 큐가 필요합니다. 그보다는 aws-cpp-sdk-transfer 컴포넌트의 TransferManager를 쓰는 편이 낫습니다. 파일 크기에 따라 단일 PutObject와 멀티파트를 알아서 고르고, 스레드 풀(Aws::Utils::Threading::PooledThreadExecutor)로 파트를 병렬 전송하며, 진행 상황 콜백과 실패한 파트의 재시도, 취소 시 AbortMultipartUpload까지 처리합니다. 앞의 직접 구현은 동작 원리를 이해하는 용도로 보고, 대용량 전송은 TransferManager를 먼저 검토하는 것이 유지보수에 유리합니다.
DynamoDB BatchGetItem / BatchWriteItem
여러 아이템을 한 요청으로 조회·쓰기하면 왕복 횟수를 줄일 수 있습니다. BatchGetItem은 한 번에 최대 100개 키, 응답 16MB까지이고, BatchWriteItem은 최대 25개 쓰기 요청까지입니다. 두 API 모두 용량 부족 등으로 일부만 처리하고 나머지를 UnprocessedKeys·UnprocessedItems로 돌려줄 수 있으며, 이것은 에러가 아니라 정상 응답이라 IsSuccess()만 보면 놓칩니다. 남은 항목을 백오프하며 다시 보내는 루프가 꼭 필요합니다.
재시도 정책·환경별 설정·모니터링
에러 처리 및 로깅
bool safePutObject(Aws::S3::S3Client& client,
const std::string& bucket, const std::string& key,
std::shared_ptr<Aws::IOStream> body) {
Aws::S3::Model::PutObjectRequest request;
request.SetBucket(bucket);
request.SetKey(key);
request.SetBody(body);
auto outcome = client.PutObject(request);
if (!outcome.IsSuccess()) {
const auto& err = outcome.GetError();
// 예외 이름·메시지·HTTP 상태·요청 ID를 함께 남겨야 AWS 쪽 로그와 대조할 수 있음
std::cerr << "[S3_ERROR] " << err.GetExceptionName()
<< ": " << err.GetMessage()
<< " status=" << static_cast<int>(err.GetResponseCode())
<< " requestId=" << err.GetRequestId()
<< " (bucket=" << bucket << ", key=" << key << ")"
<< std::endl;
return false;
}
return true;
}
AWS SDK for C++는 서비스 오류를 예외로 던지지 않고 Outcome에 담아 돌려주므로, try/catch보다 IsSuccess() 확인이 핵심입니다. GetRequestId()로 얻는 요청 ID는 AWS 지원팀에 문의하거나 CloudTrail에서 같은 요청을 찾을 때 필요합니다.
재시도 정책
SDK는 기본적으로 재시도 가능한 오류(스로틀링, 일시적 5xx, 네트워크 오류)를 지수 백오프로 다시 시도합니다. RetryStrategy를 바꿔 횟수와 대기 시간을 조정할 수 있습니다.
#include <aws/core/client/DefaultRetryStrategy.h>
Aws::Client::ClientConfiguration config;
// 최대 3회 재시도, scaleFactor 100(ms)
config.retryStrategy = Aws::MakeShared<Aws::Client::DefaultRetryStrategy>(
"custom", 3, 100);
DefaultRetryStrategy의 두 번째 인자는 “첫 대기 시간”이 아니라 지수 백오프에 곱하는 배수(scaleFactor, 밀리초)입니다. 대기 시간은 재시도 횟수에 따라 2의 거듭제곱 × scaleFactor로 늘어나므로, 큰 값을 주면 몇 번만 재시도해도 대기가 초 단위로 길어집니다. 지연에 민감한 요청이라면 의도한 값인지 확인합니다.
환경별 설정
std::string getAwsRegion() {
const char* env = std::getenv("AWS_REGION");
if (env && *env) return env;
return "ap-northeast-2"; // 기본값
}
// LocalStack 등 커스텀 엔드포인트
void setupLocalStack(Aws::Client::ClientConfiguration& config) {
const char* endpoint = std::getenv("AWS_ENDPOINT_URL");
if (endpoint) {
config.endpointOverride = endpoint;
}
}
LocalStack처럼 로컬 엔드포인트로 S3를 쓸 때는 버킷.호스트 형태의 가상 호스트 주소가 해석되지 않는 경우가 많아서, S3 클라이언트 설정에서 가상 호스트 방식 주소 지정(useVirtualAddressing)을 꺼 경로 방식으로 요청해야 합니다.
헬스체크
#include <aws/s3/model/HeadBucketRequest.h>
bool checkS3Connectivity(Aws::S3::S3Client& client, const std::string& bucket) {
Aws::S3::Model::HeadBucketRequest request;
request.SetBucket(bucket);
return client.HeadBucket(request).IsSuccess();
}
ListBuckets로 헬스체크를 하면 계정의 모든 버킷을 나열하는 s3:ListAllMyBuckets 권한이 따로 필요해서, 앱 권한은 정상인데 헬스체크만 실패하는 일이 생깁니다. 앱이 실제로 쓰는 버킷에 HeadBucket을 보내는 쪽이 필요한 권한(s3:ListBucket)과 리전 설정을 함께 검증합니다.
모니터링
S3, DynamoDB, Lambda의 서버 쪽 지표는 CloudWatch에 기본으로 쌓이지만, 클라이언트에서 본 지연·재시도·에러 수는 애플리케이션이 직접 집계해 보내야 합니다. 위의 로그처럼 예외 이름과 요청 ID를 구조화된 형태로 남겨 두면 CloudWatch Logs에서 검색하기 쉽고, 분산 추적이 필요하면 X-Ray 같은 도구로 요청 단위 세그먼트를 기록합니다.