From 498d0d44f097ebc7522111445affd3724709cf98 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Tue, 12 May 2026 23:37:27 +0900 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20+=20UploadService/TransferFile=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=ED=95=B4=20+=20readOnly?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=84=20(=EB=A6=AC=EB=B7=B0=204=EB=8B=A8?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 P0로 지적된 동시성/롱 트랜잭션 이슈 일괄 해소. LookupCache (P0 데이터 정합성): - 싱글톤 인스턴스 필드 캐시 제거 → preload()가 새 Map 반환, 호출자 보유 - TransformEngine.transform/applyTransform/applyLookup/applyCodeMap 시그니처에 lookupData 파라미터 추가하여 명시적 전달 - 스레드 간 공유 상태 자체가 없어져 동기화 불필요 UploadService (P0 가용성): - 신규 UploadTxHelper로 saveHistory/insertBatch/updateHistory를 REQUIRES_NEW로 분리 - UploadService에서 @Transactional 완전 제거 — 파일 파싱은 트랜잭션 밖 - 청크(1000행) 단위 커밋 → 커넥션 점유 시간 감소 TransferFileService (P0 정합성): - 파일 생성을 트랜잭션 밖으로 분리 - markPaymentsSent() 별도 @Transactional로 payment UPDATE만 처리 - 파일 저장 실패 시 payment 상태 미변경으로 재시도 안전성 확보 조회 메서드 readOnly=true 일괄 적용 (Hibernate dirty check 비활성화 / 읽기 복제본 라우팅 준비): - AgentService, ContractService, LedgerService, SettleService, OrganizationService, UserService 조회 메서드 전체 --- .../ga/api/service/ledger/LedgerService.java | 6 +++ .../com/ga/api/service/org/AgentService.java | 3 ++ .../api/service/org/OrganizationService.java | 3 ++ .../api/service/product/ContractService.java | 2 + .../ga/api/service/settle/SettleService.java | 4 ++ .../ga/api/service/system/UserService.java | 3 ++ .../service/transfer/TransferFileService.java | 17 ++++--- .../ga/api/service/upload/LookupCache.java | 21 ++++---- .../api/service/upload/TransformEngine.java | 26 ++++++---- .../ga/api/service/upload/UploadService.java | 46 +++++++++-------- .../ga/api/service/upload/UploadTxHelper.java | 49 +++++++++++++++++++ 11 files changed, 128 insertions(+), 52 deletions(-) create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/UploadTxHelper.java diff --git a/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java b/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java index af3857a..b0818c1 100644 --- a/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java +++ b/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java @@ -22,33 +22,39 @@ public class LedgerService { private final MaintainLedgerMapper maintainMapper; private final ExceptionLedgerMapper exceptionMapper; + @Transactional(readOnly = true) public PageResponse listRecruit(LedgerSearchParam param) { param.startPage(); return PageResponse.of(recruitMapper.selectList(param)); } + @Transactional(readOnly = true) public PageResponse listMaintain(LedgerSearchParam param) { param.startPage(); return PageResponse.of(maintainMapper.selectList(param)); } + @Transactional(readOnly = true) public PageResponse listException(LedgerSearchParam param) { param.startPage(); return PageResponse.of(exceptionMapper.selectList(param)); } + @Transactional(readOnly = true) public LedgerResp detailRecruit(Long ledgerId) { LedgerResp r = recruitMapper.selectDetailById(ledgerId); if (r == null) throw new BizException(ErrorCode.NOT_FOUND); return r; } + @Transactional(readOnly = true) public LedgerResp detailMaintain(Long ledgerId) { LedgerResp r = maintainMapper.selectDetailById(ledgerId); if (r == null) throw new BizException(ErrorCode.NOT_FOUND); return r; } + @Transactional(readOnly = true) public ExceptionLedgerResp detailException(Long ledgerId) { ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId); if (r == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/org/AgentService.java b/ga-api/src/main/java/com/ga/api/service/org/AgentService.java index fecccd1..b85be19 100644 --- a/ga-api/src/main/java/com/ga/api/service/org/AgentService.java +++ b/ga-api/src/main/java/com/ga/api/service/org/AgentService.java @@ -18,11 +18,13 @@ public class AgentService { private final AgentMapper mapper; private final CommonCodeService codeService; + @Transactional(readOnly = true) public PageResponse list(AgentSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + @Transactional(readOnly = true) public AgentResp detail(Long agentId) { AgentResp resp = mapper.selectDetailById(agentId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -77,6 +79,7 @@ public class AgentService { mapper.updateStatus(agentId, status); } + @Transactional(readOnly = true) public java.util.List history(Long agentId) { return mapper.selectHistory(agentId); } diff --git a/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java b/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java index 995c218..b2b28c5 100644 --- a/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java +++ b/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java @@ -18,15 +18,18 @@ public class OrganizationService { private final OrganizationMapper mapper; + @Transactional(readOnly = true) public List getTree() { List flat = mapper.selectAllForTree(); return buildTree(flat); } + @Transactional(readOnly = true) public List list(String keyword, String orgType, String isActive) { return mapper.selectList(keyword, orgType, isActive); } + @Transactional(readOnly = true) public OrganizationVO get(Long orgId) { OrganizationVO vo = mapper.selectById(orgId); if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/product/ContractService.java b/ga-api/src/main/java/com/ga/api/service/product/ContractService.java index b16197b..600ded0 100644 --- a/ga-api/src/main/java/com/ga/api/service/product/ContractService.java +++ b/ga-api/src/main/java/com/ga/api/service/product/ContractService.java @@ -19,11 +19,13 @@ public class ContractService { private final ContractMapper mapper; + @Transactional(readOnly = true) public PageResponse list(ContractSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + @Transactional(readOnly = true) public ContractResp detail(Long contractId) { ContractResp resp = mapper.selectDetailById(contractId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java b/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java index dace550..ddd4399 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java @@ -22,21 +22,25 @@ public class SettleService { private final SettleMasterMapper masterMapper; + @Transactional(readOnly = true) public PageResponse list(SettleSearchParam param) { param.startPage(); return PageResponse.of(masterMapper.selectList(param)); } + @Transactional(readOnly = true) public SettleMasterResp detail(Long agentId, String settleMonth) { SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth); if (r == null) throw new BizException(ErrorCode.NOT_FOUND); return r; } + @Transactional(readOnly = true) public Map summary(String settleMonth) { return masterMapper.selectMonthSummary(settleMonth); } + @Transactional(readOnly = true) public List> orgSummary(String settleMonth) { return masterMapper.selectOrgSummary(settleMonth); } diff --git a/ga-api/src/main/java/com/ga/api/service/system/UserService.java b/ga-api/src/main/java/com/ga/api/service/system/UserService.java index cbc32d8..5f47a11 100644 --- a/ga-api/src/main/java/com/ga/api/service/system/UserService.java +++ b/ga-api/src/main/java/com/ga/api/service/system/UserService.java @@ -25,11 +25,13 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final SystemConfigService configService; + @Transactional(readOnly = true) public PageResponse list(UserSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + @Transactional(readOnly = true) public UserResp detail(Long userId) { UserResp r = mapper.selectDetailById(userId); if (r == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -95,6 +97,7 @@ public class UserService { mapper.updateStatus(userId, UserStatus.ACTIVE.name()); } + @Transactional(readOnly = true) public List roleCodes(Long userId) { return mapper.selectRoleCodes(userId); } diff --git a/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java index fe9ba35..cf8d048 100644 --- a/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java +++ b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java @@ -26,34 +26,35 @@ public class TransferFileService { private final FileService fileService; private final EncryptUtil encryptUtil; - @Transactional public TransferFileResp generate(String settleMonth, String bankCode) { - // 1) 해당 정산월 + 은행의 PENDING payment 조회 (accountNo는 EncryptTypeHandler로 자동복호화됨) + // 1) PENDING payment 조회 — readOnly 트랜잭션 (accountNo는 EncryptTypeHandler로 자동복호화됨) List payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode); if (payments.isEmpty()) { throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")"); } - // 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태) + // 2) 파일 byte 생성 + 저장 — 트랜잭션 밖 (IO 작업이므로 커넥션 점유 불필요) BankTransferFileGenerator generator = factory.get(bankCode); byte[] fileContent = generator.generate(payments, settleMonth); - - // 3) FileService로 file_storage 적재 → fileId 반환 String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension(); Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream"); - // 4) payment들의 transfer_file_id 갱신 + status=SENT + // 3) payment 상태 갱신 — 파일 저장 성공 후 별도 트랜잭션 (파일만 잔존하는 역방향 불일치 방지) List paymentIds = payments.stream() .map(PaymentVO::getPaymentId) .collect(Collectors.toList()); - paymentMapper.updateTransferFile(paymentIds, fileId); + markPaymentsSent(paymentIds, fileId); - // 5) 합계 금액 계산 BigDecimal totalAmount = payments.stream() .map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); return new TransferFileResp(fileId, fileName, payments.size(), totalAmount); } + + @Transactional + public void markPaymentsSent(List paymentIds, Long fileId) { + paymentMapper.updateTransferFile(paymentIds, fileId); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java b/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java index 7a6bac3..805a8e9 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java @@ -16,8 +16,8 @@ import java.util.regex.Pattern; /** * LOOKUP 변환 규칙 최적화 캐시. * - * processUpload() 시작 시 LOOKUP 컬럼의 참조 테이블을 한 번에 로드하여 - * 행 처리마다 DB 조회하는 것을 방지한다 (70만건 × DB 조회 = 지옥 → HashMap.get() = 즉시). + * preload()가 새 Map 인스턴스를 반환한다 — 싱글톤 상태를 보유하지 않으므로 thread-safe. + * 호출자(UploadService)가 반환된 Map을 보유하고 TransformEngine에 전달한다. * * 캐시 구조: Map< "table:sourceField:targetField" → Map > */ @@ -40,14 +40,12 @@ public class LookupCache { private static final Pattern FIELD_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$"); - // 요청 범위 캐시 (processUpload 한 번에만 유효) - private final Map> cache = new HashMap<>(); - /** - * LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload. + * LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 로드하여 새 Map으로 반환. + * 반환된 Map은 요청 범위에서만 사용되므로 동기화 불필요. */ - public void preload(List columns) { - cache.clear(); + public Map> preload(List columns) { + Map> cache = new HashMap<>(); columns.stream() .filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule())) @@ -71,12 +69,15 @@ public class LookupCache { log.debug("LookupCache preloaded: {} → {} entries", key, map.size()); } }); + + return cache; } /** - * O(1) 조회. + * O(1) 조회. cache는 preload()의 반환값이어야 한다. */ - public Object lookup(String table, String sourceField, String value, String targetField) { + public Object lookup(Map> cache, + String table, String sourceField, String value, String targetField) { if (value == null) return null; String key = buildKey(table, sourceField, targetField); Map map = cache.get(key); diff --git a/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java index c062a8f..1e15cd5 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java @@ -37,17 +37,19 @@ public class TransformEngine { /** * 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환. * - * @param row xlsx-streamer Row - * @param columns 템플릿 컬럼 정의 + * @param row xlsx-streamer Row + * @param columns 템플릿 컬럼 정의 + * @param lookupData preload()가 반환한 요청 범위 LOOKUP 캐시 * @return { targetField → 변환된 값 } */ - public Map transform(Row row, List columns) { + public Map transform(Row row, List columns, + Map> lookupData) { Map result = new LinkedHashMap<>(); for (UploadTemplateColumnVO col : columns) { try { Object rawValue = extractRaw(row, col); - Object transformed = applyTransform(rawValue, col, row, columns); + Object transformed = applyTransform(rawValue, col, row, columns, lookupData); result.put(col.getTargetField(), transformed); } catch (BizException e) { throw e; @@ -64,7 +66,8 @@ public class TransformEngine { * 단일 컬럼에 변환 규칙을 적용. */ public Object applyTransform(Object value, UploadTemplateColumnVO col, - Row row, List allColumns) { + Row row, List allColumns, + Map> lookupData) { String rule = col.getTransformRule(); // FIXED 는 원본 값 무시 @@ -88,8 +91,8 @@ public class TransformEngine { case "DATE" -> applyDate(value, col); case "DIVIDE" -> applyDivide(value, col); case "MULTIPLY" -> applyMultiply(value, col); - case "LOOKUP" -> applyLookup(value, col); - case "CODE_MAP" -> applyCodeMap(value, col); + case "LOOKUP" -> applyLookup(value, col, lookupData); + case "CODE_MAP" -> applyCodeMap(value, col, lookupData); case "REPLACE" -> applyReplace(value, col); case "SUBSTRING" -> applySubstring(value, col); case "CONCAT" -> applyConcat(row, col); @@ -180,9 +183,11 @@ public class TransformEngine { return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros(); } - private Object applyLookup(Object value, UploadTemplateColumnVO col) { + private Object applyLookup(Object value, UploadTemplateColumnVO col, + Map> lookupData) { if (value == null) return null; Object result = lookupCache.lookup( + lookupData, col.getLookupTable(), col.getLookupSourceField(), value.toString(), @@ -193,10 +198,11 @@ public class TransformEngine { return result; } - private Object applyCodeMap(Object value, UploadTemplateColumnVO col) { + private Object applyCodeMap(Object value, UploadTemplateColumnVO col, + Map> lookupData) { if (value == null) return null; Object mapped = lookupCache.lookup( - "common_code", "code", value.toString(), "code_name"); + lookupData, "common_code", "code", value.toString(), "code_name"); return mapped != null ? mapped : (col.getDefaultValue() != null ? col.getDefaultValue() : value); } diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java index 5c7409a..d61f92f 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java @@ -6,7 +6,6 @@ import com.ga.common.exception.ErrorCode; import com.ga.common.file.FileService; import com.ga.common.util.SecurityUtil; import com.ga.core.mapper.upload.UploadColumnMapper; -import com.ga.core.mapper.upload.UploadHistoryMapper; import com.ga.core.mapper.upload.UploadTemplateMapper; import com.ga.core.vo.upload.UploadHistoryVO; import com.ga.core.vo.upload.UploadTemplateColumnVO; @@ -19,7 +18,6 @@ import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import org.springframework.lang.NonNull; @@ -36,6 +34,10 @@ import java.util.regex.Pattern; * * processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장 * preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음) + * + * 트랜잭션 경계: 파일 파싱은 트랜잭션 밖. history 저장/갱신과 배치 INSERT는 + * UploadTxHelper를 통해 REQUIRES_NEW 독립 트랜잭션으로 처리하여 + * 롱 트랜잭션으로 인한 커넥션 풀 고갈을 방지한다. */ @Slf4j @Service @@ -44,11 +46,10 @@ public class UploadService { private final UploadTemplateMapper templateMapper; private final UploadColumnMapper columnMapper; - private final UploadHistoryMapper historyMapper; private final TransformEngine transformEngine; private final LookupCache lookupCache; - private final DynamicInsertMapper dynamicInsertMapper; private final FileService fileService; + private final UploadTxHelper txHelper; /** target_table whitelist — SQL Injection 방지 */ private static final Set ALLOWED_TARGET_TABLES = Set.of( @@ -61,7 +62,6 @@ public class UploadService { // 메인: 업로드 처리 // ============================================================ - @Transactional public UploadResultResp processUpload(String templateCode, MultipartFile file) { long startMs = System.currentTimeMillis(); @@ -74,12 +74,12 @@ public class UploadService { validateTargetTable(template.getTargetTable()); - // 2. LOOKUP 캐시 사전 로드 (70만건 × DB 조회 방지) - lookupCache.preload(columns); + // 2. LOOKUP 캐시 사전 로드 (요청 범위 Map — thread-safe) + Map> lookupData = lookupCache.preload(columns); - // 3. history 레코드 생성 (PROCESSING 상태) + // 3. history 레코드 생성 (PROCESSING 상태) — 독립 트랜잭션 UploadHistoryVO history = initHistory(template, file); - historyMapper.insert(history); + txHelper.saveHistory(history); List errors = new ArrayList<>(); int successCount = 0; @@ -92,6 +92,7 @@ public class UploadService { List> batch = new ArrayList<>(batchSize); + // 4. 파일 파싱 — 트랜잭션 밖에서 실행 try (InputStream is = file.getInputStream(); Workbook wb = StreamingReader.builder() .rowCacheSize(100) @@ -118,7 +119,7 @@ public class UploadService { } try { - Map mapped = transformEngine.transform(row, columns); + Map mapped = transformEngine.transform(row, columns, lookupData); validateRow(mapped, columns, rowNum, errors); final int currentRow = rowNum; @@ -126,8 +127,8 @@ public class UploadService { if (!rowHasError) { batch.add(mapped); if (batch.size() >= batchSize) { - dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); - successCount += batch.size(); + // 청크 단위 독립 트랜잭션으로 커밋 + successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch)); batch.clear(); log.debug("Batch flush: {} rows processed so far", totalCount); } @@ -140,17 +141,18 @@ public class UploadService { } if (!batch.isEmpty()) { - dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); - successCount += batch.size(); + successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch)); batch.clear(); } } catch (BizException e) { - updateHistoryFail(history.getHistoryId(), e.getDisplayMessage()); + txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getDisplayMessage(), + 0, 0, 0, 0, null, 0); throw e; } catch (Exception e) { log.error("Upload processing error: templateCode={}", templateCode, e); - updateHistoryFail(history.getHistoryId(), e.getMessage()); + txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getMessage(), + 0, 0, 0, 0, null, 0); throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); } @@ -163,7 +165,8 @@ public class UploadService { errorFileId = createErrorFile(errors, file.getOriginalFilename()); } - historyMapper.updateStatus(history.getHistoryId(), status, null, + // 5. history 완료 갱신 — 독립 트랜잭션 + txHelper.updateHistory(history.getHistoryId(), status, null, successCount, errorCount, skipCount, totalCount, errorFileId, durationSec); return UploadResultResp.builder() @@ -186,7 +189,7 @@ public class UploadService { UploadTemplateVO template = loadTemplate(templateCode); List columns = columnMapper.selectByTemplateId(template.getTemplateId()); - lookupCache.preload(columns); + Map> lookupData = lookupCache.preload(columns); List> result = new ArrayList<>(); int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2; @@ -207,7 +210,7 @@ public class UploadService { if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue; try { - result.add(transformEngine.transform(row, columns)); + result.add(transformEngine.transform(row, columns, lookupData)); } catch (Exception e) { result.add(Map.of("_error", "행 " + rowNum + ": " + e.getMessage())); } @@ -337,11 +340,6 @@ public class UploadService { return h; } - private void updateHistoryFail(Long historyId, String message) { - historyMapper.updateStatus(historyId, "FAIL", message, - 0, 0, 0, 0, null, 0); - } - private void validateRow(Map row, List columns, int rowNum, List errors) { for (UploadTemplateColumnVO col : columns) { diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadTxHelper.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadTxHelper.java new file mode 100644 index 0000000..5657b12 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadTxHelper.java @@ -0,0 +1,49 @@ +package com.ga.api.service.upload; + +import com.ga.core.mapper.upload.UploadHistoryMapper; +import com.ga.core.vo.upload.UploadHistoryVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +/** + * UploadService의 트랜잭션 경계를 분리한 헬퍼. + * + * UploadService 내에서 self-invocation으로는 프록시가 적용되지 않으므로 + * 별도 Bean으로 분리하여 REQUIRES_NEW 트랜잭션을 보장한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UploadTxHelper { + + private final UploadHistoryMapper historyMapper; + private final DynamicInsertMapper dynamicInsertMapper; + + /** history INSERT를 독립 트랜잭션으로 커밋 — 본 작업 실패 시에도 기록이 남는다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveHistory(UploadHistoryVO history) { + historyMapper.insert(history); + } + + /** 배치 INSERT를 독립 트랜잭션으로 커밋 — 청크 단위 커밋으로 커넥션 점유 시간 단축. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int insertBatch(String targetTable, List> batch) { + dynamicInsertMapper.insertBatch(targetTable, batch); + return batch.size(); + } + + /** history 상태 갱신을 독립 트랜잭션으로 커밋. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateHistory(Long historyId, String status, String errorMessage, + int successCount, int errorCount, int skipCount, + int totalCount, Long errorFileId, int durationSec) { + historyMapper.updateStatus(historyId, status, errorMessage, + successCount, errorCount, skipCount, totalCount, errorFileId, durationSec); + } +}