Spring Boot 3 핵심 정리 시리즈 5편. REST API 의 실전 기능을 한 번에 풀어 갑니다 — @RequestParam 으로 쿼리 파라미터 다루기, MultipartFile 로 CSV 파일 업로드, OpenCSV 로 헤더 기반 매핑, Pageable 인터페이스로 페이징·정렬 자동 처리, Page vs Slice vs List 트레이드오프, JPA Specification 으로 동적 쿼리 조합, Spring Data REST 자동 엔드포인트까지. 회사 우편물실·도서관 비유로 풀어쓴 친절한 5편.
이 글은 Spring Boot 3 핵심 정리 시리즈의 다섯 번째 편입니다. 1~4 편에서 Spring Boot, Spring MVC, JPA, MySQL·Flyway·TestContainers 의 기본기를 잡았다면, 5 편에서는 REST API 의 실전 기능을 한 번에 풀어 가요.
이 글의 주제는 네 가지예요. 첫째, MultipartFile 로 CSV 파일을 받아서 처리하는 흐름. 둘째, @RequestParam 으로 쿼리 파라미터를 다루는 방법. 셋째, Spring Data 의 Pageable 인터페이스로 페이징과 정렬을 자동 처리하는 패턴. 넷째, JPA Specification 으로 동적 쿼리를 조합하는 방법. 한 마디로 정리하면 — REST API 가 실무에서 마주치는 가장 흔한 4 가지 기능을 다 풀어내는 글이에요.
이 글의 비유는 — MultipartFile = 회사 우편물실의 큰 박스 접수 / Pageable = 도서관 사서가 한 번에 N 권씩 꺼내 주는 방식 / Specification = 도서관 검색대의 동적 필터 조합. 이 비유들을 따라가면 어노테이션이 한 번에 정리됩니다.
왜 이 단원이 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, MultipartFile 이 일반 @RequestBody 와 어떻게 다른지 안 보여요. "JSON 도 본문이고 파일도 본문 아닌가" 싶은데, multipart/form-data 형식은 일반 JSON 과 완전히 다른 포맷이에요.
둘째, Pageable 이 마법처럼 동작해요. "내가 컨트롤러 인자에 Pageable pageable 을 박았는데 어떻게 자동으로 채워지지?" 싶은 마음이 들죠. 0 기반 vs 1 기반 페이지 번호 함정도 있어요.
셋째, Page · Slice · List 셋 중 무엇을 골라야 할지 안 보여요. 이름이 비슷해서 차이가 한 번에 안 들어와요.
넷째, Specification 의 람다 문법이 처음에는 무섭게 생겼어요. (root, query, criteriaBuilder) -> ... 같은 표현이 익숙하지 않으면 거의 다른 언어처럼 보입니다.
해결법은 한 가지예요. MultipartFile 을 우편물실 큰 박스 접수, Pageable 을 도서관 사서의 페이지 단위 책 출납, Specification 을 도서관 검색대의 동적 필터로 잡고 비유를 따라가면 갑자기 명확해집니다. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.
@RequestParam — 쿼리 파라미터의 정체
REST API 에서 URL 의 ? 뒤에 붙는 key=value 식 표현이 쿼리 파라미터예요. 자원의 식별은 @PathVariable 로, 자원 조회의 조건(필터링·검색·페이징·정렬)은 @RequestParam 으로 받는 게 REST 설계 원칙이에요.
회사 비유로 — 쿼리 파라미터는 안내 데스크에 "이런이런 조건으로 자료 찾아 줘" 라고 추가 메모를 끼워 주는 거예요. URL 경로(PathVariable)는 "어떤 자료" 인지를 가리키고, 쿼리 파라미터는 "어떤 조건" 으로 검색할지를 말해 줘요.
쿼리 파라미터 URL 예시:
GET /api/v1/products?productName=Book&category=Books&page=0&size=20&sort=productName,asc
↑ 이름 필터 ↑ 카테고리 필터 ↑ 페이지 번호 ↑ 정렬
@RequestParam 자주 쓰는 옵션:
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// 기본 쿼리 파라미터 처리
@GetMapping
public Page<ProductDTO> listProducts(
// required=false: 선택적 파라미터 (없으면 null)
@RequestParam(name = "productName", required = false) String productName,
// defaultValue: 파라미터가 없을 때 기본값
@RequestParam(name = "category", required = false) String category,
@RequestParam(name = "showInventory", defaultValue = "false") Boolean showInventory,
// Spring Data Pageable (page, size, sort 자동 처리)
Pageable pageable) {
return productService.listProducts(productName, category, showInventory, pageable);
}
// 복잡한 필터링
@GetMapping("/search")
public ResponseEntity<List<ProductDTO>> searchProducts(
@RequestParam(required = false) String name,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) Integer minQuantity) {
// 파라미터가 null 이면 해당 조건은 무시
List<ProductDTO> results = productService.searchProducts(name, minPrice, maxPrice, minQuantity);
return ResponseEntity.ok(results);
}
// 여러 값을 받는 쿼리 파라미터
@GetMapping("/by-categories")
public ResponseEntity<List<ProductDTO>> getProductsByCategories(
@RequestParam(name = "category") List<String> categories) {
// URL: /api/v1/products/by-categories?category=Books&category=Electronics
return ResponseEntity.ok(productService.getProductsByCategories(categories));
}
}
여기서 시험 함정이 하나 있어요. @PathVariable 과 @RequestParam 의 사용처가 다릅니다. 비교표로 한 번에 정리하면:
| 구분 | @PathVariable | @RequestParam |
|---|---|---|
| URL 위치 | 경로의 일부 (/products/{id}) | 쿼리 스트링 (?name=value) |
| 사용 목적 | 자원 식별 | 필터링·페이징·정렬 |
| 필수 여부 | 일반적으로 필수 | 선택적 가능 (required=false) |
| REST 원칙 | 자원 주소의 일부 | 요청 옵션 |
REST 원칙을 따르면 — /products/abc-123 은 PathVariable, ?productName=Book 은 RequestParam 이에요.
Pageable — 도서관 사서의 페이지 단위 출납
대량의 데이터를 한 번에 반환하면 성능과 클라이언트 처리 부담이 폭증해요. 수만 건의 자료를 한 번에 던지면 안내 데스크 앞에 줄이 길어지죠. 그래서 페이지 단위로 잘라서 주고받는 게 표준이에요.
Spring Data 의 Pageable 인터페이스는 페이징·정렬 정보를 캡슐화해요. Spring MVC 와의 통합으로 HTTP 요청의 쿼리 파라미터(page·size·sort)가 자동으로 Pageable 객체로 변환돼요. 개발자는 페이징 로직을 직접 구현할 필요 없이 Pageable 을 서비스와 레포지토리로 전달하기만 하면 됩니다.
회사 비유로 — Pageable 은 도서관 사서에게 "1 페이지 부터 25 권만, 제목 오름차순으로" 라고 한 번에 주문하는 표준 양식이에요. Spring MVC 가 자동으로 이 양식을 채워 줘요.
Pageable URL 파라미터:
GET /api/v1/products?page=0&size=25&sort=productName,asc
↑ 0부터 시작 ↑ 페이지당 항목 수 ↑ 정렬 필드,방향
GET /api/v1/products?page=1&size=10&sort=price,desc&sort=productName,asc
↑ 여러 정렬 조건 (쉼표로 구분)
// Pageable 을 사용하는 Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
// Pageable 을 인자로 받으면 자동으로 페이징·정렬 처리
Page<Product> findAll(Pageable pageable);
Page<Product> findAllByProductNameIsLike(String productName, Pageable pageable);
Page<Product> findAllByCategory(String category, Pageable pageable);
Page<Product> findAllByProductNameIsLikeAndCategory(String productName, String category,
Pageable pageable);
}
Page 응답 객체가 제공하는 정보:
Page<ProductDTO> page = productService.listProducts(..., pageable);
page.getContent(); // 현재 페이지의 실제 데이터 목록
page.getTotalElements(); // 전체 데이터 수
page.getTotalPages(); // 전체 페이지 수
page.getNumber(); // 현재 페이지 번호 (0부터 시작)
page.getSize(); // 페이지당 항목 수
page.isFirst(); // 첫 번째 페이지 여부
page.isLast(); // 마지막 페이지 여부
page.hasNext(); // 다음 페이지 존재 여부
page.hasPrevious(); // 이전 페이지 존재 여부
page.getSort(); // 현재 정렬 정보
page.getNumberOfElements(); // 현재 페이지의 실제 항목 수
여기서 정말 중요한 시험 함정 — Spring Data 의 페이지 번호는 0 기반이에요. page=0 이 첫 페이지입니다. 그런데 클라이언트(UI)는 일반적으로 1 기반을 기대해요. 서비스 계층에서 변환을 해 주는 게 좋아요.
// 클라이언트로부터 1 기반 페이지 번호를 받아 0 기반으로 변환
public PageRequest buildPageRequest(Integer pageNumber, Integer pageSize) {
int queryPageNumber;
if (pageNumber != null && pageNumber > 0) {
queryPageNumber = pageNumber - 1; // 1 기반 → 0 기반 변환
} else {
queryPageNumber = 0; // 기본값
}
// ...
}
또 하나의 함정 — 페이지 크기 제한 없이 클라이언트 요청을 그대로 받으면 위험해요. pageSize=999999 같은 요청을 받으면 서버 부하가 폭증해요. 최대 페이지 크기를 제한해야 합니다.
private static final int MAX_PAGE_SIZE = 1000;
if (pageSize == null) {
queryPageSize = DEFAULT_PAGE_SIZE;
} else if (pageSize > MAX_PAGE_SIZE) {
queryPageSize = MAX_PAGE_SIZE;
} else {
queryPageSize = pageSize;
}
@PageableDefault — 기본값 지정
쿼리 파라미터가 없을 때 기본 페이징 설정을 지정하려면 @PageableDefault 를 써요.
@GetMapping
public Page<ProductDTO> listProducts(
@RequestParam(required = false) String productName,
@RequestParam(required = false) String category,
// 기본값: page=0, size=25, sort=productName ASC
@PageableDefault(size = 25, sort = "productName", direction = Sort.Direction.ASC)
Pageable pageable) {
return productService.listProducts(productName, category, pageable);
}
테스트나 서비스 안에서 Pageable 을 직접 만들 때는 PageRequest.of() 를 써요.
Pageable firstPage = PageRequest.of(0, 10); // 첫 번째 페이지, 10개
Pageable sortedPage = PageRequest.of(0, 10, Sort.by("productName").ascending());
Pageable multiSort = PageRequest.of(0, 10,
Sort.by("category").ascending().and(Sort.by("productName").descending()));
Page vs Slice vs List — 어느 걸 골라야 할까
Spring Data JPA 에서 페이징 결과를 받는 타입이 셋이에요. 각자 트레이드오프가 있어요.
| 반환 타입 | COUNT 쿼리 | 전체 페이지 수 | 다음 페이지 존재 여부 | 성능 | 적합한 경우 |
|---|---|---|---|---|---|
Page | 있음 | 알 수 있음 | 알 수 있음 | 낮음 | 페이지 번호 표시 UI |
Slice | 없음 | 모름 | 알 수 있음 | 중간 | 무한 스크롤·"더 보기" |
List | 없음 | 모름 | 모름 | 높음 | 제한된 소규모 목록 |
// Page — 전체 개수 포함 (추가 COUNT 쿼리 발생)
Page<Product> findAllByCategory(String category, Pageable pageable);
// 실행 쿼리 2개:
// SELECT * FROM product WHERE category = ? LIMIT ? OFFSET ?
// SELECT COUNT(*) FROM product WHERE category = ?
// Slice — 다음 페이지 존재 여부만 (COUNT 쿼리 없음)
Slice<Product> findAllByCategory(String category, Pageable pageable);
// 실행 쿼리 1개 (size+1개 조회하여 다음 페이지 확인):
// SELECT * FROM product WHERE category = ? LIMIT ? + 1
// List — 단순 목록 (Pageable 의 정렬만 사용)
List<Product> findAllByCategory(String category, Pageable pageable);
여기서 시험 함정이 하나 있어요. 무한 스크롤 UI 에서 Page 를 쓰면 매번 불필요한 COUNT 쿼리가 나가요. 무한 스크롤은 전체 페이지 수가 필요 없으니 Slice 가 정답입니다. UI 패턴에 따라 반환 타입을 바꿔 주는 게 성능 최적화의 첫 단계예요.
회사 비유로 — Page 는 "이 카테고리에 책 총 몇 권 있고 그중 첫 25권" 식으로 알려 줘요. Slice 는 "여기 25권 있고 더 있어요/없어요" 만 알려 줘요. 도서관 검색 결과 화면에서 "총 1234권 중 25권" 이 필요하면 Page, "더 보기" 버튼 형식이면 Slice 예요.
동적 쿼리 — 조건이 선택적일 때
쿼리 파라미터가 선택적일 때, 서비스 계층에서 어떤 파라미터가 제공됐는지에 따라 다른 쿼리를 실행해야 해요. Spring Data JPA 에서는 여러 방법으로 처리할 수 있어요.
방법 1: 파생 쿼리 조합 (조건 2~3 개)
@Service
@RequiredArgsConstructor
public class ProductServiceJPA implements ProductService {
private final ProductRepository productRepository;
private final ProductMapper productMapper;
@Override
public Page<ProductDTO> listProducts(String productName, String category,
Boolean showInventory, Pageable pageable) {
Page<Product> productPage;
// 조건 조합에 따라 적절한 메서드 선택
if (StringUtils.hasText(productName) && category == null) {
productPage = listProductsByName(productName, pageable);
} else if (!StringUtils.hasText(productName) && category != null) {
productPage = listProductsByCategory(category, pageable);
} else if (StringUtils.hasText(productName) && category != null) {
productPage = listProductsByNameAndCategory(productName, category, pageable);
} else {
productPage = productRepository.findAll(pageable);
}
// showInventory 가 false 이면 quantityOnHand 를 null 로 설정
if (!showInventory) {
return productPage.map(product -> {
ProductDTO dto = productMapper.productToProductDto(product);
dto.setQuantityOnHand(null);
return dto;
});
}
return productPage.map(productMapper::productToProductDto);
}
public Page<Product> listProductsByName(String productName, Pageable pageable) {
return productRepository.findAllByProductNameIsLike("%" + productName + "%", pageable);
}
public Page<Product> listProductsByCategory(String category, Pageable pageable) {
return productRepository.findAllByCategory(category, pageable);
}
public Page<Product> listProductsByNameAndCategory(String productName, String category,
Pageable pageable) {
return productRepository.findAllByProductNameIsLikeAndCategory(
"%" + productName + "%", category, pageable);
}
}
방법 2: JPA Specification (조건 4 개 이상)
조건이 4 개 이상이면 if-else 가 폭발해요. 이때는 Specification 패턴이 정답이에요.
// Specification 정의
public class ProductSpecifications {
public static Specification<Product> hasProductName(String productName) {
return (root, query, criteriaBuilder) -> {
if (!StringUtils.hasText(productName)) return criteriaBuilder.conjunction();
return criteriaBuilder.like(
criteriaBuilder.lower(root.get("productName")),
"%" + productName.toLowerCase() + "%"
);
};
}
public static Specification<Product> hasCategory(String category) {
return (root, query, criteriaBuilder) -> {
if (category == null) return criteriaBuilder.conjunction();
return criteriaBuilder.equal(root.get("category"), category);
};
}
public static Specification<Product> hasPriceRange(BigDecimal min, BigDecimal max) {
return (root, query, criteriaBuilder) -> {
if (min == null && max == null) return criteriaBuilder.conjunction();
if (min == null) return criteriaBuilder.lessThanOrEqualTo(root.get("price"), max);
if (max == null) return criteriaBuilder.greaterThanOrEqualTo(root.get("price"), min);
return criteriaBuilder.between(root.get("price"), min, max);
};
}
}
// Repository 에 JpaSpecificationExecutor 추가
public interface ProductRepository extends JpaRepository<Product, UUID>,
JpaSpecificationExecutor<Product> {
}
// 서비스에서 Specification 조합
@Service
public class ProductServiceJPA {
public Page<ProductDTO> listProducts(String productName, String category,
BigDecimal minPrice, BigDecimal maxPrice,
Pageable pageable) {
Specification<Product> spec = Specification
.where(ProductSpecifications.hasProductName(productName))
.and(ProductSpecifications.hasCategory(category))
.and(ProductSpecifications.hasPriceRange(minPrice, maxPrice));
return productRepository.findAll(spec, pageable)
.map(productMapper::productToProductDto);
}
}
회사 비유로 — Specification 은 도서관 검색대의 동적 필터예요. "제목에 X 포함" + "카테고리 Y" + "가격 Z 이하" 같은 필터를 자유롭게 켜고 끌 수 있어요. 각 필터(Specification)가 독립적이라 조합이 자유롭고 재사용도 쉬워요.
여기서 정말 중요한 시험 함정 — null 인 조건은 criteriaBuilder.conjunction() 으로 항상 참(true) 처리해야 해요. 그래야 그 조건이 적용 안 된 것처럼 동작합니다. null 조건에서 그냥 null 을 반환하면 NullPointerException 이 나요.
동적 쿼리 4 가지 방법 비교:
| 방법 | 복잡도 | 타입 안전성 | 유지보수성 | 적합한 경우 |
|---|---|---|---|---|
| 파생 쿼리 조합 | 낮음 | 높음 | 낮음 (조건 늘수록) | 2-3개 조건 |
@Query (JPQL) | 중간 | 중간 | 중간 | 고정된 복잡한 쿼리 |
| JPA Specification | 높음 | 높음 | 높음 | 많은 선택적 조건 |
| QueryDSL | 높음 | 매우 높음 | 높음 | 타입 안전 복잡한 쿼리 |
MultipartFile — CSV 파일 업로드의 표준
REST API 에서 파일을 받으려면 application/json 이 아닌 multipart/form-data 형식으로 요청을 받아야 해요. Spring MVC 는 이를 위해 MultipartFile 인터페이스를 제공해요.
회사 비유로 — MultipartFile 은 우편물실에서 큰 박스를 접수받는 식이에요. 일반 메모(JSON)는 봉투에 넣어 우편 슬롯으로 던지지만, 큰 박스는 우편물실 카운터에 따로 접수해야 해요. multipart/form-data 는 그 큰 박스를 위한 별도 규격이에요.
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ProductCsvController {
private final ProductCsvService productCsvService;
@PostMapping(value = "/products/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<ProductCsvRecord>> importCsv(
@RequestParam("file") MultipartFile file) {
// 파일이 비어있는지 확인
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Content-Type 검증
String contentType = file.getContentType();
if (!"text/csv".equals(contentType) && !"application/vnd.ms-excel".equals(contentType)) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
}
List<ProductCsvRecord> records = productCsvService.convertCSV(file.getResource());
return ResponseEntity.ok(records);
}
}
MultipartFile 이 제공하는 정보:
file.getOriginalFilename()— 원본 파일 이름file.getSize()— 파일 크기(바이트)file.getContentType()— MIME 타입file.getInputStream()— 파일 내용 스트림file.getResource()— Resource 추상화로 접근file.isEmpty()— 빈 파일 여부
여기서 시험 함정이 하나 있어요. Spring Boot 의 기본 파일 크기 제한이 1 MB 예요. 큰 CSV 파일을 받으려면 명시적으로 늘려야 해요.
spring:
servlet:
multipart:
max-file-size: 10MB # 단일 파일 최대 크기
max-request-size: 20MB # 전체 요청 최대 크기
enabled: true # 멀티파트 활성화
또 하나의 함정 — 한도를 초과하면 MaxUploadSizeExceededException 이 발생해요. @ControllerAdvice 에서 처리해 주는 게 좋아요.
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<String> handleMaxSizeException(MaxUploadSizeExceededException e) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body("파일 크기가 허용 범위를 초과합니다. 최대 10MB까지 업로드 가능합니다.");
}
OpenCSV — 헤더 기반 매핑
CSV 파일을 자바 객체로 매핑할 때 가장 자주 쓰는 라이브러리가 OpenCSV 예요. 어노테이션 기반으로 CSV 컬럼을 자바 필드에 자동 매핑해요.
<!-- pom.xml -->
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>5.9</version>
</dependency>
// CSV 레코드 매핑 클래스
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProductCsvRecord {
@CsvBindByName
private Integer row;
@CsvBindByName(column = "id")
private Integer id;
@CsvBindByName(column = "name")
private String productName;
@CsvBindByName(column = "category")
private String category;
@CsvBindByName(column = "price")
private BigDecimal price;
@CsvBindByName(column = "ounces")
private Float ounces;
}
// CSV 파싱 서비스
@Service
public class ProductCsvServiceImpl implements ProductCsvService {
@Override
public List<ProductCsvRecord> convertCSV(Resource csvFile) {
try {
Reader reader = new InputStreamReader(csvFile.getInputStream());
CsvToBean<ProductCsvRecord> csvToBean = new CsvToBeanBuilder<ProductCsvRecord>(reader)
.withType(ProductCsvRecord.class)
.withMappingStrategy(new HeaderColumnNameMappingStrategy<>())
.withIgnoreLeadingWhiteSpace(true)
.withIgnoreEmptyLine(true)
.build();
return csvToBean.parse();
} catch (IOException e) {
throw new RuntimeException("CSV 파일 처리 중 오류가 발생했습니다.", e);
}
}
}
여기서 시험 함정이 하나 있어요. CSV 에 한글이 포함되면 인코딩 문제가 발생할 수 있어요. UTF-8 BOM 이 붙은 파일을 그냥 읽으면 첫 컬럼이 깨져요. BOMInputStream + StandardCharsets.UTF_8 조합이 안전합니다.
@Override
public List<ProductCsvRecord> convertCSV(Resource csvFile) throws IOException {
try (InputStream is = csvFile.getInputStream();
BOMInputStream bomIs = BOMInputStream.builder().setInputStream(is).get();
Reader reader = new InputStreamReader(bomIs, StandardCharsets.UTF_8)) {
CsvToBean<ProductCsvRecord> csvToBean = new CsvToBeanBuilder<ProductCsvRecord>(reader)
.withType(ProductCsvRecord.class)
.build();
return csvToBean.parse();
}
}
CSV 파싱 라이브러리 비교 한 줄:
| 라이브러리 | 어노테이션 | 성능 | 유연성 | 스트리밍 |
|---|---|---|---|---|
| OpenCSV | @CsvBindByName | 보통 | 높음 | 가능 |
| Apache Commons CSV | 없음 (직접 매핑) | 좋음 | 중간 | 가능 |
| Jackson CsvMapper | 있음 | 좋음 | 높음 | 가능 |
| Super CSV | 있음 | 좋음 | 높음 | 가능 |
CSV 데이터를 DB 에 일괄 저장
대량 CSV 데이터를 DB 에 저장할 때는 개별 save() 가 아닌 saveAll() 을 써요. 4 편의 BootstrapData 패턴과 같아요.
@Component
@RequiredArgsConstructor
public class BootstrapData implements ApplicationRunner {
private final ProductRepository productRepository;
private final ProductCsvService productCsvService;
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
loadProductData();
}
private void loadProductData() throws FileNotFoundException {
// 멱등성 — 이미 데이터가 있으면 건너뜀
if (productRepository.count() > 0) {
return;
}
Resource resource = new ClassPathResource("csvdata/products.csv");
List<ProductCsvRecord> records = productCsvService.convertCSV(resource);
List<Product> products = records.stream()
.filter(rec -> StringUtils.hasText(rec.getCategory()))
.map(rec -> Product.builder()
.productName(StringUtils.truncate(rec.getProductName(), 50))
.category(rec.getCategory())
.price(rec.getPrice() != null ? rec.getPrice() : BigDecimal.TEN)
.upc(rec.getRow().toString())
.quantityOnHand(rec.getId())
.build())
.collect(Collectors.toList());
productRepository.saveAll(products); // 배치 INSERT
log.info("Loaded Products: {}", productRepository.count());
}
}
여기서 시험 함정이 하나 있어요. CSV 파일의 일부 행에 오류가 있을 때 전체 파싱이 실패해요. 한 행 때문에 전체가 깨지면 곤란하니, OpenCSV 의 withThrowExceptions(false) 로 오류를 수집만 하고 나머지를 진행하는 게 좋아요.
CsvToBean<ProductCsvRecord> csvToBean = new CsvToBeanBuilder<ProductCsvRecord>(reader)
.withType(ProductCsvRecord.class)
.withThrowExceptions(false) // 예외 던지지 않고 수집
.build();
List<CsvException> exceptions = csvToBean.getCapturedExceptions();
if (!exceptions.isEmpty()) {
log.warn("CSV 파싱 중 {} 개의 오류 발생", exceptions.size());
}
CSV 업로드 통합 테스트
MockMVC 로 파일 업로드도 테스트할 수 있어요. MockMultipartFile 을 써요.
@SpringBootTest
@AutoConfigureMockMvc
class ProductCsvControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void testUploadCSV() throws Exception {
ClassPathResource csvResource = new ClassPathResource("csvdata/products.csv");
MockMultipartFile csvFile = new MockMultipartFile(
"file", // 파라미터 이름
"products.csv", // 원본 파일 이름
"text/csv", // Content-Type
csvResource.getInputStream() // 파일 내용
);
mockMvc.perform(multipart("/api/v1/products/import")
.file(csvFile))
.andExpect(status().isOk());
}
@Test
void testUploadEmptyFile() throws Exception {
MockMultipartFile emptyFile = new MockMultipartFile(
"file", "empty.csv", "text/csv", new byte[0]);
mockMvc.perform(multipart("/api/v1/products/import").file(emptyFile))
.andExpect(status().isBadRequest());
}
}
Spring Data REST — 자동 엔드포인트 생성
Spring Data REST 는 Spring Data Repository 를 기반으로 REST API 를 자동으로 생성해 주는 프레임워크예요. JpaRepository 인터페이스를 정의하기만 하면 CRUD 엔드포인트가 자동으로 노출돼요. 빠른 프로토타이핑이나 단순 CRUD API 에 적합해요. 자세한 내용은 Spring Data 공식 문서에서 확인할 수 있어요.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
자동으로 생성되는 엔드포인트:
GET /products → 목록 조회 (페이징 지원)
GET /products/{id} → 단건 조회
POST /products → 생성
PUT /products/{id} → 전체 수정
PATCH /products/{id} → 부분 수정
DELETE /products/{id} → 삭제
GET /products/search → 파생 쿼리 메서드 목록
응답은 HATEOAS 표준을 따라 링크 정보를 포함해요.
# Spring Data REST 전역 설정
spring:
data:
rest:
base-path: /api/data # 기본 경로 분리
default-page-size: 20
max-page-size: 100
여기서 시험 함정이 하나 있어요. Spring Data REST 와 직접 작성한 @RestController 를 같은 경로로 같이 쓰면 충돌해요. 기본 경로를 분리하거나, 둘 중 하나만 쓰는 게 안전해요.
Spring Data REST vs 직접 구현 비교:
| 구분 | Spring Data REST | 직접 구현 (@RestController) |
|---|---|---|
| 구현 속도 | 매우 빠름 | 느림 |
| 커스터마이징 | 제한적 | 완전한 제어 |
| 비즈니스 로직 | 어려움 | 용이 |
| 응답 형식 | HAL/HATEOAS 고정 | 자유로움 |
| 적합한 경우 | 단순 CRUD·프로토타입 | 복잡한 비즈니스 로직 |
정렬 필드 화이트리스트 — 보안 함정
Pageable 을 컨트롤러에서 직접 받을 때 클라이언트가 임의의 필드로 정렬을 요청할 수 있어 보안 위험이 있어요.
// 안전한 방법: 허용된 정렬 필드 화이트리스트 검증
@GetMapping
public Page<ProductDTO> listProducts(Pageable pageable) {
Set<String> allowedSortFields = Set.of("productName", "category", "price", "createdDate");
pageable.getSort().stream()
.map(Sort.Order::getProperty)
.filter(field -> !allowedSortFields.contains(field))
.findFirst()
.ifPresent(field -> {
throw new IllegalArgumentException("허용되지 않는 정렬 필드: " + field);
});
return productService.listProducts(pageable);
}
회사 비유로 — 도서관 검색대에서 "직원 비밀번호 컬럼으로 정렬" 같은 요청이 들어오면 막아야 해요. 허용된 필드 목록을 미리 정해 두는 게 정답이에요.
페이징 + N+1 — 위험한 조합
@OneToMany 관계의 엔티티를 페이징할 때 FETCH JOIN 을 쓰면 Hibernate 가 메모리에서 페이징을 수행한다는 경고가 발생해요. 전체 데이터를 메모리에 로드해서 위험합니다.
// 위험한 패턴 — HibernateJpaDialect 경고 발생
@Query("SELECT p FROM Product p LEFT JOIN FETCH p.reviews")
Page<Product> findAllWithReviews(Pageable pageable);
// WARNING: HHH90003004: firstResult/maxResults specified with collection fetch
// 안전한 패턴 — 2단계로 분리
// 1. ID 만 페이징으로 조회
@Query("SELECT p.id FROM Product p")
Page<UUID> findAllIds(Pageable pageable);
// 2. ID 로 FETCH JOIN 조회
@Query("SELECT DISTINCT p FROM Product p LEFT JOIN FETCH p.reviews WHERE p.id IN :ids")
List<Product> findAllByIdWithReviews(@Param("ids") List<UUID> ids);
@RequestParam 검증
쿼리 파라미터에도 Bean Validation 을 걸 수 있어요. 단, 클래스 레벨에 @Validated 가 박혀 있어야 활성화돼요.
@Validated // 클래스 레벨에 추가해야 @RequestParam 검증 활성화
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@GetMapping
public Page<ProductDTO> listProducts(
@RequestParam(required = false)
@Size(max = 50, message = "검색어는 50자 이하여야 합니다.")
String productName,
@RequestParam(required = false)
@Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.")
Integer pageNumber,
@RequestParam(required = false)
@Max(value = 1000, message = "페이지 크기는 최대 1000입니다.")
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
Integer pageSize) {
return productService.listProducts(productName, null, false, pageNumber, pageSize);
}
}
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 5 편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- MultipartFile = 우편물실 큰 박스 / Pageable = 도서관 사서의 페이지 출납 / Specification = 검색대 동적 필터
@PathVariable= 자원 식별 /@RequestParam= 필터·페이징·정렬 옵션@RequestParam(required = false)— 선택적 파라미터@RequestParam(defaultValue = "X")— 없을 때 기본값@RequestParam List— 같은 키 여러 번 (?style=A&style=B)- Spring Data 페이지 번호 0 기반 — 클라이언트 1 기반과 변환 필요
Pageable—page·size·sort자동 변환@PageableDefault(size=25, sort="productName")— 기본값 지정PageRequest.of(0, 10, Sort.by("name").ascending())— 코드에서 직접 생성- Page vs Slice vs List — Page(전체 개수) / Slice(다음 페이지 여부만) / List(단순)
- 무한 스크롤은 Slice 가 정답 — Page 는 불필요한 COUNT 쿼리
- 페이지 크기 최대값 제한 —
pageSize=999999같은 요청 막기 - 정렬 필드 화이트리스트 검증 — 임의 필드 정렬 보안 위험
- 동적 쿼리 4 가지 — 파생 쿼리(2-3개) /
@Query/ Specification(많은 조건) / QueryDSL - Specification — null 조건은
criteriaBuilder.conjunction()으로 항상 참 처리 MultipartFile—multipart/form-data로 받는 파일- 파일 크기 기본 한도 1MB —
spring.servlet.multipart.max-file-size로 늘리기 - 한도 초과 시 —
MaxUploadSizeExceededException(@ExceptionHandler처리) - OpenCSV —
@CsvBindByName으로 헤더 기반 매핑 - CSV 한글 깨짐 —
BOMInputStream+ UTF-8 조합으로 안전 - 대량 INSERT — 개별
save()대신saveAll()(배치) - CSV 일부 행 오류 —
withThrowExceptions(false)로 수집만 하고 진행 - 테스트 —
MockMultipartFile+mockMvc.perform(multipart(...).file(...)) - Spring Data REST — Repository 만으로 자동 엔드포인트, HATEOAS 응답
- Spring Data REST 와
@RestController같은 경로 충돌 주의 —base-path분리 - 페이징 + FETCH JOIN — 메모리 페이징 경고 (
HHH90003004) - 안전 패턴 — ID 만 페이징 → ID 로 FETCH JOIN 2단계 분리
@RequestParam검증 활성화 — 클래스 레벨에@Validated
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리 (현재 글)
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)