diff --git a/.github/workflows/deploy-lambda-dev.yml b/.github/workflows/deploy-lambda-dev.yml index 2b63f594..1f32dee3 100644 --- a/.github/workflows/deploy-lambda-dev.yml +++ b/.github/workflows/deploy-lambda-dev.yml @@ -2,12 +2,32 @@ name: Deploy Lambda to Dev on: workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + repository_dispatch: + types: [deploy-to-test-event] + push: + branches: [ develop ] + jobs: deploy: - name: Build and Deploy Lambda + name: Deploy Lambda Dev runs-on: ubuntu-latest + env: + S3_BUCKET: sopt-makers-internal + STACK_NAME: playground-dev + AWS_REGION: ap-northeast-2 + steps: - name: Checkout code uses: actions/checkout@v3 @@ -18,97 +38,52 @@ jobs: distribution: 'corretto' java-version: '17' - - name: Setup Gradle cache - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v2 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_TEMP }} aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_TEMP }} - aws-region: ${{ secrets.AWS_REGION }} + aws-region: ${{ env.AWS_REGION }} - - name: Get application-lambda-dev.yml from AWS S3 + - name: Get application config from S3 run: | aws s3 cp \ - --region ap-northeast-2 \ - s3://sopt-makers-internal/dev/deploy/application-lambda-dev.yml src/main/resources/application-lambda-dev.yml + s3://${{ env.S3_BUCKET }}/dev/deploy/application-lambda-dev.yml \ + src/main/resources/application-lambda-dev.yml - - name: Get Apple key from AWS S3 + - name: Get Apple key from S3 run: | aws s3 cp \ - --region ap-northeast-2 \ - s3://sopt-makers-internal/dev/deploy/${{ secrets.APPLE_KEY }} src/main/resources/static/${{ secrets.APPLE_KEY }} - - - name: Set up QEMU for multi-platform builds - uses: docker/setup-qemu-action@v2 - with: - platforms: linux/arm64 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to ECR Public - run: | - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws - - - name: Login to ECR Private - run: | - aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + s3://${{ env.S3_BUCKET }}/dev/deploy/${{ secrets.APPLE_KEY }} \ + src/main/resources/static/${{ secrets.APPLE_KEY }} - - name: Generate timestamp tag - id: timestamp - run: | - TIMESTAMP=$(date +%Y%m%d-%H%M%S) - echo "IMAGE_TAG=build-$TIMESTAMP" >> $GITHUB_OUTPUT - echo "Generated image tag: build-$TIMESTAMP" + - name: Build Lambda + run: ./gradlew clean lambdaJar -x test - - name: Build Docker image with GraalVM native compilation + - name: Upload JAR to S3 run: | - docker buildx build \ - --platform=linux/arm64 \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - -f lambda/dev.Dockerfile \ - -t ${{ secrets.AWS_LAMBDA_DEV_ECR_REPO }}:${{ steps.timestamp.outputs.IMAGE_TAG }} \ - --load \ - . - - - name: Tag and push Docker image to ECR - run: | - REPO_URI=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.AWS_LAMBDA_DEV_ECR_REPO }} - - # Push with timestamp tag - docker tag ${{ secrets.AWS_LAMBDA_DEV_ECR_REPO }}:${{ steps.timestamp.outputs.IMAGE_TAG }} $REPO_URI:${{ steps.timestamp.outputs.IMAGE_TAG }} - docker push $REPO_URI:${{ steps.timestamp.outputs.IMAGE_TAG }} + # 빌드 ZIP 파일 찾기 + JAR_FILE=$(ls build/distributions/*-lambda.zip | head -1) - # Push with latest tag - docker tag ${{ secrets.AWS_LAMBDA_DEV_ECR_REPO }}:${{ steps.timestamp.outputs.IMAGE_TAG }} $REPO_URI:latest - docker push $REPO_URI:latest + # 타임스탬프 생성 + TIMESTAMP=$(date +"%Y%m%d-%H%M%S") + S3_KEY="lambda/playground-dev-${TIMESTAMP}-lambda.zip" - echo "IMAGE_URI=$REPO_URI:${{ steps.timestamp.outputs.IMAGE_TAG }}" >> $GITHUB_ENV + # S3 업로드 + aws s3 cp "$JAR_FILE" "s3://${{ env.S3_BUCKET }}/$S3_KEY" - - name: Set up Python for SAM CLI - uses: actions/setup-python@v4 - with: - python-version: '3.11' + echo "S3_KEY=$S3_KEY" >> $GITHUB_ENV - - name: Install AWS SAM CLI - run: | - pip install aws-sam-cli + - name: Install SAM CLI + uses: aws-actions/setup-sam@v2 - - name: Deploy to Lambda with SAM + - name: Deploy with SAM working-directory: ./lambda run: | sam deploy \ --config-env dev \ - --no-confirm-changeset \ + --stack-name ${{ env.STACK_NAME }} \ --no-fail-on-empty-changeset \ - --parameter-overrides ImageUri=${{ env.IMAGE_URI }} + --parameter-overrides \ + S3Bucket=${{ env.S3_BUCKET }} \ + S3Key=${{ env.S3_KEY }} diff --git a/build.gradle b/build.gradle index 84347d3d..aec5d0cc 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,6 @@ plugins { id 'org.springframework.boot' version '3.2.0' id 'io.spring.dependency-management' version '1.1.7' id 'java' - id 'org.graalvm.buildtools.native' version '0.10.4' - id 'org.springframework.boot.aot' version '3.2.0' apply false id 'org.hibernate.orm' version '6.2.13.Final' } @@ -37,7 +35,14 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-actuator" implementation "org.springframework.boot:spring-boot-starter-data-jpa" implementation "org.springframework.boot:spring-boot-starter-aop" - implementation "org.springframework.boot:spring-boot-starter-web" + if (project.hasProperty('lambda')) { + implementation("org.springframework.boot:spring-boot-starter-web") { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + } else { + // 로컬 테스트시 Tomcat 포함 + implementation "org.springframework.boot:spring-boot-starter-web" + } implementation "org.springframework.boot:spring-boot-starter-mail" implementation "org.springframework.boot:spring-boot-starter-security" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" @@ -49,6 +54,11 @@ dependencies { implementation 'com.bucket4j:bucket4j-core:8.4.0' implementation 'net.minidev:json-smart' + // AWS Lambda Dependencies for JAR deployment + implementation 'com.amazonaws.serverless:aws-serverless-java-container-springboot3:2.1.5' + implementation 'com.amazonaws:aws-lambda-java-core:1.4.0' + implementation 'com.amazonaws:aws-lambda-java-events:3.16.1' + // log implementation 'net.logstash.logback:logstash-logback-encoder:7.3' @@ -104,9 +114,6 @@ dependencies { implementation 'com.google.apis:google-api-services-sheets:v4-rev516-1.23.0' implementation 'com.google.auth:google-auth-library-oauth2-http:0.20.0' - // Web Client - implementation("org.springframework.boot:spring-boot-starter-webflux") - // Mac M1/M2 ARM 환경에서 Netty DNS Resolver implementation "io.netty:netty-resolver-dns-native-macos:4.1.82.Final:osx-aarch_64" @@ -128,368 +135,37 @@ tasks.named('test') { useJUnitPlatform() } -// Native Image 호환성 체크 태스크 -tasks.register('checkNativeImageCompatibility') { - group = 'verification' - description = 'Native Image 호환성을 체크합니다' - - doLast { - println "🔍 Native Image 호환성 체크 중..." - - def warnings = [] - - // @QueryProjection 사용 체크 - fileTree('src/main/java').matching { - include '**/*.java' - }.each { file -> - if (file.text.contains('@QueryProjection') && file.text.contains('record ')) { - warnings.add("⚠️ ${file.name}: Record + @QueryProjection 사용 (Projections.constructor() 권장)") - } - - // LAZY 없이 @OneToMany 체크 - if (file.text.contains('@OneToMany') && !file.text.contains('FetchType.EAGER') && !file.text.contains('fetch = FetchType')) { - warnings.add("ℹ️ ${file.name}: @OneToMany에 fetch type 명시 권장") - } - } - - if (warnings.isEmpty()) { - println "✅ Native Image 호환성 문제 없음!" - } else { - println "\n경고 사항:" - warnings.each { println it } - } - } -} - -// ========== Native Image Hints 자동 생성 유틸리티 ========== - -// Java 파일 스캔 공통 함수 -def scanJavaFiles(Closure processor) { - fileTree('src/main/java').matching { - include '**/*.java' - }.each { file -> processor(file, file.text) } -} - -// Hints 파일 작성 공통 함수 -def writeHintsFile(File outputDir, String fileName, String content) { - new File(outputDir, fileName).text = content - println " ✅ ${fileName}" -} - -// Hints 클래스 템플릿 생성 함수 -def generateHintsTemplate(String className, String javadoc, String imports, String body) { - """package org.sopt.makers.internal.graalVm.nativeImageHints; - -${imports} -/** - * ${javadoc} - * - * ⚠️ 자동 생성 파일 - 수정하지 마세요! - * 생성: Native Image 빌드 시 (./gradlew nativeCompile) - */ -@Configuration -@ImportRuntimeHints(${className}.${className}RuntimeHints.class) -public class ${className} { - - static class ${className}RuntimeHints implements RuntimeHintsRegistrar { - - @Override - public void registerHints(RuntimeHints hints, ClassLoader classLoader) { -${body} - } - } -} -""" -} - -// Native Image Hints 자동 생성 태스크 (Native Image 빌드 시에만 실행) -tasks.register('generateNativeImageHints') { - group = 'native-image' - description = 'Native Image 빌드를 위한 Hints 파일들을 자동 생성 (빌드 타임 전용)' - - def outputDir = file("${buildDir}/generated-sources/nativeHints/org/sopt/makers/internal/graalVm/nativeImageHints") - - doLast { - outputDir.mkdirs() - println "🤖 Native Image Hints 자동 생성 중..." - println "📁 출력 경로: ${outputDir}" - - [ - [1, 'QueryDSL Projection', { generateQueryDslProjectionHints(outputDir) }], - [2, 'Feign', { generateFeignHints(outputDir) }], - [3, 'Admin Resources', { generateAdminResourcesHints(outputDir) }], - [4, 'Hibernate Types', { generateHibernateTypesHints(outputDir) }] - ].each { step, name, generator -> - println "\n🔍 [${step}/4] ${name} Hints 생성..." - generator() - } - - println "\n✅ 모든 Hints 파일 생성 완료!" - println "ℹ️ 이 파일들은 Native Image 빌드 시에만 사용되며 Git에 커밋되지 않습니다." - } -} - -def generateQueryDslProjectionHints(File outputDir) { - def complexDtos = [] - def packagePattern = ~/package\s+([\w.]+);/ - - scanJavaFiles { file, content -> - if (content.contains('@Reflective') && content.contains('record ')) { - def hasComplexType = content =~ /(?:List|Set|Map)<[^>]+>/ || - content =~ /\b\w+\[\]\s+\w+[,)]/ || - content =~ /record\s+\w+\([^)]*[A-Z]\w+\s+\w+/ - - if (hasComplexType) { - def pkgMatcher = content =~ packagePattern - def recMatcher = content =~ /(?:public\s+)?record\s+(\w+)/ - if (pkgMatcher.find() && recMatcher.find()) { - def fullClassName = "${pkgMatcher.group(1)}.${recMatcher.group(1)}" - complexDtos.add(fullClassName) - println " ✓ 감지: ${recMatcher.group(1)}" - } - } +// Lambda ZIP 빌드 설정 - buildZip 설정을 포함한 Lambda 최적화 +task lambdaJar(type: Zip) { + dependsOn bootJar // bootJar 작업이 먼저 실행되도록 명시적 의존성 추가 + archiveClassifier = 'lambda' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + zip64 = true // 대용량 ZIP 파일 지원 (65535개 이상의 파일) + + // buildZip처럼 lib 디렉토리 구조로 패키징 + into('lib') { + from(jar) + from(configurations.runtimeClasspath) { + // Tomcat 관련 제외 - Lambda에서는 불필요 + exclude "org/apache/tomcat/embed/**" + exclude "META-INF/*.SF" + exclude "META-INF/*.DSA" + exclude "META-INF/*.RSA" + exclude "META-INF/MANIFEST.MF" + exclude "**/module-info.class" } } - - def imports = """import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHints; -import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.aot.hint.TypeReference; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportRuntimeHints;""" - - def body = """ registerComplexProjectionDtos(hints); - } - - private void registerComplexProjectionDtos(RuntimeHints hints) { - String[] complexDtoClasses = { -${complexDtos.collect { " \"${it}\"" }.join(',\n')} - }; - - for (String className : complexDtoClasses) { - hints.reflection().registerType( - TypeReference.of(className), - builder -> builder.withMembers( - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.PUBLIC_FIELDS, - MemberCategory.DECLARED_FIELDS - ) - ); - }""" - - def javadoc = """QueryDSL Projections.constructor()를 위한 GraalVM Native Image 힌트 - * - * 복잡한 타입(List, 배열 등)을 가진 Record의 생성자를 등록합니다.""" - - def content = generateHintsTemplate('QueryDslProjectionNativeHints', javadoc, imports, body) - writeHintsFile(outputDir, 'QueryDslProjectionNativeHints.java', content) - println " (${complexDtos.size()}개 DTO)" } -def generateHibernateTypesHints(File outputDir) { - def usedTypes = [] as Set - def typePattern = ~/@Type\(([\w.]+)\.class\)/ - - scanJavaFiles { file, content -> - def matcher = content =~ typePattern - while (matcher.find()) { - def typeName = matcher.group(1) - if (typeName.contains('ArrayType')) usedTypes.add(typeName) - } - } - - def imports = """import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHints; -import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportRuntimeHints;""" - - def body = """ registerHypersistenceTypes(hints); - } - - private void registerHypersistenceTypes(RuntimeHints hints) { - String[] hypersistenceTypes = { - "io.hypersistence.utils.hibernate.type.array.ListArrayType", - "io.hypersistence.utils.hibernate.type.array.StringArrayType" - }; - - for (String typeName : hypersistenceTypes) { - try { - Class type = Class.forName(typeName); - hints.reflection().registerType(type, builder -> builder.withMembers( - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_PUBLIC_METHODS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.PUBLIC_FIELDS, - MemberCategory.DECLARED_FIELDS - )); - } catch (ClassNotFoundException ignored) {} - }""" - - def javadoc = """Hypersistence Utils Hibernate Types를 위한 GraalVM Native Image 힌트 - * - * Hibernate 커스텀 타입들의 리플렉션 인스턴스화를 지원합니다.""" - - def content = generateHintsTemplate('HibernateTypesNativeHints', javadoc, imports, body) - writeHintsFile(outputDir, 'HibernateTypesNativeHints.java', content) - - if (usedTypes.isEmpty()) { - println " ⚠️ 사용 중인 타입 없음" - } else { - println " ✓ 감지된 타입: ${usedTypes.join(', ')}" - } -} - -def generateAdminResourcesHints(File outputDir) { - def imports = """import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHints; -import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportRuntimeHints;""" - - def body = """ hints.resources().registerPattern("templates/admin/*.html"); - hints.resources().registerPattern("static/css/*.css"); - - hints.reflection().registerType( - org.thymeleaf.spring6.SpringTemplateEngine.class, - hint -> hint.withMembers( - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, - MemberCategory.INVOKE_PUBLIC_METHODS - ) - ); - - hints.reflection().registerType( - org.thymeleaf.spring6.view.ThymeleafViewResolver.class, - hint -> hint.withMembers( - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, - MemberCategory.INVOKE_PUBLIC_METHODS - ) - );""" - - def javadoc = """Admin 페이지를 위한 GraalVM Native Image 힌트 - * - * Thymeleaf 템플릿과 정적 리소스(CSS)를 Native Image에 포함시킵니다.""" - - def content = generateHintsTemplate('AdminResourcesNativeHints', javadoc, imports, body) - writeHintsFile(outputDir, 'AdminResourcesNativeHints.java', content) -} +// Lambda 빌드를 기본 빌드에 포함 +build.dependsOn lambdaJar -def generateFeignHints(File outputDir) { - def imports = """import feign.*; -import feign.codec.*; -import org.springframework.aot.hint.ExecutableMode; -import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHints; -import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportRuntimeHints;""" - - def enrichMethods = [ - 'RequestInterceptor', 'ResponseInterceptor.Chain', 'Client', 'Decoder', 'Encoder', - 'Contract', 'Logger', 'Logger.Level', 'InvocationHandlerFactory', 'QueryMapEncoder', - 'ErrorDecoder', 'Request.Options', 'Retryer', 'ExceptionPropagationPolicy' - ].collect { """ hints.reflection().registerMethod( - Capability.class.getMethod("enrich", ${it}.class), ExecutableMode.INVOKE - );""" }.join('\n') - - def body = """ registerFeignCoreClasses(hints); - registerCapabilityMethods(hints); - registerSpringCloudFeignClasses(hints); - } - - private void registerFeignCoreClasses(RuntimeHints hints) { - Class[] coreClasses = { - Capability.class, Client.class, Contract.class, Decoder.class, Encoder.class, - ErrorDecoder.class, InvocationHandlerFactory.class, Logger.class, QueryMapEncoder.class, - RequestInterceptor.class, ResponseInterceptor.class, ResponseInterceptor.Chain.class, - Retryer.class, ExceptionPropagationPolicy.class, Request.Options.class - }; - - for (Class clazz : coreClasses) { - hints.reflection().registerType(clazz, - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.DECLARED_FIELDS, MemberCategory.PUBLIC_FIELDS - ); - } - } - - private void registerCapabilityMethods(RuntimeHints hints) { - try { -${enrichMethods} - } catch (NoSuchMethodException ignored) {} - } - - private void registerSpringCloudFeignClasses(RuntimeHints hints) { - String[] springCloudClasses = { - "org.springframework.cloud.openfeign.FeignClientFactoryBean", - "org.springframework.cloud.openfeign.FeignContext", - "org.springframework.cloud.openfeign.support.SpringMvcContract", - "org.springframework.cloud.openfeign.support.SpringEncoder", - "org.springframework.cloud.openfeign.support.SpringDecoder", - "org.springframework.cloud.openfeign.support.ResponseEntityDecoder", - "org.springframework.cloud.openfeign.FeignLoggerFactory", - "org.springframework.cloud.openfeign.clientconfig.FeignClientConfigurer" - }; - - for (String className : springCloudClasses) { - try { - Class clazz = Class.forName(className); - hints.reflection().registerType(clazz, - MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_METHODS - ); - } catch (ClassNotFoundException ignored) {} - }""" - - def javadoc = """Feign Client를 위한 GraalVM Native Image 힌트 - * - * Feign의 default interface methods를 Native Image에 등록합니다.""" - - def content = generateHintsTemplate('FeignNativeHints', javadoc, imports, body) - writeHintsFile(outputDir, 'FeignNativeHints.java', content) +// Spring Boot JAR 빌드 설정 +jar { + enabled = true + archiveClassifier = '' } -// 생성된 소스를 컴파일 경로에 추가 (먼저 선언) -sourceSets { - main { - java { - srcDir "${buildDir}/generated-sources/nativeHints" - } - } -} - -// Native Image 빌드 전에 자동으로 Hints 생성 -// processAot보다 더 early stage인 compileJava에 연결 -tasks.named('compileJava').configure { - dependsOn 'generateNativeImageHints' -} - -// Hibernate bytecode enhancement 설정 (Native Image 지원) -hibernate { - enhancement { - enableLazyInitialization = true - enableDirtyTracking = true - enableAssociationManagement = true - } -} - -// GraalVM 네이티브 컴파일을 위한 설정 -graalvmNative { - binaries { - main { - imageName = 'internal' - mainClass = 'org.sopt.makers.internal.InternalApplication' - buildArgs.add('--verbose') - buildArgs.add('-H:+ReportExceptionStackTraces') - buildArgs.add('--initialize-at-run-time=io.netty') - buildArgs.add('-H:+AddAllCharsets') - buildArgs.add('-H:+AllowDeprecatedBuilderClassesOnImageClasspath') - } - } - metadataRepository { - enabled = true - } +bootJar { + enabled = true } diff --git a/lambda/dev.Dockerfile b/lambda/dev.Dockerfile deleted file mode 100644 index d2c7b61d..00000000 --- a/lambda/dev.Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# GraalVM 네이티브 컴파일을 위한 멀티스테이지 빌드 -FROM ghcr.io/graalvm/native-image-community:17.0.9 as builder - -# 작업 디렉토리 설정 -WORKDIR /app - -# 필요한 빌드 도구 설치 -RUN microdnf install -y findutils - -# Gradle Wrapper와 빌드 설정 파일만 먼저 복사 (dependency 캐싱용) -COPY gradlew . -COPY gradle gradle -COPY build.gradle settings.gradle ./ - -# Gradle Wrapper 실행 권한 부여 -RUN chmod +x ./gradlew - -# Dependency 다운로드 (이 레이어는 build.gradle이 변경되지 않으면 캐시됨) -RUN ./gradlew dependencies --no-daemon || true - -# 소스 코드 복사 (dependency 다운로드 후) -COPY src ./src - -# 네이티브 컴파일 실행 (메모리 최적화) -ENV SPRING_PROFILES_ACTIVE=lambda-dev -ENV GRADLE_OPTS="-Xmx3g" -RUN ./gradlew clean nativeCompile -x test --no-daemon \ - -Dorg.gradle.jvmargs="-Xmx3g" \ - && ls -lah /app/build/native/nativeCompile/ - -# 최종 이미지 - glibc 호환을 위해 amazonlinux 사용 -FROM public.ecr.aws/amazonlinux/amazonlinux:2023 - -# Lambda adapter 복사 (ECR 공식 이미지에서) -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter - -WORKDIR /app - -# 네이티브 바이너리 복사 (빌드 스테이지에서) -COPY --from=builder /app/build/native/nativeCompile/internal /app/internal - -# 환경변수 설정 -ENV SPRING_PROFILES_ACTIVE=lambda-dev -ENV PORT=8080 -ENV AWS_LWA_READINESS_CHECK_PATH=/actuator/health -ENV AWS_LWA_READINESS_CHECK_PORT=8080 - -# 네이티브 바이너리 실행 권한 부여 및 실행 -RUN chmod +x /app/internal -CMD ["/app/internal"] diff --git a/lambda/lambda-deploy-test.sh b/lambda/lambda-deploy-test.sh new file mode 100755 index 00000000..7c7e98d4 --- /dev/null +++ b/lambda/lambda-deploy-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# 간단한 Lambda JAR 배포 스크립트 + +set -e # 에러 발생시 중단 + +# 설정 +ENV=${1:-dev} +S3_BUCKET="sopt-makers-internal" +STACK_NAME="playground-${ENV}" +AWS_REGION="ap-northeast-2" + +# 색상 정의 +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +echo "🚀 Lambda JAR 배포 시작 (환경: $ENV)" + +# 1. JAR 빌드 +echo "📦 JAR 빌드 중..." +./gradlew clean lambdaJar -x test + +# 2. S3 업로드 +JAR_FILE=$(ls build/distributions/*-lambda.zip | head -1) +# 타임스탬프 추가 (YYYYMMDD-HHMMSS 형식) +TIMESTAMP=$(date +"%Y%m%d-%H%M%S") +S3_KEY="lambda/playground-${ENV}-${TIMESTAMP}-lambda.zip" + +echo "☁️ S3 업로드 중..." +echo " 파일: $JAR_FILE" +echo " S3 경로: s3://${S3_BUCKET}/${S3_KEY}" +aws s3 cp "$JAR_FILE" "s3://${S3_BUCKET}/${S3_KEY}" + +# 4. SAM으로 배포 +echo "🔄 SAM 배포 중..." +cd lambda + +# SAM 배포 실행 +sam deploy \ + --config-env ${ENV} \ + --stack-name ${STACK_NAME} \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + S3Bucket=${S3_BUCKET} \ + S3Key=${S3_KEY} + +cd .. + +echo -e "${GREEN}✅ 배포 완료!${NC}" + +# API 엔드포인트 출력 +API_ENDPOINT=$(aws cloudformation describe-stacks \ + --stack-name ${STACK_NAME} \ + --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ + --output text \ + --region ${AWS_REGION}) + +echo -e "${GREEN}🌐 API: ${API_ENDPOINT}${NC}" diff --git a/lambda/local_dev_lambda_deploy.sh b/lambda/local_dev_lambda_deploy.sh deleted file mode 100755 index 5ec5c8a3..00000000 --- a/lambda/local_dev_lambda_deploy.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash - -set -e # 에러 발생 시 스크립트 중단 - -echo "aws profile 을 입력해주세요 (기본 configuration 활용 시, default 입력) :" -read profile -if [ -z "$profile" ]; then - profile="default" -fi -profile=$(echo $profile | tr '[:upper:]' '[:lower:]') - -mode="dev" - -echo "=== 컨테이너 이미지 기반 람다 배포 시작 ===" - -# 환경별 리전 및 리포지토리 설정 -REGION="ap-northeast-2" -ACCOUNT_ID="379013966998" -REPOSITORY_NAME="playground-$mode" - -REPO_URI="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPOSITORY_NAME" - -echo "사용할 설정:" -echo " - AWS Profile: $profile" -echo " - Mode: $mode" -echo " - Region: $REGION" -echo " - Repository: $REPOSITORY_NAME" -echo " - ECR URI: $REPO_URI" - -# 타임스탬프 기반 고유 태그 생성 -TIMESTAMP=$(date +%Y%m%d-%H%M%S) -IMAGE_TAG="build-$TIMESTAMP" - -echo "=== 1단계: ECR 로그인 ===" -# Public ECR 로그인 (Lambda adapter 이미지 접근용) -aws ecr-public get-login-password --region us-east-1 --profile "$profile" | docker login --username AWS --password-stdin public.ecr.aws -if [ $? -ne 0 ]; then - echo "❌ 공개 ECR 로그인 실패" - exit 1 -fi - -# Private ECR 로그인 (이미지 푸시용) -aws ecr get-login-password --region "$REGION" --profile "$profile" | docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com" -if [ $? -ne 0 ]; then - echo "❌ 개인 ECR 로그인 실패" - exit 1 -fi -echo "✅ ECR 로그인 완료" - -echo "=== 2단계: Docker를 사용한 GraalVM 네이티브 빌드 ===" -echo "Docker를 사용하여 GraalVM 네이티브 컴파일을 수행합니다..." -docker build -f lambda/dev.Dockerfile --platform=linux/arm64 -t "$REPOSITORY_NAME:$IMAGE_TAG" . -if [ $? -ne 0 ]; then - echo "❌ Docker 네이티브 빌드 실패" - exit 1 -fi -echo "✅ Docker 네이티브 빌드 완료" - -echo "=== 3단계: ECR 리포지토리 확인/생성 ===" -echo "리포지토리 확인 중: $REPOSITORY_NAME" -aws ecr describe-repositories --region "$REGION" --profile "$profile" --repository-names "$REPOSITORY_NAME" > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "ECR 리포지토리가 존재하지 않습니다. 생성 중..." - aws ecr create-repository --region "$REGION" --profile "$profile" --repository-name "$REPOSITORY_NAME" --image-scanning-configuration scanOnPush=true - if [ $? -ne 0 ]; then - echo "❌ ECR 리포지토리 생성 실패" - exit 1 - fi - echo "✅ ECR 리포지토리 생성 완료" -else - echo "✅ ECR 리포지토리 존재 확인" -fi - -echo "=== 4단계: ECR에 이미지 푸시 ===" -# 고유 태그로 이미지 푸시 -docker tag "$REPOSITORY_NAME:$IMAGE_TAG" "$REPO_URI:$IMAGE_TAG" -docker push "$REPO_URI:$IMAGE_TAG" -if [ $? -ne 0 ]; then - echo "❌ ECR 이미지 푸시 실패" - exit 1 -fi - -# latest 태그도 업데이트 -docker tag "$REPOSITORY_NAME:$IMAGE_TAG" "$REPO_URI:latest" -docker push "$REPO_URI:latest" -if [ $? -ne 0 ]; then - echo "❌ ECR latest 태그 업데이트 실패" - exit 1 -fi - -echo "✅ ECR 이미지 푸시 완료" - -echo "=== 5단계: 로컬 이미지 정리 ===" -echo "로컬 Docker 이미지 정리 중..." -docker rmi "$REPOSITORY_NAME:$IMAGE_TAG" 2>/dev/null || true -docker rmi "$REPO_URI:$IMAGE_TAG" 2>/dev/null || true -docker rmi "$REPO_URI:latest" 2>/dev/null || true - -echo "✅ 로컬 이미지 정리 완료" - -echo "=== 6단계: Lambda 배포 ===" -cd lambda - -# 고유 태그로 배포 -IMAGE_URI="$REPO_URI:$IMAGE_TAG" -echo "배포 대상 이미지: $IMAGE_URI" - -sam deploy \ - --config-env "$mode" \ - --profile "$profile" \ - --parameter-overrides ImageUri="$IMAGE_URI" - -echo "✅ 람다 배포 성공" - diff --git a/lambda/template-dev.yaml b/lambda/template-dev.yaml index de6fead8..bb091bf5 100644 --- a/lambda/template-dev.yaml +++ b/lambda/template-dev.yaml @@ -1,67 +1,81 @@ AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: > - Playground Backend DEV - Spring Boot on AWS Lambda Container + Playground Backend DEV - Spring Boot JAR on AWS Lambda with SnapStart Globals: Function: Timeout: 300 - MemorySize: 4096 # 속도 개선을 위해 기본 스펙 높게 설정 + MemorySize: 3072 # 메모리 최적화 (JAR 실행에 적절한 수준) + Runtime: java17 Parameters: - ImageUri: + S3Bucket: Type: String - Description: ECR Image URI (need to be overridden) + Default: "" + Description: S3 bucket containing the Lambda + S3Key: + Type: String + Default: "" + Description: S3 key (path) to the Lambda file + Profile: + Type: String + Default: "lambda-dev" + Description: Spring profile to use Resources: PlaygroundApiFunction: Type: AWS::Serverless::Function Properties: FunctionName: !Sub "playground-api-dev" - PackageType: Image - ImageUri: !Ref ImageUri - Architectures: - - arm64 + CodeUri: + Bucket: !Ref S3Bucket + Key: !Ref S3Key + Handler: org.sopt.makers.internal.LambdaHandler + SnapStart: + ApplyOn: PublishedVersions # SnapStart 활성화 AutoPublishAlias: live + Environment: + Variables: + SPRING_PROFILES_ACTIVE: !Ref Profile + TZ: Asia/Seoul + JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1" Policies: - AWSLambdaBasicExecutionRole + - Statement: + - Effect: Allow + Action: + - s3:GetObject + Resource: !Sub "arn:aws:s3:::${S3Bucket}/*" + Events: - Warmer: - Type: Schedule - Properties: - Schedule: rate(5 minutes) - Enabled: true - ApiRoot: - Type: HttpApi - Properties: - ApiId: !Ref PlaygroundHttpApi - Path: / - Method: ANY +# # Warmer 이벤트 (SnapStart 환경에는 권장되지 않음) +# Warmer: +# Type: Schedule +# Properties: +# Schedule: rate(5 minutes) +# Enabled: true +# Input: '{"warmer": true}' ApiProxy: - Type: HttpApi + Type: Api Properties: - ApiId: !Ref PlaygroundHttpApi + RestApiId: !Ref PlaygroundApi Path: /{proxy+} Method: ANY - PlaygroundHttpApi: - Type: AWS::Serverless::HttpApi + + PlaygroundApi: + Type: AWS::Serverless::Api Properties: - StageName: $default - CorsConfiguration: - AllowOrigins: - - "*" - AllowHeaders: - - "*" - AllowMethods: - - GET - - POST - - PUT - - PATCH - - DELETE - - OPTIONS + StageName: dev + Cors: + AllowOrigin: "'*'" + AllowHeaders: "'*'" + AllowMethods: "'*'" + AllowCredentials: "'false'" Outputs: ApiEndpoint: - Description: "HTTP API Gateway endpoint URL" - Value: !Sub "https://${PlaygroundHttpApi}.execute-api.${AWS::Region}.amazonaws.com" + Description: "API Gateway endpoint URL" + Value: !Sub "https://${PlaygroundApi}.execute-api.${AWS::Region}.amazonaws.com" + diff --git a/src/main/java/org/sopt/makers/internal/LambdaHandler.java b/src/main/java/org/sopt/makers/internal/LambdaHandler.java new file mode 100644 index 00000000..7701167f --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/LambdaHandler.java @@ -0,0 +1,41 @@ +package org.sopt.makers.internal; + +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class LambdaHandler implements RequestStreamHandler { + + private static SpringBootLambdaContainerHandler handler; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + try { + handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(InternalApplication.class); + SpringBootLambdaContainerHandler.getContainerConfig().addBinaryContentTypes( + "image/png", + "image/jpeg", + "image/gif", + "application/octet-stream" + ); + } catch (ContainerInitializationException e) { + throw new RuntimeException("Could not initialize Spring Boot application", e); + } catch (Exception e) { + throw new RuntimeException("Could not initialize Lambda handler", e); + } + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} diff --git a/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthClient.java b/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthClient.java index d0b946dc..b3f1f947 100644 --- a/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthClient.java +++ b/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthClient.java @@ -4,32 +4,37 @@ import lombok.extern.slf4j.Slf4j; import org.sopt.makers.internal.auth.external.code.ClientFailure; import org.sopt.makers.internal.auth.external.exception.ClientException; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; @Slf4j @Component @RequiredArgsConstructor public class AuthClient { - private final WebClient authWebClient; + private final RestTemplate authRestTemplate; private final AuthClientProperty authProperty; public String getJwk() { try { - return authWebClient.get() - .uri(authProperty.endpoints().jwk()) - .retrieve() - .bodyToMono(String.class) - .onErrorMap(WebClientResponseException.class, ex -> { - log.error("Failed to receive response from Auth server: {}", ex.getResponseBodyAsString(), ex); - return new ClientException(ClientFailure.RESPONSE_ERROR); - }) - .block(); + ResponseEntity response = authRestTemplate.getForEntity( + authProperty.endpoints().jwk(), + String.class + ); + return response.getBody(); + } catch (HttpClientErrorException | HttpServerErrorException ex) { + log.error("Failed to receive response from Auth server: {}", ex.getResponseBodyAsString(), ex); + throw new ClientException(ClientFailure.RESPONSE_ERROR); + } catch (ResourceAccessException e) { + log.error("Unexpected exception occurred during Auth server communication: {}", e.getMessage(), e); + throw new ClientException(ClientFailure.COMMUNICATION_ERROR); } catch (RuntimeException e) { log.error("Unexpected exception occurred during Auth server communication: {}", e.getMessage(), e); throw new ClientException(ClientFailure.COMMUNICATION_ERROR); } } -} +} \ No newline at end of file diff --git a/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthRestTemplateConfig.java b/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthRestTemplateConfig.java new file mode 100644 index 00000000..4b4057d5 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthRestTemplateConfig.java @@ -0,0 +1,39 @@ +package org.sopt.makers.internal.auth.external.auth; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.Collections; + +@Configuration +public class AuthRestTemplateConfig { + + public static final String HEADER_API_KEY = "X-Api-Key"; + public static final String HEADER_SERVICE_NAME = "X-Service-Name"; + private static final int TIMEOUT_SECONDS = 5; + + @Bean + public RestTemplate authRestTemplate(RestTemplateBuilder builder, AuthClientProperty property) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + factory.setReadTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + + return builder + .rootUri(property.url()) + .requestFactory(() -> factory) + .interceptors((request, body, execution) -> { + HttpHeaders headers = request.getHeaders(); + headers.add(HEADER_API_KEY, property.apiKey()); + headers.add(HEADER_SERVICE_NAME, property.serviceName()); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + return execution.execute(request, body); + }) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthWebClientConfig.java b/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthWebClientConfig.java deleted file mode 100644 index b888a498..00000000 --- a/src/main/java/org/sopt/makers/internal/auth/external/auth/AuthWebClientConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.sopt.makers.internal.auth.external.auth; - -import io.netty.channel.ChannelOption; -import io.netty.handler.timeout.ReadTimeoutHandler; -import io.netty.handler.timeout.WriteTimeoutHandler; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.netty.http.client.HttpClient; - -import java.time.Duration; - -@Configuration -public class AuthWebClientConfig { - - public static final String HEADER_API_KEY = "X-Api-Key"; - public static final String HEADER_SERVICE_NAME = "X-Service-Name"; - private static final int TIMEOUT_MILLIS = 5000; - private static final int TIMEOUT_SECONDS = 5; - - @Bean - public WebClient authWebClient(AuthClientProperty property) { - return WebClient.builder() - .baseUrl(property.url()) - .clientConnector(new ReactorClientHttpConnector(createDefaultHttpClient())) - .defaultHeader(HEADER_API_KEY, property.apiKey()) - .defaultHeader(HEADER_SERVICE_NAME, property.serviceName()) - .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .build(); - } - - private HttpClient createDefaultHttpClient() { - return HttpClient.create() - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TIMEOUT_MILLIS) - .responseTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)) - .doOnConnected(conn -> conn - .addHandlerLast(new ReadTimeoutHandler(TIMEOUT_SECONDS)) - .addHandlerLast(new WriteTimeoutHandler(TIMEOUT_SECONDS)) - ); - } -} \ No newline at end of file diff --git a/src/main/java/org/sopt/makers/internal/common/exception/GlobalExceptionHandler.java b/src/main/java/org/sopt/makers/internal/common/exception/GlobalExceptionHandler.java index e82a81a9..31c24e2f 100644 --- a/src/main/java/org/sopt/makers/internal/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/sopt/makers/internal/common/exception/GlobalExceptionHandler.java @@ -21,6 +21,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.context.request.ServletWebRequest; @Slf4j @RestControllerAdvice diff --git a/src/main/java/org/sopt/makers/internal/member/repository/MemberRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/MemberRepository.java index 90652e45..30e97040 100644 --- a/src/main/java/org/sopt/makers/internal/member/repository/MemberRepository.java +++ b/src/main/java/org/sopt/makers/internal/member/repository/MemberRepository.java @@ -13,5 +13,9 @@ public interface MemberRepository extends JpaRepository { List findAllByIdIn(List ids); List findAllByHasProfileTrueAndIdIn(List memberIds); + + @Query("SELECT DISTINCT m FROM Member m LEFT JOIN FETCH m.careers WHERE m.hasProfile = true AND m.id IN :memberIds") + List findAllByHasProfileTrueAndIdInWithCareers(@Param("memberIds") List memberIds); + List findAllByWorkPreferenceNotNull(); } diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberService.java b/src/main/java/org/sopt/makers/internal/member/service/MemberService.java index 6db6e277..3a2f8768 100644 --- a/src/main/java/org/sopt/makers/internal/member/service/MemberService.java +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberService.java @@ -953,7 +953,7 @@ private String checkTeamNullCondition(String team) { public List getAllMakersMembersProfiles() { List makerMemberIds = MakersMemberId.getMakersMember(); - List members = memberRepository.findAllByHasProfileTrueAndIdIn(makerMemberIds); + List members = memberRepository.findAllByHasProfileTrueAndIdInWithCareers(makerMemberIds); List userDetails = platformService.getInternalUsers(makerMemberIds); Map> careerMap = members.stream() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 523ab805..aebbe243 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -113,7 +113,7 @@ push-notification: action: test x-api-key: test service: test - + crew: server-url: test @@ -135,4 +135,4 @@ external: jwk: /.well-known/jwks.json admin: - key: test-admin-key \ No newline at end of file + key: test-admin-key