diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 523c17a17..1fdcc8e3d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -7,7 +7,7 @@ on: - 'src/**' - 'build.gradle' - 'Dockerfile' - - 'ecs-task-definitions/api-task-definition.json' + - 'api-task-definition.json' # 수정된 부분 - '.github/workflows/deploy-api.yml' workflow_dispatch: inputs: @@ -21,7 +21,7 @@ env: AWS_REGION: ap-northeast-2 ECR_REPOSITORY: survey-api/service ECS_CLUSTER: survey-cluster - ECS_SERVICE: api-service + ECS_SERVICE: api-task-service-v12 ECS_TASK_DEFINITION: api-task-definition.json jobs: @@ -59,10 +59,24 @@ jobs: - name: Build application run: ./gradlew bootJar -x test + - name: 'Debug: Dump OIDC Token Payload' + uses: actions/github-script@v6 + with: + script: | + try { + const token = await core.getIDToken(); + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + console.log('--- OIDC Token Payload ---'); + console.log(JSON.stringify(payload, null, 2)); + console.log('--------------------------'); + } catch (error) { + core.setFailed(`Error dumping OIDC token: ${error.message}`); + } + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: arn:aws:iam::025861172546:role/github-actions-role # 실제 ARN으로 교체 필요 + role-to-assume: arn:aws:iam::025861172546:role/github-actions-role role-session-name: GitHub-Actions-Survey-API aws-region: ${{ env.AWS_REGION }} @@ -97,7 +111,7 @@ jobs: id: task-def uses: aws-actions/amazon-ecs-render-task-definition@v1 with: - task-definition: ecs-task-definitions/${{ env.ECS_TASK_DEFINITION }} + task-definition: ${{ env.ECS_TASK_DEFINITION }} # 수정된 부분 container-name: api-container image: ${{ steps.build-image.outputs.image }} diff --git a/Dockerfile b/Dockerfile index 92bea1383..704e8f4e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ -FROM docker.elastic.co/elasticsearch/elasticsearch:8.15.0 +FROM eclipse-temurin:17-jre-alpine -RUN bin/elasticsearch-plugin install analysis-nori +COPY build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/api-task-definition.json b/api-task-definition.json new file mode 100644 index 000000000..3a5f6252f --- /dev/null +++ b/api-task-definition.json @@ -0,0 +1,80 @@ +{ + "family": "api-task", + "networkMode": "awsvpc", + "requiresCompatibilities": ["EC2"], + "cpu": "1024", + "memory": "1536", + "executionRoleArn": "arn:aws:iam::025861172546:role/ecsTaskExecutionRole", + "containerDefinitions": [{ + "name": "api-container", + "image": "025861172546.dkr.ecr.ap-northeast-2.amazonaws.com/survey-api/service:latest", + "memory": 1024, + "portMappings": [{ + "containerPort": 8080, + "protocol": "tcp" + }], + "essential": true, + "secrets": [ + { "name": "DB_USERNAME", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-db/credentials:username::" }, + { "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-db/credentials:password::" }, + + { "name": "SECRET_KEY", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:SECRET_KEY::" }, + { "name": "RABBITMQ_USERNAME", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:RABBITMQ_USERNAME::" }, + { "name": "RABBITMQ_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:RABBITMQ_PASSWORD::" }, + { "name": "MONGODB_USERNAME", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:MONGODB_USERNAME::" }, + { "name": "MONGODB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:MONGODB_PASSWORD::" }, + { "name": "NAVER_SECRET", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:NAVER_SECRET::" }, + { "name": "GOOGLE_SECRET", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:GOOGLE_SECRET::" }, + + { "name": "FIREBASE_PRIVATE_KEY_ID", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:FIREBASE_PRIVATE_KEY_ID::" }, + { "name": "FIREBASE_PRIVATE_KEY", "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:025861172546:secret:prod/survey-api/secrets:FIREBASE_PRIVATE_KEY::" } + ], + "environment": [ + {"name": "SPRING_PROFILES_ACTIVE", "value": "prod"}, + {"name": "SPRING_JPA_HIBERNATE_DDL_AUTO", "value": "none"}, + {"name": "DB_HOST", "value": "survey-database.c5gckg46ikaa.ap-northeast-2.rds.amazonaws.com"}, + {"name": "DB_PORT", "value": "5432"}, + {"name": "DB_SCHEME", "value": "survey_db"}, + {"name": "REDIS_HOST", "value": "cache.survey-cache.local"}, + {"name": "REDIS_PORT", "value": "6379"}, + {"name": "RABBITMQ_HOST", "value": "message.survey-message.local"}, + {"name": "RABBITMQ_PORT", "value": "5672"}, + {"name": "MONGODB_HOST", "value": "document.survey-document.local"}, + {"name": "MONGODB_PORT", "value": "27017"}, + {"name": "MONGODB_DATABASE", "value": "survey_read_db"}, + {"name": "MONGODB_AUTHDB", "value": "admin"}, + {"name": "ELASTIC_URIS", "value": "http://elastic.survey-elastic.local:9200"}, + {"name": "ELASTICSEARCH_ENABLED", "value": "true"}, + {"name": "SPRING_MAIL_ENABLED", "value": "false"}, + {"name": "MANAGEMENT_HEALTH_MAIL_ENABLED", "value": "false"}, + {"name": "MANAGEMENT_HEALTH_ELASTICSEARCH_ENABLED", "value": "false"}, + + {"name": "FIREBASE_ENABLED", "value": "true"}, + {"name": "FIREBASE_PROJECT_ID", "value": "survey-f5a93"}, + {"name": "FIREBASE_CLIENT_EMAIL", "value": "firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com"}, + {"name": "FIREBASE_CLIENT_ID", "value": "100191250643521230154"}, + + {"name": "KAKAO_CLIENT_ID", "value": "095680b1255e001a75547779c2466dc7"}, + {"name": "KAKAO_REDIRECT_URL", "value": "https://api.surveylink.site/auth/kakao/login"}, + {"name": "NAVER_CLIENT_ID", "value": "fXngEOOPoc9G6_PYaVwk"}, + {"name": "NAVER_REDIRECT_URL", "value": "https://api.surveylink.site/auth/naver/login"}, + {"name": "GOOGLE_CLIENT_ID", "value": "143003014057-q9baq71l6sohdveir85j124hbo17hhqu.apps.googleusercontent.com"}, + {"name": "GOOGLE_REDIRECT_URL", "value": "https://api.surveylink.site/auth/google/login"} + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/api-task", + "awslogs-region": "ap-northeast-2", + "awslogs-stream-prefix": "ecs" + } + }, + "healthCheck": { + "command": ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + } + }] +} diff --git a/src/main/java/com/example/surveyapi/global/config/FcmConfig.java b/src/main/java/com/example/surveyapi/global/config/FcmConfig.java index 601860e51..ff8dda5ef 100644 --- a/src/main/java/com/example/surveyapi/global/config/FcmConfig.java +++ b/src/main/java/com/example/surveyapi/global/config/FcmConfig.java @@ -1,38 +1,160 @@ package com.example.surveyapi.global.config; +import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StringUtils; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Configuration public class FcmConfig { - @Value("${firebase.credentials.path}") - private String firebaseCredentialsPath; - - @Bean - public FirebaseApp firebaseApp() throws IOException { - ClassPathResource resource = new ClassPathResource(firebaseCredentialsPath.replace("classpath:", "")); - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(resource.getInputStream())) - .build(); - - if(FirebaseApp.getApps().isEmpty()) { - return FirebaseApp.initializeApp(options); - } - return FirebaseApp.getInstance(); - } - - @Bean - public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { - return FirebaseMessaging.getInstance(firebaseApp); - } + + // 기존 파일 경로 방식 (로컬 개발용) + @Value("${firebase.credentials.path:}") + private String firebaseCredentialsPath; + + // 환경변수 방식 (프로덕션용) + @Value("${firebase.project-id:survey-f5a93}") + private String projectId; + + @Value("${firebase.private-key-id:}") + private String privateKeyId; + + @Value("${firebase.private-key:}") + private String privateKey; + + @Value("${firebase.client-email:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com}") + private String clientEmail; + + @Value("${firebase.client-id:100191250643521230154}") + private String clientId; + + @Value("${firebase.enabled:true}") + private boolean firebaseEnabled; + + @PostConstruct + public void init() { + if (!firebaseEnabled) { + log.info("Firebase is disabled by configuration"); + return; + } + + if (StringUtils.hasText(firebaseCredentialsPath)) { + log.info("Firebase will be initialized using file: {}", firebaseCredentialsPath); + } else if (StringUtils.hasText(privateKey) && StringUtils.hasText(privateKeyId)) { + log.info("Firebase will be initialized using environment variables"); + } else { + log.warn("Firebase credentials not found. Firebase features will be disabled."); + } + } + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (!firebaseEnabled) { + log.warn("Firebase is disabled. Skipping FirebaseApp initialization."); + return null; + } + + InputStream credentialsStream = getCredentialsStream(); + + if (credentialsStream == null) { + log.error("Failed to get Firebase credentials. Firebase features will be disabled."); + return null; + } + + try { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(credentialsStream)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp app = FirebaseApp.initializeApp(options); + log.info("FirebaseApp initialized successfully"); + return app; + } + return FirebaseApp.getInstance(); + } finally { + credentialsStream.close(); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + if (firebaseApp == null) { + log.warn("FirebaseApp is null. FirebaseMessaging will not be available."); + return null; + } + return FirebaseMessaging.getInstance(firebaseApp); + } + + private InputStream getCredentialsStream() throws IOException { + // 1. 먼저 파일 경로 방식 시도 (기존 방식 - 로컬 개발용) + if (StringUtils.hasText(firebaseCredentialsPath)) { + try { + if (firebaseCredentialsPath.startsWith("classpath:")) { + ClassPathResource resource = new ClassPathResource( + firebaseCredentialsPath.replace("classpath:", "") + ); + return resource.getInputStream(); + } else { + return new FileInputStream(firebaseCredentialsPath); + } + } catch (IOException e) { + log.warn("Failed to load Firebase credentials from file: {}", e.getMessage()); + } + } + + // 2. 환경변수 방식 시도 (프로덕션용) + if (StringUtils.hasText(privateKey) && StringUtils.hasText(privateKeyId)) { + String firebaseConfig = buildFirebaseConfig(); + return new ByteArrayInputStream(firebaseConfig.getBytes(StandardCharsets.UTF_8)); + } + + // 3. 둘 다 실패한 경우 + log.error("No Firebase credentials found. Check firebase.credentials.path or firebase.private-key/firebase.private-key-id"); + return null; + } + + private String buildFirebaseConfig() { + // 환경변수의 \n을 실제 개행 문자로 변환 + String formattedPrivateKey = privateKey.replace("\\n", "\n"); + + return String.format(""" + { + "type": "service_account", + "project_id": "%s", + "private_key_id": "%s", + "private_key": "%s", + "client_email": "%s", + "client_id": "%s", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/%s", + "universe_domain": "googleapis.com" + } + """, + projectId, + privateKeyId, + formattedPrivateKey, + clientEmail, + clientId, + clientEmail.replace("@", "%40") + ); + } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2b02452cf..f784abbf5 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,4 +1,4 @@ -# 운영(prod) 환경 전용 설정 + spring: datasource: driver-class-name: org.postgresql.Driver @@ -11,10 +11,9 @@ spring: connection-timeout: 10000 idle-timeout: 600000 max-lifetime: 1800000 - jpa: hibernate: - ddl-auto: validate + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} properties: hibernate: format_sql: false @@ -27,7 +26,6 @@ spring: order_updates: true batch_fetch_style: DYNAMIC default_batch_fetch_size: 100 - cache: cache-names: - projectMemberCache @@ -39,16 +37,13 @@ spring: expireAfterWrite=10m, expireAfterAccess=5m, recordStats - rabbitmq: host: ${RABBITMQ_HOST} port: ${RABBITMQ_PORT} username: ${RABBITMQ_USERNAME} password: ${RABBITMQ_PASSWORD} - elasticsearch: uris: ${ELASTIC_URIS} - data: mongodb: host: ${MONGODB_HOST} @@ -60,18 +55,26 @@ spring: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} - mail: host: smtp.gmail.com port: 587 - username: ${MAIL_ADDRESS} - password: ${MAIL_PASSWORD} + username: ${MAIL_ADDRESS:} + password: ${MAIL_PASSWORD:} + enabled: ${SPRING_MAIL_ENABLED:false} properties: mail: smtp: auth: true starttls: enable: true + +firebase: + enabled: ${FIREBASE_ENABLED:true} + project-id: ${FIREBASE_PROJECT_ID:survey-f5a93} + private-key-id: ${FIREBASE_PRIVATE_KEY_ID} + private-key: ${FIREBASE_PRIVATE_KEY} + client-email: ${FIREBASE_CLIENT_EMAIL:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com} + client-id: ${FIREBASE_CLIENT_ID:100191250643521230154} server: tomcat: @@ -79,7 +82,7 @@ server: max: 50 min-spare: 20 -# Actuator 설정 +# Actuator 설정 (운영환경에서는 제한적으로 노출) management: endpoints: web: @@ -96,11 +99,15 @@ management: http.server.requests: 0.5,0.95,0.99 health: elasticsearch: - enabled: false + enabled: ${MANAGEMENT_HEALTH_ELASTICSEARCH_ENABLED:false} + mail: + enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false} jwt: secret: key: ${SECRET_KEY} + statistic: + token: ${STATISTIC_TOKEN:} oauth: kakao: @@ -113,4 +120,4 @@ oauth: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URL} \ No newline at end of file + redirect-uri: ${GOOGLE_REDIRECT_URL} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 43617c08c..f2b8af33e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,8 @@ + +# 개발 환경 전용 설정 spring: profiles: active: dev - datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/survey_db @@ -13,7 +14,6 @@ spring: connection-timeout: 5000 idle-timeout: 600000 max-lifetime: 1800000 - jpa: hibernate: ddl-auto: update @@ -29,7 +29,6 @@ spring: order_updates: true batch_fetch_style: DYNAMIC default_batch_fetch_size: 50 - cache: cache-names: - projectMemberCache @@ -41,16 +40,13 @@ spring: expireAfterWrite=5m, expireAfterAccess=2m, recordStats - rabbitmq: host: ${RABBITMQ_HOST:localhost} port: ${RABBITMQ_PORT:5672} username: ${RABBITMQ_USERNAME:user} password: ${RABBITMQ_PASSWORD:password} - elasticsearch: uris: ${ELASTIC_URIS:http://localhost:9200} - data: mongodb: host: ${MONGODB_HOST:localhost} @@ -62,21 +58,28 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - mail: host: smtp.gmail.com port: 587 - username: ${MAIL_ADDRESS} - password: ${MAIL_PASSWORD} + username: ${MAIL_ADDRESS:} + password: ${MAIL_PASSWORD:} properties: mail: smtp: auth: true starttls: enable: true + +# Firebase 설정 - 개발 환경 firebase: + enabled: ${FIREBASE_ENABLED:true} credentials: - path: classpath:firebase-survey-account.json + path: ${FIREBASE_CREDENTIALS_PATH:classpath:firebase-survey-account.json} + project-id: ${FIREBASE_PROJECT_ID:survey-f5a93} + private-key-id: ${FIREBASE_PRIVATE_KEY_ID:} + private-key: ${FIREBASE_PRIVATE_KEY:} + client-email: ${FIREBASE_CLIENT_EMAIL:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com} + client-id: ${FIREBASE_CLIENT_ID:100191250643521230154} server: tomcat: @@ -102,12 +105,14 @@ management: health: elasticsearch: enabled: false + mail: + enabled: false jwt: secret: key: ${SECRET_KEY} statistic: - token: ${STATISTIC_TOKEN} + token: ${STATISTIC_TOKEN:} oauth: kakao: @@ -120,4 +125,4 @@ oauth: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URL} \ No newline at end of file + redirect-uri: ${GOOGLE_REDIRECT_URL}