운영 화면에서는 특정 상품의 속성을 한 건만 가볍게 바꾸는 작업과 목록에서 여러 건을 체크해 한 번에 바꾸는 작업이 항상 공존한다. 한 가지 API로 억지로 처리하면 검증/에러/트랜잭션/성능/권한 가드가 서로 충돌한다. 그래서 단건 API와 벌크 API를 분리해 각각의 사용 맥락에 최적화하는 것이 실전적으로 맞다.
사용성 분리
에러 모델 단순화 vs 부분성공
트랜잭션/성능 최적화
안전장치 차등
영속성 컨텍스트 일관성
항목 | 단건(confirm one) | 벌크(confirm many) |
---|---|---|
주요 사용처 | 상세 화면에서 즉시 저장 | 다중 선택 일괄 변경, 배치성 작업 |
응답/에러 | 단순(성공/실패) | 부분 성공(성공/실패 목록, 실패 사유) |
트랜잭션 | 1건 원자성 | 전체 원샷 또는 청크/부분 커밋 |
성능 | 호출 수 많으면 RTT↑ | 네트워크/DB 왕복↓, 벌크 쿼리 1회 |
구현 포인트 | findById → set → flush | UPDATE ... WHERE id IN (...) + clear |
위험도/가드 | 낮음 | 높음 → 권한/크기 제한/드라이런/감사 로그 |
관측/감사 | 리소스 단위 | 요청 메타 + 항목별 결과 로깅/다운로드 |
public record ConfirmOneRequest(
@NotNull Boolean isConfirmed
) {}
public record ConfirmBulkRequest(
@NotEmpty @Size(max = 1000) List<Long> ids,
@NotNull Boolean isConfirmed
) {}
@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
@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);
}
}
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 라운드 트립과 엔티티 관리 비용을 크게 줄일 수 있었지만, 그 대가로 영속성 컨텍스트 일관성, 엔티티 이벤트 부재 같은 특성을 이해하고 대응해야 했다. 결국 단건과 벌크를 분리하는 것은 기능 분할 그 자체가 아니라 각 맥락에 최적인 트랜잭션/검증/관측/성능 전략을 적용하기 위한 전제 조건임을 확실히 체감했다.