Project/Chats

Chats 프로젝트 (4) - Spring Security, Signup

Llimy1 2024. 1. 9. 23:16
반응형
SMALL
반응형
SMALL

이번엔 먼저 전화번호 길이 제한을 진행하고 회원 가입 버튼을 통해 회원 가입 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

 

반응형
LIST