17 KiB
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 권장)
# Docker 로 PostgreSQL + Redis 한 번에 띄우기
docker compose up -d postgres redis
운영 DB(192.168.0.60:55432/trading_ai/ga)에 바로 붙을 경우 Docker 불필요.
백엔드 실행
# 프로젝트 루트에서
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
# 로컬 DB 사용 시 (기본 프로파일)
./gradlew :ga-api:bootRun
프론트엔드 실행
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 생성.
-- 예: 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종 생성.
// 검색 파라미터
@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)
// 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:
<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)
// 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)
// 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:
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 — 공통코드 드롭다운
import CodeSelect from '@/components/common/CodeSelect';
// groupCode 는 V9/V12/V55 에 등록된 공통코드 그룹 코드
<CodeSelect groupCode="SETTLE_STATUS" value={status} onChange={setStatus} />
ExcelExportButton — 엑셀 다운로드
<ExcelExportButton
url="/api/recruit-ledger/excel"
params={params} // 현재 검색 조건 그대로 전달
label="엑셀 다운로드"
/>
백엔드 엔드포인트는 ExcelService.download(mapper::selectList, columns, response) 패턴으로 구현.
CodeBadge — 상태 배지
import CodeBadge from '@/components/common/CodeBadge';
// status 값에 해당하는 공통코드 label + 색상 자동 매핑
<CodeBadge groupCode="DISPUTE_STATUS" value={row.status} />
PermissionButton — 권한 게이트
<PermissionButton menuCode="PERIOD_CLOSE" permCode="APPROVE">
마감 확정
</PermissionButton>
권한이 없으면 렌더링 자체를 하지 않습니다 (return null).
AG Grid 서버사이드 페이징
DataGrid 컴포넌트는 ClientSideRowModelModule을 사용하고 페이징은 API에서 PageHelper 결과를 받아 컴포넌트 외부에서 처리합니다. 100만건 이상의 대용량 화면에서는 ServerSideRowModelModule로 교체하세요.
// 대용량 — 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 에 직접 색상 코드를 쓰지 마세요.
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 테이블을 통해 처리합니다.
// 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 컴포넌트로 현재 결재 단계를 시각화합니다.
import ApprovalProgress from '@/components/biz/ApprovalProgress';
<ApprovalProgress targetType="WITHDRAW" targetId={withdrawId} />
7. @RequirePermission / @DataChangeLog / @JsonValue Enum
@RequirePermission
@GetMapping("/export")
@RequirePermission(menu = "RECRUIT_LEDGER", perm = "EXPORT")
public void exportExcel(HttpServletResponse response) { ... }
menu 값은 ga.menu.menu_code와 일치해야 합니다. 불일치하면 403.
@DataChangeLog
@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
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 에 없거나 해당 역할에 권한이 없는 경우입니다.
SELECT * FROM ga.menu WHERE menu_code = 'FOO';— 메뉴가 존재하는지 확인SELECT * FROM ga.role_menu_permission WHERE menu_code = 'FOO';— 권한 매트릭스 확인- 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=1AND 패턴 금지) - 대량 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(동사 사용 허용)