JAVA Spring Boot 프로젝트- 4. 와인 검색 페이지 구현
이번에는 DB에 저장되어있는 국산와인에 대한 값을 와인명 및 조건별 검색을 통해 출력하는 페이지를 만들어보려고 합니다.
1. 국산와인데이터 DB저장 chatgpt활용
우선 국산 와인에 대한 데이터는 공공데이터포털(DATA.GO.KR)이라는 웹사이트에서 전통주정보 데이터를 사용하였습니다. 하지만 제가 필요한 부분은 와인과 관련된 부분이니 필터링이 필요합니다.
chatgpt를 활용하여 csv파일을 와인부분만 필터링한 sql파일로 변환하려고 합니다.

chatgpt4 에 csv파일을 업로드하고 명령어로 와인이 포함된 데이터를 sql insert명령어로 제공해달라고 요청합니다.
그러면 chatgpt가 데이터구조를 파악한뒤 와인이 포함된 행만 필터링하여 sql명령어로 변환한뒤에 sql파일을 다운로드 할수 있는 링크를 제공해줍니다.
하지만 문제가 있었으니.. 이미지는 공공데이터에 포함되어있지않았습니다..
결국 해당되는 와인의 이미지를 하나하나씩 구글링한후에 이미지를 저장하였습니다..
이미지를 /img/wine_images/image1.jpg 처럼 img폴더에 저장한뒤 다시한번 chatgpt에게 파일제공 명령어를 통해 update sql명령문을 작성하여 파일로 제공해 달라고 요청합니다.

2. Search.java JPA엔티티 작성
와인에 대한 속성값과 이러한 속성들을 데이터베이스의 테이블과 매핑시키는 엔티티클래스를 작성합니다.
와인의 속성을 나타내는 변수를 선언합니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Search {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "wine_name")
private String wineName;
@Column(name = "alcohol_degree")
private Integer alcoholDegree;
private String specification;
@Column(name = "main_ingredient")
private String mainIngredient;
private String manufacturer;
@Column(name = "image_url")
private String imageUrl;
private String region;
}
@Entity 클래스가 jpa엔티티임을 나타냅니다. 클래스가 데이터베이스 테이블에 매핑되는 객체임을 의미합니다.
@Data @NoArgsConstructor @AllArgsConstructor Lombok라이브러리를 사용하여 getter/setter메소드를 자동으로 생성합니다.
3. SearchRepository.java 작성
Search엔티티에 대한 CRUD작업을 수행할수 있는 SearchRepository 인터페이스를 작성합니다. 이 인터페이스는 Spring Data JPA의 JpaRepository를 상속받아 기본적인 CRUD 메소드를 자동으로 제공받습니다.
public interface SearchRepository extends JpaRepository<Search, Long> {
// 와인 이름에 주어진 키워드가 포함된 검색 결과를 반환하는 메서드
List<Search> findByWineNameContaining(String keyword);
// 원료에 주어진 원료가 포함된 검색 결과를 반환하는 메서드
List<Search> findByMainIngredientContaining(String ingredient);
// 용량에 주어진 값이 포함된 검색 결과를 반환하는 메서드
List<Search> findBySpecificationContaining(String volume);
}
4. SearchService작성
SearchRepository를 주입받아 필요한 비즈니스 로직을 수행하는 서비스클래스를 작성합니다.
@Service
public class SearchService {
private final SearchRepository searchRepository;
private String lastShownManufacturer = null;
private String lastShownRegion = null;
public SearchService(SearchRepository searchRepository) {
this.searchRepository = searchRepository;
}
//키워드 기반 검색: 와인 이름에 특정 키워드가 포함된 와인을 검색합니다.
@Transactional(readOnly = true)
public List<Search> searchByKeyword(String keyword) {
return searchRepository.findByWineNameContaining(keyword);
}
//원료 기반 검색: 특정 원료를 포함하는 와인을 검색합니다.
@Transactional(readOnly = true)
public List<Search> searchByIngredient(String ingredient) {
return searchRepository.findByMainIngredientContaining(ingredient);
}
//전체 검색: 모든 와인 정보를 조회합니다.
@Transactional(readOnly = true)
public List<Search> findAll() {
return searchRepository.findAll();
}
@Transactional
각 메소드가 데이터베이스와의 상호작용을 하는 동안 일관된 데이터 상태를 유지하도록 합니다.
readOnly = true
데이터를 변경하지 않는 조회 작업에 사용됩니다.
5. SearchController.java 작성
사용자의 와인검색 요청을 처리하고, 이에 대한 결과값의 응답을 반환하는 controller 클래스를 작성해봅시다.
@Controller
public class SearchController {
private final SearchRepository searchRepository;
public SearchController(SearchRepository searchRepository) {
this.searchRepository = searchRepository;
}
// 와인검색 페이지
@GetMapping("/search")
public String showSearchPage() {
return "search/search";
}
//와인명 검색
@GetMapping("/search/integrated")
@ResponseBody
public List<Search> integratedSearch(@RequestParam String keyword) {
return searchRepository.findByWineNameContaining(keyword);
}
//조건별 검색
@GetMapping("/search/detailed")
@ResponseBody
public List<Search> detailedSearch(
@RequestParam String ingredient,
@RequestParam String region,
@RequestParam String volume,
@RequestParam String degree) {
List<Search> results = searchRepository.findAll();
// if구문: 사용자가 지정한 조건을 기반으로 와인을 검색하고자 할때 실행됩니다.
// 원료별 검색
if (!ingredient.isEmpty()) { // user가 원료를 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
results = filterByIngredient(results, ingredient);
}
// 지역별 검색
if (!region.isEmpty()) { // user가 지역을 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
results = filterByRegion(results, region);
}
// 용량별 검색
if (!volume.isEmpty()) { // user가 용량을 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
results = filterByVolume(results, volume);
}
// 도수별 검색
if (!degree.isEmpty()) { // user가 도수를 선택한경우(파라미터가 비어있지않은 경우) 조건이 실행됩니다.
results = filterByDegree(results, degree);
}
return results;
}
// 원료기반 필터링을 수행합니다.
private List<Search> filterByIngredient(List<Search> results, String ingredient) { //filterByIngredient 메서드는 사용자가 지정한 원료에 따라 와인을 필터링합니다.
List<String> ingredientList = Arrays.asList(ingredient.split(",")); //사용자가 입력한 원료 문자열을 쉼표(,)로 분리하여 배열로 변환합니다.
if (!"기타".equals(ingredient)) { //"기타".equals(ingredient)가 false인 경우 (사용자가 구체적인 원료를 선택한 경우)
return results.stream()
.filter(search -> containsAnyIngredient(search, ingredientList)) //containsAnyIngredient 메서드를 사용하여, 전체와인리스트(search)중에서 사용자가 선택한 사용자가 선택한원료가 있는 와인을 필터링합니다.
.collect(Collectors.toList()); //true로 반환된 list를 모아서 반환합니다
} else { //"기타".equals(ingredient)가 true인 경우 (사용자가 '기타'를 선택한 경우)
return results.stream()
.filter(search -> !Arrays.asList("포도", "머루", "사과", "복숭아", "복분자").contains(search.getMainIngredient())) //전체와인리스트(search)중에서 "포도", "머루", "사과", "복숭아", "복분자"를 제외한 다른 원료로 만든 와인을 필터링합니다.
.collect(Collectors.toList()); //true로 반환된 list를 모아서 반환합니다
}
}
// 전체와인리스트 중 user가 선택한 원료 목록중 하나 이상을 포함하는지 확인
private boolean containsAnyIngredient(Search search, List<String> ingredientList) {
return ingredientList.stream().anyMatch(ingredient -> search.getMainIngredient().contains(ingredient)); //anyMatch 어떤 원료라도 주어진 조건(contains(ingredient)을 만족하면 true를 반환
} // 주어진 와인 객체의 mainIngredient 필드가 해당 원료를 포함하고 있는지 검사합니다.
private List<Search> filterByRegion(List<Search> results, String region) {
List<String> regionList = Arrays.asList(region.split(","));
if (!"기타".equals(region)) {
return results.stream()
.filter(search -> regionList.contains(search.getRegion()))
.collect(Collectors.toList());
} else {
return results.stream()
.filter(search -> !Arrays.asList("경상북도", "충청북도", "충청남도", "경상남도", "강원도").contains(search.getRegion()))
.collect(Collectors.toList());
}
}
private List<Search> filterByVolume(List<Search> results, String volume) {
List<String> volumeList = Arrays.asList(volume.split(","));
return results.stream()
.filter(search -> volumeList.stream().anyMatch(vol -> search.getSpecification().contains(vol)))
.collect(Collectors.toList());
}
private List<Search> filterByDegree(List<Search> results, String degree) {
List<String> degreeList = Arrays.asList(degree.split(","));
return results.stream()
.filter(search -> matchesDegree(search, degreeList))
.collect(Collectors.toList());
}
private boolean matchesDegree(Search search, List<String> degreeList) {
double alcohol = search.getAlcoholDegree();
return degreeList.stream().anyMatch(deg -> {
switch(deg) {
case "10이하":
return alcohol <= 10;
case "10-11도":
return alcohol > 10 && alcohol <= 11;
case "11-12도":
return alcohol > 11 && alcohol <= 12;
case "12-13도":
return alcohol > 12 && alcohol <= 13;
case "14이상":
return alcohol >= 14;
default:
return false;
}
});
}
}
Stream 스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로 정의할수 있습니다.
스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있습니다.
https://zangzangs.tistory.com/171
스트림에 대한 chatgpt 검색결과
https://chat.openai.com/share/ab329fe5-a68b-4ee1-943d-0bf03cdccf0a
5. 프론트엔드 구현
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wine Search Page</title>
<script src="/js/search.js" defer></script>
<meta content="" name="keywords">
<meta content="" name="description">
<!-- Favicon -->
<link href="img/favicon.ico" rel="icon">
<!-- Google Web Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@600;700&family=Ubuntu:wght@400;500&display=swap" rel="stylesheet">
<!-- Icon Font Stylesheet -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
<!-- Libraries Stylesheet -->
<link href="lib/animate/animate.min.css" rel="stylesheet">
<link href="lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet">
<link href="lib/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet" />
<!-- Customized Bootstrap Stylesheet -->
<link href="css/bootstrap.min.css" rel="stylesheet">
<!-- Template Stylesheet -->
<link href="/css/style.css" rel="stylesheet">
<!-- Search1 Stylesheet -->
<link href="css/search1.css" rel="stylesheet">
</head>
<body>
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light shadow sticky-top p-0">
<a href="/" class="navbar-brand d-flex align-items-center px-4 px-lg-5">
<h2 class="m-0 text-primary" style="font-family: 'Nanum Gothic', sans-serif;">
<img src="img/winelogo.jpg" alt="Image" width="50" height="50">
KOWASA <h5>한국와인 정보 커뮤니티</h5>
</h2>
</a>
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav ms-auto p-4 p-lg-0">
<a href="/" class="nav-item nav-link active">Home</a>
<div class="nav-item dropdown">
<a href="search" class="nav-item nav-link active">와인검색</a>
</div>
<div class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">커뮤니티</a>
<div class="dropdown-menu fade-up m-0">
<a href="/community/freeboard/list" class="dropdown-item">자유게시판</a>
<a href="/community/eventboard/list" class="dropdown-item">행사정보</a>
</div>
</div>
<div class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown">와인파트너스</a>
<div class="dropdown-menu fade-up m-0">
<a href="/partners/producer/list" class="dropdown-item">생산자</a>
<a href="/partners/shop/list"class="dropdown-item">샵/레스토랑 정보</a>
<a href="/partners/job/list" class="dropdown-item">구인구직</a>
</div>
</div>
</div>
<a href="/login" class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAnonymous()')}">
Login<i class="fa fa-arrow-right ms-3"></i>
</a>
<div class="btn btn-primary py-4 px-lg-5 d-none d-lg-block" th:if="${#authorization.expression('isAuthenticated()')}">
<p style="margin-top: 10px;"><span th:text="${#authentication.name}"></span>님, 환영합니다.</p>
<button style="padding: 2px 5px;" onclick="location.href='/logout'">로그아웃</button>
<button style="padding: 2px 5px;" onclick="location.href='/mypage'">마이페이지</button>
</div>
</div>
</nav>
<!-- Navbar End -->
<div class="s008">
<form>
<div class="inner-form">
<h4>와인 검색</h4><br/>
<div class="basic-search">
<div class="input-field">
<input id="integratedSearchInput" type="text" placeholder="와인명으로 검색">
<div class="icon-wrap" onclick="integratedSearch()">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20">
<path d="M18.869 19.162l-5.943-6.484c1.339-1.401 2.075-3.233 2.075-5.178 0-2.003-0.78-3.887-2.197-5.303s-3.3-2.197-5.303-2.197-3.887 0.78-5.303 2.197-2.197 3.3-2.197 5.303 0.78 3.887 2.197 5.303 3.3 2.197 5.303 2.197c1.726 0 3.362-0.579 4.688-1.645l5.943 6.483c0.099 0.108 0.233 0.162 0.369 0.162 0.121 0 0.242-0.043 0.338-0.131 0.204-0.187 0.217-0.503 0.031-0.706zM1 7.5c0-3.584 2.916-6.5 6.5-6.5s6.5 2.916 6.5 6.5-2.916 6.5-6.5 6.5-6.5-2.916-6.5-6.5z"></path>
</svg>
</div>
</div>
</div>
<div class="advance-search">
<span class="desc">조건별 검색</span>
<div class="row">
<div class="input-field">
<div class="input-select">
<select data-trigger="" name="ingredient">
<option placeholder="" value="">원료별</option>
<option value="포도">포도</option>
<option value="머루">머루</option>
<option value="사과">사과</option>
<option value="복숭아">복숭아</option>
<option value="복분자">복분자</option>
<option value="기타">기타</option>
</select>
</div>
</div>
<div class="input-field">
<div class="input-select">
<select data-trigger="" name="region">
<option placeholder="" value="">지역별</option>
<option value="경상북도">경상북도</option>
<option value="충청북도">충청북도</option>
<option value="충청남도">충청남도</option>
<option value="경상남도">경상남도</option>
<option value="강원도">강원도</option>
<option value="기타">기타</option>
</select>
</div>
</div>
</div>
<div class="row second">
<div class="input-field">
<div class="input-select">
<select data-trigger="" name="volume">
<option placeholder="" value="">용량별</option>
<option value="300ml">300ml</option>
<option value="375ml">375ml</option>
<option value="500ml">500ml</option>
<option value="750ml">750ml</option>
</select>
</div>
</div>
<div class="input-field">
<div class="input-select">
<select data-trigger="" name="degree">
<option placeholder="" value="">도수별</option>
<option value="10이하">10도이하</option>
<option value="10-11도">10-11도</option>
<option value="11-12도">11-12도</option>
<option value="12-13도">12-13도</option>
<option value="14이상">14도 이상</option>
</select>
</div>
</div>
</div>
<div class="group-btn">
<button class="btn-delete" id="delete">초기화</button>
<button class="btn-search" onclick="detailedSearch(event)">조건별검색</button>
</div>
<div class="result-count">
TOTAL <span id="resultCount">0 </span>
</div>
</div>
</div>
</div>
<div class="inner-form" id="searchResults"></div>
<!-- Footer Start -->
<div class="container-fluid bg-dark text-light footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
<div class="container py-5">
<div class="row g-5">
<div class="col-lg-3 col-md-6">
<h4 class="text-light mb-4">주소</h4>
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>서울특별시 강남구 테헤란로1길</p>
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>010-7583-7320</p>
<p class="mb-2"><i class="fa fa-envelope me-3"></i>kowasa@wine.com</p>
<div class="d-flex pt-2">
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<h4 class="text-light mb-4">영업시간</h4>
<h6 class="text-light">월요일 - 금요일:</h6>
<p class="mb-4">오전9시 - 저녁6시</p>
</div>
<div class="col-lg-3 col-md-6">
<h4 class="text-light mb-4">서비스</h4>
<a class="btn btn-link" href="">국산와인 B2B 상담</a>
<a class="btn btn-link" href="">판매전략</a>
<a class="btn btn-link" href="">전시회 일정안내 </a>
<a class="btn btn-link" href="">파트너스 등록</a>
</div>
<div class="col-lg-3 col-md-6">
<h4 class="text-light mb-4">와인 뉴스레터 구독</h4>
<p>매달 다양한 와인관련 소식을 이메일로 받아보세요!</p>
<div class="position-relative mx-auto" style="max-width: 400px;">
<input class="form-control border-0 w-100 py-3 ps-4 pe-5" type="text" placeholder="이메일주소를 입력해주세요">
<button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">등록</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="copyright">
<div class="row">
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
© <a class="border-bottom" href="#">Kowasa</a>, All Right Reserved.
</div>
<div class="col-md-6 text-center text-md-end">
<div class="footer-menu">
<a href="">Home</a>
<a href="">Cookies</a>
<a href="">Help</a>
<a href="">FQAs</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer End -->
<!-- Add this before the end of the body tag -->
<script src="js/extention/choices.js"></script>
</body>
</html>
