스프링과 JPA 기반 웹 애플리케이션 개발 #36 닉네임 수정 (+ 스프링 MVC의 폼처리 패턴)

Jake Seo·2021년 6월 7일
0

스프링과 JPA 기반 웹 애플리케이션 개발 #36 닉네임 수정

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

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

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


1. Form Backing Object 만들기

@Data
public class NicknameForm {
    @NotBlank
    @Length(min = 3, max = 20)
    @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9_-]{3,20}$")
    private String nickname;
}

Form Backing Object는 나중에 스프링 MVC의 @ModelAttribute로 활용되어 데이터의 기본적인 유효성 검증 및 뷰와의 연동에 도움을 준다. 여기서 @NotBlank, @Length, @Pattern등의 검증을 할 수 있다. 스프링 MVC의 아규먼트로서 이용할 때는 반드시 앞에 @Valid 애노테이션을 붙여주어야 한다.

2. Validator 작성하기

@Component
@RequiredArgsConstructor
public class NicknameFormValidator implements Validator {

    private final AccountRepository accountRepository;

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

    @Override
    public void validate(Object target, Errors errors) {
        NicknameForm nicknameForm = (NicknameForm) target;
        if(accountRepository.existsByNickname(nicknameForm.getNickname())){
            errors.rejectValue("nickname", "invalid.nickname", new Object[]{nicknameForm.getNickname()}, "이미 사용중인 닉네임입니다.");
        }
    }
}

Validator는 이전에 Form Backing Object와 같은 일반적인 클래스에서 할 수 없고, 빈에서 할 수 있는 검증들을 해주면 된다. supports()는 해당 클래스에 .isAssianbleFrom() 메소드를 넣어서 위와 같이 작성해주면 된다. Validator는 웹 데이터를 바인드할 때 검증해주는 단계이다.

빈으로 하는 검증을 하는 만큼 AccountRepository 빈을 이용하여 데이터 중복 검사를 해주었다. 중복된 닉네임이 있는 경우 닉네임 변경을 허락하지 않는다. 중복된 닉네임이 있는 경우 errors.rejectValue()와 같은 메소드로 반환을 해주게 되면, 해당 에러를 추후에 컨트롤러에서 Errors 객체로 받을 수 있다.

문서에서 필드명, 에러코드, 에러 인자, 기본 메세지등을 입력하라고 잘 설명이 되어있다.

3. 컨트롤러 작성

3.1. initBinder 작성

    @InitBinder("nicknameForm")
    public void initBinderNicknameForm(WebDataBinder webDataBinder) {
        webDataBinder.addValidators(nicknameFormValidator);
    }

애노테이트된 핸들로 메소드의 커멘드나 폼 오브젝트 인자를 바인딩할 때의 WebDataBinder 객체를 이용할 수 있다. WebDataBinder.addValidators() 메소드를 통해 우리가 만든 Validator를 넣어주면, Validator에 따른 검증이 들어간다.

3.2. GPRG 구성하기 및 비즈니스 로직 작성

    @GetMapping(ACCOUNT_MAPPING_PATH)
    public String updateAccountForm(@LoginAccount Account loginAccount, Model model) {
        model.addAttribute(loginAccount);
        model.addAttribute(new NicknameForm());
        return ACCOUNT_MAPPING_PATH;
    }

    @PostMapping(NICKNAME_MAPPING_PATH)
    public String updateNickname(@LoginAccount Account loginAccount, @Valid @ModelAttribute NicknameForm nicknameForm, Errors errors, Model model) {

        if(errors.hasErrors()) {
            model.addAttribute(loginAccount);
            return ACCOUNT_MAPPING_PATH;
        }
		
        redirectAttributes.addFlashAttribute("message", "닉네임이 변경되었습니다.");
        accountService.updateNickname(loginAccount, nicknameForm.getNickname());

        model.addAttribute(loginAccount);
        return "redirect:/" + ACCOUNT_MAPPING_PATH;
    }

GPRG 로직은 Get -> Post -> Redirect -> Get으로 이어지는 HTTP 요청을 줄여본 것이다. 스프링 MVC 컨트롤러에서 폼데이터를 처리하기 위한 순서는 먼저

  1. Get Mapping으로 입력 Form이 있는 html을 반환한다.
    1.1. 이 과정에서 Model을 이용하여 addAttribute(new Form())과 같은 형식으로 Form을 내려주어, th:object="${form}"에 사용될 수 있게 해준다.
  2. Post Mapping으로 입력된 데이터를 처리할 수 있는 컨트롤러를 만든다
    2.1. @Valid @ModelAttribute 애노테이션을 이용하여 검증해주고, 해당 데이터를 받아 처리해준다.
  3. 비즈니스 로직에 따른 데이터 처리를 Service 레이어에 위임하고 Redirect:/ 로 기존의 Form 페이지 혹은 다른 페이지로 이동한다.
    3.1. 이동하며 RedirectAttributes.addFlashAttribute()등을 이용하여 메세지를 전달할 수 있다.
  4. Redirect:/를 받고 마지막에 다른 페이지로 GET 메소드를 통해 이동하게 된다.

여기서 비즈니스 로직은 폼에 데이터를 뿌려주고, 폼을 이용해 데이터를 받고 닉네임을 업데이트하는 부분이 들어갔다. 닉네임을 업데이트하는 것과 같이 DB 데이터에 변화가 일어나는 부분은 반드시 서비스쪽으로 로직을 빼주자.

4. 서비스쪽 메소드 구현

    public void updateNickname(Account account, String nickname) {
        // 닉네임을 변경할 때는, Authentication 의 Username 이 변하는 케이스 이므로, 다시 재로그인을 해줌
        // NavBar 에서 쓰여서 Authentication 을 바꾸어주어야 뷰에 올바르게 반영됨
        account.setNickname(nickname);
    }

기존 강의에서는 닉네임을 업데이트하고, login()을 다시 수행해서 변경된 정보로 다시 로그인되도록 해준다. 왜냐하면 뷰단의 네비게이션에서 계속 SecurityContext 내부 Authentication에 있는 데이터를 갖다 쓰는데, 유저네임 부분은 업데이트 되지 않기 때문이다.

그런데 나는 애초에 다른 방식으로 설계를 구성해서, 다시 로그인하지 않는다 단, HTML에서 데이터를 빼올 때 조금 다른 방식으로 빼온다.

5. html 작성

5.1. settings/account.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='account')"></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-sm-12">닉네임 변경</h2>
      </div>
      <div class="row mt-3">
        <form class="needs-validation col-12" action="#" th:action="@{/settings/account/nickname}"
              th:object="${nicknameForm}" method="post" novalidate>

          <div class="form-group">
            <label for="newPassword">닉네임</label>
            <input id="newPassword" type="text" th:field="*{nickname}"
                   class="form-control" aria-describedby="nicknameHelp" required min="3" max="20">
            <small id="newPasswordHelp" class="form-text text-muted">
              공백없이 문자와 숫자로만 3자 이상 20자 이내로 입력하세요. 가입 후에 변경할 수 있습니다.
            </small>
            <small class="invalid-feedback">닉네임을 입력하세요.</small>
            <small class="form-text text-danger" th:if="${#fields.hasErrors('nickname')}"
                   th:errors="*{nickname}">
              닉네임 형식을 맞춰주세요.
            </small>
          </div>

          <div class="form-group">
            <button class="btn btn-primary btn-block" type="submit"
                    aria-describedby="submitHelp">수정하기
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
  <th:block th:replace="fragments :: footer"></th:block>
</div>

<script th:replace="fragments :: form-validation"></script>
</body>
</html>

여기서 일반 html의 서브밋과 다르게 중요한 건 th:object="${nicknameForm}"를 이용하여 폼을 매핑했다는 것이다. 매핑한 요소들은 th:field="*{nickname}"과 같은 방식 혹은 th:error="*{nickname}"과 같은 방식을 이용하여 서버로부터 내려오는 값을 받거나 서버로 값을 올릴 수 있다.

inputname 속성을 넣지 않았는데,

name 속성이 잘 들어가있는 것을 확인할 수 있다 이는 모두 th:field가 한 일이다.

5.2. fragments :: nav 속성

위와 같이 th:text="${#authentication.getPrincipal().getAccount().getNickname()}"를 이용하였다. 나는 SecurityContextUsername 부분에는 변하지 않는 email을 넣었고, 해당 id를 기억해서 authentication에 대한 정보가 필요할 때는 매번 불러오도록 했다.

그래서 detached된 객체를 불러오지 않고, 강의처럼 login()을 한번 더 안해도 된다. 또한 로그인한 계정 정보가 변할 때, 매번 .save()도 안해도 된다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글