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:
GA Pro
2026-05-12 23:37:27 +09:00
parent af3c6b9d51
commit 498d0d44f0
11 changed files with 128 additions and 52 deletions
@@ -22,33 +22,39 @@ public class LedgerService {
private final MaintainLedgerMapper maintainMapper; private final MaintainLedgerMapper maintainMapper;
private final ExceptionLedgerMapper exceptionMapper; private final ExceptionLedgerMapper exceptionMapper;
@Transactional(readOnly = true)
public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) { public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(recruitMapper.selectList(param)); return PageResponse.of(recruitMapper.selectList(param));
} }
@Transactional(readOnly = true)
public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) { public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(maintainMapper.selectList(param)); return PageResponse.of(maintainMapper.selectList(param));
} }
@Transactional(readOnly = true)
public PageResponse<ExceptionLedgerResp> listException(LedgerSearchParam param) { public PageResponse<ExceptionLedgerResp> listException(LedgerSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(exceptionMapper.selectList(param)); return PageResponse.of(exceptionMapper.selectList(param));
} }
@Transactional(readOnly = true)
public LedgerResp detailRecruit(Long ledgerId) { public LedgerResp detailRecruit(Long ledgerId) {
LedgerResp r = recruitMapper.selectDetailById(ledgerId); LedgerResp r = recruitMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND); if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r; return r;
} }
@Transactional(readOnly = true)
public LedgerResp detailMaintain(Long ledgerId) { public LedgerResp detailMaintain(Long ledgerId) {
LedgerResp r = maintainMapper.selectDetailById(ledgerId); LedgerResp r = maintainMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND); if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r; return r;
} }
@Transactional(readOnly = true)
public ExceptionLedgerResp detailException(Long ledgerId) { public ExceptionLedgerResp detailException(Long ledgerId) {
ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId); ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND); if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -18,11 +18,13 @@ public class AgentService {
private final AgentMapper mapper; private final AgentMapper mapper;
private final CommonCodeService codeService; private final CommonCodeService codeService;
@Transactional(readOnly = true)
public PageResponse<AgentResp> list(AgentSearchParam param) { public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(mapper.selectList(param)); return PageResponse.of(mapper.selectList(param));
} }
@Transactional(readOnly = true)
public AgentResp detail(Long agentId) { public AgentResp detail(Long agentId) {
AgentResp resp = mapper.selectDetailById(agentId); AgentResp resp = mapper.selectDetailById(agentId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -77,6 +79,7 @@ public class AgentService {
mapper.updateStatus(agentId, status); mapper.updateStatus(agentId, status);
} }
@Transactional(readOnly = true)
public java.util.List<AgentOrgHistoryVO> history(Long agentId) { public java.util.List<AgentOrgHistoryVO> history(Long agentId) {
return mapper.selectHistory(agentId); return mapper.selectHistory(agentId);
} }
@@ -18,15 +18,18 @@ public class OrganizationService {
private final OrganizationMapper mapper; private final OrganizationMapper mapper;
@Transactional(readOnly = true)
public List<OrganizationResp> getTree() { public List<OrganizationResp> getTree() {
List<OrganizationResp> flat = mapper.selectAllForTree(); List<OrganizationResp> flat = mapper.selectAllForTree();
return buildTree(flat); return buildTree(flat);
} }
@Transactional(readOnly = true)
public List<OrganizationResp> list(String keyword, String orgType, String isActive) { public List<OrganizationResp> list(String keyword, String orgType, String isActive) {
return mapper.selectList(keyword, orgType, isActive); return mapper.selectList(keyword, orgType, isActive);
} }
@Transactional(readOnly = true)
public OrganizationVO get(Long orgId) { public OrganizationVO get(Long orgId) {
OrganizationVO vo = mapper.selectById(orgId); OrganizationVO vo = mapper.selectById(orgId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -19,11 +19,13 @@ public class ContractService {
private final ContractMapper mapper; private final ContractMapper mapper;
@Transactional(readOnly = true)
public PageResponse<ContractResp> list(ContractSearchParam param) { public PageResponse<ContractResp> list(ContractSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(mapper.selectList(param)); return PageResponse.of(mapper.selectList(param));
} }
@Transactional(readOnly = true)
public ContractResp detail(Long contractId) { public ContractResp detail(Long contractId) {
ContractResp resp = mapper.selectDetailById(contractId); ContractResp resp = mapper.selectDetailById(contractId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -22,21 +22,25 @@ public class SettleService {
private final SettleMasterMapper masterMapper; private final SettleMasterMapper masterMapper;
@Transactional(readOnly = true)
public PageResponse<SettleMasterResp> list(SettleSearchParam param) { public PageResponse<SettleMasterResp> list(SettleSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(masterMapper.selectList(param)); return PageResponse.of(masterMapper.selectList(param));
} }
@Transactional(readOnly = true)
public SettleMasterResp detail(Long agentId, String settleMonth) { public SettleMasterResp detail(Long agentId, String settleMonth) {
SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth); SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND); if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r; return r;
} }
@Transactional(readOnly = true)
public Map<String, Object> summary(String settleMonth) { public Map<String, Object> summary(String settleMonth) {
return masterMapper.selectMonthSummary(settleMonth); return masterMapper.selectMonthSummary(settleMonth);
} }
@Transactional(readOnly = true)
public List<Map<String, Object>> orgSummary(String settleMonth) { public List<Map<String, Object>> orgSummary(String settleMonth) {
return masterMapper.selectOrgSummary(settleMonth); return masterMapper.selectOrgSummary(settleMonth);
} }
@@ -25,11 +25,13 @@ public class UserService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final SystemConfigService configService; private final SystemConfigService configService;
@Transactional(readOnly = true)
public PageResponse<UserResp> list(UserSearchParam param) { public PageResponse<UserResp> list(UserSearchParam param) {
param.startPage(); param.startPage();
return PageResponse.of(mapper.selectList(param)); return PageResponse.of(mapper.selectList(param));
} }
@Transactional(readOnly = true)
public UserResp detail(Long userId) { public UserResp detail(Long userId) {
UserResp r = mapper.selectDetailById(userId); UserResp r = mapper.selectDetailById(userId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND); if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -95,6 +97,7 @@ public class UserService {
mapper.updateStatus(userId, UserStatus.ACTIVE.name()); mapper.updateStatus(userId, UserStatus.ACTIVE.name());
} }
@Transactional(readOnly = true)
public List<String> roleCodes(Long userId) { public List<String> roleCodes(Long userId) {
return mapper.selectRoleCodes(userId); return mapper.selectRoleCodes(userId);
} }
@@ -26,34 +26,35 @@ public class TransferFileService {
private final FileService fileService; private final FileService fileService;
private final EncryptUtil encryptUtil; private final EncryptUtil encryptUtil;
@Transactional
public TransferFileResp generate(String settleMonth, String bankCode) { 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); List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
if (payments.isEmpty()) { if (payments.isEmpty()) {
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")"); throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
} }
// 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태) // 2) 파일 byte 생성 + 저장 — 트랜잭션 밖 (IO 작업이므로 커넥션 점유 불필요)
BankTransferFileGenerator generator = factory.get(bankCode); BankTransferFileGenerator generator = factory.get(bankCode);
byte[] fileContent = generator.generate(payments, settleMonth); byte[] fileContent = generator.generate(payments, settleMonth);
// 3) FileService로 file_storage 적재 → fileId 반환
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension(); String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream"); 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() List<Long> paymentIds = payments.stream()
.map(PaymentVO::getPaymentId) .map(PaymentVO::getPaymentId)
.collect(Collectors.toList()); .collect(Collectors.toList());
paymentMapper.updateTransferFile(paymentIds, fileId); markPaymentsSent(paymentIds, fileId);
// 5) 합계 금액 계산
BigDecimal totalAmount = payments.stream() BigDecimal totalAmount = payments.stream()
.map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO) .map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
return new TransferFileResp(fileId, fileName, payments.size(), totalAmount); 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 변환 규칙 최적화 캐시. * LOOKUP 변환 규칙 최적화 캐시.
* *
* processUpload() 시작 시 LOOKUP 컬럼의 참조 테이블을 한 번에 로드하여 * preload()가 새 Map 인스턴스를 반환한다 — 싱글톤 상태를 보유하지 않으므로 thread-safe.
* 행 처리마다 DB 조회하는 것을 방지한다 (70만건 × DB 조회 = 지옥 → HashMap.get() = 즉시). * 호출자(UploadService)가 반환된 Map을 보유하고 TransformEngine에 전달한다.
* *
* 캐시 구조: Map< "table:sourceField:targetField" → Map<sourceValue, targetValue> > * 캐시 구조: Map< "table:sourceField:targetField" → Map<sourceValue, targetValue> >
*/ */
@@ -40,14 +40,12 @@ public class LookupCache {
private static final Pattern FIELD_PATTERN = private static final Pattern FIELD_PATTERN =
Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$"); 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) { public Map<String, Map<String, Object>> preload(List<UploadTemplateColumnVO> columns) {
cache.clear(); Map<String, Map<String, Object>> cache = new HashMap<>();
columns.stream() columns.stream()
.filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule())) .filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule()))
@@ -71,12 +69,15 @@ public class LookupCache {
log.debug("LookupCache preloaded: {} → {} entries", key, map.size()); 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; if (value == null) return null;
String key = buildKey(table, sourceField, targetField); String key = buildKey(table, sourceField, targetField);
Map<String, Object> map = cache.get(key); Map<String, Object> map = cache.get(key);
@@ -37,17 +37,19 @@ public class TransformEngine {
/** /**
* 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환. * 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환.
* *
* @param row xlsx-streamer Row * @param row xlsx-streamer Row
* @param columns 템플릿 컬럼 정의 * @param columns 템플릿 컬럼 정의
* @param lookupData preload()가 반환한 요청 범위 LOOKUP 캐시
* @return { targetField → 변환된 값 } * @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<>(); Map<String, Object> result = new LinkedHashMap<>();
for (UploadTemplateColumnVO col : columns) { for (UploadTemplateColumnVO col : columns) {
try { try {
Object rawValue = extractRaw(row, col); 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); result.put(col.getTargetField(), transformed);
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
@@ -64,7 +66,8 @@ public class TransformEngine {
* 단일 컬럼에 변환 규칙을 적용. * 단일 컬럼에 변환 규칙을 적용.
*/ */
public Object applyTransform(Object value, UploadTemplateColumnVO col, 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(); String rule = col.getTransformRule();
// FIXED 는 원본 값 무시 // FIXED 는 원본 값 무시
@@ -88,8 +91,8 @@ public class TransformEngine {
case "DATE" -> applyDate(value, col); case "DATE" -> applyDate(value, col);
case "DIVIDE" -> applyDivide(value, col); case "DIVIDE" -> applyDivide(value, col);
case "MULTIPLY" -> applyMultiply(value, col); case "MULTIPLY" -> applyMultiply(value, col);
case "LOOKUP" -> applyLookup(value, col); case "LOOKUP" -> applyLookup(value, col, lookupData);
case "CODE_MAP" -> applyCodeMap(value, col); case "CODE_MAP" -> applyCodeMap(value, col, lookupData);
case "REPLACE" -> applyReplace(value, col); case "REPLACE" -> applyReplace(value, col);
case "SUBSTRING" -> applySubstring(value, col); case "SUBSTRING" -> applySubstring(value, col);
case "CONCAT" -> applyConcat(row, col); case "CONCAT" -> applyConcat(row, col);
@@ -180,9 +183,11 @@ public class TransformEngine {
return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros(); 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; if (value == null) return null;
Object result = lookupCache.lookup( Object result = lookupCache.lookup(
lookupData,
col.getLookupTable(), col.getLookupTable(),
col.getLookupSourceField(), col.getLookupSourceField(),
value.toString(), value.toString(),
@@ -193,10 +198,11 @@ public class TransformEngine {
return result; 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; if (value == null) return null;
Object mapped = lookupCache.lookup( 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); 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.file.FileService;
import com.ga.common.util.SecurityUtil; import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.upload.UploadColumnMapper; 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.mapper.upload.UploadTemplateMapper;
import com.ga.core.vo.upload.UploadHistoryVO; import com.ga.core.vo.upload.UploadHistoryVO;
import com.ga.core.vo.upload.UploadTemplateColumnVO; 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.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
@@ -36,6 +34,10 @@ import java.util.regex.Pattern;
* *
* processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장 * processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장
* preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음) * preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음)
*
* 트랜잭션 경계: 파일 파싱은 트랜잭션 밖. history 저장/갱신과 배치 INSERT는
* UploadTxHelper를 통해 REQUIRES_NEW 독립 트랜잭션으로 처리하여
* 롱 트랜잭션으로 인한 커넥션 풀 고갈을 방지한다.
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -44,11 +46,10 @@ public class UploadService {
private final UploadTemplateMapper templateMapper; private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper; private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
private final TransformEngine transformEngine; private final TransformEngine transformEngine;
private final LookupCache lookupCache; private final LookupCache lookupCache;
private final DynamicInsertMapper dynamicInsertMapper;
private final FileService fileService; private final FileService fileService;
private final UploadTxHelper txHelper;
/** target_table whitelist — SQL Injection 방지 */ /** target_table whitelist — SQL Injection 방지 */
private static final Set<String> ALLOWED_TARGET_TABLES = Set.of( 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) { public UploadResultResp processUpload(String templateCode, MultipartFile file) {
long startMs = System.currentTimeMillis(); long startMs = System.currentTimeMillis();
@@ -74,12 +74,12 @@ public class UploadService {
validateTargetTable(template.getTargetTable()); validateTargetTable(template.getTargetTable());
// 2. LOOKUP 캐시 사전 로드 (70만건 × DB 조회 방지) // 2. LOOKUP 캐시 사전 로드 (요청 범위 Map — thread-safe)
lookupCache.preload(columns); Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
// 3. history 레코드 생성 (PROCESSING 상태) // 3. history 레코드 생성 (PROCESSING 상태) — 독립 트랜잭션
UploadHistoryVO history = initHistory(template, file); UploadHistoryVO history = initHistory(template, file);
historyMapper.insert(history); txHelper.saveHistory(history);
List<ExcelErrorRow> errors = new ArrayList<>(); List<ExcelErrorRow> errors = new ArrayList<>();
int successCount = 0; int successCount = 0;
@@ -92,6 +92,7 @@ public class UploadService {
List<Map<String, Object>> batch = new ArrayList<>(batchSize); List<Map<String, Object>> batch = new ArrayList<>(batchSize);
// 4. 파일 파싱 — 트랜잭션 밖에서 실행
try (InputStream is = file.getInputStream(); try (InputStream is = file.getInputStream();
Workbook wb = StreamingReader.builder() Workbook wb = StreamingReader.builder()
.rowCacheSize(100) .rowCacheSize(100)
@@ -118,7 +119,7 @@ public class UploadService {
} }
try { try {
Map<String, Object> mapped = transformEngine.transform(row, columns); Map<String, Object> mapped = transformEngine.transform(row, columns, lookupData);
validateRow(mapped, columns, rowNum, errors); validateRow(mapped, columns, rowNum, errors);
final int currentRow = rowNum; final int currentRow = rowNum;
@@ -126,8 +127,8 @@ public class UploadService {
if (!rowHasError) { if (!rowHasError) {
batch.add(mapped); batch.add(mapped);
if (batch.size() >= batchSize) { if (batch.size() >= batchSize) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); // 청크 단위 독립 트랜잭션으로 커밋
successCount += batch.size(); successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
batch.clear(); batch.clear();
log.debug("Batch flush: {} rows processed so far", totalCount); log.debug("Batch flush: {} rows processed so far", totalCount);
} }
@@ -140,17 +141,18 @@ public class UploadService {
} }
if (!batch.isEmpty()) { if (!batch.isEmpty()) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
successCount += batch.size();
batch.clear(); batch.clear();
} }
} catch (BizException e) { } catch (BizException e) {
updateHistoryFail(history.getHistoryId(), e.getDisplayMessage()); txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getDisplayMessage(),
0, 0, 0, 0, null, 0);
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("Upload processing error: templateCode={}", templateCode, 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()); throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
} }
@@ -163,7 +165,8 @@ public class UploadService {
errorFileId = createErrorFile(errors, file.getOriginalFilename()); 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); successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
return UploadResultResp.builder() return UploadResultResp.builder()
@@ -186,7 +189,7 @@ public class UploadService {
UploadTemplateVO template = loadTemplate(templateCode); UploadTemplateVO template = loadTemplate(templateCode);
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId()); 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<>(); List<Map<String, Object>> result = new ArrayList<>();
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2; int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
@@ -207,7 +210,7 @@ public class UploadService {
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue; if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue;
try { try {
result.add(transformEngine.transform(row, columns)); result.add(transformEngine.transform(row, columns, lookupData));
} catch (Exception e) { } catch (Exception e) {
result.add(Map.of("_error", "" + rowNum + ": " + e.getMessage())); result.add(Map.of("_error", "" + rowNum + ": " + e.getMessage()));
} }
@@ -337,11 +340,6 @@ public class UploadService {
return h; 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, private void validateRow(Map<String, Object> row, List<UploadTemplateColumnVO> columns,
int rowNum, List<ExcelErrorRow> errors) { int rowNum, List<ExcelErrorRow> errors) {
for (UploadTemplateColumnVO col : columns) { 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);
}
}