스프링과 JPA 기반 웹 애플리케이션 개발 #47 지역 정보 추가 삭제 및 테스트

jakeseo_me·2021년 6월 10일
0

스프링과 JPA 기반 웹 애플리케이션 개발 #47 지역 정보 추가 삭제 및 테스트

해당 내용은 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발의 강의 내용을 바탕으로 작성된 내용입니다.

강의를 학습하며 요약한 내용을 출처를 표기하고 블로깅 또는 문서로 공개하는 것을 허용합니다 라는 원칙 하에 요약 내용을 공개합니다. 출처는 위에 언급되어있듯, 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발입니다.

제가 학습한 소스코드는 https://github.com/n00nietzsche/jakestudy_webapp 에 지속적으로 업로드 됩니다. 매 커밋 메세지에 강의의 어디 부분까지 진행됐는지 기록해놓겠습니다.


AccountZone

@Entity
@EqualsAndHashCode(of = "id")
@Getter @Setter
public class AccountZone {
    @Id @GeneratedValue
    private Long id;

    // @ManyToOne 을 쓰는 곳은 외래키를 갖는 주인이 되어야 함
    // 항상 N의 입장인 테이블이 외래키를 갖는다.
    // @ManyToOne 에는 @JoinColumn 이 필요
    @ManyToOne
    @JoinColumn(name = "account_id")
    private Account account;

    @ManyToOne
    @JoinColumn(name = "zone_id")
    private Zone zone;
}

AccountZone을 연결하는 AccountZone을 추가했다. @ManyToMany 관계를 @OneToMany 관계 1개와 @ManyToOne의 관계 2개로 풀어낸 것이다. (현재는 Account 기준에서 AccountZone을 조회하는 것만 관심사이기 때문에 @OneToMany은 1개)

Account

...
    @OneToMany(mappedBy = "account")
    private List<AccountZone> accountZones = new ArrayList<>();
...

Tag

Tag에는 딱히 추가한 거 없다. 나중에 Tag를 기준으로 AccountTag를 조회하게 된다면, @OneToMany 애노테이션 하나 추가 예정

Zone

...
    @Override
    public String toString() {
        return city + '/' + localNameOfCity + '/' + province;
    }
...

toString() 메소드를 추가해주었다. 사용자에게 보일 최종 형태의 문자열이며, ZoneForm에 들어왔을 때, split을 이용해 해석될 문자열이다.

ZoneForm

@Data
@NoArgsConstructor
public class ZoneForm {

    @Pattern(regexp = "[^/]+/[^/]+/[^/]+")
    private String zone;
    private String city;
    private String localNameOfCity;
    private String province;

    public void setZone(String zone) {
        this.zone = zone;
        String[] split = zone.split("/");

        if(split.length == 3) {
            this.city = split[0];
            this.localNameOfCity = split[1];
            this.province = split[2];
        }
    }
}

zone에 대한 문자열이 들어왔을 때, 자동으로 스플릿을 수행한다. 그리고 몇가지 Validation도 있다. abc/abc/abc와 같은 형태의 문자열이 들어왔을 때만 split을 하며, splitlength가 3이어야만 해당 split에 대한 결과를 넣음으로써 에러를 방지했다.

ZoneFormValidator

@Component
@RequiredArgsConstructor
public class ZoneFormValidator implements Validator {

    private final ZoneRepository zoneRepository;

    @Override
    public boolean supports(Class<?> clazz) {
        return ZoneForm.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ZoneForm zoneForm = (ZoneForm) target;

        boolean isZone = zoneRepository.existsByCityAndLocalNameOfCityAndProvince(
                zoneForm.getCity(),
                zoneForm.getLocalNameOfCity(),
                zoneForm.getProvince()
        );

        if(!isZone) {
            errors.rejectValue("zone", "zone.wrong");
        }
    }
}

이건 혹여나 /로 3개의 길이로 쪼개지는 잘못된 입력값이 들어왔을 때를 대비하여 만들어두었다. 리포지토리를 통해 존재하는 Zone인지 확인한다.

SettingsController

initBinder 등록

    @InitBinder("zoneForm")
    public void initBinderZoneForm(WebDataBinder webDataBinder) {
        webDataBinder.addValidators(zoneFormValidator);
    }

조회, 추가, 삭제 기타 로직

    @GetMapping(ZONES_MAPPING_PATH)
    public String updateZonesForm(@LoginAccount Account loginAccount, Model model) throws JsonProcessingException {
        model.addAttribute(loginAccount);

        model.addAttribute("zones"
                , loginAccount.getAccountZones()
                        .stream()
                        .map(AccountZone::getZone)
                        .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 ZONES_MAPPING_PATH;
    }

    @PostMapping(ADD_ZONES_MAPPING_PATH)
    @ResponseBody
    public ResponseEntity addZone(@LoginAccount Account loginAccount, @Valid @RequestBody ZoneForm zoneForm) {
        Zone zone = zoneRepository.findByCityAndLocalNameOfCityAndProvince(
                zoneForm.getCity(),
                zoneForm.getLocalNameOfCity(),
                zoneForm.getProvince()
        );

        if(zone == null) {
            return ResponseEntity.badRequest().build();
        }

        accountService.addAccountZone(loginAccount, zone);
        return ResponseEntity.ok().build();
    }

    /**
     * 사실 태그를 실 서비스에서 사용할 때는
     * 관리자모드로 사용하지 않거나 참조되지 않는 태그들을 정리할 수 있는 기능도 생각해봐야 함
     */
    @PostMapping(REMOVE_ZONES_MAPPING_PATH)
    @ResponseBody
    public ResponseEntity removeZone(@LoginAccount Account loginAccount, @Valid @RequestBody ZoneForm zoneForm) {
        Zone zone = zoneRepository.findByCityAndLocalNameOfCityAndProvince(
                zoneForm.getCity(),
                zoneForm.getLocalNameOfCity(),
                zoneForm.getProvince()
        );

        if(zone == null){
            return ResponseEntity.badRequest().build();
        }

        accountService.removeAccountZone(loginAccount, zone);
        return ResponseEntity.ok().build();
    }

기존에 관심 태그 추가하던 것과 전혀 다를 게 없다.

AccountService

addAccountTag

    public void addAccountZone(Account account, Zone zone) {
        AccountZone accountZone = new AccountZone();
        accountZone.setAccount(account);
        accountZone.setZone(zone);
        accountZoneRepository.save(accountZone);

        if(!account.getAccountZones().contains(accountZone)) {
            account.getAccountZones().add(accountZone);
        }

    }

    public void removeAccountZone(Account account, Zone zone) {
        AccountZone accountZone = accountZoneRepository.findByAccountAndZone(account, zone);
        accountZoneRepository.delete(accountZone);

        if(account.getAccountZones().contains(accountZone)) {
            account.getAccountZones().remove(accountZone);
        }

이번에는 Account 객체 내부에 있는 @OneToMany 관계를 가진 필드에도 값을 잘 넣어주었다.

또한, Spring Data Jpa에서 제공하는 findByAAndB 등의 기능을 잘 이용했다.

딱히 특별한 건 없다.

zones.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
  <title>관심 지역 수정</title>
  <th:block th:replace="fragments :: headLibraryInjection"></th:block>
</head>

<body class="bg-light">

<th:block th:replace="fragments :: main-nav"></th:block>
<div th:replace="fragments :: email-verify-alarm"></div>

<div class="container">
  <!-- row의 기본은 col-12만큼의 크기를 갖는다.-->
  <div class="row mt-5 justify-content-center">
    <div class="col-2">
      <div th:replace="fragments :: settings-menu(currentMenu='zones')"></div>
    </div>
    <div class="col-8">
      <div th:if="${message}" class="alert alert-info alert-dismissible fade show mt-3" role="alert">
        <span th:text="${message}">메시지</span>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
          <span aria-hidden="true">x</span>
        </button>
      </div>
      <div class="row">
        <h2 class="col-12">관심있는 지역</h2>
      </div>
      <div class="row">
        <div class="col-12">
          <div class="alert alert-info" role="alert">
            관심있는 지역을 입력해주세요. 해당 지역의 스터디가 생기면 알림을 받을 수 있습니다. 태그를 입력하고 콤마(,)
            또는 엔터를 입력하세요.
          </div>
          <div id="whitelist" th:text="${whitelist}" hidden></div>
          <!-- List 형태로 들어온 데이터를 "element1, element2, ..." 으로 변경함 -->
          <input id="tags" type="text" name="tags" th:value="${#strings.listJoin(zones, ',')}"
                 class="tagify-outside" aria-describedby="tagHelp">
        </div>
      </div>
    </div>
  </div>
  <th:block th:replace="fragments :: footer"></th:block>
</div>

<script th:replace="fragments :: form-validation"></script>
<script th:replace="fragments :: ajax-with-csrf"></script>
<script type="application/javascript">
  $(function () {
    function tagRequest(url, zone) {
      $.ajax({
        dataType: "json",
        autoComplete: {
          enabled: true,
          rightKey: true
        },
        contentType: "application/json; charset=utf-8",
        method: "POST",
        // URL에는 ADD, REMOVE 등이 들어갈 것임
        url: "/settings/zones" + url,
        data: JSON.stringify({'zone': zone})
      }).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);
    }

    let tagInput = document.querySelector("#tags");

    let tagify = new Tagify(tagInput, {
      whitelist: JSON.parse($("#whitelist").text()),
      enforceWhitelist: true,
      dropdown: {
        enabled: 1 // 한글자 치면 추천 태그를 띄워줌
      } // 맵 태그들
    });

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

    // Tagify 의 input 박스에 클래스 추가
    tagify.DOM.input.classList.add('form-control');
    // Tagify의 input 요소를엘리먼트의 밖(tagify.DOM.scope)으로, 하기 바로 전에
    tagify.DOM.scope.parentNode.insertBefore(tagify.DOM.input, tagify.DOM.scope);
  });
</script>
</body>
</html>

기존의 tags.html과 상당부분 겹치는 내용이다. enforceWhitelist: true 옵션을 주어, 오직 whitelist 내부에 있는 것만 추가되도록 변경하였다.

SettingsControllerTest

    @DisplayName("관심지역 정보 추가 - 정상 케이스")
    @WithAccount(nickname = "jake")
    @Test
    public void addZoneCorrect() throws Exception{

        String zoneCorrectInput = "Andong/안동시/North Gyeongsang";
        ZoneForm zoneForm = new ZoneForm();
        zoneForm.setZone(zoneCorrectInput);

        mockMvc.perform(post("/" + SettingsController.ADD_ZONES_MAPPING_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(zoneForm))
                .with(csrf()))
                .andExpect(status().isOk());

        Zone zone = zoneRepository.findByCityAndLocalNameOfCityAndProvince(
                zoneForm.getCity(),
                zoneForm.getLocalNameOfCity(),
                zoneForm.getProvince()
        );

        // 인풋으로 넣었던 Zone 이 잘 등록되었는지 확인
        assertTrue(zone.toString().equals(zoneCorrectInput));

        Account account = accountRepository.findByNickname("jake");
        AccountZone accountAndZone = accountZoneRepository.findByAccountAndZone(account, zone);

        // account 정보에 등록한 accountZone 을 리스트로 잘 갖고 있는지 확인
        List<AccountZone> accountZones = account.getAccountZones();
        assertTrue(accountZones.contains(accountAndZone));

        // 마지막으로 출력해보기
        for (AccountZone accountZone : accountZones) {
            System.out.println("accountZone = " + accountZone.getZone().toString());
        }

    }

    @DisplayName("존 추가 후 삭제 - 정상 케이스")
    @WithAccount(nickname = "jake")
    @Test
    public void removeZoneCorrect() throws Exception{

        String zoneCorrectInput = "Andong/안동시/North Gyeongsang";
        ZoneForm zoneForm = new ZoneForm();
        zoneForm.setZone(zoneCorrectInput);

        mockMvc.perform(post("/" + SettingsController.ADD_ZONES_MAPPING_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(zoneForm))
                .with(csrf()))
                .andExpect(status().isOk());

        mockMvc.perform(post("/" + SettingsController.REMOVE_ZONES_MAPPING_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(zoneForm))
                .with(csrf()))
                .andExpect(status().isOk());

        Account account = accountRepository.findByNickname("jake");
        List<AccountZone> accountZones = account.getAccountZones();
        assertTrue(accountZones.stream().count() == 0);


    }

    @DisplayName("존 삭제 혹은 추가 - 입력값 비정상 케이스")
    @WithAccount(nickname = "jake")
    @Test
    public void removeZoneWrong() throws Exception{

        String zoneWrongInput = "asdfasdfasdf";
        ZoneForm zoneForm = new ZoneForm();
        zoneForm.setZone(zoneWrongInput);

        mockMvc.perform(post("/" + SettingsController.REMOVE_ZONES_MAPPING_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(zoneWrongInput))
                .with(csrf()))
                .andExpect(status().is4xxClientError());

        mockMvc.perform(post("/" + SettingsController.ADD_ZONES_MAPPING_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(zoneWrongInput))
                .with(csrf()))
                .andExpect(status().is4xxClientError());
    }

이전에 tags 테스트하던 것과 비슷하게 작성했다.

profile
대전에 있는 (주) 아이와즈에서 풀스택 웹개발자로 일하고 있는 서진규입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: spring, node.js, nest.js, JPA, type orm 에 관심이 있습니다.

관심 있을 만한 포스트

0개의 댓글