diff --git a/CLAUDE.md b/CLAUDE.md
index 13c956d..2667297 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -501,13 +501,31 @@ QA Engineer
- **컴파일**: 최상위 루트에서 `./gradlew {service-name}:compileJava` 명령 사용
- **서버 시작**: AI가 직접 서버를 시작하지 말고 반드시 사람에게 요청할것
+## JSON 데이터 바인딩 문제
+- **문제**: DTO에서 JSON 요청 데이터가 바인딩되지 않아 모든 필드가 "필수입니다" 검증 오류 발생
+- **원인**: Jackson JSON 직렬화/역직렬화 시 명시적 프로퍼티 매핑 누락
+- **해결책**: DTO 필드에 `@JsonProperty("fieldName")` 어노테이션 추가 필수
+- **적용**: UserRegistrationRequest, LoginRequest 등 모든 Request DTO에 적용
+
## 실행 프로파일 작성 경험
- **Gradle 실행 프로파일**: Spring Boot가 아닌 Gradle 실행 프로파일 사용 필수
- **환경변수 매핑**: `` 형태로 환경변수 설정
- **컴포넌트 스캔 이슈**: common 모듈의 @Component가 인식되지 않는 경우 발생
- **의존성 주입 오류**: JwtTokenProvider 빈을 찾을 수 없는 오류 확인됨
+## Authorization Header 문제
+- **문제**: Swagger UI에서 생성된 curl 명령에 Authorization 헤더 누락
+- **원인**: SwaggerConfig의 SecurityRequirement 이름과 Controller의 @SecurityRequirement 이름 불일치
+- **해결책**: SwaggerConfig의 "Bearer Authentication"을 "bearerAuth"로 통일
+- **적용**: bill-service, product-service 모두 수정 완료
+
## 백킹서비스 연결 정보
- **LoadBalancer External IP**: kubectl 명령으로 실제 IP 확인 후 환경변수 설정
- **DB 연결정보**: 각 서비스별 별도 DB 사용 (auth, bill_inquiry, product_change)
- **Redis 공유**: 모든 서비스가 동일한 Redis 인스턴스 사용
+
+## 쿠버네티스 DB 접근 방법
+- **패스워드 확인**: `kubectl get secret -n {namespace} {secret-name} -o jsonpath='{.data.postgres-password}' | base64 -d`
+- **환경변수 확인**: `kubectl exec -n {namespace} {pod-name} -c postgresql -- env | grep POSTGRES`
+- **SQL 실행**: `kubectl exec -n {namespace} {pod-name} -c postgresql -- bash -c 'PGPASSWORD="$POSTGRES_POSTGRES_PASSWORD" psql -U postgres -d {database} -c "{SQL}"'`
+- **예시**: `kubectl exec -n phonebill-dev product-change-postgres-dev-postgresql-0 -c postgresql -- bash -c 'PGPASSWORD="$POSTGRES_POSTGRES_PASSWORD" psql -U postgres -d product_change_db -c "ALTER TABLE product_change.pc_product_change_history ALTER COLUMN customer_id TYPE VARCHAR(100);"'`
diff --git a/api-gateway/.run/api-gateway.run.xml b/api-gateway/.run/api-gateway.run.xml
index ba289bc..a2bad0b 100644
--- a/api-gateway/.run/api-gateway.run.xml
+++ b/api-gateway/.run/api-gateway.run.xml
@@ -3,18 +3,15 @@
@@ -37,4 +34,4 @@
false
-
+
\ No newline at end of file
diff --git a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java
index 5924d8d..a4ad379 100644
--- a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java
+++ b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java
@@ -1,6 +1,7 @@
package com.unicorn.phonebill.gateway.config;
import com.unicorn.phonebill.gateway.filter.JwtAuthenticationGatewayFilterFactory;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
@@ -31,6 +32,9 @@ public class GatewayConfig {
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
+ @Value("${cors.allowed-origins")
+ private String allowedOrigins;
+
public GatewayConfig(JwtAuthenticationGatewayFilterFactory jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@@ -48,28 +52,30 @@ public class GatewayConfig {
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// Auth Service 라우팅 (인증 불필요)
- .route("auth-service", r -> r
- .path("/auth/login", "/auth/refresh")
+ .route("user-service-login", r -> r
+ .path("/api/auth/login", "/api/auth/refresh")
.and()
.method("POST")
- .uri("lb://auth-service"))
+ .filters(f -> f.rewritePath("/api/auth/(?.*)", "/auth/${segment}"))
+ .uri("lb://user-service"))
// Auth Service 라우팅 (인증 필요)
- .route("auth-service-authenticated", r -> r
- .path("/auth/**")
+ .route("user-service-authenticated", r -> r
+ .path("/api/auth/**")
.filters(f -> f
+ .rewritePath("/api/auth/(?.*)", "/auth/${segment}")
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
.circuitBreaker(cb -> cb
- .setName("auth-service-cb")
+ .setName("user-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"))
+ .uri("lb://user-service"))
// Bill-Inquiry Service 라우팅 (인증 필요)
.route("bill-service", r -> r
- .path("/bills/**")
+ .path("/api/v1/bills/**")
.filters(f -> f
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
.circuitBreaker(cb -> cb
@@ -131,15 +137,11 @@ public class GatewayConfig {
@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" // 운영 도메인
- ));
-
+
+ // 환경변수에서 허용할 Origin 패턴 설정
+ String[] origins = allowedOrigins.split(",");
+ corsConfig.setAllowedOriginPatterns(Arrays.asList(origins));
+
// 허용할 HTTP 메서드
corsConfig.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
diff --git a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/service/JwtTokenService.java b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/service/JwtTokenService.java
index 48b43e6..e53ea09 100644
--- a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/service/JwtTokenService.java
+++ b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/service/JwtTokenService.java
@@ -44,9 +44,9 @@ public class JwtTokenService {
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) {
+ @Value("${jwt.secret}") String jwtSecret,
+ @Value("${jwt.access-token-validity:1800}") long accessTokenValidityInSeconds,
+ @Value("${jwt.refresh-token-validity:86400}") long refreshTokenValidityInSeconds) {
this.secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidityInSeconds = accessTokenValidityInSeconds;
diff --git a/api-gateway/src/main/resources/application-dev.yml b/api-gateway/src/main/resources/application-dev.yml
index 43d7a12..50d0500 100644
--- a/api-gateway/src/main/resources/application-dev.yml
+++ b/api-gateway/src/main/resources/application-dev.yml
@@ -1,128 +1,10 @@
-# 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
\ No newline at end of file
+ com.unicorn.phonebill.gateway: DEBUG
+ org.springframework.cloud.gateway: DEBUG
+ org.springframework.data.redis: DEBUG
+ org.springframework.web.reactive: DEBUG
+ reactor.netty.http.client: DEBUG
+ io.netty.handler.ssl: DEBUG
+ root: DEBUG
diff --git a/api-gateway/src/main/resources/application-prod.yml b/api-gateway/src/main/resources/application-prod.yml
index 6e84076..3a4cb0d 100644
--- a/api-gateway/src/main/resources/application-prod.yml
+++ b/api-gateway/src/main/resources/application-prod.yml
@@ -1,219 +1,9 @@
-# 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
+ org.springframework.cloud.gateway: INFO
+ org.springframework.data.redis: INFO
+ org.springframework.web.reactive: WARN
+ reactor.netty.http.client: WARN
+ io.netty.handler.ssl: 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
\ No newline at end of file
diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml
index a11e11d..1cf9eea 100644
--- a/api-gateway/src/main/resources/application.yml
+++ b/api-gateway/src/main/resources/application.yml
@@ -7,15 +7,15 @@ server:
connection-timeout: ${SERVER_NETTY_CONNECTION_TIMEOUT:30s}
idle-timeout: ${SERVER_NETTY_IDLE_TIMEOUT:60s}
http2:
- enabled: ${SERVER_HTTP2_ENABLED:true}
+ enabled: true
spring:
application:
name: api-gateway
profiles:
- active: dev
-
+ active: ${SPRING_PROFILES_ACTIVE:dev}
+
# Spring Cloud Gateway 설정
cloud:
gateway:
@@ -59,12 +59,15 @@ spring:
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시간
+# CORS
+cors:
+ allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
+
+# JWT 토큰 설정
+jwt:
+ secret: ${JWT_SECRET:}
+ access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
+ refresh-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:86400}
# 서비스 URL 설정
services:
@@ -142,14 +145,8 @@ management:
# 로깅 설정
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}
+ name: logs/api-gateway.log
logback:
rollingpolicy:
max-file-size: 10MB
diff --git a/bill-service/.run/bill-service.run.xml b/bill-service/.run/bill-service.run.xml
index e45fa58..3c026df 100644
--- a/bill-service/.run/bill-service.run.xml
+++ b/bill-service/.run/bill-service.run.xml
@@ -3,64 +3,35 @@
@@ -80,7 +51,7 @@
false
true
false
- false
+ false
\ No newline at end of file
diff --git a/bill-service/build.gradle b/bill-service/build.gradle
index 1b580bc..6613315 100644
--- a/bill-service/build.gradle
+++ b/bill-service/build.gradle
@@ -28,6 +28,7 @@ dependencies {
// Common modules (로컬 의존성)
implementation project(':common')
+ implementation project(':kos-mock')
// Test Dependencies (bill service specific)
testImplementation 'org.testcontainers:postgresql'
diff --git a/bill-service/src/main/java/com/phonebill/bill/config/JpaAuditingConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/JpaAuditingConfig.java
new file mode 100644
index 0000000..765e493
--- /dev/null
+++ b/bill-service/src/main/java/com/phonebill/bill/config/JpaAuditingConfig.java
@@ -0,0 +1,22 @@
+package com.phonebill.bill.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/**
+ * JPA Auditing 설정
+ *
+ * BaseTimeEntity의 @CreatedDate, @LastModifiedDate 자동 설정을 위한 구성
+ * - 엔티티 저장/수정 시 자동으로 시간 정보 설정
+ * - 모든 엔티티의 생성/수정 시간 추적 가능
+ *
+ * @author 이개발(백엔더)
+ * @version 1.0.0
+ * @since 2025-09-09
+ */
+@Configuration
+@EnableJpaAuditing
+public class JpaAuditingConfig {
+ // JPA Auditing 활성화를 위한 설정 클래스
+ // 별도의 Bean 정의 없이 @EnableJpaAuditing만으로 충분
+}
\ No newline at end of file
diff --git a/bill-service/src/main/java/com/phonebill/bill/config/JwtConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/JwtConfig.java
new file mode 100644
index 0000000..101c81d
--- /dev/null
+++ b/bill-service/src/main/java/com/phonebill/bill/config/JwtConfig.java
@@ -0,0 +1,38 @@
+package com.phonebill.bill.config;
+
+import com.phonebill.common.security.JwtTokenProvider;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * JWT 설정
+ *
+ * Bill Service의 JWT 토큰 검증을 위한 설정
+ * User Service와 동일한 시크릿 키를 사용하여 토큰 검증
+ *
+ * @author 이개발(백엔더)
+ * @version 1.0.0
+ * @since 2025-09-09
+ */
+@Configuration
+public class JwtConfig {
+
+ /**
+ * JwtTokenProvider 빈 생성
+ *
+ * @param secret JWT 시크릿 키
+ * @param expirationInSeconds JWT 만료 시간 (초)
+ * @return JwtTokenProvider 인스턴스
+ */
+ @Bean
+ public JwtTokenProvider jwtTokenProvider(
+ @Value("${jwt.secret:dev-jwt-secret-key-for-development-only}") String secret,
+ @Value("${jwt.access-token-validity:86400000}") long expirationInMillis) {
+
+ // 만료 시간을 초 단위로 변환
+ long expirationInSeconds = expirationInMillis / 1000;
+
+ return new JwtTokenProvider(secret, expirationInSeconds);
+ }
+}
\ No newline at end of file
diff --git a/bill-service/src/main/java/com/phonebill/bill/config/RedisConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/RedisConfig.java
index 6813e90..4296be0 100644
--- a/bill-service/src/main/java/com/phonebill/bill/config/RedisConfig.java
+++ b/bill-service/src/main/java/com/phonebill/bill/config/RedisConfig.java
@@ -98,8 +98,8 @@ public class RedisConfig {
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
- // Value 직렬화: JSON 사용
- GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());
+ // Value 직렬화: Redis 전용 ObjectMapper 사용
+ GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisObjectMapper());
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
@@ -128,8 +128,8 @@ public class RedisConfig {
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
- .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())))
- .disableCachingNullValues(); // null 값 캐싱 비활성화
+ .fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));
+ // null 값 캐싱은 @Cacheable unless 조건으로 처리
// 캐시별 개별 설정
Map cacheConfigurations = new HashMap<>();
@@ -163,25 +163,24 @@ public class RedisConfig {
}
/**
- * ObjectMapper 구성
+ * Redis 전용 ObjectMapper 구성
*
- * @return JSON 직렬화용 ObjectMapper
+ * @return Redis 직렬화용 ObjectMapper (다형성 타입 정보 포함)
*/
- @Bean
- public ObjectMapper objectMapper() {
+ private ObjectMapper redisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
// Java Time 모듈 등록 (LocalDateTime 등 지원)
mapper.registerModule(new JavaTimeModule());
- // 타입 정보 포함 (다형성 지원)
+ // 타입 정보 포함 (다형성 지원) - Redis 캐싱에만 필요
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
- log.debug("ObjectMapper 구성 완료");
+ log.debug("Redis 전용 ObjectMapper 구성 완료");
return mapper;
}
diff --git a/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java
index 49661bd..a736fc6 100644
--- a/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java
+++ b/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java
@@ -1,7 +1,10 @@
package com.phonebill.bill.config;
+import com.phonebill.common.security.JwtAuthenticationFilter;
+import com.phonebill.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@@ -21,6 +24,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
+import java.util.List;
/**
* Spring Security 설정
@@ -42,6 +46,11 @@ import java.util.Arrays;
@RequiredArgsConstructor
public class SecurityConfig {
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Value("${cors.allowed-origins")
+ private String allowedOrigins;
+
/**
* 보안 필터 체인 구성
*
@@ -53,76 +62,55 @@ public class SecurityConfig {
log.info("Security Filter Chain 구성 시작");
http
- // CSRF 비활성화 (REST API는 CSRF 불필요)
- .csrf(AbstractHttpConfigurer::disable)
+ // CSRF 비활성화 (JWT 사용으로 불필요)
+ .csrf(csrf -> csrf.disable())
- // CORS 설정 활성화
+ // CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
- // 세션 관리 - Stateless (JWT 사용)
+ // 세션 비활성화 (JWT 기반 Stateless)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- // 요청별 인증/인가 설정
- .authorizeHttpRequests(auth -> auth
- // 공개 엔드포인트 - 인증 불필요
+ // 권한 설정
+ .authorizeHttpRequests(authz -> authz
+ // Public endpoints (인증 불필요)
.requestMatchers(
- // Health Check
- "/actuator/**",
- // Swagger UI
- "/swagger-ui/**",
+ "/actuator/health",
+ "/actuator/info",
+ "/actuator/prometheus",
"/v3/api-docs/**",
+ "/api-docs/**",
+ "/swagger-ui/**",
+ "/swagger-ui.html",
"/swagger-resources/**",
- "/webjars/**",
- // 정적 리소스
- "/favicon.ico",
- "/error"
+ "/webjars/**"
).permitAll()
// OPTIONS 요청은 모두 허용 (CORS Preflight)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
+
+ // Actuator endpoints (관리용)
+ .requestMatchers("/actuator/**").hasRole("ADMIN")
- // 요금 조회 API - 인증 필요
- .requestMatchers("/api/bills/**").authenticated()
-
- // 나머지 모든 요청 - 인증 필요
+ // 나머지 모든 요청 인증 필요
.anyRequest().authenticated()
)
- // JWT 인증 필터 추가
- // TODO: JWT 필터 구현 후 활성화
- // .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
+ // JWT 필터 추가
+ .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
- // 예외 처리
- .exceptionHandling(exception -> exception
- // 인증 실패 시 처리
+ // Exception 처리
+ .exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authException) -> {
- log.warn("인증 실패 - URI: {}, 오류: {}",
- request.getRequestURI(), authException.getMessage());
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
- response.getWriter().write("""
- {
- "success": false,
- "message": "인증이 필요합니다",
- "timestamp": "%s"
- }
- """.formatted(java.time.LocalDateTime.now()));
+ response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
})
-
- // 권한 부족 시 처리
.accessDeniedHandler((request, response, accessDeniedException) -> {
- log.warn("접근 거부 - URI: {}, 오류: {}",
- request.getRequestURI(), accessDeniedException.getMessage());
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
- response.getWriter().write("""
- {
- "success": false,
- "message": "접근 권한이 없습니다",
- "timestamp": "%s"
- }
- """.formatted(java.time.LocalDateTime.now()));
+ response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
})
);
@@ -130,99 +118,37 @@ public class SecurityConfig {
return http.build();
}
- /**
- * CORS 설정
- *
- * @return CORS 설정 소스
- */
+ @Bean
+ public JwtAuthenticationFilter jwtAuthenticationFilter() {
+ return new JwtAuthenticationFilter(jwtTokenProvider);
+ }
+
@Bean
public CorsConfigurationSource corsConfigurationSource() {
- log.debug("CORS 설정 구성 시작");
-
CorsConfiguration configuration = new CorsConfiguration();
-
- // 허용할 Origin 설정 (개발환경)
- configuration.setAllowedOriginPatterns(Arrays.asList(
- "http://localhost:*",
- "https://localhost:*",
- "http://127.0.0.1:*",
- "https://127.0.0.1:*"
- // TODO: 운영환경 도메인 추가
- ));
-
- // 허용할 HTTP 메소드
- configuration.setAllowedMethods(Arrays.asList(
- "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
- ));
-
- // 허용할 헤더
- configuration.setAllowedHeaders(Arrays.asList(
- "Authorization",
- "Content-Type",
- "X-Requested-With",
- "Accept",
- "Origin",
- "Access-Control-Request-Method",
- "Access-Control-Request-Headers"
- ));
-
- // 자격 증명 허용 (쿠키, Authorization 헤더 등)
- configuration.setAllowCredentials(true);
-
- // Preflight 요청 캐시 시간 (초)
- configuration.setMaxAge(3600L);
+ // 환경변수에서 허용할 Origin 패턴 설정
+ String[] origins = allowedOrigins.split(",");
+ configuration.setAllowedOriginPatterns(Arrays.asList(origins));
+
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(Arrays.asList("*"));
+ configuration.setAllowCredentials(true);
+ configuration.setMaxAge(3600L);
+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
-
- log.debug("CORS 설정 구성 완료");
return source;
}
- /**
- * 비밀번호 인코더 구성
- *
- * @return BCrypt 패스워드 인코더
- */
@Bean
public PasswordEncoder passwordEncoder() {
- log.debug("Password Encoder 구성 - BCrypt 사용");
- return new BCryptPasswordEncoder();
+ return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
}
- /**
- * 인증 매니저 구성
- *
- * @param config 인증 설정
- * @return 인증 매니저
- */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
- log.debug("Authentication Manager 구성");
return config.getAuthenticationManager();
}
- /**
- * JWT 인증 필터 구성
- *
- * TODO: JWT 토큰 검증 필터 구현
- *
- * @return JWT 인증 필터
- */
- // @Bean
- // public JwtAuthenticationFilter jwtAuthenticationFilter() {
- // return new JwtAuthenticationFilter();
- // }
-
- /**
- * JWT 토큰 제공자 구성
- *
- * TODO: JWT 토큰 생성/검증 서비스 구현
- *
- * @return JWT 토큰 제공자
- */
- // @Bean
- // public JwtTokenProvider jwtTokenProvider() {
- // return new JwtTokenProvider();
- // }
}
\ No newline at end of file
diff --git a/bill-service/src/main/java/com/phonebill/bill/config/SwaggerConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/SwaggerConfig.java
index 2249705..9bf1062 100644
--- a/bill-service/src/main/java/com/phonebill/bill/config/SwaggerConfig.java
+++ b/bill-service/src/main/java/com/phonebill/bill/config/SwaggerConfig.java
@@ -39,9 +39,9 @@ public class SwaggerConfig {
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8082")
.description("Server port"))))
- .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
+ .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components()
- .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
+ .addSecuritySchemes("bearerAuth", createAPIKeyScheme()));
}
private Info apiInfo() {
diff --git a/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java b/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java
index a178341..e3e4759 100644
--- a/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java
+++ b/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java
@@ -1,6 +1,8 @@
package com.phonebill.bill.controller;
import com.phonebill.bill.dto.*;
+import com.phonebill.kosmock.dto.KosCommonResponse;
+import com.phonebill.kosmock.dto.KosBillInquiryResponse;
import com.phonebill.bill.service.BillInquiryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -32,7 +34,7 @@ import org.springframework.web.bind.annotation.*;
*/
@Slf4j
@RestController
-@RequestMapping("/bills")
+@RequestMapping("/api/v1/bills")
@RequiredArgsConstructor
@Validated
@Tag(name = "Bill Inquiry", description = "요금조회 관련 API")
@@ -115,63 +117,22 @@ public class BillController {
description = "KOS 시스템 장애 (Circuit Breaker Open)"
)
})
- public ResponseEntity> inquireBill(
+ public ResponseEntity> inquireBill(
@Valid @RequestBody BillInquiryRequest request) {
log.info("요금조회 요청 - 회선번호: {}, 조회월: {}",
request.getLineNumber(), request.getInquiryMonth());
- BillInquiryResponse response = billInquiryService.inquireBill(request);
+ KosBillInquiryResponse response = billInquiryService.inquireBill(request);
- if (response.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
- log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
- response.getRequestId(), request.getLineNumber());
- return ResponseEntity.ok(
- ApiResponse.success(response, "요금조회가 완료되었습니다")
- );
- } else {
- log.info("요금조회 비동기 처리 - 요청ID: {}, 상태: {}",
- response.getRequestId(), response.getStatus());
- return ResponseEntity.accepted().body(
- ApiResponse.success(response, "요금조회 요청이 접수되었습니다")
- );
- }
- }
-
- /**
- * 요금조회 결과 확인
- *
- * 비동기로 처리된 요금조회 결과를 확인합니다.
- * requestId를 통해 조회 상태와 결과를 반환합니다.
- */
- @GetMapping("/inquiry/{requestId}")
- @Operation(
- summary = "요금조회 결과 확인",
- description = "비동기로 처리된 요금조회의 상태와 결과를 확인합니다.",
- security = @SecurityRequirement(name = "bearerAuth")
- )
- @ApiResponses(value = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "200",
- description = "요금조회 결과 조회 성공"
- ),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(
- responseCode = "404",
- description = "요청 ID를 찾을 수 없음"
- )
- })
- public ResponseEntity> getBillInquiryResult(
- @Parameter(description = "요금조회 요청 ID", example = "REQ_20240308_001")
- @PathVariable String requestId) {
- log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
+ log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
+ response.getRequestId(), request.getLineNumber());
- BillInquiryResponse response = billInquiryService.getBillInquiryResult(requestId);
-
- log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
return ResponseEntity.ok(
- ApiResponse.success(response, "요금조회 결과를 조회했습니다")
+ KosCommonResponse.success(response, "요금 조회가 완료되었습니다")
);
}
+
/**
* 요금조회 이력 조회
*
diff --git a/bill-service/src/main/java/com/phonebill/bill/dto/ApiResponse.java b/bill-service/src/main/java/com/phonebill/bill/dto/ApiResponse.java
index 676c3a1..17836c4 100644
--- a/bill-service/src/main/java/com/phonebill/bill/dto/ApiResponse.java
+++ b/bill-service/src/main/java/com/phonebill/bill/dto/ApiResponse.java
@@ -13,10 +13,12 @@ import java.time.LocalDateTime;
*
* 모든 API 응답에 대한 공통 구조를 제공
* - success: 성공/실패 여부
+ * - resultCode: 결과 코드
+ * - resultMessage: 결과 메시지
* - data: 실제 응답 데이터 (성공시)
* - error: 오류 정보 (실패시)
- * - message: 응답 메시지
* - timestamp: 응답 시간
+ * - traceId: 추적 ID
*
* @param 응답 데이터 타입
* @author 이개발(백엔더)
@@ -35,6 +37,16 @@ public class ApiResponse {
*/
private boolean success;
+ /**
+ * 결과 코드
+ */
+ private String resultCode;
+
+ /**
+ * 결과 메시지
+ */
+ private String resultMessage;
+
/**
* 응답 데이터 (성공시에만 포함)
*/
@@ -45,17 +57,17 @@ public class ApiResponse {
*/
private ErrorDetail error;
- /**
- * 응답 메시지
- */
- private String message;
-
/**
* 응답 시간
*/
@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();
+ /**
+ * 추적 ID
+ */
+ private String traceId;
+
/**
* 성공 응답 생성
*
@@ -67,8 +79,9 @@ public class ApiResponse {
public static ApiResponse success(T data, String message) {
return ApiResponse.builder()
.success(true)
+ .resultCode("0000")
+ .resultMessage(message)
.data(data)
- .message(message)
.build();
}
@@ -93,8 +106,9 @@ public class ApiResponse {
public static ApiResponse failure(ErrorDetail error, String message) {
return ApiResponse.builder()
.success(false)
+ .resultCode(error.getCode())
+ .resultMessage(message)
.error(error)
- .message(message)
.build();
}
@@ -110,7 +124,12 @@ public class ApiResponse {
.code(code)
.message(message)
.build();
- return failure(error, message);
+ return ApiResponse.builder()
+ .success(false)
+ .resultCode(code)
+ .resultMessage(message)
+ .error(error)
+ .build();
}
}
diff --git a/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java b/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java
index 733a9ef..d2c3bb9 100644
--- a/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java
+++ b/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java
@@ -1,5 +1,6 @@
package com.phonebill.bill.dto;
+import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
@@ -28,6 +29,7 @@ public class BillInquiryRequest {
* 조회할 회선번호 (필수)
* 010-XXXX-XXXX 형식만 허용
*/
+ @JsonProperty("lineNumber")
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(
regexp = "^010-\\d{4}-\\d{4}$",
@@ -37,11 +39,12 @@ public class BillInquiryRequest {
/**
* 조회월 (선택)
- * YYYY-MM 형식, 미입력시 당월 조회
+ * YYYYMM 형식, 미입력시 당월 조회
*/
+ @JsonProperty("inquiryMonth")
@Pattern(
- regexp = "^\\d{4}-\\d{2}$",
- message = "조회월은 YYYY-MM 형식이어야 합니다"
+ regexp = "^\\d{6}$",
+ message = "조회월은 YYYYMM 형식이어야 합니다"
)
private String inquiryMonth;
}
\ No newline at end of file
diff --git a/bill-service/src/main/java/com/phonebill/bill/exception/GlobalExceptionHandler.java b/bill-service/src/main/java/com/phonebill/bill/exception/GlobalExceptionHandler.java
index a3f370c..dc7ba95 100644
--- a/bill-service/src/main/java/com/phonebill/bill/exception/GlobalExceptionHandler.java
+++ b/bill-service/src/main/java/com/phonebill/bill/exception/GlobalExceptionHandler.java
@@ -108,7 +108,7 @@ public class GlobalExceptionHandler {
.body(ApiResponse.