This commit is contained in:
hiondal
2025-09-09 01:12:14 +09:00
parent 7ec8a682c6
commit b489c73201
276 changed files with 43859 additions and 98 deletions
+86
View File
@@ -0,0 +1,86 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="bill-service" type="GradleRunConfiguration" factoryName="Gradle">
<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="DB_CONNECTION_TIMEOUT" value="30000" />
<entry key="DB_IDLE_TIMEOUT" value="600000" />
<entry key="DB_MAX_LIFETIME" value="1800000" />
<entry key="DB_LEAK_DETECTION" value="60000" />
<!-- Redis Pool Settings -->
<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_TIMEOUT" value="2000" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="bill-service:bootRun" />
</list>
</option>
<option name="vmOptions" value="-Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<ForceTestExec>false</ForceTestExec>
<method v="2" />
</configuration>
</component>
+292
View File
@@ -0,0 +1,292 @@
# Bill Service - 통신요금 조회 서비스
통신요금 관리 시스템의 요금조회 마이크로서비스입니다.
## 📋 서비스 개요
- **서비스명**: Bill Service (요금조회 서비스)
- **포트**: 8081
- **컨텍스트 패스**: /bill-service
- **버전**: 1.0.0
## 🏗️ 아키텍처
### 기술 스택
- **Java**: 17
- **Spring Boot**: 3.2
- **Spring Security**: JWT 기반 인증
- **Spring Data JPA**: 데이터 접근 계층
- **MySQL**: 8.0+
- **Redis**: 캐시 서버
- **Resilience4j**: Circuit Breaker, Retry, TimeLimiter
- **Swagger/OpenAPI**: API 문서화
### 주요 패턴
- **Layered Architecture**: Controller → Service → Repository
- **Circuit Breaker Pattern**: 외부 시스템 장애 격리
- **Cache-Aside Pattern**: Redis를 통한 성능 최적화
- **Async Pattern**: 이력 저장 비동기 처리
## 🚀 주요 기능
### 1. 요금조회 메뉴 (GET /api/bills/menu)
- 고객 정보 및 조회 가능한 월 목록 제공
- 캐시를 통한 빠른 응답
### 2. 요금조회 신청 (POST /api/bills/inquiry)
- 실시간 요금 정보 조회
- KOS 시스템 연동
- Circuit Breaker를 통한 장애 격리
- 비동기 이력 저장
### 3. 요금조회 결과 확인 (GET /api/bills/inquiry/{requestId})
- 비동기 처리된 요금조회 결과 확인
- 처리 상태별 응답 제공
### 4. 요금조회 이력 (GET /api/bills/history)
- 사용자별 요금조회 이력 목록
- 페이징, 필터링 지원
## 📁 프로젝트 구조
```
bill-service/
├── src/main/java/com/phonebill/bill/
│ ├── BillServiceApplication.java # 메인 애플리케이션
│ ├── common/ # 공통 컴포넌트
│ │ ├── entity/BaseTimeEntity.java # 기본 엔티티
│ │ └── response/ApiResponse.java # API 응답 래퍼
│ ├── config/ # 설정 클래스
│ │ ├── CircuitBreakerConfig.java # Circuit Breaker 설정
│ │ ├── KosProperties.java # KOS 연동 설정
│ │ ├── RedisConfig.java # Redis 캐시 설정
│ │ ├── RestTemplateConfig.java # HTTP 클라이언트 설정
│ │ └── SecurityConfig.java # Spring Security 설정
│ ├── controller/ # REST 컨트롤러
│ │ └── BillController.java # 요금조회 API
│ ├── dto/ # 데이터 전송 객체
│ │ ├── BillHistoryResponse.java # 이력 응답
│ │ ├── BillInquiryRequest.java # 조회 요청
│ │ ├── BillInquiryResponse.java # 조회 응답
│ │ └── BillMenuResponse.java # 메뉴 응답
│ ├── exception/ # 예외 처리
│ │ ├── BillInquiryException.java # 요금조회 예외
│ │ ├── BusinessException.java # 비즈니스 예외
│ │ ├── CircuitBreakerException.java # Circuit Breaker 예외
│ │ ├── GlobalExceptionHandler.java # 전역 예외 핸들러
│ │ └── KosConnectionException.java # KOS 연동 예외
│ ├── repository/ # 데이터 접근 계층
│ │ ├── BillInquiryHistoryRepository.java # 이력 리포지토리
│ │ └── entity/
│ │ └── BillInquiryHistoryEntity.java # 이력 엔티티
│ ├── service/ # 비즈니스 로직
│ │ ├── BillCacheService.java # 캐시 서비스
│ │ ├── BillHistoryService.java # 이력 서비스
│ │ ├── BillInquiryService.java # 조회 서비스 인터페이스
│ │ ├── BillInquiryServiceImpl.java # 조회 서비스 구현
│ │ └── KosClientService.java # KOS 연동 서비스
│ └── model/ # 외부 시스템 모델
│ ├── KosRequest.java # KOS 요청
│ └── KosResponse.java # KOS 응답
└── src/main/resources/
├── application.yml # 기본 설정
├── application-dev.yml # 개발환경 설정
└── application-prod.yml # 운영환경 설정
```
## 🔧 설치 및 실행
### 사전 요구사항
- Java 17
- MySQL 8.0+
- Redis 6.0+
- Maven 3.8+
### 데이터베이스 설정
```sql
-- 데이터베이스 생성
CREATE DATABASE bill_service_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE bill_service_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 사용자 생성 및 권한 부여
CREATE USER 'dev_user'@'%' IDENTIFIED BY 'dev_pass';
GRANT ALL PRIVILEGES ON bill_service_dev.* TO 'dev_user'@'%';
CREATE USER 'bill_user'@'%' IDENTIFIED BY 'bill_pass';
GRANT ALL PRIVILEGES ON bill_service_prod.* TO 'bill_user'@'%';
FLUSH PRIVILEGES;
```
### 테이블 생성
```sql
-- 요금조회 이력 테이블
CREATE TABLE bill_inquiry_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_id VARCHAR(50) NOT NULL UNIQUE,
line_number VARCHAR(20) NOT NULL,
inquiry_month VARCHAR(7) NOT NULL,
request_time DATETIME(6) NOT NULL,
process_time DATETIME(6),
status VARCHAR(20) NOT NULL,
result_summary TEXT,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
INDEX idx_line_number (line_number),
INDEX idx_inquiry_month (inquiry_month),
INDEX idx_request_time (request_time),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 애플리케이션 실행
#### 개발환경 실행
```bash
# 소스 컴파일 및 실행
./mvnw clean compile
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# 또는 JAR 실행
./mvnw clean package
java -jar target/bill-service-1.0.0.jar --spring.profiles.active=dev
```
#### 운영환경 실행
```bash
java -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 \
-jar bill-service-1.0.0.jar
```
## 🔗 API 문서
### Swagger UI
- **개발환경**: http://localhost:8081/bill-service/swagger-ui.html
- **API Docs**: http://localhost:8081/bill-service/v3/api-docs
### 주요 API 엔드포인트
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/bills/menu` | 요금조회 메뉴 조회 |
| POST | `/api/bills/inquiry` | 요금조회 신청 |
| GET | `/api/bills/inquiry/{requestId}` | 요금조회 결과 확인 |
| GET | `/api/bills/history` | 요금조회 이력 목록 |
## 📊 모니터링
### Health Check
- **URL**: http://localhost:8081/bill-service/actuator/health
- **상태**: Database, Redis, Disk Space 상태 확인
### Metrics
- **Prometheus**: http://localhost:8081/bill-service/actuator/prometheus
- **Metrics**: http://localhost:8081/bill-service/actuator/metrics
### 로그 파일
- **개발환경**: `logs/bill-service-dev.log`
- **운영환경**: `logs/bill-service.log`
## ⚙️ 환경변수 설정
### 필수 환경변수 (운영환경)
```bash
# 데이터베이스 연결 정보
export DB_URL="jdbc:mysql://prod-db-host:3306/bill_service_prod"
export DB_USERNAME="bill_user"
export DB_PASSWORD="secure_password"
# Redis 연결 정보
export REDIS_HOST="prod-redis-host"
export REDIS_PASSWORD="redis_password"
# KOS 시스템 연동
export KOS_BASE_URL="https://kos-system.company.com"
export KOS_API_KEY="production_api_key"
export KOS_SECRET_KEY="production_secret_key"
```
## 🚀 배포 가이드
### Docker 배포
```dockerfile
FROM openjdk:17-jre-slim
COPY target/bill-service-1.0.0.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "/app.jar"]
```
### Kubernetes 배포
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: bill-service
spec:
replicas: 3
selector:
matchLabels:
app: bill-service
template:
metadata:
labels:
app: bill-service
spec:
containers:
- name: bill-service
image: bill-service:1.0.0
ports:
- containerPort: 8081
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
```
## 📈 성능 최적화
### 캐시 전략
- **요금 데이터**: 1시간 TTL
- **고객 정보**: 4시간 TTL
- **조회 가능 월**: 24시간 TTL
### Circuit Breaker 설정
- **실패율 임계값**: 50%
- **응답시간 임계값**: 10초
- **Open 상태 유지**: 60초
### 데이터베이스 최적화
- 커넥션 풀 최대 크기: 50 (운영환경)
- 배치 처리 활성화
- 쿼리 인덱스 최적화
## 🐛 트러블슈팅
### 일반적인 문제들
1. **데이터베이스 연결 실패**
- 연결 정보 확인
- 방화벽 설정 확인
- 데이터베이스 서비스 상태 확인
2. **Redis 연결 실패**
- Redis 서비스 상태 확인
- 네트워크 연결 확인
- 인증 정보 확인
3. **KOS 시스템 연동 실패**
- Circuit Breaker 상태 확인
- API 키/시크릿 키 확인
- 네트워크 연결 확인
## 👥 개발팀
- **Backend Developer**: 이개발(백엔더)
- **Email**: dev@phonebill.com
- **Version**: 1.0.0
- **Last Updated**: 2025-09-08
+96
View File
@@ -0,0 +1,96 @@
// bill-service 모듈
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
plugins {
id 'jacoco'
}
dependencies {
// Database (bill service specific)
runtimeOnly 'org.postgresql:postgresql'
implementation 'com.zaxxer:HikariCP:5.0.1'
// Redis (bill service specific)
implementation 'redis.clients:jedis:4.4.6'
// Circuit Breaker & Resilience
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.1.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.1.0'
implementation 'io.github.resilience4j:resilience4j-retry:2.1.0'
implementation 'io.github.resilience4j:resilience4j-timelimiter:2.1.0'
// Logging (bill service specific)
implementation 'org.slf4j:slf4j-api'
implementation 'ch.qos.logback:logback-classic'
// HTTP Client
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Common modules (로컬 의존성)
implementation project(':common')
// Test Dependencies (bill service specific)
testImplementation 'org.testcontainers:postgresql'
testImplementation 'redis.embedded:embedded-redis:0.7.3'
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.0'
}
tasks.named('test') {
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
csv.required = false
html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
}
}
jacoco {
toolVersion = "0.8.8"
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.80
}
}
}
}
springBoot {
buildInfo()
}
// 환경별 실행 프로필 설정
task runDev(type: JavaExec, dependsOn: 'classes') {
group = 'application'
description = 'Run the application with dev profile'
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.phonebill.bill.BillServiceApplication'
systemProperty 'spring.profiles.active', 'dev'
}
task runProd(type: JavaExec, dependsOn: 'classes') {
group = 'application'
description = 'Run the application with prod profile'
classpath = sourceSets.main.runtimeClasspath
mainClass = 'com.phonebill.bill.BillServiceApplication'
systemProperty 'spring.profiles.active', 'prod'
}
// JAR 파일명 설정
jar {
enabled = false
archiveBaseName = 'bill-service'
}
bootJar {
enabled = true
archiveBaseName = 'bill-service'
archiveClassifier = ''
}
@@ -0,0 +1,31 @@
package com.phonebill.bill;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* Bill Service 메인 애플리케이션 클래스
*
* 통신요금 조회 서비스의 메인 진입점
* - 요금조회 메뉴 제공
* - KOS 시스템 연동을 통한 요금 데이터 조회
* - Redis 캐싱을 통한 성능 최적화
* - Circuit Breaker를 통한 외부 시스템 장애 격리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@SpringBootApplication
@EnableCaching
@EnableAsync
@EnableTransactionManagement
public class BillServiceApplication {
public static void main(String[] args) {
SpringApplication.run(BillServiceApplication.class, args);
}
}
@@ -0,0 +1,211 @@
package com.phonebill.bill.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-08
*/
@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");
}
}
@@ -0,0 +1,303 @@
package com.phonebill.bill.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-08
*/
@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 getBillInquiryUrl() {
return baseUrl + "/api/bill/inquiry";
}
/**
* 상태 확인 API URL 조회
*
* @return 상태 확인 API 전체 URL
*/
public String getStatusCheckUrl() {
return baseUrl + "/api/bill/status";
}
/**
* 헬스체크 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);
}
}
@@ -0,0 +1,266 @@
package com.phonebill.bill.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* Redis 캐시 설정
*
* Redis를 활용한 캐싱 시스템 설정
* - Redis 연결 설정
* - 직렬화/역직렬화 설정
* - 캐시별 TTL 설정
* - Cache Manager 구성
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${spring.redis.host:localhost}")
private String redisHost;
@Value("${spring.redis.port:6379}")
private int redisPort;
@Value("${spring.redis.password:}")
private String redisPassword;
@Value("${spring.redis.database:0}")
private int redisDatabase;
@Value("${spring.redis.timeout:5000}")
private int redisTimeout;
/**
* Redis 연결 팩토리 구성
*
* @return Redis 연결 팩토리
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
log.info("Redis 연결 설정 - 호스트: {}, 포트: {}, DB: {}", redisHost, redisPort, redisDatabase);
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(redisHost);
config.setPort(redisPort);
config.setDatabase(redisDatabase);
if (redisPassword != null && !redisPassword.trim().isEmpty()) {
config.setPassword(redisPassword);
}
JedisConnectionFactory factory = new JedisConnectionFactory(config);
factory.setTimeout(redisTimeout);
log.info("Redis 연결 팩토리 구성 완료");
return factory;
}
/**
* Redis Template 구성
*
* @param connectionFactory Redis 연결 팩토리
* @return Redis Template
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
log.debug("Redis Template 구성 시작");
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// Key 직렬화: String 사용
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value 직렬화: JSON 사용
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
// 기본 직렬화 설정
template.setDefaultSerializer(jsonSerializer);
template.afterPropertiesSet();
log.info("Redis Template 구성 완료");
return template;
}
/**
* Cache Manager 구성
*
* @param connectionFactory Redis 연결 팩토리
* @return Cache Manager
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
log.debug("Cache Manager 구성 시작");
// 기본 캐시 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 기본 TTL: 1시간
.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 값 캐싱 비활성화
// 캐시별 개별 설정
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 요금 데이터 캐시 (1시간)
cacheConfigurations.put("billData", defaultConfig.entryTtl(Duration.ofHours(1)));
// 고객 정보 캐시 (4시간)
cacheConfigurations.put("customerInfo", defaultConfig.entryTtl(Duration.ofHours(4)));
// 조회 가능 월 캐시 (24시간)
cacheConfigurations.put("availableMonths", defaultConfig.entryTtl(Duration.ofHours(24)));
// 상품 정보 캐시 (2시간)
cacheConfigurations.put("productInfo", defaultConfig.entryTtl(Duration.ofHours(2)));
// 회선 상태 캐시 (30분)
cacheConfigurations.put("lineStatus", defaultConfig.entryTtl(Duration.ofMinutes(30)));
// 시스템 설정 캐시 (12시간)
cacheConfigurations.put("systemConfig", defaultConfig.entryTtl(Duration.ofHours(12)));
RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.transactionAware() // 트랜잭션 인식
.build();
log.info("Cache Manager 구성 완료 - 캐시 종류: {}개", cacheConfigurations.size());
return cacheManager;
}
/**
* ObjectMapper 구성
*
* @return JSON 직렬화용 ObjectMapper
*/
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// Java Time 모듈 등록 (LocalDateTime 등 지원)
mapper.registerModule(new JavaTimeModule());
// 타입 정보 포함 (다형성 지원)
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
log.debug("ObjectMapper 구성 완료");
return mapper;
}
/**
* Redis 캐시 키 생성기 구성
*
* @return 캐시 키 생성기
*/
@Bean
public org.springframework.cache.interceptor.KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder keyBuilder = new StringBuilder();
keyBuilder.append(target.getClass().getSimpleName()).append(":");
keyBuilder.append(method.getName()).append(":");
for (Object param : params) {
if (param != null) {
keyBuilder.append(param.toString()).append(":");
}
}
// 마지막 콜론 제거
String key = keyBuilder.toString();
if (key.endsWith(":")) {
key = key.substring(0, key.length() - 1);
}
return key;
};
}
/**
* Redis 연결 상태 확인
*
* @param redisTemplate Redis Template
* @return 연결 상태
*/
@Bean
public RedisHealthIndicator redisHealthIndicator(RedisTemplate<String, Object> redisTemplate) {
return new RedisHealthIndicator(redisTemplate);
}
/**
* Redis 상태 확인을 위한 헬스 인디케이터
*/
public static class RedisHealthIndicator {
private final RedisTemplate<String, Object> redisTemplate;
public RedisHealthIndicator(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* Redis 연결 상태 확인
*
* @return 연결 가능 여부
*/
public boolean isRedisAvailable() {
try {
String response = redisTemplate.getConnectionFactory().getConnection().ping();
return "PONG".equals(response);
} catch (Exception e) {
log.warn("Redis 연결 상태 확인 실패: {}", e.getMessage());
return false;
}
}
/**
* Redis 정보 조회
*
* @return Redis 서버 정보
*/
public String getRedisInfo() {
try {
return redisTemplate.getConnectionFactory().getConnection().info().toString();
} catch (Exception e) {
log.warn("Redis 정보 조회 실패: {}", e.getMessage());
return "정보 조회 실패: " + e.getMessage();
}
}
}
}
@@ -0,0 +1,212 @@
package com.phonebill.bill.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.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* RestTemplate 설정
*
* KOS 시스템 및 외부 API 연동을 위한 HTTP 클라이언트 설정
* - 연결 타임아웃 설정
* - 읽기 타임아웃 설정
* - 요청/응답 로깅 인터셉터
* - 에러 핸들러 설정
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestTemplateConfig {
private final KosProperties kosProperties;
/**
* KOS 시스템 연동용 RestTemplate 구성
*
* @param restTemplateBuilder RestTemplate 빌더
* @return KOS용 RestTemplate
*/
@Bean
public RestTemplate kosRestTemplate(RestTemplateBuilder restTemplateBuilder) {
log.info("KOS RestTemplate 구성 시작");
RestTemplate restTemplate = restTemplateBuilder
// 타임아웃 설정
.setConnectTimeout(Duration.ofMillis(kosProperties.getConnectTimeout()))
.setReadTimeout(Duration.ofMillis(kosProperties.getReadTimeout()))
// 요청 팩토리 설정
.requestFactory(() -> createRequestFactory())
// 기본 에러 핸들러 설정
.errorHandler(new RestTemplateErrorHandler())
// 요청/응답 로깅 인터셉터 추가
.additionalInterceptors(new RestTemplateLoggingInterceptor())
.build();
log.info("KOS RestTemplate 구성 완료 - 연결타임아웃: {}ms, 읽기타임아웃: {}ms",
kosProperties.getConnectTimeout(), kosProperties.getReadTimeout());
return restTemplate;
}
/**
* 일반용 RestTemplate 구성
*
* @param restTemplateBuilder RestTemplate 빌더
* @return 일반용 RestTemplate
*/
@Bean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
log.info("일반 RestTemplate 구성 시작");
RestTemplate restTemplate = restTemplateBuilder
// 기본 타임아웃 설정 (더 관대한 설정)
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(30))
// 요청 팩토리 설정
.requestFactory(() -> createRequestFactory())
// 기본 에러 핸들러 설정
.errorHandler(new RestTemplateErrorHandler())
.build();
log.info("일반 RestTemplate 구성 완료");
return restTemplate;
}
/**
* HTTP 요청 팩토리 생성
*
* @return 클라이언트 HTTP 요청 팩토리
*/
private ClientHttpRequestFactory createRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 연결 타임아웃 설정
factory.setConnectTimeout(kosProperties.getConnectTimeout());
// 읽기 타임아웃 설정
factory.setReadTimeout(kosProperties.getReadTimeout());
// 요청/응답 본문을 여러 번 읽을 수 있도록 버퍼링 활성화
return new BufferingClientHttpRequestFactory(factory);
}
/**
* RestTemplate 로깅 인터셉터
*
* 요청 및 응답 로그를 기록하는 인터셉터
*/
private class RestTemplateLoggingInterceptor implements
org.springframework.http.client.ClientHttpRequestInterceptor {
@Override
public org.springframework.http.client.ClientHttpResponse intercept(
org.springframework.http.HttpRequest request,
byte[] body,
org.springframework.http.client.ClientHttpRequestExecution execution) throws java.io.IOException {
long startTime = System.currentTimeMillis();
// 요청 로깅
if (log.isDebugEnabled()) {
log.debug("HTTP 요청 - 메소드: {}, URI: {}, 헤더: {}",
request.getMethod(), request.getURI(), request.getHeaders());
if (body.length > 0) {
log.debug("HTTP 요청 본문: {}", new String(body, java.nio.charset.StandardCharsets.UTF_8));
}
}
// 요청 실행
org.springframework.http.client.ClientHttpResponse response = execution.execute(request, body);
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// 응답 로깅
if (log.isDebugEnabled()) {
log.debug("HTTP 응답 - 상태: {}, 소요시간: {}ms, 헤더: {}",
response.getStatusCode(), duration, response.getHeaders());
try {
String responseBody = new String(
response.getBody().readAllBytes(),
java.nio.charset.StandardCharsets.UTF_8
);
log.debug("HTTP 응답 본문: {}", responseBody);
} catch (Exception e) {
log.debug("HTTP 응답 본문 읽기 실패: {}", e.getMessage());
}
}
// 성능 모니터링 로그
if (duration > kosProperties.getMonitoring().getSlowRequestThreshold()) {
log.warn("느린 HTTP 요청 감지 - URI: {}, 소요시간: {}ms, 임계값: {}ms",
request.getURI(), duration, kosProperties.getMonitoring().getSlowRequestThreshold());
}
return response;
}
}
/**
* RestTemplate 에러 핸들러
*
* HTTP 에러 응답을 커스텀 예외로 변환하는 핸들러
*/
private static class RestTemplateErrorHandler implements org.springframework.web.client.ResponseErrorHandler {
@Override
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws java.io.IOException {
return response.getStatusCode().is4xxClientError() || response.getStatusCode().is5xxServerError();
}
@Override
public void handleError(org.springframework.http.client.ClientHttpResponse response) throws java.io.IOException {
String statusCode = response.getStatusCode().toString();
String statusText = response.getStatusText();
String responseBody = "";
try {
responseBody = new String(
response.getBody().readAllBytes(),
java.nio.charset.StandardCharsets.UTF_8
);
} catch (Exception e) {
log.debug("HTTP 에러 응답 본문 읽기 실패: {}", e.getMessage());
}
log.error("HTTP 에러 응답 - 상태: {} {}, 응답 본문: {}",
statusCode, statusText, responseBody);
// 상태 코드별 예외 처리
if (response.getStatusCode().is4xxClientError()) {
throw new RuntimeException(
String.format("클라이언트 오류 - %s %s: %s", statusCode, statusText, responseBody)
);
} else if (response.getStatusCode().is5xxServerError()) {
throw new RuntimeException(
String.format("서버 오류 - %s %s: %s", statusCode, statusText, responseBody)
);
}
}
}
}
@@ -0,0 +1,228 @@
package com.phonebill.bill.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
*
* JWT 기반 인증/인가 시스템 구성
* - Stateless 인증 방식
* - API 엔드포인트별 접근 권한 설정
* - CORS 설정
* - 예외 처리 설정
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
/**
* 보안 필터 체인 구성
*
* @param http HTTP 보안 설정
* @return 보안 필터 체인
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("Security Filter Chain 구성 시작");
http
// CSRF 비활성화 (REST API는 CSRF 불필요)
.csrf(AbstractHttpConfigurer::disable)
// CORS 설정 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 세션 관리 - Stateless (JWT 사용)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 요청별 인증/인가 설정
.authorizeHttpRequests(auth -> auth
// 공개 엔드포인트 - 인증 불필요
.requestMatchers(
// Health Check
"/actuator/**",
// Swagger UI
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
// 정적 리소스
"/favicon.ico",
"/error"
).permitAll()
// OPTIONS 요청은 모두 허용 (CORS Preflight)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 요금 조회 API - 인증 필요
.requestMatchers("/api/bills/**").authenticated()
// 나머지 모든 요청 - 인증 필요
.anyRequest().authenticated()
)
// JWT 인증 필터 추가
// TODO: JWT 필터 구현 후 활성화
// .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// 예외 처리
.exceptionHandling(exception -> exception
// 인증 실패 시 처리
.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()));
})
// 권한 부족 시 처리
.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()));
})
);
log.info("Security Filter Chain 구성 완료");
return http.build();
}
/**
* CORS 설정
*
* @return CORS 설정 소스
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
log.debug("CORS 설정 구성 시작");
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 Origin 설정 (개발환경)
configuration.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:*",
"https://localhost:*",
"http://127.0.0.1:*",
"https://127.0.0.1:*"
// TODO: 운영환경 도메인 추가
));
// 허용할 HTTP 메소드
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"
));
// 자격 증명 허용 (쿠키, Authorization 헤더 등)
configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 (초)
configuration.setMaxAge(3600L);
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();
}
/**
* 인증 매니저 구성
*
* @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();
// }
}
@@ -0,0 +1,235 @@
package com.phonebill.bill.controller;
import com.phonebill.bill.dto.*;
import com.phonebill.bill.service.BillInquiryService;
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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 요금조회 관련 REST API 컨트롤러
*
* 통신요금 조회 서비스의 주요 기능을 제공:
* - UFR-BILL-010: 요금조회 메뉴 접근
* - UFR-BILL-020: 요금조회 신청 (동기/비동기 처리)
* - UFR-BILL-030: 요금조회 결과 확인
* - UFR-BILL-040: 요금조회 이력 관리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@RestController
@RequestMapping("/bills")
@RequiredArgsConstructor
@Validated
@Tag(name = "Bill Inquiry", description = "요금조회 관련 API")
public class BillController {
private final BillInquiryService billInquiryService;
/**
* 요금조회 메뉴 조회
*
* UFR-BILL-010: 요금조회 메뉴 접근
* - 고객 회선번호 표시
* - 조회월 선택 옵션 제공
* - 요금 조회 신청 버튼 활성화
*/
@GetMapping("/menu")
@Operation(
summary = "요금조회 메뉴 조회",
description = "요금조회 메뉴 화면에 필요한 정보(고객정보, 조회가능월)를 제공합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@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 = "401",
description = "인증 실패"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 내부 오류"
)
})
public ResponseEntity<ApiResponse<BillMenuResponse>> getBillMenu() {
log.info("요금조회 메뉴 조회 요청");
BillMenuResponse menuData = billInquiryService.getBillMenu();
log.info("요금조회 메뉴 조회 완료 - 고객: {}", menuData.getCustomerInfo().getCustomerId());
return ResponseEntity.ok(
ApiResponse.success(menuData, "요금조회 메뉴를 성공적으로 조회했습니다")
);
}
/**
* 요금조회 요청
*
* UFR-BILL-020: 요금조회 신청
* - 시나리오 1: 조회월 미선택 (당월 청구요금 조회)
* - 시나리오 2: 조회월 선택 (특정월 청구요금 조회)
*
* Cache-Aside 패턴과 Circuit Breaker 패턴 적용
*/
@PostMapping("/inquiry")
@Operation(
summary = "요금조회 요청",
description = "지정된 회선번호와 조회월의 요금 정보를 조회합니다. " +
"캐시 확인 후 KOS 시스템 연동을 통해 실시간 데이터를 제공합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@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 = "202",
description = "요금조회 요청 접수 (비동기 처리)"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청 데이터"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "503",
description = "KOS 시스템 장애 (Circuit Breaker Open)"
)
})
public ResponseEntity<ApiResponse<BillInquiryResponse>> inquireBill(
@Valid @RequestBody BillInquiryRequest request) {
log.info("요금조회 요청 - 회선번호: {}, 조회월: {}",
request.getLineNumber(), request.getInquiryMonth());
BillInquiryResponse response = billInquiryService.inquireBill(request);
if (response.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
response.getRequestId(), request.getLineNumber());
return ResponseEntity.ok(
ApiResponse.success(response, "요금조회가 완료되었습니다")
);
} else {
log.info("요금조회 비동기 처리 - 요청ID: {}, 상태: {}",
response.getRequestId(), response.getStatus());
return ResponseEntity.accepted().body(
ApiResponse.success(response, "요금조회 요청이 접수되었습니다")
);
}
}
/**
* 요금조회 결과 확인
*
* 비동기로 처리된 요금조회 결과를 확인합니다.
* requestId를 통해 조회 상태와 결과를 반환합니다.
*/
@GetMapping("/inquiry/{requestId}")
@Operation(
summary = "요금조회 결과 확인",
description = "비동기로 처리된 요금조회의 상태와 결과를 확인합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "요금조회 결과 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "요청 ID를 찾을 수 없음"
)
})
public ResponseEntity<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, "요금조회 결과를 조회했습니다")
);
}
/**
* 요금조회 이력 조회
*
* UFR-BILL-040: 요금조회 결과 전송 및 이력 관리
* - 요금 조회 요청 이력: MVNO → MP
* - 요금 조회 처리 이력: MP → KOS
*/
@GetMapping("/history")
@Operation(
summary = "요금조회 이력 조회",
description = "사용자의 요금조회 요청 및 처리 이력을 페이징으로 제공합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "요금조회 이력 조회 성공"
)
})
public ResponseEntity<ApiResponse<BillHistoryResponse>> getBillHistory(
@Parameter(description = "회선번호 (미입력시 인증된 사용자의 모든 회선)")
@RequestParam(required = false)
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "회선번호 형식이 올바르지 않습니다")
String lineNumber,
@Parameter(description = "조회 시작일 (YYYY-MM-DD)")
@RequestParam(required = false)
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)")
String startDate,
@Parameter(description = "조회 종료일 (YYYY-MM-DD)")
@RequestParam(required = false)
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)")
String endDate,
@Parameter(description = "페이지 번호 (1부터 시작)")
@RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "페이지 크기")
@RequestParam(defaultValue = "20") Integer size,
@Parameter(description = "처리 상태 필터")
@RequestParam(required = false) BillInquiryResponse.ProcessStatus status) {
log.info("요금조회 이력 조회 - 회선: {}, 기간: {} ~ {}, 페이지: {}/{}",
lineNumber, startDate, endDate, page, size);
BillHistoryResponse historyData = billInquiryService.getBillHistory(
lineNumber, startDate, endDate, page, size, status
);
log.info("요금조회 이력 조회 완료 - 총 {}건, 페이지: {}/{}",
historyData.getPagination().getTotalItems(),
historyData.getPagination().getCurrentPage(),
historyData.getPagination().getTotalPages());
return ResponseEntity.ok(
ApiResponse.success(historyData, "요금조회 이력을 조회했습니다")
);
}
}
@@ -0,0 +1,39 @@
package com.phonebill.bill.domain;
import jakarta.persistence.*;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 기본 시간 정보를 담는 추상 엔티티 클래스
*
* 모든 엔티티의 공통 필드인 생성일시와 수정일시를 자동 관리
* JPA Auditing을 통해 자동으로 시간 정보가 설정됨
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
/**
* 생성일시 - 엔티티가 처음 저장될 때 자동 설정
*/
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 최종 수정일시 - 엔티티가 변경될 때마다 자동 업데이트
*/
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,147 @@
package com.phonebill.bill.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* API 응답 공통 포맷 클래스
*
* 모든 API 응답에 대한 공통 구조를 제공
* - success: 성공/실패 여부
* - data: 실제 응답 데이터 (성공시)
* - error: 오류 정보 (실패시)
* - message: 응답 메시지
* - timestamp: 응답 시간
*
* @param <T> 응답 데이터 타입
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
/**
* 성공/실패 여부
*/
private boolean success;
/**
* 응답 데이터 (성공시에만 포함)
*/
private T data;
/**
* 오류 정보 (실패시에만 포함)
*/
private ErrorDetail error;
/**
* 응답 메시지
*/
private String message;
/**
* 응답 시간
*/
@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();
/**
* 성공 응답 생성
*
* @param data 응답 데이터
* @param message 성공 메시지
* @param <T> 데이터 타입
* @return 성공 응답
*/
public static <T> ApiResponse<T> success(T data, String message) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.message(message)
.build();
}
/**
* 성공 응답 생성 (기본 메시지)
*
* @param data 응답 데이터
* @param <T> 데이터 타입
* @return 성공 응답
*/
public static <T> ApiResponse<T> success(T data) {
return success(data, "요청이 성공적으로 처리되었습니다");
}
/**
* 실패 응답 생성
*
* @param error 오류 정보
* @param message 오류 메시지
* @return 실패 응답
*/
public static ApiResponse<Void> failure(ErrorDetail error, String message) {
return ApiResponse.<Void>builder()
.success(false)
.error(error)
.message(message)
.build();
}
/**
* 실패 응답 생성 (단순 오류)
*
* @param code 오류 코드
* @param message 오류 메시지
* @return 실패 응답
*/
public static ApiResponse<Void> failure(String code, String message) {
ErrorDetail error = ErrorDetail.builder()
.code(code)
.message(message)
.build();
return failure(error, message);
}
}
/**
* 오류 상세 정보 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
class ErrorDetail {
/**
* 오류 코드
*/
private String code;
/**
* 오류 메시지
*/
private String message;
/**
* 상세 오류 정보
*/
private String detail;
/**
* 오류 발생 시간
*/
@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();
}
@@ -0,0 +1,23 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 요금 상세 정보 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BillDetailInfo {
private String itemName;
private String itemType;
private BigDecimal amount;
private String description;
private String category;
}
@@ -0,0 +1,19 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 요금조회 이력 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class BillHistoryRequest {
private String userId;
private String startDate;
private String endDate;
private int page;
private int size;
}
@@ -0,0 +1,124 @@
package com.phonebill.bill.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 요금조회 이력 응답 DTO
*
* 요금조회 이력 목록을 담는 응답 객체
* - 이력 항목 리스트
* - 페이징 정보
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BillHistoryResponse {
/**
* 요금조회 이력 목록
*/
private List<BillHistoryItem> items;
/**
* 페이징 정보
*/
private PaginationInfo pagination;
/**
* 요금조회 이력 항목 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BillHistoryItem {
/**
* 요금조회 요청 ID
*/
private String requestId;
/**
* 회선번호
*/
private String lineNumber;
/**
* 조회월 (YYYY-MM 형식)
*/
private String inquiryMonth;
/**
* 요청일시
*/
private LocalDateTime requestTime;
/**
* 처리일시
*/
private LocalDateTime processTime;
/**
* 처리 결과
*/
private BillInquiryResponse.ProcessStatus status;
/**
* 결과 요약 (성공시 요금제명과 금액)
*/
private String resultSummary;
}
/**
* 페이징 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PaginationInfo {
/**
* 현재 페이지
*/
private Integer currentPage;
/**
* 전체 페이지 수
*/
private Integer totalPages;
/**
* 전체 항목 수
*/
private Long totalItems;
/**
* 페이지 크기
*/
private Integer pageSize;
/**
* 다음 페이지 존재 여부
*/
private Boolean hasNext;
/**
* 이전 페이지 존재 여부
*/
private Boolean hasPrevious;
}
}
@@ -0,0 +1,47 @@
package com.phonebill.bill.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 요금조회 요청 DTO
*
* 요금조회 API 요청에 필요한 데이터를 담는 객체
* - 회선번호 (필수): 조회할 대상 회선
* - 조회월 (선택): 특정월 조회시 사용, 미입력시 당월 조회
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BillInquiryRequest {
/**
* 조회할 회선번호 (필수)
* 010-XXXX-XXXX 형식만 허용
*/
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(
regexp = "^010-\\d{4}-\\d{4}$",
message = "회선번호는 010-XXXX-XXXX 형식이어야 합니다"
)
private String lineNumber;
/**
* 조회월 (선택)
* YYYY-MM 형식, 미입력시 당월 조회
*/
@Pattern(
regexp = "^\\d{4}-\\d{2}$",
message = "조회월은 YYYY-MM 형식이어야 합니다"
)
private String inquiryMonth;
}
@@ -0,0 +1,187 @@
package com.phonebill.bill.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 요금조회 응답 DTO
*
* 요금조회 결과를 담는 응답 객체
* - 요청 ID: 조회 요청 추적용
* - 처리 상태: COMPLETED, PROCESSING, FAILED
* - 요금 정보: KOS에서 조회된 실제 요금 데이터
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BillInquiryResponse {
/**
* 요금조회 요청 ID
*/
private String requestId;
/**
* 처리 상태
* - COMPLETED: 조회 완료
* - PROCESSING: 처리 중
* - FAILED: 조회 실패
*/
private ProcessStatus status;
/**
* 요금 정보 (COMPLETED 상태일 때만 포함)
*/
private BillInfo billInfo;
/**
* 처리 상태 열거형
*/
public enum ProcessStatus {
COMPLETED, PROCESSING, FAILED
}
/**
* 요금 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class BillInfo {
/**
* 현재 이용 중인 요금제
*/
private String productName;
/**
* 계약 약정 조건
*/
private String contractInfo;
/**
* 요금 청구 월 (YYYY-MM 형식)
*/
private String billingMonth;
/**
* 청구 요금 금액 (원)
*/
private Integer totalAmount;
/**
* 적용된 할인 내역
*/
private List<DiscountInfo> discountInfo;
/**
* 사용량 정보
*/
private UsageInfo usage;
/**
* 중도 해지 시 비용 (원)
*/
private Integer terminationFee;
/**
* 단말기 할부 잔액 (원)
*/
private Integer deviceInstallment;
/**
* 납부 정보
*/
private PaymentInfo paymentInfo;
}
/**
* 할인 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DiscountInfo {
/**
* 할인 명칭
*/
private String name;
/**
* 할인 금액 (원)
*/
private Integer amount;
}
/**
* 사용량 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UsageInfo {
/**
* 통화 사용량
*/
private String voice;
/**
* SMS 사용량
*/
private String sms;
/**
* 데이터 사용량
*/
private String data;
}
/**
* 납부 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PaymentInfo {
/**
* 요금 청구일 (YYYY-MM-DD 형식)
*/
private String billingDate;
/**
* 납부 상태 (PAID, UNPAID, OVERDUE)
*/
private PaymentStatus paymentStatus;
/**
* 납부 방법
*/
private String paymentMethod;
}
/**
* 납부 상태 열거형
*/
public enum PaymentStatus {
PAID, UNPAID, OVERDUE
}
}
@@ -0,0 +1,62 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 요금조회 메뉴 응답 DTO
*
* 요금조회 메뉴 화면에 필요한 정보를 담는 응답 객체
* - 고객 정보 (고객ID, 회선번호)
* - 조회 가능한 월 목록
* - 기본 선택된 현재 월
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BillMenuResponse {
/**
* 고객 정보
*/
private CustomerInfo customerInfo;
/**
* 조회 가능한 월 목록 (YYYY-MM 형식)
*/
private List<String> availableMonths;
/**
* 기본 선택된 현재 월 (YYYY-MM 형식)
*/
private String currentMonth;
/**
* 고객 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class CustomerInfo {
/**
* 고객 ID
*/
private String customerId;
/**
* 회선번호 (010-XXXX-XXXX 형식)
*/
private String lineNumber;
}
}
@@ -0,0 +1,20 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 요금조회 상태 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BillStatusResponse {
private String requestId;
private String status;
private String message;
private String processedAt;
}
@@ -0,0 +1,24 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 할인 정보 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiscountInfo {
private String discountName;
private String discountType;
private BigDecimal discountAmount;
private String description;
private String validFrom;
private String validTo;
}
@@ -0,0 +1,24 @@
package com.phonebill.bill.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 사용량 정보 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UsageInfo {
private String serviceType;
private Long usageAmount;
private String unit;
private BigDecimal unitPrice;
private BigDecimal totalAmount;
private String description;
}
@@ -0,0 +1,129 @@
package com.phonebill.bill.exception;
/**
* 요금조회 관련 비즈니스 예외 클래스
*
* 요금조회 프로세스에서 발생하는 비즈니스 로직 오류를 처리
* - 유효하지 않은 회선번호
* - 조회 불가능한 월
* - 고객 정보 불일치
* - 요금 데이터 없음
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
public class BillInquiryException extends BusinessException {
/**
* 기본 생성자
*
* @param message 오류 메시지
*/
public BillInquiryException(String message) {
super("BILL_INQUIRY_ERROR", message);
}
/**
* 상세 정보를 포함한 생성자
*
* @param message 오류 메시지
* @param detail 상세 오류 정보
*/
public BillInquiryException(String message, String detail) {
super("BILL_INQUIRY_ERROR", message, detail);
}
/**
* 원인 예외를 포함한 생성자
*
* @param message 오류 메시지
* @param cause 원인 예외
*/
public BillInquiryException(String message, Throwable cause) {
super("BILL_INQUIRY_ERROR", message, cause);
}
// 특정 오류 상황을 위한 정적 팩토리 메소드들
/**
* 사용자 정의 오류 코드를 포함한 생성자
*
* @param errorCode 오류 코드
* @param message 오류 메시지
* @param detail 상세 정보
*/
public BillInquiryException(String errorCode, String message, String detail) {
super(errorCode, message, detail);
}
/**
* 유효하지 않은 회선번호 예외
*
* @param lineNumber 회선번호
* @return BillInquiryException
*/
public static BillInquiryException invalidLineNumber(String lineNumber) {
return new BillInquiryException("INVALID_LINE_NUMBER",
String.format("유효하지 않은 회선번호: %s", lineNumber), null);
}
/**
* 요금 데이터를 찾을 수 없음 예외
*
* @param requestId 요청 ID
* @param type 데이터 타입
* @return BillInquiryException
*/
public static BillInquiryException billDataNotFound(String requestId, String type) {
return new BillInquiryException("BILL_DATA_NOT_FOUND",
String.format("요금 데이터 없음 - %s: %s", type, requestId), null);
}
/**
* 조회 불가능한 월 예외
*
* @param inquiryMonth 조회 월
* @return BillInquiryException
*/
public static BillInquiryException invalidInquiryMonth(String inquiryMonth) {
return new BillInquiryException("INVALID_INQUIRY_MONTH",
String.format("조회 불가능한 월: %s", inquiryMonth));
}
/**
* 고객 정보 불일치 예외
*
* @param customerId 고객 ID
* @param lineNumber 회선번호
* @return BillInquiryException
*/
public static BillInquiryException customerMismatch(String customerId, String lineNumber) {
return new BillInquiryException("CUSTOMER_MISMATCH",
String.format("고객 정보 불일치 - 고객ID: %s, 회선번호: %s", customerId, lineNumber));
}
/**
* 요금 데이터 없음 예외
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회 월
* @return BillInquiryException
*/
public static BillInquiryException noBillData(String lineNumber, String inquiryMonth) {
return new BillInquiryException("NO_BILL_DATA",
String.format("요금 데이터 없음 - 회선번호: %s, 조회월: %s", lineNumber, inquiryMonth));
}
/**
* 요금조회 권한 없음 예외
*
* @param customerId 고객 ID
* @return BillInquiryException
*/
public static BillInquiryException noPermission(String customerId) {
return new BillInquiryException("NO_PERMISSION",
String.format("요금조회 권한 없음 - 고객ID: %s", customerId));
}
}
@@ -0,0 +1,84 @@
package com.phonebill.bill.exception;
/**
* 비즈니스 로직 예외를 위한 기본 예외 클래스
*
* 애플리케이션의 비즈니스 규칙 위반이나 예상 가능한 오류 상황을 표현
* 모든 비즈니스 예외의 부모 클래스로 사용
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
public abstract class BusinessException extends RuntimeException {
/**
* 오류 코드
*/
private final String errorCode;
/**
* 상세 오류 정보
*/
private final String detail;
/**
* 생성자
*
* @param errorCode 오류 코드
* @param message 오류 메시지
*/
protected BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
this.detail = null;
}
/**
* 생성자 (상세 정보 포함)
*
* @param errorCode 오류 코드
* @param message 오류 메시지
* @param detail 상세 오류 정보
*/
protected BusinessException(String errorCode, String message, String detail) {
super(message);
this.errorCode = errorCode;
this.detail = detail;
}
/**
* 생성자 (원인 예외 포함)
*
* @param errorCode 오류 코드
* @param message 오류 메시지
* @param cause 원인 예외
*/
protected BusinessException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.detail = null;
}
/**
* 생성자 (모든 정보 포함)
*
* @param errorCode 오류 코드
* @param message 오류 메시지
* @param detail 상세 오류 정보
* @param cause 원인 예외
*/
protected BusinessException(String errorCode, String message, String detail, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.detail = detail;
}
public String getErrorCode() {
return errorCode;
}
public String getDetail() {
return detail;
}
}
@@ -0,0 +1,131 @@
package com.phonebill.bill.exception;
/**
* Circuit Breaker Open 상태 예외 클래스
*
* Circuit Breaker가 Open 상태일 때 발생하는 예외
* 외부 시스템의 장애나 응답 지연으로 인해 요청이 차단되는 상황을 처리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
public class CircuitBreakerException extends BusinessException {
/**
* 서비스명
*/
private final String serviceName;
/**
* Circuit Breaker 상태 정보
*/
private final String stateInfo;
/**
* 기본 생성자
*
* @param serviceName 서비스명
* @param message 오류 메시지
*/
public CircuitBreakerException(String serviceName, String message) {
super("CIRCUIT_BREAKER_OPEN", message);
this.serviceName = serviceName;
this.stateInfo = null;
}
/**
* 상태 정보를 포함한 생성자
*
* @param serviceName 서비스명
* @param message 오류 메시지
* @param stateInfo 상태 정보
*/
public CircuitBreakerException(String serviceName, String message, String stateInfo) {
super("CIRCUIT_BREAKER_OPEN", message, stateInfo);
this.serviceName = serviceName;
this.stateInfo = stateInfo;
}
/**
* 원인 예외를 포함한 생성자
*
* @param serviceName 서비스명
* @param message 오류 메시지
* @param cause 원인 예외
*/
public CircuitBreakerException(String serviceName, String message, Throwable cause) {
super("CIRCUIT_BREAKER_OPEN", message, cause);
this.serviceName = serviceName;
this.stateInfo = null;
}
public String getServiceName() {
return serviceName;
}
public String getStateInfo() {
return stateInfo;
}
// 특정 오류 상황을 위한 정적 팩토리 메소드들
/**
* Circuit Breaker Open 상태 예외
*
* @param serviceName 서비스명
* @return 예외 인스턴스
*/
public static CircuitBreakerException circuitBreakerOpen(String serviceName) {
return new CircuitBreakerException(
serviceName,
"일시적으로 서비스 이용이 어렵습니다",
String.format("%s 서비스가 일시적으로 중단되었습니다. 잠시 후 다시 시도해주세요.", serviceName)
);
}
/**
* Circuit Breaker Open 상태 예외 (상세 정보 포함)
*
* @param serviceName 서비스명
* @param failureRate 실패율
* @param slowCallRate 느린 호출 비율
* @return 예외 인스턴스
*/
public static CircuitBreakerException circuitBreakerOpenWithDetails(String serviceName, double failureRate, double slowCallRate) {
return new CircuitBreakerException(
serviceName,
"서비스 품질 저하로 인해 일시적으로 차단되었습니다",
String.format("서비스: %s, 실패율: %.2f%%, 느린 호출 비율: %.2f%%", serviceName, failureRate * 100, slowCallRate * 100)
);
}
/**
* Circuit Breaker Half-Open 상태에서 호출 차단 예외
*
* @param serviceName 서비스명
* @return 예외 인스턴스
*/
public static CircuitBreakerException callNotPermittedInHalfOpenState(String serviceName) {
return new CircuitBreakerException(
serviceName,
"서비스 상태 확인 중입니다",
String.format("%s 서비스의 상태를 확인하는 중이므로 잠시 후 다시 시도해주세요.", serviceName)
);
}
/**
* Circuit Breaker 설정 오류 예외
*
* @param serviceName 서비스명
* @param configError 설정 오류 내용
* @return 예외 인스턴스
*/
public static CircuitBreakerException configurationError(String serviceName, String configError) {
return new CircuitBreakerException(
serviceName,
"Circuit Breaker 설정에 문제가 있습니다",
String.format("서비스: %s, 설정 오류: %s", serviceName, configError)
);
}
}
@@ -0,0 +1,224 @@
package com.phonebill.bill.exception;
import com.phonebill.bill.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 전역 예외 처리 핸들러
*
* 애플리케이션에서 발생하는 모든 예외를 일관된 형태로 처리
* - 비즈니스 예외: 예상 가능한 오류 상황
* - 시스템 예외: 예상치 못한 시스템 오류
* - 검증 예외: 입력값 검증 실패
* - HTTP 예외: HTTP 프로토콜 관련 오류
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 요금조회 관련 비즈니스 예외 처리
*/
@ExceptionHandler(BillInquiryException.class)
public ResponseEntity<ApiResponse<Void>> handleBillInquiryException(
BillInquiryException ex, HttpServletRequest request) {
log.warn("요금조회 비즈니스 예외 발생: {} - {}", ex.getErrorCode(), ex.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
}
/**
* KOS 연동 예외 처리
*/
@ExceptionHandler(KosConnectionException.class)
public ResponseEntity<ApiResponse<Void>> handleKosConnectionException(
KosConnectionException ex, HttpServletRequest request) {
log.error("KOS 연동 오류 발생: {} - {}, 서비스: {}",
ex.getErrorCode(), ex.getMessage(), ex.getServiceName());
// KOS 연동 오류는 503 Service Unavailable로 응답
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
}
/**
* Circuit Breaker 예외 처리
*/
@ExceptionHandler(CircuitBreakerException.class)
public ResponseEntity<ApiResponse<Void>> handleCircuitBreakerException(
CircuitBreakerException ex, HttpServletRequest request) {
log.warn("Circuit Breaker 예외 발생: {} - {}, 서비스: {}",
ex.getErrorCode(), ex.getMessage(), ex.getServiceName());
// Circuit Breaker 오류는 503 Service Unavailable로 응답
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
}
/**
* 일반 비즈니스 예외 처리
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(
BusinessException ex, HttpServletRequest request) {
log.warn("비즈니스 예외 발생: {} - {}", ex.getErrorCode(), ex.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
}
/**
* Bean Validation 예외 처리 (@Valid 어노테이션)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
MethodArgumentNotValidException ex) {
log.warn("입력값 검증 실패: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}
/**
* Bean Validation 예외 처리 (@ModelAttribute)
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleBindException(BindException ex) {
log.warn("바인딩 검증 실패: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}
/**
* Constraint Validation 예외 처리 (경로 변수, 요청 파라미터)
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleConstraintViolationException(
ConstraintViolationException ex) {
log.warn("제약조건 검증 실패: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
String fieldName = violation.getPropertyPath().toString();
errors.put(fieldName, violation.getMessage());
}
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.success(false)
.data(errors)
.message("입력값이 올바르지 않습니다")
.timestamp(LocalDateTime.now())
.build());
}
/**
* 필수 요청 파라미터 누락 예외 처리
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParameterException(
MissingServletRequestParameterException ex) {
log.warn("필수 파라미터 누락: {}", ex.getMessage());
String message = String.format("필수 파라미터가 누락되었습니다: %s", ex.getParameterName());
return ResponseEntity.badRequest()
.body(ApiResponse.failure("MISSING_PARAMETER", message));
}
/**
* 타입 불일치 예외 처리
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(
MethodArgumentTypeMismatchException ex) {
log.warn("파라미터 타입 불일치: {}", ex.getMessage());
String message = String.format("파라미터 '%s'의 값이 올바르지 않습니다", ex.getName());
return ResponseEntity.badRequest()
.body(ApiResponse.failure("INVALID_PARAMETER_TYPE", message));
}
/**
* HTTP 메소드 지원하지 않음 예외 처리
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(
HttpRequestMethodNotSupportedException ex) {
log.warn("지원하지 않는 HTTP 메소드: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.failure("METHOD_NOT_ALLOWED",
"지원하지 않는 HTTP 메소드입니다"));
}
/**
* JSON 파싱 오류 예외 처리
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException(
HttpMessageNotReadableException ex) {
log.warn("JSON 파싱 오류: {}", ex.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.failure("INVALID_JSON_FORMAT",
"요청 데이터 형식이 올바르지 않습니다"));
}
/**
* 기타 모든 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(
Exception ex, HttpServletRequest request) {
log.error("예상치 못한 시스템 오류 발생: ", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.failure("INTERNAL_SERVER_ERROR",
"서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요"));
}
}
@@ -0,0 +1,127 @@
package com.phonebill.bill.exception;
/**
* KOS 시스템 연동 관련 예외 클래스
*
* KOS(통신사 백엔드 시스템)와의 연동에서 발생하는 오류를 처리
* - 네트워크 연결 실패
* - 응답 시간 초과
* - KOS API 오류 응답
* - 데이터 변환 오류
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
public class KosConnectionException extends BusinessException {
/**
* 연동 서비스명
*/
private final String serviceName;
/**
* 기본 생성자
*
* @param serviceName 연동 서비스명
* @param message 오류 메시지
*/
public KosConnectionException(String serviceName, String message) {
super("KOS_CONNECTION_ERROR", message);
this.serviceName = serviceName;
}
/**
* 상세 정보를 포함한 생성자
*
* @param serviceName 연동 서비스명
* @param message 오류 메시지
* @param detail 상세 오류 정보
*/
public KosConnectionException(String serviceName, String message, String detail) {
super("KOS_CONNECTION_ERROR", message, detail);
this.serviceName = serviceName;
}
/**
* 원인 예외를 포함한 생성자
*
* @param serviceName 연동 서비스명
* @param message 오류 메시지
* @param cause 원인 예외
*/
public KosConnectionException(String serviceName, String message, Throwable cause) {
super("KOS_CONNECTION_ERROR", message, cause);
this.serviceName = serviceName;
}
public String getServiceName() {
return serviceName;
}
// 특정 오류 상황을 위한 정적 팩토리 메소드들
/**
* 연결 시간 초과 예외
*
* @param serviceName 서비스명
* @param timeout 시간 초과 값(초)
* @return KosConnectionException
*/
public static KosConnectionException timeout(String serviceName, int timeout) {
return new KosConnectionException(serviceName,
String.format("KOS 연결 시간 초과 (%d초)", timeout));
}
/**
* 네트워크 연결 실패 예외
*
* @param serviceName 서비스명
* @param host 호스트명
* @param port 포트번호
* @return KosConnectionException
*/
public static KosConnectionException connectionFailed(String serviceName, String host, int port) {
return new KosConnectionException(serviceName,
String.format("KOS 연결 실패 - %s:%d", host, port));
}
/**
* KOS API 오류 응답 예외
*
* @param serviceName 서비스명
* @param errorCode KOS 오류 코드
* @param errorMessage KOS 오류 메시지
* @return KosConnectionException
*/
public static KosConnectionException apiError(String serviceName, String errorCode, String errorMessage) {
return new KosConnectionException(serviceName, errorCode,
String.format("KOS API 오류 - 코드: %s, 메시지: %s", errorCode, errorMessage));
}
/**
* 데이터 변환 오류 예외
*
* @param serviceName 서비스명
* @param dataType 데이터 타입
* @param cause 원인 예외
* @return KosConnectionException
*/
public static KosConnectionException dataConversionError(String serviceName, String dataType, Throwable cause) {
return new KosConnectionException(serviceName,
String.format("KOS 데이터 변환 오류 - 타입: %s", dataType), cause);
}
/**
* 네트워크 오류 예외
*
* @param serviceName 서비스명
* @param cause 원인 예외
* @return KosConnectionException
*/
public static KosConnectionException networkError(String serviceName, Throwable cause) {
return new KosConnectionException(serviceName,
"KOS 네트워크 연결 오류", cause);
}
}
@@ -0,0 +1,203 @@
package com.phonebill.bill.external;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* KOS 시스템 요청 모델
*
* 통신사 백엔드 시스템(KOS)으로 전송하는 요청 데이터 구조
* - 요금조회 요청에 필요한 정보 포함
* - KOS API 스펙에 맞춘 필드명 매핑
* - 요청 추적을 위한 메타 정보 포함
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KosRequest {
/**
* 회선번호 (KOS 필드명: lineNum)
*/
@JsonProperty("lineNum")
private String lineNumber;
/**
* 조회월 (KOS 필드명: searchMonth, YYYY-MM 형식)
*/
@JsonProperty("searchMonth")
private String inquiryMonth;
/**
* 서비스 구분 코드 (KOS 필드명: svcDiv)
* - BILL_INQ: 요금조회
* - BILL_DETAIL: 상세조회
*/
@JsonProperty("svcDiv")
@Builder.Default
private String serviceCode = "BILL_INQ";
/**
* 요청 시스템 코드 (KOS 필드명: reqSysCode)
*/
@JsonProperty("reqSysCode")
@Builder.Default
private String requestSystemCode = "MVNO";
/**
* 요청 채널 코드 (KOS 필드명: reqChnlCode)
* - WEB: 웹
* - APP: 모바일앱
* - API: API
*/
@JsonProperty("reqChnlCode")
@Builder.Default
private String requestChannelCode = "API";
/**
* 요청자 ID (KOS 필드명: reqUserId)
*/
@JsonProperty("reqUserId")
private String requestUserId;
/**
* 요청일시 (KOS 필드명: reqDttm, YYYY-MM-DD HH:MM:SS 형식)
*/
@JsonProperty("reqDttm")
private LocalDateTime requestTime;
/**
* 요청 고유번호 (KOS 필드명: reqSeqNo)
*/
@JsonProperty("reqSeqNo")
private String requestSequenceNumber;
/**
* 고객 구분 코드 (KOS 필드명: custDiv)
* - PERS: 개인
* - CORP: 법인
*/
@JsonProperty("custDiv")
@Builder.Default
private String customerType = "PERS";
/**
* 인증 토큰 (KOS 필드명: authToken)
*/
@JsonProperty("authToken")
private String authToken;
/**
* 응답 형식 (KOS 필드명: respFormat)
*/
@JsonProperty("respFormat")
@Builder.Default
private String responseFormat = "JSON";
/**
* 타임아웃 설정 (초, KOS 필드명: timeout)
*/
@JsonProperty("timeout")
@Builder.Default
private Integer timeout = 30;
// === Static Factory Methods ===
/**
* 요금조회 요청 생성
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @param requestUserId 요청자 ID
* @return KOS 요청 객체
*/
public static KosRequest createBillInquiryRequest(String lineNumber, String inquiryMonth, String requestUserId) {
return KosRequest.builder()
.lineNumber(lineNumber)
.inquiryMonth(inquiryMonth)
.requestUserId(requestUserId)
.requestTime(LocalDateTime.now())
.requestSequenceNumber(generateSequenceNumber())
.serviceCode("BILL_INQ")
.build();
}
/**
* 상세조회 요청 생성
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @param requestUserId 요청자 ID
* @return KOS 요청 객체
*/
public static KosRequest createBillDetailRequest(String lineNumber, String inquiryMonth, String requestUserId) {
return KosRequest.builder()
.lineNumber(lineNumber)
.inquiryMonth(inquiryMonth)
.requestUserId(requestUserId)
.requestTime(LocalDateTime.now())
.requestSequenceNumber(generateSequenceNumber())
.serviceCode("BILL_DETAIL")
.build();
}
// === Helper Methods ===
/**
* 요청 순번 생성
*
* @return 요청 순번
*/
private static String generateSequenceNumber() {
return String.valueOf(System.currentTimeMillis());
}
/**
* 인증 토큰 설정
*
* @param authToken 인증 토큰
*/
public void setAuthToken(String authToken) {
this.authToken = authToken;
}
/**
* 요청자 ID 설정
*
* @param requestUserId 요청자 ID
*/
public void setRequestUserId(String requestUserId) {
this.requestUserId = requestUserId;
}
/**
* 요청 유효성 검증
*
* @return 유효한 요청인지 여부
*/
public boolean isValid() {
return lineNumber != null && !lineNumber.trim().isEmpty() &&
inquiryMonth != null && !inquiryMonth.trim().isEmpty() &&
requestUserId != null && !requestUserId.trim().isEmpty();
}
/**
* 요청 정보 요약
*
* @return 요청 요약 정보
*/
public String getSummary() {
return String.format("KOS 요청 - 회선: %s, 조회월: %s, 서비스: %s",
lineNumber, inquiryMonth, serviceCode);
}
}
@@ -0,0 +1,344 @@
package com.phonebill.bill.external;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* KOS 시스템 응답 모델
*
* 통신사 백엔드 시스템(KOS)에서 수신하는 응답 데이터 구조
* - 요금조회 결과 데이터 포함
* - KOS API 스펙에 맞춘 필드명 매핑
* - 내부 모델로 변환하기 위한 구조 제공
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KosResponse {
/**
* 요청 ID (KOS 필드명: reqId)
*/
@JsonProperty("reqId")
private String requestId;
/**
* 처리 상태 (KOS 필드명: procStatus)
* - SUCCESS: 성공
* - PROCESSING: 처리 중
* - FAILED: 실패
*/
@JsonProperty("procStatus")
private String status;
/**
* 결과 코드 (KOS 필드명: resultCode)
* - 0000: 성공
* - 기타: 오류 코드
*/
@JsonProperty("resultCode")
private String resultCode;
/**
* 결과 메시지 (KOS 필드명: resultMsg)
*/
@JsonProperty("resultMsg")
private String resultMessage;
/**
* 응답일시 (KOS 필드명: respDttm)
*/
@JsonProperty("respDttm")
private LocalDateTime responseTime;
/**
* 처리 시간 (밀리초, KOS 필드명: procTimeMs)
*/
@JsonProperty("procTimeMs")
private Long processingTimeMs;
/**
* 요금 데이터 (KOS 필드명: billData)
*/
@JsonProperty("billData")
private BillData billData;
/**
* 추가 정보 (KOS 필드명: addInfo)
*/
@JsonProperty("addInfo")
private String additionalInfo;
/**
* 요금 데이터 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BillData {
/**
* 요금제명 (KOS 필드명: prodNm)
*/
@JsonProperty("prodNm")
private String productName;
/**
* 계약 정보 (KOS 필드명: contractInfo)
*/
@JsonProperty("contractInfo")
private String contractInfo;
/**
* 청구월 (KOS 필드명: billMonth)
*/
@JsonProperty("billMonth")
private String billingMonth;
/**
* 청구 금액 (KOS 필드명: billAmt)
*/
@JsonProperty("billAmt")
private Integer totalAmount;
/**
* 할인 정보 목록 (KOS 필드명: discList)
*/
@JsonProperty("discList")
private List<DiscountData> discounts;
/**
* 사용량 정보 (KOS 필드명: usageInfo)
*/
@JsonProperty("usageInfo")
private UsageData usage;
/**
* 위약금 (KOS 필드명: penaltyAmt)
*/
@JsonProperty("penaltyAmt")
private Integer terminationFee;
/**
* 할부금 잔액 (KOS 필드명: installAmt)
*/
@JsonProperty("installAmt")
private Integer deviceInstallment;
/**
* 결제 정보 (KOS 필드명: payInfo)
*/
@JsonProperty("payInfo")
private PaymentData payment;
}
/**
* 할인 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DiscountData {
/**
* 할인명 (KOS 필드명: discNm)
*/
@JsonProperty("discNm")
private String name;
/**
* 할인 금액 (KOS 필드명: discAmt)
*/
@JsonProperty("discAmt")
private Integer amount;
/**
* 할인 유형 (KOS 필드명: discType)
*/
@JsonProperty("discType")
private String type;
/**
* 할인 기간 (KOS 필드명: discPeriod)
*/
@JsonProperty("discPeriod")
private String period;
}
/**
* 사용량 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UsageData {
/**
* 통화 사용량 (KOS 필드명: voiceUsage)
*/
@JsonProperty("voiceUsage")
private String voice;
/**
* SMS 사용량 (KOS 필드명: smsUsage)
*/
@JsonProperty("smsUsage")
private String sms;
/**
* 데이터 사용량 (KOS 필드명: dataUsage)
*/
@JsonProperty("dataUsage")
private String data;
/**
* 기본료 (KOS 필드명: basicFee)
*/
@JsonProperty("basicFee")
private Integer basicFee;
/**
* 초과료 (KOS 필드명: overageFee)
*/
@JsonProperty("overageFee")
private Integer overageFee;
}
/**
* 결제 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PaymentData {
/**
* 청구일 (KOS 필드명: billDate)
*/
@JsonProperty("billDate")
private String billingDate;
/**
* 결제 상태 (KOS 필드명: payStatus)
* - PAID: 결제 완료
* - UNPAID: 미결제
* - OVERDUE: 연체
*/
@JsonProperty("payStatus")
private String status;
/**
* 결제 방법 (KOS 필드명: payMethod)
*/
@JsonProperty("payMethod")
private String method;
/**
* 결제일 (KOS 필드명: payDate)
*/
@JsonProperty("payDate")
private String paymentDate;
/**
* 결제 은행 (KOS 필드명: payBank)
*/
@JsonProperty("payBank")
private String paymentBank;
/**
* 계좌번호 (마스킹, KOS 필드명: acctNum)
*/
@JsonProperty("acctNum")
private String accountNumber;
}
// === Helper Methods ===
/**
* 성공 응답인지 확인
*
* @return 성공 여부
*/
public boolean isSuccess() {
return "SUCCESS".equalsIgnoreCase(status) && "0000".equals(resultCode);
}
/**
* 처리 중 상태인지 확인
*
* @return 처리 중 여부
*/
public boolean isProcessing() {
return "PROCESSING".equalsIgnoreCase(status);
}
/**
* 실패 응답인지 확인
*
* @return 실패 여부
*/
public boolean isFailed() {
return "FAILED".equalsIgnoreCase(status) || (!"0000".equals(resultCode) && resultCode != null);
}
/**
* 요금 데이터 존재 여부 확인
*
* @return 요금 데이터 존재 여부
*/
public boolean hasBillData() {
return billData != null && billData.getTotalAmount() != null;
}
/**
* 오류 정보 조회
*
* @return 오류 정보 (결과코드: 결과메시지)
*/
public String getErrorInfo() {
if (isFailed()) {
return String.format("%s: %s", resultCode, resultMessage);
}
return null;
}
/**
* 응답 요약 정보
*
* @return 응답 요약
*/
public String getSummary() {
if (isSuccess() && hasBillData()) {
return String.format("KOS 응답 성공 - 요금제: %s, 금액: %,d원",
billData.getProductName(), billData.getTotalAmount());
} else if (isProcessing()) {
return "KOS 응답 - 처리 중";
} else {
return String.format("KOS 응답 실패 - %s", getErrorInfo());
}
}
/**
* 처리 시간이 느린지 확인 (임계값: 3초)
*
* @return 느린 응답 여부
*/
public boolean isSlowResponse() {
return processingTimeMs != null && processingTimeMs > 3000;
}
}
@@ -0,0 +1,239 @@
package com.phonebill.bill.repository;
import com.phonebill.bill.repository.entity.BillInquiryHistoryEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 요금조회 이력 Repository 인터페이스
*
* 요금조회 이력 데이터에 대한 접근을 담당하는 Repository
* - JPA를 통한 기본 CRUD 작업
* - 복합 조건 검색을 위한 커스텀 쿼리
* - 페이징 처리를 통한 대용량 데이터 조회
* - 성능 최적화를 위한 인덱스 활용 쿼리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Repository
public interface BillInquiryHistoryRepository extends JpaRepository<BillInquiryHistoryEntity, Long> {
/**
* 요청 ID로 이력 조회
*
* @param requestId 요청 ID
* @return 이력 엔티티 (Optional)
*/
Optional<BillInquiryHistoryEntity> findByRequestId(String requestId);
/**
* 회선번호로 이력 목록 조회 (최신순)
*
* @param lineNumber 회선번호
* @param pageable 페이징 정보
* @return 이력 페이지
*/
Page<BillInquiryHistoryEntity> findByLineNumberOrderByRequestTimeDesc(
String lineNumber, Pageable pageable
);
/**
* 회선번호와 상태로 이력 목록 조회
*
* @param lineNumber 회선번호
* @param status 처리 상태
* @param pageable 페이징 정보
* @return 이력 페이지
*/
Page<BillInquiryHistoryEntity> findByLineNumberAndStatusOrderByRequestTimeDesc(
String lineNumber, String status, Pageable pageable
);
/**
* 회선번호 목록으로 이력 조회 (사용자 권한 기반)
*
* @param lineNumbers 회선번호 목록
* @param pageable 페이징 정보
* @return 이력 페이지
*/
Page<BillInquiryHistoryEntity> findByLineNumberInOrderByRequestTimeDesc(
List<String> lineNumbers, Pageable pageable
);
/**
* 기간별 이력 조회
*
* @param startTime 조회 시작 시간
* @param endTime 조회 종료 시간
* @param pageable 페이징 정보
* @return 이력 페이지
*/
Page<BillInquiryHistoryEntity> findByRequestTimeBetweenOrderByRequestTimeDesc(
LocalDateTime startTime, LocalDateTime endTime, Pageable pageable
);
/**
* 복합 조건을 통한 이력 조회 (동적 쿼리)
*
* @param lineNumbers 사용자 권한이 있는 회선번호 목록
* @param lineNumber 특정 회선번호 필터 (선택)
* @param startTime 조회 시작 시간 (선택)
* @param endTime 조회 종료 시간 (선택)
* @param status 처리 상태 필터 (선택)
* @param pageable 페이징 정보
* @return 이력 페이지
*/
@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(
@Param("lineNumbers") List<String> lineNumbers,
@Param("lineNumber") String lineNumber,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("status") String status,
Pageable pageable
);
/**
* 특정 회선의 최근 이력 조회
*
* @param lineNumber 회선번호
* @param limit 조회 건수
* @return 최근 이력 목록
*/
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE h.lineNumber = :lineNumber " +
"ORDER BY h.requestTime DESC")
List<BillInquiryHistoryEntity> findRecentHistoryByLineNumber(
@Param("lineNumber") String lineNumber, Pageable pageable
);
/**
* 처리 상태별 통계 조회
*
* @param lineNumbers 회선번호 목록
* @param startTime 조회 시작 시간
* @param endTime 조회 종료 시간
* @return 상태별 개수 목록
*/
@Query("SELECT h.status, COUNT(h) FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.requestTime BETWEEN :startTime AND :endTime " +
"GROUP BY h.status")
List<Object[]> getStatusStatistics(
@Param("lineNumbers") List<String> lineNumbers,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);
/**
* 처리 시간이 긴 요청 조회 (성능 모니터링용)
*
* @param thresholdMs 임계값 (밀리초)
* @param startTime 조회 시작 시간
* @param endTime 조회 종료 시간
* @param pageable 페이징 정보
* @return 느린 요청 목록
*/
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.kosResponseTimeMs > :thresholdMs " +
"AND h.requestTime BETWEEN :startTime AND :endTime " +
"ORDER BY h.kosResponseTimeMs DESC")
Page<BillInquiryHistoryEntity> findSlowRequests(
@Param("thresholdMs") Long thresholdMs,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable
);
/**
* 캐시 히트율 통계 조회
*
* @param startTime 조회 시작 시간
* @param endTime 조회 종료 시간
* @return [총 요청 수, 캐시 히트 수]
*/
@Query("SELECT COUNT(h), SUM(CASE WHEN h.cacheHit = true THEN 1 ELSE 0 END) " +
"FROM BillInquiryHistoryEntity h WHERE " +
"h.requestTime BETWEEN :startTime AND :endTime")
Object[] getCacheHitRateStatistics(
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);
/**
* 실패한 요청 조회 (디버깅용)
*
* @param lineNumbers 회선번호 목록
* @param startTime 조회 시작 시간
* @param endTime 조회 종료 시간
* @param pageable 페이징 정보
* @return 실패한 요청 목록
*/
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers " +
"AND h.status = 'FAILED' " +
"AND h.requestTime BETWEEN :startTime AND :endTime " +
"ORDER BY h.requestTime DESC")
Page<BillInquiryHistoryEntity> findFailedRequests(
@Param("lineNumbers") List<String> lineNumbers,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable
);
/**
* 오래된 처리 중 상태 요청 조회 (데이터 정리용)
*
* @param thresholdTime 임계 시간 (이 시간 이전의 PROCESSING 상태 요청)
* @return 오래된 처리 중 요청 목록
*/
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
"h.status = 'PROCESSING' AND h.requestTime < :thresholdTime " +
"ORDER BY h.requestTime")
List<BillInquiryHistoryEntity> findOldProcessingRequests(
@Param("thresholdTime") LocalDateTime thresholdTime
);
/**
* 특정 조회월의 이력 개수 조회
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 이력 개수
*/
long countByLineNumberAndInquiryMonth(String lineNumber, String inquiryMonth);
/**
* 회선번호별 이력 개수 조회
*
* @param lineNumbers 회선번호 목록
* @return 회선번호별 이력 개수
*/
@Query("SELECT h.lineNumber, COUNT(h) FROM BillInquiryHistoryEntity h WHERE " +
"h.lineNumber IN :lineNumbers GROUP BY h.lineNumber")
List<Object[]> getHistoryCountByLineNumber(@Param("lineNumbers") List<String> lineNumbers);
/**
* 데이터 정리를 위한 오래된 이력 삭제
*
* @param beforeTime 이 시간 이전의 데이터 삭제
* @return 삭제된 레코드 수
*/
@Query("DELETE FROM BillInquiryHistoryEntity h WHERE h.requestTime < :beforeTime")
int deleteByRequestTimeBefore(@Param("beforeTime") LocalDateTime beforeTime);
}
@@ -0,0 +1,246 @@
package com.phonebill.bill.repository.entity;
import com.phonebill.bill.domain.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 요금조회 이력 엔티티
*
* 요금조회 요청 및 처리 이력을 저장하는 엔티티
* - 요청 ID를 통한 추적 가능
* - 처리 상태별 이력 관리
* - 성능을 위한 인덱스 최적화
* - 페이징 처리를 위한 정렬 기준 제공
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Entity
@Table(
name = "bill_inquiry_history",
indexes = {
@Index(name = "idx_request_id", columnList = "request_id"),
@Index(name = "idx_line_number", columnList = "line_number"),
@Index(name = "idx_request_time", columnList = "request_time"),
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_line_request_time", columnList = "line_number, request_time")
}
)
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BillInquiryHistoryEntity extends BaseTimeEntity {
/**
* 기본 키 (자동 증가)
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 요금조회 요청 ID (고유 식별자)
*/
@Column(name = "request_id", nullable = false, unique = true, length = 50)
private String requestId;
/**
* 회선번호
*/
@Column(name = "line_number", nullable = false, length = 15)
private String lineNumber;
/**
* 조회월 (YYYY-MM 형식)
*/
@Column(name = "inquiry_month", nullable = false, length = 7)
private String inquiryMonth;
/**
* 요청일시
*/
@Column(name = "request_time", nullable = false)
private LocalDateTime requestTime;
/**
* 처리일시
*/
@Column(name = "process_time")
private LocalDateTime processTime;
/**
* 처리 상태 (COMPLETED, PROCESSING, FAILED)
*/
@Column(name = "status", nullable = false, length = 20)
private String status;
/**
* 결과 요약 (성공시 요금제명과 금액, 실패시 오류 메시지)
*/
@Column(name = "result_summary", length = 500)
private String resultSummary;
/**
* KOS 응답 시간 (성능 모니터링용)
*/
@Column(name = "kos_response_time_ms")
private Long kosResponseTimeMs;
/**
* 캐시 히트 여부 (성능 모니터링용)
*/
@Column(name = "cache_hit")
private Boolean cacheHit;
/**
* 오류 코드 (실패시)
*/
@Column(name = "error_code", length = 50)
private String errorCode;
/**
* 오류 메시지 (실패시)
*/
@Column(name = "error_message", length = 1000)
private String errorMessage;
// === Business Methods ===
/**
* 상태 업데이트
*
* @param newStatus 새로운 상태
*/
public void updateStatus(String newStatus) {
this.status = newStatus;
}
/**
* 처리 시간 업데이트
*
* @param processTime 처리 완료 시간
*/
public void updateProcessTime(LocalDateTime processTime) {
this.processTime = processTime;
}
/**
* 결과 요약 업데이트
*
* @param resultSummary 결과 요약
*/
public void updateResultSummary(String resultSummary) {
this.resultSummary = resultSummary;
}
/**
* KOS 응답 시간 설정
*
* @param kosResponseTimeMs KOS 응답 시간 (밀리초)
*/
public void setKosResponseTime(Long kosResponseTimeMs) {
this.kosResponseTimeMs = kosResponseTimeMs;
}
/**
* 캐시 히트 여부 설정
*
* @param cacheHit 캐시 히트 여부
*/
public void setCacheHit(Boolean cacheHit) {
this.cacheHit = cacheHit;
}
/**
* 오류 정보 설정
*
* @param errorCode 오류 코드
* @param errorMessage 오류 메시지
*/
public void setErrorInfo(String errorCode, String errorMessage) {
this.errorCode = errorCode;
this.errorMessage = errorMessage;
this.status = "FAILED";
}
/**
* 처리 완료로 상태 변경
*
* @param resultSummary 결과 요약
*/
public void markAsCompleted(String resultSummary) {
this.status = "COMPLETED";
this.processTime = LocalDateTime.now();
this.resultSummary = resultSummary;
}
/**
* 처리 실패로 상태 변경
*
* @param errorCode 오류 코드
* @param errorMessage 오류 메시지
*/
public void markAsFailed(String errorCode, String errorMessage) {
this.status = "FAILED";
this.processTime = LocalDateTime.now();
this.errorCode = errorCode;
this.errorMessage = errorMessage;
this.resultSummary = "조회 실패: " + errorMessage;
}
/**
* 처리 중 상태인지 확인
*
* @return 처리 중 상태 여부
*/
public boolean isProcessing() {
return "PROCESSING".equals(this.status);
}
/**
* 처리 완료 상태인지 확인
*
* @return 처리 완료 상태 여부
*/
public boolean isCompleted() {
return "COMPLETED".equals(this.status);
}
/**
* 처리 실패 상태인지 확인
*
* @return 처리 실패 상태 여부
*/
public boolean isFailed() {
return "FAILED".equals(this.status);
}
/**
* 캐시에서 조회된 요청인지 확인
*
* @return 캐시 히트 여부
*/
public boolean isCacheHit() {
return Boolean.TRUE.equals(this.cacheHit);
}
/**
* 처리 소요 시간 계산 (밀리초)
*
* @return 처리 소요 시간 (밀리초), 처리 중이거나 처리시간이 없으면 null
*/
public Long getProcessingTimeMs() {
if (requestTime != null && processTime != null) {
return java.time.Duration.between(requestTime, processTime).toMillis();
}
return null;
}
}
@@ -0,0 +1,242 @@
package com.phonebill.bill.service;
import com.phonebill.bill.dto.BillInquiryResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 요금조회 캐시 서비스
*
* Redis를 활용한 요금 정보 캐싱으로 성능 최적화 구현
* Cache-Aside 패턴을 적용하여 데이터 일관성과 성능을 균형있게 관리
*
* 캐시 전략:
* - 요금 정보: 1시간 TTL (외부 시스템 연동 부하 감소)
* - 고객 정보: 4시간 TTL (변경 빈도가 낮음)
* - 조회 가능 월: 24시간 TTL (일별 업데이트)
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BillCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
// 캐시 TTL 상수
private static final Duration BILL_DATA_TTL = Duration.ofHours(1);
private static final Duration CUSTOMER_INFO_TTL = Duration.ofHours(4);
private static final Duration AVAILABLE_MONTHS_TTL = Duration.ofHours(24);
// 캐시 키 접두사
private static final String BILL_DATA_PREFIX = "bill:data:";
private static final String CUSTOMER_INFO_PREFIX = "bill:customer:";
private static final String AVAILABLE_MONTHS_PREFIX = "bill:months:";
/**
* 캐시에서 요금 데이터 조회
*
* 캐시 키: bill:data:{lineNumber}:{inquiryMonth}
* TTL: 1시간
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 캐시된 요금 데이터 (없으면 null)
*/
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth")
public BillInquiryResponse getCachedBillData(String lineNumber, String inquiryMonth) {
log.debug("요금 데이터 캐시 조회 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
try {
Object cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
BillInquiryResponse response = objectMapper.convertValue(cachedData, BillInquiryResponse.class);
log.info("요금 데이터 캐시 히트 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
return response;
}
log.debug("요금 데이터 캐시 미스 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
return null;
} catch (Exception e) {
log.error("요금 데이터 캐시 조회 오류 - 회선: {}, 조회월: {}, 오류: {}",
lineNumber, inquiryMonth, e.getMessage());
return null;
}
}
/**
* 요금 데이터를 캐시에 저장
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @param billData 요금 데이터
*/
public void cacheBillData(String lineNumber, String inquiryMonth, BillInquiryResponse billData) {
log.debug("요금 데이터 캐시 저장 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
try {
redisTemplate.opsForValue().set(cacheKey, billData, BILL_DATA_TTL);
log.info("요금 데이터 캐시 저장 완료 - 회선: {}, 조회월: {}, TTL: {}시간",
lineNumber, inquiryMonth, BILL_DATA_TTL.toHours());
} catch (Exception e) {
log.error("요금 데이터 캐시 저장 오류 - 회선: {}, 조회월: {}, 오류: {}",
lineNumber, inquiryMonth, e.getMessage());
}
}
/**
* 고객 정보 캐시 조회
*
* 캐시 키: bill:customer:{lineNumber}
* TTL: 4시간
*
* @param lineNumber 회선번호
* @return 캐시된 고객 정보 (없으면 null)
*/
public Object getCachedCustomerInfo(String lineNumber) {
log.debug("고객 정보 캐시 조회 - 회선: {}", lineNumber);
String cacheKey = CUSTOMER_INFO_PREFIX + lineNumber;
try {
Object cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
log.info("고객 정보 캐시 히트 - 회선: {}", lineNumber);
return cachedData;
}
log.debug("고객 정보 캐시 미스 - 회선: {}", lineNumber);
return null;
} catch (Exception e) {
log.error("고객 정보 캐시 조회 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
return null;
}
}
/**
* 고객 정보를 캐시에 저장
*
* @param lineNumber 회선번호
* @param customerInfo 고객 정보
*/
public void cacheCustomerInfo(String lineNumber, Object customerInfo) {
log.debug("고객 정보 캐시 저장 - 회선: {}", lineNumber);
String cacheKey = CUSTOMER_INFO_PREFIX + lineNumber;
try {
redisTemplate.opsForValue().set(cacheKey, customerInfo, CUSTOMER_INFO_TTL);
log.info("고객 정보 캐시 저장 완료 - 회선: {}, TTL: {}시간",
lineNumber, CUSTOMER_INFO_TTL.toHours());
} catch (Exception e) {
log.error("고객 정보 캐시 저장 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
}
}
/**
* 특정 회선의 요금 데이터 캐시 무효화
*
* 상품 변경 등으로 요금 정보가 변경된 경우 호출
*
* @param lineNumber 회선번호
*/
@CacheEvict(value = "billData", key = "#lineNumber + '*'")
public void evictBillDataCache(String lineNumber) {
log.info("요금 데이터 캐시 무효화 - 회선: {}", lineNumber);
try {
// 패턴을 사용한 키 삭제
String pattern = BILL_DATA_PREFIX + lineNumber + ":*";
redisTemplate.delete(redisTemplate.keys(pattern));
log.info("요금 데이터 캐시 무효화 완료 - 회선: {}", lineNumber);
} catch (Exception e) {
log.error("요금 데이터 캐시 무효화 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
}
}
/**
* 특정 월의 모든 요금 데이터 캐시 무효화
*
* 시스템 점검이나 대량 데이터 업데이트 시 사용
*
* @param inquiryMonth 조회월
*/
public void evictBillDataCacheByMonth(String inquiryMonth) {
log.info("월별 요금 데이터 캐시 무효화 - 조회월: {}", inquiryMonth);
try {
// 패턴을 사용한 키 삭제
String pattern = BILL_DATA_PREFIX + "*:" + inquiryMonth;
redisTemplate.delete(redisTemplate.keys(pattern));
log.info("월별 요금 데이터 캐시 무효화 완료 - 조회월: {}", inquiryMonth);
} catch (Exception e) {
log.error("월별 요금 데이터 캐시 무효화 오류 - 조회월: {}, 오류: {}", inquiryMonth, e.getMessage());
}
}
/**
* 전체 요금 데이터 캐시 무효화
*
* 시스템 점검이나 긴급 상황에서 사용
*/
@CacheEvict(value = "billData", allEntries = true)
public void evictAllBillDataCache() {
log.warn("전체 요금 데이터 캐시 무효화 실행");
try {
// 모든 요금 데이터 캐시 삭제
String pattern = BILL_DATA_PREFIX + "*";
redisTemplate.delete(redisTemplate.keys(pattern));
log.warn("전체 요금 데이터 캐시 무효화 완료");
} catch (Exception e) {
log.error("전체 요금 데이터 캐시 무효화 오류: {}", e.getMessage());
}
}
/**
* 캐시 상태 확인
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 캐시 존재 여부
*/
public boolean isCacheExists(String lineNumber, String inquiryMonth) {
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
return Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey));
}
/**
* 캐시 만료 시간 조회
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 캐시 만료까지 남은 시간 (초)
*/
public Long getCacheExpiry(String lineNumber, String inquiryMonth) {
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
return redisTemplate.getExpire(cacheKey);
}
}
@@ -0,0 +1,279 @@
package com.phonebill.bill.service;
import com.phonebill.bill.dto.*;
import com.phonebill.bill.exception.BillInquiryException;
import com.phonebill.bill.repository.BillInquiryHistoryRepository;
import com.phonebill.bill.repository.entity.BillInquiryHistoryEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
/**
* 요금조회 이력 관리 서비스
*
* 요금조회 요청 및 처리 이력을 관리하는 서비스
* - 비동기 이력 저장으로 응답 성능에 영향 없음
* - 페이징 처리로 대용량 이력 데이터 효율적 조회
* - 다양한 필터 조건 지원
* - 사용자별 권한 기반 이력 접근 제어
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BillHistoryService {
private final BillInquiryHistoryRepository historyRepository;
/**
* 요금조회 이력 비동기 저장
*
* 응답 성능에 영향을 주지 않도록 비동기로 처리
*
* @param requestId 요청 ID
* @param request 요금조회 요청 데이터
* @param response 요금조회 응답 데이터
*/
@Async
@Transactional
public void saveInquiryHistoryAsync(String requestId, BillInquiryRequest request, BillInquiryResponse response) {
log.debug("요금조회 이력 비동기 저장 시작 - 요청ID: {}", requestId);
try {
// 조회월 기본값 설정
String inquiryMonth = request.getInquiryMonth();
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
}
// 결과 요약 생성
String resultSummary = generateResultSummary(response);
// 이력 엔티티 생성
BillInquiryHistoryEntity historyEntity = BillInquiryHistoryEntity.builder()
.requestId(requestId)
.lineNumber(request.getLineNumber())
.inquiryMonth(inquiryMonth)
.requestTime(LocalDateTime.now())
.processTime(LocalDateTime.now())
.status(response.getStatus().name())
.resultSummary(resultSummary)
.build();
historyRepository.save(historyEntity);
log.info("요금조회 이력 저장 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
} catch (Exception e) {
log.error("요금조회 이력 저장 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
// 이력 저장 실패는 전체 프로세스에 영향을 주지 않도록 예외를 던지지 않음
}
}
/**
* 요금조회 상태 업데이트
*
* 비동기 처리된 요청의 상태가 변경되었을 때 호출
*
* @param requestId 요청 ID
* @param response 업데이트된 응답 데이터
*/
@Transactional
public void updateInquiryStatus(String requestId, BillInquiryResponse response) {
log.debug("요금조회 상태 업데이트 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
try {
BillInquiryHistoryEntity historyEntity = historyRepository.findByRequestId(requestId)
.orElseThrow(() -> BillInquiryException.billDataNotFound(requestId, "요청 ID"));
// 상태 업데이트
historyEntity.updateStatus(response.getStatus().name());
historyEntity.updateProcessTime(LocalDateTime.now());
// 결과 요약 업데이트
String resultSummary = generateResultSummary(response);
historyEntity.updateResultSummary(resultSummary);
historyRepository.save(historyEntity);
log.info("요금조회 상태 업데이트 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
} catch (Exception e) {
log.error("요금조회 상태 업데이트 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
}
}
/**
* 요금조회 결과 조회
*
* @param requestId 요청 ID
* @return 요금조회 응답 데이터
*/
public BillInquiryResponse getBillInquiryResult(String requestId) {
log.debug("요금조회 결과 조회 - 요청ID: {}", requestId);
try {
BillInquiryHistoryEntity historyEntity = historyRepository.findByRequestId(requestId)
.orElse(null);
if (historyEntity == null) {
log.debug("요금조회 결과 없음 - 요청ID: {}", requestId);
return null;
}
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.valueOf(historyEntity.getStatus());
BillInquiryResponse response = BillInquiryResponse.builder()
.requestId(requestId)
.status(status)
.build();
// 성공 상태이고 요금 정보가 있는 경우 (실제로는 별도 테이블에서 조회해야 함)
if (status == BillInquiryResponse.ProcessStatus.COMPLETED) {
// TODO: 실제 요금 정보 조회 로직 구현
// 현재는 결과 요약만 반환
}
log.debug("요금조회 결과 조회 완료 - 요청ID: {}, 상태: {}", requestId, status);
return response;
} catch (Exception e) {
log.error("요금조회 결과 조회 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
return null;
}
}
/**
* 요금조회 이력 목록 조회
*
* @param userLineNumbers 사용자 권한이 있는 회선번호 목록
* @param lineNumber 특정 회선번호 필터 (선택)
* @param startDate 조회 시작일 (선택)
* @param endDate 조회 종료일 (선택)
* @param page 페이지 번호
* @param size 페이지 크기
* @param status 상태 필터 (선택)
* @return 이력 응답 데이터
*/
public BillHistoryResponse getBillHistory(
List<String> userLineNumbers, String lineNumber, String startDate, String endDate,
Integer page, Integer size, BillInquiryResponse.ProcessStatus status) {
log.debug("요금조회 이력 목록 조회 - 사용자 회선수: {}, 필터 회선: {}, 기간: {} ~ {}, 페이지: {}/{}",
userLineNumbers.size(), lineNumber, startDate, endDate, page, size);
try {
// 페이징 설정 (최신순 정렬)
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("requestTime").descending());
// 검색 조건 설정
LocalDateTime startDateTime = null;
LocalDateTime endDateTime = null;
if (startDate != null && !startDate.trim().isEmpty()) {
startDateTime = LocalDate.parse(startDate).atStartOfDay();
}
if (endDate != null && !endDate.trim().isEmpty()) {
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
);
// 응답 데이터 변환
List<BillHistoryResponse.BillHistoryItem> historyItems = historyPage.getContent()
.stream()
.map(this::convertToHistoryItem)
.collect(Collectors.toList());
// 페이징 정보 구성
BillHistoryResponse.PaginationInfo paginationInfo = BillHistoryResponse.PaginationInfo.builder()
.currentPage(page)
.totalPages(historyPage.getTotalPages())
.totalItems(historyPage.getTotalElements())
.pageSize(size)
.hasNext(historyPage.hasNext())
.hasPrevious(historyPage.hasPrevious())
.build();
BillHistoryResponse response = BillHistoryResponse.builder()
.items(historyItems)
.pagination(paginationInfo)
.build();
log.info("요금조회 이력 목록 조회 완료 - 총 {}건, 현재 페이지: {}/{}",
historyPage.getTotalElements(), page, historyPage.getTotalPages());
return response;
} catch (Exception e) {
log.error("요금조회 이력 목록 조회 오류 - 오류: {}", e.getMessage(), e);
throw new BillInquiryException("이력 조회 중 오류가 발생했습니다", e);
}
}
/**
* 엔티티를 이력 아이템으로 변환
*/
private BillHistoryResponse.BillHistoryItem convertToHistoryItem(BillInquiryHistoryEntity entity) {
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.valueOf(entity.getStatus());
return BillHistoryResponse.BillHistoryItem.builder()
.requestId(entity.getRequestId())
.lineNumber(entity.getLineNumber())
.inquiryMonth(entity.getInquiryMonth())
.requestTime(entity.getRequestTime())
.processTime(entity.getProcessTime())
.status(status)
.resultSummary(entity.getResultSummary())
.build();
}
/**
* 응답 데이터를 기반으로 결과 요약 생성
*/
private String generateResultSummary(BillInquiryResponse response) {
try {
switch (response.getStatus()) {
case COMPLETED:
if (response.getBillInfo() != null) {
return String.format("%s, %,d원",
response.getBillInfo().getProductName(),
response.getBillInfo().getTotalAmount());
} else {
return "조회 완료";
}
case PROCESSING:
return "처리 중";
case FAILED:
return "조회 실패";
default:
return "알 수 없는 상태";
}
} catch (Exception e) {
log.warn("결과 요약 생성 오류: {}", e.getMessage());
return response.getStatus().name();
}
}
}
@@ -0,0 +1,83 @@
package com.phonebill.bill.service;
import com.phonebill.bill.dto.*;
/**
* 요금조회 서비스 인터페이스
*
* 통신요금 조회와 관련된 비즈니스 로직을 정의
* - 요금조회 메뉴 데이터 제공
* - KOS 시스템 연동을 통한 실시간 요금 조회
* - 요금조회 결과 상태 관리
* - 요금조회 이력 관리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
public interface BillInquiryService {
/**
* 요금조회 메뉴 조회
*
* UFR-BILL-010: 요금조회 메뉴 접근
* - 인증된 사용자의 고객정보 조회
* - 조회 가능한 월 목록 생성 (최근 12개월)
* - 현재 월 정보 제공
*
* @return 요금조회 메뉴 응답 데이터
*/
BillMenuResponse getBillMenu();
/**
* 요금조회 요청 처리
*
* UFR-BILL-020: 요금조회 신청
* - Cache-Aside 패턴으로 캐시 확인
* - 캐시 Miss 시 KOS 시스템 연동
* - Circuit Breaker 패턴으로 장애 격리
* - 비동기 처리 시 요청 상태 관리
*
* @param request 요금조회 요청 데이터
* @return 요금조회 응답 데이터
*/
BillInquiryResponse inquireBill(BillInquiryRequest request);
/**
* 요금조회 결과 확인
*
* 비동기로 처리된 요금조회의 상태와 결과를 반환
* - PROCESSING: 처리 중 상태
* - COMPLETED: 처리 완료 (요금 정보 포함)
* - FAILED: 처리 실패 (오류 메시지 포함)
*
* @param requestId 요금조회 요청 ID
* @return 요금조회 응답 데이터
*/
BillInquiryResponse getBillInquiryResult(String requestId);
/**
* 요금조회 이력 조회
*
* UFR-BILL-040: 요금조회 결과 전송 및 이력 관리
* - 사용자별 요금조회 이력 목록 조회
* - 필터링: 회선번호, 기간, 상태
* - 페이징 처리
*
* @param lineNumber 회선번호 (선택)
* @param startDate 조회 시작일 (선택)
* @param endDate 조회 종료일 (선택)
* @param page 페이지 번호
* @param size 페이지 크기
* @param status 처리 상태 필터 (선택)
* @return 요금조회 이력 응답 데이터
*/
BillHistoryResponse getBillHistory(
String lineNumber,
String startDate,
String endDate,
Integer page,
Integer size,
BillInquiryResponse.ProcessStatus status
);
}
@@ -0,0 +1,296 @@
package com.phonebill.bill.service;
import com.phonebill.bill.dto.*;
import com.phonebill.bill.exception.BillInquiryException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* 요금조회 서비스 구현체
*
* 통신요금 조회와 관련된 비즈니스 로직 구현
* - KOS 시스템 연동을 통한 실시간 데이터 조회
* - Redis 캐싱을 통한 성능 최적화
* - Circuit Breaker를 통한 외부 시스템 장애 격리
* - 비동기 처리 및 이력 관리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BillInquiryServiceImpl implements BillInquiryService {
private final BillCacheService billCacheService;
private final KosClientService kosClientService;
private final BillHistoryService billHistoryService;
/**
* 요금조회 메뉴 조회
*
* UFR-BILL-010: 요금조회 메뉴 접근
*/
@Override
public BillMenuResponse getBillMenu() {
log.info("요금조회 메뉴 조회 시작");
// 현재 인증된 사용자의 고객 정보 조회 (JWT에서 추출)
// TODO: SecurityContext에서 사용자 정보 추출 로직 구현
String customerId = getCurrentCustomerId();
String lineNumber = getCurrentLineNumber();
// 조회 가능한 월 목록 생성 (최근 12개월)
List<String> availableMonths = generateAvailableMonths();
// 현재 월
String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
BillMenuResponse response = BillMenuResponse.builder()
.customerInfo(BillMenuResponse.CustomerInfo.builder()
.customerId(customerId)
.lineNumber(lineNumber)
.build())
.availableMonths(availableMonths)
.currentMonth(currentMonth)
.build();
log.info("요금조회 메뉴 조회 완료 - 고객: {}, 회선: {}", customerId, lineNumber);
return response;
}
/**
* 요금조회 요청 처리
*
* UFR-BILL-020: 요금조회 신청
*/
@Override
@Transactional
public BillInquiryResponse inquireBill(BillInquiryRequest request) {
log.info("요금조회 요청 처리 시작 - 회선: {}, 조회월: {}",
request.getLineNumber(), request.getInquiryMonth());
// 요청 ID 생성
String requestId = generateRequestId();
// 조회월 기본값 설정 (미입력시 당월)
String inquiryMonth = request.getInquiryMonth();
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
}
try {
// 1단계: 캐시에서 데이터 확인 (Cache-Aside 패턴)
BillInquiryResponse cachedResponse = billCacheService.getCachedBillData(
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);
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);
// 실패 응답 생성
BillInquiryResponse errorResponse = BillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.FAILED)
.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;
}
/**
* 요금조회 이력 조회
*/
@Override
public BillHistoryResponse getBillHistory(
String lineNumber, String startDate, String endDate,
Integer page, Integer size, BillInquiryResponse.ProcessStatus status) {
log.info("요금조회 이력 조회 - 회선: {}, 기간: {} ~ {}, 페이지: {}/{}, 상태: {}",
lineNumber, startDate, endDate, page, size, status);
// 현재 사용자의 회선번호 목록 조회 (권한 확인)
List<String> userLineNumbers = getCurrentUserLineNumbers();
// 지정된 회선번호가 사용자 소유가 아닌 경우 권한 오류
if (lineNumber != null && !userLineNumbers.contains(lineNumber)) {
throw new BillInquiryException("UNAUTHORIZED_LINE_NUMBER",
"조회 권한이 없는 회선번호입니다", "회선번호: " + lineNumber);
}
// 이력 조회 (사용자 권한 기반)
BillHistoryResponse historyResponse = billHistoryService.getBillHistory(
userLineNumbers, lineNumber, startDate, endDate, page, size, status
);
log.info("요금조회 이력 조회 완료 - 총 {}건",
historyResponse.getPagination().getTotalItems());
return historyResponse;
}
// === Private Helper Methods ===
/**
* 현재 인증된 사용자의 고객 ID 조회
*/
private String getCurrentCustomerId() {
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 고객 ID 추출
// 현재는 더미 데이터 반환
return "CUST001";
}
/**
* 현재 인증된 사용자의 회선번호 조회
*/
private String getCurrentLineNumber() {
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 회선번호 추출
// 현재는 더미 데이터 반환
return "010-1234-5678";
}
/**
* 현재 사용자의 모든 회선번호 목록 조회
*/
private List<String> getCurrentUserLineNumbers() {
// TODO: 사용자 권한에 따른 회선번호 목록 조회
// 현재는 더미 데이터 반환
List<String> lineNumbers = new ArrayList<>();
lineNumbers.add("010-1234-5678");
return lineNumbers;
}
/**
* 조회 가능한 월 목록 생성 (최근 12개월)
*/
private List<String> generateAvailableMonths() {
List<String> months = new ArrayList<>();
LocalDate currentDate = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
for (int i = 0; i < 12; i++) {
LocalDate monthDate = currentDate.minusMonths(i);
months.add(monthDate.format(formatter));
}
return months;
}
/**
* 요청 ID 생성
*/
private String generateRequestId() {
String currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String uuid = UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return String.format("REQ_%s_%s", currentDate, uuid);
}
}
@@ -0,0 +1,327 @@
package com.phonebill.bill.service;
import com.phonebill.bill.config.KosProperties;
import com.phonebill.bill.dto.BillInquiryResponse;
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 io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.concurrent.CompletableFuture;
/**
* KOS 시스템 연동 클라이언트 서비스
*
* 통신사 백엔드 시스템(KOS)과의 연동을 담당하는 서비스
* - Circuit Breaker 패턴으로 외부 시스템 장애 격리
* - Retry 패턴으로 일시적 네트워크 오류 극복
* - Timeout 설정으로 응답 지연 방지
* - 데이터 변환 및 오류 처리
*
* @author 이개발(백엔더)
* @version 1.0.0
* @since 2025-09-08
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KosClientService {
private final RestTemplate restTemplate;
private final KosProperties kosProperties;
/**
* KOS 시스템에서 요금 정보 조회
*
* Circuit Breaker, Retry, TimeLimiter 패턴 적용
*
* @param lineNumber 회선번호
* @param inquiryMonth 조회월
* @return 요금조회 응답
*/
@CircuitBreaker(name = "kos-bill-inquiry", fallbackMethod = "inquireBillFallback")
@Retry(name = "kos-bill-inquiry")
@TimeLimiter(name = "kos-bill-inquiry")
public CompletableFuture<BillInquiryResponse> inquireBillFromKos(String lineNumber, String inquiryMonth) {
return CompletableFuture.supplyAsync(() -> {
log.info("KOS 요금조회 요청 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
try {
// KOS 요청 데이터 구성
KosRequest kosRequest = KosRequest.builder()
.lineNumber(lineNumber)
.inquiryMonth(inquiryMonth)
.requestTime(LocalDateTime.now())
.build();
// 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 호출
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/inquiry";
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.POST, requestEntity, KosResponse.class
);
KosResponse kosResponse = responseEntity.getBody();
if (kosResponse == null) {
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
}
// KOS 응답을 내부 모델로 변환
BillInquiryResponse response = convertKosResponseToBillResponse(kosResponse);
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 Fallback 메소드
*/
public CompletableFuture<BillInquiryResponse> inquireBillFallback(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.PROCESSING)
.build();
log.info("KOS 요금조회 fallback 응답 - 비동기 처리로 전환");
return CompletableFuture.completedFuture(fallbackResponse);
}
/**
* KOS 시스템에서 요금조회 상태 확인
*
* @param requestId 요청 ID
* @return 요금조회 응답
*/
@CircuitBreaker(name = "kos-status-check", fallbackMethod = "checkInquiryStatusFallback")
@Retry(name = "kos-status-check")
public BillInquiryResponse checkInquiryStatus(String requestId) {
log.info("KOS 요금조회 상태 확인 - 요청ID: {}", requestId);
try {
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
headers.set("X-Request-ID", requestId);
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// KOS 상태 확인 API 호출
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/status/" + requestId;
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
kosUrl, HttpMethod.GET, requestEntity, KosResponse.class
);
KosResponse kosResponse = responseEntity.getBody();
if (kosResponse == null) {
throw KosConnectionException.apiError("KOS-STATUS-CHECK",
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
}
// KOS 응답을 내부 모델로 변환
BillInquiryResponse response = convertKosResponseToBillResponse(kosResponse);
log.info("KOS 상태 확인 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
return response;
} catch (Exception e) {
log.error("KOS 상태 확인 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
throw new KosConnectionException("KOS-STATUS-CHECK",
"KOS 상태 확인 중 오류가 발생했습니다", e);
}
}
/**
* KOS 상태 확인 Circuit Breaker Fallback 메소드
*/
public BillInquiryResponse checkInquiryStatusFallback(String requestId, Exception ex) {
log.warn("KOS 상태 확인 Circuit Breaker 작동 - 요청ID: {}, 오류: {}", requestId, ex.getMessage());
// 상태 확인 실패시 처리 중 상태로 반환
return BillInquiryResponse.builder()
.requestId(requestId)
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
.build();
}
/**
* KOS 응답을 내부 응답 모델로 변환
*/
private BillInquiryResponse convertKosResponseToBillResponse(KosResponse kosResponse) {
try {
// 상태 변환
BillInquiryResponse.ProcessStatus status;
switch (kosResponse.getStatus().toUpperCase()) {
case "SUCCESS":
case "COMPLETED":
status = BillInquiryResponse.ProcessStatus.COMPLETED;
break;
case "PROCESSING":
case "PENDING":
status = BillInquiryResponse.ProcessStatus.PROCESSING;
break;
case "FAILED":
case "ERROR":
status = BillInquiryResponse.ProcessStatus.FAILED;
break;
default:
status = BillInquiryResponse.ProcessStatus.PROCESSING;
break;
}
BillInquiryResponse.BillInfo billInfo = null;
// 성공한 경우에만 요금 정보 변환
if (status == BillInquiryResponse.ProcessStatus.COMPLETED && kosResponse.getBillData() != null) {
// 할인 정보 변환
List<BillInquiryResponse.DiscountInfo> discounts = new ArrayList<>();
if (kosResponse.getBillData().getDiscounts() != null) {
kosResponse.getBillData().getDiscounts().forEach(discount ->
discounts.add(BillInquiryResponse.DiscountInfo.builder()
.name(discount.getName())
.amount(discount.getAmount())
.build())
);
}
// 사용량 정보 변환
BillInquiryResponse.UsageInfo usage = null;
if (kosResponse.getBillData().getUsage() != null) {
usage = BillInquiryResponse.UsageInfo.builder()
.voice(kosResponse.getBillData().getUsage().getVoice())
.sms(kosResponse.getBillData().getUsage().getSms())
.data(kosResponse.getBillData().getUsage().getData())
.build();
}
// 납부 정보 변환
BillInquiryResponse.PaymentInfo payment = null;
if (kosResponse.getBillData().getPayment() != null) {
BillInquiryResponse.PaymentStatus paymentStatus;
switch (kosResponse.getBillData().getPayment().getStatus().toUpperCase()) {
case "PAID":
paymentStatus = BillInquiryResponse.PaymentStatus.PAID;
break;
case "UNPAID":
paymentStatus = BillInquiryResponse.PaymentStatus.UNPAID;
break;
case "OVERDUE":
paymentStatus = BillInquiryResponse.PaymentStatus.OVERDUE;
break;
default:
paymentStatus = BillInquiryResponse.PaymentStatus.UNPAID;
break;
}
payment = BillInquiryResponse.PaymentInfo.builder()
.billingDate(kosResponse.getBillData().getPayment().getBillingDate())
.paymentStatus(paymentStatus)
.paymentMethod(kosResponse.getBillData().getPayment().getMethod())
.build();
}
billInfo = BillInquiryResponse.BillInfo.builder()
.productName(kosResponse.getBillData().getProductName())
.contractInfo(kosResponse.getBillData().getContractInfo())
.billingMonth(kosResponse.getBillData().getBillingMonth())
.totalAmount(kosResponse.getBillData().getTotalAmount())
.discountInfo(discounts)
.usage(usage)
.terminationFee(kosResponse.getBillData().getTerminationFee())
.deviceInstallment(kosResponse.getBillData().getDeviceInstallment())
.paymentInfo(payment)
.build();
}
return BillInquiryResponse.builder()
.requestId(kosResponse.getRequestId())
.status(status)
.billInfo(billInfo)
.build();
} catch (Exception e) {
log.error("KOS 응답 변환 오류: {}", e.getMessage(), e);
throw KosConnectionException.dataConversionError("KOS-BILL-INQUIRY", "BillInquiryResponse", e);
}
}
/**
* KOS 시스템 연결 상태 확인
*
* @return 연결 가능 여부
*/
@CircuitBreaker(name = "kos-health-check")
public boolean isKosSystemAvailable() {
try {
String healthUrl = kosProperties.getBaseUrl() + "/health";
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;
}
}
}
@@ -0,0 +1,169 @@
# 통신요금 관리 서비스 - 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.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
@@ -0,0 +1,237 @@
# 통신요금 관리 서비스 - 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.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
@@ -0,0 +1,256 @@
# 통신요금 관리 서비스 - Bill Service 기본 설정
# 공통 설정 및 개발환경 기본값
#
# @author 이개발(백엔더)
# @version 1.0.0
# @since 2025-09-08
spring:
application:
name: bill-service
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}
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}
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: ${JPA_SHOW_SQL:false}
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
connection:
provider_disables_autocommit: true
open-in-view: false
# Redis 설정
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:5000}
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}
# Jackson 설정
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
write-durations-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
accept-single-value-as-array: true
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:8081}
servlet:
context-path: /bill-service
encoding:
charset: UTF-8
enabled: true
force: true
error:
include-message: always
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:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env,beans
base-path: /actuator
path-mapping:
health: health
enabled-by-default: false
endpoint:
health:
enabled: true
show-details: ${ACTUATOR_HEALTH_DETAILS:when_authorized}
show-components: always
probes:
enabled: true
info:
enabled: true
metrics:
enabled: true
prometheus:
enabled: true
health:
redis:
enabled: true
db:
enabled: true
diskspace:
enabled: true
ping:
enabled: true
metrics:
export:
prometheus:
enabled: true
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.95, 0.99
sla:
http.server.requests: 100ms, 300ms, 500ms
# KOS 시스템 연동 설정
kos:
base-url: ${KOS_BASE_URL:http://localhost:9090}
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
read-timeout: ${KOS_READ_TIMEOUT:30000}
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_DURATION:10000}
slow-call-rate-threshold: ${KOS_CB_SLOW_RATE:0.5}
sliding-window-size: ${KOS_CB_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_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}
path: /v3/api-docs
swagger-ui:
enabled: ${SWAGGER_UI_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}
writer-with-default-pretty-printer: true
# 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 }
# 애플리케이션 정보
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}