스프링 부트 게시판 프로젝트 - 8 | 로그인 기능 개발

seren-dev·2022년 8월 28일
1

로그인 컨트롤러, 서비스 개발

로그인 폼 전송용 객체

  • 로그인 ID와 비밀번호를 전송하기 위한 폼 전송용 객체를 따로 생성한다.
  • controller 패키지 내 login 패키지를 생성하여 login 패키지 안에서 생성한다.

UserLoginForm

package hello.board.controller.login;

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotBlank;

@Getter @Setter
public class UserLoginForm {

    @NotBlank
    private String loginId;

    @NotBlank
    private String password;
}

로그인 서비스

LoginService

package hello.board.service;

import hello.board.entity.User;
import hello.board.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@RequiredArgsConstructor
public class LoginService {

    private final UserRepository userRepository;

    /**
     * 로그인
     * @return null 로그인 실패
     */
    public User login(String loginId, String password) {
        return userRepository.findByLoginId(loginId)
                .filter(u -> u.getPassword().equals(password))
                .orElse(null);
    }
}
  • 핵심 비즈니스 로직 : 로그인이 되는지 판단하는 로직
    로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서 같으면 회원을 반환하고, 만약 password가 다르면 null을 반환한다.
Optional<User> findUser = userRepository.findByLoginId(loginId);
User user = findUser.get();
if (user.getPassword().equals(password)) {
		return user;
}
else return null;

⇒ 스트림 사용

return userRepository.findByLoginId(loginId)
                .filter(u -> u.getPassword().equals(password))
                .orElse(null);

로그인 컨트롤러

  • login 패키지 안에서 생성한다.

loginController

package hello.board.controller.login;

import hello.board.entity.User;
import hello.board.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import javax.validation.Valid;

@Controller
@Slf4j
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") UserLoginForm loginForm) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String login(@Valid @ModelAttribute("loginForm") UserLoginForm loginForm, BindingResult bindingResult) {

        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "login/loginForm";
        }

        User loginUser = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

        if (loginUser == null) {
            log.info("login Fail");
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다");
            return "login/loginForm";
        }

        //로그인 성공 TODO

        return "redirect:/";
    }
}
  • 로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류( ObjectError)를 생성한다. 그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.
  • 글로벌 오류는 직접 작성하는 것이 낫다. (DB 조회 과정을 거치는 경우도 있기 때문)

로그인 폼 뷰 템플릿

templates/login/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">
    <style>
    .container {
    max-width: 560px;
    }
    .field-error {
    border-color: #dc3545;
    color: #dc3545;
    }
</style>
</head>

<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>
    <form action="item.html" 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>
        </div>

        <div>
            <label for="loginId">ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}" />
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}"
                   class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}" />
        </div>

        <hr class="my-4">
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">
                    로그인</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

로그인 화면


로그인 상태 유지

  • 쿠키와 세션(HttpSession)을 사용하여 로그인 상태를 유지하고, 로그인이 되면 홈 화면에 사용자의 로그인 ID가 보이도록 한다.

서블릿 HTTP 세션

  • 서블릿은 세션을 위해 HttpSession 이라는 기능을 제공하는데, 세션을 생성, 조회, 삭제하는 기능을 제공한다. 추가로 세션을 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능을 제공한다.
  • 서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID이고, 값은 추정 불가능한 랜덤 값이다.

Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

Cookie, Session, Token 의 차이점
세션 / 쿠키 방식과 토큰의 가장 큰 차이점은 세션 / 쿠키세션 저장소에 유저 정보를 넣는 반면, JWT토큰 안에 유저의 정보들이 넣어진다는 점 입니다. 클라이언트 입장에서는 HTTP 헤더에 세션 ID 나 토큰을 실어서 보내준다는 점에선 동일하지만, 서버 측에서는 인증을 위해 암호화를 한다 vs 별도의 저장소를 이용한다 의 차이가 발생합니다.

SessionConst

package hello.board.controller;

public abstract class SessionConst {
    public static final String LOGIN_USER = "loginUser";
}
  • HttpSession 에 데이터를 보관하고 조회할 때, 같은 이름이 중복되어 사용되므로, 상수를 하나 정의했다.
  • 생성하는 객체가 아니라 static final로 글자만 참조할 것이기 때문에, 추상 클래스나 인터페이스로 생성한다.

로그인 컨트롤러

package hello.board.controller.login;

import hello.board.controller.SessionConst;
import hello.board.entity.User;
import hello.board.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;

@Controller
@Slf4j
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") UserLoginForm loginForm) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String login(@Valid @ModelAttribute("loginForm") UserLoginForm loginForm, BindingResult bindingResult, HttpServletRequest request) {

        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "login/loginForm";
        }

        User loginUser = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

        if (loginUser == null) {
            log.info("login Fail");
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다");
            return "login/loginForm";
        }

        //로그인 성공
        //세션이 있으면 세션 반환, 없으면 신규 세션을 생성
        HttpSession session = request.getSession();

        //세션에 로그인 회원 정보 보관
        session.setAttribute(SessionConst.LOGIN_USER, loginUser);

        return "redirect:/";
    }

    @PostMapping("/logout")
    public String logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/";
    }
}

세션 생성과 조회
세션을 생성하려면 request.getSession(true) 를 사용하면 된다. public HttpSession getSession(boolean create);

  • request.getSession(true) : default
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성해서 반환한다.
  • request.getSession(false)
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.

세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_USER, loginUser);

로그아웃
session.invalidate() : 세션을 제거한다.

HttpSession 동작원리

  • request.getSession()을 하면
    request 정보에서 얻어온 UUID값으로 이뤄진 쿠키의 value 값을 보고 Session들을 모아둔 Session저장소에서 동일한 sessionId(=UUID) 값이 있는지 찾는다.
  • Session저장소에서 sessionId는 key값으로 쓰인다.
  • 동일한 sessionId가 있으면 해당 Session을 가져오고, sessionId가 없으면 Session을 새로 만들어 반환한다.
  • session.setAttribute(SessionConst.LOGIN_USER, loginUser)를 하면 request.getSession으로 가져온 특정 Session 내에 key(=SESSIONCONST.LOGIN_USER)value(=loginUser)를 저장시켜 나중에, sessionId를 통해 특정 Session을 가져올 때 가져온 Session 내에서 key(loginUser)를 가지고 loginUser 값을 가져올 수 있다.
    참고 : 인프런 커뮤니티 질문 글

세션 생성 후, 응답에 쿠키로 jsessionid가 담겨져 클라이언트에게 전달된다. jsessionid발급되고 응답에 쿠키로 넣는 것tomcat이 처리한다.
세션 생성 시, 세션 ID 생성 및 쿠키 생성을 tomcat이 다 처리한다.

홈 컨트롤러 수정

HomeController - homeLogin()

package hello.board.controller;

import hello.board.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.SessionAttribute;

@Controller
@Slf4j
public class HomeController {

//    @GetMapping("/")
//    public String home() {
//        log.info("home controller");
//        return "home";
//    }

    @GetMapping("/")
    public String loginHome(@SessionAttribute(name = SessionConst.LOGIN_USER, required = false) User loginUser, Model model) {

        if (loginUser == null) {
            return "home";
        }

        model.addAttribute("user", loginUser);
        return "loginHome";
    }
}
  • @SessionAttribute를 사용하여 세션을 찾고, 세션에 들어있는 데이터를 찾는 과정을 편리하게 처리한다.

홈 화면 - 로그인 사용자 전용

templates/loginHome.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">
</head>
<body>
<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>

    <h4 class="mb-3" th:text="|로그인: ${user.loginId}|">로그인 사용자 이름</h4>

    <hr class="my-4">

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-info btn-lg" type="button"
                    th:onclick="|location.href='@{/board}'|">
                게시판
            </button>
        </div>
        <div class="col">
            <form th:action="@{/logout}" method="post">
                <button class="w-100 btn btn-dark btn-lg" type="submint">
                    로그아웃
                </button>
            </form>
        </div>
    </div>
    <hr class="my-4">

</div> <!-- /container -->
</body>
</html>

문제점

로그인을 처음 시도하면 홈 화면이 뜨지 않고 Welcom Page가 뜬다.

로그를 찍어본 결과, login을 하면 홈 화면으로 리다이렉트 되어야 하는데 리다이렉트 되지 않는다.
하지만 다른 창에서 다시 localhost로 접속하면 로그인 화면이 제대로 뜬다.

해결

로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=6B84F014CBDF6C3D24DE6E532C83B291
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
URL에 jsessionid가 같이 전달되어 컨트롤러에서 해당되는 URL을 찾지 못하고 Welcome Page가 뜨는 것이다.
URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음 옵션을 넣어주면 된다. 이렇게 하면 URL에 jsessionid가 노출되지 않는다.
application.properties
server.servlet.session.tracking-modes=cookie

TrackingModes

로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다. 이 방법을 사용하려면 URL에 이 값을 계속 포함해서 전달해야 한다. 타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다. 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로, 쿠키 값도 전달하고, URL에 jsessionid도 함께 전달한다.

정상적으로 로그인 화면이 뜨는 것을 확인할 수 있다.

로그인 성공 화면

0개의 댓글