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 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);
}
}