HTML 메뉴 렌더링 리팩토링하기

리팩토링할 결심

  • 데이터베이스에서 메뉴데이터를 가져와 화면에 html로 보여주는 JSP 코드가 있었는데 매우 복잡하였다. 아래 사진은 코드의 일부분이다 ㅎㅎ

환장의 코드

  • Scriptlet과 HTML이 섞여있고, 메뉴뎁스가 깊어질수록 for문안에 for문 if문이 중첩적으로 계속 늘어나면서 총 200라인이 넘어갔다.
    이러다보니 메뉴쪽 디자인을 변경할 때마다 매우 고통스러웠다.
    보기만해도 다리가 후들거리고 숨이 탁탁 막힌다. 더 이상 고통받고 싶지 않아 리팩토링을 하기로 결심했다.

고통받는 짤

리팩토링

먼저 메뉴 정보를 담을 Menu 클래스를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
public class Menu {
private String title;
private String url;
private int depth;

public Menu(String title, String url, int depth) {
this.title = title;
this.url = url;
this.depth = depth;
}
}

실무에서는 더 많은 클래스 필드가 있었지만 여기서는 구현방법만 보여주기 위해 최대한 간단히 구성했다.

다음으로 테스트 코드를 만든다.

1
2
3
4
5
6
7
8
9
public class MenuTest {
@Test
void toHtml() {
Menu menu = new Menu("홈", "/site/home", 1);
String expected = """
<li><a href="/site/home">홈</a></li>""";
assertEquals(expected, menu.html());
}
}

html() 메소드를 호출하면 위처럼 나오게하고 싶다. html() 메소드를 작성해보자.

1
2
3
4
5
public String html() {
StringBuilder sb = new StringBuilder();
sb.append("<li><a href=\"").append(url).append("\">").append(title).append("</a></li>");
return sb.toString();
}

위와같이 작성하고 다시 테스트를 돌리면 성공한다.

다음으로 하위메뉴가 들어갔을 때를 테스트해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
void 하위메뉴html() {
Menu menu = new Menu("홈", "/site/home", 1);
menu.addChild(new Menu("소개", "/site/introduce", 2));
menu.addChild(new Menu("인사", "/site/greet", 2));

String expected = """
<li>
<a href="/site/home">홈</a>
<ul>
<li><a href="/site/introduce">소개</a></li>
<li><a href="/site/greet">인사</a></li>
</ul>
</li>""";

assertEquals(expected, menu.html());
}

하위 메뉴를 가지기 위해 Menu에 List<Menu> children 을 추가하고 addChild(..) 메소드도 추가한다.

또한 하위메뉴가 있으면 위처럼 ul 태그 안에 하위 리스트가 들어가게 html() 메서드도 수정해주자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Menu {
private String title;
private String url;
private int depth;
private List<Menu> children;

public Menu(String title, String url, int depth) {
this.title = title;
this.url = url;
this.depth = depth;
this.children = new ArrayList<Menu>();
}

public String html() {
StringBuilder sb = new StringBuilder();
sb.append("<li><a href=\"").append(url).append("\">").append(title).append("</a>");
if (!children.isEmpty()) {
sb.append("<ul>");
for (Menu child : children) {
sb.append(child.html());
}
sb.append("</ul>");
}
sb.append("</li>");

return sb.toString();
}

public void addChild(Menu childMenu) {
this.children.add(childMenu);
}
}

테스트를 돌리니 실패했다. 확인해보니 expected 텍스트 블록의 띄어쓰기 때문이다. 정규식을 이용해 아래처럼 한줄로 바꿔서 다시 테스트를하면 성공한다.

1
2
3
4
5
6
7
8
9
String expected = """
<li>
<a href="/site/home">홈</a>
<ul>
<li><a href="/site/introduce">소개</a></li>
<li><a href="/site/greet">인사</a></li>
</ul>
</li>"""
.replaceAll(">\\s+<", "><");

하위 메뉴까지 성공했으니 이제 같은 depth의 이웃 메뉴 테스트를 해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Test
void depth3NaviToHtml() {
Menu menu = new Menu("홈", "/site/home", 1);
Menu introduceMenu = new Menu("소개", "/site/introduce", 2);
introduceMenu.addChild(new Menu("경력소개", "/site/career", 3));
introduceMenu.addChild(new Menu("취미", "/site/hobby", 3));
menu.addChild(introduceMenu);
menu.addChild(new Menu("인사", "/site/greet", 2));

Menu boardMenu = new Menu("게시판", "/site/board", 1);
boardMenu.addChild(new Menu("공지사항", "/site/board/notice", 2));
boardMenu.addChild(new Menu("포토갤러리", "/site/board/photo", 2));

Menu rootMenu = new Menu("", "", 0);
rootMenu.addChild(menu);
rootMenu.addChild(boardMenu);

String expected = """
<ul>
<li><a href="/site/home">홈</a>
<ul>
<li><a href="/site/introduce">소개</a>
<ul>
<li><a href="/site/career">경력소개</a></li>
<li><a href="/site/hobby">취미</a></li>
</ul>
</li>
<li><a href="/site/greet">인사</a></li>
</ul>
</li>
<li><a href="/site/board">게시판</a>
<ul>
<li><a href="/site/board/notice">공지사항</a></li>
<li><a href="/site/board/photo">포토갤러리</a></li>
</ul>
</li>
</ul>"""
.replaceAll(">\\s+<", "><");

assertEquals(expected, rootMenu.html());
}

홈메뉴와 같은 레벨의 게시판 메뉴를 만들어서 루트 메뉴에 추가했다.
위와 같은 expected 구조를 예상하여 테스트 했더니 실패했다.

테스트 실패

루트메뉴는 링크 태그가 생성되면 안되는데 생성되어서 실패한다.

해결하기 위해서 아래와같이 html() 메소드를 수정했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String html() {
StringBuilder sb = new StringBuilder();
if (depth != 0) {
sb.append("<li><a href=\"").append(url).append("\">").append(title).append("</a>");
}

if (!children.isEmpty()) {
sb.append("<ul>");
for (Menu child : children) {
sb.append(child.html());
}
sb.append("</ul>");
}

if (depth != 0) {
sb.append("</li>");
}

return sb.toString();
}

루트메뉴가 아닐 때 (depth != 0) 만 <li> 태그를 생성하게 만들고 다시 테스트를 실행하면 성공한다.
메뉴 생성 로직에 중복이 보이니 이쯤에서 테스트코드를 리팩토링해준다.
메뉴생성 코드를 필드로 추출하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private final Menu menu1 = new Menu("홈", "/site/home", 1);
private final Menu menu1_1 = new Menu("소개", "/site/introduce", 2);
private final Menu menu1_1_1 = new Menu("경력소개", "/site/career", 3);
private final Menu menu1_1_2 = new Menu("취미", "/site/hobby", 3);
private final Menu menu1_2 = new Menu("인사", "/site/greet", 2);
private final Menu menu2 = new Menu("게시판", "/site/board", 1);
private final Menu menu2_1 = new Menu("공지사항", "/site/board/notice", 2);
private final Menu menu2_2 = new Menu("포토갤러리", "/site/board/photo", 2);

@TEST
void test() {
//테스트 메소드들도 위 필드를 사용하도록 수정
}

변경 후 다시 테스트를 실행시켜보자. 성공한다면 일단 기본적인 HTML rendering은 끝났다.

다음으로 DB에서 가져온 메뉴 리스트로 Menu 를 생성할 수 있게 만들어야한다.
DB에서 계층쿼리를 사용해 가져오기 때문에 순서만 정렬된 인접리스트 형태일 것이다.
트리계층 구조로 바꾸려면 식별자와 부모식별자가 필요하다. Menu 클래스에 id와 parentId를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Menu {
private final Long id;
private final Long parentId;
private final String title;
private final String url;
private final int depth;
private final List<Menu> children;

public Menu(Long id, Long parentId, String title, String url, int depth) {
this.id = id;
this.parentId = parentId;
this.title = title;
this.url = url;
this.depth = depth;
this.children = new ArrayList<>();
}

public Menu(String title, String url, int depth) {
this(null, null, title, url, depth);
}

//생략...
}

class MenuTest {
private final Menu root = new Menu(0L, null, "root", "/", 0);
private final Menu menu1 = new Menu(1L, 0L, "홈", "/site/home", 1);
private final Menu menu1_1 = new Menu(2L, 1L, "소개", "/site/introduce", 2);
private final Menu menu1_1_1 = new Menu(3L, 2L, "경력소개", "/site/career", 3);
private final Menu menu1_1_2 = new Menu(4L, 2L, "취미", "/site/hobby", 3);
private final Menu menu1_2 = new Menu(5L, 1L, "인사", "/site/greet", 2);
private final Menu menu2 = new Menu(6L, 0L, "게시판", "/site/board", 1);
private final Menu menu2_1 = new Menu(7L, 6L, "공지사항", "/site/board/notice", 2);
private final Menu menu2_2 = new Menu(8L, 6L, "포토갤러리", "/site/board/photo", 2);

//생략...
}

인접리스트 메뉴를 생성자 인자로 받아서 트리 메뉴를 생성하는 테스트를 다음과 같이 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Test
@DisplayName("메뉴목록을 계층구조로 생성하여 렌더링")
void menuListToMenuTree() {
List<Menu> adjacentMenuList = List.of(root, menu1, menu1_1, menu1_1_1, menu1_1_2, menu1_2, menu2, menu2_1, menu2_2);
Menu rootMenu = new Menu(adjacentMenuList);

String expected = """
<ul>
<li><a href="/site/home">홈</a>
<ul>
<li><a href="/site/introduce">소개</a>
<ul>
<li><a href="/site/career">경력소개</a></li>
<li><a href="/site/hobby">취미</a></li>
</ul>
</li>
<li><a href="/site/greet">인사</a></li>
</ul>
</li>
<li><a href="/site/board">게시판</a>
<ul>
<li><a href="/site/board/notice">공지사항</a></li>
<li><a href="/site/board/photo">포토갤러리</a></li>
</ul>
</li>
</ul>"""
.replaceAll(">\\s+<", "><");

assertEquals(expected, menu.html());
}

생성자를 구현해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Menu {
public Menu(List<Menu> adjacentMenuList) {
Map<Long, Menu> menuMap = new HashMap<>();
Menu rootMenu = adjacentMenuList.get(0);
for (Menu menu : adjacentMenuList) {
if (menu.getId() != null) menuMap.put(menu.getId(), menu);
Menu parentMenu = menuMap.get(menu.getParentId());
if (parentMenu != null) {
parentMenu.addChild(menu);
}
}

this.id = rootMenu.getId();
this.parentId = rootMenu.getParentId();
this.title = rootMenu.getTitle();
this.url = rootMenu.getUrl();
this.depth = rootMenu.getDepth();
this.children = rootMenu.getChildren();
}
}

위 생성 알고리즘에서 중요한 점은 adjacentMenuList가 정렬이 되어있어야한다는 것이다.
부모메뉴가 먼저 있어야 제대로 생성된다.

트리메뉴는 생성시 메모리를 많이 쓰며, 메뉴는 자주 로딩되기때문에 캐시를 사용하는 것이 좋겠다.

끝~~ ^__^