크롤링 상품 벌크 저장: 중복/실패를 분류하고 요약 응답 만들기

송현진·2025년 9월 15일
0

프로젝트 회고

목록 보기
19/22

운영자가 한 번에 여러 상품을 등록할 때 기존에는 “성공/실패”만 알 수 있어 원인 파악과 재시도가 불편했습니다. 특히 product_url의 유니크 제약으로 중복이 자주 발생하는데 요청 리스트 중 어떤 항목이 중복이었고 어떤 항목이 다른 이유로 실패했는지를 한눈에 보기 어려웠습니다.
이번에 벌크 저장 흐름을 항목 단위로 처리하면서 결과를 요약(Summary) + 상세로 반환하도록 정리했습니다.

목표

  • 요청 N건에 대해 성공 / 중복 / 기타 실패를 분류
  • 각 항목별로 원인을 함께 내려 문제를 바로 파악
  • 최상단에 요약 카운트를 제공해 운영 가시성 향상
  • 클라이언트가 부분 성공을 자연스럽게 처리할 수 있도록 응답 구조 표준화

핵심 아이디어

  • 건별 저장은 CrawlingProductSaver.save(dto)에서 @Transactional(REQUIRES_NEW)로 실행 → 트랜잭션 단위 분리
  • CrawlingProductService.saveAll()@Transactional(NOT_SUPPORTED)로 전체 루프 제어만 담당
  • 예외를 유형별로 잡아 표준 코드/메시지 매핑
    • ConstraintViolationExceptionVALIDATION_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로 분리하면 하나의 실패가 전체 요청을 망치지 않고 독립적으로 처리된다는 점이 핵심이었다. 또한 예외 메시지를 그대로 노출하는 대신 reasonCodereasonMessage를 표준화해서 제공하면 운영자 입장에서도 로그를 보지 않고 바로 문제를 파악할 수 있고 클라이언트에서도 사용자 친화적으로 결과를 처리할 수 있다는 장점을 확인했다. 마지막으로 단순히 “성공/실패”로만 구분하던 기존 구조보다 성공, 중복, 검증 실패 등으로 세분화된 결과를 제공하니 운영 가시성이 크게 향상되었고 실패 건만 골라 재시도하는 등 클라이언트 로직도 훨씬 유연해질 수 있음을 배웠다.

profile
개발자가 되고 싶은 취준생

0개의 댓글