# 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` (동사 사용 허용)
