프론트는 JSP 로 하려고 했으나, 뷰에서는 백엔드와는 완전히 분리되는게 좋을 것 같다.
(경험을 위한 JSP 는 다른 프로젝트에서 해보는 걸로...)
JSP vs Mustache vs Thymeleaf
항목 | JSP | Mustache | Thymeleaf |
템플릿 문법 | 자바 코드 삽입 가능 (<% %>) | 순수 템플릿, 로직 없음 | HTML 태그에 속성(th:)으로 표현 |
문법 표현력 | 강력하지만 복잡 | 단순, 제한적 ({{#each}}) | 풍부하고 직관적 (th:if, th:each) |
HTML 정합성 | ❌ 깨질 수 있음 (스크립틀릿 등) | ⛔ HTML로 직접 열면 깨질 수 있음 | ✅ HTML로 열어도 유효한 마크업 |
성능/속도 | 느림 (JSP 컴파일 필요) | 빠름 (경량 템플릿) | 중간 (HTML DOM 기반) |
유지보수성 | ❌ 로직 섞이기 쉬움 | ✅ 표현만 담당, 구조 명확 | ✅ 로직 표현 분리 잘 됨 |
학습 난이도 | 낮음 (하지만 복잡해지기 쉬움) | 매우 쉬움 | 중간 (태그 속성 방식 이해 필요) |
의존성 | Servlet 기반 (전통 Spring MVC) | Spring Boot 내장 지원 | Spring Boot 내장, 공식 추천 |
확장성 | 제한적, 오래된 기술 | 제한적 (유틸 호출 불가) | ✅ 유틸리티 메서드, Form 처리 등 풍부 |
템플릿 경로 | /WEB-INF/views/*.jsp | /templates/*.mustache | /templates/*.html |
폼 입력 처리 | 가능하지만 복잡 | ❌ 직접 처리 불가 | ✅ 자동 바인딩 지원 |
적합한 상황 | 레거시 유지보수, 단순 개발 | 빠른 시각화, API 응답 뷰 | 실무 전반, Spring 공식 추천 |
실무 사용도 | 📉 줄어드는 중 | 📈 증가 중 (경량 프로젝트) | 📈📈 가장 널리 사용 중 |
Spring Boot 에서는 여러 기능을 생각하면 타임리프를 선택하는게 맞으나,
타임리프는 실무에서도 많이 써봤고, 경험삼아 머스테치를 사용해보려고 한다.
머스테치 화면 테스트
implementation("org.springframework.boot:spring-boot-starter-mustache")
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>지역별 대표 품종 (TOP10)</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<h2>지역별 대표 품종 (TOP10)</h2>
<!-- 막대그래프 영역 -->
<canvas id="breedChart" width="600" height="400"></canvas>
<script>
const chartData = {
labels: [{{#topAnimalTypeList}}"{{type}}"{{^last}}, {{/last}}{{/topAnimalTypeList}}],
datasets: [{
label: '마릿수',
data: [{{#topAnimalTypeList}}{{count}}{{^last}}, {{/last}}{{/topAnimalTypeList}}],
backgroundColor: 'rgba(54, 162, 235, 0.6)'
}]
};
new Chart(document.getElementById('breedChart'), {
type: 'bar',
data: chartData,
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: '지역별 대표 품종 (Top10)' }
}
}
});
</script>
<!-- 텍스트 리스트 -->
<h3>품종 리스트</h3>
<ul>
{{#topAnimalTypeList}}
<li>{{type}} - {{count}}마리</li>
{{/topAnimalTypeList}}
</ul>
</body>
</html>
@GetMapping("/animal-types/top")
public String getTopAnimalType(Model model, RegionTopAnimalTypeRequest regionTopAnimalTypeRequest) {
List<RegionTopAnimalTypeResponse> topList = animalTypeService.getTopAnimalTypes(regionTopAnimalTypeRequest);
model.addAttribute("topAnimalTypeList", topList);
return "top-animal-types";
}
머스테치 문법은 단순하다.
중괄호 두개({{}})를 사용해서 사용하고자 하는 요소를 표현할 수 있다.
백엔드에서 topAnimalTypeList 라는 리스트를 반환했기 때문에,
{{#topAnimalTypeList}} 문법을 사용해서 반복을 해줬고, 그 안에 있는 요소를 {{type}}, {{count}} 로 표현했다.
콤마를 찍으며 리스트를 반복할 때 유의할 점은 마지막에 콤마를 찍으면 안된다는 것인데
{{^last}}, {{/last}} 여기보면 반복 조건이 ^last 로 마지막 요소가 아닐 때 콤마를 찍겠다는 뜻이다.
만약 마지막일 때만 찍고 싶으면 ^를 없애면 된다.
http://localhost:8080/animal-types/top 로 접속해보니 데이터가 잘 나온다.
데이터가 나오는 걸 확인했으니 내가 원하는대로 템플릿을 수정해보자. (🥲...)
화면 수정
- 화면 요구사항
- 메뉴바 추가
- 지역 필터 추가 (최초 페이지 진입 시 전체 조회)
- 강아지/고양이 탭 분리
피그마로 화면 디자인을 해봤다.
오른쪽 상단에는 지역 필터를 넣고 싶었는데 옵션 못찾아서 임의로 넣었다..
디자인은 차치하고 이 구성대로 화면에 넣는 걸 목표로 한다.
1. 메뉴바 추가
<!-- head.mustache -->
<head>
<meta charset="UTF-8">
<title>Pet Stats</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<!-- sidebar.mustache -->
<h1>Pet Stats</h1>
<p>경기도데이터드림에서 제공하는<br>반려동물 등록 데이터 시각화</p>
<nav>
<ul>
<li><a href="/">소개</a></li>
<li><a href="/top-animal-types">우리 지역 견종과 묘종은?</a></li>
<li><a href="/rfid">RFID 비율</a></li>
<li><a href="/etc">기타</a></li>
</ul>
</nav>
<!-- top-animal-types.mustache -->
<!DOCTYPE html>
<html lang="ko">
{{> layout/head }}
<body>
<div class="container">
<aside class="sidebar">
{{> layout/sidebar }}
</aside>
</div>
</body>
</html>
body {
margin: 0;
font-family: 'Noto Sans KR', sans-serif;
}
.container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
padding: 30px;
background-color: #f5f5f5;
}
nav ul {
list-style: none;
padding-left: 0;
}
nav ul li {
margin: 10px 0;
}
여러 페이지에서 사용할 것이기 때문에 레이아웃을 분리한다.
head 영역과 메뉴에 들어가는 sideBar를 별도 레이아웃으로 뺐다.
순조롭다.
2. 지역 필터 추가
List<RegionResponse> regions = animalTypeService.getRegions();
model.addAttribute("regions", regions.stream()
.map(r -> Map.of(
"id", r.getId(),
"name", r.getProvince(),
"selected", regionTopAnimalTypeRequest.getRegionId() != null && r.getId().equals(regionTopAnimalTypeRequest.getRegionId())
)).toList());
<div style="display: flex; justify-content: flex-end; margin-bottom: 20px;">
<form method="get">
<label for="regionId">지역:</label>
<select name="regionId" id="regionId" onchange="this.form.submit()">
<option value="">전체</option>
{{#regions}}
<option value="{{id}}" {{#selected}}selected{{/selected}}>{{name}}</option>
{{/regions}}
</select>
</form>
</div>
백에서 지역 정보를 내려주고, 화면에 select 로 표현한다.
{{#selected}}selected{{/selected}} 이 부분은, selected 가 true 상태일 때 표시하라는 거다.
앞에서 #이 붙은 요소는 반복요소일 때 사용한다고 했으나, 조건부 렌더링에도 사용된다. ^와 반대라고 보면 됨.
오른쪽 상단에 지역 필터를 추가했다!
3. 강아지/고양이 탭 분리
List<List<RegionTopAnimalTypeResponse>> topList = animalTypeService.getTopAnimalTypes(regionTopAnimalTypeRequest);
model.addAttribute("dogList", topList.get(0));
model.addAttribute("catList", topList.get(1));
먼저 백엔드에서 dog와 cat을 별도로 보낸다.
List get 하는게 마음에 안드나 화면 구현이 우선이므로 일단 진행한다.
<div class="tabs">
<button class="tab-button active" onclick="showTab('dog')">강아지</button>
<button class="tab-button" onclick="showTab('cat')">고양이</button>
</div>
<!-- 강아지 영역 -->
<div id="tab-dog" class="tab-content" style="display: block;">
<canvas id="dogChart" width="600" height="300"></canvas>
<table>
<thead><tr><th>품종</th><th>마릿수</th></tr></thead>
<tbody>
{{#dogList}}
<tr><td>{{type}}</td><td>{{count}}</td></tr>
{{/dogList}}
</tbody>
</table>
</div>
<!-- 고양이 영역 -->
<div id="tab-cat" class="tab-content" style="display: none;">
<canvas id="catChart" width="600" height="300"></canvas>
<table>
<thead><tr><th>품종</th><th>마릿수</th></tr></thead>
<tbody>
{{#catList}}
<tr><td>{{type}}</td><td>{{count}}</td></tr>
{{/catList}}
</tbody>
</table>
</div>
<script>
function showTab(type) {
// 모든 탭 숨기기
document.getElementById('tab-dog').style.display = 'none';
document.getElementById('tab-cat').style.display = 'none';
// 클릭된 탭만 보여주기
document.getElementById('tab-' + type).style.display = 'block';
// 버튼 상태 변경
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelector('.tab-button[onclick="showTab(\'' + type + '\')"]').classList.add('active');
}
new Chart(document.getElementById('dogChart'), {
type: 'bar',
data: {
labels: [{{#dogList}}"{{type}}"{{^last}},{{/last}}{{/dogList}}],
datasets: [{
label: '강아지 마릿수',
data: [{{#dogList}}{{count}}{{^last}},{{/last}}{{/dogList}}],
backgroundColor: 'rgba(255, 159, 64, 0.6)'
}]
}
});
new Chart(document.getElementById('catChart'), {
type: 'bar',
data: {
labels: [{{#catList}}"{{type}}"{{^last}},{{/last}}{{/catList}}],
datasets: [{
label: '고양이 마릿수',
data: [{{#catList}}{{count}}{{^last}},{{/last}}{{/catList}}],
backgroundColor: 'rgba(153, 102, 255, 0.6)'
}]
}
});
</script>
.tabs {
margin-bottom: 1rem;
}
.tab-button {
background: #f0f0f0;
border: 1px solid #ccc;
padding: 8px 16px;
cursor: pointer;
margin-right: 5px;
}
.tab-button.active {
background: #4285f4;
color: white;
font-weight: bold;
}
차트와 테이블을 두 개 다 그린 다음 display 로 탭을 구분할 수 있게 했다.
탭 누르면 강아지/고양이 차트와 테이블이 전환된다.
등록된 고양이 수 작고 소중해...
- 프론트 수정사항
- 아래 테이블 정보 가독성 증가 or 차트 내 숫자 표시
- 강아지/고양이 탭 전환 시 container의 width 너비 변하는 문제 해결
프론트 수정사항
1. 아래 테이블 정보 가독성 증가 or 차트 내 숫자 표시
차트가 꽉 차다보니 아래에 있는 테이블은 안보게 된다.
그래서 가로로 넣으려고 했으나 헤더까지 해서 어중간한 크기로 3분할이 되면 이상할 것 같아서
차트 내에 숫자를 추가하는거로 결정했다.
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
function getOptions(species) {
return {
responsive: true,
plugins: {
title: { display: true, text: `${species} 대표 품종 TOP10` },
legend: { display: false },
datalabels: {
anchor: 'end',
align: 'bottom',
font: {
weight: 'bold'
},
formatter: function(value) {
return value.toLocaleString();
}
}
}
}
}
new Chart(document.getElementById('dogChart'), {
type: 'bar',
data: {
labels: [{{#dogList}}"{{type}}"{{^last}},{{/last}}{{/dogList}}],
datasets: [{
label: '강아지 마릿수',
data: [{{#dogList}}{{count}}{{^last}},{{/last}}{{/dogList}}],
backgroundColor: 'rgba(255, 159, 64, 0.6)'
}]
},
options: getOptions('강아지'),
plugins: [ChartDataLabels]
});
강아지와 고양이 통합해서 사용해야하기 때문에 함수로 뺐다.
옵션 | 설명 |
anchor: 'end' | 막대의 꼭대기 기준으로 |
align: 'bottom' | 안에 띄움 |
그래프만 보이니 깔끔하다.
2. 강아지/고양이 탭 전환 시 container의 width 너비 변하는 문제 해결
있었는데... 없습니다...
테이블을 없애니까 자동으로 해결됐다.
완벽하다!
디자인적으로는 수정할 게 많지만
당장은 디자인이 중요한 요소가 아니기 때문에 백엔드로 넘어가려고 한다.
- 백엔드 수정 사항
- region 캐시
- species 캐시
- dog/cat List 말고 다른 방식 구현
- QueryRepository = Service 역할 분리
지금으로는 이렇게인 것 같다.
다음에 이어서 해보겠다!
'프로젝트 > 반려동물' 카테고리의 다른 글
[반려동물 프로젝트] 마무리 (1) | 2025.04.26 |
---|---|
[반려동물 프로젝트] 백엔드 수정 (SpringCache) (3) | 2025.04.25 |
[반려동물 프로젝트] 백엔드 구현 (QueryDSL) (3) | 2025.04.24 |
[반려동물 프로젝트] DB 구성 (JPA, Python) (0) | 2025.04.24 |
[반려동물 프로젝트] 데이터 활용 고민 (0) | 2025.04.24 |