본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지#027] 회원 컬럼 추가하기 / 회원가입 화면 수정 (유효성검사 등)

by 뚜루리 2024. 3. 19.
728x90
320x100
[참고]
김영한님 스프링 강의를 바탕으로 진행되는 토이프로젝트의 과정을 기록하는 글입니다. 
둥근 피드백은 언제나 환영입니다.
[오늘의 개발내용]
1. 회원 테이블의 컬럼 추가하기
2. 가입용 객체 수정하기
3. 수정용 객체 만들기
4. 회원 레파지토리 수정하기
5. 회원 컨트롤러 수정하기
6. 서버 유효성 검사 분기처리하기
7. 회원가입 폼 수정하기

 

[서론]

현재 JPA로 전환하기 전 어느정도 기본적인 기능은 구현되어 있는 상태이고

JPA 전환하기 전에 각 화면마다 필요한 기능들을 추가하고 다듬어보려 한다. 

그리고 이번에는 회원 테이블에 필요한 컬럼을 추가하고, 회원가입 화면을 수정해보려 함.

 

 

1. 회원 테이블의 컬럼 추가하기

Member.java

package toyproject.bookbookclub.domain.Members;

import lombok.Getter;
import lombok.Setter;
import toyproject.bookbookclub.domain.UploadFile;

import java.time.LocalDateTime;

@Getter @Setter
public class Member {

    private String id;
    private String NickName;
    private String password;
    private LocalDateTime firstJoinDate; //새로운 컬럼 추가
    private LocalDateTime lastUpdateDate; //새로운 컬럼 추가
    private String Bios; //새로운 컬럼 추가
    private UploadFile profileImage;

    ///생략///

}
  • 가입일과 마지막 정보 수정일 그리고 프로필 소개에 해당되는 3개의 컬럼을 추가하였다. 

 

 

2. 가입용 객체 수정하기

JoinForm.java

package toyproject.bookbookclub.domain.Members;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDateTime;

@Data
public class JoinForm {

    private String id;
    private String NickName;
    private String password;
    private LocalDateTime firstJoinDate; //추가
    private String Bios; //추가
    private MultipartFile profileImage;

}
  • 가입할 때는 최종 수정일은 필요 없고 가입일 컬럼만 필요하기 때문에 최종수정일 컬럼은 뺐고, 그 것을 제외하고 회원 테이블에서 생성한 컬럼을 모두 넣었다.
  • (+) 진짜 비지니스적으로 간다면 회원가입 할 때 프로필 사진이나 내 소개 등은 받지 않고 일단 간단하게 아이디, 비밀번호 등으로만 가입하게끔 유도하고 가입 후에 프로필 사진이나 내소개 등을 수정할 수 있게끔 하는 것이 맞다는 생각이 들었다. 그건 나중에 좀 더 구체화 될 때 변경하는 것으로 염두해두고 일단 작업!

 

 

3. 수정용 객체 만들기

UpdateForm.java

package toyproject.bookbookclub.domain.Members;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDateTime;

@Data
public class UpdateForm {

    private String NickName;
    private String password;
    private LocalDateTime localDateTime;
    private String Bios;
    private MultipartFile profileImage;

}
  • 가입용 객체를 만든김에 수정용 객체도 만들어 줬다. 
  • 가입용 객체와 다른 것은 여기서는 최종수정일을 가지고가고 가입용객체는 가입일을 가지고 간다는 것! 나머지는 동일하다.

 

 

4. 회원레파지토리 수정하기

MemberRepository.java

package toyproject.bookbookclub.domain.Members;

import org.apache.el.stream.Stream;
import org.springframework.stereotype.Repository;
import toyproject.bookbookclub.domain.UploadFile;

import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class MemberRepository {

    private static final Map<String, Member> store = new ConcurrentHashMap<>();


    public Member save(Member member){
        member.setFirstJoinDate(LocalDateTime.now()); //추가
        store.put(member.getId(), member);
        return member;
    }

    //////생략//////
    
    public void update(String memberId, UpdateForm updateParam, UploadFile uploadFile){
        Member findMember = findById(memberId);
        findMember.setPassword(updateParam.getPassword());
        findMember.setNickName(updateParam.getNickName());
        findMember.setBios(updateParam.getBios()); //추가
        findMember.setProfileImage(uploadFile); //추가
        findMember.setLastUpdateDate(LocalDateTime.now()); //추가
    }

    //////생략//////


}
  • 저장 메소드에는 가입 당시 일자와 시간을 넣어주고 수정 메서드에도 새로 생성한 컬럼들을 넣어준다.
  • 이 부분은 '왜 이렇게 해야 하지?' 라며 의문이 드는 부분이 있어서 추후에 수정해야 할 것 같다.

 

 

 

4. 회원 컨트롤러 수정하기

BasicMemberController.java

package toyproject.bookbookclub.web.member.basic;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.cglib.core.Local;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;
import toyproject.bookbookclub.domain.FileStore;
import toyproject.bookbookclub.domain.Members.Member;
import toyproject.bookbookclub.domain.Members.JoinForm;
import toyproject.bookbookclub.domain.Members.MemberRepository;
import toyproject.bookbookclub.domain.Members.UpdateForm;
import toyproject.bookbookclub.domain.UploadFile;
import toyproject.bookbookclub.web.validation.MemberValidator;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;


@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/members")
public class BasicMemberController {


	//////생략//////
    
    /**
     * 회원 가입
     * @param form
     * @param bindingResult
     * @param redirectAttributes
     * @return
     * @throws IOException
     */
    @PostMapping("/join")
    public String join(@Validated @ModelAttribute("member") JoinForm form
    , BindingResult bindingResult
    , RedirectAttributes redirectAttributes) throws IOException {

        if (bindingResult.hasErrors()){
            return "basic/joinForm";
        }
        UploadFile profileImage = fileStore.storeFile(form.getProfileImage());

        Member member = new Member();
        member.setId(form.getId());
        member.setPassword(form.getPassword());
        member.setNickName(form.getNickName());
        member.setBios(form.getBios());
        member.setProfileImage(profileImage);

        Member savedMember = memberRepository.save(member);
        redirectAttributes.addAttribute("memberId", savedMember.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/basic/members/{memberId}";
    }

	//////생략//////

    /**
     * 회원 정보 수정
     * @param memberId
     * @param form
     * @return
     */
    @PostMapping("/{memberId}/edit")
    public String edit(@PathVariable String memberId
            , @ModelAttribute("member") UpdateForm form
            , BindingResult bindingResult
            , RedirectAttributes redirectAttributes) throws IOException {

        if (bindingResult.hasErrors()){
            return "basic/editForm";
        }

        UploadFile profileImage = fileStore.storeFile(form.getProfileImage());
        memberRepository.update(memberId, form, profileImage);
        return "redirect:/basic/members/{memberId}";
    }

	//////생략//////

}
  • 가입 컨트롤러에서는 추가된 컬럼들을 넣어주었고, 수정 컨트롤러에도 추가된 컬럼을 넣었고 가입과 똑같이 서버 유효성검사를 넣어주었다. 

 

5. 서버 유효성검사 분기처리하기

MemberValidator.java

package toyproject.bookbookclub.web.validation;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import toyproject.bookbookclub.domain.Members.Member;
import toyproject.bookbookclub.domain.Members.JoinForm;
import toyproject.bookbookclub.domain.Members.UpdateForm;

@Component
public class MemberValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Member.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        if (target instanceof UpdateForm) { //분기
            UpdateForm member = (UpdateForm) target;
        } else if (target instanceof JoinForm) {
            JoinForm member = (JoinForm) target;
        }
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "id", "required");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "nickName", "required");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "password", "required");
    }

}

가입과 수정 각가 유효성 검사가 필요한데 사실 유효성 체크하는 항목이 같아서 따로 분기처리를 안했더니 target을 객체로 변환할 때 가입과 수정 폼의 형태가 달라서 하나로는 같이 쓸수가 없더라. 그래서 어떻게 할까 하다가 GPT한테 물어본 결과를 토대로 수정했다. 

 

(+) ChatGPT 물어보기

 

첨엔 컨트롤러 안에 이 부분을 수정하면 되는줄 알고 이 소스코드를 들고 가져가서 물어봤음.

 

개떡같이 물어봐도 찰떡같이 의도를 알아채고 방법을 알려줬는데 나는 가입과 수정 컨트롤러를 하나에 다 쓰고 있어서 다시 물어보았다. 

 

역시나 개떡 같이 물어봐도 찰떡같이 알려주는 피티때문에 나는 1번 방법을 사용하기로 했다. 1번 방법이 소스 관리하기 쉬울것 같아서..!

 

 

6. 회원가입 폼 수정하기

JoinForm.html

  • 회원가입 폼 Html은 정말 대대적인 수정이 들어갔다...여기서 가장 많은 시간을 쏟았음. 
  • 일단 서버 유효성 검증을 하긴 했지만 서버 유효성 검증으로 확인하기 어려운 (ex. keyup 시 유효성검사 등) 것들이 있어서 디테일한 유효성 검사는 클라이언트 방식 즉, 자바스크립트로 해결하기로 했다. 그러나 클라이언트 방식으로 유효성검사를 하면 사용자가 임의로 수정할 수 있는 가능성도 있어서 디테일한 검증은 클라이언트에서 하고 그 후에 혹시 모를 꼭 필요한 유효성 검사는 서버로 하는 식으로 진행함.
  • 아무튼 클라이언트 방식으로 유효성검사를 하려면 자바스크립트의 소스가 길어짐으로 따로 Js파일을 분리하기로 했다. 

1) 따로 분리한 js파일 Import 하기

<script th:src="@{/js/basic/joinForm.js}"></script>

 

(+) 뻘 짓한 것

아무리 해도 import가 안되는 것임.......알고보니 내가 로그인 기능을 미리 구현해 둬서 Js파일을 제외시켰어야 하는데 제외를 안해서.....생긴....엄청 삽질함. 

WebConfig.java

package toyproject.bookbookclub.web;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import toyproject.bookbookclub.web.interceptor.LogInterceptor;
import toyproject.bookbookclub.web.interceptor.LoginCheckInterceptor;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**", "/js/**");

       registry.addInterceptor(new LoginCheckInterceptor())
               .order(2)
               .addPathPatterns("/**")
               .excludePathPatterns(
                       "/", "/basic/members/join", "/login", "/logout",
                       "/css/**", "/*.ico", "/error", "/error-page/**", "/js/**");
    }
}

 

 

2) 소스 수정하기

<form id="joinForm" action="member.html" th:action th:object="${member}"
          method="post" enctype="multipart/form-data">

        <div style="text-align: center;">
            <svg id="imagePreviewEmpty" xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="currentColor" class="bi bi-person-circle" viewBox="0 0 16 16" style="color: lightgrey;">
                <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/>
                <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1"/>
            </svg>

        </div>

        <div style="text-align: center;">
            <img id="imagePreview" src="#" alt="미리보기" style="display:none; max-width: 100%;">
        </div>

        <label for="profileImage" th:text="#{label.member.profileImage}">프로필 이미지</label>
        <input type="file" id="profileImage" th:field="*{profileImage}" class="form-control" accept="image/*">

        <div class="form-floating input-group mb-3">
            <input type="text" id="id" th:field="*{id}" th:errorclass="is-invalid"
                   class="form-control" placeholder="회원 아이디를 입력하세요">
            <label for="id" th:text="#{label.member.id}">회원 아이디</label>
            <button type="button" class="btn btn-outline-secondary">중복검사</button>
        </div>
        <div class="is-invalid" th:errors="*{id}">회원ID 오류</div>
        <div class="is-invalid" id="idError" style="display: none;">회원ID 오류</div>

        <div class="form-floating">
            <input type="text" id="nickName" th:field="*{nickName}" th:errorclass="is-invalid"
                   class="form-control" placeholder="닉네임을 입력하세요">
            <label for="nickName" th:text="#{label.member.NickName}">닉네임</label>
            <div class="is-invalid" th:errors="*{nickName}">별명 오류</div>
            <div class="is-invalid" id="nickNameError" style="display: none;">별명 오류</div>
        </div>

        <div class="form-floating">
            <input type="password" id="password" th:field="*{password}" th:errorclass="is-invalid"
                   class="form-control" placeholder="비밀번호를 입력하세요">
            <label for="password" th:text="#{label.member.password}">비밀번호</label>
        </div>
        <div class="is-invalid" th:errors="*{password}">비밀번호 오류</div>
        <div class="is-invalid" id="passwordError" style="display: none;">비밀번호 오류</div>

        <div class="form-floating">
            <input type="password" id="checkPassword" name="checkPassword"
                   class="form-control" placeholder="비밀번호를 다시 입력하세요">
            <label for="checkPassword" >비밀번호 확인</label>
            <div class="valid-feedback" id="checkPasswordSuccess" style="display: none;">비밀번호 일치</div>
            <div class="is-invalid" id="checkPasswordError" style="display: none;">비밀번호 미일치</div>
        </div>

        <div class="form-floating">
            <textarea class="form-control" id="bios" th:field="*{bios}" th:errorclass="is-invalid"
                      placeholder="소개글을 입력해주세요." style="height: 100px"></textarea>
            <label for="bios">소개글</label>
            <div id="biosCounter" style="font-size: 0.87em; color: gray; text-align: right;">0/120</div>
            <div class="is-invalid" id="biosError" style="display: none;">자기소개 글자수 제한</div>
        </div>

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="button" id="saveBtn" th:text="#{button.member.save}">회원 가입</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='members.html'"
                        th:onclick="|location.href='@{/login}'|" type="button"
                th:text="#{button.cancel}">취소</button>
            </div>
        </div>
    </form>
  • 유효성 체크가 필요한 항목은 모두 키보드 입력시, 저장버튼 클릭시에 모두 체크한다.
  • 유효성검사에 걸릴 경우 하단에 안내문구를 띄운다.

이 위의 가장 큰 두 기준을 가지고 수정을 했다. 

  • 개인적으로 타임리프가 서버 검증 방식에 대한 지원을 해준 덕분에 소스가 많이 간결하고 짧아졌는데 사실 클라이언트 검증하면서 좀 복잡해진 경향이 있어서 이게 맞는지 다시 돌아보긴 해야할 것 같다. 

 

 

joinForm.js

function previewImage(event) {
    const input = event.target;

    // 파일이 선택되지 않았거나 선택된 파일이 이미지가 아닌 경우 미리보기를 표시하지 않음
    if (!input || !input.files || input.files.length === 0 || !input.files[0].type.startsWith('image/')) {
        return;
    }

    // FileReader 객체를 사용하여 이미지 파일을 읽어오고 미리보기를 업데이트함
    const reader = new FileReader();
    reader.onload = function () {
        const preview = document.getElementById('imagePreview');
        preview.src = reader.result;
        preview.style.display = 'block'; // 이미지 미리보기 표시
    }
    reader.readAsDataURL(input.files[0]); // 파일을 base64 문자열로 읽어오기
    document.getElementById("imagePreviewEmpty").style.display = 'none';
}

function idValidate(){
    let idInput = document.getElementById('id');
    let idError = document.getElementById('idError');

    if (idInput.value.trim() === '') {
        idInput.classList.add('is-invalid');
        idError.style.display = '';
        idError.textContent = 'ID는 필수입니다.';
    } else {
        idInput.classList.remove('is-invalid');
        idError.style.display = 'none';
    }

    let idRegExp = /^[a-zA-Z0-9!_.-]{4,12}$/;
    if( !idRegExp.test(idInput.value) ) {
        idInput.classList.add('is-invalid');
        idError.style.display = '';
        idError.textContent = '4~12글자, 영어나 숫자만 가능합니다. (입력불가특수문자 : @\#$%&=/)';
    } else {
        idInput.classList.remove('is-invalid');
        idError.style.display = 'none';
    }
}

function nickNameValidate(){
    let nickNameInput = document.getElementById('nickName');
    let nickNameError = document.getElementById('nickNameError');

    if (nickNameInput.value.trim() === '') {
        nickNameInput.classList.add('is-invalid');
        nickNameError.style.display = '';
        nickNameError.textContent = '닉네임은 필수입니다.';
    } else {
        nickNameInput.classList.remove('is-invalid');
        nickNameError.textContent = 'none';
    }

    let nicknameRegExp = /^[a-zA-Z0-9가-힣]{1,20}$/;
    if( !nicknameRegExp.test(nickNameInput.value) ) {
        nickNameInput.classList.add('is-invalid');
        nickNameError.style.display = '';
        nickNameError.textContent = '1~20글자, 한글, 영어, 숫자만 가능합니다.';
    } else {
        nickNameInput.classList.remove('is-invalid');
        nickNameError.style.display = 'none';
    }
}

function passwordValidate() {
    let passwordInput = document.getElementById('password');
    let passwordError = document.getElementById('passwordError');
    if (passwordInput.value.trim() === '') {
        passwordInput.classList.add('is-invalid');
        passwordError.textContent = '비밀번호는 필수 입니다.';
    } else {
        passwordInput.classList.remove('is-invalid');
        passwordError.textContent = '';
    }

    let passwordRegExp = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/;
    if (!passwordRegExp.test(passwordInput.value)) {
        passwordInput.classList.add('is-invalid');
        passwordError.style.display = '';
        passwordError.textContent = '최소 8자 이상 영문, 한글, 숫자, 특수문자가 포함되어야 합니다.';
    } else {
        passwordInput.classList.remove('is-invalid');
        passwordError.style.display = 'none';
    }
}

function checkPasswordValidate(){
    let passwordInput = document.getElementById('password');
    let checkPasswordInput = document.getElementById('checkPassword');

    let checkPasswordSuccess = document.getElementById('checkPasswordSuccess');
    let checkPasswordError = document.getElementById('checkPasswordError');

    if ( passwordInput.value.trim() !== '' ) {
        if (passwordInput.value === checkPasswordInput.value) {
            checkPasswordSuccess.style.display = '';
            checkPasswordError.style.display = 'none';
            checkPasswordInput.classList.remove('is-invalid');
            checkPasswordInput.classList.add('is-valid');
            checkPasswordSuccess.textContent = '비밀번호가 일치합니다.';
        } else {
            checkPasswordSuccess.style.display = 'none';
            checkPasswordError.style.display = '';
            checkPasswordInput.classList.add('is-invalid');
            checkPasswordInput.classList.remove('is-valid');
            checkPasswordError.textContent = '비밀번호가 일치하지 않습니다.';
        }
    }
}

function biosValidate(){
    let bioInput = document.getElementById('bios');
    let maxLength = 120;
    let textLength = bioInput.value.length;
    let biosError = document.getElementById("biosError");
    let counter = document.getElementById('biosCounter');

    if (textLength > maxLength) {
        bioInput.classList.add('is-invalid');
        bioInput.value = bioInput.value.substring(0, maxLength);
        biosError.style.display = 'block';
        biosError.textContent = '120자까지만 입력이 가능합니다.';
        counter.style.color = 'red';
    } else {
        bioInput.classList.remove('is-invalid');
        biosError.style.display = 'none';
        biosError.textContent = '';
        counter.style.color = 'gray';
    }
    counter.textContent = textLength + '/' + maxLength;
}

document.addEventListener('DOMContentLoaded', function () {

    document.getElementById('saveBtn').addEventListener('click', function(event) {
        event.preventDefault();
        if (!document.getElementById('id').classList.contains('is-invalid')
            && !document.getElementById('nickName').classList.contains('is-invalid')
            && !document.getElementById('password').classList.contains('is-invalid')
            && !document.getElementById('checkPassword').classList.contains('is-invalid')) {
            document.getElementById('joinForm').submit();
        }
    });

    document.getElementById('id').addEventListener('keyup', function() { idValidate(); });
    document.getElementById('nickName').addEventListener('keyup', function() { nickNameValidate(); });
    document.getElementById('password').addEventListener('keyup', function() { passwordValidate(); });
    document.getElementById('checkPassword').addEventListener('keyup', function() { checkPasswordValidate(); });
    document.getElementById('bios').addEventListener('keyup', function() { biosValidate(); });
    document.getElementById('profileImage').addEventListener('change', previewImage);


});
  • 일단 바닐라JS를 사용했다. 
  • 각 항목마다 유효성 검사를 진행했다.
    1. 아이디 : 빈값 체크, 정규식 검사 (가장 중요한 중복검사는 JPA 적용하면서 하기로)
    2. 닉네임 : 빈값체크, 정규식 검사 (중복검사는 JPA적용할 때 하기로)
    3. 패스워드 : 빈값 체크, 정규식 검사
    4. 패스워드 확인 : 빈값 체크, 패스워드와 동일한지 확인
    5. 바이오 : 필수항목은 아니지만 120자 이상 작성하지 못하게끔 해놓았고 하단에 글자수를 보여주었다.
    6. 프로필 사진 : 필수항목은 아니지만 이미지 파일만 첨부하도록 해두었고 이미지 파일 첨부시 미리보기가 가능하다.

 

 

[구현화면]

  • 그렇게 구현된 회원가입 화면.
  • 정말 고생 많이 했다. 특히 부트스트랩을 이용해서 내가 원하는 비주얼을 보여주고 싶다는 마음이 크다보니 그 부분이 쉽지 않았다. 게다가 지금 보면 알겠지만 프로필 사진은 가운데 정렬이 죽어라 해도 안됨.....왜 안되는겨....하.....
728x90
320x100