AWS와 같은 Cloud 환경에서 이벤트 기반으로 개발할 때, AWS 계정의 권한 유무에 따라 개발자의 생산성에 영향을 미침. 이에 LocalStack 기반으로 로컬 환경에서 직접 E2E 테스트를 해보면서 개발하기 전에 필요한 리소스를 절약 가능.
-
AWS의 경우, AWS Adapter 라이브러리 필수! Azure와 GCP의 경우도 별도의 Adapter를 직접 추가하면서 테스트 가능
-
개별 비즈니스 로직은 Junit이나 SpringBootTest로 테스트 가능하나, 실제 환경과 비슷한 E2E 테스트가 가능하다.
-
build.gradle 내 jar 만드는 과정을 잘 설정해줘야 실제 프로젝트에서 실행 오류가 나지 않는다.
LocalStack의 경우, AWS만 지원되므로 Azure는 Azurite, GCP의 경우 별도의 Mocking용 Cli 기능을 제공한다.
build.gradle
plugins { id 'java' id 'org.springframework.boot' version '3.4.4' id 'io.spring.dependency-management' version '1.1.7' id 'com.github.johnrengelman.shadow' version '8.1.1' id 'maven-publish' id 'org.springframework.boot.experimental.thin-launcher' version "1.0.31.RELEASE" } // https://github.com/spring-cloud/spring-cloud-function/blob/main/spring-cloud-function-samples/function-sample-aws/build.gradle group = 'dev.rumble' version = '0.0.1-SNAPSHOT' java { sourceCompatibility = '17' } repositories { mavenCentral() mavenLocal() maven { url 'https://repo.spring.io/milestone' } } ext { set('springCloudVersion', "2024.0.1") } assemble.dependsOn = [thinJar, shadowJar] publishing { publications { maven(MavenPublication) { from components.java versionMapping { usage('java-api') { fromResolutionOf('runtimeClasspath') } usage('java-runtime') { fromResolutionResult() } } } } } shadowJar.mustRunAfter thinJar import com.github.jengelman.gradle.plugins.shadow.transformers.* shadowJar { archiveClassifier = 'aws' manifest { inheritFrom(project.tasks.thinJar.manifest) } // Required for Spring mergeServiceFiles() append 'META-INF/spring.handlers' append 'META-INF/spring.schemas' append 'META-INF/spring.tooling' append 'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' append 'META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports' transform(PropertiesFileTransformer) { paths = ['META-INF/spring.factories'] mergeStrategy = "append" } } dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-function-adapter-aws' implementation 'software.amazon.awssdk:dynamodb:2.25.4' # aws sdk 2버전 implementation 'software.amazon.awssdk:s3:2.25.4' implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' # aws 내 람다에서 사용 implementation 'com.amazonaws:aws-lambda-java-events:3.8.0' implementation 'org.springframework.cloud:spring-cloud-function-context' testImplementation 'org.springframework.boot:spring-boot-starter-test' } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } tasks.named('test') { useJUnitPlatform() }
실제 AWS 와 유사한 시뮬레이션 환경 제공.
AWS에서 실제 사용되는 Request, Response 값을 기준으로 초기 검증에 도움
예시 코드
version: '3.8'
services:
localstack:
image: localstack/localstack:latest
container_name: localstack
ports:
- "4566:4566" # Edge port -
environment:
- SERVICES=s3,lambda,dynamodb,sqs,sns,ssm,cloudformation,iam,logs,apigateway # 예제로 사용할 Service 목록
- DEBUG=1
- DOCKER_HOST=unix:///var/run/docker.sock
- LAMBDA_EXECUTOR=docker # lambda
- LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT=60 # runtime init timeout default = 10
volumes:
- ./localstack:/var/lib/localstack
- /var/run/docker.sock:/var/run/docker.sock실시간 로그 확인
> docker logs -f localstack
실시간 로그 예시
2025-04-11T13:17:33.726 INFO --- [et.reactor-3] localstack.request.http : POST /_localstack_lambda/69783bc1bdf84380ad219ae7e33f97f1/status/69783bc1bdf84380ad219ae7e33f97f1/ready => 202
2025-04-11T13:17:33.728 DEBUG --- [er:$LATEST_0] l.s.l.i.execution_environm : Start of execution environment 69783bc1bdf84380ad219ae7e33f97f1 for function arn:aws:lambda:us-east-1:000000000000:function:spring-cloud-aws-local-dev-s3Handler:$LATEST took 43141.54ms
2025-04-11T13:17:33.731 DEBUG --- [er:$LATEST_0] l.s.l.i.docker_runtime_exe : Sending invoke-payload '{"invoke-id": "3fc434ce-bd56-4621-9724-8540339a92f3", "invoked-function-arn": "arn:aws:lambda:us-east-1:000000000000:function:spring-cloud-aws-local-dev-s3Handler", "payload": "{\"Records\": [{\"eventVersion\": \"2.1\", \"eventSource\": \"aws:s3\", \"awsRegion\": \"us-east-1\", \"eventTime\": \"2025-04-11T13:16:52.987Z\", \"eventName\": \"ObjectCreated:Put\", \"userIdentity\": {\"principalId\": \"AIDAJDPLRKLG7UEXAMPLE\"}, \"requestParameters\": {\"sourceIPAddress\": \"127.0.0.1\"}, \"responseElements\": {\"x-amz-request-id\": \"b14a66e1\", \"x-amz-id-2\": \"eftixk72aD6Ap51TnqcoF8eFidJG9Z/2\"}, \"s3\": {\"s3SchemaVersion\": \"1.0\", \"configurationId\": \"84b27787\", \"bucket\": {\"name\": \"my-bucket\", \"ownerIdentity\": {\"principalId\": \"A3NL1KOZZKExample\"}, \"arn\": \"arn:aws:s3:::my-bucket\"}, \"object\": {\"key\": \"aws\", \"sequencer\": \"0055AED6DCD90281E5\", \"eTag\": \"098f6bcd4621d373cade4e832627b4f6\", \"size\": 4}}}]}", "trace-id": "Root=1-67f91645-fce3a2f1bc3ccadcadf2d564;Parent=fd5bcd2ce33ef878;Sampled=1"}' to executor '69783bc1bdf84380ad219ae7e33f97f1'
2025-04-11T13:17:33.981 INFO --- [et.reactor-2] localstack.request.http : POST /_localstack_lambda/69783bc1bdf84380ad219ae7e33f97f1/invocations/3fc434ce-bd56-4621-9724-8540339a92f3/logs => 202
CloudFormation과 같은 IaC 작업을 YML 기반으로 쉽게 작성 가능한 프레임워크
사전작업
> brew install node
> npm install -g serverless
> npm install --save-dev serverless-localstack
Serverless 기반으로 배포 예시
> serverless deploy --stage dev
Serverless 기반 CloudFormation Json 확인
> serverless info
| 명령어 | 설명 |
|---|---|
serverless deploy / sls deploy |
프로젝트 배포 (로컬 or AWS) |
sls invoke -f 함수명 |
함수 수동 호출 |
sls remove |
리소스 삭제 |
sls info |
배포된 정보 확인 |
sls logs -f 함수명 |
Lambda 로그 보기 |
sls print |
Cloudformation json 보기 |
소스 변경 사항이 있는 경우는 1번부터 수행.
인프라만 변경 사항이 있는 경우는 2번부터 수행.
-
Gradle Build 수행
./gradlew clean build -
Serverless Deploy 수행
샘플의 경우, 테스트할 stage를 dev로 설정했지만, local로도 변경하여 사용 가능하다.
serverless deploy --stage dev배포 완료시, API Gateway가 매핑된 람다의 경우, Gateway ResourceId가 생성된다. 이를 기반으로 호출하면 된다.
특히, ResourceId는 LocalStack을 다시 띄우면 바뀌므로 주의해야한다.
sls print 시, get-file이라는 POST 메서드가 생성된다는 것을 볼 수 있다.
getFile: handler: org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest environment: SPRING_CLOUD_FUNCTION_DEFINITION: getS3Object MAIN_CLASS: dev.rumble.serverless.ServerlessApplication events: - http: path: get-file method: post
위의 CloudFormation이 정상 배포시 아래와 같이 표시된다.
C:\Users\root\Desktop\Study\localstack\serverless>sls deploy --stage dev Deploying "spring-cloud-aws-local" to stage "dev" (us-east-1) Using serverless-localstack serverless-localstack: Reconfigured endpoints Skipping template validation: Unsupported in Localstack ✔ Service deployed to stack spring-cloud-aws-local-dev (98s) endpoint: http://localhost:4566/restapis/7nnetdx0fv/dev/_user_request_ functions: s3Handler: spring-cloud-aws-local-dev-s3Handler (35 MB) getFile: spring-cloud-aws-local-dev-getFile (35 MB) snsHandler: spring-cloud-aws-local-dev-snsHandler (35 MB) sqsHandler: spring-cloud-aws-local-dev-sqsHandler (35 MB) -
Aws Cli 명령어 호출 혹은 API Gateway의 API Path 호출
-
API Gateway와 연결된 람다의 경우, 29초가 timeout 시간으로 설정.
위의 예시의 경우, 아래와 같이 샘플 API를 호출하면 된다.
> C:\Users\root\Desktop>curl -X POST -H "Content-Type: application/json" http://localhost:4566/restapis/7nnetdx0fv/dev/_user_request_/get-file -d "myfile.txt" "File content:\ntest" -
Event Trigger를 기반으로 테스트를 한다면, Aws Cli로 호출해야한다.
> C:\Users\root\Desktop>aws --endpoint-url=http://localhost:4566 sns publish --topic-arn arn:aws:sns:us-east-1:000000000000:MyTopic --message "Hello from SNS 2" { "MessageId": "4ab06c8e-8108-4b83-a78d-f9d9ec5dc37b" } > C:\Users\root\Desktop>aws --endpoint-url=http://localhost:4566 dynamodb scan --table-name MyTable { "Items": [ { "message": { "S": "{\"Records\": [{\"messageId\": \"2dd7046c-9869-46e1-87b6-eebe5a82a50a\", \"receiptHandle\": \"YTU1NzA0OTgtNzlkZC00NDI2LTk0NWYtOWRkOWE4OWFlZWZiIGFybjphd3M6c3FzOnVzLWVhc3QtMTowMDAwMDAwMDAwMDA6TXlRdWV1ZSAyZGQ3MDQ2Yy05ODY5LTQ2ZTEtODdiNi1lZWJlNWE4MmE1MGEgMTc0NDM3MTEwMy40NzUzODQ1\", \"body\": \"{\\\"Type\\\": \\\"Notification\\\", \\\"MessageId\\\": \\\"4ab06c8e-8108-4b83-a78d-f9d9ec5dc37b\\\", \\\"TopicArn\\\": \\\"arn:aws:sns:us-east-1:000000000000:MyTopic\\\", \\\"Message\\\": \\\"Hello from SNS 2\\\", \\\"Timestamp\\\": \\\"2025-04-11T11:31:31.386Z\\\", \\\"UnsubscribeURL\\\": \\\"http://localhost.localstack.cloud:4566/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:000000000000:MyTopic:e60ba03d-a23d-470b-a8de-926e950bc763\\\", \\\"SignatureVersion\\\": \\\"1\\\", \\\"Signature\\\": \\\"So/mISrgBNYej5/+ymY6kjYAwV3xtUhuofTNUtBHlA0qJtf4pdGCpxJVVio7N+6yLXYPsaH83Ho5eXpuBHy8ljg94BLfX0caI1Ygcj5ii+deqEdi5EsOYHqowGBb7n0FoUhL/DMg3b2PuS7tpTWfzu6/6E6EyIaiXVKFoPt3k7+uGBbP9c/X5qpGzPf34f9xyhDIRFqxNl0zSS1wPubxgxkNAEmbCYZhS2i+lbGbjF+R7JH1e5ts4qgXuwzpDuREcwF4UUhfhd+ibrNaF5UhV2lA/nNIL0AHgI3GhYFgk+27JkgbhdJwlkUKxTTXfirbgPuXPtATx7uyT5gr4XyV5Q==\\\", \\\"SigningCertURL\\\": \\\"http://localhost.localstack.cloud:4566/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem\\\"}\", \"attributes\": {\"SenderId\": \"000000000000\", \"SentTimestamp\": \"1744371091553\", \"ApproximateReceiveCount\": \"1\", \"ApproximateFirstReceiveTimestamp\": \"1744371103475\"}, \"messageAttributes\": {}, \"md5OfBody\": \"c67f1b908db7450961f534d3e44b2ccb\", \"eventSourceARN\": \"arn:aws:sqs:us-east-1:000000000000:MyQueue\", \"eventSource\": \"aws:sqs\", \"awsRegion\": \"us-east-1\"}]}" }, "id": { "S": "a092b949-e06f-437d-a7d0-d7ae0b99faad" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null }
-
현재 프로젝트에서 localstack은 wsl의 docker 환경에서 띄우고 있다. wsl의 docker 환경과 windows를 연결시키려면 아래와 같은 방식으로 연결 가능하다.
-
WSL에서 dev eth0 proto kernel scope link IP 주소를 찾는다.
$ ip route -
PowerShell을 관리자 모드로 켜고, 원하는 포트와 ip를 허용해 준다.
> netsh interface portproxy add v4tov4 listenport=4566 listenaddress=0.0.0.0 connectport=4566 connectaddress=위에서 찾은 ip -
포트가 정상적으로 등록됬는지 확인 한다.
(proxy 형태로 허용하는 형태여서, windows에서 해당 port를 쓸일이 있으면 지워줘야된다.)
> netsh interface portproxy show all