Chats 프로젝트 (4) - Spring Security, Signup
이번엔 먼저 전화번호 길이 제한을 진행하고 회원 가입 버튼을 통해 회원 가입 API 연동을 진행하도록 하겠습니다.
<!-- 전화번호 입력 필드 -->
<div class="form-row">
<input type="tel" id="phone" placeholder="전화번호" required
maxlength="11" minlength="11" title="11자리 숫자를 입력해주세요.">
</div>
전화번호 입력 필드를 다음과 같이 수정을 합니다.
그리고 filter.js를 추가해줍니다.
filter.js
document.getElementById('phone').addEventListener('input', function (e) {
this.value = this.value.replace(/[^0-9]/g, '');
});
이렇게 하면 필드에 숫자만 입력이 가능하도록 만들졌습니다. 그러면 이제 각 필드의 값을 받아
회원가입 버튼을 누르면 API를 불러오게 해보겠습니다.
fetch를 사용하여 API 통신을 하도록 하겠습니다.
signup.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 - Malls</title>
<link rel="stylesheet" href="/css/signupPage.css">
</head>
<body>
<div class="signup-container">
<h1 class="signup-title">회원가입</h1>
<form class="signup-form" id="signupForm">
<!-- 닉네임 입력 필드와 중복 확인 버튼 -->
<div class="form-row">
<input type="text" id="nickname" name="nickname" placeholder="닉네임" required>
<button type="button" class="check-button">중복 확인</button>
</div>
<!-- 이메일 입력 필드와 중복 확인 버튼 -->
<div class="form-row">
<input type="email" id="email" name="email" placeholder="이메일" required>
<button type="button" class="check-button">중복 확인</button>
</div>
<!-- 비밀번호 입력 필드 -->
<div class="form-row password-row">
<input type="password" id="password" name="password" placeholder="비밀번호" required oninput="checkPasswordRequirements()">
</div>
<!-- 비밀번호 요구 사항 -->
<div class="password-requirements">
<ul id="password-criteria">
<li id="uppercase">대문자 포함</li>
<li id="numbers">숫자 포함</li>
<li id="special-characters">특수문자 포함</li>
<li id="min-characters">8~20자 이내</li>
</ul>
</div>
<!-- 비밀번호 확인 입력 필드 -->
<div class="form-row password-row">
<input type="password" id="password-confirm" placeholder="비밀번호 확인" required oninput="checkPasswordMatch()">
</div>
<!-- 비밀번호 일치 여부 메시지 -->
<div id="password-match-message"></div>
<!-- 주소 입력 필드 -->
<!-- 주소 입력 부분 -->
<div class="address-inputs">
<div class="address-input-row">
<input type="text" id="postcode" name="postCode" placeholder="우편번호" required>
<button type="button" class="postcode-button" onclick="daumPostcode()">우편번호 찾기</button>
</div>
<div class="address-input-row">
<input type="text" id="address" name="mainAddress" placeholder="주소" required>
</div>
<div class="address-input-row">
<input type="text" id="detailAddress" name="detailAddress" placeholder="상세주소" required>
</div>
<!-- <div class="address-input-row">-->
<!-- <input type="text" id="extraAddress" placeholder="참고항목">-->
<!-- </div>-->
</div>
<!-- 전화번호 입력 필드 -->
<div class="form-row">
<input type="tel" id="phone" name="phoneNumber" placeholder="전화번호" required
maxlength="11" minlength="11" title="11자리 숫자를 입력해주세요.">
</div>
<!-- 회원가입 버튼 -->
<div class="form-row">
<button type="submit" id="signupButton" class="signup-button">회원가입</button>
</div>
</form>
</div>
<script src="/js/filter.js"></script>
<script src="/js/password.js"></script>
<script src="/js/signup.js"></script>
<script src="/js/kakaoAddress.js"></script>
<script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
</body>
</html>
입력 필드 부분에 name을 설정해서 값을 Dto와 일치시켜 가져옵니다.
이후
signup.js
document.getElementById('signupButton').addEventListener('click', function (e) {
e.preventDefault(); // 폼의 기본 제출을 막습니다.
const form = document.getElementById('signupForm');
const formData = new FormData(form);
const requestData = {
nickname: formData.get('nickname'),
email: formData.get('email'),
password: formData.get('password'),
phoneNumber: formData.get('phoneNumber'),
postCode: formData.get('postCode'),
mainAddress: formData.get('mainAddress'),
detailAddress: formData.get('detailAddress')
};
fetch('http://localhost:8080/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
console.log(data); // 성공 시 응답을 콘솔에 로그합니다.
})
.catch((error) => {
console.error('Error:', error);
});
});
API 통신을 하여 정보를 보내줍니다.
현재는 비밀번호가 암호화 되지 않고 DB에 들어가지만 추후에 Security 설정을 해줄 때 같이 변경을 해주도록 하겠습니다.
이제 할 일은 닉네임 중복과 이메일 중복을 확인 하려고 합니다.
이것 또한 API 통신으로 진행을 할 예정이므로 스프링부트에서 중복 확인(이메일, 닉네임)을 먼저 만들고 테스트까지 진행하고
연동을 하도록 하겠습니다.
먼저 중복 확인을 위해 닉네임 중복과 이메일 중복에 대해서 코드 작성을 하도록 하겠습니다.
SignupService.java
// 닉네임 중복 확인
public void duplicationNickname(String nickname) {
userRepository.findByNickname(nickname).ifPresent(a -> {
throw new Duplication(ErrorMessage.NICK_NAME_DUPLICATION);
});
}
// 이메일 중복 확인
public void duplicationEmail(String email) {
userRepository.findByEmail(email).ifPresent(a -> {
throw new Duplication(ErrorMessage.EMAIL_DUPLICATION);
});
}
ifPresent를 사용해 값이 있다면 예외를 발생하도록 했습니다.
예외는 custom exception을 사용했습니다.
@Getter
public enum ErrorMessage {
NICK_NAME_DUPLICATION("닉네임 중복 입니다."),
EMAIL_DUPLICATION("이메일 중복 입니다.");
private final String description;
ErrorMessage(String description) {
this.description = description;
}
}
예외 메세지를 enum 타입으로 지정해줬습니다.
Duplication.java
public class Duplication extends RuntimeException {
public Duplication(ErrorMessage message) {
super(message.getDescription());
}
}
중복 예외는 하나의 exception에서 처리를 하려고 합니다.
RuntimeException을 상속 받고 메소드를 오버라이드 하여 제가 설정한 메소드로 값이 출력되게 만들려고 합니다.
그리고 메세지만 각각 변경하여 각 예외가 일어나면 해당 예외에 맞는 메세지로 출력이 되도록 하기 위해
ExceptionAdviceController.java를 만들었습니다.
@RestControllerAdvice
@RequiredArgsConstructor
public class ExceptionAdviceController {
private final CommonService commonService;
// Duplication Exception
@ExceptionHandler(Duplication.class)
public ResponseEntity<Object> nicknameDuplicationException(Duplication ndi) {
CommonResponseDto<Object> commonResponseDto = commonService.errorResponse(ndi.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(commonResponseDto);
}
}
그 후에 서비스 테스트를 만들었습니다.
SignupServiceTest.java
@Test
@DisplayName("[Service] 닉네임 중복")
void duplication_nickname() {
//given
User user = User.createUser(
signupRequestDto.getNickname(),
signupRequestDto.getEmail(),
signupRequestDto.getPassword(),
signupRequestDto.getPhoneNumber());
given(userRepository.findByNickname(signupRequestDto.getNickname()))
.willReturn(Optional.of(user));
//when
//then
assertThatThrownBy(() -> signupService.duplicationNickname(signupRequestDto.getNickname()))
.isInstanceOf(Duplication.class);
}
@Test
@DisplayName("[Service] 이메일 중복")
void duplication_email() {
//given
User user = User.createUser(
signupRequestDto.getNickname(),
signupRequestDto.getEmail(),
signupRequestDto.getPassword(),
signupRequestDto.getPhoneNumber());
given(userRepository.findByEmail(signupRequestDto.getEmail()))
.willReturn(Optional.of(user));
//when
//then
assertThatThrownBy(() -> signupService.duplicationEmail(signupRequestDto.getEmail()))
.isInstanceOf(Duplication.class);
}
이후 API 통신을 위해 API도 만들었습니다.
@PostMapping("/check/nickname")
@ResponseStatus(HttpStatus.OK)
public ResponseEntity<Object> checkNickname(@RequestParam String nickname) {
log.debug("닉네임 중복 확인 API 호출");
signupService.duplicationNickname(nickname);
CommonResponseDto<Object> commonResponse =
commonService.successResponse(SuccessMessage.CHECK_NICKNAME_SUCCESS.getDescription(), null);
return ResponseEntity.status(HttpStatus.CREATED).body(commonResponse);
}
@PostMapping("/check/email")
@ResponseStatus(HttpStatus.OK)
public ResponseEntity<Object> checkEmail(@RequestParam String email) {
log.debug("이메일 중복 확인 API 호출");
signupService.duplicationEmail(email);
CommonResponseDto<Object> commonResponse =
commonService.successResponse(SuccessMessage.CHECK_NICKNAME_SUCCESS.getDescription(), null);
return ResponseEntity.status(HttpStatus.CREATED).body(commonResponse);
}
Controller test를 위해
@Test
@DisplayName("[API] 닉네임 사용 가능 - 성공")
void duplication_nickname() throws Exception {
CommonResponseDto<Object> commonResponseDto =
CommonResponseDto.builder()
.status(ResponseStatus.SUCCESS.getDescription())
.message(SuccessMessage.CHECK_NICKNAME_SUCCESS.getDescription())
.data(null)
.build();
//given
given(commonService.successResponse(
SuccessMessage.CHECK_NICKNAME_SUCCESS.getDescription(),
null
)).willReturn(commonResponseDto);
//when
//then
mvc.perform(post("/check/nickname")
.contentType(MediaType.APPLICATION_JSON)
.param("nickname", "nickname"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status")
.value(ResponseStatus.SUCCESS.getDescription()))
.andExpect(jsonPath("$.message")
.value(SuccessMessage.CHECK_NICKNAME_SUCCESS.getDescription()))
.andDo(print());
}
@Test
@DisplayName("[API] 이메일 사용 가능 - 성공")
void duplication_email() throws Exception {
CommonResponseDto<Object> commonResponseDto =
CommonResponseDto.builder()
.status(ResponseStatus.SUCCESS.getDescription())
.message(SuccessMessage.CHECK_EMAIL_SUCCESS.getDescription())
.data(null)
.build();
//given
given(commonService.successResponse(
SuccessMessage.CHECK_EMAIL_SUCCESS.getDescription(),
null
)).willReturn(commonResponseDto);
//when
//then
mvc.perform(post("/check/email")
.contentType(MediaType.APPLICATION_JSON)
.param("email", "email"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status")
.value(ResponseStatus.SUCCESS.getDescription()))
.andExpect(jsonPath("$.message")
.value(SuccessMessage.CHECK_EMAIL_SUCCESS.getDescription()))
.andDo(print());
}
성공 테스트를 작성 해줍니다.
RestControllerAdvice를 사용하여 전역적으로 예외 처리를 하고 반환 값에 대한 테스트를 작성을 해보려고 했지만
이 부분에 대해선 공부가 필요할 것 같아 넘어가고 나중에 작성을 해보도록 하겠습니다.
이제 API가 모두 작성이 되었으니 버튼이랑 연결을 해주면 될 것 같습니다.
let isNicknameAvailable = false;
let isEmailAvailable = false;
const signupButton = document.getElementById('signupButton');
const signupForm = document.getElementById('signupForm');
const nicknameCheckButton = document.querySelector('.check-button.nickname');
const emailCheckButton = document.querySelector('.check-button.email');
// 초기에는 회원가입 버튼을 비활성화합니다.
signupButton.disabled = true;
document.addEventListener('DOMContentLoaded', function () {
// 닉네임 중복 검사
nicknameCheckButton.addEventListener('click', function () {
const nickname = document.getElementById('nickname').value;
if (nickname.trim() === '') { // 빈 문자열 검사
alert('닉네임을 입력해주세요.');
return;
}
checkDuplicate('nickname', nickname, function (isAvailable) {
isNicknameAvailable = isAvailable;
console.log(isNicknameAvailable);
toggleSignupButton(); // 중복 검사 후 회원가입 버튼 활성화 상태 업데이트
});
});
// 이메일 중복 검사
emailCheckButton.addEventListener('click', function () {
const email = document.getElementById('email').value;
if (email.trim() === '') { // 빈 문자열 검사
alert('이메일을 입력해주세요.');
return;
}
checkDuplicate('email', email, function (isAvailable) {
isEmailAvailable = isAvailable;
console.log(isEmailAvailable)
toggleSignupButton(); // 중복 검사 후 회원가입 버튼 활성화 상태 업데이트
});
});
// 비밀번호 조건 확인 이벤트 리스너
document.getElementById('password').addEventListener('input', checkPasswordValidity);
document.getElementById('password-confirm').addEventListener('input', checkPasswordValidity);
// 중복 검사 함수
function checkDuplicate(type, value, callback) {
const queryParams = new URLSearchParams({[type]: value});
fetch(`http://localhost:8080/check/${type}?${queryParams}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => {
if (response.status === 409) { // 오류 코드 409 처리
return response.json().then(data => {
throw new Error(data.message); // 오류 메시지를 throw
});
}
if (!response.ok) { // 기타 오류 처리
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
alert(data.message); // 성공 시 메시지 표시
callback(data.status === 'success');
})
.catch(error => {
console.error('Error:', error);
alert(error.message); // 오류 메시지 표시
callback(false);
});
}
function checkPasswordRequirements() {
const password = document.getElementById('password').value;
const minCharacters = password.length >= 8 && password.length <= 20;
const uppercase = /[A-Z]/.test(password);
const numbers = /[0-9]/.test(password);
const specialCharacters = /[\W_]/.test(password);
document.getElementById('min-characters').className = minCharacters ? 'valid' : 'invalid';
document.getElementById('uppercase').className = uppercase ? 'valid' : 'invalid';
document.getElementById('numbers').className = numbers ? 'valid' : 'invalid';
document.getElementById('special-characters').className = specialCharacters ? 'valid' : 'invalid';
}
function checkPasswordMatch() {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('password-confirm').value;
const messageContainer = document.getElementById('password-match-message');
if (password && confirmPassword) { // 두 필드 모두에 입력이 있는 경우에만 검사합니다.
if (password === confirmPassword) {
messageContainer.textContent = '비밀번호가 일치합니다.';
messageContainer.className = 'valid';
} else {
messageContainer.textContent = '비밀번호가 일치하지 않습니다.';
messageContainer.className = 'invalid';
}
} else {
messageContainer.textContent = ''; // 입력이 없는 경우 메시지를 비웁니다.
messageContainer.className = '';
}
}
function checkPasswordValidity() {
checkPasswordRequirements();
checkPasswordMatch();
isPasswordValid = document.querySelectorAll('.password-requirement .valid').length === 4 &&
document.getElementById('password').value ===
document.getElementById('password-confirm').value;
}
// 회원가입 버튼 활성화 상태를 결정하는 함수
function toggleSignupButton() {
signupButton.disabled = !(isNicknameAvailable && isEmailAvailable);
}
// 회원가입 요청 함수
signupButton.addEventListener('click', function (e) {
e.preventDefault(); // 폼의 기본 제출을 막습니다.
if (isNicknameAvailable && isEmailAvailable) {
// 폼 데이터를 객체로 변환
const formData = new FormData(signupForm);
const requestData = {
nickname: formData.get('nickname'),
email: formData.get('email'),
password: formData.get('password'),
phoneNumber: formData.get('phoneNumber'),
postCode: formData.get('postCode'),
mainAddress: formData.get('address'),
detailAddress: formData.get('detailAddress')
};
// 회원가입 요청
fetch('http://localhost:8080/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
console.log(data); // 성공 시 응답을 콘솔에 로그합니다.
window.location.href = "/";
})
.catch((error) => {
console.error('Error:', error);
});
}
});
});
document.getElementById('phone').addEventListener('input', function (e) {
this.value = this.value.replace(/[^0-9]/g, '');
});
다음과 같이 회원 가입에 사용되는 js를 한 파일로 모았습니다.
중복 확인이 되지 않는다면 회원 가입 버튼이 비활성화가 될 수 있게 만들었습니다.
지금까지 회원 가입 기능을 만들고 버튼 별로 API 를 연결을 진행을 하였고 테스트 코드까지 작성 했습니다.
이제 로그인을 작성 해보려고 합니다. 로그인을 진행을 할 땐 스프링 시큐리티를 적용하여 테스트 코드와 기존 코드의 변경이 살짝 있을 것으로 예상 됩니다.
이전 글들이 왜 쇼핑몰이냐.. 쇼핑몰을 하려다 급하게 채팅으로 돌려서 그렇습니다...
Chats 프로젝트 (3) - logack, Signup
이번엔 스프링부트 로그 파일 설정과 회원 가입 API 생성을 해보도록 하겠습니다. 물론 회원가입을 하기 위한 ERD도 작성을 하겠습니다. 로깅을 위해 logback-spring.xml 설정을 하겠습니다. ${CONSOLE_LOG_
classruntime.tistory.com