Spring

스프링부트에서 옵션을 구현하는 방법

기억용블로그 2022. 6. 3. 10:55
728x90

본인이 옵션을 구현한 방식은 사용자가 직접 옵션을 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 정도뿐이라 아마 변수명을 바꿨어도 됐을 것같긴 하다.