본문 바로가기
스프링/스프링 웹 개발 활용

[Spring] 로그인 구현, 쿠키 적용

by drCode 2023. 11. 13.
728x90
반응형

 

지난 포스팅에 이어서 로그인을 구현해보면 아래와 같다.

 

LoginService.java

package hello.login.domain.login;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;

    /****
     * @return null이면 로그인 실패
     */
    public Member login(String loginId, String password) {
/*        Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
        Member member = findMemberOptional.get();
        if(member.getPassword().equals(password)) {
            return member;
        } else {
            return null;
        }*/

        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
}

 

로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서 같으면

회원을 반환하고, 만약 password와 다르면 null을 반환한다.

 

LoginForm.java

package hello.login.web.login;

import lombok.Data;

import javax.validation.constraints.NotEmpty;

@Data
public class LoginForm {

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
}

 

 

LoginController.java

package hello.login.web.login;

import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
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.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

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

    @PostMapping("/login")
    public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
        if(bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        log.info("loginMember={}", loginMember);

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

        return "redirect:/";
    }
}

 

로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고,

로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류(ObjectError)를 생성한다.

 

그리고 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.

 

로그인 폼 뷰 템플릿

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"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

 

로그인 폼 뷰 템플릿에는 특별한 코드는 없다.

loginId, password 가 틀리면 글로벌 오류가 나타난다.

 

로그인 실패

 

실행해보면 로그인이 성공하면 홈으로 이동하고,

로그인에 실패하면 "아이디 또는 비밀번호가 맞지 않습니다."라는 경고와 함께 로그인 폼이 나타난다.

 

로그인을 성공하여 로그인 상태를 유지하면서 로그인 후 고객의 이름을 보여주려면 쿠키를 사용해야 한다.

 

 

쿠키 관련 포스팅은 하단 링크를 참고하자

https://drcode-devblog.tistory.com/390

 

[HTTP] Stateful, Stateless

무상태 프로토콜(stateless) 서버가 클라이언트의 상태를 보존 X 장점 : 서버 확장성 높음(스케일 아웃) 단점 : 클라이언트가 추가 데이터 전송 Stateful, Stateless 차이 Stateful (상태 유지) 고객 : 이 노트

drcode-devblog.tistory.com

 

https://drcode-devblog.tistory.com/403

 

[HTTP] HTTP 헤더 - 일반헤더 : 일반 정보, 특별한 정보, 인증, 쿠키

일반 정보 From : 유저 에이전트의 이메일 정보 Referer : 이전 웹 페이지 주소 User-Agent : 유저 에이전트 애플리케이션 정보 Server : 요청을 처리하는 오리진 서버의 소프트웨어 정보 Date : 메시지가 생

drcode-devblog.tistory.com

 

로그인 상태를 유지하려면 쿠키를 사용하는 방법이 있다.

 

쿠키?

서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면,

브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.

 

쿠키 생성

 

 

클라이언트에 쿠키 전달 1

 

클라이언트 쿠키 전달 2

 

쿠키에는 영속 쿠키와 세션 쿠키가 있다.

  • 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
  • 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

 

브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다

 

LoginController - login()

로그인 성공시 세션 쿠키를 생성하자.

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
    if(bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
    log.info("loginMember={}", loginMember);

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

    // 로그인 성공 처리

    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

    return "redirect:/";
}

 

 

쿠키 생성 로직

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);

로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 에 담는다.

쿠키 이름은 memberId 이고, 값은 회원의 id 를 담아둔다.

웹 브라우저는 종료 전까지 회원의 id 를 서버에 계속 보내줄 것이다.

 

실행

크롬 브라우저를 통해 HTTP 응답 헤더에 쿠키가 추가된 것을 확인할 수 있다.

 

개발자 도구 - 애플리케이션 - 쿠키

 

이제 요구사항에 맞추어 로그인에 성공하면 로그인 한 사용자 전용 홈 화면을 보여주자.

 

홈 - 로그인 처리

HomeController.java

package hello.login.web;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

    //    @GetMapping("/")
    public String home() {
        return "home";
    }

    @GetMapping("/")
    // 자동으로 타입컨버팅 해준다. 쿠키는 원래 String
    public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {

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

        // 로그인
        Member loginMember = memberRepository.findById(memberId);
        if(loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";

    }
}

 

  • 기존 home()에 있는 @GetMapping("/") 은 주석 처리한다.
  • @CookieValue 를 사용하면 편리하게 쿠키를 조회할 수 있다.
  • 로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false 를 사용한다.

로직 분석

  • 로그인 쿠키(memberId)가 없는 사용자는 기존 home으로 보낸다.
    추가로 로그인 쿠키가 있어도 회원이 없으면 home으로 보낸다.
  • 로그인 쿠키(memberId)가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome으로 보낸다.
    추가로 홈 화면에 회원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달한다.

홈 - 로그인 사용자 전용

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="|로그인: ${member.name}|">로그인 사용자 이름</h4>
    <hr class="my-4">
    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/items}'|">
                상품 관리
            </button>
        </div>
        <div class="col">
            <form th:action="@{/logout}" method="post">
                <button class="w-100 btn btn-dark btn-lg" type="submit">
                    로그아웃
                </button>
            </form>
        </div>
    </div>
    <hr class="my-4">
</div> <!-- /container -->
</body>
</html>
  • th:text="|로그인: ${member.name}|" : 로그인에 성공한 사용자 이름을 출력한다.
  • 상품 관리, 로그아웃 버튼을 노출한다.

실행

로그인에 성공하면 사용자 이름이 출력되면서 상품 관리, 로그아웃 버튼을 확인할 수 있다.

로그인에 성공시 세션 쿠키가 지속해서 유지되고, 웹 브라우저에서 서버에 요청시 memberId 쿠키를 계속 보내준다.

 

로그아웃 기능

  • 세션 쿠키이므로 웹 브라우저 종료 시 서버에서 해당 쿠키의 종료 날짜를 0으로 지정

 

LoginController - logout 기능 추가

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    // Control + Alt + M 누르면 리펙토링 가능
    expireCookie(response, "memberId");
    return "redirect:/";
}

private void expireCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

 

로그아웃도 응답 쿠키를 생성하는데 Max-Age=0 를 확인할 수 있다. 해당 쿠키는 즉시 종료된다.

 

Cache-Control : max-age=0

728x90
반응형

댓글