이번 SpringBoot 프로젝트의 주요 기능인 검색 기능 구현을 시작했다.
DB item 테이블에서 item_name 키워드와 동일한 검색 키워드가 들어오면,
검색 완료 페이지에서 item-info, recyclable 등 DB에 저장된 정보를 반환한다.
만약 DB에 없는 키워드를 검색하면 검색 실패 페이지로 이동시킨다.
1. entity 작성
현재 item 관련된 데이터베이스는 이렇게 구성되어있다.
테이블명 | 컬럼명 | 설명 | 비고 |
---|---|---|---|
category(대분류) | category_id | 카테고리 PK | A: 종이, B: 종이팩, ... |
name | 카테고리 이름 | 분리배출 가이드라인 pdf p.27 참조 | |
item(소분류) | item_id | 아이템 PK | A숫자: 종이류, B숫자: 종이팩, ... |
item_name | 아이템 이름 | ||
recycle_info | 버리는 방법 | ||
recyclable | 재활용 가능여부 | boolean 타입으로 Y/N | |
category_id | 카테고리 FK | ||
item_similar | Item_similar_pk | 아이템 유사 검색어 PK | |
similar_search_terms | 유사검색어 | nullable | |
item_id | 아이템FK | ||
item_image | image_id | 이미지 PK | auto increment 사용하여 1씩 증가 |
image_path | 이미지 경로 | ||
image_name | 이미지명 | item 테이블 item_id 컬럼 + UUID | |
item_id | 아이템 FK | Item join을 위한 컬럼 |
여기서 item_name 을 검색 키워드로 두고,
DB에 있다면 다른 정보들 반환
없다면 검색 실패 페이지로 이동
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
@Builder
@Accessors(chain = true)
public class Category {
@Id
@Enumerated(EnumType.STRING)
private CategoryId category_id;
private String name;
// Category 1 : N Item
@OneToMany(fetch = FetchType.LAZY, mappedBy = "category")
private List<Item> itemList;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity // JPA를 사용하여 테이블과 매핑할 클래스임을 나타냅니다.
@EntityListeners(AuditingEntityListener.class)
@Builder
@Accessors(chain = true)
public class Item {
@Id
private String item_id;
@Column(name = "item_name")
private String itemName; // 변수 이름 수정
private String recycle_info;
private Boolean recyclable;
@ManyToOne
@JoinColumn(name = "category_id")
@Enumerated(EnumType.STRING)
private Category category;
}
2. CategoryId 작성 (enum Type)
public enum CategoryId {
A("A", "가전제품"),
B("B", "고철"),
C("C", "금속캔"),
D("D", "대형폐기물"),
E("E", "불연성종량제"),
F("F", "비닐"),
G("G", "유리병"),
H("H", "음식물"),
I("I", "의류"),
J("J", "재질별분리"),
K("K", "전문시설"),
L("L", "전용함"),
M("M", "종량제봉투"),
N("N", "종이"),
O("O", "종이팩"),
P("P", "주의"),
Q("Q", "플라스틱");
private String category_id;
private String name;
CategoryId(String category_id, String name) {
this.category_id = category_id;
this.name = name;
}
public String getCategory_id() {
return category_id;
}
public String getName() {
return name;
}
}
3. Repository 작성
@Repository
public interface CategoryRepository extends JpaRepository<Category, String> {
// Category getOne(CategoryId category_id);
}
@Repository //엔티티, PK로 지정한 컬럼의 데이터 타입
public interface ItemRepository extends JpaRepository<Item, String> {
// ItemName을 기준으로 해당 키워드가 포함된 것을 찾겠다.
Item findByItemNameContaining(String itemName);
}
4. Controller 작성
// 검색 페이지 제공용 컨트롤러
@Controller
public class SearchController {
@Autowired
private ItemRepository itemRepository;
@GetMapping("/search")
public String searchItem(@RequestParam("itemName") String itemName, Model model) {
Item foundItem = itemRepository.findByItemNameContaining(itemName);
if (foundItem == null) {
return "search_fail"; // HTML 템플릿 이름
}
model.addAttribute("item", foundItem);
return "search_success"; // HTML 템플릿 이름
}
@GetMapping("/게시판")
public String 게시판() {
return "게시판";
}
}
5. SearchService 작성
@Service
public class SearchService {
private final CategoryRepository categoryRepository;
private final ItemRepository itemRepository;
@Autowired
public SearchService(CategoryRepository categoryRepository, ItemRepository itemRepository) {
this.categoryRepository = categoryRepository;
this.itemRepository = itemRepository;
}
// 아이템 조회
public Item getItem(String item_id) {
Optional<Item> item = itemRepository.findById(item_id);
if (item.isPresent()) {
return item.get();
} else {
throw new RuntimeException();
}
}
}
6. HTML 작성
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>REUSE</title>
<!-- CSS FILES -->
<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=Montserrat:wght@500;600;700&family=Open+Sans&display=swap" rel="stylesheet">
<link href="/static2/css/bootstrap.min.css" rel="stylesheet">
<link href="/static2/css/bootstrap-icons.css" rel="stylesheet">
<link href="/static2/css/templatemo-topic-listing.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head>
<body id="top">
<main>
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" th:href="@{/}">
<img src="/static2/images/logo.png" class="logo" alt="">
<span>Reuse</span>
</a>
<div class="d-lg-none ms-auto me-4">
<a href="#top" class="navbar-icon bi-person smoothscroll"></a>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-lg-5 me-lg-auto">
<li class="nav-item">
<a class="nav-link click-scroll" th:href="@{/}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link click-scroll" th:href="@{/게시판}">Community</a>
</li>
<!-- 회원가입 -->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAnonymous()" th:href="@{/user/join}">회원가입</a>
</li>
<!-- 로그인한 회원만 볼수있는 메뉴 -->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="hasRole('ROLE_USER')" th:href="@{/}">마이페이지</a>
</li>
<!-- 로그인, 로그아웃-->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
</ul>
<div class="d-none d-lg-block">
<a href="#top" class="navbar-icon bi-person smoothscroll"></a>
</div>
</div>
</div>
</nav>
<section class="hero-section d-flex justify-content-center align-items-center" id="section_1">
<div class="container">
<div class="row">
<div class="col-lg-8 col-12 mx-auto">
<h1 class="text-white text-center">Correct, Recycle, Reuse</h1>
<h6 class="text-center">Recycling Discharge Methods</h6>
<form method="get" class="custom-form mt-4 pt-2 mb-lg-0 mb-5" role="search" action="/search">
<div class="input-group input-group-lg">
<span class="input-group-text bi-search" id="basic-addon1">
</span>
<input name="itemName" type="search" class="form-control" id="keyword" placeholder="예) 가습기, 보온병, 액자.." aria-label="Search">
<button type="submit" class="form-control">Search</button>
</div>
</form>
</div>
</div>
</div>
</section>
<section class="featured-section">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-4 col-12 mb-4 mb-lg-0">
<div class="custom-block bg-white shadow-lg">
<a href="topics-detail.html">
<div class="d-flex">
<div>
<h5 class="mb-2">Update Trash</h5>
</div>
</div>
<img src="/static2/images/topics/M095.png" class="custom-block-image img-fluid" alt="">
</a>
</div>
</div>
<div class="col-lg-6 col-12">
<div class="custom-block custom-block-overlay">
<div class="d-flex flex-column h-100">
<img src="/static2/images/topics/M098.png" class="custom-block-image img-fluid" alt="">
<div class="custom-block-overlay-text d-flex">
<div>
<h5 class="text-white mb-2">Popular Trash</h5>
<a href="/static/static2/topics-detail.html" class="btn custom-btn mt-2 mt-lg-3">Learn More</a>
</div>
</div>
<div class="section-overlay"></div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer section-padding">
<div class="container">
<div class="row">
<div class="col-lg-3 col-12 mb-4 pb-2">
<a class="navbar-brand mb-2" href="index.html">
<img src="/static2/images/logo.png" class="logo" alt="">
<span>Reuse</span>
</a>
</div>
<div class="col-lg-3 col-md-4 col-6">
<h6 class="site-footer-title mb-3">Resources</h6>
<ul class="site-footer-links">
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Home</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Community</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">SIGN IN</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Contact</a>
</li>
</ul>
</div>
<div class="col-lg-3 col-md-4 col-6 mb-4 mb-lg-0">
<h6 class="site-footer-title mb-3">Information</h6>
<p class="text-white d-flex mb-1">
<a href="tel: 305-240-9671" class="site-footer-link">
305-240-9671
</a>
</p>
<p class="text-white d-flex">
<a href="mailto:reuse@company.com" class="site-footer-link">
reuse@company.com
</a>
</p>
</div>
<div class="col-lg-3 col-md-4 col-12 mt-4 mt-lg-0 ms-auto">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
English</button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" type="button">Thai</button></li>
<li><button class="dropdown-item" type="button">Myanmar</button></li>
<li><button class="dropdown-item" type="button">Arabic</button></li>
</ul>
</div>
<p class="copyright-text mt-lg-5 mt-4">Copyright © 2048 Topic Listing Center. All rights reserved.
<br><br>Design: <a rel="nofollow" href="https://templatemo.com" target="_blank">TemplateMo</a> Distribution <a href="https://themewagon.com">ThemeWagon</a></p>
</div>
</div>
</div>
</footer>
<!-- JAVASCRIPT FILES -->
<script src="/static2/js/jquery.min.js"></script>
<script src="/static2/js/bootstrap.bundle.min.js"></script>
<script src="/static2/js/jquery.sticky.js"></script>
<script src="/static2/js/click-scroll.js"></script>
<script src="/static2/js/custom.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Item Detail</title>
</head>
<body>
<h1>Item Detail</h1>
<p><strong>Item Name:</strong> <span th:text="${item.itemName}"></span></p>
<p><strong>Recycle Info:</strong> <span th:text="${item.recycle_info}"></span></p>
<p><strong>Recyclable:</strong> <span th:text="${item.recyclable}"></span></p>
<p><strong>Category:</strong> <span th:text="${item.category.name}"></span></p>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>REUSE</title>
<!-- CSS FILES -->
<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=Montserrat:wght@500;600;700&family=Open+Sans&display=swap" rel="stylesheet">
<link href="/static2/css/bootstrap.min.css" rel="stylesheet">
<link href="/static2/css/bootstrap-icons.css" rel="stylesheet">
<link href="/static2/css/templatemo-topic-listing.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head>
<body id="top">
<main>
<!-- 네비바 시작 -->
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" th:href="@{/}">
<img src="/static2/images/logo.png" class="logo" alt="">
<span>Reuse</span>
</a>
<div class="d-lg-none ms-auto me-4">
<a href="#top" class="navbar-icon bi-person smoothscroll"></a>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-lg-5 me-lg-auto">
<li class="nav-item">
<a class="nav-link click-scroll" th:href="@{/}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link click-scroll" th:href="@{/게시판}">Community</a>
</li>
<!-- 회원가입 -->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAnonymous()" th:href="@{/user/join}">회원가입</a>
</li>
<!-- 로그인한 회원만 볼수있는 메뉴 -->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="hasRole('ROLE_USER')" th:href="@{/}">마이페이지</a>
</li>
<!-- 로그인, 로그아웃-->
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link click-scroll" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
</ul>
<div class="d-none d-lg-block">
<a href="#top" class="navbar-icon bi-person smoothscroll"></a>
</div>
</div>
</div>
</nav>
<!-- 네비바 끝 -->
<section class="search-fail-top d-flex justify-content-center align-items-center" id="section_1">
<div class="container">
<img src="/static2/images/exclamation-mark.png" class="exclamation-mark-img" alt="">
<h4 class="text-white text-center search-fail-text-1">검색 결과가 없습니다.</h4>
<div class="search-fail-btn-aline">
<a th:href="@{/}" class="btn search-fail-btn">돌아가기</a>
</div>
<div class="search-fail-btn-aline">
<a th:href="@{/게시판}" class="btn search-fail-btn">재활용 추가 신청</a>
</div>
</div>
</section>
<section class="featured-section">
<div class="container">
<div class="row">
<h6 class="search-fail-text-2">혹시 이런건 어떠세요?</h6>
<div class="col-lg-8">
<a th:href="@{/게시판}" class="btn search-fail-text-btn">우산</a>
<a th:href="@{/게시판}" class="btn search-fail-text-btn">그릇</a>
<a th:href="@{/게시판}" class="btn search-fail-text-btn">보조배터리</a>
</div>
</div>
</div>
</section>
</main>
<!-- 푸터 시작 -->
<footer class="site-footer section-padding">
<div class="container">
<div class="row">
<div class="col-lg-3 col-12 mb-4 pb-2">
<a class="navbar-brand mb-2" href="index.html">
<img src="/static2/images/logo.png" class="logo" alt="">
<span>Reuse</span>
</a>
</div>
<div class="col-lg-3 col-md-4 col-6">
<h6 class="site-footer-title mb-3">Resources</h6>
<ul class="site-footer-links">
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Home</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Community</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">SIGN IN</a>
</li>
<li class="site-footer-link-item">
<a href="#" class="site-footer-link">Contact</a>
</li>
</ul>
</div>
<div class="col-lg-3 col-md-4 col-6 mb-4 mb-lg-0">
<h6 class="site-footer-title mb-3">Information</h6>
<p class="text-white d-flex mb-1">
<a href="tel: 305-240-9671" class="site-footer-link">
305-240-9671
</a>
</p>
<p class="text-white d-flex">
<a href="mailto:reuse@company.com" class="site-footer-link">
reuse@company.com
</a>
</p>
</div>
<div class="col-lg-3 col-md-4 col-12 mt-4 mt-lg-0 ms-auto">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
English</button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" type="button">Thai</button></li>
<li><button class="dropdown-item" type="button">Myanmar</button></li>
<li><button class="dropdown-item" type="button">Arabic</button></li>
</ul>
</div>
<p class="copyright-text mt-lg-5 mt-4">Copyright © 2048 Topic Listing Center. All rights reserved.
<br><br>Design: <a rel="nofollow" href="https://templatemo.com" target="_blank">TemplateMo</a> Distribution <a href="https://themewagon.com">ThemeWagon</a></p>
</div>
</div>
</div>
</footer>
<!-- 푸터 끝 -->
<!-- JAVASCRIPT FILES -->
<script src="/static2/js/jquery.min.js"></script>
<script src="/static2/js/bootstrap.bundle.min.js"></script>
<script src="/static2/js/jquery.sticky.js"></script>
<script src="/static2/js/click-scroll.js"></script>
<script src="/static2/js/custom.js"></script>
</body>
</html>
구현하면서 생겼던 문제점과 해결법
처음 build를 했을 때 검색 실패 로직은 정상 작동하는데, 검색 완료 페이지가 구현이 안됐다. 찾아보니 Item 엔티티 클래스의 변수 이름과 search_success.html 템플릿 파일에서 사용하고자 하는 변수 이름이 다른데, 이로 인해 Thymeleaf에서 변수를 찾을 수 없는 문제가 발생했다. 그래서 Item 엔티티 클래스의 변수명을 수정했다.
그리고 search_success.html 템플릿 파일에서 Thymeleaf 문법 사용 시 ${item.recycleInfo}와 같은 부분을 모두 ${item.recycle_info}로 수정했더니 검색 완료 페이지가 떴다.
그러나 검색 완료 페이지에서 DB에 저장된 item에 관한 정보들을 불러오지 못했다. 찾아보니 search_success.html 템플릿 파일에서의 th:if 문법을 사용하면 조건이 참일 때만 해당 요소가 표시되니까 이 경우, 조건을 만족하지 않을 때에는 요소 자체가 표시되지 않으므로 이로 인해 의도치 않은 오류가 발생할 수 있다고 한다. 조건을 만족하지 않더라도 요소가 표시되어야 한다면 th:if 대신 th:text를 사용하여 조건에 맞지 않을 때에는 빈 문자열을 출력하도록 처리해야 한다고 해서 코드를 수정했다.