Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
Tags
- 인텔리제이 한글 깨짐 해결법
- multi-threading
- 생성자
- Java데이터 타입
- for문
- JAVA기초
- 연관관계
- 형 변환
- 컴파일
- 인텔리제이 기초 설정
- 반복문
- 포함관계
- 접근제어지시자
- Java
- 메서드 오버로딩
- 집합관계
- IntelliJ IDEA
- java변수
- function
- 메서드
- 상수
- break문
- JAVA객체지향
- While
- Thread
- this예약어
- 시스템 환경 변수 편집
- continue문
- 자바 멀티스레딩
- OPP개념
Archives
- Today
- Total
최원종의 개발 블로그
V9 게시글 목록 검색 기능 추가 본문

board/list.mustache 코드⬇️
더보기
{{> layout/header}}
<div class="container p-5 flex-grow-1">
<!-- 검색 폼 START (GET ) -->
<div class="card mb-4">
<div class="card-body">
<form action="/board/list" method="get" class="row g-3">
<!-- 부트스트랩5 12 등분으로 화면을 분배할 수 있음 -->
<div class="col-md-8">
<input type="text" class="form-control"
name="keyword" placeholder="제목 또는 내용으로 검색..." value="{{keyword}}">
</div>
<div class="col-md-2">
<input type="hidden" name="page" value="1">
<input type="hidden" name="size" value="{{boardPage.size}}">
<button type="submit" class="btn btn-primary w-100">검색</button>
</div>
<div class="col-md-2">
<a href="/board/list" class="btn btn-secondary w-100">초기화</a>
</div>
</form>
</div>
</div>
<!-- 검색 폼 END -->
<!-- 게시글 리스트 -->
{{#boardPage.list}}
<div class="card mb-3">
<div class="card-body">
<h4 class="card-title mb-3">{{title}}</h4>
<!-- 머스태치 문법은 getter 메서드를 가지고 오고 get은 탈락 가능 -->
<div> 작성자 : {{username}} | 작성일 : {{createdAt}}</div>
<a href="/board/{{id}}" class="btn btn-primary">상세보기</a>
</div>
</div>
{{/boardPage.list}}
<!-- 페이지 네비게이션 시작 -->
<ul class="pagination d-flex justify-content-center">
<!-- 이전 페이지 버튼 (첫 페이지 disabled 처리) -->
<li class="page-item {{#boardPage.first}} disabled {{/boardPage.first}} ">
<a class="page-link " href="/?page={{boardPage.prevPage}}&size={{boardPage.size}}">Previous</a>
</li>
<!-- 페이지 번호 윈도우 (편재 페이지 기준 앞뒤 2페이지 최대 5개 -->
{{#boardPage.pageItemNumbers}}
<li class="page-item {{#active}} active {{/active}}">
<a class="page-link" href="/?page={{number}}&size={{boardPage.size}}">{{number}}</a>
</li>
{{/boardPage.pageItemNumbers}}
<li class="page-item {{#boardPage.last}} disabled {{/boardPage.last}} ">
<a class="page-link" href="/?page={{boardPage.nextPage}}&size={{boardPage.size}}">Next</a>
</li>
</ul>
<!-- 페이지 네비게이션 종료 -->
</div>
{{> layout/footer}}
BoardController
코드 리팩토링 부분 요약
검색 파라미터(keyword) 결합 : @RequestParam(required = false)를 활용해 검색어가 없는
일반 메인 접속과 검색어가 있는 요청을 동시에 유연하게 수용
서비스 레이어 확장 연동 : boardService.게시글목록() 메서드에 page, size와 함께
keyword를 인자로 넘겨 동적 페이징 쿼리의 기반 마련
화면 검색어 유지(UX 개선) : 삼항 연산자를 활용해
null 방어 코드(keyword != null ? keyword : "")를 작성하고,
검색 후에도 인풋창에 키워드가 유지되도록 모델에 탑재
아키텍처의 확장성 증명 : 기존의 컨트롤러 구조를 거의 해치지 않고
매개변수 하나를 추가하는 것만으로 대형 기능을 매끄럽게 흡수
BoardController 코드
/**
* 게시글 목록 화면 요청
* 주소설계 : http://localhost:8080/
*/
// 페이징 처리 주소설계 : http://localhost:8080/?page=1&size=2
// 페이징 처리 주소설계 : http://localhost:8080/ <--- defaultValue 로 동작
// @RequestParam(name= "page") 필수 값 처리
@GetMapping({"/board/list", "/"})
public String list(Model model,
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "size", defaultValue = "5") Integer size,
@RequestParam(name = "keyword", required = false) String keyword) {
BoardResponse.PageDTO boardPage = boardService.게시글목록(page, size, keyword);
model.addAttribute("boardPage", boardPage);
model.addAttribute("keyword", keyword != null ? keyword : "");
return "board/list";
}
만들어야 될 쿼리 결정
SELECT b.*, u.username
FROM board_tb b
INNER JOIN user_tb u ON b.user_id = u.id
WHERE LOWER(b.title) LIKE LOWER('%SPRING%')
OR LOWER(b.content) LIKE LOWER('%SPRING%')
ORDER BY b.created_at DESC
LIMIT 5 OFFSET 0;
BoardRepository
코드 요약
N+1 문제 원천 차단 (JOIN FETCH) : b.user를 한방에 묶어 가져오는 패치 조인을 적용하여
게시글 작성자를 지연 로딩할 때 발생하는 무수한 추가 쿼리 차단
카운트 쿼리 최적화 (countQuery 분리) : 페이징의 필수 관문인 전체 개수 계산 시,
불필요한 JOIN FETCH를 걷어낸 슬림한 전용 countQuery를 선언하여 DB 과부하 방지
대소문자 무시(Case-Insensitive) 검색 : LOWER() 함수를 양방향으로 맵핑하여
사용자가 대문자나 소문자 어떤 형식으로 검색해도 빠짐없이 매칭되도록 설계
DISTINCT를 통한 중복 데이터 정제 : 패치 조인 과정에서 데이터베이스 조인 특성상
발생할 수 있는 데이터 뻥튀기(중복) 현상을 JPQL 레벨에서 깔끔하게 제거
BoardRepository 코드
// 5. 전체 게시글 조회 + 페이징 처리 + LIKE 검색
@Query(value = """
SELECT DISTINCT b
FROM Board b
JOIN FETCH b.user
WHERE LOWER(b.title) LIKE LOWER( CONCAT('%', :keyword , '%'))
OR LOWER(b.content) LIKE LOWER( CONCAT('%', :keyword , '%'))
ORDER BY b.createdAt DESC
""",
countQuery = """
SELECT count(DISTINCT b)
FROM Board b
WHERE LOWER(b.title) LIKE LOWER( CONCAT('%', :keyword , '%'))
OR LOWER(b.content) LIKE LOWER( CONCAT('%', :keyword , '%'))
""")
Page<Board> findByTitleContainingOrContentContaining(@Param("keyword") String keyword,
Pageable pageable);
Boardservice
코드 요약
키워드 동적 분기 처리 : keyword == null || keyword.isBlank() 조건을 활용해
전체 목록 조회와 키워드 검색 조회를 완벽하게 스위칭
데이터 입력 정제 (Sanitization) : keyword.trim()을 반영하여
앞뒤 불필요한 무의 의미한 공백을 제거 후 쿼리 파라미터로 바인딩하여 검색 정확도 향상
방어적 페이징 스펙 유지 : Math.max/min 알고리즘 기반의 인덱스 및 사이즈 검증 로직이
검색 쿼리(Pageable)에도 그대로 안전하게 상속 적용
OSIV false 아키텍처 관통 : 영속성 컨텍스트가 닫히기 직전인 서비스 계층의 최하단에서
완성형 PageDTO로 변환하여 뷰 레이어로 안전하게 패스
Boardservice 코드
/**
* 게시글 목록 조회 페이징 처리
* OSIV false 환경 대응 - 응답 DTO 설계
*/
public BoardResponse.PageDTO 게시글목록(int page, int size, String keyword) {
// 화면 기준에서 넘어 오는 값 (사용자에게 보여지는 기준) 0이 아니라 1부터 시작
// 반면 Spring Data JPA 기준으로 offset 0번 부터 시작이다.
// 사용자가 일부 음수값을 넣어라도 기본 값 0으로 셋팅될 수 있도록 방어적 코드 작성 필수!
int pageIndex = Math.max(0, page - 1);
// 사용자가 임의로 많은 값을 던지는 것 방지
int validSize = Math.max(1, Math.min(50, size));
// Pageable 이란?
// 어떤 페이지를(pageIndex), 몇 개씩(validSize), 어떤 정렬(sort) 가져올지를
// 한 묶음으로 표현하는 Spring Data 표준 페이징 인터페이스 이다.
// 즉 Repository 에 Pageable 객체를 넘기면 자동으로 Limit, Offset 만들어 준다.
Sort sort = Sort.by(Sort.Direction.DESC, "createdAt");
Pageable pageable = PageRequest.of(pageIndex, validSize, sort);
// Page<T> 이란?
// 조회된 데이터 한 페이지와 페이징 관련된 메타 데이터를 한꺼번에 담아 주는 객체 이다.
// - getContent() : 현재 페이지의 데이터 목록
// - getNumbers() : 현재 페이지 번호 (0번부터 시작 함)
// - getTotalElements() : 전체 항목 수
// - getTotalPage() : 전체 페이지 수
// - isFrist() / isLast() : 첫 페이지/마지막 페이지 여부 boolean()
Page<Board> boardPage;
if(keyword == null || keyword.isBlank()) {
boardPage = boardRepository.findAllWithUserOrderByCreatedAtDesc(pageable);
} else {
boardPage = boardRepository.findByTitleContainingOrContentContaining(keyword.trim(),
pageable);
}
// DTO 변환해서 컨트롤러로 내려 줌 (OSIV 대응)
return new BoardResponse.PageDTO(boardPage);
}'Spring boot 입문' 카테고리의 다른 글
| V10-2 프로필 이미지 출력과 정적 리소스 핸들러 처리 (0) | 2026.05.22 |
|---|---|
| V10 -1 이미지 업로드와 프로필 화면 (0) | 2026.05.22 |
| V8 게시글 페이징 처리 (0) | 2026.05.21 |
| V7-4 댓글 삭제 기능 추가 (0) | 2026.05.21 |
| V7-3 댓글 조회 기능 추가 (0) | 2026.05.21 |