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으로 변환
})
로그인 페이지 만들기
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
댓글