h2 database 사용
build.gradle dependency 추가
runtimeOnly 'com.h2database:h2'
application.yml setting
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/springpractice
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
format_sql: true
default_batch_fetch_size: 100
logging.level:
org.hibernate.SQL: debug
org.hibernate.type: trace
org.springframework: debug
org.springframework.web: debug
server:
port: 8080
terminal에서 h2 실행
h2 webpage가 실행되면 key값을 유지한 상태로 jdbc url을 최초 한번만 아래와 같이 변경하여 접속한다.
jdbc:h2:~/springpractice
mv 파일이 생성된 것을 확인할 수 있다.
이후 부터는 tcp를 통해서 접속하면 된다. (jdbc:h2:tcp://localhost/~/springpractice)
Welcom page 생성
HomeController를 생성하여 welcome page를 local host에서 확인해보자.
package com.example.springpractice20220303.domain;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}
@Controller
"위 클래스는 컨트롤러입니다." 라고 spring framework에 알리는 동시에 bean으로 등록한다.
bean으로 등록할 때는 무조건 싱글톤의 형태를 취하게 되며 (유일하게 하나만 등록한다), 설정을 통해 수동으로 변경할 수도 있지만 그렇게 사용하는 것은 아직 본적이 없다.
컨트롤러는 뷰와 서비스간의 중간 제어자 역할을 한다.
@GetMapping("/")
HTTP에서 ("/")쪽으로 Get 요청이 올때 home() 메서드가 실행된다.
Get 요청은 일반적으로 CRUD에서 조회(R)를 할 때 사용한다.
return을 통해 "home.html"을 렌더링하도록 요청한다.
welcom page (home.html) 생성
특별한건 존재하지 않는다.
다만 createAccount 버튼을 누르게 되면 members/new 쪽으로 이동하도록 링크를 걸어 놓았다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Clamorphism Login form</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css"/>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="container">
<form>
<h3>Log in</h3>
<div class="inputBox">
<span>Username</span>
<div class="box">
<div class="icon"><ion-icon name="person"></ion-icon></div>
<input type="text" id="id">
</div>
</div>
<div class="inputBox">
<span>Password</span>
<div class="box">
<div class="icon"><ion-icon name="lock-closed"></ion-icon></div>
<input type="password" id="password">
</div>
</div>
<label>
<input type="checkbox"> Remember me
</label>
<div class="inputBox">
<div class="box">
<input type="submit" value="Log in">
</div>
</div>
<ion-icon class="separate-icon" name="lock-closed"></ion-icon><a href="#" class="forgot">Forget Password</a><br>
<ion-icon class="separate-icon" name="person"></ion-icon><a href="members/new" class="forgot">Create Account</a>
</form>
</div>
<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
</body>
</html>
style.css 파일 생성
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #2f363e;
}
.container {
position: relative;
width: 350px;
min-height: 500px;
display: flex;
justify-content: center;
align-items: center;
background: #2f363e;
box-shadow: 25px 25px 75px rgba(0,0,0,0.25),
10px 10px 70px rgba(0,0,0,0.25),
inset 5px 5px 10px rgba(0,0,0,0.5),
inset 5px 5px 20px rgba(255,266,266,0.2),
inset -5px -5px 15px rgba(0,0,0,0.75);
border-radius: 30px;
padding: 50px;
}
form {
position: relative;
width: 100%;
}
.container h3 {
color: #fff;
font-weight: 600;
font-size: 2em;
width: 100%;
text-align: center;
margin-bottom: 30px;
letter-spacing: 2px;
text-transform: uppercase;
}
.inputBox {
position: relative;
width: 100%;
margin-bottom: 20px;
}
.inputBox span {
display: inline-block;
color: #ffffff;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.75em;
border-left: 4px solid #ffffff;
padding-left: 4px;
line-height: 1em;
}
.inputBox .box {
display: flex;
}
.inputBox .box .icon {
position: relative;
min-width: 40px;
height: 40px;
background: #ff2c74;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
margin-right: 10px;
color: #ffffff;
font-size: 1.15em;
box-shadow: 5px 5px 7px rgba(0, 0, 0, 0.25),
inset 2px 2px 5px rgba(255,255,2550.25),
inset -3px -3px 5px rgba(0,0,0,0.5);
}
.inputBox .box input {
position: relative;
width: 100%;
border: none;
outline: none;
padding: 10px 20px;
border-radius: 30px;
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: 5px 5px 7px rgba(0,0,0,0.35),
inset 2px 2px 5px rgba(255,255,255,0.25),
inset -3px -3px 5px rgba(0,0,0,0.5);
}
.inputBox .box input[type="submit"] {
background: #1f83f2;
box-shadow: 5px 5px 7px rgba(0, 0, 0, 0.35),
inset 2px 2px 5px rgba(255, 255, 255, 0.25),
inset -3px -3px 5px rgba(0, 0, 0, 0.5);
color: #ffffff;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
margin-top: 10px;
}
.inputBox .box input[type="submit"] :hover {
filter: brightness(1.1);
}
label {
color: #ffffff;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.85em;
display: flex;
align-items: center;
}
label input {
margin-right: 5px;
}
.forgot {
color: #ffffff;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.85em;
text-decoration: none;
}
.separate-icon {
color: white;
padding-right: 5px;
}
.errors {
color: red;
padding-top: 6px;
}
서버를 올리면 localhost:8080 으로 우리가 만든 웹 페이지를 확인할 수 있다.
회원가입
회원가입을 위해 회원 정보를 가지고 있는 Member domain을 우선적으로 생성해보았다.
package com.example.springpractice20220303.domain;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import lombok.Getter;
@Entity
@Getter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
private String userId;
private String userPassword;
}
간단하게 userId, userPassword 두가지를 회원 정보로 받을 예정이다.
Member Class를 그대로 노출하는 것은 위험하기 때문에 MemberForm이라는 DTO도 같이 생성하였다.
DTO는 일반적으로 로직이 없으며, 계층간 데이터 교환을 위해 생성하는 클래스이다, 그렇기 때문에 일반적으로는 getter와 setter를 모두 열어 놓는다.
MemberForm
package com.example.springpractice20220303.domain;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class MemberForm {
@NotEmpty(message = "회원 아이디는 필수 입니다")
private String userId;
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String userPassword;
private String checkPassword;
}
@NotEmpty, @NotBlank, @Pattern은 javax.validation 패키지이다.
자바 버전이 2.3.0 이상인 경우, 이 패키지를 사용하기 위해서는 build.gradle 설정을 추가해주어야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
MemberRepository
멤버 도메인들을 모두 생성 완료하였다.
이 정보들을 저장할 MemberRepository를 생성해보자
MemberRepository는 Member를 저장하는 기능과,
사용자 id의 존재여부를 확인하는 두가지 메서드를 생성하였다.
package com.example.springpractice20220303.domain;
import java.util.HashMap;
import java.util.List;
import javax.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
private HashMap<String, String> store = new HashMap<>();
public Long save(Member member) {
em.persist(member);
System.out.println("save member");
return member.getId();
}
public boolean existId(String userId) {
List<Member> users = em
.createQuery("select m from Member m where m.userId =: userId", Member.class)
.setParameter("userId", userId)
.getResultList();
if (users.size() >= 1) {
return true;
}
return false;
}
}
MemberService
특별한 기능은 존재하지 않는다.
현재 기능만 봤을 때는 Service없이 Controller와 Repository만 존재해도 무방하다. (하지만 나는 Service를 생성하였다.)
package com.example.springpractice20220303.domain;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Long join(Member member) {
return memberRepository.save(member);
}
public boolean existId(String memberId) {
return memberRepository.existId(memberId);
}
}
createAccount.html
welcome page에서 createAccount 버튼을 눌렀을 때 오는 화면이다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
<title>Clamorphism Login form</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css"/>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="container">
<form role="form" action="/members/new" th:object="${memberForm}" method="post">
<h3>회원 가입</h3>
<div class="inputBox">
<span>아이디</span>
<div class="box">
<div class="icon"><ion-icon name="person"></ion-icon></div>
<input type="text" th:field="*{userId}" >
</div>
<p th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}" th:class="errors"><br>Incorrect date</p>
</div>
<div class="inputBox">
<span>비밀번호</span>
<div class="box">
<div class="icon"><ion-icon name="lock-closed"></ion-icon></div>
<input type="password" th:field="${memberForm.userPassword}">
</div>
<p th:if="${#fields.hasErrors('userPassword')}" th:errors="*{userPassword}" th:class="errors"><br>Incorrect date</p>
</div>
<div class="inputBox">
<span>비밀번호 재확인</span>
<div class="box">
<div class="icon"><ion-icon name="bag-check"></ion-icon></div>
<input type="password" th:field="${memberForm.checkPassword}">
</div>
<p th:if="${#fields.hasErrors('checkPassword')}" th:errors="*{checkPassword}" th:class="errors"><br>Incorrect date</p>
</div>
<div class="inputBox">
<div class="box">
<input type="submit" value="가입하기">
</div>
</div>
</form>
</div>
<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
</body>
</html>
여기에는 기본적인 타임리프 문법을 사용하였다.
아래 코드를 넣음으로써 thymeleaf를 사용하겠다고 선언하였다.
<html xmlns:th="http://www.thymeleaf.org">
th:object, th:field
th:object와 th:field가 가장 범용적으로 사용되는 타임리프 문법으로 보인다.
일반적으로 submit을 할 때 post쪽으로 데이터를 넘기게 되는데
이때 사용하는 것이 th:object, th:field이다.
th:object : 객체의 이름을 적어주면 된다.
th:field: 객체 내의 인자의 이름을 적어주면 된다.
이렇게 지정을 해놓는다면 submit이 실행될 때 객체내에 모든 정보들이 담기게 되며, 이 데이터는 post로 넘어가면서 @ModelAttribute에 매핑된다.
th:object="${memberForm}"
th:field="*{userId}" // th:field="${memberForm.userId}"
@ModelAttribute
만약 위와 같이 사용했다면 post에서는 아래와 같은 형태로 다시 꺼내서 사용할 수 있다.
자세한 내용은 컨트롤러에서 확인할 수 있을 것이다.
@ModelAttribute MemberForm memberForm
MemberController
가장 많은 기능을 담고있다.
@RequiredArgsConstructor를 통해 생성자 주입을 진행하였다.
package com.example.springpractice20220303.domain;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/members/new")
public String createMemberForm(Model model) {
model.addAttribute("memberForm", new MemberForm());
return "createAccount";
}
@PostMapping("/members/new")
public String createMember(@Valid MemberForm memberForm, BindingResult bindingResult) {
checkCorrectPassword(bindingResult, memberForm);
checkDuplicateId(bindingResult, memberForm);
if (bindingResult.hasErrors()) {
return "createAccount";
}
return "redirect:/";
}
private void checkDuplicateId(BindingResult bindingResult, MemberForm memberForm) {
String userId = memberForm.getUserId();
if (memberService.existId(userId)) {
bindingResult.addError(new FieldError(
"memberForm",
"userId",
"중복된 아이디입니다."));
}
}
private void checkCorrectPassword(BindingResult bindingResult, MemberForm memberForm) {
String password = memberForm.getUserPassword();
String checkPassword = memberForm.getCheckPassword();
if (!password.equals(checkPassword)) {
bindingResult.addError(new FieldError(
"memberForm",
"checkPassword",
"비밀번호가 동일하지 않습니다"));
}
}
}
오늘 프로젝트를 하면서 가장 어려웠던 부분중 하나였다.
어떻게 오류 메세지를 출력할 수 있을까에 대한 것이다.
@Valid를 이용하여 MemberForm에서 지정해 놓은 것을 간편하게 검증할 수 있다.
하지만 id 중복체크는 @Valid로 간단하게 검증할 수 없다.
이 문제는 BindingResult를 이용하여 해결하였다.
오류가 생겼을 때 BindingResult에 addError 메서드를 이용하고
각각 객체명, 필드명, 오류 메세지를 적어주면 된다.
@PostMapping("/members/new")
public String createMember(@Valid MemberForm memberForm, BindingResult bindingResult) {
checkCorrectPassword(bindingResult, memberForm);
checkDuplicateId(bindingResult, memberForm);
if (bindingResult.hasErrors()) {
return "createAccount";
}
return "redirect:/";
}
private void checkDuplicateId(BindingResult bindingResult, MemberForm memberForm) {
String userId = memberForm.getUserId();
if (memberService.existId(userId)) {
bindingResult.addError(new FieldError(
"memberForm",
"userId",
"중복된 아이디입니다."));
}
}
그리고 오류 메세지를 다시 출력하는 것은 다시 타임리프를 이용하였다.
th:if="${#fileds.hasErrors('필드이름')}" : 해당 필드가 오류가 생겼다면
th:errors="*{필드이름}" : 태그안에 에러 메세지를 출력하겠다.
th:class="적용할 클래스명" : 클래스 내에 색상에 대한 속성과 padding에 대한 속성을 추가하여 에러메세지가 잘 보이게 만들었다.
<p th:if="${#fields.hasErrors('userPassword')}" th:errors="*{userPassword}" th:class="errors"><br>Incorrect date</p>