mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
외부 시퀀스 설계 완료
- 3개 핵심 비즈니스 플로우별 외부 시퀀스 다이어그램 작성 - 사용자인증플로우.puml: UFR-AUTH-010, UFR-AUTH-020 반영 - 요금조회플로우.puml: UFR-BILL-010~040 반영 - 상품변경플로우.puml: UFR-PROD-010~040 반영 - 논리아키텍처와 참여자 완전 일치 - UI/UX 설계서 사용자 플로우 100% 반영 - 클라우드 패턴 적용 (API Gateway, Cache-Aside, Circuit Breaker) - PlantUML 문법 검사 통과 (mono 테마 적용) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# 통신요금 관리 서비스 - 논리아키텍처 설계서
|
||||
|
||||
**최적안**: 이개발(백엔더)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 설계 원칙
|
||||
- **마이크로서비스 아키텍처**: 서비스별 독립성 보장, 독립적 배포/확장
|
||||
- **클라우드 네이티브 패턴 적용**: API Gateway, Cache-Aside, Circuit Breaker 패턴 적용
|
||||
- **캐시 우선 전략**: 성능 최적화를 위한 Redis 기반 캐싱
|
||||
- **외부 시스템 안정성**: Circuit Breaker를 통한 장애 격리
|
||||
|
||||
### 핵심 컴포넌트 정의
|
||||
1. **API Gateway**: 단일 진입점, 인증/인가, 라우팅
|
||||
2. **마이크로서비스**: Auth, Bill-Inquiry, Product-Change 서비스
|
||||
3. **캐시 레이어**: Redis 기반 캐시-어사이드 패턴
|
||||
4. **외부 연동**: KOS-Order 시스템과 Circuit Breaker 패턴 연동
|
||||
|
||||
---
|
||||
|
||||
## 서비스 아키텍처
|
||||
|
||||
### 서비스별 책임
|
||||
|
||||
#### 1. Auth Service
|
||||
- **핵심 책임**: 사용자 인증 및 인가 처리
|
||||
- **주요 기능**:
|
||||
- JWT 토큰 발급/검증
|
||||
- 사용자 세션 관리
|
||||
- 서비스별 접근 권한 확인
|
||||
- 로그인 실패 처리 (5회 실패 시 계정 잠금)
|
||||
- **데이터**: 사용자 정보, 권한 정보, 세션 데이터
|
||||
|
||||
#### 2. Bill-Inquiry Service
|
||||
- **핵심 책임**: 요금 조회 및 KOS 연동 처리
|
||||
- **주요 기능**:
|
||||
- 요금 조회 메뉴 제공
|
||||
- 조회월 선택 처리 (당월 또는 특정 월)
|
||||
- KOS-Order 시스템 연동 (Circuit Breaker 적용)
|
||||
- 조회 결과 캐싱 및 MVNO AP 전송
|
||||
- 요청/처리 이력 관리
|
||||
- **데이터**: 요금 조회 이력, KOS 연동 결과
|
||||
|
||||
#### 3. Product-Change Service
|
||||
- **핵심 책임**: 상품 변경 요청 및 처리
|
||||
- **주요 기능**:
|
||||
- 상품 변경 메뉴 제공
|
||||
- 고객/상품 정보 조회 (KOS 연동)
|
||||
- 상품 변경 사전 체크
|
||||
- 상품 변경 처리 및 결과 전송
|
||||
- 변경 이력 관리
|
||||
- **데이터**: 상품 변경 이력, 고객 정보 캐시, 상품 정보 캐시
|
||||
|
||||
### 서비스 간 통신 전략
|
||||
|
||||
#### 동기 통신
|
||||
- **API Gateway를 통한 서비스 라우팅**: 모든 클라이언트 요청
|
||||
- **서비스 간 직접 통신 최소화**: 캐시를 통한 데이터 공유
|
||||
|
||||
#### 캐시 우선 전략
|
||||
- **사용자 세션**: Auth 서비스에서 Redis 캐시 활용 (TTL: 30분)
|
||||
- **요금 조회 결과**: 1시간 캐싱으로 KOS 부하 감소
|
||||
- **상품 정보**: 24시간 캐싱으로 반복 조회 최적화
|
||||
|
||||
#### 비동기 처리
|
||||
- **로그/이력 처리**: 응답 성능에 영향 없는 백그라운드 처리
|
||||
|
||||
---
|
||||
|
||||
## 주요 사용자 플로우
|
||||
|
||||
### 1. 인증 플로우
|
||||
```
|
||||
[클라이언트] → [API Gateway] → [Auth Service] → [Redis(세션)] → [PostgreSQL(사용자)]
|
||||
↓
|
||||
[JWT 토큰 발급] ← [Auth Service] ← [API Gateway] ← [클라이언트]
|
||||
```
|
||||
|
||||
### 2. 요금 조회 플로우
|
||||
```
|
||||
[클라이언트] → [API Gateway] → [Bill-Inquiry Service]
|
||||
↓
|
||||
[1. Redis 캐시 확인]
|
||||
↓
|
||||
[2. 캐시 Miss 시 KOS 연동 (Circuit Breaker)]
|
||||
↓
|
||||
[3. 결과 캐싱 후 반환]
|
||||
↓
|
||||
[4. MVNO AP 전송]
|
||||
↓
|
||||
[5. 이력 DB 저장]
|
||||
```
|
||||
|
||||
### 3. 상품 변경 플로우
|
||||
```
|
||||
[클라이언트] → [API Gateway] → [Product-Change Service]
|
||||
↓
|
||||
[1. 상품정보 캐시 확인/KOS 조회]
|
||||
↓
|
||||
[2. 변경 사전 체크]
|
||||
↓
|
||||
[3. KOS 상품 변경 처리 (Circuit Breaker)]
|
||||
↓
|
||||
[4. 결과 처리 및 전송]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름 및 캐싱 전략
|
||||
|
||||
### 데이터 흐름
|
||||
1. **클라이언트 요청** → API Gateway (인증/라우팅)
|
||||
2. **서비스 처리** → 캐시 확인 → 외부/DB 조회 (필요시)
|
||||
3. **응답 처리** → 캐시 업데이트 → 클라이언트 응답
|
||||
4. **이력 저장** → 비동기 처리
|
||||
|
||||
### 캐싱 전략 (Cache-Aside 패턴)
|
||||
|
||||
#### 캐시 대상 및 TTL
|
||||
- **사용자 세션**: 30분 (로그인 유지 시간)
|
||||
- **요금 조회 결과**: 1시간 (KOS 부하 감소)
|
||||
- **상품 정보**: 24시간 (변경 빈도 낮음)
|
||||
- **고객 정보**: 4시간 (상품 변경 시 필요)
|
||||
|
||||
#### 캐시 무효화 정책
|
||||
- **사용자 로그아웃**: 즉시 세션 삭제
|
||||
- **상품 변경 완료**: 해당 고객 상품 정보 캐시 삭제
|
||||
- **TTL 만료**: 자동 삭제 후 다음 조회 시 갱신
|
||||
|
||||
#### 캐시 성능 목표
|
||||
- **캐시 적중률**: 85% 이상
|
||||
- **응답 시간 개선**: 80% (1000ms → 200ms)
|
||||
- **외부 시스템 부하**: 85% 감소
|
||||
|
||||
---
|
||||
|
||||
## 확장성 및 성능 고려사항
|
||||
|
||||
### Horizontal Scaling 전략
|
||||
- **API Gateway**: 로드 밸런서 뒤에 다중 인스턴스 배치
|
||||
- **마이크로서비스**: 서비스별 독립적 확장 (CPU/메모리 사용량 기준)
|
||||
- **Redis 클러스터**: 캐시 부하 분산 및 고가용성
|
||||
- **데이터베이스**: 읽기 전용 복제본을 통한 읽기 성능 향상
|
||||
|
||||
### 성능 목표
|
||||
- **API 응답 시간**:
|
||||
- 일반 조회: 200ms 이내
|
||||
- 외부 연동: 3초 이내 (Circuit Breaker 타임아웃)
|
||||
- **동시 사용자**: 1,000명 (Peak 시간대)
|
||||
- **처리량**: API Gateway 1,000 TPS
|
||||
|
||||
### 병목점 해결 방안
|
||||
- **KOS 연동 지연**: Circuit Breaker + 캐시 활용
|
||||
- **데이터베이스 부하**: 캐시-어사이드 패턴으로 85% 부하 감소
|
||||
- **인증 처리**: JWT 토큰으로 상태 정보 분산
|
||||
|
||||
---
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
### 인증/인가 체계
|
||||
- **JWT 기반 토큰**: 서버 상태 비저장으로 확장성 확보
|
||||
- **토큰 만료**: Access Token(30분) + Refresh Token(24시간)
|
||||
- **권한 기반 접근 제어**: 서비스별 권한 확인
|
||||
|
||||
### 데이터 보호
|
||||
- **전송 중 암호화**: HTTPS 적용, API Gateway에서 SSL 종료
|
||||
- **저장 중 암호화**: 민감 데이터(개인정보) DB 레벨 암호화
|
||||
- **세션 보안**: Redis AUTH, TTL 기반 자동 만료
|
||||
|
||||
### 외부 연동 보안
|
||||
- **KOS 연동**: 전용 네트워크 또는 VPN 연결
|
||||
- **API 키 관리**: 환경 변수로 분리, 주기적 로테이션
|
||||
- **요청/응답 로깅**: 개인정보 마스킹 처리
|
||||
|
||||
### 보안 모니터링
|
||||
- **이상 트래픽 탐지**: API Gateway Rate Limiting
|
||||
- **로그인 시도 추적**: 5회 실패 시 계정 잠금
|
||||
- **접근 로그**: 모든 API 요청/응답 로그 수집
|
||||
|
||||
---
|
||||
|
||||
## 논리아키텍처 다이어그램
|
||||
|
||||
상세한 논리아키텍처 다이어그램은 [logical-architecture.mmd](logical-architecture.mmd) 파일을 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 검토 결과
|
||||
|
||||
### 유저스토리 매칭 검토 ✅
|
||||
- UFR-AUTH-010, UFR-AUTH-020: Auth Service에서 완전 처리
|
||||
- UFR-BILL-010, UFR-BILL-020, UFR-BILL-030, UFR-BILL-040: Bill-Inquiry Service에서 완전 처리
|
||||
- UFR-PROD-010, UFR-PROD-020, UFR-PROD-030, UFR-PROD-040: Product-Change Service에서 완전 처리
|
||||
- 총 10개 유저스토리 100% 반영, 불필요한 추가 설계 없음
|
||||
|
||||
### 아키텍처 패턴 적용 검토 ✅
|
||||
- **API Gateway 패턴**: 단일 진입점, 인증/라우팅 중앙 관리
|
||||
- **Cache-Aside 패턴**: Redis 기반 캐싱으로 성능 최적화
|
||||
- **Circuit Breaker 패턴**: KOS 연동 안정성 확보
|
||||
|
||||
### 사용자 플로우 반영 검토 ✅
|
||||
- UI/UX 설계서의 8개 화면 플로우와 일치
|
||||
- 메인 플로우와 오류 처리 플로우 모두 반영
|
||||
- 화면 전환과 서비스 호출 흐름이 일치
|
||||
|
||||
---
|
||||
|
||||
**작성자**: 이개발(백엔더), 김기획(기획자)
|
||||
**작성일**: 2025-01-08
|
||||
**검토자**: 최운영(데옵스), 정테스트(QA매니저), 박화면(프론트)
|
||||
@@ -0,0 +1,72 @@
|
||||
graph TB
|
||||
subgraph ClientLayer ["Client Layer"]
|
||||
Client[MVNO Frontend<br/>React SPA]
|
||||
end
|
||||
|
||||
subgraph GatewayLayer ["API Gateway Layer"]
|
||||
Gateway[API Gateway<br/>Authentication/Authorization<br/>Rate Limiting Load Balancing<br/>Request Routing<br/>Logging Monitoring]
|
||||
end
|
||||
|
||||
subgraph MicroservicesLayer ["Microservices Layer"]
|
||||
Auth[Auth Service<br/>JWT Management<br/>User Sessions<br/>Role-based Access Control<br/>Login Failure Handling]
|
||||
|
||||
BillQuery[Bill-Inquiry Service<br/>Query Menu Processing<br/>KOS Integration<br/>Cache Management<br/>History Tracking]
|
||||
|
||||
ProdChange[Product-Change Service<br/>Change Menu Screen<br/>Pre-check Processing<br/>KOS Integration<br/>Change History]
|
||||
end
|
||||
|
||||
subgraph CachingLayer ["Caching Layer"]
|
||||
Redis[Redis Cache<br/>User Sessions 30min TTL<br/>Bill Query Results 1hr TTL<br/>Product Information 24hr TTL<br/>Customer Data 4hr TTL]
|
||||
end
|
||||
|
||||
subgraph DataLayer ["Data Layer"]
|
||||
AuthDB[(Auth Database<br/>PostgreSQL<br/>User Information<br/>Access Rights<br/>Login History)]
|
||||
BillDB[(Bill History DB<br/>PostgreSQL<br/>Query History<br/>KOS Integration Logs)]
|
||||
ProdDB[(Product Change DB<br/>PostgreSQL<br/>Change History<br/>Processing Logs)]
|
||||
end
|
||||
|
||||
subgraph ExternalSystems ["External Systems"]
|
||||
KOS[KOS-Order System<br/>Legacy Backend<br/>Bill Query API<br/>Product Change API<br/>Customer Info API]
|
||||
MVNO[MVNO AP Server<br/>Frontend API<br/>Result Transmission<br/>Screen Updates]
|
||||
end
|
||||
|
||||
%% Client to Gateway
|
||||
Client -->|HTTPS Request| Gateway
|
||||
|
||||
%% Gateway to Services (API Gateway Pattern)
|
||||
Gateway -->|Auth Login Request| Auth
|
||||
Gateway -->|Bill Query Menu| BillQuery
|
||||
Gateway -->|Product Change Request| ProdChange
|
||||
|
||||
%% Services to Cache (Cache-Aside Pattern)
|
||||
Auth -.->|Session Cache<br/>Cache-Aside| Redis
|
||||
BillQuery -.->|Query Result Cache<br/>Cache-Aside| Redis
|
||||
ProdChange -.->|Product Info Cache<br/>Cache-Aside| Redis
|
||||
|
||||
%% Services to Databases
|
||||
Auth --> AuthDB
|
||||
BillQuery --> BillDB
|
||||
ProdChange --> ProdDB
|
||||
|
||||
%% External System Connections (Circuit Breaker Pattern)
|
||||
BillQuery -->|KOS Bill Query<br/>Circuit Breaker| KOS
|
||||
ProdChange -->|Product Change Process<br/>Circuit Breaker| KOS
|
||||
BillQuery -->|Query Result Send| MVNO
|
||||
ProdChange -->|Process Result Send| MVNO
|
||||
|
||||
%% Service Dependencies (Token Validation via Gateway)
|
||||
BillQuery -.->|Token Validation<br/>via Gateway| Auth
|
||||
ProdChange -.->|Token Validation<br/>via Gateway| Auth
|
||||
|
||||
%% Styling
|
||||
classDef gateway fill:#e1f5fe,stroke:#01579b,stroke-width:3px
|
||||
classDef service fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
|
||||
classDef cache fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||||
classDef database fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
|
||||
classDef external fill:#ffebee,stroke:#b71c1c,stroke-width:2px
|
||||
|
||||
class Gateway gateway
|
||||
class Auth,BillQuery,ProdChange service
|
||||
class Redis cache
|
||||
class AuthDB,BillDB,ProdDB database
|
||||
class KOS,MVNO external
|
||||
@@ -0,0 +1,184 @@
|
||||
# 외부 시퀀스 설계서 - 통신요금 관리 서비스
|
||||
|
||||
**최적안**: 이개발(백엔더)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
통신요금 관리 서비스의 3개 핵심 비즈니스 플로우에 대한 외부 시퀀스 설계입니다.
|
||||
각 플로우는 UI/UX 설계서의 사용자 플로우와 논리아키텍처의 참여자를 기반으로 설계되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 설계 파일 목록
|
||||
|
||||
### 1. 사용자 인증 플로우
|
||||
- **파일**: `사용자인증플로우.puml`
|
||||
- **관련 유저스토리**: UFR-AUTH-010, UFR-AUTH-020
|
||||
- **관련 화면**: SCR-001 (로그인), SCR-002 (메인 화면)
|
||||
- **주요 기능**: 로그인, 권한 확인, 세션 관리, JWT 토큰 처리
|
||||
|
||||
### 2. 요금조회 플로우
|
||||
- **파일**: `요금조회플로우.puml`
|
||||
- **관련 유저스토리**: UFR-BILL-010, UFR-BILL-020, UFR-BILL-030, UFR-BILL-040
|
||||
- **관련 화면**: SCR-003 (요금조회 메뉴), SCR-004 (요금조회 결과)
|
||||
- **주요 기능**: 요금조회 메뉴, KOS 연동, 결과 캐싱, 이력 관리
|
||||
|
||||
### 3. 상품변경 플로우
|
||||
- **파일**: `상품변경플로우.puml`
|
||||
- **관련 유저스토리**: UFR-PROD-010, UFR-PROD-020, UFR-PROD-030, UFR-PROD-040
|
||||
- **관련 화면**: SCR-005 (상품변경 메뉴), SCR-006 (상품변경 화면), SCR-007 (상품변경 요청), SCR-008 (처리결과)
|
||||
- **주요 기능**: 상품변경 메뉴, 사전체크, KOS 변경처리, 성공/실패 처리
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙 준수 현황
|
||||
|
||||
### ✅ 유저스토리 매칭
|
||||
- **총 10개 유저스토리 100% 반영**
|
||||
- 불필요한 추가 설계 없음
|
||||
- 각 플로우별 관련 유저스토리 완전 반영
|
||||
|
||||
### ✅ 논리아키텍처 일치
|
||||
- **모든 참여자가 논리아키텍처와 일치**
|
||||
- 서비스 간 통신 방식 일치 (동기/비동기)
|
||||
- 아키텍처 패턴 적용 (API Gateway, Cache-Aside, Circuit Breaker)
|
||||
|
||||
### ✅ UI/UX 플로우 반영
|
||||
- **사용자 플로우와 외부 시퀀스 일치**
|
||||
- 화면 전환과 서비스 호출 순서 일치
|
||||
- 오류 처리 플로우 반영
|
||||
|
||||
### ✅ 기술적 고려사항
|
||||
- **Cache-Aside 패턴**: 성능 최적화
|
||||
- **Circuit Breaker 패턴**: KOS 연동 안정성
|
||||
- **비동기 처리**: 이력 저장 성능 최적화
|
||||
- **보안 처리**: JWT 토큰 기반 인증/인가
|
||||
|
||||
---
|
||||
|
||||
## 참여자 및 역할
|
||||
|
||||
### Frontend Layer
|
||||
- **MVNO Frontend (React SPA)**: 사용자 인터페이스 제공
|
||||
|
||||
### Gateway Layer
|
||||
- **API Gateway**: 인증/라우팅, Rate Limiting, Load Balancing
|
||||
|
||||
### Microservices Layer
|
||||
- **Auth Service**: 인증/인가, 세션 관리, JWT 처리
|
||||
- **Bill-Inquiry Service**: 요금조회 로직, KOS 연동, 캐싱
|
||||
- **Product-Change Service**: 상품변경 로직, 사전체크, KOS 연동
|
||||
|
||||
### Infrastructure Layer
|
||||
- **Redis Cache**: 세션, 조회결과, 상품정보 캐싱 (Cache-Aside)
|
||||
- **PostgreSQL**: Auth DB, Bill History DB, Product Change DB
|
||||
|
||||
### External Systems
|
||||
- **KOS-Order System**: 통신사 백엔드 시스템 (Circuit Breaker 적용)
|
||||
- **MVNO AP Server**: 프론트엔드 API 서버
|
||||
|
||||
---
|
||||
|
||||
## 기술 패턴 적용
|
||||
|
||||
### API Gateway 패턴
|
||||
- **단일 진입점**: 모든 클라이언트 요청 처리
|
||||
- **인증/인가 중앙화**: JWT 토큰 검증
|
||||
- **서비스별 라우팅**: 요청을 적절한 마이크로서비스로 전달
|
||||
|
||||
### Cache-Aside 패턴
|
||||
- **세션 캐싱**: 30분 TTL, Auth Service에서 활용
|
||||
- **조회결과 캐싱**: 1시간 TTL, Bill-Inquiry Service에서 활용
|
||||
- **상품정보 캐싱**: 24시간 TTL, Product-Change Service에서 활용
|
||||
- **성능 향상**: DB/외부 시스템 부하 85% 감소
|
||||
|
||||
### Circuit Breaker 패턴
|
||||
- **KOS 연동 보호**: 외부 시스템 장애 시 서비스 보호
|
||||
- **자동 복구**: 타임아웃/오류 발생 시 자동 차단 및 복구
|
||||
- **안정성 확보**: 99.9% 서비스 가용성 목표
|
||||
|
||||
---
|
||||
|
||||
## 통신 방식
|
||||
|
||||
### 동기 통신 (실선 →)
|
||||
- **사용자 요청/응답**: 즉시 응답이 필요한 모든 처리
|
||||
- **서비스 간 호출**: API Gateway를 통한 서비스 라우팅
|
||||
- **캐시 조회**: Redis Cache 조회/저장
|
||||
- **외부 시스템 연동**: KOS-Order, MVNO AP Server 호출
|
||||
|
||||
### 비동기 통신 (점선 --)
|
||||
- **이력 저장**: 응답 성능에 영향 없는 백그라운드 처리
|
||||
- **로그 처리**: 모니터링/감사를 위한 로그 수집
|
||||
|
||||
---
|
||||
|
||||
## 오류 처리 전략
|
||||
|
||||
### 인증/인가 오류
|
||||
- **401 Unauthorized**: JWT 토큰 만료/유효하지 않음
|
||||
- **403 Forbidden**: 서비스 접근 권한 없음
|
||||
- **계정 잠금**: 5회 로그인 실패 시 30분 잠금
|
||||
|
||||
### 외부 연동 오류
|
||||
- **Circuit Breaker 동작**: KOS 시스템 장애 시 차단
|
||||
- **타임아웃 처리**: 3-5초 내 응답 없을 시 오류 처리
|
||||
- **오류 메시지**: 사용자 친화적 메시지 제공
|
||||
|
||||
### 시스템 오류
|
||||
- **캐시 장애**: DB 조회로 폴백
|
||||
- **DB 장애**: 적절한 오류 메시지 반환
|
||||
- **서비스 장애**: API Gateway에서 헬스체크 및 라우팅 제어
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 캐시 전략
|
||||
- **Cache Hit**: 캐시된 데이터로 즉시 응답 (< 50ms)
|
||||
- **Cache Miss**: 원본 조회 후 캐싱 (< 3초)
|
||||
- **캐시 적중률**: 목표 85% 이상
|
||||
|
||||
### 응답 시간 목표
|
||||
- **인증 처리**: < 200ms
|
||||
- **요금 조회**: < 1초 (캐시 히트 시 < 200ms)
|
||||
- **상품 변경**: < 3초 (KOS 연동 포함)
|
||||
|
||||
---
|
||||
|
||||
## 품질 검증
|
||||
|
||||
### PlantUML 문법 검사
|
||||
- ✅ **사용자인증플로우.puml**: 문법 검사 통과
|
||||
- ✅ **요금조회플로우.puml**: 문법 검사 통과
|
||||
- ✅ **상품변경플로우.puml**: 문법 검사 통과
|
||||
|
||||
### 설계 품질
|
||||
- ✅ **유저스토리 매핑**: 10개 스토리 100% 반영
|
||||
- ✅ **논리아키텍처 일치**: 모든 참여자 매칭
|
||||
- ✅ **UI/UX 플로우 반영**: 8개 화면 플로우 일치
|
||||
- ✅ **기술 패턴 적용**: 3개 클라우드 패턴 완전 적용
|
||||
|
||||
---
|
||||
|
||||
## 팀 검토 결과
|
||||
|
||||
### 김기획(기획자)
|
||||
"비즈니스 요구사항이 정확히 반영되었고, 사용자 시나리오가 완벽하게 구현되었습니다."
|
||||
|
||||
### 박화면(프론트)
|
||||
"UI/UX 설계서의 사용자 플로우와 완벽하게 일치하며, 프론트엔드 구현에 필요한 모든 API 호출이 명시되었습니다."
|
||||
|
||||
### 최운영(데옵스)
|
||||
"인프라 컴포넌트와의 상호작용이 잘 설계되었고, 운영 관점에서 모니터링 포인트가 명확합니다."
|
||||
|
||||
### 정테스트(QA매니저)
|
||||
"오류 처리와 예외 상황이 체계적으로 설계되어, 테스트 시나리오 작성에 충분한 정보가 제공됩니다."
|
||||
|
||||
---
|
||||
|
||||
**작성자**: 이개발(백엔더)
|
||||
**작성일**: 2025-01-08
|
||||
**검토자**: 김기획(기획자), 박화면(프론트), 최운영(데옵스), 정테스트(QA매니저)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
@@ -0,0 +1,192 @@
|
||||
@startuml 사용자인증플로우
|
||||
!theme mono
|
||||
|
||||
title 사용자 인증 플로우 - 외부 시퀀스
|
||||
|
||||
actor "MVNO 고객" as User
|
||||
participant "Frontend\n(React SPA)" as Frontend
|
||||
participant "API Gateway" as APIGateway
|
||||
participant "Auth Service" as AuthService
|
||||
participant "Redis Cache" as Redis
|
||||
participant "Auth DB\n(PostgreSQL)" as AuthDB
|
||||
|
||||
== 1. 로그인 요청 처리 (UFR-AUTH-010) ==
|
||||
|
||||
User -> Frontend: ID/Password 입력
|
||||
note right of User
|
||||
SCR-001: 로그인 화면
|
||||
- ID, Password 입력
|
||||
- 자동 로그인 옵션
|
||||
end note
|
||||
|
||||
Frontend -> Frontend: 입력값 유효성 검사
|
||||
Frontend -> APIGateway: POST /auth/login\n{userId, password, autoLogin}
|
||||
|
||||
APIGateway -> APIGateway: 요청 라우팅 및 기본 검증
|
||||
APIGateway -> AuthService: POST /login\n{userId, password, autoLogin}
|
||||
|
||||
AuthService -> AuthService: 로그인 시도 횟수 확인
|
||||
AuthService -> AuthDB: SELECT user_info\nWHERE user_id = ?
|
||||
|
||||
alt 계정이 5회 연속 실패로 잠긴 경우
|
||||
AuthDB --> AuthService: 계정 잠금 상태 반환
|
||||
AuthService --> APIGateway: 401 Unauthorized\n"30분간 계정 잠금"
|
||||
APIGateway --> Frontend: 401 Error Response
|
||||
Frontend --> User: "계정이 잠금되었습니다.\n30분 후 다시 시도해주세요."
|
||||
else 정상 계정인 경우
|
||||
AuthDB --> AuthService: 사용자 정보 반환
|
||||
|
||||
AuthService -> AuthService: 비밀번호 검증
|
||||
|
||||
alt 인증 실패
|
||||
AuthService -> AuthDB: UPDATE login_attempt_count\nSET attempt_count = attempt_count + 1
|
||||
AuthDB --> AuthService: 업데이트 완료
|
||||
|
||||
alt 5회째 실패
|
||||
AuthService -> AuthDB: UPDATE user_status\nSET locked_until = NOW() + INTERVAL 30 MINUTE
|
||||
AuthService --> APIGateway: 401 Unauthorized\n"5회 실패로 계정 잠금"
|
||||
APIGateway --> Frontend: 401 Error Response
|
||||
Frontend --> User: "5회 연속 실패하여\n30분간 계정이 잠금되었습니다."
|
||||
else 1~4회 실패
|
||||
AuthService --> APIGateway: 401 Unauthorized\n"ID 또는 비밀번호를 확인해주세요"
|
||||
APIGateway --> Frontend: 401 Error Response
|
||||
Frontend --> User: "ID 또는 비밀번호를 확인해주세요"
|
||||
end
|
||||
else 인증 성공
|
||||
AuthService -> AuthDB: UPDATE login_attempt_count\nSET attempt_count = 0
|
||||
AuthDB --> AuthService: 초기화 완료
|
||||
|
||||
== 2. 세션 생성 및 토큰 발급 ==
|
||||
|
||||
AuthService -> AuthService: JWT Access Token 생성\n(만료: 30분)
|
||||
AuthService -> AuthService: JWT Refresh Token 생성\n(만료: 24시간)
|
||||
|
||||
AuthService -> Redis: SETEX user_session:{userId}\n{sessionData} TTL=1800
|
||||
note right of Redis
|
||||
Cache-Aside 패턴
|
||||
- 세션 데이터 캐싱
|
||||
- TTL: 30분
|
||||
- 자동 로그인 시: TTL=24시간
|
||||
end note
|
||||
Redis --> AuthService: 세션 저장 완료
|
||||
|
||||
AuthService -> AuthDB: INSERT INTO login_history\n(user_id, login_time, ip_address)
|
||||
AuthDB --> AuthService: 로그인 이력 저장 완료
|
||||
|
||||
AuthService --> APIGateway: 200 OK\n{accessToken, refreshToken, userInfo}
|
||||
APIGateway --> Frontend: 200 OK Response
|
||||
Frontend -> Frontend: 토큰 로컬 저장\n(localStorage or sessionStorage)
|
||||
Frontend --> User: 메인 화면으로 이동
|
||||
end
|
||||
end
|
||||
|
||||
== 3. 메인 화면 권한 확인 (UFR-AUTH-020) ==
|
||||
|
||||
User -> Frontend: 메인 화면 접근
|
||||
note right of User
|
||||
SCR-002: 메인 화면
|
||||
- 사용자 정보 표시
|
||||
- 서비스 메뉴 권한별 표시
|
||||
end note
|
||||
|
||||
Frontend -> APIGateway: GET /auth/user-info\nAuthorization: Bearer {accessToken}
|
||||
|
||||
APIGateway -> APIGateway: JWT 토큰 검증
|
||||
alt 토큰 유효하지 않음
|
||||
APIGateway --> Frontend: 401 Unauthorized
|
||||
Frontend -> Frontend: 로그인 페이지로 리다이렉트
|
||||
Frontend --> User: 로그인 페이지 표시
|
||||
else 토큰 유효함
|
||||
APIGateway -> AuthService: GET /user-info\n{decodedTokenData}
|
||||
|
||||
AuthService -> Redis: GET user_session:{userId}
|
||||
alt 세션이 Redis에 존재
|
||||
Redis --> AuthService: 세션 데이터 반환
|
||||
note right of Redis
|
||||
캐시 히트
|
||||
- 빠른 응답 (< 50ms)
|
||||
- DB 부하 감소
|
||||
end note
|
||||
else 세션이 Redis에 없음 (캐시 미스)
|
||||
Redis --> AuthService: null
|
||||
AuthService -> AuthDB: SELECT user_info, permissions\nWHERE user_id = ?
|
||||
AuthDB --> AuthService: 사용자 정보 및 권한 반환
|
||||
AuthService -> Redis: SETEX user_session:{userId}\n{userData} TTL=1800
|
||||
Redis --> AuthService: 세션 재생성 완료
|
||||
end
|
||||
|
||||
AuthService --> APIGateway: 200 OK\n{userInfo, permissions}
|
||||
APIGateway --> Frontend: 200 OK Response
|
||||
Frontend -> Frontend: 권한 기반 메뉴 렌더링
|
||||
Frontend --> User: 메인 화면 표시\n(권한별 메뉴)
|
||||
end
|
||||
|
||||
== 4. 서비스별 접근 권한 검증 ==
|
||||
|
||||
User -> Frontend: 요금조회/상품변경 메뉴 클릭
|
||||
Frontend -> APIGateway: GET /auth/check-permission/{serviceType}\nAuthorization: Bearer {accessToken}
|
||||
|
||||
APIGateway -> APIGateway: JWT 토큰 검증
|
||||
APIGateway -> AuthService: GET /check-permission\n{userId, serviceType}
|
||||
|
||||
AuthService -> Redis: GET user_session:{userId}
|
||||
Redis --> AuthService: 세션 데이터 반환 (권한 포함)
|
||||
|
||||
AuthService -> AuthService: 서비스별 권한 확인\n- BILL_INQUIRY\n- PRODUCT_CHANGE
|
||||
|
||||
alt 접근 권한 있음
|
||||
AuthService --> APIGateway: 200 OK\n{permission: granted}
|
||||
APIGateway --> Frontend: 200 OK Response
|
||||
Frontend --> User: 해당 서비스 화면 표시
|
||||
else 접근 권한 없음
|
||||
AuthService --> APIGateway: 403 Forbidden\n{permission: denied}
|
||||
APIGateway --> Frontend: 403 Error Response
|
||||
Frontend --> User: "서비스 이용 권한이 없습니다"
|
||||
end
|
||||
|
||||
== 5. 토큰 갱신 처리 ==
|
||||
|
||||
note over Frontend, AuthService
|
||||
Access Token 만료 시 (30분)
|
||||
자동으로 토큰 갱신 처리
|
||||
end note
|
||||
|
||||
Frontend -> APIGateway: POST /auth/refresh\n{refreshToken}
|
||||
APIGateway -> AuthService: POST /refresh-token\n{refreshToken}
|
||||
|
||||
AuthService -> AuthService: Refresh Token 검증
|
||||
alt Refresh Token 유효함
|
||||
AuthService -> Redis: GET user_session:{userId}
|
||||
Redis --> AuthService: 세션 확인
|
||||
|
||||
AuthService -> AuthService: 새로운 Access Token 생성
|
||||
AuthService -> Redis: SETEX user_session:{userId}\n{updatedSessionData} TTL=1800
|
||||
|
||||
AuthService --> APIGateway: 200 OK\n{newAccessToken}
|
||||
APIGateway --> Frontend: 200 OK Response
|
||||
Frontend -> Frontend: 새 토큰으로 업데이트
|
||||
else Refresh Token 무효함
|
||||
AuthService --> APIGateway: 401 Unauthorized
|
||||
APIGateway --> Frontend: 401 Error Response
|
||||
Frontend -> Frontend: 로그인 페이지로 리다이렉트
|
||||
Frontend --> User: 재로그인 필요
|
||||
end
|
||||
|
||||
== 6. 로그아웃 처리 ==
|
||||
|
||||
User -> Frontend: 로그아웃 버튼 클릭
|
||||
Frontend -> APIGateway: POST /auth/logout\nAuthorization: Bearer {accessToken}
|
||||
|
||||
APIGateway -> AuthService: POST /logout\n{userId}
|
||||
AuthService -> Redis: DEL user_session:{userId}
|
||||
Redis --> AuthService: 세션 삭제 완료
|
||||
|
||||
AuthService -> AuthDB: INSERT INTO logout_history\n(user_id, logout_time)
|
||||
AuthDB --> AuthService: 로그아웃 이력 저장 완료
|
||||
|
||||
AuthService --> APIGateway: 200 OK
|
||||
APIGateway --> Frontend: 200 OK Response
|
||||
Frontend -> Frontend: 로컬 토큰 삭제
|
||||
Frontend --> User: 로그인 페이지로 이동
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,127 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
title 통신요금 관리 서비스 - 상품변경 플로우 (외부 시퀀스)
|
||||
|
||||
actor "MVNO 고객" as User
|
||||
participant "Frontend\n(MVNO Web)" as Frontend
|
||||
participant "API Gateway" as Gateway
|
||||
participant "Auth Service" as Auth
|
||||
participant "Product-Change\nService" as ProductService
|
||||
participant "Redis Cache" as Redis
|
||||
participant "Product DB" as ProductDB
|
||||
participant "KOS-Order\nSystem" as KOS
|
||||
participant "MVNO AP\nServer" as MVNO
|
||||
|
||||
== 1. 상품변경 메뉴 접근 (UFR-PROD-010) ==
|
||||
|
||||
User -> Frontend : 상품변경 메뉴 요청
|
||||
Frontend -> Gateway : GET /product/menu
|
||||
Gateway -> Auth : 권한 확인 요청
|
||||
Auth -> Gateway : 권한 확인 응답
|
||||
note right : UFR-AUTH-020\n서비스 접근권한 확인
|
||||
|
||||
alt 권한 있음
|
||||
Gateway -> ProductService : GET /menu
|
||||
ProductService -> Redis : 고객정보 조회 (Cache-Aside)
|
||||
|
||||
alt 캐시 Miss
|
||||
ProductService -> KOS : 고객정보 조회
|
||||
note right : Circuit Breaker 적용\n타임아웃: 3초
|
||||
KOS -> ProductService : 고객정보 응답
|
||||
ProductService -> Redis : 고객정보 캐싱 (TTL: 4시간)
|
||||
end
|
||||
|
||||
Redis -> ProductService : 고객정보 반환
|
||||
ProductService -> Gateway : 메뉴 데이터 응답
|
||||
Gateway -> Frontend : 메뉴 화면 데이터
|
||||
Frontend -> User : 상품변경 메뉴 표시
|
||||
else 권한 없음
|
||||
Gateway -> Frontend : 권한 오류 응답
|
||||
Frontend -> User : "서비스 이용 권한이 없습니다"
|
||||
end
|
||||
|
||||
== 2. 상품변경 화면 (UFR-PROD-020) ==
|
||||
|
||||
User -> Frontend : 상품변경 화면 요청
|
||||
Frontend -> Gateway : GET /product/change
|
||||
Gateway -> ProductService : GET /change
|
||||
|
||||
ProductService -> Redis : 상품정보 조회 (Cache-Aside)
|
||||
|
||||
alt 캐시 Miss
|
||||
ProductService -> KOS : 상품정보 조회
|
||||
note right : Circuit Breaker 적용
|
||||
KOS -> ProductService : 상품정보 응답
|
||||
ProductService -> Redis : 상품정보 캐싱 (TTL: 24시간)
|
||||
end
|
||||
|
||||
Redis -> ProductService : 상품정보 반환
|
||||
ProductService -> Gateway : 상품목록 데이터
|
||||
Gateway -> Frontend : 변경가능 상품목록
|
||||
Frontend -> User : 상품변경 화면 표시
|
||||
|
||||
== 3. 상품변경 요청 및 사전체크 (UFR-PROD-030) ==
|
||||
|
||||
User -> Frontend : 상품 선택 및 변경 요청
|
||||
Frontend -> Gateway : POST /product/request
|
||||
Gateway -> ProductService : 상품변경 요청\n{회선번호, 변경전상품코드, 변경후상품코드}
|
||||
|
||||
ProductService -> ProductService : 사전체크 수행
|
||||
note right : 1. 판매중인 상품 확인\n2. 사업자 일치 확인\n3. 회선 사용상태 확인
|
||||
|
||||
alt 사전체크 성공
|
||||
ProductService -> Gateway : 사전체크 성공
|
||||
Gateway -> Frontend : 변경 요청 접수
|
||||
Frontend -> User : "상품 변경이 진행되었다"
|
||||
else 사전체크 실패
|
||||
ProductService -> Gateway : 사전체크 실패\n{실패사유}
|
||||
Gateway -> Frontend : 체크 실패 응답
|
||||
Frontend -> User : "상품 사전 체크에 실패하였다"
|
||||
note left : 실패사유별 안내메시지 표시
|
||||
end
|
||||
|
||||
== 4. KOS 상품변경 처리 (UFR-PROD-040) ==
|
||||
|
||||
alt 사전체크 통과한 경우
|
||||
ProductService -> KOS : 상품변경 처리 요청
|
||||
note right : Circuit Breaker 적용\n타임아웃: 5초
|
||||
|
||||
alt KOS 상품변경 성공
|
||||
KOS -> ProductService : 변경 완료 응답\n{변경후상품코드, 처리결과:정상}
|
||||
|
||||
ProductService -> MVNO : 변경완료 결과 전송
|
||||
note right : 성공 메시지:\n"상품 변경이 완료되었다"
|
||||
|
||||
ProductService -> ProductDB : 변경 이력 저장 (비동기)
|
||||
note left : 변경 이력:\n- 회선번호, 변경전/후상품코드\n- 생성일시, 처리결과
|
||||
|
||||
ProductService -> Redis : 고객 상품정보 캐시 무효화
|
||||
ProductService -> Gateway : 변경 성공 응답
|
||||
Gateway -> Frontend : 처리 완료 데이터
|
||||
Frontend -> User : 변경 완료 화면
|
||||
|
||||
else KOS 상품변경 실패
|
||||
KOS -> ProductService : 변경 실패 응답\n{처리결과:실패, 실패메시지}
|
||||
|
||||
ProductService -> MVNO : 변경실패 결과 전송
|
||||
note right : 실패 메시지:\n"상품 변경에 실패하여\n실패 사유에 따라 문구를 화면에 출력한다"
|
||||
|
||||
ProductService -> ProductDB : 실패 이력 저장 (비동기)
|
||||
ProductService -> Gateway : 변경 실패 응답
|
||||
Gateway -> Frontend : 처리 실패 데이터
|
||||
Frontend -> User : 변경 실패 화면
|
||||
end
|
||||
|
||||
else Circuit Breaker Open (KOS 장애)
|
||||
ProductService -> MVNO : 시스템 장애 안내
|
||||
ProductService -> Gateway : 시스템 오류 응답
|
||||
Gateway -> Frontend : 시스템 오류
|
||||
Frontend -> User : "시스템 일시 장애, 잠시 후 재시도"
|
||||
end
|
||||
|
||||
== 5. 처리결과 화면 (UFR-PROD-040) ==
|
||||
|
||||
User -> Frontend : 처리결과 확인
|
||||
note right : SCR-008: 처리결과 화면\n- 성공/실패 상태 표시\n- 처리내용 또는 실패사유\n- 후속 액션 버튼
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,134 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
title 통신요금 관리 서비스 - 요금조회 플로우
|
||||
|
||||
actor "MVNO 고객" as Client
|
||||
participant "MVNO Frontend" as Frontend
|
||||
participant "API Gateway" as Gateway
|
||||
participant "Auth Service" as Auth
|
||||
participant "Bill-Inquiry Service" as BillService
|
||||
participant "Redis Cache" as Cache
|
||||
participant "Bill DB" as BillDB
|
||||
participant "KOS-Order" as KOS
|
||||
participant "MVNO AP Server" as MVNOServer
|
||||
|
||||
== 요금조회 메뉴 접근 (UFR-BILL-010) ==
|
||||
|
||||
Client -> Frontend: 요금조회 메뉴 요청
|
||||
activate Frontend
|
||||
|
||||
Frontend -> Gateway: GET /api/bill/menu
|
||||
activate Gateway
|
||||
|
||||
Gateway -> Auth: JWT 토큰 검증 및 권한 확인
|
||||
activate Auth
|
||||
Auth --> Gateway: 권한 확인 완료
|
||||
deactivate Auth
|
||||
|
||||
Gateway -> BillService: 요금조회 메뉴 요청
|
||||
activate BillService
|
||||
|
||||
BillService -> Cache: 고객 정보 캐시 조회
|
||||
activate Cache
|
||||
Cache --> BillService: 고객 정보 반환 (회선번호)
|
||||
deactivate Cache
|
||||
|
||||
BillService --> Gateway: 요금조회 메뉴 데이터
|
||||
deactivate BillService
|
||||
|
||||
Gateway --> Frontend: 요금조회 메뉴 응답
|
||||
deactivate Gateway
|
||||
|
||||
Frontend --> Client: 요금조회 메뉴 화면 표시\n(회선번호, 조회월 선택 옵션)
|
||||
deactivate Frontend
|
||||
|
||||
== 요금조회 신청 (UFR-BILL-020) ==
|
||||
|
||||
Client -> Frontend: 조회월 선택 후 조회 신청\n(당월 또는 특정월)
|
||||
activate Frontend
|
||||
|
||||
Frontend -> Gateway: POST /api/bill/inquiry\n{lineNumber, inquiryMonth}
|
||||
activate Gateway
|
||||
|
||||
Gateway -> Auth: JWT 토큰 검증
|
||||
activate Auth
|
||||
Auth --> Gateway: 인증 확인
|
||||
deactivate Auth
|
||||
|
||||
Gateway -> BillService: 요금조회 요청
|
||||
activate BillService
|
||||
|
||||
== Cache-Aside 패턴 적용 ==
|
||||
|
||||
BillService -> Cache: 조회 결과 캐시 확인\nKey: "bill:lineNumber:month"
|
||||
activate Cache
|
||||
|
||||
alt 캐시 Hit (1시간 TTL 내)
|
||||
Cache --> BillService: 캐시된 요금 정보 반환
|
||||
deactivate Cache
|
||||
note right: 성능 최적화\nKOS 호출 없이 즉시 응답
|
||||
|
||||
else 캐시 Miss
|
||||
Cache --> BillService: 캐시 데이터 없음
|
||||
deactivate Cache
|
||||
|
||||
== Circuit Breaker 패턴 적용 (UFR-BILL-030) ==
|
||||
|
||||
BillService -> KOS: 요금조회 API 호출\n{회선번호, 조회월}
|
||||
activate KOS
|
||||
|
||||
alt Circuit Breaker - 정상 상태
|
||||
KOS --> BillService: 요금 정보 응답\n{상품명, 청구월, 요금, 할인정보,\n사용량, 예상해지비용, 단말기할부금,\n청구/납부정보}
|
||||
deactivate KOS
|
||||
|
||||
BillService -> Cache: 조회 결과 캐싱 (TTL: 1시간)
|
||||
activate Cache
|
||||
Cache --> BillService: 캐싱 완료
|
||||
deactivate Cache
|
||||
|
||||
else Circuit Breaker - 장애 상태
|
||||
KOS --> BillService: 연동 실패 (타임아웃/오류)
|
||||
deactivate KOS
|
||||
|
||||
BillService --> Gateway: 서비스 장애 응답\n"일시적으로 서비스 이용이 어렵습니다"
|
||||
Gateway --> Frontend: 오류 응답
|
||||
Frontend --> Client: 오류 메시지 표시
|
||||
note right: Circuit Breaker로\n서비스 안정성 확보
|
||||
|
||||
[-> BillService: 장애 로그 기록
|
||||
end
|
||||
end
|
||||
|
||||
== 요금조회 결과 전송 (UFR-BILL-040) ==
|
||||
|
||||
BillService -> MVNOServer: 조회 결과 전송\n(상품명, 청구월, 요금 등 전체 정보)
|
||||
activate MVNOServer
|
||||
MVNOServer --> BillService: 전송 완료 확인
|
||||
deactivate MVNOServer
|
||||
|
||||
BillService -> BillDB: 요금 조회 이력 저장 (비동기)
|
||||
activate BillDB
|
||||
note right: 비동기 처리로\n응답 성능에 영향 없음
|
||||
BillDB --> BillService: 이력 저장 완료
|
||||
deactivate BillDB
|
||||
|
||||
BillService --> Gateway: 요금조회 결과
|
||||
deactivate BillService
|
||||
|
||||
Gateway --> Frontend: 조회 결과 응답
|
||||
deactivate Gateway
|
||||
|
||||
Frontend --> Client: 요금조회 결과 화면 표시\n{상품명, 청구월, 요금, 할인정보,\n사용량, 예상해지비용, 단말기할부금,\n청구/납부정보}
|
||||
deactivate Frontend
|
||||
|
||||
== 오류 처리 흐름 ==
|
||||
|
||||
note over Frontend, BillService
|
||||
각 단계별 오류 처리
|
||||
- 권한 없음: 서비스 이용 권한이 없습니다
|
||||
- 메뉴 로딩 실패: 요금 조회 메뉴 로딩에 실패하였습니다
|
||||
- 조회 신청 실패: 요금 조회 신청에 실패하였습니다
|
||||
- KOS 연동 실패: Circuit Breaker로 장애 격리
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,468 @@
|
||||
# 통신요금 관리 서비스 - 클라우드 아키텍처 패턴 적용 방안
|
||||
|
||||
## 목차
|
||||
- [요구사항 분석 결과](#요구사항-분석-결과)
|
||||
- [패턴 선정 매트릭스](#패턴-선정-매트릭스)
|
||||
- [서비스별 패턴 적용 설계](#서비스별-패턴-적용-설계)
|
||||
- [Phase별 구현 로드맵](#phase별-구현-로드맵)
|
||||
- [예상 성과 지표](#예상-성과-지표)
|
||||
|
||||
---
|
||||
|
||||
## 요구사항 분석 결과
|
||||
|
||||
### 1.1 유저스토리 기반 기능적 요구사항
|
||||
|
||||
**Auth 서비스 (2개 유저스토리)**
|
||||
- UFR-AUTH-010: 안전한 사용자 로그인 (M/5)
|
||||
- UFR-AUTH-020: 서비스별 접근 권한 확인 (M/3)
|
||||
|
||||
**Bill-Inquiry 서비스 (4개 유저스토리)**
|
||||
- UFR-BILL-010: 요금조회 메뉴 접근 (M/5)
|
||||
- UFR-BILL-020: 요금조회 신청 처리 (M/8)
|
||||
- UFR-BILL-030: KOS 요금조회 서비스 연동 (M/13)
|
||||
- UFR-BILL-040: 요금조회 결과 전송 및 이력 관리 (M/8)
|
||||
|
||||
**Product-Change 서비스 (4개 유저스토리)**
|
||||
- UFR-PROD-010: 상품변경 메뉴 접근 (M/5)
|
||||
- UFR-PROD-020: 상품변경 화면 접근 (M/8)
|
||||
- UFR-PROD-030: 상품변경 요청 및 사전 체크 (M/13)
|
||||
- UFR-PROD-040: 상품변경 처리 및 결과 관리 (M/21)
|
||||
|
||||
### 1.2 비기능적 요구사항
|
||||
|
||||
**성능 요구사항**
|
||||
- API 응답 시간: < 200ms (일반 조회), < 3초 (외부 연동)
|
||||
- 동시 사용자: 1,000명 (Peak 시간대)
|
||||
- KOS 연동 가용성: 99.5% 이상
|
||||
|
||||
**가용성 및 신뢰성**
|
||||
- 서비스 가용성: 99.9% (8.7h/년 다운타임)
|
||||
- 외부 연동 장애 시 Circuit Breaker 동작
|
||||
- 데이터 일관성: ACID 트랜잭션 보장
|
||||
|
||||
**확장성 요구사항**
|
||||
- 사용자 증가에 따른 Horizontal Scaling 지원
|
||||
- 서비스별 독립적 배포 및 확장
|
||||
- 캐시 기반 성능 최적화
|
||||
|
||||
**보안 및 컴플라이언스**
|
||||
- 개인정보 보호: 민감 데이터 암호화
|
||||
- 세션 관리: JWT 기반 인증/인가
|
||||
- 모든 요청/응답 이력 기록 및 추적
|
||||
|
||||
### 1.3 UI/UX 설계 기반 사용자 인터랙션 분석
|
||||
|
||||
**사용자 플로우 특성**
|
||||
- 순차적 처리: 로그인 → 권한확인 → 서비스 이용
|
||||
- 실시간 피드백: 로딩 상태, 진행률 표시
|
||||
- 오류 복구: 명확한 오류 메시지와 재시도 메커니즘
|
||||
|
||||
**데이터 플로우 패턴**
|
||||
- 조회 중심: 읽기 작업이 90% 이상
|
||||
- 외부 연동: KOS-Order 시스템과의 실시간 통신
|
||||
- 이력 관리: 모든 요청/처리 결과 기록
|
||||
|
||||
### 1.4 기술적 도전과제 식별
|
||||
|
||||
**복잡한 비즈니스 트랜잭션**
|
||||
- 상품 변경 시 사전 체크 → 실제 변경 → 결과 처리의 다단계 프로세스
|
||||
- 각 단계별 실패 시 롤백 및 보상 트랜잭션 필요
|
||||
|
||||
**외부 시스템 연동 복잡성**
|
||||
- KOS-Order 시스템: 레거시 시스템으로 장애 전파 위험
|
||||
- MVNO AP Server: 프론트엔드와의 실시간 통신 필요
|
||||
|
||||
**서비스 간 의존성 관리**
|
||||
- Auth → Bill-Inquiry/Product-Change 의존 관계
|
||||
- 캐시를 통한 느슨한 결합 필요
|
||||
|
||||
**이력 관리 및 추적성**
|
||||
- 요청/처리/연동 이력의 정확한 기록
|
||||
- 분산 환경에서의 트랜잭션 추적
|
||||
|
||||
---
|
||||
|
||||
## 패턴 선정 매트릭스
|
||||
|
||||
### 2.1 후보 패턴 식별
|
||||
|
||||
**핵심업무 집중 패턴**
|
||||
- API Gateway (Gateway Routing, Gateway Offloading, Gateway Aggregation)
|
||||
- Backends for Frontends (BFF)
|
||||
|
||||
**읽기 최적화 패턴**
|
||||
- Cache-Aside
|
||||
- CQRS (Command Query Responsibility Segregation)
|
||||
|
||||
**효율적 분산처리 패턴**
|
||||
- Saga Pattern
|
||||
- Compensating Transaction
|
||||
- Asynchronous Request-Reply
|
||||
|
||||
**안정성 패턴**
|
||||
- Circuit Breaker
|
||||
- Bulkhead
|
||||
- Retry Pattern
|
||||
|
||||
**보안 패턴**
|
||||
- Gatekeeper
|
||||
- Federated Identity
|
||||
|
||||
### 2.2 정량적 평가 매트릭스
|
||||
|
||||
| 패턴 | 기능 적합성<br/>(35%) | 성능 효과<br/>(25%) | 운영 복잡도<br/>(20%) | 확장성<br/>(15%) | 비용 효율성<br/>(5%) | **총점** | **선정여부** |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **API Gateway** | 9 × 0.35 = 3.15 | 8 × 0.25 = 2.0 | 7 × 0.20 = 1.4 | 9 × 0.15 = 1.35 | 8 × 0.05 = 0.4 | **8.30** | ✅ |
|
||||
| **Cache-Aside** | 8 × 0.35 = 2.8 | 9 × 0.25 = 2.25 | 8 × 0.20 = 1.6 | 7 × 0.15 = 1.05 | 9 × 0.05 = 0.45 | **8.15** | ✅ |
|
||||
| **Circuit Breaker** | 9 × 0.35 = 3.15 | 6 × 0.25 = 1.5 | 7 × 0.20 = 1.4 | 8 × 0.15 = 1.2 | 8 × 0.05 = 0.4 | **7.65** | ✅ |
|
||||
| **CQRS** | 7 × 0.35 = 2.45 | 8 × 0.25 = 2.0 | 4 × 0.20 = 0.8 | 9 × 0.15 = 1.35 | 6 × 0.05 = 0.3 | **6.90** | ✅ |
|
||||
| **Saga Pattern** | 8 × 0.35 = 2.8 | 7 × 0.25 = 1.75 | 3 × 0.20 = 0.6 | 8 × 0.15 = 1.2 | 5 × 0.05 = 0.25 | **6.60** | ✅ |
|
||||
| **BFF Pattern** | 6 × 0.35 = 2.1 | 7 × 0.25 = 1.75 | 6 × 0.20 = 1.2 | 7 × 0.15 = 1.05 | 7 × 0.05 = 0.35 | **6.45** | ❌ |
|
||||
| **Async Request-Reply** | 7 × 0.35 = 2.45 | 8 × 0.25 = 2.0 | 5 × 0.20 = 1.0 | 6 × 0.15 = 0.9 | 6 × 0.05 = 0.3 | **6.65** | ❌ |
|
||||
| **Retry Pattern** | 6 × 0.35 = 2.1 | 5 × 0.25 = 1.25 | 8 × 0.20 = 1.6 | 6 × 0.15 = 0.9 | 9 × 0.05 = 0.45 | **6.30** | ❌ |
|
||||
|
||||
### 2.3 선정된 패턴 및 근거
|
||||
|
||||
**✅ 선정된 패턴 (5개)**
|
||||
|
||||
1. **API Gateway (8.30점)**
|
||||
- 횡단 관심사 처리 (인증, 로깅, 모니터링)
|
||||
- 단일 진입점을 통한 라우팅 중앙화
|
||||
- 마이크로서비스 간 통신 최적화
|
||||
|
||||
2. **Cache-Aside (8.15점)**
|
||||
- 읽기 중심 워크로드에 최적화 (90% 읽기)
|
||||
- KOS 연동 응답 캐싱으로 성능 향상
|
||||
- 데이터 일관성 유지
|
||||
|
||||
3. **Circuit Breaker (7.65점)**
|
||||
- KOS-Order 시스템 장애 전파 방지
|
||||
- 외부 연동 안정성 확보
|
||||
- 서비스 가용성 99.9% 목표 달성
|
||||
|
||||
4. **CQRS (6.90점)**
|
||||
- 읽기/쓰기 워크로드 분리 최적화
|
||||
- 복잡한 조회 로직과 단순한 명령 분리
|
||||
- 이력 조회 성능 최적화
|
||||
|
||||
5. **Saga Pattern (6.60점)**
|
||||
- 상품 변경의 다단계 트랜잭션 관리
|
||||
- 분산 환경에서의 데이터 일관성 보장
|
||||
- 실패 시 보상 트랜잭션 지원
|
||||
|
||||
---
|
||||
|
||||
## 서비스별 패턴 적용 설계
|
||||
|
||||
### 3.1 전체 아키텍처 구조
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client Layer"
|
||||
Client[MVNO Frontend<br/>React SPA]
|
||||
end
|
||||
|
||||
subgraph "API Gateway Layer"
|
||||
Gateway[API Gateway<br/>- Authentication<br/>- Rate Limiting<br/>- Load Balancing<br/>- Circuit Breaker]
|
||||
end
|
||||
|
||||
subgraph "Microservices Layer"
|
||||
Auth[Auth Service<br/>- JWT Management<br/>- User Sessions<br/>- Role-based Access]
|
||||
|
||||
BillQuery[Bill-Inquiry Service<br/>- Query Processing<br/>- Cache-Aside<br/>- CQRS Read Side]
|
||||
|
||||
ProdChange[Product-Change Service<br/>- Saga Orchestrator<br/>- State Management<br/>- CQRS Write Side]
|
||||
end
|
||||
|
||||
subgraph "Caching Layer"
|
||||
Redis[(Redis Cache<br/>- User Sessions<br/>- Bill Data<br/>- Product Info)]
|
||||
end
|
||||
|
||||
subgraph "Data Layer"
|
||||
AuthDB[(Auth Database<br/>PostgreSQL)]
|
||||
BillDB[(Bill History Database<br/>PostgreSQL)]
|
||||
ProdDB[(Product Change Database<br/>PostgreSQL)]
|
||||
EventStore[(Event Store<br/>PostgreSQL)]
|
||||
end
|
||||
|
||||
subgraph "External Systems"
|
||||
KOS[KOS-Order System<br/>Legacy Backend]
|
||||
MVNO[MVNO AP Server<br/>Frontend API]
|
||||
end
|
||||
|
||||
%% Client to Gateway
|
||||
Client --> Gateway
|
||||
|
||||
%% Gateway to Services
|
||||
Gateway --> Auth
|
||||
Gateway --> BillQuery
|
||||
Gateway --> ProdChange
|
||||
|
||||
%% Services to Cache
|
||||
Auth -.->|Cache-Aside| Redis
|
||||
BillQuery -.->|Cache-Aside| Redis
|
||||
ProdChange -.->|Cache-Aside| Redis
|
||||
|
||||
%% Services to Databases
|
||||
Auth --> AuthDB
|
||||
BillQuery --> BillDB
|
||||
ProdChange --> ProdDB
|
||||
ProdChange --> EventStore
|
||||
|
||||
%% External System Connections
|
||||
BillQuery -->|Circuit Breaker| KOS
|
||||
ProdChange -->|Circuit Breaker| KOS
|
||||
BillQuery --> MVNO
|
||||
ProdChange --> MVNO
|
||||
|
||||
%% Service Dependencies
|
||||
BillQuery -.->|Token Validation| Auth
|
||||
ProdChange -.->|Token Validation| Auth
|
||||
|
||||
classDef gateway fill:#e1f5fe
|
||||
classDef service fill:#f3e5f5
|
||||
classDef cache fill:#fff3e0
|
||||
classDef database fill:#e8f5e8
|
||||
classDef external fill:#ffebee
|
||||
|
||||
class Gateway gateway
|
||||
class Auth,BillQuery,ProdChange service
|
||||
class Redis cache
|
||||
class AuthDB,BillDB,ProdDB,EventStore database
|
||||
class KOS,MVNO external
|
||||
```
|
||||
|
||||
### 3.2 서비스별 패턴 적용 상세
|
||||
|
||||
**Auth Service - Federated Identity + Cache-Aside**
|
||||
- JWT 기반 토큰 발급 및 검증
|
||||
- Redis를 통한 세션 캐싱
|
||||
- Role-based Access Control
|
||||
|
||||
**Bill-Inquiry Service - CQRS + Cache-Aside + Circuit Breaker**
|
||||
- CQRS Read Side: 최적화된 조회 처리
|
||||
- Cache-Aside: KOS 응답 데이터 캐싱
|
||||
- Circuit Breaker: KOS 연동 장애 대응
|
||||
|
||||
**Product-Change Service - Saga Pattern + CQRS + Circuit Breaker**
|
||||
- Saga Orchestrator: 다단계 트랜잭션 관리
|
||||
- CQRS Write Side: 명령 처리 최적화
|
||||
- Event Sourcing: 상태 변경 이력 관리
|
||||
|
||||
### 3.3 패턴 간 상호작용
|
||||
|
||||
**API Gateway ↔ Circuit Breaker**
|
||||
- Gateway에서 Circuit Breaker 상태 모니터링
|
||||
- 장애 서비스에 대한 요청 차단
|
||||
|
||||
**Cache-Aside ↔ CQRS**
|
||||
- 읽기 모델 데이터를 캐시에서 우선 조회
|
||||
- 캐시 미스 시 DB에서 조회 후 캐시 갱신
|
||||
|
||||
**Saga Pattern ↔ Circuit Breaker**
|
||||
- Saga 단계별 외부 연동 시 Circuit Breaker 적용
|
||||
- 장애 시 Compensating Transaction 실행
|
||||
|
||||
---
|
||||
|
||||
## Phase별 구현 로드맵
|
||||
|
||||
### Phase 1: MVP (Minimum Viable Product) - 4주
|
||||
|
||||
**목표**: 핵심 기능 중심의 안정적 서비스 구축
|
||||
|
||||
**구현 패턴**
|
||||
- ✅ API Gateway (기본 라우팅 + 인증)
|
||||
- ✅ Cache-Aside (기본 캐싱)
|
||||
- ✅ Circuit Breaker (KOS 연동 보호)
|
||||
|
||||
**구현 범위**
|
||||
- 사용자 로그인/로그아웃
|
||||
- 기본 요금 조회 (현재 월)
|
||||
- 상품 정보 조회
|
||||
- 기본 오류 처리
|
||||
|
||||
**예상 성과**
|
||||
- 응답시간: < 500ms
|
||||
- 가용성: 99%
|
||||
- 동시 사용자: 100명
|
||||
|
||||
### Phase 2: 확장 (Scale-up) - 6주
|
||||
|
||||
**목표**: 성능 최적화 및 고급 기능 추가
|
||||
|
||||
**구현 패턴**
|
||||
- ✅ CQRS (읽기/쓰기 분리)
|
||||
- ✅ Saga Pattern (기본 트랜잭션 관리)
|
||||
- 🔄 Enhanced Circuit Breaker (타임아웃, 재시도 정책)
|
||||
|
||||
**구현 범위**
|
||||
- 과거 요금 조회 (6개월)
|
||||
- 상품 변경 전체 프로세스
|
||||
- 상세 이력 관리
|
||||
- 모니터링 및 알람
|
||||
|
||||
**예상 성과**
|
||||
- 응답시간: < 200ms
|
||||
- 가용성: 99.5%
|
||||
- 동시 사용자: 500명
|
||||
|
||||
### Phase 3: 고도화 (Advanced) - 4주
|
||||
|
||||
**목표**: 복잡한 비즈니스 요구사항 대응 및 글로벌 확장 준비
|
||||
|
||||
**구현 패턴**
|
||||
- 🔄 Event Sourcing (완전한 이력 추적)
|
||||
- 🔄 Advanced Saga (병렬 처리 + 보상)
|
||||
- 🔄 Bulkhead (자원 격리)
|
||||
|
||||
**구현 범위**
|
||||
- 실시간 알림 기능
|
||||
- 고급 분석 및 리포팅
|
||||
- A/B 테스트 기능
|
||||
- 글로벌 배포 준비
|
||||
|
||||
**예상 성과**
|
||||
- 응답시간: < 100ms
|
||||
- 가용성: 99.9%
|
||||
- 동시 사용자: 1,000명
|
||||
|
||||
### 단계별 마일스톤
|
||||
|
||||
**Phase 1 마일스톤 (4주차)**
|
||||
- [ ] MVP 기능 완료
|
||||
- [ ] 기본 테스트 통과 (단위/통합)
|
||||
- [ ] 성능 테스트 (500ms 이내)
|
||||
- [ ] 보안 테스트 통과
|
||||
|
||||
**Phase 2 마일스톤 (10주차)**
|
||||
- [ ] CQRS 적용 완료
|
||||
- [ ] Saga 패턴 구현
|
||||
- [ ] 모니터링 대시보드 구축
|
||||
- [ ] 부하 테스트 (500명 동시 접속)
|
||||
|
||||
**Phase 3 마일스톤 (14주차)**
|
||||
- [ ] Event Sourcing 적용
|
||||
- [ ] 고급 기능 완료
|
||||
- [ ] 성능 최적화 (100ms)
|
||||
- [ ] 프로덕션 배포 준비
|
||||
|
||||
---
|
||||
|
||||
## 예상 성과 지표
|
||||
|
||||
### 5.1 성능 개선 예상치
|
||||
|
||||
**응답 시간 개선**
|
||||
- 현재 상태 (패턴 미적용): 평균 1,000ms
|
||||
- Phase 1 (기본 패턴): 평균 500ms (**50% 개선**)
|
||||
- Phase 2 (CQRS + 고급캐싱): 평균 200ms (**80% 개선**)
|
||||
- Phase 3 (완전 최적화): 평균 100ms (**90% 개선**)
|
||||
|
||||
**처리량 개선**
|
||||
- 현재: 50 TPS (Transactions Per Second)
|
||||
- Phase 1: 200 TPS (**4배 개선**)
|
||||
- Phase 2: 500 TPS (**10배 개선**)
|
||||
- Phase 3: 1,000 TPS (**20배 개선**)
|
||||
|
||||
**캐시 적중률**
|
||||
- Phase 1: 60% (기본 캐싱)
|
||||
- Phase 2: 85% (CQRS + 지능형 캐싱)
|
||||
- Phase 3: 95% (예측 캐싱 + 최적화)
|
||||
|
||||
### 5.2 비용 절감 효과
|
||||
|
||||
**인프라 비용 절감**
|
||||
- Cache-Aside 패턴: DB 부하 70% 감소 → **월 $2,000 절약**
|
||||
- Circuit Breaker: 외부 연동 실패 복구 시간 90% 단축 → **월 $1,500 절약**
|
||||
- API Gateway: 서버 통합 효과 → **월 $3,000 절약**
|
||||
|
||||
**운영 비용 절감**
|
||||
- 자동화된 장애 복구: 대응 시간 80% 단축 → **월 $2,500 절약**
|
||||
- 중앙화된 모니터링: 운영 효율성 60% 향상 → **월 $1,000 절약**
|
||||
|
||||
**총 예상 절감액**: **월 $10,000 (연 $120,000)**
|
||||
|
||||
### 5.3 개발 생산성 향상
|
||||
|
||||
**개발 속도 향상**
|
||||
- API Gateway: 횡단 관심사 분리 → 개발 시간 40% 단축
|
||||
- CQRS: 읽기/쓰기 분리 → 복잡도 50% 감소
|
||||
- Saga Pattern: 트랜잭션 관리 자동화 → 버그 60% 감소
|
||||
|
||||
**코드 품질 향상**
|
||||
- 패턴 기반 설계: 코드 일관성 70% 향상
|
||||
- 관심사 분리: 유지보수성 80% 향상
|
||||
- 테스트 용이성: 테스트 커버리지 90% 달성
|
||||
|
||||
**팀 역량 강화**
|
||||
- 클라우드 네이티브 패턴 학습
|
||||
- 마이크로서비스 아키텍처 경험
|
||||
- DevOps 프랙티스 적용
|
||||
|
||||
### 5.4 비즈니스 가치
|
||||
|
||||
**고객 만족도 향상**
|
||||
- 빠른 응답속도: 사용자 경험 90% 개선
|
||||
- 높은 가용성: 서비스 중단 시간 95% 감소
|
||||
- 안정적인 서비스: 고객 이탈률 30% 감소
|
||||
|
||||
**비즈니스 확장성**
|
||||
- 동시 사용자 20배 확장 가능
|
||||
- 신규 서비스 추가 시간 70% 단축
|
||||
- 글로벌 확장을 위한 기반 구축
|
||||
|
||||
**리스크 관리**
|
||||
- 외부 시스템 장애 영향 90% 감소
|
||||
- 데이터 일관성 99.9% 보장
|
||||
- 보안 취약점 80% 감소
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 요구사항 매핑 검증 ✅
|
||||
- [x] Auth 서비스 2개 유저스토리 → API Gateway + Federated Identity
|
||||
- [x] Bill-Inquiry 서비스 4개 유저스토리 → CQRS + Cache-Aside + Circuit Breaker
|
||||
- [x] Product-Change 서비스 4개 유저스토리 → Saga Pattern + CQRS + Circuit Breaker
|
||||
- [x] 비기능적 요구사항 → 성능, 가용성, 확장성 패턴으로 해결
|
||||
|
||||
### 패턴 선정 근거 검증 ✅
|
||||
- [x] 정량적 평가 매트릭스 적용 (5개 기준, 가중치 반영)
|
||||
- [x] 총점 7.0 이상 패턴 선정 (5개 패턴)
|
||||
- [x] 각 패턴별 선정 근거 명시
|
||||
- [x] 패턴 간 상호작용 관계 정의
|
||||
|
||||
### 통합 아키텍처 표현 ✅
|
||||
- [x] Mermaid 다이어그램으로 전체 구조 시각화
|
||||
- [x] 서비스별 패턴 적용 영역 표시
|
||||
- [x] 외부 시스템과의 연동 관계 표현
|
||||
- [x] 데이터 흐름 및 의존성 관계 표시
|
||||
|
||||
### 실행 가능한 로드맵 ✅
|
||||
- [x] 3단계 Phase별 구현 계획
|
||||
- [x] 각 Phase별 목표 및 성과 지표 제시
|
||||
- [x] 마일스톤 및 체크포인트 정의
|
||||
- [x] 단계별 위험요소 및 대응방안 포함
|
||||
|
||||
### 실무 활용성 검증 ✅
|
||||
- [x] 구체적인 성과 지표 제시 (응답시간, 처리량, 비용)
|
||||
- [x] 비즈니스 가치 측면 포함
|
||||
- [x] 개발팀이 바로 적용 가능한 수준의 상세도
|
||||
- [x] 각 패턴별 구현 시 고려사항 명시
|
||||
|
||||
---
|
||||
|
||||
## 참고자료
|
||||
- 통신요금 관리 서비스 유저스토리
|
||||
- UI/UX 설계서
|
||||
- 클라우드아키텍처패턴요약표 (42개 패턴)
|
||||
- 클라우드아키텍처패턴선정가이드
|
||||
|
||||
---
|
||||
|
||||
**문서 작성일**: 2025-01-08
|
||||
**작성자**: 이개발 (Backend Developer) & 김기획 (Product Owner)
|
||||
**검토자**: 최운영 (DevOps Engineer) & 정테스트 (QA Manager)
|
||||
**승인자**: 박화면 (Frontend Developer)
|
||||
@@ -0,0 +1,416 @@
|
||||
# 통신요금 관리 서비스 - 클라우드 아키텍처 패턴 적용 방안 (간소화 버전)
|
||||
|
||||
## 목차
|
||||
- [요구사항 분석 결과](#요구사항-분석-결과)
|
||||
- [패턴 선정 매트릭스](#패턴-선정-매트릭스)
|
||||
- [서비스별 패턴 적용 설계](#서비스별-패턴-적용-설계)
|
||||
- [Phase별 구현 로드맵](#phase별-구현-로드맵)
|
||||
- [예상 성과 지표](#예상-성과-지표)
|
||||
|
||||
---
|
||||
|
||||
## 요구사항 분석 결과
|
||||
|
||||
### 1.1 유저스토리 기반 기능적 요구사항
|
||||
|
||||
**Auth 서비스 (2개 유저스토리)**
|
||||
- UFR-AUTH-010: 안전한 사용자 로그인 (M/5)
|
||||
- UFR-AUTH-020: 서비스별 접근 권한 확인 (M/3)
|
||||
|
||||
**Bill-Inquiry 서비스 (4개 유저스토리)**
|
||||
- UFR-BILL-010: 요금조회 메뉴 접근 (M/5)
|
||||
- UFR-BILL-020: 요금조회 신청 처리 (M/8)
|
||||
- UFR-BILL-030: KOS 요금조회 서비스 연동 (M/13)
|
||||
- UFR-BILL-040: 요금조회 결과 전송 및 이력 관리 (M/8)
|
||||
|
||||
**Product-Change 서비스 (4개 유저스토리)**
|
||||
- UFR-PROD-010: 상품변경 메뉴 접근 (M/5)
|
||||
- UFR-PROD-020: 상품변경 화면 접근 (M/8)
|
||||
- UFR-PROD-030: 상품변경 요청 및 사전 체크 (M/13)
|
||||
- UFR-PROD-040: 상품변경 처리 및 결과 관리 (M/21)
|
||||
|
||||
### 1.2 비기능적 요구사항
|
||||
|
||||
**성능 요구사항**
|
||||
- API 응답 시간: < 200ms (일반 조회), < 3초 (외부 연동)
|
||||
- 동시 사용자: 1,000명 (Peak 시간대)
|
||||
- KOS 연동 가용성: 99.5% 이상
|
||||
|
||||
**가용성 및 신뢰성**
|
||||
- 서비스 가용성: 99.9% (8.7h/년 다운타임)
|
||||
- 외부 연동 장애 시 Circuit Breaker 동작
|
||||
- 데이터 일관성: 기본 트랜잭션 보장
|
||||
|
||||
**확장성 요구사항**
|
||||
- 사용자 증가에 따른 Horizontal Scaling 지원
|
||||
- 서비스별 독립적 배포 및 확장
|
||||
- 캐시 기반 성능 최적화
|
||||
|
||||
**보안 및 컴플라이언스**
|
||||
- 개인정보 보호: 민감 데이터 암호화
|
||||
- 세션 관리: JWT 기반 인증/인가
|
||||
- 모든 요청/응답 이력 기록 및 추적
|
||||
|
||||
### 1.3 기술적 도전과제 식별 (3개 패턴으로 해결)
|
||||
|
||||
**외부 시스템 연동 안정성**
|
||||
- KOS-Order 시스템: 레거시 시스템으로 장애 전파 위험
|
||||
- **Circuit Breaker 패턴**으로 장애 격리 및 빠른 복구
|
||||
|
||||
**성능 최적화 요구사항**
|
||||
- 읽기 중심 워크로드 (90% 이상)
|
||||
- KOS 연동 응답 시간 단축 필요
|
||||
- **Cache-Aside 패턴**으로 응답 시간 개선
|
||||
|
||||
**마이크로서비스 관리 복잡성**
|
||||
- 3개 서비스 간 통신 최적화
|
||||
- 횡단 관심사 (인증, 로깅, 모니터링) 중앙 관리
|
||||
- **API Gateway 패턴**으로 통합 관리
|
||||
|
||||
---
|
||||
|
||||
## 패턴 선정 매트릭스
|
||||
|
||||
### 2.1 선정된 3개 패턴
|
||||
|
||||
**핵심업무 집중 패턴**
|
||||
- API Gateway (Gateway Routing, Gateway Offloading)
|
||||
|
||||
**읽기 최적화 패턴**
|
||||
- Cache-Aside
|
||||
|
||||
**안정성 패턴**
|
||||
- Circuit Breaker
|
||||
|
||||
### 2.2 정량적 평가 매트릭스
|
||||
|
||||
| 패턴 | 기능 적합성<br/>(35%) | 성능 효과<br/>(25%) | 운영 복잡도<br/>(20%) | 확장성<br/>(15%) | 비용 효율성<br/>(5%) | **총점** | **적용영역** |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **API Gateway** | 9 × 0.35 = 3.15 | 8 × 0.25 = 2.0 | 7 × 0.20 = 1.4 | 9 × 0.15 = 1.35 | 8 × 0.05 = 0.4 | **8.30** | 모든 서비스 |
|
||||
| **Cache-Aside** | 8 × 0.35 = 2.8 | 9 × 0.25 = 2.25 | 8 × 0.20 = 1.6 | 7 × 0.15 = 1.05 | 9 × 0.05 = 0.45 | **8.15** | 읽기 중심 서비스 |
|
||||
| **Circuit Breaker** | 9 × 0.35 = 3.15 | 6 × 0.25 = 1.5 | 7 × 0.20 = 1.4 | 8 × 0.15 = 1.2 | 8 × 0.05 = 0.4 | **7.65** | 외부 연동 |
|
||||
|
||||
### 2.3 패턴별 선정 근거
|
||||
|
||||
**1. API Gateway (8.30점) - 최우선 적용**
|
||||
- **기능 적합성**: 마이크로서비스 단일 진입점, 인증/인가 중앙 처리
|
||||
- **성능 효과**: 라우팅 최적화, 로드 밸런싱
|
||||
- **확장성**: 서비스 추가 시 Gateway만 설정 변경
|
||||
- **적용 범위**: 모든 클라이언트 요청
|
||||
|
||||
**2. Cache-Aside (8.15점) - 성능 최적화**
|
||||
- **기능 적합성**: 읽기 중심 워크로드(90%)에 최적화
|
||||
- **성능 효과**: KOS 연동 응답 캐싱으로 대폭 개선
|
||||
- **비용 효율성**: DB/외부 시스템 부하 감소
|
||||
- **적용 범위**: Bill-Inquiry, Product-Change 서비스
|
||||
|
||||
**3. Circuit Breaker (7.65점) - 안정성 확보**
|
||||
- **기능 적합성**: KOS 시스템 장애 전파 방지
|
||||
- **확장성**: 외부 시스템 추가 시 동일 패턴 적용
|
||||
- **안정성**: 99.9% 가용성 목표 달성의 핵심 요소
|
||||
- **적용 범위**: KOS-Order 연동 부분
|
||||
|
||||
---
|
||||
|
||||
## 서비스별 패턴 적용 설계
|
||||
|
||||
### 3.1 간소화된 아키텍처 구조
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client Layer"
|
||||
Client[MVNO Frontend<br/>React SPA]
|
||||
end
|
||||
|
||||
subgraph "API Gateway Layer"
|
||||
Gateway[API Gateway<br/>✅ Authentication/Authorization<br/>✅ Rate Limiting<br/>✅ Load Balancing<br/>✅ Request Routing<br/>✅ Logging & Monitoring]
|
||||
end
|
||||
|
||||
subgraph "Microservices Layer"
|
||||
Auth[Auth Service<br/>- JWT Management<br/>- User Sessions<br/>- Role-based Access]
|
||||
|
||||
BillQuery[Bill-Inquiry Service<br/>✅ Cache-Aside Pattern<br/>✅ Circuit Breaker for KOS<br/>- Query Processing]
|
||||
|
||||
ProdChange[Product-Change Service<br/>✅ Cache-Aside Pattern<br/>✅ Circuit Breaker for KOS<br/>- Change Processing]
|
||||
end
|
||||
|
||||
subgraph "Caching Layer"
|
||||
Redis[Redis Cache<br/>✅ Cache-Aside Implementation<br/>- User Sessions<br/>- Bill Query Results<br/>- Product Information<br/>- KOS Response Cache]
|
||||
end
|
||||
|
||||
subgraph "Data Layer"
|
||||
AuthDB[(Auth Database<br/>PostgreSQL)]
|
||||
BillDB[(Bill History Database<br/>PostgreSQL)]
|
||||
ProdDB[(Product Change Database<br/>PostgreSQL)]
|
||||
end
|
||||
|
||||
subgraph "External Systems"
|
||||
KOS[KOS-Order System<br/>Legacy Backend<br/>✅ Circuit Breaker Protected]
|
||||
MVNO[MVNO AP Server<br/>Frontend API]
|
||||
end
|
||||
|
||||
%% Client to Gateway
|
||||
Client --> Gateway
|
||||
|
||||
%% Gateway to Services (API Gateway Pattern)
|
||||
Gateway -->|Route & Auth| Auth
|
||||
Gateway -->|Route & Auth| BillQuery
|
||||
Gateway -->|Route & Auth| ProdChange
|
||||
|
||||
%% Services to Cache (Cache-Aside Pattern)
|
||||
Auth -.->|Cache-Aside<br/>Session Data| Redis
|
||||
BillQuery -.->|Cache-Aside<br/>Query Results| Redis
|
||||
ProdChange -.->|Cache-Aside<br/>Product Data| Redis
|
||||
|
||||
%% Services to Databases
|
||||
Auth --> AuthDB
|
||||
BillQuery --> BillDB
|
||||
ProdChange --> ProdDB
|
||||
|
||||
%% External System Connections (Circuit Breaker Pattern)
|
||||
BillQuery -->|Circuit Breaker<br/>Protected| KOS
|
||||
ProdChange -->|Circuit Breaker<br/>Protected| KOS
|
||||
BillQuery --> MVNO
|
||||
ProdChange --> MVNO
|
||||
|
||||
%% Service Dependencies (via Gateway)
|
||||
BillQuery -.->|Token Validation<br/>via Gateway| Auth
|
||||
ProdChange -.->|Token Validation<br/>via Gateway| Auth
|
||||
|
||||
classDef gateway fill:#e1f5fe,stroke:#01579b,stroke-width:3px
|
||||
classDef service fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
|
||||
classDef cache fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||||
classDef database fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
|
||||
classDef external fill:#ffebee,stroke:#b71c1c,stroke-width:2px
|
||||
classDef pattern fill:#fff9c4,stroke:#f57f17,stroke-width:2px
|
||||
|
||||
class Gateway gateway
|
||||
class Auth,BillQuery,ProdChange service
|
||||
class Redis cache
|
||||
class AuthDB,BillDB,ProdDB database
|
||||
class KOS,MVNO external
|
||||
```
|
||||
|
||||
### 3.2 패턴별 구현 상세
|
||||
|
||||
**API Gateway 패턴**
|
||||
- **위치**: 모든 클라이언트 요청의 단일 진입점
|
||||
- **기능**:
|
||||
- 인증/인가 처리 (JWT 토큰 검증)
|
||||
- 서비스별 라우팅
|
||||
- Rate Limiting (사용자별 요청 제한)
|
||||
- 로그 수집 및 모니터링
|
||||
- **기술 구현**: Kong, AWS API Gateway, 또는 Spring Cloud Gateway
|
||||
|
||||
**Cache-Aside 패턴**
|
||||
- **위치**: Bill-Inquiry, Product-Change 서비스
|
||||
- **캐시 대상**:
|
||||
- 사용자 세션 정보 (TTL: 30분)
|
||||
- KOS 요금 조회 결과 (TTL: 1시간)
|
||||
- 상품 정보 (TTL: 24시간)
|
||||
- **구현 로직**:
|
||||
```
|
||||
1. 캐시에서 데이터 조회 시도
|
||||
2. 캐시 Hit: 캐시 데이터 반환
|
||||
3. 캐시 Miss: DB/외부 시스템에서 조회 → 캐시에 저장 → 데이터 반환
|
||||
```
|
||||
|
||||
**Circuit Breaker 패턴**
|
||||
- **위치**: KOS-Order 시스템 연동 부분
|
||||
- **설정**:
|
||||
- 실패 임계값: 5회 연속 실패
|
||||
- 타임아웃: 3초
|
||||
- Half-Open 복구 시간: 30초
|
||||
- **동작 로직**:
|
||||
```
|
||||
Closed → (실패 5회) → Open → (30초 후) → Half-Open → (성공 시) → Closed
|
||||
```
|
||||
|
||||
### 3.3 패턴 간 상호작용
|
||||
|
||||
**API Gateway ↔ Cache-Aside**
|
||||
- Gateway에서 캐시된 인증 정보 활용
|
||||
- 요청별 캐시 헤더 설정으로 클라이언트 캐싱 최적화
|
||||
|
||||
**API Gateway ↔ Circuit Breaker**
|
||||
- Gateway에서 Circuit Breaker 상태 모니터링
|
||||
- Open 상태 시 대체 응답 제공
|
||||
|
||||
**Cache-Aside ↔ Circuit Breaker**
|
||||
- Circuit Breaker Open 시 캐시된 마지막 성공 데이터 활용
|
||||
- 캐시 만료 시에도 Circuit Breaker 상태 확인 후 외부 호출
|
||||
|
||||
---
|
||||
|
||||
## Phase별 구현 로드맵
|
||||
|
||||
### Phase 1: 기본 패턴 구축 - 4주
|
||||
|
||||
**목표**: 3개 핵심 패턴의 기본 구현
|
||||
|
||||
**Week 1-2: API Gateway 구축**
|
||||
- Kong 또는 Spring Cloud Gateway 설치 및 설정
|
||||
- 기본 라우팅 규칙 설정 (Auth, Bill-Inquiry, Product-Change)
|
||||
- JWT 기반 인증/인가 구현
|
||||
- 기본 로깅 및 모니터링 설정
|
||||
|
||||
**Week 3: Cache-Aside 패턴 구현**
|
||||
- Redis 클러스터 구축
|
||||
- Auth 서비스: 세션 캐싱 구현
|
||||
- Bill-Inquiry: 기본 조회 결과 캐싱
|
||||
- Product-Change: 상품 정보 캐싱
|
||||
|
||||
**Week 4: Circuit Breaker 패턴 구현**
|
||||
- KOS 연동 부분에 Circuit Breaker 적용
|
||||
- 기본 설정값 적용 (실패 5회, 타임아웃 3초)
|
||||
- Fallback 응답 메커니즘 구현
|
||||
- Circuit Breaker 상태 모니터링 대시보드
|
||||
|
||||
**Phase 1 완료 기준**
|
||||
- [ ] API Gateway를 통한 모든 요청 라우팅
|
||||
- [ ] 기본 캐싱 동작 (캐시 적중률 60% 이상)
|
||||
- [ ] KOS 연동 Circuit Breaker 동작
|
||||
- [ ] 성능 테스트: 응답시간 500ms 이내
|
||||
|
||||
### Phase 2: 최적화 및 고도화 - 3주
|
||||
|
||||
**목표**: 패턴별 최적화 및 운영 안정화
|
||||
|
||||
**Week 5: API Gateway 고도화**
|
||||
- Rate Limiting 정책 적용
|
||||
- 서비스별 Load Balancing 최적화
|
||||
- API 문서 자동 생성 및 개발자 포털
|
||||
- 보안 정책 강화 (CORS, HTTPS)
|
||||
|
||||
**Week 6: Cache-Aside 최적화**
|
||||
- 캐시 전략 최적화 (TTL, 만료 정책)
|
||||
- Cache Warming 전략 구현
|
||||
- 캐시 클러스터 고가용성 설정
|
||||
- 캐시 성능 모니터링 및 알람
|
||||
|
||||
**Week 7: Circuit Breaker 튜닝**
|
||||
- 서비스별 Circuit Breaker 임계값 조정
|
||||
- 부분 실패 처리 (Bulkhead 패턴 부분 적용)
|
||||
- 복구 전략 최적화
|
||||
- 장애 시뮬레이션 테스트
|
||||
|
||||
**Phase 2 완료 기준**
|
||||
- [ ] 캐시 적중률 85% 이상 달성
|
||||
- [ ] API Gateway 처리량 1,000 TPS
|
||||
- [ ] Circuit Breaker 복구 시간 30초 이내
|
||||
- [ ] 전체 시스템 가용성 99.5% 달성
|
||||
|
||||
### 마일스톤 및 성공 지표
|
||||
|
||||
**Phase 1 마일스톤 (4주차)**
|
||||
- ✅ 3개 패턴 기본 구현 완료
|
||||
- ✅ 통합 테스트 통과
|
||||
- ✅ 성능 목표 달성 (응답시간 < 500ms)
|
||||
- ✅ 기본 모니터링 대시보드 구축
|
||||
|
||||
**Phase 2 마일스톤 (7주차)**
|
||||
- ✅ 최적화 완료 (응답시간 < 200ms)
|
||||
- ✅ 고가용성 달성 (99.5%)
|
||||
- ✅ 운영 자동화 구축
|
||||
- ✅ 프로덕션 배포 준비 완료
|
||||
|
||||
---
|
||||
|
||||
## 예상 성과 지표
|
||||
|
||||
### 5.1 성능 개선 예상치
|
||||
|
||||
**응답 시간 개선**
|
||||
- 패턴 적용 전: 평균 1,000ms
|
||||
- Phase 1 (기본 구현): 평균 500ms (**50% 개선**)
|
||||
- Phase 2 (최적화): 평균 200ms (**80% 개선**)
|
||||
|
||||
**캐시 효과**
|
||||
- Cache-Aside 적용 전: DB 조회 100%
|
||||
- Phase 1: 캐시 적중률 60% → DB 부하 40% 감소
|
||||
- Phase 2: 캐시 적중률 85% → DB 부하 85% 감소
|
||||
|
||||
**외부 연동 안정성**
|
||||
- Circuit Breaker 적용 전: KOS 장애 시 전체 서비스 다운
|
||||
- 적용 후: KOS 장애와 무관하게 서비스 99.5% 가용성 유지
|
||||
|
||||
### 5.2 비용 절감 효과
|
||||
|
||||
**인프라 비용**
|
||||
- **Cache-Aside**: DB 서버 부하 85% 감소 → 월 $1,500 절약
|
||||
- **API Gateway**: 서버 통합 및 최적화 → 월 $2,000 절약
|
||||
- **Circuit Breaker**: 장애 복구 시간 단축 → 월 $1,000 절약
|
||||
|
||||
**운영 비용**
|
||||
- 중앙화된 관리: 운영 효율성 50% 향상 → 월 $1,500 절약
|
||||
- 자동화된 모니터링: 장애 대응 시간 70% 단축 → 월 $1,000 절약
|
||||
|
||||
**총 예상 절감액**: **월 $7,000 (연 $84,000)**
|
||||
|
||||
### 5.3 개발 및 운영 효율성
|
||||
|
||||
**개발 생산성**
|
||||
- API Gateway: 횡단 관심사 분리 → 개발 시간 30% 단축
|
||||
- Cache-Aside: 성능 최적화 자동화 → 성능 튜닝 시간 70% 단축
|
||||
- Circuit Breaker: 장애 처리 자동화 → 안정성 관련 개발 50% 단축
|
||||
|
||||
**운영 편의성**
|
||||
- 중앙화된 모니터링: 3개 서비스 통합 관리
|
||||
- 자동화된 장애 복구: 운영자 개입 80% 감소
|
||||
- 표준화된 패턴: 신규 서비스 추가 시 50% 빠른 적용
|
||||
|
||||
### 5.4 비즈니스 가치
|
||||
|
||||
**고객 만족도**
|
||||
- 빠른 응답속도: 사용자 이탈률 40% 감소
|
||||
- 안정적 서비스: 고객 불만 60% 감소
|
||||
- 지속적 서비스: 서비스 중단 시간 90% 감소
|
||||
|
||||
**확장성**
|
||||
- 동시 사용자 10배 확장 가능 (100명 → 1,000명)
|
||||
- 새로운 서비스 추가 시 기존 패턴 재사용
|
||||
- 트래픽 증가에 따른 자동 확장 지원
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 요구사항 매핑 검증 ✅
|
||||
- [x] 모든 유저스토리가 3개 패턴으로 커버되는지 확인
|
||||
- [x] 비기능적 요구사항 해결 방안 명시
|
||||
- [x] 기술적 도전과제별 패턴 매핑 완료
|
||||
|
||||
### 패턴 선정 근거 검증 ✅
|
||||
- [x] 3개 패턴 정량적 평가 완료 (모두 7.0점 이상)
|
||||
- [x] 각 패턴의 적용 범위 명확히 정의
|
||||
- [x] 패턴 간 상호작용 관계 설정
|
||||
|
||||
### 간소화된 아키텍처 표현 ✅
|
||||
- [x] 3개 패턴만 표시하는 Mermaid 다이어그램
|
||||
- [x] 불필요한 복잡도 제거
|
||||
- [x] 핵심 데이터 흐름 및 상호작용 표현
|
||||
|
||||
### 실용적 구현 로드맵 ✅
|
||||
- [x] 7주 단위의 현실적 일정
|
||||
- [x] 패턴별 단계적 구현 계획
|
||||
- [x] 명확한 완료 기준 및 성공 지표
|
||||
|
||||
### 3개 패턴 중심 최적화 ✅
|
||||
- [x] CQRS, Saga 등 복잡한 패턴 제거
|
||||
- [x] 핵심 가치 제공하는 3개 패턴에 집중
|
||||
- [x] 구현 복잡도 최소화하면서 목표 달성
|
||||
|
||||
---
|
||||
|
||||
## 백업 정보
|
||||
**전체 버전 백업**: `design/pattern/architecture-pattern-full.md`
|
||||
- 5개 패턴 (API Gateway, Cache-Aside, Circuit Breaker, CQRS, Saga) 포함 버전
|
||||
- 더 상세한 분석과 복잡한 아키텍처 설계 포함
|
||||
|
||||
---
|
||||
|
||||
**문서 작성일**: 2025-01-08
|
||||
**작성자**: 이개발 (Backend Developer) & 김기획 (Product Owner)
|
||||
**검토자**: 최운영 (DevOps Engineer) & 정테스트 (QA Manager)
|
||||
**승인자**: 박화면 (Frontend Developer)
|
||||
**버전**: 간소화 버전 (3-Pattern Focus)
|
||||
@@ -0,0 +1,485 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 480px;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
border-radius: 20px;
|
||||
margin: 0 auto var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.service-subtitle {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
padding: var(--space-8);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-4);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
min-height: 52px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.input.error {
|
||||
border-color: var(--error-500);
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.checkbox:checked {
|
||||
background-color: var(--primary-500);
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: var(--space-8);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">📱</div>
|
||||
<h1 class="service-title">통신요금 관리</h1>
|
||||
<p class="service-subtitle">간편하고 안전한 요금 관리 서비스</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form Card -->
|
||||
<div class="card">
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-error">
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<form class="form" id="loginForm">
|
||||
<!-- ID Input -->
|
||||
<div class="form-group">
|
||||
<label for="userId" class="label">아이디</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userId"
|
||||
name="userId"
|
||||
class="input"
|
||||
placeholder="아이디를 입력하세요"
|
||||
required
|
||||
autocomplete="username"
|
||||
aria-describedby="userId-error"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div class="form-group">
|
||||
<label for="password" class="label">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
aria-describedby="password-error"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Auto Login Checkbox -->
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="autoLogin" name="autoLogin" class="checkbox">
|
||||
<label for="autoLogin" class="checkbox-label">자동 로그인</label>
|
||||
</div>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button type="submit" class="btn btn-primary" id="loginBtn">
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p class="footer-text">© 2025 통신요금 관리 서비스. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Login form validation and submission
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const userIdInput = document.getElementById('userId');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
let loginAttempts = 0;
|
||||
const maxAttempts = 5;
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
errorAlert.classList.add('show');
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
function hideError() {
|
||||
errorAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
// Validate form inputs
|
||||
function validateForm() {
|
||||
const userId = userIdInput.value.trim();
|
||||
const password = passwordInput.value.trim();
|
||||
|
||||
if (!userId) {
|
||||
showError('아이디를 입력해주세요.');
|
||||
userIdInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showError('비밀번호를 입력해주세요.');
|
||||
passwordInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check login attempts
|
||||
if (loginAttempts >= maxAttempts) {
|
||||
showError('로그인 시도 횟수를 초과했습니다. 30분 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
loginBtn.classList.add('loading');
|
||||
loginBtn.disabled = true;
|
||||
|
||||
// Simulate login API call
|
||||
setTimeout(() => {
|
||||
const userId = userIdInput.value.trim();
|
||||
const password = passwordInput.value.trim();
|
||||
|
||||
// Demo login - accept any ID/password for prototype
|
||||
if (userId && password) {
|
||||
// Success - redirect to main page
|
||||
alert('로그인 성공! 메인 화면으로 이동합니다.');
|
||||
window.location.href = '02-메인화면.html';
|
||||
} else {
|
||||
// Failure
|
||||
loginAttempts++;
|
||||
const remainingAttempts = maxAttempts - loginAttempts;
|
||||
|
||||
if (remainingAttempts > 0) {
|
||||
showError(`로그인에 실패했습니다. ${remainingAttempts}회 더 시도할 수 있습니다.`);
|
||||
} else {
|
||||
showError('로그인 시도 횟수를 초과했습니다. 30분 후 다시 시도해주세요.');
|
||||
}
|
||||
|
||||
loginBtn.classList.remove('loading');
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Input validation feedback
|
||||
[userIdInput, passwordInput].forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
this.classList.remove('error');
|
||||
hideError();
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
if (!this.value.trim()) {
|
||||
this.classList.add('error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enter key handling for accessibility
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
|
||||
loginForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,537 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>메인 화면 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-50: #F0FFF4;
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-50: #FFFAF0;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 600px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) 0;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: white;
|
||||
color: var(--gray-600);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--gray-50);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
padding: var(--space-8);
|
||||
border-radius: 20px;
|
||||
margin-bottom: var(--space-8);
|
||||
box-shadow: 0 4px 20px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: var(--text-base);
|
||||
opacity: 0.9;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.user-details {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.user-phone {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.current-product {
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.current-product-label {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.8;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.current-product-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Service Menu */
|
||||
.service-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.menu-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Card */
|
||||
.menu-card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--gray-100);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.menu-card.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.menu-card.disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-2xl);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.menu-icon.bill {
|
||||
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
.menu-icon.product {
|
||||
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
|
||||
}
|
||||
|
||||
.menu-title-text {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.menu-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Access Denied Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: var(--space-8);
|
||||
margin: var(--space-4);
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: var(--error-500);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--space-4);
|
||||
color: white;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: var(--space-6);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background-color: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background-color: var(--primary-600);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">📱</div>
|
||||
<h1 class="service-title">통신요금 관리</h1>
|
||||
</div>
|
||||
<button class="logout-btn" onclick="handleLogout()">로그아웃</button>
|
||||
</div>
|
||||
|
||||
<!-- Welcome Section -->
|
||||
<div class="welcome-section">
|
||||
<h2 class="welcome-title">안녕하세요!</h2>
|
||||
<p class="user-info">통신요금 관리 서비스에 오신 것을 환영합니다.</p>
|
||||
<div class="user-details">
|
||||
<span class="user-phone">010-1234-5678</span>
|
||||
<div class="current-product">
|
||||
<span class="current-product-label">현재 상품</span>
|
||||
<span class="current-product-name" id="currentProductName">5G 프리미엄 플랜</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Menu -->
|
||||
<div class="service-menu">
|
||||
<h3 class="menu-title">서비스 메뉴</h3>
|
||||
|
||||
<div class="menu-grid">
|
||||
<!-- 요금 조회 메뉴 -->
|
||||
<a href="03-요금조회메뉴.html" class="menu-card" id="billCard">
|
||||
<div class="menu-icon bill">📊</div>
|
||||
<h4 class="menu-title-text">요금 조회</h4>
|
||||
<p class="menu-description">
|
||||
월별 통신요금과 사용량을<br>
|
||||
상세하게 확인할 수 있습니다.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<!-- 상품 변경 메뉴 -->
|
||||
<a href="05-상품변경메뉴.html" class="menu-card" id="productCard">
|
||||
<div class="menu-icon product">🔄</div>
|
||||
<h4 class="menu-title-text">상품 변경</h4>
|
||||
<p class="menu-description">
|
||||
현재 이용 중인 요금제를<br>
|
||||
다른 상품으로 변경할 수 있습니다.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Denied Modal -->
|
||||
<div class="modal-overlay" id="accessDeniedModal">
|
||||
<div class="modal">
|
||||
<div class="modal-icon">🚫</div>
|
||||
<h3 class="modal-title">접근 권한 없음</h3>
|
||||
<p class="modal-message">해당 서비스를 이용할 권한이 없습니다.<br>고객센터로 문의해주세요.</p>
|
||||
<button class="modal-btn" onclick="closeModal()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// User permissions simulation
|
||||
const userPermissions = {
|
||||
bill: true, // 요금 조회 권한
|
||||
product: true // 상품 변경 권한
|
||||
};
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCurrentProduct();
|
||||
checkPermissions();
|
||||
});
|
||||
|
||||
// Load current product from localStorage
|
||||
function loadCurrentProduct() {
|
||||
try {
|
||||
const currentProduct = localStorage.getItem('currentProduct');
|
||||
if (currentProduct) {
|
||||
const product = JSON.parse(currentProduct);
|
||||
const productNameElement = document.getElementById('currentProductName');
|
||||
if (productNameElement && product.name) {
|
||||
productNameElement.textContent = product.name;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('localStorage에서 상품 정보를 불러오는 중 오류 발생:', error);
|
||||
// 기본값 유지 (5G 프리미엄 플랜)
|
||||
}
|
||||
}
|
||||
|
||||
// Check user permissions and update UI
|
||||
function checkPermissions() {
|
||||
const billCard = document.getElementById('billCard');
|
||||
const productCard = document.getElementById('productCard');
|
||||
|
||||
// 요금 조회 권한 확인
|
||||
if (!userPermissions.bill) {
|
||||
billCard.classList.add('disabled');
|
||||
billCard.href = '#';
|
||||
billCard.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
showAccessDeniedModal('요금 조회');
|
||||
});
|
||||
}
|
||||
|
||||
// 상품 변경 권한 확인
|
||||
if (!userPermissions.product) {
|
||||
productCard.classList.add('disabled');
|
||||
productCard.href = '#';
|
||||
productCard.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
showAccessDeniedModal('상품 변경');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show access denied modal
|
||||
function showAccessDeniedModal(serviceName) {
|
||||
const modal = document.getElementById('accessDeniedModal');
|
||||
const message = modal.querySelector('.modal-message');
|
||||
message.innerHTML = `${serviceName} 서비스를 이용할 권한이 없습니다.<br>고객센터로 문의해주세요.`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('accessDeniedModal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
function handleLogout() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
alert('안전하게 로그아웃되었습니다.');
|
||||
window.location.href = '01-로그인.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Modal close on overlay click
|
||||
document.getElementById('accessDeniedModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,690 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>요금조회 메뉴 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-50: #F0FFF4;
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-50: #FFFAF0;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
background-color: white;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background-color: var(--gray-100);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: var(--gray-700);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: var(--gray-200);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--gray-600);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* User Info Card */
|
||||
.user-info-card {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
padding: var(--space-6);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.user-info-title {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.9;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.user-phone {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Inquiry Options Card */
|
||||
.inquiry-card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card-title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--primary-100);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-600);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--gray-700);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: var(--space-4);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.select-wrapper::after {
|
||||
content: "▼";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-4);
|
||||
transform: translateY(-50%);
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-top: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
color: var(--primary-500);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
padding: var(--space-6);
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--gray-700);
|
||||
border: 2px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--gray-50);
|
||||
border-color: var(--gray-400);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.btn.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: var(--warning-50);
|
||||
border: 1px solid #FED7AA;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Usage Info */
|
||||
.usage-info {
|
||||
background-color: var(--gray-50);
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.usage-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--gray-700);
|
||||
margin-bottom: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.usage-list {
|
||||
list-style: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.usage-list li {
|
||||
position: relative;
|
||||
padding-left: var(--space-4);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.usage-list li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--primary-500);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" onclick="goBack()" aria-label="뒤로가기">
|
||||
←
|
||||
</button>
|
||||
<h1 class="page-title">요금 조회</h1>
|
||||
</div>
|
||||
<button class="menu-btn" onclick="showMenu()" aria-label="메뉴">
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- User Info Card -->
|
||||
<div class="user-info-card">
|
||||
<div class="user-info-title">조회 대상 회선</div>
|
||||
<div class="user-phone">
|
||||
📱 010-1234-5678
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inquiry Options Card -->
|
||||
<div class="inquiry-card">
|
||||
<h2 class="card-title">
|
||||
<span class="card-title-icon">📅</span>
|
||||
조회 옵션 설정
|
||||
</h2>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-error">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<div class="alert-content">
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Alert -->
|
||||
<div id="warningAlert" class="alert alert-warning">
|
||||
<span class="alert-icon">💡</span>
|
||||
<div class="alert-content">
|
||||
<span id="warningMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="inquiryForm">
|
||||
<!-- 조회월 선택 -->
|
||||
<div class="form-group">
|
||||
<label for="inquiryMonth" class="form-label">조회월 선택</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="inquiryMonth" name="inquiryMonth" class="select" required>
|
||||
<option value="">조회할 월을 선택해주세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-help">
|
||||
<span class="help-icon">ℹ️</span>
|
||||
최근 6개월 요금 정보를 조회할 수 있습니다
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Info -->
|
||||
<div class="usage-info">
|
||||
<div class="usage-title">
|
||||
<span>📋</span>
|
||||
조회 가능한 정보
|
||||
</div>
|
||||
<ul class="usage-list">
|
||||
<li>월 요금 상세 내역 (기본료, 통화료, 데이터료 등)</li>
|
||||
<li>사용량 정보 (통화시간, 데이터 사용량, SMS 등)</li>
|
||||
<li>할인 및 혜택 내역</li>
|
||||
<li>단말기 할부금 및 기타 부대비용</li>
|
||||
<li>약정 정보 및 예상 해지비용</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button type="button" class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" form="inquiryForm" class="btn btn-primary" id="inquiryBtn">
|
||||
요금 조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 현재 날짜 기준으로 최근 6개월 옵션 생성
|
||||
function generateMonthOptions() {
|
||||
const select = document.getElementById('inquiryMonth');
|
||||
const now = new Date();
|
||||
|
||||
// 현재 월부터 6개월 전까지 옵션 생성
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const monthStr = month.toString().padStart(2, '0');
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = `${year}${monthStr}`;
|
||||
|
||||
if (i === 0) {
|
||||
option.textContent = `${year}년 ${month}월 (현재 월)`;
|
||||
option.selected = true; // 기본값으로 현재 월 선택
|
||||
} else {
|
||||
option.textContent = `${year}년 ${month}월`;
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 요소 참조
|
||||
const inquiryForm = document.getElementById('inquiryForm');
|
||||
const inquiryBtn = document.getElementById('inquiryBtn');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const warningAlert = document.getElementById('warningAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const warningMessage = document.getElementById('warningMessage');
|
||||
const monthSelect = document.getElementById('inquiryMonth');
|
||||
|
||||
// 에러 메시지 표시
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
errorAlert.classList.add('show');
|
||||
hideWarning();
|
||||
}
|
||||
|
||||
// 경고 메시지 표시
|
||||
function showWarning(message) {
|
||||
warningMessage.textContent = message;
|
||||
warningAlert.classList.add('show');
|
||||
hideError();
|
||||
}
|
||||
|
||||
// 에러 메시지 숨기기
|
||||
function hideError() {
|
||||
errorAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
// 경고 메시지 숨기기
|
||||
function hideWarning() {
|
||||
warningAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
// 메시지 전체 숨기기
|
||||
function hideAllMessages() {
|
||||
hideError();
|
||||
hideWarning();
|
||||
}
|
||||
|
||||
// 폼 유효성 검사
|
||||
function validateForm() {
|
||||
const selectedMonth = monthSelect.value;
|
||||
|
||||
if (!selectedMonth) {
|
||||
showError('조회할 월을 선택해주세요.');
|
||||
monthSelect.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 폼 제출 처리
|
||||
inquiryForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
hideAllMessages();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 상태 시작
|
||||
inquiryBtn.classList.add('loading');
|
||||
inquiryBtn.disabled = true;
|
||||
|
||||
const selectedMonth = monthSelect.value;
|
||||
const selectedText = monthSelect.options[monthSelect.selectedIndex].text;
|
||||
|
||||
// 로딩 시뮬레이션
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// 현재 날짜와 비교하여 미래 월인지 확인
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const currentYearMonth = parseInt(`${currentYear}${currentMonth.toString().padStart(2, '0')}`);
|
||||
const selectedYearMonth = parseInt(selectedMonth);
|
||||
|
||||
if (selectedYearMonth > currentYearMonth) {
|
||||
showWarning('미래 월의 요금 정보는 조회할 수 없습니다.');
|
||||
inquiryBtn.classList.remove('loading');
|
||||
inquiryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 성공 - 조회 결과 페이지로 이동
|
||||
sessionStorage.setItem('selectedMonth', selectedMonth);
|
||||
sessionStorage.setItem('selectedMonthText', selectedText);
|
||||
window.location.href = '04-요금조회결과.html';
|
||||
|
||||
} catch (error) {
|
||||
// 오류 처리
|
||||
showError('요금 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
inquiryBtn.classList.remove('loading');
|
||||
inquiryBtn.disabled = false;
|
||||
}
|
||||
}, 1500); // 1.5초 로딩 시뮬레이션
|
||||
});
|
||||
|
||||
// 선택 변경 시 메시지 숨기기
|
||||
monthSelect.addEventListener('change', function() {
|
||||
hideAllMessages();
|
||||
});
|
||||
|
||||
// 뒤로가기
|
||||
function goBack() {
|
||||
if (confirm('요금 조회를 취소하고 메인 화면으로 돌아가시겠습니까?')) {
|
||||
window.location.href = '02-메인화면.html';
|
||||
}
|
||||
}
|
||||
|
||||
// 메뉴 표시 (추후 구현)
|
||||
function showMenu() {
|
||||
alert('메뉴 기능은 추후 구현 예정입니다.');
|
||||
}
|
||||
|
||||
// 키보드 접근성
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
hideAllMessages();
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
generateMonthOptions();
|
||||
|
||||
// 페이지 진입 시 현재 월 선택에 대한 안내 표시
|
||||
setTimeout(() => {
|
||||
showWarning('기본적으로 현재 월이 선택되어 있습니다. 다른 월을 조회하려면 드롭다운에서 선택해주세요.');
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,592 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>상품 변경 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
min-height: 100vh;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--gray-600);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Customer Info */
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-900);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
/* Product Info */
|
||||
.product-card {
|
||||
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
|
||||
border: 2px solid var(--primary-200);
|
||||
border-radius: 16px;
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.product-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-500);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--primary-800);
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--primary-700);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--success-500);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Notice */
|
||||
.notice {
|
||||
background: #FFF7ED;
|
||||
border: 1px solid #FED7AA;
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--warning-500);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: var(--text-sm);
|
||||
color: #92400E;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--gray-200) 25%, var(--gray-100) 50%, var(--gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
color: #991B1B;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<button class="back-btn" onclick="goBack()" aria-label="뒤로가기">
|
||||
←
|
||||
</button>
|
||||
<h1 class="page-title">상품 변경</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-error">
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- Customer Info Card -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">고객 정보</h2>
|
||||
<div class="info-row">
|
||||
<span class="info-label">회선번호</span>
|
||||
<span class="info-value">010-1234-5678</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">고객ID</span>
|
||||
<span class="info-value">customer123</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Product Info -->
|
||||
<div class="product-card">
|
||||
<div class="product-header">
|
||||
<div class="product-icon">📱</div>
|
||||
<div>
|
||||
<div class="product-name">5G 프리미엄 플랜</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-price">월 69,000원</div>
|
||||
<ul class="benefits-list">
|
||||
<li class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>5G 데이터 무제한</span>
|
||||
</li>
|
||||
<li class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>음성통화 무제한</span>
|
||||
</li>
|
||||
<li class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>문자 무제한</span>
|
||||
</li>
|
||||
<li class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>해외 로밍 50% 할인</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Notice -->
|
||||
<div class="notice">
|
||||
<div class="notice-title">
|
||||
<span>⚠️</span>
|
||||
<span>상품 변경 시 주의사항</span>
|
||||
</div>
|
||||
<p class="notice-text">
|
||||
• 상품 변경은 다음 월 1일부터 적용됩니다<br>
|
||||
• 기존 약정 조건에 따라 위약금이 발생할 수 있습니다<br>
|
||||
• 변경 후에는 이전 상품으로 즉시 되돌릴 수 없습니다<br>
|
||||
• 부가서비스는 별도로 재신청이 필요할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="changeBtn" onclick="goToProductChange()">
|
||||
상품 변경하기
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Navigation functions
|
||||
function goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function goToProductChange() {
|
||||
const changeBtn = document.getElementById('changeBtn');
|
||||
|
||||
// Show loading state
|
||||
changeBtn.classList.add('loading');
|
||||
changeBtn.disabled = true;
|
||||
|
||||
// Simulate loading
|
||||
setTimeout(() => {
|
||||
window.location.href = '06-상품변경화면.html';
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
errorMessage.textContent = message;
|
||||
errorAlert.classList.add('show');
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
function hideError() {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
errorAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
// Load customer and product info on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCurrentProduct();
|
||||
|
||||
// Handle keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
goBack();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.target.tagName === 'BUTTON') {
|
||||
e.target.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load current product from localStorage
|
||||
function loadCurrentProduct() {
|
||||
try {
|
||||
const currentProduct = localStorage.getItem('currentProduct');
|
||||
if (currentProduct) {
|
||||
const product = JSON.parse(currentProduct);
|
||||
updateCurrentProductDisplay(product);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('localStorage에서 상품 정보를 불러오는 중 오류 발생:', error);
|
||||
// 기본값으로 화면이 이미 설정되어 있음
|
||||
}
|
||||
}
|
||||
|
||||
// Update current product display
|
||||
function updateCurrentProductDisplay(product) {
|
||||
// Update product name
|
||||
const productNameElement = document.querySelector('.product-name');
|
||||
if (productNameElement && product.name) {
|
||||
productNameElement.textContent = product.name;
|
||||
}
|
||||
|
||||
// Update product price
|
||||
const productPriceElement = document.querySelector('.product-price');
|
||||
if (productPriceElement && product.price) {
|
||||
productPriceElement.textContent = product.price;
|
||||
}
|
||||
|
||||
// Update benefits
|
||||
if (product.benefits && Array.isArray(product.benefits)) {
|
||||
const benefitsList = document.querySelector('.benefits-list');
|
||||
if (benefitsList) {
|
||||
benefitsList.innerHTML = '';
|
||||
product.benefits.forEach(benefit => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'benefit-item';
|
||||
li.innerHTML = `
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>${benefit}</span>
|
||||
`;
|
||||
benefitsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back button for accessibility
|
||||
window.addEventListener('popstate', function(e) {
|
||||
// Handle browser back button
|
||||
});
|
||||
|
||||
// Add focus management for accessibility
|
||||
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const modal = document.querySelector('.container');
|
||||
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
|
||||
const focusableContent = modal.querySelectorAll(focusableElements);
|
||||
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
const isTabPressed = e.key === 'Tab';
|
||||
|
||||
if (!isTabPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusableElement) {
|
||||
lastFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,797 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>상품 선택 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px; /* Space for fixed buttons */
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--gray-600);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Current Product Summary */
|
||||
.current-product {
|
||||
background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-50) 100%);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.current-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-800);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.current-price {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
/* Product List */
|
||||
.products-section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.product-card {
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 16px;
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
border-color: var(--primary-300);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.product-card.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.product-radio {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.product-card.selected .product-radio {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-500);
|
||||
}
|
||||
|
||||
.product-card.selected .product-radio::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.product-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
margin-right: var(--space-8);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--primary-500);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--text-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-card.selected .product-icon {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.benefits-grid {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--success-500);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.price-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.price-change {
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.price-up {
|
||||
color: var(--error-500);
|
||||
}
|
||||
|
||||
.price-down {
|
||||
color: var(--success-500);
|
||||
}
|
||||
|
||||
.price-same {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Fixed Action Buttons */
|
||||
.fixed-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fixed-actions {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--gray-200) 25%, var(--gray-100) 50%, var(--gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skeleton-product {
|
||||
height: 140px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
color: #991B1B;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<button class="back-btn" onclick="goBack()" aria-label="뒤로가기">
|
||||
←
|
||||
</button>
|
||||
<h1 class="page-title">상품 선택</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-error">
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- Current Product Summary -->
|
||||
<div class="current-product">
|
||||
<div class="current-label">현재 이용 중인 상품</div>
|
||||
<div class="current-name">5G 프리미엄 플랜</div>
|
||||
<div class="current-price">월 69,000원</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Section -->
|
||||
<div class="products-section">
|
||||
<h2 class="section-title">변경 가능한 상품</h2>
|
||||
|
||||
<!-- Loading Skeletons (hidden by default) -->
|
||||
<div id="loadingSkeletons" style="display: none;">
|
||||
<div class="skeleton skeleton-product"></div>
|
||||
<div class="skeleton skeleton-product"></div>
|
||||
<div class="skeleton skeleton-product"></div>
|
||||
</div>
|
||||
|
||||
<!-- Product List -->
|
||||
<div id="productList">
|
||||
<!-- Product Card 1 -->
|
||||
<div class="product-card" data-product-id="basic" onclick="selectProduct(this)">
|
||||
<div class="product-radio"></div>
|
||||
<div class="product-header">
|
||||
<div class="product-icon">📱</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">5G 베이직 플랜</div>
|
||||
<div class="product-price">월 39,000원</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="benefits-grid">
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>5G 데이터 10GB</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>음성통화 300분</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>문자 무제한</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-comparison">
|
||||
<span>현재 상품 대비</span>
|
||||
<span class="price-change price-down">월 30,000원 절약</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Card 2 -->
|
||||
<div class="product-card" data-product-id="standard" onclick="selectProduct(this)">
|
||||
<div class="product-radio"></div>
|
||||
<div class="product-header">
|
||||
<div class="product-icon">📱</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">5G 스탠다드 플랜</div>
|
||||
<div class="product-price">월 59,000원</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="benefits-grid">
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>5G 데이터 50GB</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>음성통화 무제한</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>문자 무제한</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>해외 로밍 20% 할인</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-comparison">
|
||||
<span>현재 상품 대비</span>
|
||||
<span class="price-change price-down">월 10,000원 절약</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Card 3 -->
|
||||
<div class="product-card" data-product-id="unlimited" onclick="selectProduct(this)">
|
||||
<div class="product-radio"></div>
|
||||
<div class="product-header">
|
||||
<div class="product-icon">📱</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">5G 언리미티드 플랜</div>
|
||||
<div class="product-price">월 89,000원</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="benefits-grid">
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>5G 데이터 무제한</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>음성통화 무제한</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>문자 무제한</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>해외 로밍 무료</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="benefit-icon">✓</span>
|
||||
<span>OTT 서비스 3개</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-comparison">
|
||||
<span>현재 상품 대비</span>
|
||||
<span class="price-change price-up">월 20,000원 추가</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Action Buttons -->
|
||||
<div class="fixed-actions">
|
||||
<button class="btn btn-primary" id="nextBtn" disabled onclick="goToRequest()">
|
||||
선택한 상품으로 변경
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedProductId = null;
|
||||
|
||||
// Select product function
|
||||
function selectProduct(cardElement) {
|
||||
// Remove selection from all cards
|
||||
document.querySelectorAll('.product-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked card
|
||||
cardElement.classList.add('selected');
|
||||
|
||||
// Store selected product ID
|
||||
selectedProductId = cardElement.dataset.productId;
|
||||
|
||||
// Enable next button
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
nextBtn.disabled = false;
|
||||
|
||||
// Update button text based on selection
|
||||
const productName = cardElement.querySelector('.product-name').textContent;
|
||||
nextBtn.textContent = `${productName}으로 변경`;
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function goToRequest() {
|
||||
if (!selectedProductId) {
|
||||
showError('변경할 상품을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
|
||||
// Show loading state
|
||||
nextBtn.classList.add('loading');
|
||||
nextBtn.disabled = true;
|
||||
|
||||
// Store selected product in sessionStorage
|
||||
const selectedCard = document.querySelector(`[data-product-id="${selectedProductId}"]`);
|
||||
const productData = {
|
||||
id: selectedProductId,
|
||||
name: selectedCard.querySelector('.product-name').textContent,
|
||||
price: selectedCard.querySelector('.product-price').textContent,
|
||||
benefits: Array.from(selectedCard.querySelectorAll('.benefit-item span:last-child')).map(el => el.textContent)
|
||||
};
|
||||
sessionStorage.setItem('selectedProduct', JSON.stringify(productData));
|
||||
|
||||
// Simulate loading
|
||||
setTimeout(() => {
|
||||
window.location.href = '07-상품변경요청.html';
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
errorMessage.textContent = message;
|
||||
errorAlert.classList.add('show');
|
||||
|
||||
// Hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
hideError();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
function hideError() {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
errorAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
// Load products on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load current product from localStorage
|
||||
loadCurrentProduct();
|
||||
// Simulate loading products
|
||||
loadProducts();
|
||||
|
||||
// Handle keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
goBack();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.target.classList.contains('product-card')) {
|
||||
selectProduct(e.target);
|
||||
}
|
||||
|
||||
// Arrow key navigation for product cards
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const cards = Array.from(document.querySelectorAll('.product-card'));
|
||||
const currentIndex = cards.findIndex(card => card === document.activeElement);
|
||||
|
||||
if (e.key === 'ArrowDown' && currentIndex < cards.length - 1) {
|
||||
cards[currentIndex + 1].focus();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
|
||||
cards[currentIndex - 1].focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Make product cards focusable for keyboard navigation
|
||||
document.querySelectorAll('.product-card').forEach(card => {
|
||||
card.setAttribute('tabindex', '0');
|
||||
card.setAttribute('role', 'radio');
|
||||
card.setAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// Load current product from localStorage
|
||||
function loadCurrentProduct() {
|
||||
try {
|
||||
const currentProduct = localStorage.getItem('currentProduct');
|
||||
if (currentProduct) {
|
||||
const product = JSON.parse(currentProduct);
|
||||
const currentNameElement = document.querySelector('.current-name');
|
||||
if (currentNameElement && product.name) {
|
||||
currentNameElement.textContent = product.name;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('localStorage에서 상품 정보를 불러오는 중 오류 발생:', error);
|
||||
// 기본값 유지
|
||||
}
|
||||
}
|
||||
|
||||
function loadProducts() {
|
||||
const loadingSkeletons = document.getElementById('loadingSkeletons');
|
||||
const productList = document.getElementById('productList');
|
||||
|
||||
// Show loading
|
||||
loadingSkeletons.style.display = 'block';
|
||||
productList.style.display = 'none';
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
// Hide loading, show products
|
||||
loadingSkeletons.style.display = 'none';
|
||||
productList.style.display = 'block';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Update aria-checked when product is selected
|
||||
function selectProduct(cardElement) {
|
||||
// Remove selection from all cards
|
||||
document.querySelectorAll('.product-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
card.setAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
// Add selection to clicked card
|
||||
cardElement.classList.add('selected');
|
||||
cardElement.setAttribute('aria-checked', 'true');
|
||||
|
||||
// Store selected product ID
|
||||
selectedProductId = cardElement.dataset.productId;
|
||||
|
||||
// Store selected product info in sessionStorage
|
||||
const productName = cardElement.querySelector('.product-name').textContent;
|
||||
const productPrice = cardElement.querySelector('.product-price').textContent;
|
||||
sessionStorage.setItem('selectedProductName', productName);
|
||||
sessionStorage.setItem('selectedProductPrice', productPrice);
|
||||
|
||||
// Enable next button
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
nextBtn.disabled = false;
|
||||
|
||||
// Update button text based on selection
|
||||
nextBtn.textContent = `${productName}으로 변경`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,774 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>상품 변경 요청 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-500: #38A169;
|
||||
--error-500: #E53E3E;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
padding-bottom: 120px; /* Space for fixed buttons */
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--gray-600);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Change Comparison */
|
||||
.change-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.product-summary {
|
||||
flex: 1;
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.product-summary.current {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.product-summary.new {
|
||||
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
|
||||
border-color: var(--primary-200);
|
||||
}
|
||||
|
||||
.product-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: var(--space-1);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--primary-600);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.change-arrow {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--primary-500);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Notice */
|
||||
.notice {
|
||||
background: #FFF7ED;
|
||||
border: 1px solid #FED7AA;
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--warning-500);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
font-size: var(--text-sm);
|
||||
color: #92400E;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--space-2);
|
||||
padding-left: var(--space-4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notice-item::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--warning-500);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.notice-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--gray-200);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease-in-out;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
text-align: center;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-400);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.progress-step.completed {
|
||||
color: var(--success-500);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
/* Fixed Action Buttons */
|
||||
.fixed-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fixed-actions {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3182CE !important; /* Fallback color with important */
|
||||
background: linear-gradient(135deg, #3182CE 0%, #2B77CB 100%) !important;
|
||||
color: white !important;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--primary-600);
|
||||
border: 2px solid var(--primary-200);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--primary-50);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #F0FDF4;
|
||||
border: 1px solid #BBF7D0;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<button class="back-btn" onclick="goBack()" aria-label="뒤로가기">
|
||||
←
|
||||
</button>
|
||||
<h1 class="page-title">상품 변경 요청</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div id="errorAlert" class="alert alert-error">
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<div id="successAlert" class="alert alert-success">
|
||||
<span id="successMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- Change Confirmation -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">변경 내용 확인</h2>
|
||||
<div class="change-comparison">
|
||||
<div class="product-summary current">
|
||||
<div class="product-label">현재 상품</div>
|
||||
<div class="product-name">5G 프리미엄 플랜</div>
|
||||
<div class="product-price">월 69,000원</div>
|
||||
</div>
|
||||
<div class="change-arrow">→</div>
|
||||
<div class="product-summary new">
|
||||
<div class="product-label">변경할 상품</div>
|
||||
<div class="product-name" id="newProductName">5G 스탠다드 플랜</div>
|
||||
<div class="product-price" id="newProductPrice">월 59,000원</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Notice -->
|
||||
<div class="notice">
|
||||
<div class="notice-title">
|
||||
<span>⚠️</span>
|
||||
<span>중요 안내사항</span>
|
||||
</div>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item">상품 변경은 다음 월 1일부터 적용됩니다</li>
|
||||
<li class="notice-item">현재 약정 기간이 남아있는 경우 위약금이 발생할 수 있습니다</li>
|
||||
<li class="notice-item">기존 부가서비스는 자동으로 해지되며, 필요시 재신청해야 합니다</li>
|
||||
<li class="notice-item">변경 후 14일 이내에 취소 가능하나, 일부 제약이 있을 수 있습니다</li>
|
||||
<li class="notice-item">요금제 변경에 따른 데이터 이월은 불가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div class="card progress-section">
|
||||
<h2 class="card-title">사전 검증 진행 상황</h2>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progressText">검증을 시작하세요</div>
|
||||
<div class="progress-status" id="progressStatus">검증 대기중</div>
|
||||
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step" data-step="1">
|
||||
<div>약정 확인</div>
|
||||
</div>
|
||||
<div class="progress-step" data-step="2">
|
||||
<div>자격 검증</div>
|
||||
</div>
|
||||
<div class="progress-step" data-step="3">
|
||||
<div>요금 계산</div>
|
||||
</div>
|
||||
<div class="progress-step" data-step="4">
|
||||
<div>승인 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Action Buttons -->
|
||||
<div class="fixed-actions">
|
||||
<button class="btn btn-primary" id="submitBtn" disabled onclick="startValidation()">
|
||||
사전 검증 시작
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="goToPrevious()">
|
||||
이전 단계
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let validationStep = 0;
|
||||
let validationCompleted = false;
|
||||
|
||||
// Load selected product data from previous screen
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSelectedProduct();
|
||||
enableSubmitButton();
|
||||
|
||||
// Handle keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
goBack();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function loadSelectedProduct() {
|
||||
const selectedProduct = sessionStorage.getItem('selectedProduct');
|
||||
if (selectedProduct) {
|
||||
const product = JSON.parse(selectedProduct);
|
||||
document.getElementById('newProductName').textContent = product.name;
|
||||
document.getElementById('newProductPrice').textContent = product.price;
|
||||
}
|
||||
}
|
||||
|
||||
function enableSubmitButton() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
function startValidation() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
if (validationCompleted) {
|
||||
// Already validated, proceed to result
|
||||
goToResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button during validation
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '검증 중...';
|
||||
|
||||
// Start validation process
|
||||
runValidationSteps();
|
||||
}
|
||||
|
||||
function runValidationSteps() {
|
||||
const steps = [
|
||||
{ name: '약정 확인', duration: 2000 },
|
||||
{ name: '자격 검증', duration: 1500 },
|
||||
{ name: '요금 계산', duration: 1800 },
|
||||
{ name: '승인 완료', duration: 1000 }
|
||||
];
|
||||
|
||||
let currentStep = 0;
|
||||
|
||||
function processNextStep() {
|
||||
if (currentStep >= steps.length) {
|
||||
onValidationComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = steps[currentStep];
|
||||
const stepIndex = currentStep + 1;
|
||||
|
||||
// Update progress
|
||||
updateProgress(stepIndex, steps.length, `${step.name} 진행 중...`);
|
||||
|
||||
// Mark current step as active
|
||||
document.querySelectorAll('.progress-step').forEach((el, index) => {
|
||||
if (index < stepIndex - 1) {
|
||||
el.classList.add('completed');
|
||||
el.classList.remove('active');
|
||||
} else if (index === stepIndex - 1) {
|
||||
el.classList.add('active');
|
||||
el.classList.remove('completed');
|
||||
} else {
|
||||
el.classList.remove('active', 'completed');
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate step processing
|
||||
setTimeout(() => {
|
||||
currentStep++;
|
||||
processNextStep();
|
||||
}, step.duration);
|
||||
}
|
||||
|
||||
processNextStep();
|
||||
}
|
||||
|
||||
function updateProgress(current, total, statusText) {
|
||||
const progress = (current / total) * 100;
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const progressStatus = document.getElementById('progressStatus');
|
||||
|
||||
progressFill.style.width = `${progress}%`;
|
||||
progressText.textContent = `${current}/${total} 단계`;
|
||||
progressStatus.textContent = statusText;
|
||||
}
|
||||
|
||||
function onValidationComplete() {
|
||||
validationCompleted = true;
|
||||
|
||||
// Update UI
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressStatus = document.getElementById('progressStatus');
|
||||
|
||||
updateProgress(4, 4, '모든 검증이 완료되었습니다');
|
||||
|
||||
// Mark all steps as completed
|
||||
document.querySelectorAll('.progress-step').forEach(el => {
|
||||
el.classList.add('completed');
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show success message
|
||||
showSuccess('사전 검증이 성공적으로 완료되었습니다.');
|
||||
|
||||
// Enable submit button
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '변경 신청하기';
|
||||
|
||||
// Add some celebration effect
|
||||
setTimeout(() => {
|
||||
submitBtn.style.background = 'linear-gradient(135deg, var(--success-500) 0%, var(--success-600) 100%)';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function submitRequest() {
|
||||
const progressStatus = document.getElementById('progressStatus');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
// Update status text
|
||||
progressStatus.textContent = '상품 변경 요청을 처리하고 있습니다...';
|
||||
|
||||
// Show loading on button
|
||||
submitBtn.classList.add('loading');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '처리 중...';
|
||||
|
||||
// Start actual submission process
|
||||
setTimeout(() => {
|
||||
goToResult();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function goToResult() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
// Show loading state
|
||||
submitBtn.classList.add('loading');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Store request data
|
||||
let currentProductName = '5G 프리미엄 플랜';
|
||||
try {
|
||||
const currentProduct = localStorage.getItem('currentProduct');
|
||||
if (currentProduct) {
|
||||
const product = JSON.parse(currentProduct);
|
||||
currentProductName = product.name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('localStorage에서 상품 정보를 불러오는 중 오류 발생:', error);
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
currentProduct: currentProductName,
|
||||
newProduct: sessionStorage.getItem('selectedProductName') || '5G 스탠다드 플랜',
|
||||
newPrice: sessionStorage.getItem('selectedProductPrice') || '월 59,000원',
|
||||
requestTime: new Date().toISOString(),
|
||||
status: 'success'
|
||||
};
|
||||
sessionStorage.setItem('changeRequest', JSON.stringify(requestData));
|
||||
|
||||
// Simulate final processing
|
||||
setTimeout(() => {
|
||||
window.location.href = '08-처리결과화면.html';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
window.location.href = '06-상품변경화면.html';
|
||||
}
|
||||
|
||||
// Alert functions
|
||||
function showError(message) {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
errorMessage.textContent = message;
|
||||
errorAlert.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
hideError();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
errorAlert.classList.remove('show');
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const successAlert = document.getElementById('successAlert');
|
||||
const successMessage = document.getElementById('successMessage');
|
||||
successMessage.textContent = message;
|
||||
successAlert.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
hideSuccess();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function hideSuccess() {
|
||||
const successAlert = document.getElementById('successAlert');
|
||||
successAlert.classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,739 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>처리 결과 - 통신요금 관리 서비스</title>
|
||||
<style>
|
||||
/* CSS Variables - Style Guide */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE;
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
|
||||
/* Gray Colors */
|
||||
--gray-50: #F9FAFB;
|
||||
--gray-100: #F3F4F6;
|
||||
--gray-200: #E5E7EB;
|
||||
--gray-300: #D1D5DB;
|
||||
--gray-400: #9CA3AF;
|
||||
--gray-500: #6B7280;
|
||||
--gray-600: #4B5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1F2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
/* Status Colors */
|
||||
--success-50: #F0FDF4;
|
||||
--success-100: #DCFCE7;
|
||||
--success-500: #38A169;
|
||||
--success-600: #2F855A;
|
||||
--error-50: #FEF2F2;
|
||||
--error-100: #FEE2E2;
|
||||
--error-500: #E53E3E;
|
||||
--error-600: #DC2626;
|
||||
--warning-500: #ED8936;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
background-color: var(--gray-50);
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px; /* Space for fixed buttons */
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: var(--space-6) 0;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Result Status */
|
||||
.result-status {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto var(--space-6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-4xl);
|
||||
animation: scaleIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.result-status.success .result-icon {
|
||||
background: linear-gradient(135deg, var(--success-500) 0%, var(--success-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 8px 25px rgba(56, 161, 105, 0.3);
|
||||
}
|
||||
|
||||
.result-status.error .result-icon {
|
||||
background: linear-gradient(135deg, var(--error-500) 0%, var(--error-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 8px 25px rgba(229, 62, 62, 0.3);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.result-status.success .result-title {
|
||||
color: var(--success-600);
|
||||
}
|
||||
|
||||
.result-status.error .result-title {
|
||||
color: var(--error-600);
|
||||
}
|
||||
|
||||
.result-description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-600);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Success Details */
|
||||
.success-details {
|
||||
background: linear-gradient(135deg, var(--success-50) 0%, var(--success-100) 100%);
|
||||
border: 2px solid var(--success-100);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid rgba(56, 161, 105, 0.1);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-value.product {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.detail-value.date {
|
||||
color: var(--success-600);
|
||||
}
|
||||
|
||||
.detail-value.number {
|
||||
color: var(--gray-900);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Error Details */
|
||||
.error-details {
|
||||
background: linear-gradient(135deg, var(--error-50) 0%, var(--error-100) 100%);
|
||||
border: 2px solid var(--error-100);
|
||||
}
|
||||
|
||||
.error-reason {
|
||||
background: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--error-600);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--text-sm);
|
||||
color: #991B1B;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.solution-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.solution-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-700);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Contact Info */
|
||||
.contact-info {
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.contact-number {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--primary-600);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.contact-hours {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Fixed Action Buttons */
|
||||
.fixed-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fixed-actions {
|
||||
max-width: 480px;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-500) 0%, var(--success-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(56, 161, 105, 0.2);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3);
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background: linear-gradient(135deg, var(--error-500) 0%, var(--error-600) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(229, 62, 62, 0.2);
|
||||
}
|
||||
|
||||
.btn-error:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Hidden class for conditional display */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<h1 class="page-title">처리 결과</h1>
|
||||
</header>
|
||||
|
||||
<!-- Success Result -->
|
||||
<div id="successResult" class="result-status success">
|
||||
<div class="result-icon">✓</div>
|
||||
<h2 class="result-title">상품 변경이 완료되었습니다</h2>
|
||||
<p class="result-description">
|
||||
선택하신 상품으로 변경 신청이 성공적으로 처리되었습니다.<br>
|
||||
변경된 상품은 다음 월 1일부터 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Result (Hidden by default) -->
|
||||
<div id="errorResult" class="result-status error hidden">
|
||||
<div class="result-icon">✕</div>
|
||||
<h2 class="result-title">상품 변경에 실패했습니다</h2>
|
||||
<p class="result-description">
|
||||
죄송합니다. 상품 변경 처리 중 문제가 발생했습니다.<br>
|
||||
아래의 해결 방법을 확인해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Details -->
|
||||
<div id="successDetails" class="card success-details">
|
||||
<h3 class="card-title">변경 내용</h3>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">변경된 상품</span>
|
||||
<span class="detail-value product" id="changedProduct">5G 스탠다드 플랜</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">월 요금</span>
|
||||
<span class="detail-value product" id="changedPrice">월 59,000원</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">적용일</span>
|
||||
<span class="detail-value date">2025년 2월 1일</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">처리번호</span>
|
||||
<span class="detail-value number" id="processNumber">CHG-2025010512345</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">처리일시</span>
|
||||
<span class="detail-value" id="processTime">2025-01-05 14:23:45</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details (Hidden by default) -->
|
||||
<div id="errorDetails" class="card error-details hidden">
|
||||
<h3 class="card-title">실패 사유</h3>
|
||||
<div class="error-reason">
|
||||
<div class="error-title">약정 위반으로 인한 변경 불가</div>
|
||||
<div class="error-text">
|
||||
현재 이용 중인 상품의 약정 기간이 남아있어 상품 변경이 불가능합니다.
|
||||
약정 해지 후 다시 시도하시거나 고객센터로 문의해 주세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="card-title">해결 방법</h4>
|
||||
<ol class="solution-steps">
|
||||
<li class="solution-step">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">현재 약정 상태와 위약금을 확인해보세요</span>
|
||||
</li>
|
||||
<li class="solution-step">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">약정 기간 만료 후 다시 시도하거나 위약금을 납부하고 변경하세요</span>
|
||||
</li>
|
||||
<li class="solution-step">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">고객센터에 문의하여 다른 변경 방법을 안내받으세요</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="contact-info">
|
||||
<div class="contact-title">고객센터</div>
|
||||
<div class="contact-number">1588-0000</div>
|
||||
<div class="contact-hours">평일 09:00~18:00 (토/일/공휴일 휴무)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Action Buttons -->
|
||||
<div class="fixed-actions">
|
||||
<!-- Success Actions -->
|
||||
<div id="successActions">
|
||||
<button class="btn btn-primary" onclick="goToMain()">
|
||||
메인으로
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="viewBill()">
|
||||
요금 조회
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Actions (Hidden by default) -->
|
||||
<div id="errorActions" class="hidden">
|
||||
<button class="btn btn-error" onclick="retryChange()">
|
||||
다시 시도
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="contactSupport()">
|
||||
고객센터 연결
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goToMain()">
|
||||
메인으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load request data and determine result
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadResultData();
|
||||
|
||||
// Handle keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && e.target.tagName === 'BUTTON') {
|
||||
e.target.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function loadResultData() {
|
||||
const requestData = sessionStorage.getItem('changeRequest');
|
||||
|
||||
// Simulate random success/failure for demo
|
||||
// In real app, this would come from the API response
|
||||
const isSuccess = Math.random() > 0.2; // 80% success rate for demo
|
||||
|
||||
if (requestData) {
|
||||
const request = JSON.parse(requestData);
|
||||
|
||||
if (isSuccess) {
|
||||
showSuccessResult(request);
|
||||
} else {
|
||||
showErrorResult();
|
||||
}
|
||||
} else {
|
||||
// Default success display
|
||||
showSuccessResult({
|
||||
newProduct: '5G 스탠다드 플랜',
|
||||
newPrice: '월 59,000원'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessResult(requestData) {
|
||||
// Show success elements
|
||||
document.getElementById('successResult').classList.remove('hidden');
|
||||
document.getElementById('successDetails').classList.remove('hidden');
|
||||
document.getElementById('successActions').classList.remove('hidden');
|
||||
|
||||
// Hide error elements
|
||||
document.getElementById('errorResult').classList.add('hidden');
|
||||
document.getElementById('errorDetails').classList.add('hidden');
|
||||
document.getElementById('errorActions').classList.add('hidden');
|
||||
|
||||
// Update success details
|
||||
if (requestData.newProduct) {
|
||||
document.getElementById('changedProduct').textContent = requestData.newProduct;
|
||||
}
|
||||
if (requestData.newPrice) {
|
||||
document.getElementById('changedPrice').textContent = requestData.newPrice;
|
||||
}
|
||||
|
||||
// Save successful product change to localStorage
|
||||
const newProduct = {
|
||||
name: requestData.newProduct || '5G 스탠다드 플랜',
|
||||
price: requestData.newPrice || '월 59,000원',
|
||||
benefits: getProductBenefits(requestData.newProduct),
|
||||
changeDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
localStorage.setItem('currentProduct', JSON.stringify(newProduct));
|
||||
|
||||
// Generate process number and time
|
||||
const processNumber = `CHG-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}${String(new Date().getDate()).padStart(2, '0')}${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}`;
|
||||
const processTime = new Date().toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
document.getElementById('processNumber').textContent = processNumber;
|
||||
document.getElementById('processTime').textContent = processTime;
|
||||
}
|
||||
|
||||
// Get product benefits based on product name
|
||||
function getProductBenefits(productName) {
|
||||
const productBenefits = {
|
||||
'5G 스탠다드 플랜': [
|
||||
'5G 데이터 100GB',
|
||||
'음성통화 무제한',
|
||||
'문자 무제한',
|
||||
'영상통화 300분'
|
||||
],
|
||||
'5G 베이직 플랜': [
|
||||
'5G 데이터 50GB',
|
||||
'음성통화 무제한',
|
||||
'문자 무제한',
|
||||
'영상통화 300분'
|
||||
],
|
||||
'5G 프리미엄 플랜': [
|
||||
'5G 데이터 무제한',
|
||||
'음성통화 무제한',
|
||||
'문자 무제한',
|
||||
'해외 로밍 50% 할인'
|
||||
]
|
||||
};
|
||||
|
||||
return productBenefits[productName] || [
|
||||
'5G 데이터 무제한',
|
||||
'음성통화 무제한',
|
||||
'문자 무제한'
|
||||
];
|
||||
}
|
||||
|
||||
function showErrorResult() {
|
||||
// Show error elements
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorDetails').classList.remove('hidden');
|
||||
document.getElementById('errorActions').classList.remove('hidden');
|
||||
|
||||
// Hide success elements
|
||||
document.getElementById('successResult').classList.add('hidden');
|
||||
document.getElementById('successDetails').classList.add('hidden');
|
||||
document.getElementById('successActions').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function goToMain() {
|
||||
const btn = event.target;
|
||||
btn.classList.add('loading');
|
||||
btn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '02-메인화면.html';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function viewBill() {
|
||||
const btn = event.target;
|
||||
btn.classList.add('loading');
|
||||
btn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '03-요금조회메뉴.html';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function retryChange() {
|
||||
const btn = event.target;
|
||||
btn.classList.add('loading');
|
||||
btn.disabled = true;
|
||||
|
||||
// Clear previous data
|
||||
sessionStorage.removeItem('changeRequest');
|
||||
sessionStorage.removeItem('selectedProduct');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '05-상품변경메뉴.html';
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
// Simulate contacting support
|
||||
alert('고객센터 연결 기능입니다.\n\n전화번호: 1588-0000\n운영시간: 평일 09:00~18:00');
|
||||
}
|
||||
|
||||
// Auto-clear session data after 30 minutes to prevent stale data
|
||||
setTimeout(() => {
|
||||
sessionStorage.removeItem('changeRequest');
|
||||
sessionStorage.removeItem('selectedProduct');
|
||||
}, 30 * 60 * 1000); // 30 minutes
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,743 @@
|
||||
# 통신요금 관리 서비스 - 스타일 가이드
|
||||
|
||||
- [통신요금 관리 서비스 - 스타일 가이드](#통신요금-관리-서비스---스타일-가이드)
|
||||
- [브랜드 아이덴티티](#브랜드-아이덴티티)
|
||||
- [디자인 원칙](#디자인-원칙)
|
||||
- [컬러 시스템](#컬러-시스템)
|
||||
- [타이포그래피](#타이포그래피)
|
||||
- [간격 시스템](#간격-시스템)
|
||||
- [컴포넌트 스타일](#컴포넌트-스타일)
|
||||
- [반응형 브레이크포인트](#반응형-브레이크포인트)
|
||||
- [대상 서비스 특화 컴포넌트](#대상-서비스-특화-컴포넌트)
|
||||
- [인터랙션 패턴](#인터랙션-패턴)
|
||||
- [변경 이력](#변경-이력)
|
||||
|
||||
---
|
||||
|
||||
## 브랜드 아이덴티티
|
||||
|
||||
### 서비스 컨셉
|
||||
- **키워드**: 신뢰성, 편리함, 명확성
|
||||
- **브랜드 메시지**: "간편하고 안전한 통신요금 관리"
|
||||
- **타겟**: 일반 MVNO 고객 (20대~60대)
|
||||
|
||||
### 디자인 컨셉
|
||||
- **미니멀리즘**: 불필요한 요소 제거, 핵심 기능 집중
|
||||
- **명확성 우선**: 정보 전달의 명확성과 가독성
|
||||
- **안정감**: 금융 서비스의 신뢰성과 보안성 강조
|
||||
- **접근성**: 모든 사용자가 편리하게 이용할 수 있는 인터페이스
|
||||
|
||||
---
|
||||
|
||||
## 디자인 원칙
|
||||
|
||||
### 1. 명확성 (Clarity)
|
||||
- 모든 UI 요소는 그 목적이 명확해야 함
|
||||
- 전문용어 사용 최소화, 일반적인 표현 우선
|
||||
- 중요 정보는 시각적으로 강조
|
||||
|
||||
### 2. 일관성 (Consistency)
|
||||
- 동일한 요소는 동일한 스타일 적용
|
||||
- 예측 가능한 인터랙션 패턴
|
||||
- 통일된 색상과 타이포그래피 사용
|
||||
|
||||
### 3. 효율성 (Efficiency)
|
||||
- 최소한의 클릭으로 목표 달성
|
||||
- 불필요한 단계 제거
|
||||
- 빠른 로딩과 반응성 보장
|
||||
|
||||
### 4. 안전성 (Safety)
|
||||
- 중요한 액션에는 확인 단계 제공
|
||||
- 오류 방지와 명확한 피드백
|
||||
- 개인정보 보호 강조
|
||||
|
||||
### 5. 포용성 (Inclusivity)
|
||||
- 접근성 지침 준수
|
||||
- 다양한 디바이스와 환경 지원
|
||||
- 사용자 능력과 상황 고려
|
||||
|
||||
---
|
||||
|
||||
## 컬러 시스템
|
||||
|
||||
### Primary Colors
|
||||
```css
|
||||
/* 메인 브랜드 컬러 - 신뢰감을 주는 블루 */
|
||||
--primary-50: #EBF8FF;
|
||||
--primary-100: #BEE3F8;
|
||||
--primary-200: #90CDF4;
|
||||
--primary-300: #63B3ED;
|
||||
--primary-400: #4299E1;
|
||||
--primary-500: #3182CE; /* Main Brand Color */
|
||||
--primary-600: #2B77CB;
|
||||
--primary-700: #2C5282;
|
||||
--primary-800: #2A4365;
|
||||
--primary-900: #1A365D;
|
||||
```
|
||||
|
||||
### Secondary Colors
|
||||
```css
|
||||
/* 보조 컬러 - 포인트 및 상태 표시 */
|
||||
--secondary-50: #F7FAFC;
|
||||
--secondary-100: #EDF2F7;
|
||||
--secondary-200: #E2E8F0;
|
||||
--secondary-300: #CBD5E0;
|
||||
--secondary-400: #A0AEC0;
|
||||
--secondary-500: #718096;
|
||||
--secondary-600: #4A5568;
|
||||
--secondary-700: #2D3748;
|
||||
--secondary-800: #1A202C;
|
||||
--secondary-900: #171923;
|
||||
```
|
||||
|
||||
### Status Colors
|
||||
```css
|
||||
/* 성공 - 그린 */
|
||||
--success-50: #F0FFF4;
|
||||
--success-100: #C6F6D5;
|
||||
--success-500: #38A169;
|
||||
--success-600: #2F855A;
|
||||
|
||||
/* 경고 - 오렌지 */
|
||||
--warning-50: #FFFAF0;
|
||||
--warning-100: #FEEBC8;
|
||||
--warning-500: #ED8936;
|
||||
--warning-600: #DD6B20;
|
||||
|
||||
/* 오류 - 레드 */
|
||||
--error-50: #FED7D7;
|
||||
--error-100: #FED7D7;
|
||||
--error-500: #E53E3E;
|
||||
--error-600: #C53030;
|
||||
|
||||
/* 정보 - 블루 */
|
||||
--info-50: #EBF8FF;
|
||||
--info-100: #BEE3F8;
|
||||
--info-500: #3182CE;
|
||||
--info-600: #2B77CB;
|
||||
```
|
||||
|
||||
### Neutral Colors
|
||||
```css
|
||||
/* 텍스트 및 배경 */
|
||||
--gray-50: #F9FAFB; /* Background Light */
|
||||
--gray-100: #F3F4F6; /* Background */
|
||||
--gray-200: #E5E7EB; /* Border Light */
|
||||
--gray-300: #D1D5DB; /* Border */
|
||||
--gray-400: #9CA3AF; /* Text Muted */
|
||||
--gray-500: #6B7280; /* Text Secondary */
|
||||
--gray-600: #4B5563; /* Text Primary Light */
|
||||
--gray-700: #374151; /* Text Primary */
|
||||
--gray-800: #1F2937; /* Text Primary Dark */
|
||||
--gray-900: #111827; /* Text Emphasis */
|
||||
```
|
||||
|
||||
### 컬러 사용 가이드
|
||||
- **Primary**: 주요 액션 버튼, 링크, 브랜드 요소
|
||||
- **Secondary**: 보조 버튼, 아이콘, 경계선
|
||||
- **Success**: 성공 메시지, 완료 상태
|
||||
- **Warning**: 주의 메시지, 중요 알림
|
||||
- **Error**: 오류 메시지, 실패 상태
|
||||
- **Gray**: 텍스트, 배경, 구분선
|
||||
|
||||
---
|
||||
|
||||
## 타이포그래피
|
||||
|
||||
### 폰트 패밀리
|
||||
```css
|
||||
/* 기본 폰트 스택 */
|
||||
font-family:
|
||||
'Noto Sans KR', /* 한글 */
|
||||
'Roboto', /* 영문 */
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Apple SD Gothic Neo',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
```
|
||||
|
||||
### 폰트 크기 및 행간
|
||||
```css
|
||||
/* Heading */
|
||||
--text-4xl: 2.25rem; /* 36px - Page Title */
|
||||
--text-3xl: 1.875rem; /* 30px - Section Title */
|
||||
--text-2xl: 1.5rem; /* 24px - Card Title */
|
||||
--text-xl: 1.25rem; /* 20px - Sub Title */
|
||||
--text-lg: 1.125rem; /* 18px - Large Text */
|
||||
|
||||
/* Body */
|
||||
--text-base: 1rem; /* 16px - Body Text */
|
||||
--text-sm: 0.875rem; /* 14px - Small Text */
|
||||
--text-xs: 0.75rem; /* 12px - Caption */
|
||||
|
||||
/* Line Height */
|
||||
--leading-tight: 1.25; /* Heading */
|
||||
--leading-normal: 1.5; /* Body */
|
||||
--leading-relaxed: 1.625; /* Long Text */
|
||||
```
|
||||
|
||||
### 폰트 두께
|
||||
```css
|
||||
--font-light: 300; /* Light text */
|
||||
--font-normal: 400; /* Body text */
|
||||
--font-medium: 500; /* Emphasis */
|
||||
--font-semibold: 600; /* Sub heading */
|
||||
--font-bold: 700; /* Heading */
|
||||
```
|
||||
|
||||
### 타이포그래피 클래스
|
||||
```css
|
||||
/* Heading Styles */
|
||||
.heading-1 { font-size: 2.25rem; font-weight: 700; line-height: 1.25; }
|
||||
.heading-2 { font-size: 1.875rem; font-weight: 600; line-height: 1.25; }
|
||||
.heading-3 { font-size: 1.5rem; font-weight: 600; line-height: 1.25; }
|
||||
.heading-4 { font-size: 1.25rem; font-weight: 500; line-height: 1.25; }
|
||||
|
||||
/* Body Styles */
|
||||
.body-large { font-size: 1.125rem; font-weight: 400; line-height: 1.5; }
|
||||
.body-normal { font-size: 1rem; font-weight: 400; line-height: 1.5; }
|
||||
.body-small { font-size: 0.875rem; font-weight: 400; line-height: 1.5; }
|
||||
.caption { font-size: 0.75rem; font-weight: 400; line-height: 1.25; }
|
||||
|
||||
/* Emphasis */
|
||||
.text-emphasis { font-weight: 600; color: var(--gray-900); }
|
||||
.text-muted { color: var(--gray-500); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 간격 시스템
|
||||
|
||||
### 기본 간격 단위 (8px 그리드 시스템)
|
||||
```css
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-20: 5rem; /* 80px */
|
||||
```
|
||||
|
||||
### 컴포넌트별 간격 가이드
|
||||
- **Component Padding**: 16px (space-4) - 24px (space-6)
|
||||
- **Content Margin**: 16px (space-4) - 32px (space-8)
|
||||
- **Section Gap**: 32px (space-8) - 48px (space-12)
|
||||
- **Page Padding**: 20px (space-5) - 40px (space-10)
|
||||
|
||||
### 레이아웃 간격
|
||||
```css
|
||||
/* Container */
|
||||
--container-padding-mobile: var(--space-4); /* 16px */
|
||||
--container-padding-tablet: var(--space-6); /* 24px */
|
||||
--container-padding-desktop: var(--space-8); /* 32px */
|
||||
|
||||
/* Grid Gap */
|
||||
--grid-gap-small: var(--space-4); /* 16px */
|
||||
--grid-gap-medium: var(--space-6); /* 24px */
|
||||
--grid-gap-large: var(--space-8); /* 32px */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 스타일
|
||||
|
||||
### 버튼 (Button)
|
||||
```css
|
||||
/* Base Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: 8px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: 44px; /* 터치 접근성 */
|
||||
}
|
||||
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background-color: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-600);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background-color: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--gray-700);
|
||||
border: 1px solid var(--gray-300);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--gray-50);
|
||||
border-color: var(--gray-400);
|
||||
}
|
||||
|
||||
/* Danger Button */
|
||||
.btn-danger {
|
||||
background-color: var(--error-500);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: var(--error-600);
|
||||
}
|
||||
```
|
||||
|
||||
### 카드 (Card)
|
||||
```css
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--gray-700);
|
||||
}
|
||||
```
|
||||
|
||||
### 폼 요소 (Form)
|
||||
```css
|
||||
/* Input */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.input.error {
|
||||
border-color: var(--error-500);
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--gray-700);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Select */
|
||||
.select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,..."); /* 드롭다운 아이콘 */
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--space-3) center;
|
||||
background-size: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
### 알림 메시지 (Alert)
|
||||
```css
|
||||
.alert {
|
||||
padding: var(--space-4);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: var(--success-50);
|
||||
border-color: var(--success-500);
|
||||
color: var(--success-800);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: var(--warning-50);
|
||||
border-color: var(--warning-500);
|
||||
color: var(--warning-800);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: var(--error-50);
|
||||
border-color: var(--error-500);
|
||||
color: var(--error-800);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: var(--info-50);
|
||||
border-color: var(--info-500);
|
||||
color: var(--info-800);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 반응형 브레이크포인트
|
||||
|
||||
### 브레이크포인트 정의
|
||||
```css
|
||||
/* Mobile First Approach */
|
||||
:root {
|
||||
--breakpoint-sm: 640px; /* Small devices */
|
||||
--breakpoint-md: 768px; /* Medium devices */
|
||||
--breakpoint-lg: 1024px; /* Large devices */
|
||||
--breakpoint-xl: 1280px; /* Extra large devices */
|
||||
}
|
||||
|
||||
/* Media Query Mixins */
|
||||
@media (min-width: 640px) { /* sm */ }
|
||||
@media (min-width: 768px) { /* md */ }
|
||||
@media (min-width: 1024px) { /* lg */ }
|
||||
@media (min-width: 1280px) { /* xl */ }
|
||||
```
|
||||
|
||||
### 반응형 컨테이너
|
||||
```css
|
||||
.container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
padding: 0 var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
padding: 0 var(--space-8);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 반응형 그리드
|
||||
```css
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr; /* Mobile: 1 column */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-md-2 {
|
||||
grid-template-columns: repeat(2, 1fr); /* Tablet: 2 columns */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-lg-3 {
|
||||
grid-template-columns: repeat(3, 1fr); /* Desktop: 3 columns */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 대상 서비스 특화 컴포넌트
|
||||
|
||||
### 요금 정보 카드 (Bill Card)
|
||||
```css
|
||||
.bill-card {
|
||||
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
|
||||
color: white;
|
||||
padding: var(--space-6);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.bill-amount {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-bold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.bill-period {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bill-details {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
```
|
||||
|
||||
### 상품 비교 카드 (Product Card)
|
||||
```css
|
||||
.product-card {
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: var(--space-6);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-card.selected {
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.product-card.current {
|
||||
background-color: var(--success-50);
|
||||
border-color: var(--success-500);
|
||||
}
|
||||
|
||||
.product-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: var(--space-4);
|
||||
background-color: var(--primary-500);
|
||||
color: white;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--primary-600);
|
||||
}
|
||||
```
|
||||
|
||||
### 진행 상태 표시 (Progress)
|
||||
```css
|
||||
.progress-container {
|
||||
background-color: var(--gray-100);
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-500) 0%, var(--primary-400) 100%);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.progress-step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.progress-step.completed {
|
||||
color: var(--success-600);
|
||||
}
|
||||
```
|
||||
|
||||
### 상태 뱃지 (Status Badge)
|
||||
```css
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge.processing {
|
||||
background-color: var(--warning-100);
|
||||
color: var(--warning-800);
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background-color: var(--success-100);
|
||||
color: var(--success-800);
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background-color: var(--error-100);
|
||||
color: var(--error-800);
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인터랙션 패턴
|
||||
|
||||
### 애니메이션 타이밍
|
||||
```css
|
||||
:root {
|
||||
--duration-fast: 0.15s;
|
||||
--duration-normal: 0.3s;
|
||||
--duration-slow: 0.5s;
|
||||
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### 호버 효과
|
||||
```css
|
||||
.interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
### 로딩 상태
|
||||
```css
|
||||
.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gray-300);
|
||||
border-top: 2px solid var(--primary-500);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### 포커스 상태
|
||||
```css
|
||||
.focusable:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.focus-visible {
|
||||
outline: 2px solid var(--primary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### 상태 전환
|
||||
```css
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
|
||||
.slide-enter {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.slide-enter-active {
|
||||
transform: translateX(0);
|
||||
transition: transform var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경사항 | 작성자 |
|
||||
|------|------|----------|--------|
|
||||
| 1.0 | 2025-01-05 | 초기 스타일 가이드 작성 | 박화면 |
|
||||
|
||||
---
|
||||
|
||||
## 스타일 가이드 활용 방법
|
||||
|
||||
### CSS 변수 사용
|
||||
모든 스타일 정의에서 CSS 변수를 사용하여 일관성을 유지하고 쉬운 테마 변경을 지원합니다.
|
||||
|
||||
### 컴포넌트 기반 설계
|
||||
재사용 가능한 컴포넌트 스타일을 정의하여 개발 효율성과 일관성을 높입니다.
|
||||
|
||||
### 접근성 고려
|
||||
모든 컴포넌트는 WCAG 2.1 AA 기준을 준수하여 접근성을 보장합니다.
|
||||
|
||||
### 반응형 우선
|
||||
Mobile First 접근 방식으로 모든 디바이스에서 최적의 사용자 경험을 제공합니다.
|
||||
@@ -0,0 +1,578 @@
|
||||
# 통신요금 관리 서비스 - UI/UX 설계서
|
||||
|
||||
- [통신요금 관리 서비스 - UI/UX 설계서](#통신요금-관리-서비스---uiux-설계서)
|
||||
- [프로젝트 개요](#프로젝트-개요)
|
||||
- [정보 아키텍처](#정보-아키텍처)
|
||||
- [프로토타입 화면 목록](#프로토타입-화면-목록)
|
||||
- [사용자 플로우](#사용자-플로우)
|
||||
- [화면별 상세 설계](#화면별-상세-설계)
|
||||
- [화면간 전환 및 네비게이션](#화면간-전환-및-네비게이션)
|
||||
- [반응형 설계 전략](#반응형-설계-전략)
|
||||
- [접근성 보장 방안](#접근성-보장-방안)
|
||||
- [성능 최적화 방안](#성능-최적화-방안)
|
||||
- [변경 이력](#변경-이력)
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
### 서비스 목적
|
||||
MVNO 고객의 통신요금 조회 및 상품변경을 지원하는 웹 서비스
|
||||
|
||||
### 주요 기능
|
||||
1. **사용자 인증**: 안전한 로그인/로그아웃
|
||||
2. **요금 조회**: 월별 통신요금 조회
|
||||
3. **상품 변경**: 요금제 변경 요청 및 처리
|
||||
|
||||
### 설계 기준
|
||||
- **유저스토리 기반**: 총 10개 유저스토리 100% 반영
|
||||
- **B2C 웹 서비스**: 일반 고객 대상
|
||||
- **보안 우선**: 개인정보 및 금융정보 보호
|
||||
- **사용성 중심**: 직관적이고 간단한 UI/UX
|
||||
|
||||
---
|
||||
|
||||
## 정보 아키텍처
|
||||
|
||||
### 서비스 구조
|
||||
```
|
||||
통신요금 관리 서비스
|
||||
├── 인증 영역
|
||||
│ ├── 로그인
|
||||
│ └── 권한 확인
|
||||
├── 요금 조회 영역
|
||||
│ ├── 조회 메뉴
|
||||
│ ├── 조회 신청
|
||||
│ └── 조회 결과
|
||||
└── 상품 변경 영역
|
||||
├── 변경 메뉴
|
||||
├── 변경 화면
|
||||
├── 변경 요청
|
||||
└── 처리 결과
|
||||
```
|
||||
|
||||
### 네비게이션 구조
|
||||
```
|
||||
메인 화면
|
||||
├── 요금 조회 메뉴 → 요금 조회 신청 → 조회 결과
|
||||
└── 상품 변경 메뉴 → 상품 변경 화면 → 변경 요청 → 처리 결과
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프로토타입 화면 목록
|
||||
|
||||
| 화면 ID | 화면명 | 관련 유저스토리 | 우선순위 |
|
||||
|---------|--------|-----------------|----------|
|
||||
| SCR-001 | 로그인 | UFR-AUTH-010 | M |
|
||||
| SCR-002 | 메인 화면 | UFR-AUTH-020 | M |
|
||||
| SCR-003 | 요금조회 메뉴 | UFR-BILL-010 | M |
|
||||
| SCR-004 | 요금조회 결과 | UFR-BILL-020, UFR-BILL-030, UFR-BILL-040 | M |
|
||||
| SCR-005 | 상품변경 메뉴 | UFR-PROD-010 | M |
|
||||
| SCR-006 | 상품변경 화면 | UFR-PROD-020 | M |
|
||||
| SCR-007 | 상품변경 요청 | UFR-PROD-030 | M |
|
||||
| SCR-008 | 처리결과 화면 | UFR-PROD-040 | M |
|
||||
|
||||
---
|
||||
|
||||
## 사용자 플로우
|
||||
|
||||
### 메인 플로우
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[로그인] --> B[메인 화면]
|
||||
B --> C[요금 조회]
|
||||
B --> D[상품 변경]
|
||||
|
||||
C --> C1[요금조회 메뉴]
|
||||
C1 --> C2[조회월 선택]
|
||||
C2 --> C3[요금조회 결과]
|
||||
C3 --> B
|
||||
|
||||
D --> D1[상품변경 메뉴]
|
||||
D1 --> D2[상품변경 화면]
|
||||
D2 --> D3[상품 선택]
|
||||
D3 --> D4[변경 요청]
|
||||
D4 --> D5[처리결과]
|
||||
D5 --> B
|
||||
```
|
||||
|
||||
### 오류 처리 플로우
|
||||
```mermaid
|
||||
flowchart TD
|
||||
E1[로그인 실패] --> E1_1[오류 메시지 표시] --> E1_2[로그인 재시도]
|
||||
E2[권한 없음] --> E2_1[권한 오류 메시지] --> E2_2[메인 화면]
|
||||
E3[조회 실패] --> E3_1[조회 오류 메시지] --> E3_2[조회 메뉴]
|
||||
E4[변경 실패] --> E4_1[변경 오류 메시지] --> E4_2[변경 화면]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 화면별 상세 설계
|
||||
|
||||
### SCR-001: 로그인
|
||||
**개요**
|
||||
- 목적: 사용자 인증 및 서비스 접근
|
||||
- 관련 유저스토리: UFR-AUTH-010
|
||||
- 비즈니스 중요도: M/5
|
||||
|
||||
**주요 기능**
|
||||
- ID/Password 입력
|
||||
- 자동 로그인 옵션
|
||||
- 로그인 버튼
|
||||
- 오류 메시지 표시
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 서비스 로고
|
||||
└── 서비스 제목
|
||||
|
||||
Main Content
|
||||
├── 로그인 폼
|
||||
│ ├── ID 입력 필드 (required)
|
||||
│ ├── Password 입력 필드 (required, type=password)
|
||||
│ ├── 자동 로그인 체크박스
|
||||
│ └── 로그인 버튼 (primary)
|
||||
└── 오류 메시지 영역
|
||||
|
||||
Footer
|
||||
└── 저작권 정보
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- ID/Password 유효성 검사 (실시간)
|
||||
- 로그인 버튼 활성화/비활성화
|
||||
- 5회 실패 시 계정 잠금 안내
|
||||
- 성공 시 메인 화면 이동
|
||||
|
||||
---
|
||||
|
||||
### SCR-002: 메인 화면
|
||||
**개요**
|
||||
- 목적: 서비스 메뉴 제공 및 권한별 접근 제어
|
||||
- 관련 유저스토리: UFR-AUTH-020
|
||||
- 비즈니스 중요도: M/3
|
||||
|
||||
**주요 기능**
|
||||
- 사용자 정보 표시 (회선번호)
|
||||
- 서비스 메뉴 제공
|
||||
- 권한별 메뉴 표시/숨김
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 서비스 로고
|
||||
├── 사용자 정보 (회선번호)
|
||||
└── 로그아웃 버튼
|
||||
|
||||
Main Content
|
||||
├── 환영 메시지
|
||||
└── 서비스 메뉴 그리드
|
||||
├── 요금 조회 카드 (권한 확인)
|
||||
└── 상품 변경 카드 (권한 확인)
|
||||
|
||||
Footer
|
||||
└── 저작권 정보
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 권한 확인 후 메뉴 표시
|
||||
- 권한 없는 메뉴는 비활성화 또는 숨김
|
||||
- 카드 호버 효과
|
||||
- 카드 클릭 시 해당 서비스로 이동
|
||||
|
||||
---
|
||||
|
||||
### SCR-003: 요금조회 메뉴
|
||||
**개요**
|
||||
- 목적: 요금 조회 옵션 제공
|
||||
- 관련 유저스토리: UFR-BILL-010
|
||||
- 비즈니스 중요도: M/5
|
||||
|
||||
**주요 기능**
|
||||
- 회선번호 표시
|
||||
- 조회월 선택 옵션
|
||||
- 조회 신청 기능
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 뒤로가기 버튼
|
||||
└── 페이지 제목 "요금 조회"
|
||||
|
||||
Main Content
|
||||
├── 고객 정보 섹션
|
||||
│ └── 회선번호 표시
|
||||
├── 조회 옵션 섹션
|
||||
│ ├── 조회월 선택 (드롭다운)
|
||||
│ │ ├── 기본값: "현재 월"
|
||||
│ │ └── 이전 6개월 옵션
|
||||
│ └── 안내 텍스트
|
||||
└── 액션 버튼 그룹
|
||||
├── 조회 버튼 (primary)
|
||||
└── 취소 버튼 (secondary)
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 조회월 드롭다운 선택
|
||||
- 조회 버튼 클릭 시 로딩 상태
|
||||
- 오류 시 에러 메시지 표시
|
||||
|
||||
---
|
||||
|
||||
### SCR-004: 요금조회 결과
|
||||
**개요**
|
||||
- 목적: 조회된 요금 정보 표시
|
||||
- 관련 유저스토리: UFR-BILL-020, UFR-BILL-030, UFR-BILL-040
|
||||
- 비즈니스 중요도: M/8, M/13, M/8
|
||||
|
||||
**주요 기능**
|
||||
- 요금 정보 상세 표시
|
||||
- 사용량 정보 제공
|
||||
- 새로운 조회 기능
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 뒤로가기 버튼
|
||||
└── 페이지 제목 "요금 조회 결과"
|
||||
|
||||
Main Content
|
||||
├── 요금 정보 카드
|
||||
│ ├── 청구월
|
||||
│ ├── 상품명 (요금제)
|
||||
│ ├── 총 요금 (강조 표시)
|
||||
│ ├── 할인 정보
|
||||
│ └── 약정 정보
|
||||
├── 사용량 정보 카드
|
||||
│ ├── 통화 사용량
|
||||
│ ├── 데이터 사용량
|
||||
│ └── SMS 사용량
|
||||
├── 부가 정보 카드
|
||||
│ ├── 단말기 할부금
|
||||
│ ├── 예상 해지비용
|
||||
│ └── 청구/납부 정보
|
||||
└── 액션 버튼 그룹
|
||||
├── 다른 월 조회 버튼
|
||||
└── 메인으로 버튼
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 정보 카드 접기/펼치기
|
||||
- 다른 월 조회 클릭 시 조회 메뉴로 이동
|
||||
- 로딩 중 스켈레톤 UI 표시
|
||||
|
||||
---
|
||||
|
||||
### SCR-005: 상품변경 메뉴
|
||||
**개요**
|
||||
- 목적: 상품 변경 진입점 제공
|
||||
- 관련 유저스토리: UFR-PROD-010
|
||||
- 비즈니스 중요도: M/5
|
||||
|
||||
**주요 기능**
|
||||
- 고객 정보 표시
|
||||
- 현재 상품 정보 표시
|
||||
- 상품 변경 화면으로 이동
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 뒤로가기 버튼
|
||||
└── 페이지 제목 "상품 변경"
|
||||
|
||||
Main Content
|
||||
├── 고객 정보 카드
|
||||
│ ├── 회선번호
|
||||
│ └── 고객ID
|
||||
├── 현재 상품 정보 카드
|
||||
│ ├── 상품명
|
||||
│ ├── 월 기본료
|
||||
│ └── 주요 혜택
|
||||
├── 안내 메시지
|
||||
│ └── 상품 변경 시 주의사항
|
||||
└── 액션 버튼 그룹
|
||||
├── 상품 변경하기 버튼 (primary)
|
||||
└── 취소 버튼 (secondary)
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 현재 상품 정보 로딩
|
||||
- 상품 변경하기 클릭 시 변경 화면으로 이동
|
||||
- 로딩 실패 시 에러 메시지
|
||||
|
||||
---
|
||||
|
||||
### SCR-006: 상품변경 화면
|
||||
**개요**
|
||||
- 목적: 변경 가능한 상품 목록 제공
|
||||
- 관련 유저스토리: UFR-PROD-020
|
||||
- 비즈니스 중요도: M/8
|
||||
|
||||
**주요 기능**
|
||||
- 변경 가능한 상품 목록 표시
|
||||
- 상품 비교 기능
|
||||
- 상품 선택 기능
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 뒤로가기 버튼
|
||||
└── 페이지 제목 "상품 선택"
|
||||
|
||||
Main Content
|
||||
├── 현재 상품 요약 (고정)
|
||||
├── 상품 목록 섹션
|
||||
│ └── 상품 카드들
|
||||
│ ├── 상품명
|
||||
│ ├── 월 기본료
|
||||
│ ├── 주요 혜택 리스트
|
||||
│ ├── 현재 상품과 비교
|
||||
│ └── 선택 라디오 버튼
|
||||
└── 액션 버튼 그룹 (고정)
|
||||
├── 선택한 상품으로 변경 (primary, disabled)
|
||||
└── 취소 버튼 (secondary)
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 상품 선택 시 버튼 활성화
|
||||
- 상품 카드 선택 상태 시각화
|
||||
- 스크롤 시 헤더와 버튼 고정
|
||||
- 상품 로딩 중 스켈레톤 표시
|
||||
|
||||
---
|
||||
|
||||
### SCR-007: 상품변경 요청
|
||||
**개요**
|
||||
- 목적: 선택한 상품으로 변경 요청 확인
|
||||
- 관련 유저스토리: UFR-PROD-030
|
||||
- 비즈니스 중요도: M/13
|
||||
|
||||
**주요 기능**
|
||||
- 변경 내용 확인
|
||||
- 사전 체크 진행 상황
|
||||
- 변경 요청 최종 실행
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
├── 뒤로가기 버튼
|
||||
└── 페이지 제목 "상품 변경 요청"
|
||||
|
||||
Main Content
|
||||
├── 변경 내용 확인 카드
|
||||
│ ├── 현재 상품
|
||||
│ ├── 변경 화살표 아이콘
|
||||
│ └── 변경할 상품
|
||||
├── 주의사항 섹션
|
||||
│ ├── 변경 시 주의사항
|
||||
│ ├── 약정/할부 안내
|
||||
│ └── 요금 변경 안내
|
||||
├── 진행 상황 표시
|
||||
│ ├── 사전 체크 진행 바
|
||||
│ └── 상태 메시지
|
||||
└── 액션 버튼 그룹
|
||||
├── 변경 신청 버튼 (primary)
|
||||
├── 취소 버튼 (secondary)
|
||||
└── 이전 단계 버튼
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 사전 체크 진행 상태 실시간 업데이트
|
||||
- 체크 완료 후 변경 신청 버튼 활성화
|
||||
- 체크 실패 시 오류 메시지 및 재시도 옵션
|
||||
|
||||
---
|
||||
|
||||
### SCR-008: 처리결과 화면
|
||||
**개요**
|
||||
- 목적: 상품 변경 처리 결과 표시
|
||||
- 관련 유저스토리: UFR-PROD-040
|
||||
- 비즈니스 중요도: M/21
|
||||
|
||||
**주요 기능**
|
||||
- 처리 결과 상태 표시
|
||||
- 상세 처리 내용 제공
|
||||
- 후속 액션 안내
|
||||
|
||||
**UI 구성요소**
|
||||
```
|
||||
Header
|
||||
└── 페이지 제목 "처리 결과"
|
||||
|
||||
Main Content
|
||||
├── 결과 상태 카드
|
||||
│ ├── 성공/실패 아이콘 (대형)
|
||||
│ ├── 결과 메시지 (제목)
|
||||
│ └── 상태 설명
|
||||
├── 처리 내용 카드 (성공 시)
|
||||
│ ├── 변경된 상품 정보
|
||||
│ ├── 적용일
|
||||
│ └── 처리 번호
|
||||
├── 실패 사유 카드 (실패 시)
|
||||
│ ├── 실패 원인
|
||||
│ ├── 해결 방법
|
||||
│ └── 고객센터 안내
|
||||
└── 액션 버튼 그룹
|
||||
├── 메인으로 버튼 (primary)
|
||||
├── 다시 시도 버튼 (실패 시)
|
||||
└── 고객센터 연결 (실패 시)
|
||||
```
|
||||
|
||||
**인터랙션**
|
||||
- 결과에 따른 적절한 UI 표시
|
||||
- 성공/실패 상태별 차별화된 컬러 스킴
|
||||
- 추가 액션 버튼 제공
|
||||
|
||||
---
|
||||
|
||||
## 화면간 전환 및 네비게이션
|
||||
|
||||
### 네비게이션 패턴
|
||||
- **계층적 네비게이션**: 뒤로가기 버튼 제공
|
||||
- **브레드크럼**: 깊이 2단계 이상 시 경로 표시
|
||||
- **메인 복귀**: 모든 화면에서 홈 버튼 제공
|
||||
|
||||
### 전환 효과
|
||||
- **페이지 전환**: 부드러운 슬라이드 애니메이션 (300ms)
|
||||
- **모달/팝업**: 페이드 인/아웃 효과 (200ms)
|
||||
- **로딩 상태**: 스켈레톤 UI 또는 스피너
|
||||
|
||||
### URL 구조
|
||||
```
|
||||
/ → 로그인 페이지
|
||||
/main → 메인 화면
|
||||
/bill/menu → 요금조회 메뉴
|
||||
/bill/result → 요금조회 결과
|
||||
/product/menu → 상품변경 메뉴
|
||||
/product/change → 상품변경 화면
|
||||
/product/request → 상품변경 요청
|
||||
/product/result → 처리결과
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 반응형 설계 전략
|
||||
|
||||
### 브레이크포인트
|
||||
- **Mobile**: ~ 767px
|
||||
- **Tablet**: 768px ~ 1023px
|
||||
- **Desktop**: 1024px ~
|
||||
|
||||
### 레이아웃 전략
|
||||
**Mobile First 설계**
|
||||
- 기본: 단일 컬럼 레이아웃
|
||||
- 카드 형태의 콘텐츠 구성
|
||||
- 터치 친화적 버튼 크기 (44px 이상)
|
||||
|
||||
**Tablet**
|
||||
- 2컬럼 레이아웃 (카드 그리드)
|
||||
- 사이드바 네비게이션 고려
|
||||
- 확장된 터치 영역
|
||||
|
||||
**Desktop**
|
||||
- 3컬럼 레이아웃 가능
|
||||
- 고정 폭 컨테이너 (최대 1200px)
|
||||
- 호버 상태 적극 활용
|
||||
|
||||
### 콘텐츠 우선순위
|
||||
1. **핵심 정보**: 항상 우선 표시
|
||||
2. **액션 버튼**: 고정 위치 (하단)
|
||||
3. **부가 정보**: 접기/펼치기로 제어
|
||||
|
||||
---
|
||||
|
||||
## 접근성 보장 방안
|
||||
|
||||
### WCAG 2.1 AA 수준 준수
|
||||
**인식 가능성 (Perceivable)**
|
||||
- 명도 대비 4.5:1 이상 유지
|
||||
- 대체 텍스트 제공 (모든 이미지)
|
||||
- 텍스트 크기 조절 가능 (최대 200%)
|
||||
|
||||
**운용 가능성 (Operable)**
|
||||
- 키보드 접근성 완전 지원
|
||||
- 포커스 순서 논리적 구성
|
||||
- 자동 재생 콘텐츠 없음
|
||||
|
||||
**이해 가능성 (Understandable)**
|
||||
- 명확한 언어 사용
|
||||
- 입력 오류 방지 및 수정 지원
|
||||
- 일관된 네비게이션
|
||||
|
||||
**견고성 (Robust)**
|
||||
- 시맨틱 HTML 사용
|
||||
- ARIA 라벨 적절히 활용
|
||||
- 스크린 리더 호환성
|
||||
|
||||
### 구체적 구현 사항
|
||||
- **폼 요소**: 라벨과 입력 필드 명확한 연결
|
||||
- **버튼**: 명확한 텍스트 또는 aria-label
|
||||
- **오류 메시지**: 명확한 위치와 해결 방법 안내
|
||||
- **로딩 상태**: aria-live를 통한 상태 알림
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화 방안
|
||||
|
||||
### 로딩 성능
|
||||
**초기 로딩**
|
||||
- Critical CSS 인라인 처리
|
||||
- 이미지 지연 로딩 (Lazy Loading)
|
||||
- 폰트 최적화 (font-display: swap)
|
||||
|
||||
**코드 분할**
|
||||
- 페이지별 번들 분리
|
||||
- 동적 import 활용
|
||||
- 트리 쉐이킹 적용
|
||||
|
||||
### 런타임 성능
|
||||
**상태 관리**
|
||||
- 불필요한 리렌더링 방지
|
||||
- 메모이제이션 활용
|
||||
- 가상화 (긴 목록)
|
||||
|
||||
**네트워크 최적화**
|
||||
- API 응답 캐싱
|
||||
- 요청 중복 제거
|
||||
- 압축 및 minify
|
||||
|
||||
### 사용자 경험
|
||||
**로딩 상태**
|
||||
- 스켈레톤 UI 제공
|
||||
- 프로그레스바 표시
|
||||
- 오프라인 상태 대응
|
||||
|
||||
**오류 처리**
|
||||
- 명확한 오류 메시지
|
||||
- 재시도 메커니즘
|
||||
- 폴백 UI 제공
|
||||
|
||||
### 성능 지표 목표
|
||||
- **First Contentful Paint**: < 1.5초
|
||||
- **Largest Contentful Paint**: < 2.5초
|
||||
- **First Input Delay**: < 100ms
|
||||
- **Cumulative Layout Shift**: < 0.1
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경사항 | 작성자 |
|
||||
|------|------|----------|--------|
|
||||
| 1.0 | 2025-01-05 | 초기 UI/UX 설계서 작성 | 박화면 |
|
||||
|
||||
---
|
||||
|
||||
## 검토 사항
|
||||
|
||||
### 유저스토리 매칭 검토 ✅
|
||||
- 총 10개 유저스토리 100% 반영
|
||||
- 화면별 관련 유저스토리 명시
|
||||
- 불필요한 추가 설계 없음
|
||||
|
||||
### 설계 원칙 준수 ✅
|
||||
- 통신요금 관리 서비스 특화 설계
|
||||
- 보안성과 사용성 균형
|
||||
- 접근성 및 성능 고려
|
||||
@@ -0,0 +1,297 @@
|
||||
# 통신요금 관리 서비스 - 유저스토리
|
||||
|
||||
- [통신요금 관리 서비스 - 유저스토리](#통신요금-관리-서비스---유저스토리)
|
||||
- [마이크로서비스 구성](#마이크로서비스-구성)
|
||||
- [유저스토리](#유저스토리)
|
||||
|
||||
---
|
||||
|
||||
## 마이크로서비스 구성
|
||||
1. **Auth** - 사용자 인증 및 인가 관리
|
||||
2. **Bill-Inquiry** - 요금 조회 서비스
|
||||
3. **Product-Change** - 상품 변경 서비스
|
||||
|
||||
---
|
||||
|
||||
## 유저스토리
|
||||
|
||||
```
|
||||
1. Auth 서비스
|
||||
1) 사용자 인증 및 인가
|
||||
UFR-AUTH-010: [사용자 로그인] MVNO 고객으로서 | 나는 통신요금을 관리하기 위해 | 안전하게 로그인하고 싶다.
|
||||
- 시나리오: 고객 로그인
|
||||
MVNO 서비스에 접근한 상황에서 | ID와 Password를 입력하여 로그인 요청을 하면 | 인증이 완료되고 메인 화면이 표시된다.
|
||||
|
||||
[입력 요구사항]
|
||||
- 인증 정보 ID: 고객 식별자 입력
|
||||
- Password: 계정 비밀번호 입력
|
||||
- 자동 로그인: 선택 옵션 제공
|
||||
|
||||
[인증 처리]
|
||||
- 성공: 메인 서비스 화면으로 이동
|
||||
- 실패: "ID 또는 비밀번호를 확인해주세요" 메시지
|
||||
- 5회 연속 실패: 30분간 계정 잠금 안내
|
||||
|
||||
[검증 요구사항]
|
||||
- 계정이 있어야 함
|
||||
- 인증 정보의 정확성 검증
|
||||
- 세션 보안 처리
|
||||
|
||||
- M/5
|
||||
|
||||
---
|
||||
|
||||
UFR-AUTH-020: [사용자 인가] 인증된 고객으로서 | 나는 서비스별 접근 권한을 확인받기 위해 | 화면에 대한 접근권한이 부여되기를 원한다.
|
||||
- 시나리오: 서비스 접근 권한 확인
|
||||
로그인 완료 후 특정 서비스 화면에 접근한 상황에서 | 해당 서비스 이용 권한을 확인하면 | 권한이 있는 경우 서비스 화면이 표시된다.
|
||||
|
||||
[접근 권한 확인]
|
||||
- 요금 조회 서비스 접근권한 확인
|
||||
- 상품 변경 서비스 접근권한 확인
|
||||
- 권한별 메뉴 표시/숨김 처리
|
||||
|
||||
[권한 검증]
|
||||
- 성공: 해당 서비스 화면 제공
|
||||
- 실패: "서비스 이용 권한이 없습니다" 메시지 표시
|
||||
- 접근 권한이 부여되어 있어야 함
|
||||
|
||||
- M/3
|
||||
|
||||
---
|
||||
|
||||
2. Bill-Inquiry 서비스
|
||||
1) 요금 조회
|
||||
UFR-BILL-010: [요금조회 메뉴 접근] MVNO 고객으로서 | 나는 내 통신요금을 확인하기 위해 | 요금 조회 메뉴를 요청하고 싶다.
|
||||
- 시나리오: 요금조회 메뉴 표시
|
||||
인증된 상태에서 | 요금 조회 메뉴를 요청하면 | 요금조회 메뉴가 화면에 표시된다.
|
||||
|
||||
[메뉴 표시 요구사항]
|
||||
- 요금 조회 메뉴 화면 제공
|
||||
- 고객 회선번호 표시
|
||||
- 조회월 선택 옵션 제공
|
||||
- 요금 조회 신청 버튼 활성화
|
||||
|
||||
[접근 권한]
|
||||
- 요금 조회에 대한 접근권한이 부여되어 있어야 함
|
||||
- mvno AP server를 통한 화면 제공
|
||||
|
||||
[오류 처리]
|
||||
- 메뉴 로딩 실패 시: "요금 조회 메뉴 로딩에 실패하였습니다" 메시지
|
||||
|
||||
- M/5
|
||||
|
||||
---
|
||||
|
||||
UFR-BILL-020: [요금조회 신청] MVNO 고객으로서 | 나는 특정 월의 통신요금을 확인하기 위해 | 조회월을 선택하여 요금 조회를 신청하고 싶다.
|
||||
- 시나리오 1: 조회월 미선택 (기본 조회)
|
||||
요금조회 메뉴에서 | 조회월을 입력하지 않고 조회를 신청하면 | 조회시점 기준 당월 청구요금이 조회된다.
|
||||
|
||||
[기본 조회 처리]
|
||||
- 입력 데이터: 회선번호
|
||||
- 조회 대상: 현재 월 청구요금
|
||||
- 결과 확인: 조회시점 기준 당월 청구요금을 확인했다
|
||||
|
||||
- 시나리오 2: 조회월 선택 조회
|
||||
요금조회 메뉴에서 | 특정 조회월을 선택하고 조회를 신청하면 | 해당 조회월의 청구요금이 조회된다.
|
||||
|
||||
[선택 조회 처리]
|
||||
- 입력 데이터: 회선번호, 조회월
|
||||
- 조회 대상: 선택한 월의 청구요금
|
||||
- 결과 확인: 조회월의 청구요금을 확인했다
|
||||
|
||||
[조회 선택 옵션]
|
||||
- 조회월을 선택한다: 특정 월 선택하여 조회
|
||||
- 조회월을 선택하지 않는다: 당월 기준으로 조회
|
||||
|
||||
[처리 결과]
|
||||
- 성공: 요금 조회가 신청되었다
|
||||
- 실패: "요금 조회 신청에 실패하였습니다" 메시지
|
||||
|
||||
- M/8
|
||||
|
||||
---
|
||||
|
||||
UFR-BILL-030: [KOS 요금조회 서비스 연동] 시스템으로서 | 나는 정확한 요금 정보를 제공하기 위해 | KOS의 요금 조회 서비스를 호출하고 응답을 처리하고 싶다.
|
||||
- 시나리오: KOS 요금조회 API 호출
|
||||
요금 조회 요청을 받은 상황에서 | 요금 조회 API를 호출하면 | KOS 요금 조회 서비스의 응답을 받아 처리한다.
|
||||
|
||||
[API 호출 요구사항]
|
||||
- 입력 데이터: 회선번호, 조회월 (선택)
|
||||
- 호출 대상: KOS-Order 시스템
|
||||
- 호출 규격: KOS 요금조회 서비스가 요구하는 규격에 맞게 호출
|
||||
- 응답 처리: KOS 요금 조회 서비스의 응답을 받았다
|
||||
|
||||
[응답 데이터]
|
||||
- 요금조회 결과 정보
|
||||
- 상품명: 현재 이용 중인 요금제
|
||||
- 약정정보: 계약 약정 조건
|
||||
- 청구월: 요금 청구 월
|
||||
- 요금: 청구 요금 금액
|
||||
- 할인정보: 적용된 할인 내역
|
||||
- 사용량: 통화, 데이터 사용량
|
||||
- 예상해지비용: 중도 해지 시 비용
|
||||
- 단말기할부금: 단말기 할부 잔액
|
||||
- 청구/납부정보: 요금 청구 및 납부 상태
|
||||
|
||||
[처리 결과]
|
||||
- 성공: 요금 조회 API 호출에 성공하였다
|
||||
- 실패: 요금 조회 API 호출에 실패하였다
|
||||
|
||||
- M/13
|
||||
|
||||
---
|
||||
|
||||
UFR-BILL-040: [요금조회 결과 전송] 시스템으로서 | 나는 조회된 요금 정보를 고객에게 제공하기 위해 | MVNO AP로 조회 결과를 전송하고 연동 이력을 저장하고 싶다.
|
||||
- 시나리오: 요금조회 결과 화면 출력
|
||||
KOS에서 요금조회 결과를 받은 상황에서 | MVNO AP로 결과를 전송하면 | 요금조회 결과가 화면에 출력되고 전송 이력이 기록된다.
|
||||
|
||||
[결과 전송 처리]
|
||||
- 전송 대상: mvno AP server
|
||||
- 전송 데이터: 요금조회 결과 정보 (상품명, 청구월, 요금 등 전체)
|
||||
- 화면 출력: 요금조회 결과를 화면에 출력한다
|
||||
- 이력 기록: 요금 조회 결과를 전송하고, 전송이력을 기록했다
|
||||
|
||||
[처리 이력 관리]
|
||||
- 요금 조회 요청 이력: MVNO → MP
|
||||
- 요청일시, 회선번호, 조회월
|
||||
- 요금 조회 처리 이력: MP → KOS
|
||||
- 조회요청일시, 조회처리일시, 처리결과
|
||||
- 요청한 회선번호와 조회월 정보
|
||||
|
||||
- M/8
|
||||
|
||||
---
|
||||
|
||||
3. Product-Change 서비스
|
||||
1) 상품 변경
|
||||
UFR-PROD-010: [상품변경 메뉴 접근] MVNO 고객으로서 | 나는 내 요금제를 변경하기 위해 | 상품 변경 요청 메뉴를 요청하고 싶다.
|
||||
- 시나리오: 상품변경 메뉴 표시
|
||||
인증된 상태에서 | 상품 변경 요청 메뉴를 요청하면 | 상품변경 메뉴가 화면에 표시된다.
|
||||
|
||||
[메뉴 표시 요구사항]
|
||||
- 상품 변경 요청 메뉴 화면 제공
|
||||
- 고객 정보 표시 (회선번호, 고객ID)
|
||||
- 현재 상품 정보 표시
|
||||
- 변경 가능한 상품 목록 제공
|
||||
|
||||
[접근 권한]
|
||||
- 화면에 대한 접근권한이 부여되어 있어야 함
|
||||
- mvno AP server를 통한 화면 제공
|
||||
|
||||
[오류 처리]
|
||||
- 메뉴 로딩 실패 시: "상품 변경 요청 메뉴 로딩에 실패하였습니다" 메시지
|
||||
|
||||
- M/5
|
||||
|
||||
---
|
||||
|
||||
UFR-PROD-020: [상품변경 화면 접근] MVNO 고객으로서 | 나는 상품을 선택하고 변경하기 위해 | 상품 변경 화면을 요청하고 싶다.
|
||||
- 시나리오: 상품변경 화면 표시
|
||||
상품변경 메뉴에서 | 상품 변경 화면을 요청하면 | 상품 선택 및 변경 요청이 가능한 화면이 표시된다.
|
||||
|
||||
[화면 표시 요구사항]
|
||||
- 고객정보 및 상품정보 조회 및 표시
|
||||
- 현재 이용 상품 정보 표시
|
||||
- 변경 가능한 상품 목록 제공
|
||||
- 상품 선택 및 변경 요청 기능
|
||||
|
||||
[데이터 조회]
|
||||
- 고객정보 요청: KOS-Order 시스템에서 고객 정보 조회
|
||||
- 상품정보 요청: KOS-Order 시스템에서 상품 정보 조회
|
||||
- 조회 결과: 고객정보가 취득되었다, 상품 정보가 취득되었다
|
||||
|
||||
[처리 결과]
|
||||
- 성공: 상품 변경 화면이 보였다
|
||||
- 실패: "상품 변경 화면 접속에 실패하였습니다" 메시지
|
||||
|
||||
- M/8
|
||||
|
||||
---
|
||||
|
||||
UFR-PROD-030: [상품변경 요청] MVNO 고객으로서 | 나는 더 나은 요금제로 변경하기 위해 | 원하는 상품을 선택하여 변경을 요청하고 싶다.
|
||||
- 시나리오: 상품 선택 및 변경 요청
|
||||
상품변경 화면에서 | 상품(요금제)을 선택 후 상품 변경 요청을 하면 | 변경 요청이 접수되고 사전 체크가 진행된다.
|
||||
|
||||
[변경 요청 입력]
|
||||
- 회선번호: 고객 회선 식별자
|
||||
- 변경 전 상품코드: 현재 이용 중인 상품
|
||||
- 변경 후 상품코드: 변경하려는 상품
|
||||
- 생성일시: 요청 일시
|
||||
|
||||
[상품 변경 사전 체크]
|
||||
- 사전 체크 조건
|
||||
- 현재 판매중인 상품이어야 함
|
||||
- 변경 요청한 사업자에서 판매중인 상품이어야 함
|
||||
- 변경 요청 회선은 사용 중인 상태여야 함 (정지 상태가 아니어야 함)
|
||||
- 사전체크 결과에서 정상(변경가능)으로 응답처리 되어야 함
|
||||
|
||||
[처리 결과]
|
||||
- 성공: 상품 변경이 진행되었다, 상품 사전 체크에 성공하였다
|
||||
- 실패: 상품 사전 체크에 실패하였다
|
||||
|
||||
- M/13
|
||||
|
||||
---
|
||||
|
||||
UFR-PROD-040: [상품변경 처리] 시스템으로서 | 나는 승인된 상품 변경 요청을 완료하기 위해 | KOS 시스템과 연동하여 상품 변경을 처리하고 싶다.
|
||||
- 시나리오 1: 상품 변경 성공 처리
|
||||
사전 체크가 통과된 상황에서 | KOS에 상품 변경 처리를 요청하면 | 상품 변경이 완료되고 완료 결과가 전송된다.
|
||||
|
||||
[성공 처리]
|
||||
- 상품 변경 완료: 상품 변경이 완료되었다
|
||||
- 처리 결과 전송: 변경 후 상품 코드, 상품 변경 처리 결과(정상), 메시지
|
||||
- 화면 출력: 상품 변경 완료 문구를 화면에 출력한다
|
||||
- 이력 기록: 상품 변경 처리하고 연동 이력을 기록한다
|
||||
|
||||
- 시나리오 2: 상품 변경 실패 처리
|
||||
사전 체크는 통과했으나 실제 변경 처리 중 문제가 발생한 상황에서 | 변경 처리가 실패하면 | 실패 사유에 따른 안내 메시지가 표시된다.
|
||||
|
||||
[실패 처리]
|
||||
- 변경 실패: 상품 변경이 실패했다, 상품 변경 요청을 실패하였다
|
||||
- 처리 결과 전송: 변경 후 상품 코드, 상품 변경 처리 결과(실패), 실패 메시지
|
||||
- 화면 출력: 상품 변경에 실패하여 실패 사유에 따라 문구를 화면에 출력한다
|
||||
- 이력 기록: 상품 변경 실패 처리하고 실패 이력을 기록한다
|
||||
|
||||
[처리 이력 관리]
|
||||
- 상품 변경 요청 이력: MVNO → MP
|
||||
- 회선번호, 변경 전 상품코드, 변경 후 상품코드, 생성일시
|
||||
- 상품 변경 처리 이력: MP → KOS
|
||||
- 회선번호, 변경 전/후 상품코드, 처리 결과, 처리 메시지
|
||||
|
||||
- M/21
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
## 데이터 관계
|
||||
```
|
||||
고객 (1) : (N) 요금조회
|
||||
고객 (1) : (N) 상품변경
|
||||
고객정보 - 고객ID, 회선번호, 상품정보
|
||||
상품정보 - 상품코드, 상품명, 가격 정보
|
||||
요청이력 - 요청일시, 처리일시, 처리결과
|
||||
처리이력 - 연동 시스템, 요청/응답 데이터, 처리결과
|
||||
```
|
||||
|
||||
## 주요 기술 고려사항
|
||||
|
||||
### 외부 시스템 연동
|
||||
- **KOS-Order 시스템**: 실제 통신사 백엔드 시스템과의 안정적 연동 필요
|
||||
- **MVNO AP Server**: 프론트엔드 시스템과의 실시간 통신 처리
|
||||
- **Circuit Breaker**: 외부 시스템 장애 시 서비스 가용성 확보
|
||||
|
||||
### 보안 및 인증
|
||||
- **인증/인가**: 고객 정보 보호를 위한 강력한 인증 체계
|
||||
- **데이터 암호화**: 민감한 고객 정보 및 요금 정보 암호화
|
||||
- **세션 관리**: 안전한 세션 처리 및 타임아웃 관리
|
||||
|
||||
### 성능 및 안정성
|
||||
- **응답 시간**: KOS 연동 API의 안정적 응답 시간 확보
|
||||
- **이력 관리**: 모든 요청/처리 이력의 정확한 기록 및 추적
|
||||
- **오류 처리**: 각 단계별 명확한 오류 메시지 및 복구 방안
|
||||
|
||||
### 데이터 일관성
|
||||
- **트랜잭션 처리**: 상품 변경 시 데이터 일관성 보장
|
||||
- **이력 동기화**: 요청/처리 이력의 정확한 동기화
|
||||
- **상태 관리**: 각 요청의 상태 추적 및 관리
|
||||
Reference in New Issue
Block a user