# 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, PageResponse, 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` — 모든 API 응답 표준 ```java public class ApiResponse { private boolean success; private String code; // "0000" 성공 private String message; private T data; private LocalDateTime timestamp; public static ApiResponse ok(T data) { ... } public static ApiResponse ok(T data, String msg) { ... } public static ApiResponse ok() { ... } public static ApiResponse fail(ErrorCode e) { ... } public static ApiResponse fail(String code, String msg) { ... } } ``` ### `PageResponse` — PageHelper 연동 ```java public class PageResponse { private List list; private int pageNum, pageSize, pages; private long total; public static PageResponse of(List list) { PageInfo 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 children = new ArrayList<>(); } ``` ### `GridSearchParam extends SearchParam` — AG Grid 서버사이드용 ```java public class GridSearchParam extends SearchParam { private List> filterModel; // AG Grid 필터 private List> 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` (선택적 상속) ```java public interface BaseMapper { T selectById(Long id); List 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 { ... } ``` ### `EncryptTypeHandler` — AES-256 컬럼 암호화 - **중요**: `@Component` 등록하면 mybatis-spring-boot-starter 가 자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠. - 사용처: `` 형태로 컬럼별 명시. - `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 AND name LIKE CONCAT('%', #{searchKeyword}, '%') AND code = #{searchKeyword} AND created_at >= #{startDate}::timestamp AND created_at <= #{endDate}::timestamp + interval '1 day' AND status = #{status} ``` --- ## 4. 인증/권한 (`auth/`, `security/`, `aop/`) ### JWT - `JwtTokenProvider`: HS256, 액세스 2 시간 / 리프레시 7 일 - `JwtAuthFilter`: `Authorization: Bearer ` → 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 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 build(List flat, Map> 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 void exportLargeExcel(String fileName, Class clazz, Supplier> 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 ExcelImportResult importLargeExcel(MultipartFile file, Class clazz, int batchSize, Consumer> 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` (처음 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 = { success: boolean; code: string; message: string; data: T; }; export type PageResponse = { 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/`) **데이터 표시** - `` — Ant Select + 공통코드 - `` - `` — 색 자동 - 초록: CONFIRMED/APPROVED/ACTIVE/PAID - 파랑: PENDING/CALCULATED - 빨강: HOLD/SUSPEND/REJECTED - 회색: DRAFT/NEW - `` — 정산월 - `` — 양수 검정 / 음수 빨강 / 콤마 / null → "-" **검색 / 목록** - `` - `` — Ant Design Table 래퍼 (단순 목록, 페이징, 행 클릭) - `` — AG Grid Community 래퍼 (서버사이드 페이징/정렬/필터, 셀 편집, 컬럼 고정, 합계행 `pinnedBottomRow`, 엑셀 내보내기) **액션** - `등록` - `` - `` - `` - `` — 변경이력 타임라인 ### 동적 라우터 (`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 은 모든 테스트가 사용해야 함.