Files
GA Pro 035175e895 docs: Agent Teams 실행 가이드 + 8개 도메인 spec 추가
00_PROMPTS.md  — Claude Code 실행 프롬프트 1~6
01_DB_SCHEMA.md — V1~V17 Flyway 스키마 + 프로젝트 뼈대
02_COMMON_SPEC.md — ga-common 공통 프레임워크 (모델/예외/MyBatis/AOP/엑셀/공통컴포넌트)
03_CORE_SPEC.md — ga-core VO 설계 원칙 + Mapper 패턴
04_BATCH_SPEC.md — ga-batch 정산 8 Step + DataReceiver/MappingEngine
05_API_SPEC.md — ga-api Controller/Service 표준 패턴 + 엔드포인트 목록
06_FRONTEND_SPEC.md — ga-frontend 13개 화면 + 공통 컴포넌트 사용법
07_INFRA_SPEC.md — Docker/Nginx + README + DEVELOPER_GUIDE
08_DBA_SPEC.md — V13~V15 인덱스/파티셔닝/뷰

실제 구현된 ga-commission-system 기준으로 작성. 코드는 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:15:13 +09:00

19 KiB
Raw Permalink Blame History

02_COMMON_SPEC — ga-common 공통 프레임워크

모든 다른 모듈이 의존하는 핵심. 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선.

패키지 구조

ga-common/src/main/java/com/ga/common/
├── annotation/   # @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
├── aop/          # PermissionAspect, DataChangeLogAspect, ApiLogAspect
├── auth/         # JwtTokenProvider, JwtAuthFilter, LoginUser
├── code/         # CommonCodeService/Controller/Mapper (Redis 캐시)
├── config/       # RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
├── exception/    # ErrorCode (enum), BizException, GlobalExceptionHandler
├── excel/        # ExcelService (Export 100만, Import 70만)
├── file/         # FileService/Controller (multipart 업로드/다운로드)
├── grid/         # GridSearchParam, GridResponse (AG Grid 서버사이드)
├── menu/         # MenuService/Controller, MenuTreeBuilder, PermissionChecker
├── model/        # ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
├── mybatis/      # BaseMapper, JsonTypeHandler, EncryptTypeHandler(+Initializer), AuditInterceptor
├── notification/ # NotificationService/Controller
├── security/     # SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandlerImpl
├── system/       # SystemConfigService, DataChangeLogService, ApiAccessLogService, SystemLogController
└── util/         # DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil

ga-common/src/main/resources/
├── mybatis/mybatis-config.xml
└── mapper/common/   # CommonCodeMapper.xml, MenuMapper.xml, FileMapper.xml, SystemConfigMapper.xml

build.gradle 핵심: api 'org.springframework.boot:spring-boot-starter-{web,security,aop,validation,data-redis,cache}', api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3', api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0', api 'io.jsonwebtoken:jjwt-api:0.12.5', api 'org.apache.poi:poi-ooxml:5.2.5', api 'com.monitorjbl:xlsx-streamer:2.2.0', api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'


1. 공통 모델 (model/)

ApiResponse<T> — 모든 API 응답 표준

public class ApiResponse<T> {
    private boolean success;
    private String code;          // "0000" 성공
    private String message;
    private T data;
    private LocalDateTime timestamp;

    public static <T> ApiResponse<T> ok(T data) { ... }
    public static <T> ApiResponse<T> ok(T data, String msg) { ... }
    public static ApiResponse<Void> ok() { ... }
    public static <T> ApiResponse<T> fail(ErrorCode e) { ... }
    public static <T> ApiResponse<T> fail(String code, String msg) { ... }
}

PageResponse<T> — PageHelper 연동

public class PageResponse<T> {
    private List<T> list;
    private int pageNum, pageSize, pages;
    private long total;
    public static <T> PageResponse<T> of(List<T> list) {
        PageInfo<T> p = new PageInfo<>(list);
        // ...
    }
}

SearchParam — 공통 검색 파라미터

public class SearchParam {
    private int pageNum = 1, pageSize = 20;
    private String searchType, searchKeyword;
    private String startDate, endDate;
    private String status;
    private String sortField, sortOrder;     // ASC / DESC

    public void startPage() {
        PageHelper.startPage(pageNum, pageSize);
        if (sortField != null) PageHelper.orderBy(sortField + " " + sortOrder);
    }
}

TreeNode — 트리 응답 공통 (메뉴/조직)

public class TreeNode {
    private Object id, parentId;
    private String name, type;
    private int level, sortOrder;
    private List<TreeNode> children = new ArrayList<>();
}

GridSearchParam extends SearchParam — AG Grid 서버사이드용

public class GridSearchParam extends SearchParam {
    private List<Map<String,Object>> filterModel;   // AG Grid 필터
    private List<Map<String,String>> sortModel;     // AG Grid 정렬
    public String buildFilterSql() { ... }
}

2. 예외 처리 (exception/)

ErrorCode enum

SUCCESS("0000", 200), BAD_REQUEST("E400", 400),
UNAUTHORIZED("E401", 401), FORBIDDEN("E403", 403),
NOT_FOUND("E404", 404), INVALID_PARAMETER("E411", 400),
INTERNAL_ERROR("E500", 500),

TOKEN_EXPIRED("A001", 401), LOGIN_FAIL("A003", 401), ACCOUNT_LOCKED("A004", 403),

ALREADY_CONFIRMED("B001", 400), RULE_VERSION_CONFLICT("B002", 409),
APPROVAL_REQUIRED("B003", 400), SYSTEM_CODE_LOCKED("B008", 400),
DUPLICATE_DATA("B009", 409), BATCH_RUNNING("B010", 409),

FILE_NOT_FOUND("F001", 404), FILE_UPLOAD_FAIL("F002", 500);

BizException

@Getter
public class BizException extends RuntimeException {
    private final ErrorCode errorCode;
    private final String customMessage;
    public BizException(ErrorCode e) { ... }
    public BizException(ErrorCode e, String msg) { ... }
}

GlobalExceptionHandler

  • BizExceptionApiResponse.fail(errorCode)
  • MethodArgumentNotValidException → 필드별 에러 목록
  • AccessDeniedException → 403
  • DuplicateKeyException → "중복된 데이터입니다"
  • Exception → 500 + log.error

3. MyBatis 공통 (mybatis/)

BaseMapper<T> (선택적 상속)

public interface BaseMapper<T> {
    T selectById(Long id);
    List<T> selectList(SearchParam param);
    int insert(T entity);
    int update(T entity);
    int deleteById(Long id);
    int countByCondition(SearchParam param);
}

JsonTypeHandler — JSONB ↔ JsonNode

@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> { ... }

EncryptTypeHandler — AES-256 컬럼 암호화

  • 중요: @Component 등록하면 mybatis-spring-boot-starter 가 자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠.
  • 사용처: <result property="residentNo" column="resident_no" typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/> 형태로 컬럼별 명시.
  • EncryptTypeHandlerInitializer (@Configuration) 가 부팅 시 EncryptUtil 을 정적 주입.
  • 적용 컬럼: agent.resident_no, agent.account_no, payment.account_no.

AuditInterceptor (Plugin)

  • INSERTcreated_at, created_by 자동
  • UPDATEupdated_at, updated_by 자동
  • 현재 로그인 사용자: SecurityUtil.currentUserId()

공통 SQL 조각 (mapper/common/common-sql.xml)

<sql id="searchCondition">
    <if test="searchKeyword != null and searchKeyword != ''">
        <choose>
            <when test="searchType == 'name'">AND name LIKE CONCAT('%', #{searchKeyword}, '%')</when>
            <when test="searchType == 'code'">AND code = #{searchKeyword}</when>
        </choose>
    </if>
    <if test="startDate != null">AND created_at &gt;= #{startDate}::timestamp</if>
    <if test="endDate != null">AND created_at &lt;= #{endDate}::timestamp + interval '1 day'</if>
    <if test="status != null and status != ''">AND status = #{status}</if>
</sql>

4. 인증/권한 (auth/, security/, aop/)

JWT

  • JwtTokenProvider: HS256, 액세스 2 시간 / 리프레시 7 일
  • JwtAuthFilter: Authorization: Bearer <token> → SecurityContext 에 LoginUser 주입
  • LoginUser: userId, loginId, userName, roleCodes, agentId

SecurityConfig

  • 기본 permitAll: /api/auth/**, /swagger-ui/**, /v3/api-docs/**, /api/health
  • 그 외 authenticated() + JwtAuthFilter 적용
  • 세션 STATELESS, CSRF disable, CORS allow * (개발), method-level @PreAuthorize 미사용 — @RequirePermission 으로 통일

어노테이션 + AOP

@RequirePermission(menu = "ORG_AGENT", perm = "READ")

  • PermissionAspectPermissionChecker.hasPermission(userId, menuCode, permCode) 호출
  • falseBizException(ErrorCode.FORBIDDEN)

@DataChangeLog(menu = "RULE", table = "payout_rule")

  • DataChangeLogAspect 가 메서드 실행 전 PK 로 before 조회 → 실행 → after 조회
  • data_change_logbefore_data/after_data JSONB 저장 (비동기)

@Mask(MaskType.RRN) (응답 필드용 어노테이션)

  • JsonSerializer 또는 @JsonComponent 로 직렬화 시 마스킹

ApiLogAspect

  • 모든 @RestController 자동 적용
  • request_uri, params (민감정보 마스킹), response_code, response_time_ms
  • @Async 비동기 저장 (api_access_log)

5. 유틸리티 (util/)

DateUtil

  • today()"2026-05-10"
  • getSettleMonth()"202605", getSettleMonth(LocalDate) 동일
  • getMonthRange("202605")["2026-05-01", "2026-05-31"]
  • calcMonthDiff(from, to), addMonth("202401", 3)"202404"
  • parseDate(s, "yyyy-MM-dd"), format(date, pattern)

MoneyUtil (BigDecimal HALF_UP, 소수점 2)

  • add/sub/mul/div, null 안전
  • pct(amount, ratePct)amount × ratePct / 100
  • tax(amount) → 기본 3.3%, tax(amount, rate) 사용자 지정
  • fmt/fmtDec/fmtSign (콤마 포맷)
  • isZero/isPositive/isNegative

MaskUtil

  • mask("홍길동", NAME)"홍*동"
  • mask("010-1234-5678", PHONE)"010-****-5678" (11자리/10자리 분기, 9자리 이하 원본)
  • mask("880101-1234567", RRN)"880101-*******"
  • mask("110-123-456789", ACCOUNT)"110-***-******"
  • mask("aaa@bbb.com", EMAIL)"a**@bbb.com"

EncryptUtil (@Component)

  • AES-256/CBC/PKCS5Padding, IV = key 의 앞 16 byte (운영은 별도 관리 권장)
  • 키 32 byte 미만이면 0 패딩
  • encrypt(plain) / decrypt(b64) round-trip
  • 실패 시 BizException(ErrorCode.INTERNAL_ERROR)

SecurityUtil

  • currentUserId(), currentLoginUser(), hasRole(roleCode)

6. 공통코드 (code/) — Redis 캐시

@Service @RequiredArgsConstructor
public class CommonCodeService {
    @Cacheable(value = "commonCode", key = "#groupCode")
    public List<CommonCodeVO> getCodesByGroup(String groupCode) {...}

    public String getCodeName(String groupCode, String code) {...}
    public void   validateCode(String groupCode, String code)  // 미존재 시 INVALID_PARAMETER

    @CacheEvict(value = "commonCode", key = "#groupCode")
    public void refreshCache(String groupCode) {}

    @CacheEvict(value = "commonCode", allEntries = true)
    public void refreshAll() {}

    // 그룹 CRUD: 시스템 그룹(is_system='Y')은 update/delete 시 SYSTEM_CODE_LOCKED
    // 상세 CRUD: 동일 (group, code) 중복 시 DUPLICATE_DATA
    //          시스템 그룹 코드 update 시 code/groupCode 변경 무시
}

REST: GET /api/common/codes/{groupCode} (캐시 적중), POST /api/common/codes/groups, PUT/DELETE /api/common/codes/groups/{groupCode}, 상세 코드 동일.


7. 메뉴 / 권한 (menu/)

MenuTreeBuilder (정적 유틸)

public static List<MenuResp> build(List<MenuVO> flat,
                                   Map<Long, List<String>> permissionsByMenuId)
// - parent_menu_id null → root
// - 부모 미존재 (고아) → root 로 끌어올림
// - sortOrder 로 재귀 정렬
// - permissionsByMenuId null 이면 권한 주입 스킵

PermissionChecker

@Component @RequiredArgsConstructor
public class PermissionChecker {
    @Cacheable(value = "userPerm",
        key = "T(java.lang.String).format('%d_%s_%s', #userId, #menuCode, #permCode)",
        unless = "#result == false")
    public boolean hasPermission(Long userId, String menuCode, String permCode) {
        if (userId == null) return false;
        return menuMapper.hasPermission(userId, menuCode, permCode) > 0;
    }
}

MenuService

  • getMyMenus(userId) → 사용자 권한 기반 메뉴 트리 (CTE 재귀)
  • getMenuTree() → 전체 메뉴 트리 (관리자용)

REST: GET /api/menus/my, GET /api/menus/tree, 메뉴 CRUD + 권한 매트릭스.


8. 엑셀 엔진 (excel/)

성능 목표: 다운로드 100만건 < 2분 / 업로드 70만건 < 3분

@ExcelColumn 어노테이션

@Target(FIELD) @Retention(RUNTIME)
public @interface ExcelColumn {
    String header();
    int order();
    int width() default 15;
    String format() default "";          // "#,##0", "yyyy-MM-dd"
    String codeGroup() default "";        // 자동 코드명 변환
    boolean masked() default false;       // 다운로드 시 마스킹
    String defaultValue() default "";
    boolean required() default false;
}

ExcelService

Export — SXSSFWorkbook(100) + MyBatis Cursor

public <T> void exportLargeExcel(String fileName, Class<T> clazz,
        Supplier<Cursor<T>> cursorSupplier, HttpServletResponse response) {
    // 1) SXSSFWorkbook (메모리 100행만 유지, gzip 임시파일)
    // 2) ExcelColumnParser.parse(clazz) → 헤더 자동 생성
    // 3) Cursor 로 1건씩 읽어 Row 생성
    // 4) 10만건마다 progress 로그
    // 5) workbook.write(response.getOutputStream()), workbook.dispose()
}

Import — xlsx-streamer (StreamingReader) + 배치 INSERT

public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
        int batchSize, Consumer<List<T>> batchConsumer) {
    // 1) StreamingReader (rowCacheSize 100, bufferSize 4KB)
    // 2) 1행 헤더 스킵
    // 3) ExcelRowParser.parse(row, clazz) → VO
    // 4) Bean Validation → 실패 시 ExcelErrorRow 누적
    // 5) batchSize(1000) 마다 batchConsumer.accept(batch)
    // 6) 잔여분 flush
    // 7) 에러 있으면 에러 엑셀 생성 → result.errorFileId
}

ExcelImportResult: totalCount, successCount, errorCount, errorFileId, List<ExcelErrorRow> (처음 100건).

Template Download: exportTemplate(fileName, clazz, response) — 빈 헤더 + codeGroup 컬럼에 드롭다운 유효성검사 + required=true 헤더 빨간색.


9. 파일 / 알림 / 시스템관리

FileService

  • 업로드: MultipartFilefile_storage 적재, groupId 로 그룹화, 최대 100MB
  • 다운로드: download_count 증가, Content-Disposition 한글 파일명 인코딩
  • 삭제: 논리 삭제 (is_deleted='Y')

REST: POST /api/files/upload (groupId param), GET /api/files/{fileId}, DELETE /api/files/{fileId}, GET /api/files?groupId=...

NotificationService

  • 생성/조회/읽음 처리, 미읽음 카운트 (/api/notifications/unread-count)
  • 발송은 비동기 (Redis pub/sub 또는 @Async)

SystemConfigService

  • key-value 설정. is_encrypted='Y' 인 값은 응답 시 마스킹.
  • Cache 적용 가능 (commonCode 와 동일 패턴)

로그 서비스 (비동기)

  • ApiAccessLogService.save(...)api_access_log 적재
  • DataChangeLogService.save(...)data_change_log JSONB 적재
  • 둘 다 @Async("taskExecutor") (AsyncConfig)

REST: /api/system/logs/login, /api/system/logs/api, /api/system/logs/changes (/api/system/config, /api/system/config/{key} PUT)


10. ga-frontend 공통 (함께 작성)

폴더 구조

ga-frontend/src/
├── api/             # request.ts (axios) + 도메인별 함수
├── components/
│   ├── common/      # 본 spec 의 공통 컴포넌트
│   └── biz/         # 업무 재사용
├── hooks/           # useCommonCodes, useMenuTree, usePermission, useSearchParam
├── layouts/         # MainLayout
├── pages/           # 화면 (06_FRONTEND_SPEC.md)
├── router/          # 동적 라우터
├── stores/          # Zustand (auth, menu, notification)
├── types/           # TS 타입
└── utils/           # formatMoney, formatDate

api/request.ts

const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '' });
api.interceptors.request.use(c => {
  const t = localStorage.getItem('accessToken');
  if (t) c.headers.Authorization = `Bearer ${t}`;
  return c;
});
api.interceptors.response.use(r => r, err => {
  if (err.response?.status === 401) { localStorage.removeItem('accessToken'); location.href = '/login'; }
  if (err.response?.status === 403) message.error('권한이 없습니다');
  return Promise.reject(err);
});
export type ApiResponse<T> = { success: boolean; code: string; message: string; data: T; };
export type PageResponse<T> = { list: T[]; pageNum: number; pageSize: number; total: number; pages: number; };

공통 훅 (hooks/)

  • useCommonCodes(groupCode) — React Query 캐시 (staleTime: 10분)
  • useMenuTree() — 사용자 메뉴 트리
  • usePermission(menuCode){ canRead, canCreate, canUpdate, canDelete, canApprove, canExport }
  • useSearchParam(defaults) — URL 동기화 + 페이징
  • useGridApi() — AG Grid 서버사이드 datasource

공통 컴포넌트 (components/common/)

데이터 표시

  • <CodeSelect groupCode="AGENT_STATUS" /> — Ant Select + 공통코드
  • <CodeRadio groupCode="PAY_CYCLE" />
  • <CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" /> — 색 자동
    • 초록: CONFIRMED/APPROVED/ACTIVE/PAID
    • 파랑: PENDING/CALCULATED
    • 빨강: HOLD/SUSPEND/REJECTED
    • 회색: DRAFT/NEW
  • <MonthPicker /> — 정산월
  • <MoneyText value={amt} /> — 양수 검정 / 음수 빨강 / 콤마 / null → "-"

검색 / 목록

  • <SearchForm conditions={[ {type:'text'|'code'|'dateRange'|'month', name, label, ...} ]} onSearch onReset />
  • <DataTable> — Ant Design Table 래퍼 (단순 목록, 페이징, 행 클릭)
  • <DataGrid> — AG Grid Community 래퍼 (서버사이드 페이징/정렬/필터, 셀 편집, 컬럼 고정, 합계행 pinnedBottomRow, 엑셀 내보내기)

액션

  • <PermissionButton permCode="CREATE">등록</PermissionButton>
  • <ExcelExportButton url="/api/.../export" params fileName showProgress maxRows={1000000} />
  • <ExcelImportButton url=... templateUrl=... onComplete maxSize={100} />
  • <FileUpload groupId="settle-202605" maxCount={5} />
  • <AuditTrail table="payout_rule" recordId={id} /> — 변경이력 타임라인

동적 라우터 (router/)

  • 로그인 → GET /api/menus/my → 메뉴 트리
  • menu.component_path 기반 React.lazy() 동적 import
  • 좌측 Sider 자동 렌더링, 권한 없는 URL → 403 페이지

레이아웃 (layouts/MainLayout.tsx)

  • Ant Design Layout (Sider + Header + Content)
  • Header: 알림 벨 (숫자 뱃지), 사용자 드롭다운, 정산월 표시
  • Sider: 동적 메뉴 트리 (접기/펼치기, 아이콘)
  • Content: Breadcrumb + 페이지

11. 단위 테스트 가이드 (선택)

spring-boot-starter-test 가 JUnit5 + Mockito + AssertJ 모두 포함.

상:

  • MoneyUtilTest (10) — pct/tax/mul/div HALF_UP, null 안전
  • DateUtilTest (10) — 정산월 범위, 윤년/평년, 월 차이/가산
  • MaskUtilTest (7) — NAME/PHONE/RRN/ACCOUNT/EMAIL
  • EncryptUtilTest (6) — round-trip, 한글, 짧은 키 패딩
  • MenuTreeBuilderTest (6) — 트리 빌드, 정렬, 고아 노드
  • PermissionCheckerTest (4) — userId null 단락, mapper 위임
  • CommonCodeServiceTest (14) — 그룹·상세 CRUD, 시스템 코드 잠금

주의: @Cacheable/@CacheEvict 는 단위 테스트(직접 인스턴스 호출)에서 동작하지 않음 — 비즈니스 로직만 검증. Mockito strict stubbing → @BeforeEach 의 stub 은 모든 테스트가 사용해야 함.