From 4259ef3fbc931f7ce11c2b1f965ca100e08aa7c1 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Fri, 1 May 2026 17:25:05 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[Feat]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 공통모듈 의존성 추가 및 빌드 관련 설정 변경 - build.gradle에 공통모듈 의존성 추가 및 github package 인증 관련 설정 추가 - Dockerfile 및 docker-compose.yml 파일에 공통모듈 사용에 필요한 github package 인증정보 및 jwt 환경변수 관련 설정 추가 - GatewayApplication.java의 @SpringBootApplication에서 데이터소스 관련 자동 설정 기능 비활성화 * chore: 환경변수 추가 - env.example 파일 추가 - .env 파일에 관한 .gitignore 설정 추가 * chore: application.yaml 파일 설정 수정 - application.yml 파일에서의 게이트웨이 라우팅 관련 설정은 config server에 새로 추가한 라우팅 설정으로 대체 - src/test/resources/application.yaml 파일의 설정을 수정하여 일반 실행 환경과 테스트 환경 분리 * feat: JwtTokenProvider 빈 설정 추가 - 공통모듈의 Jwt 토큰 발급 기능을 사용하기 위한 JwtTokenProvider, JwtProperties에 관한 수동 빈 등록 * feat: 게이트웨이 인증 필터 구현 - 기본 동작 메커니즘은 user-service의 JwtAuthenticationFilter와 거의 동일 - 요청 헤더의 추가/삭제 시 Wrapper를 활용할 필요가 없다는 측면에서 spring framework 제공 필터에 비해 편의성 및 가독성을 개선 - application.yaml 파일에서는 모든 api 경로가 항상 게이트웨이 기본 필터를 경유하도록 하여, 모든 api 요청에 대해 보안 정책을 일괄 적용 * feat: 게이트웨이 전용 SecurityConfig 설정 추가 - 게이트웨이를 통해 들어온 모든 요청에 대해 무조건 허용 * docs: 현재까지의 작업 내역 요약 정리 - 게이트웨이 세팅 및 인증 필터 구현, 향후 고도화 계획 관련 내용 요약 * fix: 코드래빗 수정사항 반영 - 빌드용 환경변수와 실행용 환경변수 분리를 통해 github 자격증명 정보의 노출 방지(.gitignore에 .env.runtime 추가) - 테스트용 환경변수 중 JWT_SECRET의 기본값 지정 - 토큰 검증 성공 시 사용자 식별자가 노출되어 누적되지 않도록 수정 * refactor: 서버 포트 명시 - application.yml 내 server.port 추가 --- .env.example | 13 ++++ .gitignore | 20 +++--- Dockerfile | 8 ++- build.gradle | 21 ++++-- docker-compose.yaml | 13 +++- docs/gateway-server-setup-summary.md | 38 ++++++++++ .../org/pgsg/gateway/GatewayApplication.java | 3 +- .../gateway/config/GatewaySecurityConfig.java | 24 +++++++ .../org/pgsg/gateway/config/JwtConfig.java | 18 +++++ .../pgsg/gateway/filter/JwtGatewayFilter.java | 70 +++++++++++++++++++ src/main/resources/application.yaml | 30 ++++---- src/test/resources/application.yaml | 10 ++- 12 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 .env.example create mode 100644 docs/gateway-server-setup-summary.md create mode 100644 src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java create mode 100644 src/main/java/org/pgsg/gateway/config/JwtConfig.java create mode 100644 src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa5f7cb --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# JWT — 256비트 이상 랜덤 문자열 권장 +JWT_SECRET=CHANGE_ME_BASE64_32_BYTES_MINIMUM_STRING +JWT_ACCESS_EXPIRATION=1800000 +JWT_REFRESH_EXPIRATION=604800000 + +# server port, 유레카 클라이언트 호스트명 +SERVER_PORT=도메인_서비스별_포트번호 +HOSTNAME=localhost + +# 아래의 환경변수들은 .env 파일에만 포함하여 빌드 시에만 사용되며, .env.runtime에서는 생략됨 +# 배포 환경에서도 공통 모듈을 적용하기 위해 Dockerfile에 추가해야 할 환경변수 +GPR_USER=GitHub_ID +GPR_TOKEN=GitHub_Personal_Access_Token(PAT) \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2a7071..e258e19 100644 --- a/.gitignore +++ b/.gitignore @@ -35,14 +35,14 @@ # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr + .idea/artifacts + .idea/compiler.xml + .idea/jarRepositories.xml + .idea/modules.xml + .idea/*.iml + .idea/modules + *.iml + *.ipr # CMake cmake-build-*/ @@ -220,4 +220,8 @@ gradle-app.setting # Java heap dump *.hprof +# environment variable +.env +.env.runtime + # End of https://www.toptal.com/developers/gitignore/api/macos,windows,java,gradle,intellij+all,visualstudiocode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4232058..6b1d9d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,13 @@ FROM gradle:8.7-jdk21 AS build WORKDIR /app + COPY . . -RUN gradle bootJar --no-daemon + +RUN --mount=type=secret,id=GPR_USER \ + --mount=type=secret,id=GPR_TOKEN \ + export GPR_USER=$(cat /run/secrets/GPR_USER) && \ + export GPR_TOKEN=$(cat /run/secrets/GPR_TOKEN) && \ + gradle bootJar --no-daemon FROM eclipse-temurin:21-jre WORKDIR /app diff --git a/build.gradle b/build.gradle index c5cfa6a..2fb813b 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,13 @@ java { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/89-49/common") + credentials { + username = findProperty('gpr.user') ?: System.getenv('GPR_USER') + password = findProperty('gpr.token') ?: System.getenv('GPR_TOKEN') + } + } } ext { @@ -22,15 +29,21 @@ ext { } dependencies { + + implementation 'org.pgsg:common:0.2.0-SNAPSHOT' + implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' - implementation 'org.springframework.cloud:spring-cloud-starter-config' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc' - implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' - implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + + // jwt 관련 라이브러리 + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/docker-compose.yaml b/docker-compose.yaml index 23d6d02..65c75b6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,8 +4,13 @@ services: build: context: . dockerfile: Dockerfile + secrets: + - GPR_USER + - GPR_TOKEN ports: - "8090:8090" + env_file: + - .env.runtime environment: - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ networks: @@ -13,4 +18,10 @@ services: networks: pgsg-network: - external: true \ No newline at end of file + external: true + +secrets: + GPR_USER: + environment: GPR_USER + GPR_TOKEN: + environment: GPR_TOKEN diff --git a/docs/gateway-server-setup-summary.md b/docs/gateway-server-setup-summary.md new file mode 100644 index 0000000..ccbe2af --- /dev/null +++ b/docs/gateway-server-setup-summary.md @@ -0,0 +1,38 @@ +# Gateway Server 구축 및 보안 아키텍처 작업 이력 + +## 1. 프로젝트 개요 +본 프로젝트는 MSA 환경에서 요청의 진입점 역할을 수행하는 **WebMVC 기반의 Spring Cloud Gateway**입니다. 전역적인 인증 처리와 라우팅 관리를 담당합니다. + +## 2. 작업 이력 및 기술적 진화 + +### 2.1 [Phase 1] 초기 인프라 설정 및 Config Server 통합 +- **DataSource 자동 설정 제외**: DB 미사용에 따른 기동 오류를 `DataSourceAutoConfiguration` 제외 설정을 통해 해결. +- **Docker 컨테이너화**: Multi-stage 빌드 및 `.env`를 통한 환경 변수 주입 환경 구축. +- **Config Server 우선순위 해결**: 원격 설정이 로컬 라우팅을 덮어쓰는 문제를 `spring.config.import` 순서 조정 및 `local-routes.yaml` 분리를 통해 해결. + +### 2.2 [Phase 2] 서블릿 필터 기반 JWT 인증 구현 (초기 버전) +- **JwtAuthenticationFilter (OncePerRequestFilter)**: 표준 서블릿 필터 방식으로 JWT 검증 로직 구현. +- **HttpRequestHeaderWrapper**: `HttpServletRequest`가 읽기 전용인 서블릿 특성을 극복하기 위해 래퍼 클래스를 구현하여 헤더 주입 및 스푸핑 방지 기능 수행. +- **성과**: 게이트웨이 단에서의 1차적인 인증 및 정보 전달(Header Propagation) 메커니즘을 최초로 확립. + +### 2.3 [Phase 3] 네이티브 게이트웨이 필터로 리팩토링 (현재 버전) +- **JwtGatewayFilter (HandlerFilterFunction)**: 서블릿 필터를 제거하고 Spring Cloud Gateway WebMVC의 표준인 `HandlerFilterFunction`으로 전환. +- **ServerRequest Mutability 활용**: 게이트웨이 빌트인 기능을 사용하여 별도의 래퍼 클래스 없이도 안전하게 헤더를 초기화하고 주입하는 구조로 단순화. +- **가독성 개선**: Guard Clauses 패턴을 적용하여 중첩 `if`문을 제거하고 로직을 평탄화. +- **성과**: 서블릿 종속성을 줄이고 게이트웨이 프레임워크와의 결합도를 높여 성능과 유지보수성 향상. + +## 3. 핵심 클래스 현황 +- `GatewayApplication.java`: 애플리케이션 엔트리 포인트. +- `JwtGatewayFilter.java`: 현재 활성화된 네이티브 인증 필터. +- `JwtConfig.java`: 공통 모듈 유틸리티 빈 등록. +- `GatewaySecurityConfig.java`: 전역 보안 정책 구성. + +## 4. 검증 결과 +- `/api/v1/auth/login` 라우팅 및 토큰 발급 테스트 완료. +- 발급된 토큰을 통한 게이트웨이 필터의 헤더 변환 및 사용자 정보 주입 정상 동작 확인. +- Gradle/Docker 캐시 초기화를 통한 깨끗한 빌드 환경 검증 완료. + +## 5. 향후 아키텍처 결정 사항 (블랙리스트 검증) +- **데이터 소유권**: Redis는 오직 `user-service`만 소유한다는 원칙 고수. +- **검증 방식**: 게이트웨이가 `user-service`의 내부 API(FeignClient 등)를 호출하여 블랙리스트 여부 확인. +- **최적화**: 네트워크 부하 감소를 위해 게이트웨이 내부에 **로컬 메모리 캐시** 도입 검토. diff --git a/src/main/java/org/pgsg/gateway/GatewayApplication.java b/src/main/java/org/pgsg/gateway/GatewayApplication.java index b1170a2..a67440a 100644 --- a/src/main/java/org/pgsg/gateway/GatewayApplication.java +++ b/src/main/java/org/pgsg/gateway/GatewayApplication.java @@ -2,8 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -@SpringBootApplication +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) public class GatewayApplication { public static void main(String[] args) { diff --git a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java new file mode 100644 index 0000000..ce4be36 --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,24 @@ +package org.pgsg.gateway.config; + +import org.pgsg.config.security.SecurityConfig; +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.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class GatewaySecurityConfig implements SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ); + return http.build(); + } +} diff --git a/src/main/java/org/pgsg/gateway/config/JwtConfig.java b/src/main/java/org/pgsg/gateway/config/JwtConfig.java new file mode 100644 index 0000000..063fc1c --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/JwtConfig.java @@ -0,0 +1,18 @@ +package org.pgsg.gateway.config; + +import org.pgsg.config.security.jwt.JwtProperties; +import org.pgsg.config.security.jwt.JwtTokenProvider; +import org.pgsg.config.security.token.TokenProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class JwtConfig { + + @Bean + public TokenProvider tokenProvider(JwtProperties jwtProperties) { + return new JwtTokenProvider(jwtProperties); + } +} diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java new file mode 100644 index 0000000..8f129ad --- /dev/null +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -0,0 +1,70 @@ +package org.pgsg.gateway.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.pgsg.config.security.jwt.JwtUtils; +import org.pgsg.config.security.token.TokenProvider; +import org.pgsg.config.security.token.TokenType; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.function.HandlerFilterFunction; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component("jwtGatewayFilter") +@RequiredArgsConstructor +public class JwtGatewayFilter implements HandlerFilterFunction { + + private final TokenProvider jwtTokenProvider; + + @Override + public ServerResponse filter(ServerRequest request, HandlerFunction next) throws Exception { + // 1. 스푸핑 방지 및 빌더 생성 + ServerRequest.Builder builder = ServerRequest.from(request) + .headers(headers -> headers.keySet().removeIf(name -> name.toLowerCase().startsWith("x-user-"))); + + String accessToken = JwtUtils.resolveToken(request.headers().firstHeader(HttpHeaders.AUTHORIZATION)); + + // 2. 토큰 검증 실패 시 즉시 다음 단계로 (헤더는 초기화된 상태) + if (accessToken == null || !jwtTokenProvider.validateToken(accessToken)) { + return next.handle(builder.build()); + } + + try { + Claims claims = jwtTokenProvider.parseClaims(accessToken); + String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); + + if (TokenType.ACCESS.matches(tokenType)) { + injectUserHeaders(builder, claims); + log.info("[JwtGatewayFilter] 토큰 검증 성공 - 사용자 헤더 주입"); + } + } catch (JwtException | IllegalArgumentException e) { + log.warn("[JwtGatewayFilter] 토큰 처리 오류: {}", e.getMessage()); + } + + return next.handle(builder.build()); + } + + private void injectUserHeaders(ServerRequest.Builder builder, Claims claims) { + builder.header(JwtUtils.HEADER_USER_ID, claims.getSubject()); + builder.header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)); + builder.header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)); + builder.header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))); + builder.header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))); + + Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); + builder.header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false"); + } + + private String encodeValue(String value) { + if (value == null) return ""; + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a65104f..0f9acf0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,24 +1,22 @@ -server: - port: 8090 - spring: application: name: gateway-server + config: + import: + - "optional:configserver:http://34.50.50.170:13100" + - "optional:file:.env[.properties]" cloud: - config: - enabled: false gateway: server: webmvc: - routes: - - id: trace-server - uri: lb://trace-server - predicates: - - Path=/api/v1/trace/** + default-filters: + - name: jwtGatewayFilter + config: + allow-override: true + override-none: true + override-system-properties: false + discovery: + service-id: config-server -eureka: - client: - fetch-registry: true - register-with-eureka: true - service-url: - defaultZone: http://eureka-server:8761/eureka/ \ No newline at end of file +server: + port: 8090 \ No newline at end of file diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 649397b..201a3c9 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -5,8 +5,14 @@ spring: config: enabled: false config: - import: "" + import: "optional:file:.env[.properties]" eureka: client: - enabled: false \ No newline at end of file + enabled: false + +jwt: + # 환경변수로 주입(테스트용이므로 jwt.secret도 기본값 부여) + secret: ${JWT_SECRET:test-jwt-secret-for-local-ci-only-32bytes-min} + access-token-expiration: ${JWT_ACCESS_EXPIRATION:1800000} # 30분 (ms) + refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 (ms) \ No newline at end of file From d88fa02d19d7b0e5ff13b5da887c52e9f4990e13 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Mon, 4 May 2026 13:53:11 +0900 Subject: [PATCH 02/15] Feature/#4 gateway blacklist and trace (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 의존성 설정 수정 - 게이트웨이에서 사용하지 않는 queryDsl, jpa 관련 의존성 제외 - 기존의 AppCtx 대신 게이트웨이 맞춤형 AppCtx 사용 * chore: application.yml 설정 수정 - 로컬 application.yml 파일의 게이트웨이 관련 설정을 원격 config의 gateway 설정으로 대체 * refactor: 게이트웨이 필터 구현 방식 변경 - 구현의 복잡성을 낮추면서 .yaml 파일 시반 라우팅 설정 적용을 목적으로 수행 - 기존 HandlerFilterFunction 기반 인증 필터로 요청 발송 시 라우팅 설정이 제대로 적용되지 않는 문제 개선 - HandlerFilterFunction 대신 OncePerRequestFilter를 기반으로 하여 게이트웨이 인증 필터가 동작하도록 수정 * chore: build.gradle 의존성 추가 및 FeignClient 활성화 - user-service와의 동기식 통신을 통해 토큰 블랙리스트 검증을 수행하기 위한 FeignClient 추가 - traceId를 할당 작업용 라이브러리 추가 - GatewayApplication 내 @EnableFeignClients 추가 * feat : 블랙리스트 검증 로직 호출용 FeignClient 추가 - user-service의 /internal/v1/auth/verify api 호출용 FeignClient 엔드포인트 추가 - FeignClient 요청 처리 실패 시 Fallback 로직 추가 - accessToken 검증 결과를 임시 저장(3분)하기 위한 로컬 캐시 추가 - 추후 목 테스트를 수행하기 위해 AuthProvider 인터페이스와 구현체를 분리 * feat : 토큰 블랙리스트 검증 및 traceId 할당 기능 추가 - JwtGatewayFilter에 FeignClient 기반 accessToken 블랙리스트 검증 로직을 적용 - 인증 필터 실행 시 traceId를 요청 헤더에 저장하는 기능 추가 - 게이트웨이 내부에서 JwtGatewayFilter가 MdcLoggingFilter 바로 다음에 동작하도록 필터 실행 우선순위를 조정 - 초기에는 Zipkin의 traceId를 생성한 후 MDC의 traceID로 동기화 * refactor : 토큰 블랙리스트 검증 응답 형식 수정 - AuthDto.TokenVerifyResponse 대신 AuthDto.TokenVerifyData를 CommonResponse로 래핑하는 형태로 수정 * refactor : 게이트웨이 인증 실패 시 응답 처리 코드 수정 - CustomAuthenticationEntryPoint를 활용하여 인증 실패 시 공통 모듈의 에러 메시지 형식에 맞춰 응답을 반환하도록 수정 * fix : 게이트웨이 설정 적용 방식 수정 - - 게이트웨이에서 JPA, QueryDsl 관련 설정을 확실하게 제외하기 위해 GatewayAppCtx로 커스터마이징한 빈 설정을 확실히 적용하도록 @Import를 사용 * refactor : FeignClient의 변경된 반환타입 반영 - response의 타입을 CommonResponse로 변경 * docs : 작업 내용 요약본 정리 - 게이트웨이 토큰 블랙리스트 검증 및 traceId 할당 기능 추가 관련 작업 내역 문서화 * docs : 게이트웨이 관련 작업 내역 정리 문서 업데이트 - README 추가 - 기존 작업 내역 업데이트 * fix : 코드래빗 피드백 반영 - 캐시 저장용량 상한 초과 시 캐시 삭제 로직 추가 - fallback traceId의 길이를 초기 발급된 traceId의 길이와 통일 - 오기재된 문서 및 주석 내용 수정 * fix : 코드래빗 피드백 반영 - 유효하지 않은 accessToken을 사용한 요청은 게이트웨이에서 필터링하도록 수정 - 단, 토큰 재발급 요청에 한해서는 통과 (accessToken이 로그아웃한 사용자의 토큰이더라도 user-service의 재발급 로직에서 블랙리스트 포함 여부를 확인) * refactor : 게이트웨이 필터 검증 순서 최적화 - 기존에는 블랙리스트 검증 -> 토큰 유효성 검증 -> 토큰 파싱 순으로 진행 - 인증 필터 내부에서 토큰 유효성 검증 -> 블랙리스트 검증 -> 토큰 파싱 순으로 작업을 진행하여 이미 검증된 토큰에 대해서만 원격 검증을 수행하도록 하여, 불필요한 FeignClient 호출 방지 - 문서에 관련 내용 업데이트 --- README.md | 34 ++++ build.gradle | 8 +- docs/gateway-server-improvement-summary.md | 63 ++++++++ docs/gateway-server-setup-summary.md | 62 ++++--- .../org/pgsg/gateway/GatewayApplication.java | 14 +- .../java/org/pgsg/gateway/auth/AuthDto.java | 7 + .../org/pgsg/gateway/auth/AuthProvider.java | 6 + .../pgsg/gateway/auth/AuthProviderImpl.java | 59 +++++++ .../pgsg/gateway/config/GatewayAppCtx.java | 75 +++++++++ .../gateway/config/GatewaySecurityConfig.java | 9 +- .../org/pgsg/gateway/feign/AuthClient.java | 14 ++ .../feign/AuthClientFallbackFactory.java | 23 +++ .../filter/HttpRequestHeaderWrapper.java | 69 ++++++++ .../pgsg/gateway/filter/JwtGatewayFilter.java | 153 ++++++++++++++---- src/main/resources/application.yaml | 5 - 15 files changed, 532 insertions(+), 69 deletions(-) create mode 100644 README.md create mode 100644 docs/gateway-server-improvement-summary.md create mode 100644 src/main/java/org/pgsg/gateway/auth/AuthDto.java create mode 100644 src/main/java/org/pgsg/gateway/auth/AuthProvider.java create mode 100644 src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java create mode 100644 src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java create mode 100644 src/main/java/org/pgsg/gateway/feign/AuthClient.java create mode 100644 src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java create mode 100644 src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java diff --git a/README.md b/README.md new file mode 100644 index 0000000..29b5a6f --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# PGSG Gateway Server + +PGSG 마이크로서비스 아키텍처의 강력한 보안 입구이자 통합 관측성(Observability) 허브 역할을 수행하는 게이트웨이 서버입니다. + +## 🌟 핵심 기능 (Core Capabilities) + +### 1. 보안 입구 정책 (Entry Gate Security) +- **Servlet-based Filter 강제**: `OncePerRequestFilter` 기반의 아키텍처를 채택하여 라우팅 설정과 무관하게 모든 요청에 대한 보안 검사를 강제합니다. +- **헤더 스푸핑(Spoofing) 원천 차단**: 진입 시점에 외부 유입 헤더(`x-user-*`)를 즉시 제거하고 검증된 데이터만 다시 주입하는 선제 방어 시스템을 갖추고 있습니다. +- **실시간 이중 검증**: 게이트웨이의 **로컬 JWT 서명 검증**과 유저 서비스의 **원격 블랙리스트 확인**을 결합한 하이브리드 인증 체계를 구축했습니다. +- **인증 성능 최적화**: 원격 검증 부하를 최소화하기 위해 로컬 캐시(TTL 30s)가 적용되어 있습니다. + +### 2. 정밀한 분산 추적 (Distributed Tracing) +- **Trace ID 동기화 아키텍처**: Zipkin(`Tracer`)이 생성한 표준 ID를 로그(`MDC`) 및 요청 헤더(`X-Trace-Id`)와 100% 동기화합니다. +- **전 구간 가시성**: 게이트웨이부터 하위 마이크로서비스까지 하나의 고유 ID(Single Source of Truth)로 모든 실행 로그를 연결하여 장애 추적 시간을 획기적으로 단축했습니다. + +### 3. 표준화된 장애 및 에러 대응 +- **통합 에러 핸들링**: `CustomAuthenticationEntryPoint`를 통해 어떤 인증 실패 상황에서도 공통 모듈의 `ErrorResponse` 규격에 맞는 정교한 JSON 응답을 반환합니다. +- **장애 내성 (Resilience)**: `AuthClientFallbackFactory`를 구현하여 유저 서비스 장애 시에도 게이트웨이가 패닉 없이 안전하게 대응(Fail-Safe)합니다. + +### 4. 시스템 최적화 (System Optimization) +- **의존성 격리**: DB를 사용하지 않는 게이트웨이 특성에 맞춰 `@ImportAutoConfiguration`을 통해 불필요한 JPA/DB 설정을 완벽히 제거하고 실행 컨텍스트를 경량화했습니다. + +## 🛠 기술 스택 +- **Runtime**: Java 21 / Spring Boot 3.5.13 +- **Gateway**: Spring Cloud Gateway MVC (Servlet) +- **Security**: Spring Security 6.x +- **Tracing**: Micrometer Tracing (Zipkin Ready) +- **Client**: Spring Cloud OpenFeign + +## 📂 주요 문서 +- [상세 개선 보고서](./docs/gateway-server-improvement-summary.md): 기술적 해결 방안 및 리팩토링 상세 내역 +- [설정 및 작업 이력](./docs/gateway-server-setup-summary.md): Phase별 구축 과정 및 최종 검증 결과 + diff --git a/build.gradle b/build.gradle index 2fb813b..dff3a95 100644 --- a/build.gradle +++ b/build.gradle @@ -30,10 +30,16 @@ ext { dependencies { - implementation 'org.pgsg:common:0.2.0-SNAPSHOT' + implementation('org.pgsg:common:0.2.5-SNAPSHOT') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' + exclude group: 'com.querydsl', module: 'querydsl-jpa' + } implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + + implementation 'io.micrometer:micrometer-tracing-bridge-brave' // jwt 관련 라이브러리 implementation 'io.jsonwebtoken:jjwt-api:0.12.6' diff --git a/docs/gateway-server-improvement-summary.md b/docs/gateway-server-improvement-summary.md new file mode 100644 index 0000000..47539b4 --- /dev/null +++ b/docs/gateway-server-improvement-summary.md @@ -0,0 +1,63 @@ +# Gateway Server 개선 및 보안 강화 상세 보고서 + +본 문서는 Gateway Server의 보안성, 안정성, 및 관측성 향상을 위해 진행된 주요 개선 사항 및 기술적 해결 방안을 상세히 기록합니다. + +--- + +## 1. 필터 아키텍처 개편: Servlet-based Filter 도입 + +### 배경 및 문제점 +Spring Cloud Gateway MVC 환경에서 표준 `HandlerFilterFunction`을 사용할 경우, 필터의 적용 여부가 라우팅 설정(YAML)에 의존하게 됩니다. 원격 Config 서버를 사용하거나 복잡한 라우팅 환경에서는 설정 실수로 인해 특정 경로에서 인증 필터가 누락될 수 있는 보안 허점이 존재했습니다. + +### 개선 사항 +- **`OncePerRequestFilter` 채택**: 서블릿 컨테이너(Tomcat) 레벨에서 동작하는 필터 방식을 도입하여, 게이트웨이 엔진의 라우팅 설정과 무관하게 모든 HTTP 요청에 대해 필터 실행을 강제했습니다. +- **최상위 우선순위 (`Ordered.HIGHEST_PRECEDENCE + 1`)**: 로깅 필터(`MdcLoggingFilter`) 직후에 실행되도록 보장하여, 보안 검사 이전에 추적 컨텍스트를 완벽히 준비했습니다. +- **Fail-Fast 보안 정책**: 유효하지 않은 모든 토큰(위조, 만료, 블랙리스트 등)에 대해 즉시 401 응답을 반환하고 요청을 종료하여 하위 서비스 자원을 보호합니다. + +--- + +## 2. 최적화된 이중 검증 시스템 (Performance Optimized) + +리소스 소모를 최소화하기 위해 검증 순서를 비용 효율적으로 재설계했습니다. + +1. **로컬 검증 (Local Validation - 1차)**: `TokenProvider`를 통해 JWT의 서명 위조 및 만료 여부를 게이트웨이 메모리 내에서 즉시 확인합니다. (가장 비용이 낮음) +2. **원격 검증 (Remote Verification - 2차)**: 로컬 검증을 통과한 유효한 토큰에 한해서만 유저 서비스 API 또는 로컬 캐시를 통해 블랙리스트 여부를 확인합니다. +3. **효율적 캐싱**: `AuthProviderImpl` 내부에 짧은 TTL(10~30s)의 캐시를 적용하여 실시간성과 성능 사이의 균형을 맞췄습니다. + +--- + +## 3. 분산 추적 및 관측성 (Distributed Tracing) + +### Trace ID 동기화 전략 (Zipkin Readiness) +분산 환경에서 로그의 정합성을 100% 보장하기 위해 **"단일 소스 원칙(Single Source of Truth)"**을 적용했습니다. +1. **Tracer 우선순위**: Zipkin(`Tracer`)이 생성한 실제 Trace ID를 최우선으로 가져옵니다. +2. **MDC 동기화**: 결정된 진짜 Trace ID를 `MDC.put("traceId", traceId)`를 통해 로그 시스템에 강제 동기화합니다. 이는 `MdcLoggingFilter`가 생성한 임시 ID를 진짜 ID로 교체하는 역할을 합니다. +3. **전구간 전파**: 동기화된 ID를 하위 서비스로 전달되는 `X-Trace-Id` 헤더에 주입하여 전체 트랜잭션을 하나의 ID로 연결합니다. + +--- + +## 4. 에러 핸들링 표준화 + +### 공통 에러 응답 (`ErrorResponse`) 적용 +- **에러 처리 일원화**: 필터 내부의 개별 응답 로직을 제거하고 `CustomAuthenticationEntryPoint`로 에러 처리를 위임하여 모든 인증 실패 응답 형식을 통일했습니다. +- **Trace ID 포함**: 모든 에러 응답 본문에 Trace ID를 포함시켜 장애 발생 시 로그 추적의 편의성을 극대화했습니다. + +--- + +## 5. 의존성 격리 및 최적화 (JPA Dependency Isolation) + +### 기술적 해결 방안 +- **명시적 자동 설정 제외**: `GatewayApplication`에서 `@ImportAutoConfiguration(exclude = AppCtx.class)`를 사용하여 불필요한 JPA 관련 설정을 완벽히 차단했습니다. +- **맞춤형 컨텍스트 구성 (`GatewayAppCtx`)**: 게이트웨이에 꼭 필요한 공통 기능(Feign, JSON, Error Properties 등)만 선택적으로 로드하여 컨텍스트를 경량화했습니다. + +--- + +## 6. 시스템 내결함성 및 통합 테스트 + +### Feign Client Fallback +- `AuthClientFallbackFactory`를 구현하여 인증 서비스 장애 시에도 시스템 전체가 마비되지 않도록 Fail-Safe 로직을 강화했습니다. + +### 통합 테스트 성공 +- **로그인/로그아웃/재발급**: 모든 핵심 인증 시나리오에 대해 Trace ID 추적과 함께 정상 동작 및 즉시 차단(Fail-Fast) 기능을 완벽히 검증했습니다. + +--- \ No newline at end of file diff --git a/docs/gateway-server-setup-summary.md b/docs/gateway-server-setup-summary.md index ccbe2af..c9fc736 100644 --- a/docs/gateway-server-setup-summary.md +++ b/docs/gateway-server-setup-summary.md @@ -1,38 +1,48 @@ -# Gateway Server 구축 및 보안 아키텍처 작업 이력 +# Gateway Server 구축 및 보안 아키텍처 작업 이력 (최종본) ## 1. 프로젝트 개요 -본 프로젝트는 MSA 환경에서 요청의 진입점 역할을 수행하는 **WebMVC 기반의 Spring Cloud Gateway**입니다. 전역적인 인증 처리와 라우팅 관리를 담당합니다. +본 프로젝트는 MSA 환경에서 요청의 진입점 역할을 수행하는 **Servlet 기반(WebMVC)의 Spring Cloud Gateway**입니다. 전역적인 보안 입구 컷, 실시간 블랙리스트 검증, 그리고 분산 추적(Tracing)을 핵심 아키텍처로 채택하고 있습니다. ## 2. 작업 이력 및 기술적 진화 ### 2.1 [Phase 1] 초기 인프라 설정 및 Config Server 통합 -- **DataSource 자동 설정 제외**: DB 미사용에 따른 기동 오류를 `DataSourceAutoConfiguration` 제외 설정을 통해 해결. +- **DataSource 자동 설정 제외**: DB 미사용에 따른 기동 오류를 `DataSourceAutoConfiguration` 제외 설정을 통해 1차 해결. - **Docker 컨테이너화**: Multi-stage 빌드 및 `.env`를 통한 환경 변수 주입 환경 구축. -- **Config Server 우선순위 해결**: 원격 설정이 로컬 라우팅을 덮어쓰는 문제를 `spring.config.import` 순서 조정 및 `local-routes.yaml` 분리를 통해 해결. +- **Config Server 통합**: 원격 설정 서버로부터 라우팅 및 보안 설정을 동적으로 로드하도록 구성. -### 2.2 [Phase 2] 서블릿 필터 기반 JWT 인증 구현 (초기 버전) -- **JwtAuthenticationFilter (OncePerRequestFilter)**: 표준 서블릿 필터 방식으로 JWT 검증 로직 구현. -- **HttpRequestHeaderWrapper**: `HttpServletRequest`가 읽기 전용인 서블릿 특성을 극복하기 위해 래퍼 클래스를 구현하여 헤더 주입 및 스푸핑 방지 기능 수행. -- **성과**: 게이트웨이 단에서의 1차적인 인증 및 정보 전달(Header Propagation) 메커니즘을 최초로 확립. +### 2.2 [Phase 2] 의존성 격리 및 컨테이너 최적화 (JPA Isolation) +- **문제**: 공통 모듈(`common`) 로드 시 JPA 및 QueryDSL 관련 Bean이 강제 주입되어 기동 실패 현상 발생. +- **해결**: `GatewayApplication`에서 `@ImportAutoConfiguration(exclude = AppCtx.class)`를 적용하여 공통 메인 설정을 제외하고, `GatewayAppCtx`를 통해 게이트웨이에 필요한 Bean만 선택적으로 수용하도록 최적화. -### 2.3 [Phase 3] 네이티브 게이트웨이 필터로 리팩토링 (현재 버전) -- **JwtGatewayFilter (HandlerFilterFunction)**: 서블릿 필터를 제거하고 Spring Cloud Gateway WebMVC의 표준인 `HandlerFilterFunction`으로 전환. -- **ServerRequest Mutability 활용**: 게이트웨이 빌트인 기능을 사용하여 별도의 래퍼 클래스 없이도 안전하게 헤더를 초기화하고 주입하는 구조로 단순화. +### 2.3 [Phase 3] 필터 아키텍처 확정 (Servlet-based Gatekeeping) +- **JwtGatewayFilter (OncePerRequestFilter)**: 라우팅 설정 의존성을 제거하고 보안 강제성을 확보하기 위해 서블릿 필터 방식을 최종 채택. +- **순서 조정**: `Ordered.HIGHEST_PRECEDENCE + 1`을 부여하여 로깅 준비(`MDC`) 후 즉시 보안 검사가 이루어지도록 순서 확정. - **가독성 개선**: Guard Clauses 패턴을 적용하여 중첩 `if`문을 제거하고 로직을 평탄화. -- **성과**: 서블릿 종속성을 줄이고 게이트웨이 프레임워크와의 결합도를 높여 성능과 유지보수성 향상. + +### 2.4 [Phase 4] 실시간 블랙리스트 검증 및 내결함성 (Resilience) +- **이중 검증**: 게이트웨이 로컬 검증(JWT 서명)과 `AuthProvider`를 통한 원격 실시간 검증(블랙리스트 여부)을 연동. +- **캐싱 전략**: `AuthProviderImpl`에 10~30초 단위의 짧은 로컬 캐시를 적용하여 인증 서비스 부하 감소와 실시간성 사이의 균형 확보. +- **Fallback 구현**: `AuthClientFallbackFactory`를 통해 인증 서버 장애 시에도 시스템 전체가 마비되지 않도록 Fail-Safe 로직 구축. + +### 2.5 [Phase 5] 관측성(Tracing) 및 에러 표준화 +- **Trace ID 동기화**: `Tracer`(Zipkin)가 생성한 진짜 ID를 `MDC` 및 헤더에 강제 동기화하여 분산 환경에서의 로그 일관성 100% 확보. +- **통합 에러 핸들링**: `CustomAuthenticationEntryPoint`를 필터 내부에서 직접 호출하도록 연동하여, 모든 인증 실패 시 공통 모듈의 `ErrorResponse` 규격에 맞는 JSON 응답을 보장. ## 3. 핵심 클래스 현황 -- `GatewayApplication.java`: 애플리케이션 엔트리 포인트. -- `JwtGatewayFilter.java`: 현재 활성화된 네이티브 인증 필터. -- `JwtConfig.java`: 공통 모듈 유틸리티 빈 등록. -- `GatewaySecurityConfig.java`: 전역 보안 정책 구성. - -## 4. 검증 결과 -- `/api/v1/auth/login` 라우팅 및 토큰 발급 테스트 완료. -- 발급된 토큰을 통한 게이트웨이 필터의 헤더 변환 및 사용자 정보 주입 정상 동작 확인. -- Gradle/Docker 캐시 초기화를 통한 깨끗한 빌드 환경 검증 완료. - -## 5. 향후 아키텍처 결정 사항 (블랙리스트 검증) -- **데이터 소유권**: Redis는 오직 `user-service`만 소유한다는 원칙 고수. -- **검증 방식**: 게이트웨이가 `user-service`의 내부 API(FeignClient 등)를 호출하여 블랙리스트 여부 확인. -- **최적화**: 네트워크 부하 감소를 위해 게이트웨이 내부에 **로컬 메모리 캐시** 도입 검토. +- `GatewayApplication.java`: 애플리케이션 엔트리 포인트 및 자동 설정 제외 관리. +- `JwtGatewayFilter.java`: 입구 보안 및 헤더 주입을 담당하는 핵심 필터. +- `GatewayAppCtx.java`: 게이트웨이 전용 최적화 컨텍스트 구성. +- `AuthProviderImpl.java`: 캐싱 기반 실시간 토큰 검증기. +- `AuthClient.java`: 유저 서비스 규격(DTO)에 맞춘 Feign 통신 인터페이스. +- `CustomAuthenticationEntryPoint.java`: 통합 에러 응답 처리기. + +## 4. 최종 검증 결과 +1. **로그인 성공**: 유효한 Access Token 발급 및 Trace ID 생성 확인. +2. **권한 통과**: 발급된 토큰을 통한 게이트웨이 → 하위 서비스 호출 및 데이터 수신 성공. +3. **로그아웃 및 차단**: 로그아웃된 토큰 사용 시 게이트웨이 필터 및 서비스 최종 방어에 의해 **401 Unauthorized** 차단 성공. +4. **일관성 확인**: 게이트웨이 로그와 서비스 응답 내의 Trace ID가 완벽하게 일치함을 검증. + +## 5. 설계 원칙 (Design Principles) +- **보안**: 설정 실수로 인한 보안 구멍이 발생하지 않도록 서블릿 컨테이너 레벨에서 선제 방어. +- **관측성**: "Single Source of Truth(ID)" 원칙에 기반한 전 구간 추적 시스템 구축. +- **유연성**: 공통 모듈의 에러 규격 및 DTO를 완벽히 준수하여 프론트엔드 연동성을 극대화. diff --git a/src/main/java/org/pgsg/gateway/GatewayApplication.java b/src/main/java/org/pgsg/gateway/GatewayApplication.java index a67440a..93920d4 100644 --- a/src/main/java/org/pgsg/gateway/GatewayApplication.java +++ b/src/main/java/org/pgsg/gateway/GatewayApplication.java @@ -1,14 +1,20 @@ package org.pgsg.gateway; +import org.pgsg.config.AppCtx; +import org.pgsg.gateway.config.GatewayAppCtx; import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Import; -@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +@SpringBootApplication +@ImportAutoConfiguration(exclude = AppCtx.class) +@Import(GatewayAppCtx.class) +@EnableFeignClients public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } - -} +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/auth/AuthDto.java b/src/main/java/org/pgsg/gateway/auth/AuthDto.java new file mode 100644 index 0000000..3528e9a --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthDto.java @@ -0,0 +1,7 @@ +package org.pgsg.gateway.auth; + +public class AuthDto { + public record TokenVerifyRequest(String accessToken) {} + + public record TokenVerifyData(boolean isVerifiedToken) {} +} diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java new file mode 100644 index 0000000..71589cb --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java @@ -0,0 +1,6 @@ +package org.pgsg.gateway.auth; + +public interface AuthProvider { + + boolean verifyToken(String accessToken); +} diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java new file mode 100644 index 0000000..4634c1e --- /dev/null +++ b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java @@ -0,0 +1,59 @@ +package org.pgsg.gateway.auth; + +import lombok.RequiredArgsConstructor; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.feign.AuthClient; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@RequiredArgsConstructor +public class AuthProviderImpl implements AuthProvider { + + private static final long CACHE_TTL = 30 * 1000; // 캐시 유지 시간: 30초 + private static final int MAX_CACHE_SIZE = 10000; + + // 간단한 로컬 캐시 (토큰별 검증 결과 저장) + private final Map cache = new ConcurrentHashMap<>(); + private final AuthClient authClient; + + @Override + public boolean verifyToken(String accessToken) { + CacheEntry entry = cache.get(accessToken); + + // 캐시가 유효하면 바로 반환 + if (entry != null && !entry.isExpired()) { + return entry.result; + } + + // 캐시가 없거나 만료되었으면 Feign 호출 + CommonResponse response = authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)); + + // 결과 추출 (success 가 true 이고 isVerifiedToken 이 true 인 경우에만 성공) + boolean result = response != null && response.success() && response.data() != null && response.data().isVerifiedToken(); + if (cache.size() >= MAX_CACHE_SIZE) { + cleanupCache(); + if (cache.size() >= MAX_CACHE_SIZE) { + cache.clear(); + } + } + cache.put(accessToken, new CacheEntry(result, System.currentTimeMillis() + CACHE_TTL)); + + cleanupCache(); + + return result; + } + + // 만료된 캐시를 가끔 정리 (메모리 누수 방지) + private void cleanupCache() { + cache.entrySet().removeIf(e -> e.getValue().isExpired()); + } + + private record CacheEntry(boolean result, long expiryTime) { + boolean isExpired() { + return System.currentTimeMillis() > expiryTime; + } + } +} diff --git a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java new file mode 100644 index 0000000..c23205b --- /dev/null +++ b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java @@ -0,0 +1,75 @@ +package org.pgsg.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.pgsg.common.exception.ErrorConfigProperties; +import org.pgsg.common.exception.GlobalExceptionAdvice; +import org.pgsg.common.exception.GlobalExceptionAdviceImpl; +import org.pgsg.common.filter.MdcLoggingFilter; +import org.pgsg.common.response.CommonResponseAdvice; +import org.pgsg.config.feign.FeignConfig; +import org.pgsg.config.json.JsonConfig; +import org.pgsg.config.security.*; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Configuration +@Import({ + FeignConfig.class, + JsonConfig.class, + ErrorConfigProperties.class +}) +public class GatewayAppCtx { + + @Bean + public LoginFilter loginFilter(@Lazy @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + return new LoginFilter(resolver); + } + + @Bean + public CustomAuthenticationEntryPoint customAuthenticationEntryPoint( + ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { + return new CustomAuthenticationEntryPoint(objectMapper, errorConfigProperties); + } + + @Bean + public CustomAccessDeniedHandler accessDeniedHandler( + ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { + return new CustomAccessDeniedHandler(objectMapper, errorConfigProperties); + } + + @Bean + @ConditionalOnMissingBean(SecurityConfig.class) + public SecurityConfig securityConfig( + LoginFilter loginFilter, + CustomAuthenticationEntryPoint customAuthenticationEntryPoint, + CustomAccessDeniedHandler accessDeniedHandler) { + return new SecurityConfigImpl(loginFilter, customAuthenticationEntryPoint, accessDeniedHandler); + } + + @Bean + @ConditionalOnMissingBean(GlobalExceptionAdvice.class) + public GlobalExceptionAdvice globalExceptionAdvice(ErrorConfigProperties errorConfigProperties) { + return new GlobalExceptionAdviceImpl(errorConfigProperties); + } + + @Bean + public CommonResponseAdvice commonResponseAdvice() { + return new CommonResponseAdvice(); + } + + @Bean + public FilterRegistrationBean mdcLoggingFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new MdcLoggingFilter()); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java index ce4be36..baf3453 100644 --- a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java +++ b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -16,9 +17,11 @@ public class GatewaySecurityConfig implements SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); + .sessionManagement(session + -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ); return http.build(); } } diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClient.java b/src/main/java/org/pgsg/gateway/feign/AuthClient.java new file mode 100644 index 0000000..614c113 --- /dev/null +++ b/src/main/java/org/pgsg/gateway/feign/AuthClient.java @@ -0,0 +1,14 @@ +package org.pgsg.gateway.feign; + +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.auth.AuthDto; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) +public interface AuthClient { + + @PostMapping(value = "/internal/v1/auth/verify") + CommonResponse verifyToken(@RequestBody AuthDto.TokenVerifyRequest request); +} diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java b/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java new file mode 100644 index 0000000..dc5c0a0 --- /dev/null +++ b/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java @@ -0,0 +1,23 @@ +package org.pgsg.gateway.feign; + +import lombok.extern.slf4j.Slf4j; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.auth.AuthDto; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AuthClientFallbackFactory implements FallbackFactory { + + @Override + public AuthClient create(Throwable cause) { + log.error("[AuthClientFallback] 인증 서비스 호출 실패: {}", cause.getMessage()); + return request -> new CommonResponse<>( + false, + "인증 서비스 장애 (Fallback)", + new AuthDto.TokenVerifyData(false), + null + ); + } +} diff --git a/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java b/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java new file mode 100644 index 0000000..8768c0e --- /dev/null +++ b/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java @@ -0,0 +1,69 @@ +package org.pgsg.gateway.filter; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.util.*; +import java.util.stream.Collectors; + +public class HttpRequestHeaderWrapper extends HttpServletRequestWrapper { + + private static final String FORBIDDEN_HEADER_PREFIX = "x-user-"; + + private final Map customHeaders = new HashMap<>(); + + public HttpRequestHeaderWrapper(HttpServletRequest request) { + super(request); + } + + public void putHeader(String name, String value) { + customHeaders.put(name.toLowerCase(), value); + } + + // x-user- 로 시작하는 헤더 일괄 제거 (스푸핑 방지) + public void removeHeaders(String prefix) { + Collections.list(super.getHeaderNames()).stream() + .filter(name -> name.toLowerCase().startsWith(prefix.toLowerCase())) + .forEach(name -> customHeaders.remove(name.toLowerCase())); + } + + @Override + public String getHeader(String name) { + String lowerName = name.toLowerCase(); + if (customHeaders.containsKey(lowerName)) { + return customHeaders.get(lowerName); + } + if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { + return null; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + String lowerName = name.toLowerCase(); + String value = customHeaders.get(lowerName); + + if (customHeaders.containsKey(lowerName)) { + // 리스트의 길이가 1인 경우에도 호환 + return Collections.enumeration(Collections.singletonList(value)); + } + if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { + return Collections.emptyEnumeration(); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Set names = Collections.list(super.getHeaderNames()).stream() + .map(String::toLowerCase) + .filter(headerName -> + !customHeaders.containsKey(headerName) && // 직접 추가한 요청 헤더가 아니면서 + !headerName.startsWith(FORBIDDEN_HEADER_PREFIX) //금지된 접두사로 시작하는 헤더가 아님 + ) + .collect(Collectors.toCollection(LinkedHashSet::new)); + names.addAll(customHeaders.keySet()); + return Collections.enumeration(names); + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index 8f129ad..10a2df6 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -2,69 +2,162 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; -import lombok.RequiredArgsConstructor; +import io.micrometer.tracing.Tracer; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.pgsg.config.security.CustomAuthenticationEntryPoint; import org.pgsg.config.security.jwt.JwtUtils; import org.pgsg.config.security.token.TokenProvider; import org.pgsg.config.security.token.TokenType; +import org.pgsg.gateway.auth.AuthProvider; +import org.slf4j.MDC; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.stereotype.Component; -import org.springframework.web.servlet.function.HandlerFilterFunction; -import org.springframework.web.servlet.function.HandlerFunction; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; @Slf4j -@Component("jwtGatewayFilter") -@RequiredArgsConstructor -public class JwtGatewayFilter implements HandlerFilterFunction { +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class JwtGatewayFilter extends OncePerRequestFilter { + private static final String HEADER_TRACE_ID = "X-Trace-Id"; + private static final List WHITELIST = List.of("/api/v1/auth/login", "/api/v1/auth/signup", "/api/v1/auth/reissue"); + + private final Tracer tracer; private final TokenProvider jwtTokenProvider; + private final AuthProvider authProvider; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + public JwtGatewayFilter( + Tracer tracer, + TokenProvider jwtTokenProvider, + AuthProvider authProvider, + @Lazy CustomAuthenticationEntryPoint customAuthenticationEntryPoint) { + this.tracer = tracer; + this.jwtTokenProvider = jwtTokenProvider; + this.authProvider = authProvider; + this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + } @Override - public ServerResponse filter(ServerRequest request, HandlerFunction next) throws Exception { - // 1. 스푸핑 방지 및 빌더 생성 - ServerRequest.Builder builder = ServerRequest.from(request) - .headers(headers -> headers.keySet().removeIf(name -> name.toLowerCase().startsWith("x-user-"))); + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String accessToken = JwtUtils.resolveToken(request.headers().firstHeader(HttpHeaders.AUTHORIZATION)); + HttpRequestHeaderWrapper mutableRequest = new HttpRequestHeaderWrapper(request); - // 2. 토큰 검증 실패 시 즉시 다음 단계로 (헤더는 초기화된 상태) - if (accessToken == null || !jwtTokenProvider.validateToken(accessToken)) { - return next.handle(builder.build()); + // 1. 추적 ID 동기화 및 보안 헤더 초기화 + String traceId = initializeHeaders(mutableRequest, tracer); + + log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), request.getRequestURI()); + String accessToken = JwtUtils.resolveToken(request.getHeader(HttpHeaders.AUTHORIZATION)); + String path = request.getRequestURI(); + + // 2. 토큰이 없거나, 화이트리스트 경로인 경우: 즉시 통과 + if (WHITELIST.contains(path) || accessToken == null) { + filterChain.doFilter(mutableRequest, response); + return; } + // 3. 통합 인증 프로세스 수행 (로컬 검증 -> 원격 검증 -> 헤더 주입) + if (!authenticate(mutableRequest, response, accessToken, traceId)) { + return; // 검증 실패 시 응답 종료 + } + + filterChain.doFilter(mutableRequest, response); + } + + /** + * 통합 인증 로직 (최적화된 순서) + * 1. 로컬 검증 (Signature, Expiration) - 비용 낮음 + * 2. 원격 검증 (Blacklist 체크) - 비용 높음 + * 3. Claims 파싱 및 헤더 주입 + */ + private boolean authenticate(HttpRequestHeaderWrapper request, HttpServletResponse response, String accessToken, String traceId) throws IOException, ServletException { try { + // [Step 1] 로컬 검증 (가장 먼저 수행하여 잘못된 토큰의 원격 호출 방지) + if (!jwtTokenProvider.validateToken(accessToken)) { + log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); + customAuthenticationEntryPoint.commence(request, response, + new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); + return false; + } + + // [Step 2] 원격 검증 (로컬 검증 통과 시에만 실시간 블랙리스트 확인) + if (!authProvider.verifyToken(accessToken)) { + log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); + customAuthenticationEntryPoint.commence(request, response, + new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); + return false; + } + + // [Step 3] Claims 추출 및 토큰 타입 확인 Claims claims = jwtTokenProvider.parseClaims(accessToken); String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); - if (TokenType.ACCESS.matches(tokenType)) { - injectUserHeaders(builder, claims); - log.info("[JwtGatewayFilter] 토큰 검증 성공 - 사용자 헤더 주입"); + if (!TokenType.ACCESS.matches(tokenType)) { + log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); + customAuthenticationEntryPoint.commence(request, response, + new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + return false; } + + // [Step 4] 검증 완료 - 사용자 헤더 주입 + injectUserHeaders(request, claims); + log.info("[JwtGatewayFilter] 인증 성공 - 사용자 헤더 주입 (TraceID: {})", traceId); + return true; + } catch (JwtException | IllegalArgumentException e) { - log.warn("[JwtGatewayFilter] 토큰 처리 오류: {}", e.getMessage()); + log.error("[JwtGatewayFilter] 인증 처리 중 예외 발생: {} (TraceID: {})", e.getMessage(), traceId); + customAuthenticationEntryPoint.commence(request, response, + new InsufficientAuthenticationException("토큰 인증 중 오류가 발생했습니다.")); + return false; + } + } + + private String initializeHeaders(HttpRequestHeaderWrapper mutableRequest, Tracer tracer) { + String traceId = (tracer.currentSpan() != null) + ? Objects.requireNonNull(tracer.currentSpan()).context().traceId() + : MDC.get("traceId"); + + if (traceId == null) { + traceId = UUID.randomUUID().toString().substring(0, 8); } - return next.handle(builder.build()); + MDC.put("traceId", traceId); + mutableRequest.removeHeaders("x-user-"); + mutableRequest.putHeader(HEADER_TRACE_ID, traceId); + + return traceId; } - private void injectUserHeaders(ServerRequest.Builder builder, Claims claims) { - builder.header(JwtUtils.HEADER_USER_ID, claims.getSubject()); - builder.header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)); - builder.header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)); - builder.header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))); - builder.header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))); + private void injectUserHeaders(HttpRequestHeaderWrapper request, Claims claims) { + request.putHeader(JwtUtils.HEADER_USER_ID, claims.getSubject()); + request.putHeader(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)); + request.putHeader(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)); + request.putHeader(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))); + request.putHeader(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))); Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); - builder.header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false"); + request.putHeader(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false"); } private String encodeValue(String value) { - if (value == null) return ""; - return URLEncoder.encode(value, StandardCharsets.UTF_8); + return Optional.ofNullable(value) + .map(val -> URLEncoder.encode(val, StandardCharsets.UTF_8)) + .orElse(""); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0f9acf0..75ffa90 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -6,11 +6,6 @@ spring: - "optional:configserver:http://34.50.50.170:13100" - "optional:file:.env[.properties]" cloud: - gateway: - server: - webmvc: - default-filters: - - name: jwtGatewayFilter config: allow-override: true override-none: true From 6f82e01f5d3182d94409761c30de1bdbfadb70f6 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Fri, 8 May 2026 17:49:03 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[TASK]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : 게이트웨이 JwtGatewayFilter 세부 로직 수정 및 통합테스트 코드 추가 - 게이트웨이 JwtGatewayFilter의 헤더 추가 기능은 그대로 유지하고, user-service의 인증 필터 로직을 다시 원상복구하는 방향으로 진행 예정 - JwtGatewayIntegrationTest에 게이트웨이 통합테스트 코드 추가 * docs : 게이트웨이 통합테스트 관련 작업사항 문서화 - 게이트웨이 통합테스트 코드 관련 내용 문서화 * fix : JwtGatewayFilter의 토큰 검증 로직 수정 - 요청 헤더에 accessToken이 아예 없는 경우에는 차단하도록 수정 - 화이트리스트에 포함되지 않은 경로에 대해서는 유효한 토큰이 요청 헤더에 없으면 차단하는지를 검증하는 테스트코드 추가 * feat : 회이트리스트 경로 검증 시 패턴매칭 적용 - 화이트리스트에 경로에 관한 패턴도 포함 가능하게 하여 화이트리스트에 특정 패턴이 저장된 경우에는 해당 패턴을 포함된 경로 전체를 허용 가능하도록 개선 --- .../gateway-server-integration-test-report.md | 50 ++++++ .../pgsg/gateway/filter/JwtGatewayFilter.java | 29 +++- .../gateway/JwtGatewayIntegrationTest.java | 159 ++++++++++++++++++ 3 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 docs/gateway-server-integration-test-report.md create mode 100644 src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java diff --git a/docs/gateway-server-integration-test-report.md b/docs/gateway-server-integration-test-report.md new file mode 100644 index 0000000..fe49ecb --- /dev/null +++ b/docs/gateway-server-integration-test-report.md @@ -0,0 +1,50 @@ +# 게이트웨이 서버 통합 테스트 구현 보고서 + +이 문서는 게이트웨이 서버(`gateway-server`)의 핵심 로직인 인증 필터(`JwtGatewayFilter`) 및 보안 정책에 대한 통합 테스트 구현 내역을 정리합니다. + +## 1. 개요 +조만간 예정된 **WebFlux 기반 게이트웨이로의 리팩토링**을 대비하여, 현재 서블릿 기반 환경에서의 비즈니스 정합성(인증, 헤더 주입, 보안 등)을 보장하기 위한 통합 테스트를 작성했습니다. + +## 2. 테스트 환경 및 전략 +- **도구**: `JUnit 5`, `MockMvc`, `Mockito` +- **전략**: + - 외부 서비스(`user-service`) 호출은 `FeignClient`를 `@MockBean`으로 처리하여 격리된 테스트 수행. + - 게이트웨이가 하위 서비스로 전달하는 헤더를 검증하기 위해 테스트 내부 전용 `TestDownstreamController`를 정의. + - 나중에 WebFlux로 전환 시 테스트 코드 수정을 최소화할 수 있도록 비즈니스 로직(결과 헤더 검증) 위주로 구성. + +## 3. 테스트 시나리오 +작성된 `JwtGatewayIntegrationTest`는 다음 4가지 핵심 시나리오를 검증합니다. + +| 시나리오 | 검증 내용 | 결과 | +| :--- | :--- | :---: | +| **인증 성공 및 헤더 주입** | 유효한 토큰 요청 시 `x-user-id`, `x-user-roles` 헤더가 정상 주입되는지 확인 | **PASS** | +| **블랙리스트 차단** | 로그아웃된 토큰 요청 시 인증 서비스(`user-service`) 연동을 통해 401 응답 확인 | **PASS** | +| **화이트리스트 통과** | 로그인, 회원가입 등 인증 제외 경로가 토큰 없이 정상 동작하는지 확인 | **PASS** | +| **헤더 스푸핑 방지** | 클라이언트가 보낸 임의의 `x-user-` 헤더가 무시되고 인증 정보로 덮어써지는지 확인 | **PASS** | + +## 4. 기술적 이슈 및 해결 내역 + +### 4.1 TokenType 매칭 오류 (401 Unauthorized) +- **문제**: 테스트 코드에서 `tokenType`을 `"ACCESS"`로 주입했으나 필터에서 검증 실패. +- **원인**: 공통 모듈의 `TokenType` enum이 내부 필드 `value`를 기준으로 `"access"` (소문자)와 매칭하도록 구현되어 있었음. +- **해결**: Claims 생성 시 `TokenType.ACCESS.getValue()` 값인 `"access"`를 사용하도록 수정. + +### 4.2 응답 구조 불일치 (PathNotFoundException) +- **문제**: `$.userId` 경로로 JSON 결과를 찾지 못해 테스트 실패. +- **원인**: 프로젝트의 `CommonResponseAdvice`가 적용되어 모든 응답이 `{"success":..., "data":{...}}` 구조로 감싸짐. +- **해결**: JSON Path를 `$.data.userId`, `$.data.roles`로 수정하여 실제 데이터 영역을 검증하도록 변경. + +### 4.3 WebTestClient 의존성 이슈 +- **문제**: WebFlux 전환을 고려해 `WebTestClient`를 쓰려 했으나, MVC 환경에서 이를 사용하려면 `spring-webflux` 라이브러리가 테스트 클래스패스에 추가되어야 함. +- **결정**: 현재 프로젝트의 순수성을 유지하기 위해 추가 의존성 없이 `MockMvc`를 사용하되, 테스트 로직을 단순화하여 나중에 교체가 쉽도록 구현함. + +## 5. 향후 WebFlux 리팩토링 시 가이드 +현재 작성된 테스트 코드는 비즈니스 로직 검증에 집중되어 있으므로, WebFlux 마이그레이션 시 다음 부분만 수정하면 됩니다. + +1. **테스트 클라이언트 변경**: `MockMvc` 대신 `WebTestClient` 사용 (이때 `spring-boot-starter-webflux` 의존성 필요). +2. **바인딩 방식 수정**: `MockMvcWebTestClient` 대신 WebFlux용 `WebTestClient.bindToApplicationContext()` 사용. +3. **결과 검증**: 현재 `andExpect(jsonPath(...))` 문법은 `WebTestClient`에서도 거의 동일하게 지원하므로 로직 재사용 가능. + +--- +**작성일**: 2026-05-08 +**작성자**: Gemini CLI diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index 10a2df6..a70c944 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -20,6 +20,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -36,7 +37,12 @@ public class JwtGatewayFilter extends OncePerRequestFilter { private static final String HEADER_TRACE_ID = "X-Trace-Id"; - private static final List WHITELIST = List.of("/api/v1/auth/login", "/api/v1/auth/signup", "/api/v1/auth/reissue"); + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + private static final List WHITELIST = List.of( + "/api/v1/auth/login", + "/api/v1/auth/signup", + "/api/v1/auth/reissue" + ); private final Tracer tracer; private final TokenProvider jwtTokenProvider; @@ -66,13 +72,21 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String accessToken = JwtUtils.resolveToken(request.getHeader(HttpHeaders.AUTHORIZATION)); String path = request.getRequestURI(); - // 2. 토큰이 없거나, 화이트리스트 경로인 경우: 즉시 통과 - if (WHITELIST.contains(path) || accessToken == null) { + // 2. 화이트리스트 경로인 경우: 즉시 통과 (패턴 매칭 지원) + if (isWhitelisted(path)) { filterChain.doFilter(mutableRequest, response); return; } - // 3. 통합 인증 프로세스 수행 (로컬 검증 -> 원격 검증 -> 헤더 주입) + // 3. 토큰이 없는 경우: 즉시 차단 (화이트리스트 제외) + if (accessToken == null) { + log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); + customAuthenticationEntryPoint.commence(request, response, + new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + return; + } + + // 4. 통합 인증 프로세스 수행 (로컬 검증 -> 원격 검증 -> 헤더 주입) if (!authenticate(mutableRequest, response, accessToken, traceId)) { return; // 검증 실패 시 응답 종료 } @@ -80,6 +94,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(mutableRequest, response); } + private boolean isWhitelisted(String path) { + return WHITELIST.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + /** * 통합 인증 로직 (최적화된 순서) * 1. 로컬 검증 (Signature, Expiration) - 비용 낮음 @@ -158,6 +177,6 @@ private void injectUserHeaders(HttpRequestHeaderWrapper request, Claims claims) private String encodeValue(String value) { return Optional.ofNullable(value) .map(val -> URLEncoder.encode(val, StandardCharsets.UTF_8)) - .orElse(""); + .orElse(null); } } diff --git a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java new file mode 100644 index 0000000..b563bd0 --- /dev/null +++ b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java @@ -0,0 +1,159 @@ +package org.pgsg.gateway; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.pgsg.common.response.CommonResponse; +import org.pgsg.config.security.token.TokenProvider; +import org.pgsg.gateway.auth.AuthDto; +import org.pgsg.gateway.feign.AuthClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import org.pgsg.config.security.jwt.JwtUtils; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class JwtGatewayIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TokenProvider tokenProvider; + + @MockitoBean + private AuthClient authClient; + + /** + * 테스트용 컨트롤러: 게이트웨이 필터를 거쳐 주입된 헤더를 확인하는 용도 + */ + @TestConfiguration + @RestController + static class TestDownstreamController { + @GetMapping("/test/headers") + public Map getHeaders( + @RequestHeader(value = "x-user-id", required = false) String userId, + @RequestHeader(value = "x-user-roles", required = false) String roles + ) { + return Map.of( + "userId", userId != null ? userId : "null", + "roles", roles != null ? roles : "null" + ); + } + + @GetMapping("/api/v1/auth/login") + public String whitelist() { + return "ok"; + } + } + + @Test + @DisplayName("유효한 토큰 요청 시 사용자 헤더가 정상 주입되어야 한다") + void success_token_injection() throws Exception { + // given + String token = "valid-token"; + String userId = "00000000-0000-0000-0000-000000000001"; + String role = "ROLE_USER"; + + Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); + + Claims claims = Jwts.claims() + .subject(userId) + .add(JwtUtils.CLAIM_USER_ROLE, role) + .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // TokenType.ACCESS.getValue() 값인 "access" 사용 + .add(JwtUtils.CLAIM_USERNAME, "tester") + .add(JwtUtils.CLAIM_NAME, "TesterName") + .add(JwtUtils.CLAIM_NICKNAME, "TestNick") + .add(JwtUtils.CLAIM_ENABLED, true) + .build(); + Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); + + Mockito.when(authClient.verifyToken(any())) + .thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); + + // when & then + mockMvc.perform(get("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.roles").value(role)); + } + + @Test + @DisplayName("검증한 토큰이 블랙리스트에 포함되어 있다면 401 에러를 반환해야 한다") + void fail_blacklisted_token() throws Exception { + // given + String token = "blacklisted-token"; + Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); + + // 원격 검증에서 실패(블랙리스트) 반환 + Mockito.when(authClient.verifyToken(any())) + .thenReturn(new CommonResponse<>(true, "fail", new AuthDto.TokenVerifyData(false), null)); + + // when & then + mockMvc.perform(get("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("화이트리스트에 포함된 경로는 유효한 토큰 없이도 통과되어야 한다") + void success_whitelist() throws Exception { + mockMvc.perform(get("/api/v1/auth/login")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("화이트리스트에 포함되지 않은 경로는 유효한 토큰이 없으면 차단되어야 한다") + void fail_nonWhitelist_noToken() throws Exception { + mockMvc.perform(get("/test/headers")) // 비화이트리스트 경로 + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("외부에서 주입한 보안 헤더(x-user-)는 무시되어야 한다") + void success_spoofing_protection() throws Exception { + // given + String token = "valid-token"; + String realUserId = "00000000-0000-0000-0000-000000000001"; + + Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); + Claims claims = Jwts.claims() + .subject(realUserId) + .add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER") + .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // "access" 사용 + .add(JwtUtils.CLAIM_USERNAME, "tester") + .add(JwtUtils.CLAIM_NAME, "TesterName") + .add(JwtUtils.CLAIM_NICKNAME, "TestNick") + .add(JwtUtils.CLAIM_ENABLED, true) + .build(); + Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); + Mockito.when(authClient.verifyToken(any())).thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); + + // when & then + mockMvc.perform(get("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header("x-user-id", "99999")) // 스푸핑 시도 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(realUserId)); // 게이트웨이가 주입한 값이어야 함 + } +} From fc4e8878e67c2a48bb614ac972f1034a2a0e9888 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Sun, 10 May 2026 01:35:41 +0900 Subject: [PATCH 04/15] =?UTF-8?q?chore=20:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20ci/cd=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 게이트웨이 ci/cd 설정 추가 - 게이트웨이 배포 관련 github action workflow 추가 - 게이트웨이 배포 전용 docker-compose.yml 파일 및 환경변수 템플릿 추가 - gar 이미지 관리 정책 추가(eureka-server와 동일) * chore : application.yaml 설정 추가 - management 블록 추가 * chore : 코드래빗 피드백 반영 - .env.example 파일명을 .env.template으로 변경 - deploy.yaml 파일의 워크플로우 세부사항 수정 * comment : 주석 수정 - .env.example 파일명을 .env.template으로 변경함에 따라 주석 내용 갱신 --- .github/workflows/deploy.yaml | 294 ++++++++++++++++++++++++++ Dockerfile | 4 + deploy/.env.template | 12 ++ deploy/ar-image-retention-policy.json | 11 + deploy/docker-compose.prod.yaml | 9 + src/main/resources/application.yaml | 6 + 6 files changed, 336 insertions(+) create mode 100644 .github/workflows/deploy.yaml create mode 100644 deploy/.env.template create mode 100644 deploy/ar-image-retention-policy.json create mode 100644 deploy/docker-compose.prod.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..29bf2c3 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,294 @@ +name: Deploy Gateway Server + +on: + push: + branches: [ main, dev ] + workflow_dispatch: + inputs: + force_apply_retention_policy: + description: "AR retention policy 적용" + type: boolean + default: false + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + WORK_DIR: /opt/gateway + HEALTH_RETRIES: 30 + HEALTH_INTERVAL: 10 + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Resolve image metadata + id: meta + run: | + echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) + echo "registry_host=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" + + - name: Apply AR Image Retention Policy + if: | + contains(toJSON(github.event.commits.*.modified), 'deploy/ar-image-retention-policy.json') || + contains(toJSON(github.event.commits.*.added), 'deploy/ar-image-retention-policy.json') || + (github.event_name == 'workflow_dispatch' && inputs.force_apply_retention_policy) + run: | + REPO_NAME=$(echo "$AR_IMAGE_PATH" | cut -d/ -f3) + POLICY_FILE="deploy/ar-image-retention-policy.json" + + if [ ! -f "$POLICY_FILE" ]; then + echo "Error: $POLICY_FILE not found" + exit 1 + fi + + REGION=$(echo "$AR_IMAGE_PATH" | sed 's/-docker\.pkg\.dev.*//') + gcloud artifacts repositories set-cleanup-policies "$REPO_NAME" \ + --project="$GCP_PROJECT" \ + --location="${REGION}" \ + --policy="$POLICY_FILE" \ + --quiet + echo "Successfully synced retention policy from $POLICY_FILE" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Artifact Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.registry_host }} + username: 'oauth2accesstoken' + password: ${{ steps.auth.outputs.access_token }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }} + ${{ env.AR_IMAGE_PATH }}:latest + secrets: | + GPR_USER=${{ secrets.GPR_USER }} + GPR_TOKEN=${{ secrets.GPR_TOKEN }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production + permissions: + contents: read + id-token: write + strategy: + fail-fast: true + max-parallel: 1 + matrix: + include: + - vm: gateway-server-1 + zone: asia-northeast3-b + # 나중에 3대로 늘릴 때 아래 주석 해제 + # - vm: gateway-server-2 + # zone: asia-northeast3-a + # - vm: gateway-server-3 + # zone: asia-northeast3-c + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Resolve rollback target + id: rollback + run: | + STABLE=$(gcloud artifacts docker tags list "$AR_IMAGE_PATH" \ + --project="$GCP_PROJECT" \ + --filter="tag=stable" \ + --format="value(tag)" 2>/dev/null | head -n1) + + if [ -n "$STABLE" ]; then + echo "tag=stable" >> "$GITHUB_OUTPUT" + else + echo "tag=" >> "$GITHUB_OUTPUT" + echo "No stable tag yet — first deploy. Rollback will be skipped." + fi + + - name: Sync compose file to ${{ matrix.vm }} + run: | + gcloud compute scp deploy/docker-compose.prod.yaml \ + ${{ matrix.vm }}:/tmp/docker-compose.prod.yaml \ + --zone="${{ matrix.zone }}" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap + + - name: Deploy and verify on ${{ matrix.vm }} + id: deploy_run + env: + NEW_TAG: ${{ needs.build-and-push.outputs.image_tag }} + AR_TOKEN: ${{ steps.auth.outputs.access_token }} + run: | + REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) + + gcloud compute ssh ${{ matrix.vm }} \ + --zone="${{ matrix.zone }}" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '$AR_TOKEN' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + + sudo mkdir -p $WORK_DIR + sudo mv /tmp/docker-compose.prod.yaml $WORK_DIR/docker-compose.prod.yaml + cd $WORK_DIR + + export IMAGE_TAG='$NEW_TAG' + export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' + + sudo -E docker compose -f docker-compose.prod.yaml pull + sudo -E docker compose -f docker-compose.prod.yaml up -d + + echo 'Checking actuator health (max $HEALTH_RETRIES x ${HEALTH_INTERVAL}s)...' + HEALTHY=0 + for i in \$(seq 1 $HEALTH_RETRIES); do + if curl -sf http://localhost:8090/actuator/health \ + | grep -q '\"status\":\"UP\"'; then + echo \"Actuator UP (attempt \$i)\" + HEALTHY=1 + break + fi + echo \"Waiting for service... (\$i/$HEALTH_RETRIES)\" + sleep $HEALTH_INTERVAL + done + + if [ \"\$HEALTHY\" != '1' ]; then + echo 'Actuator health check failed.' + exit 1 + fi + " + + - name: Rollback ${{ matrix.vm }} + if: failure() && steps.deploy_run.conclusion == 'failure' && steps.rollback.outputs.tag != '' + env: + ROLLBACK_TAG: ${{ steps.rollback.outputs.tag }} + AR_TOKEN: ${{ steps.auth.outputs.access_token }} + run: | + set +e + REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) + + attempt_rollback() { + gcloud compute ssh ${{ matrix.vm }} \ + --zone="${{ matrix.zone }}" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '$AR_TOKEN' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + + cd $WORK_DIR + + sudo docker compose -f docker-compose.prod.yaml down --remove-orphans -t 5 || true + + export IMAGE_TAG='$ROLLBACK_TAG' + export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' + + sudo -E docker compose -f docker-compose.prod.yaml pull + sudo -E docker compose -f docker-compose.prod.yaml up -d + + for i in \$(seq 1 18); do + if curl -sf http://localhost:8090/actuator/health \ + | grep -q '\"status\":\"UP\"'; then + echo \"Rollback container UP (attempt \$i)\" + exit 0 + fi + sleep 10 + done + + echo 'Rollback container did not become healthy.' + exit 1 + " + } + + for attempt in 1 2 3; do + echo "::group::Rollback attempt $attempt/3 on ${{ matrix.vm }}" + attempt_rollback + RC=$? + echo "::endgroup::" + + if [ $RC -eq 0 ]; then + echo "Rollback succeeded on attempt $attempt." + exit 0 + fi + + echo "Rollback attempt $attempt failed (exit=$RC)." + if [ $attempt -lt 3 ]; then + echo "Retrying in 15 seconds..." + sleep 15 + fi + done + + echo "::error::All rollback attempts failed on ${{ matrix.vm }}. Manual intervention required." + exit 1 + + promote-stable: + needs: [ build-and-push, deploy ] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: production + permissions: + contents: read + id-token: write + steps: + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Promote new SHA to :stable tag + run: | + NEW_TAG="${{ needs.build-and-push.outputs.image_tag }}" + echo "Promoting ${AR_IMAGE_PATH}:${NEW_TAG} -> ${AR_IMAGE_PATH}:stable" + gcloud artifacts docker tags add \ + "${AR_IMAGE_PATH}:${NEW_TAG}" \ + "${AR_IMAGE_PATH}:stable" \ + --quiet + echo "Stable tag updated." \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6b1d9d9..8ab1745 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ FROM gradle:8.7-jdk21 AS build WORKDIR /app +# Gradle 캐시를 활용하기 위해 의존성 파일만 먼저 복사 +COPY build.gradle settings.gradle ./ +RUN gradle build -x test --no-daemon > /dev/null 2>&1 || true + COPY . . RUN --mount=type=secret,id=GPR_USER \ diff --git a/deploy/.env.template b/deploy/.env.template new file mode 100644 index 0000000..def43c2 --- /dev/null +++ b/deploy/.env.template @@ -0,0 +1,12 @@ +# deploy/.env.template + +# JWT 설정 +JWT_SECRET= +JWT_ACCESS_EXPIRATION= +JWT_REFRESH_EXPIRATION= + +# 유레카 클라이언트 호스트명 +HOSTNAME= + +# Eureka 서버 주소 +EUREKA_CLIENT_SERVICEURL_DEFAULTZONE= \ No newline at end of file diff --git a/deploy/ar-image-retention-policy.json b/deploy/ar-image-retention-policy.json new file mode 100644 index 0000000..4df3f5c --- /dev/null +++ b/deploy/ar-image-retention-policy.json @@ -0,0 +1,11 @@ +[ + { + "name": "keep-latest-3-images", + "action": { + "type": "Keep" + }, + "condition": { + "keepCount": 3 + } + } +] \ No newline at end of file diff --git a/deploy/docker-compose.prod.yaml b/deploy/docker-compose.prod.yaml new file mode 100644 index 0000000..cb2f765 --- /dev/null +++ b/deploy/docker-compose.prod.yaml @@ -0,0 +1,9 @@ +services: + gateway-server: + container_name: gateway-server + image: ${AR_IMAGE_PATH}:${IMAGE_TAG} + restart: always + ports: + - "8090:8090" + env_file: + - .env \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 75ffa90..f3e5fb6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,5 +13,11 @@ spring: discovery: service-id: config-server +management: + endpoints: + web: + exposure: + include: health, info, refresh + server: port: 8090 \ No newline at end of file From 197d96dac94cc6f924b2b80c93beb0416a4166c2 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Tue, 12 May 2026 02:55:45 +0900 Subject: [PATCH 05/15] =?UTF-8?q?chore=20:=20github=20action=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : github action 배포 워크플로우 세부 스크립트 수정 - gateway-server-1의 영역을 asia-northeast3-a로 수정 - main 또는 dev 브랜치에서 배포 성공 시 promote-remote job이 자동 실행되도록 실행 조건 수정 * fix : 게이트웨이 인증 필터 화이트리스트 수정 - actuator 관련 api 호출은 허용하도록 화이트리스트 추가 * chore : 게이트웨이 설정 수정 - config server가 제공하는 common/application.yml 파일의 management 블록 설정 적용 * fix : ar-image-retention-policy.json 파일 수정 - 오류 수정 * fix : actuator 관련 화이트리스트 지정 범위 축소 - /actuator/health, /actuator/info로 한정해서 화이트리스트 축소 --- .github/workflows/deploy.yaml | 16 +++++++++------- deploy/ar-image-retention-policy.json | 2 +- .../pgsg/gateway/filter/JwtGatewayFilter.java | 5 ++++- src/main/resources/application.yaml | 6 ------ 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 29bf2c3..23db806 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -109,10 +109,10 @@ jobs: matrix: include: - vm: gateway-server-1 - zone: asia-northeast3-b + zone: asia-northeast3-a # 나중에 3대로 늘릴 때 아래 주석 해제 # - vm: gateway-server-2 - # zone: asia-northeast3-a + # zone: asia-northeast3-b # - vm: gateway-server-3 # zone: asia-northeast3-c steps: @@ -177,8 +177,8 @@ jobs: export IMAGE_TAG='$NEW_TAG' export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' - sudo -E docker compose -f docker-compose.prod.yaml pull - sudo -E docker compose -f docker-compose.prod.yaml up -d + sudo env IMAGE_TAG="$NEW_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG="$NEW_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml up -d echo 'Checking actuator health (max $HEALTH_RETRIES x ${HEALTH_INTERVAL}s)...' HEALTHY=0 @@ -225,8 +225,8 @@ jobs: export IMAGE_TAG='$ROLLBACK_TAG' export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' - sudo -E docker compose -f docker-compose.prod.yaml pull - sudo -E docker compose -f docker-compose.prod.yaml up -d + sudo env IMAGE_TAG="$ROLLBACK_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG="$ROLLBACK_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml up -d for i in \$(seq 1 18); do if curl -sf http://localhost:8090/actuator/health \ @@ -265,7 +265,9 @@ jobs: promote-stable: needs: [ build-and-push, deploy ] - if: github.ref == 'refs/heads/main' + if: | + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + needs.deploy.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 environment: production diff --git a/deploy/ar-image-retention-policy.json b/deploy/ar-image-retention-policy.json index 4df3f5c..2daf3c0 100644 --- a/deploy/ar-image-retention-policy.json +++ b/deploy/ar-image-retention-policy.json @@ -4,7 +4,7 @@ "action": { "type": "Keep" }, - "condition": { + "mostRecentVersions": { "keepCount": 3 } } diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index a70c944..b01b641 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -41,7 +41,10 @@ public class JwtGatewayFilter extends OncePerRequestFilter { private static final List WHITELIST = List.of( "/api/v1/auth/login", "/api/v1/auth/signup", - "/api/v1/auth/reissue" + "/api/v1/auth/reissue", + "/actuator/health", + "/actuator/health/**", + "/actuator/info" ); private final Tracer tracer; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f3e5fb6..75ffa90 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,11 +13,5 @@ spring: discovery: service-id: config-server -management: - endpoints: - web: - exposure: - include: health, info, refresh - server: port: 8090 \ No newline at end of file From 518cadac4490a7e36d26d56cbff8659b3f547932 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Tue, 12 May 2026 14:01:24 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[TASK]=20:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20VM=203=EB=8C=80=20=EA=B5=AC=EB=8F=99=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=B0=ED=8F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 게이트웨이 VM 3대 구동하도록 설정 수정 - 게이트웨이 3대 구동 시 배포 테스트용 설정 활성화 * chore : 수동 스케일아웃/스케일인 워크플로우 추가 - 부하테스트 진행 시에만 게이트웨이를 3대까지 구동하기 위해 추가 * refactor : 게이트웨이 CI/CD 워크플로우 리팩토링 - step, action 기반으로 분할하여 코드 가독성 개선 * fix : 게이트웨이 CI/CD 워크플로우 오류 수정 - permission 블록을 추가하여 하위 워크플로우에 권한 관련 정보를 명시적으로 전달 * fix : 스케일아웃 실행 중 오류 수정 - _scale.yaml 파일에 actions/checkout 추가 * fix : 코드래빗 수정사항 반영 - 이전 버전의 워크플로우 삭제 - 스케일아웃 타임아웃 시간 연장 - VM 미존재 시 명시적으로 오류 처리하도록 수정 * fix : 배포 오류 수정 - 배포 성공 직후 에러가 발생하면서 강제종료되는 문제 수정 * refactor : 배포 워크플로우 수정 - build-and-push와 scale-out 작업을 병행하도록 수정 --- .github/actions/deploy-vm/action.yaml | 173 ++++++++++++++ .github/actions/scale-vm/action.yaml | 88 +++++++ .github/workflows/_build.yaml | 92 ++++++++ .github/workflows/_deploy.yaml | 62 +++++ .github/workflows/_promote.yaml | 40 ++++ .github/workflows/_scale.yaml | 42 ++++ .github/workflows/deploy.yaml | 325 +++++--------------------- 7 files changed, 560 insertions(+), 262 deletions(-) create mode 100644 .github/actions/deploy-vm/action.yaml create mode 100644 .github/actions/scale-vm/action.yaml create mode 100644 .github/workflows/_build.yaml create mode 100644 .github/workflows/_deploy.yaml create mode 100644 .github/workflows/_promote.yaml create mode 100644 .github/workflows/_scale.yaml diff --git a/.github/actions/deploy-vm/action.yaml b/.github/actions/deploy-vm/action.yaml new file mode 100644 index 0000000..a51a749 --- /dev/null +++ b/.github/actions/deploy-vm/action.yaml @@ -0,0 +1,173 @@ +name: Deploy VM +description: GCP VM에 Docker Compose 기반으로 배포하고, 실패 시 stable 태그로 롤백합니다 + +inputs: + vm: + description: "VM 이름" + required: true + zone: + description: "VM 존" + required: true + gcp_project: + description: "GCP 프로젝트 ID" + required: true + ar_image_path: + description: "Artifact Registry 이미지 경로" + required: true + image_tag: + description: "배포할 이미지 태그" + required: true + ar_token: + description: "Artifact Registry 액세스 토큰" + required: true + work_dir: + description: "VM 내 작업 디렉토리" + required: true + health_retries: + description: "헬스체크 최대 재시도 횟수" + required: true + health_interval: + description: "헬스체크 재시도 간격(초)" + required: true + +runs: + using: composite + steps: + # TERMINATED 상태면 이후 step 전체 스킵 + - name: Check VM status + id: vm-status + shell: bash + run: | + STATUS=$(gcloud compute instances describe ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + if [ "$STATUS" = "NOT_FOUND" ]; then + echo "::error::${{ inputs.vm }} (${{ inputs.zone }}) 을(를) 찾을 수 없습니다." + exit 1 + fi + echo "status=$STATUS" >> "$GITHUB_OUTPUT" + echo "${{ inputs.vm }} → $STATUS" + + - name: Resolve rollback target + id: rollback + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + STABLE=$(gcloud artifacts docker tags list "${{ inputs.ar_image_path }}" \ + --project="${{ inputs.gcp_project }}" \ + --filter="tag=stable" \ + --format="value(tag)" 2>/dev/null | head -n1) + + if [ -n "$STABLE" ]; then + echo "tag=stable" >> "$GITHUB_OUTPUT" + else + echo "tag=" >> "$GITHUB_OUTPUT" + echo "No stable tag yet — rollback will be skipped if deploy fails." + fi + + - name: Sync compose file + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + gcloud compute scp deploy/docker-compose.prod.yaml \ + ${{ inputs.vm }}:/tmp/docker-compose.prod.yaml \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap + + - name: Deploy and verify + id: deploy_run + if: steps.vm-status.outputs.status == 'RUNNING' + shell: bash + run: | + REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1) + + gcloud compute ssh ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + + sudo mkdir -p ${{ inputs.work_dir }} + sudo mv /tmp/docker-compose.prod.yaml ${{ inputs.work_dir }}/docker-compose.prod.yaml + cd ${{ inputs.work_dir }} + + sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml up -d + + echo 'Checking actuator health (max ${{ inputs.health_retries }} x ${{ inputs.health_interval }}s)...' + HEALTHY=0 + for i in \$(seq 1 ${{ inputs.health_retries }}); do + if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then + echo \"Actuator UP (attempt \$i)\" + HEALTHY=1 + break + fi + echo \"Waiting for service... (\$i/${{ inputs.health_retries }})\" + sleep ${{ inputs.health_interval }} + done + + if [ \"\$HEALTHY\" != '1' ]; then + echo 'Actuator health check failed.' + exit 1 + fi + " + + - name: Rollback + if: failure() && steps.deploy_run.conclusion == 'failure' && steps.rollback.outputs.tag != '' + shell: bash + run: | + set +e + REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1) + + attempt_rollback() { + gcloud compute ssh ${{ inputs.vm }} \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap \ + --command=" + set -e + + echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST + cd ${{ inputs.work_dir }} + + sudo docker compose -f docker-compose.prod.yaml down --remove-orphans -t 5 || true + + sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml pull + sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ + docker compose -f docker-compose.prod.yaml up -d + + for i in \$(seq 1 18); do + if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then + echo \"Rollback container UP (attempt \$i)\" + exit 0 + fi + sleep 10 + done + + echo 'Rollback container did not become healthy.' + exit 1 + " + } + + for attempt in 1 2 3; do + echo "::group::Rollback attempt $attempt/3 on ${{ inputs.vm }}" + attempt_rollback + RC=$? + echo "::endgroup::" + + [ $RC -eq 0 ] && echo "Rollback succeeded on attempt $attempt." && exit 0 + + echo "Rollback attempt $attempt failed (exit=$RC)." + [ $attempt -lt 3 ] && echo "Retrying in 15s..." && sleep 15 + done + + echo "::error::All rollback attempts failed on ${{ inputs.vm }}. Manual intervention required." + exit 1 \ No newline at end of file diff --git a/.github/actions/scale-vm/action.yaml b/.github/actions/scale-vm/action.yaml new file mode 100644 index 0000000..4930045 --- /dev/null +++ b/.github/actions/scale-vm/action.yaml @@ -0,0 +1,88 @@ +name: Scale VM +description: GCP VM 인스턴스를 기동하거나 중지합니다 + +inputs: + direction: + description: "'out' = 기동 | 'in' = 중지" + required: true + targets: + description: "공백 구분 'vm이름:zone' 목록 (예: server-2:asia-northeast3-b server-3:asia-northeast3-c)" + required: true + gcp_project: + description: "GCP 프로젝트 ID" + required: true + +runs: + using: composite + steps: + - name: Validate direction + shell: bash + run: | + if [[ "${{ inputs.direction }}" != "out" && "${{ inputs.direction }}" != "in" ]]; then + echo "::error::direction은 'out' 또는 'in' 이어야 합니다" + exit 1 + fi + + - name: Start / Stop VMs + shell: bash + run: | + DIRECTION="${{ inputs.direction }}" + ACTION=$([ "$DIRECTION" = "out" ] && echo "start" || echo "stop") + WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED") + + for ENTRY in ${{ inputs.targets }}; do + VM="${ENTRY%%:*}" + ZONE="${ENTRY##*:}" + + CURRENT=$(gcloud compute instances describe "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + + if [ "$CURRENT" = "NOT_FOUND" ]; then + echo "::error::$VM ($ZONE) 을 찾을 수 없습니다 — VM을 먼저 생성해 주세요" + exit 1 + fi + + if [ "$CURRENT" = "$WAIT_STATUS" ]; then + echo "✅ $VM is already $CURRENT — skipping" + continue + fi + + echo "▶ ${ACTION}ing $VM ($ZONE)..." + gcloud compute instances "$ACTION" "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" + done + + - name: Wait for target status + shell: bash + run: | + DIRECTION="${{ inputs.direction }}" + WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED") + + for ENTRY in ${{ inputs.targets }}; do + VM="${ENTRY%%:*}" + ZONE="${ENTRY##*:}" + + echo "⏳ Waiting for $VM to be $WAIT_STATUS..." + for attempt in $(seq 1 20); do + CURRENT=$(gcloud compute instances describe "$VM" \ + --zone="$ZONE" \ + --project="${{ inputs.gcp_project }}" \ + --format="value(status)") + + if [ "$CURRENT" = "$WAIT_STATUS" ]; then + echo "✅ $VM is $WAIT_STATUS" + break + fi + + if [ "$attempt" = "20" ]; then + echo "::error::$VM did not reach $WAIT_STATUS in time" + exit 1 + fi + + echo " $VM is $CURRENT... ($attempt/20)" + sleep 15 + done + done \ No newline at end of file diff --git a/.github/workflows/_build.yaml b/.github/workflows/_build.yaml new file mode 100644 index 0000000..7aa49c4 --- /dev/null +++ b/.github/workflows/_build.yaml @@ -0,0 +1,92 @@ +name: _build + +on: + workflow_call: + inputs: + force_apply_retention_policy: + type: boolean + default: false + outputs: + image_tag: + value: ${{ jobs.build-and-push.outputs.image_tag }} + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Resolve image metadata + id: meta + run: | + echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) + echo "registry_host=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" + + - name: Apply AR Image Retention Policy + if: | + contains(toJSON(github.event.commits.*.modified), 'deploy/ar-image-retention-policy.json') || + contains(toJSON(github.event.commits.*.added), 'deploy/ar-image-retention-policy.json') || + inputs.force_apply_retention_policy + run: | + REPO_NAME=$(echo "$AR_IMAGE_PATH" | cut -d/ -f3) + POLICY_FILE="deploy/ar-image-retention-policy.json" + + if [ ! -f "$POLICY_FILE" ]; then + echo "Error: $POLICY_FILE not found" + exit 1 + fi + + REGION=$(echo "$AR_IMAGE_PATH" | sed 's/-docker\.pkg\.dev.*//') + gcloud artifacts repositories set-cleanup-policies "$REPO_NAME" \ + --project="$GCP_PROJECT" \ + --location="${REGION}" \ + --policy="$POLICY_FILE" \ + --quiet + echo "Successfully synced retention policy from $POLICY_FILE" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Artifact Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.registry_host }} + username: 'oauth2accesstoken' + password: ${{ steps.auth.outputs.access_token }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }} + ${{ env.AR_IMAGE_PATH }}:latest + secrets: | + GPR_USER=${{ secrets.GPR_USER }} + GPR_TOKEN=${{ secrets.GPR_TOKEN }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/_deploy.yaml b/.github/workflows/_deploy.yaml new file mode 100644 index 0000000..1bbbac2 --- /dev/null +++ b/.github/workflows/_deploy.yaml @@ -0,0 +1,62 @@ +name: _deploy + +on: + workflow_call: + inputs: + image_tag: + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + WORK_DIR: /opt/gateway + HEALTH_RETRIES: 30 + HEALTH_INTERVAL: 10 + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production + permissions: + contents: read + id-token: write + strategy: + fail-fast: true + max-parallel: 1 + matrix: + include: + - vm: gateway-server-1 + zone: asia-northeast3-a + - vm: gateway-server-2 + zone: asia-northeast3-b + - vm: gateway-server-3 + zone: asia-northeast3-c + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: 'access_token' + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Deploy to ${{ matrix.vm }} + uses: ./.github/actions/deploy-vm + with: + vm: ${{ matrix.vm }} + zone: ${{ matrix.zone }} + gcp_project: ${{ env.GCP_PROJECT }} + ar_image_path: ${{ env.AR_IMAGE_PATH }} + image_tag: ${{ inputs.image_tag }} + ar_token: ${{ steps.auth.outputs.access_token }} + work_dir: ${{ env.WORK_DIR }} + health_retries: ${{ env.HEALTH_RETRIES }} + health_interval: ${{ env.HEALTH_INTERVAL }} \ No newline at end of file diff --git a/.github/workflows/_promote.yaml b/.github/workflows/_promote.yaml new file mode 100644 index 0000000..ce771ff --- /dev/null +++ b/.github/workflows/_promote.yaml @@ -0,0 +1,40 @@ +name: _promote + +on: + workflow_call: + inputs: + image_tag: + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} + +jobs: + promote-stable: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: production + permissions: + contents: read + id-token: write + steps: + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Promote to :stable tag + run: | + echo "Promoting ${AR_IMAGE_PATH}:${{ inputs.image_tag }} -> ${AR_IMAGE_PATH}:stable" + gcloud artifacts docker tags add \ + "${AR_IMAGE_PATH}:${{ inputs.image_tag }}" \ + "${AR_IMAGE_PATH}:stable" \ + --quiet + echo "Stable tag updated." \ No newline at end of file diff --git a/.github/workflows/_scale.yaml b/.github/workflows/_scale.yaml new file mode 100644 index 0000000..90252ec --- /dev/null +++ b/.github/workflows/_scale.yaml @@ -0,0 +1,42 @@ +name: _scale + +on: + workflow_call: + inputs: + direction: + description: "'out' = 2, 3번 서버 기동 | 'in' = 2, 3번 서버 중지" + type: string + required: true + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + # VM 이름:존 목록 — 서버 추가 시 여기만 수정 + SCALE_TARGETS: "gateway-server-2:asia-northeast3-b gateway-server-3:asia-northeast3-c" + +jobs: + scale: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + # scale-vm action을 불러오기 위해 추가 + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Scale ${{ inputs.direction }} + uses: ./.github/actions/scale-vm + with: + direction: ${{ inputs.direction }} + targets: ${{ env.SCALE_TARGETS }} + gcp_project: ${{ env.GCP_PROJECT }} \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 23db806..01499bd 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -5,292 +5,93 @@ on: branches: [ main, dev ] workflow_dispatch: inputs: + action: + description: "실행할 작업" + type: choice + default: deploy-only + options: + - scale-out-and-deploy + - deploy-only + - scale-in force_apply_retention_policy: description: "AR retention policy 적용" type: boolean default: false -env: - GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} - AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} - WORK_DIR: /opt/gateway - HEALTH_RETRIES: 30 - HEALTH_INTERVAL: 10 - jobs: build-and-push: - runs-on: ubuntu-latest - timeout-minutes: 20 + if: github.event.inputs.action != 'scale-in' permissions: contents: read id-token: write - outputs: - image_tag: ${{ steps.meta.outputs.tag }} - steps: - - uses: actions/checkout@v4 - - - name: Authenticate to GCP - id: auth - uses: google-github-actions/auth@v2 - with: - token_format: 'access_token' - workload_identity_provider: ${{ secrets.WIF_PROVIDER }} - service_account: ${{ secrets.GCP_SA_EMAIL }} - project_id: ${{ vars.GCP_PROJECT_ID }} - - - name: Set up gcloud - uses: google-github-actions/setup-gcloud@v2 - - - name: Resolve image metadata - id: meta - run: | - echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) - echo "registry_host=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" - - - name: Apply AR Image Retention Policy - if: | - contains(toJSON(github.event.commits.*.modified), 'deploy/ar-image-retention-policy.json') || - contains(toJSON(github.event.commits.*.added), 'deploy/ar-image-retention-policy.json') || - (github.event_name == 'workflow_dispatch' && inputs.force_apply_retention_policy) - run: | - REPO_NAME=$(echo "$AR_IMAGE_PATH" | cut -d/ -f3) - POLICY_FILE="deploy/ar-image-retention-policy.json" - - if [ ! -f "$POLICY_FILE" ]; then - echo "Error: $POLICY_FILE not found" - exit 1 - fi + uses: ./.github/workflows/_build.yaml + with: + force_apply_retention_policy: ${{ inputs.force_apply_retention_policy || false }} + secrets: inherit - REGION=$(echo "$AR_IMAGE_PATH" | sed 's/-docker\.pkg\.dev.*//') - gcloud artifacts repositories set-cleanup-policies "$REPO_NAME" \ - --project="$GCP_PROJECT" \ - --location="${REGION}" \ - --policy="$POLICY_FILE" \ - --quiet - echo "Successfully synced retention policy from $POLICY_FILE" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Artifact Registry - uses: docker/login-action@v3 - with: - registry: ${{ steps.meta.outputs.registry_host }} - username: 'oauth2accesstoken' - password: ${{ steps.auth.outputs.access_token }} - - - name: Build and push image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - ${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }} - ${{ env.AR_IMAGE_PATH }}:latest - secrets: | - GPR_USER=${{ secrets.GPR_USER }} - GPR_TOKEN=${{ secrets.GPR_TOKEN }} - cache-from: type=gha - cache-to: type=gha,mode=max + scale-out: + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.action == 'scale-out-and-deploy' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: out + secrets: inherit deploy: - needs: build-and-push - if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: production + needs: [ build-and-push, scale-out ] + if: | + always() && + needs.build-and-push.result == 'success' && + (needs.scale-out.result == 'success' || needs.scale-out.result == 'skipped') && + github.event.inputs.action != 'scale-in' && + (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/_deploy.yaml permissions: contents: read id-token: write - strategy: - fail-fast: true - max-parallel: 1 - matrix: - include: - - vm: gateway-server-1 - zone: asia-northeast3-a - # 나중에 3대로 늘릴 때 아래 주석 해제 - # - vm: gateway-server-2 - # zone: asia-northeast3-b - # - vm: gateway-server-3 - # zone: asia-northeast3-c - steps: - - uses: actions/checkout@v4 - - - name: Authenticate to GCP - id: auth - uses: google-github-actions/auth@v2 - with: - token_format: 'access_token' - workload_identity_provider: ${{ secrets.WIF_PROVIDER }} - service_account: ${{ secrets.GCP_SA_EMAIL }} - project_id: ${{ vars.GCP_PROJECT_ID }} - - - name: Set up gcloud - uses: google-github-actions/setup-gcloud@v2 - - - name: Resolve rollback target - id: rollback - run: | - STABLE=$(gcloud artifacts docker tags list "$AR_IMAGE_PATH" \ - --project="$GCP_PROJECT" \ - --filter="tag=stable" \ - --format="value(tag)" 2>/dev/null | head -n1) - - if [ -n "$STABLE" ]; then - echo "tag=stable" >> "$GITHUB_OUTPUT" - else - echo "tag=" >> "$GITHUB_OUTPUT" - echo "No stable tag yet — first deploy. Rollback will be skipped." - fi - - - name: Sync compose file to ${{ matrix.vm }} - run: | - gcloud compute scp deploy/docker-compose.prod.yaml \ - ${{ matrix.vm }}:/tmp/docker-compose.prod.yaml \ - --zone="${{ matrix.zone }}" \ - --project="$GCP_PROJECT" \ - --tunnel-through-iap - - - name: Deploy and verify on ${{ matrix.vm }} - id: deploy_run - env: - NEW_TAG: ${{ needs.build-and-push.outputs.image_tag }} - AR_TOKEN: ${{ steps.auth.outputs.access_token }} - run: | - REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) - - gcloud compute ssh ${{ matrix.vm }} \ - --zone="${{ matrix.zone }}" \ - --project="$GCP_PROJECT" \ - --tunnel-through-iap \ - --command=" - set -e - - echo '$AR_TOKEN' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST - - sudo mkdir -p $WORK_DIR - sudo mv /tmp/docker-compose.prod.yaml $WORK_DIR/docker-compose.prod.yaml - cd $WORK_DIR - - export IMAGE_TAG='$NEW_TAG' - export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' + with: + image_tag: ${{ needs.build-and-push.outputs.image_tag }} + secrets: inherit - sudo env IMAGE_TAG="$NEW_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml pull - sudo env IMAGE_TAG="$NEW_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml up -d - - echo 'Checking actuator health (max $HEALTH_RETRIES x ${HEALTH_INTERVAL}s)...' - HEALTHY=0 - for i in \$(seq 1 $HEALTH_RETRIES); do - if curl -sf http://localhost:8090/actuator/health \ - | grep -q '\"status\":\"UP\"'; then - echo \"Actuator UP (attempt \$i)\" - HEALTHY=1 - break - fi - echo \"Waiting for service... (\$i/$HEALTH_RETRIES)\" - sleep $HEALTH_INTERVAL - done - - if [ \"\$HEALTHY\" != '1' ]; then - echo 'Actuator health check failed.' - exit 1 - fi - " - - - name: Rollback ${{ matrix.vm }} - if: failure() && steps.deploy_run.conclusion == 'failure' && steps.rollback.outputs.tag != '' - env: - ROLLBACK_TAG: ${{ steps.rollback.outputs.tag }} - AR_TOKEN: ${{ steps.auth.outputs.access_token }} - run: | - set +e - REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1) - - attempt_rollback() { - gcloud compute ssh ${{ matrix.vm }} \ - --zone="${{ matrix.zone }}" \ - --project="$GCP_PROJECT" \ - --tunnel-through-iap \ - --command=" - set -e - - echo '$AR_TOKEN' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST - - cd $WORK_DIR - - sudo docker compose -f docker-compose.prod.yaml down --remove-orphans -t 5 || true - - export IMAGE_TAG='$ROLLBACK_TAG' - export AR_IMAGE_PATH='${{ env.AR_IMAGE_PATH }}' - - sudo env IMAGE_TAG="$ROLLBACK_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml pull - sudo env IMAGE_TAG="$ROLLBACK_TAG" AR_IMAGE_PATH="${{ env.AR_IMAGE_PATH }}" docker compose -f docker-compose.prod.yaml up -d - - for i in \$(seq 1 18); do - if curl -sf http://localhost:8090/actuator/health \ - | grep -q '\"status\":\"UP\"'; then - echo \"Rollback container UP (attempt \$i)\" - exit 0 - fi - sleep 10 - done - - echo 'Rollback container did not become healthy.' - exit 1 - " - } - - for attempt in 1 2 3; do - echo "::group::Rollback attempt $attempt/3 on ${{ matrix.vm }}" - attempt_rollback - RC=$? - echo "::endgroup::" - - if [ $RC -eq 0 ]; then - echo "Rollback succeeded on attempt $attempt." - exit 0 - fi - - echo "Rollback attempt $attempt failed (exit=$RC)." - if [ $attempt -lt 3 ]; then - echo "Retrying in 15 seconds..." - sleep 15 - fi - done + scale-in: + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.action == 'scale-in' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: in + secrets: inherit - echo "::error::All rollback attempts failed on ${{ matrix.vm }}. Manual intervention required." - exit 1 + scale-in-on-failure: + needs: [ scale-out, deploy ] + if: | + always() && + needs.scale-out.result == 'success' && + needs.deploy.result != 'success' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_scale.yaml + with: + direction: in + secrets: inherit promote-stable: needs: [ build-and-push, deploy ] if: | (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.deploy.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - environment: production permissions: contents: read id-token: write - steps: - - name: Authenticate to GCP - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.WIF_PROVIDER }} - service_account: ${{ secrets.GCP_SA_EMAIL }} - project_id: ${{ vars.GCP_PROJECT_ID }} - - - name: Set up gcloud - uses: google-github-actions/setup-gcloud@v2 - - - name: Promote new SHA to :stable tag - run: | - NEW_TAG="${{ needs.build-and-push.outputs.image_tag }}" - echo "Promoting ${AR_IMAGE_PATH}:${NEW_TAG} -> ${AR_IMAGE_PATH}:stable" - gcloud artifacts docker tags add \ - "${AR_IMAGE_PATH}:${NEW_TAG}" \ - "${AR_IMAGE_PATH}:stable" \ - --quiet - echo "Stable tag updated." \ No newline at end of file + uses: ./.github/workflows/_promote.yaml + with: + image_tag: ${{ needs.build-and-push.outputs.image_tag }} + secrets: inherit \ No newline at end of file From 0f43e5e4553ba63ae3a738e751aa9d4a87e49990 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Tue, 12 May 2026 16:25:50 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[TASK]=20nginx=20CI/CD=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : nginx CI/CD 워크플로우 추가 - nginx 설정 변경용 워크플로우 추가 * fix : 코드래빗 수정사항 반영 - 수정된 nginx.conf 검증한 후 기존 파일을 업데이트하도록 수정 --- .github/workflows/deploy-nginx.yaml | 98 +++++++++++++++++++++++++++++ deploy/nginx/nginx.conf | 20 ++++++ 2 files changed, 118 insertions(+) create mode 100644 .github/workflows/deploy-nginx.yaml create mode 100644 deploy/nginx/nginx.conf diff --git a/.github/workflows/deploy-nginx.yaml b/.github/workflows/deploy-nginx.yaml new file mode 100644 index 0000000..1c1118c --- /dev/null +++ b/.github/workflows/deploy-nginx.yaml @@ -0,0 +1,98 @@ +name: Deploy Nginx + +on: + push: + branches: [ main, dev ] + paths: + - 'deploy/nginx/nginx.conf' # nginx.conf 변경 시에만 실행 + workflow_dispatch: # 수동 실행 (긴급 시) + +env: + GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }} + NGINX_VM: nginx-server + NGINX_ZONE: asia-northeast3-a + +jobs: + deploy-nginx: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SA_EMAIL }} + project_id: ${{ vars.GCP_PROJECT_ID }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Check Nginx VM status + id: vm-status + run: | + STATUS=$(gcloud compute instances describe "$NGINX_VM" \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --format="value(status)" 2>/dev/null || echo "NOT_FOUND") + echo "status=$STATUS" >> "$GITHUB_OUTPUT" + echo "$NGINX_VM → $STATUS" + + if [ "$STATUS" = "NOT_FOUND" ]; then + echo "::error::$NGINX_VM not found — VM을 먼저 생성해 주세요" + exit 1 + fi + + if [ "$STATUS" != "RUNNING" ]; then + echo "::error::$NGINX_VM is $STATUS — RUNNING 상태여야 합니다" + exit 1 + fi + + - name: Sync nginx.conf to VM + run: | + gcloud compute scp deploy/nginx/nginx.conf \ + $NGINX_VM:/tmp/nginx.conf \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap + + - name: Apply nginx.conf and reload + run: | + gcloud compute ssh $NGINX_VM \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + set -e + + sudo nginx -t -c /tmp/nginx.conf + if [ \$? -ne 0 ]; then + echo '❌ nginx.conf validation failed — gateway.conf is unchanged' + exit 1 + fi + + sudo cp /tmp/nginx.conf /etc/nginx/conf.d/gateway.conf + + sudo nginx -s reload + echo '✅ Nginx reloaded successfully' + " + + - name: Verify Nginx is running + run: | + gcloud compute ssh $NGINX_VM \ + --zone="$NGINX_ZONE" \ + --project="$GCP_PROJECT" \ + --tunnel-through-iap \ + --command=" + STATUS=\$(sudo systemctl is-active nginx) + if [ \"\$STATUS\" = 'active' ]; then + echo '✅ Nginx is active' + else + echo '::error::Nginx is not active — status: '\$STATUS + exit 1 + fi + " \ No newline at end of file diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..7307f6c --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,20 @@ +upstream gateway { + least_conn; + server 10.0.0.10:8090 max_fails=3 fail_timeout=10s; # gateway-server-1 + server 10.0.0.20:8090 max_fails=3 fail_timeout=10s; # gateway-server-2 + server 10.0.0.30:8090 max_fails=3 fail_timeout=10s; # gateway-server-3 +} + +server { + listen 80; + + # 게이트웨이 관련 리버스 프록시 설정 추가 + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_pass http://gateway; + } +} \ No newline at end of file From a49db247fca21d34645bafbb1901e38f6df70005 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Wed, 13 May 2026 01:20:21 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[TASK]=20nginx=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : nginx.conf 변경사항 검증 로직 수정 - /tmp/nginx.conf의 변경사항에 관한 검증 로직을 수행할 수 없는 문제 수정 * chore : 게이트웨이 application.yaml 파일 수정 - nginx 연동 테스트용 actuator 설정 추가 * chore : 게이트웨이 application.yaml 파일 수정 - info 설정 추가 * chore : eureka server 연동 관련 설정 추가 - config server 구동 여부에 관계없이 유레카 서버와 연동 가능하도록 추가 * chore : eureka server 연동 관련 설정 추가 - config server 구동 여부에 관계없이 유레카 서버와 연동 가능하도록 추가 * fix : 코드래빗 수정사항 반영 - nginx 설정 변경사항 검증 관련 문제 수정 * fix : nginx 빌드 오류 수정 - 큰따옴표 이스케이프 적용 * fix : nginx 빌드 오류 수정 - 이전 검증 방식으로 원상 복구 * fix : nginx 빌드 오류 수정 - .conf 파일 검증 방식 수정 * chore : application.yaml 파일 설정 수정 - info 블록의 HOSTNAME 기본값 지정 * chore : 로드밸런싱 테스트용 nginx.conf 설정 추가 - application.yaml 파일의 info 블록 삭제 * fix : 코드래빗 피드백 반영 - nginx.conf 변경사항 적용 성공 시에도 백업본을 저장하도록 수정 * fix : nginx 배포 오류 수정 - nginx 배포 워크플로우 수정 후 발생한 오류 수정 * fix : nginx 배포 오류 수정 - nginx 배포 워크플로우 수정 후 발생한 오류 수정 * fix : nginx 로드밸런싱 테스트 관련 누락된 설정 추가 - nginx.conf 파일에 add_header 추가 * chore : 게이트웨이 로드밸런싱 테스트용 nginx 로그 설정 삭제 - 로드밸런싱 테스트 완료로 인해 nginx.conf 내 nginx 로그 관련 설정 삭제 --- .env.example | 4 +++- .github/workflows/deploy-nginx.yaml | 29 +++++++++++++++++++++++------ src/main/resources/application.yaml | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index aa5f7cb..120c9de 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,6 @@ HOSTNAME=localhost # 아래의 환경변수들은 .env 파일에만 포함하여 빌드 시에만 사용되며, .env.runtime에서는 생략됨 # 배포 환경에서도 공통 모듈을 적용하기 위해 Dockerfile에 추가해야 할 환경변수 GPR_USER=GitHub_ID -GPR_TOKEN=GitHub_Personal_Access_Token(PAT) \ No newline at end of file +GPR_TOKEN=GitHub_Personal_Access_Token(PAT) + +EUREKA_SERVER_URL=http://localhost:8761/eureka/ \ No newline at end of file diff --git a/.github/workflows/deploy-nginx.yaml b/.github/workflows/deploy-nginx.yaml index 1c1118c..980ba93 100644 --- a/.github/workflows/deploy-nginx.yaml +++ b/.github/workflows/deploy-nginx.yaml @@ -60,6 +60,7 @@ jobs: --project="$GCP_PROJECT" \ --tunnel-through-iap + # 기존 conf를 bak으로 rename → sites-enabled에서 검증 → conf.d에 반영 → reload 실패 시 복구 → 성공 시 bak 제거 - name: Apply nginx.conf and reload run: | gcloud compute ssh $NGINX_VM \ @@ -68,16 +69,32 @@ jobs: --tunnel-through-iap \ --command=" set -e - - sudo nginx -t -c /tmp/nginx.conf - if [ \$? -ne 0 ]; then + + if [ -f /etc/nginx/conf.d/gateway.conf ]; then + sudo mv /etc/nginx/conf.d/gateway.conf /etc/nginx/conf.d/gateway.conf.bak + fi + + sudo cp /tmp/nginx.conf /etc/nginx/sites-enabled/gateway.tmp.conf + + if ! sudo nginx -t; then echo '❌ nginx.conf validation failed — gateway.conf is unchanged' + sudo rm /etc/nginx/sites-enabled/gateway.tmp.conf + [ -f /etc/nginx/conf.d/gateway.conf.bak ] && sudo mv /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf exit 1 fi - + + sudo rm /etc/nginx/sites-enabled/gateway.tmp.conf + sudo mv /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf 2>/dev/null || true sudo cp /tmp/nginx.conf /etc/nginx/conf.d/gateway.conf - - sudo nginx -s reload + + if ! sudo nginx -s reload; then + [ -f /etc/nginx/conf.d/gateway.conf.bak ] && sudo cp /etc/nginx/conf.d/gateway.conf.bak /etc/nginx/conf.d/gateway.conf + sudo nginx -s reload || true + echo '❌ Nginx reload failed — rolled back to previous gateway.conf' + exit 1 + fi + + sudo rm -f /etc/nginx/conf.d/gateway.conf.bak echo '✅ Nginx reloaded successfully' " diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 75ffa90..e99918b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,5 +13,21 @@ spring: discovery: service-id: config-server +eureka: + instance: + prefer-ip-address: true + hostname: ${HOSTNAME:localhost} + client: + register-with-eureka: true + fetch-registry: true + service-url: + defaultZone: ${EUREKA_SERVER_URL:http://localhost:8761/eureka/} + +management: + endpoints: + web: + exposure: + include: health, info + server: port: 8090 \ No newline at end of file From 50a7c089941bbe8a5b842bb285bd646b3ad31af7 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Wed, 13 May 2026 15:19:07 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[TASK]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20application.yaml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 게이트웨이 application.yaml 파일 설정 수정 - config server 연동 방식 수정 * chore : 게이트웨이 application.yaml 파일 설정 수정 - config server 설정 관련 오탈자 수정 --- src/main/resources/application.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e99918b..44f4286 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,7 +3,7 @@ spring: name: gateway-server config: import: - - "optional:configserver:http://34.50.50.170:13100" + - "optional:configserver:" - "optional:file:.env[.properties]" cloud: config: @@ -12,6 +12,7 @@ spring: override-system-properties: false discovery: service-id: config-server + enabled: true eureka: instance: @@ -23,11 +24,5 @@ eureka: service-url: defaultZone: ${EUREKA_SERVER_URL:http://localhost:8761/eureka/} -management: - endpoints: - web: - exposure: - include: health, info - server: port: 8090 \ No newline at end of file From e6a84dd2653c2f2c41b70906c8fac0b69339690d Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Thu, 14 May 2026 10:31:44 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[TASK]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20application.yaml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 게이트웨이 application.yaml 파일 설정 수정 - 유레카 서버에 게이트웨이의 주소를 등록하는 방식 수정 * chore : 게이트웨이 헬스체크 간격 및 횟수 조정 - 게이트웨이 빌드 시간을 단축하고 실패 시 빠른 피드백을 받기 위해 조정 --- .github/workflows/_deploy.yaml | 2 +- src/main/resources/application.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_deploy.yaml b/.github/workflows/_deploy.yaml index 1bbbac2..c286366 100644 --- a/.github/workflows/_deploy.yaml +++ b/.github/workflows/_deploy.yaml @@ -12,7 +12,7 @@ env: AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }} WORK_DIR: /opt/gateway HEALTH_RETRIES: 30 - HEALTH_INTERVAL: 10 + HEALTH_INTERVAL: 5 jobs: deploy: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 44f4286..70d5cc6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -17,7 +17,9 @@ spring: eureka: instance: prefer-ip-address: true + ip-address: ${HOSTNAME:localhost} hostname: ${HOSTNAME:localhost} + instance-id: "${HOSTNAME:${spring.application.name}}:${spring.application.name}:${server.port}" client: register-with-eureka: true fetch-registry: true From d56839992ccda5583d2939eadb347e6c0655dcf5 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Thu, 14 May 2026 20:42:33 +0900 Subject: [PATCH 11/15] =?UTF-8?q?[FIX]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : 게이트웨이 화이트리스트 수정 - 모니터링 관련 api를 게이트웨이 화이트리스트에 추가 * chore : 게이트웨이 공통모듈 버전 수정 - build.gradle의 공통모듈 버전을 0.3.2-SNAPSHOT으로 업그레이드 * chore : 분산 추적 & 로그 수집 관련 설정 추가 - zipkin, loki 연동 관련 환경변수 추가 - application.yaml 파일에 zipkin 관련 설정 추가 - deploy/docker-compose.prod.yaml 파일 내 promtail 관련 설정 추가 - deploy/promtail-config.yml 파일 추가 - deploy/promtail-config.yml 파일이 원격 서버에 배포되도록 배포 워크플로우 수정 * chore : 배포 중 발생한 도커 네트워크 관련 오류 수정 - 도커 이미지 기반으로 docker compose 명령어 실행 직전 도커 네트워크부터 먼저 탐색하도록 수정 * chore : docker-compose.prod.yaml 수정 - promtail 컨테이너명 수정 * chore : 코드래빗 피드백 반영 - 게이트웨이 내부 로그파일 생성 및 저장 경로 지정 - 게이트웨이 로그파일 생성 및 관리 관련 설정은 configs의 gateway-server/application.yml에 반영 * chore : 코드래빗 피드백 반영 - promtail-config.yml에 환경변수 값을 적용하기 위한 설정 추가 - 게이트웨이 화이트리스트에서 /actuator/refresh 삭제 --- .env.example | 3 ++- .github/actions/deploy-vm/action.yaml | 16 +++++++++++-- build.gradle | 2 +- deploy/.env.template | 5 +++- deploy/docker-compose.prod.yaml | 24 ++++++++++++++++++- deploy/promtail-config.yml | 14 +++++++++++ .../pgsg/gateway/filter/JwtGatewayFilter.java | 6 ++++- src/main/resources/application.yaml | 10 +++++++- 8 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 deploy/promtail-config.yml diff --git a/.env.example b/.env.example index 120c9de..70971de 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,5 @@ HOSTNAME=localhost GPR_USER=GitHub_ID GPR_TOKEN=GitHub_Personal_Access_Token(PAT) -EUREKA_SERVER_URL=http://localhost:8761/eureka/ \ No newline at end of file +EUREKA_SERVER_URL=http://localhost:8761/eureka/ +ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans \ No newline at end of file diff --git a/.github/actions/deploy-vm/action.yaml b/.github/actions/deploy-vm/action.yaml index a51a749..a4ce2f3 100644 --- a/.github/actions/deploy-vm/action.yaml +++ b/.github/actions/deploy-vm/action.yaml @@ -75,6 +75,12 @@ runs: --zone="${{ inputs.zone }}" \ --project="${{ inputs.gcp_project }}" \ --tunnel-through-iap + + gcloud compute scp deploy/promtail-config.yml \ + ${{ inputs.vm }}:/tmp/promtail-config.yml \ + --zone="${{ inputs.zone }}" \ + --project="${{ inputs.gcp_project }}" \ + --tunnel-through-iap - name: Deploy and verify id: deploy_run @@ -94,7 +100,13 @@ runs: sudo mkdir -p ${{ inputs.work_dir }} sudo mv /tmp/docker-compose.prod.yaml ${{ inputs.work_dir }}/docker-compose.prod.yaml + sudo mv /tmp/promtail-config.yml ${{ inputs.work_dir }}/promtail-config.yml cd ${{ inputs.work_dir }} + + if ! sudo docker network inspect pgsg-network > /dev/null 2>&1; then + echo 'pgsg-network not found. Creating...' + sudo docker network create pgsg-network + fi sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ docker compose -f docker-compose.prod.yaml pull @@ -144,12 +156,12 @@ runs: sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \ docker compose -f docker-compose.prod.yaml up -d - for i in \$(seq 1 18); do + for i in \$(seq 1 ${{ inputs.health_retries }}); do if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then echo \"Rollback container UP (attempt \$i)\" exit 0 fi - sleep 10 + sleep ${{ inputs.health_interval }} done echo 'Rollback container did not become healthy.' diff --git a/build.gradle b/build.gradle index dff3a95..32b1bba 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ ext { dependencies { - implementation('org.pgsg:common:0.2.5-SNAPSHOT') { + implementation('org.pgsg:common:0.3.2-SNAPSHOT') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'com.querydsl', module: 'querydsl-jpa' } diff --git a/deploy/.env.template b/deploy/.env.template index def43c2..54e2f43 100644 --- a/deploy/.env.template +++ b/deploy/.env.template @@ -9,4 +9,7 @@ JWT_REFRESH_EXPIRATION= HOSTNAME= # Eureka 서버 주소 -EUREKA_CLIENT_SERVICEURL_DEFAULTZONE= \ No newline at end of file +EUREKA_SERVER_URL= + +LOKI_URL= +ZIPKIN_ENDPOINT= \ No newline at end of file diff --git a/deploy/docker-compose.prod.yaml b/deploy/docker-compose.prod.yaml index cb2f765..1b7ba93 100644 --- a/deploy/docker-compose.prod.yaml +++ b/deploy/docker-compose.prod.yaml @@ -6,4 +6,26 @@ services: ports: - "8090:8090" env_file: - - .env \ No newline at end of file + - .env + volumes: + - /opt/gateway/logs:/logs # 게이트웨이에서 생성한 로그 파일을 저장할 경로 + networks: + - pgsg-network + + promtail: + image: grafana/promtail:2.9.1 + container_name: promtail + restart: always + volumes: + - /opt/gateway/promtail-config.yml:/etc/promtail/config.yml + - /opt/gateway/logs:/logs # 로그 파일을 저장한 경로와 동일하게 지정 + # promtail 2.9.1 버전은 명시적으로 환경변수 플래그를 지정해야 promtail-config.yml에 설정한 환경변수값 적용 가능 + command: -config.file=/etc/promtail/config.yml -config.expand-env=true + env_file: + - .env + networks: + - pgsg-network + +networks: + pgsg-network: + external: true \ No newline at end of file diff --git a/deploy/promtail-config.yml b/deploy/promtail-config.yml new file mode 100644 index 0000000..05284bc --- /dev/null +++ b/deploy/promtail-config.yml @@ -0,0 +1,14 @@ +server: + http_listen_port: 9080 + +clients: + - url: ${LOKI_URL:-http://loki:3100/loki/api/v1/push} + +scrape_configs: + - job_name: gateway-server + static_configs: + - targets: + - localhost + labels: + job: gateway-server + __path__: /logs/*.log \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index b01b641..ab8fc30 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -44,7 +44,11 @@ public class JwtGatewayFilter extends OncePerRequestFilter { "/api/v1/auth/reissue", "/actuator/health", "/actuator/health/**", - "/actuator/info" + "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", + "/actuator/metrics", + "/actuator/metrics/**" ); private final Tracer tracer; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 70d5cc6..ec75bb3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -27,4 +27,12 @@ eureka: defaultZone: ${EUREKA_SERVER_URL:http://localhost:8761/eureka/} server: - port: 8090 \ No newline at end of file + port: 8090 + +management: + tracing: + sampling: + probability: 0.1 + zipkin: + tracing: + endpoint: ${ZIPKIN_ENDPOINT:http://localhost:9411/api/v2/spans} From e696ff1e4f2795bba8c6327ed0af2e85a3245e70 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Sun, 17 May 2026 10:19:52 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[TASK]=201=EC=B0=A8=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : k6 부하테스트 스크립트 추가 - 게이트웨이 부하테스트 진행용 스크립트 추가 - 2차 부하테스트 진행용 스크립트 추가 - 1차 부하테스트 결과 스크립트 추가 * chore : 코드래빗 피드백 반영 - 결과 스크립트에 사용했던 access token이 저장되지 않도록 수정 * chore : 코드래빗 피드백 반영 - 테스트항목 1 부하테스트 스크립트 관련 수정사항 반영 * chore : 코드래빗 피드백 반영 - 2차 부하테스트 진행용 k6 스크립트 관련 수정사항 반영 - test2-max-users.js 테스트 스크립트를 test2-max-users-revised.js로 대체한다는 주석 추가 --- .gitignore | 3 + script/result/result-test1-baseline.json | 250 ++++++++++++++++++++++ script/result/result-test2-max-users.json | 250 ++++++++++++++++++++++ script/test1-baseline.js | 111 ++++++++++ script/test2-max-users-revised.js | 199 +++++++++++++++++ script/test2-max-users.js | 79 +++++++ 6 files changed, 892 insertions(+) create mode 100644 script/result/result-test1-baseline.json create mode 100644 script/result/result-test2-max-users.json create mode 100644 script/test1-baseline.js create mode 100644 script/test2-max-users-revised.js create mode 100644 script/test2-max-users.js diff --git a/.gitignore b/.gitignore index e258e19..3a96a62 100644 --- a/.gitignore +++ b/.gitignore @@ -224,4 +224,7 @@ gradle-app.setting .env .env.runtime +# csv file data +**/csv/*.csv + # End of https://www.toptal.com/developers/gitignore/api/macos,windows,java,gradle,intellij+all,visualstudiocode \ No newline at end of file diff --git a/script/result/result-test1-baseline.json b/script/result/result-test1-baseline.json new file mode 100644 index 0000000..2a7365a --- /dev/null +++ b/script/result/result-test1-baseline.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 29556, + "fails": 0, + "name": "status 200" + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 29524, + "fails": 32 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdErrTTY": true, + "testRunDurationMs": 126106.9503, + "isStdOutTTY": true + }, + "metrics": { + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 28871223, + "rate": 228942.36147426683 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.09858671623794212, + "min": 0, + "med": 0, + "max": 224.2047 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.007481795953912109, + "min": 0, + "med": 0, + "max": 8.9269, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 760.6497966505872, + "min": 10.5916, + "med": 417.75365, + "max": 3254.0217, + "p(90)": 2003.5394000000001, + "p(95)": 2261.3128 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9994586547570713, + "passes": 59080, + "fails": 32 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "min": 0, + "max": 300, + "value": 3 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "med": 419.56275, + "max": 3254.0217, + "p(90)": 2004.4157500000001, + "p(95)": 2262.459375, + "avg": 767.1553657971283, + "min": 10.5916 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "med": 417.75365, + "max": 3254.0217, + "p(90)": 2003.5394000000001, + "p(95)": 2261.3128, + "avg": 760.6497966505872, + "min": 10.5916 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.10065674571275464, + "min": 0, + "med": 0, + "max": 224.2047, + "p(90)": 0, + "p(95)": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 300, + "min": 300, + "max": 300 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 760.4196670752933, + "min": 9.6234, + "med": 417.56685000000004, + "max": 3254.0217, + "p(90)": 2003.1511, + "p(95)": 2261.1126999999997 + } + }, + "http_req_failed": { + "values": { + "rate": 0, + "passes": 0, + "fails": 29856 + }, + "type": "rate", + "contains": "default" + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "rate": 234.37249041141868, + "count": 29556 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.2226477793408362, + "min": 0, + "med": 0.033549999999999996, + "max": 1.6596, + "p(90)": 0.74945, + "p(95)": 0.8939 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 29856, + "rate": 236.7514235256231 + } + }, + "error_rate": { + "contains": "default", + "values": { + "rate": 0.0010826904858573555, + "passes": 32, + "fails": 29524 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 2262.5646, + "avg": 767.3446495906079, + "min": 10.5916, + "med": 419.5927, + "max": 3254.5665, + "p(90)": 2004.5055 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 13370747, + "rate": 106027.04266649767 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users.json b/script/result/result-test2-max-users.json new file mode 100644 index 0000000..9559f4b --- /dev/null +++ b/script/result/result-test2-max-users.json @@ -0,0 +1,250 @@ +{ + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 500838.9432 + }, + "metrics": { + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 116813, + "rate": 0 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 4459.4034, + "p(90)": 2587.244400000001, + "p(95)": 2983.4811599999994, + "avg": 1035.0400295583534, + "min": 9.2913, + "med": 722.7379 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 116696283, + "rate": 233001.61575774205 + } + }, + "data_sent": { + "values": { + "count": 52448771, + "rate": 104721.83066454485 + }, + "type": "counter", + "contains": "data" + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 25, + "min": 1, + "max": 500 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.21488554612928348, + "min": 0, + "med": 0.0228, + "max": 6.9092, + "p(90)": 0.7384800000000004, + "p(95)": 0.889 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496, + "avg": 1035.2617010221381 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 116813, + "rate": 233.2346587380947 + } + }, + "vus_max": { + "values": { + "value": 500, + "min": 500, + "max": 500 + }, + "type": "gauge", + "contains": "default" + }, + "http_req_tls_handshaking": { + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + }, + "type": "trend", + "contains": "time" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9785510050337294, + "passes": 228613, + "fails": 5011 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08289572735911248, + "min": 0 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "max": 13.0249, + "p(90)": 0, + "p(95)": 0, + "avg": 0.006785917663273801, + "min": 0, + "med": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08072584044584098 + } + }, + "error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "fails": 111801, + "rate": 0.04289798993254118, + "passes": 5011 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1035.2635310789904, + "min": 9.2913, + "med": 722.947, + "max": 4459.4613, + "p(90)": 2587.6791200000002, + "p(95)": 2983.81523 + } + }, + "iteration_duration": { + "values": { + "avg": 1035.5725441161828, + "min": 9.794, + "med": 723.1911, + "max": 4460.3517, + "p(90)": 2588.0131300000003, + "p(95)": 2984.32758 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "contains": "time", + "values": { + "avg": 1035.2617010221381, + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496 + }, + "type": "trend" + }, + "iterations": { + "contains": "default", + "values": { + "count": 116812, + "rate": 233.23266208824631 + }, + "type": "counter" + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 116812, + "fails": 0 + }, + { + "passes": 111801, + "fails": 5011, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce" + } + ] + } +} \ No newline at end of file diff --git a/script/test1-baseline.js b/script/test1-baseline.js new file mode 100644 index 0000000..5ee8a26 --- /dev/null +++ b/script/test1-baseline.js @@ -0,0 +1,111 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { SharedArray } from 'k6/data'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test1-baseline.js + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +// CSV에서 계정 목록 로드 +const accounts = new SharedArray('accounts', function () { + const csv = open(__ENV.ACCOUNTS_FILE); + const parsed = papaparse.parse(csv, { header: true, skipEmptyLines: true }); + return parsed.data.map((row) => row.username); +}); + +export const options = { + stages: [ + { duration: '20s', target: 300 }, // Ramp-Up + { duration: '1m', target: 300 }, // 유지 + { duration: '10s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + }, +}; + +// setup()에서 VU마다 다른 토큰 발급 후 재사용 (캐시 HIT 유도) +export function setup() { + const tokens = []; + const target = Math.min(300, accounts.length); + + console.log(`테스트 시작: ${new Date().toISOString()}`); + console.log(`토큰 발급 시작: ${target}개`); + + for (let i = 0; i < target; i++) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: accounts[i], password: 'password' }), + { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + } + ); + + if (res.status !== 200) { + console.warn(`로그인 실패 (status ${res.status}): ${accounts[i]}`); + continue; + } + + try { + const body = res.json(); + if (body && body.success && body.data && body.data.accessToken) { + tokens.push(body.data.accessToken); + } else { + console.warn(`토큰 없음: ${accounts[i]}`); + } + } catch (e) { + console.warn(`JSON 파싱 실패: ${accounts[i]} - ${e}`); + } + } + + console.log(`토큰 발급 완료: ${tokens.length}개`); + + if (tokens.length === 0) { + throw new Error('발급된 토큰이 없습니다. 테스트를 중단합니다.'); + } + + return { tokens }; +} + +export default function (data) { + // VU마다 다른 토큰 사용, 로그아웃 없이 재사용 → TTL 30초 내 캐시 HIT 유도 + const token = data.tokens[__VU % data.tokens.length]; + + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, + }); + + if (res.timings) { + latency.add(res.timings.duration); + } + errorRate.add(!ok); +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + return { + 'result/result-test1-baseline.json': JSON.stringify(rest, null, 2), + stdout: textSummary(data, { indent: ' ', enableColors: true }), + }; +} \ No newline at end of file diff --git a/script/test2-max-users-revised.js b/script/test2-max-users-revised.js new file mode 100644 index 0000000..9c19eee --- /dev/null +++ b/script/test2-max-users-revised.js @@ -0,0 +1,199 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { SharedArray } from 'k6/data'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test2-max-users.js + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +// CSV에서 계정 목록 로드 +const accounts = new SharedArray('accounts', function () { + const csv = open(__ENV.ACCOUNTS_FILE); + const parsed = papaparse.parse(csv, { header: true, skipEmptyLines: true }); + return parsed.data.map((row) => row.username); +}); + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +// 단계별 메트릭 +const stage100Latency = new Trend('stage_100vu_latency', true); +const stage200Latency = new Trend('stage_200vu_latency', true); +const stage300Latency = new Trend('stage_300vu_latency', true); +const stage400Latency = new Trend('stage_400vu_latency', true); +const stage500Latency = new Trend('stage_500vu_latency', true); + +const stage100Errors = new Rate('stage_100vu_error_rate'); +const stage200Errors = new Rate('stage_200vu_error_rate'); +const stage300Errors = new Rate('stage_300vu_error_rate'); +const stage400Errors = new Rate('stage_400vu_error_rate'); +const stage500Errors = new Rate('stage_500vu_error_rate'); + +export const options = { + stages: [ + { duration: '20s', target: 100 }, // Ramp-Up → 100명 + { duration: '1m', target: 100 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 200 }, // Ramp-Up → 200명 + { duration: '1m', target: 200 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 300 }, // Ramp-Up → 300명 + { duration: '1m', target: 300 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 400 }, // Ramp-Up → 400명 + { duration: '1m', target: 400 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 500 }, // Ramp-Up → 500명 + { duration: '1m', target: 500 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + }, +}; + +// 단계 판별 함수 (경과 시간 기준) +// 각 단계: Ramp-Up 20s + 유지 60s + Ramp-Down 20s = 100s +function getCurrentStage(elapsedSeconds) { + if (elapsedSeconds < 100) return 100; + if (elapsedSeconds < 200) return 200; + if (elapsedSeconds < 300) return 300; + if (elapsedSeconds < 400) return 400; + return 500; +} + +// setup()에서 VU마다 다른 토큰 발급 후 재사용 +export function setup() { + console.log(`테스트 시작: ${new Date().toISOString()}`); + + const tokens = []; + const target = Math.min(500, accounts.length); + + console.log(`토큰 발급 시작: ${target}개`); + + const password = __ENV.PASSWORD || 'password'; + + for (let i = 0; i < target; i++) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: accounts[i], password }), + { + headers: { 'Content-Type': 'application/json' }, + timeout: '10s', + } + ); + + if (res.status !== 200) { + console.warn(`로그인 실패 (status ${res.status}): ${accounts[i]}`); + continue; + } + + try { + const body = res.json(); + if (body && body.success && body.data && body.data.accessToken) { + tokens.push(body.data.accessToken); + } else { + console.warn(`토큰 없음: ${accounts[i]}`); + } + } catch (e) { + console.warn(`JSON 파싱 실패: ${accounts[i]} - ${e}`); + } + } + + console.log(`토큰 발급 완료: ${tokens.length}개 (최대 동시 접속자 수 탐색)`); + + if (tokens.length === 0) { + throw new Error('발급된 토큰이 없습니다. 테스트를 중단합니다.'); + } + + return { tokens, startTime: Date.now() }; +} + +export default function (data) { + // VU마다 다른 토큰 사용 + const token = data.tokens[__VU % data.tokens.length]; + + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, + }); + + if (res.timings) { + latency.add(res.timings.duration); + } + errorRate.add(!ok); + + // 단계별 메트릭 기록 + const elapsedSeconds = (Date.now() - data.startTime) / 1000; + const stage = getCurrentStage(elapsedSeconds); + const duration = res.timings?.duration; + + switch (stage) { + case 100: + if (duration !== undefined) stage100Latency.add(duration); + stage100Errors.add(!ok); + break; + case 200: + if (duration !== undefined) stage200Latency.add(duration); + stage200Errors.add(!ok); + break; + case 300: + if (duration !== undefined) stage300Latency.add(duration); + stage300Errors.add(!ok); + break; + case 400: + if (duration !== undefined) stage400Latency.add(duration); + stage400Errors.add(!ok); + break; + case 500: + if (duration !== undefined) stage500Latency.add(duration); + stage500Errors.add(!ok); + break; + } +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + // 단계별 결과 요약 출력 + const stages = [100, 200, 300, 400, 500]; + let stageSummary = '\n===== 단계별 결과 요약 =====\n'; + + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageSummary += `\n[${vu} VU]\n`; + stageSummary += ` AVG: ${(l.values.avg).toFixed(2)}ms\n`; + stageSummary += ` P90: ${(l.values['p(90)']).toFixed(2)}ms\n`; + stageSummary += ` P95: ${(l.values['p(95)']).toFixed(2)}ms\n`; + stageSummary += ` MAX: ${(l.values.max).toFixed(2)}ms\n`; + stageSummary += ` 에러율: ${(e.values.rate * 100).toFixed(2)}%\n`; + } + }); + + stageSummary += '\n============================\n'; + + return { + 'result/result-test2-max-users-revised.json': JSON.stringify(rest, null, 2), + stdout: textSummary(data, { indent: ' ', enableColors: true }) + stageSummary, + }; +} \ No newline at end of file diff --git a/script/test2-max-users.js b/script/test2-max-users.js new file mode 100644 index 0000000..d503973 --- /dev/null +++ b/script/test2-max-users.js @@ -0,0 +1,79 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; + +// 환경변수 +// 실행 방법: +// k6 run --env NGINX_IP=34.64.xxx.xxx test2-max-users.js +// ⚠️ 이 스크립트는 아카이브용으며, test2-max-users-revised.js로 대체되었습니다. + +const BASE_URL = `http://${__ENV.NGINX_IP}`; + +const errorRate = new Rate('error_rate'); +const latency = new Trend('latency_ms', true); + +export const options = { + stages: [ + { duration: '20s', target: 100 }, // Ramp-Up → 100명 + { duration: '1m', target: 100 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 200 }, // Ramp-Up → 200명 + { duration: '1m', target: 200 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 300 }, // Ramp-Up → 300명 + { duration: '1m', target: 300 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 400 }, // Ramp-Up → 400명 + { duration: '1m', target: 400 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + { duration: '20s', target: 500 }, // Ramp-Up → 500명 + { duration: '1m', target: 500 }, // 유지 + { duration: '20s', target: 0 }, // Ramp-Down + ], + thresholds: { + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + }, +}; + +// setup()에서 토큰 1개 발급 후 전체 VU 재사용 +export function setup() { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ username: 'user00001', password: 'password' }), + { headers: { 'Content-Type': 'application/json' } } + ); + + const body = res.json(); + if (!body.success || !body.data.accessToken) { + throw new Error(`로그인 실패: ${res.body}`); + } + + console.log('토큰 발급 완료 (최대 동시 접속자 수 탐색)'); + return { token: body.data.accessToken }; +} + +export default function (data) { + const res = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: data.token }, + timeout: '15s', + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'latency < 3000ms': (r) => r.timings.duration < 3000, + }); + + latency.add(res.timings.duration); + errorRate.add(!ok); +} + +export function handleSummary(data) { + const { setup_data, ...rest } = data; + + return { + 'result/result-test2-max-users.json': JSON.stringify(rest, null, 2), + stdout: textSummary(data, { indent: ' ', enableColors: true }), + }; +} \ No newline at end of file From c1c5605f7fe4b6984e4f05bf82237571474264dd Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Sun, 17 May 2026 18:44:29 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[Refactor]=20WebFlux=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EC=A0=84=ED=99=98=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : build.gradle 수정 - WebFlux 기반 게이트웨이로 전환하기 위한 webflux 관련 의존성 추가 * refactor : WebClient 도입 - 기존의 FeignClient 기반 코드를 WebClient로 대체 * refactor : Config 클래스 수정 - 기존의 서블릿 기반 설정을 WebFlux 기반 설정으로 수정 * refactor : WebFlux 기반 필터 사용 - 기존의 서블릿 기반 게이트웨이 필터를 WebFlux 기반 필터로 교체 - WebFlux 필터 로직 검증용 통합 테스트 수행 * chore : docker compose 수정 - 불필요한 환경변수 설정 삭제 * chore : 코드래빗 리뷰 반영 - 의존성 제외 설정 관련 오탈자 수정 --- build.gradle | 8 +- docker-compose.yaml | 2 - .../org/pgsg/gateway/GatewayApplication.java | 2 - .../org/pgsg/gateway/auth/AuthProvider.java | 4 +- .../pgsg/gateway/auth/AuthProviderImpl.java | 71 +++--- .../org/pgsg/gateway/client/AuthClient.java | 28 +++ .../pgsg/gateway/config/GatewayAppCtx.java | 62 +---- .../gateway/config/GatewaySecurityConfig.java | 28 +-- .../org/pgsg/gateway/feign/AuthClient.java | 14 -- .../feign/AuthClientFallbackFactory.java | 23 -- .../filter/HttpRequestHeaderWrapper.java | 69 ----- .../pgsg/gateway/filter/JwtGatewayFilter.java | 236 +++++++++--------- .../gateway/JwtGatewayIntegrationTest.java | 201 +++++++-------- 13 files changed, 309 insertions(+), 439 deletions(-) create mode 100644 src/main/java/org/pgsg/gateway/client/AuthClient.java delete mode 100644 src/main/java/org/pgsg/gateway/feign/AuthClient.java delete mode 100644 src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java delete mode 100644 src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java diff --git a/build.gradle b/build.gradle index 32b1bba..f1c83d3 100644 --- a/build.gradle +++ b/build.gradle @@ -33,11 +33,16 @@ dependencies { implementation('org.pgsg:common:0.3.2-SNAPSHOT') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'com.querydsl', module: 'querydsl-jpa' + exclude group: 'org.springdoc', module: 'springdoc-openapi-starter-webmvc-ui' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-web' } implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc' + implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'io.micrometer:micrometer-tracing-bridge-brave' @@ -51,6 +56,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yaml b/docker-compose.yaml index 65c75b6..e6b6afb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,8 +11,6 @@ services: - "8090:8090" env_file: - .env.runtime - environment: - - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ networks: - pgsg-network diff --git a/src/main/java/org/pgsg/gateway/GatewayApplication.java b/src/main/java/org/pgsg/gateway/GatewayApplication.java index 93920d4..bf583f0 100644 --- a/src/main/java/org/pgsg/gateway/GatewayApplication.java +++ b/src/main/java/org/pgsg/gateway/GatewayApplication.java @@ -5,13 +5,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Import; @SpringBootApplication @ImportAutoConfiguration(exclude = AppCtx.class) @Import(GatewayAppCtx.class) -@EnableFeignClients public class GatewayApplication { public static void main(String[] args) { diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java index 71589cb..ff389fe 100644 --- a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java +++ b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java @@ -1,6 +1,8 @@ package org.pgsg.gateway.auth; +import reactor.core.publisher.Mono; + public interface AuthProvider { - boolean verifyToken(String accessToken); + Mono verifyToken(String accessToken); } diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java index 4634c1e..b410353 100644 --- a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java +++ b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java @@ -1,59 +1,44 @@ package org.pgsg.gateway.auth; -import lombok.RequiredArgsConstructor; -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.feign.AuthClient; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.pgsg.gateway.client.AuthClient; import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +@Slf4j @Component -@RequiredArgsConstructor public class AuthProviderImpl implements AuthProvider { - private static final long CACHE_TTL = 30 * 1000; // 캐시 유지 시간: 30초 - private static final int MAX_CACHE_SIZE = 10000; - - // 간단한 로컬 캐시 (토큰별 검증 결과 저장) - private final Map cache = new ConcurrentHashMap<>(); + private final Cache tokenCache; private final AuthClient authClient; - @Override - public boolean verifyToken(String accessToken) { - CacheEntry entry = cache.get(accessToken); - - // 캐시가 유효하면 바로 반환 - if (entry != null && !entry.isExpired()) { - return entry.result; - } - - // 캐시가 없거나 만료되었으면 Feign 호출 - CommonResponse response = authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)); - - // 결과 추출 (success 가 true 이고 isVerifiedToken 이 true 인 경우에만 성공) - boolean result = response != null && response.success() && response.data() != null && response.data().isVerifiedToken(); - if (cache.size() >= MAX_CACHE_SIZE) { - cleanupCache(); - if (cache.size() >= MAX_CACHE_SIZE) { - cache.clear(); - } - } - cache.put(accessToken, new CacheEntry(result, System.currentTimeMillis() + CACHE_TTL)); - - cleanupCache(); - - return result; + public AuthProviderImpl(AuthClient authClient) { + this.authClient = authClient; + this.tokenCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10000) + .build(); } - // 만료된 캐시를 가끔 정리 (메모리 누수 방지) - private void cleanupCache() { - cache.entrySet().removeIf(e -> e.getValue().isExpired()); - } + @Override + public Mono verifyToken(String accessToken) { + Boolean cachedResult = tokenCache.getIfPresent(accessToken); - private record CacheEntry(boolean result, long expiryTime) { - boolean isExpired() { - return System.currentTimeMillis() > expiryTime; + if (cachedResult != null) { + return Mono.just(cachedResult); } + + return authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)) + .map(response -> response != null + && response.success() + && response.data() != null + && response.data().isVerifiedToken()) + .doOnNext(result -> tokenCache.put(accessToken, result)) + .onErrorReturn(false); } } + diff --git a/src/main/java/org/pgsg/gateway/client/AuthClient.java b/src/main/java/org/pgsg/gateway/client/AuthClient.java new file mode 100644 index 0000000..127ed7f --- /dev/null +++ b/src/main/java/org/pgsg/gateway/client/AuthClient.java @@ -0,0 +1,28 @@ +package org.pgsg.gateway.client; + +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.auth.AuthDto; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +//@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) +@Component +public class AuthClient { + + private final WebClient webClient; + + public AuthClient(WebClient.Builder builder) { + this.webClient = builder.baseUrl("lb://user-service").build(); + } + + public Mono> verifyToken(AuthDto.TokenVerifyRequest request) { + return webClient.post() + .uri("/internal/v1/auth/verify") + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .onErrorReturn(new CommonResponse<>(false, "인증 서비스 장애", new AuthDto.TokenVerifyData(false), null)); + } +} diff --git a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java index c23205b..7fb12ad 100644 --- a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java +++ b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java @@ -1,75 +1,23 @@ package org.pgsg.gateway.config; -import com.fasterxml.jackson.databind.ObjectMapper; import org.pgsg.common.exception.ErrorConfigProperties; -import org.pgsg.common.exception.GlobalExceptionAdvice; -import org.pgsg.common.exception.GlobalExceptionAdviceImpl; -import org.pgsg.common.filter.MdcLoggingFilter; -import org.pgsg.common.response.CommonResponseAdvice; -import org.pgsg.config.feign.FeignConfig; import org.pgsg.config.json.JsonConfig; -import org.pgsg.config.security.*; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Lazy; -import org.springframework.core.Ordered; -import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.reactive.function.client.WebClient; @Configuration @Import({ - FeignConfig.class, JsonConfig.class, ErrorConfigProperties.class }) public class GatewayAppCtx { @Bean - public LoginFilter loginFilter(@Lazy @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { - return new LoginFilter(resolver); - } - - @Bean - public CustomAuthenticationEntryPoint customAuthenticationEntryPoint( - ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { - return new CustomAuthenticationEntryPoint(objectMapper, errorConfigProperties); - } - - @Bean - public CustomAccessDeniedHandler accessDeniedHandler( - ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { - return new CustomAccessDeniedHandler(objectMapper, errorConfigProperties); - } - - @Bean - @ConditionalOnMissingBean(SecurityConfig.class) - public SecurityConfig securityConfig( - LoginFilter loginFilter, - CustomAuthenticationEntryPoint customAuthenticationEntryPoint, - CustomAccessDeniedHandler accessDeniedHandler) { - return new SecurityConfigImpl(loginFilter, customAuthenticationEntryPoint, accessDeniedHandler); - } - - @Bean - @ConditionalOnMissingBean(GlobalExceptionAdvice.class) - public GlobalExceptionAdvice globalExceptionAdvice(ErrorConfigProperties errorConfigProperties) { - return new GlobalExceptionAdviceImpl(errorConfigProperties); - } - - @Bean - public CommonResponseAdvice commonResponseAdvice() { - return new CommonResponseAdvice(); - } - - @Bean - public FilterRegistrationBean mdcLoggingFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new MdcLoggingFilter()); - registrationBean.addUrlPatterns("/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - return registrationBean; + @LoadBalanced + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); } } \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java index baf3453..98bc74d 100644 --- a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java +++ b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java @@ -1,27 +1,23 @@ package org.pgsg.gateway.config; +import lombok.RequiredArgsConstructor; import org.pgsg.config.security.SecurityConfig; 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.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; @Configuration -@EnableWebSecurity +@EnableWebFluxSecurity +@RequiredArgsConstructor public class GatewaySecurityConfig implements SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session - -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); - return http.build(); + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(auth -> auth.anyExchange().permitAll()) + .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClient.java b/src/main/java/org/pgsg/gateway/feign/AuthClient.java deleted file mode 100644 index 614c113..0000000 --- a/src/main/java/org/pgsg/gateway/feign/AuthClient.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.pgsg.gateway.feign; - -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.auth.AuthDto; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) -public interface AuthClient { - - @PostMapping(value = "/internal/v1/auth/verify") - CommonResponse verifyToken(@RequestBody AuthDto.TokenVerifyRequest request); -} diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java b/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java deleted file mode 100644 index dc5c0a0..0000000 --- a/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.pgsg.gateway.feign; - -import lombok.extern.slf4j.Slf4j; -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.auth.AuthDto; -import org.springframework.cloud.openfeign.FallbackFactory; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class AuthClientFallbackFactory implements FallbackFactory { - - @Override - public AuthClient create(Throwable cause) { - log.error("[AuthClientFallback] 인증 서비스 호출 실패: {}", cause.getMessage()); - return request -> new CommonResponse<>( - false, - "인증 서비스 장애 (Fallback)", - new AuthDto.TokenVerifyData(false), - null - ); - } -} diff --git a/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java b/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java deleted file mode 100644 index 8768c0e..0000000 --- a/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.pgsg.gateway.filter; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; - -import java.util.*; -import java.util.stream.Collectors; - -public class HttpRequestHeaderWrapper extends HttpServletRequestWrapper { - - private static final String FORBIDDEN_HEADER_PREFIX = "x-user-"; - - private final Map customHeaders = new HashMap<>(); - - public HttpRequestHeaderWrapper(HttpServletRequest request) { - super(request); - } - - public void putHeader(String name, String value) { - customHeaders.put(name.toLowerCase(), value); - } - - // x-user- 로 시작하는 헤더 일괄 제거 (스푸핑 방지) - public void removeHeaders(String prefix) { - Collections.list(super.getHeaderNames()).stream() - .filter(name -> name.toLowerCase().startsWith(prefix.toLowerCase())) - .forEach(name -> customHeaders.remove(name.toLowerCase())); - } - - @Override - public String getHeader(String name) { - String lowerName = name.toLowerCase(); - if (customHeaders.containsKey(lowerName)) { - return customHeaders.get(lowerName); - } - if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { - return null; - } - return super.getHeader(name); - } - - @Override - public Enumeration getHeaders(String name) { - String lowerName = name.toLowerCase(); - String value = customHeaders.get(lowerName); - - if (customHeaders.containsKey(lowerName)) { - // 리스트의 길이가 1인 경우에도 호환 - return Collections.enumeration(Collections.singletonList(value)); - } - if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { - return Collections.emptyEnumeration(); - } - return super.getHeaders(name); - } - - @Override - public Enumeration getHeaderNames() { - Set names = Collections.list(super.getHeaderNames()).stream() - .map(String::toLowerCase) - .filter(headerName -> - !customHeaders.containsKey(headerName) && // 직접 추가한 요청 헤더가 아니면서 - !headerName.startsWith(FORBIDDEN_HEADER_PREFIX) //금지된 접두사로 시작하는 헤더가 아님 - ) - .collect(Collectors.toCollection(LinkedHashSet::new)); - names.addAll(customHeaders.keySet()); - return Collections.enumeration(names); - } -} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index ab8fc30..b16a4d1 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -1,29 +1,31 @@ package org.pgsg.gateway.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.micrometer.tracing.Tracer; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import org.pgsg.config.security.CustomAuthenticationEntryPoint; +import org.pgsg.common.response.CommonResponse; import org.pgsg.config.security.jwt.JwtUtils; import org.pgsg.config.security.token.TokenProvider; import org.pgsg.config.security.token.TokenType; import org.pgsg.gateway.auth.AuthProvider; -import org.slf4j.MDC; -import org.springframework.context.annotation.Lazy; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; -import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; @@ -33,8 +35,7 @@ @Slf4j @Component -@Order(Ordered.HIGHEST_PRECEDENCE + 1) -public class JwtGatewayFilter extends OncePerRequestFilter { +public class JwtGatewayFilter implements GlobalFilter, Ordered { private static final String HEADER_TRACE_ID = "X-Trace-Id"; private static final AntPathMatcher pathMatcher = new AntPathMatcher(); @@ -54,136 +55,145 @@ public class JwtGatewayFilter extends OncePerRequestFilter { private final Tracer tracer; private final TokenProvider jwtTokenProvider; private final AuthProvider authProvider; - private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final ObjectMapper objectMapper; - public JwtGatewayFilter( - Tracer tracer, - TokenProvider jwtTokenProvider, - AuthProvider authProvider, - @Lazy CustomAuthenticationEntryPoint customAuthenticationEntryPoint) { + public JwtGatewayFilter(Tracer tracer, TokenProvider jwtTokenProvider, AuthProvider authProvider, ObjectMapper objectMapper) { this.tracer = tracer; this.jwtTokenProvider = jwtTokenProvider; this.authProvider = authProvider; - this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + this.objectMapper = objectMapper; } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - HttpRequestHeaderWrapper mutableRequest = new HttpRequestHeaderWrapper(request); - - // 1. 추적 ID 동기화 및 보안 헤더 초기화 - String traceId = initializeHeaders(mutableRequest, tracer); - - log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), request.getRequestURI()); - String accessToken = JwtUtils.resolveToken(request.getHeader(HttpHeaders.AUTHORIZATION)); - String path = request.getRequestURI(); - - // 2. 화이트리스트 경로인 경우: 즉시 통과 (패턴 매칭 지원) + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + String traceId = resolveTraceId(); + + // 헤더 초기화: x-user-* 제거 + traceId 주입 + ServerHttpRequest sanitized = request.mutate() + .headers(headers -> { + headers.keySet().removeIf(key -> key.toLowerCase().startsWith("x-user-")); + headers.set(HEADER_TRACE_ID, traceId); + }) + .build(); + + log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), path); + + // 화이트리스트 통과 if (isWhitelisted(path)) { - filterChain.doFilter(mutableRequest, response); - return; + return chain.filter(exchange.mutate().request(sanitized).build()); } - // 3. 토큰이 없는 경우: 즉시 차단 (화이트리스트 제외) + // 토큰 누락 + String accessToken = JwtUtils.resolveToken( + request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + if (accessToken == null) { log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); - return; - } - - // 4. 통합 인증 프로세스 수행 (로컬 검증 -> 원격 검증 -> 헤더 주입) - if (!authenticate(mutableRequest, response, accessToken, traceId)) { - return; // 검증 실패 시 응답 종료 + return onAuthError(exchange, "Access 토큰이 필요합니다.", traceId); } - filterChain.doFilter(mutableRequest, response); + return authenticate(exchange, sanitized, chain, accessToken, traceId); } - private boolean isWhitelisted(String path) { - return WHITELIST.stream() - .anyMatch(pattern -> pathMatcher.match(pattern, path)); + private Mono authenticate(ServerWebExchange exchange, ServerHttpRequest sanitized, + GatewayFilterChain chain, String accessToken, String traceId) { + // [Step 1] 로컬 검증 + return Mono.fromCallable(() -> jwtTokenProvider.validateToken(accessToken)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(valid -> { + if (!valid) { + log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); + return Mono.error(new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); + } + // [Step 2] 원격 검증 (WebClient 비동기 호출) + return authProvider.verifyToken(accessToken); + }) + .flatMap(verified -> { + if (!verified) { + log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); + return Mono.error(new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); + } + // [Step 3] Claims 파싱 + return Mono.fromCallable(() -> jwtTokenProvider.parseClaims(accessToken)) + .subscribeOn(Schedulers.boundedElastic()); + }) + .flatMap(claims -> { + String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); + if (!TokenType.ACCESS.matches(tokenType)) { + log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); + return Mono.error(new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + } + // [Step 4] 사용자 헤더 주입 + ServerHttpRequest mutated = injectUserHeaders(sanitized, claims); + log.info("[JwtGatewayFilter] 인증 성공 (TraceID: {})", traceId); + return chain.filter(exchange.mutate().request(mutated).build()); + }) + .onErrorResume(InsufficientAuthenticationException.class, + e -> onAuthError(exchange, e.getMessage(), traceId)) + .onErrorResume(JwtException.class, e -> { + log.error("[JwtGatewayFilter] JWT 예외: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }) + .onErrorResume(IllegalArgumentException.class, e -> { + log.error("[JwtGatewayFilter] 잘못된 인자: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }); } - /** - * 통합 인증 로직 (최적화된 순서) - * 1. 로컬 검증 (Signature, Expiration) - 비용 낮음 - * 2. 원격 검증 (Blacklist 체크) - 비용 높음 - * 3. Claims 파싱 및 헤더 주입 - */ - private boolean authenticate(HttpRequestHeaderWrapper request, HttpServletResponse response, String accessToken, String traceId) throws IOException, ServletException { - try { - // [Step 1] 로컬 검증 (가장 먼저 수행하여 잘못된 토큰의 원격 호출 방지) - if (!jwtTokenProvider.validateToken(accessToken)) { - log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); - return false; - } - - // [Step 2] 원격 검증 (로컬 검증 통과 시에만 실시간 블랙리스트 확인) - if (!authProvider.verifyToken(accessToken)) { - log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); - return false; - } - - // [Step 3] Claims 추출 및 토큰 타입 확인 - Claims claims = jwtTokenProvider.parseClaims(accessToken); - String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); - - if (!TokenType.ACCESS.matches(tokenType)) { - log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); - return false; - } - - // [Step 4] 검증 완료 - 사용자 헤더 주입 - injectUserHeaders(request, claims); - log.info("[JwtGatewayFilter] 인증 성공 - 사용자 헤더 주입 (TraceID: {})", traceId); - return true; - - } catch (JwtException | IllegalArgumentException e) { - log.error("[JwtGatewayFilter] 인증 처리 중 예외 발생: {} (TraceID: {})", e.getMessage(), traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("토큰 인증 중 오류가 발생했습니다.")); - return false; - } - } + private Mono onAuthError(ServerWebExchange exchange, String message, String traceId) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - private String initializeHeaders(HttpRequestHeaderWrapper mutableRequest, Tracer tracer) { - String traceId = (tracer.currentSpan() != null) - ? Objects.requireNonNull(tracer.currentSpan()).context().traceId() - : MDC.get("traceId"); + CommonResponse errorResponse = new CommonResponse<>( + false, + message, + null, + traceId + ); - if (traceId == null) { - traceId = UUID.randomUUID().toString().substring(0, 8); + try { + byte[] body = objectMapper.writeValueAsBytes(errorResponse); + return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); + } catch (Exception e) { + log.error("[JwtGatewayFilter] JSON 직렬화 오류 (TraceID: {})", traceId, e); + return Mono.error(e); } + } - MDC.put("traceId", traceId); - mutableRequest.removeHeaders("x-user-"); - mutableRequest.putHeader(HEADER_TRACE_ID, traceId); - - return traceId; + private ServerHttpRequest injectUserHeaders(ServerHttpRequest request, Claims claims) { + Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); + return request.mutate() + .header(JwtUtils.HEADER_USER_ID, claims.getSubject()) + .header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)) + .header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)) + .header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))) + .header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))) + .header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false") + .build(); } - private void injectUserHeaders(HttpRequestHeaderWrapper request, Claims claims) { - request.putHeader(JwtUtils.HEADER_USER_ID, claims.getSubject()); - request.putHeader(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)); - request.putHeader(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)); - request.putHeader(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))); - request.putHeader(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))); + private boolean isWhitelisted(String path) { + return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } - Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); - request.putHeader(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false"); + private String resolveTraceId() { + if (tracer.currentSpan() != null) { + return Objects.requireNonNull(tracer.currentSpan()).context().traceId(); + } + return UUID.randomUUID().toString().substring(0, 8); } private String encodeValue(String value) { return Optional.ofNullable(value) - .map(val -> URLEncoder.encode(val, StandardCharsets.UTF_8)) + .map(v -> URLEncoder.encode(v, StandardCharsets.UTF_8)) .orElse(null); } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; + } } diff --git a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java index b563bd0..12a6495 100644 --- a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java +++ b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java @@ -1,159 +1,164 @@ package org.pgsg.gateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.pgsg.common.response.CommonResponse; import org.pgsg.config.security.token.TokenProvider; -import org.pgsg.gateway.auth.AuthDto; -import org.pgsg.gateway.feign.AuthClient; +import org.pgsg.gateway.auth.AuthProvider; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; - +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; import org.pgsg.config.security.jwt.JwtUtils; +import reactor.core.publisher.Mono; import java.util.Map; -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.mockito.Mockito.*; import org.springframework.test.context.bean.override.mockito.MockitoBean; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "downstream.service.url=http://localhost:${wiremock.server.port}" +}) +@AutoConfigureWebTestClient +@AutoConfigureWireMock(port = 0) class JwtGatewayIntegrationTest { @Autowired - private MockMvc mockMvc; + private WebTestClient webTestClient; + + @Autowired + private ObjectMapper objectMapper; @MockitoBean private TokenProvider tokenProvider; @MockitoBean - private AuthClient authClient; + private AuthProvider authProvider; - /** - * 테스트용 컨트롤러: 게이트웨이 필터를 거쳐 주입된 헤더를 확인하는 용도 - */ @TestConfiguration - @RestController - static class TestDownstreamController { - @GetMapping("/test/headers") - public Map getHeaders( - @RequestHeader(value = "x-user-id", required = false) String userId, - @RequestHeader(value = "x-user-roles", required = false) String roles - ) { - return Map.of( - "userId", userId != null ? userId : "null", - "roles", roles != null ? roles : "null" - ); - } - - @GetMapping("/api/v1/auth/login") - public String whitelist() { - return "ok"; + static class TestRouteConfig { + @Bean + public RouteLocator testRoutes(RouteLocatorBuilder builder, @Value("${downstream.service.url}") String downstreamUrl) { + return builder.routes() + .route("test_route", r -> r.path("/test/**") + .filters(f -> f.prefixPath("/internal")) + .uri(downstreamUrl)) + .route("auth_route", r -> r.path("/api/v1/auth/**") + .uri(downstreamUrl)) + .build(); } } @Test @DisplayName("유효한 토큰 요청 시 사용자 헤더가 정상 주입되어야 한다") - void success_token_injection() throws Exception { - // given - String token = "valid-token"; + void success_token_injection() throws JsonProcessingException { + String token = "valid-token-final"; String userId = "00000000-0000-0000-0000-000000000001"; String role = "ROLE_USER"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - + when(tokenProvider.validateToken(token)).thenReturn(true); Claims claims = Jwts.claims() .subject(userId) .add(JwtUtils.CLAIM_USER_ROLE, role) - .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // TokenType.ACCESS.getValue() 값인 "access" 사용 + .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") .add(JwtUtils.CLAIM_USERNAME, "tester") - .add(JwtUtils.CLAIM_NAME, "TesterName") - .add(JwtUtils.CLAIM_NICKNAME, "TestNick") - .add(JwtUtils.CLAIM_ENABLED, true) .build(); - Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); - - Mockito.when(authClient.verifyToken(any())) - .thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(userId)) - .andExpect(jsonPath("$.data.roles").value(role)); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + // CommonResponse를 사용하여 JSON 바디 생성 + String responseBody = objectMapper.writeValueAsString( + new CommonResponse<>(true, "OK", Map.of("status", "passed"), "test-trace-id") + ); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(userId)) + .withHeader(JwtUtils.HEADER_ROLES, equalTo(role)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBody(responseBody))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.success").isEqualTo(true) + .jsonPath("$.data.status").isEqualTo("passed"); } @Test @DisplayName("검증한 토큰이 블랙리스트에 포함되어 있다면 401 에러를 반환해야 한다") - void fail_blacklisted_token() throws Exception { - // given - String token = "blacklisted-token"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - - // 원격 검증에서 실패(블랙리스트) 반환 - Mockito.when(authClient.verifyToken(any())) - .thenReturn(new CommonResponse<>(true, "fail", new AuthDto.TokenVerifyData(false), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isUnauthorized()); + void fail_blacklisted_token() { + String token = "blacklisted-token-final"; + when(tokenProvider.validateToken(token)).thenReturn(true); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(false)); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("이미 로그아웃되었거나 사용할 수 없는 토큰입니다."); } @Test @DisplayName("화이트리스트에 포함된 경로는 유효한 토큰 없이도 통과되어야 한다") - void success_whitelist() throws Exception { - mockMvc.perform(get("/api/v1/auth/login")) - .andExpect(status().isOk()); + void success_whitelist() { + stubFor(post(urlEqualTo("/api/v1/auth/login")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.post().uri("/api/v1/auth/login") + .exchange() + .expectStatus().isOk(); } @Test @DisplayName("화이트리스트에 포함되지 않은 경로는 유효한 토큰이 없으면 차단되어야 한다") - void fail_nonWhitelist_noToken() throws Exception { - mockMvc.perform(get("/test/headers")) // 비화이트리스트 경로 - .andExpect(status().isUnauthorized()); + void fail_nonWhitelist_noToken() { + webTestClient.get().uri("/test/headers") + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("Access 토큰이 필요합니다."); } @Test @DisplayName("외부에서 주입한 보안 헤더(x-user-)는 무시되어야 한다") - void success_spoofing_protection() throws Exception { - // given - String token = "valid-token"; + void success_spoofing_protection() { + String token = "spoofing-check-final"; String realUserId = "00000000-0000-0000-0000-000000000001"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - Claims claims = Jwts.claims() - .subject(realUserId) - .add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER") - .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // "access" 사용 - .add(JwtUtils.CLAIM_USERNAME, "tester") - .add(JwtUtils.CLAIM_NAME, "TesterName") - .add(JwtUtils.CLAIM_NICKNAME, "TestNick") - .add(JwtUtils.CLAIM_ENABLED, true) - .build(); - Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); - Mockito.when(authClient.verifyToken(any())).thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .header("x-user-id", "99999")) // 스푸핑 시도 - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(realUserId)); // 게이트웨이가 주입한 값이어야 함 + when(tokenProvider.validateToken(token)).thenReturn(true); + Claims claims = Jwts.claims().subject(realUserId).add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER").add(JwtUtils.CLAIM_TOKEN_TYPE, "access").build(); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(realUserId)) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(JwtUtils.HEADER_USER_ID, "99999") // 스푸핑 시도 + .exchange() + .expectStatus().isOk(); } } From aa05c8d8104675424162d1912ffb6548c7a1bffd Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Mon, 18 May 2026 19:58:02 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[TASK]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=202=EC=B0=A8=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A7=84=ED=96=89=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : 부하테스트 코드 수정 - 테스트 항목 2 관련 k6 스크립트에 테스트 시작, 종료시간 추가 * refactor : 1차 부하테스트 결과 파일명 수정 - 2차 부하테스트 준비 및 1차 부하테스트 진행 결과 보존용 * refactor : 게이트웨이 필터 코드 개선 - 게이트웨이 인증 로직 중 일부 구간에서 블로킹 방식 및 기존 서블릿 필터 방식이 적용된 코드를 수정 * refactor : JWT 토큰 검증용 캐시 추가 - 이미 Jwt 검증 및 파싱 완료된 토큰에 관한 캐시 추가(TTL 30초) * chore : 부하테스트 기록 저장 - test1-baseline-2.json 추가 * refactor : 캐시 적용 순서 조정 - 토큰 블랙리스트 검증 통과 후 JWT 검증 결과 캐싱 여부를 확인하도록 순서 조정 * chore : 이전 테스트 기록 저장 - 2차 부하테스트 기록 저장 * refactor : WebClient 커넥션 풀 타임아웃 설정 추가 - 부하테스트 지표 개선용 * fix : JWT 토큰 검증 & 캐싱 로직 보강 - 만료된 토큰이 게이트웨이 필터를 통과하는 문제에 관한 방어 로직 보강 * comment : Jwt 토큰 검증 & 캐싱 관련 주석 수정 - 캐싱된 JWT 토큰이라도 검증을 수행한다는 내용으로 수정 * test : 테스트 항목 3 k6 테스트 스크립트 추가 - 테스트 항목 2 관련 k6 테스트 스크립트를 테스트 항목 3 테스트 스크립트로 사용 * test : 2차 부하테스트 진행 기록 추가 - 부하테스트 진행 결과 스크립트 추가 --- ...line.json => result-test1-baseline-1.json} | 0 script/result/result-test1-baseline-2.json | 250 ++++++++++++ script/result/result-test1-baseline-3.json | 250 ++++++++++++ script/result/result-test1-baseline-4.json | 250 ++++++++++++ script/result/result-test2-max-users-1.json | 250 ++++++++++++ script/result/result-test2-max-users-2.json | 313 +++++++++++++++ script/result/result-test2-max-users.json | 376 +++++++++++------- script/result/result-test2-stage-100vu-2.json | 16 + script/result/result-test2-stage-100vu.json | 16 + script/result/result-test2-stage-200vu-2.json | 16 + script/result/result-test2-stage-200vu.json | 16 + script/result/result-test2-stage-300vu-2.json | 16 + script/result/result-test2-stage-300vu.json | 16 + script/result/result-test2-stage-400vu.json | 16 + script/result/result-test2-stage-500vu.json | 16 + .../result-test3-max-users-revised-1.json | 368 +++++++++++++++++ .../result-test3-max-users-revised.json | 368 +++++++++++++++++ script/result/result-test3-stage-100vu-1.json | 16 + script/result/result-test3-stage-100vu.json | 16 + script/result/result-test3-stage-200vu-1.json | 16 + script/result/result-test3-stage-200vu.json | 16 + script/result/result-test3-stage-300vu-1.json | 16 + script/result/result-test3-stage-300vu.json | 16 + script/result/result-test3-stage-400vu-1.json | 16 + script/result/result-test3-stage-400vu.json | 16 + script/result/result-test3-stage-500vu.json | 16 + script/test2-max-users.js | 121 +++++- ...-revised.js => test3-max-users-revised.js} | 32 +- .../pgsg/gateway/auth/AuthProviderImpl.java | 17 +- .../org/pgsg/gateway/cache/CacheUtil.java | 35 ++ .../org/pgsg/gateway/client/AuthClient.java | 20 +- .../pgsg/gateway/filter/JwtGatewayFilter.java | 348 ++++++++-------- 32 files changed, 2941 insertions(+), 329 deletions(-) rename script/result/{result-test1-baseline.json => result-test1-baseline-1.json} (100%) create mode 100644 script/result/result-test1-baseline-2.json create mode 100644 script/result/result-test1-baseline-3.json create mode 100644 script/result/result-test1-baseline-4.json create mode 100644 script/result/result-test2-max-users-1.json create mode 100644 script/result/result-test2-max-users-2.json create mode 100644 script/result/result-test2-stage-100vu-2.json create mode 100644 script/result/result-test2-stage-100vu.json create mode 100644 script/result/result-test2-stage-200vu-2.json create mode 100644 script/result/result-test2-stage-200vu.json create mode 100644 script/result/result-test2-stage-300vu-2.json create mode 100644 script/result/result-test2-stage-300vu.json create mode 100644 script/result/result-test2-stage-400vu.json create mode 100644 script/result/result-test2-stage-500vu.json create mode 100644 script/result/result-test3-max-users-revised-1.json create mode 100644 script/result/result-test3-max-users-revised.json create mode 100644 script/result/result-test3-stage-100vu-1.json create mode 100644 script/result/result-test3-stage-100vu.json create mode 100644 script/result/result-test3-stage-200vu-1.json create mode 100644 script/result/result-test3-stage-200vu.json create mode 100644 script/result/result-test3-stage-300vu-1.json create mode 100644 script/result/result-test3-stage-300vu.json create mode 100644 script/result/result-test3-stage-400vu-1.json create mode 100644 script/result/result-test3-stage-400vu.json create mode 100644 script/result/result-test3-stage-500vu.json rename script/{test2-max-users-revised.js => test3-max-users-revised.js} (84%) create mode 100644 src/main/java/org/pgsg/gateway/cache/CacheUtil.java diff --git a/script/result/result-test1-baseline.json b/script/result/result-test1-baseline-1.json similarity index 100% rename from script/result/result-test1-baseline.json rename to script/result/result-test1-baseline-1.json diff --git a/script/result/result-test1-baseline-2.json b/script/result/result-test1-baseline-2.json new file mode 100644 index 0000000..4ff4a04 --- /dev/null +++ b/script/result/result-test1-baseline-2.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "fails": 0, + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 31575 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 30725, + "fails": 850 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "testRunDurationMs": 126778.6542, + "isStdOutTTY": true, + "isStdErrTTY": true + }, + "metrics": { + "iteration_duration": { + "contains": "time", + "values": { + "med": 381.1319, + "max": 5764.4988, + "p(90)": 1982.0961000000002, + "p(95)": 2488.29166, + "avg": 719.2527691053028, + "min": 10.7777 + }, + "type": "trend" + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "med": 375.4834, + "max": 5761.9723, + "p(90)": 1967.20308, + "p(95)": 2474.47919, + "avg": 705.3671656470617, + "min": 9.8929 + } + }, + "http_req_duration{expected_response:true}": { + "contains": "time", + "values": { + "p(95)": 2486.3049799999994, + "avg": 713.4340477584294, + "min": 10.7777, + "med": 378.1792, + "max": 5764.4988, + "p(90)": 1979.0498000000005 + }, + "type": "trend" + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + } + }, + "vus_max": { + "contains": "default", + "values": { + "max": 300, + "value": 300, + "min": 300 + }, + "type": "gauge" + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 8.05668734431375, + "min": 0, + "med": 0.3807, + "max": 2220.995, + "p(90)": 6.072900000000002, + "p(95)": 16.92079999999997 + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "passes": 62300, + "fails": 850, + "rate": 0.9865399841646872 + } + }, + "http_req_duration": { + "thresholds": { + "p(95)<3000": { + "ok": true + } + }, + "type": "trend", + "contains": "time", + "values": { + "med": 378.1792, + "max": 5764.4988, + "p(90)": 1979.0498000000005, + "p(95)": 2486.3049799999994, + "avg": 713.4340477584294, + "min": 10.7777 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 27, + "min": 0, + "max": 300 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 0, + "avg": 0.061471730196078454, + "min": 0, + "med": 0, + "max": 30.9291, + "p(90)": 0 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "rate": 251192.8068723733, + "count": 31845886 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 31575, + "rate": 249.05612225689646 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 31875 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 31875, + "rate": 251.42245120945603 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.010194767058823526, + "min": 0, + "med": 0, + "max": 1.4257, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "max": 30.9291, + "p(90)": 0, + "p(95)": 0, + "avg": 0.05839844078431371, + "min": 0, + "med": 0 + } + }, + "data_sent": { + "contains": "data", + "values": { + "count": 14281064, + "rate": 112645.65072185472 + }, + "type": "counter" + }, + "error_rate": { + "contains": "default", + "values": { + "rate": 0.026920031670625493, + "passes": 850, + "fails": 30725 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "latency_ms": { + "values": { + "p(95)": 2487.9050799999995, + "avg": 719.0559004845587, + "min": 10.7777, + "med": 380.732, + "max": 5764.4988, + "p(90)": 1982.0908600000002 + }, + "type": "trend", + "contains": "time" + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline-3.json b/script/result/result-test1-baseline-3.json new file mode 100644 index 0000000..fab2127 --- /dev/null +++ b/script/result/result-test1-baseline-3.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 29945, + "fails": 0 + }, + { + "fails": 791, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 29154 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 128669.8229 + }, + "metrics": { + "checks": { + "type": "rate", + "contains": "default", + "values": { + "fails": 791, + "rate": 0.9867924528301887, + "passes": 59099 + } + }, + "http_req_blocked": { + "values": { + "p(95)": 0, + "avg": 0.062362638452636836, + "min": 0, + "med": 0, + "max": 48.7299, + "p(90)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 2510.6531999999997, + "avg": 751.6595685567881, + "min": 9.9928, + "med": 383.8901, + "max": 5738.9482, + "p(90)": 2001.29564 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.058840059513969235, + "min": 0, + "med": 0, + "max": 48.7299 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01201500413291453, + "min": 0, + "med": 0, + "max": 8.4046 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 30245, + "rate": 235.059000769014 + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "p(95)": 16.762799999999984, + "avg": 6.94494492643411, + "min": 0, + "med": 0.4212, + "max": 2038.285, + "p(90)": 6.410920000000003 + } + }, + "error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "rate": 0.026415094339622643, + "passes": 791, + "fails": 29154 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 30245 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 13545911, + "rate": 105276.51857054043 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 757.9021660043437, + "min": 9.9928, + "med": 386.528, + "max": 5738.9482, + "p(90)": 2003.29032, + "p(95)": 2512.7481599999996 + } + }, + "vus_max": { + "values": { + "min": 300, + "max": 300, + "value": 300 + }, + "type": "gauge", + "contains": "default" + }, + "vus": { + "contains": "default", + "values": { + "min": 0, + "max": 300, + "value": 24 + }, + "type": "gauge" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 758.1173075304725, + "min": 10.0073, + "med": 386.7922, + "max": 5739.4715, + "p(90)": 2003.3237600000002, + "p(95)": 2512.96022 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "med": 383.8901, + "max": 5738.9482, + "p(90)": 2001.29564, + "p(95)": 2510.6531999999997, + "avg": 751.6595685567881, + "min": 9.9928 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "rate": 232.72745174501983, + "count": 29945 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 30218723, + "rate": 234854.78039000236 + } + }, + "http_req_waiting": { + "contains": "time", + "values": { + "med": 380.3399, + "max": 5738.9069, + "p(90)": 1995.7995800000003, + "p(95)": 2504.5667599999997, + "avg": 744.7026086262207, + "min": 9.0535 + }, + "type": "trend" + } + } +} \ No newline at end of file diff --git a/script/result/result-test1-baseline-4.json b/script/result/result-test1-baseline-4.json new file mode 100644 index 0000000..bba3d95 --- /dev/null +++ b/script/result/result-test1-baseline-4.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 17441, + "fails": 0 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 17441, + "fails": 0 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 61693.9629 + }, + "metrics": { + "http_req_connecting": { + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.005711938447663604, + "min": 0, + "med": 0, + "max": 6.5243 + }, + "type": "trend" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 1, + "passes": 34882, + "fails": 0 + } + }, + "http_req_duration": { + "contains": "time", + "values": { + "avg": 18.54878851812194, + "min": 6.6769, + "med": 14.5145, + "max": 509.708, + "p(90)": 26.6481, + "p(95)": 44.6758 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + }, + "type": "trend" + }, + "http_req_tls_handshaking": { + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 17441, + "rate": 282.7018914033807 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 509.2297, + "p(90)": 25.8254, + "p(95)": 43.234, + "avg": 17.94140202919777, + "min": 6.5901, + "med": 13.9288 + } + }, + "error_rate": { + "contains": "default", + "values": { + "passes": 0, + "fails": 17441, + "rate": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 17741 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 10, + "min": 0, + "max": 10 + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 7896249, + "rate": 127990.62710234812 + } + }, + "data_received": { + "contains": "data", + "values": { + "count": 18090704, + "rate": 293232.97045001463 + }, + "type": "counter" + }, + "latency_ms": { + "contains": "time", + "values": { + "med": 14.4121, + "max": 209.1893, + "p(90)": 24.9339, + "p(95)": 34.4135, + "avg": 17.055405057049523, + "min": 6.6769 + }, + "type": "trend" + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "max": 209.1893, + "p(90)": 25.1525, + "p(95)": 34.6196, + "avg": 17.19121986698013, + "min": 6.6769, + "med": 14.5293 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.5992108731187618, + "min": 0, + "med": 0.3371, + "max": 98.4534, + "p(90)": 1.2546, + "p(95)": 1.7388 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "min": 10, + "max": 10, + "value": 10 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.007877509723239952, + "min": 0, + "med": 0, + "max": 6.5243 + } + }, + "http_req_sending": { + "values": { + "med": 0, + "max": 1.038, + "p(90)": 0, + "p(95)": 0, + "avg": 0.008175615805197009, + "min": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.54878851812194, + "min": 6.6769, + "med": 14.5145, + "max": 509.708, + "p(90)": 26.6481, + "p(95)": 44.6758 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 17741, + "rate": 287.5646038293319 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users-1.json b/script/result/result-test2-max-users-1.json new file mode 100644 index 0000000..9559f4b --- /dev/null +++ b/script/result/result-test2-max-users-1.json @@ -0,0 +1,250 @@ +{ + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 500838.9432 + }, + "metrics": { + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 116813, + "rate": 0 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 4459.4034, + "p(90)": 2587.244400000001, + "p(95)": 2983.4811599999994, + "avg": 1035.0400295583534, + "min": 9.2913, + "med": 722.7379 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 116696283, + "rate": 233001.61575774205 + } + }, + "data_sent": { + "values": { + "count": 52448771, + "rate": 104721.83066454485 + }, + "type": "counter", + "contains": "data" + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 25, + "min": 1, + "max": 500 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.21488554612928348, + "min": 0, + "med": 0.0228, + "max": 6.9092, + "p(90)": 0.7384800000000004, + "p(95)": 0.889 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496, + "avg": 1035.2617010221381 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 116813, + "rate": 233.2346587380947 + } + }, + "vus_max": { + "values": { + "value": 500, + "min": 500, + "max": 500 + }, + "type": "gauge", + "contains": "default" + }, + "http_req_tls_handshaking": { + "values": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + }, + "type": "trend", + "contains": "time" + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.9785510050337294, + "passes": 228613, + "fails": 5011 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08289572735911248, + "min": 0 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "max": 13.0249, + "p(90)": 0, + "p(95)": 0, + "avg": 0.006785917663273801, + "min": 0, + "med": 0 + } + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 221.2688, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08072584044584098 + } + }, + "error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", + "values": { + "fails": 111801, + "rate": 0.04289798993254118, + "passes": 5011 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1035.2635310789904, + "min": 9.2913, + "med": 722.947, + "max": 4459.4613, + "p(90)": 2587.6791200000002, + "p(95)": 2983.81523 + } + }, + "iteration_duration": { + "values": { + "avg": 1035.5725441161828, + "min": 9.794, + "med": 723.1911, + "max": 4460.3517, + "p(90)": 2588.0131300000003, + "p(95)": 2984.32758 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "contains": "time", + "values": { + "avg": 1035.2617010221381, + "min": 9.2913, + "med": 722.9712, + "max": 4459.4613, + "p(90)": 2587.6776400000003, + "p(95)": 2983.81496 + }, + "type": "trend" + }, + "iterations": { + "contains": "default", + "values": { + "count": 116812, + "rate": 233.23266208824631 + }, + "type": "counter" + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 116812, + "fails": 0 + }, + { + "passes": 111801, + "fails": 5011, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce" + } + ] + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users-2.json b/script/result/result-test2-max-users-2.json new file mode 100644 index 0000000..8466a2d --- /dev/null +++ b/script/result/result-test2-max-users-2.json @@ -0,0 +1,313 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 27799, + "fails": 7, + "name": "status 200" + }, + { + "fails": 1466, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 26340 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdErrTTY": true, + "testRunDurationMs": 225933.9728, + "isStdOutTTY": true + }, + "metrics": { + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0, + "max": 0 + } + }, + "vus": { + "values": { + "value": 300, + "min": 0, + "max": 300 + }, + "type": "gauge", + "contains": "default" + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 990.1580736747441, + "min": 12.6129, + "med": 455.3909, + "max": 8261.5448, + "p(90)": 2479.66465, + "p(95)": 3011.6099249999997 + } + }, + "stage_300vu_error_rate": { + "values": { + "rate": 0.14138438880706922, + "passes": 288, + "fails": 1749 + }, + "type": "rate", + "contains": "default" + }, + "stage_100vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0007383427046061226, + "passes": 13, + "fails": 17594 + } + }, + "checks": { + "contains": "default", + "values": { + "rate": 0.9735129108825433, + "passes": 54139, + "fails": 1473 + }, + "type": "rate" + }, + "stage_200vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1978.9006157559413, + "min": 13.53, + "med": 1966.41355, + "max": 8261.5448, + "p(90)": 3258.87942, + "p(95)": 3983.9422499999996 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.00025173517459632465, + "passes": 7, + "fails": 27800 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 500, + "min": 500, + "max": 500 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 990.4860431309825, + "min": 13.6323, + "med": 455.7493, + "max": 8262.1058, + "p(90)": 2480.364, + "p(95)": 3011.814425 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 27807, + "rate": 123.075780306024 + } + }, + "http_req_receiving": { + "contains": "time", + "values": { + "med": 0.5486, + "max": 2978.7396, + "p(90)": 15.5291, + "p(95)": 60.75906999999928, + "avg": 17.46784033876358, + "min": 0 + }, + "type": "trend" + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 12619777, + "rate": 55856.03990229131 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 28683588, + "rate": 126955.62178863258 + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "fails": 26333, + "rate": 0.05297417823491333, + "passes": 1473 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "iterations": { + "contains": "default", + "values": { + "count": 27806, + "rate": 123.07135423416058 + }, + "type": "counter" + }, + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.11899819469917652, + "min": 0, + "med": 0, + "max": 25.3413, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_sending": { + "values": { + "avg": 0.009234523681087493, + "min": 0, + "med": 0, + "max": 2.6128, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 972.7194616247732, + "min": 11.8901, + "med": 446.1265, + "max": 8243.4226, + "p(90)": 2463.877420000001, + "p(95)": 2999.11065 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 2479.7101900000002, + "p(95)": 3011.65271, + "avg": 990.3601952913644, + "min": 12.6129, + "med": 455.4952, + "max": 8261.5448 + } + }, + "http_req_blocked": { + "contains": "time", + "values": { + "avg": 0.12223022260581864, + "min": 0, + "med": 0, + "max": 25.3413, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "stage_200vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.14359225679980397, + "passes": 1172, + "fails": 6990 + }, + "type": "rate" + }, + "stage_100vu_latency": { + "values": { + "med": 345.1903, + "max": 4493.0597, + "p(90)": 975.3703, + "p(95)": 1241.89681, + "avg": 457.0563624581138, + "min": 13.4874 + }, + "type": "trend", + "contains": "time" + }, + "stage_300vu_latency": { + "contains": "time", + "values": { + "avg": 1636.3069204712822, + "min": 12.6129, + "med": 1477.4297, + "max": 6485.7606, + "p(90)": 3250.21576, + "p(95)": 3729.77304 + }, + "type": "trend" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "min": 12.6129, + "med": 455.4546, + "max": 8261.5448, + "p(90)": 2479.6575000000003, + "p(95)": 3011.6039099999994, + "avg": 990.1965364872129 + }, + "thresholds": { + "p(95)<3000": { + "ok": false + } + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test2-max-users.json b/script/result/result-test2-max-users.json index 9559f4b..78b1dc5 100644 --- a/script/result/result-test2-max-users.json +++ b/script/result/result-test2-max-users.json @@ -1,7 +1,5 @@ { "options": { - "summaryTimeUnit": "", - "noColor": false, "summaryTrendStats": [ "avg", "min", @@ -9,86 +7,75 @@ "max", "p(90)", "p(95)" - ] + ], + "summaryTimeUnit": "", + "noColor": false }, "state": { "isStdOutTTY": true, "isStdErrTTY": true, - "testRunDurationMs": 500838.9432 + "testRunDurationMs": 129949.7773 }, "metrics": { - "http_req_failed": { - "type": "rate", - "contains": "default", + "stage_200vu_latency": { + "contains": "time", "values": { - "passes": 0, - "fails": 116813, - "rate": 0 - } + "max": 5744.9292, + "p(90)": 2472.1483200000002, + "p(95)": 2952.373679999998, + "avg": 669.5951519816439, + "min": 10.1064, + "med": 76.8302 + }, + "type": "trend" }, - "http_req_waiting": { + "http_req_blocked": { "type": "trend", "contains": "time", "values": { - "max": 4459.4034, - "p(90)": 2587.244400000001, - "p(95)": 2983.4811599999994, - "avg": 1035.0400295583534, - "min": 9.2913, - "med": 722.7379 - } - }, - "data_received": { - "type": "counter", - "contains": "data", - "values": { - "count": 116696283, - "rate": 233001.61575774205 + "avg": 0.07551442090599034, + "min": 0, + "med": 0, + "max": 19.8249, + "p(90)": 0, + "p(95)": 0 } }, - "data_sent": { + "http_req_waiting": { + "contains": "time", "values": { - "count": 52448771, - "rate": 104721.83066454485 + "min": 8.3058, + "med": 333.457, + "max": 5743.6426, + "p(90)": 993.1078600000001, + "p(95)": 1469.1275400000002, + "avg": 453.97617327664796 }, - "type": "counter", - "contains": "data" - }, - "vus": { - "type": "gauge", - "contains": "default", - "values": { - "value": 25, - "min": 1, - "max": 500 - } + "type": "trend" }, "http_req_receiving": { "type": "trend", "contains": "time", "values": { - "avg": 0.21488554612928348, - "min": 0, - "med": 0.0228, - "max": 6.9092, - "p(90)": 0.7384800000000004, - "p(95)": 0.889 + "med": 0.5437, + "max": 3249.3321, + "p(90)": 10.070660000000004, + "p(95)": 30.984760000000037, + "avg": 9.86365794302735, + "min": 0 } }, - "http_req_duration": { - "type": "trend", - "contains": "time", + "error_rate": { + "type": "rate", + "contains": "default", "values": { - "min": 9.2913, - "med": 722.9712, - "max": 4459.4613, - "p(90)": 2587.6776400000003, - "p(95)": 2983.81496, - "avg": 1035.2617010221381 + "rate": 0.050441967717140664, + "passes": 1050, + "fails": 19766 }, "thresholds": { - "p(95)<3000": { - "ok": true + "rate<0.05": { + "ok": false } } }, @@ -96,10 +83,30 @@ "type": "counter", "contains": "default", "values": { - "count": 116813, - "rate": 233.2346587380947 + "count": 20817, + "rate": 160.19265621319383 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 20987859, + "rate": 161507.4641609255 } }, + "stage_100vu_latency": { + "values": { + "max": 4730.8337, + "p(90)": 779.74658, + "p(95)": 1251.6379399999998, + "avg": 437.0904259948959, + "min": 8.462, + "med": 340.8044 + }, + "type": "trend", + "contains": "time" + }, "vus_max": { "values": { "value": 500, @@ -109,64 +116,110 @@ "type": "gauge", "contains": "default" }, - "http_req_tls_handshaking": { + "data_sent": { + "type": "counter", + "contains": "data", "values": { - "min": 0, - "med": 0, - "max": 0, - "p(90)": 0, - "p(95)": 0, - "avg": 0 + "count": 9435020, + "rate": 72605.12634983947 + } + }, + "iterations": { + "values": { + "count": 20816, + "rate": 160.18496093259563 }, - "type": "trend", - "contains": "time" + "type": "counter", + "contains": "default" }, - "checks": { - "type": "rate", - "contains": "default", + "stage_200vu_error_rate": { "values": { - "rate": 0.9785510050337294, - "passes": 228613, - "fails": 5011 - } + "passes": 1032, + "fails": 1365, + "rate": 0.4305381727158949 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + }, + "type": "rate", + "contains": "default" }, - "http_req_blocked": { + "http_req_duration{expected_response:true}": { "type": "trend", "contains": "time", "values": { - "med": 0, - "max": 221.2688, - "p(90)": 0, - "p(95)": 0, - "avg": 0.08289572735911248, - "min": 0 + "avg": 483.0746389450396, + "min": 12.5681, + "med": 343.5518, + "max": 5744.9292, + "p(90)": 1010.04806, + "p(95)": 1494.1072700000002 } }, - "http_req_sending": { - "type": "trend", + "http_req_connecting": { "contains": "time", "values": { - "max": 13.0249, - "p(90)": 0, - "p(95)": 0, - "avg": 0.006785917663273801, + "avg": 0.07269279435077103, "min": 0, - "med": 0 - } + "med": 0, + "max": 19.8249, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" }, - "http_req_connecting": { + "http_req_tls_handshaking": { "type": "trend", "contains": "time", "values": { "min": 0, "med": 0, - "max": 221.2688, + "max": 0, "p(90)": 0, "p(95)": 0, - "avg": 0.08072584044584098 + "avg": 0 } }, - "error_rate": { + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.044675025219772305, + "passes": 930, + "fails": 19887 + } + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_100vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.0009772517509093871, + "passes": 18, + "fails": 18401 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "stage_500vu_error_rate": { "thresholds": { "rate<0.05": { "ok": true @@ -175,76 +228,117 @@ "type": "rate", "contains": "default", "values": { - "fails": 111801, - "rate": 0.04289798993254118, - "passes": 5011 + "rate": 0, + "passes": 0, + "fails": 0 } }, - "latency_ms": { + "iteration_duration": { "type": "trend", "contains": "time", "values": { - "avg": 1035.2635310789904, - "min": 9.2913, - "med": 722.947, - "max": 4459.4613, - "p(90)": 2587.6791200000002, - "p(95)": 2983.81523 + "avg": 464.076828636626, + "min": 8.4621, + "med": 337.696, + "max": 5745.176, + "p(90)": 998.7164, + "p(95)": 1486.51165 } }, - "iteration_duration": { + "http_req_sending": { + "contains": "time", "values": { - "avg": 1035.5725441161828, - "min": 9.794, - "med": 723.1911, - "max": 4460.3517, - "p(90)": 2588.0131300000003, - "p(95)": 2984.32758 + "min": 0, + "med": 0, + "max": 2.3792, + "p(90)": 0, + "p(95)": 0, + "avg": 0.009415669885189989 + }, + "type": "trend" + }, + "latency_ms": { + "values": { + "med": 337.48785, + "max": 5744.9292, + "p(90)": 998.5424, + "p(95)": 1485.903225, + "avg": 463.8637651662176, + "min": 8.462 }, "type": "trend", "contains": "time" }, - "http_req_duration{expected_response:true}": { - "contains": "time", + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", "values": { - "avg": 1035.2617010221381, - "min": 9.2913, - "med": 722.9712, - "max": 4459.4613, - "p(90)": 2587.6776400000003, - "p(95)": 2983.81496 + "rate": 0, + "passes": 0, + "fails": 0 }, - "type": "trend" + "thresholds": { + "rate<0.05": { + "ok": true + } + } }, - "iterations": { + "checks": { + "type": "rate", "contains": "default", "values": { - "count": 116812, - "rate": 233.23266208824631 + "rate": 0.9747790161414297, + "passes": 40582, + "fails": 1050 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "med": 337.4754, + "max": 5744.9292, + "p(90)": 998.5280600000002, + "p(95)": 1485.9025199999999, + "avg": 463.84924688956073, + "min": 8.462 }, - "type": "counter" + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 197, + "min": 0, + "max": 197 + } } }, "root_group": { + "checks": [ + { + "passes": 19886, + "fails": 930, + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1" + }, + { + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 20696, + "fails": 120, + "name": "latency < 3000ms" + } + ], "name": "", "path": "", "id": "d41d8cd98f00b204e9800998ecf8427e", - "groups": [], - "checks": [ - { - "name": "status 200", - "path": "::status 200", - "id": "fad9fa412b86fcb03bee97c80dcd61f1", - "passes": 116812, - "fails": 0 - }, - { - "passes": 111801, - "fails": 5011, - "name": "latency < 3000ms", - "path": "::latency < 3000ms", - "id": "d3208a4e8aa4a7e76e28b378bbeb21ce" - } - ] + "groups": [] } } \ No newline at end of file diff --git a/script/result/result-test2-stage-100vu-2.json b/script/result/result-test2-stage-100vu-2.json new file mode 100644 index 0000000..3d43c3c --- /dev/null +++ b/script/result/result-test2-stage-100vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "avg": 457.0563624581138, + "min": 13.4874, + "med": 345.1903, + "max": 4493.0597, + "p(90)": 975.3703, + "p(95)": 1241.89681 + }, + "errorRate": { + "rate": 0.0007383427046061226, + "passes": 13, + "fails": 17594 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-100vu.json b/script/result/result-test2-stage-100vu.json new file mode 100644 index 0000000..42794ed --- /dev/null +++ b/script/result/result-test2-stage-100vu.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "min": 8.462, + "med": 340.8044, + "max": 4730.8337, + "p(90)": 779.74658, + "p(95)": 1251.6379399999998, + "avg": 437.0904259948959 + }, + "errorRate": { + "rate": 0.0009772517509093871, + "passes": 18, + "fails": 18401 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-200vu-2.json b/script/result/result-test2-stage-200vu-2.json new file mode 100644 index 0000000..707892d --- /dev/null +++ b/script/result/result-test2-stage-200vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 1978.9006157559413, + "min": 13.53, + "med": 1966.41355, + "max": 8261.5448, + "p(90)": 3258.87942, + "p(95)": 3983.9422499999996 + }, + "errorRate": { + "rate": 0.14359225679980397, + "passes": 1172, + "fails": 6990 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-200vu.json b/script/result/result-test2-stage-200vu.json new file mode 100644 index 0000000..3f36fd2 --- /dev/null +++ b/script/result/result-test2-stage-200vu.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "p(90)": 2472.1483200000002, + "p(95)": 2952.373679999998, + "avg": 669.5951519816439, + "min": 10.1064, + "med": 76.8302, + "max": 5744.9292 + }, + "errorRate": { + "rate": 0.4305381727158949, + "passes": 1032, + "fails": 1365 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-300vu-2.json b/script/result/result-test2-stage-300vu-2.json new file mode 100644 index 0000000..77f83ad --- /dev/null +++ b/script/result/result-test2-stage-300vu-2.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "p(90)": 3250.21576, + "p(95)": 3729.77304, + "avg": 1636.3069204712822, + "min": 12.6129, + "med": 1477.4297, + "max": 6485.7606 + }, + "errorRate": { + "rate": 0.14138438880706922, + "passes": 288, + "fails": 1749 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-300vu.json b/script/result/result-test2-stage-300vu.json new file mode 100644 index 0000000..5924cc3 --- /dev/null +++ b/script/result/result-test2-stage-300vu.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "min": 5.3627, + "med": 63.597849999999994, + "max": 8747.4565, + "p(90)": 1748.8384300000002, + "p(95)": 2531.22286, + "avg": 408.5322729521228 + }, + "errorRate": { + "passes": 50941, + "fails": 8413, + "rate": 0.8582572362435557 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-400vu.json b/script/result/result-test2-stage-400vu.json new file mode 100644 index 0000000..2705528 --- /dev/null +++ b/script/result/result-test2-stage-400vu.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "p(90)": 131.39064999999997, + "p(95)": 1483.515249999999, + "avg": 256.0315454122147, + "min": 3.9399, + "med": 66.975, + "max": 15002.5695 + }, + "errorRate": { + "passes": 120020, + "fails": 6904, + "rate": 0.9456052440830733 + } +} \ No newline at end of file diff --git a/script/result/result-test2-stage-500vu.json b/script/result/result-test2-stage-500vu.json new file mode 100644 index 0000000..25836d3 --- /dev/null +++ b/script/result/result-test2-stage-500vu.json @@ -0,0 +1,16 @@ +{ + "vu": 500, + "latency": { + "avg": 849.5360995466923, + "min": 5.3775, + "med": 137.7371, + "max": 12597.1923, + "p(90)": 3533.4163, + "p(95)": 4734.8399 + }, + "errorRate": { + "rate": 0.9146985158659519, + "passes": 44190, + "fails": 4121 + } +} \ No newline at end of file diff --git a/script/result/result-test3-max-users-revised-1.json b/script/result/result-test3-max-users-revised-1.json new file mode 100644 index 0000000..0b508c0 --- /dev/null +++ b/script/result/result-test3-max-users-revised-1.json @@ -0,0 +1,368 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 61283, + "fails": 0 + }, + { + "fails": 3108, + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 58175 + } + ] + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 419958.1803 + }, + "metrics": { + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 61783, + "rate": 147.1170295000919 + } + }, + "http_req_tls_handshaking": { + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend" + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1147.446356384451, + "min": 7.4797, + "med": 984.0852, + "max": 7228.3906, + "p(90)": 2477.4257199999997, + "p(95)": 3000.9405199999997 + }, + "thresholds": { + "p(95)<3000": { + "ok": false + } + } + }, + "http_req_sending": { + "contains": "time", + "values": { + "min": 0, + "med": 0, + "max": 14.0986, + "p(90)": 0, + "p(95)": 0, + "avg": 0.009921591699982203 + }, + "type": "trend" + }, + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "fails": 14146, + "rate": 0.06928087374169353, + "passes": 1053 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "stage_400vu_latency": { + "values": { + "avg": 2184.037259406897, + "min": 9.1254, + "med": 2037.2873, + "max": 7228.3906, + "p(90)": 3753.29586, + "p(95)": 4260.74442 + }, + "type": "trend", + "contains": "time" + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "min": 7.4797, + "med": 984.0852, + "max": 7228.3906, + "p(90)": 2477.4257199999997, + "p(95)": 3000.9405199999997, + "avg": 1147.446356384451 + } + }, + "checks": { + "values": { + "rate": 0.9746422335721162, + "passes": 119458, + "fails": 3108 + }, + "type": "rate", + "contains": "default" + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1155.9542654912543, + "min": 7.4797, + "med": 987.587, + "max": 7228.3906, + "p(90)": 2479.3944600000004, + "p(95)": 3002.9133899999997 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 61283, + "rate": 145.92643476124712 + } + }, + "stage_100vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "passes": 6, + "fails": 19960, + "rate": 0.0003005108684764099 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_300vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "med": 1506.8908, + "max": 6242.6114, + "p(90)": 2749.51864, + "p(95)": 3239.5377799999997, + "avg": 1596.0461405552958, + "min": 8.2033 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 61783 + } + }, + "data_sent": { + "contains": "data", + "values": { + "count": 27890768, + "rate": 66413.20328627969 + }, + "type": "counter" + }, + "error_rate": { + "values": { + "passes": 3108, + "fails": 58175, + "rate": 0.050715532855767506 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + }, + "type": "rate", + "contains": "default" + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.19134662129314536, + "passes": 1968, + "fails": 8317 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "max": 62.1971, + "p(90)": 0, + "p(95)": 0, + "avg": 0.08041240308822814, + "min": 0, + "med": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 500, + "min": 500, + "max": 500 + } + }, + "stage_200vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "med": 998.0098, + "max": 4994.2516, + "p(90)": 1730.8779200000001, + "p(95)": 1993.4768399999998, + "avg": 1015.4606896671461, + "min": 8.8935 + } + }, + "stage_500vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_100vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 402.7560677802262, + "min": 7.4797, + "med": 286.3904, + "max": 4238.3995, + "p(90)": 956.65725, + "p(95)": 1030.56165 + } + }, + "http_req_connecting": { + "values": { + "avg": 0.07668177168476763, + "min": 0, + "med": 0, + "max": 62.1971, + "p(90)": 0, + "p(95)": 0 + }, + "type": "trend", + "contains": "time" + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "avg": 18.60727075894681, + "min": 0, + "med": 0.3768, + "max": 4241.1788, + "p(90)": 6.493060000000003, + "p(95)": 39.0664499999999 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "count": 61681644, + "rate": 146875.68165939118 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 400, + "min": 0, + "max": 400 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 2479.56598, + "p(95)": 3003.1990199999996, + "avg": 1156.1535051107078, + "min": 8.3165, + "med": 987.6403, + "max": 7228.395 + } + }, + "http_req_waiting": { + "contains": "time", + "values": { + "p(90)": 2466.39102, + "p(95)": 2990.5399399999997, + "avg": 1128.8291640338048, + "min": 7.2059, + "med": 976.738, + "max": 7227.6546 + }, + "type": "trend" + }, + "stage_200vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.005115897176782669, + "passes": 81, + "fails": 15752 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test3-max-users-revised.json b/script/result/result-test3-max-users-revised.json new file mode 100644 index 0000000..c2668b6 --- /dev/null +++ b/script/result/result-test3-max-users-revised.json @@ -0,0 +1,368 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 63809, + "fails": 0 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 60559, + "fails": 3250 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ], + "summaryTimeUnit": "", + "noColor": false + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 423969.9904 + }, + "metrics": { + "http_req_duration": { + "values": { + "avg": 1130.5311035033963, + "min": 8.4607, + "med": 974.4593, + "max": 7548.9183, + "p(90)": 2485.76554, + "p(95)": 3002.59646 + }, + "thresholds": { + "p(95)<3000": { + "ok": false + } + }, + "type": "trend", + "contains": "time" + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 64309, + "rate": 151.68290552670211 + } + }, + "http_req_connecting": { + "values": { + "min": 0, + "med": 0, + "max": 44.9252, + "p(90)": 0, + "p(95)": 0, + "avg": 0.07022190828655403 + }, + "type": "trend", + "contains": "time" + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 400, + "min": 0, + "max": 400 + } + }, + "stage_100vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.0009138569573373094, + "passes": 19, + "fails": 20772 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "checks": { + "contains": "default", + "values": { + "rate": 0.9745333730351518, + "passes": 124368, + "fails": 3250 + }, + "type": "rate" + }, + "iterations": { + "values": { + "count": 63809, + "rate": 150.50357677390934 + }, + "type": "counter", + "contains": "default" + }, + "stage_100vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "max": 5008.3, + "p(90)": 986.8648, + "p(95)": 1246.4201, + "avg": 386.1315893607812, + "min": 8.4607, + "med": 241.8182 + } + }, + "stage_200vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.8395, + "med": 983.13495, + "max": 5251.0866, + "p(90)": 1739.2049000000002, + "p(95)": 2002.04375, + "avg": 984.2043821804594 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "max": 19.5558, + "p(90)": 0, + "p(95)": 0, + "avg": 0.010356657699544377, + "min": 0, + "med": 0 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 5.115320000000011, + "p(95)": 33.208199999999856, + "avg": 16.79619285170021, + "min": 0, + "med": 0.4128, + "max": 2991.9117 + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.4607, + "med": 978.5741, + "max": 7548.9183, + "p(90)": 2487.6504600000003, + "p(95)": 3004.3980199999996, + "avg": 1138.5874364509698 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 500, + "min": 500, + "max": 500 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "max": 7548.9183, + "p(90)": 2487.7413600000004, + "p(95)": 3004.50132, + "avg": 1138.7850757965111, + "min": 8.6039, + "med": 978.6923 + } + }, + "stage_300vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1520.9256188318261, + "min": 8.7317, + "med": 1492.01835, + "max": 6004.8078, + "p(90)": 2530.6937900000003, + "p(95)": 2999.98567 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "avg": 1113.724553993999, + "min": 7.8342, + "med": 966.2663, + "max": 7548.9183, + "p(90)": 2476.70182, + "p(95)": 2993.28878 + } + }, + "stage_300vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.05003776435045317, + "passes": 795, + "fails": 15093 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.050933253929696436, + "passes": 3250, + "fails": 60559 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 2485.76554, + "p(95)": 3002.59646, + "avg": 1130.5311035033963, + "min": 8.4607, + "med": 974.4593, + "max": 7548.9183 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.07387390722915924, + "min": 0, + "med": 0, + "max": 45.264 + } + }, + "data_received": { + "values": { + "count": 64191662, + "rate": 151406.14537231173 + }, + "type": "counter", + "contains": "data" + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0, + "passes": 0, + "fails": 64309 + } + }, + "stage_400vu_latency": { + "type": "trend", + "contains": "time", + "values": { + "avg": 2258.8136778580765, + "min": 9.621, + "med": 2220.3412500000004, + "max": 7548.9183, + "p(90)": 3772.5111200000006, + "p(95)": 4495.190175 + } + }, + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.22123401889938854, + "passes": 2388, + "fails": 8406 + }, + "thresholds": { + "rate<0.05": { + "ok": false + } + } + }, + "stage_500vu_error_rate": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 0, + "rate": 0 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "stage_200vu_error_rate": { + "contains": "default", + "values": { + "rate": 0.002938295788442703, + "passes": 48, + "fails": 16288 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "data_sent": { + "contains": "data", + "values": { + "count": 29029672, + "rate": 68471.05374748712 + }, + "type": "counter" + } + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-100vu-1.json b/script/result/result-test3-stage-100vu-1.json new file mode 100644 index 0000000..05e85a7 --- /dev/null +++ b/script/result/result-test3-stage-100vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "avg": 402.7560677802262, + "min": 7.4797, + "med": 286.3904, + "max": 4238.3995, + "p(90)": 956.65725, + "p(95)": 1030.56165 + }, + "errorRate": { + "rate": 0.0003005108684764099, + "passes": 6, + "fails": 19960 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-100vu.json b/script/result/result-test3-stage-100vu.json new file mode 100644 index 0000000..47ec52a --- /dev/null +++ b/script/result/result-test3-stage-100vu.json @@ -0,0 +1,16 @@ +{ + "vu": 100, + "latency": { + "max": 5008.3, + "p(90)": 986.8648, + "p(95)": 1246.4201, + "avg": 386.1315893607812, + "min": 8.4607, + "med": 241.8182 + }, + "errorRate": { + "rate": 0.0009138569573373094, + "passes": 19, + "fails": 20772 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-200vu-1.json b/script/result/result-test3-stage-200vu-1.json new file mode 100644 index 0000000..acc80ce --- /dev/null +++ b/script/result/result-test3-stage-200vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 1015.4606896671461, + "min": 8.8935, + "med": 998.0098, + "max": 4994.2516, + "p(90)": 1730.8779200000001, + "p(95)": 1993.4768399999998 + }, + "errorRate": { + "rate": 0.005115897176782669, + "passes": 81, + "fails": 15752 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-200vu.json b/script/result/result-test3-stage-200vu.json new file mode 100644 index 0000000..8c1e417 --- /dev/null +++ b/script/result/result-test3-stage-200vu.json @@ -0,0 +1,16 @@ +{ + "vu": 200, + "latency": { + "avg": 984.2043821804594, + "min": 8.8395, + "med": 983.13495, + "max": 5251.0866, + "p(90)": 1739.2049000000002, + "p(95)": 2002.04375 + }, + "errorRate": { + "rate": 0.002938295788442703, + "passes": 48, + "fails": 16288 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-300vu-1.json b/script/result/result-test3-stage-300vu-1.json new file mode 100644 index 0000000..75bbd0a --- /dev/null +++ b/script/result/result-test3-stage-300vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "p(90)": 2749.51864, + "p(95)": 3239.5377799999997, + "avg": 1596.0461405552958, + "min": 8.2033, + "med": 1506.8908, + "max": 6242.6114 + }, + "errorRate": { + "fails": 14146, + "rate": 0.06928087374169353, + "passes": 1053 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-300vu.json b/script/result/result-test3-stage-300vu.json new file mode 100644 index 0000000..a01d4f9 --- /dev/null +++ b/script/result/result-test3-stage-300vu.json @@ -0,0 +1,16 @@ +{ + "vu": 300, + "latency": { + "min": 8.7317, + "med": 1492.01835, + "max": 6004.8078, + "p(90)": 2530.6937900000003, + "p(95)": 2999.98567, + "avg": 1520.9256188318261 + }, + "errorRate": { + "fails": 15093, + "rate": 0.05003776435045317, + "passes": 795 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-400vu-1.json b/script/result/result-test3-stage-400vu-1.json new file mode 100644 index 0000000..713536a --- /dev/null +++ b/script/result/result-test3-stage-400vu-1.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "p(90)": 3753.29586, + "p(95)": 4260.74442, + "avg": 2184.037259406897, + "min": 9.1254, + "med": 2037.2873, + "max": 7228.3906 + }, + "errorRate": { + "rate": 0.19134662129314536, + "passes": 1968, + "fails": 8317 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-400vu.json b/script/result/result-test3-stage-400vu.json new file mode 100644 index 0000000..2afe3a6 --- /dev/null +++ b/script/result/result-test3-stage-400vu.json @@ -0,0 +1,16 @@ +{ + "vu": 400, + "latency": { + "avg": 2258.8136778580765, + "min": 9.621, + "med": 2220.3412500000004, + "max": 7548.9183, + "p(90)": 3772.5111200000006, + "p(95)": 4495.190175 + }, + "errorRate": { + "passes": 2388, + "fails": 8406, + "rate": 0.22123401889938854 + } +} \ No newline at end of file diff --git a/script/result/result-test3-stage-500vu.json b/script/result/result-test3-stage-500vu.json new file mode 100644 index 0000000..86ddc0e --- /dev/null +++ b/script/result/result-test3-stage-500vu.json @@ -0,0 +1,16 @@ +{ + "vu": 500, + "latency": { + "avg": 1968.85571906203, + "min": 19.8524, + "med": 1762.5228, + "max": 7738.5768, + "p(90)": 3748.6863, + "p(95)": 4444.191539999992 + }, + "errorRate": { + "fails": 2822, + "rate": 0.14614220877458398, + "passes": 483 + } +} \ No newline at end of file diff --git a/script/test2-max-users.js b/script/test2-max-users.js index d503973..c65eaae 100644 --- a/script/test2-max-users.js +++ b/script/test2-max-users.js @@ -6,39 +6,72 @@ import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; // 환경변수 // 실행 방법: // k6 run --env NGINX_IP=34.64.xxx.xxx test2-max-users.js -// ⚠️ 이 스크립트는 아카이브용으며, test2-max-users-revised.js로 대체되었습니다. const BASE_URL = `http://${__ENV.NGINX_IP}`; const errorRate = new Rate('error_rate'); const latency = new Trend('latency_ms', true); +// 단계별 메트릭 +const stage100Latency = new Trend('stage_100vu_latency', true); +const stage200Latency = new Trend('stage_200vu_latency', true); +const stage300Latency = new Trend('stage_300vu_latency', true); +const stage400Latency = new Trend('stage_400vu_latency', true); +const stage500Latency = new Trend('stage_500vu_latency', true); + +const stage100Errors = new Rate('stage_100vu_error_rate'); +const stage200Errors = new Rate('stage_200vu_error_rate'); +const stage300Errors = new Rate('stage_300vu_error_rate'); +const stage400Errors = new Rate('stage_400vu_error_rate'); +const stage500Errors = new Rate('stage_500vu_error_rate'); + export const options = { stages: [ { duration: '20s', target: 100 }, // Ramp-Up → 100명 { duration: '1m', target: 100 }, // 유지 { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 { duration: '20s', target: 200 }, // Ramp-Up → 200명 { duration: '1m', target: 200 }, // 유지 { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 { duration: '20s', target: 300 }, // Ramp-Up → 300명 { duration: '1m', target: 300 }, // 유지 { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 { duration: '20s', target: 400 }, // Ramp-Up → 400명 { duration: '1m', target: 400 }, // 유지 { duration: '20s', target: 0 }, // Ramp-Down + { duration: '10s', target: 0 }, // 대기 { duration: '20s', target: 500 }, // Ramp-Up → 500명 { duration: '1m', target: 500 }, // 유지 { duration: '20s', target: 0 }, // Ramp-Down ], thresholds: { - 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], - 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'stage_100vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_200vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_300vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_400vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_500vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], }, }; +// 단계 판별 함수 (경과 시간 기준) +// 각 단계: Ramp-Up 20s + 유지 60s + Ramp-Down 20s + 대기 10s = 110s +function getCurrentStage(elapsedSeconds) { + if (elapsedSeconds < 110) return 100; + if (elapsedSeconds < 220) return 200; + if (elapsedSeconds < 330) return 300; + if (elapsedSeconds < 440) return 400; + return 500; +} + // setup()에서 토큰 1개 발급 후 전체 VU 재사용 export function setup() { + console.log(`테스트 시작: ${new Date().toISOString()}`); + const res = http.post( `${BASE_URL}/api/v1/auth/login`, JSON.stringify({ username: 'user00001', password: 'password' }), @@ -51,7 +84,7 @@ export function setup() { } console.log('토큰 발급 완료 (최대 동시 접속자 수 탐색)'); - return { token: body.data.accessToken }; + return { token: body.data.accessToken, startTime: Date.now() }; } export default function (data) { @@ -62,18 +95,92 @@ export default function (data) { const ok = check(res, { 'status 200': (r) => r.status === 200, - 'latency < 3000ms': (r) => r.timings.duration < 3000, + 'latency < 3000ms': (r) => r.timings?.duration < 3000, }); - latency.add(res.timings.duration); + if (res.timings) { + latency.add(res.timings.duration); + } errorRate.add(!ok); + + // 단계별 메트릭 기록 + const elapsedSeconds = (Date.now() - data.startTime) / 1000; + const stage = getCurrentStage(elapsedSeconds); + const duration = res.timings?.duration; + + switch (stage) { + case 100: + if (duration !== undefined) stage100Latency.add(duration); + stage100Errors.add(!ok); + break; + case 200: + if (duration !== undefined) stage200Latency.add(duration); + stage200Errors.add(!ok); + break; + case 300: + if (duration !== undefined) stage300Latency.add(duration); + stage300Errors.add(!ok); + break; + case 400: + if (duration !== undefined) stage400Latency.add(duration); + stage400Errors.add(!ok); + break; + case 500: + if (duration !== undefined) stage500Latency.add(duration); + stage500Errors.add(!ok); + break; + } +} + +export function teardown(data) { + console.log(`테스트 종료: ${new Date().toISOString()}`); } export function handleSummary(data) { const { setup_data, ...rest } = data; + // 단계별 결과 요약 출력 + const stages = [100, 200, 300, 400, 500]; + let stageSummary = '\n===== 단계별 결과 요약 =====\n'; + + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageSummary += `\n[${vu} VU]\n`; + stageSummary += ` AVG: ${(l.values.avg).toFixed(2)}ms\n`; + stageSummary += ` P90: ${(l.values['p(90)']).toFixed(2)}ms\n`; + stageSummary += ` P95: ${(l.values['p(95)']).toFixed(2)}ms\n`; + stageSummary += ` MAX: ${(l.values.max).toFixed(2)}ms\n`; + stageSummary += ` 에러율: ${(e.values.rate * 100).toFixed(2)}%\n`; + } + }); + + stageSummary += '\n============================\n'; + + // 단계별 메트릭 분리 저장 + const stageResults = {}; + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageResults[`result/result-test2-stage-${vu}vu.json`] = JSON.stringify({ + vu, + latency: l.values, + errorRate: e.values, + }, null, 2); + } + }); + return { 'result/result-test2-max-users.json': JSON.stringify(rest, null, 2), - stdout: textSummary(data, { indent: ' ', enableColors: true }), + ...stageResults, + stdout: textSummary(data, { indent: ' ', enableColors: true }) + stageSummary, }; } \ No newline at end of file diff --git a/script/test2-max-users-revised.js b/script/test3-max-users-revised.js similarity index 84% rename from script/test2-max-users-revised.js rename to script/test3-max-users-revised.js index 9c19eee..3f0ac41 100644 --- a/script/test2-max-users-revised.js +++ b/script/test3-max-users-revised.js @@ -7,7 +7,7 @@ import { SharedArray } from 'k6/data'; // 환경변수 // 실행 방법: -// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test2-max-users.js +// k6 run --env NGINX_IP=34.64.xxx.xxx --env ACCOUNTS_FILE=./p_user.csv test3-max-users-revised.js const BASE_URL = `http://${__ENV.NGINX_IP}`; @@ -35,6 +35,7 @@ const stage400Errors = new Rate('stage_400vu_error_rate'); const stage500Errors = new Rate('stage_500vu_error_rate'); export const options = { + setupTimeout: '3m', stages: [ { duration: '20s', target: 100 }, // Ramp-Up → 100명 { duration: '1m', target: 100 }, // 유지 @@ -53,8 +54,13 @@ export const options = { { duration: '20s', target: 0 }, // Ramp-Down ], thresholds: { - 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], - 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'error_rate': [{ threshold: 'rate<0.05', abortOnFail: true }], + 'http_req_duration': [{ threshold: 'p(95)<3000', abortOnFail: true }], + 'stage_100vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_200vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_300vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_400vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], + 'stage_500vu_error_rate': [{ threshold: 'rate<0.05', abortOnFail: false }], }, }; @@ -192,8 +198,26 @@ export function handleSummary(data) { stageSummary += '\n============================\n'; + // 단계별 메트릭 분리 저장 + const stageResults = {}; + stages.forEach(vu => { + const latencyKey = `stage_${vu}vu_latency`; + const errorKey = `stage_${vu}vu_error_rate`; + const l = data.metrics[latencyKey]; + const e = data.metrics[errorKey]; + + if (l && e) { + stageResults[`result/result-test3-stage-${vu}vu.json`] = JSON.stringify({ + vu, + latency: l.values, + errorRate: e.values, + }, null, 2); + } + }); + return { - 'result/result-test2-max-users-revised.json': JSON.stringify(rest, null, 2), + 'result/result-test3-max-users-revised.json': JSON.stringify(rest, null, 2), + ...stageResults, stdout: textSummary(data, { indent: ' ', enableColors: true }) + stageSummary, }; } \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java index b410353..a8dc267 100644 --- a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java +++ b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java @@ -1,32 +1,27 @@ package org.pgsg.gateway.auth; import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import lombok.extern.slf4j.Slf4j; +import org.pgsg.gateway.cache.CacheUtil; import org.pgsg.gateway.client.AuthClient; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; -import java.util.concurrent.TimeUnit; - @Slf4j @Component public class AuthProviderImpl implements AuthProvider { - private final Cache tokenCache; + private final Cache tokenVerifyCache; private final AuthClient authClient; - public AuthProviderImpl(AuthClient authClient) { + public AuthProviderImpl(AuthClient authClient, CacheUtil cacheUtil) { this.authClient = authClient; - this.tokenCache = Caffeine.newBuilder() - .expireAfterWrite(30, TimeUnit.SECONDS) - .maximumSize(10000) - .build(); + this.tokenVerifyCache = cacheUtil.getTokenVerifyCache(); } @Override public Mono verifyToken(String accessToken) { - Boolean cachedResult = tokenCache.getIfPresent(accessToken); + Boolean cachedResult = tokenVerifyCache.getIfPresent(accessToken); if (cachedResult != null) { return Mono.just(cachedResult); @@ -37,7 +32,7 @@ public Mono verifyToken(String accessToken) { && response.success() && response.data() != null && response.data().isVerifiedToken()) - .doOnNext(result -> tokenCache.put(accessToken, result)) + .doOnNext(result -> tokenVerifyCache.put(accessToken, result)) .onErrorReturn(false); } } diff --git a/src/main/java/org/pgsg/gateway/cache/CacheUtil.java b/src/main/java/org/pgsg/gateway/cache/CacheUtil.java new file mode 100644 index 0000000..493cffd --- /dev/null +++ b/src/main/java/org/pgsg/gateway/cache/CacheUtil.java @@ -0,0 +1,35 @@ +package org.pgsg.gateway.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class CacheUtil { + + private final Cache tokenVerifyCache; + private final Cache claimsCache; + + public CacheUtil() { + this.tokenVerifyCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10_000) + .build(); + + this.claimsCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10_000) + .build(); + } + + public Cache getTokenVerifyCache() { + return tokenVerifyCache; + } + + public Cache getClaimsCache() { + return claimsCache; + } +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/client/AuthClient.java b/src/main/java/org/pgsg/gateway/client/AuthClient.java index 127ed7f..b2be6ba 100644 --- a/src/main/java/org/pgsg/gateway/client/AuthClient.java +++ b/src/main/java/org/pgsg/gateway/client/AuthClient.java @@ -1,11 +1,18 @@ package org.pgsg.gateway.client; +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; import org.pgsg.common.response.CommonResponse; import org.pgsg.gateway.auth.AuthDto; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; //@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) @Component @@ -14,7 +21,18 @@ public class AuthClient { private final WebClient webClient; public AuthClient(WebClient.Builder builder) { - this.webClient = builder.baseUrl("lb://user-service").build(); + // WebClient 커넥션 풀 타임아웃 설정 추가 + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofSeconds(5)) + .doOnConnected(conn -> conn + .addHandlerLast(new ReadTimeoutHandler(5)) + .addHandlerLast(new WriteTimeoutHandler(5))); + + this.webClient = builder + .baseUrl("lb://user-service") + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); } public Mono> verifyToken(AuthDto.TokenVerifyRequest request) { diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index b16a4d1..ee80e87 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -1,6 +1,7 @@ package org.pgsg.gateway.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.micrometer.tracing.Tracer; @@ -10,190 +11,207 @@ import org.pgsg.config.security.token.TokenProvider; import org.pgsg.config.security.token.TokenType; import org.pgsg.gateway.auth.AuthProvider; +import org.pgsg.gateway.cache.CacheUtil; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.stereotype.Component; -import org.springframework.util.AntPathMatcher; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; +import java.util.*; +import java.util.stream.Stream; @Slf4j @Component public class JwtGatewayFilter implements GlobalFilter, Ordered { - private static final String HEADER_TRACE_ID = "X-Trace-Id"; - private static final AntPathMatcher pathMatcher = new AntPathMatcher(); - private static final List WHITELIST = List.of( - "/api/v1/auth/login", - "/api/v1/auth/signup", - "/api/v1/auth/reissue", - "/actuator/health", - "/actuator/health/**", - "/actuator/info", - "/actuator/prometheus", - "/actuator/prometheus/**", - "/actuator/metrics", - "/actuator/metrics/**" - ); - - private final Tracer tracer; - private final TokenProvider jwtTokenProvider; - private final AuthProvider authProvider; - private final ObjectMapper objectMapper; - - public JwtGatewayFilter(Tracer tracer, TokenProvider jwtTokenProvider, AuthProvider authProvider, ObjectMapper objectMapper) { - this.tracer = tracer; - this.jwtTokenProvider = jwtTokenProvider; - this.authProvider = authProvider; - this.objectMapper = objectMapper; - } - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - ServerHttpRequest request = exchange.getRequest(); - String path = request.getURI().getPath(); - String traceId = resolveTraceId(); - - // 헤더 초기화: x-user-* 제거 + traceId 주입 - ServerHttpRequest sanitized = request.mutate() - .headers(headers -> { - headers.keySet().removeIf(key -> key.toLowerCase().startsWith("x-user-")); - headers.set(HEADER_TRACE_ID, traceId); - }) - .build(); - - log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), path); - - // 화이트리스트 통과 - if (isWhitelisted(path)) { - return chain.filter(exchange.mutate().request(sanitized).build()); - } - - // 토큰 누락 - String accessToken = JwtUtils.resolveToken( - request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); - - if (accessToken == null) { - log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); - return onAuthError(exchange, "Access 토큰이 필요합니다.", traceId); - } - - return authenticate(exchange, sanitized, chain, accessToken, traceId); - } - - private Mono authenticate(ServerWebExchange exchange, ServerHttpRequest sanitized, - GatewayFilterChain chain, String accessToken, String traceId) { - // [Step 1] 로컬 검증 - return Mono.fromCallable(() -> jwtTokenProvider.validateToken(accessToken)) - .subscribeOn(Schedulers.boundedElastic()) - .flatMap(valid -> { - if (!valid) { - log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); - return Mono.error(new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); - } - // [Step 2] 원격 검증 (WebClient 비동기 호출) - return authProvider.verifyToken(accessToken); - }) - .flatMap(verified -> { - if (!verified) { - log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); - return Mono.error(new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); - } - // [Step 3] Claims 파싱 - return Mono.fromCallable(() -> jwtTokenProvider.parseClaims(accessToken)) - .subscribeOn(Schedulers.boundedElastic()); - }) - .flatMap(claims -> { - String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); - if (!TokenType.ACCESS.matches(tokenType)) { - log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); - return Mono.error(new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); - } - // [Step 4] 사용자 헤더 주입 - ServerHttpRequest mutated = injectUserHeaders(sanitized, claims); - log.info("[JwtGatewayFilter] 인증 성공 (TraceID: {})", traceId); - return chain.filter(exchange.mutate().request(mutated).build()); - }) - .onErrorResume(InsufficientAuthenticationException.class, - e -> onAuthError(exchange, e.getMessage(), traceId)) - .onErrorResume(JwtException.class, e -> { - log.error("[JwtGatewayFilter] JWT 예외: {} (TraceID: {})", e.getMessage(), traceId); - return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); - }) - .onErrorResume(IllegalArgumentException.class, e -> { - log.error("[JwtGatewayFilter] 잘못된 인자: {} (TraceID: {})", e.getMessage(), traceId); - return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); - }); - } - - private Mono onAuthError(ServerWebExchange exchange, String message, String traceId) { - ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.UNAUTHORIZED); - response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - - CommonResponse errorResponse = new CommonResponse<>( - false, - message, - null, - traceId - ); - - try { - byte[] body = objectMapper.writeValueAsBytes(errorResponse); - return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); - } catch (Exception e) { - log.error("[JwtGatewayFilter] JSON 직렬화 오류 (TraceID: {})", traceId, e); - return Mono.error(e); - } - } - - private ServerHttpRequest injectUserHeaders(ServerHttpRequest request, Claims claims) { - Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); - return request.mutate() - .header(JwtUtils.HEADER_USER_ID, claims.getSubject()) - .header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)) - .header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)) - .header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))) - .header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))) - .header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false") - .build(); - } - - private boolean isWhitelisted(String path) { - return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); - } - - private String resolveTraceId() { - if (tracer.currentSpan() != null) { - return Objects.requireNonNull(tracer.currentSpan()).context().traceId(); - } - return UUID.randomUUID().toString().substring(0, 8); - } - - private String encodeValue(String value) { - return Optional.ofNullable(value) - .map(v -> URLEncoder.encode(v, StandardCharsets.UTF_8)) - .orElse(null); - } - - @Override - public int getOrder() { - return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; - } + private static final String HEADER_TRACE_ID = "X-Trace-Id"; + private static final List WHITELIST = Stream.of( + "/api/v1/auth/login", + "/api/v1/auth/signup", + "/api/v1/auth/reissue", + "/actuator/health", + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", + "/actuator/metrics", + "/actuator/metrics/**" + ).map(PathPatternParser.defaultInstance::parse) + .toList(); + + private final Tracer tracer; + private final TokenProvider jwtTokenProvider; + private final AuthProvider authProvider; + private final ObjectMapper objectMapper; + private final Cache claimsCache; + + public JwtGatewayFilter(Tracer tracer, TokenProvider jwtTokenProvider, + AuthProvider authProvider, ObjectMapper objectMapper, + CacheUtil cacheUtil) { + this.tracer = tracer; + this.jwtTokenProvider = jwtTokenProvider; + this.authProvider = authProvider; + this.objectMapper = objectMapper; + this.claimsCache = cacheUtil.getClaimsCache(); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + String traceId = resolveTraceId(); + + // 헤더 초기화: x-user-* 제거 + traceId 주입 + ServerHttpRequest sanitized = request.mutate() + .headers(headers -> { + headers.keySet().removeIf(key -> key.toLowerCase().startsWith("x-user-")); + headers.set(HEADER_TRACE_ID, traceId); + }) + .build(); + + log.debug("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), path); + + // 화이트리스트 통과 + if (isWhitelisted(path)) { + return chain.filter(exchange.mutate().request(sanitized).build()); + } + + // 토큰 누락 + String accessToken = JwtUtils.resolveToken( + request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + + if (accessToken == null) { + log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); + return onAuthError(exchange, "Access 토큰이 필요합니다.", traceId); + } + + return authenticate(exchange, sanitized, chain, accessToken, traceId); + } + + private Mono authenticate(ServerWebExchange exchange, ServerHttpRequest sanitized, + GatewayFilterChain chain, String accessToken, String traceId) { + // [Step 1] 로컬 검증 - 만료되지 않았더라도 검증은 수행 + Claims cachedClaims = claimsCache.getIfPresent(accessToken); + Mono localValidation = (cachedClaims != null && !isExpired(cachedClaims)) + ? Mono.just(true) + : Mono.fromCallable(() -> jwtTokenProvider.validateToken(accessToken)); + + return localValidation + .flatMap(valid -> { + if (!valid) { + return Mono.error(new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); + } + // [Step 2] 블랙리스트 검증 (항상 수행) + return authProvider.verifyToken(accessToken); + }) + .flatMap(verified -> { + if (!verified) { + return Mono.error(new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); + } + // [Step 3] Claims 파싱 (캐시 HIT 시 생략) + Claims cached = claimsCache.getIfPresent(accessToken); + if (cached != null) { + log.debug("[JwtGatewayFilter] 캐시 HIT (TraceID: {})", traceId); + return Mono.just(cached); + } + return Mono.fromCallable(() -> jwtTokenProvider.parseClaims(accessToken)) + .doOnNext(claims -> claimsCache.put(accessToken, claims)); + }) + .flatMap(claims -> { + String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); + if (!TokenType.ACCESS.matches(tokenType)) { + log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); + return Mono.error(new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + } + // [Step 4] 사용자 헤더 주입 + ServerHttpRequest mutated = injectUserHeaders(sanitized, claims); + log.debug("[JwtGatewayFilter] 인증 성공 (TraceID: {})", traceId); + return chain.filter(exchange.mutate().request(mutated).build()); + }) + .onErrorResume(InsufficientAuthenticationException.class, + e -> onAuthError(exchange, e.getMessage(), traceId)) + .onErrorResume(JwtException.class, e -> { + log.error("[JwtGatewayFilter] JWT 예외: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }) + .onErrorResume(IllegalArgumentException.class, e -> { + log.error("[JwtGatewayFilter] 잘못된 인자: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }); + } + + private boolean isExpired(Claims claims) { + Date expiration = claims.getExpiration(); + return expiration != null && expiration.before(new Date()); + } + + private Mono onAuthError(ServerWebExchange exchange, String message, String traceId) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + CommonResponse errorResponse = new CommonResponse<>( + false, + message, + null, + traceId + ); + + try { + byte[] body = objectMapper.writeValueAsBytes(errorResponse); + return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); + } catch (Exception e) { + log.error("[JwtGatewayFilter] JSON 직렬화 오류 (TraceID: {})", traceId, e); + return Mono.error(e); + } + } + + private ServerHttpRequest injectUserHeaders(ServerHttpRequest request, Claims claims) { + Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); + return request.mutate() + .header(JwtUtils.HEADER_USER_ID, claims.getSubject()) + .header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)) + .header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)) + .header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))) + .header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))) + .header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false") + .build(); + } + + private boolean isWhitelisted(String path) { + PathContainer pathContainer = PathContainer.parsePath(path); + return WHITELIST.stream().anyMatch(pattern -> pattern.matches(pathContainer)); + } + + private String resolveTraceId() { + if (tracer.currentSpan() != null) { + return Objects.requireNonNull(tracer.currentSpan()).context().traceId(); + } + return UUID.randomUUID().toString().substring(0, 8); + } + + private String encodeValue(String value) { + return Optional.ofNullable(value) + .map(v -> URLEncoder.encode(v, StandardCharsets.UTF_8)) + .orElse(null); + } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; + } } From 9dfd41950480968845a88f9547032e3e0a49d918 Mon Sep 17 00:00:00 2001 From: Hyeonbin2379 Date: Tue, 19 May 2026 10:57:04 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[TASK]=20:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=EB=B6=80=ED=95=98=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=B6=94=EA=B0=80=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test : 게이트웨이 부하테스트 결과 추가 - user-service 스케일아웃 이후 게이트웨이 부하테스트 결과 저장 * docs : 게이트웨이 README 내용 업데이트 - WebFlux 기반 필터로 교체한 이후의 내용 반영 --- README.md | 33 +- script/result/result-test1-baseline.json | 250 +++++++++ .../result-test3-max-users-revised.json | 480 +++++++++--------- script/result/result-test3-stage-100vu.json | 18 +- script/result/result-test3-stage-200vu.json | 18 +- script/result/result-test3-stage-300vu.json | 18 +- script/result/result-test3-stage-400vu.json | 18 +- script/result/result-test3-stage-500vu.json | 18 +- script/test1-baseline.js | 2 +- 9 files changed, 562 insertions(+), 293 deletions(-) create mode 100644 script/result/result-test1-baseline.json diff --git a/README.md b/README.md index 29b5a6f..250fcb3 100644 --- a/README.md +++ b/README.md @@ -5,28 +5,35 @@ PGSG 마이크로서비스 아키텍처의 강력한 보안 입구이자 통합 ## 🌟 핵심 기능 (Core Capabilities) ### 1. 보안 입구 정책 (Entry Gate Security) -- **Servlet-based Filter 강제**: `OncePerRequestFilter` 기반의 아키텍처를 채택하여 라우팅 설정과 무관하게 모든 요청에 대한 보안 검사를 강제합니다. -- **헤더 스푸핑(Spoofing) 원천 차단**: 진입 시점에 외부 유입 헤더(`x-user-*`)를 즉시 제거하고 검증된 데이터만 다시 주입하는 선제 방어 시스템을 갖추고 있습니다. -- **실시간 이중 검증**: 게이트웨이의 **로컬 JWT 서명 검증**과 유저 서비스의 **원격 블랙리스트 확인**을 결합한 하이브리드 인증 체계를 구축했습니다. -- **인증 성능 최적화**: 원격 검증 부하를 최소화하기 위해 로컬 캐시(TTL 30s)가 적용되어 있습니다. +- **Reactive WebFlux 기반 설계**: `GlobalFilter` 기반의 비동기 논블로킹 아키텍처를 채택하여 고성능 요청 처리를 보장하며, 모든 라우팅 경로에 대해 보안 검사를 강제합니다. +- **헤더 스푸핑(Spoofing) 원천 차단**: 요청 진입 시점에 외부 유입 헤더(`x-user-*`)를 즉시 제거(Sanitize)하고, 검증된 인증 데이터만 다시 주입하는 선제적 보안 시스템을 갖추고 있습니다. +- **실시간 이중 검증 하이브리드 인증**: 게이트웨이의 **로컬 JWT 서명 검증**과 유저 서비스의 **원격 블랙리스트 확인**을 결합하여 보안성을 극대화했습니다. +- **인증 성능 최적화 (Dual Caching)**: Caffeine Cache를 활용하여 토큰 검증 결과와 파싱된 Claims 정보를 각각 로컬 캐싱(TTL 30s)함으로써 원격 서비스 호출 부하를 획기적으로 낮췄습니다. ### 2. 정밀한 분산 추적 (Distributed Tracing) -- **Trace ID 동기화 아키텍처**: Zipkin(`Tracer`)이 생성한 표준 ID를 로그(`MDC`) 및 요청 헤더(`X-Trace-Id`)와 100% 동기화합니다. -- **전 구간 가시성**: 게이트웨이부터 하위 마이크로서비스까지 하나의 고유 ID(Single Source of Truth)로 모든 실행 로그를 연결하여 장애 추적 시간을 획기적으로 단축했습니다. +- **Trace ID 동기화 아키텍처**: Micrometer Tracing(Brave)이 생성한 표준 ID를 로그 및 요청 헤더(`X-Trace-Id`)와 100% 동기화합니다. +- **전 구간 가시성**: 게이트웨이부터 하위 마이크로서비스까지 하나의 고유 ID(Single Source of Truth)로 모든 실행 로그를 연결하여 복잡한 분산 환경에서의 장애 추적 시간을 단축했습니다. ### 3. 표준화된 장애 및 에러 대응 -- **통합 에러 핸들링**: `CustomAuthenticationEntryPoint`를 통해 어떤 인증 실패 상황에서도 공통 모듈의 `ErrorResponse` 규격에 맞는 정교한 JSON 응답을 반환합니다. -- **장애 내성 (Resilience)**: `AuthClientFallbackFactory`를 구현하여 유저 서비스 장애 시에도 게이트웨이가 패닉 없이 안전하게 대응(Fail-Safe)합니다. +- **통합 에러 핸들링**: `JwtGatewayFilter` 내에서 발생하는 모든 인증 실패 및 예외 상황에 대해 공통 모듈의 `CommonResponse` 규격에 맞는 정교한 JSON 응답을 반환합니다. +- **비동기 장애 내성 (Resilience)**: `WebClient`에 커넥션 풀 및 Read/Write 타임아웃 설정을 적용하고, 원격 서비스 장애 시 패닉 없이 안전한 에러 응답을 반환(Fail-Safe)하도록 설계되었습니다. ### 4. 시스템 최적화 (System Optimization) -- **의존성 격리**: DB를 사용하지 않는 게이트웨이 특성에 맞춰 `@ImportAutoConfiguration`을 통해 불필요한 JPA/DB 설정을 완벽히 제거하고 실행 컨텍스트를 경량화했습니다. +- **의존성 격리 및 경량화**: DB를 사용하지 않는 게이트웨이 특성에 맞춰 JPA/DB 관련 자동 설정을 제외하고, 비동기 기반의 가벼운 실행 컨텍스트를 유지합니다. + +### 5. 인프라 자동화 및 관측성 (Infrastructure & Observability) +- **CI/CD 파이프라인**: GitHub Actions를 통해 Docker 이미지 빌드부터 GCP Artifact Registry 푸시, VM 배포까지 일련의 과정이 구축되어 있습니다. +- **안전한 배포 및 롤백**: 배포 후 Actuator 헬스체크 실패 시, 즉시 이전의 안정적인(`stable`) 태그 이미지로 자동 롤백하는 Fail-Safe 로직을 갖추고 있습니다. +- **유연한 스케일링 (Manual Trigger)**: GitHub Actions 워크플로우(`_scale.yaml`)를 통해 필요시 수동으로 게이트웨이 서버(2, 3번 노드)를 Scale-In / Scale-Out 할 수 있는 자동화 스크립트를 제공합니다. +- **통합 로그 수집 및 로드밸런싱**: Docker Compose를 통해 Promtail을 함께 배포하여 Loki로 로그를 중앙 집중화하며, Nginx(`least_conn`)를 활용해 다중 게이트웨이 인스턴스로 트래픽을 효율적으로 분산합니다. ## 🛠 기술 스택 - **Runtime**: Java 21 / Spring Boot 3.5.13 -- **Gateway**: Spring Cloud Gateway MVC (Servlet) -- **Security**: Spring Security 6.x -- **Tracing**: Micrometer Tracing (Zipkin Ready) -- **Client**: Spring Cloud OpenFeign +- **Gateway**: Spring Cloud Gateway (WebFlux) +- **Security**: Spring Security 6.x (Reactive) +- **Tracing**: Micrometer Tracing (Brave) +- **Client**: Spring WebFlux WebClient +- **Cache**: Caffeine Cache ## 📂 주요 문서 - [상세 개선 보고서](./docs/gateway-server-improvement-summary.md): 기술적 해결 방안 및 리팩토링 상세 내역 diff --git a/script/result/result-test1-baseline.json b/script/result/result-test1-baseline.json new file mode 100644 index 0000000..6823a71 --- /dev/null +++ b/script/result/result-test1-baseline.json @@ -0,0 +1,250 @@ +{ + "root_group": { + "groups": [], + "checks": [ + { + "name": "status 200", + "path": "::status 200", + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 179957, + "fails": 0 + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 178679, + "fails": 1278 + } + ], + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + }, + "options": { + "summaryTimeUnit": "", + "noColor": false, + "summaryTrendStats": [ + "avg", + "min", + "med", + "max", + "p(90)", + "p(95)" + ] + }, + "state": { + "isStdOutTTY": true, + "isStdErrTTY": true, + "testRunDurationMs": 362737.3467 + }, + "metrics": { + "http_req_connecting": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01076151883144622, + "min": 0, + "med": 0, + "max": 46.6748 + } + }, + "data_received": { + "type": "counter", + "contains": "data", + "values": { + "rate": 496463.5255738888, + "count": 180085862 + } + }, + "http_reqs": { + "type": "counter", + "contains": "default", + "values": { + "count": 180257, + "rate": 496.93532149332447 + } + }, + "http_req_duration": { + "type": "trend", + "contains": "time", + "values": { + "avg": 525.052223189116, + "min": 8.3346, + "med": 145.608, + "max": 6965.0928, + "p(90)": 1512.8542599999998, + "p(95)": 1963.1354599999995 + }, + "thresholds": { + "p(95)<3000": { + "ok": true + } + } + }, + "latency_ms": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.3346, + "med": 146.5089, + "max": 6965.0928, + "p(90)": 1513.0836, + "p(95)": 1963.53668, + "avg": 525.7460906655506 + } + }, + "http_req_receiving": { + "type": "trend", + "contains": "time", + "values": { + "med": 0.0202, + "max": 4487.403, + "p(90)": 2.485979999999996, + "p(95)": 7.3518, + "avg": 7.675597563478827, + "min": 0 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 27, + "min": 0, + "max": 300 + } + }, + "http_req_sending": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 0, + "p(95)": 0, + "avg": 0.01132287900053807, + "min": 0, + "med": 0, + "max": 12.0435 + } + }, + "http_req_failed": { + "type": "rate", + "contains": "default", + "values": { + "passes": 0, + "fails": 180257, + "rate": 0 + } + }, + "vus_max": { + "type": "gauge", + "contains": "default", + "values": { + "value": 300, + "min": 300, + "max": 300 + } + }, + "checks": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.996449151741805, + "passes": 358636, + "fails": 1278 + } + }, + "iteration_duration": { + "type": "trend", + "contains": "time", + "values": { + "p(90)": 1513.33932, + "p(95)": 1963.746219999999, + "avg": 525.9128594647588, + "min": 8.4281, + "med": 146.6424, + "max": 6965.0928 + } + }, + "iterations": { + "type": "counter", + "contains": "default", + "values": { + "count": 179957, + "rate": 496.1082767935458 + } + }, + "http_req_tls_handshaking": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + } + }, + "http_req_duration{expected_response:true}": { + "type": "trend", + "contains": "time", + "values": { + "min": 8.3346, + "med": 145.608, + "max": 6965.0928, + "p(90)": 1512.8542599999998, + "p(95)": 1963.1354599999995, + "avg": 525.052223189116 + } + }, + "http_req_waiting": { + "type": "trend", + "contains": "time", + "values": { + "max": 6964.7998, + "p(90)": 1507.14392, + "p(95)": 1944.0801, + "avg": 517.3653027466223, + "min": 8.3346, + "med": 139.5968 + } + }, + "error_rate": { + "type": "rate", + "contains": "default", + "values": { + "rate": 0.0071016965163900264, + "passes": 1278, + "fails": 178679 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + } + }, + "data_sent": { + "type": "counter", + "contains": "data", + "values": { + "count": 81152146, + "rate": 223721.50741653977 + } + }, + "http_req_blocked": { + "type": "trend", + "contains": "time", + "values": { + "avg": 0.013610653123040918, + "min": 0, + "med": 0, + "max": 46.6748, + "p(90)": 0, + "p(95)": 0 + } + } + } +} \ No newline at end of file diff --git a/script/result/result-test3-max-users-revised.json b/script/result/result-test3-max-users-revised.json index c2668b6..fd74210 100644 --- a/script/result/result-test3-max-users-revised.json +++ b/script/result/result-test3-max-users-revised.json @@ -1,27 +1,29 @@ { "root_group": { - "groups": [], "checks": [ - { - "name": "status 200", - "path": "::status 200", - "id": "fad9fa412b86fcb03bee97c80dcd61f1", - "passes": 63809, - "fails": 0 - }, - { - "name": "latency < 3000ms", - "path": "::latency < 3000ms", - "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", - "passes": 60559, - "fails": 3250 - } - ], + { + "id": "fad9fa412b86fcb03bee97c80dcd61f1", + "passes": 245556, + "fails": 1065, + "name": "status 200", + "path": "::status 200" + }, + { + "name": "latency < 3000ms", + "path": "::latency < 3000ms", + "id": "d3208a4e8aa4a7e76e28b378bbeb21ce", + "passes": 244051, + "fails": 2570 + } + ], "name": "", "path": "", - "id": "d41d8cd98f00b204e9800998ecf8427e" + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": [] }, "options": { + "summaryTimeUnit": "", + "noColor": false, "summaryTrendStats": [ "avg", "min", @@ -29,340 +31,350 @@ "max", "p(90)", "p(95)" - ], - "summaryTimeUnit": "", - "noColor": false + ] }, "state": { "isStdOutTTY": true, "isStdErrTTY": true, - "testRunDurationMs": 423969.9904 + "testRunDurationMs": 553720.9149 }, "metrics": { - "http_req_duration": { - "values": { - "avg": 1130.5311035033963, - "min": 8.4607, - "med": 974.4593, - "max": 7548.9183, - "p(90)": 2485.76554, - "p(95)": 3002.59646 - }, - "thresholds": { - "p(95)<3000": { - "ok": false - } - }, + "http_req_blocked": { "type": "trend", - "contains": "time" - }, - "http_reqs": { - "type": "counter", - "contains": "default", - "values": { - "count": 64309, - "rate": 151.68290552670211 - } - }, - "http_req_connecting": { + "contains": "time", "values": { + "p(95)": 0, + "avg": 0.13912320401746525, "min": 0, "med": 0, - "max": 44.9252, - "p(90)": 0, - "p(95)": 0, - "avg": 0.07022190828655403 - }, + "max": 198.5864, + "p(90)": 0 + } + }, + "iteration_duration": { "type": "trend", - "contains": "time" + "contains": "time", + "values": { + "avg": 490.3511144995734, + "min": 5.0978, + "med": 69.9706, + "max": 9661.3979, + "p(90)": 1727.9245, + "p(95)": 2197.4785 + } }, - "http_req_tls_handshaking": { + "stage_400vu_latency": { "type": "trend", "contains": "time", "values": { - "max": 0, - "p(90)": 0, - "p(95)": 0, - "avg": 0, - "min": 0, - "med": 0 + "min": 6.934, + "med": 260.3311, + "max": 7910.6568, + "p(90)": 2008.9612, + "p(95)": 2496.1789249999993, + "avg": 863.2435207222827 } }, - "vus": { - "type": "gauge", - "contains": "default", + "data_sent": { + "type": "counter", + "contains": "data", "values": { - "value": 400, - "min": 0, - "max": 400 + "count": 111229870, + "rate": 200877.13323974353 } }, "stage_100vu_error_rate": { + "type": "rate", "contains": "default", "values": { - "rate": 0.0009138569573373094, - "passes": 19, - "fails": 20772 + "fails": 90445, + "rate": 0, + "passes": 0 }, "thresholds": { "rate<0.05": { "ok": true } - }, - "type": "rate" + } }, - "checks": { + "stage_300vu_error_rate": { + "type": "rate", "contains": "default", "values": { - "rate": 0.9745333730351518, - "passes": 124368, - "fails": 3250 + "rate": 0.03317386047000683, + "passes": 1262, + "fails": 36780 }, - "type": "rate" + "thresholds": { + "rate<0.05": { + "ok": true + } + } }, - "iterations": { + "stage_400vu_error_rate": { + "type": "rate", + "contains": "default", "values": { - "count": 63809, - "rate": 150.50357677390934 + "rate": 0.026625799573560767, + "passes": 999, + "fails": 36521 }, - "type": "counter", - "contains": "default" + "thresholds": { + "rate<0.05": { + "ok": true + } + } }, "stage_100vu_latency": { "type": "trend", "contains": "time", "values": { - "max": 5008.3, - "p(90)": 986.8648, - "p(95)": 1246.4201, - "avg": 386.1315893607812, - "min": 8.4607, - "med": 241.8182 + "avg": 88.53316492343434, + "min": 9.3557, + "med": 38.8589, + "max": 2731.1081, + "p(90)": 230.3016, + "p(95)": 452.7225400000001 } }, - "stage_200vu_latency": { - "type": "trend", - "contains": "time", + "vus_max": { + "contains": "default", "values": { - "min": 8.8395, - "med": 983.13495, - "max": 5251.0866, - "p(90)": 1739.2049000000002, - "p(95)": 2002.04375, - "avg": 984.2043821804594 - } + "max": 500, + "value": 500, + "min": 500 + }, + "type": "gauge" }, - "http_req_sending": { + "latency_ms": { "type": "trend", "contains": "time", "values": { - "max": 19.5558, - "p(90)": 0, - "p(95)": 0, - "avg": 0.010356657699544377, - "min": 0, - "med": 0 + "min": 5.0978, + "med": 69.5646, + "max": 9661.3979, + "p(90)": 1727.745, + "p(95)": 2197.3967, + "avg": 490.05636638485777 } }, - "http_req_receiving": { + "error_rate": { + "contains": "default", + "values": { + "rate": 0.014597297067159731, + "passes": 3600, + "fails": 243021 + }, + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate" + }, + "http_req_tls_handshaking": { "type": "trend", "contains": "time", "values": { - "p(90)": 5.115320000000011, - "p(95)": 33.208199999999856, - "avg": 16.79619285170021, + "avg": 0, "min": 0, - "med": 0.4128, - "max": 2991.9117 + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 } }, - "latency_ms": { + "http_req_connecting": { "type": "trend", "contains": "time", "values": { - "min": 8.4607, - "med": 978.5741, - "max": 7548.9183, - "p(90)": 2487.6504600000003, - "p(95)": 3004.3980199999996, - "avg": 1138.5874364509698 + "avg": 0.1363648099514004, + "min": 0, + "med": 0, + "max": 198.5695, + "p(90)": 0, + "p(95)": 0 } }, - "vus_max": { - "type": "gauge", - "contains": "default", + "data_received": { + "type": "counter", + "contains": "data", "values": { - "value": 500, - "min": 500, - "max": 500 + "count": 245811787, + "rate": 443927.22107018967 } }, - "iteration_duration": { - "type": "trend", - "contains": "time", + "stage_500vu_error_rate": { + "thresholds": { + "rate<0.05": { + "ok": true + } + }, + "type": "rate", + "contains": "default", "values": { - "max": 7548.9183, - "p(90)": 2487.7413600000004, - "p(95)": 3004.50132, - "avg": 1138.7850757965111, - "min": 8.6039, - "med": 978.6923 + "passes": 1334, + "fails": 38976, + "rate": 0.033093525179856115 } }, - "stage_300vu_latency": { - "type": "trend", - "contains": "time", + "checks": { + "type": "rate", + "contains": "default", "values": { - "avg": 1520.9256188318261, - "min": 8.7317, - "med": 1492.01835, - "max": 6004.8078, - "p(90)": 2530.6937900000003, - "p(95)": 2999.98567 + "rate": 0.9926303923834547, + "passes": 489607, + "fails": 3635 } }, - "http_req_waiting": { + "http_req_duration": { "type": "trend", "contains": "time", "values": { - "avg": 1113.724553993999, - "min": 7.8342, - "med": 966.2663, - "max": 7548.9183, - "p(90)": 2476.70182, - "p(95)": 2993.28878 - } - }, - "stage_300vu_error_rate": { - "type": "rate", - "contains": "default", - "values": { - "rate": 0.05003776435045317, - "passes": 795, - "fails": 15093 + "avg": 489.2817069929304, + "min": 5.0978, + "med": 70.126, + "max": 9661.3979, + "p(90)": 1726.5174, + "p(95)": 2196.2316 }, "thresholds": { - "rate<0.05": { - "ok": false + "p(95)<3000": { + "ok": true } } }, - "error_rate": { + "stage_200vu_error_rate": { "type": "rate", "contains": "default", "values": { - "rate": 0.050933253929696436, - "passes": 3250, - "fails": 60559 + "rate": 0.0001240571655418817, + "passes": 5, + "fails": 40299 }, "thresholds": { "rate<0.05": { - "ok": false + "ok": true } } }, - "http_req_duration{expected_response:true}": { + "stage_300vu_latency": { "type": "trend", "contains": "time", "values": { - "p(90)": 2485.76554, - "p(95)": 3002.59646, - "avg": 1130.5311035033963, - "min": 8.4607, - "med": 974.4593, - "max": 7548.9183 + "avg": 633.4955815309489, + "min": 5.0978, + "med": 242.11935, + "max": 9661.3979, + "p(90)": 1500.91932, + "p(95)": 1978.197795 } }, - "http_req_blocked": { - "type": "trend", - "contains": "time", + "http_reqs": { + "type": "counter", + "contains": "default", "values": { - "p(90)": 0, - "p(95)": 0, - "avg": 0.07387390722915924, + "count": 247121, + "rate": 446.29161252583197 + } + }, + "vus": { + "type": "gauge", + "contains": "default", + "values": { + "value": 24, "min": 0, - "med": 0, - "max": 45.264 + "max": 500 } }, - "data_received": { + "iterations": { "values": { - "count": 64191662, - "rate": 151406.14537231173 + "count": 246621, + "rate": 445.38863056046716 }, "type": "counter", - "contains": "data" + "contains": "default" }, - "http_req_failed": { - "type": "rate", - "contains": "default", + "stage_500vu_latency": { "values": { - "rate": 0, - "passes": 0, - "fails": 64309 - } + "p(90)": 2490.07565, + "p(95)": 2748.272275, + "avg": 1000.3222297717737, + "min": 7.0998, + "med": 250.83159999999998, + "max": 6335.1605 + }, + "type": "trend", + "contains": "time" }, - "stage_400vu_latency": { + "http_req_duration{expected_response:true}": { "type": "trend", "contains": "time", "values": { - "avg": 2258.8136778580765, - "min": 9.621, - "med": 2220.3412500000004, - "max": 7548.9183, - "p(90)": 3772.5111200000006, - "p(95)": 4495.190175 + "avg": 490.62029454473765, + "min": 8.9364, + "med": 71.28665000000001, + "max": 9661.3979, + "p(90)": 1728.1209, + "p(95)": 2196.91085 } }, - "stage_400vu_error_rate": { - "type": "rate", - "contains": "default", + "http_req_waiting": { "values": { - "rate": 0.22123401889938854, - "passes": 2388, - "fails": 8406 + "med": 65.819, + "max": 9325.2787, + "p(90)": 1716.6991, + "p(95)": 2160.9547, + "avg": 481.6095870727298, + "min": 5.0978 }, - "thresholds": { - "rate<0.05": { - "ok": false - } - } + "type": "trend", + "contains": "time" }, - "stage_500vu_error_rate": { + "stage_200vu_latency": { + "values": { + "p(90)": 1015.3241500000001, + "p(95)": 1250.43942, + "avg": 397.96258784488384, + "min": 9.0638, + "med": 210.1042, + "max": 4209.3936 + }, + "type": "trend", + "contains": "time" + }, + "http_req_failed": { "type": "rate", "contains": "default", "values": { - "passes": 0, - "fails": 0, - "rate": 0 - }, - "thresholds": { - "rate<0.05": { - "ok": true - } + "rate": 0.0043096296955742325, + "passes": 1065, + "fails": 246056 } }, - "stage_200vu_error_rate": { - "contains": "default", + "http_req_receiving": { + "type": "trend", + "contains": "time", "values": { - "rate": 0.002938295788442703, - "passes": 48, - "fails": 16288 - }, - "thresholds": { - "rate<0.05": { - "ok": true - } - }, - "type": "rate" + "p(95)": 4.7031, + "avg": 7.661520920520775, + "min": 0, + "med": 0, + "max": 7938.8465, + "p(90)": 1.5495 + } }, - "data_sent": { - "contains": "data", + "http_req_sending": { + "type": "trend", + "contains": "time", "values": { - "count": 29029672, - "rate": 68471.05374748712 - }, - "type": "counter" + "min": 0, + "med": 0, + "max": 6.3409, + "p(90)": 0, + "p(95)": 0, + "avg": 0.010598999680318601 + } } } } \ No newline at end of file diff --git a/script/result/result-test3-stage-100vu.json b/script/result/result-test3-stage-100vu.json index 47ec52a..9c67160 100644 --- a/script/result/result-test3-stage-100vu.json +++ b/script/result/result-test3-stage-100vu.json @@ -1,16 +1,16 @@ { "vu": 100, "latency": { - "max": 5008.3, - "p(90)": 986.8648, - "p(95)": 1246.4201, - "avg": 386.1315893607812, - "min": 8.4607, - "med": 241.8182 + "avg": 88.53316492343434, + "min": 9.3557, + "med": 38.8589, + "max": 2731.1081, + "p(90)": 230.3016, + "p(95)": 452.7225400000001 }, "errorRate": { - "rate": 0.0009138569573373094, - "passes": 19, - "fails": 20772 + "rate": 0, + "passes": 0, + "fails": 90445 } } \ No newline at end of file diff --git a/script/result/result-test3-stage-200vu.json b/script/result/result-test3-stage-200vu.json index 8c1e417..78648a2 100644 --- a/script/result/result-test3-stage-200vu.json +++ b/script/result/result-test3-stage-200vu.json @@ -1,16 +1,16 @@ { "vu": 200, "latency": { - "avg": 984.2043821804594, - "min": 8.8395, - "med": 983.13495, - "max": 5251.0866, - "p(90)": 1739.2049000000002, - "p(95)": 2002.04375 + "avg": 397.96258784488384, + "min": 9.0638, + "med": 210.1042, + "max": 4209.3936, + "p(90)": 1015.3241500000001, + "p(95)": 1250.43942 }, "errorRate": { - "rate": 0.002938295788442703, - "passes": 48, - "fails": 16288 + "rate": 0.0001240571655418817, + "passes": 5, + "fails": 40299 } } \ No newline at end of file diff --git a/script/result/result-test3-stage-300vu.json b/script/result/result-test3-stage-300vu.json index a01d4f9..4e9c73e 100644 --- a/script/result/result-test3-stage-300vu.json +++ b/script/result/result-test3-stage-300vu.json @@ -1,16 +1,16 @@ { "vu": 300, "latency": { - "min": 8.7317, - "med": 1492.01835, - "max": 6004.8078, - "p(90)": 2530.6937900000003, - "p(95)": 2999.98567, - "avg": 1520.9256188318261 + "min": 5.0978, + "med": 242.11935, + "max": 9661.3979, + "p(90)": 1500.91932, + "p(95)": 1978.197795, + "avg": 633.4955815309489 }, "errorRate": { - "fails": 15093, - "rate": 0.05003776435045317, - "passes": 795 + "rate": 0.03317386047000683, + "passes": 1262, + "fails": 36780 } } \ No newline at end of file diff --git a/script/result/result-test3-stage-400vu.json b/script/result/result-test3-stage-400vu.json index 2afe3a6..32ac547 100644 --- a/script/result/result-test3-stage-400vu.json +++ b/script/result/result-test3-stage-400vu.json @@ -1,16 +1,16 @@ { "vu": 400, "latency": { - "avg": 2258.8136778580765, - "min": 9.621, - "med": 2220.3412500000004, - "max": 7548.9183, - "p(90)": 3772.5111200000006, - "p(95)": 4495.190175 + "avg": 863.2435207222827, + "min": 6.934, + "med": 260.3311, + "max": 7910.6568, + "p(90)": 2008.9612, + "p(95)": 2496.1789249999993 }, "errorRate": { - "passes": 2388, - "fails": 8406, - "rate": 0.22123401889938854 + "fails": 36521, + "rate": 0.026625799573560767, + "passes": 999 } } \ No newline at end of file diff --git a/script/result/result-test3-stage-500vu.json b/script/result/result-test3-stage-500vu.json index 86ddc0e..f5178c9 100644 --- a/script/result/result-test3-stage-500vu.json +++ b/script/result/result-test3-stage-500vu.json @@ -1,16 +1,16 @@ { "vu": 500, "latency": { - "avg": 1968.85571906203, - "min": 19.8524, - "med": 1762.5228, - "max": 7738.5768, - "p(90)": 3748.6863, - "p(95)": 4444.191539999992 + "p(95)": 2748.272275, + "avg": 1000.3222297717737, + "min": 7.0998, + "med": 250.83159999999998, + "max": 6335.1605, + "p(90)": 2490.07565 }, "errorRate": { - "fails": 2822, - "rate": 0.14614220877458398, - "passes": 483 + "rate": 0.033093525179856115, + "passes": 1334, + "fails": 38976 } } \ No newline at end of file diff --git a/script/test1-baseline.js b/script/test1-baseline.js index 5ee8a26..a3afec8 100644 --- a/script/test1-baseline.js +++ b/script/test1-baseline.js @@ -24,7 +24,7 @@ const accounts = new SharedArray('accounts', function () { export const options = { stages: [ { duration: '20s', target: 300 }, // Ramp-Up - { duration: '1m', target: 300 }, // 유지 + { duration: '5m', target: 300 }, // 유지 { duration: '10s', target: 0 }, // Ramp-Down ], thresholds: {