프로젝트/Recipository

[Dev] 23.01.05. 마이 페이지 구성

규글 2023. 1. 5. 18:55

 사용자 정보를 수정하는 기능에 앞서 마이 페이지를 구성했다. 12월 29일의 게시글을 기준으로 일주일 정도 시간이 지났는데, 연말이고 연초이기 때문인 것은 아니고 그저 조금 게을렀고 작업했던 내용을 되돌렸기 때문이다. 그래서 생각보다 작업 내용은 간단하다.

 

작업

my-page.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>my-page</title>
    <meta name="_csrf" th:content="${_csrf.token}">
    <meta name="_csrf_header" th:content="${_csrf.headerName}">
    <link rel="stylesheet" href="/lib/css/bootstrap.css">
    <link rel="stylesheet" href="/lib/css/navigation-bar.css">
    <link rel="stylesheet" href="/lib/css/my-page.css">
</head>
<style>
    .menubox{
        display:inline-block;
    }
</style>
<body>
<div class="container">
    <div th:replace="fragments/navibar :: navi"></div>
    <h1>My Page</h1>
    <div class="row">
        <div class="col-2 text-center" style="margin-left: 50px;">
            <ul>
                <li>
                    <button class="btn btn-light btn-lg"
                            onclick="location.href='/user/my-page'">내 게시글</button>
                </li>
                <li>
                    <button class="btn btn-light btn-lg"
                            onclick="location.href='/user/contents-form'">글쓰기</button>
                </li>
                <li>
                    <button class="btn btn-light btn-lg">글 삭제</button>
                </li>
                <li>
                    <button class="btn btn-light btn-lg"
                            id="profileBtn">프로필</button>
                </li>
                <li>
                    <button class="btn btn-light btn-lg">회원 탈퇴</button>
                </li>
            </ul>
        </div>
        <div class="d-flex col-1" style="height: 450px;">
            <div class="vr"></div>
        </div>
        <div id="templateDiv" class="col templateDiv">
            <h2>내 글 모아보기</h2>
            <div th:replace="fragments/menubox :: menubox"></div>
        </div>
    </div>
</div>
</body>
<script src="/app/my-page.js"></script>
</html>

 로그인 시에 navigation bar에서 볼 수 있는 마이 페이지 버튼을 통해 이동할 수 있는 마이 페이지에 대한 markup 이다. 이전에 댓글 관련 작업을 할 때에 댓글 작성 시 화면 전환 없이 화면을 동적으로 구성하려고 했으나 포기했던 적이 있다. 이번에는 그것을 가능하게 할 수 있는 방법을 찾아내어 사용해보았다.

 보여지는 코드의 <body> tag 마지막 부분을 보면 templateDiv 라는 id를 가진 div tag를 볼 수 있을 것이다. 해당 부분만이 페이지 이동 없이 변화할 수 있도록 작업했다.

 

my-page.js

    document.querySelector("#profileBtn").addEventListener("click", function(e){
        var url = "/user/my-profile";

        var promise = fetch(url);

        promise.then(function(response){
            return response.text();
        }).then(function(data){
            var dom = new DOMParser();
            dom = dom.parseFromString(data, "text/html");
            var newDiv = dom.getElementsByClassName("templateDiv")[0];
            document.querySelector(".templateDiv").replaceWith(newDiv);

            addBtnEvent();
        });
    });

    function addBtnEvent(){
        document.querySelector("#profileFormBtn").addEventListener("click", function(e){
            var url = "/user/profile-form";

            var promise = fetch(url);

            promise.then(function(response){
                return response.text();
            }).then(function(data){
                var dom = new DOMParser();
                dom = dom.parseFromString(data, "text/html");
                var newDiv = dom.getElementById("profileForm");
                document.querySelector("#myProfile").replaceWith(newDiv);

                addProfileEvent();
            });
        });

        document.querySelector("#pwdFormBtn").addEventListener("click", function(e){
            var url = "/user/password-form";

            var promise = fetch(url);

            promise.then(function(response){
                return response.text();
            }).then(function(data){
                var dom = new DOMParser();
                dom = dom.parseFromString(data, "text/html");
                var newDiv = dom.getElementById("pwdForm");
                document.querySelector("#myProfile").replaceWith(newDiv);

                addPwdEvent();
            });
        });
    }

    var nameValidation = true;
    var pwdValidation = false;

    function addProfileEvent(){
        // name constraint
        document.querySelector("#name").addEventListener("input", function(){
            nameValidation = false;
            var regex = /^[a-zA-Z0-9가-힁]{4,12}$/;

            var duplCheckBtn = document.querySelector(".duplCheckBtn");
            var name = duplCheckBtn.getAttribute("data");

            if(this.value == name){
                document.querySelector("#nameConstraint").innerText = "변경하려면 다른 닉네임을 작성해주세요.";
                nameValidation = true;
                document.querySelector("#nameDuplCheck").value = true;
                duplCheckBtn.style.display = "none";
            } else if(this.value == "" || !regex.test(this.value)){
                document.querySelector("#nameConstraint").innerText = "4~12자로 입력해주세요.";
                nameValidation = false;
                document.querySelector("#nameDuplCheck").value = false;
                duplCheckBtn.style.display = "block";
            } else if(regex.test(this.value)){
                document.querySelector("#nameConstraint").innerText = "중복 확인이 필요합니다.";
                nameValidation = true;
                document.querySelector("#nameDuplCheck").value = false;
                duplCheckBtn.style.display = "block";
            }
        });

        // 이메일, 닉네임 중복 확인
        document.querySelector(".duplCheckBtn").addEventListener("click", function(e){
            e.preventDefault();

            var url = "/duplcheck";

            var name = document.querySelector("#name").value;
            var object = {name};

            duplCheck(url, object, "#name", "닉네임");
        });

        // 프로필 변경 클릭 시 동작
        document.querySelector("#profileUpdateForm").addEventListener("submit", function(e){
            e.preventDefault();

            var nameDuplCheck = document.querySelector("#nameDuplCheck").value;
            var formValidation = nameValidation && nameDuplCheck;

            if(formValidation){
                var url = this.action;
                var formData = new FormData(this);

                var token = document.querySelector("meta[name=_csrf]").content;
                var header = document.querySelector("meta[name=_csrf_header]").content;

                var promise = fetch(url, {
                    method: "POST",
                    headers: {
                        "header": header,
                        "X-Requested-With": "XMLHttpRequest",
                        "X-CSRF-Token": token
                    },
                    body: formData
                });

                promise.then(function(response){
                    return response.json();
                }).then(function(data){
                    if(data.beUpdated){
                        alert("닉네임 정보를 변경했습니다.");
                        location.href = "/user/my-page";
                    } else {
                        if(data.errorMessage != null){
                            var errorList = Object.entries(data.errorMessage);
                            errorList.forEach(error => {
                                document.querySelector("#" + error[0] + "Constraint").innerText = error[1];
                            });
                        }
                        alert("닉네임을 변경할 수 없습니다. 문제가 반복된다면 문의 바랍니다.");
                    }
                });
            } else if(!nameValidation){
                alert("닉네임 정보를 올바르게 입력해주세요.");
            } else if(!nameDuplCheck){
                alert("닉네임 중복 확인이 필요합니다.");
            }
        });
    }

    function addPwdEvent(){
        // password constraint
        document.querySelector("#password").addEventListener("input", function(){
            var regex = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{8,20}$/;

            if(this.value == "" || !regex.test(this.value)){
                document.querySelector("#passwordConstraint").innerText = "영문과 숫자를 합쳐 8자 이상 20자 이하로 입력해주세요.";
            } else {
                document.querySelector("#passwordConstraint").innerText = "사용 가능한 비밀번호 입니다.";
            }

            pwdCheck();
        });

        // 비밀 번호 check
        document.querySelector("#pwdCheck").addEventListener("input", pwdCheck);

        // 프로필 변경 클릭 시 동작
        document.querySelector("#pwdUpdateForm").addEventListener("submit", function(e){
            e.preventDefault();

            if(pwdValidation){
                var url = this.action;
                var formData = new FormData(this);

                var token = document.querySelector("meta[name=_csrf]").content;
                var header = document.querySelector("meta[name=_csrf_header]").content;

                var promise = fetch(url, {
                    method: "POST",
                    headers: {
                        "header": header,
                        "X-Requested-With": "XMLHttpRequest",
                        "X-CSRF-Token": token
                    },
                    body: formData
                });

                promise.then(function(response){
                    return response.json();
                }).then(function(data){
                    if(data.beUpdated){
                        location.href = "/";
                        alert("비밀번호를 변경했습니다. 다시 로그인해주시기 바랍니다.");
                    } else {
                        if(data.errorMessage != null){
                            var errorList = Object.entries(data.errorMessage);
                            errorList.forEach(error => {
                                document.querySelector("#" + error[0] + "Constraint").innerText = error[1];
                            });
                        }
                        alert("비밀번호를 변경할 수 없습니다. 문제가 반복된다면 문의 바랍니다.");
                    }
                });
            } else if(!pwdValidation){
                alert("비밀번호 정보들을 올바르게 입력해주세요.");
            }
        });
    }

    // 이메일, 닉네임 중복확인 function
    function duplCheck(url, object, target1, target2){
        var token = document.querySelector("meta[name=_csrf]").content;
        var header = document.querySelector("meta[name=_csrf_header]").content;

        var promise = fetch(url, {
                method: "POST",
                headers: {
                    "header": header,
                    "X-Requested-With": "XMLHttpRequest",
                    "Content-Type": "application/json",
                    "X-CSRF-Token": token
                },
                body: JSON.stringify(object)
        });

        promise.then(function(response) {
            return response.json();
        }).then(function(data){
            if(data.isChecked){
                document.querySelector(target1 + "Constraint").innerText = "다른 "+ target2 + "을 사용해주세요";
                document.querySelector(target1 + "DuplCheck").value = false;
            } else {
                document.querySelector(target1 + "Constraint").innerText = "사용 가능한 " + target2 + "입니다.";
                document.querySelector(target1 + "DuplCheck").value = true;
            }
        });
    }

    // 비밀 번호 확인 function
    function pwdCheck(){
        var password = document.querySelector("#password").value;
        var pwdCheck = document.querySelector("#pwdCheck").value;
        var regex = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{8,20}$/;

        if(password != pwdCheck){
            document.querySelector("#pwdCheckMsg").innerText = "비밀번호가 일치하지 않습니다.";
            pwdValidation = false;
        } else if(!regex.test(password) && password == pwdCheck){
            document.querySelector("#pwdCheckMsg").innerText = "비밀번호가 일치합니다.";
            pwdValidation = false;
        } else if(regex.test(password) && password == pwdCheck){
            document.querySelector("#pwdCheckMsg").innerText = "비밀번호가 일치합니다.";
            pwdValidation = true;
        }
    }

 다른 파트는 회원 가입에서 작성했던 javascript와 크게 다르지 않고, 중복 확인이나 validation 관련 내용은 동일한 방식으로 구현했다. 다만 화면의 내용을 전환 없이 수정하는 방법에 있어서는 위 이미지에 표기된 부분이 가장 중요하다.

 

 PageController에서는 해당 url의 request가 왔을 때, fragment의 특정 part의 html을 return 하도록 작성했다. 그럼 위와 같이 data를 넘겨받을 수 있는데, 이 친구는 문자열이라 이를 parsing 해주어야 한다.

 

 Parsing 후의 결과, 위 코드에서의 dom과 newDiv를 console에 출력해보면 위와 같은 모습을 볼 수 있다. 때문에 문자열을 parsing 한 뒤, 그 중에서도 특정 부분만을 replaceWith( ) 함수를 통해 원하는 부분의 html 을 바꾸는 방식을 취했다.

 

profile-forms.html

<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">

<div id="myProfile" th:fragment="myProfile" class="col templateDiv"
     style="padding-top: 110px; padding-bottom: 110px;">
  <div class="row col-6" style="float: none; margin:0 auto;">
    <label for="email" class="form-label">Email</label>
    <div class="col">
      <input type="text" th:value="${profileData.email}"
             class="form-control text-center" disabled>
      <input type="text" id="email" name="email" th:value="${profileData.email}"
             class="form-control text-center" style="display: none;">
    </div>
  </div>
  <div class="row col-6" style="float: none; margin:0 auto;">
    <label for="name" class="form-label">Nickname</label>
    <div class="col">
      <input type="text" id="name" name="name" th:value="${profileData.name}"
             class="form-control text-center" required="required">
    </div>
  </div>
  <div class="row col-6" style="float: none; margin:0 auto;">
    <button type="submit" id="profileFormBtn" class="btn btn-primary col">프로필 변경</button>
    <button type="submit" id="pwdFormBtn" class="btn btn-primary col">비밀번호 변경</button>
  </div>
</div>

<div id="profileForm" th:fragment="profileForm" class="col templateDiv"
     style="padding-top: 100px; padding-bottom: 100px;">
  <div class="row col-6" style="float: none; margin:0 auto;">
    <label for="email" class="form-label">Email</label>
    <div class="col">
      <input type="text" id="email" name="email" th:value="${profileData.email}"
             class="form-control text-center" disabled>
    </div>
  </div>
  <form th:action="@{/user/profile/nickname}" method="post" id="profileUpdateForm" style="min-width: 400px;">
    <div class="row col-6" style="float: none; margin:0 auto;">
      <label for="name" class="form-label">Nickname</label>
      <div class="col">
        <input type="text" id="name" name="name" th:value="${profileData.name}"
               class="form-control text-center" required="required">
      </div>
      <button th:data="${profileData.name}" style="display: none; margin-right: 12px;"
              class="duplCheckBtn nameCheck btn btn-secondary col-auto">중복 확인</button>
      <input type="hidden" id="nameDuplCheck" value="true">
      <span id="nameConstraint">변경하려면 다른 닉네임을 작성해주세요.</span>
    </div>
    <div class="row col-6" style="float: none; margin:0 auto;">
      <button type="submit" id="submitBtn" class="btn btn-primary col">프로필 변경</button>
    </div>
  </form>
</div>

<div id="pwdForm" th:fragment="pwdForm" class="col templateDiv"
     style="padding-top: 50px; padding-bottom: 50px;">
  <form th:action="@{/user/profile/password}" method="post" id="pwdUpdateForm" style="min-width: 400px;">
    <div class="row col-6" style="float: none; margin:0 auto;">
      <label for="oldPassword" class="form-label">Password</label>
      <div class="col">
        <input type="text" id="oldPassword" name="oldPassword"
               class="form-control text-center" required="required">
      </div>
    </div>
    <div class="row col-6" style="float: none; margin:0 auto;">
      <label for="password" class="form-label">New Password</label>
      <div class="col">
        <input type="text" id="password" name="password"
               class="form-control text-center" required="required">
      </div>
      <span id="passwordConstraint">영문과 숫자를 합쳐 8자 이상 20자 이하로 입력해주세요.</span>
    </div>
    <div class="row col-6" style="float: none; margin:0 auto;">
      <label for="pwdCheck" class="form-label">Check New Password</label>
      <div class="col">
        <input type="text" id="pwdCheck" name="pwdCheck"
               class="form-control text-center" required="required">
      </div>
      <span id="pwdCheckMsg">비밀 번호가 일치해야 합니다.</span>
    </div>
    <div class="row col-6" style="float: none; margin:0 auto;">
      <button type="submit" id="submitBtn" class="btn btn-primary col">비밀번호 변경</button>
    </div>
  </form>
</div>
</html>

 이것은 바꿀 fragment 들을 세 개의 div 로 구분해서 작성한 것이다.

 

PageController.java

    @GetMapping("/user/my-profile")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public String goMyProfile(Model model,
                              @AuthenticationPrincipal SpUser spUser){

        String email = spUser.getEmail();
        model.addAttribute("profileData", userService.getProfile(email));

        return "fragments/profile-forms :: myProfile";
    }

    @GetMapping("/user/profile-form")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public String goProfileForm(Model model,
                              @AuthenticationPrincipal SpUser spUser){

        String email = spUser.getEmail();
        model.addAttribute("profileData", userService.getProfile(email));

        return "fragments/profile-forms :: profileForm";
    }

    @GetMapping("/user/password-form")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public String goPwdForm(){

        return "fragments/profile-forms :: pwdForm";
    }

 나머지 항목에 있어서도 마찬가지의 형식으로 PageController를 작성해주었다. 굳이 동일한 내용을 반복해서 Model에 넣어줄 필요가 있을까 싶었으나, 화면을 loading 하면서 thymeleaf tag에 맞게 data가 들어가는 구조라 method 마다 따로 해주지 않으면 값이 들어가지 않았다.

 참고한 블로그이다.[각주:1]

 

 

 이를 포함하여 마이 페이지는 내 게시글, 글쓰기, 글 삭제, 프로필, 회원 탈퇴 의 다섯 항목으로 구성되어 있는데, 지금까지의 내용은 프로필에 해당하는 내용이다. 내 게시글은 navigation bar의 마이 페이지 버튼을 누른 것과 동일한 url 요청이고, 글쓰기 또한 index page에서 볼 수 있는 글쓰기 버튼을 눌렀을 때와 동일한 url 요청이다.

 다음 게시글에서 프로필을 변경하는 내용을 이어서 기록할 것이며, 이어서 글 삭제와 회원 탈퇴에 대한 작업을 진행할 것이다.

 

Reference