Tiny Star

프로젝트/반려동물

[반려동물 프로젝트] 프론트 구현 (Mustache)

하얀지 2025. 4. 25. 20:27

프론트는 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. 메뉴바 추가
    2. 지역 필터 추가 (최초 페이지 진입 시 전체 조회) 
    3. 강아지/고양이 탭 분리

 

피그마로 화면 디자인을 해봤다.

오른쪽 상단에는 지역 필터를 넣고 싶었는데 옵션 못찾아서 임의로 넣었다..

디자인은 차치하고 이 구성대로 화면에 넣는 걸 목표로 한다.

 

 

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 로 탭을 구분할 수 있게 했다.

 

탭 누르면 강아지/고양이 차트와 테이블이 전환된다.

등록된 고양이 수 작고 소중해...

 

 

  • 프론트 수정사항
    1. 아래 테이블 정보 가독성 증가 or 차트 내 숫자 표시
    2. 강아지/고양이 탭 전환 시 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 너비 변하는 문제 해결

있었는데... 없습니다...

테이블을 없애니까 자동으로 해결됐다.

 

완벽하다!

 

 

 


 

 

디자인적으로는 수정할 게 많지만

당장은 디자인이 중요한 요소가 아니기 때문에 백엔드로 넘어가려고 한다.

 

  • 백엔드 수정 사항
    1. region 캐시
    2. species 캐시
    3. dog/cat List 말고 다른 방식 구현
    4. QueryRepository = Service 역할 분리

지금으로는 이렇게인 것 같다.

다음에 이어서 해보겠다!

 

 

top