개발일지

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;
}

 

@Email
이메일 형식 유효성 검사

@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를 작성하였다.

 

 

 

 

화면 캡쳐

main page(로그인 했을 때 나타나는 페이지 목록이다.)

 

main page(로그인 했을 때 나타나는 페이지 목록이다.)

 

 

main page(로그인 했을 때 나타나는 페이지 목록이다. 이모티콘을 넣어봤다. 귀엽)