데이터를 필요한 만큼만 읽어오기.
태그와 지역정보를 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 기반 웹 어플리케이션 개발 > 4부 스터디' 카테고리의 다른 글
56. 스터디 설정 - 경로 및 이름 수정 (0) | 2020.04.28 |
---|---|
55. 스터디 설정 - 상태변경 (0) | 2020.04.24 |
53. 스터디 설정 - 배너 (0) | 2020.04.24 |
52. 스터디 설정 - 소개 수정 (0) | 2020.04.24 |
51. 스터디 폼 & 개설 & 멤버 조회 테스트 (0) | 2020.04.24 |