SSG

[Thymeleaf 3] Egov 페이징 태그 구현 본문

Spring Boot

[Thymeleaf 3] Egov 페이징 태그 구현

하늘빛호수 2023. 3. 20. 13:58

작업 환경

Spring Boot 3.0.4
JDK 17
Thymeleaf 3
mybatis 3.0.1

 

기존의 EgovFramework를 사용하고 있던 터라 Thymeleaf 확장 태그 구현도 연습할 겸 구현을 시도해보았다.

 

페이징 계산은 EgovFramework 4.0 버전의 PaginationInfo를 사용하였다.

아쉽지만 type과 jsFunction의 경우 시간이 그렇게 많지 않아 생략

 

paginationInfo.java

/**
 * PaginationInfo.java
 * <p/><b>NOTE:</b><pre>
 *                페이징 처리를 위한 데이터가 담기는 빈.
 *                페이징 처리에 필요한 데이터를 Required Fields, Not Required Fields 로 나누었다.
 *                
 *                Required Fields
 *                : 사용자가 입력해야 하는 필드값이다.
 *                currentPageNo : 현재 페이지 번호.
 *                recordCountPerPage : 한 페이지당 게시되는 게시물 건 수.
 *                pageSize : 페이지 리스트에 게시되는 페이지 건수.
 *                totalRecordCount : 전체 게시물 건 수.
 *                
 *                Not Required Fields
 *                : 사용자가 입력한 Required Fields 값을 바탕으로 계산하여 정해지는 값이다.
 *                totalPageCount: 페이지 개수.
 *                firstPageNoOnPageList : 페이지 리스트의 첫 페이지 번호.
 *                lastPageNoOnPageList : 페이지 리스트의 마지막 페이지 번호.
 *                firstRecordIndex : 페이징 SQL의 조건절에 사용되는 시작 rownum. 
 *                lastRecordIndex : 페이징 SQL의 조건절에 사용되는 마지막 rownum.
 *                
 *                페이징 Custom 태그인 &lt;ui:pagination&gt; 사용시에 paginationInfo 필드에 PaginationInfo 객체를 값으로 주어야 한다.
 *                </pre>
 *<pre class="code">
 *&lt;th:block th:pagination = "${paginationInfo}"&gt;
 *</pre>                
 * 
 */
public class PaginationInfo {

	/**
	 * Required Fields
	 * - 이 필드들은 페이징 계산을 위해 반드시 입력되어야 하는 필드 값들이다.  
	 * 
	 * currentPageNo : 현재 페이지 번호
	 * recordCountPerPage : 한 페이지당 게시되는 게시물 건 수
	 * pageSize : 페이지 리스트에 게시되는 페이지 건수,
	 * totalRecordCount : 전체 게시물 건 수. 
	 */

	private int currentPageNo;
	private int recordCountPerPage;
	private int pageSize;
	private int totalRecordCount;

	public int getRecordCountPerPage() {
		return recordCountPerPage;
	}

	public void setRecordCountPerPage(int recordCountPerPage) {
		this.recordCountPerPage = recordCountPerPage;
	}

	public int getPageSize() {
		return pageSize;
	}

	public void setPageSize(int pageSize) {
		this.pageSize = pageSize;
	}

	public int getCurrentPageNo() {
		return currentPageNo;
	}

	public void setCurrentPageNo(int currentPageNo) {
		this.currentPageNo = currentPageNo;
	}

	public void setTotalRecordCount(int totalRecordCount) {
		this.totalRecordCount = totalRecordCount;
	}

	public int getTotalRecordCount() {
		return totalRecordCount;
	}

	/**
	 * Not Required Fields
	 * - 이 필드들은 Required Fields 값을 바탕으로 계산해서 정해지는 필드 값이다.
	 * 
	 * totalPageCount: 페이지 개수
	 * firstPageNoOnPageList : 페이지 리스트의 첫 페이지 번호
	 * lastPageNoOnPageList : 페이지 리스트의 마지막 페이지 번호
	 * firstRecordIndex : 페이징 SQL의 조건절에 사용되는 시작 rownum. 
	 * lastRecordIndex : 페이징 SQL의 조건절에 사용되는 마지막 rownum.
	 */

	private int totalPageCount;
	private int firstPageNoOnPageList;
	private int lastPageNoOnPageList;
	private int firstRecordIndex;
	private int lastRecordIndex;

	public int getTotalPageCount() {
		totalPageCount = ((getTotalRecordCount() - 1) / getRecordCountPerPage()) + 1;
		return totalPageCount;
	}

	public int getFirstPageNo() {
		return 1;
	}

	public int getLastPageNo() {
		return getTotalPageCount();
	}

	public int getFirstPageNoOnPageList() {
		firstPageNoOnPageList = ((getCurrentPageNo() - 1) / getPageSize()) * getPageSize() + 1;
		return firstPageNoOnPageList;
	}

	public int getLastPageNoOnPageList() {
		lastPageNoOnPageList = getFirstPageNoOnPageList() + getPageSize() - 1;
		if (lastPageNoOnPageList > getTotalPageCount()) {
			lastPageNoOnPageList = getTotalPageCount();
		}
		return lastPageNoOnPageList;
	}

	public int getFirstRecordIndex() {
		firstRecordIndex = (getCurrentPageNo() - 1) * getRecordCountPerPage();
		return firstRecordIndex;
	}

	public int getLastRecordIndex() {
		lastRecordIndex = getCurrentPageNo() * getRecordCountPerPage();
		return lastRecordIndex;
	}

}

 

 

dialect 클래스 생성

dialect 클래스를 생성하여 타임리프에서 새롭게 만든 커스텀 태그를 사용할 수 있도록 해준다. 사용할 접두사는 th 이고, 속성명은 pagination가 되어 화면에서 호출시에는 hello:sayto와 같이 사용한다.
PROCESSOR_PRECEDENCE는 dialect 우선순위를 결정해준다.

PaginationTag.java

package com.pagination;

import org.thymeleaf.dialect.AbstractProcessorDialect;
import org.thymeleaf.processor.IProcessor;
import org.thymeleaf.templatemode.TemplateMode;

import java.util.HashSet;
import java.util.Set;

public class  PaginationDialect extends AbstractProcessorDialect {

	public static final String NAME = "Pagination";
	public static final String DEFAULT_PREFIX = "th";
	public static final int PROCESSOR_PRECEDENCE = 800;
	private String charset;

	public PaginationDialect(String charset) {
		super(NAME, DEFAULT_PREFIX, PROCESSOR_PRECEDENCE);
		this.charset = charset;
	}

	@Override
	public Set<IProcessor> getProcessors(String dialectPrefix) {
		final Set<IProcessor> processors = new HashSet<>();
		processors.add(new PaginationProcessor(TemplateMode.HTML,
				dialectPrefix, this.charset));
		return processors;
	}
}

 

AbstractAttributeTagProcessor 클래스를 확장하여 화면에 태그 표시를 담당하는 처리기를 작성한다.

PaginationProcessor.java

package com.pagination;

import lombok.extern.slf4j.Slf4j;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.engine.AttributeName;
import org.thymeleaf.model.AttributeValueQuotes;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.processor.element.AbstractAttributeTagProcessor;
import org.thymeleaf.processor.element.IElementTagStructureHandler;
import org.thymeleaf.standard.expression.IStandardExpression;
import org.thymeleaf.standard.expression.IStandardExpressionParser;
import org.thymeleaf.standard.expression.StandardExpressions;
import org.thymeleaf.templatemode.TemplateMode;
import org.unbescape.html.HtmlEscape;

import java.util.HashMap;

@Slf4j
public class PaginationProcessor extends AbstractAttributeTagProcessor {
    public static final int ATTR_PRECEDENCE = 1300;
    public static final String ATTR_NAME = "pagination";

    private PaginationInfo paginationInfo;

    private String jsFunction = "fnLinkPage";

    private String charset;
    public PaginationProcessor(TemplateMode templateMode, String dialectPrefix, String charset) {
        super(templateMode, dialectPrefix,  null, false, ATTR_NAME, true, ATTR_PRECEDENCE, true);
        this.charset = charset;
    }

    @Override
    protected void doProcess(ITemplateContext context,
                             IProcessableElementTag tag, AttributeName attributeName,
                             String attributeValue, IElementTagStructureHandler structureHandler) {
        
        // thymeleaf로 받은 값을 파싱 후 조회
        final IEngineConfiguration configuration = context.getConfiguration();
        final IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);
        final IStandardExpression expression = parser.parseExpression(context, attributeValue);
        
        paginationInfo = (PaginationInfo) expression.execute(context);

        int firstPageNo = paginationInfo.getFirstPageNo();
        int firstPageNoOnPageList = paginationInfo.getFirstPageNoOnPageList();
        int totalPageCount = paginationInfo.getTotalPageCount();
        int pageSize = paginationInfo.getPageSize();
        int lastPageNoOnPageList = paginationInfo.getLastPageNoOnPageList();
        int currentPageNo = paginationInfo.getCurrentPageNo();
        int lastPageNo = paginationInfo.getLastPageNo();

		// context에서 modelFactory를 가져와 요소를 만드는 객체 선언
        final IModelFactory modelFactory = context.getModelFactory();

        final IModel model = modelFactory.createModel();
        model.add(modelFactory.createOpenElementTag("div"));
        if (totalPageCount > pageSize) {
            if (firstPageNoOnPageList > pageSize) {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(firstPageNo), "classNamePrevFirst", "처음으로", "[처음]"), true);
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(firstPageNoOnPageList - 1), "classNamePrev", "이전페이지", "[이전]"), true);
            } else {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(firstPageNo), "classNamePrevFirst", "처음으로", "[처음]"), true);
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(firstPageNo), "classNamePrev", "이전페이지", "[이전]"), true);
            }
        }

        for (int i = firstPageNoOnPageList; i <= lastPageNoOnPageList; i++) {
            if (i == currentPageNo) {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(i), "classNameNow", "현재페이지", String.valueOf(i)), true);
            } else {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(i), "className", "", String.valueOf(i)), true);
            }
        }

        if (totalPageCount > pageSize) {
            if (lastPageNoOnPageList < totalPageCount) {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(firstPageNoOnPageList + pageSize),  "classNameNext", "다음페이지", "[다음]"), true);
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(lastPageNo), "classNameLast", "마지막페이지로", "[마지막]"), true);
            } else {
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(lastPageNo), "classNameNext", "다음페이지", "[다음]"), true);
                structureHandler.insertImmediatelyAfter(pageLabel(structureHandler, modelFactory, model, String.valueOf(lastPageNo), "classNameLast", "마지막페이지로", "[마지막]"), true);
            }
        }

        model.add(modelFactory.createCloseElementTag("div"));
        structureHandler.replaceWith(model, false);
    }

    private IModel pageLabel(IElementTagStructureHandler structureHandler, IModelFactory modelFactory, IModel model, String onclickValue, String className, String title, String tagValue){
        HashMap<String, String> attr = new HashMap<>();
        if (!className.isEmpty()){
            attr.put("class", className);
            attr.put("title", title);
        }
        attr.put("onclick", jsFunction + "(" + onclickValue + "); return false;");
        attr.put("href", "#");
        model.add(modelFactory.createOpenElementTag("a", attr, AttributeValueQuotes.DOUBLE, false));
        model.add(modelFactory.createText(HtmlEscape.escapeHtml5(tagValue)));
        model.add(modelFactory.createCloseElementTag("a"));

        return model;
    }


}

jsFunction의 경우 따로 구현을 하지 못해서
fnLinkPage 라는 값을 선언하여 사용하였다.

결국 나오는 구현은 요런느낌?

<div>
    <a href="#" onclick="fnLinkPage(1); return false;" class="classNamePrev1" title="처음페이지">[처음]</a>
    <a href="#" onclick="fnLinkPage(13); return false;" class="classNamePrev14" title="이전페이지">[이전]</a>
    <a href="#" onclick="fnLinkPage(11); return false;" class="className">11</a>
    <a href="#" onclick="fnLinkPage(12); return false;" class="className">12</a>
    <a href="#" onclick="fnLinkPage(13); return false;" class="className">13</a>
    <a href="#" onclick="fnLinkPage(14); return false;" class="className">14</a>
    <a href="#" class="classNameNow">15</a>
    <a href="#" onclick="fnLinkPage(16); return false;" class="className">16</a>
    <a href="#" onclick="fnLinkPage(17); return false;" class="className">17</a>
    <a href="#" onclick="fnLinkPage(18); return false;" class="className">18</a>
    <a href="#" onclick="fnLinkPage(19); return false;" class="className">19</a>
    <a href="#" onclick="fnLinkPage(20); return false;" class="className">20</a>
    <a href="#" onclick="fnLinkPage(16); return false;" class="classNameNext" title="다음페이지">[다음]</a>
    <a href="#" onclick="fnLinkPage(21); return false;" class="classNameLast" title="마지막페이지">[마지막]</a>
</div>

 

 

 

Demo1Application.java

package com.example.demo;

import com.pagination.PaginationDialect;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class Demo1Application {

    public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class, args);
    }
    @Bean
    public PaginationDialect paginationDialect() {
        return new PaginationDialect("UTF-8");
    }

}

나도 공식 문서와 다른 분들의 블로그를 보고 겨우겨우 구현에 성공했다.
내가 부족해서 완벽하게 구현하는것은 실패했지만 그래도 좋은 경험이었다..

만약에 더 좋은걸 구현하셨다면 공유 부탁드립니다.ㅠ

 

 

참조 :

https://www.thymeleaf.org/doc/tutorials/3.0/extendingthymeleaf.html

 

Tutorial: Extending Thymeleaf

1 Some Reasons to Extend Thymeleaf Thymeleaf is an extremely extensible library. The key to it is that most of its user-oriented features are not directly built into its core, but rather just packaged and componentized into feature sets called dialects. Th

www.thymeleaf.org

https://appaga.github.io/posts/thymeleaf-extend-001/

 

Creating Thymeleaf Custom Tags

타임리프 커스텀 태그 만들기

appaga.github.io

 

https://appaga.github.io/posts/thymeleaf-extend-002/

 

Extending Thymeleaf dialect : processing attribute value

thymeleaf dialect 확장하여 태그 속성값을 변환하여 출력하기

appaga.github.io

https://blog.hkwon.me/thymeleaf-extend-dialect/

 

Thymeleaf 확장으로 새로운 dialect 추가해보기

최근에 몇몇 프로젝트를 Thymeleaf를 템플릿 엔진으로 선정해서 진행을 하고 있다. 일단 기존의 개발자들이 JSP & JSTL을 사용하는 것에 너무 익숙하다보니 도입을 하는게 쉽진 않았으나 Spring Framework

blog.hkwon.me

 

'Spring Boot' 카테고리의 다른 글

[Spring boot 3] jstl 사용 오류  (0) 2023.03.10