# 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 응답 표준
```java
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 연동
```java
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` — 공통 검색 파라미터
```java
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` — 트리 응답 공통 (메뉴/조직)
```java
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 서버사이드용
```java
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
```java
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`
```java
@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`
- `BizException` → `ApiResponse.fail(errorCode)`
- `MethodArgumentNotValidException` → 필드별 에러 목록
- `AccessDeniedException` → 403
- `DuplicateKeyException` → "중복된 데이터입니다"
- `Exception` → 500 + log.error

---

## 3. MyBatis 공통 (`mybatis/`)

### `BaseMapper<T>` (선택적 상속)
```java
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
```java
@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)
- `INSERT` 시 `created_at`, `created_by` 자동
- `UPDATE` 시 `updated_at`, `updated_by` 자동
- 현재 로그인 사용자: `SecurityUtil.currentUserId()`

### 공통 SQL 조각 (`mapper/common/common-sql.xml`)
```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")`**
- `PermissionAspect` 가 `PermissionChecker.hasPermission(userId, menuCode, permCode)` 호출
- `false` → `BizException(ErrorCode.FORBIDDEN)`

**`@DataChangeLog(menu = "RULE", table = "payout_rule")`**
- `DataChangeLogAspect` 가 메서드 실행 전 PK 로 before 조회 → 실행 → after 조회
- `data_change_log` 에 `before_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 캐시

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

### `PermissionChecker`
```java
@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` 어노테이션
```java
@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`**
```java
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**
```java
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`
- 업로드: `MultipartFile` → `file_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`
```typescript
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 은 모든 테스트가 사용해야 함.
