feat: 1200%룰 이연 분급 도래월 지급(PayInstallmentStep) + 코드베이스 신입교육 주석

PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을
도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고
도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움.
- BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록
- InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등
  (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리)
- RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount
- 단위테스트 3건(정상지급/멱등/계약미존재) GREEN

신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가
(동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-12 23:40:28 +09:00
parent 5f43c945f0
commit 17ab1098ec
345 changed files with 4759 additions and 441 deletions
@@ -5,6 +5,19 @@ import lombok.Data;
import java.math.BigDecimal;
/**
* 펌뱅킹 이체 요청 DTO (외부 은행/KFTC 시스템으로 보낼 입력값).
* <p>
* 데이터 흐름: 설계사가 출금 신청 → 출금 도메인이 이 객체를 만들어
* {@link BankTransferAdapter#requestTransfer}에 넘김 → 어댑터가 외부 펌뱅킹 API 호출.
* <p>
* 신입 메모:
* - withdrawRequestId는 같은 출금이 두 번 이체되지 않도록 하는 "멱등키"다.
* 네트워크 재시도가 발생해도 외부 시스템이 중복 이체를 막을 수 있게 함께 보낸다.
* - accountNo는 평문(마스킹 안 된 실제 계좌번호)이다. 이체에는 실제 번호가 필요하므로
* 여기서는 평문을 담되, 로그로 남길 때는 어댑터 내부에서 마스킹한다.
* - amount는 돈이므로 double이 아닌 {@link BigDecimal}을 쓴다(부동소수 오차 방지).
*/
@Data
@Builder
public class BankTransferRequest {
@@ -5,6 +5,17 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 펌뱅킹 이체 응답 DTO (외부 은행/KFTC 시스템이 돌려준 결과값).
* <p>
* {@link BankTransferAdapter#requestTransfer} 또는 {@link BankTransferAdapter#queryStatus}의
* 반환값으로, 호출한 출금 도메인이 이 결과를 보고 출금 상태를 갱신/대사(對査)한다.
* <p>
* 신입 메모:
* - txId는 외부 시스템이 부여한 거래 식별자다. 나중에 상태를 다시 조회(queryStatus)할 때 쓴다.
* - status는 {@link BankTransferStatus}(접수/완료/실패)로, 우리 DB 출금 상태와 매핑된다.
* - resultCode/resultMessage는 외부 시스템의 원본 응답으로, 실패 원인 추적용 로그에 남긴다.
*/
@Data
@Builder
public class BankTransferResponse {
@@ -1,7 +1,13 @@
package com.ga.common.adapter.bank;
/**
* 펌뱅킹 이체의 진행 상태.
* <p>
* 외부 응답 코드를 우리 시스템이 이해하는 3단계로 정규화한 값이다.
* 출금 도메인은 이 값을 보고 "이체 접수됨/입금 완료/실패"를 판단한다.
*/
public enum BankTransferStatus {
ACCEPTED, // 접수
SETTLED, // 입금 완료
FAILED // 실패
ACCEPTED, // 접수 — 외부에 요청이 받아들여졌으나 아직 최종 입금 전(추후 queryStatus로 확정)
SETTLED, // 입금 완료 — 수취 계좌에 실제 입금까지 끝난 최종 성공 상태
FAILED // 실패 — 잔액부족/계좌오류 등으로 이체가 거절됨
}
@@ -1,6 +1,11 @@
package com.ga.common.adapter.message;
/**
* 메시지 발송 채널 종류.
* <p>
* {@link MessageRequest#getChannel()}에 담겨 어댑터가 어느 통로로 보낼지 결정한다.
*/
public enum MessageChannel {
SMS,
KAKAO
SMS, // 일반 문자 (Toast SMS) — templateCode 불필요
KAKAO // 카카오 알림톡 — 사전 승인된 templateCode 필요
}
@@ -3,6 +3,18 @@ package com.ga.common.adapter.message;
import lombok.Builder;
import lombok.Data;
/**
* SMS/카카오 알림톡 발송 요청 DTO.
* <p>
* 데이터 흐름: 알림 도메인(승인/정산 등 이벤트)이 이 객체를 만들어
* {@link MessageAdapter#send}에 전달 → 어댑터가 Toast 등 외부 발송사 API를 호출한다.
* <p>
* 신입 메모:
* - channel(SMS/KAKAO)에 따라 필요한 필드가 다르다. 알림톡은 templateCode가 필수,
* 일반 SMS는 templateCode를 비워두면 된다.
* - refUserId/refType/refId는 "이 메시지가 어떤 사용자·어떤 업무 때문에 나갔는지"를
* 추적하기 위한 꼬리표다(user_notification 이력과 연결).
*/
@Data
@Builder
public class MessageRequest {
@@ -5,6 +5,13 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 메시지 발송 결과 DTO ({@link MessageAdapter#send}의 반환값).
* <p>
* 신입 메모:
* - sent(true/false)로 성공 여부를 판단하고, 실패 시 resultCode/resultMessage로 원인을 로그에 남긴다.
* - messageId는 외부 발송사가 부여한 식별자로, 추후 발송 결과 조회/문의에 사용한다.
*/
@Data
@Builder
public class MessageResponse {
@@ -3,6 +3,17 @@ package com.ga.common.adapter.push;
import lombok.Builder;
import lombok.Data;
/**
* 모바일 푸시 발송 요청 DTO.
* <p>
* 데이터 흐름: 출금/승인/정산 이벤트 발생 → 사용자의 디바이스 토큰을 조회 →
* 이 객체를 만들어 {@link PushAdapter#send}에 전달 → 어댑터가 FCM API 호출.
* <p>
* 신입 메모:
* - deviceToken은 앱이 발급받아 서버에 등록해 둔 FCM 토큰이다(단말 하나당 하나).
* - refType/refId는 푸시의 data payload로 함께 실려, 앱이 알림 탭 시 어느 화면으로
* 이동할지(예: WITHDRAW=출금 상세) 판단하는 데 쓰인다.
*/
@Data
@Builder
public class PushRequest {
@@ -5,6 +5,12 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 모바일 푸시 발송 결과 DTO ({@link PushAdapter#send}의 반환값).
* <p>
* 신입 메모: sent로 성공 여부를 판단하고, messageId(FCM이 부여한 이름/식별자)와
* resultCode/resultMessage를 발송 이력·실패 추적 로그에 남긴다.
*/
@Data
@Builder
public class PushResponse {
@@ -6,20 +6,30 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 데이터 변경 이력 자동 기록.
* 데이터 변경 이력 자동 기록 어노테이션.
*
* <p>왜 필요한가: 수수료 정산 시스템은 금액·규정을 다루므로 "누가 무엇을 언제 바꿨는지"를
* 반드시 남겨야 한다(감사 추적). 매 서비스 메서드마다 직접 로그 코드를 넣으면 누락·중복이
* 생기므로, 메서드 위에 이 어노테이션 한 줄만 붙이면 AOP가 자동으로 이력을 남기도록 했다.
*
* <p>어떻게 동작하나: 이 어노테이션이 붙은 메서드는 {@code DataChangeLogAspect}(AOP)가 가로채서
* 실행 전·후의 대상 데이터를 비교 → data_change_log 테이블의 JSONB 컬럼에 저장한다.
* 즉 이 파일은 "표시(marker)"일 뿐, 실제 기록 로직은 aop 패키지의 Aspect가 담당한다.
*
* <pre>
* @DataChangeLog(menu = "RULE", table = "payout_rule")
* public void updatePayoutRule(PayoutRuleVO vo) { ... }
* </pre>
*
* 동작: 메서드 실행 전·후의 대상 데이터를 비교 → data_change_log JSONB 컬럼에 저장.
*/
// @Target(METHOD): 메서드에만 붙일 수 있다.
// @Retention(RUNTIME): 실행 중 리플렉션으로 읽을 수 있어야 AOP가 감지 가능하므로 RUNTIME 필수.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataChangeLog {
/** 어떤 메뉴(화면)에서 발생한 변경인지. 이력 조회 시 분류 키로 쓰인다. */
String menu();
/** 변경 대상 DB 테이블명. 이력에 함께 기록된다. */
String table();
/** 액션 유형. 자동 추정 안 될 때 명시 */
/** 액션 유형(INSERT/UPDATE/DELETE). 비워두면 메서드 이름으로 자동 추정, 자동 추정이 틀릴 명시. */
String action() default "";
}
@@ -6,17 +6,34 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 엑셀 컬럼 매핑 어노테이션. ExcelExporter / ExcelImporter 가 사용.
* 엑셀 컬럼 매핑 어노테이션. VO(데이터 클래스)의 필드에 붙여서 엑셀의 어느 열에 어떤 형식으로
* 출력/입력될지를 선언한다.
*
* <p>왜 필요한가: 정산 데이터는 엑셀 내려받기/올리기가 잦다. 필드마다 헤더명·순서·서식을 코드로
* 일일이 다루면 번거롭고 실수가 많으므로, 필드 위에 이 어노테이션만 달면 ExcelService(엑셀 처리기)가
* 리플렉션으로 읽어 자동으로 엑셀을 생성/파싱한다.
*
* <p>어떻게 쓰이나: {@code ExcelColumnParser}가 VO의 각 필드에서 이 어노테이션을 읽어
* {@code ExcelColumnInfo} 목록으로 변환하고, {@code ExcelService}가 그 목록으로 엑셀을 만든다.
*/
// @Target(FIELD): VO의 멤버 필드에만 붙인다. @Retention(RUNTIME): 실행 중 리플렉션으로 읽으므로 필수.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
/** 엑셀 첫 행에 표시될 헤더(컬럼 제목). */
String header();
/** 컬럼 표시 순서(작을수록 왼쪽). */
int order();
/** 컬럼 너비(엑셀 단위). 기본 15. */
int width() default 15;
/** 셀 표시 서식(예: 날짜/숫자 포맷). 비우면 기본 서식. */
String format() default "";
/** 공통코드 그룹. 코드값을 사람이 읽는 코드명으로 변환해 표시할 때 사용. */
String codeGroup() default "";
/** true면 민감정보(주민번호 등)를 마스킹하여 출력. */
boolean masked() default false;
/** 값이 비어있을 때 채울 기본값. */
String defaultValue() default "";
/** true면 엑셀 업로드(임포트) 시 필수 입력 컬럼으로 검증. */
boolean required() default false;
}
@@ -8,10 +8,18 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 응답 시 자동 마스킹 어노테이션 (응답 직렬화 시 적용).
* 응답 시 자동 마스킹 어노테이션. VO 필드에 붙이면 API 응답으로 직렬화될 때 해당 값이
* 마스킹(예: 홍길동 → 홍*동, 010-1234-5678 → 010-****-5678)되어 나간다.
*
* <p>왜 필요한가: 개인정보보호(주민번호·전화·계좌 등)를 위해 화면/응답에는 가린 값만 보여야 한다.
* 필드마다 가공 코드를 넣는 대신 어노테이션으로 선언하면 누락을 줄이고 일관되게 마스킹할 수 있다.
*
* <p>value 로 마스킹 방식({@link MaskType})을 지정한다(기본 NAME=이름 마스킹).
*/
// @Target(FIELD): 마스킹할 VO 필드에 붙인다. @Retention(RUNTIME): 직렬화 시점에 리플렉션으로 읽으므로 필수.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Mask {
/** 적용할 마스킹 유형(이름/전화/주민번호/계좌 등). 기본값은 이름 마스킹. */
MaskType value() default MaskType.NAME;
}
@@ -6,19 +6,28 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 메뉴+권한 단위 권한 검증.
* 메뉴+권한 단위 권한 검증 어노테이션. 컨트롤러/서비스 메서드 위에 붙이면 호출 전에
* 현재 로그인 사용자가 그 작업을 할 권한이 있는지 자동으로 검사한다.
*
* <p>왜 필요한가: "이 화면의 조회/등록/삭제를 누가 할 수 있는가"는 역할(role)마다 다르다.
* 메서드마다 권한 검사 코드를 직접 쓰면 빠뜨리기 쉬우므로, 어노테이션으로 선언하고
* AOP가 일괄 검사하도록 했다.
*
* <p>어떻게 동작하나: {@code PermissionAspect}(AOP)가 이 어노테이션을 감지해, 현재 사용자의 역할이
* role_menu_permission 테이블에 (menu, perm) 권한을 가지는지 {@code PermissionChecker}로 확인한다.
* 없으면 {@code BizException(ErrorCode.FORBIDDEN)} 을 던져 403으로 차단한다.
*
* <pre>
* @RequirePermission(menu = "ORG_AGENT", perm = "READ")
* public ApiResponse&lt;...&gt; list(...) { ... }
* </pre>
*
* 동작: 현재 사용자의 역할 → role_menu_permission 에 (menu, perm) 권한이 있는지 확인.
* 없으면 BizException(ErrorCode.FORBIDDEN).
*/
// @Target(METHOD): 메서드에만 붙인다. @Retention(RUNTIME): 실행 중 AOP가 리플렉션으로 읽으므로 필수.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
/** 권한을 검사할 메뉴 코드(예: "ORG_AGENT"). */
String menu();
/** 필요한 권한 종류(예: READ/WRITE/DELETE). 기본값은 READ(조회). */
String perm() default "READ";
}
@@ -13,30 +13,38 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 모든 @RestController 의 요청을 비동기 로깅한다.
* - URL, Method, 응답시간, 사용자
* - 민감 파라미터(비밀번호 등)는 마스킹
* 모든 @RestController 의 요청을 비동기로 접근 로그(api_access_log)에 남기는 AOP.
* - 기록 항목: URL, HTTP Method, 응답시간(ms), 사용자, 성공/실패, 클라이언트 IP
* - 민감 파라미터(비밀번호 등)는 저장 전에 마스킹
*
* <p>왜 필요한가: 어떤 요청이 언제 얼마나 걸렸고 누가 했는지 추적해야 보안/성능 분석이 가능하다.
* 컨트롤러마다 로깅 코드를 넣지 않고, AOP로 모든 REST 컨트롤러를 한 곳에서 가로채 일괄 기록한다.
*
* <p>핵심: 로그 저장은 {@code saveAsync}(비동기)로 호출해 본 요청 응답이 로깅 때문에 느려지지 않게 한다.
* 또한 로깅 실패가 실제 비즈니스 응답을 망치면 안 되므로 finally 안에서 try-catch로 삼켜 무시한다.
*/
@Slf4j
@Aspect
@Component
@Aspect // 이 클래스가 AOP 관심사(가로채기 로직)임을 선언
@Component // 스프링 빈으로 등록되어야 AOP가 활성화됨
@RequiredArgsConstructor
public class ApiLogAspect {
private final ApiAccessLogService apiAccessLogService;
// within(@...RestController *) : @RestController 가 붙은 클래스의 모든 메서드를 대상으로 둘러싼다(@Around).
@Around("within(@org.springframework.web.bind.annotation.RestController *)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
long start = System.currentTimeMillis();
long start = System.currentTimeMillis(); // 응답시간 측정 시작점
Throwable error = null;
Object result = null;
try {
result = jp.proceed();
result = jp.proceed(); // 실제 컨트롤러 메서드 실행
return result;
} catch (Throwable e) {
error = e;
error = e; // 예외도 일단 보관했다가 로그에 실패로 기록하고 다시 던진다
throw e;
} finally {
// 성공/실패와 무관하게 항상 로그를 남긴다. 로깅 자체의 오류는 무시(아래 catch).
try {
int elapsed = (int) (System.currentTimeMillis() - start);
HttpServletRequest req = currentRequest();
@@ -58,11 +66,13 @@ public class ApiLogAspect {
}
}
/** 현재 스레드에 묶인 HTTP 요청 객체를 꺼낸다. 요청 컨텍스트가 없으면(비웹 호출 등) null. */
private HttpServletRequest currentRequest() {
var attr = RequestContextHolder.getRequestAttributes();
return attr instanceof ServletRequestAttributes sra ? sra.getRequest() : null;
}
/** 쿼리스트링을 로그용으로 가공: 비밀번호/토큰 같은 민감 값은 ***로 가린다. */
private String queryString(HttpServletRequest req) {
String qs = req.getQueryString();
if (qs == null) return null;
@@ -70,8 +80,10 @@ public class ApiLogAspect {
return qs.replaceAll("(password|pwd|token)=[^&]*", "$1=***");
}
/** 클라이언트 실제 IP를 구한다. 프록시/로드밸런서 뒤일 수 있으므로 X-Forwarded-For 헤더를 우선 사용. */
private String ipOf(HttpServletRequest req) {
String xf = req.getHeader("X-Forwarded-For");
// 여러 IP가 콤마로 나열되면 맨 앞(최초 클라이언트)이 실제 사용자 IP다.
if (xf != null && !xf.isBlank()) return xf.split(",")[0].trim();
return req.getRemoteAddr();
}
@@ -13,27 +13,32 @@ import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @DataChangeLog 처리.
* {@link com.ga.common.annotation.DataChangeLog} 어노테이션이 붙은 메서드를 가로채 데이터 변경 이력을 남기는 AOP.
*
* <p>왜 필요한가: 금액·규정 변경의 감사 추적을 위해, 어노테이션 한 줄로 변경 전/후 상태를 자동 기록하기 위함.
* (어노테이션은 "표시"만 하고, 실제 기록 동작은 이 클래스가 담당한다.)
*
* 정책:
* - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 직렬화 → before
* - 정상 종료 후 동일 인자를 직렬화 → after
* - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 JSON 직렬화 → before(변경 전 상태)
* - 정상 종료 후 동일 인자를 직렬화 → after(변경 후 상태)
* - DataChangeLogService 가 before/after 비교 후 changed_keys 산출하여 INSERT
*
* 단순화를 위해 본 구현은 메서드 인자 전체를 JSON 직렬화 후 저장한다.
* 실제 환경에서는 menu/table 별 조회 콜백을 등록하여 정확한 before-state 를 가져오는 것을 권장.
*/
@Slf4j
@Aspect
@Aspect // AOP 가로채기 로직임을 선언
@Component
@RequiredArgsConstructor
public class DataChangeLogAspect {
private final DataChangeLogService dataChangeLogService;
private final ObjectMapper objectMapper;
private final ObjectMapper objectMapper; // 인자를 JSON 문자열로 직렬화하는 데 사용
// @annotation(...) : @DataChangeLog 가 붙은 메서드만 둘러싸서(@Around) 전/후 상태를 기록한다.
@Around("@annotation(com.ga.common.annotation.DataChangeLog)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
// 가로챈 메서드에 달린 @DataChangeLog 어노테이션을 리플렉션으로 꺼내 menu/table/action 값을 읽는다.
MethodSignature sig = (MethodSignature) jp.getSignature();
DataChangeLog ann = sig.getMethod().getAnnotation(DataChangeLog.class);
@@ -41,12 +46,14 @@ public class DataChangeLogAspect {
String afterJson;
Object[] args = jp.getArgs();
try {
// 변경 전 상태: 첫 번째 인자(보통 VO)를 직렬화. 인자가 없으면 빈 객체.
beforeJson = args.length > 0 ? toJson(args[0]) : "{}";
} catch (Exception ignore) { }
Object result = jp.proceed();
Object result = jp.proceed(); // 실제 비즈니스 메서드 실행(여기서 데이터가 바뀜)
try {
// 변경 후 상태: 인자가 있으면 인자를, 없으면 반환값을 직렬화.
afterJson = args.length > 0 ? toJson(args[0]) : toJson(result);
dataChangeLogService.save(
SecurityUtil.getCurrentUserId(),
@@ -58,12 +65,14 @@ public class DataChangeLogAspect {
afterJson
);
} catch (Exception e) {
// 이력 저장 실패가 본 업무를 막으면 안 되므로 경고만 남기고 정상 진행한다.
log.warn("DataChangeLog save failed: {}", e.getMessage());
}
return result;
}
/** 객체를 JSON 문자열로 직렬화. null이거나 직렬화 실패 시 빈 객체 "{}" 반환(이력 기록이 깨지지 않게). */
private String toJson(Object o) {
if (o == null) return "{}";
try {
@@ -73,6 +82,7 @@ public class DataChangeLogAspect {
}
}
/** 인자 중 식별자(Long/Integer/String)를 골라 변경 레코드의 PK로 추정한다. 없으면 null. */
private String extractRecordId(Object[] args) {
for (Object arg : args) {
if (arg instanceof Long l) return String.valueOf(l);
@@ -82,6 +92,7 @@ public class DataChangeLogAspect {
return null;
}
/** 액션 유형 결정: 어노테이션에 명시돼 있으면 그대로, 없으면 메서드 이름 접두어로 추정(INSERT/DELETE/UPDATE). */
private String inferAction(DataChangeLog ann, String methodName) {
if (!ann.action().isBlank()) return ann.action();
String n = methodName.toLowerCase();
@@ -13,7 +13,10 @@ import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @RequirePermission 처리. 현재 사용자의 (menu, perm) 권한을 검증한다.
* {@link com.ga.common.annotation.RequirePermission} 어노테이션이 붙은 메서드를 가로채 권한을 검증하는 AOP.
*
* <p>왜 필요한가: 화면/작업별 권한 검사를 컨트롤러마다 직접 쓰지 않고 한 곳에서 일괄 처리하기 위함.
* 메서드 "실행 전(@Before)"에 검사하므로, 권한이 없으면 본 로직이 아예 실행되지 않는다.
*/
@Slf4j
@Aspect
@@ -21,17 +24,21 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionChecker permissionChecker;
private final PermissionChecker permissionChecker; // 실제 권한 보유 여부를 DB에서 조회/판단
// @Before: @RequirePermission 이 붙은 메서드가 호출되기 "직전"에 이 검사 로직이 먼저 실행된다.
@Before("@annotation(com.ga.common.annotation.RequirePermission)")
public void check(org.aspectj.lang.JoinPoint jp) {
// 로그인하지 않은 사용자(인증 토큰 없음)는 401로 차단.
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
// 가로챈 메서드의 어노테이션에서 요구 권한(menu, perm)을 읽는다.
MethodSignature sig = (MethodSignature) jp.getSignature();
RequirePermission ann = sig.getMethod().getAnnotation(RequirePermission.class);
if (ann == null) return;
// 사용자가 해당 (menu, perm) 권한을 갖고 있지 않으면 403으로 차단.
if (!permissionChecker.hasPermission(userId, ann.menu(), ann.perm())) {
log.warn("Permission denied: userId={}, menu={}, perm={}", userId, ann.menu(), ann.perm());
throw new BizException(ErrorCode.FORBIDDEN);
@@ -21,54 +21,70 @@ import java.util.List;
import java.util.Map;
/**
* Authorization: Bearer xxx 헤더에서 JWT 추출 → SecurityContext 설정.
* 모든 요청에서 "Authorization: Bearer xxx" 헤더 JWT를 꺼내 검증하고, 통과하면
* 인증 정보를 SecurityContext에 심는 필터.
*
* <p>왜 필요한가: 이 시스템은 세션이 아니라 토큰(JWT) 기반 무상태(stateless) 인증을 쓴다.
* 컨트롤러가 "현재 누가 요청했는지" 알려면, 요청이 컨트롤러에 닿기 전에 이 필터가 토큰을 해석해
* 사용자 정보를 SecurityContext에 올려줘야 한다. SecurityConfig에서 스프링 시큐리티 필터 체인에 등록된다.
*
* <p>OncePerRequestFilter: 한 요청당 정확히 한 번만 실행되도록 보장하는 스프링 기반 필터.
* 토큰이 없으면 인증을 세팅하지 않고 그냥 통과시키며(이후 보호된 URL이면 시큐리티가 401 처리),
* 토큰이 있는데 잘못됐으면 여기서 즉시 JSON 에러로 응답을 끊는다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private static final String HEADER = "Authorization"; // 토큰이 실리는 헤더명
private static final String PREFIX = "Bearer "; // 토큰 앞에 붙는 표준 접두어
private final JwtTokenProvider tokenProvider;
private final ObjectMapper objectMapper;
private final JwtTokenProvider tokenProvider; // 토큰 파싱/검증 담당
private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HEADER);
// "Bearer "로 시작하는 헤더가 있을 때만 토큰 인증을 시도한다(없으면 비로그인 상태로 통과).
if (header != null && header.startsWith(PREFIX)) {
String token = header.substring(PREFIX.length());
String token = header.substring(PREFIX.length()); // "Bearer " 뒤의 실제 토큰만 추출
try {
// 토큰을 검증/해석해 안에 담긴 사용자 정보(claims)를 꺼낸다.
Claims claims = tokenProvider.parse(token);
Long userId = claims.get("uid", Long.class);
String loginId = claims.getSubject();
Long userId = claims.get("uid", Long.class); // 발급 시 넣은 사용자 ID
String loginId = claims.getSubject(); // subject = 로그인 아이디
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of());
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of()); // 역할 목록
// 토큰에서 복원한 정보로 인증 주체(LoginUser) 생성.
LoginUser user = LoginUser.builder()
.userId(userId)
.loginId(loginId)
.roleCodes(roles)
.build();
// 스프링 시큐리티에 "이 사용자는 인증됨"으로 등록. 이후 컨트롤러/AOP가 이 정보를 참조한다.
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (BizException e) {
// 만료/위조 등 알려진 토큰 오류 → 해당 에러코드로 즉시 응답하고 체인 중단.
writeError(response, e.getErrorCode());
return;
} catch (Exception e) {
// 예상치 못한 파싱 오류 → 토큰 무효로 처리.
log.warn("JWT processing error: {}", e.getMessage());
writeError(response, ErrorCode.TOKEN_INVALID);
return;
}
}
chain.doFilter(request, response);
chain.doFilter(request, response); // 다음 필터/컨트롤러로 요청을 넘긴다
}
/** 인증 실패 시 표준 JSON 에러({success,code,message})를 직접 응답에 써준다. */
private void writeError(HttpServletResponse response, ErrorCode ec) throws IOException {
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
@@ -18,23 +18,30 @@ import java.util.List;
import java.util.Map;
/**
* JWT 발급/검증.
* JWT(JSON Web Token)를 발급하고 검증하는 핵심 유틸 컴포넌트.
*
* <p>왜 필요한가: 로그인 성공 시 여기서 토큰을 만들어 클라이언트에 주고, 이후 요청마다
* {@link JwtAuthFilter}가 여기서 토큰을 검증한다. 즉 인증 토큰의 "생성"과 "해석"을 한곳에 모은 것.
*
* <p>토큰 종류: access(짧은 수명, 실제 API 호출용)와 refresh(긴 수명, access 재발급용) 두 가지.
* 서명 비밀키(secret)와 만료시간(ms)은 application 설정(ga.jwt.*)에서 주입받는다.
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long accessExpire;
private final long refreshExpire;
private final SecretKey key; // HMAC 서명에 쓰는 비밀키(이 키로 서명/검증)
private final long accessExpire; // access 토큰 만료시간(ms)
private final long refreshExpire; // refresh 토큰 만료시간(ms)
// 생성자 주입: 설정값(@Value)이 없으면 기본값(access 2시간, refresh 7일) 사용.
public JwtTokenProvider(
@Value("${ga.jwt.secret}") String secret,
@Value("${ga.jwt.access-token-expire:7200000}") long accessExpire,
@Value("${ga.jwt.refresh-token-expire:604800000}") long refreshExpire
) {
byte[] raw = secret.getBytes(StandardCharsets.UTF_8);
// 256비트 미만이면 안전하게 패딩
// HMAC-SHA256은 키 길이가 최소 256비트(32바이트)여야 한다. 설정값이 짧으면 0으로 패딩해 길이를 맞춘다.
if (raw.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(raw, 0, padded, 0, raw.length);
@@ -45,14 +52,17 @@ public class JwtTokenProvider {
this.refreshExpire = refreshExpire;
}
/** 로그인 시 발급하는 access 토큰. 사용자 ID/로그인ID/역할을 담는다. */
public String createAccessToken(Long userId, String loginId, List<String> roles) {
return create(userId, loginId, roles, accessExpire, "ACCESS");
}
/** access 만료 시 재발급에 쓰는 refresh 토큰. 역할은 담지 않고 수명이 길다. */
public String createRefreshToken(Long userId, String loginId) {
return create(userId, loginId, List.of(), refreshExpire, "REFRESH");
}
// 토큰 생성 공통 로직: subject=로그인ID, claims에 uid/roles/type, 발급/만료 시각을 넣고 비밀키로 서명.
private String create(Long userId, String loginId, List<String> roles, long expireMs, String type) {
Date now = new Date();
return Jwts.builder()
@@ -68,17 +78,22 @@ public class JwtTokenProvider {
.compact();
}
/**
* 토큰을 비밀키로 검증하고 안의 claims(uid, roles 등)를 꺼낸다.
* 만료/위조 시 일반 예외 대신 업무 예외(BizException)로 바꿔 던져, 상위에서 표준 에러응답으로 처리하게 한다.
*/
public Claims parse(String token) {
try {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new BizException(ErrorCode.TOKEN_EXPIRED);
throw new BizException(ErrorCode.TOKEN_EXPIRED); // 만료된 토큰
} catch (JwtException | IllegalArgumentException e) {
throw new BizException(ErrorCode.TOKEN_INVALID);
throw new BizException(ErrorCode.TOKEN_INVALID); // 서명 불일치/형식 오류 등
}
}
/** 예외를 던지지 않고 true/false 로만 유효성을 확인하고 싶을 때 사용하는 편의 메서드. */
public boolean isValid(String token) {
try {
parse(token);
@@ -88,6 +103,7 @@ public class JwtTokenProvider {
}
}
// 만료시간 조회용 getter. 로그인 응답에 "토큰 만료까지 남은 시간"을 함께 내려줄 때 등에 쓰인다.
public long getAccessExpire() { return accessExpire; }
public long getRefreshExpire() { return refreshExpire; }
}
@@ -10,25 +10,32 @@ import java.util.Collection;
import java.util.List;
/**
* SecurityContext 들어갈 인증 주체.
* 로그인한 사용자를 나타내는 인증 주체(principal). 스프링 시큐리티의 SecurityContext에 저장되어,
* 어디서든 "현재 누가 요청 중인지"를 꺼낼 수 있게 한다(예: {@code SecurityUtil.getCurrentUserId()}).
*
* <p>UserDetails 구현: 스프링 시큐리티가 인증 주체로 인식하려면 이 인터페이스를 구현해야 한다.
* 이 시스템은 토큰(JWT) 기반이라 비밀번호 비교 등은 쓰지 않으므로, 계정 상태 메서드들은 모두 true를 반환한다.
*/
@Getter
@Builder
public class LoginUser implements UserDetails {
private final Long userId;
private final String loginId;
private final String userName;
private final Long agentId;
private final List<String> roleCodes;
private final Long userId; // 내부 사용자 PK
private final String loginId; // 로그인 아이디
private final String userName; // 사용자 이름(표시용)
private final Long agentId; // 소속 설계사/대리점 ID(정산 권한 범위 판단 등에 사용)
private final List<String> roleCodes; // 역할 코드 목록(예: ADMIN, MANAGER)
/** 역할 코드를 스프링 시큐리티 권한(GrantedAuthority)으로 변환. 관례상 "ROLE_" 접두어를 붙인다. */
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return roleCodes == null ? List.of() :
roleCodes.stream().<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
}
// 비밀번호는 토큰 인증에서 쓰지 않으므로 null. username 은 로그인 아이디를 반환.
@Override public String getPassword() { return null; }
@Override public String getUsername() { return loginId; }
// 아래 4개 계정 상태 플래그: 잠금/만료 검사는 로그인 단계에서 별도로 처리하므로 여기선 모두 정상(true).
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 회계 계정코드 REST API.
* <p>
* 신입 메모: 활성 목록 조회는 로그인 사용자 누구나(드롭다운/배치 참조), 관리(전체조회/등록/수정/삭제)는
* {@code @RequirePermission(SYSTEM_CODE)}로 보호한다. 로직은 {@link AccountCodeService}에 위임.
*/
@Tag(name = "계정코드")
@RestController
@RequestMapping("/api/common/account-codes")
@@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 계정코드 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다.
* selectActive는 활성만, selectAll은 비활성 포함 전체를 반환.
*/
@Mapper
public interface AccountCodeMapper {
@@ -8,26 +8,37 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 회계 계정코드(차변/대변 분류) 조회·관리 서비스.
* <p>
* 신입 메모: 정산 원장/배치가 금액을 "어떤 계정으로 잡을지" 참조하는 코드 표다.
* accountType은 DEBIT(차변)/CREDIT(대변)/BOTH(양쪽 사용 가능)를 뜻한다.
* 공통코드와 달리 별도 테이블·키(accountCode 자체가 PK)로 관리한다.
*/
@Service
@RequiredArgsConstructor
public class AccountCodeService {
private final AccountCodeMapper mapper;
/** 활성 계정코드 목록 (드롭다운/배치 참조용). accountType으로 필터 가능. */
public List<AccountCodeVO> getActiveCodes(String accountType) {
return mapper.selectActive(accountType);
}
/** 단일 계정코드 조회. 없으면 NOT_FOUND 예외. */
public AccountCodeVO getCode(String accountCode) {
AccountCodeVO vo = mapper.selectOne(accountCode);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
return vo;
}
/** 전체 계정코드 목록 (비활성 포함, 관리 화면용). */
public List<AccountCodeVO> listAll(String accountType) {
return mapper.selectAll(accountType);
}
/** 계정코드 신규 등록. 같은 코드가 있으면 중복 예외. */
@Transactional
public void create(AccountCodeVO vo) {
if (mapper.existsByCode(vo.getAccountCode()) > 0) {
@@ -36,12 +47,14 @@ public class AccountCodeService {
mapper.insert(vo);
}
/** 계정코드 수정. 존재 확인(getCode) 후 갱신. */
@Transactional
public void update(AccountCodeVO vo) {
getCode(vo.getAccountCode());
mapper.update(vo);
}
/** 계정코드 삭제. 존재 확인 후 삭제. */
@Transactional
public void delete(String accountCode) {
getCode(accountCode);
@@ -4,14 +4,17 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 회계 계정코드 한 건. accountCode가 PK다.
*/
@Data
public class AccountCodeVO {
private String accountCode;
private String accountName;
private String accountType; // DEBIT / CREDIT / BOTH
private String isActive;
private Integer sortOrder;
private String description;
private String accountCode; // 계정코드 (PK)
private String accountName; // 계정명
private String accountType; // DEBIT(차변) / CREDIT(대변) / BOTH(양쪽)
private String isActive; // 활성 여부 'Y'/'N'
private Integer sortOrder; // 정렬 순서
private String description; // 설명
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
@@ -8,6 +8,13 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 공통코드 REST API.
* <p>
* 신입 메모: 단순 조회({@code GET /{groupCode}})는 로그인만 하면 누구나 드롭다운용으로 호출하지만,
* 그룹/코드의 등록·수정·삭제는 시스템관리 화면 전용이라 {@code @RequirePermission(SYSTEM_CODE)}로 보호한다.
* 실제 로직은 모두 {@link CommonCodeService}에 위임하고, 여기서는 경로/권한/요청-응답 매핑만 한다.
*/
@Tag(name = "공통코드")
@RestController
@RequestMapping("/api/common/codes")
@@ -4,14 +4,19 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 공통코드 그룹 한 건 (코드들을 묶는 분류 단위).
* <p>
* 신입 메모: isSystem='Y'이면 시스템 동작에 묶인 보호 그룹이라 수정/삭제가 제한된다.
*/
@Data
public class CommonCodeGroupVO {
private String groupCode;
private String groupName;
private String description;
private String isSystem;
private String isActive;
private Integer sortOrder;
private String groupCode; // 그룹 코드 (PK)
private String groupName; // 그룹명
private String description; // 설명
private String isSystem; // 시스템 보호 그룹 여부 'Y'/'N' (Y면 수정·삭제 잠금)
private String isActive; // 활성 여부 'Y'/'N'
private Integer sortOrder; // 정렬 순서
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
@@ -5,6 +5,12 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 공통코드 그룹/상세코드 DB 매퍼 (MyBatis). SQL은 같은 이름의 XML 매퍼에 정의된다.
* <p>
* 신입 메모: selectActiveCodes는 활성(isActive='Y') 코드만 — 드롭다운/캐시용이고,
* selectCodes는 비활성 포함 전체 — 관리 화면용이다.
*/
@Mapper
public interface CommonCodeMapper {
@@ -17,6 +17,16 @@ import java.util.Optional;
*
* 캐시 키: commonCode::{groupCode}
* 무효화: refreshCache(groupCode) 또는 코드 변경 시 자동.
* <p>
* 신입 메모 - 공통코드란:
* "상태=정상/해지", "성별=남/여"처럼 시스템 전반에서 쓰는 분류값을 DB 한 군데에 모아 둔 것이다.
* 코드 그룹(groupCode) 아래에 코드(code)와 코드명(codeName)이 1:N으로 들어간다.
* 화면 드롭다운은 {@link #getCodesByGroup}로 목록을 받고, 엑셀 출력 등은 {@link #getCodeName}으로
* 저장된 코드값을 사람이 읽는 코드명으로 바꾼다.
* <p>
* 거의 안 바뀌는 데이터라 매번 DB를 치지 않도록 {@code @Cacheable}로 Redis에 캐싱하고,
* 코드/그룹을 수정하면 {@code @CacheEvict}나 {@link #refreshCache}로 해당 캐시를 비운다.
* isSystem='Y'인 그룹은 시스템 동작에 묶여 있어 삭제·키 변경이 막혀 있다(코드명/설명만 수정 가능).
*/
@Slf4j
@Service
@@ -60,18 +70,21 @@ public class CommonCodeService {
log.info("Common code cache evicted: ALL");
}
// ========== 그룹 관리 ==========
// ========== 그룹 관리 (시스템관리 화면용) ==========
/** 코드 그룹 목록 (keyword로 그룹코드/명 부분검색). */
public List<CommonCodeGroupVO> listGroups(String keyword) {
return mapper.selectGroupList(keyword);
}
/** 단일 그룹 조회. 없으면 NOT_FOUND 예외. */
public CommonCodeGroupVO getGroup(String groupCode) {
CommonCodeGroupVO g = mapper.selectGroup(groupCode);
if (g == null) throw new BizException(ErrorCode.NOT_FOUND);
return g;
}
/** 그룹 신규 등록. 같은 groupCode가 이미 있으면 중복 예외. */
@Transactional
public void createGroup(CommonCodeGroupVO vo) {
if (mapper.selectGroup(vo.getGroupCode()) != null) {
@@ -80,6 +93,7 @@ public class CommonCodeService {
mapper.insertGroup(vo);
}
/** 그룹 수정. 시스템 그룹(isSystem='Y')은 잠겨 있어 수정 불가. 수정 후 캐시 무효화. */
@Transactional
public void updateGroup(CommonCodeGroupVO vo) {
CommonCodeGroupVO g = getGroup(vo.getGroupCode());
@@ -90,6 +104,7 @@ public class CommonCodeService {
refreshCache(vo.getGroupCode());
}
/** 그룹 삭제. 시스템 그룹은 잠겨 있어 삭제 불가. 캐시도 함께 비운다. */
@Transactional
@CacheEvict(value = "commonCode", key = "#groupCode")
public void deleteGroup(String groupCode) {
@@ -102,16 +117,19 @@ public class CommonCodeService {
// ========== 상세 코드 관리 ==========
/** 그룹에 속한 코드 전체 목록 (비활성 포함, 관리 화면용). */
public List<CommonCodeVO> listCodes(String groupCode) {
return mapper.selectCodes(groupCode);
}
/** 단일 코드 조회. 없으면 NOT_FOUND 예외. */
public CommonCodeVO getCode(Long codeId) {
CommonCodeVO c = mapper.selectCode(codeId);
if (c == null) throw new BizException(ErrorCode.NOT_FOUND);
return c;
}
/** 코드 신규 등록. 같은 그룹에 같은 code가 있으면 중복 예외. 등록 후 캐시 무효화. */
@Transactional
public void createCode(CommonCodeVO vo) {
if (mapper.existsByGroupAndCode(vo.getGroupCode(), vo.getCode()) > 0) {
@@ -121,6 +139,7 @@ public class CommonCodeService {
refreshCache(vo.getGroupCode());
}
/** 코드 수정. 시스템 그룹이면 식별자(code)·소속 그룹은 못 바꾸고 코드명/설명만 허용. 후 캐시 무효화. */
@Transactional
public void updateCode(CommonCodeVO vo) {
CommonCodeVO old = getCode(vo.getCodeId());
@@ -135,6 +154,7 @@ public class CommonCodeService {
refreshCache(old.getGroupCode());
}
/** 코드 삭제. 시스템 그룹의 코드는 잠겨 있어 삭제 불가. 후 캐시 무효화. */
@Transactional
public void deleteCode(Long codeId) {
CommonCodeVO c = getCode(codeId);
@@ -4,20 +4,26 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 공통코드 상세 한 건 (그룹 안의 개별 코드 값).
* <p>
* 신입 메모: code는 DB/연동에 쓰는 식별값("01"), codeName은 사람이 보는 이름("정상").
* attr1~3/refCode는 코드별로 추가 부가정보를 담는 자유 칸이다(예: 세율, 연결 코드 등).
*/
@Data
public class CommonCodeVO {
private Long codeId;
private String groupCode;
private String code;
private String codeName;
private String codeNameEn;
private String codeDesc;
private String attr1;
private String attr2;
private String attr3;
private String refCode;
private Integer sortOrder;
private String isActive;
private Long codeId; // 코드 PK
private String groupCode; // 소속 그룹 코드
private String code; // 코드값 (저장/연동용 식별자)
private String codeName; // 코드명 (화면 표시용)
private String codeNameEn; // 영문 코드명
private String codeDesc; // 코드 설명
private String attr1; // 부가속성1 (코드별 자유 용도)
private String attr2; // 부가속성2
private String attr3; // 부가속성3
private String refCode; // 연관 코드 (계층/참조용)
private Integer sortOrder; // 정렬 순서
private String isActive; // 활성 여부 'Y'/'N'
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
@@ -7,17 +7,24 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 비동기 실행 설정. @Async 가 붙은 메서드를 별도 스레드 풀에서 돌리기 위한 구성.
*
* <p>왜 필요한가: API 접근 로그 저장({@code saveAsync}) 같은 부가 작업은 본 요청 응답을 느리게 하면 안 된다.
* @EnableAsync 로 비동기 기능을 켜고, 전용 스레드 풀(asyncExecutor)을 정의해 부가 작업을 백그라운드로 넘긴다.
*/
@Configuration
@EnableAsync
public class AsyncConfig {
// @Async("asyncExecutor") 로 지정해 사용하는 전용 스레드 풀.
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("ga-async-");
ex.setCorePoolSize(4); // 기본 유지 스레드 수
ex.setMaxPoolSize(16); // 큐가 가득 찰 때 늘릴 수 있는 최대 스레드 수
ex.setQueueCapacity(500); // 대기 작업 큐 크기(초과분이 생기면 maxPool까지 확장)
ex.setThreadNamePrefix("ga-async-"); // 로그에서 비동기 스레드를 알아보기 쉽게 이름 접두어 지정
ex.initialize();
return ex;
}
@@ -10,10 +10,18 @@ import org.springframework.context.annotation.Configuration;
* 모든 com.ga 패키지 하위에서 @Mapper 가 붙은 인터페이스만 스캔.
* (common.code / common.menu / common.system / common.notification / common.file / core.mapper.*)
*/
/**
* MyBatis 관련 공통 설정.
*
* <p>@MapperScan: com.ga 하위에서 {@code @Mapper}가 붙은 인터페이스만 찾아 자동으로 빈으로 등록한다.
* 덕분에 각 Mapper 인터페이스에 별도 등록 코드 없이 @Mapper 한 줄만 붙이면 주입받아 쓸 수 있다.
* 또한 감사 필드 자동 채움 인터셉터({@link AuditInterceptor})를 빈으로 등록해 MyBatis 실행에 끼운다.
*/
@Configuration
@MapperScan(basePackages = "com.ga", annotationClass = Mapper.class)
public class MyBatisConfig {
// 빈으로 등록되면 mybatis-spring-boot-starter가 자동으로 SqlSessionFactory의 인터셉터 체인에 추가한다.
@Bean
public AuditInterceptor auditInterceptor() {
return new AuditInterceptor();
@@ -25,6 +25,7 @@ import java.time.Duration;
@ConditionalOnBean(RedisConnectionFactory.class)
public class RedisConfig {
// 코드에서 Redis에 직접 값을 읽고 쓸 때 사용하는 도구. 키는 문자열, 값은 JSON으로 직렬화한다.
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisTemplate<String, Object> tpl = new RedisTemplate<>();
@@ -37,11 +38,12 @@ public class RedisConfig {
return tpl;
}
// @Cacheable 등 스프링 캐시 추상화가 사용할 캐시 매니저. 저장소로 Redis를 쓴다.
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.disableCachingNullValues()
.entryTtl(Duration.ofHours(1)) // 캐시 항목 유효시간 1시간
.disableCachingNullValues() // null은 캐싱하지 않음(빈 결과 캐싱으로 인한 혼선 방지)
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper)));
@@ -17,6 +17,8 @@ import org.springframework.data.redis.connection.RedisConnectionFactory;
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public class SimpleCacheConfig {
// 자바 메모리(ConcurrentMap) 기반 캐시. "commonCode"(공통코드), "userPerm"(사용자 권한) 두 캐시를 미리 만든다.
// 주의: 인메모리라서 서버 재시작 시 사라지고 여러 서버 간 공유도 안 된다(운영은 Redis 권장).
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("commonCode", "userPerm");
@@ -8,10 +8,17 @@ import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger(OpenAPI) 문서 설정. 실행 중인 API 목록과 모델을 자동 문서화하고 브라우저에서 테스트하게 해준다.
*
* <p>왜 필요한가: 프런트엔드/협업자가 API 명세를 쉽게 확인하고 호출 테스트하려면 문서 UI가 필요하다.
* 또한 이 시스템은 JWT 인증을 쓰므로, 문서 UI에서 "Authorize" 버튼으로 Bearer 토큰을 넣고 테스트할 수 있도록
* 보안 스킴(bearerAuth)을 함께 등록한다. (접근 URL은 SecurityConfig에서 공개로 허용됨)
*/
@Configuration
public class SwaggerConfig {
private static final String SCHEME = "bearerAuth";
private static final String SCHEME = "bearerAuth"; // 보안 스킴 이름(아래에서 등록/참조)
@Bean
public OpenAPI openAPI() {
@@ -5,13 +5,19 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 웹 계층 공통 설정. 현재는 JSON 직렬화 도구인 ObjectMapper 빈을 정의한다.
*
* <p>왜 필요한가: 응답/요청 JSON 변환에 쓰는 ObjectMapper를 명시적으로 빈 등록해 두면,
* 컨트롤러 응답뿐 아니라 JWT 필터·예외 핸들러·Redis 직렬화 등 여러 곳에서 같은 설정의 매퍼를 주입받아 일관되게 쓸 수 있다.
*/
@Configuration
public class WebMvcConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.findAndRegisterModules();
.registerModule(new JavaTimeModule()) // LocalDateTime 등 자바8 날짜/시간 타입을 JSON으로 직렬화 가능하게 함
.findAndRegisterModules(); // 클래스패스에 있는 추가 Jackson 모듈을 자동 등록
}
}
@@ -6,6 +6,13 @@ import lombok.Getter;
import java.lang.reflect.Field;
/**
* VO의 {@code @ExcelColumn} 필드 하나에서 뽑아낸 엑셀 컬럼 메타정보.
* <p>
* 신입 메모: 애너테이션 값(헤더/순서/너비/코드그룹/마스킹 여부 등)을 매번 읽는 대신,
* 한 번 추출해 이 불변 객체에 담아 두고 {@link ExcelService}가 출력/입력에 재사용한다.
* {@code field}는 리플렉션으로 값을 읽고 쓰기 위해 setAccessible(true) 처리된 상태다.
*/
@Getter
@Builder
public class ExcelColumnInfo {
@@ -19,6 +26,7 @@ public class ExcelColumnInfo {
private final String defaultValue;
private final boolean required;
/** 필드와 그 위의 {@code @ExcelColumn} 애너테이션을 합쳐 메타정보 객체를 만든다. */
public static ExcelColumnInfo of(Field field, ExcelColumn ann) {
field.setAccessible(true);
return ExcelColumnInfo.builder()
@@ -9,11 +9,16 @@ import java.util.List;
/**
* VO 클래스 → @ExcelColumn 메타정보 추출.
* <p>
* 신입 메모: VO에 선언된 필드들을 훑어 {@code @ExcelColumn}이 붙은 것만 골라
* {@link ExcelColumnInfo} 목록으로 만들고, order 값 오름차순으로 정렬해 엑셀 컬럼 순서를 확정한다.
*/
public final class ExcelColumnParser {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private ExcelColumnParser() {}
/** 주어진 VO 타입에서 엑셀 컬럼 메타정보를 order 순으로 추출한다. */
public static List<ExcelColumnInfo> parse(Class<?> clazz) {
List<ExcelColumnInfo> list = new ArrayList<>();
for (Field f : clazz.getDeclaredFields()) {
@@ -4,15 +4,22 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 엑셀 업로드 중 변환/검증에 실패한 한 행의 정보.
* <p>
* 신입 메모: 업로드는 실패한 행이 있어도 멈추지 않으므로, 어느 행(rowNumber)에서
* 무엇(fieldName/value)이 왜(errorMessage) 틀렸는지 모아 두었다가 사용자에게 돌려준다.
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelErrorRow {
private int rowNumber;
private String fieldName;
private String value;
private int rowNumber; // 엑셀 기준 행 번호(1=헤더)
private String fieldName; // 문제가 된 컬럼명 (없을 수 있음)
private String value; // 문제가 된 입력값 (없을 수 있음)
private String errorMessage;
/** 컬럼/값 특정 없이 행 번호와 오류 메시지만으로 만드는 간이 생성자. */
public ExcelErrorRow(int rowNumber, String errorMessage) {
this.rowNumber = rowNumber;
this.errorMessage = errorMessage;
@@ -5,6 +5,12 @@ import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 엑셀 업로드 결과 집계.
* <p>
* 신입 메모: 처리한 총건수/성공/실패/스킵 카운트와 실패 행 목록을 담아 화면에 돌려준다.
* errorFileId는 실패 행만 모아 다시 내려받게 만든 오류 엑셀 파일의 식별자다(있을 때).
*/
@Data
public class ExcelImportResult {
private int totalCount;
@@ -14,7 +20,9 @@ public class ExcelImportResult {
private String errorFileId;
private List<ExcelErrorRow> errors = new ArrayList<>();
/** 성공 n건을 누적(성공/총건수 동시 증가). */
public void addSuccess(int n) { successCount += n; totalCount += n; }
/** 실패 행을 목록에 추가하고 오류/총건수를 증가. */
public void addError(ExcelErrorRow row) {
errors.add(row);
errorCount++;
@@ -32,6 +32,16 @@ import java.util.function.Supplier;
* - exportLargeExcel: SXSSFWorkbook + MyBatis Cursor로 100만건 다운로드
* - importLargeExcel: xlsx-streamer(SAX) + 배치 INSERT로 70만건 업로드
* - exportTemplate: 업로드 양식 (헤더 + 코드그룹 드롭다운)
* <p>
* 신입 메모 - 왜 이렇게 복잡한가:
* 수십만 건을 한 번에 메모리에 올리면 OutOfMemory가 난다. 그래서
* (1) 다운로드는 SXSSF(작성한 행을 디스크로 흘려보내는 POI 스트리밍)와 MyBatis Cursor
* (DB 결과를 한 행씩 끌어오는 커서)를 짝지어 메모리를 일정하게 유지하고,
* (2) 업로드는 xlsx-streamer(SAX 방식, 한 행씩 읽기)로 읽어 batchSize만큼 모이면
* batchConsumer로 한꺼번에 INSERT한다.
* <p>
* VO의 어떤 필드를 어느 컬럼으로 내보낼지는 {@code @ExcelColumn} 애너테이션으로 선언하고,
* {@link ExcelColumnParser}가 그 메타정보를 읽어 순서/헤더/마스킹/코드변환을 처리한다.
*/
@Slf4j
@Service
@@ -43,6 +53,16 @@ public class ExcelService {
/* =========================================================
다운로드 (Export) — SXSSF + Cursor 스트리밍
========================================================= */
/**
* 대용량 데이터를 xlsx로 스트리밍 다운로드한다.
*
* @param clazz 행으로 내보낼 VO 타입 ({@code @ExcelColumn}이 붙은 필드만 출력)
* @param cursorSupplier 호출 시점에 DB 커서를 열어 주는 람다 (한 행씩 끌어옴 → 메모리 절약)
* @param response HTTP 응답 (여기에 직접 엑셀 바이트를 흘려보낸다)
* <p>
* 신입 메모: 트랜잭션 안에서 커서를 열어야(=@Transactional) 끝까지 한 행씩 읽을 수 있다.
* 이 어노테이션이 빠지면 export 도중 커넥션이 닫혀 500 오류가 났던 전례가 있다.
*/
@Transactional(readOnly = true)
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier,
@@ -96,6 +116,14 @@ public class ExcelService {
/* =========================================================
업로드 (Import) — SAX 스트리밍 + 배치 INSERT
========================================================= */
/**
* 업로드된 xlsx를 한 행씩 읽어 VO로 변환하고, batchSize만큼 모이면 batchConsumer로 넘긴다.
*
* @param batchSize 이 개수만큼 모이면 한 번에 INSERT (메모리/성능 균형점)
* @param batchConsumer 모인 VO 묶음을 실제로 DB에 저장하는 람다 (도메인마다 다름)
* @return 총건수/성공건수/오류행 목록을 담은 결과. <b>한 행이 깨져도 전체를 멈추지 않고</b>
* 해당 행만 {@link ExcelImportResult#addError} 로 기록하고 계속 진행한다.
*/
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
int batchSize, Consumer<List<T>> batchConsumer) {
ExcelImportResult result = new ExcelImportResult();
@@ -172,6 +200,11 @@ public class ExcelService {
}
}
/**
* 셀 하나에 값을 쓴다. 컬럼 설정(코드그룹/마스킹)과 값 타입에 따라 표현을 바꾼다.
* 신입 메모: DB에는 코드값("01")이 저장돼 있어도 엑셀에는 코드명("정상")으로 보여주고,
* 주민번호 같은 민감정보는 마스킹해서 내보낸다.
*/
private void writeCell(Cell cell, Object value, ExcelColumnInfo info) {
if (value == null) { cell.setBlank(); return; }
@@ -195,6 +228,10 @@ public class ExcelService {
else cell.setCellValue(value.toString());
}
/**
* 엑셀 한 행을 VO 한 개로 변환한다. 빈 셀이면 기본값을 채우고, 기본값도 없는데
* 필수(required) 컬럼이면 예외를 던져 해당 행을 오류로 처리하게 한다.
*/
private <T> T parseRow(Row row, Class<T> clazz, List<ExcelColumnInfo> columns) throws Exception {
T item = clazz.getDeclaredConstructor().newInstance();
for (int i = 0; i < columns.size(); i++) {
@@ -211,6 +248,11 @@ public class ExcelService {
return item;
}
/**
* 셀 하나를 VO 필드 타입에 맞는 값으로 읽어 변환한다.
* 신입 메모: 엑셀 셀에는 "타입"(문자/숫자/날짜/불리언/수식)이 있어 분기 처리하며,
* 변환 실패 시 예외 대신 null을 돌려준다(필수 검증은 호출부 parseRow가 담당).
*/
private Object readCell(Cell cell, ExcelColumnInfo info) {
if (cell == null) return null;
Class<?> type = info.getField().getType();
@@ -237,6 +279,7 @@ public class ExcelService {
}
}
/** 문자열을 대상 필드 타입으로 변환(숫자는 콤마 제거, 날짜는 ISO 파싱, enum은 이름 매칭). 빈 값은 null. */
@SuppressWarnings({"unchecked", "rawtypes"})
private Object convertString(String s, Class<?> type) {
if (s == null) return null;
@@ -251,6 +294,7 @@ public class ExcelService {
return s;
}
/** 다운로드 응답 헤더 설정. 한글 파일명이 깨지지 않도록 UTF-8로 URL 인코딩한다. */
private void setExcelResponseHeader(HttpServletResponse response, String fileName) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
String encoded = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8).replace("+", "%20");
@@ -3,32 +3,43 @@ package com.ga.common.exception;
import lombok.Getter;
/**
* 업무 예외. 서비스에서 throw new BizException(ErrorCode.XXX) 형태로 사용.
* 업무(비즈니스) 예외. "정상적으로 처리 가능한 실패"(예: 이미 확정된 정산, 권한 없음, 잔액 부족)를 표현한다.
*
* <p>왜 필요한가: 서비스 로직에서 {@code throw new BizException(ErrorCode.XXX)} 한 줄로 실패를 알리면,
* {@link GlobalExceptionHandler}가 이를 잡아 정해진 HTTP 상태/코드/메시지의 표준 JSON 응답으로 변환한다.
* 즉 "어디서 어떻게 실패했는가"와 "사용자에게 어떻게 보여줄까"를 분리하는 장치다.
*
* <p>RuntimeException 상속: 체크 예외가 아니라서 throws 선언 없이 어디서든 던질 수 있고,
* 스프링 트랜잭션은 기본적으로 RuntimeException 발생 시 자동 롤백한다.
*/
@Getter
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
private final String customMessage;
private final ErrorCode errorCode; // 어떤 종류의 실패인지(코드/HTTP상태/기본메시지 보유)
private final String customMessage; // 기본 메시지 대신 보여줄 상황별 메시지(없으면 null)
/** 에러코드만으로 던질 때. 메시지는 ErrorCode의 기본 메시지를 사용. */
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.customMessage = null;
}
/** 상황에 맞는 구체 메시지를 직접 지정해 던질 때(기본 메시지 대신 사용). */
public BizException(ErrorCode errorCode, String customMessage) {
super(customMessage);
this.errorCode = errorCode;
this.customMessage = customMessage;
}
/** 하위 원인 예외(cause)를 함께 보존해 던질 때. 로그에 원인 스택을 남기고 싶을 때 사용. */
public BizException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode;
this.customMessage = null;
}
/** 사용자에게 보여줄 최종 메시지: customMessage가 있으면 그것을, 없으면 에러코드 기본 메시지를 반환. */
public String getDisplayMessage() {
return customMessage != null ? customMessage : errorCode.getMessage();
}
@@ -4,15 +4,23 @@ import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 시스템 표준 에러 코드.
* 시스템 전체에서 공통으로 쓰는 표준 에러 코드 모음(enum).
*
* <p>왜 필요한가: 실패 상황마다 "코드 / HTTP 상태 / 사용자 메시지"를 한 곳에 모아두면,
* 서비스는 {@code throw new BizException(ErrorCode.XXX)} 만 하면 되고 응답 형식은 자동으로 일관된다.
* 프런트엔드도 code 값으로 분기 처리할 수 있다.
*
* <p>각 항목은 (코드 문자열, HTTP 상태, 사용자 메시지) 세 값을 가진다.
* 코드 분류 규칙:
* - 0xxx: 성공
* - Exxx: 공통 에러
* - Axxx: 인증/권한
* - Bxxx: 업무 에러
* - Cxxx: 외부 연동
* (Fxxx: 파일/엑셀)
*/
@Getter
@RequiredArgsConstructor
@RequiredArgsConstructor // 아래 final 필드(code/httpStatus/message)를 받는 생성자를 롬복이 자동 생성
public enum ErrorCode {
SUCCESS ("0000", 200, "성공"),
@@ -60,7 +68,7 @@ public enum ErrorCode {
// 외부 연동
EXTERNAL_API_FAIL ("C001", 502, "외부 시스템 호출에 실패했습니다");
private final String code;
private final int httpStatus;
private final String message;
private final String code; // 클라이언트가 식별에 쓰는 코드 문자열(예: "B001")
private final int httpStatus; // 응답으로 내려줄 HTTP 상태코드(예: 400, 403, 500)
private final String message; // 사용자에게 보여줄 기본 한국어 메시지
}
@@ -19,18 +19,27 @@ import java.util.LinkedHashMap;
import java.util.Map;
/**
* 전역 예외 처리. 모든 예외를 ApiResponse 표준 응답으로 변환.
* 전역 예외 처리. 컨트롤러 어디서든 예외가 터지면 여기로 모여서 표준 {@link ApiResponse} JSON으로 변환된다.
*
* <p>왜 필요한가: 각 컨트롤러가 try-catch로 직접 에러를 다루면 응답 형식이 제각각이 된다.
* @RestControllerAdvice 로 한 곳에 모아두면, 모든 API가 동일한 {success,code,message} 형식과
* 올바른 HTTP 상태코드로 응답하게 되어 프런트엔드 처리가 단순해진다.
*
* <p>각 @ExceptionHandler 메서드는 특정 예외 타입을 받아 적절한 ErrorCode로 매핑한다.
* 마지막 handleAll(Exception)은 위에서 잡지 못한 모든 예외의 안전망(500)이다.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 우리가 의도적으로 던진 업무 예외 → 그 안의 에러코드/메시지를 그대로 응답으로 변환. */
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBiz(BizException e, HttpServletRequest req) {
log.warn("BizException at {} : {} - {}", req.getRequestURI(), e.getErrorCode(), e.getDisplayMessage());
return build(e.getErrorCode(), e.getDisplayMessage());
}
/** @Valid 검증 실패(요청 본문 필드 오류) → 400. 필드별 메시지를 모은다. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
@@ -42,50 +51,59 @@ public class GlobalExceptionHandler {
.body(ApiResponse.<Map<String, String>>fail(ec.getCode(), ec.getMessage()));
}
/** 파라미터 타입 불일치(예: 숫자 자리에 문자) → 400. */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return build(ErrorCode.INVALID_PARAMETER,
"파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다");
}
/** 필수 요청 파라미터 누락 → 400. */
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
return build(ErrorCode.INVALID_PARAMETER,
"필수 파라미터 [" + e.getParameterName() + "] 가 누락되었습니다");
}
/** 허용되지 않는 HTTP 메서드(예: GET 전용 URL에 POST) → 405. */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethod(HttpRequestMethodNotSupportedException e) {
return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage());
}
/** DB 유니크 제약 위반(중복 키) → 409. 서비스에서 일일이 검사 안 해도 여기서 안전하게 잡힌다. */
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<ApiResponse<Void>> handleDup(DuplicateKeyException e) {
log.warn("DuplicateKeyException: {}", e.getMessage());
return build(ErrorCode.DUPLICATE_DATA, ErrorCode.DUPLICATE_DATA.getMessage());
}
/** 스프링 시큐리티 인가 실패(권한 없음) → 403. */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleForbidden(AccessDeniedException e) {
return build(ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN.getMessage());
}
/** 스프링 시큐리티 인증 실패(로그인 안 됨) → 401. */
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuth(AuthenticationException e) {
return build(ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getMessage());
}
/** 잘못된 인자 → 400. 예외 메시지를 그대로 사용자에게 전달. */
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegal(IllegalArgumentException e) {
return build(ErrorCode.BAD_REQUEST, e.getMessage());
}
/** 위에서 잡지 못한 모든 예외의 최종 안전망 → 500. 원인은 서버 로그에 error 레벨로 남긴다. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAll(Exception e, HttpServletRequest req) {
log.error("Unhandled exception at {}", req.getRequestURI(), e);
return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getMessage());
}
/** 공통 응답 빌더: 에러코드의 HTTP 상태 + 표준 실패 JSON(ApiResponse.fail)을 만들어 반환. */
private ResponseEntity<ApiResponse<Void>> build(ErrorCode ec, String msg) {
return ResponseEntity.status(ec.getHttpStatus())
.body(ApiResponse.fail(ec.getCode(), msg));
@@ -18,6 +18,12 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
/**
* 파일 업로드/다운로드 REST API.
* <p>
* 신입 메모: download는 ApiResponse(JSON)가 아니라 응답 스트림에 파일 바이트를 직접 흘려보낸다.
* 한글 파일명이 깨지지 않도록 Content-Disposition을 UTF-8로 인코딩한다.
*/
@Tag(name = "파일")
@Slf4j
@RestController
@@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 파일 메타데이터 DB 매퍼 (MyBatis). 실제 파일 바이트는 디스크에, 여기는 메타만 저장한다.
*/
@Mapper
public interface FileMapper {
int insert(FileVO vo);
@@ -20,6 +20,12 @@ import java.util.UUID;
/**
* 파일 업로드/다운로드. 로컬 파일시스템 저장 (S3 등으로 교체 가능한 구조).
* <p>
* 신입 메모 - 저장 구조:
* 업로드한 파일은 원본 이름 대신 UUID로 된 "저장명"으로 저장한다(이름 충돌·한글/특수문자 문제 방지).
* 실제 경로는 {@code 업로드루트/yyyy/MM/dd/UUID.ext}처럼 날짜 폴더로 나눠 한 폴더에 파일이 몰리지 않게 한다.
* DB({@link FileVO})에는 원본명·저장명·경로·크기 등 메타데이터만 적재하고, 다운로드 시 이 메타로 실제 파일을 찾아 내려준다.
* 삭제는 DB에 삭제 표시만 하고(soft delete) 물리 파일 삭제는 배치가 따로 처리한다.
*/
@Slf4j
@Service
@@ -31,6 +37,10 @@ public class FileService {
@Value("${ga.file.upload-dir:./uploads}")
private String uploadDir;
/**
* 업로드된 MultipartFile을 저장하고 메타데이터(FileVO)를 DB에 적재한다.
* @param fileGroup 첨부 묶음 분류값 (어느 업무의 첨부인지 구분)
*/
@Transactional
public FileVO upload(MultipartFile file, String fileGroup) {
if (file == null || file.isEmpty()) {
@@ -98,6 +108,7 @@ public class FileService {
}
}
/** 파일 메타 조회 + 다운로드 횟수 증가. 실제 바이트 전송은 컨트롤러가 경로로 수행. 없으면 FILE_NOT_FOUND. */
public FileVO download(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
@@ -105,10 +116,12 @@ public class FileService {
return vo;
}
/** 특정 그룹(업무 묶음)에 속한 파일 목록 조회. */
public List<FileVO> listByGroup(String fileGroup) {
return mapper.selectByGroup(fileGroup);
}
/** 파일 삭제 표시(soft delete). 실제 디스크 파일 삭제는 배치가 처리. */
@Transactional
public void delete(Long fileId) {
FileVO vo = mapper.selectById(fileId);
@@ -117,6 +130,7 @@ public class FileService {
// 물리 삭제는 배치로 처리
}
/** 파일명에서 확장자를 소문자로 추출 (없으면 빈 문자열). */
private String extension(String name) {
int idx = name.lastIndexOf('.');
return idx >= 0 ? name.substring(idx + 1).toLowerCase() : "";
@@ -4,18 +4,21 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 파일 메타데이터 한 건. 실제 파일은 filePath의 디스크 위치에 저장된다.
*/
@Data
public class FileVO {
private Long fileId;
private String fileGroup;
private String originalName;
private String storedName;
private String filePath;
private Long fileSize;
private String contentType;
private String extension;
private Integer downloadCount;
private String isDeleted;
private Long fileId; // 파일 PK
private String fileGroup; // 첨부 묶음 분류 (어느 업무의 첨부인지)
private String originalName; // 사용자가 올린 원본 파일명 (다운로드 시 보여줄 이름)
private String storedName; // 실제 저장명 (UUID 기반, 충돌 방지)
private String filePath; // 디스크 절대 경로
private Long fileSize; // 바이트 크기
private String contentType; // MIME 타입
private String extension; // 확장자(소문자)
private Integer downloadCount; // 다운로드 횟수
private String isDeleted; // 삭제 표시 'Y'/'N' (soft delete)
private LocalDateTime createdAt;
private Long createdBy;
private Long createdBy; // 업로더 user_id
}
@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 메뉴 REST API.
* <p>
* 신입 메모: {@code GET /my}는 로그인 사용자의 사이드바 메뉴(권한 포함)라 누구나 호출하고,
* 나머지 트리 조회·CRUD는 시스템관리 화면 전용이라 {@code @RequirePermission(SYSTEM_MENU)}로 보호한다.
*/
@Tag(name = "메뉴")
@RestController
@RequestMapping("/api/menus")
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 메뉴/권한 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다.
* selectUserPermissions는 (menuId, permCode) 행들을 돌려주며, 서비스가 메뉴별로 묶는다.
*/
@Mapper
public interface MenuMapper {
@@ -11,13 +11,23 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 메뉴 조회·관리 서비스.
* <p>
* 신입 메모 - 메뉴와 권한의 관계:
* 메뉴는 부모-자식이 있는 트리(parentMenuId로 연결)다. 사용자가 로그인하면 그 사람이 접근 가능한
* 메뉴만 골라({@link #getMyMenus}) 좌측 사이드바를 그린다. 이때 메뉴마다 그 사용자가 가진
* 권한 코드(READ/CREATE/UPDATE/DELETE 등)를 함께 실어 보내, 화면에서 버튼 노출 여부를 정한다.
* DB는 평면(flat) 목록으로 주므로 {@link MenuTreeBuilder}가 트리로 조립한다.
* 관리자용 전체 트리({@link #getMenuTree})는 권한 표시 없이 모든 활성 메뉴를 보여준다.
*/
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuMapper mapper;
/** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함) */
/** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함). 미로그인 시 UNAUTHORIZED. */
public List<MenuResp> getMyMenus() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
@@ -27,17 +37,19 @@ public class MenuService {
return MenuTreeBuilder.build(flat, permissions);
}
/** 전체 메뉴 트리 (관리자) */
/** 전체 메뉴 트리 (관리자용, 권한 표시 없음). */
public List<MenuResp> getMenuTree() {
return MenuTreeBuilder.build(mapper.selectAllActive(), Map.of());
}
/** 단일 메뉴 조회. 없으면 NOT_FOUND. */
public MenuVO getById(Long menuId) {
MenuVO m = mapper.selectById(menuId);
if (m == null) throw new BizException(ErrorCode.NOT_FOUND);
return m;
}
/** 메뉴 신규 등록. menuCode 중복이면 예외. 생성된 menuId 반환. */
@Transactional
public Long create(MenuVO vo) {
if (mapper.selectByCode(vo.getMenuCode()) != null) {
@@ -47,6 +59,7 @@ public class MenuService {
return vo.getMenuId();
}
/** 메뉴 수정. 존재 확인 후 갱신. */
@Transactional
public void update(Long menuId, MenuVO vo) {
getById(menuId);
@@ -54,6 +67,7 @@ public class MenuService {
mapper.update(vo);
}
/** 메뉴 삭제. 하위 메뉴가 있으면 먼저 지워야 하므로 DEPENDENT_DATA 예외. */
@Transactional
public void delete(Long menuId) {
if (mapper.countChildren(menuId) > 0) {
@@ -62,6 +76,10 @@ public class MenuService {
mapper.deleteById(menuId);
}
/**
* 사용자의 권한 행들을 (menuId → permCode 목록) 맵으로 묶는다.
* DB는 한 행에 (menuId, permCode) 하나씩 주므로, 같은 메뉴의 여러 권한을 한 리스트로 모은다.
*/
private Map<Long, List<String>> loadPermissionsByMenuId(Long userId) {
Map<Long, List<String>> result = new HashMap<>();
for (Map<String, Object> row : mapper.selectUserPermissions(userId)) {
@@ -4,11 +4,21 @@ import java.util.*;
/**
* 평면 메뉴 목록 → 트리 구조 변환.
* <p>
* 신입 메모: DB는 메뉴를 부모 정보(parentMenuId)만 가진 일렬 목록으로 준다.
* 화면 사이드바는 계층 트리가 필요하므로, 여기서 (1) 각 메뉴를 응답 객체로 바꾸고
* (2) parentMenuId를 따라 자식을 부모의 children에 끼워 넣은 뒤 (3) sortOrder로 정렬한다.
* 부모를 못 찾은 메뉴(고아)는 루트로 올려 누락되지 않게 한다.
*/
public final class MenuTreeBuilder {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private MenuTreeBuilder() {}
/**
* 평면 목록을 트리로 조립한다.
* @param permissionsByMenuId 메뉴별 사용자 권한 코드 맵 (관리자 전체트리는 빈 맵을 넘김)
*/
public static List<MenuResp> build(List<MenuVO> flat, Map<Long, List<String>> permissionsByMenuId) {
Map<Long, MenuResp> map = new LinkedHashMap<>();
// VO → Resp 변환
@@ -47,6 +57,7 @@ public final class MenuTreeBuilder {
return roots;
}
/** 같은 레벨 형제들을 sortOrder 오름차순(null은 뒤)으로 정렬하고, 자식들도 재귀로 정렬. */
private static void sortRecursive(List<MenuResp> nodes) {
nodes.sort(Comparator.comparing(MenuResp::getSortOrder, Comparator.nullsLast(Integer::compareTo)));
for (MenuResp n : nodes) sortRecursive(n.getChildren());
@@ -2,19 +2,22 @@ package com.ga.common.menu;
import lombok.Data;
/**
* 메뉴 한 건 (DB 테이블 매핑용). 트리 응답은 {@link MenuResp}를 따로 쓴다.
*/
@Data
public class MenuVO {
private Long menuId;
private Long parentMenuId;
private String menuCode;
private String menuName;
private String menuType; // DIRECTORY / PAGE / LINK
private String menuPath;
private String menuIcon;
private String componentPath;
private Integer menuLevel;
private Integer sortOrder;
private String isVisible;
private String isActive;
private String description;
private Long menuId; // 메뉴 PK
private Long parentMenuId; // 부모 메뉴 PK (null이면 최상위)
private String menuCode; // 메뉴 코드 (권한 매핑 키, 예: SYSTEM_MENU)
private String menuName; // 메뉴명
private String menuType; // DIRECTORY(폴더) / PAGE(화면) / LINK(외부링크)
private String menuPath; // 라우팅 경로 (프론트 URL)
private String menuIcon; // 아이콘
private String componentPath; // 매핑되는 프론트 컴포넌트 경로
private Integer menuLevel; // 트리 깊이
private Integer sortOrder; // 같은 레벨 내 정렬 순서
private String isVisible; // 사이드바 노출 여부 'Y'/'N'
private String isActive; // 활성 여부 'Y'/'N'
private String description; // 설명
}
@@ -4,6 +4,13 @@ import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
/**
* "이 사용자가 이 메뉴에서 이 동작을 할 권한이 있는가?"를 판정하는 컴포넌트.
* <p>
* 신입 메모: {@code @RequirePermission} 애너테이션이 붙은 API가 호출될 때 이 클래스가 불려
* 권한을 확인한다. 매 요청마다 DB를 치지 않도록 (userId, menuCode, permCode) 조합 결과를
* Redis에 캐싱하며, 결과가 false면 캐싱하지 않아(unless) 권한 부여 즉시 반영되게 한다.
*/
@Component
@RequiredArgsConstructor
public class PermissionChecker {
@@ -6,18 +6,25 @@ import lombok.Getter;
import java.time.LocalDateTime;
/**
* 모든 REST API의 표준 응답.
* 모든 REST API의 표준 응답 포장지(envelope).
*
* <p>왜 필요한가: 성공이든 실패든 응답 모양을 {success, code, message, data, timestamp}로 통일하면
* 프런트엔드가 한 가지 형태만 처리하면 된다. 컨트롤러는 데이터만 넣어 감싸 반환한다.
*
* <p>제네릭 {@code <T>}: data에 어떤 타입(단건 VO, 리스트, PageResponse 등)이든 담을 수 있게 한다.
* 신입 개발자는 ApiResponse.ok(...) / ApiResponse.fail(...) 만 사용하면 된다.
* (실패는 보통 GlobalExceptionHandler가 자동 생성하므로 직접 fail을 쓸 일은 드물다.)
*/
@Getter
public class ApiResponse<T> {
private final boolean success;
private final String code;
private final String message;
private final T data;
private final LocalDateTime timestamp = LocalDateTime.now();
private final boolean success; // 성공 여부(true/false). 프런트가 가장 먼저 보는 값
private final String code; // 결과 코드("0000"=성공, 그 외는 ErrorCode 코드)
private final String message; // 사람이 읽는 메시지
private final T data; // 실제 응답 데이터(실패 시 null)
private final LocalDateTime timestamp = LocalDateTime.now(); // 응답 생성 시각
// 외부에서 직접 new 하지 못하게 private. 아래 정적 팩토리 메서드(ok/fail)로만 만든다.
private ApiResponse(boolean success, String code, String message, T data) {
this.success = success;
this.code = code;
@@ -25,26 +32,32 @@ public class ApiResponse<T> {
this.data = data;
}
/** 성공 + 데이터 반환(가장 많이 쓰는 형태). */
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
}
/** 성공 + 데이터 + 커스텀 메시지(예: "저장되었습니다"). */
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), message, data);
}
/** 반환할 데이터가 없는 성공(예: 삭제/처리 완료). */
public static ApiResponse<Void> ok() {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
}
/** 실패 응답: 코드/메시지를 직접 지정. */
public static <T> ApiResponse<T> fail(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
/** 실패 응답: ErrorCode의 코드/기본 메시지를 사용. */
public static <T> ApiResponse<T> fail(ErrorCode errorCode) {
return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
}
/** 실패 응답: ErrorCode + 커스텀 메시지 인자를 받는 오버로드(현재는 ErrorCode 기본 메시지를 사용). */
public static <T> ApiResponse<T> fail(ErrorCode errorCode, String message) {
return new ApiResponse<>(false, errorCode.getCode(), message, null);
}
@@ -10,8 +10,13 @@ import java.util.Collections;
import java.util.List;
/**
* 페이징 응답 (PageHelper 연동).
* 사용 예: return PageResponse.of(mapper.selectList(param));
* 목록 조회의 페이징 응답 모델. 데이터 목록과 함께 "현재 페이지/페이지 크기/전체 건수/전체 페이지 수"를 담는다.
*
* <p>왜 필요한가: 목록 화면은 페이지네이션(이전/다음, 총 N건)이 필요하다. MyBatis 페이징 라이브러리인
* PageHelper가 채워준 통계를 이 모델로 옮겨 담아 프런트엔드에 일관된 형태로 내려준다.
*
* <p>사용 흐름: 서비스에서 {@code param.startPage()}로 페이징을 켜고 조회한 뒤
* {@code return PageResponse.of(mapper.selectList(param));} 한 줄로 변환한다(of가 통계까지 자동 추출).
*/
@Getter
@Builder
@@ -19,18 +24,20 @@ import java.util.List;
@NoArgsConstructor
public class PageResponse<T> {
private List<T> list;
private int pageNum;
private int pageSize;
private long total;
private int pages;
private List<T> list; // 현재 페이지의 데이터 목록
private int pageNum; // 현재 페이지 번호(1부터)
private int pageSize; // 페이지당 건수
private long total; // 조건에 맞는 전체 건수
private int pages; // 전체 페이지 수
/** 조회 결과 목록을 받아 PageHelper가 채운 페이징 통계와 함께 응답 객체로 변환. null이면 빈 결과. */
public static <T> PageResponse<T> of(List<T> list) {
if (list == null) {
return PageResponse.<T>builder()
.list(Collections.emptyList())
.pageNum(1).pageSize(0).total(0).pages(0).build();
}
// PageInfo: PageHelper가 조회 시 함께 계산해 둔 페이징 메타정보(전체 건수/페이지 수 등)를 꺼내는 래퍼.
PageInfo<T> info = new PageInfo<>(list);
return PageResponse.<T>builder()
.list(info.getList())
@@ -41,6 +48,7 @@ public class PageResponse<T> {
.build();
}
/** 조회 자체를 하지 않고 빈 페이징 응답을 내려줄 때 사용(예: 권한상 결과가 없는 경우). */
public static <T> PageResponse<T> empty() {
return PageResponse.<T>builder()
.list(Collections.emptyList())
@@ -4,40 +4,47 @@ import com.github.pagehelper.PageHelper;
import lombok.Data;
/**
* 공통 검색 파라미터. 모든 *SearchParam 의 부모.
* 목록 조회용 공통 검색 파라미터. 모든 화면별 *SearchParam 클래스가 이 클래스를 상속한다.
*
* <p>왜 필요한가: 거의 모든 목록 화면이 "페이지 번호/크기, 검색어, 기간, 상태, 정렬" 같은 공통 입력을 받는다.
* 이를 한 곳에 모아두면 각 화면은 자기만의 필드만 추가하면 되고, 페이징/정렬 처리도 공통화된다.
*
* 사용:
* public PageResponse<X> list(XSearchParam p) {
* p.startPage();
* p.startPage(); // 페이징/정렬 적용
* return PageResponse.of(mapper.selectList(p));
* }
*/
@Data
public class SearchParam {
private int pageNum = 1;
private int pageSize = 20;
private int pageNum = 1; // 현재 페이지(1부터)
private int pageSize = 20; // 페이지당 건수(기본 20)
private String searchType;
private String searchKeyword;
private String searchType; // 검색 대상 항목(예: name, code)
private String searchKeyword; // 검색어
private String startDate;
private String endDate;
private String startDate; // 조회 기간 시작
private String endDate; // 조회 기간 종료
private String status;
private String status; // 상태 필터
private String sortField;
private String sortOrder = "DESC";
private String sortField; // 정렬 기준 컬럼명
private String sortOrder = "DESC"; // 정렬 방향(ASC/DESC, 기본 내림차순)
/** MyBatis XML에서 #{offset} 참조 시 사용되는 계산값 */
public int getOffset() {
return Math.max(0, pageNum - 1) * pageSize;
}
/** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */
/**
* 페이징/정렬 활성화. 서비스에서 목록 조회 SQL을 부르기 "직전"에 호출해야 한다.
* PageHelper.startPage를 호출해 두면, 바로 다음 MyBatis 쿼리에 자동으로 LIMIT/OFFSET과 정렬이 적용된다.
*/
public void startPage() {
PageHelper.startPage(pageNum, pageSize);
if (sortField != null && !sortField.isBlank()) {
// 정렬 방향은 ASC/DESC 두 값만 허용(그 외 입력은 DESC로 강제) — 임의 SQL 주입 방지.
String order = "ASC".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
PageHelper.orderBy(sanitizeSortField(sortField) + " " + order);
}
@@ -6,16 +6,22 @@ import java.util.ArrayList;
import java.util.List;
/**
* 트리 응답 공통 모델 (메뉴, 조직 등).
* 계층 구조(트리) 데이터를 표현하는 공통 응답 모델. 메뉴 트리, 조직에 쓰인다.
*
* <p>왜 필요한가: DB는 부모-자식을 parentId로 가진 평면(flat) 목록으로 저장하지만, 화면 트리 컴포넌트는
* 자식을 children에 중첩한 형태를 원한다. 평면 목록을 이 중첩 구조로 조립해 내려주기 위한 공통 그릇이다.
*
* <p>제네릭 {@code <T>}: data에 메뉴/조직 등 어떤 원본 객체든 실어 보낼 수 있다.
*/
@Data
public class TreeNode<T> {
private Long id;
private Long parentId;
private String label;
private T data;
private List<TreeNode<T>> children = new ArrayList<>();
private Long id; // 이 노드의 ID
private Long parentId; // 부모 노드 ID(최상위면 null)
private String label; // 화면에 표시할 이름
private T data; // 원본 데이터(메뉴/조직 VO 등)
private List<TreeNode<T>> children = new ArrayList<>(); // 자식 노드 목록
/** 자식 노드를 추가한다. 트리 조립 시 부모 노드에 자식을 붙일 때 사용. */
public void addChild(TreeNode<T> child) {
children.add(child);
}
@@ -16,13 +16,17 @@ import java.util.Collection;
import java.util.Map;
/**
* INSERT / UPDATE 감사 필드(created_at, created_by, updated_at, updated_by)를 자동 채운다.
* MyBatis 인터셉터: INSERT/UPDATE 실행 직전에 감사 필드(created_at, created_by, updated_at, updated_by)를 자동으로 채운다.
*
* VO 에 다음 필드가 있을 때만 작동:
* - createdAt, createdBy, updatedAt, updatedBy
* <p>왜 필요한가: "누가 언제 만들고/고쳤는가"는 거의 모든 테이블에 필요한데, 매 INSERT/UPDATE마다 손으로
* 채우면 누락되기 쉽다. DB 접근 공통 지점(MyBatis Executor)을 가로채 자동으로 채워주면 실수가 사라진다.
*
* Map 또는 Collection 파라미터의 경우 내부 객체 각각에 대해 적용한다.
* <p>동작 대상: VO에 createdAt/createdBy/updatedAt/updatedBy 세터가 있을 때만 채운다(없으면 무시).
* Map 또는 Collection 파라미터(다건 처리)의 경우 내부 객체 각각에 대해 적용한다.
* 사용자 ID는 {@code SecurityUtil}로 현재 로그인 사용자에서 가져온다.
*/
// @Intercepts/@Signature: "Executor.update(MappedStatement, Object)" 호출을 가로채겠다는 선언.
// MyBatis의 INSERT/UPDATE/DELETE는 내부적으로 모두 Executor.update 를 거치므로 이 한 지점에서 잡을 수 있다.
@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
@@ -32,16 +36,18 @@ public class AuditInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
MappedStatement ms = (MappedStatement) args[0]; // 실행될 SQL 메타정보
Object parameter = args[1]; // SQL에 넘긴 파라미터(보통 VO)
// INSERT/UPDATE일 때만 감사 필드를 채운다(DELETE는 대상 아님).
SqlCommandType type = ms.getSqlCommandType();
if (type == SqlCommandType.INSERT || type == SqlCommandType.UPDATE) {
apply(parameter, type);
}
return invocation.proceed();
return invocation.proceed(); // 원래 SQL 실행을 그대로 진행
}
// 파라미터 형태(단건/컬렉션/맵)에 따라 내부 객체를 풀어서 각각 감사 필드를 채운다.
private void apply(Object parameter, SqlCommandType type) {
if (parameter == null) return;
@@ -62,13 +68,16 @@ public class AuditInterceptor implements Interceptor {
applyOne(parameter, type);
}
// 단일 객체 하나에 대해 감사 필드를 채운다. 해당 세터가 없거나 VO가 아니면 조용히 건너뛴다.
private void applyOne(Object obj, SqlCommandType type) {
if (obj == null) return;
try {
// MetaObject: 리플렉션 없이 객체의 프로퍼티 존재 여부/값을 다루게 해주는 MyBatis 유틸.
MetaObject meta = SystemMetaObject.forObject(obj);
LocalDateTime now = LocalDateTime.now();
Long userId = SecurityUtil.getCurrentUserId();
// 등록 시: 생성 시각/생성자는 아직 비어 있을 때만 채운다(이미 지정돼 있으면 존중).
if (type == SqlCommandType.INSERT) {
if (meta.hasSetter("createdAt") && meta.getValue("createdAt") == null) {
meta.setValue("createdAt", now);
@@ -77,6 +86,7 @@ public class AuditInterceptor implements Interceptor {
meta.setValue("createdBy", userId);
}
}
// 수정 시: 수정 시각/수정자는 항상 현재 값으로 갱신.
if (type == SqlCommandType.UPDATE) {
if (meta.hasSetter("updatedAt")) {
meta.setValue("updatedAt", now);
@@ -5,14 +5,21 @@ import com.ga.common.model.SearchParam;
import java.util.List;
/**
* 공통 CRUD Mapper. 단순한 단일 테이블에 대해 선택적으로 상속하여 사용.
* 복잡한 조인이 필요하면 일반 Mapper Interface 만 정의해도 된다.
* 단일 테이블에 대한 기본 CRUD 메서드 시그니처를 모아둔 공통 Mapper 인터페이스.
*
* <p>왜 필요한가: 대부분의 테이블은 단건 조회/목록/등록/수정/삭제/카운트라는 비슷한 메서드를 갖는다.
* 이 인터페이스를 상속하면 메서드 선언을 반복하지 않아도 되고, 팀 전체가 같은 메서드 이름을 쓰게 되어 일관성이 생긴다.
* (실제 SQL은 각 Mapper의 XML에 메서드 이름과 동일한 id로 작성한다.)
*
* <p>주의: 복잡한 조인/통계 쿼리가 필요하면 이걸 상속하지 않고 일반 Mapper 인터페이스로 필요한 메서드만 정의해도 된다.
*
* @param <T> 매핑 대상 VO 타입
*/
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);
T selectById(Long id); // PK로 단건 조회
List<T> selectList(SearchParam param); // 검색조건으로 목록 조회(페이징은 PageHelper가 처리)
int insert(T entity); // 등록(반환값=영향 행 수)
int update(T entity); // 수정(반환값=영향 행 수)
int deleteById(Long id); // PK로 삭제(반환값=영향 행 수)
int countByCondition(SearchParam param); // 검색조건에 맞는 전체 건수
}
@@ -31,11 +31,13 @@ public class EncryptTypeHandler extends BaseTypeHandler<String> {
EncryptTypeHandler.encryptUtil = util;
}
// DB에 쓸 때(파라미터 바인딩): 평문을 암호화해서 저장한다.
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, encryptUtil != null ? encryptUtil.encrypt(parameter) : parameter);
}
// DB에서 읽을 때(결과 매핑): 저장된 암호문을 복호화해서 돌려준다. (아래 인덱스 기반 오버로드들도 동일)
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return decrypt(rs.getString(columnName));
@@ -51,6 +53,7 @@ public class EncryptTypeHandler extends BaseTypeHandler<String> {
return decrypt(cs.getString(columnIndex));
}
// 복호화 공통 처리. 빈 값은 그대로, 복호화 실패는 평문(미암호화 기존 데이터)으로 간주해 원본을 돌려준다.
private String decrypt(String v) {
if (v == null || v.isBlank()) return v;
try {
@@ -4,12 +4,16 @@ import com.ga.common.util.EncryptUtil;
import org.springframework.context.annotation.Configuration;
/**
* EncryptTypeHandler 가 Spring 빈이 아닌 일반 클래스이므로,
* 부팅 시 EncryptUtil 인스턴스를 정적으로 주입해주는 초기화 빈.
* {@link EncryptTypeHandler}에 암호화 도구를 연결해주는 초기화 전용 설정 빈.
*
* <p>왜 필요한가: EncryptTypeHandler는 (MyBatis가 모든 String 컬럼에 자동 적용하는 부작용을 막으려고)
* 일부러 스프링 빈이 아닌 일반 클래스로 두었다. 그래서 일반적인 의존성 주입을 받을 수 없으므로,
* 애플리케이션 부팅 시 이 설정 빈이 생성되면서 생성자에서 EncryptUtil 인스턴스를 핸들러의 정적 필드에 1회 주입한다.
*/
@Configuration
public class EncryptTypeHandlerInitializer {
// 스프링이 EncryptUtil 빈을 주입해주면, 그 즉시 TypeHandler의 정적 필드로 전달한다.
public EncryptTypeHandlerInitializer(EncryptUtil util) {
EncryptTypeHandler.setEncryptUtil(util);
}
@@ -13,13 +13,20 @@ import java.sql.ResultSet;
import java.sql.SQLException;
/**
* PostgreSQL JSONB Jackson JsonNode 변환 TypeHandler.
* PostgreSQL JSONB 컬럼과 Jackson의 {@link JsonNode}(자바 JSON 트리)를 서로 변환하는 MyBatis TypeHandler.
*
* <p>왜 필요한가: 변경 이력(data_change_log) 등에서 구조가 유동적인 데이터를 JSONB 컬럼에 저장한다.
* JDBC는 JSONB를 그냥 String으로 다루므로, 이 핸들러가 저장 시 JsonNode→JSON문자열(PGobject),
* 조회 시 JSON문자열→JsonNode 로 자동 변환해 개발자가 JSON 트리로 편하게 다루게 해준다.
*
* <p>@MappedTypes(JsonNode.class): "JsonNode 타입 필드는 이 핸들러로 처리하라"고 MyBatis에 등록.
*/
@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> {
private static final ObjectMapper M = new ObjectMapper();
private static final ObjectMapper M = new ObjectMapper(); // JSON 직렬화/역직렬화 도구(공용 1개)
// DB에 쓸 때: JsonNode를 JSON 문자열로 만들고, PostgreSQL의 jsonb 타입 객체(PGobject)로 바인딩한다.
@Override
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException {
PGobject pg = new PGobject();
@@ -36,6 +43,7 @@ public class JsonTypeHandler extends BaseTypeHandler<JsonNode> {
@Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); }
@Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); }
// DB에서 읽은 JSON 문자열을 JsonNode 트리로 파싱. 빈 값이면 null, 형식 오류면 SQLException.
private JsonNode parse(String json) throws SQLException {
if (json == null || json.isBlank()) return null;
try {
@@ -7,6 +7,12 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 앱 내 알림 REST API (벨 아이콘 영역).
* <p>
* 신입 메모: 모두 "내 알림" 기준이라 권한 애너테이션 없이 로그인 사용자면 호출 가능하다.
* 발송(send)은 다른 도메인이 서비스로 직접 호출하므로 여기 API에는 없다.
*/
@Tag(name = "알림")
@RestController
@RequestMapping("/api/notifications")
@@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 앱 내 알림 DB 매퍼 (MyBatis). deleteOld는 보존기간 지난 알림을 배치로 정리할 때 쓴다.
*/
@Mapper
public interface NotificationMapper {
int insert(NotificationVO vo);
@@ -9,24 +9,35 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 앱 내(in-app) 알림 서비스.
* <p>
* 신입 메모: 이건 화면 종(벨) 아이콘에 쌓이는 "사내 알림"을 DB에 적재/조회하는 기능이다.
* 외부로 나가는 SMS/알림톡/푸시(adapter 패키지)와는 별개이며, 보통 업무 이벤트가 나면
* {@link #send}로 알림을 한 건 적재하고, 사용자는 목록/안읽음 개수를 조회하고 읽음 처리한다.
* 대부분의 메서드는 {@link SecurityUtil}로 현재 로그인 사용자를 기준으로 동작한다.
*/
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationMapper mapper;
/** 현재 사용자의 알림 목록. 미로그인 시 UNAUTHORIZED. */
public List<NotificationVO> myNotifications() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
return mapper.selectByUser(userId);
}
/** 현재 사용자의 안읽음 알림 개수 (벨 배지용). 미로그인 시 0. */
public int unreadCount() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) return 0;
return mapper.countUnread(userId);
}
/** 특정 알림 1건을 읽음 처리 (본인 알림만). */
@Transactional
public void markRead(Long notiId) {
Long userId = SecurityUtil.getCurrentUserId();
@@ -34,6 +45,7 @@ public class NotificationService {
mapper.markRead(notiId, userId);
}
/** 현재 사용자의 모든 알림을 읽음 처리. */
@Transactional
public void markAllRead() {
Long userId = SecurityUtil.getCurrentUserId();
@@ -41,6 +53,7 @@ public class NotificationService {
mapper.markAllRead(userId);
}
/** 알림 1건 적재(발송). 다른 도메인이 업무 이벤트 발생 시 호출한다. linkUrl은 클릭 시 이동 경로. */
@Transactional
public void send(Long userId, String type, String title, String content, String linkUrl) {
NotificationVO vo = new NotificationVO();
@@ -52,6 +65,7 @@ public class NotificationService {
mapper.insert(vo);
}
/** 여러 사용자에게 한 번에 알림 적재 (정산 완료 등 대량 발송용). 빈 목록은 무시. */
@Transactional
public void sendBatch(List<NotificationVO> list) {
if (list == null || list.isEmpty()) return;
@@ -4,15 +4,18 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 앱 내 알림 한 건.
*/
@Data
public class NotificationVO {
private Long notiId;
private Long userId;
private String notiType; // INFO / WARN / ERROR
private String title;
private String content;
private String linkUrl;
private String isRead;
private LocalDateTime readAt;
private LocalDateTime createdAt;
private Long notiId; // 알림 PK
private Long userId; // 수신자 user_id
private String notiType; // INFO / WARN / ERROR (표시 색/아이콘 구분)
private String title; // 제목
private String content; // 본문
private String linkUrl; // 클릭 시 이동할 화면 경로 (선택)
private String isRead; // 읽음 여부 'Y'/'N'
private LocalDateTime readAt; // 읽은 시각
private LocalDateTime createdAt;// 생성 시각
}
@@ -12,16 +12,23 @@ import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
/**
* 인가 실패(로그인은 했지만 권한이 없는 경우, HTTP 403) 처리기.
*
* <p>왜 필요한가: 스프링 시큐리티는 기본적으로 권한 없음 시 HTML 에러 페이지를 내보내는데, 이 시스템은
* 프런트엔드(SPA/PWA)와 JSON으로 통신한다. 그래서 일관된 표준 JSON({success,code,message})으로
* 응답하도록 이 핸들러를 SecurityConfig에 등록한다.
*/
@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ErrorCode ec = ErrorCode.FORBIDDEN;
ErrorCode ec = ErrorCode.FORBIDDEN; // 권한 없음 = 403
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
@@ -12,16 +12,23 @@ import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
/**
* 인증 실패(로그인 안 됨/토큰 없음, HTTP 401) 진입점.
*
* <p>왜 필요한가: 인증되지 않은 요청이 보호된 URL에 들어오면 스프링 시큐리티가 이 핸들러를 호출한다.
* 기본 동작(로그인 페이지 리다이렉트/HTML) 대신, 프런트엔드가 처리하기 쉬운 표준 JSON 401을 응답하기 위함.
* (권한 없음 403은 {@link AccessDeniedHandlerImpl} 이 담당하며, 둘 다 SecurityConfig에 등록된다.)
*/
@Component
@RequiredArgsConstructor
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorCode ec = ErrorCode.UNAUTHORIZED;
ErrorCode ec = ErrorCode.UNAUTHORIZED; // 인증 필요 = 401
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
@@ -21,46 +21,69 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* 스프링 시큐리티 전체 설정. "이 시스템이 인증/인가를 어떻게 처리하는가"의 중심.
*
* <p>핵심 정책:
* <ul>
* <li>JWT 토큰 기반 무상태(STATELESS) 인증 → 서버 세션을 만들지 않는다.</li>
* <li>SPA/PWA + REST API 구조라 CSRF·폼로그인·HTTP Basic은 끈다.</li>
* <li>로그인/토큰갱신/Swagger/헬스체크 같은 공개 URL만 허용하고 나머지는 모두 인증 요구.</li>
* <li>{@link JwtAuthFilter}를 시큐리티 필터 체인 앞쪽에 끼워 토큰을 먼저 해석하게 한다.</li>
* </ul>
*
* <p>@EnableMethodSecurity: 메서드 단위 보안(@PreAuthorize 등)을 켠다.
* (참고: 이 프로젝트는 메뉴 권한을 {@code @RequirePermission} + AOP로 별도 처리한다.)
*/
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final JwtAuthEntryPoint jwtAuthEntryPoint;
private final AccessDeniedHandlerImpl accessDeniedHandler;
private final JwtAuthFilter jwtAuthFilter; // 요청마다 JWT를 검증하는 필터
private final JwtAuthEntryPoint jwtAuthEntryPoint; // 인증 실패(401) 시 JSON 응답
private final AccessDeniedHandlerImpl accessDeniedHandler; // 권한 없음(403) 시 JSON 응답
// 시큐리티 필터 체인 정의. 요청이 어떤 보안 규칙을 거치는지를 한 번에 구성한다.
@Bean
public SecurityFilterChain filter(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable) // 토큰 기반이라 CSRF 불필요 → 비활성화
.cors(c -> c.configurationSource(corsSource())) // 프런트엔드 도메인에서의 호출 허용(아래 corsSource)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용
.formLogin(AbstractHttpConfigurer::disable) // 기본 로그인 폼 사용 안 함(직접 /api/auth/login 처리)
.httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 사용 안 함
.exceptionHandling(eh -> eh
.authenticationEntryPoint(jwtAuthEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authenticationEntryPoint(jwtAuthEntryPoint) // 401 처리기 등록
.accessDeniedHandler(accessDeniedHandler)) // 403 처리기 등록
.authorizeHttpRequests(a -> a
// CORS 사전요청(OPTIONS)은 인증 없이 허용해야 브라우저 호출이 가능.
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 로그인/토큰 갱신은 아직 토큰이 없으므로 공개.
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
// API 문서(Swagger)와 헬스체크는 공개.
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.requestMatchers("/actuator/health").permitAll()
// 그 외 모든 요청은 인증 필요.
.anyRequest().authenticated())
// 표준 로그인 필터(UsernamePasswordAuthenticationFilter)보다 앞에 JWT 필터를 둔다.
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/** 비밀번호 단방향 해시 인코더. 회원 비밀번호 저장/검증에 BCrypt를 사용한다. */
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** 스프링 시큐리티의 인증 매니저 빈. 로그인 처리에서 자격증명 검증에 쓰인다. */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
/** CORS 설정: 프런트엔드(다른 출처)에서의 API 호출을 허용하고, 토큰/파일다운로드 헤더를 노출한다. */
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration c = new CorsConfiguration();
@@ -5,6 +5,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* API 호출 접근 로그 적재 서비스.
* <p>
* 신입 메모: 누가 어떤 API를 언제 호출했고 응답코드/소요시간이 얼마였는지를 감사 로그로 남긴다.
* 본 업무 응답을 지연시키지 않도록 {@code @Async}로 별도 스레드에서 저장하며, 로그 저장이
* 실패해도 본 업무에 영향이 없게 예외를 삼키고 경고만 남긴다(로그 때문에 요청이 깨지면 안 됨).
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -12,6 +19,7 @@ public class ApiAccessLogService {
private final SystemLogMapper logMapper;
/** API 접근 로그 1건을 비동기로 적재. 저장 실패는 무시(경고만). */
@Async("asyncExecutor")
public void saveAsync(Long userId, String method, String url, String requestParam,
String responseCode, int responseTime, String ipAddress, String errorMessage) {
@@ -14,6 +14,11 @@ import java.util.List;
/**
* 데이터 변경 이력 저장 (비동기).
* before/after JSON 비교 → changed_keys 산출 → JSONB INSERT.
* <p>
* 신입 메모: "이 레코드의 어떤 값이 누구에 의해 어떻게 바뀌었나"를 추적하는 감사 기능이다.
* 변경 전(beforeJson)/후(afterJson) 스냅샷을 받아 실제로 달라진 필드명만 골라(changedKeys)
* 함께 저장해 두면, 나중에 "무엇이 바뀌었는지"를 빠르게 확인할 수 있다.
* API 로그와 마찬가지로 {@code @Async}로 본 업무와 분리하고 실패는 삼킨다.
*/
@Slf4j
@Service
@@ -23,6 +28,7 @@ public class DataChangeLogService {
private final SystemLogMapper logMapper;
private final ObjectMapper objectMapper;
/** 변경 이력 1건을 비동기 적재. before/after를 비교해 바뀐 필드 목록도 함께 저장. */
@Async("asyncExecutor")
public void save(Long userId, String menuCode, String tableName, String recordId,
String actionType, String beforeJson, String afterJson) {
@@ -35,6 +41,7 @@ public class DataChangeLogService {
}
}
/** after JSON의 각 필드를 before와 비교해, 값이 다르거나 새로 생긴 필드명을 콤마로 이어 반환. */
private String diffKeys(String beforeJson, String afterJson) {
try {
JsonNode b = objectMapper.readTree(beforeJson == null ? "{}" : beforeJson);
@@ -8,6 +8,9 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 시스템 설정 REST API (시스템관리 화면 전용, SYSTEM_CONFIG 권한 필요).
*/
@Tag(name = "시스템설정")
@RestController
@RequestMapping("/api/system/config")
@@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 시스템 설정 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다.
*/
@Mapper
public interface SystemConfigMapper {
List<SystemConfigVO> selectAll();
@@ -14,42 +14,59 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 시스템 설정값 조회/수정 서비스.
* <p>
* 신입 메모: 코드 배포 없이 운영 중 바꿀 수 있는 설정값(키-값)을 DB에서 읽어 온다.
* 값은 DB에 전부 문자열로 저장되므로, 쓰는 쪽 편의를 위해 getInt/getDecimal/getBoolean 등으로
* 타입 변환해 꺼내며, 값이 없으면 호출자가 준 기본값을 돌려준다.
* 자주 조회되는 값은 {@code @Cacheable}로 캐싱하고 수정 시 해당 키 캐시를 비운다.
* isEditable='N'인 설정은 보호 항목이라 수정이 막혀 있다.
*/
@Service
@RequiredArgsConstructor
public class SystemConfigService {
private final SystemConfigMapper mapper;
/** 전체 설정 목록 (관리 화면용). */
public List<SystemConfigVO> listAll() {
return mapper.selectAll();
}
/** 특정 그룹의 설정 목록. */
public List<SystemConfigVO> listByGroup(String group) {
return mapper.selectByGroup(group);
}
/** 키로 설정값(문자열)을 조회. 없으면 빈 Optional. 결과는 캐시된다. */
@Cacheable(value = "systemConfig", key = "#key")
public Optional<String> getValue(String key) {
SystemConfigVO vo = mapper.selectByKey(key);
return Optional.ofNullable(vo).map(SystemConfigVO::getConfigValue);
}
/** 문자열 설정값 (없으면 기본값). */
public String getString(String key, String defaultValue) {
return getValue(key).orElse(defaultValue);
}
/** 정수 설정값 (없으면 기본값). */
public int getInt(String key, int defaultValue) {
return getValue(key).map(Integer::parseInt).orElse(defaultValue);
}
/** 금액/비율용 BigDecimal 설정값 (없으면 기본값). */
public BigDecimal getDecimal(String key, BigDecimal defaultValue) {
return getValue(key).map(BigDecimal::new).orElse(defaultValue);
}
/** 불리언 설정값 ("true"/"Y"를 참으로 인정, 없으면 기본값). */
public boolean getBoolean(String key, boolean defaultValue) {
return getValue(key).map(s -> "true".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s)).orElse(defaultValue);
}
/** 설정값 수정. 미존재 시 NOT_FOUND, isEditable='N'이면 수정 거부. 후 해당 키 캐시 무효화. */
@Transactional
@CacheEvict(value = "systemConfig", key = "#vo.configKey")
public void update(SystemConfigVO vo) {
@@ -4,15 +4,18 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 시스템 설정 한 건 (키-값). configKey가 PK다.
*/
@Data
public class SystemConfigVO {
private String configKey;
private String configValue;
private String configGroup;
private String valueType;
private String isEncrypted;
private String isEditable;
private String description;
private String configKey; // 설정 키 (PK)
private String configValue; // 설정 값 (DB에는 항상 문자열로 저장)
private String configGroup; // 설정 그룹 (화면 분류)
private String valueType; // 값 타입 힌트 (STRING/INT/DECIMAL/BOOLEAN 등)
private String isEncrypted; // 암호화 저장 여부 'Y'/'N'
private String isEditable; // 수정 허용 여부 'Y'/'N' (N이면 화면에서 변경 불가)
private String description; // 설명
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -12,6 +12,13 @@ import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 시스템 로그 조회 REST API (시스템관리 화면 전용, SYSTEM_LOG 권한 필요).
* <p>
* 신입 메모: 로그인 로그/API 접근 로그/데이터 변경 로그 세 종류를 페이지네이션으로 조회한다.
* 로그는 컬럼이 종류마다 달라 VO 대신 Map으로 받아 그대로 화면에 넘긴다.
* 목록 조회 전 PageHelper로 페이징을 건다(이후 첫 쿼리에 LIMIT이 자동 적용).
*/
@Tag(name = "시스템로그")
@RestController
@RequestMapping("/api/system/logs")
@@ -56,6 +63,7 @@ public class SystemLogController {
return ApiResponse.ok(PageResponse.of(mapper.selectDataChangeLogs(p)));
}
/** 공통 검색 파라미터(기간)를 매퍼에 넘길 Map으로 변환. 추가 필터는 호출부에서 put. */
private Map<String, Object> toMap(SearchParam p) {
Map<String, Object> m = new HashMap<>();
m.put("startDate", p.getStartDate());
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 시스템 로그 DB 매퍼 (MyBatis) — 로그인/API접근/데이터변경 3종.
* insert*는 로그 적재용(주로 비동기 서비스가 호출), select*는 관리 화면 조회용이다.
*/
@Mapper
public interface SystemLogMapper {
@@ -8,9 +8,14 @@ import java.time.temporal.ChronoUnit;
/**
* 날짜/정산월 유틸. 정산월 포맷은 "yyyyMM" 6자리.
* <p>
* 신입 메모: 수수료 정산은 "월" 단위로 돈다. 그래서 화면/DB에서 정산월을 "202605"처럼
* 6자리 문자열로 주고받는데, 이 유틸이 그 문자열과 실제 날짜 범위(월초~월말) 사이를
* 변환해 준다. {@link java.time.YearMonth}를 써서 윤년/월별 일수 계산을 안전하게 처리한다.
*/
public final class DateUtil {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private DateUtil() {}
public static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -18,19 +23,25 @@ public final class DateUtil {
public static final DateTimeFormatter FMT_MONTH = DateTimeFormatter.ofPattern("yyyyMM");
public static final DateTimeFormatter FMT_DATE_KOR = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
/** 오늘 날짜 "yyyy-MM-dd". */
public static String today() {
return LocalDate.now().format(FMT_DATE);
}
/** 이번 달 정산월 "yyyyMM" (인자 없으면 현재 기준). */
public static String getSettleMonth() {
return YearMonth.now().format(FMT_MONTH);
}
/** 주어진 날짜가 속한 정산월 "yyyyMM". */
public static String getSettleMonth(LocalDate date) {
return YearMonth.from(date).format(FMT_MONTH);
}
/** "202605" → ["2026-05-01", "2026-05-31"] */
/**
* 정산월을 [월초, 월말] 날짜 배열로 변환. 예) "202605" → ["2026-05-01", "2026-05-31"].
* 정산 대상 기간을 SQL between 조건 등에 넘길 때 사용한다.
*/
public static String[] getMonthRange(String settleMonth) {
YearMonth ym = YearMonth.parse(settleMonth, FMT_MONTH);
return new String[] {
@@ -39,16 +50,19 @@ public final class DateUtil {
};
}
/** 두 정산월 사이의 개월 수 차이 (to - from). 경과월 계산(차수 산정 등)에 사용. */
public static int calcMonthDiff(String fromYyyymm, String toYyyymm) {
YearMonth from = YearMonth.parse(fromYyyymm, FMT_MONTH);
YearMonth to = YearMonth.parse(toYyyymm, FMT_MONTH);
return (int) ChronoUnit.MONTHS.between(from, to);
}
/** 두 날짜가 속한 월 사이의 개월 수 차이 (to - from). */
public static int calcMonthDiff(LocalDate from, LocalDate to) {
return (int) ChronoUnit.MONTHS.between(YearMonth.from(from), YearMonth.from(to));
}
/** 문자열을 주어진 패턴으로 LocalDate 파싱. 비거나 형식이 틀리면 null(예외 던지지 않음). */
public static LocalDate parseDate(String s, String pattern) {
if (s == null || s.isBlank()) return null;
try {
@@ -58,10 +72,12 @@ public final class DateUtil {
}
}
/** LocalDate를 주어진 패턴 문자열로 변환. null이면 null. */
public static String format(LocalDate date, String pattern) {
return date == null ? null : date.format(DateTimeFormatter.ofPattern(pattern));
}
/** 정산월에 개월 수를 더한 정산월 반환. 예) addMonth("202605", 3) → "202608". 음수면 과거로 이동. */
public static String addMonth(String yyyymm, int months) {
return YearMonth.parse(yyyymm, FMT_MONTH).plusMonths(months).format(FMT_MONTH);
}
@@ -7,13 +7,20 @@ package com.ga.common.util;
* - RRN : 880101-1234567 → 880101-*******
* - ACCT : 110-123-456789 → 110-***-******
* - EMAIL: aaa@bbb.com → a**@bbb.com
* <p>
* 신입 메모: 주민번호/계좌번호 등은 DB에는 암호화({@link EncryptUtil})해 저장하고,
* 화면·로그에 보여줄 때는 이 유틸로 일부만 가린다. "저장=암호화, 표시=마스킹" 두 가지가 다른 역할임에 주의.
* 형식이 예상과 다르면(자리수 불일치 등) 원본을 그대로 돌려준다.
*/
public final class MaskUtil {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private MaskUtil() {}
/** 마스킹 대상 종류 (이름/전화/주민번호/계좌/이메일). */
public enum MaskType { NAME, PHONE, RRN, ACCOUNT, EMAIL }
/** 종류에 맞는 마스킹을 골라 적용하는 진입점. 값이 비어 있으면 그대로 반환. */
public static String mask(String value, MaskType type) {
if (value == null || value.isBlank()) return value;
return switch (type) {
@@ -25,6 +32,7 @@ public final class MaskUtil {
};
}
/** 이름 마스킹: 가운데 글자를 *로. 2글자면 끝 글자만 가림(홍길동→홍*동, 홍길→홍*). */
public static String maskName(String name) {
int len = name.length();
if (len <= 1) return name;
@@ -32,6 +40,7 @@ public final class MaskUtil {
return name.charAt(0) + "*".repeat(len - 2) + name.charAt(len - 1);
}
/** 전화번호 마스킹: 가운데 국번을 *로 (숫자만 추출 후 10/11자리만 처리). */
public static String maskPhone(String phone) {
String digits = phone.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
@@ -43,12 +52,14 @@ public final class MaskUtil {
return phone;
}
/** 주민등록번호 마스킹: 뒷 7자리 전부 *로 (앞 생년월일 6자리만 노출). */
public static String maskRrn(String rrn) {
String s = rrn.replaceAll("[^0-9]", "");
if (s.length() != 13) return rrn;
return s.substring(0, 6) + "-*******";
}
/** 계좌번호 마스킹: 첫 구간만 남기고 이후 구간을 *로. 구분자(-)가 없으면 앞 4자리만 노출. */
public static String maskAccount(String account) {
if (account == null) return null;
String[] parts = account.split("-");
@@ -63,6 +74,7 @@ public final class MaskUtil {
return account.substring(0, 4) + "*".repeat(account.length() - 4);
}
/** 이메일 마스킹: 아이디 첫 글자만 남기고 나머지를 *로 (도메인은 그대로). */
public static String maskEmail(String email) {
int at = email.indexOf('@');
if (at <= 1) return email;
@@ -6,35 +6,49 @@ import java.text.DecimalFormat;
/**
* 금액 계산 유틸. 모든 금액은 BigDecimal, 반올림은 HALF_UP, 소수점 2자리.
* <p>
* 신입 메모: 돈 계산에 double/float를 쓰면 0.1+0.2 같은 데서 미세 오차가 생긴다.
* 그래서 수수료 시스템의 모든 금액은 {@link BigDecimal}로 다루고, 더하기/빼기/곱하기/나누기/퍼센트를
* 이 유틸의 정적 메서드로만 처리해 반올림 규칙(HALF_UP, 2자리)을 한 곳에서 통일한다.
* 또한 모든 입력은 null이 들어와도 0으로 간주({@link #zero})하여 NPE를 막는다.
*/
public final class MoneyUtil {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private MoneyUtil() {}
/** 금액 소수점 자리수 (원 단위 + 소수 2자리). */
public static final int SCALE = 2;
/** 퍼센트 계산용 상수 100. */
public static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/** 사업소득 원천징수 기본 세율 3.3% (소득세 3% + 지방소득세 0.3%). */
public static final BigDecimal DEFAULT_TAX_RATE = new BigDecimal("3.3");
private static final DecimalFormat FMT = new DecimalFormat("#,##0");
private static final DecimalFormat FMT_DEC = new DecimalFormat("#,##0.00");
private static final DecimalFormat FMT_SIGN = new DecimalFormat("+#,##0;-#,##0");
/** null이면 0으로 바꿔 돌려준다(모든 계산의 NPE 방지 기본기). */
public static BigDecimal zero(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
/** 더하기 (a+b, null은 0). */
public static BigDecimal add(BigDecimal a, BigDecimal b) {
return zero(a).add(zero(b));
}
/** 빼기 (a-b, null은 0). */
public static BigDecimal sub(BigDecimal a, BigDecimal b) {
return zero(a).subtract(zero(b));
}
/** 곱하기 (a×b) 후 2자리 반올림. */
public static BigDecimal mul(BigDecimal a, BigDecimal b) {
return zero(a).multiply(zero(b)).setScale(SCALE, RoundingMode.HALF_UP);
}
/** 나누기 (a÷b) 후 2자리 반올림. 0으로 나누면 예외 대신 0을 반환한다. */
public static BigDecimal div(BigDecimal a, BigDecimal b) {
if (b == null || b.signum() == 0) return BigDecimal.ZERO;
return zero(a).divide(b, SCALE, RoundingMode.HALF_UP);
@@ -46,28 +60,35 @@ public final class MoneyUtil {
return amount.multiply(ratePct).divide(HUNDRED, SCALE, RoundingMode.HALF_UP);
}
/** 세액 계산 (기본 3.3%) */
/** 세액 계산 (기본 3.3% 원천징수). 설계사 수수료 지급 시 떼는 세금 계산에 사용. */
public static BigDecimal tax(BigDecimal amount) {
return pct(amount, DEFAULT_TAX_RATE);
}
/** 세율을 직접 지정하는 세액 계산. */
public static BigDecimal tax(BigDecimal amount, BigDecimal ratePct) {
return pct(amount, ratePct);
}
/** 천단위 콤마 정수 문자열 (예: 1234567 → "1,234,567"). null은 빈 문자열. */
public static String fmt(BigDecimal v) {
return v == null ? "" : FMT.format(v);
}
/** 천단위 콤마 + 소수 2자리 문자열 (예: "1,234,567.00"). */
public static String fmtDec(BigDecimal v) {
return v == null ? "" : FMT_DEC.format(v);
}
/** 부호를 항상 표시하는 문자열 (예: +1,000 / -1,000). 환수/차감 표기에 유용. */
public static String fmtSign(BigDecimal v) {
return v == null ? "" : FMT_SIGN.format(v);
}
/** 0(또는 null)인가? */
public static boolean isZero(BigDecimal v) { return v == null || v.signum() == 0; }
/** 양수인가? (지급 대상 판단 등) */
public static boolean isPositive(BigDecimal v) { return v != null && v.signum() > 0; }
/** 음수인가? (환수/마이너스 정산 판단 등) */
public static boolean isNegative(BigDecimal v) { return v != null && v.signum() < 0; }
}
@@ -8,11 +8,19 @@ import java.util.Optional;
/**
* 현재 로그인 사용자 조회.
* <p>
* 신입 메모: Spring Security는 요청을 처리하는 스레드마다 "지금 누가 로그인했는지"를
* {@link SecurityContextHolder}에 담아 둔다. 이 유틸은 거기서 우리 앱의 사용자 객체
* {@link LoginUser}를 꺼내 주는 단축 도구다. 컨트롤러/서비스 어디서든 파라미터로
* 사용자 정보를 넘기지 않고도 현재 사용자 id/loginId를 얻을 수 있다.
* (배치 등 인증 컨텍스트가 없으면 비어 있음/null이 나온다.)
*/
public final class SecurityUtil {
/** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */
private SecurityUtil() {}
/** 현재 로그인 사용자({@link LoginUser})를 Optional로 반환. 미인증이면 빈 Optional. */
public static Optional<LoginUser> getLoginUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
@@ -21,10 +29,12 @@ public final class SecurityUtil {
return Optional.empty();
}
/** 현재 로그인 사용자의 내부 PK(user_id). 미인증이면 null. 등록자/수정자 기록에 자주 쓴다. */
public static Long getCurrentUserId() {
return getLoginUser().map(LoginUser::getUserId).orElse(null);
}
/** 현재 로그인 사용자의 로그인 아이디. 미인증이면 null. */
public static String getCurrentLoginId() {
return getLoginUser().map(LoginUser::getLoginId).orElse(null);
}