최원종의 개발 블로그

V9 게시글 목록 검색 기능 추가 본문

Spring boot 입문

V9 게시글 목록 검색 기능 추가

chl6698 2026. 5. 21. 17:45

게시글 목록 검색 기능


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);
    }