서버리스 서비스를 이용하면 서버를 설치 운용하거나 관리할 필요 없이 비지니스 로직을 코드로 구현하는 것만으로도 애플리케이션 구축이 가능합니다
지난 Amazon Polly를 이용한 서버리스 구축 방법을 통해서 AWS 관리 콘솔을 이용하여 웹 애플리케이션을 구축하는 방법을 살펴 보았습니다.
이번에는 웹 애플리케이션 구축을 위하여 프로젝트를 생성하고, 코드를 만들고, 서비스를 배포하는 일련의 과정을 Cloud9 IDE를 이용하는 방법을 살펴봅니다.
또한, SAM(Serverless Application Model)과 AWS Code 시리즈를 이용하여 DevOps 환경으로 CI/CD 프로세스를 구축하는 방법을 실습합니다.
이번 실습을 진행하고 나면 아래와 같은 것을 직접 할 수 있게 됩니다.
Service | Sub Service | Unit | Pricing | Use | free tier | etc |
---|---|---|---|---|---|---|
Cloud9 | EC2 - t2.micro | 1 hour | $0.0116/Hour | t2.micro 2hour | 750hour/month | |
EBS - 8GB | 1 month | $0.1/GB | 8GB 2hour | 30GB/month | ||
Lambda | - | 128MB | $0.000000208/100ms | 1,000번 이하 | 100만번 요청 400GB-초 | 1년 후 지속 제공 |
API Gateway | - | 1백만 API 호출당 $3.50 처음 10TB에 대해 $0.09/GB | 1,000번 이하 | 호출 100만건 | ||
DynamoDB | - | WCU당 최소 $0.47 RCU당 최소 $0.09 GB당 최소 $0.25 | WCU 5 RCU 5 스토리지 100M 이하 | 매달 2억건 요청 | 1년 후 지속 제공 | |
SNS | - | 처음 1GB/월 $0.000 GB당 최대 10TB/월 $0.090 GB당 | 100M 이하 | 매달 15GB의 데이터 전송 | ||
Polly | - | 1백만 문자당 $4 | 1,000 문자 이하 | 매달 문자 500만개 | ||
CodeStar | CodePipeline | 월별 활성 파이프라인*당 $1 | 1개 | 매월 무료 활성 파이프라인 1개 | ||
CodeCommit | 최초 5명 초과시 월별 $1 GB당 $0.06/월 (10GB/월 초과) Git 요청당 $0.001 (1000회/월 초과) | 1개 | 최초 5명까지 매달 50GB의 스토리지 매달 10,000건의 Git 요청 | 1년 후 지속 제공 | ||
CodeBuild | build.general1.small $0.005/분 | 30분 | 매월 100 빌드 분의 build.general1.small | 1년 후 지속 제공 | ||
CodeDeploy | EC2 또는 Lambda에 배포 무료 | 30회 | - | |||
CloudFormation(SAM) | - | - | - | |||
s3 | - | PUT, COPY, POST 또는 LIST 요청 $0.005/1,000건 GET, SELECT 및 기타 모든 요청 $0.0004/1,000건 처음 50TB/월 $0.023/GB | Put 100번 내외 GET 2,000번 내외 | 매달 5GB 스토리지 Get 요청 20,000건 Put 요청 2,000건 |
실습이 종료되고 나면 리소스를 삭제해야 합니다. 해당 핸즈온은 CloudFormation 기반으로 진행됩니다. 배포 Stack을 삭제하고, 실습에 사용한 Cloud9과 같은 Stack을 삭제하면, 배포되어 있는 리소스도 함께 삭제가 됩니다.
반드시 상단에 게시된 리소스에 대해서 삭제가 이루어졌는지 확인 후 실습을 종료합니다.
아래 다이어그램은 실습에서 구축하고자 하는 서버리스 웹 애플리케이션의 아키텍처 입니다. 서버리스 서비스를 이용하기 때문에, 프로비저닝, 패치, 확장에 대해 고민할 필요가 없으며, 사용한 만큼은 비용을 지불할 수 있습니다.
또한, AWS 완전 관리형 서비스이기 때문에 AWS가 이를 관리하기 때문에 우리는 애플리케이션 개발에만 집중할 수 있습니다.
이 애플리케이션은 다섯 가지 영역으로 나뉘어서 제작할 수 있습니다.
News 등록/수집/삭제는 Amazon API Gateway를 통해 RESTful API 서비스로 제공됩니다. 로직은 Lambda 서비스를 통해서 구현되며 애플리케이션이 어떻게 상호 작용 하는지 살펴 보겠습니다.
이 예제는 Cloud9이 존재하는 Singapore 리전에서 English 언어로 진행합니다.
실습은 다음과 같은 순서로 진행됩니다.
가나다
# -*- coding: utf-8 -*- from __future__ import print_function import boto3 import os import json import uuid import datetime def lambda_handler(event, context): if "body" in event: event = json.loads(event['body']) print (event) recordId = str(uuid.uuid4()) voice = event["voice"] originText = event["text"] timbre = event["timbre"] pitch = event["pitch"] updateDate = datetime.datetime.now().strftime("%Y%m%d") print('Generating new DynamoDB record, with ID: ' + recordId) # Create the item in DynamoDB table dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) table.put_item( Item={ 'id' : recordId, 'originText': originText, 'postDate': int(updateDate), 'pollyVoice' : voice, 'pollyStatus' : "PROCESSING", 'pollyTimbre': timbre, 'pollyPitch': pitch } ) # Sending notification about new post to SNS client = boto3.client('sns') client.publish( TopicArn = os.environ['SNS_TOPIC'], Message = recordId ) response = { 'statusCode': 200, 'body': json.dumps({'recordId': recordId}), 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } } return response |
가나다
AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::Serverless-2016-10-31' Description: >- Building Serverless development environment and CI/CD process for DevOps based on Cloud9 Globals: Function: Runtime: python2.7 Handler: lambda_function.lambda_handler MemorySize: 128 Timeout: 60 Resources: PostNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: PostNews Description: Post news text to convert from text to speech Events: PostNewsApi: Type: Api Properties: Path: /news Method: POST Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:PutItem' - 'sns:Publish' Resource: '*' |
가나다
AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::Serverless-2016-10-31' Description: >- Building Serverless development environment and CI/CD process for DevOps based on Cloud9 Globals: Function: Runtime: python2.7 Handler: lambda_function.lambda_handler MemorySize: 128 Timeout: 60 Environment: Variables: DB_TABLE_NAME: Ref: NewsTable SNS_TOPIC: Ref: NewsTopic BUCKET_NAME: Ref: PollyMp3Bucket Resources: NewsTable: Type: 'AWS::Serverless::SimpleTable' Properties: PrimaryKey: Name: id Type: String ProvisionedThroughput: ReadCapacityUnits: 5 WriteCapacityUnits: 5 NewsTopic: Type: 'AWS::SNS::Topic' Properties: DisplayName: NewsTopic PollyMp3Bucket: Type: 'AWS::S3::Bucket' StaticWebBucket: Type: 'AWS::S3::Bucket' Properties: AccessControl: PublicRead WebsiteConfiguration: IndexDocument: index.html ErrorDocument: error.html PostNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: PostNews Description: Post news text to convert from text to speech Events: PostNewsApi: Type: Api Properties: Path: /news Method: POST Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:PutItem' - 'sns:Publish' Resource: '*' |
정적 웹 호스팅 파일 다운로드 받기
wget https://s3.ap-northeast-2.amazonaws.com/polly.awsdemokr.com/301_static_web.zip |
압축 풀고 폴더 이동
unzip 301_static_web.zip cd 301_static_web |
Cloud9에서 scripts.js 파일 열어서 CloudFormation Stack에 배포된 Output의 APIEndpointURL 값을 소스코드에 반영 (WebsiteURL이 아니므로 주의)
var API_ENDPOINT = "https://xxxxxxxxxx.execute-api.ap-southeast-1.amazonaws.com/Prod/news/"; if (API_ENDPOINT === "") { alert("scripts.js 파일의 상단에 API Gateway에 배포한 URL을 등록하고 실행하세요."); } |
정적 웹 포스팅하고자 하는 S3 버킷에 public-read 권한으로 파일을 업로드 (CloudFormation Stack에 배포된 Output의 S3WebBucket 값을 아래에 대체)
aws s3 sync . s3://cloud9-webapp-staticwebbucket-xxxxxxxxxxxx --acl public-read |
# -*- coding: utf-8 -*- from __future__ import print_function import boto3 import os from contextlib import closing from boto3.dynamodb.conditions import Key, Attr import re def lambda_handler(event, context): postId = event["Records"][0]["Sns"]["Message"] print ("Text to Speech function. Post ID in DynamoDB: ", postId) # Retrieving information about the post from DynamoDB table dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) postItem = table.query( KeyConditionExpression=Key('id').eq(postId) ) text = postItem["Items"][0]["originText"] voice = postItem["Items"][0]["pollyVoice"] timbre = postItem["Items"][0]["pollyTimbre"] pitch = postItem["Items"][0]["pollyPitch"] rest = text # Because single invocation of the polly synthesize_speech api can # transform text with about 3,000 characters, we are dividing the # post into blocks of approximately 2,900 characters. textBlocks = [] while (len(rest) > 3000): begin = 0 end = rest.find(".", 2900) if (end == -1): end = rest.find(" ", 2900) textBlock = rest[begin:end] rest = rest[end:] textBlocks.append(textBlock) textBlocks.append(rest) #For each block, invoke Polly API, which will transform text into audio polly = boto3.client('polly') for textBlock in textBlocks: removeBrackets = re.sub(r'\([^)]*\)', '', textBlock) repTextBlock = re.sub('[·…]', '<break time="100ms"/>', removeBrackets) #repTextBlock = re.sub('["·\'…]', '<break time="100ms"/>', removeBrackets) ssmlBlock = "<speak><amazon:effect vocal-tract-length=\"" + timbre + "\"><prosody pitch=\"" + pitch + "\">" + repTextBlock + "</prosody></amazon:effect></speak>" #print (ssmlBlock) response = polly.synthesize_speech(OutputFormat='mp3', Text = ssmlBlock, VoiceId = voice, TextType = 'ssml') #Save the audio stream returned by Amazon Polly on Lambda's temp # directory. If there are multiple text blocks, the audio stream # will be combined into a single file. if "AudioStream" in response: with closing(response["AudioStream"]) as stream: output = os.path.join("/tmp/", postId) with open(output, "a") as file: file.write(stream.read()) s3 = boto3.client('s3') s3.upload_file('/tmp/' + postId, os.environ['BUCKET_NAME'], postId + ".mp3") s3.put_object_acl(ACL='public-read', Bucket=os.environ['BUCKET_NAME'], Key= postId + ".mp3") location = s3.get_bucket_location(Bucket=os.environ['BUCKET_NAME']) region = location['LocationConstraint'] if region is None: url_begining = "https://s3.amazonaws.com/" else: url_begining = "https://s3-" + str(region) + ".amazonaws.com/" \ url = url_begining \ + str(os.environ['BUCKET_NAME']) \ + "/" \ + str(postId) \ + ".mp3" #Updating the item in DynamoDB response = table.update_item( Key={'id':postId}, UpdateExpression= "SET #statusAtt = :statusValue, #urlAtt = :urlValue", ExpressionAttributeValues= {':statusValue': 'UPDATED', ':urlValue': url}, ExpressionAttributeNames= {'#statusAtt': 'pollyStatus', '#urlAtt': 'mp3Url'}, ) return |
Resources: ... ConvertAudio: Type: 'AWS::Serverless::Function' Properties: CodeUri: ConvertAudio Description: Convert Audio using Amazon Polly Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:Query' - 'dynamodb:UpdateItem' - 's3:GetBucketLocation' - 's3:PutObject' - 's3:PutObjectAcl' - 'polly:SynthesizeSpeech' Resource: '*' Events: ConvertResource: Type: SNS Properties: Topic: Ref: NewsTopic |
from __future__ import print_function import boto3 import os import json import decimal from boto3.dynamodb.conditions import Key, Attr # https://docs.aws.amazon.com/ko_kr/amazondynamodb/latest/developerguide/GettingStarted.Python.04.html # Helper class to convert a DynamoDB item to JSON. class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, decimal.Decimal): if o % 1 > 0: return float(o) else: return int(o) return super(DecimalEncoder, self).default(o) def lambda_handler(event, context): if "queryStringParameters" in event: event = event['queryStringParameters'] print (event) postId = event["postId"] dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) if postId == "*": items = table.scan() else: items = table.query(KeyConditionExpression=Key('id').eq(postId)) response = { 'statusCode': 200, 'body': json.dumps(items["Items"], cls=DecimalEncoder), 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } } return response |
Resources: ... GetNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: GetNews Description: Gather information from Ajax calls from web pages Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:Query' - 'dynamodb:Scan' Resource: '*' Events: GetNewsApi: Type: Api Properties: Path: /news Method: GET |
from __future__ import print_function import boto3 import os import json from boto3.dynamodb.conditions import Key, Attr def lambda_handler(event, context): if "body" in event: event = json.loads(event['body']) print (event) # Bad Request if event["postId"] is None or event["postId"] == "": response = { 'statusCode': 400, 'body': json.dumps({'message': "An unknown error has occurred. Missing required parameters."}), 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } } return response postId = event["postId"] dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) table.delete_item(Key={"id":postId}) s3 = boto3.client('s3') s3.delete_object(Bucket=os.environ['BUCKET_NAME'], Key= postId + ".mp3") response = { 'statusCode': 200, 'body': json.dumps({'message': "item is deleted : " + postId}), 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } } return response |
Resources: ... DeleteNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: DeleteNews Description: Delete news item in DynamoDB Table and mp3 file in S3 bucket. Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:DeleteItem' - 's3:DeleteObject' Resource: '*' Events: DeleteNewsApi: Type: Api Properties: Path: /news Method: DELETE |
Outputs: WebsiteURL: Description: Name of S3 bucket to hold website content Value: 'Fn::Join': - '' - - 'https://' - 'Fn::GetAtt': - StaticWebBucket - DomainName APIEndpointURL: Description: URL of your API endpoint Value: 'Fn::Sub': >- https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/news/ S3WebBucket: Description: S3 Bucket Name for web hosting Value: Ref: StaticWebBucket |
Globals: ... Api: # enable CORS; to make more specific, change the origin wildcard # to a particular domain name, e.g. "'www.example.com'" Cors: AllowMethods: "'*'" AllowHeaders: "'*'" AllowOrigin: "'*'" |
AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::Serverless-2016-10-31' Description: >- Building Serverless development environment and CI/CD process for DevOps based on Cloud9 Globals: Function: Runtime: python2.7 Handler: lambda_function.lambda_handler MemorySize: 128 Timeout: 60 Environment: Variables: DB_TABLE_NAME: Ref: NewsTable SNS_TOPIC: Ref: NewsTopic BUCKET_NAME: Ref: PollyMp3Bucket Api: # enable CORS; to make more specific, change the origin wildcard # to a particular domain name, e.g. "'www.example.com'" Cors: AllowMethods: "'*'" AllowHeaders: "'*'" AllowOrigin: "'*'" Resources: NewsTable: Type: 'AWS::Serverless::SimpleTable' Properties: PrimaryKey: Name: id Type: String ProvisionedThroughput: ReadCapacityUnits: 5 WriteCapacityUnits: 5 NewsTopic: Type: 'AWS::SNS::Topic' Properties: DisplayName: NewsTopic PollyMp3Bucket: Type: 'AWS::S3::Bucket' StaticWebBucket: Type: 'AWS::S3::Bucket' Properties: AccessControl: PublicRead WebsiteConfiguration: IndexDocument: index.html ErrorDocument: error.html PostNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: PostNews Description: Post news text to convert from text to speech Events: PostNewsApi: Type: Api Properties: Path: /news Method: POST Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:PutItem' - 'sns:Publish' Resource: '*' ConvertAudio: Type: 'AWS::Serverless::Function' Properties: CodeUri: ConvertAudio Description: Convert Audio using Amazon Polly Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:Query' - 'dynamodb:UpdateItem' - 's3:GetBucketLocation' - 's3:PutObject' - 's3:PutObjectAcl' - 'polly:SynthesizeSpeech' Resource: '*' Events: ConvertResource: Type: SNS Properties: Topic: Ref: NewsTopic GetNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: GetNews Description: Gather information from Ajax calls from web pages Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:Query' - 'dynamodb:Scan' Resource: '*' Events: GetNewsApi: Type: Api Properties: Path: /news Method: GET DeleteNews: Type: 'AWS::Serverless::Function' Properties: CodeUri: DeleteNews Description: Delete news item in DynamoDB Table and mp3 file in S3 bucket. Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:PutLogEvents' - 'logs:CreateLogStream' - 'dynamodb:DeleteItem' - 's3:DeleteObject' Resource: '*' Events: DeleteNewsApi: Type: Api Properties: Path: /news Method: DELETE Outputs: WebsiteURL: Description: Name of S3 bucket to hold website content Value: 'Fn::Join': - '' - - 'https://' - 'Fn::GetAtt': - StaticWebBucket - DomainName APIEndpointURL: Description: URL of your API endpoint Value: 'Fn::Sub': >- https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/news/ S3WebBucket: Description: S3 Bucket Name for web hosting Value: Ref: StaticWebBucket |
Cloud9 Code 창 하단의 Terminal로 접근
정적 웹 호스팅 파일 다운로드 받기
wget https://s3.ap-northeast-2.amazonaws.com/polly.awsdemokr.com/301_static_web.zip |
압축 풀고 폴더 이동
unzip 301_static_web.zip cd 301_static_web |
Cloud9에서 scripts.js 파일 열어서 CloudFormation Stack에 배포된 Output의 APIEndpointURL 값을 소스코드에 반영 (WebsiteURL이 아니므로 주의)
var API_ENDPOINT = "https://xxxxxxxxxx.execute-api.ap-southeast-1.amazonaws.com/Prod/news/"; if (API_ENDPOINT === "") { alert("scripts.js 파일의 상단에 API Gateway에 배포한 URL을 등록하고 실행하세요."); } |
정적 웹 포스팅하고자 하는 S3 버킷에 public-read 권한으로 파일을 업로드 (CloudFormation Stack에 배포된 Output의 S3WebBucket 값을 아래에 대체)
aws s3 sync . s3://cloud9-webapp-staticwebbucket-xxxxxxxxxxxx --acl public-read |
웹 페이지 동작 확인
CloudWatch Logs를 통해서 로그를 확인
로컬 환경에서 테스트하는 방법:
압축 풀기
스크립 파일 수정하기
정적 웹 호스팅 파일 S3에 업로드 하기
SAM(template.yml)에 DynamoDB, SNS, S3(Web, Mp3) 리소스 추가하기
"ConvertAudio" Lambda 함수 생성
"GetNews" Lambda 함수 생성
"DeleteNews" Lambda 함수 생성
SAM의 Output 설정
정적 웹 호스팅을 위한 파일 업로드하기
서비스 동작 테스트
SAM을 CloudFormation 스택에 직접 반영하기
DynamoDB는 posts 와 관련된 게시물 정보와 생성된 MP3의 URL을 저장합니다.
NoSQL인 DynamoDB를 사용하므로 스키마를 사전에 정의하지는 않겠지만, 사용하게 될 어트리뷰트가 어떤 것이 있는지 살펴 보겠습니다.
key | value |
---|---|
id | 게시물 ID (UUID로 자동 생성) |
voice | 오디오 파일을 생성하는데 사용된 Amazon Polly 음성 |
status | 처리 상태에 따라서 PROCESSING 또는 UPDATED로 구분 |
text | 원문 텍스트 |
replaceText | 변환될 텍스트 |
mp3Url | Polly에 의해서 생성된 mp3 |
updateDate | 수정된 시간 |
응용 프로그램에서 생성한 모든 오디오 파일을 저장하는 S3 버킷을 만들어야합니다.
아키텍처 다이어그램에서 알 수 있듯이 텍스트에 대한 MP3 생성 요청을 오디오 파일로 변환하는 로직을 두 개의 Lambda 함수로 분할했습니다(한자 변환 로직 제외). 몇 가지 이유로 이 작업을 수행했습니다.
첫째, 비동기 호출을 사용하여 애플리케이션에 새 게시물 정보를 보내는 사용자가 새 DynamoDB 항목의 id를 수신하도록 합니다. 이는 "New Post" Lambda 함수가 "Convert to Audio" Lambda 함수를 실행하는 동안 기다리는 것을 피할 수 있습니다. 그래서 나중에 수행해야 할 부분을 id를 통해서 공유 할 수 있습니다. 즉, 새로운 게시물 등록 작업에 대한 응답을 빠르게 클라이언트로 응답할 수 있습니다. 작은 게시물의 경우 오디오 파일로 변환하는 데 수백 밀리 초가 걸릴 수 있지만 글이 길어지면(10만 단어 이상) 텍스트를 변환하는 데 추가적인 시간이 필요할 수 있습니다. 다만, 실시간 스트리밍을 원할 때에는 문제가 되지 않습니다. Amazon Polly는 첫 번째 바이트를 사용할 수 있다면 곧바로 읽기를 시작하기 때문입니다.
두 번째 이유는 Lambda 함수는 5분 동안 실행할 수 있습니다. 이는 게시물을 변환하기 위해 충분한 시간입니다. 미래에 더 큰 것을 변화시키고 자 할 경우 Lambda 대신 AWS Batch를 사용하고자 할 수 있습니다. 애플리케이션의 이 두 부분을 분리하면 이 변경 작업이 훨씬 쉬워집니다. 위와 같이 두 개의 컴포넌트(여기서는 Lambda 함수 두 개)가 있을 때 이를 통합할 수 있습니다. 즉, 두 번째 컴포넌트가 언제 시작할지 알아야 합니다. 여러 가지 방법으로 이 작업을 수행 할 수 있습니다. 이 경우 Amazon SNS를 사용합니다. 새 게시물에 대한 메시지를 첫 번째 함수에서 두 번째 함수으로 보냅니다.
Lambda 함수를 만들기 전에 함수에 대한 IAM 역할을 만들어야 합니다. 역할은 함수가 상호 작용할 수 있는 AWS 서비스(API)를 지정합니다. 세 가지 Lambda 함수 모두에 대해 하나의 역할을 만듭니다. (원래 기능별로 역할을 만들지만, 예제 구현을 위해 하나로 만듭니다.)
역할을 생성하기 위해서 역할에 부여될 정책을 JSON 포맷으로 생성하고, 생성한 정책을 해당 역할에 부여합니다.
정책 생성 새 탭이 나타나면, JSON 편집기 탭을 선택하고 아래와 같이 AWS 권한을 정의하는 코드 를 붙여 넣고, Review policy 버튼을 클릭합니다.
{ "Version":"2012-10-17", "Statement":[ { "Effect":"Allow", "Action":[ "polly:SynthesizeSpeech", "dynamodb:Query", "dynamodb:Scan", "dynamodb:PutItem", "dynamodb:UpdateItem", "sns:Publish", "s3:PutObject", "s3:PutObjectAcl", "s3:GetBucketLocation", "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "lambda:InvokeFunction" ], "Resource":[ "*" ] } ] } |
생성된 LambdaPostsRole 역할은 앞으로 만들 Lambda 함수에 연결하여, 정책에 정의되어 있는 서비스들에 접근하여 읽기 또는 쓰기 작업을 수행할 수 있는 권한을 부여하는데 사용합니다.
첫 번째로 만들 Lambda 함수는 이 애플리케이션의 시작점입니다. 오디오 파일로 변환해야하는 새 게시물에 대한 정보를 받습니다.
아래 코드를 이 Lambda 함수의 코드로 변경합니다. 한자를 한글로 변환할 수 있는 다른 Lambda 함수를 호출(invoke)할 수 있습니다. 전처리가 필요할 경우, 함수에 추가 하거나 별도의 함수를 작성하여 호출하도록 구성할 수 있습니다. 항상 해당 함수를 호출하지 않아도 되거나, 다른 언어(여기서는 Java로 구현된 한자 변환 함수)로 구현한 함수를 연결할 수 있습니다.
import boto3 import os import json import uuid import datetime def lambda_handler(event, context): recordId = str(uuid.uuid4()) voice = event["voice"] originText = event["text"] hanja = event["hanja"] updateDate = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print('Generating new DynamoDB record, with ID: ' + recordId) print('Input Text: ' + originText) print('Selected voice: ' + voice) # Hanja to Korean if hanja: lambda_client = boto3.client('lambda') invoke_response = lambda_client.invoke( FunctionName = "HanjaToKorean", InvocationType = 'RequestResponse', Payload = json.dumps({"inputText": originText}) ) data = invoke_response['Payload'].read() resultText = json.loads(data) replaceText = resultText['outputText'] print('Hanja to Korean Text: ' + replaceText) else: replaceText = originText # Creating new record in DynamoDB table dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) table.put_item( Item={ 'id' : recordId, 'voice' : voice, 'text': originText, 'replaceText': replaceText, 'status' : "PROCESSING", 'updateDate': updateDate } ) # Sending notification about new post to SNS client = boto3.client('sns') client.publish( TopicArn = os.environ['SNS_TOPIC'], Message = recordId ) return recordId |
코드 바로 아래에 환경 변수에서 값을 넣어 줍니다.
실행 역할 에서 LambdaPostsRole 이 지정되어 있는지 확인하고, 제한 시간은 1분 으로 변경합니다.(Hanja to Korean 실행에서 발생할 수 있는 지연시간 고려)
"PostReader_NewPost" Lambda 기능이 준비되었습니다. 만약 테스트를 하려면, 다음 입력 데이터를 입력 데이터로 호출합니다.
{ "voice": "Seoyeon", "text": "안녕, 난 서연이야. Polly 서비스에서 텍스트를 읽어주는 서비스를 제공하고 있어.", "hanja": false } |
이 예제에서는 한자를 한글로 변환하는 Lambda 함수는 Java로 제작합니다. 동작 로직은 텍스트에서 한자 코드가 발견될 경우, 첨부된 hanjatohangle.xml 파일의 매핑 정보를 이용하여 한자를 한글로 변환시켜 줍니다. Java 파일은 컴파일을 하고, Jar 파일을 생성하여 Lambda에 직접 업로드하여 배포할 수 있습니다.
여기서는 Maven을 사용하여 Java 코드를 빌드하고, 배포 패키지를 Lambda 함수에 배포하는 방법을 살펴 보겠습니다. (참고: IDE 없이 Maven을 사용하여 .jar 배포 패키지 만들기(Java))
Linux 일 경우 패키지 관리자를 이용해서 다음과 같이 설치합니다.
sudo apt-get install maven |
만약 Homebrew를 사용하는 경우에는 아래와 같이 설치합니다.
brew install maven |
프로젝트를 빌드 하기 위해서는 HanjaToKorean 폴더에서 다음의 명령을 수행합니다. Maven을 수행하기 전 JDK가 설치되어 있지 않다면, JDK를 설치합니다.
mvn package |
DynamoDB 테이블에 저장된 텍스트를 오디오 파일인 "Convert to Audio"로 변환하는 Lambda 함수를 만들어 보겠습니다.
Lambda 함수 코드를 작성하기 위해서 다시 PostReader_ConvertToAudio 를 클릭하고 함수 코드를 아래에 있는 코드로 대체합니다.
import boto3 import os from contextlib import closing from boto3.dynamodb.conditions import Key, Attr def lambda_handler(event, context): postId = event["Records"][0]["Sns"]["Message"] print "Text to Speech function. Post ID in DynamoDB: " + postId #Retrieving information about the post from DynamoDB table dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) postItem = table.query( KeyConditionExpression=Key('id').eq(postId) ) text = postItem["Items"][0]["replaceText"] voice = postItem["Items"][0]["voice"] rest = text #Because single invocation of the polly synthesize_speech api can # transform text with about 1,500 characters, we are dividing the # post into blocks of approximately 1,000 characters. textBlocks = [] while (len(rest) > 1100): begin = 0 end = rest.find(".", 1000) if (end == -1): end = rest.find(" ", 1000) textBlock = rest[begin:end] rest = rest[end:] textBlocks.append(textBlock) textBlocks.append(rest) #For each block, invoke Polly API, which will transform text into audio polly = boto3.client('polly') for textBlock in textBlocks: response = polly.synthesize_speech( OutputFormat='mp3', Text = textBlock, VoiceId = voice ) #Save the audio stream returned by Amazon Polly on Lambda's temp # directory. If there are multiple text blocks, the audio stream # will be combined into a single file. if "AudioStream" in response: with closing(response["AudioStream"]) as stream: output = os.path.join("/tmp/", postId) with open(output, "a") as file: file.write(stream.read()) s3 = boto3.client('s3') s3.upload_file('/tmp/' + postId, os.environ['BUCKET_NAME'], postId + ".mp3") s3.put_object_acl(ACL='public-read', Bucket=os.environ['BUCKET_NAME'], Key= postId + ".mp3") location = s3.get_bucket_location(Bucket=os.environ['BUCKET_NAME']) region = location['LocationConstraint'] if region is None: url_begining = "https://s3.amazonaws.com/" else: url_begining = "https://s3-" + str(region) + ".amazonaws.com/" \ url = url_begining \ + str(os.environ['BUCKET_NAME']) \ + "/" \ + str(postId) \ + ".mp3" #Updating the item in DynamoDB response = table.update_item( Key={'id':postId}, UpdateExpression= "SET #statusAtt = :statusValue, #urlAtt = :urlValue", ExpressionAttributeValues= {':statusValue': 'UPDATED', ':urlValue': url}, ExpressionAttributeNames= {'#statusAtt': 'status', '#urlAtt': 'mp3Url'}, ) return |
입력 메시지 (SNS 이벤트)에서 오디오 파일로 변환해야하는 DynamoDB 항목의 ID (게시물 ID)를 검색합니다.
세 번째 Lambda 함수는 데이터베이스에서 게시물에 대한 정보를 검색하는 메소드를 제공합니다.
이번 코드는 매우 짧습니다. 이 함수는 게시물 id (DynamoDB 항목의 id)를 얻고 이 id를 기반으로 모든 정보(오디오 파일이있는 경우 S3 링크 포함)를 반환합니다. 입력 매개 변수가 별표(*)인 경우 좀 더 사용자 친화적인 것으로 만들기 위해 Lambda 함수는 데이터베이스에서 모든 항목을 반환합니다. (항목이 많을 경우 성능이 저하 될 수 있고 오랜 시간이 걸릴 수 있으므로 테스트용으로 사용하지만, 이 방법을 추천하지 않습니다.)
import boto3 import os from boto3.dynamodb.conditions import Key, Attr def lambda_handler(event, context): postId = event["postId"] dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['DB_TABLE_NAME']) if postId == "*": items = table.scan() else: items = table.query(KeyConditionExpression=Key('id').eq(postId)) return items["Items"] |
다음을 입력 데이터로 함수를 실행하여 함수를 테스트하십시오.
{ "postId": "*" } |
마지막으로해야 할 일은 애플리케이션 로직을 RESTful 웹 서비스로 노출시켜 표준 HTTP 프로토콜을 사용하여 쉽게 호출 할 수 있도록 합니다. 이를 위해 Amazon API Gateway를 사용합니다.
쿼리 문자열 파라미터인 postId를 Lambda가 인식할 수 있는 JSON 형태로 다음과 같이 매핑 테이블을 작성하고 저장합니다.
{ "postId" : "$input.params('postId')" } |
API 설정이 완료 되었습니다. 배포를 해서 애플리케이션에서 호출할 수 있는 URL을 얻습니다. 작업 에서 API 배포 를 선택합니다.
API를 Dev 스테이지로 배포를 합니다. 개발, 테스트, 프로덕션에 이르기까지 다양한 스테이지로 나누어서 배포가 가능합니다. 여기서는 dev 스테이지로 배포합니다.
API 배포까지 완료되었습니다. 해당 API를 호출 할 수 있는 URL이 생성되었음을 확인할 수 있습니다.
배포가 완료되면 해당 API를 호출할 수 있는 URL이 표시됩니다. 앞으로 동적 컨텐츠 API 호출은 이 URL로 호출할 것이기에 해당 URL을 메모합니다.
Amazon S3는 정적 웹 페이지를 호스팅 할 수 있습니다. 다음의 링크를 통해 정적 웹 호스팅을 하기 위한 패키지를 다운로드 할 수 있습니다: 3개의 파일(html, css, javascript)가 포함되어져 있으며, javascript를 사용하여 동적 컨텐츠 API 호출을 API Gateway로 연결합니다.
다음의 순서로 진행합니다.
마지막 단계는 우리 웹 사이트에 모든 사람이 액세스 할 수 있도록 버킷의 권한을 변경하는 것입니다. 권한 탭에서 다음 정책을 추가하여 버킷 정책을 편집하십시오. 12번째 줄에 BUCKET_NAME 을 방금 생성한 S3 버킷의 이름으로 교체 하십시오.
{ "Version":"2012-10-17", "Statement":[ { "Sid":"PublicReadGetObject", "Effect":"Allow", "Principal":"*", "Action":[ "s3:GetObject" ], "Resource":[ "arn:aws:s3:::BUCKET_NAME/*" ] } ] } |
모든 준비가 끝났습니다. 정적 웹 사이트 호스팅 탭에서 URL을 찾아서 웹 사이트가 작동하는지 확인할 수 있습니다. 상단의 정적 웹 페이지 호스팅에 나와 있는 엔드포인트로 접속하면 아래와 같은 웹 페이지가 나타납니다.
이 게시물에서 텍스트를 수십 개의 언어로 음성으로 변환하고 그 텍스트를 훨씬 더 많은 목소리로 말할 수있는 애플리케이션을 만들었습니다. 블로그 게시물을 음성으로 변환하는 애플리케이션을 만들었지만 웹 사이트에서 텍스트를 변환하거나 웹 애플리케이션에서 음성 기능을 추가하는 등의 다른 목적으로 블로그 게시물을 사용할 수 있습니다. 그리고 서버리스 서비스들만 이용해서 구축했습니다. 즉, 유지 관리하거나 패치해야 하는 서버들이 존재하지 않습니다. 기본적으로 AWS Lambda, Amazon API Gateway, Amazon S3 및 Amazon DynamoDB는 다수의 가용 영역을 사용하기 때문에 애플리케이션은 고가용성(HA)을 가집니다. 뿐만 아니라, Wordpress를 사용하고 있다면, Wordpress Polly Plugin을 이용하면 손쉽게 Polly를 사용할 수 있습니다. 그럼 이 다음에는 무엇을 할 수 있을까요? 이런 접근법을 사용하면 이전에 가능했던 것보다 더 나은 사용자 경험을 제공하는 새로운 애플리케이션을 상상하고 구축할 수 있습니다.
이 글은 Tomasz Stachlewski가 작성한 Build Your Own Text-to-Speech Applications with Amazon Polly 의 블로그를 한국어 콘솔에 맞게 번역 및 편집 하였습니다. - 김현수 솔루션스 아키텍트는 회사가 디지털 변환을 가속화 할 수 있게 해주는 서버리스(serverless) 아키텍처와 같은 혁신적인 기술과 인공지능(AI/ML)에 관심이 많습니다. |
기타 자료