# GA 수수료 정산 시스템 — 개발자 가이드 신입 개발자가 **5분 안에 첫 PR**을 보낼 수 있도록 작성한 온보딩 문서. README.md 의 "실행 가이드"를 먼저 읽고 로컬 환경을 기동한 다음 이 문서로 넘어올 것. --- ## 1. 환경 설정 ### 필수 설치 | 도구 | 버전 | 확인 명령 | |------|------|-----------| | JDK | 17 이상 | `java -version` | | Node.js | 18 이상 | `node -v` | | Docker Desktop | 최신 | `docker version` | | IntelliJ IDEA | 2023+ 권장 | — | ### 선택: 로컬 DB/Redis (Docker 권장) ```bash # Docker 로 PostgreSQL + Redis 한 번에 띄우기 docker compose up -d postgres redis ``` 운영 DB(`192.168.0.60:55432/trading_ai/ga`)에 바로 붙을 경우 Docker 불필요. ### 백엔드 실행 ```bash # 프로젝트 루트에서 ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' # 로컬 DB 사용 시 (기본 프로파일) ./gradlew :ga-api:bootRun ``` ### 프론트엔드 실행 ```bash cd ga-frontend npm install npm run dev # http://localhost:3000 ``` `/api/*` 요청은 Vite 프록시로 `http://localhost:8080`에 자동 전달됩니다. --- ## 2. 프로젝트 구조 ``` ga-commission-system/ ├── ga-common/ # 공통 프레임워크 │ ├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn │ ├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect │ ├── auth/ # JWT 필터/프로바이더 │ ├── code/ # 공통코드 서비스 (Redis 캐시) │ ├── excel/ # SXSSF 다운로드 / SAX 업로드 │ └── db/migration/ # V1~V55 Flyway SQL (모든 마이그레이션은 여기) │ ├── ga-core/ # 도메인 코어 — VO, Mapper, XML │ ├── vo/ # {도메인}VO + {도메인}Resp + {도메인}SaveReq + {도메인}SearchParam │ ├── mapper/ # @Mapper 인터페이스 (패키지는 도메인별) │ └── (resources)/mapper/ # MyBatis XML │ ├── ga-api/ # REST API — Service + Controller │ ├── auth/ # 로그인/토큰 갱신 │ ├── controller/ # 도메인별 Controller │ └── service/ # 도메인별 Service │ ├── ga-batch/ # Spring Batch — 월 정산 잡 (8 Step) │ └── ga-frontend/ # React 18 + Vite + TypeScript ├── src/ │ ├── components/common/ # SearchForm, DataGrid, PermissionButton, ExcelExportButton, CodeSelect, CodeBadge │ ├── components/biz/ # ApprovalProgress, AttachmentUpload, NotificationBell │ ├── pages/ # 도메인별 페이지 (50+) │ ├── hooks/ # usePermission, useCommonCodes │ └── theme/tokens.ts # 디자인 토큰 (GRAY, RADIUS, SHADOW, COLOR_*) └── nginx.conf # SPA + /api 리버스 프록시 ``` --- ## 3. 한 도메인 추가 — 표준 작업 흐름 (5분 컷) 아래 순서로 작성하면 메뉴 등록부터 화면까지 일관됩니다. ### Step 1. DB 마이그레이션 (dba) `ga-common/src/main/resources/db/migration/V{N+1}__{도메인명}.sql` 생성. ```sql -- 예: V56__회계전표.sql CREATE TABLE IF NOT EXISTS ga.accounting_entry ( id BIGSERIAL PRIMARY KEY, settle_month CHAR(6) NOT NULL, entry_type VARCHAR(20) NOT NULL, -- DEBIT / CREDIT amount NUMERIC(18,2) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 메뉴 등록 (menu 테이블) INSERT INTO ga.menu (menu_code, parent_code, menu_name, url, sort_order, is_use) VALUES ('ACCOUNTING_ENTRY', 'ACCOUNTING', '회계 전표', '/accounting/entries', 10, true) ON CONFLICT (menu_code) DO NOTHING; ``` 번호 규칙: 기존 최대 번호 + 1. 현재 마지막은 V55. ### Step 2. VO 작성 (core) `ga-core/src/main/java/com/ga/core/vo/{도메인}/` 아래 4종 생성. ```java // 검색 파라미터 @Data public class AccountingEntrySearchParam extends SearchParam { private String settleMonth; private String entryType; } // 응답 @Data public class AccountingEntryResp { private Long id; private String settleMonth; private String entryType; @JsonFormat(shape = JsonFormat.Shape.STRING) private BigDecimal amount; private LocalDateTime createdAt; } // 저장 요청 @Data public class AccountingEntrySaveReq { @NotBlank private String settleMonth; @NotBlank private String entryType; @NotNull private BigDecimal amount; } // Enum (공통코드 기반 처리는 String으로, 타입 안전성이 필요할 때만 Enum 사용) public enum EntryType { DEBIT, CREDIT; @JsonValue public String value() { return name(); } } ``` ### Step 3. Mapper Interface + XML (core) ```java // ga-core/.../mapper/accounting/AccountingEntryMapper.java @Mapper public interface AccountingEntryMapper { List selectList(AccountingEntrySearchParam p); AccountingEntryResp selectById(Long id); int insert(AccountingEntrySaveReq req); int update(AccountingEntrySaveReq req); int delete(Long id); } ``` XML은 `ga-core/src/main/resources/mapper/accounting/AccountingEntryMapper.xml`: ```xml ``` ### Step 4. Service (api) ```java // ga-api/.../service/accounting/AccountingEntryService.java @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AccountingEntryService { private final AccountingEntryMapper mapper; public PageResponse list(AccountingEntrySearchParam p) { p.startPage(); // PageHelper 트리거 return PageResponse.of(mapper.selectList(p)); } @Transactional @DataChangeLog(target = "accounting_entry", action = "INSERT") public void create(AccountingEntrySaveReq req) { mapper.insert(req); } } ``` ### Step 5. Controller (api) ```java // ga-api/.../controller/accounting/AccountingEntryController.java @RestController @RequestMapping("/api/accounting-entries") @RequiredArgsConstructor public class AccountingEntryController { private final AccountingEntryService service; @GetMapping @RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "READ") public ApiResponse> list(AccountingEntrySearchParam p) { return ApiResponse.ok(service.list(p)); } @PostMapping @RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "CREATE") public ApiResponse create(@RequestBody @Valid AccountingEntrySaveReq req) { service.create(req); return ApiResponse.ok(); } } ``` URL 규칙: `/api/{리소스s}` — 복수형 + kebab-case. 예) `/api/accounting-entries`, `/api/agent-licenses`. ### Step 6. React 페이지 (frontend) `ga-frontend/src/pages/accounting/AccountingEntries.tsx`: ```tsx import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import PageContainer from '@/components/common/PageContainer'; import SearchForm, { SearchCondition } from '@/components/common/SearchForm'; import DataGrid from '@/components/common/DataGrid'; import ExcelExportButton from '@/components/common/ExcelExportButton'; import PermissionButton from '@/components/common/PermissionButton'; import type { ColDef } from '@ag-grid-community/core'; interface Row { id: number; settleMonth: string; entryType: string; amount: string; } const CONDITIONS: SearchCondition[] = [ { type: 'month', name: 'settleMonth', label: '정산월' }, { type: 'code', name: 'entryType', label: '유형', groupCode: 'ENTRY_TYPE' }, ]; const COLUMNS: ColDef[] = [ { field: 'settleMonth', headerName: '정산월', width: 120 }, { field: 'entryType', headerName: '유형', width: 100 }, { field: 'amount', headerName: '금액', width: 150, type: 'numericColumn' }, ]; export default function AccountingEntries() { const [params, setParams] = useState>({}); const { data, isLoading } = useQuery({ queryKey: ['accounting-entries', params], queryFn: () => axios.get('/api/accounting-entries', { params }).then(r => r.data.data), }); return (
등록
); } ``` 라우터 등록은 `App.tsx`의 `` 블록에 `} />` 한 줄 추가. --- ## 4. 자주 쓰는 패턴 ### CodeSelect — 공통코드 드롭다운 ```tsx import CodeSelect from '@/components/common/CodeSelect'; // groupCode 는 V9/V12/V55 에 등록된 공통코드 그룹 코드 ``` ### ExcelExportButton — 엑셀 다운로드 ```tsx ``` 백엔드 엔드포인트는 `ExcelService.download(mapper::selectList, columns, response)` 패턴으로 구현. ### CodeBadge — 상태 배지 ```tsx import CodeBadge from '@/components/common/CodeBadge'; // status 값에 해당하는 공통코드 label + 색상 자동 매핑 ``` ### PermissionButton — 권한 게이트 ```tsx 마감 확정 ``` 권한이 없으면 렌더링 자체를 하지 않습니다 (`return null`). ### AG Grid 서버사이드 페이징 `DataGrid` 컴포넌트는 `ClientSideRowModelModule`을 사용하고 페이징은 API에서 PageHelper 결과를 받아 컴포넌트 외부에서 처리합니다. 100만건 이상의 대용량 화면에서는 `ServerSideRowModelModule`로 교체하세요. ```tsx // 대용량 — AG Grid ServerSide 직접 사용 const datasource = { getRows: (params) => { axios.get('/api/recruit-ledger', { params: { page: params.request.startRow / 100 + 1, size: 100 } }) .then(r => params.successCallback(r.data.data.list, r.data.data.total)); }, }; ``` --- ## 5. 디자인 토큰 사용법 모든 색상·간격·그림자는 `@/theme/tokens.ts` 에서 임포트합니다. Inline style 에 직접 색상 코드를 쓰지 마세요. ```tsx import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR } from '@/theme/tokens'; // 카드 스타일 예시
// 경고 텍스트 만료 임박 // 비활성 텍스트 데이터 없음 ``` --- ## 6. 결재 워크플로우 패턴 결재가 필요한 도메인(withdraw_request, commission_dispute, settle_master 확정, incentive_payment 등)은 `approval_request` 테이블을 통해 처리합니다. ```java // target_type 은 공통코드 APPROVAL_TARGET 그룹에 등록 // withdraw_request 결재 신청 예시 (Service) ApprovalRequestSaveReq req = new ApprovalRequestSaveReq(); req.setTargetType("WITHDRAW"); // WITHDRAW / SETTLE / DISPUTE / INCENTIVE req.setTargetId(withdrawId); req.setApprovalLineCode("BASIC_3"); // approval_line.line_code approvalRequestMapper.insert(req); ``` 프론트에서는 `ApprovalProgress` 컴포넌트로 현재 결재 단계를 시각화합니다. ```tsx import ApprovalProgress from '@/components/biz/ApprovalProgress'; ``` --- ## 7. @RequirePermission / @DataChangeLog / @JsonValue Enum ### @RequirePermission ```java @GetMapping("/export") @RequirePermission(menu = "RECRUIT_LEDGER", perm = "EXPORT") public void exportExcel(HttpServletResponse response) { ... } ``` menu 값은 `ga.menu.menu_code`와 일치해야 합니다. 불일치하면 403. ### @DataChangeLog ```java @Transactional @DataChangeLog(target = "agent", action = "UPDATE") public void update(Long id, AgentSaveReq req) { mapper.update(id, req); } // → data_change_log 에 before/after JSON 자동 저장 (AOP) ``` action 값: INSERT / UPDATE / DELETE / APPROVE / REJECT. ### @JsonValue Enum ```java public enum CommissionType { RECRUIT("모집"), MAINTAIN("유지"), COLLECTION("수금"), OVERRIDE("오버라이드"), RENEWAL("갱신"), REINSTATE("부활"), PERSIST("유지보수수당"), INCENTIVE("시책"), COMMISSION("위촉"); private final String label; CommissionType(String label) { this.label = label; } @JsonValue // JSON 직렬화 시 name() 반환 public String value() { return name(); } public String label() { return label; } } ``` --- ## 8. AG Grid vs Ant Design Table 사용 기준 | 상황 | 컴포넌트 | |------|----------| | 일반 조회 목록 (< 5만건) | `DataGrid` (내부적으로 AG Grid) | | 엑셀과 동일한 인라인 편집 필요 | AG Grid `editable: true` 직접 사용 | | 단순 설정/마스터 테이블 (< 100행) | Ant Design `Table` 직접 사용 | | 대용량 서버사이드 (> 10만건) | AG Grid `ServerSideRowModelModule` | | KPI/집계 합계행 있는 화면 | `DataGrid`의 `pinnedBottomRow` prop | --- ## 9. 트러블슈팅 FAQ ### Q. @RequirePermission 에서 403 이 발생함 menu 코드가 `ga.menu.menu_code` 에 없거나 해당 역할에 권한이 없는 경우입니다. 1. `SELECT * FROM ga.menu WHERE menu_code = 'FOO';` — 메뉴가 존재하는지 확인 2. `SELECT * FROM ga.role_menu_permission WHERE menu_code = 'FOO';` — 권한 매트릭스 확인 3. V54 메뉴 INSERT SQL 이 운영 DB 에 적용됐는지 확인 (ga-api 재시작 필요) ### Q. Redis 캐시와 DB 공통코드가 다름 `POST /api/codes/refresh` 를 호출하면 Redis 공통코드 캐시를 전체 갱신합니다. 또는 Redis CLI 에서 `DEL ga:codes:*` 로 수동 삭제 후 재조회. ### Q. MyBatis XML "No result found" / "column not found" - resultType 의 필드명이 SQL 컬럼명과 다른 경우: XML 에 `` 또는 SQL 에 alias 사용 - `snake_case` → `camelCase` 자동 변환은 `MyBatisConfig` 에 `mapUnderscoreToCamelCase=true` 설정되어 있어 기본 동작 ### Q. Flyway 마이그레이션 체크섬 에러 이미 적용된 SQL 파일을 수정하면 발생합니다. 수정이 필요하면 새 버전 파일(`V{N+1}__fix.sql`)을 추가하세요. 개발 환경 긴급 수정 시: `DELETE FROM ga.flyway_schema_history WHERE version = '55';` 후 재시작. ### Q. AG Grid 화면이 흰 화면으로 보임 `@ag-grid-community/styles/ag-grid.css` 와 `ag-theme-quartz.css` import 가 `DataGrid.tsx` 에 있습니다. 페이지에서 `DataGrid` 컴포넌트를 사용하면 자동으로 적용됩니다. 직접 `AgGridReact` 를 사용할 때는 수동으로 import 필요. ### Q. 프론트 401 "토큰이 만료되었습니다" `localStorage.removeItem('accessToken')` 후 `/login` 으로 재로그인. React Query 의 `onError` 인터셉터에서 401 시 자동 `/login` 리다이렉트가 설정되어 있습니다. ### Q. 빌드 시 "Cannot find symbol" — Mapper 메서드 누락 `ga-core` 의 Mapper 인터페이스에 메서드가 없으면 `ga-api` 서비스에서 컴파일 에러. `ga-core` 의 해당 Mapper 와 XML 에 메서드를 먼저 추가하고 `ga-api` 를 빌드하세요. ### Q. 운영 DB 에서 P2~P4 API 가 "relation does not exist" 에러 운영 DB 에는 V17(또는 V22) 까지만 적용되어 있습니다. `ga-api 를 재시작`하면 Flyway 가 V18~V55 를 자동 순차 적용합니다. --- ## 10. 코드 컨벤션 ### VO 네이밍 | 클래스 | 용도 | 예시 | |--------|------|------| | `{도메인}VO` | DB row 1:1 매핑 (내부 전용) | `AgentVO` | | `{도메인}Resp` | API 응답 | `AgentResp` | | `{도메인}SaveReq` | 등록/수정 요청 (공용) | `AgentSaveReq` | | `{도메인}SearchParam` | 검색 조건 + 페이징 | `AgentSearchParam` | ### Mapper XML 규칙 - namespace 는 Mapper 인터페이스 FQCN 과 정확히 일치 - resultType 은 항상 Resp 클래스 사용 (VO 직접 반환 금지) - 동적 조건은 `` + `` 사용 (`1=1` AND 패턴 금지) - 대량 INSERT 는 `` batch 사용 ### API URL 규칙 - `/api/{리소스s}` — 복수형 kebab-case - 예: `/api/agent-contracts`, `/api/commission-disputes`, `/api/period-closes` - 단건 조회: `GET /api/{리소스s}/{id}` - 상태 변경: `POST /api/{리소스s}/{id}/approve` (동사 사용 허용)