스프링과 JPA 기반 웹 애플리케이션 개발 #9 회원 가입 뷰

Jake Seo·2021년 5월 26일
0

스프링과 JPA 기반 웹 애플리케이션 개발 #9 회원 가입 뷰

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

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

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


회원 가입: 뷰

부트스트랩 적용하기

html 페이지에 js, css 적용하기

부트스트랩 공식 사이트 링크에서 파일을 다운받고 적용하면 된다.

강의에선 사실 딱히 중요한 내용이 아니라서 그냥 <head> 태그에 cdn의 주소를 적어주었는데, 나는 로컬쪽에 파일을 갖고 있는 것을 선호해서 내 로컬에 /resources/public 디렉토리에 넣어주었다.

그리고 이후, Thymeleaf의 fragment 기능을 이용하여 해당 HTML에 삽입하고 싶어서 fragment를 새로 만들었다.

<html lagn="ko"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<th:block th:fragment="headLibraryInjection">
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- 부트스트랩 CSS -->
  <link rel="stylesheet" href="/css/bootstrap.css" type="text/css" media="all">
  <!-- 부트스트랩 JS -->
  <script type="text/javascript" src="/js/bootstrap.js"></script>
</th:block>
</html>

위와 같이 생성하면, HTML 페이지에서 <th:block>이란 태그를 통해 HTML에 들어갈 헤더, 푸터, JS, CSS 등 반복적인 요소를 쉽게 넣을 수 있다. JS나 CSS 파일 등 새로운 설정이 추가되거나 경로가 바뀌는 등의 일이 있어도 fragment에만 적용시키면 모든 페이지에 적용되기 때문에 매우 유용하다.

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Title</title>
    <th:block th:replace="fragments/head :: headLibraryInjection"></th:block>
</head>
<body>
메인페이지
</body>
</html>

<th:block> 태그는 위처럼 사용하면 된다. 그리고 <th:block> 태그와 기타 thymeleaf 레이아웃에 대한 태그를 사용하기 위해서 의존성을 추가해주었다.

<dependency>
  <groupId>nz.net.ultraq.thymeleaf</groupId>
  <artifactId>thymeleaf-layout-dialect</artifactId>
  <version>2.5.3</version>
</dependency>

위와 같은 과정을 거치면 아래와 같은 에러를 맞닥뜨리게 된다. 아래의 에러는 스프링 시큐리티 때문에 나오게 되는 에러인데, 모두에게 공개될 정적 리소스에 대한 접근을 모두에게 허용해주면 된다.

먼저 static 리소스에 대한 기본 경로 설정을 변경해준다.

기본 경로는 /**인데, /static/**으로 수정해주었다.

그리고 위와 같이 /static/**으로 시작하는 경로에 대한 권한을 모두 허용으로 변경하였다.

이후엔 권한 문제없이 잘 열린다.

js를 삽입할 때 소소한 팁

<body> 태그 아래에 js 삽입 구문(<script src="..." />)을 두면, 렌더링 된 이후에 js 를 불러오기 때문에 js 를 불러오느라 버벅거리는 현상을 없앨 수 있다. 하지만 지금 환경은 네트워크가 너무 좋기 때문에 딱히 이 팁을 적용하지 않았다.

다시 베스트 프랙티스로 정적 리소스 허용하기

먼저 application.properties에 있는 정적 리소스 경로 설정 부분을 지워주었다.

@Configuration
@EnableWebSecurity // 웹 시큐리티 설정을 직접 하겠다는 애노테이션
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 웹 시큐리티 설정을 좀 더 간편하게 하기 위해 WebSecurityConfigurerAdapter 를 상속

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/login", "/sign-up", "/check-email", "/check-email-token",
                        "/email-login", "/check-email-login", "login-link").permitAll()
                .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll()
                .anyRequest().authenticated();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());

    }
}

스프링 시큐리티 설정하는 부분에서 configure 중에 파라미터로 WebSecurity 타입을 받는 메소드를 오버라이드하고, 정적 리소스에 대해서는 보안을 적용하지 않도록, web.ignoring() 메소드와 이용해서 PathRequest 클래스의 정적 메소드인 .toStaticResources().atCommonLocations()라는 메소드를 이용하여 설정하였다.

사실 근데 CSS나 JS와 같은 부분은 그냥 CDN을 쓰기로 했다. 나중에 프론트는 리액트로 재구성하기도 할 것이고.. 부트스트랩 4.대 버전은 제이쿼리 의존성이 있어서 임포트해야 할 것도 너무 많아서 귀찮기 때문이다.

html 작성하기

fragments 작성하기

<html lagn="ko"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<th:block th:fragment="headLibraryInjection">
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- 부트스트랩 CSS -->
  <!-- <link rel="stylesheet" href="/css/bootstrap.css" type="text/css" media="all"> -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">

  <!-- 부트스트랩 JS -->
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.min.js" integrity="sha384-+YQ4JLhjyBLPDQt//I+STsc9iw4uQqACwlvpslubQzn4u2UU2UFM80nGisd026JF" crossorigin="anonymous"></script>
  <!-- <script type="text/javascript" src="/js/bootstrap.js"></script> -->

  <style>
    .container {
      max-width: 100%;
    }

    .tagify-outside{
      border: 0;
      padding: 0;
      margin: 0;
    }

    #study-logo {
      height: 200px;
      width: 100%;
      overflow: hidden;
      padding: 0;
      margin: 0;
    }

    #study-logo img {
      height: auto;
      width: 100%;
      overflow: hidden;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
    }

    body,
    input,
    button,
    select,
    optgroup,
    textarea,
    .tooltip,
    .popover {
      font-family: -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
    }

    table th {
      font-weight: lighter;
    }

    mark {
      padding: 0;
      background: transparent;
      background: linear-gradient(to right, #f0ad4e 50%, transparent 50%);
      background-position: right bottom;
      background-size: 200% 100%;
      transition: all .5s ease;
      color: #fff;
    }

    mark.animate {
      background-position: left bottom;
      color: #000;
    }

    .jumbotron {
      padding-top: 3rem;
      padding-bottom: 3rem;
      margin-bottom: 0;
      background-color: #fff;
    }
    @media (min-width: 768px) {
      .jumbotron {
        padding-top: 6rem;
        padding-bottom: 6rem;
      }
    }

    .jumbotron p:last-child {
      margin-bottom: 0;
    }

    .jumbotron h1 {
      font-weight: 300;
    }

    .jumbotron .container {
      max-width: 40rem;
    }
  </style>
</th:block>

<script type="application/javascript" th:fragment="form-validation">
  (function () {
    'use strict';

    window.addEventListener('load', function () {
      // Fetch all the forms we want to apply custom Bootstrap validation styles to
      // needs-validation 클래스로 작성된 form 의 validation 을 할 수 있음
      var forms = document.getElementsByClassName('needs-validation');

      // Loop over them and prevent submission
      Array.prototype.filter.call(forms, function (form) {
        form.addEventListener('submit', function (event) {
          // form 이 유효하지 않을 때 submit 되지 않게 만든다.
          // input 태그 등에 있는 required, type="email" 등 속성에 따라 검증을 해준다.
          // 검증 시 에러가 있을 때 invalid-feedback 클래스에 있는 메세지를 보여준다.
          // .checkValidity() 메소드는 DOM 에 내장된 메소드이다.
          if (form.checkValidity() === false) {
            event.preventDefault();
            event.stopPropagation();
          }
          form.classList.add('was-validated')
        }, false)
      })
    }, false)
  }())
</script>

</html>
  • fragments.html 파일의 위치를 변경했다.
    • resources/templates/fragments.html로 작성하고 그 안에 모든 fragments를 넣어두는게 가장 편하다는 것을 깨달았다.
    • <th:block th:replace="fragments :: headLibraryInjection"></th:block> 위와 같이 간단하게 fragments를 가져올 수 있다.
  • 새로운 DOM 내장 검증 API를 찾았다.
    • form.checkValidity() 메소드를 실행시키면, form 내부의 input 등에 attribute로 설정된 제약조건들이 맞지 않을 때, false를 리턴한다. 그 경우 event.preventDefault(), event.stopPropagation() 등을 통해 동작을 정지시킬 수 있다.
    • form 태그에 .was-validated 클래스를 추가시킴으로써, UI가 변경되며, 검증 정보 중 어떤 것이 맞지 않았는지에 대해 뜬다.
      • 이 과정에서 small 태그 등이 이용된다.
      • .was-validate 클래스에 대한 디자인은 부트스트랩에 내장되어 있다.

sign-up 작성하기

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Title</title>
    <th:block th:replace="fragments :: headLibraryInjection"></th:block>
</head>
<body class="bg-light">

<nav th:fragment="main-nav" class="navbar navbar-expand-sm navbar-dark bg-dark">
    <a class="navbar-brand" href="/" th:href="@{/}">
        <img src="/images/logo_sm.png" width="30" height="30">
    </a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>

    <div class="collapse navbar-collapse" id="navbarSupportedContent">
        <ul class="navbar-nav mr-auto">
            <li class="nav-item">
                <form th:action="@{/search/study}" class="form-inline" method="get">
                    <input class="form-control mr-sm-2" name="keyword" type="search" placeholder="스터디 찾기" aria-label="Search" />
                </form>
            </li>
        </ul>

        <ul class="navbar-nav justify-content-end">
            <li class="nav-item">
                <a class="nav-link" th:href="@{/login}">로그인</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" th:href="@{/sign-up}">가입</a>
            </li>
        </ul>
    </div>
</nav>

<div class="container">
    <div class="py-5 text-center">
        <h2>계정 만들기</h2>
    </div>
    <div class="row justify-content-center">
        <!-- th:object 속성을 통해 이 폼에 렌더링될 데이터의 객체를 지정할 수 있다.
             렌더링될 객체의 프로퍼티는 *{프로퍼티명} 으로 접근이 가능하다. -->
        <form class="needs-validation col-sm-6" action="#" th:action="@{/sign-up}" th:object="${signUpForm}" method="post" novalidate>
            <div class="form-group">
                <label for="nickname">닉네임</label>
                <input id="nickname" type="text" th:field="*{nickname}" class="form-control"
                       placeholder="whiteship" aria-describedby="nicknameHelp" required minlength="3" maxlength="20">
                <small id="nicknameHelp" 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}">Nickname Error</small>
            </div>

            <div class="form-group">
                <label for="email">이메일</label>
                <input id="email" type="email" th:field="*{email}" class="form-control"
                       placeholder="your@email.com" aria-describedby="emailHelp" required>
                <small id="emailHelp" class="form-text text-muted">
                    스터디올래는 사용자의 이메일을 공개하지 않습니다.
                </small>
                <small class="invalid-feedback">이메일을 입력하세요.</small>
                <small class="form-text text-danger" th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Error</small>
            </div>

            <div class="form-group">
                <label for="password">패스워드</label>
                <input id="password" type="password" th:field="*{password}" class="form-control"
                       aria-describedby="passwordHelp" required minlength="8" maxlength="50">
                <small id="passwordHelp" class="form-text text-muted">
                    8자 이상 50자 이내로 입력하세요. 영문자, 숫자, 특수기호를 사용할 수 있으며 공백은 사용할 수 없습니다.
                </small>
                <small class="invalid-feedback">패스워드를 입력하세요.</small>
                <small class="form-text text-danger" th:if="${#fields.hasErrors('password')}" th:errors="*{password}">Password Error</small>
            </div>

            <div class="form-group">
                <button class="btn btn-primary btn-block" type="submit"
                        aria-describedby="submitHelp">가입하기</button>
                <small id="submitHelp" class="form-text text-muted">
                    <a href="#">약관</a>에 동의하시면 가입하기 버튼을 클릭하세요.
                </small>
            </div>
        </form>
    </div>
</div>

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

이건 대충 부트스트랩으로 작성된 페이지이다.

  • 경로는 상대 경로를 사용하기 위해 @{/}를 이용해 작성되었다.
  • form 태그에 th:object=${signUpForm} 속성을 줘서 컨트롤러로 부터 모델로 내려온 오브젝트를 렌더링에 이용하거나 컨트롤러로 오브젝트의 내용을 보낼 수 있다.
    • th:field=*{}를 통해 어떤 인풋이 signUpForm의 어떤 속성을 담당하는지에 대해 알려준다.

그 이외에 따로 특별한 건 없다.

SignUpForm 클래스 작성하기

회원가입 폼에서 쓰는 데이터를 담아둘 SignUpForm 클래스를 작성한다.

package com.jakestudy.account;

import lombok.Data;

@Data
public class SignUpForm {
    private String nickname;
    private String email;
    private String password;


}

위에서 @Data 애노테이션은 @ToString, @EqualsAndHash, @Getter, @Setter, @RequiredArgsConstructor를 뭉쳐놓은 것이다. 순환참조가 발생하지 않도록 주의해야 한다.

@Data is a convenient shortcut annotation that bundles the features of @ToString, @EqualsAndHashCode, @Getter / @Setter and @RequiredArgsConstructor together

AccountController 및 테스트 작성하기

AccountController 작성

@Controller
public class AccountController {

    @GetMapping("/sign-up")
    public String signUpForm(Model model) {
        // 클래스 이름의 camelCase 와 뷰로 내려보낼 오브젝트의 이름이 같을 경우 문자열 생략 가능
//        model.addAttribute("signUpForm", new SignUpForm());
        model.addAttribute(new SignUpForm());
        return "account/sign-up";
    }
}

이전과 비슷한데, Model로 View에 객체를 내려주는 부분만 추가했다. Model로 View에 내려줄 객체의 이름이 실제 클래스의 이름을 camelCase로 변경한 것과 동일하면, 따로 이름을 지정해주지 않아도 그 이름으로 내려간다.

테스트 업데이트하기

@SpringBootTest
@AutoConfigureMockMvc
// @AutoConfigureWebMvc, WebClient로도 가능
class AccountControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @DisplayName("회원 가입 화면 보이는지 테스트")
    @Test
    void signUpForm() throws Exception {
        mockMvc.perform(get("/sign-up"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(view().name("account/sign-up"))
                .andExpect(model().attributeExists("signUpForm"));
    }
}

테스트의 내용에는 내가 내려준 Model의 이름이 무사히 내려왔는지만 검사한다.

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

0개의 댓글