506 lines
17 KiB
Markdown
506 lines
17 KiB
Markdown
# 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<AccountingEntryResp> 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
|
|
<mapper namespace="com.ga.core.mapper.accounting.AccountingEntryMapper">
|
|
<select id="selectList" resultType="com.ga.core.vo.accounting.AccountingEntryResp">
|
|
SELECT id, settle_month, entry_type, amount, created_at
|
|
FROM ga.accounting_entry
|
|
<where>
|
|
<if test="settleMonth != null">AND settle_month = #{settleMonth}</if>
|
|
<if test="entryType != null">AND entry_type = #{entryType}</if>
|
|
</where>
|
|
ORDER BY id DESC
|
|
</select>
|
|
</mapper>
|
|
```
|
|
|
|
### 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<AccountingEntryResp> 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<PageResponse<AccountingEntryResp>> list(AccountingEntrySearchParam p) {
|
|
return ApiResponse.ok(service.list(p));
|
|
}
|
|
|
|
@PostMapping
|
|
@RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "CREATE")
|
|
public ApiResponse<Void> 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<Row>[] = [
|
|
{ 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<Record<string, unknown>>({});
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['accounting-entries', params],
|
|
queryFn: () => axios.get('/api/accounting-entries', { params }).then(r => r.data.data),
|
|
});
|
|
|
|
return (
|
|
<PageContainer title="회계 전표">
|
|
<SearchForm conditions={CONDITIONS} onSearch={setParams} />
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8, gap: 8 }}>
|
|
<ExcelExportButton url="/api/accounting-entries/excel" params={params} />
|
|
<PermissionButton menuCode="ACCOUNTING_ENTRY" permCode="CREATE" type="primary">
|
|
등록
|
|
</PermissionButton>
|
|
</div>
|
|
<DataGrid rows={data?.list} columns={COLUMNS} loading={isLoading} />
|
|
</PageContainer>
|
|
);
|
|
}
|
|
```
|
|
|
|
라우터 등록은 `App.tsx`의 `<Routes>` 블록에 `<Route path="/accounting/entries" element={<AccountingEntries />} />` 한 줄 추가.
|
|
|
|
---
|
|
|
|
## 4. 자주 쓰는 패턴
|
|
|
|
### CodeSelect — 공통코드 드롭다운
|
|
|
|
```tsx
|
|
import CodeSelect from '@/components/common/CodeSelect';
|
|
|
|
// groupCode 는 V9/V12/V55 에 등록된 공통코드 그룹 코드
|
|
<CodeSelect groupCode="SETTLE_STATUS" value={status} onChange={setStatus} />
|
|
```
|
|
|
|
### ExcelExportButton — 엑셀 다운로드
|
|
|
|
```tsx
|
|
<ExcelExportButton
|
|
url="/api/recruit-ledger/excel"
|
|
params={params} // 현재 검색 조건 그대로 전달
|
|
label="엑셀 다운로드"
|
|
/>
|
|
```
|
|
|
|
백엔드 엔드포인트는 `ExcelService.download(mapper::selectList, columns, response)` 패턴으로 구현.
|
|
|
|
### CodeBadge — 상태 배지
|
|
|
|
```tsx
|
|
import CodeBadge from '@/components/common/CodeBadge';
|
|
|
|
// status 값에 해당하는 공통코드 label + 색상 자동 매핑
|
|
<CodeBadge groupCode="DISPUTE_STATUS" value={row.status} />
|
|
```
|
|
|
|
### PermissionButton — 권한 게이트
|
|
|
|
```tsx
|
|
<PermissionButton menuCode="PERIOD_CLOSE" permCode="APPROVE">
|
|
마감 확정
|
|
</PermissionButton>
|
|
```
|
|
|
|
권한이 없으면 렌더링 자체를 하지 않습니다 (`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';
|
|
|
|
// 카드 스타일 예시
|
|
<div style={{
|
|
background: '#fff',
|
|
borderRadius: RADIUS.lg, // 16px
|
|
boxShadow: SHADOW.md,
|
|
border: `1px solid ${GRAY[200]}`,
|
|
padding: 24,
|
|
}}>
|
|
|
|
// 경고 텍스트
|
|
<span style={{ color: COLOR_ERROR }}>만료 임박</span>
|
|
|
|
// 비활성 텍스트
|
|
<span style={{ color: GRAY[500] }}>데이터 없음</span>
|
|
```
|
|
|
|
---
|
|
|
|
## 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';
|
|
|
|
<ApprovalProgress targetType="WITHDRAW" targetId={withdrawId} />
|
|
```
|
|
|
|
---
|
|
|
|
## 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 에 `<resultMap>` 또는 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 직접 반환 금지)
|
|
- 동적 조건은 `<where>` + `<if>` 사용 (`1=1` AND 패턴 금지)
|
|
- 대량 INSERT 는 `<foreach>` 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` (동사 사용 허용)
|