mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
release
This commit is contained in:
@@ -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>
|
||||
@@ -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매니저)
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
+251
@@ -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분 전으로 설정
|
||||
}
|
||||
}
|
||||
+173
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
+197
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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
|
||||
Reference in New Issue
Block a user