This commit is contained in:
hiondal
2025-09-09 01:12:14 +09:00
parent 7ec8a682c6
commit b489c73201
276 changed files with 43859 additions and 98 deletions
+40
View File
@@ -0,0 +1,40 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="api-gateway" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<entry key="SERVER_PORT" value="8080" />
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
<entry key="AUTH_SERVICE_URL" value="http://localhost:8081" />
<entry key="BILL_SERVICE_URL" value="http://localhost:8082" />
<entry key="PRODUCT_SERVICE_URL" value="http://localhost:8083" />
<entry key="KOS_MOCK_SERVICE_URL" value="http://localhost:8084" />
<entry key="LOG_FILE" value="logs/api-gateway.log" />
<entry key="LOG_LEVEL_GATEWAY" value="DEBUG" />
<entry key="LOG_LEVEL_SPRING_CLOUD_GATEWAY" value="DEBUG" />
<entry key="LOG_LEVEL_REACTOR_NETTY" value="INFO" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="api-gateway:bootRun" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+330
View File
@@ -0,0 +1,330 @@
# PhoneBill API Gateway
통신요금 관리 서비스의 API Gateway 모듈입니다.
## 개요
Spring Cloud Gateway를 사용하여 구현된 API Gateway로, 마이크로서비스들의 단일 진입점 역할을 담당합니다.
### 주요 기능
- **JWT 토큰 기반 인증/인가**: 모든 요청에 대한 통합 인증 처리
- **서비스별 라우팅**: 각 마이크로서비스로의 지능형 라우팅
- **Rate Limiting**: Redis 기반 요청 제한
- **Circuit Breaker**: 외부 시스템 장애 격리
- **CORS 설정**: 크로스 오리진 요청 처리
- **API 문서화 통합**: 모든 서비스의 Swagger 문서 통합
- **헬스체크**: 시스템 상태 모니터링
- **Fallback 처리**: 서비스 장애 시 대체 응답
## 기술 스택
- **Java 17**
- **Spring Boot 3.2.1**
- **Spring Cloud Gateway**
- **Spring Data Redis Reactive**
- **JWT (JJWT 0.12.3)**
- **Resilience4j** (Circuit Breaker)
- **SpringDoc OpenAPI 3**
## 아키텍처
### 라우팅 구성
```
/auth/** -> auth-service (인증 서비스)
/bills/** -> bill-service (요금조회 서비스)
/products/** -> product-service (상품변경 서비스)
/kos/** -> kos-mock-service (KOS 목업 서비스)
```
### 패키지 구조
```
com.unicorn.phonebill.gateway/
├── config/ # 설정 클래스
│ ├── GatewayConfig # Gateway 라우팅 설정
│ ├── RedisConfig # Redis 및 Rate Limiting 설정
│ ├── SwaggerConfig # API 문서화 설정
│ └── WebConfig # Web 설정
├── controller/ # 컨트롤러
│ └── HealthController # 헬스체크 API
├── dto/ # 데이터 전송 객체
│ └── TokenValidationResult # JWT 검증 결과
├── exception/ # 예외 클래스
│ └── GatewayException # Gateway 예외
├── filter/ # Gateway 필터
│ └── JwtAuthenticationGatewayFilterFactory # JWT 인증 필터
├── handler/ # 핸들러
│ └── FallbackHandler # Circuit Breaker Fallback 핸들러
├── service/ # 서비스
│ └── JwtTokenService # JWT 토큰 검증 서비스
└── util/ # 유틸리티
└── JwtUtil # JWT 유틸리티
```
## 빌드 및 실행
### 개발 환경
```bash
# 의존성 설치 및 빌드
./gradlew build
# 개발 환경 실행
./gradlew bootRun --args='--spring.profiles.active=dev'
# 또는
./gradlew bootRun -Pdev
```
### 운영 환경
```bash
# 운영용 JAR 빌드
./gradlew bootJar
# 운영 환경 실행
java -jar api-gateway-1.0.0.jar --spring.profiles.active=prod
```
## 환경 설정
### 개발 환경 (application-dev.yml)
- JWT 토큰 유효시간: 1시간 (개발 편의성)
- Redis: localhost:6379
- Rate Limiting: 1000 requests/minute
- Circuit Breaker: 관대한 설정
- Swagger UI: 활성화
### 운영 환경 (application-prod.yml)
- JWT 토큰 유효시간: 30분 (보안 강화)
- Redis: 클러스터 설정
- Rate Limiting: 500 requests/minute
- Circuit Breaker: 엄격한 설정
- Swagger UI: 비활성화
### 환경 변수
운영 환경에서는 다음 환경 변수를 설정해야 합니다:
```bash
JWT_SECRET=your-256-bit-secret-key
REDIS_HOST=redis-cluster.domain.com
REDIS_PASSWORD=your-redis-password
AUTH_SERVICE_URL=https://auth-service.internal.domain.com
BILL_SERVICE_URL=https://bill-service.internal.domain.com
PRODUCT_SERVICE_URL=https://product-service.internal.domain.com
KOS_MOCK_SERVICE_URL=https://kos-mock.internal.domain.com
```
## API 문서
### 개발 환경
Swagger UI는 개발 환경에서만 활성화됩니다:
- **Swagger UI**: http://localhost:8080/swagger-ui.html
- **API Docs**: http://localhost:8080/v3/api-docs
### 헬스체크
- **기본 헬스체크**: `GET /health`
- **상세 헬스체크**: `GET /health/detailed`
- **Actuator 헬스체크**: `GET /actuator/health`
## JWT 인증
### 토큰 형식
```
Authorization: Bearer <JWT_TOKEN>
```
### 토큰 페이로드 예시
```json
{
"sub": "user123",
"role": "USER",
"iat": 1704700800,
"exp": 1704704400,
"jti": "token-unique-id"
}
```
### 인증 제외 경로
- `/auth/login` (로그인)
- `/auth/refresh` (토큰 갱신)
- `/health` (헬스체크)
- `/actuator/health` (Actuator 헬스체크)
## Rate Limiting
### 제한 정책
- **일반 사용자**: 100 requests/minute
- **VIP 사용자**: 500 requests/minute
- **IP 기반 제한**: Fallback으로 사용
### Key Resolver
1. **userKeyResolver**: JWT에서 사용자 ID 추출 (기본)
2. **ipKeyResolver**: 클라이언트 IP 기반
3. **pathKeyResolver**: API 경로 기반
## Circuit Breaker
### 설정
- **실패율 임계값**: 50% (auth), 60% (bill, product), 70% (kos)
- **최소 호출 수**: 5-20회
- **Open 상태 대기시간**: 10-60초
- **Half-Open 상태 허용 호출**: 3-10회
### Fallback
Circuit Breaker가 Open 상태일 때 Fallback 응답을 제공:
- **인증 서비스**: 503 Service Unavailable
- **요금조회 서비스**: 캐시된 메뉴 데이터 제공 가능
- **상품변경 서비스**: 고객센터 안내 메시지
- **KOS 서비스**: 외부 시스템 점검 안내
## 모니터링
### Actuator 엔드포인트
```bash
# 애플리케이션 상태
GET /actuator/health
# Gateway 라우트 정보
GET /actuator/gateway/routes
# 메트릭 정보
GET /actuator/metrics
# 환경 정보 (개발환경만)
GET /actuator/env
```
### 로깅
- **개발환경**: DEBUG 레벨, 상세한 요청/응답 로그
- **운영환경**: INFO 레벨, 성능 고려한 최적화된 로그
## 보안
### HTTPS
운영 환경에서는 반드시 HTTPS를 사용해야 합니다.
### CORS
- **개발환경**: 모든 localhost 오리진 허용
- **운영환경**: 특정 도메인만 허용
### 보안 헤더
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- X-XSS-Protection: 1; mode=block
## 트러블슈팅
### 일반적인 문제
1. **Redis 연결 실패**
```bash
# Redis 서비스 상태 확인
systemctl status redis
# Redis 연결 테스트
redis-cli ping
```
2. **JWT 검증 실패**
```bash
# JWT 시크릿 키 확인
echo $JWT_SECRET
# 토큰 유효성 확인 (개발용)
curl -H "Authorization: Bearer <token>" http://localhost:8080/health
```
3. **Circuit Breaker Open**
```bash
# Circuit Breaker 상태 확인
curl http://localhost:8080/actuator/circuitbreakers
```
### 로그 확인
```bash
# 개발환경 로그
tail -f logs/api-gateway-dev.log
# 운영환경 로그
tail -f /var/log/api-gateway/api-gateway.log
```
## 성능 튜닝
### JVM 옵션 (운영환경)
```bash
java -server \
-Xms512m -Xmx1024m \
-XX:+UseG1GC \
-XX:G1HeapRegionSize=16m \
-XX:+UseStringDeduplication \
-jar api-gateway-1.0.0.jar
```
### Redis 최적화
- Connection Pool 설정 조정
- Pipeline 사용 고려
- 클러스터 모드 활용
## 개발 가이드
### 새로운 서비스 추가
1. `GatewayConfig`에 라우팅 규칙 추가
2. `SwaggerConfig`에 API 문서 URL 추가
3. `FallbackHandler`에 Fallback 로직 추가
4. Circuit Breaker 설정 추가
### 커스텀 필터 추가
```java
@Component
public class CustomGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {
// 필터 구현
}
```
## 릴리스 노트
### v1.0.0 (2025-01-08)
- 초기 릴리스
- JWT 인증 시스템 구현
- 4개 마이크로서비스 라우팅 지원
- Circuit Breaker 및 Rate Limiting 구현
- Swagger 통합 문서화
- 헬스체크 및 모니터링 기능
## 라이선스
이 프로젝트는 회사 내부 프로젝트입니다.
## 기여
- **개발팀**: 이개발(백엔더)
- **검토**: 김기획(기획자), 박화면(프론트), 최운영(데옵스), 정테스트(QA매니저)
+87
View File
@@ -0,0 +1,87 @@
// API Gateway 모듈
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
// API Gateway는 WebFlux를 사용하므로 일부 다른 설정 필요
// Spring Cloud 버전 정의
ext {
set('springCloudVersion', '2023.0.0')
}
dependencies {
// Common module dependency
implementation project(':common')
// Spring Cloud Gateway (api-gateway specific)
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
// Circuit Breaker (api-gateway specific)
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'
// Monitoring (api-gateway specific)
implementation 'io.micrometer:micrometer-registry-prometheus'
// Logging (api-gateway specific)
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
// Netty macOS DNS resolver (api-gateway specific)
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.100.Final:osx-aarch_64'
// Test Dependencies (api-gateway specific)
testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
// 추가 테스트 설정 (루트에서 기본 설정됨)
tasks.named('test') {
systemProperty 'spring.profiles.active', 'test'
}
// JAR 파일명 설정
jar {
archiveBaseName = 'api-gateway'
enabled = false
}
bootJar {
archiveBaseName = 'api-gateway'
// 빌드 정보 추가
manifest {
attributes(
'Implementation-Title': 'PhoneBill API Gateway',
'Implementation-Version': "${version}",
'Built-By': System.getProperty('user.name'),
'Built-JDK': System.getProperty('java.version'),
'Build-Time': new Date().format('yyyy-MM-dd HH:mm:ss')
)
}
}
// 개발 환경 실행 설정
if (project.hasProperty('dev')) {
bootRun {
systemProperty 'spring.profiles.active', 'dev'
jvmArgs = ['-Dspring.devtools.restart.enabled=true']
}
}
// 프로덕션 환경 실행 설정
if (project.hasProperty('prod')) {
bootRun {
systemProperty 'spring.profiles.active', 'prod'
jvmArgs = [
'-server',
'-Xms512m',
'-Xmx1024m',
'-XX:+UseG1GC',
'-XX:G1HeapRegionSize=16m',
'-XX:+UseStringDeduplication',
'-XX:+OptimizeStringConcat'
]
}
}
@@ -0,0 +1,39 @@
package com.unicorn.phonebill.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.config.GatewayLoadBalancerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
* API Gateway 애플리케이션 메인 클래스
*
* Spring Cloud Gateway를 사용하여 마이크로서비스들의 단일 진입점 역할을 담당합니다.
*
* 주요 기능:
* - JWT 토큰 기반 인증/인가
* - 서비스별 라우팅 (user-service, bill-service, product-service, kos-mock)
* - CORS 설정
* - Circuit Breaker 패턴 적용
* - Rate Limiting
* - API 문서화 통합
* - 모니터링 및 헬스체크
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@SpringBootApplication(exclude = {
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class
})
@EnableConfigurationProperties(GatewayLoadBalancerProperties.class)
public class ApiGatewayApplication {
public static void main(String[] args) {
// 시스템 프로퍼티 설정 (성능 최적화)
System.setProperty("spring.main.lazy-initialization", "true");
System.setProperty("reactor.bufferSize.small", "256");
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
@@ -0,0 +1,175 @@
package com.unicorn.phonebill.gateway.config;
import com.unicorn.phonebill.gateway.filter.JwtAuthenticationGatewayFilterFactory;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Cloud Gateway 라우팅 및 CORS 설정
*
* 마이크로서비스별 라우팅 규칙과 CORS 정책을 정의합니다.
*
* 라우팅 구성:
* - /auth/** -> auth-service (인증 서비스)
* - /bills/** -> bill-service (요금조회 서비스)
* - /products/** -> product-service (상품변경 서비스)
* - /kos/** -> kos-mock (KOS 목업 서비스)
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Configuration
public class GatewayConfig {
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
public GatewayConfig(JwtAuthenticationGatewayFilterFactory jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
/**
* 라우팅 규칙 정의
*
* 각 마이크로서비스로의 라우팅 규칙과 필터를 설정합니다.
* JWT 인증이 필요한 경로와 불필요한 경로를 구분하여 처리합니다.
*
* @param builder RouteLocatorBuilder
* @return RouteLocator
*/
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// Auth Service 라우팅 (인증 불필요)
.route("auth-service", r -> r
.path("/auth/login", "/auth/refresh")
.and()
.method("POST")
.uri("lb://auth-service"))
// Auth Service 라우팅 (인증 필요)
.route("auth-service-authenticated", r -> r
.path("/auth/**")
.filters(f -> f
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
.circuitBreaker(cb -> cb
.setName("auth-service-cb")
.setFallbackUri("forward:/fallback/auth"))
.retry(retry -> retry
.setRetries(3)
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true)))
.uri("lb://auth-service"))
// Bill-Inquiry Service 라우팅 (인증 필요)
.route("bill-service", r -> r
.path("/bills/**")
.filters(f -> f
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
.circuitBreaker(cb -> cb
.setName("bill-service-cb")
.setFallbackUri("forward:/fallback/bill"))
.retry(retry -> retry
.setRetries(3)
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true))
)
.uri("lb://bill-service"))
// Product-Change Service 라우팅 (인증 필요)
.route("product-service", r -> r
.path("/products/**")
.filters(f -> f
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
.circuitBreaker(cb -> cb
.setName("product-service-cb")
.setFallbackUri("forward:/fallback/product"))
.retry(retry -> retry
.setRetries(3)
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true))
)
.uri("lb://product-service"))
// KOS Mock Service 라우팅 (내부 서비스용)
.route("kos-mock-service", r -> r
.path("/kos/**")
.filters(f -> f
.circuitBreaker(cb -> cb
.setName("kos-mock-cb")
.setFallbackUri("forward:/fallback/kos"))
.retry(retry -> retry
.setRetries(5)
.setBackoff(java.time.Duration.ofSeconds(1), java.time.Duration.ofSeconds(5), 2, true)))
.uri("lb://kos-mock-service"))
// Health Check 라우팅 (인증 불필요)
.route("health-check", r -> r
.path("/health", "/actuator/health")
.uri("http://localhost:8080"))
// Swagger UI 라우팅 (개발환경에서만 사용)
.route("swagger-ui", r -> r
.path("/swagger-ui/**", "/v3/api-docs/**")
.uri("http://localhost:8080"))
.build();
}
/**
* CORS 설정
*
* 프론트엔드에서 API Gateway로의 크로스 오리진 요청을 허용합니다.
* 개발/운영 환경에 따라 허용 오리진을 다르게 설정합니다.
*
* @return CorsWebFilter
*/
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration corsConfig = new CorsConfiguration();
// 허용할 Origin 설정
corsConfig.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:3000", // React 개발 서버
"http://localhost:3001", // Next.js 개발 서버
"https://*.unicorn.com", // 운영 도메인
"https://*.phonebill.com" // 운영 도메인
));
// 허용할 HTTP 메서드
corsConfig.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
));
// 허용할 헤더
corsConfig.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Requested-With",
"X-Request-ID",
"X-User-Agent"
));
// 노출할 헤더 (클라이언트가 접근 가능한 헤더)
corsConfig.setExposedHeaders(Arrays.asList(
"X-Request-ID",
"X-Response-Time",
"X-Rate-Limit-Remaining"
));
// 자격 증명 허용 (쿠키, Authorization 헤더 등)
corsConfig.setAllowCredentials(true);
// Preflight 요청 캐시 시간 (초)
corsConfig.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig);
return new CorsWebFilter(source);
}
}
@@ -0,0 +1,185 @@
package com.unicorn.phonebill.gateway.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springdoc.core.models.GroupedOpenApi;
import org.springdoc.core.properties.SwaggerUiConfigParameters;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
/**
* Swagger 통합 문서화 설정
*
* API Gateway를 통해 모든 마이크로서비스의 OpenAPI 문서를 통합하여 제공합니다.
* 개발 환경에서만 활성화되며, 각 서비스별 API 문서를 중앙집중식으로 관리합니다.
*
* 주요 기능:
* - 마이크로서비스별 OpenAPI 문서 통합
* - Swagger UI 커스터마이징
* - JWT 인증 정보 포함
* - 환경별 설정 (개발환경에서만 활성화)
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Configuration
@Profile("!prod") // 운영환경에서는 비활성화
public class SwaggerConfig {
@Value("${services.auth-service.url:http://localhost:8081}")
private String authServiceUrl;
@Value("${services.bill-service.url:http://localhost:8082}")
private String billServiceUrl;
@Value("${services.product-service.url:http://localhost:8083}")
private String productServiceUrl;
@Value("${services.kos-mock-service.url:http://localhost:8084}")
private String kosMockServiceUrl;
/**
* Swagger UI 설정 파라미터
*
* @return SwaggerUiConfigParameters
*/
@Bean
public SwaggerUiConfigParameters swaggerUiConfigParameters() {
// Spring Boot 3.x에서는 SwaggerUiConfigParameters 생성자가 변경됨
SwaggerUiConfigParameters parameters = new SwaggerUiConfigParameters(
new org.springdoc.core.properties.SwaggerUiConfigProperties()
);
// 각 마이크로서비스의 OpenAPI 문서 URL 설정
List<String> urls = new ArrayList<>();
urls.add("Gateway::/v3/api-docs");
urls.add("Auth Service::" + authServiceUrl + "/v3/api-docs");
urls.add("Bill Service::" + billServiceUrl + "/v3/api-docs");
urls.add("Product Service::" + productServiceUrl + "/v3/api-docs");
urls.add("KOS Mock::" + kosMockServiceUrl + "/v3/api-docs");
// Spring Boot 3.x 호환성을 위한 설정
System.setProperty("springdoc.swagger-ui.urls", String.join(",", urls));
return parameters;
}
/**
* API Gateway OpenAPI 그룹 정의
*
* @return GroupedOpenApi
*/
@Bean
public GroupedOpenApi gatewayApi() {
return GroupedOpenApi.builder()
.group("gateway")
.displayName("API Gateway")
.pathsToMatch("/health/**", "/actuator/**")
.addOpenApiCustomizer(openApi -> {
openApi.info(new io.swagger.v3.oas.models.info.Info()
.title("PhoneBill API Gateway")
.version("1.0.0")
.description("통신요금 관리 서비스 API Gateway\n\n" +
"이 문서는 API Gateway의 헬스체크 및 관리 기능을 설명합니다.")
);
// JWT 보안 스키마 추가
openApi.addSecurityItem(
new io.swagger.v3.oas.models.security.SecurityRequirement()
.addList("bearerAuth")
);
openApi.getComponents()
.addSecuritySchemes("bearerAuth",
new io.swagger.v3.oas.models.security.SecurityScheme()
.type(io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT 토큰을 Authorization 헤더에 포함시켜 주세요.\n" +
"형식: Authorization: Bearer {token}")
);
})
.build();
}
/**
* Swagger UI 리다이렉트 라우터
*
* @return RouterFunction
*/
@Bean
public RouterFunction<ServerResponse> swaggerRouterFunction() {
return RouterFunctions.route()
// 루트 경로에서 Swagger UI로 리다이렉트
.GET("/", request ->
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
// docs 경로에서 Swagger UI로 리다이렉트
.GET("/docs", request ->
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
// api-docs 경로에서 Swagger UI로 리다이렉트
.GET("/api-docs", request ->
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
// 서비스별 API 문서 프록시
.GET("/v3/api-docs/auth", request ->
proxyApiDocs(authServiceUrl + "/v3/api-docs"))
.GET("/v3/api-docs/bills", request ->
proxyApiDocs(billServiceUrl + "/v3/api-docs"))
.GET("/v3/api-docs/products", request ->
proxyApiDocs(productServiceUrl + "/v3/api-docs"))
.GET("/v3/api-docs/kos", request ->
proxyApiDocs(kosMockServiceUrl + "/v3/api-docs"))
.build();
}
/**
* API 문서 프록시
*
* 각 마이크로서비스의 OpenAPI 문서를 프록시하여 제공합니다.
*
* @param apiDocsUrl API 문서 URL
* @return ServerResponse
*/
private Mono<ServerResponse> proxyApiDocs(String apiDocsUrl) {
// 실제 구현에서는 WebClient를 사용하여 마이크로서비스의 API 문서를 가져와야 합니다.
// 현재는 임시로 빈 문서를 반환합니다.
return ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue("{\n" +
" \"openapi\": \"3.0.1\",\n" +
" \"info\": {\n" +
" \"title\": \"Service API\",\n" +
" \"version\": \"1.0.0\",\n" +
" \"description\": \"마이크로서비스 API 문서\\n\\n" +
"실제 서비스가 시작되면 상세한 API 문서가 표시됩니다.\"\n" +
" },\n" +
" \"paths\": {\n" +
" \"/status\": {\n" +
" \"get\": {\n" +
" \"summary\": \"서비스 상태 확인\",\n" +
" \"responses\": {\n" +
" \"200\": {\n" +
" \"description\": \"서비스 정상\"\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
"}");
}
}
@@ -0,0 +1,66 @@
package com.unicorn.phonebill.gateway.config;
import com.unicorn.phonebill.gateway.handler.FallbackHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Web 설정 및 라우터 함수 정의
*
* Spring WebFlux의 함수형 라우팅을 사용하여 Fallback 엔드포인트를 정의합니다.
* Circuit Breaker에서 호출할 Fallback 경로를 설정합니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Configuration
public class WebConfig {
private final FallbackHandler fallbackHandler;
public WebConfig(FallbackHandler fallbackHandler) {
this.fallbackHandler = fallbackHandler;
}
/**
* Fallback 라우터 함수
*
* Circuit Breaker에서 사용할 Fallback 엔드포인트를 정의합니다.
*
* @return RouterFunction
*/
@Bean
public RouterFunction<ServerResponse> fallbackRouterFunction() {
return RouterFunctions.route()
// 인증 서비스 Fallback
.GET("/fallback/auth", fallbackHandler::authServiceFallback)
.POST("/fallback/auth", fallbackHandler::authServiceFallback)
// 요금조회 서비스 Fallback
.GET("/fallback/bill", fallbackHandler::billServiceFallback)
.POST("/fallback/bill", fallbackHandler::billServiceFallback)
// 상품변경 서비스 Fallback
.GET("/fallback/product", fallbackHandler::productServiceFallback)
.POST("/fallback/product", fallbackHandler::productServiceFallback)
.PUT("/fallback/product", fallbackHandler::productServiceFallback)
// KOS Mock 서비스 Fallback
.GET("/fallback/kos", fallbackHandler::kosServiceFallback)
.POST("/fallback/kos", fallbackHandler::kosServiceFallback)
// Rate Limit Fallback
.GET("/fallback/ratelimit", fallbackHandler::rateLimitFallback)
.POST("/fallback/ratelimit", fallbackHandler::rateLimitFallback)
// 일반 Fallback (기타 모든 경로)
.GET("/fallback/**", fallbackHandler::genericFallback)
.POST("/fallback/**", fallbackHandler::genericFallback)
.build();
}
}
@@ -0,0 +1,49 @@
package com.unicorn.phonebill.gateway.config;
import org.springframework.boot.web.codec.CodecCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.ServerCodecConfigurer;
/**
* WebFlux 설정
*
* Spring Cloud Gateway에서 필요한 WebFlux 관련 빈들을 정의합니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Configuration
public class WebFluxConfig {
/**
* ServerCodecConfigurer 빈 정의
*
* Spring Cloud Gateway가 요구하는 ServerCodecConfigurer를 직접 정의합니다.
*
* @return ServerCodecConfigurer
*/
@Bean
public ServerCodecConfigurer serverCodecConfigurer() {
return ServerCodecConfigurer.create();
}
/**
* CodecCustomizer 빈 정의 (선택적)
*
* 필요한 경우 코덱을 커스터마이징할 수 있습니다.
*
* @return CodecCustomizer
*/
@Bean
public CodecCustomizer codecCustomizer() {
return configurer -> {
// 최대 메모리 크기 설정 (기본값: 256KB)
configurer.defaultCodecs().maxInMemorySize(1024 * 1024); // 1MB
// 기타 필요한 코덱 설정
configurer.defaultCodecs().enableLoggingRequestDetails(true);
};
}
}
@@ -0,0 +1,251 @@
package com.unicorn.phonebill.gateway.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/**
* API Gateway 헬스체크 컨트롤러
*
* API Gateway와 연관된 시스템들의 상태를 점검합니다.
*
* 주요 기능:
* - Gateway 자체 상태 확인
* - Redis 연결 상태 확인
* - 각 마이크로서비스 연결 상태 확인
* - 전체 시스템 상태 요약
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@RestController
public class HealthController {
private final ReactiveRedisTemplate<String, Object> redisTemplate;
@Autowired
public HealthController(ReactiveRedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 기본 헬스체크 엔드포인트
*
* @return 상태 응답
*/
@GetMapping("/health")
public Mono<ResponseEntity<Map<String, Object>>> health() {
return checkSystemHealth()
.map(healthStatus -> {
HttpStatus status = healthStatus.get("status").equals("UP")
? HttpStatus.OK
: HttpStatus.SERVICE_UNAVAILABLE;
return ResponseEntity.status(status).body(healthStatus);
})
.onErrorReturn(
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.<String, Object>of(
"status", "DOWN",
"error", "Health check failed",
"timestamp", Instant.now().toString()
))
);
}
/**
* 상세 헬스체크 엔드포인트
*
* @return 상세 상태 정보
*/
@GetMapping("/health/detailed")
public Mono<ResponseEntity<Map<String, Object>>> detailedHealth() {
return Mono.zip(
checkGatewayHealth(),
checkRedisHealth(),
checkDownstreamServices()
).map(tuple -> {
Map<String, Object> gatewayHealth = tuple.getT1();
Map<String, Object> redisHealth = tuple.getT2();
Map<String, Object> servicesHealth = tuple.getT3();
boolean allHealthy =
"UP".equals(gatewayHealth.get("status")) &&
"UP".equals(redisHealth.get("status")) &&
"UP".equals(servicesHealth.get("status"));
Map<String, Object> response = Map.of(
"status", allHealthy ? "UP" : "DOWN",
"timestamp", Instant.now().toString(),
"components", Map.of(
"gateway", gatewayHealth,
"redis", redisHealth,
"services", servicesHealth
)
);
HttpStatus status = allHealthy ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
return ResponseEntity.status(status).body(response);
}).onErrorReturn(
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.<String, Object>of(
"status", "DOWN",
"error", "Detailed health check failed",
"timestamp", Instant.now().toString()
))
);
}
/**
* 간단한 상태 확인 엔드포인트
*
* @return 상태 응답
*/
@GetMapping("/status")
public Mono<ResponseEntity<Map<String, Object>>> status() {
return Mono.just(ResponseEntity.ok(Map.<String, Object>of(
"status", "UP",
"service", "API Gateway",
"timestamp", Instant.now().toString(),
"version", "1.0.0"
)));
}
/**
* 전체 시스템 상태 점검
*
* @return 시스템 상태
*/
private Mono<Map<String, Object>> checkSystemHealth() {
return Mono.zip(
checkGatewayHealth(),
checkRedisHealth()
).map(tuple -> {
Map<String, Object> gatewayHealth = tuple.getT1();
Map<String, Object> redisHealth = tuple.getT2();
boolean allHealthy =
"UP".equals(gatewayHealth.get("status")) &&
"UP".equals(redisHealth.get("status"));
return Map.<String, Object>of(
"status", allHealthy ? "UP" : "DOWN",
"timestamp", Instant.now().toString(),
"version", "1.0.0",
"uptime", getUptime()
);
});
}
/**
* Gateway 자체 상태 점검
*
* @return Gateway 상태
*/
private Mono<Map<String, Object>> checkGatewayHealth() {
return Mono.fromCallable(() -> {
// 메모리 사용량 확인
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
double memoryUsage = (double) usedMemory / totalMemory * 100;
return Map.<String, Object>of(
"status", memoryUsage < 90 ? "UP" : "DOWN",
"memory", Map.<String, Object>of(
"used", usedMemory,
"total", totalMemory,
"usage_percent", String.format("%.2f%%", memoryUsage)
),
"threads", Thread.activeCount(),
"timestamp", Instant.now().toString()
);
});
}
/**
* Redis 연결 상태 점검
*
* @return Redis 상태
*/
private Mono<Map<String, Object>> checkRedisHealth() {
return redisTemplate.hasKey("health:check")
.timeout(Duration.ofSeconds(3))
.map(result -> Map.<String, Object>of(
"status", "UP",
"connection", "OK",
"response_time", "< 3s",
"timestamp", Instant.now().toString()
))
.onErrorReturn(Map.<String, Object>of(
"status", "DOWN",
"connection", "FAILED",
"error", "Connection timeout or error",
"timestamp", Instant.now().toString()
));
}
/**
* 다운스트림 서비스 상태 점검
*
* @return 서비스 상태
*/
private Mono<Map<String, Object>> checkDownstreamServices() {
// 실제 구현에서는 Circuit Breaker 상태를 확인하거나
// 각 서비스에 대한 간단한 health check를 수행할 수 있습니다.
return Mono.fromCallable(() -> Map.<String, Object>of(
"status", "UP",
"services", Map.<String, Object>of(
"auth-service", "UNKNOWN",
"bill-service", "UNKNOWN",
"product-service", "UNKNOWN",
"kos-mock-service", "UNKNOWN"
),
"note", "Service health checks not implemented yet",
"timestamp", Instant.now().toString()
));
}
/**
* 애플리케이션 업타임 계산
*
* @return 업타임 문자열
*/
private String getUptime() {
long uptimeMs = System.currentTimeMillis() - getStartTime();
long seconds = uptimeMs / 1000;
long minutes = seconds / 60;
long hours = minutes / 60;
long days = hours / 24;
if (days > 0) {
return String.format("%dd %dh %dm", days, hours % 24, minutes % 60);
} else if (hours > 0) {
return String.format("%dh %dm %ds", hours, minutes % 60, seconds % 60);
} else if (minutes > 0) {
return String.format("%dm %ds", minutes, seconds % 60);
} else {
return String.format("%ds", seconds);
}
}
/**
* 애플리케이션 시작 시간 반환 (임시 구현)
*
* @return 시작 시간 (밀리초)
*/
private long getStartTime() {
// 실제 구현에서는 ApplicationContext에서 시작 시간을 가져와야 합니다.
return System.currentTimeMillis() - 300000; // 임시로 5분 전으로 설정
}
}
@@ -0,0 +1,173 @@
package com.unicorn.phonebill.gateway.dto;
import java.time.Instant;
/**
* JWT 토큰 검증 결과 DTO
*
* JWT 토큰 검증 결과와 관련 정보를 담는 데이터 전송 객체입니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
public class TokenValidationResult {
private final boolean valid;
private final String userId;
private final String userRole;
private final Instant expiresAt;
private final boolean needsRefresh;
private final String failureReason;
private TokenValidationResult(boolean valid, String userId, String userRole,
Instant expiresAt, boolean needsRefresh, String failureReason) {
this.valid = valid;
this.userId = userId;
this.userRole = userRole;
this.expiresAt = expiresAt;
this.needsRefresh = needsRefresh;
this.failureReason = failureReason;
}
/**
* 유효한 토큰 결과 생성
*
* @param userId 사용자 ID
* @param userRole 사용자 역할
* @param expiresAt 만료 시간
* @param needsRefresh 갱신 필요 여부
* @return TokenValidationResult
*/
public static TokenValidationResult valid(String userId, String userRole,
Instant expiresAt, boolean needsRefresh) {
return new TokenValidationResult(true, userId, userRole, expiresAt, needsRefresh, null);
}
/**
* 유효한 토큰 결과 생성 (갱신 불필요)
*
* @param userId 사용자 ID
* @param userRole 사용자 역할
* @param expiresAt 만료 시간
* @return TokenValidationResult
*/
public static TokenValidationResult valid(String userId, String userRole, Instant expiresAt) {
return new TokenValidationResult(true, userId, userRole, expiresAt, false, null);
}
/**
* 유효하지 않은 토큰 결과 생성
*
* @param failureReason 실패 원인
* @return TokenValidationResult
*/
public static TokenValidationResult invalid(String failureReason) {
return new TokenValidationResult(false, null, null, null, false, failureReason);
}
/**
* 토큰 유효성 여부
*
* @return 유효성 여부
*/
public boolean isValid() {
return valid;
}
/**
* 사용자 ID
*
* @return 사용자 ID
*/
public String getUserId() {
return userId;
}
/**
* 사용자 역할
*
* @return 사용자 역할
*/
public String getUserRole() {
return userRole;
}
/**
* 토큰 만료 시간
*
* @return 만료 시간
*/
public Instant getExpiresAt() {
return expiresAt;
}
/**
* 토큰 갱신 필요 여부
*
* @return 갱신 필요 여부
*/
public boolean needsRefresh() {
return needsRefresh;
}
/**
* 검증 실패 원인
*
* @return 실패 원인
*/
public String getFailureReason() {
return failureReason;
}
/**
* 토큰이 유효하지 않은지 확인
*
* @return 무효성 여부
*/
public boolean isInvalid() {
return !valid;
}
/**
* 사용자 정보가 있는지 확인
*
* @return 사용자 정보 존재 여부
*/
public boolean hasUserInfo() {
return valid && userId != null && !userId.trim().isEmpty();
}
/**
* 관리자 권한 확인
*
* @return 관리자 권한 여부
*/
public boolean isAdmin() {
return valid && "ADMIN".equalsIgnoreCase(userRole);
}
/**
* VIP 사용자 확인
*
* @return VIP 사용자 여부
*/
public boolean isVipUser() {
return valid && ("VIP".equalsIgnoreCase(userRole) || "PREMIUM".equalsIgnoreCase(userRole));
}
@Override
public String toString() {
if (valid) {
return String.format(
"TokenValidationResult{valid=true, userId='%s', userRole='%s', expiresAt=%s, needsRefresh=%s}",
userId, userRole, expiresAt, needsRefresh
);
} else {
return String.format(
"TokenValidationResult{valid=false, failureReason='%s'}",
failureReason
);
}
}
}
@@ -0,0 +1,104 @@
package com.unicorn.phonebill.gateway.exception;
/**
* API Gateway 전용 예외 클래스
*
* Gateway에서 발생할 수 있는 다양한 예외 상황을 표현합니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
public class GatewayException extends RuntimeException {
private final String errorCode;
private final int httpStatus;
public GatewayException(String message) {
super(message);
this.errorCode = "GATEWAY_ERROR";
this.httpStatus = 500;
}
public GatewayException(String message, Throwable cause) {
super(message, cause);
this.errorCode = "GATEWAY_ERROR";
this.httpStatus = 500;
}
public GatewayException(String errorCode, String message, int httpStatus) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
public GatewayException(String errorCode, String message, int httpStatus, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
public String getErrorCode() {
return errorCode;
}
public int getHttpStatus() {
return httpStatus;
}
}
/**
* JWT 인증 관련 예외
*/
class JwtAuthenticationException extends GatewayException {
public JwtAuthenticationException(String message) {
super("JWT_AUTH_ERROR", message, 401);
}
public JwtAuthenticationException(String message, Throwable cause) {
super("JWT_AUTH_ERROR", message, 401, cause);
}
}
/**
* 서비스 연결 관련 예외
*/
class ServiceConnectionException extends GatewayException {
public ServiceConnectionException(String serviceName, String message) {
super("SERVICE_CONNECTION_ERROR",
String.format("Service '%s' connection failed: %s", serviceName, message),
503);
}
public ServiceConnectionException(String serviceName, String message, Throwable cause) {
super("SERVICE_CONNECTION_ERROR",
String.format("Service '%s' connection failed: %s", serviceName, message),
503, cause);
}
}
/**
* Rate Limit 관련 예외
*/
class RateLimitExceededException extends GatewayException {
public RateLimitExceededException(String message) {
super("RATE_LIMIT_EXCEEDED", message, 429);
}
}
/**
* 설정 관련 예외
*/
class GatewayConfigurationException extends GatewayException {
public GatewayConfigurationException(String message) {
super("GATEWAY_CONFIG_ERROR", message, 500);
}
public GatewayConfigurationException(String message, Throwable cause) {
super("GATEWAY_CONFIG_ERROR", message, 500, cause);
}
}
@@ -0,0 +1,197 @@
package com.unicorn.phonebill.gateway.filter;
import com.unicorn.phonebill.gateway.service.JwtTokenService;
import com.unicorn.phonebill.gateway.dto.TokenValidationResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBuffer;
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.stereotype.Component;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
/**
* JWT 인증 Gateway Filter Factory
*
* Spring Cloud Gateway에서 JWT 토큰 기반 인증을 처리하는 필터입니다.
* Authorization 헤더의 Bearer 토큰을 검증하고, 유효하지 않은 경우 요청을 차단합니다.
*
* 주요 기능:
* - JWT 토큰 유효성 검증
* - 토큰 만료 검사
* - 사용자 정보 추출 및 헤더 전달
* - 인증 실패 시 적절한 HTTP 응답 반환
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Component
public class JwtAuthenticationGatewayFilterFactory
extends AbstractGatewayFilterFactory<JwtAuthenticationGatewayFilterFactory.Config> {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationGatewayFilterFactory.class);
private final JwtTokenService jwtTokenService;
public JwtAuthenticationGatewayFilterFactory(JwtTokenService jwtTokenService) {
super(Config.class);
this.jwtTokenService = jwtTokenService;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String requestPath = request.getPath().value();
String requestId = request.getHeaders().getFirst("X-Request-ID");
logger.debug("JWT Authentication Filter - Path: {}, Request-ID: {}", requestPath, requestId);
// Authorization 헤더 추출
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
logger.warn("Missing or invalid Authorization header - Path: {}, Request-ID: {}",
requestPath, requestId);
return handleAuthenticationError(exchange, "인증 토큰이 없습니다", HttpStatus.UNAUTHORIZED);
}
// Bearer 토큰 추출
String token = authHeader.substring(7);
// JWT 토큰 검증 (비동기)
return jwtTokenService.validateToken(token)
.flatMap(validationResult -> {
if (validationResult.isValid()) {
// 인증 성공 - 사용자 정보를 헤더에 추가하여 하위 서비스로 전달
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-User-ID", validationResult.getUserId())
.header("X-User-Role", validationResult.getUserRole())
.header("X-Token-Expires-At", String.valueOf(validationResult.getExpiresAt()))
.header("X-Request-ID", requestId != null ? requestId : generateRequestId())
.build();
logger.debug("JWT Authentication success - User: {}, Role: {}, Path: {}, Request-ID: {}",
validationResult.getUserId(), validationResult.getUserRole(),
requestPath, requestId);
return chain.filter(exchange.mutate().request(modifiedRequest).build());
} else {
// 인증 실패
logger.warn("JWT Authentication failed - Reason: {}, Path: {}, Request-ID: {}",
validationResult.getFailureReason(), requestPath, requestId);
HttpStatus status = determineHttpStatus(validationResult.getFailureReason());
return handleAuthenticationError(exchange, validationResult.getFailureReason(), status);
}
})
.onErrorResume(throwable -> {
logger.error("JWT Authentication error - Path: {}, Request-ID: {}, Error: {}",
requestPath, requestId, throwable.getMessage(), throwable);
return handleAuthenticationError(exchange, "인증 처리 중 오류가 발생했습니다",
HttpStatus.INTERNAL_SERVER_ERROR);
});
};
}
/**
* 실패 원인에 따른 HTTP 상태 코드 결정
*
* @param failureReason 실패 원인
* @return HTTP 상태 코드
*/
private HttpStatus determineHttpStatus(String failureReason) {
if (failureReason == null) {
return HttpStatus.UNAUTHORIZED;
}
if (failureReason.contains("만료")) {
return HttpStatus.UNAUTHORIZED;
} else if (failureReason.contains("권한")) {
return HttpStatus.FORBIDDEN;
} else if (failureReason.contains("형식")) {
return HttpStatus.BAD_REQUEST;
}
return HttpStatus.UNAUTHORIZED;
}
/**
* 인증 오류 응답 처리
*
* @param exchange ServerWebExchange
* @param message 오류 메시지
* @param status HTTP 상태 코드
* @return Mono<Void>
*/
private Mono<Void> handleAuthenticationError(
org.springframework.web.server.ServerWebExchange exchange,
String message,
HttpStatus status) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
response.getHeaders().add("Content-Type", MediaType.APPLICATION_JSON_VALUE);
// 표준 오류 응답 형식
String jsonResponse = String.format(
"{\n" +
" \"success\": false,\n" +
" \"error\": {\n" +
" \"code\": \"AUTH%03d\",\n" +
" \"message\": \"%s\",\n" +
" \"timestamp\": \"%s\"\n" +
" }\n" +
"}",
status.value(),
message,
java.time.Instant.now().toString()
);
DataBuffer buffer = response.bufferFactory().wrap(jsonResponse.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
/**
* 요청 ID 생성
*
* @return 생성된 요청 ID
*/
private String generateRequestId() {
return "REQ-" + System.currentTimeMillis() + "-" +
(int)(Math.random() * 1000);
}
/**
* Filter 설정 클래스
*/
public static class Config {
// 필요에 따라 설정 프로퍼티 추가 가능
private boolean enabled = true;
private String[] excludePaths = {};
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String[] getExcludePaths() {
return excludePaths;
}
public void setExcludePaths(String[] excludePaths) {
this.excludePaths = excludePaths;
}
}
}
@@ -0,0 +1,266 @@
package com.unicorn.phonebill.gateway.handler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
/**
* Circuit Breaker Fallback 핸들러
*
* Circuit Breaker가 Open 상태일 때 또는 서비스 호출이 실패했을 때
* 대체 응답을 제공하는 핸들러입니다.
*
* 주요 기능:
* - 서비스별 개별 fallback 응답
* - 표준화된 오류 응답 형식
* - 적절한 HTTP 상태 코드 반환
* - 로깅 및 모니터링 지원
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Component
public class FallbackHandler {
private static final Logger logger = LoggerFactory.getLogger(FallbackHandler.class);
/**
* 인증 서비스 Fallback
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> authServiceFallback(ServerRequest request) {
logger.warn("Auth service fallback triggered - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"AUTH503",
"인증 서비스가 일시적으로 사용할 수 없습니다",
"잠시 후 다시 시도해 주세요",
"auth-service"
);
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fallbackResponse);
}
/**
* 요금조회 서비스 Fallback
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> billServiceFallback(ServerRequest request) {
logger.warn("Bill service fallback triggered - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"BILL503",
"요금조회 서비스가 일시적으로 사용할 수 없습니다",
"시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
"bill-service"
);
// 요금조회의 경우 캐시된 데이터 제공 가능한지 확인
if (request.path().contains("/bills/menu")) {
return provideCachedMenuData();
}
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fallbackResponse);
}
/**
* 상품변경 서비스 Fallback
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> productServiceFallback(ServerRequest request) {
logger.warn("Product service fallback triggered - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"PROD503",
"상품변경 서비스가 일시적으로 사용할 수 없습니다",
"시스템 점검 중입니다. 고객센터로 문의하시거나 잠시 후 다시 시도해 주세요",
"product-service"
);
// 상품변경 요청의 경우 더 신중한 처리 필요
if (request.method().name().equals("POST")) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(createCriticalServiceFallback("상품변경"));
}
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fallbackResponse);
}
/**
* KOS Mock 서비스 Fallback
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> kosServiceFallback(ServerRequest request) {
logger.warn("KOS service fallback triggered - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"KOS503",
"외부 연동 시스템이 일시적으로 사용할 수 없습니다",
"통신사 시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
"kos-mock-service"
);
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fallbackResponse);
}
/**
* 일반 Fallback (알 수 없는 서비스)
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> genericFallback(ServerRequest request) {
logger.warn("Generic fallback triggered - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"SYS503",
"서비스가 일시적으로 사용할 수 없습니다",
"시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
"unknown-service"
);
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fallbackResponse);
}
/**
* 표준 Fallback 응답 생성
*
* @param errorCode 오류 코드
* @param message 사용자 메시지
* @param details 상세 설명
* @param service 서비스명
* @return JSON 형식 응답
*/
private String createFallbackResponse(String errorCode, String message, String details, String service) {
return String.format(
"{\n" +
" \"success\": false,\n" +
" \"error\": {\n" +
" \"code\": \"%s\",\n" +
" \"message\": \"%s\",\n" +
" \"details\": \"%s\",\n" +
" \"service\": \"%s\",\n" +
" \"timestamp\": \"%s\",\n" +
" \"retry_after\": \"30\"\n" +
" }\n" +
"}",
errorCode,
message,
details,
service,
Instant.now().toString()
);
}
/**
* 중요 서비스 Fallback 응답 생성
*
* @param serviceName 서비스명
* @return JSON 형식 응답
*/
private String createCriticalServiceFallback(String serviceName) {
return String.format(
"{\n" +
" \"success\": false,\n" +
" \"error\": {\n" +
" \"code\": \"CRITICAL_SERVICE_UNAVAILABLE\",\n" +
" \"message\": \"%s 서비스가 현재 이용할 수 없습니다\",\n" +
" \"details\": \"중요한 작업이므로 시스템이 안정된 후 다시 시도해 주시기 바랍니다\",\n" +
" \"action\": \"CONTACT_SUPPORT\",\n" +
" \"support_phone\": \"1588-0000\",\n" +
" \"timestamp\": \"%s\",\n" +
" \"retry_after\": \"300\"\n" +
" }\n" +
"}",
serviceName,
Instant.now().toString()
);
}
/**
* 캐시된 메뉴 데이터 제공
*
* 요금조회 메뉴는 변경이 적으므로 캐시된 데이터를 제공할 수 있습니다.
*
* @return ServerResponse
*/
private Mono<ServerResponse> provideCachedMenuData() {
String cachedMenuResponse =
"{\n" +
" \"success\": true,\n" +
" \"message\": \"캐시된 메뉴 정보입니다\",\n" +
" \"data\": {\n" +
" \"menus\": [\n" +
" {\n" +
" \"id\": \"bill_inquiry\",\n" +
" \"name\": \"요금조회\",\n" +
" \"description\": \"현재 요금 정보를 조회합니다\",\n" +
" \"available\": true\n" +
" }\n" +
" ]\n" +
" },\n" +
" \"cache_info\": {\n" +
" \"cached\": true,\n" +
" \"timestamp\": \"" + Instant.now().toString() + "\",\n" +
" \"note\": \"서비스 점검 중이므로 캐시된 정보를 제공합니다\"\n" +
" }\n" +
"}";
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.header("X-Cache", "HIT")
.header("X-Cache-Reason", "SERVICE_UNAVAILABLE")
.bodyValue(cachedMenuResponse);
}
/**
* Rate Limit 초과 Fallback
*
* @param request ServerRequest
* @return ServerResponse
*/
public Mono<ServerResponse> rateLimitFallback(ServerRequest request) {
logger.warn("Rate limit exceeded fallback - URI: {}", request.uri());
String fallbackResponse = createFallbackResponse(
"RATE_LIMIT_EXCEEDED",
"요청 한도를 초과했습니다",
"잠시 후 다시 시도해 주세요",
"rate-limiter"
);
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_JSON)
.header("Retry-After", "60")
.bodyValue(fallbackResponse);
}
}
@@ -0,0 +1,66 @@
package com.unicorn.phonebill.gateway.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import java.time.Instant;
/**
* API Gateway Health Indicator
*
* Spring Boot Actuator의 HealthIndicator 인터페이스를 구현하여
* API Gateway의 상태를 점검합니다.
*
* 주요 점검 항목:
* - 메모리 사용률
* - 시스템 상태
* - 스레드 상태
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Component("gateway")
public class GatewayHealthIndicator implements HealthIndicator {
/**
* Actuator HealthIndicator 인터페이스 구현
*
* @return Health 상태
*/
@Override
public Health health() {
try {
// 메모리 사용률 확인
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
double memoryUsage = (double) usedMemory / totalMemory * 100;
Health.Builder healthBuilder = Health.up()
.withDetail("service", "API Gateway")
.withDetail("timestamp", Instant.now().toString())
.withDetail("memory", String.format("%.2f%%", memoryUsage))
.withDetail("threads", Thread.activeCount())
.withDetail("system", "Gateway routing only");
// 메모리 사용률이 90% 이상이면 DOWN
if (memoryUsage >= 90.0) {
return healthBuilder.down()
.withDetail("status", "Memory usage too high")
.build();
}
return healthBuilder.build();
} catch (Exception e) {
return Health.down()
.withDetail("service", "API Gateway")
.withDetail("error", e.getMessage())
.withDetail("timestamp", Instant.now().toString())
.build();
}
}
}
@@ -0,0 +1,174 @@
package com.unicorn.phonebill.gateway.service;
import com.unicorn.phonebill.gateway.dto.TokenValidationResult;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
/**
* JWT 토큰 검증 서비스
*
* JWT 토큰의 유효성을 검증합니다.
*
* 주요 기능:
* - JWT 토큰 파싱 및 서명 검증
* - 토큰 만료 검사
* - 사용자 정보 추출
* - 토큰 갱신 필요 여부 판단
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Service
public class JwtTokenService {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenService.class);
private final SecretKey secretKey;
private final long accessTokenValidityInSeconds;
private final long refreshTokenValidityInSeconds;
public JwtTokenService(
@Value("${app.jwt.secret}") String jwtSecret,
@Value("${app.jwt.access-token-validity-in-seconds:1800}") long accessTokenValidityInSeconds,
@Value("${app.jwt.refresh-token-validity-in-seconds:86400}") long refreshTokenValidityInSeconds) {
this.secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidityInSeconds = accessTokenValidityInSeconds;
this.refreshTokenValidityInSeconds = refreshTokenValidityInSeconds;
logger.info("JWT Token Service initialized - Access token validity: {}s, Refresh token validity: {}s",
accessTokenValidityInSeconds, refreshTokenValidityInSeconds);
}
/**
* JWT 토큰 검증 (비동기)
*
* @param token JWT 토큰
* @return TokenValidationResult
*/
public Mono<TokenValidationResult> validateToken(String token) {
if (token == null || token.trim().isEmpty()) {
return Mono.just(TokenValidationResult.invalid("토큰이 비어있습니다"));
}
try {
// JWT 토큰 파싱 및 서명 검증
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
// 기본 정보 추출
String userId = claims.getSubject();
String userRole = claims.get("role", String.class);
Instant expiresAt = claims.getExpiration().toInstant();
String tokenId = claims.getId(); // jti claim
if (userId == null || userId.trim().isEmpty()) {
return Mono.just(TokenValidationResult.invalid("사용자 정보가 없습니다"));
}
// 토큰 만료 검사
if (expiresAt.isBefore(Instant.now())) {
return Mono.just(TokenValidationResult.invalid("토큰이 만료되었습니다"));
}
// 토큰 갱신 필요 여부 판단 (만료 10분 전)
boolean needsRefresh = expiresAt.isBefore(
Instant.now().plus(Duration.ofMinutes(10))
);
return Mono.just(TokenValidationResult.valid(userId, userRole, expiresAt, needsRefresh));
} catch (ExpiredJwtException e) {
logger.debug("JWT token expired: {}", e.getMessage());
return Mono.just(TokenValidationResult.invalid("토큰이 만료되었습니다"));
} catch (UnsupportedJwtException e) {
logger.debug("Unsupported JWT token: {}", e.getMessage());
return Mono.just(TokenValidationResult.invalid("지원하지 않는 토큰 형식입니다"));
} catch (MalformedJwtException e) {
logger.debug("Malformed JWT token: {}", e.getMessage());
return Mono.just(TokenValidationResult.invalid("잘못된 토큰 형식입니다"));
} catch (SignatureException e) {
logger.debug("Invalid JWT signature: {}", e.getMessage());
return Mono.just(TokenValidationResult.invalid("토큰 서명이 유효하지 않습니다"));
} catch (IllegalArgumentException e) {
logger.debug("Empty JWT token: {}", e.getMessage());
return Mono.just(TokenValidationResult.invalid("토큰이 비어있습니다"));
} catch (Exception e) {
logger.error("JWT token validation error: {}", e.getMessage(), e);
return Mono.just(TokenValidationResult.invalid("토큰 검증 중 오류가 발생했습니다"));
}
}
// Redis 블랙리스트 기능은 API Gateway에서 제거됨
// 필요한 경우 각 마이크로서비스에서 직접 처리
/**
* 토큰에서 사용자 ID 추출 (검증 없이)
*
* @param token JWT 토큰
* @return 사용자 ID
*/
public String extractUserIdWithoutValidation(String token) {
try {
// 서명 검증 없이 클레임만 추출
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
} catch (Exception e) {
logger.debug("Failed to extract user ID from token: {}", e.getMessage());
return null;
}
}
/**
* 토큰 만료 시간까지 남은 시간 계산
*
* @param token JWT 토큰
* @return 남은 시간 (초)
*/
public Long getTokenRemainingTime(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
Instant expiresAt = claims.getExpiration().toInstant();
Duration remaining = Duration.between(Instant.now(), expiresAt);
return remaining.isNegative() ? 0L : remaining.getSeconds();
} catch (Exception e) {
logger.debug("Failed to get token remaining time: {}", e.getMessage());
return 0L;
}
}
}
@@ -0,0 +1,176 @@
package com.unicorn.phonebill.gateway.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
/**
* JWT 유틸리티 클래스
*
* JWT 토큰 관련 유틸리티 메서드를 제공합니다.
* 주로 디버깅이나 개발 과정에서 사용되는 헬퍼 메서드들입니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
public class JwtUtil {
private static final String DEFAULT_SECRET = "phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-required";
/**
* JWT 토큰에서 클레임 추출 (검증 없이)
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return Claims
*/
public static Claims extractClaimsWithoutVerification(String token, String secretKey) {
try {
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (Exception e) {
return null;
}
}
/**
* JWT 토큰에서 사용자 ID 추출
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 사용자 ID
*/
public static String extractUserId(String token, String secretKey) {
Claims claims = extractClaimsWithoutVerification(token, secretKey);
return claims != null ? claims.getSubject() : null;
}
/**
* JWT 토큰에서 사용자 역할 추출
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 사용자 역할
*/
public static String extractUserRole(String token, String secretKey) {
Claims claims = extractClaimsWithoutVerification(token, secretKey);
return claims != null ? claims.get("role", String.class) : null;
}
/**
* JWT 토큰 만료 시간 확인
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 만료 시간
*/
public static Instant extractExpiration(String token, String secretKey) {
Claims claims = extractClaimsWithoutVerification(token, secretKey);
if (claims != null && claims.getExpiration() != null) {
return claims.getExpiration().toInstant();
}
return null;
}
/**
* JWT 토큰 만료 여부 확인
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 만료 여부
*/
public static boolean isTokenExpired(String token, String secretKey) {
Instant expiration = extractExpiration(token, secretKey);
return expiration != null && expiration.isBefore(Instant.now());
}
/**
* 토큰 남은 시간 계산 (초)
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 남은 시간 (초), 만료된 경우 0
*/
public static long getTokenRemainingTimeSeconds(String token, String secretKey) {
Instant expiration = extractExpiration(token, secretKey);
if (expiration == null) {
return 0L;
}
long remainingSeconds = expiration.getEpochSecond() - Instant.now().getEpochSecond();
return Math.max(0L, remainingSeconds);
}
/**
* Bearer 토큰에서 JWT 부분만 추출
*
* @param bearerToken Bearer 토큰 (Authorization 헤더 값)
* @return JWT 토큰 부분
*/
public static String extractJwtFromBearer(String bearerToken) {
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
/**
* JWT 토큰의 기본 정보 요약
*
* @param token JWT 토큰
* @param secretKey 비밀키
* @return 토큰 정보 문자열
*/
public static String getTokenSummary(String token, String secretKey) {
try {
Claims claims = extractClaimsWithoutVerification(token, secretKey);
if (claims == null) {
return "Invalid token";
}
return String.format(
"User: %s, Role: %s, Expires: %s, Remaining: %d seconds",
claims.getSubject(),
claims.get("role", String.class),
claims.getExpiration(),
getTokenRemainingTimeSeconds(token, secretKey)
);
} catch (Exception e) {
return "Token parsing error: " + e.getMessage();
}
}
/**
* 개발용 임시 토큰 생성 (테스트 목적)
*
* @param userId 사용자 ID
* @param userRole 사용자 역할
* @param validitySeconds 유효 시간 (초)
* @return JWT 토큰
*/
public static String createDevelopmentToken(String userId, String userRole, long validitySeconds) {
SecretKey key = Keys.hmacShaKeyFor(DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8));
Instant now = Instant.now();
Instant expiration = now.plusSeconds(validitySeconds);
return Jwts.builder()
.setSubject(userId)
.claim("role", userRole)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.setId("DEV-" + System.currentTimeMillis())
.signWith(key)
.compact();
}
}
@@ -0,0 +1,128 @@
# API Gateway 개발 환경 설정
server:
port: 8080
spring:
# Cloud Gateway 개발환경 설정
cloud:
gateway:
default-filters: []
globalcors:
cors-configurations:
'[/**]':
allowed-origin-patterns:
- "http://localhost:*"
- "http://127.0.0.1:*"
- "https://localhost:*"
allowed-methods: "*"
allowed-headers: "*"
allow-credentials: true
max-age: 86400 # 24시간
# 개발도구 설정
devtools:
restart:
enabled: true
additional-paths: src/main/java,src/main/resources
livereload:
enabled: true
# JWT 설정 (개발용 - 더 긴 유효시간)
app:
jwt:
secret: ${JWT_SECRET:dev-phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-for-development}
access-token-validity-in-seconds: 3600 # 1시간 (개발편의성)
refresh-token-validity-in-seconds: 172800 # 48시간 (개발편의성)
# Circuit Breaker 설정 (개발환경 - 더 관대한 설정)
resilience4j:
circuitbreaker:
instances:
auth-service-cb:
failure-rate-threshold: 80 # 개발환경은 더 관대한 임계값
wait-duration-in-open-state: 10s
sliding-window-size: 5
minimum-number-of-calls: 3
bill-service-cb:
failure-rate-threshold: 80
wait-duration-in-open-state: 10s
sliding-window-size: 5
minimum-number-of-calls: 3
product-service-cb:
failure-rate-threshold: 80
wait-duration-in-open-state: 10s
sliding-window-size: 5
minimum-number-of-calls: 3
kos-mock-cb:
failure-rate-threshold: 90
wait-duration-in-open-state: 5s
sliding-window-size: 5
minimum-number-of-calls: 2
# Actuator 설정 (개발환경 - 모든 엔드포인트 노출)
management:
endpoints:
web:
exposure:
include: "*" # 개발환경에서는 모든 엔드포인트 노출
base-path: /actuator
endpoint:
health:
show-details: always # 개발환경에서는 상세 정보 항상 표시
shutdown:
enabled: true # 개발환경에서만 활성화
beans:
enabled: true
env:
enabled: true
configprops:
enabled: true
# 로깅 설정 (개발환경 - 더 상세한 로그)
logging:
level:
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:DEBUG}
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
org.springframework.data.redis: ${LOG_LEVEL_SPRING_DATA_REDIS:DEBUG}
org.springframework.web.reactive: ${LOG_LEVEL_SPRING_WEB_REACTIVE:DEBUG}
reactor.netty.http.client: ${LOG_LEVEL_REACTOR_NETTY_HTTP_CLIENT:DEBUG}
io.netty.handler.ssl: ${LOG_LEVEL_IO_NETTY_HANDLER_SSL:WARN}
root: ${LOG_LEVEL_ROOT:INFO}
file:
name: ${LOG_FILE:logs/api-gateway.log}
max-size: ${LOG_FILE_MAX_SIZE:100MB}
max-history: ${LOG_FILE_MAX_HISTORY:7}
# OpenAPI 설정 (개발환경)
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
try-it-out-enabled: true # 개발환경에서 Try it out 활성화
urls:
- name: Auth Service (Dev)
url: http://localhost:8081/v3/api-docs
- name: Bill Service (Dev)
url: http://localhost:8082/v3/api-docs
- name: Product Service (Dev)
url: http://localhost:8083/v3/api-docs
- name: KOS Mock Service (Dev)
url: http://localhost:8084/v3/api-docs
# CORS 설정 (개발환경 - 더 관대한 설정) - 이미 위에서 설정됨
# 개발환경 특성 설정
debug: false
trace: false
# 애플리케이션 정보 (개발환경)
info:
app:
environment: development
debug-mode: enabled
hot-reload: enabled
@@ -0,0 +1,219 @@
# API Gateway 운영 환경 설정
server:
port: 8080
netty:
connection-timeout: 20s
idle-timeout: 30s
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain
http2:
enabled: true
spring:
profiles:
active: prod
# Redis 설정 (운영용) - 현재 사용하지 않음
# data:
# redis:
# host: ${REDIS_HOST:redis-cluster.unicorn.com}
# port: ${REDIS_PORT:6379}
# database: ${REDIS_DATABASE:0}
# password: ${REDIS_PASSWORD}
# timeout: 2000ms
# ssl: true # 운영환경에서는 SSL 사용
# lettuce:
# pool:
# max-active: 20
# max-wait: 2000ms
# max-idle: 10
# min-idle: 5
# cluster:
# refresh:
# adaptive: true
# period: 30s
# Cloud Gateway 운영환경 설정
cloud:
gateway:
default-filters:
# - name: RequestRateLimiter # Redis 사용하지 않으므로 주석처리
# args:
# redis-rate-limiter.replenishRate: 500 # 운영환경 적정 한도
# redis-rate-limiter.burstCapacity: 1000
# key-resolver: "#{@userKeyResolver}"
- name: RequestSize
args:
maxSize: 5MB # 요청 크기 제한
globalcors:
cors-configurations:
'[/**]':
allowed-origins:
- "https://app.phonebill.com"
- "https://admin.phonebill.com"
- "https://*.unicorn.com"
allowed-methods:
- GET
- POST
- PUT
- DELETE
allowed-headers:
- Authorization
- Content-Type
- X-Requested-With
allow-credentials: true
max-age: 3600
# JWT 설정 (운영용 - 보안 강화)
app:
jwt:
secret: ${JWT_SECRET} # 환경변수에서 주입 (필수)
access-token-validity-in-seconds: 1800 # 30분 (보안 강화)
refresh-token-validity-in-seconds: 86400 # 24시간
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
resilience4j:
circuitbreaker:
instances:
auth-service-cb:
failure-rate-threshold: 50
slow-call-rate-threshold: 60
slow-call-duration-threshold: 3s
wait-duration-in-open-state: 30s
sliding-window-size: 100
minimum-number-of-calls: 20
permitted-number-of-calls-in-half-open-state: 10
bill-service-cb:
failure-rate-threshold: 50
slow-call-rate-threshold: 60
slow-call-duration-threshold: 5s
wait-duration-in-open-state: 30s
sliding-window-size: 100
minimum-number-of-calls: 20
product-service-cb:
failure-rate-threshold: 50
slow-call-rate-threshold: 60
slow-call-duration-threshold: 5s
wait-duration-in-open-state: 30s
sliding-window-size: 100
minimum-number-of-calls: 20
kos-mock-cb:
failure-rate-threshold: 60
slow-call-rate-threshold: 70
slow-call-duration-threshold: 10s
wait-duration-in-open-state: 60s
sliding-window-size: 50
minimum-number-of-calls: 10
retry:
instances:
default:
max-attempts: 3
wait-duration: 1s
exponential-backoff-multiplier: 2
retry-exceptions:
- java.net.ConnectException
- java.net.SocketTimeoutException
- org.springframework.web.client.ResourceAccessException
# Actuator 설정 (운영환경 - 보안 강화)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,gateway # 필요한 것만 노출
base-path: /actuator
endpoint:
health:
show-details: never # 운영환경에서는 상세 정보 숨김
show-components: never
gateway:
enabled: true
shutdown:
enabled: false # 운영환경에서는 비활성화
health:
redis:
enabled: true
circuitbreakers:
enabled: true
info:
env:
enabled: false # 환경 정보 숨김
java:
enabled: true
build:
enabled: true
metrics:
export:
prometheus:
enabled: true
# 로깅 설정 (운영환경 - 성능 고려)
logging:
level:
com.unicorn.phonebill.gateway: INFO
org.springframework.cloud.gateway: WARN
reactor.netty: WARN
io.netty: WARN
root: WARN
file:
name: /var/log/api-gateway/api-gateway.log
max-size: 500MB
max-history: 30
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
loggers:
org.springframework.security: WARN
org.springframework.web: WARN
# OpenAPI 설정 (운영환경 - 비활성화)
springdoc:
api-docs:
enabled: false # 운영환경에서는 비활성화
swagger-ui:
enabled: false # 운영환경에서는 비활성화
# CORS 설정은 위의 spring.cloud.gateway 섹션에서 설정됨
# 보안 설정
security:
headers:
frame:
deny: true
content-type:
nosniff: true
xss:
protection: true
# JVM 튜닝 (운영환경)
jvm:
heap:
initial: 512m
maximum: 1024m
gc:
algorithm: G1GC
options:
- "-server"
- "-XX:+UseG1GC"
- "-XX:G1HeapRegionSize=16m"
- "-XX:+UseStringDeduplication"
- "-XX:+OptimizeStringConcat"
- "-XX:+UnlockExperimentalVMOptions"
- "-XX:+UseJVMCICompiler"
# 모니터링 및 트레이싱
tracing:
enabled: true
sampling:
probability: 0.1 # 10% 샘플링
zipkin:
base-url: ${ZIPKIN_BASE_URL:http://zipkin.monitoring.unicorn.com:9411}
# 애플리케이션 정보 (운영환경)
info:
app:
environment: production
security-level: high
monitoring: enabled
@@ -0,0 +1,186 @@
# API Gateway 기본 설정
# Spring Boot 3.2 + Spring Cloud Gateway
server:
port: ${SERVER_PORT:8080}
netty:
connection-timeout: ${SERVER_NETTY_CONNECTION_TIMEOUT:30s}
idle-timeout: ${SERVER_NETTY_IDLE_TIMEOUT:60s}
http2:
enabled: ${SERVER_HTTP2_ENABLED:true}
spring:
application:
name: api-gateway
profiles:
active: dev
# Spring Cloud Gateway 설정
cloud:
gateway:
default-filters:
- name: AddRequestHeader
args:
name: X-Gateway-Request
value: API-Gateway
- name: AddResponseHeader
args:
name: X-Gateway-Response
value: API-Gateway
# Global CORS 설정
globalcors:
cors-configurations:
'[/**]':
allowed-origin-patterns: "*"
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
- HEAD
allowed-headers: "*"
allow-credentials: true
max-age: 3600
# Discovery 설정 비활성화 (직접 라우팅 사용)
discovery:
locator:
enabled: false
# JSON 설정
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
# JWT 설정
app:
jwt:
secret: ${JWT_SECRET:phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-required}
access-token-validity-in-seconds: 1800 # 30분
refresh-token-validity-in-seconds: 86400 # 24시간
# 서비스 URL 설정
services:
auth-service:
url: ${AUTH_SERVICE_URL:http://localhost:8081}
bill-service:
url: ${BILL_SERVICE_URL:http://localhost:8082}
product-service:
url: ${PRODUCT_SERVICE_URL:http://localhost:8083}
kos-mock-service:
url: ${KOS_MOCK_SERVICE_URL:http://localhost:8084}
# Circuit Breaker 설정
resilience4j:
circuitbreaker:
instances:
auth-service-cb:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
minimum-number-of-calls: 5
permitted-number-of-calls-in-half-open-state: 3
bill-service-cb:
failure-rate-threshold: 60
wait-duration-in-open-state: 30s
sliding-window-size: 20
minimum-number-of-calls: 10
product-service-cb:
failure-rate-threshold: 60
wait-duration-in-open-state: 30s
sliding-window-size: 20
minimum-number-of-calls: 10
kos-mock-cb:
failure-rate-threshold: 70
wait-duration-in-open-state: 10s
sliding-window-size: 10
minimum-number-of-calls: 5
retry:
instances:
default:
max-attempts: 3
wait-duration: 2s
exponential-backoff-multiplier: 2
retry-exceptions:
- java.net.ConnectException
- java.net.SocketTimeoutException
- org.springframework.web.client.ResourceAccessException
# Actuator 설정
management:
endpoints:
web:
exposure:
include: health,info,metrics,gateway
base-path: /actuator
endpoint:
health:
show-details: when-authorized
show-components: always
gateway:
enabled: true
health:
redis:
enabled: true
circuitbreakers:
enabled: true
info:
env:
enabled: true
java:
enabled: true
build:
enabled: true
# 로깅 설정
logging:
level:
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:INFO}
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
reactor.netty: ${LOG_LEVEL_REACTOR_NETTY:INFO}
io.netty: ${LOG_LEVEL_IO_NETTY:WARN}
root: ${LOG_LEVEL_ROOT:INFO}
file:
name: ${LOG_FILE:logs/api-gateway.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
console: "%d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan([%X{traceId:-},%X{spanId:-}]) %logger{36} - %msg%n"
# OpenAPI 설정
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
urls:
- name: Auth Service
url: /v3/api-docs/auth
- name: Bill Service
url: /v3/api-docs/bills
- name: Product Service
url: /v3/api-docs/products
# 애플리케이션 정보
info:
app:
name: PhoneBill API Gateway
description: 통신요금 관리 서비스 API Gateway
version: 1.0.0
encoding: UTF-8
java:
version: 17