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>
19 KiB
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
BizException→ApiResponse.fail(errorCode)MethodArgumentNotValidException→ 필드별 에러 목록AccessDeniedException→ 403DuplicateKeyException→ "중복된 데이터입니다"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)
INSERT시created_at,created_by자동UPDATE시updated_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 >= #{startDate}::timestamp</if>
<if test="endDate != null">AND created_at <= #{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_dataJSONB 저장 (비동기)
@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 / 100tax(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
- 업로드:
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_logJSONB 적재- 둘 다
@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/EMAILEncryptUtilTest(6) — round-trip, 한글, 짧은 키 패딩MenuTreeBuilderTest(6) — 트리 빌드, 정렬, 고아 노드PermissionCheckerTest(4) — userId null 단락, mapper 위임CommonCodeServiceTest(14) — 그룹·상세 CRUD, 시스템 코드 잠금
주의: @Cacheable/@CacheEvict 는 단위 테스트(직접 인스턴스 호출)에서 동작하지 않음 — 비즈니스 로직만 검증.
Mockito strict stubbing → @BeforeEach 의 stub 은 모든 테스트가 사용해야 함.