회선번호 처리 개선 및 다양한 API 기능 강화

- user-service: 회원등록 API를 upsert 방식으로 변경 (기존 사용자 업데이트 지원)
- user-service: userName 필드 응답 누락 문제 해결 (DB 데이터 업데이트)
- kos-mock: Mock 데이터 생성 기간을 3개월에서 6개월로 확장
- product-service: 회선번호 대시 처리 지원 (010-1234-5678, 01012345678 모두 허용)
- bill-service: 회선번호 대시 선택적 처리 지원 (유연한 입력 형식)
- api-gateway: CORS 중복 헤더 제거 필터 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hiondal
2025-09-10 19:25:13 +09:00
parent 9bfdeda316
commit 2599d57a37
17 changed files with 323 additions and 238 deletions
@@ -6,11 +6,6 @@ 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 설정
@@ -32,8 +27,6 @@ public class GatewayConfig {
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
@Value("${cors.allowed-origins}")
private String allowedOrigins;
@Value("${services.user-service.url}")
private String userServiceUrl;
@@ -123,7 +116,7 @@ public class GatewayConfig {
.setBackoff(java.time.Duration.ofSeconds(1), java.time.Duration.ofSeconds(5), 2, true))
)
.uri(kosMockUrl))
// 주의: Gateway 자체 엔드포인트는 라우팅하지 않음
// Health Check와 Swagger UI는 Spring Boot에서 직접 제공
@@ -131,51 +124,8 @@ public class GatewayConfig {
}
/**
* CORS 설정
*
* 프론트엔드에서 API Gateway로의 크로스 오리진 요청을 허용합니다.
* 개발/운영 환경에 따라 허용 오리진을 다르게 설정합니다.
*
* @return CorsWebFilter
* CORS 설정은 application.yml의 globalcors에서 관리
* add-to-simple-url-handler-mapping: true 설정으로
* 라우트 predicate와 매치되지 않는 OPTIONS 요청도 처리
*/
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration corsConfig = new CorsConfiguration();
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
corsConfig.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 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,83 @@
package com.unicorn.phonebill.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
/**
* Global Filter for removing duplicate CORS headers
*
* 이 필터는 백엔드 서비스에서 오는 CORS 헤더를 제거하여
* API Gateway의 GlobalCors 설정만 사용하도록 합니다.
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-01-08
*/
@Slf4j
@Component
public class CorsDedupeGlobalFilter implements GlobalFilter, Ordered {
private static final List<String> CORS_HEADERS = Arrays.asList(
"Access-Control-Allow-Origin",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
log.info("=== CorsDedupeGlobalFilter 시작 - Path: {}", path);
// Response를 감싸서 헤더 수정 가능하게 만들기
ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(exchange.getResponse()) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders originalHeaders = super.getHeaders();
HttpHeaders filteredHeaders = new HttpHeaders();
// 원본 헤더를 안전하게 복사하면서 CORS 헤더는 제외
final int[] removedCount = {0};
originalHeaders.forEach((key, values) -> {
if (CORS_HEADERS.contains(key)) {
removedCount[0]++;
log.info("CORS 헤더 제거: {} = {}", key, values);
} else {
try {
filteredHeaders.addAll(key, values);
} catch (Exception e) {
log.warn("헤더 추가 실패: {} = {}, 에러: {}", key, values, e.getMessage());
}
}
});
if (removedCount[0] > 0) {
log.info("=== CorsDedupeGlobalFilter 완료 - 제거된 헤더 수: {}", removedCount[0]);
}
return filteredHeaders;
}
};
// 수정된 response로 exchange 생성
ServerWebExchange mutatedExchange = exchange.mutate().response(decoratedResponse).build();
return chain.filter(mutatedExchange);
}
@Override
public int getOrder() {
return -1; // 높은 우선순위로 설정
}
}
+22 -25
View File
@@ -30,6 +30,28 @@ spring:
response-timeout: 60s
connect-timeout: 5000
# Global CORS 설정
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000}"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
- HEAD
allowedHeaders: "*"
allow-credentials: true
max-age: 3600
# Discovery 설정 비활성화 (직접 라우팅 사용)
discovery:
locator:
enabled: false
# Default filters
default-filters:
- name: AddRequestHeader
args:
@@ -39,27 +61,6 @@ spring:
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:
@@ -69,10 +70,6 @@ spring:
deserialization:
fail-on-unknown-properties: false
# CORS
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# JWT 토큰 설정
jwt:
secret: ${JWT_SECRET:}