스프링부트에서 옵션을 구현하는 방법
본인이 옵션을 구현한 방식은 사용자가 직접 옵션을 key-value 형태로 저장 할 수 있고 그 key-value 형태에 따라 서버에 적절히 저장되며 이후에 HTML에서도 dynamic하게 출력될 수 있게 하였다.
프론트에서 서버로
유저가 직접 옵션과 세부 옵션을 추가해서 선택할 수 있게 옵션을 입력하는 input과 그 input을 보여주는 컨테이너를 만들었다.
<div id="optionAdd" class="d-none">
<div class="input-group mb-3">
<input type="text" id="option1" placeholder="옵션" class="form-control">
<input type="text" id="option2" placeholder="세부 옵션" class="form-control">
<button type="button" id="optionAddButton" class="input-group-text">옵션 추가</button>
</div>
<div class="card">
<div class="card-body">
<div id="optionBody" class="position-relative">
</div>
</div>
</div>
</div>
사용자가 옵션을 넣고 입력하면 입력값을 적절하게 가공해야한다.
일단 특수문자가 들어올 수 없게 특수문자를 처리해주고 trim해준다.
이후에 "옵션-세부옵션 (삭제버튼)"의 형태로 사용자에게 보여주기 위해 HTML을 적절히 조합해서 사용자가 입력한 옵션의 형태를 볼 수 있게 하였다.
이때 약간 야매스러운 방법을 사용했는데 옵션을 삭제할 때 해당 옵션만 삭제하고싶은데 다른게 삭제되는 문제가 생겨서
사용자가 입력할 때마다 index를 순차적으로 증가시키면서 버튼과 옵션 body에 똑같은 id를 주었다.
그래서 this.id를 하면 옵션의 body까지 선택되어서 잘 삭제가 되긴 하였는데 id가 중복되었으므로 최선의 방법은 아니라 생각한다.
본인은 이때 "option1" + "-" + "option2"처럼 String으로 가공해서 올리는 방법을 선택했다.
function regExp(str) {
var reg = /[\{\}\[\]\/?.,;:|\)*~`!^\-_+<>@\#$%&\\\=\(\'\"]/gi
if (reg.test(str)) {
return str.replace(reg, "");
} else {
return str;
}
}
let optionArray = new Array();
let index = 0;
document.getElementById('optionAddButton').addEventListener('click', () => {
let option1 = document.getElementById('option1').value.trim();
let option2 = document.getElementById('option2').value.trim();
option1 = regExp(option1);
option2 = regExp(option2);
if (option1 == '' || option2 == '') {
alert('옵션을 적어주세요');
return false;
}
if (option1.length > 10 || option2.length > 10) {
alert('10글자 이내로 적어주세요');
return false;
}
let span = document.createElement('span');
let optionCombined = option1 + '-' + option2;
let text = document.createTextNode(optionCombined);
let button = document.createElement('button');
let i = document.createElement('i');
i.setAttribute('class', 'fas fa-times');
button.type = 'button';
button.setAttribute('class', 'card-link btn btn-outline-danger btn-sm');
button.onclick = function () {
document.getElementById(this.id).remove();
}
span.setAttribute('id', 'span' + index);
button.setAttribute('id', 'span' + index);
index += 1;
button.appendChild(i);
span.appendChild(text);
span.appendChild(button);
document.getElementById('option2').value = '';
if (optionArray.includes(optionCombined)) {
alert('중복값은 들어올 수 없습니다');
return false;
} else {
optionArray.push(optionCombined);
document.getElementById('optionBody').appendChild(span);
document.getElementById('optionArray').value = optionArray;
}
})
<input type="hidden" name="optionArray" id="optionArray">
그리고 String 배열의 형태를 input에 hidden으로 두어 form에 넣어 올리면 된다.
API로 하면 좀 더 수월할 것이다.
백엔드
이제 String을 받는 DTO를 만들어 List<String>형태로 받는다.
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class WriteItemRequest {
@NotBlank
private String name;
@NotBlank
private String description;
private List<String> optionArray;
이때 파싱을 따로 해야하므로 본인은 Controller에서 로직을 진행하기보다 Service에 통째로 넘겨서 validation하는 식으로 진행하였다.
프론트에서 validation을 하더라도 잘못된 값을 보내는 유저가 있을 수 있으므로
세션 기반 검증이나 다른 값들과는 다르게 String은 똑같은 검증과정을 한 번 더 해야한다고 생각해서
똑같이 검증을 한번 더 진행해주었고 해당 옵션들을 파싱해서 Option Entity에 저장해주었다.
for (String optionCombined : writeItemRequest.getOptionArray()) {
int index = optionCombined.indexOf('-');
String option1 = optionCombined.substring(0, index).trim();
String option2 = optionCombined.substring(index + 1).trim();
if (option1.length() > 10 || option2.length() > 10 || option1.matches("^[\\{\\}\\[\\]\\/?.,;:|\\)*~`!^\\-_+<>@\\#$%&\\\\\\=\\(\\'\\\"]$") || option2.matches("^[\\{\\}\\[\\]\\/?.,;:|\\)*~`!^\\-_+<>@\\#$%&\\\\\\=\\(\\'\\\"]$")) {
throw new NotValidException("잘못된 값입니다");
}
ItemOption itemOption = ItemOption.builder()
.option1(option1)
.option2(option2)
.item(item)
.build();
itemOptionRepository.save(itemOption);
}
서버에서 프론트로
이제 해당값을 꺼내서 잘 담아 보내기만 하면 되는데 본인이 구현하고자 하였던건 '옵션1-옵션2'는 중복될 수 없지만 옵션1은 중복될 수 있는 대분류-소분류의 형식이었다.
예를 들어 색깔-빨강, 색깔-파랑 식으로 말이다.
어떤 자료구조를 쓰면 가장 적절할까 생각하다가 Map<String, List<String>> 구조를 사용하기로 하였다.
String으로 다시 concat해서 보내는 것과 List 생성 비용과 다시 Map에 담는 비용 중에 어떤 것이 더 비쌀지는 추후 테스트를 해볼 예정.
해당 상품의 옵션 전체를 꺼내오고 option1은 중복될 수 있어 distinct 후
각 option1에 대한 option Entity를 List로 받아와 option2를 담은 list를 생성 후
map에 넣어 Map<String, List<String>>의 형태로 구성하였다.
Map<String,List<String>> optionMap = new HashMap<>();
final List<String> itemOptions = itemOptionRepository.findByItemId(item.getId()).stream().map(ItemOption::getOption1).distinct().collect(Collectors.toList());
for (String option1 : itemOptions) {
List<ItemOption> option2 = itemOptionRepository.findAllByOption1(option1);
final List<String> option2list = option2.stream().map(ItemOption::getOption2).collect(Collectors.toList());
optionMap.put(option1, option2list);
}
Response 객체는 아래와 같다.
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class ItemDetailResponse {
private Long id;
private String name;
private Map<String,List<String>> optionMap;
옵션 렌더링
여기서부터 상당히 복잡해진다.
애초에 Map을 한번도 타임리프에서 사용해본 적도 없고 자바스크립트로 사용해본 적도 없어 꽤 고전했다.
렌더링하는 방법이 이전과는 많이 달랐는데 th:each와 th:text를 한줄에 작성하는 것이 Map의 사용 방법이었다.
계속 이상하게 렌더링돼서 th:each를 div 태그에도 넣어보고 select 태그에도 넣어보고 했는데 같은줄에 넣을 생각은 못 했다.
여기서 핵심이 바로 ${XXX.key}이다.
Map을 사용할 때 .key와 .value는 keyword이다. 즉 정해진 용어이고 그냥 key, value를 쓰면 접근이 가능하다.
처음에 스택오버플로우에서 전부 key라고만 하길래 도대체 key를 어디서 정한건가 싶었는데 그냥 key 자체가 keyword였다.
<div>
<select id="options1" class="form-select form-select-sm" aria-label=".form-select-sm example">
<option selected>선택해주세요</option>
<option th:each="option : ${response.optionMap}" th:value="${option.key}" th:text="${option.key}">큰 범주</option>
</select>
<select id="options2" class="form-select form-select-sm" aria-label=".form-select-sm example">
<option selected>선택해주세요</option>
</select>
</div>
마지막으로 가장 고전했던 부분이다. 지나고 생각해보면 쉽고 간단한데 막상 할때는 꽤 복잡했다.
일단 타임리프를 통해 options1 즉 대분류에 해당하는 값은 미리 렌더링을 해두었고 그 값은 select의 option의 형태로 쭉 들어가있다.
그러면 이제 onchange를 통해 변화를 감지하고 그 변화에 맞춰 맞는 소분류를 밀어넣어주기만 하면 되는 것이다.
options1의 변화를 감지해서 그 값을 let options1에 넣어둔다.
그 다음 렌더링의 target인 options2를 받아온다.
이제 가장 핵심인데 map으로 넘긴 값을 그대로 JSON.parse를 하면 object의 형태가 된다.
그러면 Object.keys를 통해 key의 전체 집합, Object.values를 통해 value의 전체 집합을 얻을 수 있다.
이때 key-value의 순서는 같으므로 index를 통해 접근하기로 결정했다.
만약 유저가 고른 대분류가 keys의 어딘가에 걸린다면 그 key의 index를 통해 values의 index에 접근해서 value를 받아온다.
이때 value는 list이므로 또 한 번 더 돌며 파싱하여 options2 즉 소분류에 하나씩 하나씩 option 태그로 넣어주어야 한다.
마지막으로 options2에 넣은 값은 누적이 되므로 값이 바뀔 때마다 초기화 될 수 있게
options2.options.length = 0;로 초기화를 해준다.
이때 ".options.length"는 정해진 값이다. 그냥 복붙하면 된다는 뜻.
document.getElementById('options1').addEventListener('change', () => {
let options1 = document.getElementById('options1')[document.getElementById('options1')
.selectedIndex].text;
let options2 = document.getElementById('options2');
let object = JSON.parse('[[${response.optionMap}]]');
let keys = Object.keys(object);
let values = Object.values(object);
for (let i = 0; i < keys.length; i++) {
if (options1 == keys[i]) {
option2 = values[i];
}
}
options2.options.length = 0;
for (let i = 0; i < option2.length; i++) {
let option = document.createElement('option');
option.innerText = option2[i];
options2.append(option);
}
})
위를 끝으로 프론트에서 대분류-소분류를 직접 만들어 보내고 서버에서 그 값을 처리 후 저장,
서버에서 프론트로 Map을 통해 보내고 프론트에서 Map의 Key값에 따른 List를 파싱하는 방법을 공부 할 수 있었다.
찾아볼 때 자료가 많지 않았는데 이게 복잡하고 다른 더 쉬운 방법이 있는건지 궁금하다.
Map을 통해 값을 주고받는 방법을 제대로 공부할 수 있어서 즐거웠다.
검색했을 때 Map<String, List<String>>은 찾지 못 했어서 이 방법이 다른 사람들에게 도움이 되면 좋겠다.
아쉬웠던 점
변수명을 딱 정하지 못한게 너무 아쉬웠다.
계속 바보같이 option1, option2로 변수명을 지으려니 뭔가 틀린 것같이 기분이 좋지 않았는데
mainOption, subOption 형태로 지으려고 하니 나중에 확장성에서 문제가 생길 것같았다.
subOption에 더 하위 옵션이 들어가야 한다면? subSubOption이라 할 수도 없을거라 생각해서
차라리 한 option5 정도까지는 커버가 될 것같은 숫자로 변수명을 짓기로 생각했다.
근데 어떤 이커머스를 봐도 대부분 2 Depth 정도뿐이라 아마 변수명을 바꿨어도 됐을 것같긴 하다.