← 개발일지

Thymeleaf Ajax 검색 화면 구현 — Fragment 패턴과 흔한 실수들


Spring MVC + Thymeleaf 조합으로 Ajax 검색 화면을 만들 때 처음 설계를 잘못 잡으면 디버깅 시간이 생각보다 오래 걸린다. 이 글에서는 Thymeleaf Ajax fragment 패턴을 기준으로 실무에서 실제로 마주친 실수들과 그 해결 방법을 정리한다.

핵심 구조: 메인 뷰 + Fragment 분리

Ajax로 검색 결과를 갱신하는 화면은 두 가지 뷰가 필요하다.

메인 뷰 — 레이아웃 포함, 최초 진입 시 full page 렌더링
Fragment 뷰 — 레이아웃 없음, Ajax 응답으로만 사용

파일 구조는 이렇게 잡는다:

page/sign/
├── searchPage.html           # 메인 (layout:decorate 포함)
└── inc/
    └── searchResult.html     # fragment만

메인 뷰에서 초기 렌더링 시 fragment를 th:replace로 include한다:

<div id="resultArea" class="mt30">
    <th:block th:replace="page/sign/inc/searchResult :: resultFragment"></th:block>
</div>

Fragment 뷰는 layout 없이 th:fragment만 정의한다:

<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="resultFragment">
    <!-- 결과 리스트 -->
</th:block>
</html>

Ajax 엔드포인트는 이 fragment 파일을 view로 반환한다. layout:decorate가 없는 파일이어야 한다. 있으면 Ajax 응답에 전체 레이아웃 HTML이 포함되어 화면이 깨진다.

replaceWith() 쓰면 두 번째 검색부터 실패한다

흔한 실수다. jQuery에서 Ajax 결과를 DOM에 반영할 때 replaceWith()를 쓰면 첫 번째 검색은 잘 된다. 그런데 두 번째 검색부터 아무 일도 일어나지 않는다.

원인은 간단하다. replaceWith()는 선택한 요소 자체를 DOM에서 제거한다. 첫 번째 Ajax 후 #resultArea div가 사라진다. 두 번째 검색 시 $('#resultArea')는 빈 jQuery 객체를 반환하고, .replaceWith(result) 호출은 조용히 무시된다.

// Wrong
$('#resultArea').replaceWith(result);

// Correct — 요소 내부만 교체, div 자체는 유지
$('#resultArea').html(result);

페이징도 동일한 함수를 타게 만들면 코드가 깔끔해진다:

var fn = {
    search: function() {
        $("#pageNum").val(1); // 검색 시 1페이지로 리셋
        this.loadResult();
    },
    paging: function(pageNum, pageSize) {
        $("#pageNum").val(pageNum);
        $("#pageSize").val(pageSize);
        this.loadResult();
    },
    loadResult: function() {
        $.ajax({
            url: '/sign/searchResult',
            type: 'get',
            data: $("#searchForm").serialize()
        }).done(function(result) {
            $('#resultArea').html(result);
        });
    }
};

DAO/DTO 분리: 검색 조건과 결과 매핑을 섞지 않는다

처음 구현할 때 검색 조건(searchType, keyword)을 결과 DTO에 같이 넣는 경우가 있다. 구조가 커지면 문제가 생긴다. 검색 조건은 DAO(Data Access Object)로, 결과 매핑은 DTO(Data Transfer Object)로 명확히 분리한다.

// DAO — 검색 조건 전용
@Data
@NoArgsConstructor
public class SearchDao {
    private String mbrNo;   // 세션에서 주입
    private String cmpyNo;  // 세션에서 주입
    private String searchType;
    private String keyword;
}

// DTO — 결과 매핑 전용
@Data
public class ResultDto {
    private String mbrNm;
    private String mbrId;
    private String deptNo;
    private String deptNm;
    private String usePsbBudget;
}

@NoArgsConstructor는 생략하기 쉬운데, Spring MVC가 request parameter를 바인딩할 때 기본 생성자가 필요하다. 없으면 모든 필드가 null로 들어온다. Ajax 요청 후 서버에서 값이 없는 것처럼 보일 때 이걸 먼저 확인한다.

MyBatis 동적 검색 쿼리

searchType에 따라 검색 컬럼을 바꾸는 쿼리는 <choose><when>으로 처리한다:

<select id="selectResults" parameterType="com.example.SearchDao"
        resultType="com.example.ResultDto">
    SELECT B.MBR_NM, B.MBR_ID, C.DEPT_NO, D.DEPT_NM
      FROM MEMBER A
     INNER JOIN MEMBER_BASE B     ON A.MBR_NO = B.MBR_NO
     INNER JOIN MEMBER_INFO C     ON B.MBR_NO = C.MBR_NO
     INNER JOIN DEPT_INFO D       ON C.DEPT_NO = D.DEPT_NO
     WHERE A.APPR_MBR_NO = #{mbrNo}
    <if test="searchType != null and searchType != '' and keyword != null and keyword != ''">
        <choose>
            <when test="searchType == 'mbrNm'"> AND B.MBR_NM  LIKE CONCAT('%', #{keyword}, '%')</when>
            <when test="searchType == 'mbrId'"> AND B.MBR_ID  LIKE CONCAT('%', #{keyword}, '%')</when>
            <when test="searchType == 'deptNm'">AND D.DEPT_NM LIKE CONCAT('%', #{keyword}, '%')</when>
        </choose>
    </if>
</select>

LIKE 검색 시 '%${keyword}%' 대신 CONCAT('%', #{keyword}, '%')를 쓴다. ${} 표기는 값을 문자열로 직접 삽입해 SQL Injection이 가능하다. #{}는 PreparedStatement 바인딩을 유지한다.

Ajax 값이 안 넘어올 때 디버깅 순서

  1. Chrome F12 → Network 탭 → Ajax 요청 클릭 → Payload 탭
  2. Query String Parameters에 searchType, keyword 값이 있는지 확인
  3. 값이 없다 → HTML input/select의 name 속성 누락 (id만 있고 name 없는 경우)
  4. 값이 있다 → DAO의 @NoArgsConstructor 누락 또는 필드명 불일치

reset.css가 있는 프로젝트에서 table 쓸 때

많은 프로젝트의 reset.css는 table { width:100%; table-layout:fixed; border-collapse:collapse; }를 전역 적용한다. th, td에는 아무 스타일도 없다. <table>을 쓰면 padding, border를 전부 직접 작성해야 한다.

프로젝트에 이미 리스트 UI 공통 CSS가 있다면 그걸 쓰는 게 낫다. 추가 CSS 없이 일관된 디자인이 보장된다. <table> 고집할 이유가 없다.

정리

  • Fragment는 별도 파일로 분리하고, Ajax endpoint에서 layout 없는 fragment view를 반환
  • Ajax 결과 교체는 .html(), 절대 .replaceWith() 쓰지 않음
  • 검색 조건은 DAO, 결과 매핑은 DTO — 역할을 섞지 않음
  • @NoArgsConstructor 누락은 null 바인딩의 흔한 원인
  • LIKE는 CONCAT('%', #{keyword}, '%')${} 쓰면 SQL Injection