Files
ga-commission-system/DEVELOPER_GUIDE.md
T
2026-05-14 01:21:27 +09:00

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/집계 합계행 있는 화면 DataGridpinnedBottomRow 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_casecamelCase 자동 변환은 MyBatisConfigmapUnderscoreToCamelCase=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.cssag-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 (동사 사용 허용)