Project · Etc/Project
[ Project · Travel Road ] 회원가입 시, 프로필 업로드 리팩토링
jimin-log
2023. 6. 15. 11:30
회원가입 시 프로필 업로드
기존에는 프로필사진 없이 회원가입을 진행하는 로직이였는데,
프로필 사진도 가입 시 추가 가능하도록 리팩토링 해보았다.
00) 설정 파일 수정
( build.gradle ,application.properties )
- 디펜턴시 추가
- Apache Commons IO 라이브러리에는 유틸리티 클래스, 스트림 구현, 파일 필터, 파일 비교기, endian 변환 클래스 등이 포함되어 사용할 수 있습니다.
📑build.gradle
더보기
// https://mvnrepository.com/artifact/commons-io/commons-io 파일 저장
implementation group: 'commons-io', name: 'commons-io', version: '2.11.0'
- properties 추가
- 멀티파트 사용여부 : spring.servlet.multipart.enabled=true
- 요청 파일 하나의 사이즈 : spring.servlet.multipart.max-file-size=10MB
- 요청 파일 총 사이즈 : spring.servlet.multipart.max-request-size=20MB
- 임시 파일 업로드 : spring.servlet.multipart.location= 경로
- 실제 파일 업로드 : file.upload.path= 경로
📑application.properties
더보기
# file
# total file size cannot exceed 10MB.
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB
# total request size for a multipart/form-data cannot exceed 20MB.
spring.servlet.multipart.max-request-size=20MB
spring.servlet.multipart.location= C:\\back_project\\travel\\src\\main\\resources\\static\\upload\\
file.upload.path= C:\\back_project\\travel\\src\\main\\resources\\static\\upload\\
01) 회원과 이미지 객체 Domain
( UserTravel, Image, UserImage )
이미지 객체는 회원 프로필 외에도 사용될 예정으로 공통 사용하는 속성을 정의하여 UserImage(프로필 객체)에서 상속받는다.
📑UserTravel.java
package com.example.travel.domain;
import lombok.*;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
@ToString(exclude = "roleSet")
public class UserTravel extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userNo;
private String userId;
private String userEmail;
private String password;
private String name;
private String userBirthday;
private String userGender;
private String userPhone;
@OneToOne(fetch = FetchType.LAZY)
private UserImage userImg;//프로필 사진
//주소
private String addressPostcode;
private String address;
private String addressDetail;
private String addressExtra;
private Boolean userAgree;//개인정보 동의
private Boolean userSocial;//소셜회원 정보
@ElementCollection(fetch = FetchType.LAZY)
@Builder.Default
private Set<UserRole> roleSet = new HashSet<>();
public void roleAdd(UserRole userRole){
roleSet.add(userRole);
}
//이미지 업데이트 시 사용
public void updateUserImage(UserImage userImg) {
this.userImg = userImg;
}
}
📑Image.java
더보기
package com.example.travel.domain;
import lombok.*;
import lombok.experimental.SuperBuilder;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
@MappedSuperclass
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@AllArgsConstructor
public abstract class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String uuid;
private String fileName;
private String originFileName;
private String fileUrl;
}
📑UserImage.java
더보기
package com.example.travel.domain;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import javax.persistence.Entity;
@Entity
@Getter
@NoArgsConstructor
@SuperBuilder
public class UserImage extends Image {
}
02) 회원가입 폼 수정
(join.html)
- Form태그에 속성추가
- <form id="joinForm" th:action="@{/join}" enctype="multipart/form-data" th:method="post" class="box content_box" >
- 인풋 파일에 속성 추가
- <input type="file" th:field="*{userImg}" name="" id="userImg" accept="image/*" multiple="multiple">
📑join.html
더보기
<form id="joinForm" th:action="@{/join}" enctype="multipart/form-data" th:method="post" class="box content_box" >
<input th:type="hidden" name="idCheckValue" id="idCheckValue" value="false">
<input th:type="hidden" name="emailCheckValue" id="emailCheckValue" value="false">
<input th:field="*{userEmail}" name="userEmail" id="userEmail" th:errorclass="err" class="form-control" type="hidden">
<table>
<colgroup>
<col style="width:100px">
<col style="width:calc(100% - 100px)">
</colgroup>
<tbody>
<tr>
<th>
<label for="userId">
아이디 <span class="txt_info ck">*</span>
</label>
</th>
<td>
<div class="x_auto">
<input th:field="*{userId}" name="userId" id="userId" class="form-control id"
th:errorclass="err" type="text" placeholder="아이디를 입력하세요.">
<button type="button" id="btnIdCheck" class="btn btn-dark btn-sm ml-2">확인</button>
</div>
<p class="check-msg-id txt_info mt-1"></p>
</td>
</tr>
<tr>
<th>비밀번호 <span class="txt_info ck">*</span></th>
<td>
<input th:field="*{password}" name="password" id="password" type="password" class="form-control" placeholder="영어와 숫자로 포함해서 6~12자리 이내로 입력해주세요.">
</td>
</tr>
<tr>
<th><label for="name">이름 <span class="txt_info ck">*</span></label></th>
<td><input th:field="*{name}" name="name" id="name" class="form-control" type="text" placeholder="이름을 입력하세요."></td>
</tr>
<tr>
<th>생년월일 <span class="txt_info ck">*</span></th>
<td><input th:field="*{userBirthday}" name="userBirthday" id="userBirthday" th:errorclass="err" class="form-control" type="text" oninput="this.value = this.value.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1');" placeholder="생년월일 6자리를 입력하세요." maxlength="6"></td>
</tr>
<tr>
<th><label for="userEmail1">이메일 <span class="txt_info ck">*</span></label></th>
<td>
<div class="input-group">
<input type="text" class="form-control" name="userEmail1" id="userEmail1" placeholder="이메일" >
<select class="form-select select" name="userEmail2" id="userEmail2" >
<option>@naver.com</option>
<option>@daum.net</option>
<option>@gmail.com</option>
<option>@hanmail.com</option>
<option>@yahoo.co.kr</option>
</select>
<div class="input-group-addon">
<button type="button" class="btn btn-dark" id="mail-Check-Btn" >본인인증</button>
</div>
</div>
<div class="x_auto mt-1 mail-check-wrap" style="display:none;">
<div class="mail-check-box">
<input class="form-control mail-check-input" placeholder="인증번호 8자리를 입력해주세요!" disabled="disabled" maxlength="8">
<p id="timerUp">
<span id="timer" class="point-color"></span>
<i class='bi bi-clock point-color'></i>
</p>
</div>
<button id="mailCheckPw" class="btn btn-danger btn-sm ml-2" type="button">인증확인</button>
</div>
<p class="mail-check-warn check-msg-email txt_info mt-1"></p>
</td>
</tr>
<tr>
<th>연락처 <span class="txt_info ck">*</span></th>
<td><input th:field="*{userPhone}" name="userPhone" id="userPhone" class="form-control" type="text" placeholder="연락처를 입력하세요. EX) 010-0000-0000"></td>
</tr>
<tr>
<th>주소 <span class="txt_info ck">*</span></th>
<td class="address_wrap">
<div class="address_post x_auto">
<input type="text" id="addressPostcode" th:field="*{addressPostcode}" placeholder="우편번호" class="form-control">
<input type="button" onclick="addressPostcodes()" value="우편번호 찾기" class="btn btn-dark btn-sm ml-2">
</div>
<div class="address_info">
<input type="text" id="address" th:field="*{address}" placeholder="주소" class="form-control">
</div>
<div class="address_detail x_auto">
<input type="text" id="addressDetail" th:field="*{addressDetail}" placeholder="상세주소" class="form-control">
<input type="text" id="addressExtra" th:field="*{addressExtra}" placeholder="참고항목" class="form-control ml-2">
</div>
</td>
</tr>
<tr>
<th>성별</th>
<td>
<label for="userGenderM">
<input name="userGender" id="userGenderM" value="userGenderM" type="radio" th:checked="${userTravel.userGender eq 'userGenderM'}" checked="checked" /> 남성
</label>
<label for="userGenderW" class="ml-2">
<input name="userGender" id="userGenderW" value="userGenderW" type="radio" th:checked="${userTravel.userGender eq 'userGenderW'}" /> 여성
</label>
</td>
</tr>
<tr>
<th>프로필 <br />이미지</th>
<td>
<div class="userimg_wrap">
<div class="userimg_box">
<img id="preview" src="" alt="">
</div>
<label for="userImg" class="btn btn-dark btn-sm ">
👆프로필 선택하기👆
</label>
<input type="file" th:field="*{userImg}" name="" id="userImg" accept="image/*" multiple="multiple">
</div>
</td>
</tr>
</tbody>
</table>
<label for="userAgree" class="mt-2">
<input th:field="*{userAgree}" name="userAgree" id="userAgree" type="checkbox"/> 개인정보 동의 <span class="txt_info ck">*</span>
</label>
<div class="user_agree_wrap mt-2">
여행지 찾기'메뉴 에서 모두와 공유하고 소통할 수 있어요.
</div>
<a class="w-100 btn btn-primary mt-4" href="javascript:void(0)" onclick="joinForm()" >가입하기</a>
</form>
03) 회원가입 폼 저장 Controller
( UserController.java )
위 화면에서 가입 버튼을 클릭하면 Post 방식으로 📑UserController.java 파일에 전달된다.
- 전달 파라미터
- 화면에서 전달 받은 값 📑UserDTO : @Valid @ModelAttribute("userTravel") UserDTO user@ModelAttribute : 객체를 맵핑 하여 받아온다.@Valid : 객체에서 지정한 제약 조건을 검사하는 어노테이션이다.
- 검증오류 내용을 보관하는 객체 : BindingResult bindingResult
- 리다이렉트로 페이지를 이동할 시 전달 하는 값: RedirectAttributes redirectAttributes
- 오류가 있을 시, 다시 작성폼으로 이동
- bindingResult.hasErrors()
- 📑join.html : ... <span th:if="${#fields.hasErrors('userEmail')}" th:errors="*{userEmail}"></span> ...
- 오류가 없을 시
- 📑UserService 회원가입 로직 실행 : UserTravel userTravel1 = userService.userSave(user);
- 📑join.html : ... <span th:if="${#fields.hasErrors('userEmail')}" th:errors="*{userEmail}"></span> ...
- 📑MailSendService 회원가입 메일 전달 : mailSendService.sendEmail(userEmail, userTravel1.getName(),"joinSuccess");
- 성공 메시지 리턴 화면으로 전달 : redirectAttributes.addFlashAttribute("joinSuccess","회원가입이 완료되었습니다.");
- 회원 성공 후 로그인 페이지로 이동 : return "redirect:/loginForm";
📑UserController.java
더보기
@PostMapping("join")
public String userJoin(@Valid @ModelAttribute("userTravel") UserDTO user,
BindingResult bindingResult,
RedirectAttributes redirectAttributes
) throws Exception, IOException {
if(bindingResult.hasErrors()) {
log.info("회원 가입 실패");
log.info(bindingResult.hasErrors());
return "member/join";
}else{
//성공 로직
System.out.println(user.getUserImg());
System.out.println("-------------------------------");
UserTravel userTravel1 = userService.userSave(user);
log.info("회원가입 성공 :{}" , userTravel1);
String userEmail = user.getUserEmail();
mailSendService.sendEmail(userEmail, userTravel1.getName(),"joinSuccess");
redirectAttributes.addFlashAttribute("joinSuccess","회원가입이 완료되었습니다.");
return "redirect:/loginForm";
}
}
📑UserDTO.java
더보기
package com.example.travel.dto.user;
import com.example.travel.domain.UserRole;
import lombok.*;
import org.hibernate.validator.constraints.Length;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;
import javax.persistence.ElementCollection;
import javax.persistence.FetchType;
import javax.validation.constraints.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "roleSet")
@Builder
@Validated
public class UserDTO {
private Long userNo;
@NotNull(message = "아이디 : 아이디를 입력하세요.")
private String userId;
@Email(message = "이메일 : 올바른 형식의 이메일주소를 입력해주세요.")
@NotBlank(message = "이메일 : 이메일을 입력하세요.")
private String userEmail;
@NotBlank(message = "비밀번호 : 비밀번호는 영어와 숫자로 포함해서 6~12자리 이내로 입력해주세요.")
@Pattern(regexp="[a-zA-Z1-9]{6,12}", message = "비밀번호는 영어와 숫자로 포함해서 6~12자리 이내로 입력해주세요.")
private String password;
@NotBlank(message = "이름 : 이름을 입력하세요.")
private String name;
@NotBlank(message = "연락처 : 연락처를 입력하세요.")
private String userPhone;
@NotBlank(message = "생년월일 : 생년월일 6자리를 입력하세요.")
@Length(min = 6,max = 6,message = "생년월일 : 생년월일 6자리를 입력하세요.")
private String userBirthday;
//주소
private String addressPostcode;
private String address;
private String addressDetail;
private String addressExtra;
private String userGender;
private MultipartFile userImg;
@NotNull(message = "개인정보 동의를 체크해주세요.")
private Boolean userAgree; //개인정보 동의
private Boolean userSocial;
@ElementCollection(fetch = FetchType.LAZY)
@Builder.Default
private Set<UserRole> roleSet = new HashSet<>();
public void roleAdd(UserRole userRole){
roleSet.add(userRole);
}
}
04) 회원가입 서비스 로직 Service
(UserService.interface , UserServiceImpl.java,FileService.java)
회원관련 서비스를 정의하는 📑UserService 인터페이스와 📑UserService 인터페이스를 상속받아 구현한 📑UserServiceImpl 서비스 파일을 저장하는 로직을 가지고 있는 📑FileService가 사용된다.
📑UserService.interface
더보기
package com.example.travel.service.user;
...
public interface UserService {
public UserTravel userSave(UserDTO userSaveDTO); // user 저장
...
@Transactional
public UserResponseDTO userInfo(UserTravelDTO userDTO);
//entity - dto : 받는 값
default UserTravel dtoToEntity(UserDTO dto){
UserTravel result = UserTravel.builder()
.userNo(dto.getUserNo())
.userId(dto.getUserId())
.userEmail(dto.getUserEmail())
.password(dto.getPassword())
.name(dto.getName())
.userBirthday(dto.getUserBirthday())
.userGender(dto.getUserGender())
.userPhone(dto.getUserPhone())
//.userImg((UserImage)dto.getUserImg())
.address(dto.getAddress())
.addressPostcode(dto.getAddressPostcode())
.addressDetail(dto.getAddressDetail())
.addressExtra(dto.getAddressExtra())
.userAgree(dto.getUserAgree())
.userSocial(dto.getUserSocial())
.build();
System.out.println("role ---------------");
Set<UserRole> roleSet = dto.getRoleSet();
roleSet.stream().forEach(userRole -> {
result.roleAdd(userRole);
});
return result;
}
default UserDTO entityToDto(UserTravel userTravel){
UserDTO result = UserDTO.builder()
.userNo(userTravel.getUserNo())
.userId(userTravel.getUserId())
.userEmail(userTravel.getUserEmail())
.password(userTravel.getPassword())
.name(userTravel.getName())
.userBirthday(userTravel.getUserBirthday())
.userGender(userTravel.getUserGender())
.userPhone(userTravel.getUserPhone())
//.userImg((MultipartFile)userTravel.getUserImg())
.address(userTravel.getAddress())
.addressPostcode(userTravel.getAddressPostcode())
.addressDetail(userTravel.getAddressDetail())
.addressExtra(userTravel.getAddressExtra())
.userSocial(userTravel.getUserSocial())
.build();
System.out.println("role ---------------");
Set<UserRole> roleSet = userTravel.getRoleSet();
roleSet.stream().forEach(userRole -> {
result.roleAdd(userRole);
});
return result;
}
...
}
- 파일저장 경로 : @Value 어노테이션을 사용하여 📑application.properties 에서 필요한 값을 가져온다.
@Value("${spring.servlet.multipart.location}")
private String uploadPath; - 회원가입 시 전달 받은 이미지가 있다면 이미지 파일 저장 후 이미지 객체 전달하여 User객체를 저장 한다.
📑UserServiceImpl.java
더보기
package com.example.travel.service.user;
...
@RequiredArgsConstructor
@Service
@Log4j2
public class UserServiceImpl implements UserService {
final UserRepository userRepository;
final PasswordEncoder passwordEncoder;
// 이미지 관련 추가
private final FileService fileService;
private final UserImageRepository userImageRepository;
@Value("${spring.servlet.multipart.location}")
private String uploadPath;
@Override
@Transactional
public UserTravel userSave(UserDTO userDto) {
log.info("user 객체 저장 및 반환 ==========================");
log.info("userSaveDTO : {}" , userDto);
// 일반회원 가입
userDto.setUserSocial(false);
userDto.setPassword(passwordEncoder.encode(userDto.getPassword())); // 패스워드 암호화
UserTravel entity = dtoToEntity(userDto); //entity 변경
entity.roleAdd(UserRole.USER); // 권한 추가
System.out.println();
log.info("getUserImg : " + userDto.getUserImg());
if(!(userDto.getUserImg() == null)) {
log.info("이미지 파일이 있을때");
UserImage userImage = saveMemberImage(userDto.getUserImg());
entity.updateUserImage(userImage);
}
UserTravel result = userRepository.save(entity);
return result;
}
//이미지 관련
@Transactional(readOnly = false)
UserImage saveMemberImage(MultipartFile file) {
log.info("이미지 저장 ==========================");
if(file.getContentType().startsWith("image") == false) {
log.warn("이미지 파일이 아닙니다.");
return null;
}
String originalName = file.getOriginalFilename();
Path root = Paths.get(uploadPath, "member");
try {
ImageDTO imageDTO = fileService.createImageDTO(originalName, root);
UserImage memberImage = UserImage.builder()
.originFileName(imageDTO.getOriginFileName())
.uuid(imageDTO.getUuid())
.fileName(imageDTO.getFileName())
.fileUrl(imageDTO.getFileUrl())
.build();
file.transferTo(Paths.get(imageDTO.getFileUrl()));
return userImageRepository.save(memberImage);
} catch (IOException e) {
e.printStackTrace();
log.warn("업로드 폴더 생성 실패: " + e.getMessage());
}
return null;
}
...
}
📑FileService.java
더보기
package com.example.travel.service;
import com.example.travel.dto.ImageDTO;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
@Service
@Log4j2
public class FileService {
@Transactional
public ImageDTO createImageDTO(String originalSourceName, Path path) throws IOException {
String fileName = originalSourceName.substring(originalSourceName.lastIndexOf("\\") + 1);
String uuid = UUID.randomUUID().toString();
String fileUrl = getDirectory(path) + File.separator + uuid + "_" + fileName;
String originFileName = uuid + "_" + fileName;
return ImageDTO.builder()
.fileName(fileName)
.originFileName(originFileName)
.uuid(uuid)
.fileUrl(fileUrl)
.build();
}
private String getDirectory(Path path) throws IOException {
if(!Files.exists(path)) {
Files.createDirectory(path);
}
return path.toString();
}
}