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 값이 안 넘어올 때 디버깅 순서
- Chrome F12 → Network 탭 → Ajax 요청 클릭 → Payload 탭
- Query String Parameters에 searchType, keyword 값이 있는지 확인
- 값이 없다 → HTML input/select의
name속성 누락 (id만 있고 name 없는 경우) - 값이 있다 → 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