docs: Agent Teams 실행 가이드 + 8개 도메인 spec 추가

00_PROMPTS.md  — Claude Code 실행 프롬프트 1~6
01_DB_SCHEMA.md — V1~V17 Flyway 스키마 + 프로젝트 뼈대
02_COMMON_SPEC.md — ga-common 공통 프레임워크 (모델/예외/MyBatis/AOP/엑셀/공통컴포넌트)
03_CORE_SPEC.md — ga-core VO 설계 원칙 + Mapper 패턴
04_BATCH_SPEC.md — ga-batch 정산 8 Step + DataReceiver/MappingEngine
05_API_SPEC.md — ga-api Controller/Service 표준 패턴 + 엔드포인트 목록
06_FRONTEND_SPEC.md — ga-frontend 13개 화면 + 공통 컴포넌트 사용법
07_INFRA_SPEC.md — Docker/Nginx + README + DEVELOPER_GUIDE
08_DBA_SPEC.md — V13~V15 인덱스/파티셔닝/뷰

실제 구현된 ga-commission-system 기준으로 작성. 코드는 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-10 21:15:13 +09:00
parent 936d376794
commit 035175e895
9 changed files with 2756 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
# 00_PROMPTS — Claude Code 실행 프롬프트 모음
## 사전 준비
### 1) Agent Teams 활성화 (`~/.claude/settings.json`)
```json
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
```
### 2) Git Worktree 격리 (선택)
```bash
git init ga-commission-system && cd ga-commission-system
git commit --allow-empty -m "init"
git worktree add ../ga-wt-common -b feat/common
git worktree add ../ga-wt-core -b feat/core
git worktree add ../ga-wt-batch -b feat/batch
git worktree add ../ga-wt-api -b feat/api
git worktree add ../ga-wt-front -b feat/frontend
git worktree add ../ga-wt-infra -b feat/infra
```
### 3) 본 docs/ 폴더의 모든 파일을 프로젝트 루트에 배치
- `docs/01_DB_SCHEMA.md` ~ `docs/08_DBA_SPEC.md` (8 spec)
- `docs/00_PROMPTS.md` (this file)
> 각 프롬프트 안에 "docs/0N_*.md 읽어줘" 지시가 들어 있어, Claude Code 가
> 자동으로 해당 spec 을 읽고 그대로 구현합니다.
---
## 프롬프트 1: 리더 단독 — 프로젝트 뼈대 + DB
```
GA(법인보험대리점) 수수료 정산 솔루션 프로젝트를 만들어줘.
docs/01_DB_SCHEMA.md 를 읽고 그대로 구현해줘.
핵심 요약:
- Java 17 + Spring Boot 3.2 + MyBatis 3 (JPA 아님!) + PageHelper
- PostgreSQL + Redis + Flyway
- React 18 + Vite + Ant Design 5 + AG Grid Community
- Gradle 멀티모듈: ga-common / ga-core / ga-batch / ga-api / ga-admin / ga-frontend
작업:
1. settings.gradle / build.gradle / 모듈별 build.gradle
2. application.yml (mybatis-config 포함, mapUnderscoreToCamelCase=true)
3. Flyway V1~V17 SQL 전부 작성 (docs/01 의 스키마/초기데이터/인덱스/파티션/뷰/관리자/테스트데이터)
4. 모듈별 빈 패키지 + 진입점 (GaApiApplication 등)
완료 후 ./gradlew build 가 통과해야 함.
```
---
## 프롬프트 2: 리더 단독 — ga-common 공통 프레임워크
```
docs/02_COMMON_SPEC.md 를 읽고 ga-common 모듈을 그대로 구현해줘.
핵심:
- 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선
- 패키지 구조: model / exception / util / annotation / aop / auth / security /
config / mybatis / grid / code / menu / excel / file / system / notification
- 모든 Controller 가 ApiResponse<T> 형태로 응답
- 검색 파라미터는 SearchParam 상속, 페이징은 PageHelper + PageResponse.of()
- @RequirePermission / @DataChangeLog / @ExcelColumn / @Mask 어노테이션 + AOP
- JWT (HS256) + BCrypt + AES-256 CBC 암호화 (EncryptTypeHandler)
- Excel SXSSF + xlsx-streamer 으로 100만건/70만건 처리
- 공통코드 Redis 캐시 (@Cacheable)
- 메뉴 트리 빌더 + 권한 체커
- ga-frontend 의 공통 컴포넌트 (CodeSelect / CodeBadge / SearchForm /
DataTable / DataGrid / PermissionButton / ExcelExportButton 등) 도 함께 작성
완료 후 ./gradlew :ga-common:build 통과 + 다른 팀원이 ga-common 만 의존하면 즉시 시작 가능해야 함.
```
---
## 프롬프트 3: Agent Teams 5명 병렬 스폰
```
GA 수수료 정산 솔루션의 뼈대 + ga-common 이 완성됐어.
Agent Teams 5명을 병렬로 스폰해줘.
각 팀원은 자기 담당 docs 파일을 읽고 그대로 구현한다:
1) core-architect → docs/03_CORE_SPEC.md → ga-core (VO/Mapper/XML)
2) batch-engineer → docs/04_BATCH_SPEC.md → ga-batch (8 Step)
3) api-developer → docs/05_API_SPEC.md → ga-api (Controller/Service)
4) frontend-developer → docs/06_FRONTEND_SPEC.md → ga-frontend (13 화면)
5) dba-architect → docs/08_DBA_SPEC.md → V13~V15 + 인덱스/파티션/뷰
의존성:
- core-architect 가 가장 먼저 (다른 팀원이 VO/Mapper 사용)
- batch / api / frontend 는 core 완료 후 병렬 시작
- dba 는 core 의 Mapper XML 검증 후 인덱스/파티셔닝 적용
각 팀원 작업 규칙:
- 담당 모듈만 수정 (다른 팀 코드 건드리지 말기)
- ga-common 의 표준 패턴(ApiResponse, PageResponse, SearchParam, @RequirePermission,
@DataChangeLog) 을 반드시 따른다
- Mapper XML 은 SELECT * 금지 (필드 명시), 목록은 XxxResp 에 매핑
- 신입이 복붙 후 수정만으로 새 화면 만들 수 있도록 동일 패턴 유지
- 완료 시 리더에게 "[팀명] 완료" 메시지
팀을 만들고 작업을 시작해줘.
```
---
## 프롬프트 4: 리더 단독 — 인프라 + 통합 검증
```
모든 팀원 작업 완료. docs/07_INFRA_SPEC.md 를 읽고 마지막 작업을 해줘.
작업:
1. docker-compose.yml: postgres / redis / api / batch / frontend
2. Dockerfile: 멀티스테이지 (Gradle 빌드 → JRE 17 실행)
3. nginx.conf: React SPA + /api → backend:8080 프록시
4. README.md: 시나리오 A(운영 DB) / B(로컬) / C(Docker) 3 가지 실행 가이드
5. DEVELOPER_GUIDE.md: 신입 개발자 온보딩 (새 화면 만들기 step-by-step)
통합 검증:
- ./gradlew clean build 성공
- cd ga-frontend && npm install && npm run build 성공
- docker-compose up -d --build 전체 기동 후 http://localhost:3000 로그인 동작
- Flyway V1~V17 자동 적용 확인
- 단위 테스트 ./gradlew test 통과
문제 있으면 수정 후 v1.0.0-alpha 태그.
```
---
## 프롬프트 5: 리더 단독 — 단위 테스트 (선택)
```
docs/02_COMMON_SPEC.md / 04_BATCH_SPEC.md 를 참고해 단위 테스트를 추가해줘.
대상:
- ga-common 유틸: MoneyUtilTest / DateUtilTest / MaskUtilTest / EncryptUtilTest
- ga-common 서비스: CommonCodeServiceTest / MenuTreeBuilderTest / PermissionCheckerTest
- ga-batch 핵심 로직: CommissionCalculatorTest / MappingEngineTest
규칙:
- JUnit 5 + Mockito + AssertJ (spring-boot-starter-test 에 포함)
- @Cacheable / @CacheEvict 는 단위 테스트에서 동작 안 함 → 비즈니스 로직만 검증
- Mockito strict stubbing 위반 주의 (@BeforeEach 의 stub 은 모든 테스트가 사용해야 함)
- 모든 테스트 통과 후 커밋
```
---
## 프롬프트 6: 리더 단독 — 운영 DB 배포 (선택)
```
운영 DB(192.168.0.60:55432/trading_ai/ga) 에 V1~V17 마이그레이션 + 테스트 데이터를 적용해줘.
방법:
- Python + pg8000 으로 migrate.py 작성 (저장소 외부에 둔다)
- ga 스키마 존재 여부 확인 후 idempotent guard
- application-trading_ai.yml 프로파일 추가 (currentSchema=ga)
- 실행: ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
확인:
- /api/health 200
- /login admin / admin1234! 로 토큰 발급
- 메뉴 트리 / 공통코드 / 설계사 50명 / 계약 200건 조회 OK
```
---
## 비용 절약 팁
- 리더: Opus / 팀원: Sonnet (하이브리드 모델)
- 5명이 적정. 그 이상은 조율 오버헤드 증가
- 작업 범위 명확 + 서로 독립적일수록 효과적
- `--dangerously-skip-permissions` 사용 시 모든 팀원 동일 권한
+313
View File
@@ -0,0 +1,313 @@
# 01_DB_SCHEMA — DB 스키마 + 프로젝트 뼈대
## 1. 기술 스택
| 영역 | 기술 |
|---|---|
| Backend | Java 17, Spring Boot 3.2.5, Spring Security, Spring Batch |
| ORM | **MyBatis 3** + mybatis-spring-boot-starter:3.0.3 (JPA 아님) |
| 페이징 | pagehelper-spring-boot-starter:2.1.0 |
| DB | PostgreSQL 16 |
| Cache | Redis 7 (선택) |
| Frontend | React 18 + Vite + TS + Ant Design 5 + AG Grid Community + React Query + Zustand |
| Build | Gradle 멀티모듈 (Java 17 toolchain) |
| 기타 | Lombok, MapStruct 1.5.5, Flyway, Springdoc OpenAPI 2.3 |
## 2. 프로젝트 구조 (멀티모듈)
```
ga-commission-system/
├── settings.gradle # 모듈 7개 (ga-common/core/api/batch/admin/frontend)
├── build.gradle # subprojects { Lombok, MapStruct, JUnit5 }
├── gradle.properties
├── ga-common/ # 공통 프레임워크 (라이브러리, bootJar disabled)
├── ga-core/ # 도메인 코어 (VO/Mapper/XML, ga-common 의존)
├── ga-api/ # REST API (executable jar, 8080)
├── ga-batch/ # Spring Batch (executable jar, 8081)
├── ga-admin/ # (현재 빈 모듈, 향후 관리자 전용)
├── ga-frontend/ # Vite 빌드, dist/ → nginx
├── docker-compose.yml
├── Dockerfile
└── docs/ # 본 폴더
```
### 모듈 의존성
- `ga-core``ga-common`
- `ga-api``ga-core` (transitively `ga-common`)
- `ga-batch``ga-core`
- `ga-admin``ga-core` (현재 미사용)
### 핵심 build.gradle 설정
```groovy
// 루트 build.gradle subprojects
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
}
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
compileJava { options.compilerArgs << '-parameters' } // MyBatis 파라미터 이름
// ga-common: 라이브러리 (bootJar disabled, jar enabled)
// ga-api / ga-batch: bootJar enabled, jar disabled
```
### MyBatis 설정 (`ga-common/src/main/resources/mybatis/mybatis-config.xml`)
```xml
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="cacheEnabled" value="false"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="defaultStatementTimeout" value="60"/>
<setting name="jdbcTypeForNull" value="NULL"/>
</settings>
<typeHandlers>
<!-- JsonTypeHandler 만 글로벌. EncryptTypeHandler 는 컬럼별 명시 -->
<typeHandler handler="com.ga.common.mybatis.JsonTypeHandler"/>
</typeHandlers>
</configuration>
```
### application.yml (ga-api)
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/ga
username: ga
password: ga
hikari: { maximum-pool-size: 20 }
flyway:
locations: classpath:db/migration # ga-common 의 V1~V17
baseline-on-migrate: true
mybatis:
config-location: classpath:mybatis/mybatis-config.xml
mapper-locations: classpath*:mapper/**/*.xml
ga:
jwt:
secret: ${JWT_SECRET:replace-me-256-bit-or-longer-secret-key}
access-ttl-min: 120
refresh-ttl-day: 7
encrypt:
key: ${ENCRYPT_KEY:32byte-key-for-aes-256-cbc-aaaa} # 32 byte
```
---
## 3. Flyway 마이그레이션 V1~V17
위치: `ga-common/src/main/resources/db/migration/`
**모든 금액 `DECIMAL(15,2)`, 모든 수수료율 `DECIMAL(7,4)`. 모든 테이블에 인덱스 부여.**
### V1__조직_인사.sql (4 테이블)
```sql
grade (grade_id PK, grade_name, grade_level, grade_group, description, is_active, sort_order)
organization (org_id PK, parent_org_id FK self, org_name, org_type[HQ/BR/TM], org_level, region,
effective_from, effective_to, is_active, created_at, updated_at, created_by)
agent (agent_id PK, org_id FK, grade_id FK, agent_name, resident_no(AES), license_no,
phone, email, bank_code, account_no(AES), status[ACTIVE/LEAVE/SUSPEND],
join_date, leave_date, created_at, updated_at)
agent_org_history(history_id PK, agent_id FK, from_org_id, to_org_id, from_grade_id, to_grade_id,
change_type[TRANSFER/PROMOTE/DEMOTE/JOIN/LEAVE], change_date, change_reason)
```
인덱스: `idx_org_parent`, `idx_org_type(org_type,is_active)`, `idx_agent_org(org_id,status)`,
`idx_agent_lic(license_no)`, `idx_agent_status`.
### V2__상품_계약.sql (3 테이블)
```sql
insurance_company(company_id PK, company_code UQ, company_name, company_type, biz_no,
contact_name, contact_phone, contact_email, is_active)
product (product_id PK, company_id FK, product_code, product_name, insurance_type,
product_group, pay_period_type, is_active, launch_date, end_date)
contract (contract_id PK, agent_id FK, product_id FK, policy_no UQ, contractor_name,
insured_name, premium DECIMAL(15,2), pay_cycle, pay_period, coverage_period,
status, contract_date, effective_date, lapse_date, revival_date, created_at, updated_at)
```
### V3__수수료규정.sql (5 테이블)
```sql
commission_rate (rate_id PK, product_id FK, commission_year, rate_pct DECIMAL(7,4),
pay_method_cond, min_premium, effective_from, effective_to, version)
payout_rule (rule_id PK, grade_id FK, insurance_type, commission_year, payout_pct DECIMAL(7,4),
pay_method_cond, performance_grade, effective_from, effective_to, version)
override_rule (override_id PK, from_grade, to_grade, override_pct DECIMAL(7,4),
calc_type, insurance_type_cond, cap_amount, effective_from, effective_to)
chargeback_rule (cb_rule_id PK, company_code, insurance_type, lapse_month_from, lapse_month_to,
cb_rate DECIMAL(7,4), include_override, installment_allowed, max_installments,
effective_from, effective_to)
exception_type_code (exception_code PK, exception_name, category, direction[PLUS/MINUS],
auto_calc_yn, approval_required, tax_included, description, is_active)
```
### V4__데이터수신.sql (5 테이블)
```sql
company_profile (company_code PK FK, data_format, receive_method, file_encoding, delimiter,
header_row, data_start_row, sheet_name, date_format, amount_format,
api_url, ftp_host, ftp_path, receive_schedule, is_active)
company_field_mapping (mapping_id PK, company_code FK, source_field, source_col_index,
target_table, target_field, data_type, transform_rule, default_value,
is_required, sort_order, version, effective_from)
code_mapping (code_id PK, company_code, code_type, source_code, source_name,
target_code, target_name, is_active)
raw_commission_data (raw_id PK, company_code, batch_id, settle_month, data_type,
raw_content TEXT, parse_status[PENDING/OK/ERROR], mapped_ledger_id,
mapped_table, file_name, row_number, received_at, parsed_at)
parse_error_log (error_id PK, raw_id FK, company_code, error_type, error_field,
error_value, error_message, resolve_status[OPEN/RESOLVED/IGNORED],
resolved_by, resolve_note, resolved_at, created_at)
```
### V5__원장관리.sql (3 테이블, 동일 컬럼)
```sql
recruit_ledger / maintain_ledger / exception_ledger
:
ledger_id PK, contract_id FK, agent_id FK, policy_no, company_code, product_code,
insurance_type, commission_year, premium DECIMAL(15,2), pay_method,
company_rate DECIMAL(7,4), company_amount DECIMAL(15,2),
payout_rate DECIMAL(7,4), agent_amount DECIMAL(15,2),
tax_amount DECIMAL(15,2),
reconcile_status[PENDING/MATCHED/DIFF], reconcile_diff,
advance_yn, advance_amount, chargeback_yn, settle_month, status[CALCULATED/CONFIRMED/HOLD],
version, created_at, updated_at
exception_ledger :
exception_code, approve_status, approver_id, approve_date,
recurring_yn, recurring_until, recurring_parent_id
```
### V6__정산_지급.sql (4 테이블)
```sql
settle_master (settle_id PK, agent_id FK, settle_month,
recruit_total, maintain_total, exception_plus, exception_minus,
override_total, chargeback_total, gross_amount, tax_amount, net_amount,
status[DRAFT/CONFIRMED/HOLD/PAID], calc_date, confirmed_by, confirmed_at,
hold_reason, UNIQUE(agent_id, settle_month))
override_settle(os_id PK, settle_id FK, from_agent_id, to_agent_id, base_amount,
override_pct, override_amount, calc_basis)
chargeback (cb_id PK, contract_id FK, agent_id FK, lapse_month_diff, base_amount,
cb_rate, cb_amount, installment_seq, installment_total, status, created_at)
payment (payment_id PK, settle_id FK, agent_id FK, bank_code, account_no(AES),
amount, transfer_date, status[PENDING/SENT/SUCCESS/FAIL], result_code, result_msg)
```
### V7__배치_감사.sql (4 테이블)
```sql
batch_job_log (job_log_id PK, job_name, job_params, settle_month, status[REQUESTED/RUNNING/
COMPLETED/FAILED], step_name, total_count, success_count, error_count,
error_message, started_at, ended_at, duration_ms)
reconciliation (recon_id PK, company_code, settle_month, expected_amount, actual_amount,
diff_amount, recon_status, resolved_by, resolved_at)
audit_log (audit_id PK, user_id, action_type, target_table, target_id,
before_data JSONB, after_data JSONB, ip_address, user_agent, created_at)
users (user_id PK, login_id UQ, password(BCrypt), user_name, agent_id FK nullable,
email, phone, status[ACTIVE/LOCKED/EXPIRED], last_login_at,
fail_count, password_changed_at, created_at, updated_at)
```
### V8__초기데이터.sql
- grade 6 건 (FC/SFC/SMGR/DM/GM/SVP)
- exception_type_code 10 건 (ADJUST_PLUS / ADJUST_MINUS / BONUS / PENALTY / ...)
### V9__공통코드.sql (2 테이블)
```sql
common_code_group (group_code VARCHAR(20) PK, group_name, description, is_system CHAR(1),
is_active, sort_order, created_at, created_by, updated_at, updated_by)
common_code (code_id PK, group_code FK, code VARCHAR(30), code_name, code_name_en,
code_desc, attr1, attr2, attr3, ref_code, sort_order, is_active,
created_at, created_by, updated_at, updated_by,
UNIQUE(group_code, code))
```
### V10__메뉴관리.sql (5 테이블)
```sql
menu (menu_id PK, parent_menu_id FK self, menu_code UQ, menu_name,
menu_type[DIRECTORY/PAGE/LINK], menu_path, menu_icon, component_path,
menu_level, sort_order, is_visible, is_active, description)
menu_permission (perm_id PK, menu_id FK, perm_code[READ/CREATE/UPDATE/DELETE/APPROVE/
EXPORT/PRINT], perm_name, sort_order, UNIQUE(menu_id, perm_code))
role (role_id PK, role_code UQ, role_name, role_desc, role_level,
is_system, is_active)
role_menu_permission (rmp_id PK, role_id FK, menu_id FK, perm_code, is_granted,
UNIQUE(role_id, menu_id, perm_code))
user_role (ur_id PK, user_id FK, role_id FK, UNIQUE(user_id, role_id))
```
### V11__시스템관리.sql (6 테이블)
```sql
login_log (log_id PK, user_id, login_id, login_type[PWD/SSO], result[SUCCESS/FAIL],
fail_reason, ip_address, user_agent, login_at)
api_access_log (access_id PK, user_id, request_uri, http_method, params, response_code,
response_time_ms, ip_address, created_at) -- 비동기 저장
data_change_log (change_id PK, user_id, menu_code, table_name, record_id,
before_data JSONB, after_data JSONB, action[CREATE/UPDATE/DELETE], created_at)
file_storage (file_id PK, group_id, original_name, stored_name, file_path, file_size,
content_type, download_count, is_deleted, created_by, created_at)
notification (noti_id PK, user_id, noti_type, title, content, link_url, is_read, read_at, created_at)
system_config (config_key VARCHAR(50) PK, config_value, config_group, description,
is_encrypted, updated_by, updated_at)
```
### V12__공통_초기데이터.sql
- 공통코드 18 그룹 + 상세 42 건
(AGENT_STATUS / CONTRACT_STATUS / SETTLE_STATUS / PAY_CYCLE / INSURANCE_TYPE /
ORG_TYPE / GRADE_GROUP / RECONCILE_STATUS / PARSE_STATUS / EXCEPTION_DIRECTION /
APPROVE_STATUS / PAYMENT_STATUS / NOTI_TYPE / FILE_TYPE / DATA_FORMAT /
RECEIVE_METHOD / TRANSFORM_RULE / CHANGE_TYPE)
- 역할 4 건 (`SUPER_ADMIN` / `ADMIN` / `MANAGER` / `AGENT`)
- 대메뉴 10 건 + 시스템설정 13 건
### V13__인덱스최적화.sql
- `recruit_ledger(settle_month, agent_id)`, `recruit_ledger(policy_no)`
- `maintain_ledger(settle_month, agent_id)`, `maintain_ledger(policy_no)`
- `exception_ledger(settle_month, approve_status)`
- `settle_master(settle_month, status)`
- `contract(agent_id, status)`, `contract(policy_no)`
- `raw_commission_data(company_code, settle_month, parse_status)`
- `api_access_log(created_at)`, `data_change_log(created_at, table_name)`
### V14__파티셔닝.sql
- 헬퍼 함수 `create_monthly_partition(table_name, year_month)` 정의
- `recruit_ledger / maintain_ledger / api_access_log / data_change_log`
`settle_month` 또는 `created_at` 기준 RANGE 파티션으로 변환 (PG 13+)
### V15__뷰.sql
```sql
v_agent_monthly_summary (agent_id, agent_name, org_name, settle_month,
recruit_amt, maintain_amt, exception_plus, exception_minus,
override_amt, chargeback_amt, gross, tax, net, status)
v_org_settle_summary (org_id, org_name, settle_month, agent_count, total_gross, total_net)
v_chargeback_risk (contract_id, policy_no, agent_name, lapse_month_diff,
predicted_cb_amount, risk_grade)
```
### V16__관리자_초기데이터.sql
- `users``admin / admin1234!` (BCrypt) + `SUPER_ADMIN` 역할 매핑
- 메뉴 33건 + 메뉴별 권한 150건 + 역할-메뉴-권한 매트릭스 323건
### V17__테스트데이터.sql
- 보험사 5 (SAMSUNG / HANWHA / KYOBO / DB / KB)
- 상품 20 (보험사당 4)
- 조직 7 (본부 1 / 지점 3 / 팀 3) + 설계사 50
- 계약 200 (ACTIVE 190 / LAPSE 10)
- 수수료 규정: commission_rate 100 / payout_rule 120 / override_rule 10 / chargeback_rule 4
---
## 4. 환경변수 / 시크릿
| 키 | 기본값 | 설명 |
|---|---|---|
| `JWT_SECRET` | `replace-me-...` | HS256 256bit+ |
| `ENCRYPT_KEY` | `32byte-key-...` | AES-256 키 (32 byte) |
| `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USERNAME` / `DB_PASSWORD` | localhost/5432/ga/ga/ga | DataSource |
| `REDIS_HOST` / `REDIS_PORT` | localhost / 6379 | 선택 (캐시) |
운영 DB 프로파일 (`application-trading_ai.yml`):
```yaml
spring.datasource:
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: kyu
password: ${DB_PASSWORD}
spring.flyway.schemas: ga
```
+503
View File
@@ -0,0 +1,503 @@
# 02_COMMON_SPEC — ga-common 공통 프레임워크
> 모든 다른 모듈이 의존하는 핵심. 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선.
## 패키지 구조
```
ga-common/src/main/java/com/ga/common/
├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
├── auth/ # JwtTokenProvider, JwtAuthFilter, LoginUser
├── code/ # CommonCodeService/Controller/Mapper (Redis 캐시)
├── config/ # RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
├── exception/ # ErrorCode (enum), BizException, GlobalExceptionHandler
├── excel/ # ExcelService (Export 100만, Import 70만)
├── file/ # FileService/Controller (multipart 업로드/다운로드)
├── grid/ # GridSearchParam, GridResponse (AG Grid 서버사이드)
├── menu/ # MenuService/Controller, MenuTreeBuilder, PermissionChecker
├── model/ # ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
├── mybatis/ # BaseMapper, JsonTypeHandler, EncryptTypeHandler(+Initializer), AuditInterceptor
├── notification/ # NotificationService/Controller
├── security/ # SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandlerImpl
├── system/ # SystemConfigService, DataChangeLogService, ApiAccessLogService, SystemLogController
└── util/ # DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil
ga-common/src/main/resources/
├── mybatis/mybatis-config.xml
└── mapper/common/ # CommonCodeMapper.xml, MenuMapper.xml, FileMapper.xml, SystemConfigMapper.xml
```
build.gradle 핵심: `api 'org.springframework.boot:spring-boot-starter-{web,security,aop,validation,data-redis,cache}'`,
`api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'`,
`api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'`,
`api 'io.jsonwebtoken:jjwt-api:0.12.5'`, `api 'org.apache.poi:poi-ooxml:5.2.5'`,
`api 'com.monitorjbl:xlsx-streamer:2.2.0'`, `api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'`
---
## 1. 공통 모델 (`model/`)
### `ApiResponse<T>` — 모든 API 응답 표준
```java
public class ApiResponse<T> {
private boolean success;
private String code; // "0000" 성공
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> ok(T data) { ... }
public static <T> ApiResponse<T> ok(T data, String msg) { ... }
public static ApiResponse<Void> ok() { ... }
public static <T> ApiResponse<T> fail(ErrorCode e) { ... }
public static <T> ApiResponse<T> fail(String code, String msg) { ... }
}
```
### `PageResponse<T>` — PageHelper 연동
```java
public class PageResponse<T> {
private List<T> list;
private int pageNum, pageSize, pages;
private long total;
public static <T> PageResponse<T> of(List<T> list) {
PageInfo<T> p = new PageInfo<>(list);
// ...
}
}
```
### `SearchParam` — 공통 검색 파라미터
```java
public class SearchParam {
private int pageNum = 1, pageSize = 20;
private String searchType, searchKeyword;
private String startDate, endDate;
private String status;
private String sortField, sortOrder; // ASC / DESC
public void startPage() {
PageHelper.startPage(pageNum, pageSize);
if (sortField != null) PageHelper.orderBy(sortField + " " + sortOrder);
}
}
```
### `TreeNode` — 트리 응답 공통 (메뉴/조직)
```java
public class TreeNode {
private Object id, parentId;
private String name, type;
private int level, sortOrder;
private List<TreeNode> children = new ArrayList<>();
}
```
### `GridSearchParam extends SearchParam` — AG Grid 서버사이드용
```java
public class GridSearchParam extends SearchParam {
private List<Map<String,Object>> filterModel; // AG Grid 필터
private List<Map<String,String>> sortModel; // AG Grid 정렬
public String buildFilterSql() { ... }
}
```
---
## 2. 예외 처리 (`exception/`)
### `ErrorCode` enum
```java
SUCCESS("0000", 200), BAD_REQUEST("E400", 400),
UNAUTHORIZED("E401", 401), FORBIDDEN("E403", 403),
NOT_FOUND("E404", 404), INVALID_PARAMETER("E411", 400),
INTERNAL_ERROR("E500", 500),
TOKEN_EXPIRED("A001", 401), LOGIN_FAIL("A003", 401), ACCOUNT_LOCKED("A004", 403),
ALREADY_CONFIRMED("B001", 400), RULE_VERSION_CONFLICT("B002", 409),
APPROVAL_REQUIRED("B003", 400), SYSTEM_CODE_LOCKED("B008", 400),
DUPLICATE_DATA("B009", 409), BATCH_RUNNING("B010", 409),
FILE_NOT_FOUND("F001", 404), FILE_UPLOAD_FAIL("F002", 500);
```
### `BizException`
```java
@Getter
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
private final String customMessage;
public BizException(ErrorCode e) { ... }
public BizException(ErrorCode e, String msg) { ... }
}
```
### `GlobalExceptionHandler`
- `BizException``ApiResponse.fail(errorCode)`
- `MethodArgumentNotValidException` → 필드별 에러 목록
- `AccessDeniedException` → 403
- `DuplicateKeyException` → "중복된 데이터입니다"
- `Exception` → 500 + log.error
---
## 3. MyBatis 공통 (`mybatis/`)
### `BaseMapper<T>` (선택적 상속)
```java
public interface BaseMapper<T> {
T selectById(Long id);
List<T> selectList(SearchParam param);
int insert(T entity);
int update(T entity);
int deleteById(Long id);
int countByCondition(SearchParam param);
}
```
### `JsonTypeHandler` — JSONB ↔ JsonNode
```java
@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> { ... }
```
### `EncryptTypeHandler` — AES-256 컬럼 암호화
- **중요**: `@Component` 등록하면 mybatis-spring-boot-starter 가
자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠.
- 사용처: `<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>` 형태로 컬럼별 명시.
- `EncryptTypeHandlerInitializer` (`@Configuration`) 가 부팅 시
`EncryptUtil` 을 정적 주입.
- 적용 컬럼: `agent.resident_no`, `agent.account_no`, `payment.account_no`.
### `AuditInterceptor` (Plugin)
- `INSERT` 시 `created_at`, `created_by` 자동
- `UPDATE` 시 `updated_at`, `updated_by` 자동
- 현재 로그인 사용자: `SecurityUtil.currentUserId()`
### 공통 SQL 조각 (`mapper/common/common-sql.xml`)
```xml
<sql id="searchCondition">
<if test="searchKeyword != null and searchKeyword != ''">
<choose>
<when test="searchType == 'name'">AND name LIKE CONCAT('%', #{searchKeyword}, '%')</when>
<when test="searchType == 'code'">AND code = #{searchKeyword}</when>
</choose>
</if>
<if test="startDate != null">AND created_at &gt;= #{startDate}::timestamp</if>
<if test="endDate != null">AND created_at &lt;= #{endDate}::timestamp + interval '1 day'</if>
<if test="status != null and status != ''">AND status = #{status}</if>
</sql>
```
---
## 4. 인증/권한 (`auth/`, `security/`, `aop/`)
### JWT
- `JwtTokenProvider`: HS256, 액세스 2 시간 / 리프레시 7 일
- `JwtAuthFilter`: `Authorization: Bearer <token>` → SecurityContext 에 `LoginUser` 주입
- `LoginUser`: `userId`, `loginId`, `userName`, `roleCodes`, `agentId`
### `SecurityConfig`
- 기본 `permitAll`: `/api/auth/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/api/health`
- 그 외 `authenticated()` + JwtAuthFilter 적용
- 세션 STATELESS, CSRF disable, CORS allow `*` (개발), method-level `@PreAuthorize` 미사용 — `@RequirePermission` 으로 통일
### 어노테이션 + AOP
**`@RequirePermission(menu = "ORG_AGENT", perm = "READ")`**
- `PermissionAspect` 가 `PermissionChecker.hasPermission(userId, menuCode, permCode)` 호출
- `false` → `BizException(ErrorCode.FORBIDDEN)`
**`@DataChangeLog(menu = "RULE", table = "payout_rule")`**
- `DataChangeLogAspect` 가 메서드 실행 전 PK 로 before 조회 → 실행 → after 조회
- `data_change_log` 에 `before_data`/`after_data` JSONB 저장 (비동기)
**`@Mask(MaskType.RRN)` (응답 필드용 어노테이션)**
- `JsonSerializer` 또는 `@JsonComponent` 로 직렬화 시 마스킹
**`ApiLogAspect`**
- 모든 `@RestController` 자동 적용
- `request_uri`, `params` (민감정보 마스킹), `response_code`, `response_time_ms`
- `@Async` 비동기 저장 (`api_access_log`)
---
## 5. 유틸리티 (`util/`)
### `DateUtil`
- `today()` → `"2026-05-10"`
- `getSettleMonth()` → `"202605"`, `getSettleMonth(LocalDate)` 동일
- `getMonthRange("202605")` → `["2026-05-01", "2026-05-31"]`
- `calcMonthDiff(from, to)`, `addMonth("202401", 3)` → `"202404"`
- `parseDate(s, "yyyy-MM-dd")`, `format(date, pattern)`
### `MoneyUtil` (BigDecimal HALF_UP, 소수점 2)
- `add/sub/mul/div`, null 안전
- `pct(amount, ratePct)` → `amount × ratePct / 100`
- `tax(amount)` → 기본 3.3%, `tax(amount, rate)` 사용자 지정
- `fmt`/`fmtDec`/`fmtSign` (콤마 포맷)
- `isZero/isPositive/isNegative`
### `MaskUtil`
- `mask("홍길동", NAME)` → `"홍*동"`
- `mask("010-1234-5678", PHONE)` → `"010-****-5678"` (11자리/10자리 분기, 9자리 이하 원본)
- `mask("880101-1234567", RRN)` → `"880101-*******"`
- `mask("110-123-456789", ACCOUNT)` → `"110-***-******"`
- `mask("aaa@bbb.com", EMAIL)` → `"a**@bbb.com"`
### `EncryptUtil` (`@Component`)
- AES-256/CBC/PKCS5Padding, IV = key 의 앞 16 byte (운영은 별도 관리 권장)
- 키 32 byte 미만이면 0 패딩
- `encrypt(plain)` / `decrypt(b64)` round-trip
- 실패 시 `BizException(ErrorCode.INTERNAL_ERROR)`
### `SecurityUtil`
- `currentUserId()`, `currentLoginUser()`, `hasRole(roleCode)`
---
## 6. 공통코드 (`code/`) — Redis 캐시
```java
@Service @RequiredArgsConstructor
public class CommonCodeService {
@Cacheable(value = "commonCode", key = "#groupCode")
public List<CommonCodeVO> getCodesByGroup(String groupCode) {...}
public String getCodeName(String groupCode, String code) {...}
public void validateCode(String groupCode, String code) // 미존재 시 INVALID_PARAMETER
@CacheEvict(value = "commonCode", key = "#groupCode")
public void refreshCache(String groupCode) {}
@CacheEvict(value = "commonCode", allEntries = true)
public void refreshAll() {}
// 그룹 CRUD: 시스템 그룹(is_system='Y')은 update/delete 시 SYSTEM_CODE_LOCKED
// 상세 CRUD: 동일 (group, code) 중복 시 DUPLICATE_DATA
// 시스템 그룹 코드 update 시 code/groupCode 변경 무시
}
```
REST: `GET /api/common/codes/{groupCode}` (캐시 적중), `POST /api/common/codes/groups`,
`PUT/DELETE /api/common/codes/groups/{groupCode}`, 상세 코드 동일.
---
## 7. 메뉴 / 권한 (`menu/`)
### `MenuTreeBuilder` (정적 유틸)
```java
public static List<MenuResp> build(List<MenuVO> flat,
Map<Long, List<String>> permissionsByMenuId)
// - parent_menu_id null → root
// - 부모 미존재 (고아) → root 로 끌어올림
// - sortOrder 로 재귀 정렬
// - permissionsByMenuId null 이면 권한 주입 스킵
```
### `PermissionChecker`
```java
@Component @RequiredArgsConstructor
public class PermissionChecker {
@Cacheable(value = "userPerm",
key = "T(java.lang.String).format('%d_%s_%s', #userId, #menuCode, #permCode)",
unless = "#result == false")
public boolean hasPermission(Long userId, String menuCode, String permCode) {
if (userId == null) return false;
return menuMapper.hasPermission(userId, menuCode, permCode) > 0;
}
}
```
### `MenuService`
- `getMyMenus(userId)` → 사용자 권한 기반 메뉴 트리 (CTE 재귀)
- `getMenuTree()` → 전체 메뉴 트리 (관리자용)
REST: `GET /api/menus/my`, `GET /api/menus/tree`, 메뉴 CRUD + 권한 매트릭스.
---
## 8. 엑셀 엔진 (`excel/`)
성능 목표: **다운로드 100만건 < 2분 / 업로드 70만건 < 3분**
### `@ExcelColumn` 어노테이션
```java
@Target(FIELD) @Retention(RUNTIME)
public @interface ExcelColumn {
String header();
int order();
int width() default 15;
String format() default ""; // "#,##0", "yyyy-MM-dd"
String codeGroup() default ""; // 자동 코드명 변환
boolean masked() default false; // 다운로드 시 마스킹
String defaultValue() default "";
boolean required() default false;
}
```
### `ExcelService`
**Export — `SXSSFWorkbook(100)` + `MyBatis Cursor`**
```java
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier, HttpServletResponse response) {
// 1) SXSSFWorkbook (메모리 100행만 유지, gzip 임시파일)
// 2) ExcelColumnParser.parse(clazz) → 헤더 자동 생성
// 3) Cursor 로 1건씩 읽어 Row 생성
// 4) 10만건마다 progress 로그
// 5) workbook.write(response.getOutputStream()), workbook.dispose()
}
```
**Import — `xlsx-streamer (StreamingReader)` + 배치 INSERT**
```java
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
int batchSize, Consumer<List<T>> batchConsumer) {
// 1) StreamingReader (rowCacheSize 100, bufferSize 4KB)
// 2) 1행 헤더 스킵
// 3) ExcelRowParser.parse(row, clazz) → VO
// 4) Bean Validation → 실패 시 ExcelErrorRow 누적
// 5) batchSize(1000) 마다 batchConsumer.accept(batch)
// 6) 잔여분 flush
// 7) 에러 있으면 에러 엑셀 생성 → result.errorFileId
}
```
**`ExcelImportResult`**: `totalCount`, `successCount`, `errorCount`, `errorFileId`,
`List<ExcelErrorRow>` (처음 100건).
**Template Download**:
`exportTemplate(fileName, clazz, response)` — 빈 헤더 + `codeGroup` 컬럼에
드롭다운 유효성검사 + `required=true` 헤더 빨간색.
---
## 9. 파일 / 알림 / 시스템관리
### `FileService`
- 업로드: `MultipartFile` → `file_storage` 적재, `groupId` 로 그룹화, 최대 100MB
- 다운로드: `download_count` 증가, `Content-Disposition` 한글 파일명 인코딩
- 삭제: 논리 삭제 (`is_deleted='Y'`)
REST: `POST /api/files/upload` (`groupId` param), `GET /api/files/{fileId}`,
`DELETE /api/files/{fileId}`, `GET /api/files?groupId=...`
### `NotificationService`
- 생성/조회/읽음 처리, 미읽음 카운트 (`/api/notifications/unread-count`)
- 발송은 비동기 (Redis pub/sub 또는 `@Async`)
### `SystemConfigService`
- key-value 설정. `is_encrypted='Y'` 인 값은 응답 시 마스킹.
- Cache 적용 가능 (`commonCode` 와 동일 패턴)
### 로그 서비스 (비동기)
- `ApiAccessLogService.save(...)` — `api_access_log` 적재
- `DataChangeLogService.save(...)` — `data_change_log` JSONB 적재
- 둘 다 `@Async("taskExecutor")` (`AsyncConfig`)
REST: `/api/system/logs/login`, `/api/system/logs/api`, `/api/system/logs/changes`
(`/api/system/config`, `/api/system/config/{key}` PUT)
---
## 10. ga-frontend 공통 (함께 작성)
### 폴더 구조
```
ga-frontend/src/
├── api/ # request.ts (axios) + 도메인별 함수
├── components/
│ ├── common/ # 본 spec 의 공통 컴포넌트
│ └── biz/ # 업무 재사용
├── hooks/ # useCommonCodes, useMenuTree, usePermission, useSearchParam
├── layouts/ # MainLayout
├── pages/ # 화면 (06_FRONTEND_SPEC.md)
├── router/ # 동적 라우터
├── stores/ # Zustand (auth, menu, notification)
├── types/ # TS 타입
└── utils/ # formatMoney, formatDate
```
### `api/request.ts`
```typescript
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '' });
api.interceptors.request.use(c => {
const t = localStorage.getItem('accessToken');
if (t) c.headers.Authorization = `Bearer ${t}`;
return c;
});
api.interceptors.response.use(r => r, err => {
if (err.response?.status === 401) { localStorage.removeItem('accessToken'); location.href = '/login'; }
if (err.response?.status === 403) message.error('권한이 없습니다');
return Promise.reject(err);
});
export type ApiResponse<T> = { success: boolean; code: string; message: string; data: T; };
export type PageResponse<T> = { list: T[]; pageNum: number; pageSize: number; total: number; pages: number; };
```
### 공통 훅 (`hooks/`)
- `useCommonCodes(groupCode)` — React Query 캐시 (`staleTime: 10분`)
- `useMenuTree()` — 사용자 메뉴 트리
- `usePermission(menuCode)` — `{ canRead, canCreate, canUpdate, canDelete, canApprove, canExport }`
- `useSearchParam(defaults)` — URL 동기화 + 페이징
- `useGridApi()` — AG Grid 서버사이드 datasource
### 공통 컴포넌트 (`components/common/`)
**데이터 표시**
- `<CodeSelect groupCode="AGENT_STATUS" />` — Ant Select + 공통코드
- `<CodeRadio groupCode="PAY_CYCLE" />`
- `<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />` — 색 자동
- 초록: CONFIRMED/APPROVED/ACTIVE/PAID
- 파랑: PENDING/CALCULATED
- 빨강: HOLD/SUSPEND/REJECTED
- 회색: DRAFT/NEW
- `<MonthPicker />` — 정산월
- `<MoneyText value={amt} />` — 양수 검정 / 음수 빨강 / 콤마 / null → "-"
**검색 / 목록**
- `<SearchForm conditions={[ {type:'text'|'code'|'dateRange'|'month', name, label, ...} ]} onSearch onReset />`
- `<DataTable>` — Ant Design Table 래퍼 (단순 목록, 페이징, 행 클릭)
- `<DataGrid>` — AG Grid Community 래퍼 (서버사이드 페이징/정렬/필터, 셀 편집,
컬럼 고정, 합계행 `pinnedBottomRow`, 엑셀 내보내기)
**액션**
- `<PermissionButton permCode="CREATE">등록</PermissionButton>`
- `<ExcelExportButton url="/api/.../export" params fileName showProgress maxRows={1000000} />`
- `<ExcelImportButton url=... templateUrl=... onComplete maxSize={100} />`
- `<FileUpload groupId="settle-202605" maxCount={5} />`
- `<AuditTrail table="payout_rule" recordId={id} />` — 변경이력 타임라인
### 동적 라우터 (`router/`)
- 로그인 → `GET /api/menus/my` → 메뉴 트리
- `menu.component_path` 기반 `React.lazy()` 동적 import
- 좌측 Sider 자동 렌더링, 권한 없는 URL → 403 페이지
### 레이아웃 (`layouts/MainLayout.tsx`)
- Ant Design Layout (Sider + Header + Content)
- Header: 알림 벨 (숫자 뱃지), 사용자 드롭다운, 정산월 표시
- Sider: 동적 메뉴 트리 (접기/펼치기, 아이콘)
- Content: Breadcrumb + 페이지
---
## 11. 단위 테스트 가이드 (선택)
`spring-boot-starter-test` 가 JUnit5 + Mockito + AssertJ 모두 포함.
대상:
- `MoneyUtilTest` (10) — pct/tax/mul/div HALF_UP, null 안전
- `DateUtilTest` (10) — 정산월 범위, 윤년/평년, 월 차이/가산
- `MaskUtilTest` (7) — NAME/PHONE/RRN/ACCOUNT/EMAIL
- `EncryptUtilTest` (6) — round-trip, 한글, 짧은 키 패딩
- `MenuTreeBuilderTest` (6) — 트리 빌드, 정렬, 고아 노드
- `PermissionCheckerTest` (4) — userId null 단락, mapper 위임
- `CommonCodeServiceTest` (14) — 그룹·상세 CRUD, 시스템 코드 잠금
주의: `@Cacheable`/`@CacheEvict` 는 단위 테스트(직접 인스턴스 호출)에서 동작하지 않음 — 비즈니스 로직만 검증.
Mockito strict stubbing → `@BeforeEach` 의 stub 은 모든 테스트가 사용해야 함.
+345
View File
@@ -0,0 +1,345 @@
# 03_CORE_SPEC — ga-core (도메인 VO + Mapper + SQL)
> 22 개 테이블의 용도별 VO + Mapper Interface + Mapper XML.
> ga-common 의 표준 패턴(`SearchParam`, `PageResponse`, `BaseMapper`) 을 사용한다.
## 패키지 구조
```
ga-core/src/main/java/com/ga/core/
├── enums/ # AgentStatus, ContractStatus, SettleStatus 등
├── mapper/
│ ├── org/ # GradeMapper, AgentMapper
│ ├── product/ # InsuranceCompanyMapper, ProductMapper, ContractMapper
│ ├── rule/ # RuleMapper (5 테이블 통합)
│ ├── receive/ # ReceiveMapper (5 테이블 통합)
│ ├── ledger/ # RecruitLedgerMapper, MaintainLedgerMapper, ExceptionLedgerMapper
│ ├── settle/ # SettleMasterMapper, OverrideSettleMapper, ChargebackMapper, PaymentMapper
│ ├── batch/ # BatchJobLogMapper, ReconciliationMapper
│ └── user/ # UserMapper, RoleMapper
├── vo/
│ ├── org/ # GradeVO, OrganizationVO/Resp, AgentVO/Resp/SaveReq/SearchParam/ExcelVO, AgentOrgHistoryVO
│ ├── product/ # InsuranceCompanyVO, ProductVO/Resp, ContractVO/Resp/SaveReq/SearchParam
│ ├── rule/ # CommissionRateVO, PayoutRuleVO, OverrideRuleVO, ChargebackRuleVO, ExceptionTypeCodeVO
│ ├── receive/ # CompanyProfileVO, CompanyFieldMappingVO, CodeMappingVO, RawCommissionDataVO, ParseErrorLogVO
│ ├── ledger/ # LedgerVO, LedgerResp, LedgerSearchParam, LedgerExcelVO (3 테이블 공유)
│ ├── settle/ # SettleMasterVO/Resp/SearchParam, OverrideSettleVO, ChargebackVO, PaymentVO
│ ├── batch/ # BatchJobLogVO, ReconciliationVO
│ └── user/ # UserVO/Resp/SaveReq/SearchParam, RoleVO
└── mapstruct/ # AgentMapstruct, ContractMapstruct (SaveReq ↔ VO)
ga-core/src/main/resources/mapper/
├── org/ # GradeMapper.xml, AgentMapper.xml
├── product/ # ContractMapper.xml, ProductMapper.xml, InsuranceCompanyMapper.xml
├── rule/ # RuleMapper.xml
├── receive/ # ReceiveMapper.xml
├── ledger/ # RecruitLedgerMapper.xml, MaintainLedgerMapper.xml, ExceptionLedgerMapper.xml
├── settle/ # SettleMasterMapper.xml, PaymentMapper.xml, OverrideSettleMapper.xml, ChargebackMapper.xml
├── batch/ # BatchJobLogMapper.xml
└── user/ # UserMapper.xml, RoleMapper.xml
```
build.gradle: `implementation project(':ga-common')`.
---
## 1. VO 설계 원칙 ★ 핵심
> **VO 는 테이블 1:1 이 아니라, 하나의 업무 기능당 용도별 3~4 개를 만든다.**
| VO 종류 | 용도 | 네이밍 예 | 특징 |
|---|---|---|---|
| `XxxVO` | 테이블 1:1 매핑 | `AgentVO` | INSERT/UPDATE 전용, 모든 컬럼 |
| `XxxResp` | API 응답 | `AgentResp` | 조인 결과 포함 (조직명/직급명/집계) — 화면 표시 전용 |
| `XxxSaveReq` | API 요청 | `AgentSaveReq` | 사용자 입력 필드만 + `@Valid` (ID/상태/일시 없음) |
| `XxxSearchParam` | 검색 조건 | `AgentSearchParam` | `SearchParam` 상속, 화면별 필터 |
| `XxxExcelVO` | 엑셀 전용 | `AgentExcelVO` | `@ExcelColumn` 어노테이션, 목록과 다른 구성 |
### 예시: 설계사 (`org/`)
```java
// AgentVO — 테이블 1:1
@Data public class AgentVO {
private Long agentId, orgId, gradeId;
private String agentName, residentNo, licenseNo, phone, email, bankCode, accountNo, status;
private LocalDate joinDate, leaveDate;
private LocalDateTime createdAt, updatedAt;
}
// AgentResp — 조인 결과 (화면 표시용)
@Data public class AgentResp {
private Long agentId, orgId, gradeId;
private String agentName, status;
private String statusName; // 공통코드 변환
private String orgName, gradeName;// 조인
private int contractCount; // 집계
private BigDecimal totalAmt; // settle_master 집계
private String phone; // 마스킹
private String licenseNo;
private LocalDate joinDate;
}
// AgentSaveReq — 등록/수정 입력
@Data public class AgentSaveReq {
@NotBlank private String agentName;
@NotNull private Long orgId;
@NotNull private Long gradeId;
private String licenseNo, phone, email, bankCode;
private String residentNo, accountNo; // 서버에서 EncryptTypeHandler 로 암호화
private LocalDate joinDate;
public AgentVO toVO() { ... MapStruct ... }
}
// AgentSearchParam — 검색
@Data
public class AgentSearchParam extends SearchParam {
private Long orgId, gradeId;
private String licenseNo;
// status 는 SearchParam 에 이미 있음
}
// AgentExcelVO — 엑셀 다운로드 전용
public class AgentExcelVO {
@ExcelColumn(header = "설계사번호", order = 1) private Long agentId;
@ExcelColumn(header = "성명", order = 2) private String agentName;
@ExcelColumn(header = "조직", order = 3) private String orgName;
@ExcelColumn(header = "직급", order = 4) private String gradeName;
@ExcelColumn(header = "상태", order = 5, codeGroup = "AGENT_STATUS") private String status;
@ExcelColumn(header = "주민번호", order = 6, masked = true) private String residentNo;
}
```
### Map 사용이 적절한 경우 (VO 대신)
- 통계/리포트 (동적 컬럼)
- 대시보드 집계 (1회성)
- 동적 피벗 결과
- 공통코드 캐시 (key-value)
---
## 2. Mapper Interface 패턴
```java
@Mapper
public interface AgentMapper {
// 조회
AgentResp selectDetailById(@Param("agentId") Long agentId);
AgentVO selectById(@Param("agentId") Long agentId);
List<AgentResp> selectList(AgentSearchParam param);
Cursor<AgentExcelVO> selectCursorForExcel(AgentSearchParam param); // ★ 엑셀 100만건
// 검증
int existsByLicenseNo(@Param("licenseNo") String licenseNo);
// CUD
int insert(AgentVO vo);
int update(AgentVO vo);
int updateStatus(@Param("agentId") Long id, @Param("status") String status);
int updateOrgGrade(@Param("agentId") Long id, @Param("orgId") Long o, @Param("gradeId") Long g);
// 이력
List<AgentOrgHistoryVO> selectHistory(@Param("agentId") Long agentId);
int insertHistory(AgentOrgHistoryVO vo);
}
```
### `BaseMapper<T>` 상속 (선택)
- 단순 CRUD 만 필요한 Mapper 는 `extends BaseMapper<XxxVO>` 로 짧게.
- 복잡한 조인/집계 가 필요하면 직접 정의.
---
## 3. Mapper XML 패턴 (필수 규칙)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.org.AgentMapper">
<!-- 컬럼 암호화는 명시적으로 -->
<resultMap id="AgentVoMap" type="com.ga.core.vo.org.AgentVO">
<id property="agentId" column="agent_id"/>
<result property="orgId" column="org_id"/>
<result property="gradeId" column="grade_id"/>
<result property="agentName" column="agent_name"/>
<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
<result property="accountNo" column="account_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
<!-- 나머지는 mapUnderscoreToCamelCase 자동 -->
</resultMap>
<resultMap id="AgentRespMap" type="com.ga.core.vo.org.AgentResp">
<id property="agentId" column="agent_id"/>
<result property="orgName" column="org_name"/>
<result property="gradeName" column="grade_name"/>
<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
</resultMap>
<!-- 목록: Resp 매핑, 조인 포함 -->
<select id="selectList" parameterType="com.ga.core.vo.org.AgentSearchParam"
resultMap="AgentRespMap">
SELECT a.agent_id, a.agent_name, a.status, a.phone, a.license_no, a.join_date,
o.org_name, g.grade_name,
(SELECT COUNT(*) FROM contract c WHERE c.agent_id = a.agent_id) AS contract_count
FROM agent a
JOIN organization o ON a.org_id = o.org_id
JOIN grade g ON a.grade_id = g.grade_id
WHERE 1=1
<if test="orgId != null"> AND a.org_id = #{orgId}</if>
<if test="gradeId != null"> AND a.grade_id = #{gradeId}</if>
<if test="status != null"> AND a.status = #{status}</if>
<if test="licenseNo != null"> AND a.license_no = #{licenseNo}</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
</if>
ORDER BY a.agent_id DESC
</select>
<!-- INSERT: VO 만 받음, 조인 필드는 절대 사용 X -->
<insert id="insert" parameterType="com.ga.core.vo.org.AgentVO"
useGeneratedKeys="true" keyProperty="agentId">
INSERT INTO agent (org_id, grade_id, agent_name, resident_no, license_no, phone, email,
bank_code, account_no, status, join_date)
VALUES (#{orgId}, #{gradeId}, #{agentName},
#{residentNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
#{licenseNo}, #{phone}, #{email}, #{bankCode},
#{accountNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
#{status}, #{joinDate})
</insert>
<!-- 엑셀용 Cursor (절대 List 로 받지 말 것) -->
<select id="selectCursorForExcel" resultType="com.ga.core.vo.org.AgentExcelVO">
SELECT a.agent_id, a.agent_name, o.org_name, g.grade_name, a.status,
a.resident_no
FROM agent a JOIN organization o ON a.org_id = o.org_id
JOIN grade g ON a.grade_id = g.grade_id
ORDER BY a.agent_id
</select>
</mapper>
```
### 필수 규칙
1. **`SELECT *` 금지** — 모든 컬럼 명시
2. **목록 SELECT 는 XxxResp 에 매핑** (조인 포함)
3. **INSERT/UPDATE 는 XxxVO 의 필드만** 사용
4. **암호화 컬럼은 `typeHandler` 명시** (resident_no, account_no, payment.account_no)
5. **동적 WHERE**`<if>` / `<choose>` 적극 사용
6. **신입 학습용 주석** — 복잡한 쿼리에 한글 주석
7. **CTE 재귀** — 조직 트리 (organization), 메뉴 트리 (menu)
### CTE 재귀 예시 (organization)
```xml
<select id="selectTree" resultType="com.ga.core.vo.org.OrganizationResp">
WITH RECURSIVE org_tree AS (
SELECT org_id, parent_org_id, org_name, org_type, org_level, 1 AS depth
FROM organization WHERE parent_org_id IS NULL AND is_active = 'Y'
UNION ALL
SELECT o.org_id, o.parent_org_id, o.org_name, o.org_type, o.org_level, t.depth + 1
FROM organization o JOIN org_tree t ON o.parent_org_id = t.org_id
WHERE o.is_active = 'Y'
)
SELECT org_id, parent_org_id, org_name, org_type, org_level
FROM org_tree ORDER BY org_level, org_name
</select>
```
---
## 4. 모듈별 Mapper 요약
### `org/`
- **`GradeMapper`** : selectAll, selectById, insert/update/delete
- **`AgentMapper`** : (위 예시 — 22 개 메서드)
### `product/`
- **`InsuranceCompanyMapper`** : 5 개 보험사 CRUD
- **`ProductMapper`** : 보험사별 상품, insurance_type 별 통계
- **`ContractMapper`** : 검색 (agent/product/status), `existsByPolicyNo`, `countByAgent(agentId, status)`
### `rule/` — `RuleMapper` (단일 인터페이스, 5 테이블 통합)
```java
// 핵심 조회 (배치 계산에서 호출)
BigDecimal findCompanyRate(@Param("productId") Long, @Param("year") Integer,
@Param("baseDate") LocalDate);
BigDecimal findPayoutRate(@Param("gradeId") Long, @Param("insuranceType") String,
@Param("year") Integer, @Param("baseDate") LocalDate);
BigDecimal findChargebackRate(@Param("companyCode") String, @Param("insuranceType") String,
@Param("lapseMonth") Integer, @Param("baseDate") LocalDate);
List<OverrideRuleVO> selectOverrideRules();
List<ExceptionTypeCodeVO> selectExceptionCodes();
// + 5 개 테이블 각각의 CRUD
```
`effective_from <= baseDate < effective_to` 조건 (NULL = 무한). 같은 base 에 여러 행이면 `version DESC` 최신.
### `receive/` — `ReceiveMapper` (5 테이블 통합)
```java
List<CompanyFieldMappingVO> selectFieldMappings(companyCode, targetTable);
String findTargetCode(companyCode, codeType, sourceCode);
int insertRaw(RawCommissionDataVO);
int insertRawBatch(List<RawCommissionDataVO>);
int updateRawStatus(rawId, parseStatus, mappedLedgerId, mappedTable);
int insertError(ParseErrorLogVO);
int resolveError(errorId, status, note, userId);
```
### `ledger/` — 3 개 Mapper (구조 동일)
- `RecruitLedgerMapper`, `MaintainLedgerMapper`, `ExceptionLedgerMapper`
- 모두 `LedgerVO` 사용 (테이블 컬럼 동일)
- 핵심: `selectList(LedgerSearchParam)`, `selectCursorForExcel(...)`,
`batchInsert(List<LedgerVO>)`, `aggregateByAgent(settleMonth)`,
`sumByAgentMonth(agentId, settleMonth)`, `deleteBySettleMonth(settleMonth)`
- `ExceptionLedgerMapper` 추가: `approve(ledgerId, approverId)`, `reject(ledgerId, reason)`
### `settle/`
- **`SettleMasterMapper`** : `upsert(SettleMasterVO)` (PG `ON CONFLICT(agent_id, settle_month) DO UPDATE`),
`confirm(settleId, userId)`, `hold(settleId, reason)`, `selectMonthlySummary(month)`
- **`OverrideSettleMapper`** : 정산별 오버라이드 내역
- **`ChargebackMapper`** : 환수 내역, 분할 회차 (`installment_seq`)
- **`PaymentMapper`** : 이체 상태 추적, `account_no` AES 암호화
### `batch/`
- **`BatchJobLogMapper`** : `request(jobName, settleMonth)`, `start/complete/fail`,
`selectLatest(jobName)`, `existsRunning(jobName)`
### `user/`
- **`UserMapper`** : `selectByLoginId`, `selectMyMenus`, BCrypt 비밀번호 검증, 잠금/실패 카운트
- **`RoleMapper`** : 역할 목록, 사용자-역할 매핑, 역할-메뉴-권한 매트릭스
---
## 5. Enum + MapStruct
### Enum (코드그룹 대응)
```java
public enum AgentStatus { ACTIVE, LEAVE, SUSPEND;
public boolean isActive() { return this == ACTIVE; }
}
public enum ContractStatus { ACTIVE, LAPSE, REVIVED, EXPIRED, CANCELED; }
public enum SettleStatus { DRAFT, CONFIRMED, HOLD, PAID; }
public enum ParseStatus { PENDING, OK, ERROR; }
public enum ApproveStatus { PENDING, APPROVED, REJECTED; }
```
VO 의 String 필드는 enum 으로 받지 말고 **String 그대로** 둔다 (DB 와 1:1, MyBatis 호환성).
서비스 레이어에서 enum 으로 변환해서 사용.
### MapStruct (`mapstruct/`)
```java
@Mapper(componentModel = "spring")
public interface AgentMapstruct {
AgentVO toVO(AgentSaveReq req);
@Mapping(target = "createdAt", ignore = true)
void update(@MappingTarget AgentVO target, AgentSaveReq req);
}
```
`SaveReq → VO` 변환 정도만 사용. `Resp` 는 SQL ResultMap 이 채우므로 MapStruct 불필요.
---
## 6. 테스트 시나리오 (선택)
`ga-core` 의 Mapper XML 검증은 통상 H2 호환 안되어 **Testcontainers PostgreSQL** 또는
운영 DB 의 read-only 트랜잭션으로 통합 테스트. (현재 미구현, 향후 작업.)
+284
View File
@@ -0,0 +1,284 @@
# 04_BATCH_SPEC — ga-batch (정산 배치 8 Step)
> Spring Batch 기반 월별 정산 잡. ga-core 의 Mapper 를 사용. 별도 실행 모듈 (포트 8081).
## 패키지 구조
```
ga-batch/src/main/java/com/ga/batch/
├── GaBatchApplication.java # @SpringBootApplication, scanBasePackages = "com.ga"
├── config/
│ └── BatchConfig.java # MonthlySettlementJob 정의
├── controller/
│ └── JobLauncherController.java # POST /run, GET /status
└── settlement/
├── SettlementContext.java # @JobScope 공유 컨텍스트
├── calc/
│ └── CommissionCalculator.java # 모집/유지 수수료 계산기
├── receive/
│ ├── DataReceiver.java # 전략 패턴 인터페이스
│ └── ManualReceiver.java # 기본 구현 (수동 업로드)
├── transform/
│ └── MappingEngine.java # raw → 표준 원장
└── step/
├── ReceiveDataStep.java # Step 1
├── TransformDataStep.java # Step 2
├── ReconcileStep.java # Step 3
├── CalcRecruitStep.java # Step 4
├── CalcMaintainStep.java # Step 5
├── ChargebackExceptionStep.java # Step 6
├── CalcOverrideStep.java # Step 7
└── AggregateStep.java # Step 8
```
build.gradle:
```groovy
apply plugin: 'org.springframework.boot'
bootJar { archiveFileName = 'ga-batch.jar'; mainClass = 'com.ga.batch.GaBatchApplication' }
jar.enabled = false
dependencies {
implementation project(':ga-core')
implementation 'org.springframework.boot:spring-boot-starter-batch'
}
```
`application.yml`:
```yaml
server.port: 8081
spring:
batch:
jdbc:
initialize-schema: always # BATCH_JOB_* 메타테이블
job: { enabled: false } # 자동 실행 끄기, 컨트롤러로만 트리거
```
---
## 1. SettlementContext (`@JobScope`)
```java
@Component @JobScope @Getter @Setter
public class SettlementContext {
@Value("#{jobParameters['settleMonth']}") private String settleMonth; // "202605"
@Value("#{jobParameters['companyCode']}") private String companyCode; // 선택 (없으면 전체)
@Value("#{jobParameters['jobLogId']}") private Long jobLogId;
// Step 간 공유 카운트
private int receiveCount, transformOk, transformErr, reconcileOk, reconcileDiff;
private int recruitCount, maintainCount, chargebackCount, exceptionCount, overrideCount;
private int aggregateCount;
}
```
---
## 2. Job 정의 (`BatchConfig`)
```java
@Configuration @RequiredArgsConstructor
public class BatchConfig {
private final JobRepository jobRepo;
private final PlatformTransactionManager txMgr;
@Bean
public Job monthlySettlementJob(
ReceiveDataStep s1, TransformDataStep s2, ReconcileStep s3,
CalcRecruitStep s4, CalcMaintainStep s5,
ChargebackExceptionStep s6, CalcOverrideStep s7, AggregateStep s8) {
return new JobBuilder("monthlySettlementJob", jobRepo)
.start(toStep("receive", s1))
.next(toStep("transform", s2))
.next(toStep("reconcile", s3))
.next(toStep("calcRecruit", s4))
.next(toStep("calcMaintain", s5))
.next(toStep("chargeback", s6))
.next(toStep("calcOverride", s7))
.next(toStep("aggregate", s8))
.build();
}
private Step toStep(String name, Tasklet t) {
return new StepBuilder(name, jobRepo).tasklet(t, txMgr).build();
}
}
```
각 Step 은 `Tasklet` (단순 단계) 또는 `Chunk` 기반.
대용량 (Step 4, 5) 은 `MyBatisCursorItemReader` + `ItemWriter` 청크 처리.
---
## 3. 8 Step 명세
### Step 1 — `receiveData`
- 입력: `companyCode` (또는 전체 활성 보험사)
- 처리: `DataReceiver` 전략 패턴
- `Excel/CSV` (수동 업로드 — 기본): `ManualReceiver`
- `EDI` (FTP), `API` (HTTP) 는 향후 확장
- 각 receiver 가 `raw_commission_data` 에 1 행씩 (`raw_content` JSON) 저장
- 우선 구현: `ManualReceiver` 만. 다른 receiver 는 인터페이스만 두고 stub.
- 출력: `ctx.receiveCount`
```java
public interface DataReceiver {
String getMethod(); // EXCEL / CSV / EDI / API / MANUAL
int receive(String companyCode, String settleMonth);
}
```
### Step 2 — `transformData` (★ 핵심)
- 입력: `raw_commission_data` 에서 `parse_status='PENDING'` 인 행
- 처리: `MappingEngine.transform(raw)` 호출
- `company_field_mapping` 로 source → target 매핑
- 변환 규칙: `TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP`
- `policy_no``contract` 매칭 → `contract_id`, `agent_id` 채움
- `data_type` 에 따라 `recruit_ledger` / `maintain_ledger` 에 INSERT
- 실패 시 `parse_error_log` 에 적재
- 9 가지 에러 유형:
`INVALID_INPUT / UNKNOWN_TYPE / NO_MAPPING / JSON_PARSE / REQUIRED /
TRANSFORM_FAIL / NO_POLICY / CONTRACT_NOT_FOUND / CODE_MAP_FAIL`
- 출력: `ctx.transformOk`, `ctx.transformErr`
`MappingEngine` 의 핵심 메서드:
```java
@Transactional
public Long transform(RawCommissionDataVO raw) {
// 1) companyCode/rawContent null check
// 2) data_type → target_table (RECRUIT/MAINTAIN)
// 3) selectFieldMappings(companyCode, target_table)
// 4) JSON 파싱 (ObjectMapper)
// 5) 각 매핑별 applyTransform(value, mapping, companyCode) → mapped Map
// 6) policy_no 로 contract 매칭
// 7) buildLedger(mapped, contract, raw) → recruit/maintain mapper insert
}
```
### Step 3 — `reconcile`
- 입력: 보험사 통보 합계 vs 시스템 계산 합계 (`raw_commission_data` 기준)
- 처리: `reconciliation` 테이블에 `expected/actual/diff` 적재
- 차이 임계값(시스템설정 `RECON_DIFF_THRESHOLD`) 초과 시 `recon_status='DIFF'`
- 출력: `ctx.reconcileOk`, `ctx.reconcileDiff`
### Step 4 — `calcRecruit`
- 입력: `recruit_ledger` 의 status='CALCULATED' 또는 신규 계약 (계약일이 정산월 이내)
- 처리: `CommissionCalculator.calcRecruitLedger(contract, settleMonth)`
- `contractDate` 기준 `commission_rate` (commission_year=1) → `companyAmount`
- `gradeId` + `insuranceType` + `commission_year=1` 기준 `payout_rate``agentAmount`
- `tax = agentAmount × 3.3%` (HALF_UP)
- 출력: `ctx.recruitCount`
```java
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
AgentVO agent = agentMapper.selectById(c.getAgentId());
if (agent == null) return null;
LocalDate base = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), 1, base);
if (companyRate == null) companyRate = BigDecimal.ZERO;
BigDecimal payoutRate = ruleMapper.findPayoutRate(agent.getGradeId(),
c.getInsuranceType(), 1, base);
if (payoutRate == null) payoutRate = BigDecimal.ZERO;
BigDecimal premium = MoneyUtil.zero(c.getPremium());
BigDecimal companyAmt = MoneyUtil.pct(premium, companyRate);
BigDecimal agentAmt = MoneyUtil.pct(companyAmt, payoutRate);
BigDecimal tax = MoneyUtil.tax(agentAmt);
// LedgerVO 필드 채워서 반환
}
```
### Step 5 — `calcMaintain`
- 입력: 유지 계약 (`status='ACTIVE'`, `effective_date` 가 1~5 년차 도래)
- 처리: `CommissionCalculator.calcMaintainLedger(contract, settleMonth, year)`
- `commission_year` 만 1 → year (2~5) 로 다름
- 같은 공식 적용
- 출력: `ctx.maintainCount`
### Step 6 — `chargebackException`
- 입력: 정산월 직전 실효 계약 (`status='LAPSE'`, `lapse_date` 가 정산월 범위)
- 처리:
- `ruleMapper.findChargebackRate(companyCode, insuranceType, lapseMonthDiff, base)`
- 환수 계산: `cb_amount = base × cb_rate`
- 분할 회차(`installment_seq`) 정의 시 N 회로 분할
- 결과를 `chargeback` 에 적재
- 예외 자동 생성: `exception_type_code``auto_calc_yn='Y'` 인 코드별로
`exception_ledger` 에 기록 (수동 승인 대기)
- 출력: `ctx.chargebackCount`, `ctx.exceptionCount`
### Step 7 — `calcOverride`
- 입력: Step 4/5 에서 생성된 모집/유지 수수료 합계 (per agent)
- 처리:
- 조직 트리 상향 탐색 (CTE): 설계사 → 팀장 → 지점장 → 본부장
- 각 단계마다 `override_rule` (from_grade → to_grade) 의 `override_pct` 적용
- `cap_amount` 가 있으면 상한 적용
- `override_settle` 에 (from_agent, to_agent, base, pct, amount) 적재
- 출력: `ctx.overrideCount`
### Step 8 — `aggregate`
- 입력: 모든 원장의 설계사별 합계 + 환수 + 오버라이드 + 예외(PLUS/MINUS)
- 처리:
```
gross = recruit + maintain + override + excPlus - chargeback - excMinus
tax = gross × TAX_RATE (system_config 의 TAX_RATE, 기본 3.3%)
net = gross - tax
```
- `settle_master` 에 `(agent_id, settle_month)` UPSERT (`ON CONFLICT DO UPDATE`)
- `status = 'CALCULATED'` (확정은 별도 API)
- 활성 설계사 누락 보충: `agentMapper.selectList(status=ACTIVE)` 로 0 행도 빈 settle 생성
- 출력: `ctx.aggregateCount`
---
## 4. JobLauncherController
```java
@RestController @RequestMapping("/api/batch/settlement")
public class JobLauncherController {
private final JobLauncher launcher;
private final Job monthlySettlementJob;
private final BatchJobLogMapper logMapper;
@PostMapping("/run")
public ApiResponse<Long> run(@RequestParam String settleMonth,
@RequestParam(required=false) String companyCode) {
if (logMapper.existsRunning("monthlySettlementJob") > 0)
throw new BizException(ErrorCode.BATCH_RUNNING);
Long jobLogId = logMapper.request("monthlySettlementJob", settleMonth);
JobParameters p = new JobParametersBuilder()
.addString("settleMonth", settleMonth)
.addString("companyCode", companyCode)
.addLong ("jobLogId", jobLogId)
.addLong ("ts", System.currentTimeMillis()) // 동일 파라미터 재실행
.toJobParameters();
new Thread(() -> launcher.run(monthlySettlementJob, p)).start();
return ApiResponse.ok(jobLogId);
}
@GetMapping("/status")
public ApiResponse<BatchJobLogVO> status(@RequestParam Long jobLogId) {
return ApiResponse.ok(logMapper.selectById(jobLogId));
}
}
```
ga-api 도 동일 엔드포인트 노출 가능 — 배치 서버가 따로 떠 있을 때는
ga-api 가 `batch_job_log.status='REQUESTED'` 로 적재만 하고, ga-batch 가 폴링/직접 호출.
---
## 5. 단위 테스트 (현재 작성됨)
`ga-batch/src/test/java/com/ga/batch/`:
- `settlement/calc/CommissionCalculatorTest` (6) — 모집/유지/폴백/agent 누락
- `settlement/transform/MappingEngineTest` (13) — RECRUIT/MAINTAIN 라우팅,
TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP, 9 가지 에러
---
## 6. 향후 확장 (현재 단순화 부분)
1. **DataReceiver**: EDI / FTP / API 구현 (현재 ManualReceiver 만)
2. **MappingEngine**: REPLACE / SUBSTRING / CONCAT / LOOKUP 등 추가 변환 규칙
3. **이체파일**: 은행별 EBCDIC / CSV 출력 어댑터 (Step 9 으로 분리)
4. **재실행**: 특정 Step 부터 재실행 (`/api/batch/settlement/restart?fromStep=4`)
5. **병렬 처리**: Step 4/5 를 `partitioning` 으로 멀티스레드화
+293
View File
@@ -0,0 +1,293 @@
# 05_API_SPEC — ga-api (REST API)
> 모든 Controller / Service 가 동일한 패턴으로 작성된다.
> 신입이 복붙 후 테이블명/필드명만 바꾸면 새 화면이 완성되도록.
## 패키지 구조
```
ga-api/src/main/java/com/ga/api/
├── GaApiApplication.java # @SpringBootApplication(scanBasePackages = "com.ga")
├── auth/
│ ├── AuthController.java # /api/auth/*
│ └── AuthService.java
├── controller/
│ ├── batch/ BatchTriggerController.java
│ ├── ledger/ LedgerController.java # 모집/유지/예외 통합
│ ├── org/ OrganizationController.java, AgentController.java
│ ├── product/ CompanyController.java, ProductController.java, ContractController.java
│ ├── receive/ ReceiveController.java
│ ├── rule/ RuleController.java
│ ├── settle/ SettleController.java, PaymentController.java
│ └── system/ UserController.java, RoleController.java
└── service/
└── (controller 미러 구조 — XxxService.java)
```
build.gradle:
```groovy
apply plugin: 'org.springframework.boot'
bootJar { archiveFileName = 'ga-api.jar'; mainClass = 'com.ga.api.GaApiApplication' }
jar.enabled = false
dependencies {
implementation project(':ga-core')
}
```
`application.yml``01_DB_SCHEMA.md` 의 yml + `server.port: 8080`.
---
## 1. 표준 Controller 패턴
```java
@RestController
@RequestMapping("/api/agents")
@Tag(name = "설계사 관리")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
@GetMapping
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
return ApiResponse.ok(agentService.list(param));
}
@GetMapping("/{id}")
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<AgentResp> detail(@PathVariable Long id) {
return ApiResponse.ok(agentService.detail(id));
}
@PostMapping
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Long> create(@Valid @RequestBody AgentSaveReq req) {
return ApiResponse.ok(agentService.create(req));
}
@PutMapping("/{id}")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Void> update(@PathVariable Long id, @Valid @RequestBody AgentSaveReq req) {
agentService.update(id, req); return ApiResponse.ok();
}
@PatchMapping("/{id}/status")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
agentService.updateStatus(id, status); return ApiResponse.ok();
}
@GetMapping("/export")
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
public void export(AgentSearchParam p, HttpServletResponse res) {
agentService.exportExcel(p, res);
}
}
```
규칙:
1. **클래스 어노테이션**: `@RestController @RequestMapping @Tag @RequiredArgsConstructor`
2. **메서드 어노테이션**: `@RequirePermission` + (CUD 일 때) `@DataChangeLog`
3. **응답**: `ApiResponse<T>` / `ApiResponse<PageResponse<T>>` / `ApiResponse<Void>`
4. **요청 바디**: `@Valid @RequestBody XxxSaveReq`
5. **검색**: `XxxSearchParam` (쿼리 파라미터로 자동 바인딩)
6. **PathVariable**: id 는 `Long`
7. **엑셀 export 는 `void` + `HttpServletResponse` 직접 사용**
---
## 2. 표준 Service 패턴
```java
@Service @RequiredArgsConstructor
public class AgentService {
private final AgentMapper mapper;
private final CommonCodeService codeService;
private final AgentMapstruct mapstruct;
private final ExcelService excelService;
public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AgentResp detail(Long id) {
AgentResp r = mapper.selectDetailById(id);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional
public Long create(AgentSaveReq req) {
if (req.getLicenseNo() != null && mapper.existsByLicenseNo(req.getLicenseNo()) > 0)
throw new BizException(ErrorCode.DUPLICATE_DATA);
AgentVO vo = mapstruct.toVO(req);
vo.setStatus("ACTIVE");
mapper.insert(vo);
return vo.getAgentId();
}
@Transactional
public void update(Long id, AgentSaveReq req) {
AgentVO vo = mapper.selectById(id);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
mapstruct.update(vo, req);
mapper.update(vo);
}
@Transactional
public void updateStatus(Long id, String status) {
codeService.validateCode("AGENT_STATUS", status);
mapper.updateStatus(id, status);
}
public void exportExcel(AgentSearchParam p, HttpServletResponse res) {
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
() -> mapper.selectCursorForExcel(p), res);
}
}
```
규칙:
1. **Service 는 Mapper + 다른 Service 만 의존** (Controller 직접 호출 X)
2. **CUD 메서드는 `@Transactional`**
3. **공통코드 검증은 `codeService.validateCode(group, code)`**
4. **중복 검증 → `DUPLICATE_DATA`, 미존재 → `NOT_FOUND`**
5. **엑셀은 `excelService.exportLargeExcel(fileName, ExcelVO.class, cursorSupplier, response)`**
---
## 3. API 엔드포인트 목록 (현재 구현된 것)
### 인증 (`AuthController` — `/api/auth`)
- `POST /login``{loginId, password}``{accessToken, refreshToken, user}` + `login_log` 적재
- `POST /refresh` — refresh 토큰으로 access 갱신
- `POST /logout` — 클라이언트 토큰 폐기 (서버는 로그만)
- `GET /me` — 현재 사용자 + 역할 + 메뉴 트리
### 조직 / 설계사
- `OrganizationController /api/orgs`
- `GET /tree` — 트리 (CTE 재귀)
- `GET /{id}` / `POST` / `PUT /{id}` / `DELETE /{id}`
- `AgentController /api/agents`
- `GET` (검색+페이징) / `GET /{id}` / `POST` / `PUT /{id}`
- `PATCH /{id}/status` / `PATCH /{id}/transfer` (조직/직급 변경)
- `GET /{id}/history` (조직/직급 이력)
- `GET /export` (엑셀 다운로드)
### 보험상품 / 계약
- `CompanyController /api/companies` — 보험사 5 건 CRUD
- `ProductController /api/products` — 보험사 × 상품
- `ContractController /api/contracts` — 검색/CRUD/엑셀, `policy_no` 중복검증
### 수수료 규정
- `RuleController /api/rules`
- `GET /commission-rates`, `POST`, `PUT /{id}`, `DELETE /{id}`
- `GET /payout-rules`, ... 동일 패턴
- `GET /override-rules`, `GET /chargeback-rules`, `GET /exception-codes`
### 데이터 수신 / 매핑
- `ReceiveController /api/receive`
- `POST /upload?companyCode=&dataType=` — multipart 업로드 → `raw_commission_data` 적재
- `POST /process?companyCode=&settleMonth=``MappingEngine` 으로 변환 호출
- `GET /status?companyCode=&settleMonth=` — 진행 현황
- `GET /errors?companyCode=&resolveStatus=` — 에러 목록
- `PATCH /errors/{errorId}/resolve` — 에러 해결 처리
- 매핑 관리: `GET /mapping/{companyCode}/fields`, `POST/PUT/DELETE`
### 원장 (`LedgerController /api/ledger` — 통합)
- `GET /recruit` (검색+페이징) / `GET /recruit/export` (엑셀)
- `GET /maintain`, `GET /exception` — 동일 패턴
- `POST /exception` — 예외금액 등록 (수동)
- `PATCH /exception/{id}/approve` — 승인 (`@RequirePermission perm=APPROVE`)
- `PATCH /exception/{id}/reject` — 반려
### 정산
- `SettleController /api/settle`
- `GET` (월별 검색) / `GET /{id}` / `GET /summary?settleMonth=`
- `PATCH /{id}/confirm` (`@RequirePermission APPROVE`) — `@Transactional`,
`version` 체크 → `RULE_VERSION_CONFLICT` 가능
- `PATCH /{id}/hold` `{reason}`
- `PaymentController /api/payments`
- `GET` 검색 / `POST` 이체 생성 (`settle_master.status='CONFIRMED'` 인 것만)
- `POST /{id}/transfer-file` 이체파일 생성
- `PATCH /{id}/success`, `PATCH /{id}/fail`
### 배치 트리거 (`BatchTriggerController /api/batch`)
- `POST /settlement/run?settleMonth=&companyCode=``batch_job_log` 적재 + ga-batch 트리거
- `GET /settlement/status?jobLogId=` — 진행 상태
- `POST /settlement/restart?jobLogId=&fromStep=` — 재실행 (선택 구현)
### 시스템관리
- `UserController /api/system/users`
- `GET` / `POST` / `PUT /{id}` / `DELETE /{id}`
- `PATCH /{id}/reset-password` — 임시 비밀번호 발급 (이메일/알림)
- `PATCH /{id}/unlock` — 잠금 해제
- `PUT /{id}/roles` — 역할 매핑
- `RoleController /api/system/roles`
- 역할 CRUD + `GET /{id}/permissions` (메뉴 × 권한 매트릭스)
- `PUT /{id}/permissions` (전체 매트릭스 갱신)
### 공통 (ga-common 에 위치하지만 ga-api 가 노출)
- `MenuController /api/menus``GET /my`, `GET /tree`, CRUD
- `CommonCodeController /api/common/codes` — 그룹/상세 CRUD
- `FileController /api/files` — 업로드/다운로드/목록
- `NotificationController /api/notifications` — 목록/읽음
- `SystemConfigController /api/system/config` — 시스템 설정
- `SystemLogController /api/system/logs` — 로그인/API/변경 이력
---
## 4. 인증 / 권한 흐름
1. **로그인**: `POST /api/auth/login` → JWT 발급
2. **헤더**: 모든 요청에 `Authorization: Bearer <token>`
3. **`JwtAuthFilter`**: SecurityContext 에 `LoginUser` 주입
4. **`@RequirePermission`**: `PermissionAspect``role_menu_permission` 조회 → 없으면 `FORBIDDEN`
5. **`@DataChangeLog`**: 변경 전/후 데이터 비교 후 `data_change_log` JSONB 비동기 적재
6. **`ApiLogAspect`**: 모든 요청 `api_access_log` 비동기 적재 (민감정보 마스킹)
---
## 5. 응답 표준
### 성공
```json
{
"success": true, "code": "0000", "message": "OK",
"data": { ... }, "timestamp": "2026-05-10T12:34:56"
}
```
### 페이징
```json
{
"success": true, "code": "0000",
"data": {
"list": [...], "pageNum": 1, "pageSize": 20, "total": 123, "pages": 7
}
}
```
### 실패
```json
{
"success": false, "code": "B009", "message": "중복된 데이터입니다",
"data": null, "timestamp": "..."
}
```
---
## 6. 테스트 (선택)
- MockMvc 기반 Controller 테스트
- `@WebMvcTest(AgentController.class)` + `@MockBean AgentService`
- 인증/권한 검증 (403 응답)
- 요청 바디 검증 (`@Valid` 실패 시 400)
- WireMock 으로 ga-batch 호출 stub
+306
View File
@@ -0,0 +1,306 @@
# 06_FRONTEND_SPEC — ga-frontend (React 18)
> Vite + React 18 + TypeScript + Ant Design 5 + AG Grid Community + React Query + Zustand.
> ga-common 에서 작성한 공통 컴포넌트(02_COMMON_SPEC §10)를 반드시 재사용.
## 패키지 구조
```
ga-frontend/
├── package.json # AG Grid Community ^31.3, antd ^5.18, react-query ^5.40
├── vite.config.ts # /api → http://localhost:8080 proxy
├── tsconfig.json # strict: true
├── index.html
└── src/
├── main.tsx # ReactDOM.createRoot, QueryClientProvider, ConfigProvider(ko)
├── App.tsx # 라우터 + 전역 메시지/모달
├── api/
│ ├── request.ts # axios + 인터셉터 + ApiResponse 타입
│ ├── auth.ts, agent.ts, contract.ts, ledger.ts, settle.ts, payment.ts,
│ ├── system.ts, menu.ts, code.ts, file.ts, batch.ts
├── components/
│ └── common/
│ ├── CodeSelect.tsx, CodeBadge.tsx, MoneyText.tsx
│ ├── SearchForm.tsx, DataGrid.tsx
│ ├── PermissionButton.tsx, ExcelExportButton.tsx, PageContainer.tsx
├── hooks/
│ ├── useCommonCodes.ts, useMenuTree.ts, usePermission.ts, useSearchParam.ts
├── layouts/
│ └── MainLayout.tsx # Sider + Header + Content + Breadcrumb
├── pages/
│ ├── Dashboard.tsx
│ ├── LoginPage.tsx
│ ├── org/ # AgentList / AgentDetail / AgentForm
│ ├── product/ # ContractList
│ ├── ledger/ # RecruitLedger / ExceptionLedger
│ ├── settle/ # SettleList / BatchRun / PaymentList
│ └── system/ # UserList / RoleList / CodeList
├── router/
│ └── DynamicRouter.tsx # /api/menus/my → React.lazy 매핑
├── stores/
│ ├── authStore.ts (Zustand) # accessToken, user, login/logout
│ ├── menuStore.ts # 메뉴 트리 캐시
│ └── notificationStore.ts # 미읽음 카운트
├── types/
│ └── api.ts, domain.ts
└── utils/
└── formatMoney.ts, formatDate.ts, downloadFile.ts
```
`vite.config.ts`:
```typescript
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }
}
});
```
---
## 1. 공통 컴포넌트 / 훅
상세 시그니처는 `02_COMMON_SPEC.md §10` 참고. 핵심:
```tsx
// 공통코드 셀렉트
<CodeSelect groupCode="AGENT_STATUS" allowClear />
<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />
<MoneyText value={123456} sign /> // +123,456 (음수 빨강)
<MonthPicker value={month} onChange={setMonth} />
// 검색폼 — conditions 배열로 자동 렌더링
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '검색어' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
{ type: 'dateRange', name: 'dateRange', label: '기간' },
{ type: 'month', name: 'settleMonth', label: '정산월' },
]}
onSearch={setParam} onReset={resetParam}
/>
// 권한 버튼 — perm 없으면 안 보임
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('new')}></PermissionButton>
// 엑셀 다운로드 — 100만건 진행률 표시
<ExcelExportButton url="/api/agents/export" params={searchParam}
fileName="설계사목록" showProgress />
// AG Grid 서버사이드
<DataGrid columnDefs={cols} dataSource={(p) => agentApi.list(p)}
totalRow rowSelection="multiple" />
```
훅:
```tsx
const { data: codes } = useCommonCodes('AGENT_STATUS'); // React Query staleTime 10분
const { canCreate, canExport, canApprove } = usePermission('ORG_AGENT');
const [param, setParam] = useSearchParam({ status: '' }); // URL 동기화
```
---
## 2. AG Grid vs DataTable 사용 기준
| 화면 | 컴포넌트 | 이유 |
|---|---|---|
| 설계사 목록, 사용자 목록 | Ant DataTable | 단순 CRUD, < 100 행 |
| 계약 목록 | Ant DataTable | 검색 위주 |
| **모집/유지/예외 원장** | **AG Grid** | 1만~100만 행, 합계행, 셀 편집 |
| **정산 결과** | **AG Grid** | 설계사 × 항목, 합계행 |
| 수수료율 편집 | AG Grid | 인라인 셀 편집 |
| 지급 관리 | Ant DataTable | 단순 상태 추적 |
판단 규칙:
- 100 건 이상 → AG Grid
- 셀 편집 필요 → AG Grid
- 합계행 (`pinnedBottomRowData`) → AG Grid
- 그 외 단순 목록 → Ant DataTable
---
## 3. 동적 라우터 (`router/DynamicRouter.tsx`)
```tsx
export function DynamicRouter() {
const { menuTree } = useMenuTree(); // /api/menus/my
const flat = flatten(menuTree).filter(m => m.menuType === 'PAGE' && m.componentPath);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<MainLayout />}>
<Route path="/" element={<Dashboard />} />
{flat.map(m => {
const Page = lazy(() => import(`../pages/${m.componentPath}.tsx`));
return (
<Route key={m.menuId} path={m.menuPath}
element={<Suspense fallback={<Spin />}><Page /></Suspense>} />
);
})}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
```
- 로그인 → `/api/menus/my` 호출 → Sider 메뉴 + 라우터 동적 등록
- `menu.component_path``org/AgentList``pages/org/AgentList.tsx` 로 lazy import
- 권한 없는 URL 진입 시 403
---
## 4. 화면 13개 (현재 구현됨)
### 1) `Dashboard.tsx` (`/`)
- 정산 요약 카드 4개: 모집 / 유지 / 예외 / 환수 (월간 합계)
- 배치 진행 상황 (최근 monthlySettlementJob)
- 미해결 대사 오류 알림
- 월별 추이 (recharts `LineChart`)
### 2) `LoginPage.tsx` (`/login`)
- 로그인 폼 (`loginId`, `password`)
- 성공 시 토큰 저장 + 메뉴 트리 fetch + `/` 리다이렉트
### 3~5) `org/AgentList.tsx`, `AgentDetail.tsx`, `AgentForm.tsx` (`/org/agents`)
- 목록 (DataTable, 50명 더미 표시)
- SearchForm: 이름/상태/조직
- 상세 모달 또는 페이지 (탭: 기본정보/조직이력/계약/정산이력)
- 등록/수정 폼 (`AgentSaveReq`, Ant Form + `<Form.Item rules>`)
- 엑셀 다운로드 (`ExcelExportButton`)
### 6) `product/ContractList.tsx` (`/contracts`)
- 200건 더미 표시
- 필터: 상태(ACTIVE/LAPSE), 보험사, 보험종류, 기간
### 7) `ledger/RecruitLedger.tsx` (`/ledger/recruit`)
- AG Grid (서버사이드 페이징/정렬/필터)
- 합계행 (`pinnedBottomRowData`)
- 컬럼: 정산월, 증권번호, 설계사, 보험사, 보험료, 수수료율, 수수료, 세액
- 엑셀 다운로드
### 8) `ledger/ExceptionLedger.tsx` (`/ledger/exception`)
- AG Grid + 등록 모달 (수동 등록)
- 행 액션: 승인 / 반려 (`@RequirePermission perm=APPROVE`)
### 9) `settle/SettleList.tsx` (`/settle`)
- 월별 정산 요약
- 설계사별 카드 / AG Grid (모집/유지/예외/오버라이드/환수/총액/세금/실지급)
- 행 액션: 확정 / 보류
### 10) `settle/BatchRun.tsx` (`/settle/batch`)
- 정산월 선택 → 배치 실행 버튼
- 진행률 폴링 (5초 간격, `GET /api/batch/settlement/status`)
- 8 Step 별 진행 카운트 표시
### 11) `settle/PaymentList.tsx` (`/payments`)
- 지급 상태 (PENDING / SENT / SUCCESS / FAIL)
- 이체파일 생성 / 결과 등록
### 12) `system/UserList.tsx` (`/system/users`)
- 사용자 CRUD, 역할 할당 체크박스
- 비밀번호 초기화 / 잠금 해제 액션
### 13) `system/RoleList.tsx` (`/system/roles`)
- 역할 CRUD + 권한 매트릭스 (메뉴 × 6 권한 체크박스 그리드)
### `system/CodeList.tsx` (`/system/codes`)
- 좌측: 그룹 리스트 (DataTable)
- 우측: 선택한 그룹의 상세 코드 (DataTable)
- `is_system='Y'` 인 그룹은 수정/삭제 disable
---
## 5. 색상 / 디자인 규칙
- **상태 뱃지 색** (`CodeBadge` 자동):
- 초록: CONFIRMED / APPROVED / ACTIVE / PAID / SUCCESS
- 파랑: PENDING / CALCULATED / PROCESSING / SENT
- 빨강: HOLD / SUSPEND / REJECTED / FAIL / LAPSE / ERROR
- 회색: DRAFT / NEW / OPEN / IGNORED
- **금액**: `MoneyText` 사용 (양수 검정 / 음수 빨강 / null `-`)
- **마스킹**: 주민번호/계좌번호는 응답에서 마스킹돼 옴 (`@Mask` 어노테이션)
- **빈 상태**: `<Empty />` (Ant Design)
- **로딩**: `<Spin />`
---
## 6. Zustand Store 패턴
```typescript
interface AuthState {
accessToken: string | null;
user: { userId: number; loginId: string; userName: string; roleCodes: string[] } | null;
login: (token: string, user: any) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: localStorage.getItem('accessToken'),
user: JSON.parse(localStorage.getItem('user') ?? 'null'),
login: (token, user) => {
localStorage.setItem('accessToken', token);
localStorage.setItem('user', JSON.stringify(user));
set({ accessToken: token, user });
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('user');
set({ accessToken: null, user: null });
location.href = '/login';
},
}));
```
---
## 7. 신입 개발자 5분 컷 패턴
```tsx
export default function AgentList() {
const { canCreate, canExport } = usePermission('ORG_AGENT');
const [param, setParam] = useSearchParam({ status: '' });
const { data, isLoading } = useQuery({
queryKey: ['agents', param],
queryFn: () => agentApi.list(param),
});
return (
<PageContainer title="설계사 관리">
<SearchForm conditions={[
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]} onSearch={setParam} />
<Space style={{ marginBottom: 16, justifyContent: 'flex-end' }}>
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')}></PermissionButton>
{canExport && <ExcelExportButton url="/api/agents/export" params={param}
fileName="설계사목록" showProgress />}
</Space>
<DataTable
loading={isLoading}
dataSource={data?.list ?? []}
rowKey="agentId"
pagination={{ total: data?.total ?? 0, current: param.pageNum, pageSize: param.pageSize }}
columns={[
{ title: '설계사명', dataIndex: 'agentName' },
{ title: '소속', dataIndex: 'orgName' },
{ title: '직급', dataIndex: 'gradeName' },
{ title: '상태', dataIndex: 'status',
render: v => <CodeBadge groupCode="AGENT_STATUS" value={v} /> },
{ title: '계약수', dataIndex: 'contractCount', align: 'right' },
{ title: '수수료', dataIndex: 'totalAmt',
render: v => <MoneyText value={v} />, align: 'right' },
]}
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
/>
</PageContainer>
);
}
```
+279
View File
@@ -0,0 +1,279 @@
# 07_INFRA_SPEC — Docker / Nginx / 문서
## 1. docker-compose.yml
루트에 위치. 5 서비스: `postgres / redis / api / batch / frontend`.
```yaml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ga
POSTGRES_USER: ga
POSTGRES_PASSWORD: ga
TZ: Asia/Seoul
ports: ["5432:5432"]
volumes: [ga-postgres-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ga -d ga"]
interval: 10s
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: [ga-redis-data:/data]
api:
build:
context: .
dockerfile: Dockerfile
args: { MODULE: ga-api }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
environment:
SPRING_PROFILES_ACTIVE: docker
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ga
SPRING_DATASOURCE_USERNAME: ga
SPRING_DATASOURCE_PASSWORD: ga
SPRING_DATA_REDIS_HOST: redis
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
ports: ["8080:8080"]
batch:
build: { context: ., dockerfile: Dockerfile, args: { MODULE: ga-batch } }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
api: { condition: service_started }
environment: # api 와 동일
...
ports: ["8081:8081"]
frontend:
build: { context: ./ga-frontend, dockerfile: Dockerfile }
depends_on: [api]
ports: ["3000:80"]
volumes: { ga-postgres-data: , ga-redis-data: }
```
---
## 2. Dockerfile (루트, ga-api/ga-batch 공용)
멀티스테이지: Gradle 빌드 → 슬림 JRE.
```dockerfile
# stage 1: builder
FROM gradle:8.6-jdk17 AS builder
WORKDIR /workspace
COPY settings.gradle build.gradle gradle.properties ./
COPY ga-common/build.gradle ga-common/
COPY ga-core/build.gradle ga-core/
COPY ga-api/build.gradle ga-api/
COPY ga-batch/build.gradle ga-batch/
COPY ga-admin/build.gradle ga-admin/
RUN gradle dependencies --no-daemon || true
COPY . .
ARG MODULE=ga-api
RUN gradle :${MODULE}:bootJar --no-daemon -x test
RUN cp /workspace/${MODULE}/build/libs/${MODULE}.jar /workspace/app.jar
# stage 2: runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
COPY --from=builder /workspace/app.jar /app/app.jar
EXPOSE 8080 8081
ENTRYPOINT ["java","-XX:+UseContainerSupport","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
```
---
## 3. ga-frontend/Dockerfile + nginx.conf
```dockerfile
# ga-frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build # → /app/dist
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
```nginx
# ga-frontend/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API 프록시
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
proxy_read_timeout 300s; # 엑셀 대용량 다운로드 대비
}
# 정적 자원 캐시
location ~* \.(?:css|js|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
}
```
---
## 4. README.md (루트)
작성 항목 (현재 README.md 가 이미 갖춘 형태):
- 기술 스택 요약 표
- 모듈 구조 다이어그램
- **시나리오 A — 운영 DB(`192.168.0.60:55432/trading_ai/ga`) 연결**
- Gradle wrapper 생성 → `bootJar``bootRun --args='--spring.profiles.active=trading_ai'`
- 환경변수 `DB_PASSWORD`, `REDIS_HOST` 오버라이드
- **시나리오 B — 로컬 PostgreSQL** (개발용)
- `docker compose up -d postgres redis``bootRun`
- **시나리오 C — Docker Compose 전체** (운영 흐름)
- `docker compose up -d --build`
- DB 마이그레이션 직접 실행 (Python `migrate.py`)
- 데이터 확인 표 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 / ...)
- 트러블슈팅 FAQ
- `gradle wrapper --gradle-version 8.6`
- `Failed to determine a suitable driver class` → 프로파일 확인
- `NoSuchKey: ga``currentSchema=ga`
- 401 토큰 만료 → 재로그인
- AG Grid 빈 화면 → CSS import 확인
- 주요 기능 (백엔드/도메인/배치 8 Step 요약)
- 보안 (JWT/AES/RBAC/감사로그)
- 진행 상황 체크리스트
- 향후 확장
- 외부 시스템 연결 정보
---
## 5. DEVELOPER_GUIDE.md (신입 개발자 온보딩)
### 1. 환경 설정
- JDK 17 (`java -version` 확인)
- Node 20 + npm
- PostgreSQL 14+ (운영 또는 Docker)
- Redis 7+ (선택)
- IntelliJ IDEA Community + Lombok 플러그인 (자동 설치 권장)
### 2. 프로젝트 구조 한 장 다이어그램
```
ga-common ─┬─> ga-core ─┬─> ga-api (REST API, 8080)
│ ├─> ga-batch (Spring Batch, 8081)
│ └─> ga-admin (예약 모듈)
└─> 다른 모듈 모두 의존 (web/security/aop/cache/mybatis/excel...)
ga-frontend (독립 빌드, dist → nginx)
```
### 3. 새 화면 만들기 Step-by-Step (예: "공지사항")
**Step 1 — DB**: `notice` 테이블 + 메뉴/권한 등록 (V18 마이그레이션)
**Step 2 — VO** (`ga-core/vo/notice/`):
- `NoticeVO.java` (테이블 1:1)
- `NoticeResp.java` (조인 결과)
- `NoticeSaveReq.java` (`@Valid` 검증)
- `NoticeSearchParam.java extends SearchParam`
**Step 3 — Mapper**:
- `mapper/notice/NoticeMapper.java` (`@Mapper`)
- `resources/mapper/notice/NoticeMapper.xml` (기존 AgentMapper.xml 복사 후 수정)
**Step 4 — Service** (`ga-api/service/notice/`):
- `NoticeService.java` (단순 CRUD 패턴 + `@Transactional`)
**Step 5 — Controller**:
- `NoticeController.java``@RequirePermission(menu="NOTICE", perm="...")`
- CUD 메서드에 `@DataChangeLog`
**Step 6 — 프론트** (`ga-frontend/src/`):
- `api/notice.ts` — 함수 5개 (list/detail/create/update/delete)
- `pages/notice/NoticeList.tsx``06_FRONTEND_SPEC.md §7` 패턴
- `pages/notice/NoticeForm.tsx` — Ant Form
**Step 7 — 메뉴 등록**:
- `system/menus` 화면에서 메뉴/권한 등록 → 사용자 역할에 권한 부여
### 4. 자주 쓰는 패턴
- **공통코드 드롭다운**: `<CodeSelect groupCode="..." />`
- **엑셀 다운로드**: `<ExcelExportButton url={...} fileName={...} showProgress />`
- **상태 뱃지**: `<CodeBadge groupCode="..." value={status} />`
- **권한 버튼**: `<PermissionButton menuCode permCode>...</PermissionButton>`
- **AG Grid 서버사이드**: `<DataGrid dataSource={p => api.list(p)} />`
### 5. 코드 컨벤션
- VO 네이밍: `XxxVO / XxxSearchParam / XxxSaveReq / XxxResp / XxxExcelVO`
- Mapper XML: `XxxMapper.xml` (resources/mapper/{도메인}/)
- API URL: `/api/{리소스}s` (복수형, kebab-case)
- 금액: 항상 `BigDecimal` + `MoneyUtil` 헬퍼
- 날짜: `LocalDate / LocalDateTime` (java.util.Date 금지)
- 예외: `BizException(ErrorCode.XXX)` (RuntimeException 직접 throw 금지)
- 로그: `lombok @Slf4j` + `log.info("msg, key={}", value)` (한글 메시지 OK)
### 6. 트러블슈팅 FAQ
- **권한 에러**: `@RequirePermission` 의 menuCode 가 `menu_permission` 에 등록되어 있는지 + 사용자 역할에 권한 부여됐는지
- **공통코드 캐시 안 맞음**: `CommonCodeService.refreshCache(groupCode)` 또는 Redis FLUSH
- **MyBatis 매핑 에러**: ResultMap 의 column / property 명 + `mapUnderscoreToCamelCase` 확인
- **암호화 컬럼 평문 노출**: `EncryptTypeHandler` 가 컬럼별 명시(typeHandler 속성)되어 있는지
- **JSONB null**: `JsonTypeHandler``@MappedTypes(JsonNode.class)` 로 등록됐는지
- **PageHelper 안 먹음**: `param.startPage()` 호출 후 SELECT 1회만 + 같은 트랜잭션
- **AG Grid 빈 화면**: `@ag-grid-community/styles/ag-theme-quartz.css` import 확인
---
## 6. 통합 검증 체크리스트
```bash
# 1) 빌드
./gradlew clean build # 모든 모듈 컴파일 + 단위 테스트 통과
cd ga-frontend && npm install && npm run build # → dist/
# 2) Docker 전체 기동
docker compose up -d --build
# 3) Smoke
curl http://localhost:8080/api/health # {"status":"UP"}
curl http://localhost:3000/ # nginx 200
# 브라우저: admin / admin1234! 로그인 → 메뉴 트리 보임 → 설계사 50 행 표시
# 4) 정산 배치
curl -X POST -H "Authorization: Bearer $TOKEN" \
"http://localhost:8081/api/batch/settlement/run?settleMonth=202605"
# 5) 태그
git tag v1.0.0-alpha
```
기대 결과:
- Flyway V1~V17 자동 적용
- ga-api 8080 / ga-batch 8081 / frontend 3000 모두 OK
- 단위테스트 76+ 건 통과
- 운영 DB 시나리오 A 도 동일 동작 (`--spring.profiles.active=trading_ai`)
+252
View File
@@ -0,0 +1,252 @@
# 08_DBA_SPEC — DB 성능 (인덱스 / 파티셔닝 / 뷰)
> core-architect 의 Mapper XML 작성 완료 후, EXPLAIN ANALYZE 로 검증하고
> V13~V15 마이그레이션을 추가한다.
## 1. 검토 절차
1. core-architect 가 작성한 모든 Mapper XML 의 SELECT 쿼리를 추출
2. 운영 DB 또는 테스트 DB 에서 `EXPLAIN ANALYZE` 실행
3. `Seq Scan` 발견 시 인덱스 후보 정리
4. 통계/리포트 쿼리는 View 또는 Materialized View 로 분리
5. 대용량 테이블은 파티셔닝 검토 (recruit_ledger, api_access_log, data_change_log)
---
## 2. V13__인덱스최적화.sql
이미 생성된 V1~V12 의 인덱스 외에 추가:
```sql
-- settle_master: 정산월 기준 빈번한 조회
CREATE INDEX IF NOT EXISTS idx_sm_month_status ON settle_master(settle_month, status);
CREATE INDEX IF NOT EXISTS idx_sm_agent_month ON settle_master(agent_id, settle_month);
-- recruit_ledger / maintain_ledger: 정산월 + 설계사
CREATE INDEX IF NOT EXISTS idx_rl_month_agent ON recruit_ledger(settle_month, agent_id);
CREATE INDEX IF NOT EXISTS idx_ml_month_agent ON maintain_ledger(settle_month, agent_id);
CREATE INDEX IF NOT EXISTS idx_rl_company ON recruit_ledger(company_code, settle_month);
CREATE INDEX IF NOT EXISTS idx_ml_company ON maintain_ledger(company_code, settle_month);
-- exception_ledger: 정산월 + 승인상태
CREATE INDEX IF NOT EXISTS idx_el_month_approve ON exception_ledger(settle_month, approve_status);
-- contract: 만기/실효 처리용 (부분 인덱스)
CREATE INDEX IF NOT EXISTS idx_contract_lapse ON contract(lapse_date) WHERE lapse_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_contract_status_agent ON contract(status, agent_id);
-- chargeback / payment
CREATE INDEX IF NOT EXISTS idx_cb_month ON chargeback(settle_month, status);
CREATE INDEX IF NOT EXISTS idx_pay_month ON payment(pay_date, pay_status);
-- raw / 에러 / 매핑 (부분 인덱스 적극 사용)
CREATE INDEX IF NOT EXISTS idx_raw_status ON raw_commission_data(parse_status, settle_month);
CREATE INDEX IF NOT EXISTS idx_pe_company ON parse_error_log(company_code, resolve_status);
CREATE INDEX IF NOT EXISTS idx_codemap_lookup ON code_mapping(company_code, code_type, source_code)
WHERE is_active = 'Y';
-- 로그 테이블 (DESC 인덱스로 최신순 조회 최적화)
CREATE INDEX IF NOT EXISTS idx_dcl_table_recent ON data_change_log(table_name, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_aal_url_recent ON api_access_log(url, created_at DESC);
```
### 인덱스 설계 원칙
- **WHERE 자주 쓰이는 컬럼 + ORDER BY 컬럼** 을 복합 인덱스로
- **선택도(cardinality) 낮은 컬럼은 후순위** (`status` 보다 `agent_id` 가 앞)
- **부분 인덱스**(`WHERE`) — 활성 데이터만 인덱싱하여 크기 축소
- **DESC 인덱스** — 최신순 조회가 빈번한 로그 테이블
- **외래키는 자동 인덱스 X** — PG 는 FK 컬럼에 자동 인덱스 안 만듦. 직접 추가.
---
## 3. V14__파티셔닝.sql
대용량/시계열 테이블을 RANGE 파티셔닝으로 변환.
### 헬퍼 함수
```sql
CREATE OR REPLACE FUNCTION create_monthly_partition(
parent_table TEXT,
year_month TEXT -- 'YYYYMM'
) RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_name := parent_table || '_p' || year_month;
start_date := to_date(year_month || '01', 'YYYYMMDD');
end_date := start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
partition_name, parent_table, start_date, end_date
);
END $$ LANGUAGE plpgsql;
```
### 적용 테이블
- **`recruit_ledger`, `maintain_ledger`**: `settle_month` 기준 (VARCHAR 'YYYYMM')
- 변환 절차: 임시 테이블로 데이터 이전 → 파티션 마스터 생성 → 월별 파티션 생성
- 신규 시스템이면 처음부터 파티션 테이블로 정의 (V5 변경)
- **`api_access_log`, `data_change_log`**: `created_at` (TIMESTAMP) 기준 월별
- 90일 이상 자료는 자동 분리 (별도 cron 으로 detach)
### 자동 파티션 생성 (예약)
```sql
-- 매월 1일 차월 파티션 생성 (pg_cron 또는 외부 스케줄러)
SELECT create_monthly_partition('recruit_ledger', to_char(now() + interval '1 month', 'YYYYMM'));
SELECT create_monthly_partition('api_access_log', to_char(now() + interval '1 month', 'YYYYMM'));
```
### 주의사항
- 파티션 테이블은 글로벌 UNIQUE INDEX 못 만듦 → 파티션 키 포함 UNIQUE 만 가능
- ON CONFLICT 사용 시 모든 파티션에 동일 인덱스 필요
- Flyway 적용 시 운영 데이터가 있으면 별도 데이터 이관 스크립트 필요 (개발 환경에서는 빈 테이블 가정)
---
## 4. V15__뷰.sql
### `v_agent_monthly_summary` — 설계사별 월 실적
```sql
CREATE OR REPLACE VIEW v_agent_monthly_summary AS
SELECT sm.agent_id, a.agent_name, o.org_id, o.org_name, g.grade_name,
sm.settle_month,
sm.recruit_total, sm.maintain_total,
sm.exception_plus, sm.exception_minus,
sm.override_total, sm.chargeback_total,
sm.gross_amount, sm.tax_amount, sm.net_amount, sm.status
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN grade g ON g.grade_id = a.grade_id;
```
### `v_org_settle_summary` — 조직별 월 합계
```sql
CREATE OR REPLACE VIEW v_org_settle_summary AS
SELECT a.org_id, o.org_name, o.org_type, sm.settle_month,
COUNT(sm.settle_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS recruit_total,
COALESCE(SUM(sm.maintain_total), 0) AS maintain_total,
COALESCE(SUM(sm.gross_amount), 0) AS gross_amount,
COALESCE(SUM(sm.tax_amount), 0) AS tax_amount,
COALESCE(SUM(sm.net_amount), 0) AS net_amount
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
GROUP BY a.org_id, o.org_name, o.org_type, sm.settle_month;
```
### `v_chargeback_risk` — 환수 위험 계약
```sql
CREATE OR REPLACE VIEW v_chargeback_risk AS
SELECT cb.cb_id, cb.contract_id, cb.agent_id, a.agent_name, o.org_name,
cb.policy_no, cb.lapse_month, cb.cb_rate, cb.cb_amount,
cb.paid_amount, cb.remain_amount, cb.settle_month, cb.status
FROM chargeback cb
JOIN agent a ON a.agent_id = cb.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id;
```
### Materialized View 후보 (대시보드 차트)
- `mv_monthly_total` — 월별 전사 합계 (`REFRESH MATERIALIZED VIEW` 일 1회)
- `mv_agent_top10` — 설계사 실적 상위 10
---
## 5. 테스트 데이터 (V17 — `infra-reviewer` 가 아닌 dba-architect 가 생성)
| 테이블 | 건수 | 설명 |
|---|---:|---|
| insurance_company | 5 | SAMSUNG / HANWHA / KYOBO / DB / KB |
| product | 20 | 보험사당 4 (생명/건강/저축/연금) |
| organization | 7 | 본부 1 / 지점 3 / 팀 3 |
| agent | 50 | GM 3 / DM 3 / SMGR 6 / SFC 12 / FC 26 |
| contract | 200| ACTIVE 190 / LAPSE 10 |
| commission_rate | 100| 상품 × 1~5년차 |
| payout_rule | 120| grade × insurance_type × year |
| override_rule | 10 | grade pair |
| chargeback_rule | 4 | 보험종 × 실효 월수 |
생성 SQL 패턴:
```sql
-- 50명 설계사 자동 생성
INSERT INTO agent (org_id, grade_id, agent_name, license_no, phone, status, join_date)
SELECT
-- org/grade 분배
CASE WHEN n <= 3 THEN 1 WHEN n <= 6 THEN 2 ... END,
CASE WHEN n <= 3 THEN 5 WHEN n <= 6 THEN 4 ... END,
'설계사_' || LPAD(n::text, 3, '0'),
'L' || LPAD(n::text, 5, '0'),
'010-' || LPAD((1000 + n)::text, 4, '0') || '-' || LPAD(n::text, 4, '0'),
'ACTIVE',
CURRENT_DATE - (n * 30)
FROM generate_series(1, 50) n;
```
---
## 6. PostgreSQL 설정 권장값 (`postgresql.conf`)
```ini
# 메모리 (8GB 서버 기준)
shared_buffers = 2GB # 25%
effective_cache_size = 6GB # 75%
work_mem = 32MB
maintenance_work_mem = 512MB
# WAL / 체크포인트 (대량 INSERT 대비)
wal_buffers = 16MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
# 병렬
max_parallel_workers_per_gather = 4
max_worker_processes = 8
# 통계 (쿼리 플래너 정확도)
default_statistics_target = 100
# autovacuum (대용량 로그 테이블 대비)
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.025
```
---
## 7. 대량 배치 쿼리 튜닝
### MyBatis 배치 INSERT (현재 구현)
```xml
<insert id="batchInsert" parameterType="list">
INSERT INTO recruit_ledger (contract_id, agent_id, policy_no, ...)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.contractId}, #{item.agentId}, #{item.policyNo}, ...)
</foreach>
</insert>
```
- 1000 건씩 끊어서 호출 (ExcelService.importLargeExcel 의 batchSize)
- 70만건 INSERT: 약 700 회 호출 → 약 1~2 분 소요
### COPY 명령 활용 (10만건+ 시 10배 빠름)
```java
// JDBC CopyManager 직접 사용
CopyManager copy = new CopyManager((BaseConnection) conn);
copy.copyIn("COPY recruit_ledger FROM STDIN WITH (FORMAT csv)", reader);
```
- 트랜잭션 격리 / 트리거 / 제약 조건 주의
- 향후 70만건 이상 단발성 적재용 옵션으로 추가
---
## 8. 검토 체크리스트 (core-architect 와 협업)
- [ ] 모든 SELECT 쿼리에 `ORDER BY` + `LIMIT/OFFSET`(또는 PageHelper) 적용
- [ ] `LIKE '%키워드%'` 가 자주 쓰이는 컬럼은 `pg_trgm` GIN 인덱스 검토
- [ ] JSONB 컬럼 검색은 `jsonb_path_ops` GIN 인덱스
- [ ] 날짜 범위 검색은 BTREE 인덱스 + WHERE `created_at >= ...`
- [ ] N+1 SELECT 가 발생하지 않도록 ResultMap 의 `<collection>` 검토
- [ ] 정산 배치 Step 4/5 의 SELECT 가 `idx_rl_month_agent` 를 쓰는지 EXPLAIN