이전에 비밀번호 암호화를 공부하면서 인터넷을 찾아보며 회원가입을 구현해보았는데 회원가입 뿐만 아니라 로그인까지 구체적으로 구현해보고 싶어 다시 처음부터 구현하게 되었습니다.
또한 지금까지는 model 을 이용해서 화면과 데이터를 주고 받았는데 이번에는 연습삼아 JSON 형식으로 주고 받는 방식을 사용해보았습니다. 이와 관련한 내용을 잘 모르시는 분들은 이전 게시글 에 주고 받는 방법에 대해 나와있으니 참고하시면 되겠습니다.
이번 게시글에서는 코드를 중심으로 제가 했던 내용을 간단하게 정리해보려 합니다. 구현할 때 이전 게시글에 있는 passwordEncoder 만 사용했기 때문에 추가적으로 다루는 내용은 없습니다. 전체 코드는 github 에서 확인하실 수 있습니다.
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/", "/signUp", "/login", "/js/**").permitAll() // js 실행을 위해 필요
.anyRequest().authenticated();
}
}
이전 게시글에서 말씀드린 것처럼 Spring Security 를 사용할 때 설정하는 설정파일입니다. 회원가입 시 DB 에 데이터를 저장할 때 암호화 하기 위해 사용합니다.
configure()
에는 홈, 회원가입 페이지, 로그인 페이지, js 하위의 모든 파일를 인증없이 접근 가능하도록 설정하고, 나머지 모든 요청은 인증이 필요하도록 설정하였습니다.
JS 와 같은 정적 리소스는 white list 를 만들어서 이 리스트에 있는 것들의 인증을 무시한다고 작성하는 방법도 존재합니다.
뒤에서 나오겠지만 저는 Response 를 직접 구현하여 내려주었습니다. 포스트맨을 사용할 때는 직접 만든 Response 객체를 JSON 형식으로 잘 파싱해서 보여주지만 thymeleaf 에서 이 정보를 이용하려면 JS 를 거쳐서 사용해야했기에 JS 도 작성하게 되었습니다.
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long no;
@NotBlank
private String id;
@NotBlank
private String name;
@NotBlank
private String password;
@NotBlank
private String email;
@Enumerated(EnumType.STRING)
private UserRole role;
@Builder
public User(String id, String name, String password, String email, UserRole role) {
this.id = id;
this.name = name;
this.password = password;
this.email = email;
this.role = role;
}
public User() {
}
// User 가 생성되기 이전 DTO 로 User 를 생성할 때 사용
// 비밀번호 암호화까지 동시에 수행
public static User createUser(UserDTO dto, PasswordEncoder passwordEncoder) {
User user = User.builder()
.id(dto.getId())
.name(dto.getName())
.email(dto.getEmail())
.password(passwordEncoder.encode(dto.getPassword())) // 암호화해서 User 생성
.role(UserRole.USER) // 역할 지정
.build();
return user;
}
}
유저의 역할을 Enum 으로 정의해서 사용했습니다. 로그인에서 User 가 디폴트로 사용되기 때문에 사용자 관련 객체는 Member 라고 하는 것이 좋습니다.
UserDTO 를 전달받아서 UserEntity 를 만들 수 있도록 createUser()
메서드를 구현하였습니다. User Entity 를 만드는 친구이기 때문에 User 생성과 관계 없이 사용하기 위해 static 으로 선언하였습니다.
또한 비밀번호를 암호화하여 저장하기 위해 파라미터로 passwordEncoder 를 전달받아 DTO 의 비밀번호를 암호화해서 Entity 를 만들도록 하였습니다.
@Data
public class UserDTO {
@NotBlank(message="id는 필수 입력값 입니다")
private String id;
@NotBlank(message="이름은 필수 입력값 입니다")
private String name;
@NotBlank(message="비밀번호는 필수 입력값 입니다")
private String password;
@NotBlank(message="이메일은 필수 입력값 입니다")
@Email
private String email;
}
화면에서 전달받을 User 데이터를 담는 클래스입니다. 회원 번호나 역할 같은 것은 화면에서 받는 것이 아니기 때문에 회원가입 시 사용자에게 입력받을 데이터만 추린 클래스입니다.
또한 Entity 를 만들기 전에 validation 체크를 하기 위해 DTO 에서 @NotBlank
나 @Email
같은 검증 어노테이션을 활용하였습니다. 검증에서 에러가 발생한다면 어노테이션에 지정한 message 가 출력되게 되는데 이는 조금 더 뒤에서 확인해보도록 하겠습니다.
@Repository
@Transactional
public class JpaUserRepository implements UserRepository{
private final EntityManager em;
public JpaUserRepository(EntityManager em) {
this.em = em;
}
@Override
public User save(User user) {
em.persist(user);
return user;
}
@Override
public Optional<User> findById(String id) {
String jpql = "select u from User u where u.id=:id";
TypedQuery<User> query = em.createQuery(jpql, User.class).setParameter("id", id);
List<User> userList = query.getResultList();
if (userList.size() == 0) {
return Optional.empty();
}
return Optional.ofNullable(userList.get(0));
}
}
JPA 를 사용하기 때문에 EntityManager 를 주입받습니다. Repopsitory 에서는 우선 간단하게 저장하고 id 중복 체크를 위한 findById 메서드만 구현했습니다.
쿼리를 실행하고 결과를 받을 때 getResultList()
를 사용한 이유는 getResultList()
의 경우 조회되는 것이 없으면 빈 리스트가 반환되지만 getSingleResult()
를 사용하면 예외가 발생하기 때문에 이를 방지하고자 사용했습니다.
또한 값이 있는지 없는지 모르기 때문에 Optioinal 로 반환하도록 구현하였습니다. 기본적으로 Spring Data JPA 에서 findById 는 Optional 을 반환합니다.
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User save(UserDTO dto) {
User user = User.createUser(dto, passwordEncoder);
return userRepository.save(user);
}
public Optional<User> findById(String id) {
return userRepository.findById(id);
}
public boolean isExistId(String id) {
return this.findById(id).isPresent();
}
}
다음은 서비스입니다. Repository 와 passwordEncoder 를 주입받아 사용합니다. 회원을 저장할 때 화면, 정확히는 Controller 에서 DTO 를 전달받고, dto 와 passwordEncoder 를 함께 파라미터로 넘겨주면서 UserEntity 를 만들게 됩니다.
id 로 조회하는 경우는 repository 에서 구현한 메서드를 이용합니다. id 중복을 확인하기 위해 isExistId()
메서드를 만들었는데 같은 Service 내부의 findById()
메서드를 사용했습니다.
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/signUp")
public String loadSignUpPage() {
return "home/signUpHome";
}
@PostMapping("/signUp")
@ResponseBody // 화면도 같이 사용하기 때문에 @RestController 대신 @ResponseBody 사용
public ResponseDTO<?> signUp(@Valid @RequestBody UserDTO userDTO, BindingResult bindingResult) throws JsonProcessingException {
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
errorMap.put("error-" + fieldError.getField(), fieldError.getDefaultMessage());
}
return new ResponseDTO<>(errorMap, HttpStatus.BAD_REQUEST);
}
boolean isExist = userService.isExistId(userDTO.getId());
if (isExist) {
return new ResponseDTO<>("동일한 id 가 존재합니다", HttpStatus.CONFLICT); // 409 상태코드
}
User savedUser = userService.save(userDTO);
return new ResponseDTO<>(savedUser, HttpStatus.OK);
}
@GetMapping("/login")
public String loadLoginHome() {
return "home/loginHome";
}
}
회원가입 로직을 보면 ResponseDTO 라는 것을 반환하는데 이는 제가 응답을 위해 직접 만들었습니다.
ResponseEntity 를 사용해도 되지만, UserDTO 를 검증하고 오류가 있다면 사용자에게 알려주어야 하는데 Model 을 사용하지 않다보니 에러 메세지를 전달할 방법이 마땅치 않아 만들게 되었습니다.
이렇게 전달된 ResponseDTO 는 JS 가 받아 처리한 후 thymeleaf 로 전달하여 사용자에게 보여주게 됩니다. JS 는 뒤에서 다루도록 하겠습니다.
view 의 이름을 반환하는 메서드와 회원가입 후 응답 메세지를 반환하는 Controller 가 동일하기 때문에 @RestController
가 아닌 @Controller
를 선언하였습니다.
응답 메세지를 반환하는 메서드에는 @ResponseBody
를 붙여주는 방식을 사용하였습니다. 이렇게 하면 반환하는 내용이 message body 에 들어가게 됩니다.
추가로 @RestController
는 @Controller
+ @ResponseBody
라고 생각하시면 됩니다.
만약 @RestController
로 선언하게 되면 회원가입 페이지 GET 요청이 왔을 때 페이지가 보여지는 것이 아니라 반환되는 내용이 String 으로 message body 에 들어가 home/signUpHome 라는 문자열이 그대로 보여지게 됩니다.
DTO 를 검증했을 떄 오류가 있다면 오류가 발생한 필드와 에러 메세지를 Map 에 넣고 상태코드와 함께 반환합니다. 여기서 에러 메세지는 위에서 어노테이션에 지정한 message 가 들어가게 됩니다.
첫 번째 검증이 끝났다면 아이디 중복 확인을 수행합니다. 이는 서비스에서 구현한 isExistId()
를 활용하며, 마찬가지로 ResponseDTO 를 반환하는데 이번에는 에러 메세지를 직접 작성하여 반환합니다.
모든 검증이 끝난다면 save 로직을 수행하고, 저장된 User 를 반환합니다.
const signUpUser = document.querySelector("#sign-up-user");
// 버튼 클릭 이벤트 감지
signUpUser.addEventListener("click", () => {
// 태그의 id 를 이용해 입력된 값들을 불러와 객체 생성
const user = {
id: document.querySelector("#id").value,
password: document.querySelector("#password").value,
name: document.querySelector("#name").value,
email: document.querySelector("#email").value
}
// RestAPI 호출
fetch("/signUp", {
method: "post",
headers: {"Content-Type": "application/json"}, // body 에 담긴 데이터 타입을 명시
body: JSON.stringify(user) // 생성한 객체를 JSON 형식으로 변경
}).then(response => {
response.text().then(textData => {
["error-id", "error-password", "error-name", "error-email"].forEach(tagId => {
document.getElementById(tagId).innerText = '' }) // 기존에 출력된 오류 메세지 제거
const res = JSON.parse(textData); // ResponseDTO 를 JSON 형식으로 변환
if (res.status === "OK") {
alert("회원가입이 완료되었습니다");
location.href = "/login"
} else if (res.status === "CONFLICT") {
alert(res.data); // 동일한 id 가 존재합니다
} else if (res.status === "BAD_REQUEST") {
Object.entries(res.data).forEach( // res.data 에는 오류 필드와 메세지가 담겨있음
([key, value]) =>
document.getElementById(`${key}`).innerText = `${value}` // 에러 메세지 출력
);
} else {
alert("회원가입에 실패하였습니다");
}
});
})
});
document.querySelector
를 통해 태그들을 가져올 수 있습니다. 이 때 앞에 # 을 붙이면 태그의 id 로 조회한다는 의미입니다. document.getElementById
를 사용해도 괜찮습니다.
response.text()
는 흔히 말해서 응답 메세지에 담긴 내용을 String 으로 반환합니다. 정확히는 JSON String 이기 때문에 이를 JSON.parse()
를 통해 온전한 JSON 형식으로 만들어줄 수 있습니다.
response.text()
를 통해 나온 String을 JSON 으로 파싱하면 아래처럼 data 에 오류가 발생한 필드와 DTO 에서 지정한 오류 메세지가 JSON 형식으로 담겨있습니다. 또한 status 에는 문자열로 응답 상태 코드가 들어있습니다.
{
"data" : {
"error-name" : "이름은 필수 입력값 입니다"
"error-name" : "이메일은 필수 입력값 입니다"
},
"stataus" : "BAD_REQUEST"
}
document.querySelector
로 버튼을 가져와 이벤트를 등록합니다. 이벤트를 등록하면서 클릭 시 로직을 함께 작성하는데 input 태그의 id 로 태그의 데이터를 가져와 object 를 만들고, 회원가입 API 를 호출하면서 이를 JSON 데이터로 변환합니다.
그 후 응답이 오면 이에 대한 처리를 해야하는데 여기서 정말 중요한 것은 response.text()
를 해야 ResponseDTO 에 담긴 상태코드와 데이터를 온전히 볼 수 있습니다. ( 이걸로 2시간 날렸습니다...ㅎ... )
오류가 발생했다면 JSON 데이터에 오류가 발생한 필드와 메세지가 존재하기 때문에 이 오류 필드는 태그의 id 와 동일하게 맞추었기 때문에 오류 필드를 id 로 해서 태그를 가져와 내부 텍스트를 오류 메세지로 지정합니다.
다시 회원가입을 호출했을 때 검증 후 오류 메세지를 다시 바꾸어야 하기 때문에 위에서 오류 필드를 배열로 만들고 태그의 내부 텍스트를 초기화시킵니다.
정리가 잘 된 글이네요. 도움이 됐습니다.