개발일지
Personal Project - 가계부 구현(1)
준서이
2024. 5. 8. 13:55
이번 프로젝트는 실 생활에서 쓰일 수 있는 프로그램을 구현하고자 한다.
가계부를 만들기로 하였다.
내가 쓸 거지만 프로젝트이기에,
회원과 가계부는 1:N 연관관계를 맺어 작성하였고,
회원 가입 시 비밀번호를 중복 체크하고, passwordencode로 암호화 하여 저장하여, 회원 보안 인증 기능을 좀 더 강화하였다.
가계부의 분류와 지출 타입은 간결하고 관리하기 쉽게 열거형으로 작성하였고,
회원과 가계부의 관계를 cascade로 설정하여 회원 탈퇴 시 가계부 내역을 모두 삭제하는 기능을 넣었다.
디자인은 간단하게 작성하였다.

MemberDTO
package org.example.account_book.DTO;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.example.account_book.Constant.RoleType;
import org.hibernate.validator.constraints.Length;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberDTO {
//회원 번호
private Long memberId;
//회원 이름
@NotBlank
private String name;
@NotBlank
//회원 닉네임
private String nickName;
//회원 이메일
@NotBlank(message = "이메일은 필수입니다.")
@Email
private String email;
//회원 비밀번호
@NotBlank (message = "비밀번호는 필수입니다.")
@Length(min = 4, max = 20, message = "4~20자 사이로 입력해주세요")
private String password;
@NotBlank (message = "비밀번호 확인은 필수입니다.")
private String passwordConfirm;
//회원 전화번호
private String phone;
//회원 분류
private RoleType roleType;
//등록일
private LocalDateTime createdDate;
//수정일
private LocalDateTime modifiedDate;
}
이메일 형식 유효성 검사
@NotBlank
@NotBlank는 null 과 "" 과 " " 모두 허용하지 않는다.
@Length
필드 크기가 min 과 max 사이여야 값을 저장할 수 있도록 유효성을 검사해준다.
MemberEntity
package org.example.account_book.Entity;
import jakarta.persistence.*;
import lombok.*;
import org.example.account_book.Constant.RoleType;
@Getter
@Setter
@ToString
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "member")
@SequenceGenerator(name = "member_SEQ", sequenceName = "member_SEQ", allocationSize = 1)
public class MemberEntity extends BaseEntity {
//회원 번호
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_SEQ")
@Column
private Long memberId;
//회원 이름
@Column(length = 10)
private String name;
//회원 닉네임
@Column(unique = true, length = 10)
private String nickName;
//회원 이메일
@Column(unique = true, length = 20, nullable = false)
private String email;
//회원 비밀번호
@Column(length = 50, nullable = false)
private String password;
//회원 비밀번호 확인
@Column(length = 20, nullable = false)
private String passwordConfirm;
//회원 전화번호
@Column(length = 20)
private String phone;
//회원 분류
@Enumerated(EnumType.STRING)
private RoleType roleType;
}
닉네임 필드와 이메일 필드에 uniqte키를 걸어, 중복되는 것을 금지하는 제약조건을 추가 하였다.
MemberService
package org.example.account_book.Service;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.example.account_book.DTO.MemberDTO;
import org.example.account_book.Entity.MemberEntity;
import org.example.account_book.Repository.MemberRepository;
import org.modelmapper.ModelMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final ModelMapper modelMapper;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
//삽입
public void save(MemberDTO memberDTO) throws IllegalAccessException{
//기존 사용하는 email을 조회
Optional<MemberEntity> memberEntity = Optional.ofNullable(memberRepository
.findByEmail(memberDTO.getEmail()));
//email이 일치하는 회원이 없으면
if (memberEntity.isEmpty()) {
//비밀번호를 암호화해서
MemberEntity member = modelMapper.map(memberDTO, MemberEntity.class);
member.setPassword(passwordEncoder.
encode(memberDTO.getPassword()));
member.setPasswordConfirm(passwordEncoder.
encode(memberDTO.getPasswordConfirm()));
//저장
memberRepository.save(member);
} else {
throw new IllegalAccessException("이미 가입된 회원입니다.");
}
}
//수정
public void update(MemberDTO memberDTO) {
MemberEntity member = modelMapper.map(memberDTO, MemberEntity.class);
//읽어온 값에서 비밀번호가 존재하면
if (!memberDTO.getPassword().isEmpty() && member.getPassword().equals(member.getPasswordConfirm())) {
//비밀번호 암호화 작업
member.setPassword(passwordEncoder.
encode(memberDTO.getPassword()));
member.setPasswordConfirm(passwordEncoder.
encode(memberDTO.getPasswordConfirm()));
}
memberRepository.save(member);
}
//삭제
public void delete(Long memberId) {
memberRepository.deleteById(memberId);
}
//전체조회
public List<MemberDTO> getmemberList() {
List<MemberEntity> memberEntities = memberRepository.findAll();
List<MemberDTO> memberDTOS = Arrays.asList(modelMapper.map(memberEntities, MemberDTO[].class));
return memberDTOS;
}
//개별조회
public MemberDTO findById(Long memberId) {
MemberEntity member = memberRepository.findById(memberId).orElseThrow();
MemberDTO memberDTO = modelMapper.map(member, MemberDTO.class);
return memberDTO;
}
//마이페이지 조회
public MemberDTO findByEmail(String email) {
MemberEntity member = memberRepository.findByEmail(email);
MemberDTO memberDTO = modelMapper.map(member, MemberDTO.class);
return memberDTO;
}
}
@RequiredArgsConstructor
생성자 자동 생성 및 final 변수를 의존관계를 자동으로 설정해 준다.
MemberController
package org.example.account_book.Controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.example.account_book.Constant.RoleType;
import org.example.account_book.DTO.MemberDTO;
import org.example.account_book.Service.MemberService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
//삽입
@GetMapping("/member/save")
public String saveForm(Model model) {
MemberDTO memberDTO = new MemberDTO();
model.addAttribute("data", memberDTO);
return "member/save";
}
@PostMapping("/member/save")
public String saveProcess(@Valid MemberDTO memberDTO, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//오류가 있으면 회원 가입페이지로 이동
if (bindingResult.hasErrors()) {
return "redirect:/member/save";
}
if (!memberDTO.getPassword().equals(memberDTO.getPasswordConfirm())) {
bindingResult.rejectValue("passwordConfirm", "<PASSWORD>",
"2개의 비밀번호가 일치하지 않습니다.");
return "redirect:/member/save";
}
//회원 ID 또는 이메일 주소가 존재할 경우 예외 발생처리
try {
memberDTO.setRoleType(RoleType.USER);
memberService.save(memberDTO);
} catch (DataIntegrityViolationException e) {
e.printStackTrace();
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "redirect:/member/save";
} catch (Exception e) {
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "redirect:/member/save";
}
//회원 리스트 조회
List<MemberDTO> memberDTO1 = memberService.getmemberList();
//닉네임이 있으면
if (memberDTO1.get(0).getNickName().equals(memberDTO.getNickName())) {
redirectAttributes.addFlashAttribute("error",
"닉네임이 중복입니다.");
return "redirect:/member/save";
}
redirectAttributes.addFlashAttribute("successMassage",
"가입 되었습니다.");
return "redirect:/login";
}
//수정
@GetMapping("/member/update")
public String updateForm(Long id, Model model) {
MemberDTO memberDTO = memberService.findById(id);
model.addAttribute("data", memberDTO);
return "member/update";
}
@PostMapping("/member/update")
public String updateProcess(MemberDTO memberDTO, RedirectAttributes redirectAttributes) {
memberService.update(memberDTO);
redirectAttributes.addFlashAttribute("successMassage",
"수정 되었습니다.");
return "redirect:/";
}
//삭제
@GetMapping("/member/delete")
public String deleteProcess(Long id, RedirectAttributes redirectAttributes) {
memberService.delete(id);
redirectAttributes.addFlashAttribute("successMassage",
"삭제 되었습니다.");
return "redirect:/";
}
//전체조회
@GetMapping("/member/list")
public String memberList(Model model) {
List<MemberDTO> memberDTO = memberService.getmemberList();
model.addAttribute("list", memberDTO);
return "member/list";
}
//개별조회
@GetMapping("/member/detail")
public String memberDetail(Long id, Model model) {
MemberDTO memberDTO = memberService.findById(id);
model.addAttribute("data", memberDTO);
return "member/detail";
}
//마이페이지
//로그인 한 경우에만 실행
@PreAuthorize("isAuthenticated()")
@GetMapping("/member/mypage")
public String myPageForm(Authentication authentication,
Model model) {
//보안 인증 된 유저의 이메일로 회원 정보 찾기
String memberEmail = authentication.getName();
MemberDTO memberDTO = memberService.findByEmail(memberEmail);
if (memberDTO == null) {
return "redirect:/login";
}
model.addAttribute("data", memberDTO);
return "mypage/detail";
}
}
@RequiredArgsConstructor
생성자 자동 생성 및 final 변수를 의존관계를 자동으로 설정해 준다.
@GetMapping
@RequestMapping(Method=RequestMethod.GET)과 같다.
@PostMapping
@RequestMapping(Method=RequestMethod.POST)과 같다.
save.html
<!DOCTYPE html>
<html lang="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"
layout:decorate="~{layouts/main}">
<head>
<meta charset="UTF-8">
<title>AccountBook</title>
<!-- bootstrap -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- google font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Single+Day&display=swap" rel="stylesheet">
<style>
.row {
font-family: "Single Day", cursive;
font-weight: 500;
font-style: normal;
font-size: 15px;
background-color: ivory;
padding: 20px;
margin: auto;
}
.button {
background-color: #f4511e;
border: none;
color: white;
width: 100%;
padding: 10px 32px;
text-align: center;
font-size: 16px;
margin: 10px 10px;
opacity: 0.3;
transition: 0.3s;
display: inline-block;
text-decoration: none;
cursor: pointer;
}
.button:hover {
opacity: 1
}
</style>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<!-- 여백 -->
<div class="col-sm-3"></div>
<!-- 내용 -->
<div class="col-sm-6">
<div class="container mt-5 mb-5 p-5 my-5">
<!-- 회원 가입 페이지 -->
<form th:action="@{/member/save}" method="post" th:object="${data}"
onsubmit="return validateForm()">
<div th:replace="~{fragments/error :: formErrorsFragment}"></div>
<div class="mb-3 mt-3">
<label for="email" class="form-label">이메일 : </label>
<input type="email" class="form-control" name="email"
id="email" th:field="*{email}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호 : </label>
<input type="password" class="form-control" name="password"
id="password" th:field="*{password}" required>
<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}"
class="alert alert-danger"></div>
</div>
<div class="mb-3">
<label for="passwordConfirm" class="form-label">비밀번호 : </label>
<input type="password" class="form-control" name="passwordConfirm"
id="passwordConfirm" th:field="*{passwordConfirm}" onkeyup>
<div th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}"
class="alert alert-danger"></div>
</div>
<div class="mb-3">
<label for="name" class="form-label">회원명 : </label>
<input type="text" id="name" class="form-control"
name="name" th:field="*{name}" required>
</div>
<div class="mb-3">
<label for="nickName" class="form-label">닉네임 : </label>
<input type="text" id="nickName" class="form-control"
name="nickName" th:field="*{nickName}" required>
</div>
<div class="mb-3">
<label for="phone" class="form-label">전화번호 : </label>
<input type="text" id="phone" class="form-control"
name="phone" th:field="*{phone}" required>
</div>
<button type="submit" class="button">
submit
</button>
</form>
</div>
</div>
<!-- 여백 -->
<div class="col-sm-3"></div>
</div>
</div>
<th:block layout:fragment="script">
<script type="text/javascript">
//이메일 유효성 검사 & 비밀번호 일치 여부 검사 수행
function validateForm() {
if (validateEmail() && validatePassword()) {
return true; // 폼 제출
} else {
return false; // 폼 제출 방지
}
}
//이메일 유효성 검사
function validateEmail() {
var emailInput = document.getElementById('email');
var email = emailInput.value;
var emailRegex = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;
if (emailRegex.test(email)) {
return true;
} else {
alert('유효하지 않은 이메일 주소입니다.');
emailInput.focus();
return false;
}
}
//비밀번호 일치 여부 검사
function validatePassword() {
var password = document.getElementById("password").value;
var confirmPassword = document.getElementById("passwordConfirm").value;
if (password !== confirmPassword) {
alert("비밀번호와 비밀번호 확인이 일치하지 않습니다.");
return false;
}
return true;
}
</script>
<script th:inline="javascript">
/* 작업 성공했을 때 성공메세지 창을 출력 */
var successMessage = /*[[ ${successMessage} ]]*/ null;
if (successMessage) {
alert(successMessage);
}
</script>
<script th:inline="javascript">
/* 에러가 있을 때 에러메세지 창을 출력 */
var errorMessage = /*[[ ${errorMessage} ]]*/ null;
if (errorMessage) {
alert(errorMessage);
}
</script>
</th:block>
</body>
</html>
email 형식이 맞는지 확인하는 유효성 검사를 하는 script 코드를 작성하였고,
password도 한 번 더 입력하게 하여 불 일치 시 폼 제출을 막아 데이터를 전송할 수 없게 하였다.
detail.html
<!DOCTYPE html>
<html lang="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"
layout:decorate="~{layouts/main}">
<head>
<meta charset="UTF-8">
<title>AccountBook</title>
<!-- bootstrap -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- google icon -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
<!-- google font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Single+Day&display=swap" rel="stylesheet">
<style>
.row {
font-family: "Single Day", cursive;
font-weight: 500;
font-style: normal;
font-size: 15px;
background-color: ivory;
padding: 20px;
margin: auto;
}
h3 {
font-family: "Nanum Pen Script", cursive;
font-weight: 400;
font-size: 30px;
font-style: normal;
padding-bottom: 50px;
margin: auto;
}
.button {
background-color: #f4511e;
border: none;
color: white;
width: 100%;
padding: 10px 32px;
font-size: 20px;
text-align: center;
margin: 2px 2px;
opacity: 0.3;
transition: 0.3s;
display: inline-block;
text-decoration: none;
cursor: pointer;
}
.button:hover {
opacity: 1
}
.p1 {
font-weight: bold;
}
.noto-color-emoji-regular {
font-family: "Noto Color Emoji", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 20px;
}
</style>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<!-- 여백 -->
<div class="col-sm-3"></div>
<!-- 내용 -->
<div class="col-sm-6">
<div class="container mt-5 mb-5 p-5 my-5">
<h3>My Page ♥</h3>
<input type="hidden" name="memberId" th:value="${data.memberId}">
<p>
<span class="p1" th:text="${data.nickName}"/>
님, 반갑습니다.
<span class="noto-color-emoji-regular">😍</span>
</p>
<hr>
<br>
<p>
회원 정보 수정
<span class="noto-color-emoji-regular">️🧐</span>
</p>
<button type="button" class="button"
th:onclick="|location.href='@{/member/update(id=${data.memberId})}'|"
sec:authorize="hasAnyRole('USER', 'ADMIN')">
<span class="spinner-border spinner-border-sm"></span>
update
</button>
<br>
<br>
<hr>
<br>
<p>
회원 탈퇴
<span class="noto-color-emoji-regular">😱️</span>
</p>
<button type="button" class="button"
th:onclick="|confirmDelete(${data.memberId})|"
sec:authorize="hasAnyRole('USER', 'ADMIN')">
<span class="spinner-border spinner-border-sm"></span>
delete
</button>
</div>
</div>
<!-- 여백 -->
<div class="col-sm-3"></div>
</div>
</div>
<th:block layout:fragment="script">
<script th:inline="javascript">
//회원 탈퇴 시 더블 체크
function confirmDelete(memberId) {
if (window.confirm("이 계정을 정말로 삭제하시겠습니까?")) {
// 서버로 삭제 요청 보내기
fetch(`/member/delete?id=${memberId}`, {
method: 'DELETE'
})
.then(response => {
if (response.ok) {
// 삭제 성공 시 사용자에게 메시지 표시
alert("계정이 성공적으로 삭제되었습니다.");
// 삭제 후 리디렉션
window.location.href = "/member/list";
} else {
// 삭제 실패 시 사용자에게 메시지 표시
alert("계정 삭제 중 오류가 발생했습니다. 다시 시도해주세요.");
}
})
.catch(error => {
// 네트워크 오류 등 예기치 못한 오류 발생 시 사용자에게 메시지 표시
alert("계정 삭제 중 오류가 발생했습니다. 다시 시도해주세요.");
console.error('Error:', error);
});
} else {
// 사용자가 취소를 누른 경우
// 아무 작업도 하지 않음
}
}
</script>
</th:block>
</body>
</html>
회원 탈퇴 시 중복 체크
혹여나 잘못 눌렀을 경우, 실수를 방지하기 위해 회원 탈퇴 시 확인 여부를 한 번 더 체크하는 script를 작성하였다.
화면 캡쳐