본문 바로가기
💻 뚝딱뚝딱/팀내도서대여시스템(OBRS)

[개발일지#009] 로그인 / 로그아웃 / 회원가입 수정 및 구현

by 뚜루리 2024. 4. 10.
728x90
320x100
로그인, 회원가입 기능을 수정하고 로그아웃 기능을 추가해보자!

 

[개발목표]

  1. 로그인 기능 수정 및 구현
  2. 로그아웃 기능 구현
  3. 회원가입 수정

 


 

1. 로그인 기능 수정 및 구현

[구현화면]

[요구사항]

  • 아이디와 비밀번호를 통해서 로그인을 한다.
  • 아이디와 비밀번호 둘 중에 하나라도 빈 값을 입력할 시 로그인 할 수 없고 화면에 안내문구를 띄운다.
  • '비밀번호보기' 기능 : 눈 아이콘을 클릭하면 입력한 비밀번호가 그대로 보여지고, 다시 클릭하면 다시 패스워스 형태로 돌아간다. 
  • '아이디 기억하기' 기능 : '아이디 기억하기' 기능을 체크하고 로그인 하면 다음 로그인 시 아이디가 입력된 상태로 보여진다.

 

 

LoginForm.java

package seulgi.bookRentalSystem.domain.login;

import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Data
public class LoginForm {

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
}
  • 로그인을 위한 객체를 하나 생성한다.
  • 각 항목마다 @NotEmpty 어노테이션을 사용했는데, '아이디와 비밀번호 둘 중에 하나라도 빈 값을 입력할 시 로그인 할 수 없고 화면에 안내문구를 띄운다.' <- 이 요구사항을 충족하기 위해 Bean Valication 을 사용하고자 사용한 어노테이션이다. 

 

 

LoginService.java

package seulgi.bookRentalSystem.domain.login;


import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import seulgi.bookRentalSystem.domain.member.Member;
import seulgi.bookRentalSystem.domain.member.MemberMapper;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberMapper memberMapper;

    /**
     * 로그인 (null 이면 로그인 실패)
     *
     * @param loginId
     * @param password
     * @return
     */
    public Member login (String loginId, String password){
        return memberMapper.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }

}

로그인을 위한 Service를 하나 생성. Null이면 로그인이 실패인 로직이다. 

 

 

LoginController.java

package seulgi.bookRentalSystem.web.login;


import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import seulgi.bookRentalSystem.domain.login.LoginForm;
import seulgi.bookRentalSystem.domain.login.LoginService;
import seulgi.bookRentalSystem.domain.member.Member;
import seulgi.bookRentalSystem.domain.member.MemberService;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;
    private final MemberService memberService;

    /**
     * 로그인 화면 호출
     * @param loginForm
     * @return
     */
    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm")LoginForm loginForm){
        return "login/loginForm";
    }

    /**
     * 로그인
     * @param form
     * @param bindingResult
     * @param redirectURL
     * @param request
     * @return
     */
    @PostMapping("/login")
    public String login(@Valid @ModelAttribute LoginForm form
            , BindingResult bindingResult
            , @RequestParam(defaultValue = "/") String redirectURL
            , HttpServletRequest request){


        if (bindingResult.hasErrors()){
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if (loginMember == null){
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        String memberName = loginMember.getMemberName();
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        session.setAttribute("loginId", form.getLoginId());
        session.setAttribute("loginName", memberName);
        return "redirect:" + redirectURL;
    }

    /**
     * 로그아웃
     * @param request
     * @return
     */
    @GetMapping("/logout")
    public String logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/book";
    }

    @PostMapping("/{memberId}/idCheck")
    public ResponseEntity<Boolean> idCheck(@PathVariable String memberId){
       String idCheck = memberService.idCheck(memberId);
        if (idCheck == null){
            return ResponseEntity.ok(true);
        } else {
            return ResponseEntity.ok(false);
        }
    }
}

로그인에 필요한 컨트롤러를 따로 만들었고 톧아보기로 한다. 

 

1) 로그인 화면 호출 / 로그인 

    /**
     * 로그인 화면 호출
     * @param loginForm
     * @return
     */
    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm")LoginForm loginForm){
        return "login/loginForm";
    }

    /**
     * 로그인
     * @param form
     * @param bindingResult
     * @param redirectURL
     * @param request
     * @return
     */
    @PostMapping("/login")
    public String login(@Valid @ModelAttribute LoginForm form
            , BindingResult bindingResult
            , @RequestParam(defaultValue = "/") String redirectURL
            , HttpServletRequest request){


        if (bindingResult.hasErrors()){
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if (loginMember == null){
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        String memberName = loginMember.getMemberName();
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        session.setAttribute("loginId", form.getLoginId());
        session.setAttribute("loginName", memberName);
        return "redirect:" + redirectURL;
    }
  • 로그인 화면 호출은 get방식으로 화면을 호출하는 아주 간단한 방식으로 구현하였고, 
  • 로그인 처리는 @Valid 를 사용하여 Validation을 진행하였다. 
  • 로그인이 된 후에는 그 로그인 정보를 계속 활용하기 위해 세션을 하나 만들어 거기에 로그인 정보를 저장해 두었다. 

 

2) 로그아웃 기능 구현

/**
 * 로그아웃
 * @param request
 * @return
 */
@GetMapping("/logout")
public String logout(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/book";
}

로그아웃 기능은 간단하다. 세션을 가져와서 세션에 로그인 정보가 담겨 있을 경우 그 세션을 무효화 하는 방법으로 로그아웃을 진행하였다.

 

 

loginForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" rel="stylesheet">
    <script th:src="@{/js/login/loginForm.js}"></script>
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
        .is-invalid {
            border-color: #dc3545;
            color: #dc3545;
        }
        .form-floating{
            margin-top: 20px;
        }
        div.is-invalid{
            font-size: 0.87em;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>

    <form action="item.html" id="loginForm" th:action th:object="${loginForm}" method="post">
        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}"
               th:text="${err}">전체 오류 메시지</p>
            <p>
        </div>
        <div class="form-floating">
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   placeholder="아이디를 입력하세요." th:errorclass="is-invalid">
            <label for="loginId">ID</label>
        </div>

        <div class="form-floating input-group mb-3">
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   placeholder="비밀번호를 입력하세요." th:errorclass="is-invalid">
            <span class="input-group-text" id="inputGroup-sizing-lg" style="background-color: white;">
                <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" id="passwordShow" fill="currentColor"
                     class="bi bi-eye" viewBox="0 0 16 16" style="color: lightgrey;">
                  <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/>
                  <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/>
                </svg>
            </span>
        </div>

        <div class="form-check text-start my-3">
            <input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
            <label class="form-check-label" for="flexCheckDefault">ID 기억하기</label>
        </div>

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" type="button"
                        th:onclick="|location.href='@{/member/join}'|">
                    회원 가입
                </button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" id="loginBtn" type="submit">로그인</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

복잡해 보이지만 역시나 부트스트랩을 사용해서 그렇다.

 

 

loginForm.js

function setCookie(cname, cvalue, exdays) {
    let d = new Date();
    d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
    let expires = "expires=" + d.toUTCString();
    document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}


function getCookie(cname) {
    let name = cname + "=";
    let decodedCookie = decodeURIComponent(document.cookie);
    let ca = decodedCookie.split(';');
    for (let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}


function rememberId() {
    let loginId = document.getElementById("loginId").value;
    // 체크박스가 체크되어 있으면, 쿠키에 아이디를 저장합니다.
    if (document.getElementById("flexCheckDefault").checked) {
        setCookie("rememberedId", loginId, 30); // 쿠키는 30일 동안 유효합니다.
    } else {
        // 체크박스가 체크되어 있지 않으면, 쿠키를 삭제합니다.
        document.cookie = "rememberedId=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
    }
}

function togglePasswordVisibility() {
    let passwordInput = document.getElementById("password");

    if (passwordInput.type === "password") {
        passwordInput.type = "text";
    } else {
        passwordInput.type = "password";
    }
}
document.addEventListener('DOMContentLoaded', function () {

    let rememberedId = getCookie("rememberedId");
    if (rememberedId !== "") {
        document.getElementById("loginId").value = rememberedId;
    }

    document.getElementById('loginBtn').addEventListener('click', function(event) {
        event.preventDefault();
        rememberId();
        document.getElementById('loginForm').submit();
    });

    document.getElementById('passwordShow').addEventListener('click', function () { togglePasswordVisibility();})
    document.getElementById('flexCheckDefault').addEventListener('change', function() { rememberId(); });
});

로그인 화면에서 '아이디 기억하기' 기능과 '비밀번호 보이기/숨기기' 기능 구현을 위해 자바스크립트를 사용했다. 내용이 생각보다 많으니 톧아 보기로 한다. 

 

1) 아이디 저장하기 기능 구현

실행 순서대로 톧아볼 예정

document.addEventListener('DOMContentLoaded', function () {
	document.getElementById('flexCheckDefault').addEventListener('change', function() { rememberId(); });
});

아이디 저장하기 체크박스에 변화가 감지되면 rememberId() 함수가 실행된다.

 

rememberId() 함수 실행

function rememberId() {
    let loginId = document.getElementById("loginId").value;
    // 체크박스가 체크되어 있으면, 쿠키에 아이디를 저장합니다.
    if (document.getElementById("flexCheckDefault").checked) {
        setCookie("rememberedId", loginId, 30); // 쿠키는 30일 동안 유효합니다.
    } else {
        // 체크박스가 체크되어 있지 않으면, 쿠키를 삭제합니다.
        document.cookie = "rememberedId=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
    }
}

체크박스가 체크되었다면 쿠키를 생성하고 그 기간을 30일 동안 유지되며, 체크되지 않았다면 쿠키를 삭제해준다.

 

setCookie()함수 호출

function setCookie(cname, cvalue, exdays) {
    let d = new Date();
    d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
    let expires = "expires=" + d.toUTCString();
    document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}

 

쿠키를 생성해주는 실질적인 함수. GPT의 도움을 받았다. 

 

화면이 처음 로드될 때

document.addEventListener('DOMContentLoaded', function () {

    let rememberedId = getCookie("rememberedId");
    if (rememberedId !== "") {
        document.getElementById("loginId").value = rememberedId;
    }
});

쿠키를 가져와서 그 쿠키가 존재한다면 쿠키에 담긴 로그인 아이디를 아이디 인풋에 넣어주는 방식으로 아이디 기억하기 기능이 구현된다. 

 

getCookie()

쿠키를 실질적으로 가져오는 부분

function getCookie(cname) {
    let name = cname + "=";
    let decodedCookie = decodeURIComponent(document.cookie);
    let ca = decodedCookie.split(';');
    for (let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}

 

 

2) 비밀번호 보이기/숨기기 기능 구현

document.getElementById('passwordShow').addEventListener('click', function () { togglePasswordVisibility();})
function togglePasswordVisibility() {
    let passwordInput = document.getElementById("password");

    if (passwordInput.type === "password") {
        passwordInput.type = "text";
    } else {
        passwordInput.type = "password";
    }
}

 

비밀번호 숨기기/보이기 버튼을 클릭하면 input의 타입을 password에서 Text로 토글 전환 함으로 구현한다. 

 

3) 로그인 기능 구현

document.getElementById('loginBtn').addEventListener('click', function(event) {
    event.preventDefault();
    rememberId();
    document.getElementById('loginForm').submit();
});

로그인 버튼 클릭시 아이디 기억하기 기능을 체크한 후 submit하여 컨트롤러에 접근한다. 

 


2. 로그아웃 기능 구현

 

LoginController.java

/**
 * 로그아웃
 * @param request
 * @return
 */
@GetMapping("/logout")
public String logout(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/book";
}

로그아웃 기능은 로그인 기능에 비해 무척이나 간단한다. 로그아웃을 할경우 세션을 무효화 시켜주기만 하면 된다. 

 

 


3. 회원가입 수정 및 구현

[요구사항]

  • 회원아이디, 닉네임, 비밀번호, 비밀번호 확인 모두 필수값이며 하나도 입력하지 않았을 경우 회원가입이 불가능하다.
  • 회원아이디, 닉네임, 비밀번호 모두 각각의 유효성체크를 필요로 한다.
  • 회원아이디의 경우 중복검사를 반드시 진행해야 하며 진행하지 않았을 경우 회원가입이 불가능하다. 
  • 비밀번호 입력후 비밀번호를 한번 더 입력하지 않을 경우 회원가입이 불가능하다.

 

 

joinForm.js

이전 개발일지에 회원가입에 대한 기본적인 컨트롤러, 서비스, 메퍼들은 생성하고 소개했으니 수정 /추가한 부분에 대해서만 언급할 예정이다.

function idValidate(){
    let idInput = document.getElementById('memberId');
    let idError = document.getElementById('idError');

    if (idInput.value.trim() === '') {
        idInput.classList.add('is-invalid');
        idError.style.display = '';
        idError.textContent = 'ID는 필수입니다.';
    } else {
        idInput.classList.remove('is-invalid');
        idError.style.display = 'none';
    }

    let idRegExp = /^[a-zA-Z0-9!_.-]{4,12}$/;
    if( !idRegExp.test(idInput.value) ) {
        idInput.classList.add('is-invalid');
        idError.style.display = '';
        idError.textContent = '4~12글자, 영어나 숫자만 가능합니다. (입력불가특수문자 : @\#$%&=/)';
    } else {
        idInput.classList.remove('is-invalid');
        idError.style.display = 'none';
    }
}

function nameValidate(){
    let memberNameInput = document.getElementById('memberName');
    let memberNameError = document.getElementById('memberNameError');

    if (memberNameInput.value.trim() === '') {
        memberNameInput.classList.add('is-invalid');
        memberNameError.style.display = '';
        memberNameError.textContent = '닉네임은 필수입니다.';
    } else {
        memberNameInput.classList.remove('is-invalid');
        memberNameError.textContent = 'none';
    }

    let nicknameRegExp = /^[a-zA-Z0-9가-힣]{1,20}$/;
    if( !nicknameRegExp.test(memberNameInput.value) ) {
        memberNameInput.classList.add('is-invalid');
        memberNameError.style.display = '';
        memberNameError.textContent = '1~20글자, 한글, 영어, 숫자만 가능합니다.';
    } else {
        memberNameInput.classList.remove('is-invalid');
        memberNameError.style.display = 'none';
    }
}

function passwordValidate() {
    let passwordInput = document.getElementById('password');
    let passwordError = document.getElementById('passwordError');
    if (passwordInput.value.trim() === '') {
        passwordInput.classList.add('is-invalid');
        passwordError.textContent = '비밀번호는 필수 입니다.';
    } else {
        passwordInput.classList.remove('is-invalid');
        passwordError.textContent = '';
    }

    let passwordRegExp = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/;
    if (!passwordRegExp.test(passwordInput.value)) {
        passwordInput.classList.add('is-invalid');
        passwordError.style.display = '';
        passwordError.textContent = '최소 8자 이상 영문, 한글, 숫자, 특수문자가 포함되어야 합니다.';
    } else {
        passwordInput.classList.remove('is-invalid');
        passwordError.style.display = 'none';
    }
}

function checkPasswordValidate(){
    let passwordInput = document.getElementById('password');
    let checkPasswordInput = document.getElementById('checkPassword');

    let checkPasswordSuccess = document.getElementById('checkPasswordSuccess');
    let checkPasswordError = document.getElementById('checkPasswordError');

    if ( passwordInput.value.trim() !== '' ) {
        if (passwordInput.value === checkPasswordInput.value) {
            checkPasswordSuccess.style.display = '';
            checkPasswordError.style.display = 'none';
            checkPasswordInput.classList.remove('is-invalid');
            checkPasswordInput.classList.add('is-valid');
            checkPasswordSuccess.textContent = '비밀번호가 일치합니다.';
        } else {
            checkPasswordSuccess.style.display = 'none';
            checkPasswordError.style.display = '';
            checkPasswordInput.classList.add('is-invalid');
            checkPasswordInput.classList.remove('is-valid');
            checkPasswordError.textContent = '비밀번호가 일치하지 않습니다.';
        }
    }
}

function idCheck() {
    let idInput = document.getElementById('memberId');
    let idError = document.getElementById('idError');
    let idNotError = document.getElementById('idNotError');

    fetch("/" + idInput.value + "/idCheck", {
        method : "POST"
    })
        .then(response => {
            if(response.ok){
                console.log("아이디 사용 가능");
                idInput.classList.remove('is-invalid');
                idInput.classList.add('is-valid');
                idError.style.display = 'none';
                idNotError.textContent = '사용 가능한 아이디 입니다.';
                idNotError.style.display = '';
            } else {
                console.log("이미 사용 중인 아이디 입니다.");
                idInput.classList.remove('is-valid');
                idInput.classList.add('is-invalid');
                idError.style.display = '';
                idError.textContent = '이미 사용 중인 아이디 입니다.';
                idError.style.display = '';
                idNotError.style.display = 'none';
            }
        })
        .catch(error => {
            console.error("error", error);
        })
}

document.addEventListener('DOMContentLoaded', function () {

    document.getElementById('saveBtn').addEventListener('click', function(event) {
        event.preventDefault();

        idValidate();
        idCheck();
        nameValidate();
        passwordValidate();
        checkPasswordValidate();


        if(!document.getElementById('memberId').classList.contains('is-valid')){
            alert("아이디 중복검사는 필수 입니다.");
            return;
        }
        if (   document.getElementById('memberId').classList.contains('is-valid')
            && !document.getElementById('memberName').classList.contains('is-invalid')
            && !document.getElementById('password').classList.contains('is-invalid')
            && document.getElementById('memberId').classList.contains('is-valid') ) {
            document.getElementById('joinForm').submit();
        }
    });

    document.getElementById('memberId').addEventListener('keyup', function() { idValidate(); });
    document.getElementById('memberName').addEventListener('keyup', function() { nameValidate(); });
    document.getElementById('password').addEventListener('keyup', function() { passwordValidate(); });
    document.getElementById('checkPassword').addEventListener('keyup', function() { checkPasswordValidate(); });


    document.getElementById('idCheck').addEventListener('click', idCheck);
});

아이디 (아이디 유효성검사, 중복검사), 닉네임, 비밀번호, 비밀번호 확인에 대한 유효성검사를 진행하고 모든 항목의 유효성 검사에 문제가 없을 때, 회원가입이 진행되도록 한다. 

 

728x90
320x100