본문 바로가기
스프링과 JPA 기반 웹 어플리케이션 개발/4부 스터디

54. 스터디 설정 - 태그/지역

by Backchus 2020. 4. 24.

데이터를 필요한 만큼만 읽어오기.

태그와 지역정보를 Ajax로 수정할 때 스터디(+멤버, +매니저, +태그, +지역) 정보를 전부 가져올 필요가 있을까?

스프링 데이터 JPA 메소드 작명, @EntityGraph와 @NamedEntityGraph 활용하기

TagRepository 리팩토링

package me.weekbelt.studyolle.tag;

// ......

@Transactional(readOnly = true)
public interface TagRepository extends JpaRepository<Tag, Long> {

    Tag findByTitle(String title);
}

 

SettingController에서 removeTag 리팩토링

package me.weekbelt.studyolle.settings;

@RequiredArgsConstructor
@Controller
@RequestMapping("/settings")
public class SettingsController {

    // .......

    @ResponseBody
    @PostMapping(TAGS + "/remove")
    public ResponseEntity<?> removeTag(@CurrentAccount Account account,
                                       @RequestBody TagForm tagForm) {
        String title = tagForm.getTagTitle();
        Tag tag = tagRepository.findByTitle(title); // 수정
        if (tag == null){
            return ResponseEntity.badRequest().build();
        }

        accountService.removeTag(account, tag);
        return ResponseEntity.ok().build();
    }

    // .......
}

 

SettingsControllerTest 수정

package me.weekbelt.studyolle.settings;

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
class SettingsControllerTest {

    // ......

    @WithAccount("joohyuk")
    @DisplayName("계정에 태그 추가")
    @Test
    public void addTag() throws Exception {
        TagForm tagForm = new TagForm();
        tagForm.setTagTitle("newTag");

        mockMvc.perform(post("/settings/tags/add")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(tagForm))
                .with(csrf()))
                .andExpect(status().isOk());

        Tag newTag = tagRepository.findByTitle("newTag");   // 수정
        assertThat(newTag).isNotNull();
        Account joohyuk = accountRepository.findByNickname("joohyuk");
        // 만약 @Transactional이 없다면 account는 detached 상태
        assertThat(joohyuk.getTags().contains(newTag))
                .isTrue();
    }

    // .....
}

 

스터디 주제 관련 태그 처리

태그를 추가하는 폼을 보여주는 요청을 받는 핸들러 작성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@RequestMapping("/study/{path}/settings")
@Controller
public class StudySettingsController {

    // ......
    
    private final TagRepository tagRepository;
    private final ObjectMapper objectMapper;

    // ......
    
    @GetMapping("/tags")
    public String studyTagsForm(@CurrentAccount Account account, @PathVariable String path,
                                Model model) throws JsonProcessingException {
        Study study = studyService.getStudyToUpdate(account, path);
        model.addAttribute(account);
        model.addAttribute(study);
        model.addAttribute("tags", study.getTags().stream()
                .map(Tag::getTitle).collect(Collectors.toList()));
        List<String> allTagTitles = tagRepository.findAll().stream()
                .map(Tag::getTitle).collect(Collectors.toList());
        model.addAttribute("whitelist", objectMapper.writeValueAsString(allTagTitles));
        return "study/settings/tags";
    }

}

 

스터디 주제 태그를 추가하는 페이지 작성

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments.html::head"></head>
<body class="bg-light">
<div th:replace="fragments.html::main-nav"></div>
<div th:replace="fragments.html::study-banner"></div>
<div class="container">
    <div th:replace="fragments.html::study-info"></div>
    <div th:replace="fragments.html::study-menu(studyMenu='settings')"></div>
    <div class="row mt-3 justify-content-center">
        <div class="col-2">
            <div th:replace="fragments.html::study-settings-menu(currentMenu='tags')"></div>
        </div>
        <div class="col-8">
            <div class="row">
                <h2 class="col-sm-12">스터디 주제</h2>
            </div>
            <div class="row">
                <div class="col-sm-12">
                    <div class="alert alert-info" role="alert">
                        스터디에서 주로 다루는 주제를 태그로 등록하세요. 태그를 입력하고 콤마(,) 또는 엔터를 입력하세요.
                    </div>
                    <div id="whitelist" th:text="${whitelist}" hidden></div>
                    <input type="text" id="tags" name="tags" th:value="${#strings.listJoin(tags, ',')}"
                           class="tagify-outside" aria-describedby="tagHelp">
                </div>
            </div>
        </div>
    </div>
    <!-- footer -->
    <div th:replace="fragments.html::footer"></div>
</div>
<script th:replace="fragments.html :: ajax-csrf-header"></script>
<script th:replace="fragments.html :: update-tags(baseUrl='/study/' + ${study.path} + '/settings/tags')"></script>
</body>
</html>

 

아래 스크립트를 참조하는 fragment추가(fragments.html)

<div th:fragment="update-tags (baseUrl)">
    <script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
    <script type="application/javascript" th:inline="javascript">
        $(function() {
            function tagRequest(url, tagTitle) {
                $.ajax({
                    dataType: "json",
                    autocomplete: {
                        enabled: true,
                        rightKey: true,
                    },
                    contentType: "application/json; charset=utf-8",
                    method: "POST",
                    url: "[(${baseUrl})]" + url,
                    data: JSON.stringify({'tagTitle': tagTitle})
                }).done(function (data, status) {
                    console.log("${data} and status is ${status}");
                });
            }

            function onAdd(e) {
                tagRequest("/add", e.detail.data.value);
            }

            function onRemove(e) {
                tagRequest("/remove", e.detail.data.value);
            }

            var tagInput = document.querySelector("#tags");
            var tagify = new Tagify(tagInput, {
                pattern: /^.{0,20}$/,
                whitelist: JSON.parse(document.querySelector("#whitelist").textContent),
                dropdown : {
                    enabled: 1, // suggest tags after a single character input
                } // map tags
            });
            tagify.on("add", onAdd);
            tagify.on("remove", onRemove);
            // add a class to Tagify's input element
            tagify.DOM.input.classList.add('form-control');
            // re-place Tagify's input element outside of the  element (tagify.DOM.scope), just before it
            tagify.DOM.scope.parentNode.insertBefore(tagify.DOM.input, tagify.DOM.scope);
        });
    </script>
</div>

 

관심 주제 태그추가/삭제 처리를 받는 핸들러 작성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@RequestMapping("/study/{path}/settings")
@Controller
public class StudySettingsController {

    // ........
    
    private final TagService tagService;

    // ........
    
    @PostMapping("/tags/add")
    @ResponseBody
    public ResponseEntity<?> addTag(@CurrentAccount Account account, @PathVariable String path,
                                 @RequestBody TagForm tagForm){
        Study study = studyService.getStudyToUpdateTag(account, path);
        Tag tag = tagService.findOrCreateNew(tagForm.getTagTitle());
        studyService.addTag(study, tag);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/tags/remove")
    @ResponseBody
    public ResponseEntity<?> removeTag(@CurrentAccount Account account, @PathVariable String path,
                                    @RequestBody TagForm tagForm){
        Study study = studyService.getStudyToUpdateTag(account, path);
        Tag tag = tagRepository.findByTitle(tagForm.getTagTitle());
        if (tag == null) {
            return ResponseEntity.badRequest().build();
        }
        
        studyService.removeTag(study, tag);
        return ResponseEntity.ok().build();
    }

}

 

TagService에서 tag를 찾는 findOrCreateNew메소드 생성

package me.weekbelt.studyolle.tag;

@RequiredArgsConstructor
@Transactional
@Service
public class TagService {

    private final TagRepository tagRepository;

    public Tag findOrCreateNew(String tagTitle) {
        Tag tag = tagRepository.findByTitle(tagTitle);
        if (tag == null) {
            tag = tagRepository.save(Tag.builder().title(tagTitle).build());
        }
        return tag;
    }
}

 

SettingsController에서 tag를 추가하는 핸들러를 리팩토링

package me.weekbelt.studyolle.settings;

@RequiredArgsConstructor
@Controller
@RequestMapping("/settings")
public class SettingsController {

    // .........

    private final TagService tagService;

    // ........

    @ResponseBody
    @PostMapping(TAGS + "/add")
    public ResponseEntity<?> addTag(@CurrentAccount Account account,
                                    @RequestBody TagForm tagForm) {
        Tag tag = tagService.findOrCreateNew(tagForm.getTagTitle());
        accountService.addTag(account, tag);
        return ResponseEntity.ok().build();
    }

    // ........
    
}

 

태그 추가/삭제할 태그를 가져오게하는 처리를 추가하는 메소드 작성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@Transactional
@Service
public class StudyService {

    // ......

    public Study getStudyToUpdateTag(Account account, String path) {
        Study study = studyRepository.findAccountWithTagsByPath(path);
        checkIfExistingStudy(path, study);
        checkIfManager(account, study);
        return study;
    }

    private void checkIfExistingStudy(String path, Study study) {
        if (study == null){
            throw new IllegalArgumentException(path + "에 해당하는 스터디가 없습니다.");
        }
    }

    private void checkIfManager(Account account, Study study) {
        if (!study.isManagedBy(account)){
            throw new AccessDeniedException("해당 기능을 사용할 수 없습니다.");
        }
    }

    public void addTag(Study study, Tag tag) {
        study.getTags().add(tag);
    }

    public void removeTag(Study study, Tag tag) {
        study.getTags().remove(tag);
    }
}

 

StudyRepository에 findAccountWithTagByPath메소드 추가

package me.weekbelt.studyolle.study;

@Transactional(readOnly = true)
public interface StudyRepository extends JpaRepository<Study, Long> {
    
    // ......

    Study findAccountWithTagsByPath(String path);
}

 

활동 지역 태그 관련 처리

활동 지역 페이지요청을 처리하는 핸들러 작성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@RequestMapping("/study/{path}/settings")
@Controller
public class StudySettingsController {

    // ........
    
    private final ZoneRepository zoneRepository;

    // ........

    @GetMapping("/zones")
    public String studyZonesForm(@CurrentAccount Account account, @PathVariable String path,
                                 Model model) throws JsonProcessingException {
        Study study = studyService.getStudyToUpdate(account, path);
        model.addAttribute(account);
        model.addAttribute(study);
        model.addAttribute("zones", study.getZones().stream()
                .map(Zone::toString).collect(Collectors.toList()));
        List<String> allZones = zoneRepository.findAll().stream().map(Zone::toString).collect(Collectors.toList());
        model.addAttribute("whitelist", objectMapper.writeValueAsString(allZones));
        return "study/settings/zones";
    }

}

 

활동 지역 페이지 작성(study/settings/zones.html)

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments.html::head"></head>
<body class="bg-light">
<div th:replace="fragments.html::main-nav"></div>
<div th:replace="fragments.html::study-banner"></div>
<div class="container">
    <div th:replace="fragments.html::study-info"></div>
    <div th:replace="fragments.html::study-menu(studyMenu='settings')"></div>
    <div class="row mt-3 justify-content-center">
        <div class="col-2">
            <div th:replace="fragments.html::study-settings-menu(currentMenu='zones')"></div>
        </div>
        <div class="col-8">
            <div class="row">
                <h2 class="col-sm-12">주요 활동 지역</h2>
            </div>
            <div class="row">
                <div class="col-sm-12">
                    <div class="alert alert-info" role="alert">
                        주로 스터디를 하는 지역을 등록하세요.
                        시스템에 등록된 지역만 선택할 수 있습니다.
                    </div>
                    <div id="whitelist" th:text="${whitelist}" hidden></div>
                    <input type="text" id="zones" name="tags" th:value="${#strings.listJoin(zones, ',')}"
                           class="tagify-outside" aria-describedby="tagHelp">
                </div>
            </div>
        </div>
    </div>
    <!-- footer -->
    <div th:replace="fragments.html::footer"></div>
</div>
<script th:replace="fragments.html :: ajax-csrf-header"></script>
<script th:replace="fragments.html :: update-zones(baseUrl='/study/' + ${study.path} + '/settings/zones')"></script>
</body>
</html>

 

update-zones frament추가

<div th:fragment="update-zones (baseUrl)">
    <script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
    <script type="application/javascript">
        $(function () {
            function tagRequest(url, zoneName) {
                $.ajax({
                    dataType: "json",
                    autocomplete: {
                        enabled: true,
                        rightKey: true,
                    },
                    contentType: "application/json; charset=utf-8",
                    method: "POST",
                    url: "[(${baseUrl})]" + url,
                    data: JSON.stringify({'zoneName': zoneName})
                }).done(function (data, status) {
                    console.log("${data} and status is ${status}");
                });
            }

            function onAdd(e) {
                tagRequest("/add", e.detail.data.value);
            }

            function onRemove(e) {
                tagRequest("/remove", e.detail.data.value);
            }

            var tagInput = document.querySelector("#zones");

            var tagify = new Tagify(tagInput, {
                enforceWhitelist: true,
                whitelist: JSON.parse(document.querySelector("#whitelist").textContent),
                dropdown : {
                    enabled: 1, // suggest tags after a single character input
                } // map tags
            });

            tagify.on("add", onAdd);
            tagify.on("remove", onRemove);

            // add a class to Tagify's input element
            tagify.DOM.input.classList.add('form-control');
            // re-place Tagify's input element outside of the  element (tagify.DOM.scope), just before it
            tagify.DOM.scope.parentNode.insertBefore(tagify.DOM.input, tagify.DOM.scope);
        });
    </script>
</div>

 

활동지역 추가/삭제 처리하는 핸들러 생성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@RequestMapping("/study/{path}/settings")
@Controller
public class StudySettingsController {

    // ......
    
    private final ZoneRepository zoneRepository;

    // ......

    @PostMapping("/zones/add")
    @ResponseBody
    public ResponseEntity<?> addZone(@CurrentAccount Account account, @PathVariable String path,
                                     @RequestBody ZoneForm zoneForm) {
        Study study = studyService.getStudyToUpdateZone(account, path);
        Zone zone = zoneRepository.findByCityAndProvince(zoneForm.getCityName(), zoneForm.getProvinceName());
        if(zone == null) {
            return ResponseEntity.badRequest().build();
        }

        studyService.addZone(study, zone);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/zones/remove")
    @ResponseBody
    public ResponseEntity<?> removeZone(@CurrentAccount Account account, @PathVariable String path,
                                     @RequestBody ZoneForm zoneForm) {
        Study study = studyService.getStudyToUpdateZone(account, path);
        Zone zone = zoneRepository.findByCityAndProvince(zoneForm.getCityName(), zoneForm.getProvinceName());
        if(zone == null) {
            return ResponseEntity.badRequest().build();
        }

        studyService.removeZone(study, zone);
        return ResponseEntity.ok().build();
    }
}

 

활동지역 추가/삭제를 처리하는 addZone, removeZone메소드 생성

package me.weekbelt.studyolle.study;

@RequiredArgsConstructor
@Transactional
@Service
public class StudyService {

    // .....

    public Study getStudyToUpdateZone(Account account, String path) {
        Study study = studyRepository.findStudyWithZonesByPath(path);
        checkIfExistingStudy(path, study);
        checkIfManager(account, study);
        return study;
    }

    public void addZone(Study study, Zone zone) {
        study.getZones().add(zone);
    }

    public void removeZone(Study study, Zone zone) {
        study.getZones().remove(zone);
    }

    // ......
}

 

관심 주제와 활동 지역 추가/삭제를 처리할때 스터디 설정 페이지 처럼 모든 엔티티를 조인해서 데이터를 가져오게 되는데 너무 비효율 적이다. 따라서 관심주제 추가/삭제 처리는 권한이 있는지 체크후 처리가 가능하기만 하면 되고 활동 지역 또한 마찬가지이므로 NamedQuery를 설정해주어서 해당 엔티티만 조인해서 가져오도록 한다.

package me.weekbelt.studyolle.domain;

// .....

@NamedEntityGraph(name = "Study.withTagsAndManagers", attributeNodes = {
        @NamedAttributeNode("tags"),
        @NamedAttributeNode("managers")
})
@NamedEntityGraph(name = "Study.withZonesAndManagers", attributeNodes = {
        @NamedAttributeNode("zones"),
        @NamedAttributeNode("managers")
})

// .....

public class Study {

    // ........
}
package me.weekbelt.studyolle.study;

@Transactional(readOnly = true)
public interface StudyRepository extends JpaRepository<Study, Long> {
    
    // .....
    
    @EntityGraph(value = "Study.withTagsAndManagers", type = EntityGraph.EntityGraphType.FETCH)
    Study findStudyWithTagsByPath(String path);

    @EntityGraph(value = "Study.withZonesAndManagers", type = EntityGraph.EntityGraphType.FETCH)
    Study findStudyWithZonesByPath(String path);
}

 

 

참고: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-JPA-%EC%9B%B9%EC%95%B1#

 

스프링과 JPA 기반 웹 애플리케이션 개발 - 인프런

이 강좌에서 여러분은 실제로 운영 중인 서비스를 스프링, JPA 그리고 타임리프를 비롯한 여러 자바 기반의 여러 오픈 소스 기술을 사용하여 웹 애플리케이션을 개발하는 과정을 학습할 수 있습니다. 이 강좌를 충분히 학습한다면 여러분 만의 웹 서비스를 만들거나 취직에 도움이 될만한 포트폴리오를 만들 수 있을 겁니다. 활용 웹 개발 프레임워크 및 라이브러리 Java Spring Spring Boot Spring Data JPA Thymeleaf 온라인 강의 스

www.inflearn.com