kos-mock 상품변경 실제 DB 업데이트 기능 추가

- MockDataService에 updateCustomerProduct 메서드 추가
- KosMockService에 실제 고객 데이터 업데이트 로직 추가
- 상품변경 시 고객의 current_product_code를 실제로 업데이트하도록 수정
- 트랜잭션 처리로 데이터 일관성 보장
- product-service Hibernate dialect 설정 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hiondal 2025-09-10 02:06:24 +09:00
parent 6ca4daed8d
commit 02bcfa5434
122 changed files with 6116 additions and 3983 deletions

View File

@ -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 실행 프로파일 사용 필수
- **환경변수 매핑**: `<entry key="..." value="..." />` 형태로 환경변수 설정
- **컴포넌트 스캔 이슈**: 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);"'`

View File

@ -3,18 +3,15 @@
<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="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
<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" />
<entry key="SERVER_PORT" value="8080" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
</map>
</option>
<option name="executionName" />

View File

@ -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/(?<segment>.*)", "/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/(?<segment>.*)", "/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
@ -132,13 +138,9 @@ public class GatewayConfig {
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(

View File

@ -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;

View File

@ -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
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

View File

@ -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

View File

@ -7,14 +7,14 @@ 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:
@ -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

View File

@ -3,64 +3,35 @@
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Connection -->
<entry key="DB_HOST" value="20.249.175.46" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="bill_inquiry_db" />
<entry key="DB_USERNAME" value="bill_inquiry_user" />
<entry key="DB_PASSWORD" value="BillUser2025!" />
<entry key="DB_KIND" value="postgresql" />
<!-- Redis Connection -->
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_DATABASE" value="1" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8082" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
<!-- JPA Configuration -->
<entry key="JPA_DDL_AUTO" value="update" />
<entry key="SHOW_SQL" value="true" />
<!-- Logging Configuration -->
<entry key="LOG_FILE_NAME" value="logs/bill-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<!-- KOS Mock URL -->
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
<!-- Development optimized settings -->
<entry key="JPA_SHOW_SQL" value="true" />
<entry key="JPA_FORMAT_SQL" value="true" />
<entry key="JPA_SQL_COMMENTS" value="true" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_SERVICE" value="DEBUG" />
<entry key="LOG_LEVEL_REPOSITORY" value="DEBUG" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="DEBUG" />
<entry key="LOG_LEVEL_CACHE" value="DEBUG" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<!-- Connection Pool Settings -->
<entry key="DB_MIN_IDLE" value="5" />
<entry key="DB_MAX_POOL" value="20" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
<entry key="DB_CONNECTION_TIMEOUT" value="30000" />
<entry key="DB_HOST" value="20.249.175.46" />
<entry key="DB_IDLE_TIMEOUT" value="600000" />
<entry key="DB_MAX_LIFETIME" value="1800000" />
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_LEAK_DETECTION" value="60000" />
<!-- Redis Pool Settings -->
<entry key="DB_MAX_LIFETIME" value="1800000" />
<entry key="DB_MAX_POOL" value="20" />
<entry key="DB_MIN_IDLE" value="5" />
<entry key="DB_NAME" value="bill_inquiry_db" />
<entry key="DB_PASSWORD" value="BillUser2025!" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_USERNAME" value="bill_inquiry_user" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
<entry key="LOG_FILE_NAME" value="logs/bill-service.log" />
<entry key="REDIS_DATABASE" value="1" />
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_MAX_ACTIVE" value="8" />
<entry key="REDIS_MAX_IDLE" value="8" />
<entry key="REDIS_MIN_IDLE" value="0" />
<entry key="REDIS_MAX_WAIT" value="-1" />
<entry key="REDIS_MIN_IDLE" value="0" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_TIMEOUT" value="2000" />
<entry key="SERVER_PORT" value="8082" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
</map>
</option>
<option name="executionName" />
@ -80,7 +51,7 @@
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<ForceTestExec>false</ForceTestExec>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -28,6 +28,7 @@ dependencies {
// Common modules ( )
implementation project(':common')
implementation project(':kos-mock')
// Test Dependencies (bill service specific)
testImplementation 'org.testcontainers:postgresql'

View File

@ -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만으로 충분
}

View File

@ -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);
}
}

View File

@ -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<String, RedisCacheConfiguration> 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;
}

View File

@ -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()
// 요금 조회 API - 인증 필요
.requestMatchers("/api/bills/**").authenticated()
// Actuator endpoints (관리용)
.requestMatchers("/actuator/**").hasRole("ADMIN")
// 나머지 모든 요청 - 인증 필요
// 나머지 모든 요청 인증 필요
.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: 운영환경 도메인 추가
));
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 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.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 ()
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();
// }
}

View File

@ -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() {

View File

@ -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,62 +117,21 @@ public class BillController {
description = "KOS 시스템 장애 (Circuit Breaker Open)"
)
})
public ResponseEntity<ApiResponse<BillInquiryResponse>> inquireBill(
public ResponseEntity<KosCommonResponse<KosBillInquiryResponse>> 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, "요금조회 요청이 접수되었습니다")
KosCommonResponse.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<ApiResponse<BillInquiryResponse>> getBillInquiryResult(
@Parameter(description = "요금조회 요청 ID", example = "REQ_20240308_001")
@PathVariable String requestId) {
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
BillInquiryResponse response = billInquiryService.getBillInquiryResult(requestId);
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
return ResponseEntity.ok(
ApiResponse.success(response, "요금조회 결과를 조회했습니다")
);
}
/**
* 요금조회 이력 조회

View File

@ -13,10 +13,12 @@ import java.time.LocalDateTime;
*
* 모든 API 응답에 대한 공통 구조를 제공
* - success: 성공/실패 여부
* - resultCode: 결과 코드
* - resultMessage: 결과 메시지
* - data: 실제 응답 데이터 (성공시)
* - error: 오류 정보 (실패시)
* - message: 응답 메시지
* - timestamp: 응답 시간
* - traceId: 추적 ID
*
* @param <T> 응답 데이터 타입
* @author 이개발(백엔더)
@ -35,6 +37,16 @@ public class ApiResponse<T> {
*/
private boolean success;
/**
* 결과 코드
*/
private String resultCode;
/**
* 결과 메시지
*/
private String resultMessage;
/**
* 응답 데이터 (성공시에만 포함)
*/
@ -45,17 +57,17 @@ public class ApiResponse<T> {
*/
private ErrorDetail error;
/**
* 응답 메시지
*/
private String message;
/**
* 응답 시간
*/
@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();
/**
* 추적 ID
*/
private String traceId;
/**
* 성공 응답 생성
*
@ -67,8 +79,9 @@ public class ApiResponse<T> {
public static <T> ApiResponse<T> success(T data, String message) {
return ApiResponse.<T>builder()
.success(true)
.resultCode("0000")
.resultMessage(message)
.data(data)
.message(message)
.build();
}
@ -93,8 +106,9 @@ public class ApiResponse<T> {
public static ApiResponse<Void> failure(ErrorDetail error, String message) {
return ApiResponse.<Void>builder()
.success(false)
.resultCode(error.getCode())
.resultMessage(message)
.error(error)
.message(message)
.build();
}
@ -110,7 +124,12 @@ public class ApiResponse<T> {
.code(code)
.message(message)
.build();
return failure(error, message);
return ApiResponse.<Void>builder()
.success(false)
.resultCode(code)
.resultMessage(message)
.error(error)
.build();
}
}

View File

@ -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;
}

View File

@ -108,7 +108,7 @@ public class GlobalExceptionHandler {
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.resultMessage("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}
@ -129,7 +129,7 @@ public class GlobalExceptionHandler {
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.resultMessage("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}
@ -152,7 +152,7 @@ public class GlobalExceptionHandler {
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.resultMessage("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}

View File

@ -27,17 +27,23 @@ import java.time.LocalDateTime;
public class KosRequest {
/**
* 회선번호 (KOS 필드명: lineNum)
* 회선번호 (KOS 필드명: lineNumber)
*/
@JsonProperty("lineNum")
@JsonProperty("lineNumber")
private String lineNumber;
/**
* 조회월 (KOS 필드명: searchMonth, YYYY-MM 형식)
* 조회월 (KOS 필드명: billingMonth, YYYYMM 형식)
*/
@JsonProperty("searchMonth")
@JsonProperty("billingMonth")
private String inquiryMonth;
/**
* 요청 ID (KOS 필드명: requestId)
*/
@JsonProperty("requestId")
private String requestId;
/**
* 서비스 구분 코드 (KOS 필드명: svcDiv)
* - BILL_INQ: 요금조회
@ -122,9 +128,14 @@ public class KosRequest {
* @return KOS 요청 객체
*/
public static KosRequest createBillInquiryRequest(String lineNumber, String inquiryMonth, String requestUserId) {
// YYYY-MM 형식을 YYYYMM 형식으로 변환
String billingMonth = inquiryMonth.replace("-", "");
String requestId = "REQ_" + java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
return KosRequest.builder()
.lineNumber(lineNumber)
.inquiryMonth(inquiryMonth)
.lineNumber(lineNumber.replace("-", "")) // 하이픈 제거
.inquiryMonth(billingMonth)
.requestId(requestId)
.requestUserId(requestUserId)
.requestTime(LocalDateTime.now())
.requestSequenceNumber(generateSequenceNumber())

View File

@ -95,12 +95,92 @@ public interface BillInquiryHistoryRepository extends JpaRepository<BillInquiryH
*/
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND (:lineNumber IS NULL OR h.lineNumber = :lineNumber) " +
"AND (:startTime IS NULL OR h.requestTime >= :startTime) " +
"AND (:endTime IS NULL OR h.requestTime <= :endTime) " +
"AND (:status IS NULL OR h.status = :status) " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryWithFilters(
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbers(
@Param("lineNumbers") List<String> lineNumbers,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.lineNumber = :lineNumber " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumber(
@Param("lineNumbers") List<String> lineNumbers,
@Param("lineNumber") String lineNumber,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.requestTime >= :startTime " +
"AND h.requestTime <= :endTime " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndDateRange(
@Param("lineNumbers") List<String> lineNumbers,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.status = :status " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndStatus(
@Param("lineNumbers") List<String> lineNumbers,
@Param("status") String status,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.lineNumber = :lineNumber " +
"AND h.requestTime >= :startTime " +
"AND h.requestTime <= :endTime " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumberAndDateRange(
@Param("lineNumbers") List<String> lineNumbers,
@Param("lineNumber") String lineNumber,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.lineNumber = :lineNumber " +
"AND h.status = :status " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumberAndStatus(
@Param("lineNumbers") List<String> lineNumbers,
@Param("lineNumber") String lineNumber,
@Param("status") String status,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.requestTime >= :startTime " +
"AND h.requestTime <= :endTime " +
"AND h.status = :status " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndDateRangeAndStatus(
@Param("lineNumbers") List<String> lineNumbers,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("status") String status,
Pageable pageable
);
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.lineNumber = :lineNumber " +
"AND h.requestTime >= :startTime " +
"AND h.requestTime <= :endTime " +
"AND h.status = :status " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findBillHistoryWithAllFilters(
@Param("lineNumbers") List<String> lineNumbers,
@Param("lineNumber") String lineNumber,
@Param("startTime") LocalDateTime startTime,

View File

@ -55,7 +55,7 @@ public class BillCacheService {
* @param inquiryMonth 조회월
* @return 캐시된 요금 데이터 (없으면 null)
*/
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth")
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth", unless = "#result == null")
public BillInquiryResponse getCachedBillData(String lineNumber, String inquiryMonth) {
log.debug("요금 데이터 캐시 조회 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
@ -90,6 +90,12 @@ public class BillCacheService {
public void cacheBillData(String lineNumber, String inquiryMonth, BillInquiryResponse billData) {
log.debug("요금 데이터 캐시 저장 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
// null 값은 캐시하지 않음
if (billData == null) {
log.debug("요금 데이터가 null이므로 캐시 저장을 건너뜀 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
return;
}
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
try {

View File

@ -194,11 +194,9 @@ public class BillHistoryService {
endDateTime = LocalDate.parse(endDate).atTime(23, 59, 59);
}
String statusFilter = status != null ? status.name() : null;
// 이력 조회
Page<BillInquiryHistoryEntity> historyPage = historyRepository.findBillHistoryWithFilters(
userLineNumbers, lineNumber, startDateTime, endDateTime, statusFilter, pageable
// 조건에 따라 적절한 쿼리 선택
Page<BillInquiryHistoryEntity> historyPage = getBillHistoryByConditions(
userLineNumbers, lineNumber, startDateTime, endDateTime, status, pageable
);
// 응답 데이터 변환
@ -233,6 +231,64 @@ public class BillHistoryService {
}
}
/**
* 조건에 따라 적절한 쿼리를 선택하여 이력 조회
*/
private Page<BillInquiryHistoryEntity> getBillHistoryByConditions(
List<String> userLineNumbers, String lineNumber,
LocalDateTime startDateTime, LocalDateTime endDateTime,
BillInquiryResponse.ProcessStatus status, Pageable pageable) {
boolean hasLineNumber = lineNumber != null && !lineNumber.trim().isEmpty();
boolean hasDateRange = startDateTime != null && endDateTime != null;
boolean hasStatus = status != null;
String statusFilter = hasStatus ? status.name() : null;
// 8가지 경우의 수에 따라 적절한 쿼리 선택
if (hasLineNumber && hasDateRange && hasStatus) {
// 모든 필터 적용
return historyRepository.findBillHistoryWithAllFilters(
userLineNumbers, lineNumber, startDateTime, endDateTime, statusFilter, pageable
);
} else if (hasLineNumber && hasDateRange) {
// 회선번호 + 날짜 범위
return historyRepository.findBillHistoryByLineNumbersAndLineNumberAndDateRange(
userLineNumbers, lineNumber, startDateTime, endDateTime, pageable
);
} else if (hasLineNumber && hasStatus) {
// 회선번호 + 상태
return historyRepository.findBillHistoryByLineNumbersAndLineNumberAndStatus(
userLineNumbers, lineNumber, statusFilter, pageable
);
} else if (hasDateRange && hasStatus) {
// 날짜 범위 + 상태
return historyRepository.findBillHistoryByLineNumbersAndDateRangeAndStatus(
userLineNumbers, startDateTime, endDateTime, statusFilter, pageable
);
} else if (hasLineNumber) {
// 회선번호만
return historyRepository.findBillHistoryByLineNumbersAndLineNumber(
userLineNumbers, lineNumber, pageable
);
} else if (hasDateRange) {
// 날짜 범위만
return historyRepository.findBillHistoryByLineNumbersAndDateRange(
userLineNumbers, startDateTime, endDateTime, pageable
);
} else if (hasStatus) {
// 상태만
return historyRepository.findBillHistoryByLineNumbersAndStatus(
userLineNumbers, statusFilter, pageable
);
} else {
// 필터 없음 (기본)
return historyRepository.findBillHistoryByLineNumbers(
userLineNumbers, pageable
);
}
}
/**
* 엔티티를 이력 아이템으로 변환
*/

View File

@ -1,6 +1,7 @@
package com.phonebill.bill.service;
import com.phonebill.bill.dto.*;
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
/**
* 요금조회 서비스 인터페이스
@ -41,20 +42,8 @@ public interface BillInquiryService {
* @param request 요금조회 요청 데이터
* @return 요금조회 응답 데이터
*/
BillInquiryResponse inquireBill(BillInquiryRequest request);
KosBillInquiryResponse inquireBill(BillInquiryRequest request);
/**
* 요금조회 결과 확인
*
* 비동기로 처리된 요금조회의 상태와 결과를 반환
* - PROCESSING: 처리 상태
* - COMPLETED: 처리 완료 (요금 정보 포함)
* - FAILED: 처리 실패 (오류 메시지 포함)
*
* @param requestId 요금조회 요청 ID
* @return 요금조회 응답 데이터
*/
BillInquiryResponse getBillInquiryResult(String requestId);
/**
* 요금조회 이력 조회

View File

@ -2,8 +2,12 @@ package com.phonebill.bill.service;
import com.phonebill.bill.dto.*;
import com.phonebill.bill.exception.BillInquiryException;
import com.phonebill.common.security.UserPrincipal;
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -47,12 +51,11 @@ public class BillInquiryServiceImpl implements BillInquiryService {
log.info("요금조회 메뉴 조회 시작");
// 현재 인증된 사용자의 고객 정보 조회 (JWT에서 추출)
// TODO: SecurityContext에서 사용자 정보 추출 로직 구현
String customerId = getCurrentCustomerId();
String lineNumber = getCurrentLineNumber();
// 조회 가능한 목록 생성 (최근 12개월)
List<String> availableMonths = generateAvailableMonths();
// 실제 요금 데이터가 있는 목록 조회
List<String> availableMonths = getAvailableMonthsWithData(lineNumber);
// 현재
String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
@ -77,7 +80,7 @@ public class BillInquiryServiceImpl implements BillInquiryService {
*/
@Override
@Transactional
public BillInquiryResponse inquireBill(BillInquiryRequest request) {
public KosBillInquiryResponse inquireBill(BillInquiryRequest request) {
log.info("요금조회 요청 처리 시작 - 회선: {}, 조회월: {}",
request.getLineNumber(), request.getInquiryMonth());
@ -87,125 +90,33 @@ public class BillInquiryServiceImpl implements BillInquiryService {
// 조회월 기본값 설정 (미입력시 당월)
String inquiryMonth = request.getInquiryMonth();
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
try {
// 1단계: 캐시에서 데이터 확인 (Cache-Aside 패턴)
BillInquiryResponse cachedResponse = billCacheService.getCachedBillData(
// KOS Mock 서비스 직접 호출
KosBillInquiryResponse response = kosClientService.inquireBillFromKosDirect(
request.getLineNumber(), inquiryMonth
);
if (cachedResponse != null) {
log.info("캐시에서 요금 데이터 조회 완료 - 요청ID: {}", requestId);
cachedResponse = BillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
.billInfo(cachedResponse.getBillInfo())
.build();
// 이력 저장 (비동기)
billHistoryService.saveInquiryHistoryAsync(requestId, request, cachedResponse);
return cachedResponse;
}
// 2단계: KOS 시스템 연동 (Circuit Breaker 적용)
CompletableFuture<BillInquiryResponse> kosResponseFuture = kosClientService.inquireBillFromKos(
request.getLineNumber(), inquiryMonth
);
BillInquiryResponse kosResponse;
try {
kosResponse = kosResponseFuture.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BillInquiryException("요금조회 처리가 중단되었습니다", e);
} catch (Exception e) {
throw new BillInquiryException("요금조회 처리 중 오류가 발생했습니다", e);
}
if (kosResponse != null && kosResponse.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
// 3단계: 캐시에 저장 (1시간 TTL)
billCacheService.cacheBillData(request.getLineNumber(), inquiryMonth, kosResponse);
// 응답 데이터 구성
BillInquiryResponse response = BillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
.billInfo(kosResponse.getBillInfo())
.build();
// 이력 저장 (비동기)
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
log.info("KOS 연동을 통한 요금조회 완료 - 요청ID: {}", requestId);
log.info("KOS Mock 요금조회 완료 - 요청ID: {}, 상태: {}",
response.getRequestId(), response.getProcStatus());
return response;
} else {
// KOS에서 비동기 처리 중인 경우
BillInquiryResponse response = BillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
.build();
// 이력 저장 (처리 상태)
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
log.info("KOS 연동 비동기 처리 - 요청ID: {}", requestId);
return response;
}
} catch (Exception e) {
log.error("요금조회 처리 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
log.error("KOS Mock 요금조회 실패 - 회선: {}, 오류: {}",
request.getLineNumber(), e.getMessage(), e);
// 실패 응답 생성
BillInquiryResponse errorResponse = BillInquiryResponse.builder()
// 실패 기본 응답 반환
return KosBillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.FAILED)
.procStatus("FAILED")
.resultCode("9999")
.resultMessage("요금 조회 중 오류가 발생했습니다")
.build();
// 이력 저장 (실패 상태)
billHistoryService.saveInquiryHistoryAsync(requestId, request, errorResponse);
// 비즈니스 예외는 그대로 던지고, 시스템 예외는 래핑
if (e instanceof BillInquiryException) {
throw e;
} else {
throw new BillInquiryException("요금조회 처리 중 시스템 오류가 발생했습니다", e);
}
}
}
/**
* 요금조회 결과 확인
*/
@Override
public BillInquiryResponse getBillInquiryResult(String requestId) {
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
// 이력에서 요청 정보 조회
BillInquiryResponse response = billHistoryService.getBillInquiryResult(requestId);
if (response == null) {
throw BillInquiryException.billDataNotFound(requestId, "요청 ID");
}
// 처리 중인 경우 KOS에서 최신 상태 확인
if (response.getStatus() == BillInquiryResponse.ProcessStatus.PROCESSING) {
try {
BillInquiryResponse latestResponse = kosClientService.checkInquiryStatus(requestId);
if (latestResponse != null) {
// 상태 업데이트
billHistoryService.updateInquiryStatus(requestId, latestResponse);
response = latestResponse;
}
} catch (Exception e) {
log.warn("KOS 상태 확인 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage());
// 상태 확인 실패해도 기존 상태 그대로 반환
}
}
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
return response;
}
/**
* 요금조회 이력 조회
@ -244,8 +155,21 @@ public class BillInquiryServiceImpl implements BillInquiryService {
* 현재 인증된 사용자의 고객 ID 조회
*/
private String getCurrentCustomerId() {
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 고객 ID 추출
// 현재는 더미 데이터 반환
// JWT에서 인증된 사용자의 고객 ID 추출
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
String customerId = userPrincipal.getCustomerId();
if (customerId != null && !customerId.trim().isEmpty()) {
log.debug("사용자 {}의 고객 ID: {}", userPrincipal.getUserId(), customerId);
return customerId;
}
}
// 인증 정보가 없거나 고객 ID가 없는 경우 기본값 반환
log.warn("사용자의 고객 ID 정보를 찾을 수 없습니다. 기본값 사용");
return "CUST001";
}
@ -253,8 +177,21 @@ public class BillInquiryServiceImpl implements BillInquiryService {
* 현재 인증된 사용자의 회선번호 조회
*/
private String getCurrentLineNumber() {
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 회선번호 추출
// 현재는 더미 데이터 반환
// JWT에서 인증된 사용자의 회선번호 추출
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
String lineNumber = userPrincipal.getLineNumber();
if (lineNumber != null && !lineNumber.trim().isEmpty()) {
log.debug("사용자 {}의 회선번호: {}", userPrincipal.getUserId(), lineNumber);
return lineNumber;
}
}
// 인증 정보가 없거나 회선번호가 없는 경우 기본값 반환
log.warn("사용자의 회선번호 정보를 찾을 수 없습니다. 기본값 사용");
return "010-1234-5678";
}
@ -262,22 +199,60 @@ public class BillInquiryServiceImpl implements BillInquiryService {
* 현재 사용자의 모든 회선번호 목록 조회
*/
private List<String> getCurrentUserLineNumbers() {
// TODO: 사용자 권한에 따른 회선번호 목록 조회
// 현재는 더미 데이터 반환
// JWT에서 인증된 사용자의 회선번호 추출
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
String lineNumber = userPrincipal.getLineNumber();
if (lineNumber != null) {
List<String> lineNumbers = new ArrayList<>();
lineNumbers.add("010-1234-5678");
lineNumbers.add(lineNumber);
log.debug("사용자 {}의 회선번호: {}", userPrincipal.getUserId(), lineNumber);
return lineNumbers;
}
}
// 인증 정보가 없거나 회선번호가 없는 경우 목록 반환
log.warn("사용자의 회선번호 정보를 찾을 수 없습니다");
return new ArrayList<>();
}
/**
* 조회 가능한 목록 생성 (최근 12개월)
* 실제 요금 데이터가 있는 목록 조회
*/
private List<String> generateAvailableMonths() {
private List<String> getAvailableMonthsWithData(String lineNumber) {
try {
log.info("회선 {}의 실제 요금 데이터가 있는 월 목록 조회", lineNumber);
// KOS Mock 서비스를 통해 실제 데이터가 있는 목록 조회
List<String> availableMonths = kosClientService.getAvailableMonths(lineNumber);
if (availableMonths == null || availableMonths.isEmpty()) {
log.warn("KOS에서 조회 가능한 월 정보가 없음. 기본 최근 3개월 반환");
return generateDefaultMonths(3);
}
log.info("KOS에서 조회된 데이터 보유 월: {}", availableMonths);
return availableMonths;
} catch (Exception e) {
log.error("KOS 시스템에서 조회 가능한 월 정보 조회 실패: {}", e.getMessage(), e);
// 실패 기본 최근 3개월 반환
return generateDefaultMonths(3);
}
}
/**
* 기본 목록 생성 (fallback용)
*/
private List<String> generateDefaultMonths(int monthCount) {
List<String> months = new ArrayList<>();
LocalDate currentDate = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
for (int i = 0; i < 12; i++) {
for (int i = 0; i < monthCount; i++) {
LocalDate monthDate = currentDate.minusMonths(i);
months.add(monthDate.format(formatter));
}

View File

@ -6,6 +6,10 @@ import com.phonebill.bill.exception.CircuitBreakerException;
import com.phonebill.bill.exception.KosConnectionException;
import com.phonebill.bill.external.KosRequest;
import com.phonebill.bill.external.KosResponse;
import com.phonebill.kosmock.dto.KosCommonResponse;
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
@ -48,7 +52,146 @@ public class KosClientService {
private final KosProperties kosProperties;
/**
* KOS 시스템에서 요금 정보 조회
* KOS Mock 시스템에서 요금 정보 조회 (KosBillInquiryResponse 직접 반환)
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return KOS 원본 응답 데이터
*/
@CircuitBreaker(name = "kos-bill-inquiry-direct", fallbackMethod = "inquireBillDirectFallback")
@Retry(name = "kos-bill-inquiry-direct")
public KosBillInquiryResponse inquireBillFromKosDirect(String lineNumber, String inquiryMonth) {
log.info("KOS Mock 직접 호출 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
try {
// 회선번호 형식 변환 (010-1234-5678 01012345678)
String formattedLineNumber = lineNumber.replaceAll("-", "");
// KOS Mock 요청 데이터 구성 (KosBillInquiryRequest 형식)
Map<String, Object> kosRequest = Map.of(
"lineNumber", formattedLineNumber,
"billingMonth", inquiryMonth,
"requestId", generateRequestId()
);
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(kosRequest, headers);
// KOS Mock API 호출
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
ResponseEntity<Map> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.POST, requestEntity, Map.class
);
Map<String, Object> response = responseEntity.getBody();
if (response == null) {
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
}
// KosCommonResponse의 data 부분에서 KosBillInquiryResponse 추출
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data == null) {
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
"NO_DATA", "응답에서 data를 찾을 수 없습니다");
}
// KosBillInquiryResponse 객체 구성
KosBillInquiryResponse result = convertMapToKosBillInquiryResponse(data);
log.info("KOS Mock 직접 호출 성공 - 요청ID: {}", result.getRequestId());
return result;
} catch (Exception e) {
log.error("KOS Mock 직접 호출 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
throw new KosConnectionException("KOS-BILL-INQUIRY-DIRECT",
"KOS Mock 시스템 연동 중 오류가 발생했습니다", e);
}
}
/**
* KOS 시스템에서 요금 정보 조회 (동기 처리)
*
* Circuit Breaker, Retry 패턴 적용
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 요금조회 응답
*/
@CircuitBreaker(name = "kos-bill-inquiry", fallbackMethod = "inquireBillSyncFallback")
@Retry(name = "kos-bill-inquiry")
public BillInquiryResponse inquireBillFromKosSync(String lineNumber, String inquiryMonth) {
log.info("KOS 요금조회 요청 (동기) - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
try {
// KOS 요청 데이터 구성
KosRequest kosRequest = KosRequest.createBillInquiryRequest(lineNumber, inquiryMonth, "system");
log.info("KOS Mock으로 전송하는 요청: lineNumber={}, inquiryMonth={}, requestId={}",
kosRequest.getLineNumber(), kosRequest.getInquiryMonth(), kosRequest.getRequestId());
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
// KOS API 호출 (KOS Mock 응답 구조에 맞게 수정)
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
ResponseEntity<Map> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.POST, requestEntity, Map.class
);
Map<String, Object> kosCommonResponse = responseEntity.getBody();
log.info("KOS Mock 응답 받음: {}", kosCommonResponse);
if (kosCommonResponse == null) {
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
}
// KOS Mock 응답을 내부 모델로 변환
BillInquiryResponse response = convertKosMockResponseToBillResponse(kosCommonResponse);
log.info("KOS 요금조회 성공 (동기) - 회선: {}, 조회월: {}, 상태: {}",
lineNumber, inquiryMonth, response.getStatus());
return response;
} catch (HttpClientErrorException e) {
log.error("KOS API 클라이언트 오류 - 회선: {}, 상태: {}, 응답: {}",
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
} catch (HttpServerErrorException e) {
log.error("KOS API 서버 오류 - 회선: {}, 상태: {}, 응답: {}",
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
} catch (ResourceAccessException e) {
log.error("KOS 네트워크 연결 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
throw KosConnectionException.networkError("KOS-BILL-INQUIRY", e);
} catch (Exception e) {
log.error("KOS 연동 중 예상치 못한 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
throw new KosConnectionException("KOS-BILL-INQUIRY",
"KOS 시스템 연동 중 오류가 발생했습니다", e);
}
}
/**
* KOS 시스템에서 요금 정보 조회 (비동기 처리)
*
* Circuit Breaker, Retry, TimeLimiter 패턴 적용
*
@ -65,11 +208,7 @@ public class KosClientService {
try {
// KOS 요청 데이터 구성
KosRequest kosRequest = KosRequest.builder()
.lineNumber(lineNumber)
.inquiryMonth(inquiryMonth)
.requestTime(LocalDateTime.now())
.build();
KosRequest kosRequest = KosRequest.createBillInquiryRequest(lineNumber, inquiryMonth, "system");
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
@ -80,7 +219,7 @@ public class KosClientService {
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
// KOS API 호출
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/inquiry";
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.POST, requestEntity, KosResponse.class
);
@ -124,6 +263,27 @@ public class KosClientService {
});
}
/**
* KOS 요금조회 동기 처리 Circuit Breaker Fallback 메소드
*/
public BillInquiryResponse inquireBillSyncFallback(String lineNumber, String inquiryMonth, Exception ex) {
log.warn("KOS 요금조회 동기 처리 Circuit Breaker 작동 - 회선: {}, 조회월: {}, 오류: {}",
lineNumber, inquiryMonth, ex.getMessage());
// Circuit Breaker가 Open 상태인 경우
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
throw CircuitBreakerException.circuitBreakerOpen("KOS-BILL-INQUIRY");
}
// 기타 오류의 경우 실패 처리로 전환
BillInquiryResponse fallbackResponse = BillInquiryResponse.builder()
.status(BillInquiryResponse.ProcessStatus.FAILED)
.build();
log.info("KOS 요금조회 동기 처리 fallback 응답 - 실패 처리로 전환");
return fallbackResponse;
}
/**
* KOS 요금조회 Circuit Breaker Fallback 메소드
*/
@ -203,14 +363,130 @@ public class KosClientService {
.build();
}
/**
* KOS Mock 응답을 내부 응답 모델로 변환
*/
private BillInquiryResponse convertKosMockResponseToBillResponse(Map<String, Object> kosCommonResponse) {
try {
// KosCommonResponse에서 success와 data 추출
Boolean success = (Boolean) kosCommonResponse.get("success");
String resultCode = (String) kosCommonResponse.get("resultCode");
Map<String, Object> data = (Map<String, Object>) kosCommonResponse.get("data");
if (!Boolean.TRUE.equals(success) || !"0000".equals(resultCode) || data == null) {
log.warn("KOS Mock 요금조회 실패 - success: {}, resultCode: {}", success, resultCode);
return BillInquiryResponse.builder()
.requestId(data != null ? (String) data.get("requestId") : null)
.status(BillInquiryResponse.ProcessStatus.FAILED)
.build();
}
// data에서 실제 요금 정보 추출
String procStatus = (String) data.get("procStatus");
Map<String, Object> billInfo = (Map<String, Object>) data.get("billInfo");
// 상태 변환
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.COMPLETED;
if ("SUCCESS".equalsIgnoreCase(procStatus)) {
status = BillInquiryResponse.ProcessStatus.COMPLETED;
} else if ("PROCESSING".equalsIgnoreCase(procStatus)) {
status = BillInquiryResponse.ProcessStatus.PROCESSING;
} else if ("FAILED".equalsIgnoreCase(procStatus)) {
status = BillInquiryResponse.ProcessStatus.FAILED;
}
BillInquiryResponse.BillInfo convertedBillInfo = null;
if (billInfo != null && status == BillInquiryResponse.ProcessStatus.COMPLETED) {
// 할인 정보 처리
List<BillInquiryResponse.DiscountInfo> discounts = new ArrayList<>();
Object discountAmount = billInfo.get("discountAmount");
if (discountAmount != null && convertToInteger(discountAmount) > 0) {
discounts.add(BillInquiryResponse.DiscountInfo.builder()
.name("기본 할인")
.amount(convertToInteger(discountAmount))
.build());
}
convertedBillInfo = BillInquiryResponse.BillInfo.builder()
.productName((String) billInfo.get("productName"))
.contractInfo((String) billInfo.get("lineNumber"))
.billingMonth((String) billInfo.get("billingMonth"))
.totalAmount(convertToInteger(billInfo.get("totalFee")))
.discountInfo(discounts)
.usage(BillInquiryResponse.UsageInfo.builder()
.voice((String) billInfo.get("voiceUsage"))
.sms((String) billInfo.get("smsUsage"))
.data((String) billInfo.get("dataUsage"))
.build())
.terminationFee(0) // KOS Mock에서 기본값
.deviceInstallment(0) // KOS Mock에서 기본값
.paymentInfo(BillInquiryResponse.PaymentInfo.builder()
.billingDate((String) billInfo.get("dueDate"))
.paymentStatus(getBillPaymentStatus((String) billInfo.get("billStatus")))
.paymentMethod("자동이체")
.build())
.build();
}
return BillInquiryResponse.builder()
.requestId((String) data.get("requestId"))
.status(status)
.billInfo(convertedBillInfo)
.build();
} catch (Exception e) {
log.error("KOS Mock 응답 변환 오류: {}", e.getMessage(), e);
throw KosConnectionException.dataConversionError("KOS-BILL-INQUIRY", "BillInquiryResponse", e);
}
}
/**
* BigDecimal이나 다른 타입을 Integer로 변환
*/
private Integer convertToInteger(Object value) {
if (value == null) return null;
if (value instanceof Integer) return (Integer) value;
if (value instanceof Number) return ((Number) value).intValue();
try {
return Integer.valueOf(value.toString().split("\\.")[0]); // 소수점 제거
} catch (NumberFormatException e) {
log.warn("숫자 변환 실패: {}", value);
return 0;
}
}
/**
* KOS Mock의 billStatus를 PaymentStatus로 변환
*/
private BillInquiryResponse.PaymentStatus getBillPaymentStatus(String billStatus) {
if (billStatus == null) return BillInquiryResponse.PaymentStatus.UNPAID;
switch (billStatus.toUpperCase()) {
case "PAID":
case "CONFIRMED":
return BillInquiryResponse.PaymentStatus.PAID;
case "UNPAID":
return BillInquiryResponse.PaymentStatus.UNPAID;
case "OVERDUE":
return BillInquiryResponse.PaymentStatus.OVERDUE;
default:
return BillInquiryResponse.PaymentStatus.UNPAID;
}
}
/**
* KOS 응답을 내부 응답 모델로 변환
*/
private BillInquiryResponse convertKosResponseToBillResponse(KosResponse kosResponse) {
try {
// 상태 변환
// 상태 변환 - null 체크 추가
BillInquiryResponse.ProcessStatus status;
switch (kosResponse.getStatus().toUpperCase()) {
String kosStatus = kosResponse.getStatus();
if (kosStatus == null || kosStatus.trim().isEmpty()) {
log.warn("KOS 응답 상태가 null이거나 빈 문자열입니다. 기본값(PROCESSING)으로 설정합니다.");
status = BillInquiryResponse.ProcessStatus.PROCESSING;
} else {
switch (kosStatus.toUpperCase()) {
case "SUCCESS":
case "COMPLETED":
status = BillInquiryResponse.ProcessStatus.COMPLETED;
@ -224,9 +500,11 @@ public class KosClientService {
status = BillInquiryResponse.ProcessStatus.FAILED;
break;
default:
log.warn("알 수 없는 KOS 상태: {}. 기본값(PROCESSING)으로 설정합니다.", kosStatus);
status = BillInquiryResponse.ProcessStatus.PROCESSING;
break;
}
}
BillInquiryResponse.BillInfo billInfo = null;
@ -304,6 +582,165 @@ public class KosClientService {
}
}
/**
* KOS Mock 직접 호출 Circuit Breaker Fallback 메소드
*/
public KosBillInquiryResponse inquireBillDirectFallback(String lineNumber, String inquiryMonth, Exception ex) {
log.warn("KOS Mock 직접 호출 Circuit Breaker 작동 - 회선: {}, 조회월: {}, 오류: {}",
lineNumber, inquiryMonth, ex.getMessage());
// 기본 실패 응답 생성
return KosBillInquiryResponse.builder()
.requestId(generateRequestId())
.procStatus("FAILED")
.resultCode("9999")
.resultMessage("시스템 오류로 인한 조회 실패")
.build();
}
/**
* Map을 KosBillInquiryResponse로 변환
*/
private KosBillInquiryResponse convertMapToKosBillInquiryResponse(Map<String, Object> data) {
try {
// billInfo 데이터 처리
KosBillInquiryResponse.BillInfo billInfo = null;
Map<String, Object> billInfoMap = (Map<String, Object>) data.get("billInfo");
if (billInfoMap != null) {
billInfo = KosBillInquiryResponse.BillInfo.builder()
.lineNumber((String) billInfoMap.get("lineNumber"))
.billingMonth((String) billInfoMap.get("billingMonth"))
.productCode((String) billInfoMap.get("productCode"))
.productName((String) billInfoMap.get("productName"))
.monthlyFee(convertToBigDecimal(billInfoMap.get("monthlyFee")))
.usageFee(convertToBigDecimal(billInfoMap.get("usageFee")))
.discountAmount(convertToBigDecimal(billInfoMap.get("discountAmount")))
.totalFee(convertToBigDecimal(billInfoMap.get("totalFee")))
.dataUsage((String) billInfoMap.get("dataUsage"))
.voiceUsage((String) billInfoMap.get("voiceUsage"))
.smsUsage((String) billInfoMap.get("smsUsage"))
.billStatus((String) billInfoMap.get("billStatus"))
.dueDate((String) billInfoMap.get("dueDate"))
.build();
}
// customerInfo 데이터 처리
KosBillInquiryResponse.CustomerInfo customerInfo = null;
Map<String, Object> customerInfoMap = (Map<String, Object>) data.get("customerInfo");
if (customerInfoMap != null) {
customerInfo = KosBillInquiryResponse.CustomerInfo.builder()
.customerName((String) customerInfoMap.get("customerName"))
.customerId((String) customerInfoMap.get("customerId"))
.operatorCode((String) customerInfoMap.get("operatorCode"))
.lineStatus((String) customerInfoMap.get("lineStatus"))
.build();
}
return KosBillInquiryResponse.builder()
.requestId((String) data.get("requestId"))
.procStatus((String) data.get("procStatus"))
.resultCode((String) data.get("resultCode"))
.resultMessage((String) data.get("resultMessage"))
.billInfo(billInfo)
.customerInfo(customerInfo)
.build();
} catch (Exception e) {
log.error("Map을 KosBillInquiryResponse로 변환 실패: {}", e.getMessage(), e);
throw new RuntimeException("응답 데이터 변환 실패", e);
}
}
/**
* Object를 BigDecimal로 변환
*/
private java.math.BigDecimal convertToBigDecimal(Object value) {
if (value == null) return null;
if (value instanceof java.math.BigDecimal) return (java.math.BigDecimal) value;
if (value instanceof Number) return java.math.BigDecimal.valueOf(((Number) value).doubleValue());
try {
return new java.math.BigDecimal(value.toString());
} catch (NumberFormatException e) {
log.warn("BigDecimal 변환 실패: {}", value);
return java.math.BigDecimal.ZERO;
}
}
/**
* 요청 ID 생성
*/
private String generateRequestId() {
String currentDate = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
String uuid = java.util.UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return String.format("REQ_%s_%s", currentDate, uuid);
}
/**
* 회선번호의 실제 요금 데이터가 있는 목록 조회
*
* @param lineNumber 회선번호
* @return 데이터가 있는 목록 (yyyy-MM 형식)
*/
@CircuitBreaker(name = "kos-available-months", fallbackMethod = "getAvailableMonthsFallback")
@Retry(name = "kos-available-months")
public List<String> getAvailableMonths(String lineNumber) {
log.info("KOS에서 회선 {}의 데이터 보유 월 조회", lineNumber);
try {
// 회선번호 형식 변환 (010-1234-5678 01012345678)
String formattedLineNumber = lineNumber.replaceAll("-", "");
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// KOS Mock API 호출 - 목록 조회
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/available-months/" + formattedLineNumber;
ResponseEntity<Map> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.GET, requestEntity, Map.class
);
Map<String, Object> response = responseEntity.getBody();
if (response == null) {
log.warn("KOS에서 월 목록 응답이 없음");
return new ArrayList<>();
}
// KosCommonResponse의 data 부분에서 목록 추출
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data == null) {
log.warn("KOS 응답에서 data를 찾을 수 없음");
return new ArrayList<>();
}
List<String> availableMonths = (List<String>) data.get("availableMonths");
if (availableMonths == null) {
availableMonths = new ArrayList<>();
}
log.info("KOS에서 조회된 데이터 보유 월: {} (총 {}개월)", availableMonths, availableMonths.size());
return availableMonths;
} catch (Exception e) {
log.error("KOS에서 데이터 보유 월 조회 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
throw new KosConnectionException("KOS-AVAILABLE-MONTHS",
"KOS 시스템에서 데이터 보유 월 조회 중 오류가 발생했습니다", e);
}
}
/**
* 데이터 보유 조회 Circuit Breaker Fallback 메소드
*/
public List<String> getAvailableMonthsFallback(String lineNumber, Exception ex) {
log.warn("KOS 데이터 보유 월 조회 Circuit Breaker 작동 - 회선: {}, 오류: {}", lineNumber, ex.getMessage());
return new ArrayList<>(); // 목록 반환
}
/**
* KOS 시스템 연결 상태 확인
*

View File

@ -1,169 +1,6 @@
# 통신요금 관리 서비스 - Bill Service 개발환경 설정
# 개발자 편의성과 디버깅을 위한 설정
#
# @author 이개발(백엔더)
# @version 1.0.0
# @since 2025-09-08
spring:
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:bill_inquiry_db}
username: ${DB_USERNAME:bill_inquiry_user}
password: ${DB_PASSWORD:BillUser2025!}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA 설정
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Redis2025Dev!}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:1}
# 캐시 설정 (개발환경 - 짧은 TTL)
cache:
redis:
time-to-live: 300000 # 5분
# 서버 설정 (개발환경)
server:
port: ${SERVER_PORT:8082}
error:
include-message: always
include-binding-errors: always
include-stacktrace: always # 개발환경에서는 스택트레이스 포함
include-exception: true # 예외 정보 포함
# 액추에이터 설정 (개발환경)
management:
endpoints:
web:
exposure:
include: "*" # 개발환경에서는 모든 엔드포인트 노출
endpoint:
health:
show-details: always
show-components: always
security:
enabled: false # 개발환경에서는 액추에이터 보안 비활성화
# KOS 시스템 연동 설정 (개발환경)
kos:
base-url: http://localhost:9090 # 로컬 KOS Mock 서버
connect-timeout: 3000
read-timeout: 10000
max-retries: 2
retry-delay: 500
# Circuit Breaker 설정 (개발환경 - 관대한 설정)
circuit-breaker:
failure-rate-threshold: 0.7 # 70% 실패율
slow-call-duration-threshold: 15000 # 15초
slow-call-rate-threshold: 0.7 # 70% 느린 호출
sliding-window-size: 5 # 작은 윈도우
minimum-number-of-calls: 3 # 적은 최소 호출
permitted-number-of-calls-in-half-open-state: 2
wait-duration-in-open-state: 30000 # 30초
# 인증 설정 (개발환경)
authentication:
enabled: false # 개발환경에서는 인증 비활성화
api-key: dev-api-key
secret-key: dev-secret-key
token-expiration-seconds: 7200 # 2시간
# 모니터링 설정 (개발환경)
monitoring:
performance-logging-enabled: true
slow-request-threshold: 1000 # 1초 (더 민감한 감지)
metrics-enabled: true
health-check-interval: 10000 # 10초
# 로깅 설정 (개발환경)
# 로깅 설정
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.phonebill: ${LOG_LEVEL_APP:DEBUG} # 애플리케이션 로그 디버그 레벨
com.phonebill: DEBUG
com.phonebill.bill.service: DEBUG
com.phonebill.bill.repository: DEBUG
org.springframework.cache: DEBUG
org.springframework.web: DEBUG
org.springframework.security: DEBUG
org.hibernate.SQL: DEBUG # SQL 쿼리 로그
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # SQL 파라미터 로그
io.github.resilience4j: DEBUG
redis.clients.jedis: DEBUG
org.springframework.web.client.RestTemplate: DEBUG
pattern:
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
file:
name: ${LOG_FILE_NAME:logs/bill-service.log}
max-size: 50MB
max-history: 7 # 개발환경에서는 7일만 보관
# Swagger 설정 (개발환경)
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
tags-sorter: alpha
operations-sorter: alpha
display-request-duration: true
default-models-expand-depth: 2
default-model-expand-depth: 2
try-it-out-enabled: true
filter: true
doc-expansion: list
show-actuator: true
# 개발환경 전용 설정
debug: false # Spring Boot 디버그 모드
# 개발편의를 위한 프로파일 정보
---
spring:
config:
activate:
on-profile: dev
# 개발환경 정보
info:
environment: development
debug:
enabled: true
database:
name: bill_service_dev
host: localhost
port: 3306
redis:
host: localhost
port: 6379
database: 1
kos:
host: localhost
port: 9090
mock: true

View File

@ -1,237 +1,6 @@
# 통신요금 관리 서비스 - Bill Service 운영환경 설정
# 운영환경 안정성과 보안을 위한 설정
#
# @author 이개발(백엔더)
# @version 1.0.0
# @since 2025-09-08
spring:
# 데이터베이스 설정 (운영환경)
datasource:
url: ${DB_URL:jdbc:mysql://prod-db-host:3306/bill_service_prod?useUnicode=true&characterEncoding=utf8&useSSL=true&requireSSL=true&serverTimezone=Asia/Seoul}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
minimum-idle: 10
maximum-pool-size: 50
idle-timeout: 600000 # 10분
max-lifetime: 1800000 # 30분
connection-timeout: 30000 # 30초
validation-timeout: 5000 # 5초
leak-detection-threshold: 60000 # 1분
# JPA 설정 (운영환경)
jpa:
hibernate:
ddl-auto: validate # 운영환경에서는 스키마 검증만
show-sql: false # 운영환경에서는 SQL 로그 비활성화
properties:
hibernate:
format_sql: false
use_sql_comments: false
default_batch_fetch_size: 100
jdbc:
batch_size: 50
connection:
provider_disables_autocommit: true
# Redis 설정 (운영환경)
redis:
host: ${REDIS_HOST:prod-redis-host}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
database: ${REDIS_DATABASE:0}
timeout: 5000
ssl: ${REDIS_SSL:true}
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 5
max-wait: 5000
cluster:
refresh:
adaptive: true
period: 30s
# 캐시 설정 (운영환경)
cache:
redis:
time-to-live: 3600000 # 1시간
# 서버 설정 (운영환경)
server:
port: ${SERVER_PORT:8081}
error:
include-message: never
include-binding-errors: never
include-stacktrace: never # 운영환경에서는 스택트레이스 숨김
include-exception: false # 예외 정보 숨김
tomcat:
max-connections: 10000
accept-count: 200
threads:
max: 300
min-spare: 20
connection-timeout: 20000
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css
min-response-size: 1024
# 액추에이터 설정 (운영환경)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus # 제한적 노출
base-path: /actuator
endpoint:
health:
show-details: when_authorized # 인증된 사용자에게만 상세 정보 제공
show-components: when_authorized
probes:
enabled: true
info:
enabled: true
metrics:
enabled: true
prometheus:
enabled: true
security:
enabled: true
health:
redis:
enabled: true
db:
enabled: true
diskspace:
enabled: true
threshold: 500MB
metrics:
export:
prometheus:
enabled: true
descriptions: false
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.95, 0.99
sla:
http.server.requests: 100ms, 500ms, 1s, 2s
# KOS 시스템 연동 설정 (운영환경)
kos:
base-url: ${KOS_BASE_URL}
connect-timeout: 5000
read-timeout: 30000
max-retries: 3
retry-delay: 1000
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
circuit-breaker:
failure-rate-threshold: 0.5 # 50% 실패율
slow-call-duration-threshold: 10000 # 10초
slow-call-rate-threshold: 0.5 # 50% 느린 호출
sliding-window-size: 20 # 큰 윈도우로 정확한 측정
minimum-number-of-calls: 10 # 충분한 샘플
permitted-number-of-calls-in-half-open-state: 5
wait-duration-in-open-state: 60000 # 60초
# 인증 설정 (운영환경)
authentication:
enabled: true
api-key: ${KOS_API_KEY}
secret-key: ${KOS_SECRET_KEY}
token-expiration-seconds: 3600 # 1시간
token-refresh-threshold-seconds: 300 # 5분
# 모니터링 설정 (운영환경)
monitoring:
performance-logging-enabled: true
slow-request-threshold: 3000 # 3초
metrics-enabled: true
health-check-interval: 30000 # 30초
# 로깅 설정 (운영환경)
# 로깅 설정
logging:
level:
root: WARN
com.phonebill: INFO # 애플리케이션 로그는 INFO 레벨
com.phonebill: INFO
com.phonebill.bill.service: INFO
com.phonebill.bill.repository: WARN
org.springframework.cache: WARN
org.springframework.web: WARN
org.springframework.security: WARN
org.hibernate.SQL: WARN # SQL 로그 비활성화
org.hibernate.type.descriptor.sql.BasicBinder: WARN
io.github.resilience4j: INFO
redis.clients.jedis: WARN
org.springframework.web.client.RestTemplate: WARN
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
file:
name: ${LOG_FILE:/app/logs/bill-service.log}
max-size: 200MB
max-history: 30 # 30일 보관
logback:
rollingpolicy:
total-size-cap: 5GB
appender:
console:
enabled: false # 운영환경에서는 콘솔 로그 비활성화
# Swagger 설정 (운영환경 - 보안상 비활성화)
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
show-actuator: false
# 운영환경 보안 설정
security:
require-ssl: true
headers:
frame:
deny: true
content-type:
nosniff: true
xss-protection:
and-block: true
# 운영환경 전용 설정
debug: false
---
spring:
config:
activate:
on-profile: prod
# 운영환경 정보
info:
environment: production
debug:
enabled: false
security:
ssl-enabled: true
database:
name: bill_service_prod
ssl-enabled: true
redis:
ssl-enabled: true
cluster-enabled: true
kos:
ssl-enabled: true
authentication-enabled: true
# 운영환경 JVM 옵션 권장사항
# -Xms2g -Xmx4g
# -XX:+UseG1GC
# -XX:MaxGCPauseMillis=200
# -XX:+HeapDumpOnOutOfMemoryError
# -XX:HeapDumpPath=/app/logs/heap-dump.hprof
# -Djava.security.egd=file:/dev/./urandom
# -Dspring.profiles.active=prod
com.phonebill.bill.repository: INFO

View File

@ -11,58 +11,49 @@ spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
include:
- common
# 데이터베이스 설정
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/bill_inquiry_db}
username: ${DB_USERNAME:bill_user}
password: ${DB_PASSWORD:bill_pass}
url: jdbc:postgresql://${DB_HOST:20.249.107.185}:${DB_PORT:5432}/${DB_NAME:product_change}
username: ${DB_USERNAME:product_user}
password: ${DB_PASSWORD:product_pass}
driver-class-name: org.postgresql.Driver
hikari:
minimum-idle: ${DB_MIN_IDLE:5}
maximum-pool-size: ${DB_MAX_POOL:20}
idle-timeout: ${DB_IDLE_TIMEOUT:300000}
max-lifetime: ${DB_MAX_LIFETIME:1800000}
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
validation-timeout: ${DB_VALIDATION_TIMEOUT:5000}
leak-detection-threshold: ${DB_LEAK_DETECTION:60000}
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: ${JPA_SHOW_SQL:false}
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: ${JPA_FORMAT_SQL:false}
use_sql_comments: ${JPA_SQL_COMMENTS:false}
default_batch_fetch_size: ${JPA_BATCH_SIZE:100}
jdbc:
batch_size: ${JPA_JDBC_BATCH_SIZE:20}
order_inserts: true
order_updates: true
format_sql: true
use_sql_comments: true
connection:
provider_disables_autocommit: true
open-in-view: false
provider_disables_autocommit: false
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:5000}
timeout: 2000ms
lettuce:
pool:
max-active: ${REDIS_MAX_ACTIVE:20}
max-idle: ${REDIS_MAX_IDLE:8}
min-idle: ${REDIS_MIN_IDLE:0}
max-wait: ${REDIS_MAX_WAIT:-1}
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:2}
# Cache 개발 설정 (TTL 단축)
cache:
redis:
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
# Jackson 설정
jackson:
@ -76,22 +67,6 @@ spring:
time-zone: Asia/Seoul
date-format: yyyy-MM-dd HH:mm:ss
# Servlet 설정
servlet:
multipart:
max-file-size: ${SERVLET_MAX_FILE_SIZE:10MB}
max-request-size: ${SERVLET_MAX_REQUEST_SIZE:100MB}
# 비동기 처리 설정
task:
execution:
pool:
core-size: ${ASYNC_CORE_SIZE:5}
max-size: ${ASYNC_MAX_SIZE:20}
queue-capacity: ${ASYNC_QUEUE_CAPACITY:100}
keep-alive: ${ASYNC_KEEP_ALIVE:60s}
thread-name-prefix: "bill-async-"
# 서버 설정
server:
port: ${SERVER_PORT:8082}
@ -105,13 +80,6 @@ server:
include-binding-errors: always
include-stacktrace: on_param
include-exception: false
tomcat:
uri-encoding: UTF-8
max-connections: ${TOMCAT_MAX_CONNECTIONS:8192}
accept-count: ${TOMCAT_ACCEPT_COUNT:100}
threads:
max: ${TOMCAT_MAX_THREADS:200}
min-spare: ${TOMCAT_MIN_THREADS:10}
# 액추에이터 설정 (모니터링)
management:
@ -126,7 +94,7 @@ management:
endpoint:
health:
enabled: true
show-details: ${ACTUATOR_HEALTH_DETAILS:when_authorized}
show-details: always
show-components: always
probes:
enabled: true
@ -159,7 +127,7 @@ management:
# KOS 시스템 연동 설정
kos:
base-url: ${KOS_BASE_URL:http://localhost:9090}
base-url: ${KOS_BASE_URL:http://localhost:8084}
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
read-timeout: ${KOS_READ_TIMEOUT:30000}
max-retries: ${KOS_MAX_RETRIES:3}
@ -175,81 +143,39 @@ kos:
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
wait-duration-in-open-state: ${KOS_CB_OPEN_DURATION:60000}
# 인증 설정
authentication:
enabled: ${KOS_AUTH_ENABLED:true}
api-key: ${KOS_API_KEY:}
secret-key: ${KOS_SECRET_KEY:}
token-expiration-seconds: ${KOS_TOKEN_EXPIRATION:3600}
token-refresh-threshold-seconds: ${KOS_TOKEN_REFRESH_THRESHOLD:300}
# 모니터링 설정
monitoring:
performance-logging-enabled: ${KOS_PERF_LOGGING:true}
slow-request-threshold: ${KOS_SLOW_THRESHOLD:3000}
metrics-enabled: ${KOS_METRICS_ENABLED:true}
health-check-interval: ${KOS_HEALTH_INTERVAL:30000}
# 로깅 설정
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.phonebill: ${LOG_LEVEL_APP:INFO}
com.phonebill.bill.service: ${LOG_LEVEL_SERVICE:INFO}
com.phonebill.bill.repository: ${LOG_LEVEL_REPOSITORY:INFO}
org.springframework.cache: ${LOG_LEVEL_CACHE:INFO}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:INFO}
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:WARN}
io.github.resilience4j: ${LOG_LEVEL_RESILIENCE4J:INFO}
redis.clients.jedis: ${LOG_LEVEL_REDIS:INFO}
pattern:
console: "${LOG_PATTERN_CONSOLE:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
file: "${LOG_PATTERN_FILE:%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
file:
name: ${LOG_FILE_NAME:logs/bill-service.log}
max-size: ${LOG_FILE_MAX_SIZE:100MB}
max-history: ${LOG_FILE_MAX_HISTORY:30}
# Swagger/OpenAPI 설정
springdoc:
api-docs:
enabled: ${SWAGGER_ENABLED:true}
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: ${SWAGGER_UI_ENABLED:true}
enabled: true
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
display-request-duration: true
default-models-expand-depth: 1
default-model-expand-depth: 1
show-actuator: ${SWAGGER_SHOW_ACTUATOR:false}
show-actuator: false
writer-with-default-pretty-printer: true
paths-to-exclude: /actuator/**
# CORS
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# JWT 보안 설정
jwt:
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
expiration: ${JWT_EXPIRATION:86400000} # 24시간 (밀리초)
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 (밀리초)
header: ${JWT_HEADER:Authorization}
prefix: ${JWT_PREFIX:Bearer }
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:86400}
# 애플리케이션 정보
info:
app:
name: ${spring.application.name}
description: 통신요금 조회 및 관리 서비스
version: ${BUILD_VERSION:1.0.0}
author: 이개발(백엔더)
contact: dev@phonebill.com
build:
time: ${BUILD_TIME:@project.build.time@}
artifact: ${BUILD_ARTIFACT:@project.artifactId@}
group: ${BUILD_GROUP:@project.groupId@}
java:
version: ${java.version}
git:
branch: ${GIT_BRANCH:unknown}
commit: ${GIT_COMMIT:unknown}
# 로깅 설정
logging:
pattern:
console: "${LOG_PATTERN_CONSOLE:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
file: "${LOG_PATTERN_FILE:%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
file:
name: logs/bill-service.log
max-size: ${LOG_FILE_MAX_SIZE:100MB}
max-history: ${LOG_FILE_MAX_HISTORY:30}

View File

@ -17,9 +17,14 @@ allprojects {
subprojects {
apply plugin: 'java'
apply plugin: 'io.freefair.lombok'
if (it.name == 'common') {
apply plugin: 'io.spring.dependency-management'
} else {
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'io.freefair.lombok'
}
java {
toolchain {

View File

@ -37,6 +37,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String userId = jwtTokenProvider.getUserId(token);
String username = null;
String authority = null;
String customerId = null;
String lineNumber = null;
try {
username = jwtTokenProvider.getUsername(token);
@ -50,12 +52,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
}
try {
customerId = jwtTokenProvider.getCustomerId(token);
} catch (Exception e) {
log.debug("JWT에 customerId 클레임이 없음: {}", e.getMessage());
}
try {
lineNumber = jwtTokenProvider.getLineNumber(token);
} catch (Exception e) {
log.debug("JWT에 lineNumber 클레임이 없음: {}", e.getMessage());
}
if (StringUtils.hasText(userId)) {
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(userId)
.username(username != null ? username : "unknown")
.authority(authority != null ? authority : "USER")
.customerId(customerId)
.lineNumber(lineNumber)
.build();
UsernamePasswordAuthenticationToken authentication =

View File

@ -28,8 +28,8 @@ public class JwtTokenProvider {
private final SecretKey secretKey;
private final long tokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${security.jwt.secret:}") String secret,
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
public JwtTokenProvider(@Value("${jwt.secret:}") String secret,
@Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) {
if (StringUtils.hasText(secret)) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
} else {
@ -112,6 +112,32 @@ public class JwtTokenProvider {
return claims.get("authority", String.class);
}
/**
* JWT 토큰에서 고객 ID 추출
*/
public String getCustomerId(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get("customerId", String.class);
}
/**
* JWT 토큰에서 회선번호 추출
*/
public String getLineNumber(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get("lineNumber", String.class);
}
/**
* 토큰 만료 시간 확인
*/

View File

@ -28,6 +28,16 @@ public class UserPrincipal {
*/
private final String authority;
/**
* 고객 ID
*/
private final String customerId;
/**
* 회선번호
*/
private final String lineNumber;
/**
* 사용자 ID 반환 (별칭)
*/

View File

@ -3,28 +3,8 @@
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8084" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/kos-mock.log" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<!-- Redis Configuration (Optional - KOS Mock에서는 캐시 기능 비활성화 상태) -->
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_DATABASE" value="4" />
<!-- KOS Mock Specific Settings -->
<entry key="KOS_MOCK_DELAY_MIN" value="100" />
<entry key="KOS_MOCK_DELAY_MAX" value="500" />
<entry key="KOS_MOCK_ERROR_RATE" value="0.05" />
<!-- Development Settings -->
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
</map>
</option>
<option name="executionName" />

View File

@ -11,10 +11,7 @@ import org.springframework.cache.annotation.EnableCaching;
/**
* KOS Mock Service 메인 애플리케이션 클래스
*/
@SpringBootApplication(exclude = {
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class,
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class
})
@SpringBootApplication
@EnableCaching
@RequiredArgsConstructor
@Slf4j
@ -33,8 +30,5 @@ public class KosMockApplication implements CommandLineRunner {
mockDataService.initializeMockData();
log.info("KOS Mock Service가 성공적으로 시작되었습니다.");
log.info("Swagger UI: http://localhost:8080/kos-mock/swagger-ui.html");
log.info("Health Check: http://localhost:8080/kos-mock/actuator/health");
}
}

View File

@ -0,0 +1,21 @@
package com.phonebill.kosmock.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 설정
*/
@Configuration
public class RestTemplateConfig {
/**
* RestTemplate Bean 등록
* @return RestTemplate 인스턴스
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@ -95,77 +95,114 @@ public class KosMockController {
}
/**
* 처리 상태 조회 API
* 상품 정보 목록 조회 API
*/
@GetMapping("/status/{requestId}")
@Operation(summary = "처리 상태 조회", description = "요청의 처리 상태를 조회합니다.")
@GetMapping("/product/list")
@Operation(summary = "상품 목록 조회", description = "등록된 통신 상품들의 목록을 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "요청 ID를 찾을 수 없음"),
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<Object>> getProcessingStatus(
@Parameter(description = "요청 ID", example = "REQ_20250108_001")
@PathVariable String requestId) {
public ResponseEntity<KosCommonResponse<KosProductListResponse>> getProductList() {
log.info("처리 상태 조회 요청 - RequestId: {}", requestId);
log.info("상품 목록 조회 요청 수신");
try {
// Mock 데이터에서 처리 결과 조회 로직은 간단하게 구현
// 실제로는 mockDataService.getProcessingResult(requestId) 사용
KosProductListResponse response = kosMockService.getProductList();
return ResponseEntity.ok(KosCommonResponse.success(
"PROCESSING 상태 - 처리 중입니다.",
"처리 상태 조회가 완료되었습니다"));
if ("0000".equals(response.getResultCode())) {
return ResponseEntity.ok(KosCommonResponse.success(response, "상품 목록 조회가 완료되었습니다"));
} else {
return ResponseEntity.ok(KosCommonResponse.failure(
response.getResultCode(), response.getResultMessage()));
}
} catch (Exception e) {
log.error("처리 상태 조회 중 오류 발생 - RequestId: {}", requestId, e);
log.error("상품 목록 조회 처리 중 오류 발생", e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* 서비스 상태 체크 API
* 데이터 보유 목록 조회 API
*/
@GetMapping("/health")
@Operation(summary = "서비스 상태 체크", description = "KOS Mock 서비스의 상태를 확인합니다.")
public ResponseEntity<KosCommonResponse<Object>> healthCheck() {
@GetMapping("/bill/available-months/{lineNumber}")
@Operation(summary = "데이터 보유 월 목록 조회", description = "회선번호의 실제 요금 데이터가 있는 월 목록을 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<KosAvailableMonthsResponse>> getAvailableMonths(
@Parameter(description = "회선번호 (하이픈 제거된 형태)", example = "01012345678")
@PathVariable String lineNumber) {
log.debug("KOS Mock 서비스 상태 체크 요청");
log.info("데이터 보유 월 목록 조회 요청 수신 - LineNumber: {}", lineNumber);
try {
return ResponseEntity.ok(KosCommonResponse.success(
"KOS Mock Service is running normally",
"서비스가 정상 동작 중입니다"));
// 하이픈 없는 형태 그대로 사용 (MockDataService와 일치)
KosAvailableMonthsResponse response = kosMockService.getAvailableMonths(lineNumber);
if ("0000".equals(response.getResultCode())) {
return ResponseEntity.ok(KosCommonResponse.success(response, "데이터 보유 월 목록 조회가 완료되었습니다"));
} else {
return ResponseEntity.ok(KosCommonResponse.failure(
response.getResultCode(), response.getResultMessage()));
}
} catch (Exception e) {
log.error("서비스 상태 체크 중 오류 발생", e);
log.error("데이터 보유 월 목록 조회 처리 중 오류 발생 - LineNumber: {}", lineNumber, e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* Mock 설정 조회 API (개발/테스트용)
* 가입상품 조회 API
*/
@GetMapping("/mock/config")
@Operation(summary = "Mock 설정 조회", description = "현재 Mock 서비스의 설정을 조회합니다. (개발/테스트용)")
public ResponseEntity<KosCommonResponse<Object>> getMockConfig() {
@PostMapping("/product/inquiry")
@Operation(summary = "가입상품 조회", description = "고객의 가입상품 정보를 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<KosProductInquiryResponse>> inquireProduct(
@Valid @RequestBody KosProductInquiryRequest request) {
log.info("Mock 설정 조회 요청");
log.info("가입상품 조회 요청 수신 - RequestId: {}, LineNumber: {}",
request.getRequestId(), request.getLineNumber());
try {
// Mock 설정 정보를 간단히 반환
String configInfo = String.format(
"Response Delay: %dms, Failure Rate: %.2f%%, Service Status: ACTIVE",
500, 1.0); // 하드코딩된 (실제로는 MockConfig에서 가져올 있음)
KosProductInquiryResponse response = kosMockService.processProductInquiry(request);
return ResponseEntity.ok(KosCommonResponse.success(
configInfo,
"Mock 설정 조회가 완료되었습니다"));
if ("0000".equals(response.getResultCode())) {
return ResponseEntity.ok(KosCommonResponse.success(response, "가입상품 조회가 완료되었습니다"));
} else {
return ResponseEntity.ok(KosCommonResponse.failure(
response.getResultCode(), response.getResultMessage()));
}
} catch (Exception e) {
log.error("Mock 설정 조회 중 오류 발생", e);
log.error("가입상품 조회 처리 중 오류 발생 - RequestId: {}", request.getRequestId(), e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* 회선번호 형식 변환 (01012345678 010-1234-5678)
*/
private String formatLineNumber(String lineNumber) {
if (lineNumber == null || lineNumber.length() != 11) {
return lineNumber;
}
return lineNumber.substring(0, 3) + "-" +
lineNumber.substring(3, 7) + "-" +
lineNumber.substring(7, 11);
}
}

View File

@ -0,0 +1,135 @@
package com.phonebill.kosmock.controller;
import com.phonebill.kosmock.dto.MockDataCreateRequest;
import com.phonebill.kosmock.dto.MockDataCreateResponse;
import com.phonebill.kosmock.service.MockDataCreateService;
import com.phonebill.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Mock 데이터 생성 조회 API 컨트롤러
*/
@RestController
@RequestMapping("/api/v1/mock-datas")
@RequiredArgsConstructor
@Tag(name = "Mock Data Management", description = "Mock 데이터 생성, 조회 및 관리 API")
public class MockDataController {
private static final Logger log = LoggerFactory.getLogger(MockDataController.class);
private final MockDataCreateService mockDataCreateService;
@PostMapping
@Operation(summary = "Mock 데이터 생성", description = "고객 정보와 요금 정보 Mock 데이터를 생성합니다")
public ResponseEntity<ApiResponse<MockDataCreateResponse>> createMockData(
@Valid @RequestBody MockDataCreateRequest request) {
log.info("Mock 데이터 생성 요청 - CustomerId: {}, LineNumber: {}",
request.getCustomerId(), request.getLineNumber());
try {
MockDataCreateResponse response = mockDataCreateService.createMockData(request);
return ResponseEntity.ok(ApiResponse.success("Mock 데이터가 성공적으로 생성되었습니다", response));
} catch (IllegalArgumentException e) {
log.warn("Mock 데이터 생성 실패 - 잘못된 요청: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage(), "4000"));
} catch (IllegalStateException e) {
log.error("Mock 데이터 생성 실패 - 시스템 상태 오류: {}", e.getMessage());
return ResponseEntity.internalServerError()
.body(ApiResponse.error(e.getMessage(), "5000"));
} catch (Exception e) {
log.error("Mock 데이터 생성 실패 - 예기치 못한 오류", e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error("Mock 데이터 생성 중 오류가 발생했습니다", "5000"));
}
}
/**
* 가입상품정보 조회 API
*/
@GetMapping("/customer/product")
@Operation(summary = "가입상품정보 조회", description = "고객 ID와 회선번호로 가입상품정보를 조회합니다.")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "고객 정보를 찾을 수 없음"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<ApiResponse<Object>> getCustomerProduct(
@Parameter(description = "고객 ID", example = "CUST_001", required = true)
@RequestParam String customerId,
@Parameter(description = "회선번호", example = "01012345679", required = true)
@RequestParam String lineNumber) {
log.info("가입상품정보 조회 요청 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
try {
Object productInfo = mockDataCreateService.getCustomerProduct(customerId, lineNumber);
if (productInfo != null) {
return ResponseEntity.ok(ApiResponse.success("가입상품정보 조회가 완료되었습니다", productInfo));
} else {
return ResponseEntity.ok(ApiResponse.error("해당 고객의 상품정보를 찾을 수 없습니다", "1001"));
}
} catch (Exception e) {
log.error("가입상품정보 조회 중 오류 발생 - CustomerId: {}, LineNumber: {}",
customerId, lineNumber, e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error("가입상품정보 조회 중 오류가 발생했습니다", "5000"));
}
}
/**
* 요금정보 조회 API
*/
@GetMapping("/customer/bill")
@Operation(summary = "요금정보 조회", description = "고객 ID와 회선번호로 요금정보를 조회합니다.")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "고객 정보를 찾을 수 없음"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<ApiResponse<Object>> getCustomerBill(
@Parameter(description = "고객 ID", example = "CUST_001", required = true)
@RequestParam String customerId,
@Parameter(description = "회선번호", example = "01012345679", required = true)
@RequestParam String lineNumber) {
log.info("요금정보 조회 요청 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
try {
Object billInfo = mockDataCreateService.getCustomerBill(customerId, lineNumber);
if (billInfo != null) {
return ResponseEntity.ok(ApiResponse.success("요금정보 조회가 완료되었습니다", billInfo));
} else {
return ResponseEntity.ok(ApiResponse.error("해당 고객의 요금정보를 찾을 수 없습니다", "1002"));
}
} catch (Exception e) {
log.error("요금정보 조회 중 오류 발생 - CustomerId: {}, LineNumber: {}",
customerId, lineNumber, e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error("요금정보 조회 중 오류가 발생했습니다", "5000"));
}
}
}

View File

@ -1,17 +1,24 @@
package com.phonebill.kosmock.data;
import com.phonebill.kosmock.entity.BillEntity;
import com.phonebill.kosmock.entity.CustomerEntity;
import com.phonebill.kosmock.entity.ProductEntity;
import com.phonebill.kosmock.repository.BillRepository;
import com.phonebill.kosmock.repository.CustomerRepository;
import com.phonebill.kosmock.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Arrays;
/**
* KOS Mock 데이터 서비스
* KOS Mock 데이터 서비스 (H2 데이터베이스 기반)
* 통신요금 조회 상품변경에 필요한 Mock 데이터를 제공합니다.
*/
@Service
@ -19,232 +26,67 @@ import java.util.concurrent.ConcurrentHashMap;
@Slf4j
public class MockDataService {
// Mock 사용자 데이터 (회선번호 기반)
private final Map<String, MockCustomerData> mockCustomers = new ConcurrentHashMap<>();
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
private final BillRepository billRepository;
// Mock 상품 데이터
private final Map<String, MockProductData> mockProducts = new ConcurrentHashMap<>();
// Mock 요금 데이터
private final Map<String, MockBillData> mockBills = new ConcurrentHashMap<>();
// 요청 처리 이력
private final Map<String, MockProcessingResult> processingResults = new ConcurrentHashMap<>();
// 요청 처리 이력 (메모리 기반 유지)
private final Map<String, MockProcessingResult> processingResults = new HashMap<>();
/**
* 초기 Mock 데이터 생성
* 초기 Mock 데이터 생성 (user-service 기반)
*/
@Transactional
public void initializeMockData() {
log.info("KOS Mock 데이터 초기화 시작");
// 상품 데이터만 초기화 (고객 데이터는 API 요청 동적 생성)
initializeMockProducts();
initializeMockCustomers();
initializeMockBills();
log.info("KOS Mock 데이터 초기화 완료 - 고객: {}, 상품: {}, 요금: {}",
mockCustomers.size(), mockProducts.size(), mockBills.size());
long productCount = productRepository.count();
log.info("KOS Mock 데이터 초기화 완료 - 상품: {}", productCount);
}
/**
* Mock 상품 데이터 초기화
*/
private void initializeMockProducts() {
// 5G 상품
mockProducts.put("5G-PREMIUM-001", MockProductData.builder()
.productCode("5G-PREMIUM-001")
.productName("5G 프리미엄 플랜")
.monthlyFee(new BigDecimal("89000"))
.dataAllowance("무제한")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 무제한 프리미엄 요금제")
.build());
mockProducts.put("5G-STANDARD-001", MockProductData.builder()
.productCode("5G-STANDARD-001")
.productName("5G 스탠다드 플랜")
.monthlyFee(new BigDecimal("69000"))
.dataAllowance("100GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 스탠다드 요금제")
.build());
// LTE 상품
mockProducts.put("LTE-PREMIUM-001", MockProductData.builder()
.productCode("LTE-PREMIUM-001")
.productName("LTE 프리미엄 플랜")
.monthlyFee(new BigDecimal("59000"))
.dataAllowance("50GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 프리미엄 요금제")
.build());
mockProducts.put("LTE-BASIC-001", MockProductData.builder()
.productCode("LTE-BASIC-001")
.productName("LTE 베이직 플랜")
.monthlyFee(new BigDecimal("39000"))
.dataAllowance("20GB")
.voiceAllowance("무제한")
.smsAllowance("기본 제공")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 베이직 요금제")
.build());
// 종료된 상품 (변경 불가)
mockProducts.put("3G-OLD-001", MockProductData.builder()
.productCode("3G-OLD-001")
.productName("3G 레거시 플랜")
.monthlyFee(new BigDecimal("29000"))
.dataAllowance("5GB")
.voiceAllowance("500분")
.smsAllowance("100건")
.operatorCode("KT")
.networkType("3G")
.status("DISCONTINUED")
.description("3G 네트워크 레거시 요금제 (신규 가입 불가)")
.build());
}
/**
* Mock 고객 데이터 초기화
*/
private void initializeMockCustomers() {
// 테스트용 고객 데이터
String[] testNumbers = {
"01012345678", "01087654321", "01055554444",
"01099998888", "01077776666", "01033332222"
};
String[] testNames = {
"김테스트", "이샘플", "박데모", "최모의", "정시험", "한실험"
};
String[] currentProducts = {
"5G-PREMIUM-001", "5G-STANDARD-001", "LTE-PREMIUM-001",
"LTE-BASIC-001", "3G-OLD-001", "5G-PREMIUM-001"
};
for (int i = 0; i < testNumbers.length; i++) {
mockCustomers.put(testNumbers[i], MockCustomerData.builder()
.lineNumber(testNumbers[i])
.customerName(testNames[i])
.customerId("CUST" + String.format("%06d", i + 1))
.operatorCode("KT")
.currentProductCode(currentProducts[i])
.lineStatus("ACTIVE")
.contractDate(LocalDateTime.now().minusMonths(12 + i))
.lastModified(LocalDateTime.now().minusDays(i))
.build());
}
// 비활성 회선 테스트용
mockCustomers.put("01000000000", MockCustomerData.builder()
.lineNumber("01000000000")
.customerName("비활성사용자")
.customerId("CUST999999")
.operatorCode("KT")
.currentProductCode("LTE-BASIC-001")
.lineStatus("SUSPENDED")
.contractDate(LocalDateTime.now().minusMonths(6))
.lastModified(LocalDateTime.now().minusDays(30))
.build());
}
/**
* Mock 요금 데이터 초기화
*/
private void initializeMockBills() {
for (MockCustomerData customer : mockCustomers.values()) {
MockProductData product = mockProducts.get(customer.getCurrentProductCode());
if (product != null) {
// 최근 3개월 요금 데이터 생성
for (int month = 0; month < 3; month++) {
LocalDateTime billDate = LocalDateTime.now().minusMonths(month);
String billKey = customer.getLineNumber() + "_" + billDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
BigDecimal usageFee = calculateUsageFee(product, month);
BigDecimal totalFee = product.getMonthlyFee().add(usageFee);
mockBills.put(billKey, MockBillData.builder()
.lineNumber(customer.getLineNumber())
.billingMonth(billDate.format(DateTimeFormatter.ofPattern("yyyyMM")))
.productCode(product.getProductCode())
.productName(product.getProductName())
.monthlyFee(product.getMonthlyFee())
.usageFee(usageFee)
.totalFee(totalFee)
.dataUsage(generateRandomDataUsage(product))
.voiceUsage(generateRandomVoiceUsage(product))
.smsUsage(generateRandomSmsUsage())
.billStatus("CONFIRMED")
.dueDate(billDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyyMMdd")))
.build());
}
}
}
}
private BigDecimal calculateUsageFee(MockProductData product, int month) {
// 간단한 사용료 계산 로직 (랜덤하게 0~30000원)
Random random = new Random();
return new BigDecimal(random.nextInt(30000));
}
private String generateRandomDataUsage(MockProductData product) {
Random random = new Random();
if ("무제한".equals(product.getDataAllowance())) {
return random.nextInt(200) + "GB";
} else {
int allowance = Integer.parseInt(product.getDataAllowance().replace("GB", ""));
return random.nextInt(allowance) + "GB";
}
}
private String generateRandomVoiceUsage(MockProductData product) {
Random random = new Random();
if ("무제한".equals(product.getVoiceAllowance())) {
return random.nextInt(500) + "";
} else {
int allowance = Integer.parseInt(product.getVoiceAllowance().replace("", ""));
return random.nextInt(allowance) + "";
}
}
private String generateRandomSmsUsage() {
Random random = new Random();
return random.nextInt(100) + "";
}
// Getter methods
// 기존 메소드들 - H2 데이터베이스 기반으로 재구현
public MockCustomerData getCustomerData(String lineNumber) {
return mockCustomers.get(lineNumber);
log.info("MockDataService: 고객 데이터 조회 - LineNumber: {}", lineNumber);
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumber(lineNumber);
if (customerOpt.isEmpty()) {
log.warn("MockDataService: 고객 정보를 찾을 수 없음 - LineNumber: {}", lineNumber);
return null;
}
CustomerEntity entity = customerOpt.get();
log.info("MockDataService: 고객 정보 발견 - CustomerId: {}, LineNumber: {}",
entity.getCustomerId(), entity.getLineNumber());
return convertToMockCustomerData(entity);
}
public MockProductData getProductData(String productCode) {
return mockProducts.get(productCode);
Optional<ProductEntity> productOpt = productRepository.findById(productCode);
return productOpt.map(this::convertToMockProductData).orElse(null);
}
public MockBillData getBillData(String lineNumber, String billingMonth) {
return mockBills.get(lineNumber + "_" + billingMonth);
Optional<BillEntity> billOpt = billRepository.findByLineNumberAndBillingMonth(lineNumber, billingMonth);
return billOpt.map(this::convertToMockBillData).orElse(null);
}
public List<MockProductData> getAllAvailableProducts() {
return mockProducts.values().stream()
.filter(product -> "ACTIVE".equals(product.getStatus()))
.sorted(Comparator.comparing(MockProductData::getMonthlyFee).reversed())
List<ProductEntity> products = productRepository.findByStatusOrderByMonthlyFeeDesc("ACTIVE");
return products.stream()
.map(this::convertToMockProductData)
.toList();
}
public List<MockProductData> getAllProducts() {
List<ProductEntity> products = productRepository.findAll();
return products.stream()
.map(this::convertToMockProductData)
.toList();
}
@ -257,9 +99,294 @@ public class MockDataService {
}
public List<MockBillData> getBillHistory(String lineNumber) {
return mockBills.values().stream()
.filter(bill -> lineNumber.equals(bill.getLineNumber()))
.sorted(Comparator.comparing(MockBillData::getBillingMonth).reversed())
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
return bills.stream()
.map(this::convertToMockBillData)
.toList();
}
/**
* 회선번호의 실제 요금 데이터가 있는 목록 조회
*/
public List<String> getAvailableMonths(String lineNumber) {
log.info("회선 {}의 실제 데이터 보유 월 조회", lineNumber);
// 데이터베이스에서 실제 청구 데이터가 있는 목록 조회
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
if (bills.isEmpty()) {
// 실제 데이터가 없으면 Mock 데이터를 생성하여 최근 3개월 반환
log.info("실제 청구 데이터가 없어 Mock 데이터 생성: {}", lineNumber);
createMockBillDataForRecentMonths(lineNumber);
// 다시 조회
bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
}
// 청구월을 yyyy-MM 형식으로 변환하여 반환
List<String> availableMonths = bills.stream()
.map(bill -> {
String billingMonth = bill.getBillingMonth();
// yyyyMM 형식을 yyyy-MM 형식으로 변환
if (billingMonth.length() == 6) {
return billingMonth.substring(0, 4) + "-" + billingMonth.substring(4, 6);
}
return billingMonth;
})
.distinct()
.sorted(java.util.Collections.reverseOrder()) // 최신 월부터
.toList();
log.info("회선 {}의 데이터 보유 월: {} (총 {}개월)", lineNumber, availableMonths, availableMonths.size());
return availableMonths;
}
/**
* 최근 3개월 Mock 청구 데이터 생성
*/
@Transactional
private void createMockBillDataForRecentMonths(String lineNumber) {
log.info("회선 {}의 Mock 청구 데이터 생성", lineNumber);
// 고객 정보 조회
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumberWithProduct(lineNumber);
if (customerOpt.isEmpty()) {
log.warn("회선번호 {}에 대한 고객 정보가 없어 청구 데이터를 생성할 수 없습니다", lineNumber);
return;
}
CustomerEntity customer = customerOpt.get();
// 현재 상품 정보 조회
Optional<ProductEntity> productOpt = productRepository.findById(customer.getCurrentProductCode());
if (productOpt.isEmpty()) {
log.warn("상품 코드 {}를 찾을 수 없어 청구 데이터를 생성할 수 없습니다", customer.getCurrentProductCode());
return;
}
ProductEntity product = productOpt.get();
// 최근 3개월 청구 데이터 생성
LocalDateTime now = LocalDateTime.now();
List<BillEntity> mockBills = new ArrayList<>();
for (int i = 0; i < 3; i++) {
LocalDateTime monthDate = now.minusMonths(i);
String billingMonth = monthDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
// 이미 해당 데이터가 있는지 확인
if (billRepository.findByLineNumberAndBillingMonth(lineNumber, billingMonth).isPresent()) {
continue;
}
// Mock 청구 데이터 생성
BigDecimal usageFee = generateRandomUsageFee();
BillEntity billEntity = BillEntity.builder()
.lineNumber(lineNumber)
.billingMonth(billingMonth)
.productCode(product.getProductCode())
.productName(product.getProductName())
.monthlyFee(product.getMonthlyFee())
.usageFee(usageFee)
.totalFee(product.getMonthlyFee().add(usageFee))
.dataUsage(generateRandomDataUsage())
.voiceUsage(generateRandomVoiceUsage())
.smsUsage(generateRandomSmsUsage())
.billStatus(i == 0 ? "UNPAID" : "PAID") // 당월만 미납
.dueDate(monthDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.build();
mockBills.add(billEntity);
}
if (!mockBills.isEmpty()) {
billRepository.saveAll(mockBills);
log.info("회선 {}의 Mock 청구 데이터 {}개 생성 완료", lineNumber, mockBills.size());
}
}
// Mock 데이터 생성을 위한 헬퍼 메소드들
private BigDecimal generateRandomUsageFee() {
Random random = new Random();
return new BigDecimal(random.nextInt(20000)); // 0~20,000원
}
private String generateRandomDataUsage() {
Random random = new Random();
double usage = random.nextDouble() * 100; // 0~100GB
return String.format("%.1fGB", usage);
}
private String generateRandomVoiceUsage() {
Random random = new Random();
int minutes = random.nextInt(500); // 0~500분
return String.format("%d분", minutes);
}
private String generateRandomSmsUsage() {
Random random = new Random();
int count = random.nextInt(100); // 0~100건
return String.format("%d건", count);
}
// 엔티티를 Mock 데이터로 변환하는 메소드들
private MockCustomerData convertToMockCustomerData(CustomerEntity entity) {
return MockCustomerData.builder()
.lineNumber(entity.getLineNumber())
.customerName("Mock_Customer") // 고객명은 저장하지 않음
.customerId(entity.getCustomerId())
.operatorCode(entity.getOperatorCode())
.currentProductCode(entity.getCurrentProductCode())
.lineStatus(entity.getLineStatus())
.contractDate(entity.getContractDate())
.lastModified(entity.getUpdatedAt())
.build();
}
private MockProductData convertToMockProductData(ProductEntity entity) {
return MockProductData.builder()
.productCode(entity.getProductCode())
.productName(entity.getProductName())
.monthlyFee(entity.getMonthlyFee())
.dataAllowance(entity.getDataAllowance())
.voiceAllowance(entity.getVoiceAllowance())
.smsAllowance(entity.getSmsAllowance())
.operatorCode(entity.getOperatorCode())
.networkType(entity.getNetworkType())
.status(entity.getStatus())
.description(entity.getDescription())
.build();
}
private MockBillData convertToMockBillData(BillEntity entity) {
return MockBillData.builder()
.lineNumber(entity.getLineNumber())
.billingMonth(entity.getBillingMonth())
.productCode(entity.getProductCode())
.productName(entity.getProductName())
.monthlyFee(entity.getMonthlyFee())
.usageFee(entity.getUsageFee())
.totalFee(entity.getTotalFee())
.dataUsage(entity.getDataUsage())
.voiceUsage(entity.getVoiceUsage())
.smsUsage(entity.getSmsUsage())
.billStatus(entity.getBillStatus())
.dueDate(entity.getDueDate())
.discountAmount(BigDecimal.ZERO) // BillEntity에 없으므로 기본값
.build();
}
/**
* Mock 상품 데이터 초기화
*/
@Transactional
private void initializeMockProducts() {
log.info("Mock 상품 데이터 초기화 시작");
// 기존 상품이 있으면 초기화하지 않음
if (productRepository.count() > 0) {
log.info("이미 상품 데이터가 존재합니다. 초기화를 건너뜁니다.");
return;
}
// 기본 상품 데이터 생성
List<ProductEntity> products = Arrays.asList(
ProductEntity.builder()
.productCode("5G-PREMIUM-001")
.productName("5G 프리미엄")
.monthlyFee(new BigDecimal("89000"))
.dataAllowance("무제한")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 프리미엄 요금제")
.build(),
ProductEntity.builder()
.productCode("5G-STANDARD-001")
.productName("5G 스탠다드")
.monthlyFee(new BigDecimal("65000"))
.dataAllowance("100GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 스탠다드 요금제")
.build(),
ProductEntity.builder()
.productCode("LTE-PREMIUM-001")
.productName("LTE 프리미엄")
.monthlyFee(new BigDecimal("55000"))
.dataAllowance("무제한")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 프리미엄 요금제")
.build(),
ProductEntity.builder()
.productCode("LTE-BASIC-001")
.productName("LTE 베이직")
.monthlyFee(new BigDecimal("35000"))
.dataAllowance("50GB")
.voiceAllowance("300분")
.smsAllowance("100건")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 베이직 요금제")
.build(),
ProductEntity.builder()
.productCode("3G-OLD-001")
.productName("3G 기본")
.monthlyFee(new BigDecimal("25000"))
.dataAllowance("10GB")
.voiceAllowance("200분")
.smsAllowance("50건")
.operatorCode("KT")
.networkType("3G")
.status("INACTIVE")
.description("3G 기본 요금제 (단종)")
.build()
);
productRepository.saveAll(products);
log.info("Mock 상품 데이터 {}개 생성 완료", products.size());
}
/**
* 고객의 상품 코드 업데이트
*/
@Transactional
public boolean updateCustomerProduct(String lineNumber, String newProductCode) {
log.info("고객 상품 코드 업데이트 - LineNumber: {}, NewProductCode: {}", lineNumber, newProductCode);
// 고객 정보 조회
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumber(lineNumber);
if (customerOpt.isEmpty()) {
log.warn("존재하지 않는 회선번호 - LineNumber: {}", lineNumber);
return false;
}
CustomerEntity customer = customerOpt.get();
String oldProductCode = customer.getCurrentProductCode();
// 상품 코드 업데이트
customer.setCurrentProductCode(newProductCode);
customer.setUpdatedAt(LocalDateTime.now());
// 저장
customerRepository.save(customer);
log.info("고객 상품 코드 업데이트 완료 - LineNumber: {}, {} -> {}",
lineNumber, oldProductCode, newProductCode);
return true;
}
}

View File

@ -0,0 +1,53 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* KOS 데이터 보유 목록 응답 DTO
*
* 특정 회선번호의 실제 요금 데이터가 있는 목록을 반환
*
* @author 이개발(백엔더)
* @version 1.0.1
* @since 2025-09-09
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KosAvailableMonthsResponse {
/**
* 처리 결과 코드
* - 0000: 성공
* - 1001: 존재하지 않는 회선번호
* - 1002: 비활성 상태의 회선
*/
@JsonProperty("resultCode")
private String resultCode;
/**
* 처리 결과 메시지
*/
@JsonProperty("resultMessage")
private String resultMessage;
/**
* 회선번호
*/
@JsonProperty("lineNumber")
private String lineNumber;
/**
* 데이터가 있는 목록 (yyyy-MM 형식)
* : ["2025-09", "2025-08", "2025-07"]
*/
@JsonProperty("availableMonths")
private List<String> availableMonths;
}

View File

@ -12,19 +12,16 @@ import lombok.Data;
@Schema(description = "KOS 요금 조회 요청")
public class KosBillInquiryRequest {
@Schema(description = "회선번호", example = "01012345678", required = true)
@Schema(description = "회선번호", example = "01012345679", required = true)
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
private String lineNumber;
@Schema(description = "청구월 (YYYYMM)", example = "202501")
@Schema(description = "청구월 (YYYYMM)", example = "202508")
@Pattern(regexp = "^\\d{6}$", message = "청구월은 YYYYMM 형식이어야 합니다")
private String billingMonth;
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
@NotBlank(message = "요청 ID는 필수입니다")
private String requestId;
@Schema(description = "요청자 ID", example = "BILL_SERVICE")
private String requestorId;
}

View File

@ -17,6 +17,9 @@ public class KosBillInquiryResponse {
@Schema(description = "요청 ID", example = "REQ_20250108_001")
private String requestId;
@Schema(description = "처리 상태", example = "SUCCESS")
private String procStatus;
@Schema(description = "처리 결과 코드", example = "0000")
private String resultCode;

View File

@ -12,7 +12,7 @@ import lombok.Data;
@Schema(description = "KOS 상품 변경 요청")
public class KosProductChangeRequest {
@Schema(description = "회선번호", example = "01012345678", required = true)
@Schema(description = "회선번호", example = "01012345679", required = true)
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
private String lineNumber;
@ -28,14 +28,4 @@ public class KosProductChangeRequest {
@Schema(description = "요청 ID", example = "REQ_20250108_002", required = true)
@NotBlank(message = "요청 ID는 필수입니다")
private String requestId;
@Schema(description = "요청자 ID", example = "PRODUCT_SERVICE")
private String requestorId;
@Schema(description = "변경 사유", example = "고객 요청에 의한 상품 변경")
private String changeReason;
@Schema(description = "적용 일자 (YYYYMMDD)", example = "20250115")
@Pattern(regexp = "^\\d{8}$", message = "적용 일자는 YYYYMMDD 형식이어야 합니다")
private String effectiveDate;
}

View File

@ -0,0 +1,60 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* KOS 상품 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "KOS 상품 정보")
public class KosProductInfo {
@JsonProperty("product_code")
@Schema(description = "상품 코드", example = "5G_BASIC_001")
private String productCode;
@JsonProperty("product_name")
@Schema(description = "상품명", example = "5G 베이직 요금제")
private String productName;
@JsonProperty("product_type")
@Schema(description = "상품 유형", example = "DATA")
private String productType;
@JsonProperty("monthly_fee")
@Schema(description = "월정액", example = "55000")
private Integer monthlyFee;
@JsonProperty("data_allowance")
@Schema(description = "데이터 제공량(GB)", example = "100")
private Integer dataAllowance;
@JsonProperty("voice_allowance")
@Schema(description = "음성통화 제공량(분)", example = "300")
private Integer voiceAllowance;
@JsonProperty("sms_allowance")
@Schema(description = "SMS 제공량(건)", example = "200")
private Integer smsAllowance;
@JsonProperty("network_type")
@Schema(description = "네트워크 유형", example = "5G")
private String networkType;
@JsonProperty("status")
@Schema(description = "상품 상태", example = "ACTIVE")
private String status;
@JsonProperty("description")
@Schema(description = "상품 설명", example = "5G 네트워크를 이용한 대용량 데이터 요금제")
private String description;
}

View File

@ -0,0 +1,26 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* KOS 가입상품 조회 요청 DTO
*/
@Data
@Schema(description = "KOS 가입상품 조회 요청")
public class KosProductInquiryRequest {
@Schema(description = "회선번호", example = "01012345679", required = true)
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
@JsonProperty("lineNumber")
private String lineNumber;
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
@NotBlank(message = "요청 ID는 필수입니다")
@JsonProperty("requestId")
private String requestId;
}

View File

@ -0,0 +1,122 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* KOS 가입상품 조회 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "KOS 가입상품 조회 응답")
public class KosProductInquiryResponse {
@Schema(description = "요청 ID", example = "REQ_20250108_001")
@JsonProperty("requestId")
private String requestId;
@Schema(description = "처리 상태", example = "SUCCESS")
@JsonProperty("procStatus")
private String procStatus;
@Schema(description = "결과 코드", example = "0000")
@JsonProperty("resultCode")
private String resultCode;
@Schema(description = "결과 메시지", example = "정상 처리되었습니다")
@JsonProperty("resultMessage")
private String resultMessage;
@Schema(description = "상품 정보")
@JsonProperty("productInfo")
private ProductInfo productInfo;
@Schema(description = "고객 정보")
@JsonProperty("customerInfo")
private CustomerInfo customerInfo;
/**
* 상품 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "상품 정보")
public static class ProductInfo {
@Schema(description = "회선번호", example = "01012345679")
@JsonProperty("lineNumber")
private String lineNumber;
@Schema(description = "현재 상품 코드", example = "KT_5G_BASIC")
@JsonProperty("currentProductCode")
private String currentProductCode;
@Schema(description = "현재 상품명", example = "KT 5G 베이직")
@JsonProperty("currentProductName")
private String currentProductName;
@Schema(description = "월 요금", example = "45000")
@JsonProperty("monthlyFee")
private BigDecimal monthlyFee;
@Schema(description = "데이터 허용량", example = "무제한")
@JsonProperty("dataAllowance")
private String dataAllowance;
@Schema(description = "음성 허용량", example = "무제한")
@JsonProperty("voiceAllowance")
private String voiceAllowance;
@Schema(description = "SMS 허용량", example = "무제한")
@JsonProperty("smsAllowance")
private String smsAllowance;
@Schema(description = "상품 상태", example = "ACTIVE")
@JsonProperty("productStatus")
private String productStatus;
@Schema(description = "계약일")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
@JsonProperty("contractDate")
private LocalDateTime contractDate;
}
/**
* 고객 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "고객 정보")
public static class CustomerInfo {
@Schema(description = "고객명", example = "홍길동")
@JsonProperty("customerName")
private String customerName;
@Schema(description = "고객 ID", example = "CUST_001")
@JsonProperty("customerId")
private String customerId;
@Schema(description = "통신사 코드", example = "KT")
@JsonProperty("operatorCode")
private String operatorCode;
@Schema(description = "회선 상태", example = "ACTIVE")
@JsonProperty("lineStatus")
private String lineStatus;
}
}

View File

@ -0,0 +1,38 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* KOS 상품 목록 조회 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "KOS 상품 목록 조회 응답")
public class KosProductListResponse {
@JsonProperty("result_code")
@Schema(description = "결과 코드", example = "0000")
private String resultCode;
@JsonProperty("result_message")
@Schema(description = "결과 메시지", example = "상품 목록 조회 성공")
private String resultMessage;
@JsonProperty("product_count")
@Schema(description = "조회된 상품 개수", example = "5")
private Integer productCount;
@JsonProperty("products")
@Schema(description = "상품 목록")
private List<KosProductInfo> products;
}

View File

@ -0,0 +1,22 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* Mock 데이터 생성 요청 DTO
*/
@Data
public class MockDataCreateRequest {
@JsonProperty("customerId")
@NotBlank(message = "고객 ID는 필수입니다")
private String customerId;
@JsonProperty("lineNumber")
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "회선번호 형식이 올바르지 않습니다 (예: 01012345678)")
private String lineNumber;
}

View File

@ -0,0 +1,31 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
/**
* Mock 데이터 생성 응답 DTO
*/
@Data
@Builder
public class MockDataCreateResponse {
@JsonProperty("customer_id")
private String customerId;
@JsonProperty("line_number")
private String lineNumber;
@JsonProperty("current_product_code")
private String currentProductCode;
@JsonProperty("current_product_name")
private String currentProductName;
@JsonProperty("bill_count_created")
private int billCountCreated;
@JsonProperty("message")
private String message;
}

View File

@ -0,0 +1,106 @@
package com.phonebill.kosmock.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
/**
* User Service API 응답 DTO
* /api/v1/users API에서 반환하는 사용자 정보
*/
public class UserResponseDto {
@JsonProperty("user_id")
private String userId;
@JsonProperty("customer_id")
private String customerId;
@JsonProperty("line_number")
private String lineNumber;
@JsonProperty("user_name")
private String userName;
@JsonProperty("account_status")
private String accountStatus;
@JsonProperty("last_login_at")
private LocalDateTime lastLoginAt;
@JsonProperty("permissions")
private List<String> permissions;
// 기본 생성자
public UserResponseDto() {}
// Getters and Setters
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCustomerId() {
return customerId;
}
public void setCustomerId(String customerId) {
this.customerId = customerId;
}
public String getLineNumber() {
return lineNumber;
}
public void setLineNumber(String lineNumber) {
this.lineNumber = lineNumber;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getAccountStatus() {
return accountStatus;
}
public void setAccountStatus(String accountStatus) {
this.accountStatus = accountStatus;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public List<String> getPermissions() {
return permissions;
}
public void setPermissions(List<String> permissions) {
this.permissions = permissions;
}
@Override
public String toString() {
return "UserResponseDto{" +
"userId='" + userId + '\'' +
", customerId='" + customerId + '\'' +
", lineNumber='" + lineNumber + '\'' +
", userName='" + userName + '\'' +
", accountStatus='" + accountStatus + '\'' +
", lastLoginAt=" + lastLoginAt +
", permissions=" + permissions +
'}';
}
}

View File

@ -0,0 +1,83 @@
package com.phonebill.kosmock.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 요금 정보 엔티티
*/
@Entity
@Table(name = "bills")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BillEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "line_number", nullable = false, length = 20)
private String lineNumber;
@Column(name = "billing_month", nullable = false, length = 6)
private String billingMonth;
@Column(name = "product_code", nullable = false, length = 50)
private String productCode;
@Column(name = "product_name", nullable = false, length = 100)
private String productName;
@Column(name = "monthly_fee", nullable = false, precision = 10, scale = 2)
private BigDecimal monthlyFee;
@Column(name = "usage_fee", nullable = false, precision = 10, scale = 2)
private BigDecimal usageFee;
@Column(name = "total_fee", nullable = false, precision = 10, scale = 2)
private BigDecimal totalFee;
@Column(name = "data_usage", length = 20)
private String dataUsage;
@Column(name = "voice_usage", length = 20)
private String voiceUsage;
@Column(name = "sms_usage", length = 20)
private String smsUsage;
@Column(name = "bill_status", nullable = false, length = 20)
private String billStatus;
@Column(name = "due_date", length = 8)
private String dueDate;
@CreationTimestamp
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
// 고객 정보와의 관계
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "line_number", insertable = false, updatable = false)
private CustomerEntity customer;
// 상품 정보와의 관계
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_code", insertable = false, updatable = false)
private ProductEntity product;
// 복합 인덱스 설정
@Table(indexes = {
@Index(name = "idx_line_billing_month", columnList = "line_number, billing_month")
})
public static class BillEntityIndex {}
}

View File

@ -0,0 +1,55 @@
package com.phonebill.kosmock.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
/**
* 고객 정보 엔티티 (상품가입정보 포함)
*/
@Entity
@Table(name = "customers")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomerEntity {
@Id
@Column(name = "line_number", nullable = false, length = 20)
private String lineNumber;
@Column(name = "customer_id", nullable = false, length = 50)
private String customerId;
@Column(name = "operator_code", nullable = false, length = 10)
private String operatorCode;
@Column(name = "current_product_code", nullable = false, length = 50)
private String currentProductCode;
@Column(name = "line_status", nullable = false, length = 20)
private String lineStatus;
@Column(name = "contract_date", nullable = false)
private LocalDateTime contractDate;
@CreationTimestamp
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// 상품 엔티티와의 관계 설정 (조회 성능을 위해)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "current_product_code", insertable = false, updatable = false)
private ProductEntity product;
}

View File

@ -0,0 +1,63 @@
package com.phonebill.kosmock.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 상품 정보 엔티티
*/
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProductEntity {
@Id
@Column(name = "product_code", nullable = false, length = 50)
private String productCode;
@Column(name = "product_name", nullable = false, length = 100)
private String productName;
@Column(name = "monthly_fee", nullable = false, precision = 10, scale = 2)
private BigDecimal monthlyFee;
@Column(name = "data_allowance", length = 20)
private String dataAllowance;
@Column(name = "voice_allowance", length = 20)
private String voiceAllowance;
@Column(name = "sms_allowance", length = 20)
private String smsAllowance;
@Column(name = "operator_code", nullable = false, length = 10)
private String operatorCode;
@Column(name = "network_type", nullable = false, length = 10)
private String networkType;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "description", length = 200)
private String description;
@CreationTimestamp
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,48 @@
package com.phonebill.kosmock.repository;
import com.phonebill.kosmock.entity.BillEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 요금 정보 Repository
*/
@Repository
public interface BillRepository extends JpaRepository<BillEntity, Long> {
/**
* 회선번호와 청구월로 요금 정보 조회
*/
Optional<BillEntity> findByLineNumberAndBillingMonth(String lineNumber, String billingMonth);
/**
* 회선번호별 요금 이력 조회 (최신순)
*/
List<BillEntity> findByLineNumberOrderByBillingMonthDesc(String lineNumber);
/**
* 청구월별 요금 정보 조회
*/
List<BillEntity> findByBillingMonth(String billingMonth);
/**
* 회선번호별 특정 개수만큼 최근 요금 이력 조회
*/
@Query("SELECT b FROM BillEntity b WHERE b.lineNumber = :lineNumber ORDER BY b.billingMonth DESC LIMIT :limit")
List<BillEntity> findRecentBillsByLineNumber(@Param("lineNumber") String lineNumber, @Param("limit") int limit);
/**
* 회선번호와 청구월로 요금 정보 존재 여부 확인
*/
boolean existsByLineNumberAndBillingMonth(String lineNumber, String billingMonth);
/**
* 회선번호별 요금 정보 개수
*/
long countByLineNumber(String lineNumber);
}

View File

@ -0,0 +1,53 @@
package com.phonebill.kosmock.repository;
import com.phonebill.kosmock.entity.CustomerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 고객 정보 Repository
*/
@Repository
public interface CustomerRepository extends JpaRepository<CustomerEntity, String> {
/**
* 고객 ID로 조회
*/
Optional<CustomerEntity> findByCustomerId(String customerId);
/**
* 회선 번호로 조회
*/
Optional<CustomerEntity> findByLineNumber(String lineNumber);
/**
* 상품 코드별 고객 목록 조회
*/
List<CustomerEntity> findByCurrentProductCode(String productCode);
/**
* 회선 상태별 고객 목록 조회
*/
List<CustomerEntity> findByLineStatus(String lineStatus);
/**
* 고객 ID와 회선번호로 존재 여부 확인
*/
boolean existsByCustomerIdAndLineNumber(String customerId, String lineNumber);
/**
* 고객 ID와 회선번호로 조회
*/
Optional<CustomerEntity> findByCustomerIdAndLineNumber(String customerId, String lineNumber);
/**
* 상품 정보와 함께 고객 조회
*/
@Query("SELECT c FROM CustomerEntity c LEFT JOIN FETCH c.product WHERE c.lineNumber = :lineNumber")
Optional<CustomerEntity> findByLineNumberWithProduct(@Param("lineNumber") String lineNumber);
}

View File

@ -0,0 +1,42 @@
package com.phonebill.kosmock.repository;
import com.phonebill.kosmock.entity.ProductEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 상품 정보 Repository
*/
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, String> {
/**
* 활성화된 상품 목록 조회
*/
List<ProductEntity> findByStatusOrderByMonthlyFeeDesc(String status);
/**
* 네트워크 타입별 상품 조회
*/
List<ProductEntity> findByNetworkTypeAndStatusOrderByMonthlyFeeDesc(String networkType, String status);
/**
* 상품 코드로 조회
*/
Optional<ProductEntity> findByProductCode(String productCode);
/**
* 상품 존재 여부 확인
*/
boolean existsByProductCode(String productCode);
/**
* 전체 상품 조회
*/
@Query("SELECT COUNT(p) FROM ProductEntity p")
long countAllProducts();
}

View File

@ -0,0 +1,126 @@
package com.phonebill.kosmock.service;
import com.phonebill.kosmock.entity.ProductEntity;
import com.phonebill.kosmock.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
/**
* 애플리케이션 시작 초기 데이터 생성 서비스
*/
@Service
@RequiredArgsConstructor
public class DataInitializationService implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializationService.class);
private final ProductRepository productRepository;
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
initializeProductData();
}
/**
* 상품 정보 초기화
* 상품이 없을 때만 수행
*/
private void initializeProductData() {
long productCount = productRepository.countAllProducts();
if (productCount == 0) {
log.info("상품 정보가 없습니다. 초기 상품 데이터를 생성합니다...");
List<ProductEntity> initialProducts = createInitialProducts();
productRepository.saveAll(initialProducts);
log.info("초기 상품 데이터 {}개 생성 완료", initialProducts.size());
} else {
log.info("기존 상품 정보 {}개가 존재합니다. 초기화를 건너뜁니다.", productCount);
}
}
/**
* 초기 상품 데이터 생성
*/
private List<ProductEntity> createInitialProducts() {
return Arrays.asList(
// 5G 상품
ProductEntity.builder()
.productCode("5G-PREMIUM-001")
.productName("5G 프리미엄 플랜")
.monthlyFee(new BigDecimal("89000"))
.dataAllowance("무제한")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 무제한 프리미엄 요금제")
.build(),
ProductEntity.builder()
.productCode("5G-STANDARD-001")
.productName("5G 스탠다드 플랜")
.monthlyFee(new BigDecimal("69000"))
.dataAllowance("100GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 스탠다드 요금제")
.build(),
// LTE 상품
ProductEntity.builder()
.productCode("LTE-PREMIUM-001")
.productName("LTE 프리미엄 플랜")
.monthlyFee(new BigDecimal("59000"))
.dataAllowance("50GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 프리미엄 요금제")
.build(),
ProductEntity.builder()
.productCode("LTE-BASIC-001")
.productName("LTE 베이직 플랜")
.monthlyFee(new BigDecimal("39000"))
.dataAllowance("20GB")
.voiceAllowance("무제한")
.smsAllowance("기본 제공")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 베이직 요금제")
.build(),
// 종료된 상품 (변경 불가)
ProductEntity.builder()
.productCode("3G-OLD-001")
.productName("3G 레거시 플랜")
.monthlyFee(new BigDecimal("29000"))
.dataAllowance("5GB")
.voiceAllowance("500분")
.smsAllowance("100건")
.operatorCode("KT")
.networkType("3G")
.status("DISCONTINUED")
.description("3G 네트워크 레거시 요금제 (신규 가입 불가)")
.build()
);
}
}

View File

@ -2,6 +2,7 @@ package com.phonebill.kosmock.service;
import com.phonebill.kosmock.config.MockConfig;
import com.phonebill.kosmock.data.*;
import com.phonebill.kosmock.data.MockDataService;
import com.phonebill.kosmock.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -72,6 +73,7 @@ public class KosMockService {
// 성공 응답 생성
KosBillInquiryResponse response = KosBillInquiryResponse.builder()
.requestId(request.getRequestId())
.procStatus("SUCCESS")
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.billInfo(KosBillInquiryResponse.BillInfo.builder()
@ -158,10 +160,15 @@ public class KosMockService {
// KOS 주문 번호 생성
String kosOrderNumber = generateKosOrderNumber();
// 적용 일자 설정 (없으면 내일 사용)
String effectiveDate = request.getEffectiveDate();
if (effectiveDate == null || effectiveDate.isEmpty()) {
effectiveDate = LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
// 적용 일자 설정 (현재 날짜로 자동 설정)
String effectiveDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
// 실제 고객 데이터 업데이트
boolean updateSuccess = mockDataService.updateCustomerProduct(request.getLineNumber(), request.getTargetProductCode());
if (!updateSuccess) {
log.error("고객 상품 코드 업데이트 실패 - LineNumber: {}, TargetProduct: {}",
request.getLineNumber(), request.getTargetProductCode());
return createProductChangeErrorResponse(request.getRequestId(), "2099", "상품 변경 처리 중 오류가 발생했습니다");
}
// 성공 응답 생성
@ -202,6 +209,161 @@ public class KosMockService {
return response;
}
/**
* 상품 목록 조회 (Mock)
*/
public KosProductListResponse getProductList() {
log.info("KOS Mock 상품 목록 조회 요청 처리 시작");
// Mock 응답 지연 시뮬레이션
simulateProcessingDelay();
// Mock 실패 시뮬레이션
if (shouldSimulateFailure()) {
log.warn("KOS Mock 상품 목록 조회 실패 시뮬레이션");
throw new RuntimeException("KOS 시스템 일시적 오류");
}
try {
// Mock 데이터에서 상품 목록 조회
java.util.List<MockProductData> productDataList = mockDataService.getAllProducts();
// KosProductInfo 리스트로 변환
java.util.List<KosProductInfo> productInfoList = productDataList.stream()
.map(this::convertToProductInfo)
.collect(java.util.stream.Collectors.toList());
log.info("KOS Mock 상품 목록 조회 완료 - 상품 수: {}", productInfoList.size());
return KosProductListResponse.builder()
.resultCode("0000")
.resultMessage("상품 목록 조회가 완료되었습니다")
.productCount(productInfoList.size())
.products(productInfoList)
.build();
} catch (Exception e) {
log.error("KOS Mock 상품 목록 조회 처리 중 오류 발생", e);
return KosProductListResponse.builder()
.resultCode("9999")
.resultMessage("시스템 오류가 발생했습니다")
.productCount(0)
.products(java.util.Collections.emptyList())
.build();
}
}
/**
* 가입상품 조회 처리 (Mock)
*/
public KosProductInquiryResponse processProductInquiry(KosProductInquiryRequest request) {
log.info("KOS Mock 가입상품 조회 요청 처리 시작 - RequestId: {}, LineNumber: {}",
request.getRequestId(), request.getLineNumber());
// Mock 응답 지연 시뮬레이션
simulateProcessingDelay();
// Mock 실패 시뮬레이션
if (shouldSimulateFailure()) {
log.warn("KOS Mock 가입상품 조회 실패 시뮬레이션 - RequestId: {}", request.getRequestId());
throw new RuntimeException("KOS 시스템 일시적 오류");
}
// 고객 데이터 조회
MockCustomerData customerData = mockDataService.getCustomerData(request.getLineNumber());
if (customerData == null) {
log.warn("존재하지 않는 회선번호 - LineNumber: {}", request.getLineNumber());
return createProductInquiryErrorResponse(request.getRequestId(), "3001", "존재하지 않는 회선번호입니다");
}
// 회선 상태 확인
if (!"ACTIVE".equals(customerData.getLineStatus())) {
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
request.getLineNumber(), customerData.getLineStatus());
return createProductInquiryErrorResponse(request.getRequestId(), "3002", "비활성 상태의 회선입니다");
}
// 현재 상품 정보 조회
MockProductData productData = mockDataService.getProductData(customerData.getCurrentProductCode());
if (productData == null) {
log.warn("존재하지 않는 상품 코드 - ProductCode: {}", customerData.getCurrentProductCode());
return createProductInquiryErrorResponse(request.getRequestId(), "3003", "상품 정보를 찾을 수 없습니다");
}
// 성공 응답 생성
KosProductInquiryResponse response = KosProductInquiryResponse.builder()
.requestId(request.getRequestId())
.procStatus("SUCCESS")
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.productInfo(KosProductInquiryResponse.ProductInfo.builder()
.lineNumber(customerData.getLineNumber())
.currentProductCode(productData.getProductCode())
.currentProductName(productData.getProductName())
.monthlyFee(productData.getMonthlyFee())
.dataAllowance(productData.getDataAllowance())
.voiceAllowance(productData.getVoiceAllowance())
.smsAllowance(productData.getSmsAllowance())
.productStatus(productData.getStatus())
.contractDate(customerData.getContractDate())
.build())
.customerInfo(KosProductInquiryResponse.CustomerInfo.builder()
.customerName(customerData.getCustomerName())
.customerId(customerData.getCustomerId())
.operatorCode(customerData.getOperatorCode())
.lineStatus(customerData.getLineStatus())
.build())
.build();
log.info("KOS Mock 가입상품 조회 처리 완료 - RequestId: {}", request.getRequestId());
return response;
}
/**
* 회선번호의 실제 요금 데이터가 있는 목록 조회 (Mock)
*/
public KosAvailableMonthsResponse getAvailableMonths(String lineNumber) {
log.info("KOS Mock 데이터 보유 월 목록 조회 - LineNumber: {}", lineNumber);
// Mock 응답 지연 시뮬레이션
simulateProcessingDelay();
// 고객 데이터 조회
MockCustomerData customerData = mockDataService.getCustomerData(lineNumber);
if (customerData == null) {
log.warn("존재하지 않는 회선번호 - LineNumber: {}", lineNumber);
return KosAvailableMonthsResponse.builder()
.resultCode("1001")
.resultMessage("존재하지 않는 회선번호입니다")
.availableMonths(java.util.Collections.emptyList())
.build();
}
// 회선 상태 확인
if (!"ACTIVE".equals(customerData.getLineStatus())) {
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
lineNumber, customerData.getLineStatus());
return KosAvailableMonthsResponse.builder()
.resultCode("1002")
.resultMessage("비활성 상태의 회선입니다")
.availableMonths(java.util.Collections.emptyList())
.build();
}
// Mock 데이터에서 실제 데이터가 있는 목록 조회
java.util.List<String> availableMonths = mockDataService.getAvailableMonths(lineNumber);
log.info("KOS Mock 데이터 보유 월 목록 조회 완료 - LineNumber: {}, 월 수: {}",
lineNumber, availableMonths.size());
return KosAvailableMonthsResponse.builder()
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.lineNumber(lineNumber)
.availableMonths(availableMonths)
.build();
}
/**
* 처리 지연 시뮬레이션
*/
@ -235,6 +397,7 @@ public class KosMockService {
private KosBillInquiryResponse createBillInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
return KosBillInquiryResponse.builder()
.requestId(requestId)
.procStatus("FAILED")
.resultCode(errorCode)
.resultMessage(errorMessage)
.build();
@ -250,4 +413,76 @@ public class KosMockService {
.resultMessage(errorMessage)
.build();
}
/**
* 가입상품 조회 오류 응답 생성
*/
private KosProductInquiryResponse createProductInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
return KosProductInquiryResponse.builder()
.requestId(requestId)
.procStatus("FAILED")
.resultCode(errorCode)
.resultMessage(errorMessage)
.build();
}
/**
* MockProductData를 KosProductInfo로 변환
*/
private KosProductInfo convertToProductInfo(MockProductData productData) {
return KosProductInfo.builder()
.productCode(productData.getProductCode())
.productName(productData.getProductName())
.productType(productData.getPlanType())
.monthlyFee(productData.getMonthlyFee().intValue())
.dataAllowance(parseDataAllowance(productData.getDataAllowance()))
.voiceAllowance(parseVoiceAllowance(productData.getVoiceAllowance()))
.smsAllowance(parseSmsAllowance(productData.getSmsAllowance()))
.networkType(productData.getNetworkType())
.status(productData.getStatus())
.description(productData.getDescription())
.build();
}
/**
* 데이터 허용량을 정수로 변환 (GB 단위)
*/
private Integer parseDataAllowance(String dataAllowance) {
if (dataAllowance == null || "무제한".equals(dataAllowance)) {
return -1; // 무제한을 -1로 표현
}
try {
return Integer.parseInt(dataAllowance.replaceAll("[^0-9]", ""));
} catch (NumberFormatException e) {
return 0;
}
}
/**
* 음성 허용량을 정수로 변환 ( 단위)
*/
private Integer parseVoiceAllowance(String voiceAllowance) {
if (voiceAllowance == null || "무제한".equals(voiceAllowance)) {
return -1; // 무제한을 -1로 표현
}
try {
return Integer.parseInt(voiceAllowance.replaceAll("[^0-9]", ""));
} catch (NumberFormatException e) {
return 0;
}
}
/**
* SMS 허용량을 정수로 변환 ( 단위)
*/
private Integer parseSmsAllowance(String smsAllowance) {
if (smsAllowance == null || "무제한".equals(smsAllowance)) {
return -1; // 무제한을 -1로 표현
}
try {
return Integer.parseInt(smsAllowance.replaceAll("[^0-9]", ""));
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@ -0,0 +1,324 @@
package com.phonebill.kosmock.service;
import com.phonebill.kosmock.dto.MockDataCreateRequest;
import com.phonebill.kosmock.dto.MockDataCreateResponse;
import com.phonebill.kosmock.entity.BillEntity;
import com.phonebill.kosmock.entity.CustomerEntity;
import com.phonebill.kosmock.entity.ProductEntity;
import com.phonebill.kosmock.repository.BillRepository;
import com.phonebill.kosmock.repository.CustomerRepository;
import com.phonebill.kosmock.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
/**
* Mock 데이터 생성 서비스
*/
@Service
@RequiredArgsConstructor
public class MockDataCreateService {
private static final Logger log = LoggerFactory.getLogger(MockDataCreateService.class);
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
private final BillRepository billRepository;
private final com.phonebill.kosmock.data.MockDataService mockDataService;
/**
* Mock 데이터 생성 (고객 정보 + 요금 정보)
*/
@Transactional
public MockDataCreateResponse createMockData(MockDataCreateRequest request) {
log.info("Mock 데이터 생성 시작 - CustomerId: {}, LineNumber: {}",
request.getCustomerId(), request.getLineNumber());
// 1. 기존 데이터 존재 여부 확인
if (customerRepository.existsByCustomerIdAndLineNumber(request.getCustomerId(), request.getLineNumber())) {
throw new IllegalArgumentException("이미 존재하는 고객 정보입니다: " + request.getCustomerId() + ", " + request.getLineNumber());
}
// 2. 랜덤 상품 선택
List<ProductEntity> activeProducts = productRepository.findByStatusOrderByMonthlyFeeDesc("ACTIVE");
if (activeProducts.isEmpty()) {
throw new IllegalStateException("활성화된 상품이 없습니다");
}
ProductEntity selectedProduct = selectRandomProduct(activeProducts);
// 3. 고객 정보 생성
CustomerEntity customer = createCustomer(request, selectedProduct);
customerRepository.save(customer);
// 4. 요금 정보 생성 (최근 3개월)
List<BillEntity> bills = createBills(customer, selectedProduct);
billRepository.saveAll(bills);
log.info("Mock 데이터 생성 완료 - CustomerId: {}, LineNumber: {}, Product: {}, Bills: {}",
request.getCustomerId(), request.getLineNumber(), selectedProduct.getProductCode(), bills.size());
return MockDataCreateResponse.builder()
.customerId(customer.getCustomerId())
.lineNumber(customer.getLineNumber())
.currentProductCode(selectedProduct.getProductCode())
.currentProductName(selectedProduct.getProductName())
.billCountCreated(bills.size())
.message("Mock 데이터가 성공적으로 생성되었습니다")
.build();
}
/**
* 랜덤 상품 선택 (가중치 적용)
*/
private ProductEntity selectRandomProduct(List<ProductEntity> products) {
Random random = new Random();
// 가중치 적용: 프리미엄 상품(30%), 스탠다드 상품(70%)
if (random.nextDouble() < 0.7) {
// 스탠다드/베이직 상품 우선
Optional<ProductEntity> basicProduct = products.stream()
.filter(p -> p.getProductName().contains("베이직") || p.getProductName().contains("스탠다드"))
.findFirst();
if (basicProduct.isPresent()) {
return basicProduct.get();
}
}
// 랜덤 선택
return products.get(random.nextInt(products.size()));
}
/**
* 고객 정보 생성
*/
private CustomerEntity createCustomer(MockDataCreateRequest request, ProductEntity product) {
Random random = new Random();
return CustomerEntity.builder()
.lineNumber(request.getLineNumber())
.customerId(request.getCustomerId())
.operatorCode("KT")
.currentProductCode(product.getProductCode())
.lineStatus("ACTIVE")
.contractDate(LocalDateTime.now().minusMonths(random.nextInt(24) + 1)) // 1~24개월 가입
.build();
}
/**
* 요금 정보 생성 (최근 3개월)
*/
private List<BillEntity> createBills(CustomerEntity customer, ProductEntity product) {
List<BillEntity> bills = new ArrayList<>();
Random random = new Random();
for (int month = 0; month < 3; month++) {
LocalDateTime billDate = LocalDateTime.now().minusMonths(month);
String billingMonth = billDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
// 사용료 계산 (랜덤)
BigDecimal usageFee = new BigDecimal(random.nextInt(30000));
BigDecimal totalFee = product.getMonthlyFee().add(usageFee);
BillEntity bill = BillEntity.builder()
.lineNumber(customer.getLineNumber())
.billingMonth(billingMonth)
.productCode(product.getProductCode())
.productName(product.getProductName())
.monthlyFee(product.getMonthlyFee())
.usageFee(usageFee)
.totalFee(totalFee)
.dataUsage(generateDataUsage(product))
.voiceUsage(generateVoiceUsage(product))
.smsUsage(generateSmsUsage())
.billStatus("CONFIRMED")
.dueDate(billDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyyMMdd")))
.build();
bills.add(bill);
}
return bills;
}
private String generateDataUsage(ProductEntity product) {
Random random = new Random();
if ("무제한".equals(product.getDataAllowance())) {
return random.nextInt(200) + "GB";
} else {
try {
int allowance = Integer.parseInt(product.getDataAllowance().replace("GB", ""));
return random.nextInt(allowance) + "GB";
} catch (NumberFormatException e) {
return "10GB"; // 기본값
}
}
}
private String generateVoiceUsage(ProductEntity product) {
Random random = new Random();
if ("무제한".equals(product.getVoiceAllowance())) {
return random.nextInt(500) + "";
} else {
try {
int allowance = Integer.parseInt(product.getVoiceAllowance().replace("", ""));
return random.nextInt(allowance) + "";
} catch (NumberFormatException e) {
return "100분"; // 기본값
}
}
}
private String generateSmsUsage() {
Random random = new Random();
return random.nextInt(100) + "";
}
/**
* 고객 가입상품정보 조회
*/
public Object getCustomerProduct(String customerId, String lineNumber) {
log.info("고객 상품정보 조회 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
Optional<CustomerEntity> customer = customerRepository
.findByCustomerIdAndLineNumber(customerId, lineNumber);
if (customer.isEmpty()) {
log.warn("고객 정보를 찾을 수 없음 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
return null;
}
CustomerEntity customerEntity = customer.get();
// 현재 상품 정보 조회
Optional<ProductEntity> product = productRepository
.findByProductCode(customerEntity.getCurrentProductCode());
if (product.isEmpty()) {
log.warn("상품 정보를 찾을 수 없음 - ProductCode: {}", customerEntity.getCurrentProductCode());
return null;
}
ProductEntity productEntity = product.get();
// 응답 데이터 구성
return CustomerProductInfo.builder()
.customerId(customerEntity.getCustomerId())
.lineNumber(customerEntity.getLineNumber())
.operatorCode(customerEntity.getOperatorCode())
.lineStatus(customerEntity.getLineStatus())
.contractDate(customerEntity.getContractDate())
.currentProductCode(productEntity.getProductCode())
.currentProductName(productEntity.getProductName())
.monthlyFee(productEntity.getMonthlyFee())
.dataAllowance(productEntity.getDataAllowance())
.voiceAllowance(productEntity.getVoiceAllowance())
.smsAllowance(productEntity.getSmsAllowance())
.productStatus(productEntity.getStatus())
.build();
}
/**
* 고객 요금정보 조회
*/
public Object getCustomerBill(String customerId, String lineNumber) {
log.info("고객 요금정보 조회 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
Optional<CustomerEntity> customer = customerRepository
.findByCustomerIdAndLineNumber(customerId, lineNumber);
if (customer.isEmpty()) {
log.warn("고객 정보를 찾을 수 없음 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
return null;
}
// 최근 3개월 요금 정보 조회
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
if (bills.isEmpty()) {
log.warn("요금 정보를 찾을 수 없음 - LineNumber: {}", lineNumber);
return null;
}
// 응답 데이터 구성
List<CustomerBillInfo> billInfos = bills.stream()
.map(bill -> CustomerBillInfo.builder()
.billingMonth(bill.getBillingMonth())
.productCode(bill.getProductCode())
.productName(bill.getProductName())
.monthlyFee(bill.getMonthlyFee())
.usageFee(bill.getUsageFee())
.totalFee(bill.getTotalFee())
.dataUsage(bill.getDataUsage())
.voiceUsage(bill.getVoiceUsage())
.smsUsage(bill.getSmsUsage())
.billStatus(bill.getBillStatus())
.dueDate(bill.getDueDate())
.build())
.toList();
return CustomerBillsInfo.builder()
.customerId(customer.get().getCustomerId())
.lineNumber(lineNumber)
.billCount(billInfos.size())
.bills(billInfos)
.build();
}
/**
* 고객 상품정보 응답 DTO
*/
@lombok.Builder
@lombok.Data
public static class CustomerProductInfo {
private String customerId;
private String lineNumber;
private String operatorCode;
private String lineStatus;
private LocalDateTime contractDate;
private String currentProductCode;
private String currentProductName;
private BigDecimal monthlyFee;
private String dataAllowance;
private String voiceAllowance;
private String smsAllowance;
private String productStatus;
}
/**
* 고객 요금정보 응답 DTO
*/
@lombok.Builder
@lombok.Data
public static class CustomerBillsInfo {
private String customerId;
private String lineNumber;
private int billCount;
private List<CustomerBillInfo> bills;
}
@lombok.Builder
@lombok.Data
public static class CustomerBillInfo {
private String billingMonth;
private String productCode;
private String productName;
private BigDecimal monthlyFee;
private BigDecimal usageFee;
private BigDecimal totalFee;
private String dataUsage;
private String voiceUsage;
private String smsUsage;
private String billStatus;
private String dueDate;
}
}

View File

@ -1,48 +1,3 @@
spring:
# H2 데이터베이스 설정 (Mock 서비스용)
datasource:
url: jdbc:h2:mem:kosmock;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
driver-class-name: org.h2.Driver
# JPA 설정
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
# H2 Console (개발환경에서만)
h2:
console:
enabled: true
path: /h2-console
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:4}
# Mock 응답 시간 (개발 환경에서는 빠른 응답)
kos:
mock:
response-delay: 100 # milliseconds
failure-rate: 0.01 # 1% 실패율
# 로깅 레벨 (개발환경)
logging:
level:

View File

@ -1,27 +1,6 @@
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# Mock 응답 시간 (실제 KOS 시스템을 모방)
kos:
mock:
response-delay: 1000 # milliseconds (1초)
failure-rate: 0.05 # 5% 실패율
# 로깅 레벨 (운영환경)
# 로깅 레벨
logging:
level:
com.phonebill.kosmock: INFO
org.springframework.web: WARN
org.springframework.data.redis: WARN
file:
name: /var/log/kos-mock-service.log

View File

@ -2,7 +2,28 @@ spring:
application:
name: kos-mock-service
profiles:
active: dev
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: jdbc:h2:file:./data/kos_mock
driver-class-name: org.h2.Driver
username: sa
password: password
sql:
init:
platform: h2
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
server:
port: ${SERVER_PORT:8084}
@ -22,8 +43,12 @@ management:
logging:
level:
com.phonebill.kosmock: INFO
com.phonebill.kosmock: DEBUG
org.springframework.web: INFO
org.springframework.jdbc.core: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE
com.zaxxer.hikari: DEBUG
pattern:
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
file: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
@ -39,3 +64,4 @@ springdoc:
tags-sorter: alpha
operations-sorter: alpha
show-actuator: true
paths-to-exclude: /actuator/**

View File

@ -1,18 +0,0 @@
package com.phonebill.kosmock;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* KOS Mock Application 통합 테스트
*/
@SpringBootTest
@ActiveProfiles("test")
class KosMockApplicationTest {
@Test
void contextLoads() {
// Spring Context가 정상적으로 로드되는지 확인
}
}

View File

@ -1,98 +0,0 @@
package com.phonebill.kosmock.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.phonebill.kosmock.dto.KosBillInquiryRequest;
import com.phonebill.kosmock.dto.KosProductChangeRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* KOS Mock Controller 테스트
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class KosMockControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("서비스 상태 체크 API 테스트")
void healthCheck() throws Exception {
mockMvc.perform(get("/api/v1/kos/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.resultCode").value("0000"));
}
@Test
@DisplayName("요금 조회 API 성공 테스트")
void inquireBill_Success() throws Exception {
KosBillInquiryRequest request = new KosBillInquiryRequest();
request.setLineNumber("01012345678");
request.setBillingMonth("202501");
request.setRequestId("TEST_REQ_001");
request.setRequestorId("TEST_SERVICE");
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("요금 조회 API 입력값 검증 실패 테스트")
void inquireBill_ValidationFailure() throws Exception {
KosBillInquiryRequest request = new KosBillInquiryRequest();
// 필수값 누락
request.setBillingMonth("202501");
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("상품 변경 API 성공 테스트")
void changeProduct_Success() throws Exception {
KosProductChangeRequest request = new KosProductChangeRequest();
request.setLineNumber("01012345678");
request.setCurrentProductCode("LTE-BASIC-001");
request.setTargetProductCode("5G-PREMIUM-001");
request.setRequestId("TEST_REQ_002");
request.setRequestorId("TEST_SERVICE");
request.setChangeReason("테스트 상품 변경");
mockMvc.perform(post("/api/v1/kos/product/change")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("Mock 설정 조회 API 테스트")
void getMockConfig() throws Exception {
mockMvc.perform(get("/api/v1/kos/mock/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.resultCode").value("0000"));
}
}

View File

@ -1,20 +0,0 @@
spring:
data:
redis:
host: localhost
port: 6379
timeout: 1000ms
# 테스트용 Mock 설정
kos:
mock:
response-delay: 0 # 테스트에서는 지연 없음
failure-rate: 0.0 # 테스트에서는 실패 시뮬레이션 없음
debug-mode: true
# 로깅 레벨 (테스트환경)
logging:
level:
com.phonebill.kosmock: DEBUG
org.springframework.web: INFO
org.springframework.test: INFO

View File

@ -3,56 +3,27 @@
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Connection -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
<entry key="DB_HOST" value="20.249.107.185" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="product_change_db" />
<entry key="DB_USERNAME" value="product_change_user" />
<entry key="DB_PASSWORD" value="ProductUser2025!" />
<entry key="DB_KIND" value="postgresql" />
<!-- Redis Connection -->
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="DB_NAME" value="product_change_db" />
<entry key="DB_PASSWORD" value="ProductUser2025!" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_USERNAME" value="product_change_user" />
<entry key="DDL_AUTO" value="update" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
<entry key="KOS_API_KEY" value="dev-api-key" />
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
<entry key="KOS_CLIENT_ID" value="product-service-dev" />
<entry key="KOS_MOCK_ENABLED" value="true" />
<entry key="REDIS_DATABASE" value="2" />
<!-- Server Configuration -->
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_PORT" value="6379" />
<entry key="SERVER_PORT" value="8083" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
<entry key="JWT_EXPIRATION" value="3600" />
<!-- JPA Configuration -->
<entry key="JPA_DDL_AUTO" value="update" />
<entry key="DDL_AUTO" value="update" />
<entry key="SHOW_SQL" value="true" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/product-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<!-- KOS Mock Configuration -->
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
<entry key="KOS_MOCK_ENABLED" value="true" />
<entry key="KOS_API_KEY" value="dev-api-key" />
<entry key="KOS_CLIENT_ID" value="product-service-dev" />
<!-- Product Service Specific Settings -->
<entry key="PRODUCT_PROCESSING_ASYNC_ENABLED" value="false" />
<entry key="PRODUCT_CACHE_CUSTOMER_INFO_TTL" value="600" />
<entry key="PRODUCT_CACHE_PRODUCT_INFO_TTL" value="300" />
<entry key="PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL" value="1800" />
<entry key="PRODUCT_CACHE_PRODUCT_STATUS_TTL" value="300" />
<entry key="PRODUCT_CACHE_LINE_STATUS_TTL" value="180" />
<entry key="PRODUCT_VALIDATION_ENABLED" value="true" />
<entry key="PRODUCT_VALIDATION_STRICT_MODE" value="false" />
<entry key="TEST_DATA_ENABLED" value="true" />
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
</map>
</option>
<option name="executionName" />

View File

@ -0,0 +1,211 @@
package com.unicorn.phonebill.product.config;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry;
import io.github.resilience4j.timelimiter.TimeLimiter;
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Circuit Breaker 패턴 설정
*
* Resilience4j를 활용한 장애 격리 복구 시스템 구성
* - KOS 시스템 연동 장애 상황에 대한 자동 회복
* - 실패율 기반 Circuit Breaker
* - 응답 시간 기반 Time Limiter
* - 재시도 정책 구성
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-09
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class CircuitBreakerConfig {
private final KosProperties kosProperties;
/**
* KOS 시스템 연동용 Circuit Breaker 구성
*
* @return Circuit Breaker 레지스트리
*/
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
log.info("Circuit Breaker 레지스트리 구성 시작");
// KOS 시스템용 Circuit Breaker 설정
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig kosCircuitBreakerConfig =
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
// 실패율 임계값 (50%)
.failureRateThreshold(kosProperties.getCircuitBreaker().getFailureRateThreshold() * 100)
// 느린 호출 임계값 (10초)
.slowCallDurationThreshold(Duration.ofMillis(
kosProperties.getCircuitBreaker().getSlowCallDurationThreshold()))
// 느린 호출 비율 임계값 (50%)
.slowCallRateThreshold(kosProperties.getCircuitBreaker().getSlowCallRateThreshold() * 100)
// 슬라이딩 윈도우 크기 (10회)
.slidingWindowSize(kosProperties.getCircuitBreaker().getSlidingWindowSize())
// 슬라이딩 윈도우 타입 (횟수 기반)
.slidingWindowType(SlidingWindowType.COUNT_BASED)
// 최소 호출 (5회)
.minimumNumberOfCalls(kosProperties.getCircuitBreaker().getMinimumNumberOfCalls())
// Half-Open 상태에서 허용되는 호출 (3회)
.permittedNumberOfCallsInHalfOpenState(
kosProperties.getCircuitBreaker().getPermittedNumberOfCallsInHalfOpenState())
// Open 상태 유지 시간 (60초)
.waitDurationInOpenState(Duration.ofMillis(
kosProperties.getCircuitBreaker().getWaitDurationInOpenState()))
// Circuit Breaker 상태 변경 이벤트 리스너
.recordExceptions(Exception.class)
.ignoreExceptions()
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(kosCircuitBreakerConfig);
// KOS Circuit Breaker 등록
CircuitBreaker kosCircuitBreaker = registry.circuitBreaker("kos-system", kosCircuitBreakerConfig);
// 이벤트 리스너 등록
kosCircuitBreaker.getEventPublisher()
.onStateTransition(event -> {
log.warn("Circuit Breaker 상태 변경 - From: {}, To: {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
})
.onCallNotPermitted(event -> {
log.error("Circuit Breaker OPEN 상태 - 호출 차단됨: {}", event.getCircuitBreakerName());
})
.onFailureRateExceeded(event -> {
log.error("Circuit Breaker 실패율 초과");
});
log.info("Circuit Breaker 레지스트리 구성 완료 - KOS Circuit Breaker 등록됨");
return registry;
}
/**
* 재시도 정책 레지스트리 구성
*
* @return 재시도 레지스트리
*/
@Bean
public RetryRegistry retryRegistry() {
log.info("Retry 레지스트리 구성 시작");
// KOS 시스템용 재시도 설정
io.github.resilience4j.retry.RetryConfig kosRetryConfig =
io.github.resilience4j.retry.RetryConfig.custom()
// 최대 재시도 횟수
.maxAttempts(kosProperties.getMaxRetries())
// 재시도 간격
.waitDuration(Duration.ofMillis(kosProperties.getRetryDelay()))
// 지수 백오프 비활성화 (고정 간격 사용)
// .intervalFunction() 대신 waitDuration 사용
// 재시도 대상 예외
.retryExceptions(Exception.class)
// 재시도 제외 예외
.ignoreExceptions(IllegalArgumentException.class, SecurityException.class)
.build();
RetryRegistry registry = RetryRegistry.of(kosRetryConfig);
// KOS Retry 등록
Retry kosRetry = registry.retry("kos-system", kosRetryConfig);
// 재시도 이벤트 리스너
kosRetry.getEventPublisher()
.onRetry(event -> {
log.warn("재시도 실행 - 시도 횟수: {}/{}, 마지막 오류: {}",
event.getNumberOfRetryAttempts(),
kosRetryConfig.getMaxAttempts(),
event.getLastThrowable().getMessage());
})
.onError(event -> {
log.error("재시도 최종 실패 - 총 시도 횟수: {}, 최종 오류: {}",
event.getNumberOfRetryAttempts(),
event.getLastThrowable().getMessage());
});
log.info("Retry 레지스트리 구성 완료 - 최대 재시도: {}회, 간격: {}ms",
kosProperties.getMaxRetries(), kosProperties.getRetryDelay());
return registry;
}
/**
* Time Limiter 레지스트리 구성
*
* @return Time Limiter 레지스트리
*/
@Bean
public TimeLimiterRegistry timeLimiterRegistry() {
log.info("Time Limiter 레지스트리 구성 시작");
// KOS 시스템용 타임아웃 설정
io.github.resilience4j.timelimiter.TimeLimiterConfig kosTimeLimiterConfig =
io.github.resilience4j.timelimiter.TimeLimiterConfig.custom()
// 타임아웃 (연결 타임아웃 + 읽기 타임아웃)
.timeoutDuration(Duration.ofMillis(kosProperties.getTotalTimeout()))
// 타임아웃 작업 취소 여부
.cancelRunningFuture(true)
.build();
TimeLimiterRegistry registry = TimeLimiterRegistry.of(kosTimeLimiterConfig);
// KOS Time Limiter 등록
TimeLimiter kosTimeLimiter = registry.timeLimiter("kos-system", kosTimeLimiterConfig);
// 타임아웃 이벤트 리스너
kosTimeLimiter.getEventPublisher()
.onTimeout(event -> {
log.error("Time Limiter 타임아웃 발생 - 설정 시간: {}ms",
kosTimeLimiterConfig.getTimeoutDuration().toMillis());
});
log.info("Time Limiter 레지스트리 구성 완료 - 타임아웃: {}ms",
kosProperties.getTotalTimeout());
return registry;
}
/**
* KOS Circuit Breaker 조회
*
* @param circuitBreakerRegistry Circuit Breaker 레지스트리
* @return KOS Circuit Breaker
*/
@Bean
public CircuitBreaker kosCircuitBreaker(CircuitBreakerRegistry circuitBreakerRegistry) {
return circuitBreakerRegistry.circuitBreaker("kos-system");
}
/**
* KOS Retry 조회
*
* @param retryRegistry Retry 레지스트리
* @return KOS Retry
*/
@Bean
public Retry kosRetry(RetryRegistry retryRegistry) {
return retryRegistry.retry("kos-system");
}
/**
* KOS Time Limiter 조회
*
* @param timeLimiterRegistry Time Limiter 레지스트리
* @return KOS Time Limiter
*/
@Bean
public TimeLimiter kosTimeLimiter(TimeLimiterRegistry timeLimiterRegistry) {
return timeLimiterRegistry.timeLimiter("kos-system");
}
}

View File

@ -1,182 +0,0 @@
package com.unicorn.phonebill.product.config;
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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;
/**
* JWT 인증 필터
*
* 주요 기능:
* - Authorization 헤더에서 JWT 토큰 추출
* - JWT 토큰 검증 파싱
* - 사용자 인증 정보를 SecurityContext에 설정
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Value("${app.jwt.secret:mySecretKey}")
private String jwtSecret;
@Value("${app.jwt.expiration:86400}")
private long jwtExpirationInSeconds;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// JWT 토큰 추출
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && validateToken(jwt)) {
// JWT에서 사용자 정보 추출
Authentication authentication = getAuthenticationFromToken(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 사용자 정보를 헤더에 추가 (다운스트림 서비스에서 활용)
addUserInfoToHeaders(request, response, jwt);
}
} catch (Exception ex) {
logger.error("JWT 인증 처리 중 오류 발생", ex);
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
/**
* Authorization 헤더에서 JWT 토큰 추출
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
/**
* JWT 토큰 유효성 검증
*/
private boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return true;
} catch (MalformedJwtException e) {
logger.error("JWT 토큰이 유효하지 않습니다: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT 토큰이 만료되었습니다: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT 클레임이 비어있습니다: {}", e.getMessage());
} catch (Exception e) {
logger.error("JWT 토큰 검증 중 오류 발생: {}", e.getMessage());
}
return false;
}
/**
* JWT 토큰에서 인증 정보 추출
*/
private Authentication getAuthenticationFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
String userId = claims.getSubject();
String authorities = claims.get("auth", String.class);
Collection<SimpleGrantedAuthority> grantedAuthorities =
StringUtils.hasText(authorities) ?
Arrays.stream(authorities.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()) :
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(userId, "", grantedAuthorities);
}
/**
* 사용자 정보를 응답 헤더에 추가
*/
private void addUserInfoToHeaders(HttpServletRequest request, HttpServletResponse response, String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
// 사용자 ID 헤더 추가
String userId = claims.getSubject();
if (StringUtils.hasText(userId)) {
response.setHeader("X-User-ID", userId);
}
// 고객 ID 헤더 추가 (있는 경우)
String customerId = claims.get("customerId", String.class);
if (StringUtils.hasText(customerId)) {
response.setHeader("X-Customer-ID", customerId);
}
// 요청 ID 헤더 추가 (추적용)
String requestId = request.getHeader("X-Request-ID");
if (StringUtils.hasText(requestId)) {
response.setHeader("X-Request-ID", requestId);
}
} catch (Exception e) {
logger.warn("사용자 정보 헤더 추가 중 오류 발생: {}", e.getMessage());
}
}
/**
* 필터 적용 제외 경로 설정
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// Health Check 문서화 API는 필터 제외
return path.startsWith("/actuator/") ||
path.startsWith("/v3/api-docs") ||
path.startsWith("/swagger-ui");
}
}

View File

@ -0,0 +1,50 @@
package com.unicorn.phonebill.product.config;
import com.phonebill.common.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.Getter;
import lombok.Setter;
/**
* JWT 설정 프로퍼티
*/
@Configuration
@ConfigurationProperties(prefix = "jwt")
@Getter
@Setter
public class JwtConfig {
private String secret;
private long accessTokenValidity = 1800000; // 30분 (milliseconds)
private long refreshTokenValidity = 86400000; // 24시간 (milliseconds)
private String issuer = "phonebill-auth-service";
/**
* Access Token 만료 시간 ( 단위)
*/
public int getAccessTokenValidityInSeconds() {
return (int) (accessTokenValidity / 1000);
}
/**
* Refresh Token 만료 시간 ( 단위)
*/
public int getRefreshTokenValidityInSeconds() {
return (int) (refreshTokenValidity / 1000);
}
/**
* JwtTokenProvider 정의
*/
@Bean
public JwtTokenProvider jwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long tokenValidityInMilliseconds) {
long tokenValidityInSeconds = tokenValidityInMilliseconds / 1000;
return new JwtTokenProvider(secret, tokenValidityInSeconds);
}
}

View File

@ -0,0 +1,312 @@
package com.unicorn.phonebill.product.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
/**
* KOS 시스템 연동 설정 프로퍼티
*
* application.yml 파일의 kos 설정을 바인딩하는 설정 클래스
* - 연결 정보 (URL, 타임아웃 )
* - 재시도 정책
* - Circuit Breaker 설정
* - 인증 관련 설정
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-09
*/
@Component
@ConfigurationProperties(prefix = "kos")
@Getter
@Setter
@Validated
public class KosProperties {
/**
* KOS 시스템 기본 URL
*/
@NotBlank(message = "KOS 기본 URL은 필수입니다")
private String baseUrl;
/**
* 연결 타임아웃 (밀리초)
*/
@NotNull
@Positive
private Integer connectTimeout = 5000;
/**
* 읽기 타임아웃 (밀리초)
*/
@NotNull
@Positive
private Integer readTimeout = 30000;
/**
* 최대 재시도 횟수
*/
@NotNull
@Positive
private Integer maxRetries = 3;
/**
* 재시도 간격 (밀리초)
*/
@NotNull
@Positive
private Long retryDelay = 1000L;
/**
* Circuit Breaker 설정
*/
private CircuitBreaker circuitBreaker = new CircuitBreaker();
/**
* 인증 설정
*/
private Authentication authentication = new Authentication();
/**
* 모니터링 설정
*/
private Monitoring monitoring = new Monitoring();
/**
* Circuit Breaker 설정 내부 클래스
*/
@Getter
@Setter
public static class CircuitBreaker {
/**
* 실패율 임계값 (0.0 ~ 1.0)
*/
private Float failureRateThreshold = 0.5f;
/**
* 느린 호출 임계값 (밀리초)
*/
private Long slowCallDurationThreshold = 10000L;
/**
* 느린 호출 비율 임계값 (0.0 ~ 1.0)
*/
private Float slowCallRateThreshold = 0.5f;
/**
* 슬라이딩 윈도우 크기
*/
private Integer slidingWindowSize = 10;
/**
* 최소 호출
*/
private Integer minimumNumberOfCalls = 5;
/**
* Half-Open 상태에서 허용되는 호출
*/
private Integer permittedNumberOfCallsInHalfOpenState = 3;
/**
* Open 상태 유지 시간 (밀리초)
*/
private Long waitDurationInOpenState = 60000L;
}
/**
* 인증 설정 내부 클래스
*/
@Getter
@Setter
public static class Authentication {
/**
* 인증 토큰 사용 여부
*/
private Boolean enabled = true;
/**
* API
*/
private String apiKey;
/**
* 시크릿
*/
private String secretKey;
/**
* 토큰 만료 시간 ()
*/
private Long tokenExpirationSeconds = 3600L;
/**
* 토큰 갱신 임계 시간 ()
*/
private Long tokenRefreshThresholdSeconds = 300L;
}
/**
* 모니터링 설정 내부 클래스
*/
@Getter
@Setter
public static class Monitoring {
/**
* 성능 로깅 사용 여부
*/
private Boolean performanceLoggingEnabled = true;
/**
* 느린 요청 임계값 (밀리초)
*/
private Long slowRequestThreshold = 3000L;
/**
* 메트릭 수집 사용 여부
*/
private Boolean metricsEnabled = true;
/**
* 상태 체크 주기 (밀리초)
*/
private Long healthCheckInterval = 30000L;
}
// === Computed Properties ===
/**
* 상품목록 API URL 조회
*
* @return 상품목록 API 전체 URL
*/
public String getProductListUrl() {
return baseUrl + "/api/v1/kos/product/list";
}
/**
* 가입상품 조회 API URL 조회
*
* @return 가입상품 조회 API 전체 URL
*/
public String getProductInquiryUrl() {
return baseUrl + "/api/v1/kos/product/inquiry";
}
/**
* 상품변경 API URL 조회
*
* @return 상품변경 API 전체 URL
*/
public String getProductChangeUrl() {
return baseUrl + "/api/v1/kos/product/change";
}
/**
* 헬스체크 API URL 조회
*
* @return 헬스체크 API 전체 URL
*/
public String getHealthCheckUrl() {
return baseUrl + "/health";
}
/**
* 전체 타임아웃 계산 (연결 + 읽기)
*
* @return 전체 타임아웃 (밀리초)
*/
public Integer getTotalTimeout() {
return connectTimeout + readTimeout;
}
/**
* 최대 재시도 시간 계산
*
* @return 최대 재시도 시간 (밀리초)
*/
public Long getMaxRetryDuration() {
return retryDelay * maxRetries;
}
// === Validation Methods ===
/**
* 설정 유효성 검증
*
* @return 유효한 설정인지 여부
*/
public boolean isValid() {
return baseUrl != null && !baseUrl.trim().isEmpty() &&
connectTimeout > 0 && readTimeout > 0 &&
maxRetries > 0 && retryDelay > 0;
}
/**
* Circuit Breaker 설정 유효성 검증
*
* @return 유효한 설정인지 여부
*/
public boolean isCircuitBreakerConfigValid() {
return circuitBreaker.failureRateThreshold >= 0.0f && circuitBreaker.failureRateThreshold <= 1.0f &&
circuitBreaker.slowCallRateThreshold >= 0.0f && circuitBreaker.slowCallRateThreshold <= 1.0f &&
circuitBreaker.slidingWindowSize > 0 &&
circuitBreaker.minimumNumberOfCalls > 0 &&
circuitBreaker.permittedNumberOfCallsInHalfOpenState > 0;
}
/**
* 인증 설정 유효성 검증
*
* @return 유효한 설정인지 여부
*/
public boolean isAuthenticationConfigValid() {
if (!authentication.enabled) {
return true;
}
return authentication.apiKey != null && !authentication.apiKey.trim().isEmpty() &&
authentication.secretKey != null && !authentication.secretKey.trim().isEmpty();
}
// === Utility Methods ===
/**
* 설정 정보 요약
*
* @return 설정 요약 문자열
*/
public String getConfigSummary() {
return String.format(
"KOS 설정 - URL: %s, 연결타임아웃: %dms, 읽기타임아웃: %dms, 재시도: %d회",
baseUrl, connectTimeout, readTimeout, maxRetries
);
}
/**
* 마스킹된 인증 정보 조회 (로깅용)
*
* @return 마스킹된 인증 정보
*/
public String getMaskedAuthInfo() {
if (!authentication.enabled || authentication.apiKey == null) {
return "인증 비활성화";
}
String maskedApiKey = authentication.apiKey.length() > 8 ?
authentication.apiKey.substring(0, 4) + "****" +
authentication.apiKey.substring(authentication.apiKey.length() - 4) :
"****";
return String.format("API키: %s, 토큰만료: %d초", maskedApiKey, authentication.tokenExpirationSeconds);
}
}

View File

@ -0,0 +1,50 @@
package com.unicorn.phonebill.product.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* RestTemplate 설정 클래스
*
* KOS 시스템 연동을 위한 HTTP 클라이언트 구성
* - Connection Pool 설정
* - Timeout 설정
* - 재시도 회로 차단기와 연동
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-09
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestTemplateConfig {
private final KosProperties kosProperties;
/**
* KOS 연동용 RestTemplate 생성
*
* @param builder RestTemplate 빌더
* @return 설정된 RestTemplate
*/
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
log.info("RestTemplate 빈 생성 - 연결 타임아웃: {}ms, 읽기 타임아웃: {}ms",
kosProperties.getConnectTimeout(), kosProperties.getReadTimeout());
return builder
// 타임아웃 설정
.setConnectTimeout(Duration.ofMillis(kosProperties.getConnectTimeout()))
.setReadTimeout(Duration.ofMillis(kosProperties.getReadTimeout()))
// RestTemplate 생성
.build();
}
}

View File

@ -1,11 +1,14 @@
package com.unicorn.phonebill.product.config;
import com.phonebill.common.security.JwtAuthenticationFilter;
import com.phonebill.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -16,133 +19,106 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* Spring Security 설정 클래스
*
* 주요 기능:
* - JWT 인증 필터 설정
* - CORS 설정
* - API 엔드포인트 보안 설정
* - 세션 비활성화 (Stateless)
* Spring Security 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins")
private String allowedOrigins;
public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler,
JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
/**
* Security Filter Chain 설정
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 (JWT 사용으로 불필요)
.csrf(AbstractHttpConfigurer::disable)
.csrf(csrf -> csrf.disable())
// CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 세션 비활성화 (Stateless)
// 세션 비활성화 (JWT 기반 Stateless)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 예외 처리 설정
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
// 권한 설정
.authorizeHttpRequests(authorize -> authorize
// Health Check 문서화 API는 인증 불필요
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/v3/api-docs/**", "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
.authorizeHttpRequests(authz -> authz
// Public endpoints (인증 불필요)
.requestMatchers(
"/actuator/health",
"/actuator/info",
"/actuator/prometheus",
"/v3/api-docs/**",
"/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/swagger-resources/**",
"/webjars/**"
).permitAll()
// OPTIONS 요청은 인증 불필요 (CORS Preflight)
// OPTIONS 요청은 모두 허용 (CORS Preflight)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 모든 API는 인증 필요
// Protected endpoints (인증 필요)
.requestMatchers("/products/**").authenticated()
// 나머지 요청은 모두 인증 필요
.anyRequest().authenticated())
// Actuator endpoints (관리용)
.requestMatchers("/actuator/**").hasRole("ADMIN")
// JWT 인증 필터 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// 나머지 모든 요청 인증 필요
.anyRequest().authenticated()
)
// JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// Exception 처리
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
})
);
return http.build();
}
/**
* CORS 설정
*/
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 Origin 설정
configuration.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:3000", // 개발환경 프론트엔드
"http://localhost:8080", // API Gateway
"https://*.mvno.com", // 운영환경
"https://*.mvno-dev.com" // 개발환경
));
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 HTTP 메서드
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
"X-User-ID",
"X-Customer-ID",
"X-Request-ID"
));
// 노출할 헤더
configuration.setExposedHeaders(Arrays.asList(
"Authorization",
"X-Request-ID",
"X-Total-Count"
));
// 자격 증명 허용
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 설정 (1시간)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* 비밀번호 암호화기
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -39,9 +39,9 @@ public class SwaggerConfig {
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8083")
.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() {

View File

@ -29,7 +29,6 @@ import java.time.LocalDate;
* 상품변경 서비스 REST API 컨트롤러
*
* 주요 기능:
* - 상품변경 메뉴 조회 (UFR-PROD-010)
* - 고객 상품 정보 조회 (UFR-PROD-020)
* - 상품변경 요청 사전체크 (UFR-PROD-030)
* - KOS 연동 상품변경 처리 (UFR-PROD-040)
@ -50,41 +49,12 @@ public class ProductController {
this.productService = productService;
}
/**
* 상품변경 메뉴 조회
* UFR-PROD-010 구현
*/
@GetMapping("/menu")
@Operation(summary = "상품변경 메뉴 조회",
description = "상품변경 메뉴 접근 시 필요한 기본 정보를 조회합니다")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "메뉴 조회 성공",
content = @Content(schema = @Schema(implementation = ProductMenuResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "403", description = "권한 없음",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<ProductMenuResponse> getProductMenu() {
String userId = getCurrentUserId();
logger.info("상품변경 메뉴 조회 요청: userId={}", userId);
try {
ProductMenuResponse response = productService.getProductMenu(userId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("상품변경 메뉴 조회 실패: userId={}", userId, e);
throw new RuntimeException("메뉴 조회 중 오류가 발생했습니다");
}
}
/**
* 고객 정보 조회
* UFR-PROD-020 구현
*/
@GetMapping("/customer/{lineNumber}")
@GetMapping("/customer")
@Operation(summary = "고객 정보 조회",
description = "특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다")
@ApiResponses({
@ -99,7 +69,7 @@ public class ProductController {
})
public ResponseEntity<CustomerInfoResponse> getCustomerInfo(
@Parameter(description = "고객 회선번호", example = "01012345678")
@PathVariable
@RequestParam("lineNumber")
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
String lineNumber) {
@ -130,20 +100,18 @@ public class ProductController {
})
public ResponseEntity<AvailableProductsResponse> getAvailableProducts(
@Parameter(description = "현재 상품코드 (필터링용)")
@RequestParam(required = false) String currentProductCode,
@Parameter(description = "사업자 코드")
@RequestParam(required = false) String operatorCode) {
@RequestParam(required = false) String currentProductCode) {
String userId = getCurrentUserId();
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, operatorCode={}, userId={}",
currentProductCode, operatorCode, userId);
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, userId={}",
currentProductCode, userId);
try {
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode, operatorCode);
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, operatorCode={}, userId={}",
currentProductCode, operatorCode, userId, e);
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, userId={}",
currentProductCode, userId, e);
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다");
}
}
@ -186,12 +154,10 @@ public class ProductController {
*/
@PostMapping("/change")
@Operation(summary = "상품변경 요청",
description = "실제 상품변경 처리를 요청합니다")
description = "실제 상품변경 처리를 요청합니다 (동기 처리)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "상품변경 처리 완료",
content = @Content(schema = @Schema(implementation = ProductChangeResponse.class))),
@ApiResponse(responseCode = "202", description = "상품변경 요청 접수 (비동기 처리)",
content = @Content(schema = @Schema(implementation = ProductChangeAsyncResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "사전체크 실패 또는 처리 불가 상태",
@ -201,63 +167,24 @@ public class ProductController {
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<?> requestProductChange(
@Valid @RequestBody ProductChangeRequest request,
@Parameter(description = "처리 모드 (sync: 동기, async: 비동기)")
@RequestParam(defaultValue = "sync") String mode) {
public ResponseEntity<ProductChangeResponse> requestProductChange(
@Valid @RequestBody ProductChangeRequest request) {
String userId = getCurrentUserId();
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, mode={}, userId={}",
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, userId={}",
request.getLineNumber(), request.getCurrentProductCode(),
request.getTargetProductCode(), mode, userId);
request.getTargetProductCode(), userId);
try {
if ("async".equalsIgnoreCase(mode)) {
// 비동기 처리
ProductChangeAsyncResponse response = productService.requestProductChangeAsync(request, userId);
return ResponseEntity.accepted().body(response);
} else {
// 동기 처리 (기본값)
// 동기 처리
ProductChangeResponse response = productService.requestProductChange(request, userId);
return ResponseEntity.ok(response);
}
} catch (Exception e) {
logger.error("상품변경 요청 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
throw new RuntimeException("상품변경 처리 중 오류가 발생했습니다");
}
}
/**
* 상품변경 결과 조회
*/
@GetMapping("/change/{requestId}")
@Operation(summary = "상품변경 결과 조회",
description = "특정 요청ID의 상품변경 처리 결과를 조회합니다")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "처리 결과 조회 성공",
content = @Content(schema = @Schema(implementation = ProductChangeResultResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "요청 정보를 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<ProductChangeResultResponse> getProductChangeResult(
@Parameter(description = "상품변경 요청 ID")
@PathVariable String requestId) {
String userId = getCurrentUserId();
logger.info("상품변경 결과 조회 요청: requestId={}, userId={}", requestId, userId);
try {
ProductChangeResultResponse response = productService.getProductChangeResult(requestId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("상품변경 결과 조회 실패: requestId={}, userId={}", requestId, userId, e);
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다");
}
}
/**
* 상품변경 이력 조회

View File

@ -1,5 +1,8 @@
package com.unicorn.phonebill.product.domain;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Getter;
@ -10,6 +13,7 @@ import java.math.BigDecimal;
*/
@Getter
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public class Product {
private final String productCode;
@ -22,6 +26,30 @@ public class Product {
private final String operatorCode;
private final String description;
/**
* Jackson 역직렬화를 위한 생성자
*/
@JsonCreator
public Product(@JsonProperty("productCode") String productCode,
@JsonProperty("productName") String productName,
@JsonProperty("monthlyFee") BigDecimal monthlyFee,
@JsonProperty("dataAllowance") String dataAllowance,
@JsonProperty("voiceAllowance") String voiceAllowance,
@JsonProperty("smsAllowance") String smsAllowance,
@JsonProperty("status") ProductStatus status,
@JsonProperty("operatorCode") String operatorCode,
@JsonProperty("description") String description) {
this.productCode = productCode;
this.productName = productName;
this.monthlyFee = monthlyFee;
this.dataAllowance = dataAllowance;
this.voiceAllowance = voiceAllowance;
this.smsAllowance = smsAllowance;
this.status = status;
this.operatorCode = operatorCode;
this.description = description;
}
/**
* 다른 상품으로 변경 가능한지 확인
*/

View File

@ -1,25 +1,167 @@
package com.unicorn.phonebill.product.domain;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 상품변경 처리 결과 도메인 모델
* Updated for compilation fix
*/
@Getter
@Builder
public class ProductChangeResult {
private final String requestId;
private final boolean success;
private final String resultCode;
private final String resultMessage;
private final String failureReason;
private final String kosOrderNumber;
private final String effectiveDate;
private final Product changedProduct;
private final LocalDateTime processedAt;
private final Map<String, Object> additionalData;
private final Map<String, Object> kosResponseData;
// Builder 패턴용 생성자
private ProductChangeResult(Builder builder) {
this.requestId = builder.requestId;
this.success = builder.success;
this.resultCode = builder.resultCode;
this.resultMessage = builder.resultMessage;
this.failureReason = builder.failureReason;
this.kosOrderNumber = builder.kosOrderNumber;
this.effectiveDate = builder.effectiveDate;
this.changedProduct = builder.changedProduct;
this.processedAt = builder.processedAt;
this.additionalData = builder.additionalData;
this.kosResponseData = builder.kosResponseData;
}
// Getter 메서드들
public String getRequestId() {
return requestId;
}
public boolean isSuccess() {
return success;
}
public String getResultCode() {
return resultCode;
}
public String getResultMessage() {
return resultMessage;
}
public String getFailureReason() {
return failureReason;
}
public String getKosOrderNumber() {
return kosOrderNumber;
}
public String getEffectiveDate() {
return effectiveDate;
}
public Product getChangedProduct() {
return changedProduct;
}
public LocalDateTime getProcessedAt() {
return processedAt;
}
public Map<String, Object> getAdditionalData() {
return additionalData;
}
public Map<String, Object> getKosResponseData() {
return kosResponseData;
}
public boolean isFailure() {
return !success;
}
// Builder 패턴
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String requestId;
private boolean success;
private String resultCode;
private String resultMessage;
private String failureReason;
private String kosOrderNumber;
private String effectiveDate;
private Product changedProduct;
private LocalDateTime processedAt;
private Map<String, Object> additionalData;
private Map<String, Object> kosResponseData;
public Builder requestId(String requestId) {
this.requestId = requestId;
return this;
}
public Builder success(boolean success) {
this.success = success;
return this;
}
public Builder resultCode(String resultCode) {
this.resultCode = resultCode;
return this;
}
public Builder resultMessage(String resultMessage) {
this.resultMessage = resultMessage;
return this;
}
public Builder failureReason(String failureReason) {
this.failureReason = failureReason;
return this;
}
public Builder kosOrderNumber(String kosOrderNumber) {
this.kosOrderNumber = kosOrderNumber;
return this;
}
public Builder effectiveDate(String effectiveDate) {
this.effectiveDate = effectiveDate;
return this;
}
public Builder changedProduct(Product changedProduct) {
this.changedProduct = changedProduct;
return this;
}
public Builder processedAt(LocalDateTime processedAt) {
this.processedAt = processedAt;
return this;
}
public Builder additionalData(Map<String, Object> additionalData) {
this.additionalData = additionalData;
return this;
}
public Builder kosResponseData(Map<String, Object> kosResponseData) {
this.kosResponseData = kosResponseData;
return this;
}
public ProductChangeResult build() {
return new ProductChangeResult(this);
}
}
/**
* 성공 결과 생성 (팩토리 메소드)
@ -52,6 +194,7 @@ public class ProductChangeResult {
.success(false)
.resultCode(resultCode)
.resultMessage(resultMessage)
.failureReason(resultMessage)
.processedAt(LocalDateTime.now())
.build();
}
@ -70,22 +213,9 @@ public class ProductChangeResult {
.success(false)
.resultCode(resultCode)
.resultMessage(resultMessage)
.failureReason(resultMessage)
.additionalData(additionalData)
.processedAt(LocalDateTime.now())
.build();
}
/**
* 결과가 성공인지 확인
*/
public boolean isSuccess() {
return success;
}
/**
* 결과가 실패인지 확인
*/
public boolean isFailure() {
return !success;
}
}

View File

@ -1,6 +1,6 @@
package com.unicorn.phonebill.product.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -8,8 +8,6 @@ import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 상품변경 요청 DTO
@ -21,19 +19,16 @@ import java.time.LocalDateTime;
@AllArgsConstructor
public class ProductChangeRequest {
@JsonProperty("lineNumber")
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
private String lineNumber;
@JsonProperty("currentProductCode")
@NotBlank(message = "현재 상품 코드는 필수입니다")
private String currentProductCode;
@JsonProperty("targetProductCode")
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
private String targetProductCode;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime requestDate;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate changeEffectiveDate;
}

View File

@ -1,72 +0,0 @@
package com.unicorn.phonebill.product.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
/**
* 상품변경 메뉴 조회 응답 DTO
*/
@Getter
@Builder
@Schema(description = "상품변경 메뉴 조회 응답")
public class ProductMenuResponse {
@Schema(description = "응답 성공 여부", example = "true")
private final boolean success;
@Schema(description = "메뉴 데이터")
private final MenuData data;
@Schema(description = "응답 시간")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private final LocalDateTime timestamp;
@Getter
@Builder
@Schema(description = "메뉴 데이터")
public static class MenuData {
@Schema(description = "고객 ID", example = "CUST001")
private final String customerId;
@Schema(description = "회선번호", example = "01012345678")
private final String lineNumber;
@Schema(description = "현재 상품 정보")
private final ProductInfoDto currentProduct;
@Schema(description = "메뉴 항목 목록")
private final List<MenuItem> menuItems;
}
@Getter
@Builder
@Schema(description = "메뉴 항목")
public static class MenuItem {
@Schema(description = "메뉴 ID", example = "MENU001")
private final String menuId;
@Schema(description = "메뉴명", example = "상품변경")
private final String menuName;
@Schema(description = "사용 가능 여부", example = "true")
private final boolean available;
@Schema(description = "메뉴 설명", example = "현재 이용 중인 상품을 다른 상품으로 변경합니다")
private final String description;
}
public static ProductMenuResponse success(MenuData data) {
return ProductMenuResponse.builder()
.success(true)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
}

View File

@ -0,0 +1,84 @@
package com.unicorn.phonebill.product.dto.kos;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* KOS 공통 응답 DTO
*/
@Data
@Builder
@Schema(description = "KOS 공통 응답")
public class KosCommonResponse<T> {
@Schema(description = "성공 여부", example = "true")
private Boolean success;
@Schema(description = "처리 결과 코드", example = "0000")
private String resultCode;
@Schema(description = "처리 결과 메시지", example = "정상 처리되었습니다")
private String resultMessage;
@Schema(description = "응답 데이터")
private T data;
@Schema(description = "처리 시간", example = "2025-01-08T14:30:00")
private LocalDateTime timestamp;
@Schema(description = "요청 추적 ID", example = "TRACE_20250108_001")
private String traceId;
/**
* 성공 응답 생성
*/
public static <T> KosCommonResponse<T> success(T data) {
return KosCommonResponse.<T>builder()
.success(true)
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 성공 응답 생성 (메시지 포함)
*/
public static <T> KosCommonResponse<T> success(T data, String message) {
return KosCommonResponse.<T>builder()
.success(true)
.resultCode("0000")
.resultMessage(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 실패 응답 생성
*/
public static <T> KosCommonResponse<T> failure(String errorCode, String errorMessage) {
return KosCommonResponse.<T>builder()
.success(false)
.resultCode(errorCode)
.resultMessage(errorMessage)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 시스템 오류 응답 생성
*/
public static <T> KosCommonResponse<T> systemError() {
return KosCommonResponse.<T>builder()
.success(false)
.resultCode("9999")
.resultMessage("시스템 오류가 발생했습니다")
.timestamp(LocalDateTime.now())
.build();
}
}

View File

@ -0,0 +1,48 @@
package com.unicorn.phonebill.product.dto.kos;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* KOS 상품 정보 DTO
* kos-mock 서비스의 KosProductInfo와 동일한 구조
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KosProductInfo {
@JsonProperty("product_code")
private String productCode;
@JsonProperty("product_name")
private String productName;
@JsonProperty("product_type")
private String productType;
@JsonProperty("monthly_fee")
private Integer monthlyFee;
@JsonProperty("data_allowance")
private Integer dataAllowance;
@JsonProperty("voice_allowance")
private Integer voiceAllowance;
@JsonProperty("sms_allowance")
private Integer smsAllowance;
@JsonProperty("network_type")
private String networkType;
@JsonProperty("status")
private String status;
@JsonProperty("description")
private String description;
}

View File

@ -0,0 +1,26 @@
package com.unicorn.phonebill.product.dto.kos;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* KOS 가입상품 조회 요청 DTO
*/
@Data
@Schema(description = "KOS 가입상품 조회 요청")
public class KosProductInquiryRequest {
@Schema(description = "회선번호", example = "01012345679")
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
@JsonProperty("lineNumber")
private String lineNumber;
@Schema(description = "요청 ID", example = "REQ_20250108_001")
@NotBlank(message = "요청 ID는 필수입니다")
@JsonProperty("requestId")
private String requestId;
}

View File

@ -0,0 +1,122 @@
package com.unicorn.phonebill.product.dto.kos;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* KOS 가입상품 조회 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "KOS 가입상품 조회 응답")
public class KosProductInquiryResponse {
@Schema(description = "요청 ID", example = "REQ_20250108_001")
@JsonProperty("requestId")
private String requestId;
@Schema(description = "처리 상태", example = "SUCCESS")
@JsonProperty("procStatus")
private String procStatus;
@Schema(description = "결과 코드", example = "0000")
@JsonProperty("resultCode")
private String resultCode;
@Schema(description = "결과 메시지", example = "정상 처리되었습니다")
@JsonProperty("resultMessage")
private String resultMessage;
@Schema(description = "상품 정보")
@JsonProperty("productInfo")
private ProductInfo productInfo;
@Schema(description = "고객 정보")
@JsonProperty("customerInfo")
private CustomerInfo customerInfo;
/**
* 상품 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "상품 정보")
public static class ProductInfo {
@Schema(description = "회선번호", example = "01012345679")
@JsonProperty("lineNumber")
private String lineNumber;
@Schema(description = "현재 상품 코드", example = "KT_5G_BASIC")
@JsonProperty("currentProductCode")
private String currentProductCode;
@Schema(description = "현재 상품명", example = "KT 5G 베이직")
@JsonProperty("currentProductName")
private String currentProductName;
@Schema(description = "월 요금", example = "45000")
@JsonProperty("monthlyFee")
private BigDecimal monthlyFee;
@Schema(description = "데이터 허용량", example = "무제한")
@JsonProperty("dataAllowance")
private String dataAllowance;
@Schema(description = "음성 허용량", example = "무제한")
@JsonProperty("voiceAllowance")
private String voiceAllowance;
@Schema(description = "SMS 허용량", example = "무제한")
@JsonProperty("smsAllowance")
private String smsAllowance;
@Schema(description = "상품 상태", example = "ACTIVE")
@JsonProperty("productStatus")
private String productStatus;
@Schema(description = "계약일")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
@JsonProperty("contractDate")
private LocalDateTime contractDate;
}
/**
* 고객 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "고객 정보")
public static class CustomerInfo {
@Schema(description = "고객명", example = "홍길동")
@JsonProperty("customerName")
private String customerName;
@Schema(description = "고객 ID", example = "CUST_001")
@JsonProperty("customerId")
private String customerId;
@Schema(description = "통신사 코드", example = "KT")
@JsonProperty("operatorCode")
private String operatorCode;
@Schema(description = "회선 상태", example = "ACTIVE")
@JsonProperty("lineStatus")
private String lineStatus;
}
}

View File

@ -0,0 +1,26 @@
package com.unicorn.phonebill.product.dto.kos;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* KOS 상품 목록 응답 DTO
* kos-mock 서비스의 KosProductListResponse와 동일한 구조
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KosProductListResponse {
@JsonProperty("total_count")
private Integer totalCount;
@JsonProperty("products")
private List<KosProductInfo> products;
}

View File

@ -1,45 +1,35 @@
package com.unicorn.phonebill.product.exception;
/**
* Circuit Breaker Open 상태 예외
* Circuit Breaker 관련 예외
*/
public class CircuitBreakerException extends BusinessException {
private static final long serialVersionUID = 1L;
private final String serviceName;
private final String circuitBreakerState;
public CircuitBreakerException(String errorCode, String message, String serviceName, String circuitBreakerState) {
public CircuitBreakerException(String errorCode, String message, String serviceName) {
super(errorCode, message);
this.serviceName = serviceName;
this.circuitBreakerState = circuitBreakerState;
}
public CircuitBreakerException(String errorCode, String message, String serviceName, Throwable cause) {
super(errorCode, message, cause);
this.serviceName = serviceName;
}
public String getServiceName() {
return serviceName;
}
public String getCircuitBreakerState() {
return circuitBreakerState;
}
// 자주 사용되는 Circuit Breaker 예외 팩토리 메소드들
public static CircuitBreakerException circuitOpen(String serviceName) {
public static CircuitBreakerException circuitBreakerOpen(String serviceName) {
return new CircuitBreakerException("CIRCUIT_BREAKER_OPEN",
"서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
serviceName, "OPEN");
"Circuit Breaker가 OPEN 상태입니다. 잠시 후 다시 시도해주세요", serviceName);
}
public static CircuitBreakerException halfOpenFailed(String serviceName) {
return new CircuitBreakerException("CIRCUIT_BREAKER_HALF_OPEN_FAILED",
"서비스 복구 시도 중 실패했습니다",
serviceName, "HALF_OPEN");
}
public static CircuitBreakerException callNotPermitted(String serviceName) {
return new CircuitBreakerException("CIRCUIT_BREAKER_CALL_NOT_PERMITTED",
"서비스 호출이 차단되었습니다",
serviceName, "OPEN");
public static CircuitBreakerException circuitBreakerTimeout(String serviceName) {
return new CircuitBreakerException("CIRCUIT_BREAKER_TIMEOUT",
"Circuit Breaker 타임아웃이 발생했습니다", serviceName);
}
}

View File

@ -43,4 +43,19 @@ public class KosConnectionException extends BusinessException {
return new KosConnectionException("KOS_AUTH_FAILED",
"KOS 시스템 인증에 실패했습니다", serviceName);
}
public static KosConnectionException apiError(String serviceName, String statusCode, String details) {
return new KosConnectionException("KOS_API_ERROR",
"KOS API 호출 오류 [" + statusCode + "]: " + details, serviceName);
}
public static KosConnectionException networkError(String serviceName, Throwable cause) {
return new KosConnectionException("KOS_NETWORK_ERROR",
"KOS 네트워크 연결 오류", serviceName, cause);
}
public static KosConnectionException dataConversionError(String serviceName, String dataType, Throwable cause) {
return new KosConnectionException("KOS_DATA_CONVERSION_ERROR",
"KOS 데이터 변환 오류: " + dataType, serviceName, cause);
}
}

View File

@ -33,8 +33,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
ProductChangeHistoryEntity entity = ProductChangeHistoryEntity.fromDomain(history);
ProductChangeHistoryEntity savedEntity = jpaRepository.save(entity);
log.info("상품변경 이력 저장 완료: id={}, requestId={}",
savedEntity.getId(), savedEntity.getRequestId());
log.info("상품변경 이력 저장 완료: id={}", savedEntity.getId());
return savedEntity.toDomain();
}
@ -43,7 +42,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
public Optional<ProductChangeHistory> findByRequestId(String requestId) {
log.debug("요청 ID로 이력 조회: requestId={}", requestId);
return jpaRepository.findByRequestId(requestId)
return jpaRepository.findById(requestId)
.map(ProductChangeHistoryEntity::toDomain);
}
@ -160,14 +159,14 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
public boolean existsByRequestId(String requestId) {
log.debug("요청 ID 존재 여부 확인: requestId={}", requestId);
return jpaRepository.existsByRequestId(requestId);
return jpaRepository.existsById(requestId);
}
@Override
public void deleteById(Long id) {
log.info("상품변경 이력 삭제: id={}", id);
jpaRepository.deleteById(id);
jpaRepository.deleteById(id.toString());
}
@Override

View File

@ -2,14 +2,18 @@ package com.unicorn.phonebill.product.repository;
import com.unicorn.phonebill.product.domain.Product;
import com.unicorn.phonebill.product.domain.ProductStatus;
import com.unicorn.phonebill.product.service.KosClientService;
import com.unicorn.phonebill.product.dto.kos.KosProductInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Redis 캐시를 활용한 상품 Repository 구현체
@ -21,6 +25,7 @@ public class ProductRepositoryImpl implements ProductRepository {
private static final Logger logger = LoggerFactory.getLogger(ProductRepositoryImpl.class);
private final RedisTemplate<String, Object> redisTemplate;
private final KosClientService kosClientService;
// 캐시 접두사
private static final String PRODUCT_CACHE_PREFIX = "product:";
@ -32,8 +37,10 @@ public class ProductRepositoryImpl implements ProductRepository {
private static final long PRODUCT_CACHE_TTL = 3600; // 1시간
private static final long PRODUCTS_CACHE_TTL = 1800; // 30분
public ProductRepositoryImpl(RedisTemplate<String, Object> redisTemplate) {
public ProductRepositoryImpl(RedisTemplate<String, Object> redisTemplate,
KosClientService kosClientService) {
this.redisTemplate = redisTemplate;
this.kosClientService = kosClientService;
}
@Override
@ -76,14 +83,17 @@ public class ProductRepositoryImpl implements ProductRepository {
logger.debug("Cache miss for available products");
incrementCacheMisses();
// TODO: KOS API 호출로 실제 데이터 조회
// 현재는 테스트 데이터 반환
List<Product> products = createTestAvailableProducts();
// KOS API 호출로 실제 데이터 조회
List<KosProductInfo> kosProducts = kosClientService.getProductListFromKos();
List<Product> products = convertKosProductsToProducts(kosProducts);
cacheProducts(products, AVAILABLE_PRODUCTS_KEY);
logger.info("KOS에서 조회한 상품 개수: {}", products.size());
return products;
} catch (Exception e) {
logger.error("Error finding available products", e);
logger.error("Error finding available products from KOS", e);
// KOS 연동 실패 목록 반환 (fallback은 KosClientService에서 처리)
return List.of();
}
}
@ -220,7 +230,94 @@ public class ProductRepositoryImpl implements ProductRepository {
}
}
// 테스트 데이터 생성 메서드들 (실제 운영에서는 KOS API 호출로 대체)
/**
* KOS 상품 정보를 Product 도메인으로 변환
*/
private List<Product> convertKosProductsToProducts(List<KosProductInfo> kosProducts) {
return kosProducts.stream()
.filter(kosProduct -> "ACTIVE".equalsIgnoreCase(kosProduct.getStatus()))
.map(this::convertKosProductToProduct)
.collect(Collectors.toList());
}
/**
* KosProductInfo를 Product로 변환
*/
private Product convertKosProductToProduct(KosProductInfo kosProduct) {
return Product.builder()
.productCode(kosProduct.getProductCode())
.productName(kosProduct.getProductName())
.monthlyFee(kosProduct.getMonthlyFee() != null ?
new BigDecimal(kosProduct.getMonthlyFee()) : BigDecimal.ZERO)
.dataAllowance(formatDataAllowance(kosProduct.getDataAllowance()))
.voiceAllowance(formatVoiceAllowance(kosProduct.getVoiceAllowance()))
.smsAllowance(formatSmsAllowance(kosProduct.getSmsAllowance()))
.status(convertKosStatusToProductStatus(kosProduct.getStatus()))
.operatorCode(determineOperatorCode(kosProduct))
.description(kosProduct.getDescription())
.build();
}
/**
* 데이터 허용량 포맷팅
*/
private String formatDataAllowance(Integer dataAllowanceGB) {
if (dataAllowanceGB == null || dataAllowanceGB == 0) {
return "0GB";
}
if (dataAllowanceGB >= 1000) {
return "무제한";
}
return dataAllowanceGB + "GB";
}
/**
* 음성통화 허용량 포맷팅
*/
private String formatVoiceAllowance(Integer voiceAllowanceMin) {
if (voiceAllowanceMin == null || voiceAllowanceMin == 0) {
return "0분";
}
if (voiceAllowanceMin >= 10000) {
return "무제한";
}
return voiceAllowanceMin + "";
}
/**
* SMS 허용량 포맷팅
*/
private String formatSmsAllowance(Integer smsAllowanceCount) {
if (smsAllowanceCount == null || smsAllowanceCount == 0) {
return "0건";
}
if (smsAllowanceCount >= 10000) {
return "무제한";
}
return smsAllowanceCount + "";
}
/**
* KOS 상태를 Product 상태로 변환
*/
private ProductStatus convertKosStatusToProductStatus(String kosStatus) {
if ("ACTIVE".equalsIgnoreCase(kosStatus)) {
return ProductStatus.ACTIVE;
}
return ProductStatus.DISCONTINUED;
}
/**
* 사업자 코드 결정 (KOS에서는 별도로 제공하지 않으므로 상품코드나 기타 정보로 추정)
*/
private String determineOperatorCode(KosProductInfo kosProduct) {
// 실제 운영에서는 KOS API에서 사업자 정보를 제공하거나
// 상품코드 패턴으로 판단해야
// 현재는 기본값으로 "KOS" 사용
return "KOS";
}
// 테스트 데이터 생성 메서드들 (KOS 연동 실패 fallback용으로 유지)
private Optional<Product> createTestProduct(String productCode) {
Product product = Product.builder()
.productCode(productCode)

View File

@ -7,15 +7,12 @@ import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
/**
* 상품변경 이력 엔티티
* 모든 상품변경 요청 처리 이력을 관리
* 상품변경 이력 엔티티 (실제 DB 스키마에 맞춘 버전)
*/
@Entity
@Table(name = "pc_product_change_history")
@ -24,105 +21,94 @@ import java.util.Map;
public class ProductChangeHistoryEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "request_id", nullable = false, unique = true, length = 50)
private String requestId;
@Column(name = "id", nullable = false, unique = true, length = 100)
private String id;
@Column(name = "line_number", nullable = false, length = 20)
private String lineNumber;
@Column(name = "customer_id", nullable = false, length = 50)
@Column(name = "customer_id", nullable = false, length = 100)
private String customerId;
@Column(name = "current_product_code", nullable = false, length = 20)
@Column(name = "old_product_code", length = 50)
private String currentProductCode;
@Column(name = "target_product_code", nullable = false, length = 20)
@Column(name = "new_product_code", nullable = false, length = 50)
private String targetProductCode;
@Enumerated(EnumType.STRING)
@Column(name = "process_status", nullable = false, length = 20)
@Column(name = "change_status", length = 20)
private ProcessStatus processStatus;
@Column(name = "validation_result", columnDefinition = "TEXT")
private String validationResult;
@Column(name = "change_reason", length = 255)
private String changeReason;
@Column(name = "process_message", columnDefinition = "TEXT")
private String processMessage;
@Column(name = "change_method", length = 50)
private String changeMethod;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "kos_request_data", columnDefinition = "jsonb")
private Map<String, Object> kosRequestData;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "kos_response_data", columnDefinition = "jsonb")
private Map<String, Object> kosResponseData;
@Column(name = "requested_at", nullable = false)
@Column(name = "request_time", nullable = false)
private LocalDateTime requestedAt;
@Column(name = "validated_at")
private LocalDateTime validatedAt;
@Column(name = "approval_time")
private LocalDateTime approvalTime;
@Column(name = "processed_at")
private LocalDateTime processedAt;
@Column(name = "completion_time")
private LocalDateTime completionTime;
@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
@Column(name = "approver_id", length = 50)
private String approverId;
@Column(name = "processor_id", length = 50)
private String processorId;
@Column(name = "kos_request_id", length = 100)
private String kosRequestId;
@Column(name = "kos_response_code", length = 20)
private String kosResponseCode;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "retry_count")
private Integer retryCount;
@Builder
public ProductChangeHistoryEntity(
String requestId,
String id,
String lineNumber,
String customerId,
String currentProductCode,
String targetProductCode,
ProcessStatus processStatus,
String validationResult,
String processMessage,
Map<String, Object> kosRequestData,
Map<String, Object> kosResponseData,
LocalDateTime requestedAt,
LocalDateTime validatedAt,
LocalDateTime processedAt) {
this.requestId = requestId;
String changeReason,
String changeMethod,
LocalDateTime requestedAt) {
this.id = id;
this.lineNumber = lineNumber;
this.customerId = customerId;
this.currentProductCode = currentProductCode;
this.targetProductCode = targetProductCode;
this.processStatus = processStatus != null ? processStatus : ProcessStatus.REQUESTED;
this.validationResult = validationResult;
this.processMessage = processMessage;
this.kosRequestData = kosRequestData;
this.kosResponseData = kosResponseData;
this.changeReason = changeReason;
this.changeMethod = changeMethod != null ? changeMethod : "API";
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
this.validatedAt = validatedAt;
this.processedAt = processedAt;
this.retryCount = 0;
}
/**
* 도메인 모델로 변환
* 도메인 모델로 변환 (간소화)
*/
public ProductChangeHistory toDomain() {
return ProductChangeHistory.builder()
.id(this.id)
.requestId(this.requestId)
.id(null) // Long type을 위해 null 처리
.requestId(this.id)
.lineNumber(this.lineNumber)
.customerId(this.customerId)
.currentProductCode(this.currentProductCode)
.targetProductCode(this.targetProductCode)
.processStatus(this.processStatus)
.validationResult(this.validationResult)
.processMessage(this.processMessage)
.kosRequestData(this.kosRequestData)
.kosResponseData(this.kosResponseData)
.requestedAt(this.requestedAt)
.validatedAt(this.validatedAt)
.processedAt(this.processedAt)
.version(this.version)
.build();
}
@ -131,30 +117,22 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
*/
public static ProductChangeHistoryEntity fromDomain(ProductChangeHistory domain) {
return ProductChangeHistoryEntity.builder()
.requestId(domain.getRequestId())
.id(UUID.randomUUID().toString()) // 새로운 UUID 생성
.lineNumber(domain.getLineNumber())
.customerId(domain.getCustomerId())
.currentProductCode(domain.getCurrentProductCode())
.targetProductCode(domain.getTargetProductCode())
.processStatus(domain.getProcessStatus())
.validationResult(domain.getValidationResult())
.processMessage(domain.getProcessMessage())
.kosRequestData(domain.getKosRequestData())
.kosResponseData(domain.getKosResponseData())
.requestedAt(domain.getRequestedAt())
.validatedAt(domain.getValidatedAt())
.processedAt(domain.getProcessedAt())
.build();
}
/**
* 상태를 완료로 변경
*/
public void markAsCompleted(String message, Map<String, Object> kosResponseData) {
public void markAsCompleted(String message) {
this.processStatus = ProcessStatus.COMPLETED;
this.processMessage = message;
this.kosResponseData = kosResponseData;
this.processedAt = LocalDateTime.now();
this.completionTime = LocalDateTime.now();
}
/**
@ -162,37 +140,14 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
*/
public void markAsFailed(String message) {
this.processStatus = ProcessStatus.FAILED;
this.processMessage = message;
this.processedAt = LocalDateTime.now();
this.errorMessage = message;
this.completionTime = LocalDateTime.now();
}
/**
* 검증 완료로 상태 변경
*/
public void markAsValidated(String validationResult) {
this.processStatus = ProcessStatus.VALIDATED;
this.validationResult = validationResult;
this.validatedAt = LocalDateTime.now();
}
/**
* 처리 중으로 상태 변경
* 상태를 처리중으로 변경
*/
public void markAsProcessing() {
this.processStatus = ProcessStatus.PROCESSING;
}
/**
* KOS 요청 데이터 설정
*/
public void setKosRequestData(Map<String, Object> kosRequestData) {
this.kosRequestData = kosRequestData;
}
/**
* 처리 메시지 업데이트
*/
public void updateProcessMessage(String message) {
this.processMessage = message;
}
}

View File

@ -17,12 +17,12 @@ import java.util.Optional;
* 상품변경 이력 JPA Repository
*/
@Repository
public interface ProductChangeHistoryJpaRepository extends JpaRepository<ProductChangeHistoryEntity, Long> {
public interface ProductChangeHistoryJpaRepository extends JpaRepository<ProductChangeHistoryEntity, String> {
/**
* 요청 ID로 이력 조회
* 요청 ID로 이력 조회 (id 필드 사용)
*/
Optional<ProductChangeHistoryEntity> findByRequestId(String requestId);
Optional<ProductChangeHistoryEntity> findById(String id);
/**
* 회선번호로 이력 조회 (최신순)
@ -82,7 +82,7 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
* 처리 중인 요청 조회 (타임아웃 체크용)
*/
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
"WHERE h.processStatus IN ('PROCESSING', 'VALIDATED') " +
"WHERE h.processStatus IN (com.unicorn.phonebill.product.domain.ProcessStatus.PROCESSING, com.unicorn.phonebill.product.domain.ProcessStatus.VALIDATED) " +
"AND h.requestedAt < :timeoutThreshold " +
"ORDER BY h.requestedAt ASC")
List<ProductChangeHistoryEntity> findProcessingRequestsOlderThan(
@ -93,8 +93,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
*/
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
"WHERE h.lineNumber = :lineNumber " +
"AND h.processStatus = 'COMPLETED' " +
"ORDER BY h.processedAt DESC")
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
"ORDER BY h.completionTime DESC")
Page<ProductChangeHistoryEntity> findLatestSuccessfulChangeByLineNumber(
@Param("lineNumber") String lineNumber,
Pageable pageable);
@ -115,8 +115,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
"WHERE h.currentProductCode = :currentProductCode " +
"AND h.targetProductCode = :targetProductCode " +
"AND h.processStatus = 'COMPLETED' " +
"AND h.processedAt >= :fromDate")
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
"AND h.completionTime >= :fromDate")
long countSuccessfulChangesByProductCodesSince(
@Param("currentProductCode") String currentProductCode,
@Param("targetProductCode") String targetProductCode,
@ -127,11 +127,11 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
*/
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
"WHERE h.lineNumber = :lineNumber " +
"AND h.processStatus IN ('PROCESSING', 'VALIDATED')")
"AND h.processStatus IN (com.unicorn.phonebill.product.domain.ProcessStatus.PROCESSING, com.unicorn.phonebill.product.domain.ProcessStatus.VALIDATED)")
long countInProgressRequestsByLineNumber(@Param("lineNumber") String lineNumber);
/**
* 요청 ID 존재 여부 확인
* 요청 ID 존재 여부 확인 (id 필드 사용)
*/
boolean existsByRequestId(String requestId);
boolean existsById(String id);
}

View File

@ -0,0 +1,400 @@
package com.unicorn.phonebill.product.service;
import com.unicorn.phonebill.product.config.KosProperties;
import com.unicorn.phonebill.product.exception.CircuitBreakerException;
import com.unicorn.phonebill.product.exception.KosConnectionException;
import com.unicorn.phonebill.product.dto.kos.KosCommonResponse;
import com.unicorn.phonebill.product.dto.kos.KosProductInfo;
import com.unicorn.phonebill.product.dto.kos.KosProductListResponse;
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryRequest;
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryResponse;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* KOS 시스템 연동 클라이언트 서비스 (상품 관리)
*
* 통신사 백엔드 시스템(KOS)과의 상품 관련 연동을 담당하는 서비스
* - Circuit Breaker 패턴으로 외부 시스템 장애 격리
* - Retry 패턴으로 일시적 네트워크 오류 극복
* - Timeout 설정으로 응답 지연 방지
* - 데이터 변환 오류 처리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KosClientService {
private final RestTemplate restTemplate;
private final KosProperties kosProperties;
/**
* KOS 시스템에서 전체 상품 목록 조회
*
* @return KOS 상품 목록 응답
*/
@CircuitBreaker(name = "kos-product-list", fallbackMethod = "getProductListFallback")
@Retry(name = "kos-product-list")
public List<KosProductInfo> getProductListFromKos() {
log.info("KOS 상품 목록 조회 요청");
try {
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Service-Name", "MVNO-PRODUCT-SERVICE");
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// KOS Mock API 호출
String kosUrl = kosProperties.getProductListUrl();
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.GET, requestEntity,
new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() {}
);
Map<String, Object> response = responseEntity.getBody();
if (response == null) {
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
String.valueOf(responseEntity.getStatusCode().value()), "응답 데이터가 없습니다");
}
// KosCommonResponse의 data 부분에서 KosProductListResponse 추출
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data == null) {
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
"NO_DATA", "응답에서 data를 찾을 수 없습니다");
}
// 상품 목록 추출 변환
List<KosProductInfo> productList = convertToKosProductInfoList(data);
log.info("KOS 상품 목록 조회 성공 - 상품 개수: {}", productList.size());
return productList;
} catch (HttpClientErrorException e) {
log.error("KOS API 클라이언트 오류 - 상태: {}, 응답: {}",
e.getStatusCode(), e.getResponseBodyAsString());
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
} catch (HttpServerErrorException e) {
log.error("KOS API 서버 오류 - 상태: {}, 응답: {}",
e.getStatusCode(), e.getResponseBodyAsString());
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
} catch (ResourceAccessException e) {
log.error("KOS 네트워크 연결 오류 - 오류: {}", e.getMessage());
throw KosConnectionException.networkError("KOS-PRODUCT-LIST", e);
} catch (Exception e) {
log.error("KOS 연동 중 예상치 못한 오류 - 오류: {}", e.getMessage(), e);
throw new KosConnectionException("KOS-PRODUCT-LIST",
"KOS 시스템 연동 중 오류가 발생했습니다", "KOS-PRODUCT-LIST", e);
}
}
/**
* KOS 시스템에서 상품 변경 처리
*
* @param lineNumber 회선번호
* @param currentProductCode 현재 상품코드
* @param targetProductCode 변경할 상품코드
* @return 상품변경 결과
*/
@CircuitBreaker(name = "kos-product-change", fallbackMethod = "changeProductFallback")
@Retry(name = "kos-product-change")
public Map<String, Object> changeProductInKos(String lineNumber, String currentProductCode, String targetProductCode) {
log.info("KOS 상품 변경 요청 - 회선: {}, 현재상품: {}, 변경상품: {}",
lineNumber, currentProductCode, targetProductCode);
try {
// 요청 데이터 구성
Map<String, Object> requestData = Map.of(
"lineNumber", lineNumber.replaceAll("-", ""),
"currentProductCode", currentProductCode,
"targetProductCode", targetProductCode,
"requestId", generateRequestId()
);
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Service-Name", "MVNO-PRODUCT-SERVICE");
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestData, headers);
// KOS Mock API 호출
String kosUrl = kosProperties.getProductChangeUrl();
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.POST, requestEntity,
new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() {}
);
Map<String, Object> response = responseEntity.getBody();
if (response == null) {
throw KosConnectionException.apiError("KOS-PRODUCT-CHANGE",
String.valueOf(responseEntity.getStatusCode().value()), "응답 데이터가 없습니다");
}
log.info("KOS 상품 변경 성공 - 회선: {}", lineNumber);
return response;
} catch (Exception e) {
log.error("KOS 상품 변경 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
throw new KosConnectionException("KOS-PRODUCT-CHANGE",
"KOS 시스템 상품 변경 중 오류가 발생했습니다", "KOS-PRODUCT-CHANGE", e);
}
}
/**
* 상품 목록 조회 Circuit Breaker Fallback 메소드
*/
public List<KosProductInfo> getProductListFallback(Exception ex) {
log.warn("KOS 상품 목록 조회 Circuit Breaker 작동 - 오류: {}", ex.getMessage());
// Circuit Breaker가 Open 상태인 경우
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
throw CircuitBreakerException.circuitBreakerOpen("KOS-PRODUCT-LIST");
}
// 기본 상품 목록 반환 ( 목록)
log.info("KOS 상품 목록 조회 fallback - 빈 목록 반환");
return new ArrayList<>();
}
/**
* 상품 변경 Circuit Breaker Fallback 메소드
*/
public Map<String, Object> changeProductFallback(String lineNumber, String currentProductCode,
String targetProductCode, Exception ex) {
log.warn("KOS 상품 변경 Circuit Breaker 작동 - 회선: {}, 오류: {}", lineNumber, ex.getMessage());
// Circuit Breaker가 Open 상태인 경우
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
throw CircuitBreakerException.circuitBreakerOpen("KOS-PRODUCT-CHANGE");
}
// 실패 응답 반환
return Map.of(
"success", false,
"resultCode", "9999",
"resultMessage", "시스템 오류로 인한 상품 변경 실패",
"timestamp", LocalDateTime.now().toString()
);
}
/**
* KOS 가입상품 조회
*
* @param lineNumber 회선번호
* @return KOS 가입상품 조회 응답
*/
@SuppressWarnings({"rawtypes", "unchecked"})
@CircuitBreaker(name = "kosClient", fallbackMethod = "getProductInquiryFallback")
@Retry(name = "kosClient")
public KosCommonResponse<KosProductInquiryResponse> getProductInquiry(String lineNumber) {
log.info("KOS 가입상품 조회 요청: lineNumber={}", lineNumber);
try {
// 요청 ID 생성
String requestId = generateRequestId();
// 요청 데이터 생성
KosProductInquiryRequest request = new KosProductInquiryRequest();
request.setLineNumber(lineNumber);
request.setRequestId(requestId);
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("X-Request-ID", requestId);
headers.set("X-Service-Name", "product-service");
HttpEntity<KosProductInquiryRequest> requestEntity = new HttpEntity<>(request, headers);
// KOS API 호출
String url = kosProperties.getProductInquiryUrl();
log.debug("KOS API 호출: url={}, requestId={}", url, requestId);
ResponseEntity<KosCommonResponse<KosProductInquiryResponse>> response = restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
new ParameterizedTypeReference<KosCommonResponse<KosProductInquiryResponse>>() {}
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
KosCommonResponse<KosProductInquiryResponse> kosResponse = response.getBody();
if (kosResponse.getSuccess()) {
log.info("KOS 가입상품 조회 성공: lineNumber={}, requestId={}", lineNumber, requestId);
return kosResponse;
} else {
log.error("KOS 가입상품 조회 실패: lineNumber={}, requestId={}, resultCode={}, resultMessage={}",
lineNumber, requestId, kosResponse.getResultCode(), kosResponse.getResultMessage());
throw new RuntimeException("KOS 가입상품 조회 실패: " + kosResponse.getResultMessage());
}
} else {
throw new RuntimeException("KOS API 호출 실패: HTTP " + response.getStatusCode());
}
} catch (ResourceAccessException e) {
log.error("KOS API 호출 중 네트워크 오류: lineNumber={}", lineNumber, e);
throw new RuntimeException("KOS 시스템과의 통신 중 오류가 발생했습니다", e);
} catch (Exception e) {
log.error("KOS 가입상품 조회 중 예상치 못한 오류: lineNumber={}", lineNumber, e);
throw new RuntimeException("가입상품 조회 중 시스템 오류가 발생했습니다", e);
}
}
/**
* KOS 가입상품 조회 실패 Fallback 메서드
*/
public KosCommonResponse<KosProductInquiryResponse> getProductInquiryFallback(String lineNumber, Exception ex) {
log.error("KOS 가입상품 조회 Fallback 실행: lineNumber={}, reason={}", lineNumber, ex.getMessage());
// Fallback 응답 생성 (임시 데이터)
KosProductInquiryResponse.ProductInfo productInfo = KosProductInquiryResponse.ProductInfo.builder()
.lineNumber(lineNumber)
.currentProductCode("FALLBACK_PLAN")
.currentProductName("임시 플랜 (시스템 점검 중)")
.monthlyFee(new java.math.BigDecimal("0"))
.dataAllowance("정보 없음")
.voiceAllowance("정보 없음")
.smsAllowance("정보 없음")
.productStatus("UNKNOWN")
.contractDate(java.time.LocalDateTime.now())
.build();
KosProductInquiryResponse.CustomerInfo customerInfo = KosProductInquiryResponse.CustomerInfo.builder()
.customerName("임시 고객")
.customerId("FALLBACK_CUSTOMER")
.operatorCode("UNKNOWN")
.lineStatus("UNKNOWN")
.build();
KosProductInquiryResponse data = KosProductInquiryResponse.builder()
.requestId(generateRequestId())
.procStatus("FALLBACK")
.resultCode("9999")
.resultMessage("KOS 시스템 일시 점검 중입니다")
.productInfo(productInfo)
.customerInfo(customerInfo)
.build();
return KosCommonResponse.success(data, "KOS 시스템 일시 점검 중 - 임시 정보 제공");
}
/**
* KOS 시스템 연결 상태 확인
*
* @return 연결 가능 여부
*/
@CircuitBreaker(name = "kos-health-check")
public boolean isKosSystemAvailable() {
try {
String healthUrl = kosProperties.getHealthCheckUrl();
ResponseEntity<String> response = restTemplate.getForEntity(healthUrl, String.class);
boolean available = response.getStatusCode().is2xxSuccessful();
log.debug("KOS 시스템 상태 확인 - 사용가능: {}", available);
return available;
} catch (Exception e) {
log.warn("KOS 시스템 상태 확인 실패: {}", e.getMessage());
return false;
}
}
// ========== Private Helper Methods ==========
/**
* Map 데이터를 KosProductInfo 리스트로 변환
*/
private List<KosProductInfo> convertToKosProductInfoList(Map<String, Object> data) {
try {
List<KosProductInfo> productList = new ArrayList<>();
// products 배열 추출
@SuppressWarnings("unchecked")
List<Map<String, Object>> products = (List<Map<String, Object>>) data.get("products");
if (products != null) {
for (Map<String, Object> product : products) {
KosProductInfo productInfo = KosProductInfo.builder()
.productCode((String) product.get("product_code"))
.productName((String) product.get("product_name"))
.productType((String) product.get("product_type"))
.monthlyFee(convertToInteger(product.get("monthly_fee")))
.dataAllowance(convertToInteger(product.get("data_allowance")))
.voiceAllowance(convertToInteger(product.get("voice_allowance")))
.smsAllowance(convertToInteger(product.get("sms_allowance")))
.networkType((String) product.get("network_type"))
.status((String) product.get("status"))
.description((String) product.get("description"))
.build();
productList.add(productInfo);
}
}
return productList;
} catch (Exception e) {
log.error("상품 목록 데이터 변환 오류: {}", e.getMessage(), e);
throw KosConnectionException.dataConversionError("KOS-PRODUCT-LIST", "KosProductInfo", e);
}
}
/**
* Object를 Integer로 변환
*/
private Integer convertToInteger(Object value) {
if (value == null) return null;
if (value instanceof Integer) return (Integer) value;
if (value instanceof Number) return ((Number) value).intValue();
try {
return Integer.valueOf(value.toString().split("\\.")[0]); // 소수점 제거
} catch (NumberFormatException e) {
log.warn("숫자 변환 실패: {}", value);
return 0;
}
}
/**
* 요청 ID 생성
*/
private String generateRequestId() {
String currentDate = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
String uuid = java.util.UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return String.format("PROD_%s_%s", currentDate, uuid);
}
}

View File

@ -37,7 +37,6 @@ public class ProductCacheService {
private static final String AVAILABLE_PRODUCTS_PREFIX = "availableProducts:";
private static final String PRODUCT_STATUS_PREFIX = "productStatus:";
private static final String LINE_STATUS_PREFIX = "lineStatus:";
private static final String MENU_INFO_PREFIX = "menuInfo:";
private static final String PRODUCT_CHANGE_RESULT_PREFIX = "productChangeResult:";
public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
@ -155,27 +154,6 @@ public class ProductCacheService {
}
}
// ========== 메뉴정보 캐시 (TTL: 6시간) ==========
/**
* 메뉴정보 캐시 조회
*/
@Cacheable(value = "menuInfo", key = "#userId", unless = "#result == null")
public Object getMenuInfo(String userId) {
logger.debug("메뉴정보 캐시 조회: {}", userId);
return null;
}
/**
* 메뉴정보 캐시 저장
*/
public void cacheMenuInfo(String userId, Object menuInfo) {
if (StringUtils.hasText(userId) && menuInfo != null) {
String key = MENU_INFO_PREFIX + userId;
redisTemplate.opsForValue().set(key, menuInfo, Duration.ofHours(6));
logger.debug("메뉴정보 캐시 저장: {}", userId);
}
}
// ========== 상품변경결과 캐시 (TTL: 1시간) ==========
@ -207,9 +185,6 @@ public class ProductCacheService {
public void evictCustomerCaches(String lineNumber, String customerId) {
evictCustomerProductInfo(lineNumber);
evictLineStatus(lineNumber);
if (StringUtils.hasText(customerId)) {
evictMenuInfo(customerId);
}
logger.info("고객 관련 캐시 무효화 완료: lineNumber={}, customerId={}", lineNumber, customerId);
}
@ -271,10 +246,6 @@ public class ProductCacheService {
logger.debug("회선상태 캐시 무효화: {}", lineNumber);
}
@CacheEvict(value = "menuInfo", key = "#userId")
public void evictMenuInfo(String userId) {
logger.debug("메뉴정보 캐시 무효화: {}", userId);
}
@CacheEvict(value = "productChangeResult", key = "#requestId")
public void evictProductChangeResult(String requestId) {

View File

@ -9,21 +9,12 @@ import java.util.List;
* 상품 관리 서비스 인터페이스
*
* 주요 기능:
* - 상품변경 메뉴 조회
* - 고객 상품 정보 조회
* - 상품변경 처리
* - 상품변경 이력 관리
*/
public interface ProductService {
/**
* 상품변경 메뉴 조회
* UFR-PROD-010 구현
*
* @param userId 사용자 ID
* @return 메뉴 응답
*/
ProductMenuResponse getProductMenu(String userId);
/**
* 고객 정보 조회
@ -39,10 +30,9 @@ public interface ProductService {
* UFR-PROD-020 구현
*
* @param currentProductCode 현재 상품코드 (필터링용)
* @param operatorCode 사업자 코드 (필터링용)
* @return 가용 상품 목록 응답
*/
AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode);
AvailableProductsResponse getAvailableProducts(String currentProductCode);
/**
* 상품변경 사전체크
@ -73,13 +63,6 @@ public interface ProductService {
*/
ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId);
/**
* 상품변경 결과 조회
*
* @param requestId 상품변경 요청 ID
* @return 상품변경 결과 응답
*/
ProductChangeResultResponse getProductChangeResult(String requestId);
/**
* 상품변경 이력 조회

View File

@ -3,8 +3,11 @@ package com.unicorn.phonebill.product.service;
import com.unicorn.phonebill.product.dto.*;
import com.unicorn.phonebill.product.domain.Product;
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
import com.unicorn.phonebill.product.domain.ProductChangeResult;
import com.unicorn.phonebill.product.repository.ProductRepository;
import com.unicorn.phonebill.product.repository.ProductChangeHistoryRepository;
import com.unicorn.phonebill.product.dto.kos.KosCommonResponse;
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
@ -41,49 +44,20 @@ public class ProductServiceImpl implements ProductService {
private final ProductChangeHistoryRepository historyRepository;
private final ProductValidationService validationService;
private final ProductCacheService cacheService;
// TODO: KOS 연동 서비스 추가 예정
// private final KosClientService kosClientService;
private final KosClientService kosClientService;
public ProductServiceImpl(ProductRepository productRepository,
ProductChangeHistoryRepository historyRepository,
ProductValidationService validationService,
ProductCacheService cacheService) {
ProductCacheService cacheService,
KosClientService kosClientService) {
this.productRepository = productRepository;
this.historyRepository = historyRepository;
this.validationService = validationService;
this.cacheService = cacheService;
this.kosClientService = kosClientService;
}
@Override
public ProductMenuResponse getProductMenu(String userId) {
logger.info("상품변경 메뉴 조회: userId={}", userId);
try {
// 캐시에서 메뉴 정보 조회
Object cachedMenu = cacheService.getMenuInfo(userId);
if (cachedMenu instanceof ProductMenuResponse) {
logger.debug("메뉴 정보 캐시 히트: userId={}", userId);
return (ProductMenuResponse) cachedMenu;
}
// 메뉴 정보 생성 (실제로는 사용자 권한에 따라 동적 생성)
ProductMenuResponse.MenuData menuData = createMenuData(userId);
ProductMenuResponse response = ProductMenuResponse.builder()
.success(true)
.data(menuData)
.build();
// 캐시에 저장
cacheService.cacheMenuInfo(userId, response);
logger.info("상품변경 메뉴 조회 완료: userId={}", userId);
return response;
} catch (Exception e) {
logger.error("상품변경 메뉴 조회 중 오류: userId={}", userId, e);
throw new RuntimeException("메뉴 조회 중 오류가 발생했습니다", e);
}
}
@Override
public CustomerInfoResponse getCustomerInfo(String lineNumber) {
@ -117,36 +91,36 @@ public class ProductServiceImpl implements ProductService {
}
@Override
public AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode) {
logger.info("가용 상품 목록 조회: currentProductCode={}, operatorCode={}", currentProductCode, operatorCode);
public AvailableProductsResponse getAvailableProducts(String currentProductCode) {
logger.info("가용 상품 목록 조회: currentProductCode={}", currentProductCode);
try {
// 캐시에서 상품 목록 조회
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts(operatorCode);
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts("all");
if (cachedProducts != null && !cachedProducts.isEmpty()) {
logger.debug("상품 목록 캐시 히트: operatorCode={}, count={}", operatorCode, cachedProducts.size());
logger.debug("상품 목록 캐시 히트: count={}", cachedProducts.size());
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(cachedProducts, currentProductCode);
return AvailableProductsResponse.success(filteredProducts);
}
// 캐시 미스 실제 조회
List<Product> products = productRepository.findAvailableProductsByOperator(operatorCode);
List<Product> products = productRepository.findAvailableProducts();
List<ProductInfoDto> productDtos = products.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
// 캐시에 저장
cacheService.cacheAvailableProducts(operatorCode, productDtos);
cacheService.cacheAvailableProducts("all", productDtos);
// 현재 상품 기준 필터링
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(productDtos, currentProductCode);
logger.info("가용 상품 목록 조회 완료: operatorCode={}, totalCount={}, filteredCount={}",
operatorCode, productDtos.size(), filteredProducts.size());
logger.info("가용 상품 목록 조회 완료: totalCount={}, filteredCount={}",
productDtos.size(), filteredProducts.size());
return AvailableProductsResponse.success(filteredProducts);
} catch (Exception e) {
logger.error("가용 상품 목록 조회 중 오류: operatorCode={}", operatorCode, e);
logger.error("가용 상품 목록 조회 중 오류", e);
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다", e);
}
}
@ -191,12 +165,18 @@ public class ProductServiceImpl implements ProductService {
// 4. 처리 결과에 따른 이력 업데이트
if (changeResult.isSuccess()) {
// KOS 응답 데이터를 Map으로 변환
Map<String, Object> kosResponseData = Map.of(
// KOS 응답 데이터 사용 (실제 응답 데이터 또는 기본 데이터)
Map<String, Object> kosResponseData = changeResult.getKosResponseData();
if (kosResponseData == null) {
kosResponseData = Map.of(
"resultCode", changeResult.getResultCode(),
"resultMessage", changeResult.getResultMessage(),
"kosOrderNumber", changeResult.getKosOrderNumber() != null ? changeResult.getKosOrderNumber() : "N/A",
"effectiveDate", changeResult.getEffectiveDate() != null ? changeResult.getEffectiveDate() : "N/A",
"processedAt", LocalDateTime.now().toString()
);
}
history = history.markAsCompleted(changeResult.getResultMessage(), kosResponseData);
// 캐시 무효화
@ -280,41 +260,6 @@ public class ProductServiceImpl implements ProductService {
}
}
@Override
public ProductChangeResultResponse getProductChangeResult(String requestId) {
logger.info("상품변경 결과 조회: requestId={}", requestId);
try {
// 캐시에서 결과 조회
ProductChangeResultResponse.ProductChangeResult cachedResult = cacheService.getProductChangeResult(requestId);
if (cachedResult != null) {
logger.debug("상품변경 결과 캐시 히트: requestId={}", requestId);
return ProductChangeResultResponse.success(cachedResult);
}
// 캐시 미스 DB에서 조회
Optional<ProductChangeHistory> historyOpt = historyRepository.findByRequestId(requestId);
if (!historyOpt.isPresent()) {
throw new RuntimeException("요청 정보를 찾을 수 없습니다: " + requestId);
}
ProductChangeHistory history = historyOpt.get();
ProductChangeResultResponse.ProductChangeResult result = convertToResultDto(history);
// 완료된 결과만 캐시에 저장
if (history.getProcessStatus().equals("COMPLETED") || history.getProcessStatus().equals("FAILED")) {
cacheService.cacheProductChangeResult(requestId, result);
}
logger.info("상품변경 결과 조회 완료: requestId={}, status={}", requestId, history.getProcessStatus());
return ProductChangeResultResponse.success(result);
} catch (Exception e) {
logger.error("상품변경 결과 조회 중 오류: requestId={}", requestId, e);
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다", e);
}
}
@Override
public ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable) {
@ -365,49 +310,49 @@ public class ProductServiceImpl implements ProductService {
// ========== Private Helper Methods ==========
/**
* 메뉴 데이터 생성
*/
private ProductMenuResponse.MenuData createMenuData(String userId) {
// TODO: 실제로는 사용자 권한 고객 정보에 따라 동적 생성
return ProductMenuResponse.MenuData.builder()
.customerId("CUST001") // 임시값
.lineNumber("01012345678") // 임시값
.menuItems(Arrays.asList(
ProductMenuResponse.MenuItem.builder()
.menuId("MENU001")
.menuName("상품변경")
.available(true)
.description("현재 이용 중인 상품을 다른 상품으로 변경합니다")
.build()
))
.build();
}
/**
* 데이터소스에서 고객 정보 조회
* 데이터소스에서 고객 정보 조회 (KOS 연동)
*/
private CustomerInfoResponse.CustomerInfo getCustomerInfoFromDataSource(String lineNumber) {
// TODO: 실제 KOS 연동 또는 DB 조회 구현
// 현재는 임시 데이터 반환
try {
logger.debug("KOS 시스템에서 고객 정보 조회: lineNumber={}", lineNumber);
// KOS 시스템 호출
KosCommonResponse<KosProductInquiryResponse> kosResponse = kosClientService.getProductInquiry(lineNumber);
if (kosResponse.getSuccess() && kosResponse.getData() != null) {
KosProductInquiryResponse kosData = kosResponse.getData();
// KOS 응답을 내부 DTO로 변환
ProductInfoDto currentProduct = ProductInfoDto.builder()
.productCode("PLAN001")
.productName("5G 베이직 플랜")
.monthlyFee(new java.math.BigDecimal("45000"))
.dataAllowance("50GB")
.voiceAllowance("무제한")
.smsAllowance("기본 무료")
.isAvailable(true)
.operatorCode("MVNO001")
.productCode(kosData.getProductInfo().getCurrentProductCode())
.productName(kosData.getProductInfo().getCurrentProductName())
.monthlyFee(kosData.getProductInfo().getMonthlyFee())
.dataAllowance(kosData.getProductInfo().getDataAllowance())
.voiceAllowance(kosData.getProductInfo().getVoiceAllowance())
.smsAllowance(kosData.getProductInfo().getSmsAllowance())
.isAvailable("ACTIVE".equals(kosData.getProductInfo().getProductStatus()))
.operatorCode(kosData.getCustomerInfo().getOperatorCode())
.build();
return CustomerInfoResponse.CustomerInfo.builder()
.customerId("CUST001")
.customerId(kosData.getCustomerInfo().getCustomerId())
.lineNumber(lineNumber)
.customerName("홍길동")
.customerName(kosData.getCustomerInfo().getCustomerName())
.currentProduct(currentProduct)
.lineStatus("ACTIVE")
.lineStatus(kosData.getCustomerInfo().getLineStatus())
.build();
} else {
logger.error("KOS 시스템에서 고객 정보를 찾을 수 없습니다: lineNumber={}, resultCode={}, resultMessage={}",
lineNumber, kosResponse.getResultCode(), kosResponse.getResultMessage());
return null;
}
} catch (Exception e) {
logger.error("KOS 연동 중 오류 발생: lineNumber={}", lineNumber, e);
throw new RuntimeException("고객 정보 조회 중 시스템 오류가 발생했습니다", e);
}
}
/**
@ -444,7 +389,7 @@ public class ProductServiceImpl implements ProductService {
*/
private ProductChangeHistory createProductChangeHistory(String requestId, ProductChangeRequest request, String userId) {
return ProductChangeHistory.createNew(
requestId,
requestId, // UUID는 엔티티에서 자동 생성됨
request.getLineNumber(),
userId, // customerId로 사용
request.getCurrentProductCode(),
@ -456,21 +401,71 @@ public class ProductServiceImpl implements ProductService {
* KOS 연동 상품변경 처리 (임시 구현)
*/
private ProductChangeResult processProductChangeWithKos(ProductChangeRequest request, String requestId) {
// TODO: 실제 KOS 연동 구현
// 현재는 임시 성공 결과 반환
logger.info("KOS 상품 변경 처리 시작: requestId={}, lineNumber={}", requestId, request.getLineNumber());
try {
Thread.sleep(100); // 처리 시간 시뮬레이션
// KOS 상품변경 API 호출
Map<String, Object> kosResponse = kosClientService.changeProductInKos(
request.getLineNumber(),
request.getCurrentProductCode(),
request.getTargetProductCode()
);
// KOS 응답 분석
Boolean success = (Boolean) kosResponse.get("success");
String resultCode = (String) kosResponse.get("resultCode");
String resultMessage = (String) kosResponse.get("resultMessage");
if (Boolean.TRUE.equals(success) && "0000".equals(resultCode)) {
logger.info("KOS 상품 변경 성공: requestId={}, lineNumber={}", requestId, request.getLineNumber());
// data 섹션에서 상세 정보 추출
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) kosResponse.get("data");
if (data != null) {
@SuppressWarnings("unchecked")
Map<String, Object> changeInfo = (Map<String, Object>) data.get("changeInfo");
if (changeInfo != null) {
String kosOrderNumber = (String) changeInfo.get("kosOrderNumber");
String effectiveDate = (String) changeInfo.get("effectiveDate");
return ProductChangeResult.builder()
.success(true)
.resultCode("SUCCESS")
.resultMessage("상품 변경이 완료되었습니다")
.resultCode(resultCode)
.resultMessage(resultMessage)
.kosOrderNumber(kosOrderNumber)
.effectiveDate(effectiveDate)
.kosResponseData(kosResponse)
.build();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return ProductChangeResult.builder()
.success(true)
.resultCode(resultCode)
.resultMessage(resultMessage)
.kosResponseData(kosResponse)
.build();
} else {
logger.error("KOS 상품 변경 실패: requestId={}, resultCode={}, resultMessage={}",
requestId, resultCode, resultMessage);
return ProductChangeResult.builder()
.success(false)
.resultCode(resultCode != null ? resultCode : "KOS_ERROR")
.failureReason(resultMessage != null ? resultMessage : "KOS 시스템 오류")
.kosResponseData(kosResponse)
.build();
}
} catch (Exception e) {
logger.error("KOS 연동 중 예외 발생: requestId={}, lineNumber={}", requestId, request.getLineNumber(), e);
return ProductChangeResult.builder()
.success(false)
.resultCode("SYSTEM_ERROR")
.failureReason("처리 중 시스템 오류 발생")
.failureReason("KOS 시스템 연동 중 오류가 발생했습니다: " + e.getMessage())
.build();
}
}
@ -531,45 +526,4 @@ public class ProductServiceImpl implements ProductService {
.build();
}
/**
* 상품변경 결과 임시 클래스
*/
private static class ProductChangeResult {
private final boolean success;
private final String resultCode;
private final String resultMessage;
private final String failureReason;
private ProductChangeResult(boolean success, String resultCode, String resultMessage, String failureReason) {
this.success = success;
this.resultCode = resultCode;
this.resultMessage = resultMessage;
this.failureReason = failureReason;
}
public static ProductChangeResultBuilder builder() {
return new ProductChangeResultBuilder();
}
public boolean isSuccess() { return success; }
public String getResultCode() { return resultCode; }
public String getResultMessage() { return resultMessage; }
public String getFailureReason() { return failureReason; }
public static class ProductChangeResultBuilder {
private boolean success;
private String resultCode;
private String resultMessage;
private String failureReason;
public ProductChangeResultBuilder success(boolean success) { this.success = success; return this; }
public ProductChangeResultBuilder resultCode(String resultCode) { this.resultCode = resultCode; return this; }
public ProductChangeResultBuilder resultMessage(String resultMessage) { this.resultMessage = resultMessage; return this; }
public ProductChangeResultBuilder failureReason(String failureReason) { this.failureReason = failureReason; return this; }
public ProductChangeResult build() {
return new ProductChangeResult(success, resultCode, resultMessage, failureReason);
}
}
}
}

View File

@ -59,13 +59,13 @@ public class ProductValidationService {
failureReasonBuilder.append("변경 대상 상품이 판매중이 아닙니다. ");
}
// 2. 사업자 일치 확인
boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
request.getTargetProductCode(), validationDetails);
if (!isOperatorMatch) {
overallSuccess = false;
failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
}
// 2. 사업자 일치 확인 (제외됨)
// boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
// request.getTargetProductCode(), validationDetails);
// if (!isOperatorMatch) {
// overallSuccess = false;
// failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
// }
// 3. 회선 상태 확인
boolean isLineStatusValid = validateLineStatus(request.getLineNumber(), validationDetails);

View File

@ -1,200 +1,6 @@
spring:
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:product_change_db}
username: ${DB_USERNAME:phonebill_user}
password: ${DB_PASSWORD:phonebill_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA 설정
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:2}
# Cache 개발 설정 (TTL 단축)
cache:
redis:
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
# Server 개발 설정
server:
port: ${SERVER_PORT:8083}
error:
include-stacktrace: always
include-message: always
include-binding-errors: always
# Logging 개발 설정
logging:
level:
com.unicorn.phonebill: ${LOG_LEVEL_APP:DEBUG}
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.web: DEBUG
org.springframework.cache: DEBUG
pattern:
console: "%clr(%d{HH:mm:ss.SSS}){faint} %clr([%thread]){faint} %clr(%-5level){spring} %clr(%logger{36}){cyan} - %msg%n"
# Management 개발 설정
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
show-components: always
info:
env:
enabled: true
# OpenAPI 개발 설정
springdoc:
swagger-ui:
enabled: true
try-it-out-enabled: true
api-docs:
enabled: true
show-actuator: true
# Resilience4j 개발 설정 (더 관대한 설정)
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 70
minimum-number-of-calls: 3
wait-duration-in-open-state: 5s
instances:
kosClient:
failure-rate-threshold: 80
wait-duration-in-open-state: 10s
retry:
instances:
kosClient:
max-attempts: 3
wait-duration: 1s
# KOS Mock 서버 설정 (개발환경용)
kos:
base-url: ${KOS_BASE_URL:http://localhost:9090/kos}
connect-timeout: 5s
read-timeout: 10s
max-retries: 3
retry-delay: 1s
# Mock 모드 설정
mock:
enabled: ${KOS_MOCK_ENABLED:true}
response-delay: 500ms # Mock 응답 지연 시뮬레이션
endpoints:
customer-info: /api/v1/customer/{lineNumber}
product-info: /api/v1/product/{productCode}
available-products: /api/v1/products/available
product-change: /api/v1/product/change
headers:
api-key: ${KOS_API_KEY:dev-api-key}
client-id: ${KOS_CLIENT_ID:product-service-dev}
# 비즈니스 개발 설정
app:
product:
cache:
customer-info-ttl: ${PRODUCT_CACHE_CUSTOMER_INFO_TTL:600} # 10분 (개발환경에서 단축)
product-info-ttl: ${PRODUCT_CACHE_PRODUCT_INFO_TTL:300} # 5분
available-products-ttl: ${PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL:1800} # 30분
product-status-ttl: ${PRODUCT_CACHE_PRODUCT_STATUS_TTL:300} # 5분
line-status-ttl: ${PRODUCT_CACHE_LINE_STATUS_TTL:180} # 3분
validation:
enabled: ${PRODUCT_VALIDATION_ENABLED:true}
strict-mode: ${PRODUCT_VALIDATION_STRICT_MODE:false} # 개발환경에서는 유연하게
processing:
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:false} # 개발환경에서는 동기 처리
# 개발용 테스트 데이터
test-data:
enabled: ${TEST_DATA_ENABLED:true}
customers:
- lineNumber: "01012345678"
customerId: "CUST001"
customerName: "홍길동"
currentProductCode: "PLAN001"
- lineNumber: "01087654321"
customerId: "CUST002"
customerName: "김철수"
currentProductCode: "PLAN002"
products:
- productCode: "PLAN001"
productName: "5G 베이직 플랜"
monthlyFee: 45000
dataAllowance: "50GB"
- productCode: "PLAN002"
productName: "5G 프리미엄 플랜"
monthlyFee: 65000
dataAllowance: "100GB"
security:
jwt:
secret: ${JWT_SECRET:dev-secret-key-for-testing-only}
expiration: ${JWT_EXPIRATION:3600} # 1시간 (개발환경에서 단축)
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:*} # 개발환경에서만 허용
# DevTools 설정
spring.devtools:
restart:
enabled: true
exclude: static/**,public/**,templates/**
livereload:
enabled: true
port: 35729
add-properties: true
# 디버깅 설정
debug: false
trace: false
# 개발 환경 정보
info:
app:
name: ${spring.application.name}
description: Product-Change Service Development Environment
version: ${spring.application.version}
encoding: UTF-8
java:
version: ${java.version}
build:
artifact: ${project.artifactId:product-service}
name: ${project.name:Product Service}
version: ${project.version:1.0.0}
time: ${build.time:2024-03-15T10:00:00Z}
root: INFO
com.unicorn.phonebill: DEBUG
org.springframework.security: DEBUG
org.hibernate: DEBUG

View File

@ -1,273 +1,6 @@
spring:
# Database - 운영환경 (PostgreSQL)
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_product_prod}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
max-lifetime: 1800000
connection-timeout: 20000
validation-timeout: 5000
leak-detection-threshold: 60000
# JPA 운영 설정
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: false
use_sql_comments: false
generate_statistics: false
# Redis - 운영환경 (클러스터)
data:
redis:
cluster:
nodes: ${REDIS_CLUSTER_NODES}
password: ${REDIS_PASSWORD}
timeout: 2000ms
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
pool:
max-active: 50
max-idle: 20
min-idle: 5
max-wait: 3000ms
# Server 운영 설정
server:
port: ${SERVER_PORT:8080}
shutdown: graceful
compression:
enabled: true
min-response-size: 1024
tomcat:
connection-timeout: 30s
max-connections: 8192
max-threads: 200
min-spare-threads: 10
accept-count: 100
error:
include-stacktrace: never
include-message: on-param
include-binding-errors: never
# Graceful Shutdown
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
# Logging 운영 설정
logging:
level:
root: WARN
com.unicorn.phonebill: INFO
org.springframework.security: WARN
org.hibernate: WARN
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
file:
name: /app/logs/product-service.log
max-size: 500MB
max-history: 30
total-size-cap: 10GB
logback:
rollingpolicy:
clean-history-on-start: true
# Management 운영 설정
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: never
show-components: never
info:
enabled: true
health:
probes:
enabled: true
livenessstate:
enabled: true
readinessstate:
enabled: true
metrics:
distribution:
percentiles:
http.server.requests: 0.5, 0.95, 0.99
slo:
http.server.requests: 50ms, 100ms, 200ms, 500ms, 1s, 2s
# OpenAPI 운영 설정 (비활성화)
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
# Resilience4j 운영 설정
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50
slow-call-rate-threshold: 50
slow-call-duration-threshold: 3s
permitted-number-of-calls-in-half-open-state: 5
minimum-number-of-calls: 10
wait-duration-in-open-state: 30s
sliding-window-size: 20
instances:
kosClient:
base-config: default
failure-rate-threshold: 40
wait-duration-in-open-state: 60s
minimum-number-of-calls: 20
retry:
configs:
default:
max-attempts: 3
wait-duration: 2s
exponential-backoff-multiplier: 2
instances:
kosClient:
base-config: default
max-attempts: 2
wait-duration: 3s
timelimiter:
configs:
default:
timeout-duration: 8s
instances:
kosClient:
timeout-duration: 15s
# KOS 서버 설정 (운영환경)
kos:
base-url: ${KOS_BASE_URL}
connect-timeout: 10s
read-timeout: 30s
max-retries: 2
retry-delay: 3s
endpoints:
customer-info: /api/v1/customer/{lineNumber}
product-info: /api/v1/product/{productCode}
available-products: /api/v1/products/available
product-change: /api/v1/product/change
headers:
api-key: ${KOS_API_KEY}
client-id: ${KOS_CLIENT_ID:product-service}
# 운영환경 보안 설정
ssl:
enabled: true
trust-store: ${SSL_TRUST_STORE:/app/certs/truststore.jks}
trust-store-password: ${SSL_TRUST_STORE_PASSWORD}
key-store: ${SSL_KEY_STORE:/app/certs/keystore.jks}
key-store-password: ${SSL_KEY_STORE_PASSWORD}
# 비즈니스 운영 설정
app:
product:
cache:
customer-info-ttl: 14400 # 4시간
product-info-ttl: 7200 # 2시간
available-products-ttl: 86400 # 24시간
product-status-ttl: 3600 # 1시간
line-status-ttl: 1800 # 30분
validation:
enabled: true
strict-mode: true
max-retry-attempts: 2
validation-timeout: 10s
processing:
async-enabled: true
max-concurrent-requests: 500
request-timeout: 60s
security:
jwt:
secret: ${JWT_SECRET}
expiration: 86400 # 24시간
refresh-expiration: 604800 # 7일
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers:
- Authorization
- Content-Type
- Accept
- X-Requested-With
- X-Forwarded-For
- X-Forwarded-Proto
allow-credentials: true
max-age: 3600
# 모니터링 설정
monitoring:
health-check:
interval: 30s
timeout: 10s
metrics:
enabled: true
export-interval: 60s
alerts:
email-enabled: ${ALERT_EMAIL_ENABLED:false}
slack-enabled: ${ALERT_SLACK_ENABLED:false}
webhook-url: ${ALERT_WEBHOOK_URL:}
# 운영 환경 정보
info:
app:
name: ${spring.application.name}
description: Product-Change Service Production Environment
version: ${spring.application.version}
environment: production
build:
artifact: product-service
version: ${BUILD_VERSION:1.0.0}
time: ${BUILD_TIME}
commit: ${GIT_COMMIT:unknown}
branch: ${GIT_BRANCH:main}
# JVM 튜닝 설정 (환경변수로 설정)
# JAVA_OPTS=-Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/heapdumps/
# -Dspring.profiles.active=prod
# 외부 의존성 URLs
external:
auth-service:
url: ${AUTH_SERVICE_URL:http://auth-service:8080}
bill-inquiry-service:
url: ${BILL_INQUIRY_SERVICE_URL:http://bill-inquiry-service:8081}
# 데이터베이스 마이그레이션 (Flyway)
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true

View File

@ -6,67 +6,58 @@ spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# Database 기본 설정
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:product_change}
username: ${DB_USERNAME:product_user}
password: ${DB_PASSWORD:product_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 20000
validation-timeout: 5000
leak-detection-threshold: 60000
# JPA 기본 설정
# JPA 설정
jpa:
open-in-view: false
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: false
use_sql_comments: false
jdbc:
batch_size: 25
order_inserts: true
order_updates: true
format_sql: true
use_sql_comments: true
connection:
provider_disables_autocommit: true
provider_disables_autocommit: false
dialect: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis 기본 설정
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 20
max-active: 8
max-idle: 8
min-idle: 2
min-idle: 0
max-wait: -1ms
time-between-eviction-runs: 30s
database: ${REDIS_DATABASE:2}
# Cache 설정
# Cache 개발 설정 (TTL 단축)
cache:
type: redis
cache-names:
- customerInfo
- productInfo
- availableProducts
- productStatus
- lineStatus
redis:
time-to-live: 14400000 # 4시간 (ms)
cache-null-values: false
use-key-prefix: true
key-prefix: "product-service:"
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
# Security 기본 설정
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${JWT_ISSUER_URI:http://localhost:8080/auth}
# Server 개발 설정
server:
port: ${SERVER_PORT:8083}
error:
include-stacktrace: always
include-message: always
include-binding-errors: always
# Jackson 설정
jackson:
@ -79,21 +70,15 @@ spring:
time-zone: Asia/Seoul
date-format: yyyy-MM-dd'T'HH:mm:ss
# HTTP 설정
webflux: {}
# CORS
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# Server 설정
server:
port: ${SERVER_PORT:8083}
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain
http2:
enabled: true
error:
include-stacktrace: never
include-message: always
include-binding-errors: always
# JWT 토큰 설정
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
# Management & Actuator
management:
@ -128,25 +113,6 @@ management:
build:
enabled: true
# Logging 설정
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.unicorn.phonebill: ${LOG_LEVEL_APP:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
org.hibernate.type: WARN
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE:logs/product-service.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
# OpenAPI/Swagger 설정
springdoc:
api-docs:
@ -214,42 +180,44 @@ resilience4j:
base-config: default
timeout-duration: 10s
# 비즈니스 설정
app:
product:
cache:
customer-info-ttl: 14400 # 4시간 (초)
product-info-ttl: 7200 # 2시간 (초)
available-products-ttl: 86400 # 24시간 (초)
product-status-ttl: 3600 # 1시간 (초)
line-status-ttl: 1800 # 30분 (초)
validation:
max-retry-attempts: 3
validation-timeout: 5s
processing:
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:true}
max-concurrent-requests: ${PRODUCT_PROCESSING_MAX_CONCURRENT_REQUESTS:100}
request-timeout: ${PRODUCT_PROCESSING_REQUEST_TIMEOUT:30s}
# KOS Mock 서버 설정
kos:
base-url: ${KOS_BASE_URL:http://localhost:9090}
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
read-timeout: ${KOS_READ_TIMEOUT:10000}
max-retries: ${KOS_MAX_RETRIES:3}
retry-delay: ${KOS_RETRY_DELAY:1000}
# Circuit Breaker 설정
circuit-breaker:
failure-rate-threshold: ${KOS_CB_FAILURE_RATE:0.5}
slow-call-duration-threshold: ${KOS_CB_SLOW_CALL_THRESHOLD:10000}
slow-call-rate-threshold: ${KOS_CB_SLOW_CALL_RATE:0.5}
sliding-window-size: ${KOS_CB_SLIDING_WINDOW_SIZE:10}
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
wait-duration-in-open-state: ${KOS_CB_WAIT_DURATION:60000}
# Logging 운영 설정
logging:
level:
root: WARN
com.unicorn.phonebill: INFO
com.phonebill.common.security: DEBUG
org.springframework.security: DEBUG
org.hibernate: WARN
org.hibernate.resource.transaction: ERROR
org.hibernate.internal: ERROR
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
file:
name: logs/product-service.log
max-size: 500MB
max-history: 30
total-size-cap: 10GB
logback:
rollingpolicy:
clean-history-on-start: true
security:
jwt:
secret: ${JWT_SECRET:product-service-secret-key-change-in-production}
expiration: ${JWT_EXPIRATION:86400} # 24시간
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7일
cors:
allowed-origins:
- http://localhost:3000
- https://mvno.com
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers:
- Authorization
- Content-Type
- Accept
- X-Requested-With
allow-credentials: true
max-age: 3600

View File

@ -4,6 +4,7 @@
<option name="env">
<map>
<entry key="BILL_INQUIRY_URL" value="http://localhost:8082" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
<entry key="DB_HOST" value="20.249.70.6" />
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_NAME" value="phonebill_auth" />
@ -11,11 +12,9 @@
<entry key="DB_PORT" value="5432" />
<entry key="DB_USERNAME" value="auth_user" />
<entry key="DDL_AUTO" value="update" />
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
<entry key="LOG_FILE" value="logs/user-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="PRODUCT_CHANGE_URL" value="http://localhost:8083" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
<entry key="REDIS_DATABASE" value="0" />
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />

View File

@ -0,0 +1,54 @@
package com.phonebill.user.config;
import com.phonebill.user.entity.AuthPermissionEntity;
import com.phonebill.user.enums.PermissionCode;
import com.phonebill.user.repository.AuthPermissionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 애플리케이션 시작 기본 데이터 초기화
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
private final AuthPermissionRepository authPermissionRepository;
@Override
@Transactional
public void run(String... args) throws Exception {
initializePermissions();
}
/**
* 기본 권한 데이터 초기화
*/
private void initializePermissions() {
log.info("권한 데이터 초기화 시작");
for (PermissionCode permissionCode : PermissionCode.values()) {
// 이미 존재하는 권한인지 확인
if (!authPermissionRepository.existsByPermissionCodeAndIsActiveTrue(permissionCode.getCode())) {
AuthPermissionEntity permission = AuthPermissionEntity.builder()
.serviceCode("USER_SERVICE")
.permissionCode(permissionCode.getCode())
.permissionName(permissionCode.getCode())
.permissionDescription(permissionCode.getDescription())
.isActive(true)
.build();
authPermissionRepository.save(permission);
log.info("권한 생성: {}", permissionCode.getCode());
} else {
log.debug("권한 이미 존재: {}", permissionCode.getCode());
}
}
log.info("권한 데이터 초기화 완료");
}
}

View File

@ -42,8 +42,9 @@ public class JwtConfig {
*/
@Bean
public JwtTokenProvider jwtTokenProvider(
@Value("${security.jwt.secret:phonebill-jwt-secret-key-2025-dev}") String secret,
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long tokenValidityInMilliseconds) {
long tokenValidityInSeconds = tokenValidityInMilliseconds / 1000;
return new JwtTokenProvider(secret, tokenValidityInSeconds);
}
}

View File

@ -3,6 +3,7 @@ package com.phonebill.user.config;
import com.phonebill.common.security.JwtAuthenticationFilter;
import com.phonebill.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@ -28,6 +29,8 @@ import java.util.List;
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@ -46,8 +49,10 @@ public class SecurityConfig {
.authorizeHttpRequests(authz -> authz
// Public endpoints (인증 불필요)
.requestMatchers(
"/auth/login",
"/auth/refresh",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/refresh",
"/api/v1/users",
"/actuator/health",
"/actuator/info",
"/actuator/prometheus",
@ -103,8 +108,9 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 개발환경에서는 모든 Origin 허용, 운영환경에서는 특정 도메인만 허용
configuration.setAllowedOriginPatterns(List.of("*"));
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));

Some files were not shown because too many files have changed in this diff Show More