운영자가 한 번에 여러 상품을 등록할 때 기존에는 “성공/실패”만 알 수 있어 원인 파악과 재시도가 불편했습니다. 특히 product_url
의 유니크 제약으로 중복이 자주 발생하는데 요청 리스트 중 어떤 항목이 중복이었고 어떤 항목이 다른 이유로 실패했는지를 한눈에 보기 어려웠습니다.
이번에 벌크 저장 흐름을 항목 단위로 처리하면서 결과를 요약(Summary) + 상세로 반환하도록 정리했습니다.
CrawlingProductSaver.save(dto)
에서 @Transactional(REQUIRES_NEW)
로 실행 → 트랜잭션 단위 분리CrawlingProductService.saveAll()
은 @Transactional(NOT_SUPPORTED)
로 전체 루프 제어만 담당ConstraintViolationException
→ VALIDATION_ERROR
DataIntegrityViolationException (UNIQUE KEY 위반)
→ DUPLICATE_KEY
TRANSACTION_ERROR
/ TRANSACTION_ROLLBACK
UNEXPECTED_ERROR
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public CrawlingProductBulkSaveResponseDto saveAll(List<CrawlingProductRequestDto> requestDtoList) {
int success = 0, duplicated = 0, failed = 0;
List<BulkItemResultDto> results = new ArrayList<>();
for (CrawlingProductRequestDto dto : requestDtoList) {
try {
CrawlingProductResponseDto savedDto = crawlingProductSaver.save(dto);
results.add(new BulkItemResultDto(
dto.productUrl(),
BulkStatus.SUCCESS,
null, null,
savedDto.id(),
savedDto
));
success++;
} catch (ConstraintViolationException cve) {
results.add(new BulkItemResultDto(
dto.productUrl(),
BulkStatus.FAILED,
"VALIDATION_ERROR",
joinViolationMsgs(cve),
null, null
));
failed++;
} catch (DataIntegrityViolationException dive) {
results.add(new BulkItemResultDto(
dto.productUrl(),
BulkStatus.DUPLICATED,
"DUPLICATE_KEY",
"이미 존재하는 URL",
null, null
));
duplicated++;
} catch (Exception e) {
results.add(new BulkItemResultDto(
dto.productUrl(),
BulkStatus.FAILED,
"UNEXPECTED_ERROR",
"예상치 못한 오류가 발생했습니다",
null, null
));
failed++;
}
}
return new CrawlingProductBulkSaveResponseDto(
new BulkSummaryDto(requestDtoList.size(), success, duplicated, failed),
results
);
}
{
"summary": {
"total": 5,
"success": 3,
"duplicated": 1,
"failed": 1
},
"results": [
{
"url": "https://shop.example.com/p/1001",
"status": "SUCCESS",
"reasonCode": null,
"reasonMessage": null,
"id": 1,
"data": { /* 저장된 응답 DTO */ }
},
{
"url": "https://shop.example.com/p/1002",
"status": "DUPLICATED",
"reasonCode": "DUPLICATE_KEY",
"reasonMessage": "이미 존재하는 URL",
"id": null,
"data": null
},
{
"url": "https://shop.example.com/p/1003",
"status": "FAILED",
"reasonCode": "VALIDATION_ERROR",
"reasonMessage": "price: 0 이상이어야 합니다",
"id": null,
"data": null
}
]
}
이번 개선을 통해 트랜잭션 전파 옵션을 어떻게 조정해야 부분 성공 정책을 구현할 수 있는지 명확히 이해할 수 있었다. 전체 루프는 NOT_SUPPORTED
로 두고 단건 저장은 REQUIRES_NEW
로 분리하면 하나의 실패가 전체 요청을 망치지 않고 독립적으로 처리된다는 점이 핵심이었다. 또한 예외 메시지를 그대로 노출하는 대신 reasonCode
와 reasonMessage
를 표준화해서 제공하면 운영자 입장에서도 로그를 보지 않고 바로 문제를 파악할 수 있고 클라이언트에서도 사용자 친화적으로 결과를 처리할 수 있다는 장점을 확인했다. 마지막으로 단순히 “성공/실패”로만 구분하던 기존 구조보다 성공, 중복, 검증 실패 등으로 세분화된 결과를 제공하니 운영 가시성이 크게 향상되었고 실패 건만 골라 재시도하는 등 클라이언트 로직도 훨씬 유연해질 수 있음을 배웠다.