상품 조회 기능 구현하기
상품 조회 기능의 요구 사항은 다음과 같이 할 수 있다.
① 상품 번호를 기준으로 하나의 상품을 조회할 수 있어야 한다.
② 전체 상품 목록을 조회할 수 있어야 한다.
③ 상품 이름에 포함된 특정 문자열을 기준으로 검색할 수 있어야 한다.
상품 번호를 기준으로 하나의 상품을 조회
상품 관리 기능에서 가장 기본적인 조회는 특정 상품 하나를 가져오는 것이다.
이때 기준이 되는 값은 상품 번호, 즉 id다.
상품 번호는 각 상품을 구분하는 고유한 값이기 때문에, 단건 조회는 자연스럽게 id 기준으로 구현하게 된다.
먼저 레포지토리에서는 리스트 안에 저장된 상품들 중에서,
요청으로 들어온 id와 같은 값을 가진 상품을 찾아야 한다.
public Product findById(Long id){
return products.stream()
.filter(product -> product.sameId(id))
.findFirst()
.orElseThrow();
}
이 코드는 products 리스트를 스트림으로 바꾼 뒤, filter()를 사용해 조건에 맞는 상품만 남긴다.
즉, 컬렉션 안에 있는 상품들 중에서 sameId(id)가 true인 상품만 추려내는 것이다.
그다음 findFirst()를 통해 첫 번째 상품 하나를 가져오고,
만약 조건에 맞는 상품이 없다면 orElseThrow()로 예외를 던진다.
여기서 중요한 점은, 단순히 product.getId().equals(id)를 직접 쓰는 대신
도메인 객체인 Product 안에 비교 메서드를 추가했다는 점이다.
public Boolean sameId(Long id){
return this.id.equals(id);
}
이렇게 하면 “상품이 자신의 id와 파라미터 id가 같은지 판단한다”는 의미가 Product 안으로 들어간다.
즉, 비교 로직이 서비스나 레포지토리에 흩어지는 대신,
상품이라는 도메인 객체가 자기 자신의 규칙을 조금 더 표현하게 되는 것이다.
서비스 계층에서는 레포지토리에서 조회한 Product를 외부 응답용 객체인 ProductDto로 바꾸어 반환한다.
public ProductDto findById(Long id){
Product product = listProductRepository.findById(id);
ProductDto productDto = modelMapper.map(product, ProductDto.class);
return productDto;
}
컨트롤러에서는 클라이언트가 URL 경로로 넘긴 id를 받아 서비스에 전달한다.
@RequestMapping(value = "/products/{id}", method = RequestMethod.GET)
public ProductDto findProductById(@PathVariable Long id){
return simpleProductService.findById(id);
}
여기서 @PathVariable은 URL 경로의 일부를 값으로 받아오는 역할을 한다.
예를 들어 /products/1로 요청이 들어오면, 1이 id 값으로 전달된다.
그리고 조회 기능이므로 HTTP 메서드는 GET을 사용한다.

전체 상품 목록을 조회
단건 조회가 특정 상품 하나를 가져오는 기능이라면, 전체 조회는 저장된 상품 목록 전체를 가져오는 기능이다.
레포지토리에서는 현재 저장소 역할을 하는 리스트를 그대로 반환하면 된다.
public List<Product> findAll(){
return products;
}
지금은 메모리 기반 리스트를 사용하고 있기 때문에 구현이 단순하다.
하지만 중요한 것은 레포지토리가 “전체 상품 목록을 조회하는 책임”을 가지고 있다는 점이다.
나중에 저장소가 데이터베이스로 바뀌더라도, 서비스와 컨트롤러는 여전히 findAll()이라는 기능만 호출하면 되고,
실제 조회 방식은 레포지토리 내부에서만 달라질 수 있다.
서비스에서는 이 List<Product>를 그대로 외부에 내보내지 않고, List<ProductDto>로 변환해서 반환한다.
public List<ProductDto> findAll(){
List<Product> products = listProductRepository.findAll();
List<ProductDto> productDtos = products.stream()
.map(product -> modelMapper.map(product, ProductDto.class))
.toList();
return productDtos;
}
단건 조회에서는 상품 하나만 DTO로 바꾸면 되었지만,
전체 조회에서는 리스트 안의 모든 상품을 하나씩 DTO로 변환해야 한다.
map()은 리스트의 각 요소를 다른 형태로 바꾸는 데 매우 자주 쓰이는 함수형 방식이다.
즉, Product 하나하나를 ProductDto로 변환한 뒤, 다시 리스트로 모아서 반환하는 구조다.
컨트롤러는 전체 조회 요청을 GET /products로 받아 서비스에 전달한다.
@RequestMapping(value = "/products", method = RequestMethod.GET)
public List<ProductDto> findAllProduct(){
return simpleProductService.findAll();
}
이렇게 하면 클라이언트는 /products 요청을 통해 전체 상품 목록을 받을 수 있다.

상품 이름에 포함된 특정 문자열을 기준으로 검색
상품 이름에 특정 문자열이 포함된 상품만 검색하는 기능은,
결국 “전체 상품 중에서 조건에 맞는 것만 골라내는 조회”라고 볼 수 있다.
즉, 구조는 전체 조회와 비슷하지만 검색 조건이 하나 더 붙는 형태다.
먼저 Product 도메인 객체에 이름 포함 여부를 판단하는 메서드를 추가할 수 있다.
public Boolean containsName(String name){
return this.name.contains(name);
}
이 메서드는 현재 상품의 이름 안에 파라미터로 들어온 문자열이 포함되어 있는지 확인한다.
레포지토리에서는 이 메서드를 활용해 조건에 맞는 상품만 필터링한다.
public List<Product> findByNameContaining(String name){
return products.stream()
.filter(product -> product.containsName(name))
.toList();
}
이 코드는 저장된 상품들 중에서 containsName(name)이 참인 것만 골라 리스트로 반환한다.
서비스에서는 이전과 마찬가지로 조회된 Product 목록을 ProductDto 목록으로 변환해서 반환한다.
public List<ProductDto> findByNameContaining(String name){
List<Product> products = listProductRepository.findByNameContaining(name);
List<ProductDto> productDtos = products.stream()
.map(product -> modelMapper.map(product, ProductDto.class))
.toList();
return productDtos;
}
여기서 눈에 띄는 점은 전체 조회의 DTO 변환 코드와 거의 동일하다는 것이다.
즉, 이런 경우에는 중복을 줄이기 위해 private 메서드로 묶어서 재사용하는 것도 좋은 리팩토링 방법이 될 수 있다.
컨트롤러는 처음에는 전체 조회와 이름 검색을 각각 다른 엔드포인트로 만들고 싶을 수도 있다.
하지만 두 기능은 본질적으로 모두 “상품 목록 조회”라는 큰 범주 안에 들어간다.
차이는 조건이 있느냐 없느냐뿐이다.
그래서 REST 관점에서는 같은 GET /products 요청 안에서, Query String 유무에 따라 동작을 나누는 방식이 자연스럽다.
@RequestMapping(value = "/products", method = RequestMethod.GET)
public List<ProductDto> findProducts(@RequestParam(required = false) String name){
if (null == name) {
return simpleProductService.findAll();
}
return simpleProductService.findByNameContaining(name);
}
여기서 @RequestParam(required = false)는 쿼리 파라미터 name이 있어도 되고 없어도 된다는 의미다.
→ /products 로 요청하면 name이 없으므로 전체 조회
→ /products?name=abc 로 요청하면 name=abc가 있으므로 이름 포함 검색
이 구조의 장점은 GET /products라는 하나의 자원 경로를 유지하면서도,
조건에 따라 조회 기능을 확장할 수 있다는 점이다.

이 방식은 클라이언트 입장에서도 직관적이다.
기본적으로는 전체 목록을 조회하고, 필요할 때만 검색 조건을 Query String으로 붙이면 되기 때문이다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP18. 상품 관리 애플리케이션에 유효성 검사 추가하기 1편 (0) | 2026.04.20 |
|---|---|
| EP17. List를 이용한 상품 관리 애플리케이션 만들기 5편 (0) | 2026.04.10 |
| EP15. List를 이용한 상품 관리 애플리케이션 만들기 3편 (0) | 2026.04.09 |
| EP14. List를 이용한 상품 관리 애플리케이션 만들기 2편 (0) | 2026.04.08 |
| EP13. List를 이용한 상품 관리 애플리케이션 만들기 1편 (0) | 2026.04.07 |