단건 API와 여러건(벌크) API를 왜 나누고 어떻게 설계할까

송현진·2025년 8월 13일
0

프로젝트 회고

목록 보기
17/22

운영 화면에서는 특정 상품의 속성을 한 건만 가볍게 바꾸는 작업과 목록에서 여러 건을 체크해 한 번에 바꾸는 작업이 항상 공존한다. 한 가지 API로 억지로 처리하면 검증/에러/트랜잭션/성능/권한 가드가 서로 충돌한다. 그래서 단건 API와 벌크 API를 분리해 각각의 사용 맥락에 최적화하는 것이 실전적으로 맞다.

왜 나누는가

  • 사용성 분리

    • 단건: 상세 화면/작업 중 빠른 저장, 직관적 UX.
    • 벌크: 체크박스 일괄 편집/배치/스크립트 등 운영 생산성↑.
  • 에러 모델 단순화 vs 부분성공

    • 단건: 성공/실패가 명확.
    • 벌크: “성공/실패 목록 + 사유”가 필요한 부분 성공 모델 필요.
  • 트랜잭션/성능 최적화

    • 단건: 1건 원자성.
    • 벌크: 네트워크/DB 왕복↓, JPQL 벌크 UPDATE로 대폭 단축(1쿼리).
  • 안전장치 차등

    • 단건: 영향 범위 작음.
    • 벌크: 오염 리스크↑ → 권한/청크 제한/드라이런/감사 로그 필수.
  • 영속성 컨텍스트 일관성

    • 벌크 UPDATE는 1차 캐시를 건너뛰므로 clear 필요(불일치 방지).

단건 vs 벌크 비교

항목단건(confirm one)벌크(confirm many)
주요 사용처상세 화면에서 즉시 저장다중 선택 일괄 변경, 배치성 작업
응답/에러단순(성공/실패)부분 성공(성공/실패 목록, 실패 사유)
트랜잭션1건 원자성전체 원샷 또는 청크/부분 커밋
성능호출 수 많으면 RTT↑네트워크/DB 왕복↓, 벌크 쿼리 1회
구현 포인트findById → set → flushUPDATE ... WHERE id IN (...) + clear
위험도/가드낮음높음 → 권한/크기 제한/드라이런/감사 로그
관측/감사리소스 단위요청 메타 + 항목별 결과 로깅/다운로드

코드 예시

DTO

public record ConfirmOneRequest(
    @NotNull Boolean isConfirmed
) {}

public record ConfirmBulkRequest(
    @NotEmpty @Size(max = 1000) List<Long> ids,
    @NotNull Boolean isConfirmed
) {}

Controller

@RestController
@RequestMapping("/admin/crawling-products")
@RequiredArgsConstructor
public class AdminCrawlingProductController {

    private final CrawlingProductService service;

    // 단건 확정
    @PatchMapping("/{id}/confirm")
    public ResponseEntity<Void> confirmOne(
            @PathVariable Long id,
            @Valid @RequestBody ConfirmOneRequest req) {
        service.confirmOne(id, req.isConfirmed());
        return ResponseEntity.noContent().build();
    }

    // 벌크 확정
    @PatchMapping("/confirm")
    public ResponseEntity<BulkResult> confirmBulk(
            @Valid @RequestBody ConfirmBulkRequest req) {
        BulkResult result = service.confirmBulk(req.ids(), req.isConfirmed());
        return ResponseEntity.ok(result);
    }
}

Service

@Service
@RequiredArgsConstructor
public class CrawlingProductService {

    private final CrawlingProductRepository repository;

    @Transactional
    public void confirmOne(Long id, boolean isConfirmed) {
        CrawlingProduct p = repository.findById(id)
            .orElseThrow(() -> new NotFoundException("product " + id));
        p.setIsConfirmed(isConfirmed);
        // flush 시 반영
    }

    @Transactional
    public BulkResult confirmBulk(List<Long> ids, boolean isConfirmed) {
        // 중복 제거
        List<Long> distinctIds = ids.stream().distinct().toList();

        // 존재하는 ID 한 번에 조회
        List<Long> existingIds = repository.findExistingIds(distinctIds);
        Set<Long> existingSet = new HashSet<>(existingIds);

        // 실패 분류(NOT_FOUND)
        List<FailedItem> failed = distinctIds.stream()
            .filter(id -> !existingSet.contains(id))
            .map(id -> new FailedItem(id, "NOT_FOUND"))
            .toList();

        if (!existingIds.isEmpty()) {
            repository.bulkUpdateConfirm(existingIds, isConfirmed);
        }

        return new BulkResult(existingIds, failed);
    }
}

Repository (JPQL 벌크 + 존재 ID 조회)

public interface CrawlingProductRepository extends JpaRepository<CrawlingProduct, Long> {

    // 존재 ID만 추출 (네이티브/JPQL/Querydsl 등 방식 자유)
    @Query("SELECT p.id FROM CrawlingProduct p WHERE p.id IN :ids")
    List<Long> findExistingIds(@Param("ids") List<Long> ids);

    // 핵심: 벌크 UPDATE
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("""
      UPDATE CrawlingProduct p
         SET p.isConfirmed = :isConfirmed,
             p.updatedAt = CURRENT_TIMESTAMP
       WHERE p.id IN :ids
    """)
    int bulkUpdateConfirm(@Param("ids") List<Long> ids,
                          @Param("isConfirmed") boolean isConfirmed);
}

이렇게 나눴을 때의 이점

분리의 가장 실질적인 이점은 속도와 안전성의 동시 확보다. 단건 경로는 코드, 테스트, 문서가 간결해지고 작은 변경이 빈번할 때 사용자 경험이 매끄럽다. 벌크 경로는 네트워크/DB 비용을 감축해 운영 생산성을 올리고 대량 작업을 가시화하여 실패 재시도와 회복이 쉬워진다. 또한 위험도가 높은 작업에만 강화된 권한과 검증을 적용할 수 있어 권한 모델을 미세 조정할 수 있다. 마지막으로 관측/감사, 드라이런, Idempotency-Key 같은 운영성을 벌크 경로에 집중 투자하면서 단건 경로는 영향 없이 가볍게 유지하는 유지보수성의 분리 효과가 크다.

📝 배운점

이번 설계에서 가장 크게 느낀 점은 사용 맥락이 다른 일을 한 API로 처리하려고 하면 모든 게 복잡해진다는 것이다. 단건은 UX 관점에서 빠른 피드백과 명확한 원자성이 핵심이기 때문에 도메인 검증과 예외 모델이 단순하다. 반면 벌크는 운영 생산성을 비약적으로 높이지만, 실패 케이스를 구조화하고(부분 성공), 권한·청크·감사 로깅·드라이런 같은 운영 가드 레일을 반드시 함께 설계해야 안정적이다. 또한 JPA 벌크 UPDATE를 도입하면 DB 라운드 트립과 엔티티 관리 비용을 크게 줄일 수 있었지만, 그 대가로 영속성 컨텍스트 일관성, 엔티티 이벤트 부재 같은 특성을 이해하고 대응해야 했다. 결국 단건과 벌크를 분리하는 것은 기능 분할 그 자체가 아니라 각 맥락에 최적인 트랜잭션/검증/관측/성능 전략을 적용하기 위한 전제 조건임을 확실히 체감했다.

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

0개의 댓글