refactor(api): 동시성 정정 + UploadService/TransferFile 트랜잭션 분해 + readOnly 일괄 (리뷰 4단계)
리뷰에서 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 조회 메서드 전체
This commit is contained in:
@@ -22,33 +22,39 @@ public class LedgerService {
|
||||
private final MaintainLedgerMapper maintainMapper;
|
||||
private final ExceptionLedgerMapper exceptionMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) {
|
||||
param.startPage();
|
||||
return PageResponse.of(recruitMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) {
|
||||
param.startPage();
|
||||
return PageResponse.of(maintainMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ExceptionLedgerResp> 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);
|
||||
|
||||
@@ -18,11 +18,13 @@ public class AgentService {
|
||||
private final AgentMapper mapper;
|
||||
private final CommonCodeService codeService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AgentResp> 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<AgentOrgHistoryVO> history(Long agentId) {
|
||||
return mapper.selectHistory(agentId);
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ public class OrganizationService {
|
||||
|
||||
private final OrganizationMapper mapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<OrganizationResp> getTree() {
|
||||
List<OrganizationResp> flat = mapper.selectAllForTree();
|
||||
return buildTree(flat);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<OrganizationResp> 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);
|
||||
|
||||
@@ -19,11 +19,13 @@ public class ContractService {
|
||||
|
||||
private final ContractMapper mapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ContractResp> 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);
|
||||
|
||||
@@ -22,21 +22,25 @@ public class SettleService {
|
||||
|
||||
private final SettleMasterMapper masterMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SettleMasterResp> 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<String, Object> summary(String settleMonth) {
|
||||
return masterMapper.selectMonthSummary(settleMonth);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Map<String, Object>> orgSummary(String settleMonth) {
|
||||
return masterMapper.selectOrgSummary(settleMonth);
|
||||
}
|
||||
|
||||
@@ -25,11 +25,13 @@ public class UserService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final SystemConfigService configService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<UserResp> 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<String> roleCodes(Long userId) {
|
||||
return mapper.selectRoleCodes(userId);
|
||||
}
|
||||
|
||||
@@ -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<PaymentVO> 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<Long> 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<Long> paymentIds, Long fileId) {
|
||||
paymentMapper.updateTransferFile(paymentIds, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<sourceValue, targetValue> >
|
||||
*/
|
||||
@@ -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<String, Map<String, Object>> cache = new HashMap<>();
|
||||
|
||||
/**
|
||||
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload.
|
||||
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 로드하여 새 Map으로 반환.
|
||||
* 반환된 Map은 요청 범위에서만 사용되므로 동기화 불필요.
|
||||
*/
|
||||
public void preload(List<UploadTemplateColumnVO> columns) {
|
||||
cache.clear();
|
||||
public Map<String, Map<String, Object>> preload(List<UploadTemplateColumnVO> columns) {
|
||||
Map<String, Map<String, Object>> 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<String, Map<String, Object>> cache,
|
||||
String table, String sourceField, String value, String targetField) {
|
||||
if (value == null) return null;
|
||||
String key = buildKey(table, sourceField, targetField);
|
||||
Map<String, Object> map = cache.get(key);
|
||||
|
||||
@@ -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<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
|
||||
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns,
|
||||
Map<String, Map<String, Object>> lookupData) {
|
||||
Map<String, Object> 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<UploadTemplateColumnVO> allColumns) {
|
||||
Row row, List<UploadTemplateColumnVO> allColumns,
|
||||
Map<String, Map<String, Object>> 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<String, Map<String, Object>> 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<String, Map<String, Object>> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> 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<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
|
||||
|
||||
// 3. history 레코드 생성 (PROCESSING 상태)
|
||||
// 3. history 레코드 생성 (PROCESSING 상태) — 독립 트랜잭션
|
||||
UploadHistoryVO history = initHistory(template, file);
|
||||
historyMapper.insert(history);
|
||||
txHelper.saveHistory(history);
|
||||
|
||||
List<ExcelErrorRow> errors = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
@@ -92,6 +92,7 @@ public class UploadService {
|
||||
|
||||
List<Map<String, Object>> 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<String, Object> mapped = transformEngine.transform(row, columns);
|
||||
Map<String, Object> 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<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
||||
|
||||
lookupCache.preload(columns);
|
||||
Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
|
||||
|
||||
List<Map<String, Object>> 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<String, Object> row, List<UploadTemplateColumnVO> columns,
|
||||
int rowNum, List<ExcelErrorRow> errors) {
|
||||
for (UploadTemplateColumnVO col : columns) {
|
||||
|
||||
@@ -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<Map<String, Object>> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user