[KOSTA 프로젝트] 03. 사이드바에서 현재 페이지 표시하기
프로젝트를 기획하면서 우리 팀은 가독성이 좋으면서도 예쁜 화면에 욕심냈다.
모두 백엔드 개발자를 희망하지만 예쁜 화면은 놓칠 수 없었나 보다.
그래서 피그마 작업에 꽤나 오랜 시간을 투자하게 되었고, 사이드 고정 바 역시 꽤나 유니크한 디자인이 되었다.
문제는 이걸 어떻게 구현하는가 였다.
그리고 프로젝트 기본 설정을 맡은 내가 layout을 설정하면서 자연스럽게 헤더와 사이드바를 맡게 되었다.
금방 해결할 수 있는 간단한 문제라고 생각했지만 생각보다 시행착오를 겪었던 기능이었다.
화면에서처럼 현재 열린 페이지가 속한 메뉴 탭이 둥근 네모 모양으로 열리고,
해당하는 하위 메뉴 탭을 배경색으로 표시하고 싶었는데 이건 사실 프론트만으로 불가능한 말이었다.
상위 메뉴 탭이 열리는 것 까지는 성공했으나 하위 페이지로 넘어가거나 url을 직접 입력하면
사이드바에 표시할 수 없었기 때문이다.
fragment기능을 사용하지 않고 side와 header를 모든 페이지에 넣어서 커스텀했다면 가능했을지도 모른다.ㅎㅎ
그래서 시도한 첫 번째 방법은 Menu 객체와 리스트를 만들고 매칭되는 url을
사이드바에서 표시할 수 있도록 static 메서드를 만드는 것이었다.
이 방식은 페이지가 속한 탭을 직접 파라미터로 적어넣어야 했으며 Model객체를 통해
템플릿에 그 파라미터가 전달되면 탬플릿에서 동일한 코드를 가진 탭에 매핑되는 것이었다.
// Menu 객체
@Getter @Setter @ToString
@AllArgsConstructor
public class MenuDetail {
private String code;
private String name;
private String url;
}
public static List<MenuDetail> gets(String code) {
List<MenuDetail> menus = new ArrayList<>();
// 게시판 하위 메뉴
switch (code) {
case "mypage":
menus.add(new MenuDetail("attendance", "근태관리", "/mypage/attendance"));
menus.add(new MenuDetail("schedule", "일정관리", "/mypage/schedule/show"));
menus.add(new MenuDetail("conf", "회의실 예약", "/mypage/conf"));
break;
case "document":
menus.add(new MenuDetail("mydocu", "나의 결재", "/document/mydocu"));
menus.add(new MenuDetail("createDoc", "결재문서 작성", "/document/createDoc"));
menus.add(new MenuDetail("recieved", "수신함", "/document/recieved"));
menus.add(new MenuDetail("tempsaved", "임시저장함", "/document/tempsaved"));
break;
}
return menus;
}
/** 메뉴 파라미터를 받아서 해당하는 메뉴와 서브메뉴 리스트를 탬플릿으로 전달 */
public static void getMenu(HttpServletRequest request, Model model, String code) {
String URI = request.getRequestURI();
// 메뉴 코드 전달
model.addAttribute("menuCode", code);
// 현재 페이지 정보 전달
String URI = request.getRequestURI();
model.addAttribute("URI", URI);
// 해당되는 탭에 대한 하위 메뉴 리스트 전달
List<MenuDetail> submenus = gets(code);
if(submenus.size() > 0) {
model.addAttribute("submenus", submenus);
}
}
그러나 이 방식을 사용하면 모든 getMapping 메서드에 해당 static 메서드를 넣어야만 했으며 넣지 않으면 탬플릿 오류가 발생하는 참변을 겪을 수 있었다.. 아주 불편하고 귀찮았으며 함께하는 팀원들에게도 매우 양해의 말씀을 올렸다.
그래서 이건 폐기
해결방법
아무래도 모든 페이지마다 코드를 한 줄씩 넣는게 번거로운 일인 것 같아 고민하던 중 인터셉터가 생각났다.
preHandle을 사용하면 여러번 같은 코드를 작성할 필요 없이 아주 편할 것 같았다.
모든 페이지의 view가 생성되기 전 url을 menu와 submenu로 파싱하여 템플릿에 전달해보기로 했다.
<MenuInterceptor>
@Component("menu") // bean에 "menu"라는 이름으로 저장
@Getter // 멤버변수를 템플릿에서 사용하기 위함
public class MenuInterceptor implements HandlerInterceptor {
// 해당 페이지의 url이 가리키는 메뉴와 서브메뉴
private String menu;
private String subMenu;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI().substring(1); // 현재 페이지 url (제일 앞의 "/" 제외)
// 잘라내고 남은 url
String mess = "";
// 상위 메뉴 파싱
if(uri.contains("/")) { // 하위 메뉴가 있는 경우
menu = uri.substring(0, uri.indexOf("/"));
mess = uri.substring(uri.indexOf("/") + 1);
} else { // 하위 메뉴가 없는 경우
menu = uri;
mess = "";
}
// 하위 메뉴 파싱
if(mess.contains("/")) { // 그 뒤에 또다른 상세주소가 있는 경우
subMenu = mess.substring(0, mess.indexOf("/"));
} else { // 상세주소가 없는 경우
subMenu = mess;
}
// 파싱을 완료하고 멤버변수에 저장했으니까 view 생성!
return true;
}
// 템플릿에서 하위메뉴 리스트를 불러오기 위한 함수
public List<MenuDetail> getSubList() {
return MenuService.gets(menu);
}
}
컴포넌트에 이름을 적어주면 bean에 저 이름으로 등록되어 템플릿에서 해당 클래스의 메서드를 쉽게 사용할 수 있다.
파싱된 menu와 subMenu를 가져오기 위한 Getter 메서드와 서브메뉴 리스트를 가져오기 위한 getSubList() 메서드를 사용할 것이다.
<sidebar.html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<aside th:fragment="sidebar" class="sidebar">
<!-- 로고 생략 -->
<div class="accro">
<!-- 상위 메뉴 -->
<div class="head">
<input class="" type="radio" id="myPage" name="nav" aria-controls="flush-myPage"
th:checked="${@menu.getMenu() != null && @menu.getMenu().equals('mypage')} ? true">
<label for="myPage">
<a th:href="@{/mypage}">마이페이지</a>
</label>
</div>
<!-- 하위 메뉴 -->
<ul id="flush-myPage" th:style="${@menu.getMenu() == null || !@menu.getMenu().equals('mypage')}? 'display: none;'">
<li th:each="submenu : ${@menu.getSubList()}" th:object="${submenu}">
<a th:href="*{url}" th:text="*{name}" th:classappend="${submenu.code == @menu.getSubMenu()} ? 'submenu'"></a>
</li>
</ul>
</div>
<div class="accro">
<div class="head">
<input class="" type="radio" id="docu" name="nav" aria-controls="flush-docu"
th:checked="${@menu.getMenu() != null && @menu.getMenu().equals('document')} ? true">
<label for="docu">
<a th:href="@{/document}">전자결재</a>
</label>
</div>
<ul id="flush-docu" th:style="${@menu.getMenu() == null || !@menu.getMenu().equals('document')}? 'display: none;'">
<li th:each="submenu : ${@menu.getSubList()}" th:object="${submenu}">
<a th:href="*{url}" th:text="*{name}" th:classappend="${submenu.code == @menu.getSubMenu()} ? 'submenu'"></a>
</li>
</ul>
</div>
</aside>
</html>
"${@bean이름.메서드이름()}"
이런식으로 스프링 컨테이너에 등록된 bean 내부의 메서드를thymeleaf로 사용할 수 있다.
상위 메뉴에서는 getMenu()로 현재 페이지에 해당하는 Menu탭이 checked되면 배경색상이 적용되도록 했다.(단순 css)
하위 메뉴는 javascript를 사용하여 상위 메뉴가 선택되면 display 설정이 block으로 변하게 했다.
그리고 getSubList()로 하위 메뉴 리스트를 받아와 th:each로 간단학 표시할 수 있었다.
현재 페이지와 비교하여 코드가 같은 경우 배경색상이 적용되도록 했다.
이 방법의 주의점은 컨트롤러에서 각 페이지의 url을 지정할 때 첫번째 경로(menu)와 두번째 경로(submenu)에 유의해야 한다는 점이다.
그리고 하위메뉴 리스트를 찾는데 사용하는 메서드의 내용에도 유의해야 한다.
submenu의 code와 url의 두번째 경로를 일치시켜 주어야 제대로 작동할 수 있다.
ex) menus.add(new MenuDetail("mydocu", "나의 결재", "/document/mydocu"));
++ 그리고 어쩌다 알게된 정보
탬플릿에서 바로 현재 페이지의 url을 가져오고 싶었다.
분명 배울때는 가져올 수 있다고 알고 있어서 예전 코드를 참고했지만 제대로 출력되지 않았다.
결국 stackoverfloow에서 답을 찾을 수 있었는데
알고보니 Springboot 3.0에서는 템플릿에서 현재 페이지의 url을 불러오기가 가능했으나 3.1에서는 불가하며, 컨트롤러의 Model객체를 이용하여 url을 가져와야 하는 것으로 바뀌었다고 한다.
https://stackoverflow.com/questions/74594544/getrequesturi-is-null-with-netty-and-spring-boot-3
getRequestURI is null with Netty and Spring Boot 3
In Thymeleaf < 3.1 I used below expression to get the request URI. th:classappend="${#arrays.contains(urls, #httpServletRequest.getRequestURI()) ? 'active' : ''}" It worked all the time
stackoverflow.com
다만 굳이 인터셉터에서 메서드를 하나 더 만들어 url을 가져오게 하고 싶지 않았고
각 페이지마다 Model객체를 사용하면 이전과 같이 너무 번거로운 방법이라는 생각이 들었다.
하지만 위의 링크에서 조금 내려보니 친절한 분이 대안을 제시한 댓글을 볼 수 있었다.
모든 컨트롤러에 같은 코드를 적용할 수 있는 @ControllerAdvise라는게 있다는 것을 알게 되었고 이럴거면 기존의 방법도 괜찮지 않았을까 하는 생각이 들지만 아무래도 안괜찮았을 것 같다.
그리고 결국 템플릿에서 현주소를 가져오지 않고도 매핑하는 방법을 찾아냈기 때문에 @ControllerAdvise는 다음에 사용해보기로 했다.
이 포스트에 사용한 코드들은 아래 링크에서 확인할 수 있다.
https://github.com/subin9804/starducks/tree/master/src/main/java/org/kosta/starducks/commons/menus
템플릿은 resources - templates - outlines - side.html
js는 resources - static - css - commons - script.js
css는 resources - static - js - commons - style.css