본문 바로가기
공부 기록

[스프링 부트로 게시판 만들기] 06. 회원가입 마무리

by 매트(Mat) 2021. 7. 20.



open-in-view

  • 그 전에 application.yml에서 open-in-view: true 옵션에 대한 설명을 하겠습니다. (저도 어느정도만 알고 있기 때문에 사실 이 설명보다는 좀 더 좋은 개발자의 설명을 듣는 것이 좋다고 생각합니다.)
  • 참고로 스프링 부트 2.x에서는 open-in-view의 디폴트가 true입니다.
캡처
  • 요청이 오면 톰캣이 시작되고 web.xml에서 요청 핸들링이 해주고, servlet-context.xml에서 분기처리를 해주고, root-context.xml에서는 DB 연결 세션을 생성해주는데 이 때 JPA를 사용한다면 Controller로 들어가기전에 영속성 컨텍스트 생성이 됩니다.
  • 그리고 Service가 시작될 때 트랜잭션과 JDBC Connection이 생성이 됩니다.
  • JPA에서는 연관관계가 중요한데 예를 들어, Player 테이블과 Team 테이블이 있습니다.
캡처
  • 이대호의 정보를 가져오려면 teamId로 조인해서 가져와야 할 것입니다.
  • 이 때 만약 Eager 전략 사용하게 되면 필요하지 않든 필요하든 무조건 연관된 데이터(롯데 팀)를 모두 다 가져와서 영속성 컨텍스트 1차 캐시에 객체로 저장이 되죠. 이러면 단점이 생깁니다. 필요하지 않은 데이터라면 메모리 낭비가 되서 DB에 부하가 걸려서 속도가 느리다 단점이 있겠습니다. 그리고 Earger 전략Service 계층에서 트랜잭션과 JDBC Connection, 영속성 컨텍스트가 모두 종료됩니다.
  • 이번에 Lazy 전략을 사용하게 되면 연관된 데이터(롯데 팀)가 필요하지 않을 수도 있으니 일단 영속성 컨텍스트에 1차 캐시에 롯데 팀 프록시 객체를 하나 만들어둡니다. 이 프록시 객체는 실제로 빈 객체이고, 만약 내가 필요해서 getTeam으로 가져와야 한다면 JDBC Connection만 열어서 데이터를 가져옵니다. 이러면 필요할 때만 가져오기 때문에 메모리가 낭비될리가 없겠죠. 그리고 Lazy 전략Service 계층에서 트랜잭션과 JDBC Connection가 종료되고, 영속성 컨텍스트는 아직 열려있기 때문에 필요할 때 재요청을 해서 데이터를 가져올 수 있는 것입니다.
  • 결론은 open-in-view: true 라는 옵션은 이 Lazy 전략을 따라가겠는 것이고, Spring Boot 2.x 부터는 디폴트 값이 true라서 따로 설정을 안해줘도 됩니다. 다만, false를 주게 되면 Eager 전략을 따라가겠다는 겁니다. 실무에서는 주로 Lazy 전략을 선택하는 것 같습니다!



스프링 시큐리티 적용

  • 먼저 시큐리티 의존성을 추가해주겠습니다.
dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-security'


  • config 패키지와 SecurityConfig 클래스를 생성하여 시큐리티에 대한 설정을 작성하겠습니다.
캡처

SecurityConfig 클래스

package com.azurealstn.blogproject.config;


import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                    .authorizeRequests()
                    .antMatchers("/", "/auth/**", "/js/**", "/css/**", "/image/**").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                    .loginPage("/auth/user/login")
                    //.loginProcessingUrl("/auth/api/v1/user/login")
                    .defaultSuccessUrl("/");
    }
}
  • @EnableWebSecurity : 스프링 시큐리티 설정들을 활성화시킵니다.
  • csrf().disable() : csrf 토큰 해제
  • authorizeRequests() : URL별 권환 관리를 설정하는 옵션
  • antMatchers() : 권한 관리 대상을 지정하는 옵션
    • "/" 등 지정된 URL들은 모두 permitAll() 메소드를 통해 전체 열람 권한을 주었습니다.
  • formLogin() : 권한이 없는 사람이 페이지를 이동하려고 하면 로그인 페이지로 이동
  • loginPage() : 해당하는 로그인 페이지 URL로 이동
  • loginProcessingUrl() : 스프링 시큐리티가 해당 주소로 요청오는 로그인을 가로채서 대신 로그인해줍니다. (loginProcessingUrl는 잠시 주석처리해놓겠습니다. 지금 당장 사용하지 않을 겁니다.)
  • defaultSuccessUrl() : 로그인이 성공하면 해당 URL로 이동


  • header.html 에서 로그인, 회원가입 경로를 수정하겠습니다.
  • /auth 경로를 추가해주어서 link 태그에 ../ 경로를 더 추가해주어야 합니다.

header.html

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head th:fragment="head (title)">
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <link th:href="@{../../css/sticky-footer-navbar.css}" rel="stylesheet">
    <link rel="stylesheet" th:href="@{../../css/style.css}">
    <link rel="stylesheet" th:href="@{../../css/user-save.css}">

    <title th:text="${title}">블로그</title>

</head>
<body>

<header th:fragment="header">
                ...
                <ul class="navbar-nav me-auto mb-2 mb-md-0">
                    <li class="nav-item">
                        <a class="nav-link" href="/auth/user/login">로그인</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/auth/user/save">회원가입</a>
                    </li>
                </ul>
                ...
</html>


UserApiController 클래스

@RequiredArgsConstructor
@RestController
public class UserApiController {

    private final UserService userService;

    @PostMapping("/auth/api/v1/user")
    public Long save(@RequestBody UserSaveRequestDto userSaveRequestDto) {
        return userService.save(userSaveRequestDto.toEntity());
    }
}
  • @PostMapping("/auth/api/v1/user") 수정
  • /auth 추가해야 회원가입이 가능합니다.

user.js

$.ajax({
            type: "POST", //Http method
            url: "/auth/api/v1/user", //추가 /auth
            data: JSON.stringify(data), //JSON으로 변환
})
  • ajax에도 url을 똑같이 추가합니다.



로그인 페이지 만들기

  • controller 코드 추가

UserController 클래스

package com.azurealstn.blogproject.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class UserController {
    ...

    @GetMapping("/auth/user/login")
    public String userLogin() {
        return "layout/user/user-login";
    }
}


user-login.html

<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('로그인')"></head>

<body class="text-center d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<main class="form-signin">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <form>
            <h1 class="h3 m-3 fw-normal">로그인</h1>

            <div class="form-floating m-3">
                <input type="text" class="form-control" id="username" placeholder="아이디를 입력하세요." required="required">
                <label for="username">아이디</label>
            </div>
            <div class="form-floating m-3">
                <input type="password" class="form-control" id="password" placeholder="패스워드를 입력하세요." required="required">
                <label for="password">패스워드</label>
            </div>
            <div class="checkbox mb-3">
                <label>
                    <input type="checkbox" value="remember-me"> Remember me
                </label>
            </div>
        </form>
        <button class="w-100 btn btn-lg btn-success" id="btn-save">로그인</button>
    </div>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script th:src="@{/js/user.js}"></script>
</body>

</html>



비밀번호 암호화

  • 스프링 부트 2.0부터는 비밀번호를 암호화를 해야합니다.
  • SecurityConfig 클래스에서 @Bean으로 등록해 사용할 수 있습니다.

SecurityConfig 클래스

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    ...
}
  • 이제 회원가입 할 때 BCryptPasswordEncoder를 이용해서 save 해주면 되겠죠.
  • User 클래스에 setPassword 메소드를 만듭니다.

User 클래스

public class User {
    ...
    public void setPassword(String password) {
        this.password = password;
    }
}

UserService 클래스

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    /**
     * 회원가입 로직
     */
    @Transactional
    public Long save(User user) {
        String hashPw = bCryptPasswordEncoder.encode(user.getPassword());
        user.setPassword(hashPw);
        return userRepository.save(user).getId();
    }
}
  • 이러면 암호화가 완료되었습니다.



회원가입 테스트

  • 회원가입을 테스트 하기 위해 UserApiControllerTest 클래스에 와서 다시 테스트를 해봅니다.
  • 아마 에러가 뜰텐데 이 부분은 BCryptPasswordEncoder를 DI받고, url을 수정해주시면 됩니다.

UserApiControllerTest 클래스

package com.azurealstn.blogproject.controller.api;

import com.azurealstn.blogproject.domain.user.Role;
import com.azurealstn.blogproject.domain.user.User;
import com.azurealstn.blogproject.domain.user.UserRepository;
import com.azurealstn.blogproject.dto.user.UserSaveRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @AfterEach
    public void cleanup() throws Exception {
        userRepository.deleteAll();
    }

    @Test
    public void User_가입완료_테스트() throws Exception {
        //given
        String username = "test";
        String nickname = "babo";

        UserSaveRequestDto userSaveRequestDto = UserSaveRequestDto.builder()
                .username(username)
                .password(bCryptPasswordEncoder.encode("1234"))
                .email("test@naver.com")
                .nickname(nickname)
                .role(Role.USER)
                .build();

        String url = "http://localhost:" + port + "/auth/api/v1/user";

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, userSaveRequestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<User> userList = userRepository.findAll();
        assertThat(userList.get(0).getUsername()).isEqualTo(username);
        assertThat(userList.get(0).getNickname()).isEqualTo(nickname);
    }
}



회원가입 제약조건


user-save.html

<!doctype html>
<html lang="en" class="h-100" xmlns:th="http://www.thymeleaf.org">

<head th:replace="layout/header :: head ('회원가입')"></head>

<body class="text-center d-flex flex-column h-100">
<header th:replace="layout/header :: header"></header>

<main class="form-signin">
    <div class="container border rounded flex-md-row mb-4 shadow-sm h-md-250">
        <form id="needs-validation" novalidate>
            <h1 class="h3 m-3 fw-normal">회원가입</h1>

            <div class="form-floating m-3">
                <input type="text" class="form-control" id="username" placeholder="아이디를 입력하세요." required
                       minlength="4" size="20">
                <label for="username">아이디</label>
                <div class="valid-feedback">
                    good!
                </div>
                <div class="invalid-feedback">
                    아이디는 4자 이상 입력해야 합니다.
                </div>
            </div>

            <div class="form-floating m-3">
                <input type="password" class="form-control" id="password" placeholder="패스워드를 입력하세요." required
                       minlength="8" size="20">
                <label for="password">패스워드</label>
                <div class="valid-feedback">
                    very good!
                </div>
                <div class="invalid-feedback">
                    패스워드는 8자 이상 입력해야 합니다.
                </div>
            </div>
            <div class="form-floating m-3">
                <input type="email" class="form-control" id="email" placeholder="이메일을 입력하세요." required>
                <label for="email">이메일</label>
                <div class="valid-feedback">
                    nice!
                </div>
                <div class="invalid-feedback">
                    이메일 형식으로 입력해야 합니다.
                </div>
            </div>
            <div class="form-floating m-3">
                <input type="text" class="form-control" id="nickname" placeholder="닉네임을 입력하세요." required
                       minlength="4" size="20">
                <label for="nickname">닉네임</label>
                <div class="valid-feedback">
                    very nice!
                </div>
                <div class="invalid-feedback">
                    닉네임은 4자 이상 입력해야 합니다.
                </div>
            </div>
        </form>
        <button class="w-100 btn btn-lg btn-success" id="btn-save">회원가입</button>
    </div>
</main>

<footer th:replace="layout/footer :: footer"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script th:src="@{/js/validation.js}"></script>
<script th:src="@{/js/user.js}"></script>
</body>

</html>


validation.js (파일 추가)

'use strict';

(function () {
    window.addEventListener("load", function () {
        let form = this.document.querySelector("#needs-validation");
        let btnSave = this.document.querySelector("#btn-save");

        btnSave.addEventListener("click", function (event) {
            if (form.checkValidity() == false) {
                event.preventDefault();
                event.stopPropagation();
                form.classList.add("was-validated");
            }
        }, false);
    }, false);
})();


user.js (init 함수 수정)

init: function () {
        $("#btn-save").on("click", () => { //this를 바인딩하기 위해 화샬표 함수 사용
            let form = document.querySelector("#needs-validation");
            if (form.checkValidity() == false) {
                console.log("회원가입 안됨")
            } else {
                this.save();
            }
        });
    },


User 클래스 (수정)

public class User {
    ...

    @Column(nullable = false, length = 20, unique = true)
    private String username; //아이디

    ...

    @Column(nullable = false, length = 20)
    private String nickname; //닉네임
    ...
}






다음으로

  • 회원가입은 여기서 마치도록 하겠습니다.
  • 다음에는 로그인을 해보도록 하겠습니다.
  • 아마 전체 테스트를 돌리면 HelloController 클래스에서 에러가 납니다. 그 이유는 시큐리티 때문인데요. 이 부분은 제가 좀 더 공부해보고 알아보겠습니다.






References

댓글