이전에 함께 개발했었던 대학 동기와 취업 준비 겸 포토폴리오를 준비하기 위해 프로젝트를 하나 제작하기로 하였습니다.
- 프로젝트의 주제는 "소상공인 가게 사장님을 위한 개인비서" 입니다.
- 직원의 근무일지, 가계기능(급여정산, 시급확인, 세금계산)등의 서비스를 제공합니다.
이번에 포스팅할 주제는 프로젝트에 적용한 회원가입 기능을 제작한 과정입니다.
Using Skills
- Back-end : Spring boot 3.3.0
- Front-end : Thymeleaf
- Data Base : Mysql 8.0++
- ORM : JPA
추후에 배포는 AWS EC2를 사용할 것이며 git과 notion으로 협업을 진행중에 있습니다.
domain/User
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder //빌더 패턴사용
@Entity(name = "USER") // 엔티티임을 명시
@NoArgsConstructor // 기본 생성자 추가
@AllArgsConstructor // 모든 필드를 초기화하는 생성자 추가
public class User {
@Id
@GeneratedValue( strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
@Column(name = "USER_NAME",
length = 30,
nullable = false)
private String userName;
@Column(name = "EMAIL",
length = 50,
unique = true,
nullable = false)
private String email;
@Column(name = "STORE_NAME",
length = 30,
nullable = false)
private String storeName;
@Column(name = "PASSWORD",
nullable = false)
private String password;
}
먼저 회원가입 정보를 매핑할 User 엔티티 클래스를 작성하였습니다.
객체 | ↔ | 테이블 |
userId | ↔ | USER_ID |
userName | ↔ | USER_NAME |
store_Name | ↔ | STORE_NAME |
password | ↔ | PASSWORD |
userId 객체에는 기본키 생성 전략인 'IDENTITY' 전략을 사용하였습니다. 이 전략은 설정이 간단하고, 데이터베이스가 알아서 고유한 기본키 ID를 생성해 주고, 데이터베이스가 기본 키를 직접 생성하므로 애플리케이션 레벨에서 별도의 ID 생성 로직이 필요 없어 성능상 이점이 있습니다. 이 전략은 MySQL의 AUTO_INCREMENT와 같습니다.
userName은 길이 30, not null로 설정하였습니다. 실제로 배포하고 사용하는 유저의 국적이 대한민국이 아니라면 유저의 이름은 길어질 수 있습니다.
storeName은 충분한 가게이름을 입력하기위해 길이 50, not null로 설정하였습니다.
email은 로그인을 할 때 사용하기에 unique 제약조건을 설정하였습니다. 추후에 비밀번호 찾기 기능의 이메일로 인증번호를 발생하여 비밀번호를 재설정하기 위한 실제email은 중복될 수 없기 때문의 이유가 있습니다.
password는 not null 제약조건을 제외한 다른 설정들은 추가하지 않았습니다. 비밀번호는 Bcrpyt 암호화를 사용하여 암호화 하였습니다. Bcrpyt는 비밀번호 해시화를 위한 강력한 알고리즘으로 여러 보안 기능을 통해 비밀번호를 안전하게 보호합니다. Byrypt는 해시화를 시작하기 전에 랜덤 솔트를 생성하여 동일한 비밀번호라도 매번 다른 해시 값을 생성합니다. 해시 계산은 기본적으로 10번 반복하며, 이는 2^10 = 1024번의 해시 계산을 말합니다. 이를 통해 해커들이 비밀번호를 역추적하거나 무차별 대입 공격을 시도하는 것을 어렵게 만듭니다.
이미 많은 애플리케이션에서 BCrypt를 사용하여 비밀번호를 안전하게 관리하고 있고, 검증된 암호화로써 이 프로젝트에 사용하기로 하였습니다.
dto/UserDto
@Builder // 빌더 패턴 사용
@Data // getter, setter, toString, equles 등등
@NoArgsConstructor // 파라미터 없는 생성자
@AllArgsConstructor // 모든 필드 파라미터 필수 생성자
public class UserDto {
@NotEmpty(message = "사용자명은 필수 입력 항목입니다.")
@Size(min = 2, max = 30, message = "사용자명은 2자에서 30자 사이어야 합니다.")
private String userName;
@NotEmpty(message = "이메일은 필수 입력 항목입니다.")
@Email(message = "유효한 이메일 주소여야 합니다.")
@EmailUnique(message = "이미 존재하는 이메일입니다.") // 커스텀
private String email;
@NotEmpty(message = "가게 이름은 필수 입력 항목입니다.")
@Size(min = 2, max = 30, message = "가게 이름은 2자에서 30자 사이어야 합니다.")
private String storeName;
@NotEmpty(message = "비밀번호는 필수 입력 항목입니다.")
@PasswordComplexity(message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하여야 합니다.") // 커스텀
private String password;
@NotEmpty(message = "비밀번호 확인은 필수 입력 항목입니다.")
private String reconfirmPassword;
@AssertTrue(message = "비밀번호와 비밀번호 확인이 일치해야 합니다.")
public boolean isPasswordConfirmed() {
return password != null && password.equals(reconfirmPassword);
}
}
ORM으로 연결해줄 UserDto입니다. User domain의 제약조건과 일치 하지 않으면 오류메세지를 반환하기위해 validation 어노테이션을 사용하였고, 제약조건 역시 dto와 domain을 같게 설정하였습니다.
@NotEmpty, @Size는 컨트롤러의 BingdingResult와 함께 작동합니다.
dto에 @NotEmpty 어노테이션을 추가합니다. 이 어노테이션은 필드가 비어 있지 않아야 합니다.
컨트롤러에 @Valid 어노테이션을 사용하여 전달된 데이터가 dto 객체로 바인딩 될 때 자동으로 검증이 수행됩니다. 이 때 BingdingResult는 검증 결과를 담는 객체로 사용됩니다.
만약 dto의 userName 필드가 비어있으면 검증에 실패합니다
검증 결과는 BindingResult 객체에 저장되고, 오류가 있는지 확인하고 적절하게 응답을 반환할 수 있습니다.
@PasswordComplexity와 @EmailUnique는 커스텀으로 생성한 validation입니다.
validation/PasswordComplexity
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
@ReportAsSingleViolation
@Pattern(regexp = "(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,20}",
message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하여야 합니다.")
public @interface PasswordComplexity {
String message() default "Invalid password";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Target은 어노테이션을 사용할 수 있는 대상을 지정합니다. 필드, 파라미터, 어노테이션타입에 적용할 수 있습니다.
@Retention(RetentionPloicy.RunTIME) @PasswordComplexity는 실행('RUNTIME') 시점까지 유지되어야 함을 나타냅니다. 런타임에 어노테이션 정보를 리플렉션을 통해 사용 가능합니다,
@Constraint(validatedBy = {});는 Bean Validation에서 유효성 검사를 위해 사용됩니다. {}안에 유효성 검사 클래스를 지정할 수 있지만 기본적으로 제공되는 것을 사용하지 않아 비워두었습니다.
@ReportAsSingleViolation은 하나의 오류로만 보고되어야 함을 나타냅니다. 여러 조건을 만족하지 않더라도 한 번의 오류 메시지만 반환합니다.
@Pattern은 유효성 검사 규칙을 정합니다. regexp는 정규 표현식이고, message는 비밀번호가 조건을 만족하지 않을 때 출력할 메세지 입니다.
- (?=.*[a-zA-Z]): 적어도 한 개의 영문자가 포함
- (?=.*\d): 적어도 한 개의 숫자가 포함.
- (?=.*[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?]): 적어도 한 개의 특수문자가 포함
- .{8,20}: 문자열의 길이가 8에서 20 사이여야 함
validation/EmailUnique
@Constraint(validatedBy = EmailUniqueValidator.class)
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface EmailUnique {
String message() default "이미 존재하는 이메일입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
validation EmailUniqueValidator
public class EmailUniqueValidator implements ConstraintValidator<EmailUnique, String> {
@Autowired
private UserRepository userRepository;
@Override
public void initialize(EmailUnique constraintAnnotation) {
}
@Override
public boolean isValid(String email, ConstraintValidatorContext constraintValidatorContext) {
return !userRepository.existsByEmail(email);
}
}
이클래스는 ConstraintValidator 인터페이스를 구현하는 클래스 입니다. Bean Validaiton API에서 제공하는 커스텀 유효성 검사를 위한 기본 인터에피스 입니다.
파라미터 EmailUnique는 적용될 어노테이션을 이고, String은 검증 대상의 필드 타입입니다.
@Autowired를 사용하여 UserRepository를 주입합니다. DB에서 사용자의 존재 여부를 확인하기 위해서 입니다.
isValid는 실제 유효성 검사 로직이 구현된 메서드입니다. Bean Validation API에서 자동으로 호출되고 검증합니다.
constaintValidatorContext는 컨텍스트 객체로 유효성 검사 결과를 커스터마이징할 떄 사용합니다.
email은 email이 들어갈 자리입니다.
!userRepository.existsByEmail 메소드로 이메일 주소가 이미 존재하는지 확인합니다. 존재하면 false를 반환합니다.
repository/UserRepositroy
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
}
위 메서드는 Spring Data JPA의 메서드 네이밍 규칙을 지키면 구현체를 직접 작성하지 않고 DB 조회 및 조작이 가능하고 간편하게 사용할 수 있습니다.
회원가입을 위한 save메소드는 JPA에서 엔티티의 기본 CRUD save, findById, findAll 등등의 메서드는 기본적으로 제공되기 떄문에 기본 구현체를 사용하여 save 메소드를 사용할 수 있기에 명시적으로 정의하지 않았습니다.
service/JoinService
public interface JoinService {
void save(UserDto userDto);
}
void save는 회원가입 기능이며, 구현 클래스에서 기능을 구현합니다
인터페이스를 사용하는 이유는 추상화, 다형성, 코드 재사용성 등의 이유가 있습니다.
service/JoinServiceImpl
import com.example.managerFlow.user.domain.User;
import com.example.managerFlow.user.dto.UserDto;
import com.example.managerFlow.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class JoinServiceImpl implements JoinService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Autowired
public JoinServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public void save(UserDto userDto) {
String encodedPassword = passwordEncoder.encode(userDto.getPassword());
User user = User.builder().
userName(userDto.getUserName()).
email(userDto.getEmail()).
storeName(userDto.getStoreName()).
password(encodedPassword).
build();
userRepository.save(user);
}
}
가독성을 높이고, 테스트 용이성 userRepository와 passwordEncode에 @Autowired 생성자 주입을 사용하였습니다.
save 메소드는 주입받은 PasswordEncoder를 사용해 dto로 받은 비밀번호를 암호화 하였으며, 가독성 및 유지보수성을 위해 빌더패턴을 사용하여 dto를 데이터를 넘겨주고, userRepositroy.save()를 통해 dto 데이터를 데이터베이스에 저장하였습니다.
config/SecurityConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
PasswordEncoder는 BCrptyPasswordEncoder를 사용하기위해 @Bean 객체로 등록하였습니다.
controller/JoinController
package com.example.managerFlow.user.controller;
import com.example.managerFlow.user.dto.UserDto;
import com.example.managerFlow.user.service.JoinService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
@Controller // 컨트롤러 임을 명시
public class JoinController {
private final JoinService joinService;
@Autowired
public JoinController(JoinService joinService) {
this.joinService = joinService;
}
@GetMapping("/join") //GET
public String showJoinForm(Model model) {
model.addAttribute("userDto", new UserDto()); // 빈 객체 폼에 전달
return "user/joinPage"; // 뷰 반환
}
@PostMapping("/join-submit")
public String join(@Valid @ModelAttribute UserDto userDto, BindingResult Result) {
if(Result.hasErrors()){ // 폼데이터 유효성 검증
return "user/joinPage"; // 오류 있으면 다시 뷰 반환
}
joinService.save(userDto); // join
return "user/login"; // 성공 뷰 반환
}
}
JoinController는 JoinService를 주입받습니다.
클라이언트가 '/join'으로 get요청이 오면 빈 UserDto를 객체를 넘겨주고 joinPage를 반환합니다.
클라이언트가 '/join-submit'요청이 오면 입력된 userDto 객체를 넘겨받습니다.
dto에서 유효성검사가 실패한 결과가 있으면 joinPage를 반환하고
그렇지 않으면 login페이지로 이동합니다.
template/user/join
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Join</title>
</head>
<body>
<h1>Join</h1>
<form action="#" th:action="@{/join-submit}" th:object="${userDto}" method="post">
<label for="userName">Username:</label>
<input type="text" id="userName" th:field="*{userName}"/><br/>
<div th:if="${#fields.hasErrors('userName')}" th:errors="*{userName}">Username Error</div>
<!-- userName 필드에 대해서 에러 발생했을때, 그 필드에 대한 valid 메세지 반환-->
<label for="email">Email:</label>
<input type="email" id="email" th:field="*{email}"/><br/>
<div th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Error</div>
<label for="storeName">Store Name:</label>
<input type="text" id="storeName" th:field="*{storeName}"/><br/>
<div th:if="${#fields.hasErrors('storeName')}" th:errors="*{storeName}">Store Name Error</div>
<label for="password">Password:</label>
<input type="password" id="password" th:field="*{password}"/><br/>
<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}">Password Error</div>
<label for="reconfirmPassword">Confirm Password:</label>
<input type="password" id="reconfirmPassword" th:field="*{reconfirmPassword}"/><br/>
<div th:if="${#fields.hasErrors('reconfirmPassword')}" th:errors="*{reconfirmPassword}">Confirm Password Error</div>
<button type="submit">Sign Up</button>
</form>
</body>
</html>
th:object는 userDto를 매핑합니다. 처음에 userDto를 빈 객체로 받기 때문에 모든 input은 비어있습니다.
th:if는 true일때 결과를 반환합니다.
모든 입력을 마치고 제출하면 BindingResult의 fields.hasErrors로 각 필드의 유효성 검사를 한 후
오류가 있으면 th:errors로 dto의 유효성검사 통과하지 못한 어노테이션의 메세지를 띄워줍니다.
개발 초기단계라 설계가 미흡한점도 있고, 동기와 의견충돌이 번번히 일어납니다.
ERD설계를 넓게 하고싶은 나! <-> 아직 쓸모가 없는것 같다. 나중에 필요하면 추가하자!
애플리케이션 경량화하고싶은 나! <-> 데이터베이스 경량화 하자!
(음 내 동기 의견도 나쁘진 않은데..?)
아직 경험을 쌓는 단계이기도하고 어떠한 경험도 득이 될 수 있는 경험이라 동기의 의견을 따라갑니다!!
하지만 정말 나의 의견이 확실하다면 근거와 논리를 통해 설득할겁니다!!
다음 프로젝트 포스팅은 회원가입 테스트 작성입니다.
감사합니다.