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를 사용했다.
- 각 항목마다 유효성 검사를 진행했다.
- 아이디 : 빈값 체크, 정규식 검사 (가장 중요한 중복검사는 JPA 적용하면서 하기로)
- 닉네임 : 빈값체크, 정규식 검사 (중복검사는 JPA적용할 때 하기로)
- 패스워드 : 빈값 체크, 정규식 검사
- 패스워드 확인 : 빈값 체크, 패스워드와 동일한지 확인
- 바이오 : 필수항목은 아니지만 120자 이상 작성하지 못하게끔 해놓았고 하단에 글자수를 보여주었다.
- 프로필 사진 : 필수항목은 아니지만 이미지 파일만 첨부하도록 해두었고 이미지 파일 첨부시 미리보기가 가능하다.
[구현화면]
- 그렇게 구현된 회원가입 화면.
- 정말 고생 많이 했다. 특히 부트스트랩을 이용해서 내가 원하는 비주얼을 보여주고 싶다는 마음이 크다보니 그 부분이 쉽지 않았다. 게다가 지금 보면 알겠지만 프로필 사진은 가운데 정렬이 죽어라 해도 안됨.....왜 안되는겨....하.....
728x90
320x100
'💻 뚝딱뚝딱 > (구) 북북클럽' 카테고리의 다른 글
[개발일지#029] 타임라인 등록 수정하기 (레이아웃 변경) (0) | 2024.03.22 |
---|---|
[개발일지#028] 로그인 화면 수정하기 (레이아웃 변경 및 아이디 기억하기 기능 구현) (0) | 2024.03.20 |
[개발일지#026] 파일첨부 기능 만들기 (회원 프로필 이미지) (+) 피드백 반영 (2) | 2024.03.15 |
[개발일지#024] 인터셉터를 활용하여 로그인 구현하기 (3) | 2024.03.11 |
[개발일지#023] Validation 설정하기 (서버검증) (0) | 2024.03.08 |