Ids: 반복될 수 있는 id attribute를 처리할 수 있는 유틸리티 메소드들을 가지는 클래스
Thymeleaf tutorial에 따르면 내부에 사용할 수 있는 메소드들에 대해 다음과 같이 설명합니다.
/*
* 일반적으로 th:id attributes 에서 사용하며 지정한 id 값 뒤에 카운터를 붙힌다.
* 따라서 반복 과정에 관여할 때 고유성을 유지할 수 있다.
*/
${#ids.seq('someId')}
/*
* 일반적으로 <label> 태그에 th:for attributes에서 사용한다. 그래서 label들은
* #ids.seq(...) function을 통해 생성된 ID를 참조할 수 있다.
*
* <label>은 #ids.seq(...) function을 가진 요소의 앞 또는 뒤에 있는지 여부에 따라 달라진다.
* "next"는 seq function 호출 전에, "prev"는 seq function 호출 후에 사용할 수 있다.
*/
${#ids.next('someId')}
${#ids.prev('someId')}
번역을 잘 못해서 그런진 모르겠지만 매끄럽게 이해하지 못해 하나하나 뜯어보기로 했습니다.
결론만 보고싶다면 제일 아래 정리로 이동하시면 됩니다!
타임리프는 특정 페이지를 렌더링할 때, TypeValue 클래스를 deque에 담아 관리합니다. TypeValue는 type과 type에 대한 설명을 가지는 클래스입니다. 이 때 , #ids, #maps 등 다양한 Utility Objects나 Basic Objects는 VariableReference라는 클래스에 담기게 되는데, 렌더링 할 페이지에서 VariableReference를 사용했다면 자동으로 등록되게 됩니다. #ids의 경우 다음과 같이 생성됩니다.
context는 파라미터로 expressionObjectName과 함께 넘어오는데, WebEngineContext로 넘어옵니다. 공식 문서에 따르면 웹 처리에 기본적으로 사용되는 컨텍스트로, 내부 구현이기에 사용자의 코드가 구현된 인터페이스 대신 직접 참조하거나 사용할 이유가 없다고 나와있습니다. 현재 요청했던 url의 경로와, 쿠키와 세션, servletContext, locale 등이 담겨 있는걸 확인할 수 있습니다. 즉, 요청마다 WebEngineContext를 가지는 것이고, Ids 구현체가 생성되기까지 위와 같은 과정을 거치게 됩니다.
그렇다면 이 구현체에서 사용할 수 있는 메소드 3개가 어떻게 구현되어있을까요?
ITemplateContext는 템플릿 처리에 필요한 컨텍스트를 포함하는 모든 클래스에서 구현되는 인터페이스입니다. 이 인터페이스에 WebEngineContext가 담기는 것이고, 이후 seq, next, prev는 이 context 내부의 IdentifierSequence를 통해 카운터를 관리합니다.
WebEngineContext 구현체는 getIdentifierSequences()를 호출하면 자신의 상위 추상 클래스인 AbstractEngineContext에 선언한 메소드를 실행하게 됩니다.
null인 경우 IdentifierSequences를 통해 생성하고, 존재한다면 그대로 반환합니다. 다음은 IdentifierSequences 클래스입니다.
/**
* Thymeleaf 표준 식 내에서 markup id attribute와 관련된 작업을 수행하기 위한 식 개체다.
* 이 클래스의 개체는 일반적으로 이름이 #ids인 변수식에서 사용할 수 있다.
* Since: 1.0
* Author: Daniel Fernández
*/
public final class IdentifierSequences {
private final Map<String,Integer> idCounts;
// 최초 생성시 해시맵 초기화
public IdentifierSequences() {
super();
this.idCounts = new HashMap<String,Integer>(1,1.0f);
}
/**
* 해당 키 값(id명)이 존재할 경우 그 값을 반환하고 카운터를 1 증가시켜 저장한다.
* 해당 키 값이 존재하지 않을 경우 1로 초기화 후 반환하고 카운터를 1 증가시켜 저장한다.
* Ids의 seq(name) 메소드와 AbstractSpringFieldTagProcessor의 computeId 메소드가 호출한다.
*/
public Integer getAndIncrementIDSeq(final String id) {
Validate.notNull(id, "ID cannot be null");
Integer count = this.idCounts.get(id);
if (count == null) {
count = Integer.valueOf(1);
}
this.idCounts.put(id, Integer.valueOf(count.intValue() + 1));
return count;
}
/**
* 해당 키 값(id명)이 존재한다면 반환하고, 없다면 1을 반환한다
* 값을 변경시키진 않으며 Ids 클래스의 next(name) 메소드가 호출한다.
*/
public Integer getNextIDSeq(final String id) {
Validate.notNull(id, "ID cannot be null");
Integer count = this.idCounts.get(id);
if (count == null) {
count = Integer.valueOf(1);
}
return count;
}
/**
* 해당 키 값이 있다면 (값-1)을 반환하고, 키 값이 없다면
* 값을 변경시키진 않으며 Ids 클래스의 prev(name) 메소드가 호출한다.
*/
public Integer getPreviousIDSeq(final String id) {
Validate.notNull(id, "ID cannot be null");
final Integer count = this.idCounts.get(id);
if (count == null) {
throw new TemplateProcessingException(
"Cannot obtain previous ID count for ID \"" + id + "\"");
}
return Integer.valueOf(count.intValue() - 1);
}
}
Ids의 prev 메소드를 호출하면, 현재 아이디 값을 키 값으로 가지는 value보다 1 작은 값을 얻고, next 메소드를 호출하면 현재 아이디 값을 키 값으로 가지는 value를 얻을 수 있습니다.
타임리프에서 Ids 사용하기
이제부터 예시를 통해 확인해 보겠습니다. 예시는 인프런 김영한님의 MVC 2편 강의 코드 일부입니다.
@ModelAttribute("regions")
public Map<String, String> regions() {
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("BUSAN", "부산");
regions.put("JEJU", "제주");
return regions;
}
ModelAttribute에 이름이 regions인 LinkedHashMap<String, String>을 생성하고 th:each를 이용해 멀티 체크박스를 만듭니다.
<div th:each="region : ${regions}">
<label th:for="${#ids.next('regions')}" th:text="${region.value}"></label>
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
</div>
저는 label에 th:for = "#{#ids.next('regions')}"를 사용했으니, "regions" 라는 key 값이 존재하지 않아 매번 1을 반환하기 때문에 모든 label의 for attribute는 regions1이 될 것이라고 생각했습니다. 하지만 결과는 다음과 같았습니다.
<div>
<label for="regions1">서울</label>
<input type="checkbox" value="SEOUL" id="regions1" name="regions">
</div>
<div>
<label for="regions2">부산</label>
<input type="checkbox" value="BUSAN" id="regions2" name="regions">
</div>
<div>
<label for="regions3">제주</label>
<input type="checkbox" value="JEJU" id="regions3" name="regions">
</div>
분명, ids의 next 메소드는, 카운트를 변경하지 않고 현재 카운트만 반환하고, 만약 key가 존재하지 않는다면 매번 1을 반환해야 합니다.
하지만 매 반복마다 카운트는 1씩 증가함을 알 수 있습니다.
이 때, th:each 반복 내에서, input checkbox 태그를 제거하면 기대했던 대로 label 태그 전체는 for="regions1"로 출력됩니다.
<div>
<label for="regions1">서울</label>
</div>
<div>
<label for="regions1">부산</label>
</div>
<div>
<label for="regions1">제주</label>
</div>
input checkbox의 th:field attribute가 id, name을 생성할 때 IdentifierSequences의 idCount의 해시맵에 영향을 준다고 생각해서 디버깅을 통해 확인해봤습니다.
정답! 위에서 설명할 때 IdentifierSequences 클래스의 getAndIncrementIDSeq 메소드는 AbstractSpringFieldTagProcessor의 computeId 메소드도 호출한다고 했었습니다. th:field는 html attribute의 id, name, value를 생성해 주는데, 이 때, name과 value는 같을 수 있지만 id는 구분을 위해서 매번 다른 아이디를 반환해야합니다. 그래서 computeId 메소드를 사용해서 id에 카운트를 붙힙니다.
이때, context는 Ids 인스턴스를 만들 때 사용했던 context와 같은 WebEngineContext를 사용합니다. 같은 IdentifierSequences를 공유한다는 것! computeId 메소드를 확인해보면, 파라미터로 넘어온 boolean sequence가 true일 때만 동작하게 되어있습니다.
computeId 메소드를 호출하는 곳을 알아보면, sequence가 true를 넘기는 Processor는 input[checkbox, radio]입니다.
이 두 input의 경우만, 동일한 아이디가 있다면 뒤에 카운트를 붙혀서 반환해준다는 것을 알 수 있습니다.
label에서 th:for의 ids가 호출하는 id와 input[checkbox, radio]에서 호출하는 th:field의 변수명이 같은 경우 위와 같은 효과를 냅니다.
위 방법의 경우, 스프링 부트가 내부적으로 호출하기 때문에 인지만 하시면 좋을 것 같습니다.
다양한 예시
1. TemplateProcessingException
<div th:each="region : ${regions}">
<label th:for="${#ids.prev('regions')}" th:text="${region.value}"></label>
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
</div>
org.thymeleaf.exceptions.TemplateProcessingException: Cannot obtain previous ID count for ID "regions" (template: "form/item" - line 58, col 20)
TemplateProcessingException이 발생합니다. 최초로 ids.prev를 호출하는데, regions는 등록된 적이 없기 때문입니다.
2. label - input 불일치
<div th:each="region : ${regions}">
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
<label th:for="${#ids.next('regions')}" th:text="${region.value}"></label>
</div>
each에서 생성된 첫 번째 div만 보면 , checkbox의 id는 "regions1" 이지만, 라벨의 for는 "regions2"가 됩니다. 내부적으로 th:field가 id를 sequence에 맞게 생성하기 위해 현재 카운트 1을 붙혀 반환하며 count를 1증가 시켜 2가 되었습니다. 그리고 label이 th:for를 호출하며 next 메소드를 사용한 순간, 증가된 2의 값이 붙기 때문입니다.
그리고 두 번째 div를 생성할 때 checkbox의 id는 2를 반환받고 3으로 증가시켜 label은 다시 3을 반환받게 되며 하나씩 밀리게됩니다.
3. seq를 사용한 불일치
<div th:each="region : ${regions}">
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
<label th:for="${#ids.seq('regions')}" th:text="${region.value}"></label>
</div>
checkbox가 th:field로 id를 생성할 때 값을 2로 증가시켰고, label의 th:for로 ids.seq를 다시 호출하며 checkbox의 id는 1, label의 for는 2, 다음 checkbox의 id는 3, 다음 라벨의 for는 4.... 이런식으로 증가합니다.
4. 제대로 사용된 경우
<div th:each="region : ${regions}">
<!-- 현재 count를 넣기 -->
<label th:for="${#ids.next('regions')}" th:text="${region.value}"></label>
<!-- checkbox의 th:field가 현재 count를 받으며 값을 증가 -->
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
</div>
<!-- OR -->
<div th:each="region : ${regions}">
<!-- checkbox의 th:field가 count를 증가시킴 -->
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}"/>
<!-- checkbox의 th:field가 값을 증가시켰기 때문에 prev 값 호출-->
<label th:for="${#ids.prev('regions')}" th:text="${region.value}"></label>
</div>
정리
1. ids - seq(id) method
- 매개변수로 들어온 id에 현재 count를 붙혀서 반환합니다. ex) id1, id2...
- count는 1부터 시작하고, 반환 전에 count+1로 업데이트하여 저장합니다.
- 예를들어 최초로 호출했다면 1이 반환되며 count는 2로 업데이트되고, count가 3이라면 3을 반환하며 4로 업데이트 됩니다.
2. ids - prev(id) method
- 값을 증감하지 않고, 현재 count 값보다 1 작은 값을 반환합니다.
- 만약 해당 id에 대한 정보가 없다면 TemplateProcessingException을 던집니다.
3. ids - next(id) method
- 값을 증감하지 않고 현재 count 값을 반환합니다.
- 만약 해당 id에 대한 정보가 없다면 1을 반환합니다.
4. ids - seq(id) method 외에 count를 증가시키는 방법
- input[checkbox, radio]에서 th:field로 호출