diff --git a/.DS_Store b/.DS_Store index 763dc46c..3efd186f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/deploy-to-ecr.yml b/.github/workflows/deploy-to-ecr.yml index d4347b87..4e0fb177 100644 --- a/.github/workflows/deploy-to-ecr.yml +++ b/.github/workflows/deploy-to-ecr.yml @@ -2,7 +2,7 @@ name: Deploy to Amazon ECR (Develop) on: push: - branches: [ "develop" ] # develop 브랜치에 푸시(머지)될 때만 실행 + branches: [ "develop", "improve/infra/217" ] # develop 브랜치에 푸시(머지)될 때만 실행 workflow_dispatch: # 수동 실행 버튼 추가 inputs: @@ -24,6 +24,7 @@ on: env: AWS_REGION: ap-northeast-2 # 리전 (서울) ECR_REPOSITORY_PREFIX: rushdeal # ECR 리포지토리 이름 접두사 (예: rushdeal-user-service) + ECS_CLUSTER: rush_deal_msa permissions: contents: read @@ -112,9 +113,70 @@ jobs: echo "Pushing $REPO_NAME:latest..." docker push $ECR_REGISTRY/$REPO_NAME:latest - echo "✅ Successfully pushed $REPO_NAME" - - # 7. 빌드 결과 요약 + echo "--- Successfully pushed $REPO_NAME" + + # 7. ECS 서비스에 새로운 ERC 이미지 갱신, 새로운 태스크 정의 등록 및 서비스 업데이트 (# 예: user-service) + - name: Register new Task Definition an Update ECS Service with new image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: develop-${{ github.sha }} + run: | + # 이름 규칙 정의 + # matrix.service: product-service -> SERVICE_BASE: product + SERVICE_BASE=$(echo "${{ matrix.service }}" | sed 's/-service//') + + # 태스크 정의 패밀리 명: rushdeal-product-task + TASK_FAMILY="rushdeal-${SERVICE_BASE}-task" + + + # 새 이미지 주소: .../rushdeal-product-service:develop-abcdef + NEW_IMAGE_URI="$ECR_REGISTRY/${{ env.ECR_REPOSITORY_PREFIX }}-${{ matrix.service }}:$IMAGE_TAG" + + echo "Target Task Family: $TASK_FAMILY" + echo "Target Service Name: $REAL_ECS_SERVICE_NAME" + echo "New Image URI: $NEW_IMAGE_URI" + + # 현재 사용 중인 태스크 정의를 가져옵니다. + TASK_DEFINITION=$(aws ecs describe-task-definition --task-definition $TASK_FAMILY --region ${{ env.AWS_REGION }}) + + # 이미지 주소만 최신 이미지(SHA 포함된 것)로 갈아끼웁니다. + NEW_TASK_DEF=$(echo $TASK_DEFINITION | jq --arg IMAGE "$NEW_IMAGE_URI" \ + '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)') + + # 새로운 태스크 정의 리비전을 등록합니다. + NEW_TASK_DEF_ARN=$(aws ecs register-task-definition --region ${{ env.AWS_REGION }} --cli-input-json "$NEW_TASK_DEF" --query 'taskDefinition.taskDefinitionArn' --output text) + + # 실제 ECS에 등록된 서비스 이름을 정확히 맞춰야 합니다. + # matrix.service가 user-service라면 -> rushdeal-user-service + REAL_ECS_SERVICE_NAME="${{ env.ECR_REPOSITORY_PREFIX }}-${{ matrix.service }}" + + echo "----Deploying to ECS Cluster: ${{ env.ECS_CLUSTER }}----" + echo "----Service Name: $REAL_ECS_SERVICE_NAME----" + + # ECS 서비스 강제 재배포 (최신 이미지 사용) + # 새 리비전으로 서비스를 업데이트 (새 태스크 정의) + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service $REAL_ECS_SERVICE_NAME \ + --task-definition $NEW_TASK_DEF_ARN \ + --force-new-deployment \ + --region ${{ env.AWS_REGION }} + + + - name: Wait for deployment to complete + run: | + REAL_ECS_SERVICE_NAME="${{ env.ECR_REPOSITORY_PREFIX }}-${{ matrix.service }}" + + echo "---Waiting for $REAL_ECS_SERVICE_NAME deployment to stabilize..." + + aws ecs wait services-stable \ + --cluster ${{ env.ECS_CLUSTER }} \ + --services $REAL_ECS_SERVICE_NAME \ + --region ${{ env.AWS_REGION }} + + echo "--- $REAL_ECS_SERVICE_NAME is now stable!" + + # 8. 빌드 결과 요약 - name: Image digest run: | echo "Service: ${{ matrix.service }}" diff --git a/auth-service/src/main/java/com/rushcrew/auth_service/auth/infrastructure/external/UserFeignClient.java b/auth-service/src/main/java/com/rushcrew/auth_service/auth/infrastructure/external/UserFeignClient.java index 28a5003e..0a9969fd 100644 --- a/auth-service/src/main/java/com/rushcrew/auth_service/auth/infrastructure/external/UserFeignClient.java +++ b/auth-service/src/main/java/com/rushcrew/auth_service/auth/infrastructure/external/UserFeignClient.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -@FeignClient(name = "user-service") +@FeignClient(name = "user-service", url = "${USER_SERVICE_URL}") public interface UserFeignClient { @PostMapping("/api/v1/users") UserCreateResponse createUser(@RequestBody UserCreateRequest request); diff --git a/auth-service/src/main/java/com/rushcrew/auth_service/global/config/SecurityConfig.java b/auth-service/src/main/java/com/rushcrew/auth_service/global/config/SecurityConfig.java index 9c272bf7..834368b1 100644 --- a/auth-service/src/main/java/com/rushcrew/auth_service/global/config/SecurityConfig.java +++ b/auth-service/src/main/java/com/rushcrew/auth_service/global/config/SecurityConfig.java @@ -1,9 +1,11 @@ package com.rushcrew.auth_service.global.config; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -15,7 +17,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() + .anyRequest().authenticated() + ); return http.build(); } } diff --git a/auth-service/src/main/resources/application-prod.yml b/auth-service/src/main/resources/application-prod.yml index b4fc7e50..b17179c5 100644 --- a/auth-service/src/main/resources/application-prod.yml +++ b/auth-service/src/main/resources/application-prod.yml @@ -18,6 +18,8 @@ spring: host: ${AUTH_REDIS_HOST:localhost} port: ${AUTH_REDIS_PORT:6378} password: ${REDIS_PASSWORD} + ssl: + enabled: false # 스프링 부트에서도 SSL(TLS)을 사용하겠다고 설정 timeout: 3000ms lettuce: pool: @@ -31,6 +33,9 @@ server: eureka: client: + enabled: false # Eureka 클라이언트 기능 자체를 끕니다. + register-with-eureka: false # 서버에 등록하지 않음 + fetch-registry: false # 서버로부터 정보를 가져오지 않음 service-url: defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} @@ -55,12 +60,18 @@ management: endpoint: health: show-details: always + redis: + enabled: false # Redis가 죽어도 전체 앱을 DOWN 시키지 않음 + db: + enabled: false # DB가 죽어도 전체 앱을 DOWN 시키지 않음 prometheus: access: unrestricted tracing: + enabled: false sampling: probability: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:1.0} zipkin: tracing: + enabled: false endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} diff --git a/auth-service/src/main/resources/application.yml b/auth-service/src/main/resources/application.yml index 397bb8ae..e63bed7b 100644 --- a/auth-service/src/main/resources/application.yml +++ b/auth-service/src/main/resources/application.yml @@ -79,9 +79,6 @@ resilience4j: timeout-duration: 3s cancel-running-future: true -server: - port: 9000 - eureka: client: service-url: diff --git a/build.gradle b/build.gradle index ca93954f..2b970c91 100644 --- a/build.gradle +++ b/build.gradle @@ -64,5 +64,7 @@ subprojects { // 테스트 시에도 Lombok 사용 가능하도록 testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' } } \ No newline at end of file diff --git a/ecs_stop_service.sh b/ecs_stop_service.sh new file mode 100644 index 00000000..a7680f2e --- /dev/null +++ b/ecs_stop_service.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# ========================================== +# AWS 리소스 비용 절감 스크립트 (퇴근용) +# ========================================== + +CLUSTER_NAME="rush_deal_msa" +DB_INSTANCE_ID="postgres" # RDS 인스턴스 식별자 +REGION="ap-northeast-2" + +echo "🛑 AWS 리소스 정리를 시작합니다..." + +# 1. ECS 서비스 개수 0으로 줄이기 (Fargate 비용 0원 만들기) +# 서비스 목록: user, order, payment, product, queue, timedeal (auth 포함) +SERVICES=("user-service" "order-service" "payment-service" "product-service" "queue-service" "timedeal-service" "auth-service") + +for SERVICE in "${SERVICES[@]}" +do + echo " [ECS] Stopping tasks for $SERVICE..." + # Auto Scaling이 다시 늘리는 걸 막기 위해 Min/Max도 0으로 수정 (선택사항이나 안전함) + # 단순히 Desired Count만 0으로 하면 Auto Scaling이 다시 1로 늘릴 수 있음! + aws application-autoscaling register-scalable-target \ + --service-namespace ecs \ + --resource-id service/$CLUSTER_NAME/$SERVICE \ + --scalable-dimension ecs:service:DesiredCount \ + --min-capacity 0 \ + --max-capacity 0 \ + --region $REGION > /dev/null 2>&1 + + aws ecs update-service \ + --cluster $CLUSTER_NAME \ + --service $SERVICE \ + --desired-count 0 \ + --region $REGION > /dev/null +done +echo "✅ ECS 모든 태스크 종료 완료 (비용 발생 중단)" + +# 2. RDS 데이터베이스 일시 정지 (Stop) +# 주의: 최대 7일간만 정지됨. 그 이후엔 자동으로 다시 켜짐. +echo " [RDS] Stopping Database ($DB_INSTANCE_ID)..." +aws rds stop-db-instance \ + --db-instance-identifier $DB_INSTANCE_ID \ + --region $REGION > /dev/null 2>&1 + +if [ $? -eq 0 ]; then + echo "✅ RDS 정지 명령 전송 완료 (완전히 멈추는데 몇 분 걸림)" +else + echo "⚠️ RDS 정지 실패 (이미 꺼져있거나 이름이 틀림)" +fi + +# 3. NAT Gateway 경고 (스크립트로 지우긴 위험함) +echo "--------------------------------------------------------" +echo "⚠️ [중요] NAT Gateway와 MSK는 '일시 정지'가 불가능합니다!" +echo " 비용이 걱정된다면 AWS 콘솔에서 직접 '삭제(Delete)' 하세요." +echo " (NAT Gateway는 시간당 약 60원이 계속 나갑니다)" +echo "--------------------------------------------------------" diff --git a/order-service/src/main/java/com/rushcrew/order_service/global/security/config/SecurityConfig.java b/order-service/src/main/java/com/rushcrew/order_service/global/security/config/SecurityConfig.java index 7cf2f258..80f905bb 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/global/security/config/SecurityConfig.java +++ b/order-service/src/main/java/com/rushcrew/order_service/global/security/config/SecurityConfig.java @@ -1,9 +1,11 @@ package com.rushcrew.order_service.global.security.config; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -15,10 +17,22 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/orders/**", "/api/v1/batch/**").permitAll() + .anyRequest().authenticated() + ); return http.build(); } } diff --git a/order-service/src/main/java/com/rushcrew/order_service/global/security/filter/AuthorizationFilter.java b/order-service/src/main/java/com/rushcrew/order_service/global/security/filter/AuthorizationFilter.java index faa40661..79bd0731 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/global/security/filter/AuthorizationFilter.java +++ b/order-service/src/main/java/com/rushcrew/order_service/global/security/filter/AuthorizationFilter.java @@ -27,6 +27,13 @@ protected void doFilterInternal( FilterChain filterChain ) throws IOException, ServletException { + String requestURI = request.getRequestURI(); + // internal 경로는 JWT 검사 없이 바로 통과시키기 + if (requestURI.startsWith("/api/v1/internal/")) { + filterChain.doFilter(request, response); + return; + } + String userId = request.getHeader(USER_ID_HEADER); String email = request.getHeader(USER_NAME_HEADER); String role = request.getHeader(USER_ROLE_HEADER); diff --git a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PaymentFeignClient.java b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PaymentFeignClient.java index f7fcdb90..e25192a0 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PaymentFeignClient.java +++ b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PaymentFeignClient.java @@ -8,7 +8,7 @@ import com.rushcrew.order_service.infrastructure.dto.payment.PaymentPrepareResponse; import com.rushcrew.order_service.infrastructure.dto.payment.PaymentRequest; -@FeignClient(name = "payment-service") +@FeignClient(name = "payment-service", url = "${PAYMENT_SERVICE_URL}") public interface PaymentFeignClient { @PostMapping("/api/v1/payments") PaymentPrepareResponse requestPayment(@RequestBody PaymentRequest request); diff --git a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PointFeignClient.java b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PointFeignClient.java index da75e3c3..e9d8f39a 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PointFeignClient.java +++ b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/PointFeignClient.java @@ -10,7 +10,7 @@ import com.rushcrew.order_service.infrastructure.dto.point.PointBalanceResponse; import com.rushcrew.order_service.infrastructure.dto.point.UsePointRequest; -@FeignClient(name = "user-service") +@FeignClient(name = "user-service", url = "${USER_SERVICE_URL}") public interface PointFeignClient { /* 포인트 사용 */ diff --git a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/QueueFeignClient.java b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/QueueFeignClient.java index 3f51f1e6..70564bf4 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/QueueFeignClient.java +++ b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/QueueFeignClient.java @@ -7,7 +7,7 @@ import com.rushcrew.common.dto.ApiResponse; -@FeignClient(name = "queue-service") +@FeignClient(name = "queue-service", url = "${QUEUE_SERVICE_URL}") public interface QueueFeignClient { @GetMapping("/api/v1/internal/queues/tokens/verify") diff --git a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/TimeDealStockFeignClient.java b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/TimeDealStockFeignClient.java index 133c8101..3c1d62ce 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/TimeDealStockFeignClient.java +++ b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/adapter/out/client/feign/TimeDealStockFeignClient.java @@ -6,7 +6,7 @@ import com.rushcrew.order_service.infrastructure.dto.timedeal.TimeDealResponse; import com.rushcrew.order_service.infrastructure.dto.timedeal.TimeDealStockResponse; -@FeignClient(name = "timedeal-service") +@FeignClient(name = "timedeal-service", url = "${TIMEDEAL_SERVICE_URL}") public interface TimeDealStockFeignClient { @GetMapping("/api/v1/timedeals/{timeDealId}/order") diff --git a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/config/RedissonConfig.java b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/config/RedissonConfig.java index b1ba0b6d..bfe1a5f9 100644 --- a/order-service/src/main/java/com/rushcrew/order_service/infrastructure/config/RedissonConfig.java +++ b/order-service/src/main/java/com/rushcrew/order_service/infrastructure/config/RedissonConfig.java @@ -31,7 +31,7 @@ public RedissonClient redissonClient() { .setRetryInterval(1500); if (redisPassword != null && !redisPassword.trim().isEmpty()) { - singleServerConfig.setPassword(redisPassword); +// singleServerConfig.setPassword(redisPassword); } return Redisson.create(config); diff --git a/order-service/src/main/resources/application-prod.yml b/order-service/src/main/resources/application-prod.yml index 6e59a34f..af87e010 100644 --- a/order-service/src/main/resources/application-prod.yml +++ b/order-service/src/main/resources/application-prod.yml @@ -15,6 +15,12 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver + data: + redis: + host: ${USER_REDIS_HOST:localhost} + port: ${USER_REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + jpa: hibernate: ddl-auto: ${JPA_DDL_AUTO:none} # 운영에선 none이나 validate로 바꾸기 위해 변수 처리 @@ -43,6 +49,7 @@ spring: multiplier: 2.0 # 재시도 간격: 1초 → 2초 → 4초 max-interval: 10000 # 최대 10초 + server: port: ${ORDER_SERVER_PORT:8050} @@ -56,29 +63,18 @@ management: endpoints: web: exposure: - include: health,info,metrics,prometheus,scheduledtasks + include: health,info,metrics,prometheus base-path: /actuator - endpoint: health: show-details: always prometheus: access: unrestricted - - metrics: - tags: - application: ${spring.application.name} - - prometheus: - metrics: - export: - enabled: true - tracing: sampling: probability: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:1.0} - export: - zipkin: - enabled: ${ZIPKIN_ENABLED:false} - endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} + zipkin: + tracing: + enabled: false + endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} \ No newline at end of file diff --git a/order-service/src/main/resources/application.yaml b/order-service/src/main/resources/application.yaml index 4c53be3c..b62e44a9 100644 --- a/order-service/src/main/resources/application.yaml +++ b/order-service/src/main/resources/application.yaml @@ -15,6 +15,12 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver + data: + redis: + host: ${USER_REDIS_HOST:localhost} + port: ${USER_REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + jpa: hibernate: ddl-auto: create-drop diff --git a/order-service/src/main/resources/redisson.yml b/order-service/src/main/resources/redisson.yml deleted file mode 100644 index a60ead46..00000000 --- a/order-service/src/main/resources/redisson.yml +++ /dev/null @@ -1,13 +0,0 @@ -singleServerConfig: - address: "redis://${ORDER_REDIS_HOST:localhost}:${ORDER_REDIS_PORT:6381}" - connectionMinimumIdleSize: 10 - connectionPoolSize: 30 - idleConnectionTimeout: 10000 - connectTimeout: 10000 - timeout: 3000 - retryAttempts: 3 - retryInterval: 1500 - -threads: 4 -nettyThreads: 4 -codec: ! {} diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentPrepareResult.java b/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentPrepareResult.java index fcc6efd8..2970af59 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentPrepareResult.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentPrepareResult.java @@ -9,7 +9,7 @@ public record PaymentPrepareResult( UUID paymentId, String portOnePaymentId, - BigDecimal amount, + Long amount, String status ) { public static PaymentPrepareResult of(String portOnePaymentId, Payment payment) { diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentResult.java b/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentResult.java index 91a2288a..4af6c5df 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentResult.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/application/result/PaymentResult.java @@ -9,7 +9,7 @@ public record PaymentResult( UUID paymentId, UUID orderId, - BigDecimal totalAmount, + Long totalAmount, String status ) { public static PaymentResult from(Payment payment) { diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/global/security/config/SecurityConfig.java b/payment-service/src/main/java/com/rushcrew/payment_service/global/security/config/SecurityConfig.java index 3213905c..bc7a96c0 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/global/security/config/SecurityConfig.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/global/security/config/SecurityConfig.java @@ -1,14 +1,14 @@ package com.rushcrew.payment_service.global.security.config; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; - - @Configuration @EnableMethodSecurity public class SecurityConfig { @@ -19,7 +19,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/payments/**").permitAll() + .anyRequest().authenticated() + ); return http.build(); } } diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/infrastructure/client/OrderClient.java b/payment-service/src/main/java/com/rushcrew/payment_service/infrastructure/client/OrderClient.java index 366c0be5..419621e5 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/infrastructure/client/OrderClient.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/infrastructure/client/OrderClient.java @@ -9,7 +9,7 @@ import java.util.UUID; -@FeignClient(name = "order-service") +@FeignClient(name = "order-service", url = "${ORDER_SERVICE_URL}") public interface OrderClient { @GetMapping("/api/v1/orders/{orderId}") diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/PaymentController.java b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/PaymentController.java index 9c6be031..5720901c 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/PaymentController.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/PaymentController.java @@ -12,6 +12,7 @@ import jakarta.validation.Valid; import kotlin.Unit; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -19,6 +20,7 @@ import java.util.UUID; +@Slf4j @RestController @RequestMapping("/api/v1/payments") @RequiredArgsConstructor diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/request/PaymentRequest.java b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/request/PaymentRequest.java index 43d488a7..6440ed8c 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/request/PaymentRequest.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/request/PaymentRequest.java @@ -10,7 +10,7 @@ public record PaymentRequest( @NotNull(message = "주문 ID는 필수입니다.") UUID orderId, @NotNull(message = "결제 금액은 필수입니다.") - BigDecimal totalAmount + Long totalAmount ) { public PaymentCommand toCommand() { return new PaymentCommand( diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentPrepareResponse.java b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentPrepareResponse.java index 65e9dbee..92c310b3 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentPrepareResponse.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentPrepareResponse.java @@ -8,7 +8,7 @@ public record PaymentPrepareResponse( UUID paymentId, String portOnePaymentId, - BigDecimal amount, + Long amount, String status ) { public static PaymentPrepareResponse from(PaymentPrepareResult result) { diff --git a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentResponse.java b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentResponse.java index ee187243..54bbc06c 100644 --- a/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentResponse.java +++ b/payment-service/src/main/java/com/rushcrew/payment_service/presentation/dto/response/PaymentResponse.java @@ -8,7 +8,7 @@ public record PaymentResponse( UUID paymentId, UUID orderId, - BigDecimal totalAmount, + Long totalAmount, String status ) { public static PaymentResponse from(PaymentResult result) { diff --git a/payment-service/src/main/resources/application-prod.yml b/payment-service/src/main/resources/application-prod.yml index bcbe808a..c1a22b08 100644 --- a/payment-service/src/main/resources/application-prod.yml +++ b/payment-service/src/main/resources/application-prod.yml @@ -1,6 +1,10 @@ spring: application: name: payment-service + cloud: + service-registry: + auto-registration: + enabled: false datasource: url: ${DB_URL} username: ${DB_USERNAME} @@ -24,8 +28,8 @@ server: eureka: client: enabled: ${EUREKA_ENABLED:false} # AWS에서는 false - service-url: - defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + register-with-eureka: false + fetch-registry: true portone: secret: @@ -46,9 +50,11 @@ management: prometheus: access: unrestricted tracing: + enabled: false sampling: probability: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:1.0} zipkin: tracing: + enabled: false endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index bdf49d3f..9b76aa9c 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -18,14 +18,15 @@ spring: create_namespaces: true database-platform: org.hibernate.dialect.PostgreSQLDialect kafka: - bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all retries: 3 consumer: - group-id: payment-service-group + group-id: + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer auto-offset-reset: earliest diff --git a/product-service/src/main/java/com/rushcrew/product/global/security/filter/AuthorizationFilter.java b/product-service/src/main/java/com/rushcrew/product/global/security/filter/AuthorizationFilter.java index e08a63e7..b62424b1 100644 --- a/product-service/src/main/java/com/rushcrew/product/global/security/filter/AuthorizationFilter.java +++ b/product-service/src/main/java/com/rushcrew/product/global/security/filter/AuthorizationFilter.java @@ -26,6 +26,13 @@ protected void doFilterInternal( FilterChain filterChain ) throws IOException, ServletException { + String requestURI = request.getRequestURI(); + // internal 경로는 JWT 검사 없이 바로 통과시키기 + if (requestURI.startsWith("/internal/")) { + filterChain.doFilter(request, response); + return; + } + String userId = request.getHeader(USER_ID_HEADER); String email = request.getHeader(USER_NAME_HEADER); String role = request.getHeader(USER_ROLE_HEADER); diff --git a/product-service/src/main/java/com/rushcrew/product/infrastructure/config/SecurityConfig.java b/product-service/src/main/java/com/rushcrew/product/infrastructure/config/SecurityConfig.java new file mode 100644 index 00000000..0a72e6c2 --- /dev/null +++ b/product-service/src/main/java/com/rushcrew/product/infrastructure/config/SecurityConfig.java @@ -0,0 +1,40 @@ +package com.rushcrew.product.infrastructure.config; + + +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/products/**").permitAll() + .requestMatchers("/api/v1/stocks/**").permitAll() + .requestMatchers("/internal/v1/products/**").permitAll() + .anyRequest().authenticated() + ); + return http.build(); + } +} diff --git a/product-service/src/main/resources/application-prod.yml b/product-service/src/main/resources/application-prod.yml index ccd3655f..91e1f082 100644 --- a/product-service/src/main/resources/application-prod.yml +++ b/product-service/src/main/resources/application-prod.yml @@ -2,6 +2,11 @@ spring: application: name: product-service + cloud: + service-registry: + auto-registration: + enabled: false + datasource: url: ${DB_URL} username: ${DB_USERNAME} @@ -20,13 +25,17 @@ spring: mode: ${SQL_INIT_MODE:never} # 기본은 안 함. 로컬에서만 환경변수로 always 주입. 매번 초기화 스크립트 실행하는 문제 발생 (data.sql, schema.sql) server: + tomcat: + accesslog: + enabled: true + pattern: "%h %l %u %t \"%r\" %s %b" # 표준 Apache 로그 형식 port: ${PRODUCT_SERVER_PORT:8020} eureka: client: enabled: ${EUREKA_ENABLED:false} # AWS에서는 false - service-url: - defaultZone: ${EUREKA_URL} + register-with-eureka: false + fetch-registry: true management: endpoints: @@ -45,4 +54,5 @@ management: zipkin: tracing: + enabled: false endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} \ No newline at end of file diff --git a/queue-service/build.gradle b/queue-service/build.gradle index 53c2ca15..b662f48e 100644 --- a/queue-service/build.gradle +++ b/queue-service/build.gradle @@ -23,6 +23,7 @@ dependencies { // Kafka implementation 'org.springframework.kafka:spring-kafka' + implementation 'software.amazon.msk:aws-msk-iam-auth:2.0.3' // Eureka 클라이언트 implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' diff --git a/queue-service/src/main/java/com/rushcrew/queue/application/port/in/QueuePort.java b/queue-service/src/main/java/com/rushcrew/queue/application/port/in/QueuePort.java index bb0b46d2..7dfb0d36 100644 --- a/queue-service/src/main/java/com/rushcrew/queue/application/port/in/QueuePort.java +++ b/queue-service/src/main/java/com/rushcrew/queue/application/port/in/QueuePort.java @@ -26,7 +26,7 @@ public interface QueuePort { /** * 토큰 형식 검증 + 유저 토큰 활성화 여부 */ - boolean validateActivatedQueueToken(UUID productId, String token, Long userId, String role); + boolean validateActivatedQueueToken(UUID productId, String token); /** * 토큰 활성화 diff --git a/queue-service/src/main/java/com/rushcrew/queue/application/service/QueueService.java b/queue-service/src/main/java/com/rushcrew/queue/application/service/QueueService.java index 470ba7f0..35ffc83d 100644 --- a/queue-service/src/main/java/com/rushcrew/queue/application/service/QueueService.java +++ b/queue-service/src/main/java/com/rushcrew/queue/application/service/QueueService.java @@ -62,7 +62,7 @@ public QueueRedisResponse enterQueue(EnterQueueCommand command) { } String tokenValue = queueToken.getId().getValue().toString(); - return getQueueRank(command.productId(), tokenValue, command.userId(), command.role()); + return getQueueRank(command.productId(), tokenValue, command.userId()); } /** @@ -180,7 +180,7 @@ public void exitQueue(UUID productId, String token, Long userId) { * 토큰 유효성 검증 (활성화 여부) */ @Override - public boolean validateActivatedQueueToken(UUID productId, String token, Long userId, String role) { + public boolean validateActivatedQueueToken(UUID productId, String token) { TokenId tokenId = extractValidQueueTokenId(token); return queueRepository.isActivatedToken(productId, tokenId); } diff --git a/queue-service/src/main/java/com/rushcrew/queue/infrastructure/config/SecurityConfig.java b/queue-service/src/main/java/com/rushcrew/queue/infrastructure/config/SecurityConfig.java index 05ffbd26..0ce21329 100644 --- a/queue-service/src/main/java/com/rushcrew/queue/infrastructure/config/SecurityConfig.java +++ b/queue-service/src/main/java/com/rushcrew/queue/infrastructure/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.rushcrew.queue.infrastructure.config; import com.rushcrew.queue.infrastructure.filter.AuthorizationFilter; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -29,10 +30,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) .logout(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() // 경로별 권한 설정 // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 - .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/internal/queues/**").permitAll() + .requestMatchers("/api/v1/queues/**").permitAll() + .requestMatchers("/api/v1/queue/policies/**").permitAll() + .requestMatchers("/api/v1/test/scheduler/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/queue-service/src/main/java/com/rushcrew/queue/presentation/controller/RedisCheckController.java b/queue-service/src/main/java/com/rushcrew/queue/presentation/controller/RedisCheckController.java new file mode 100644 index 00000000..82c668c2 --- /dev/null +++ b/queue-service/src/main/java/com/rushcrew/queue/presentation/controller/RedisCheckController.java @@ -0,0 +1,48 @@ +package com.rushcrew.queue.presentation.controller; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/queues") +@RequiredArgsConstructor +public class RedisCheckController { + private final StringRedisTemplate redisTemplate; + + @GetMapping("/redis/check/{key}") + public String getValue(@PathVariable String key) { + return "Value: " + redisTemplate.opsForValue().get(key); + } + + @DeleteMapping("/redis/clear") + public ResponseEntity clearRedis() { + try { + // 패턴 매칭으로 모든 키 찾기 ("*"은 모든 키) + ScanOptions options = ScanOptions.scanOptions().match("*").count(1000).build(); + + // Cursor를 사용하여 반복적으로 키를 조회 및 삭제 (메모리 부하 방지) + Cursor cursor = redisTemplate.getConnectionFactory().getConnection().scan(options); + + int count = 0; + while (cursor.hasNext()) { + byte[] key = cursor.next(); + redisTemplate.delete(new String(key)); // 하나씩 삭제 + count++; + } + + return ResponseEntity.ok("Redis Cleaned! Deleted " + count + " keys."); + } catch (Exception e) { + e.printStackTrace(); + return ResponseEntity.internalServerError().body("Failed to clear Redis: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/queue-service/src/main/resources/application-prod.yml b/queue-service/src/main/resources/application-prod.yml index 074775de..4d708c5c 100644 --- a/queue-service/src/main/resources/application-prod.yml +++ b/queue-service/src/main/resources/application-prod.yml @@ -1,7 +1,10 @@ spring: application: name: queue-service - + cloud: + service-registry: + auto-registration: + enabled: false kafka: # 환경변수 KAFKA_BROKERS가 있으면 그걸 쓰고, 없으면 로컬값(localhost:9092) 사용 bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} # 로컬 개발 환경 기준 (Docker 내부 통신 시 kafka:29092) @@ -17,6 +20,8 @@ spring: redis: host: ${QUEUE_REDIS_HOST:localhost} # Docker 환경이라면 컨테이너 이름 또는 IP port: ${QUEUE_REDIS_PORT:6380} + ssl: + enabled: false datasource: url: ${DB_URL} @@ -47,13 +52,14 @@ logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + eureka: client: enabled: ${EUREKA_ENABLED:false} # AWS에서는 false - register-with-eureka: true + register-with-eureka: false fetch-registry: true - service-url: - defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} +# service-url: +# defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} server: port: ${QUEUE_SERVER_PORT:8040} @@ -75,4 +81,5 @@ management: zipkin: tracing: + enabled: false endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} diff --git a/queue-service/src/main/resources/application.yaml b/queue-service/src/main/resources/application.yaml index 75598f7b..c748a708 100644 --- a/queue-service/src/main/resources/application.yaml +++ b/queue-service/src/main/resources/application.yaml @@ -5,6 +5,12 @@ spring: kafka: # 환경변수 KAFKA_BROKERS가 있으면 그걸 쓰고, 없으면 로컬값(localhost:9092) 사용 bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} # 로컬 개발 환경 기준 (Docker 내부 통신 시 kafka:29092) + properties: + # [중요] MSK Serverless는 무조건 SASL_SSL + IAM 사용 + security.protocol: SASL_SSL + sasl.mechanism: AWS_MSK_IAM + sasl.jaas.config: software.amazon.msk.auth.iam.IAMLoginModule required; + sasl.client.callback.handler.class: software.amazon.msk.auth.iam.IAMClientCallbackHandler consumer: group-id: queue-service-group # 컨슈머 그룹 ID (중요) auto-offset-reset: latest # 가장 최신 메시지부터 읽음 @@ -16,7 +22,7 @@ spring: data: redis: host: ${QUEUE_REDIS_HOST} - port: ${QUEUE_REDIS_PORT} + port: ${QUEUE_REDIS_PORT:6379} datasource: url: ${DB_URL} diff --git a/timedeal-rush.js b/timedeal-rush.js new file mode 100644 index 00000000..f9362967 --- /dev/null +++ b/timedeal-rush.js @@ -0,0 +1,238 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; + +// ============================================ +// 1. 커스텀 메트릭 정의 (결과 측정용) +// ============================================ +const errorRate = new Rate('errors'); +const enterQueueSuccess = new Rate('enter_queue_success'); + +const USER_ID_OFFSET = 1000; + +// ============================================ +// 2. 테스트 설정 (시나리오 정의) +// ============================================ +export const options = { + // ★ 수정 1: systemTags는 scenarios 밖으로 뺐습니다. + // systemTags: ['status', 'method', 'url', 'name', 'group', 'check', 'error', 'iter', 'scenario', 'vu'], + // scenarios: { + // // 시나리오: 타임딜 오픈 폭주 + // timedeal_rush: { + // executor: 'ramping-vus', // 점진적으로 사용자 증가 + // startVUs: 0, // 시작: 0명 + // stages: [ + // { duration: '10s', target: 100 }, // 10초 동안 100명까지 증가 + // { duration: '20s', target: 500 }, // 20초 동안 500명까지 증가 + // { duration: '30s', target: 1000 }, // 30초 동안 1000명까지 증가 (피크) + // { duration: '20s', target: 1000 }, // 20초 동안 1000명 유지 + // { duration: '10s', target: 0 }, // 10초 동안 0명으로 감소 (종료) + // ], + // gracefulRampDown: '5s', // 부드러운 종료 + // }, + systemTags: ['status', 'method', 'url', 'name', 'group', 'check', 'error', 'iter', 'scenario', 'vu'], + scenarios: { + timedeal_rush: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 300 }, // 1분 동안 300명까지 워밍업 + { duration: '1m', target: 800 }, // 1분 동안 800명까지 증가 + { duration: '5m', target: 1000 }, // [핵심] 5분 동안 1000명 유지 (이때 스케일 아웃 발생!) + { duration: '1m', target: 0 }, // 1분 동안 종료 + ], + gracefulRampDown: '10s', + }, + }, + + // 임계값 설정 (테스트 실패 기준) + thresholds: { + 'http_req_duration': ['p(95)<3000'], // 95% 요청이 3초 이내 + 'http_req_failed': ['rate<0.05'], // 에러율 5% 미만 + 'errors': ['rate<0.05'], + }, +}; + +// ============================================ +// 3. 환경 변수 (수정 필요) +// ============================================ +const BASE_URL = __ENV.BASE_URL || 'https://f2iy2uv1o3.execute-api.ap-northeast-2.amazonaws.com'; +const PRODUCT_ID = __ENV.PRODUCT_ID || '82b2c5d1-b3e4-4614-8167-1da00591cffb'; + +// ============================================ +// 4. JWT 토큰 생성 (간소화 버전) +// ============================================ +function generateTestToken(userId) { + // 실제로는 백엔드에서 발급받아야 하지만, + // 테스트용으로 간단히 userId만 포함 + // TODO: 실제 JWT 발급 API 호출로 변경 + return `Bearer test-token-user-${userId}`; +} + +// ============================================ +// 5. 메인 테스트 함수 (각 VU가 실행) +// ============================================ +export default function () { + // 각 가상 유저(VU)는 고유한 userId를 가짐 + // const userId = __VU; // Virtual User ID (1, 2, 3, ...) + const userId = __VU + USER_ID_OFFSET; + + // JWT 토큰 생성 + const token = generateTestToken(userId); + + // ============================================ + // 테스트 1: 대기열 진입 요청 + // ============================================ + const enterQueuePayload = JSON.stringify({ + productId: PRODUCT_ID, + }); + + const enterQueueParams = { + headers: { + 'Content-Type': 'application/json', + // 'Authorization': `Bearer test-token-user-${userId}`, + 'X-User-Id': `${userId}`, + 'X-User-Role': 'USER', + 'X-User-Email': `user_${userId}@test.com` + }, + timeout: '10s', // 10초 타임아웃 + tags: { name: '01_Enter_Queue' }, + }; + + const enterResponse = http.post( + `${BASE_URL}/api/v1/queues/enter`, + enterQueuePayload, + enterQueueParams + ); + + // 409(이미 대기 중)는 에러로 치지 않기 위한 로직 + if (enterResponse.status === 409) { + // 이미 대기열에 있다면, 실패가 아니라 "Pass"로 간주하고 루프 종료 + // (Polling 단계로 넘어갈 수도 있지만, 토큰이 없으므로 여기서 멈춤) + return; + } + + // ★ 수정 포인트: 응답 시간 체크를 분리했습니다. + // 응답 시간이 1초 넘어도 201이면 일단 "기능적 성공"으로 칩니다. + const isStatusOk = enterResponse.status === 201; + + // 응답 검증 +// 성능 체크용 (로그만 찍거나 메트릭엔 반영하되 로직은 진행) + check(enterResponse, { + '응답 시간 < 2초': (r) => r.timings.duration < 2000, + }); + + const isTokenOk = check(enterResponse, { + '대기열 진입 성공 (201)': (r) => r.status === 201, + '토큰 발급됨': (r) => { + try { + const body = JSON.parse(r.body); + return body.data && body.data.token; + } catch { + return false; + } + }, + }); + + // 기능적으로 실패했을 때만 에러 처리 + if (!isStatusOk || !isTokenOk) { + errorRate.add(1); + console.error(`[VU ${userId}] 진입 실패: ${enterResponse.status} - ${enterResponse.body}`); + return; // 진짜 실패했으니 여기서 종료 + } + + // 성공했으니 메트릭 기록하고 폴링으로 넘어감 + enterQueueSuccess.add(1); + + // 진입 실패 시 로그 출력 + // if (!enterSuccess) { + // console.error(`[VU ${userId}] 대기열 진입 실패: ${enterResponse.status} - ${enterResponse.body}`); + // } else { + // // 성공 시 토큰 추출 + // + // } + + let queueToken = null; + + try { + // 1. 응답 본문이 비어있는지 먼저 확인 + if (!enterResponse.body) { + throw new Error("응답 본문(Body)이 비어있습니다!"); + } + // 2. 파싱 시도 + const responseBody = JSON.parse(enterResponse.body); + // 3. 토큰 추출 시도 + if (responseBody.data && responseBody.data.token) { + queueToken = responseBody.data.token; + } else { + // JSON은 맞는데 token 필드가 없는 경우 + throw new Error(`토큰 없음! 응답 구조 확인 필요: ${enterResponse.body}`); + } + + const rank = responseBody.data.rank; + + console.log(`[VU ${userId}] 대기열 진입 성공! 순번: ${rank}, 토큰: ${queueToken.substring(0, 8)}...`); + + // ============================================ + // 테스트 2: 대기 순번 조회 (Polling 시뮬레이션) + // ============================================ + sleep(1); // 1초 대기 (실제 사용자가 기다리는 시간) + + const rankParams = { + headers: { + // 'Authorization': token, + 'X-Queue-Token': queueToken, + 'X-User-Id': `${userId}`, + 'X-User-Role': 'USER', + 'X-User-Email': `user_${userId}@test.com` + }, + // ★ 여기가 핵심: 그래프에서 조회 요청은 따로 표시됨 + tags: { name: '02_Check_Rank' }, + }; + + const rankResponse = http.get( + `${BASE_URL}/api/v1/queues/rank?productId=${PRODUCT_ID}`, + rankParams + ); + + const rankSuccess = check(rankResponse, { + '순번 조회 성공 (200)': (r) => r.status === 200, + }); + + if (!rankSuccess) { + // ★ 수정된 부분: rankRes -> rankResponse로 변수명 수정 + console.error(`[VU ${userId}] 조회 실패! Status: ${rankResponse.status} / Msg: ${rankResponse.body}`); + errorRate.add(1); + } else { + console.log(`[VU ${userId}] 순번 조회 성공! 순번: ${rank}, 토큰: ${queueToken.substring(0, 8)}...`); + } + + } catch (error) { + console.error(`[VU ${userId}] 응답 파싱 실패: ${error}`); + // 서버가 JSON 대신 뭘 보냈는지 눈으로 확인하는 로그 + console.error(`---------------------------------------------------`); + console.error(`[VU ${userId}] 파싱/로직 에러 발생!`); + console.error(`[에러 메시지]: ${error}`); + console.error(`[서버 응답 상태]: ${enterResponse.status}`); + console.error(`[서버 응답 본문]: ${enterResponse.body}`); // <--- 이걸 봐야 범인을 잡습니다. + console.error(`---------------------------------------------------`); + errorRate.add(1); + } + + // 요청 간 간격 (1~3초 랜덤) + sleep(Math.random() * 2 + 1); +} + +// ============================================ +// 6. 테스트 종료 후 요약 출력 +// ============================================ +export function handleSummary(data) { + return { + // 1. 콘솔에는 텍스트 요약 출력 + 'stdout': textSummary(data, { indent: ' ', enableColors: true }), + // 2. 브라우저로 볼 수 있는 HTML 파일 생성 (이게 핵심!) + 'summary.html': htmlReport(data), + }; +} diff --git a/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/config/SecurityConfig.java b/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/config/SecurityConfig.java new file mode 100644 index 00000000..fe8bac7d --- /dev/null +++ b/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package com.rushcrew.timedeal.infrastructure.config; + +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/timedeals/**").permitAll() + .requestMatchers("/api/v1/stocks/**").permitAll() + .anyRequest().authenticated() + ); + return http.build(); + } +} diff --git a/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/external/client/ProductFeignClient.java b/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/external/client/ProductFeignClient.java index 31ed2369..98b5c056 100644 --- a/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/external/client/ProductFeignClient.java +++ b/timedeal-service/src/main/java/com/rushcrew/timedeal/infrastructure/external/client/ProductFeignClient.java @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -@FeignClient(name = "product-service", path = "/internal/v1/products") +@FeignClient(name = "product-service", path = "/internal/v1/products", url = "${PRODUCT_SERVICE_URL}") public interface ProductFeignClient { @GetMapping("/{productId}/info") diff --git a/timedeal-service/src/main/resources/application-prod.yml b/timedeal-service/src/main/resources/application-prod.yml index fa652a9e..f85eb711 100644 --- a/timedeal-service/src/main/resources/application-prod.yml +++ b/timedeal-service/src/main/resources/application-prod.yml @@ -1,10 +1,16 @@ spring: application: name: timedeal-service + cloud: + service-registry: + auto-registration: + enabled: false data: redis: host: ${TIMEDEAL_REDIS_HOST:localhost} port: ${TIMEDEAL_REDIS_PORT:6382} + ssl: + enabled: false datasource: url: ${DB_URL} username: ${DB_USERNAME} @@ -24,13 +30,15 @@ spring: kafka: bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} + server: port: ${TIMEDEAL_SERVER_PORT:8030} eureka: client: - service-url: - defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + enabled: ${EUREKA_ENABLED:false} # AWS에서는 false + register-with-eureka: false + fetch-registry: true management: endpoints: @@ -49,4 +57,5 @@ management: zipkin: tracing: + enabled: false endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} \ No newline at end of file diff --git a/user-service/src/main/java/com/rushcrew/user_service/global/security/config/RedissonConfig.java b/user-service/src/main/java/com/rushcrew/user_service/global/security/config/RedissonConfig.java index 5143a00d..0bef64c0 100644 --- a/user-service/src/main/java/com/rushcrew/user_service/global/security/config/RedissonConfig.java +++ b/user-service/src/main/java/com/rushcrew/user_service/global/security/config/RedissonConfig.java @@ -32,7 +32,7 @@ public RedissonClient redissonClient() { .setRetryInterval(1500); if (redisPassword != null && !redisPassword.trim().isEmpty()) { - singleServerConfig.setPassword(redisPassword); +// singleServerConfig.setPassword(redisPassword); } return Redisson.create(config); diff --git a/user-service/src/main/java/com/rushcrew/user_service/global/security/config/SecurityConfig.java b/user-service/src/main/java/com/rushcrew/user_service/global/security/config/SecurityConfig.java index 347a727d..8bf04d0b 100644 --- a/user-service/src/main/java/com/rushcrew/user_service/global/security/config/SecurityConfig.java +++ b/user-service/src/main/java/com/rushcrew/user_service/global/security/config/SecurityConfig.java @@ -1,10 +1,12 @@ package com.rushcrew.user_service.global.security.config; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -19,7 +21,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 (Stateless 설정) + .authorizeHttpRequests(auth -> auth + // Actuator 엔드포인트를 경로와 상관없이 모두 허용 + // 엔드포인트 요청(health, info 등)을 자동으로 인식하여 허용합니다. + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() + // 경로별 권한 설정 + // 보통 @PreAuthorize로 처리하므로 모두 허용하거나 authenticated로 설정 + // Actuator나 헬스 체크 경로는 열어두는 것이 좋음 + .requestMatchers("/actuator/**", "/health").permitAll() + .requestMatchers("/api/v1/users/**").permitAll() + .requestMatchers("/api/v1/points/**").permitAll() + .anyRequest().authenticated() + ); return http.build(); } diff --git a/user-service/src/main/resources/application-prod.yml b/user-service/src/main/resources/application-prod.yml index dfc25818..e2c6f033 100644 --- a/user-service/src/main/resources/application-prod.yml +++ b/user-service/src/main/resources/application-prod.yml @@ -4,6 +4,10 @@ server: spring: application: name: user-service + cloud: + service-registry: + auto-registration: + enabled: false datasource: url: ${DB_URL} username: ${DB_USERNAME} @@ -25,10 +29,27 @@ spring: host: ${USER_REDIS_HOST:localhost} port: ${USER_REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + + kafka: + bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} + consumer: + group-id: point-service-group + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + listener: + ack-mode: manual + host: ${USER_REDIS_HOST:localhost} + port: ${USER_REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + eureka: client: - service-url: - defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + # 유레카 클라이언트 자체를 끄기 (가장 확실) + register-with-eureka: false + fetch-registry: false + enabled: false management: endpoints: @@ -47,4 +68,10 @@ management: zipkin: tracing: - endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} \ No newline at end of file + enabled: false + endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans} + +logging: + level: + org.apache.kafka: ERROR # cloudwatch 불필요한 로그 쌓이는 것 방지 목적 + org.springframework.kafka: ERROR