RE-Heat 개발자 일지

스프링 MVC 2편 - [6] 로그인 처리 1 - 쿠키·세션(상편) 본문

백엔드/스프링

스프링 MVC 2편 - [6] 로그인 처리 1 - 쿠키·세션(상편)

RE-Heat 2023. 7. 20. 17:38

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

인프런 김영한님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

 

[1] 로그인 요구사항

■ 로그인 요구사항

  • 홈 화면 - 로그인 전
    회원 가입
    로그인
  • 홈 화면 - 로그인 후
    사용자 이름
    상품 관리
    로그 아웃
  • 보안 요구사항
    로그인 사용자만 상품에 접근하고, 관리할 수 있음
    로그인하지 않은 사용자가 상품 관리에 접근하면 로그인 화면으로 이동
  • 회원 가입, 상품 관리

■ 화면

 


[2] 프로젝트 생성

■ 패키지 구조 설계

  • domain 
    • item 
    • member 
    • login
  • web
    • item 
    • member 
    • login

■ 도메인 = 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 일컬음(화면·UI·기술 인프라 등의 영역 등은 제외).

향후 web이 다른 기술(타임리프 → API 등)로 바뀌어도 도메인 영역은 그대로 유지돼야 한다.

이렇게 하려면 web은 domain을 의존하지만, domain은 web에 의존하지 않게 설계하면 된다.

 

예시) 웹의 ItemSaveForm에 의존하는 것을 피하기 위해 domain의 ItemRepository를 사용할 때, 컨트롤러에서 ItemSaveForm 대신에 Item을 사용하도록 변경.

 


[3] 홈 화면 · 회원가입

■ 홈 화면

 

HomeController

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

 

Home.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>
    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/members/add}'|">
                회원 가입
            </button>
        </div>
        <div class="col">
            <button class="w-100 btn btn-dark btn-lg"
                    onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/login}'|" type="button">
                로그인
            </button>
        </div>
    </div>
    <hr class="my-4">
</div> <!-- /container -->
</body>
</html>

 

■ 회원가입

MemberController

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult){
        if (bindingResult.hasErrors()){
            return "members/addMemberForm";
        }
        memberRepository.save(member);
        return "redirect:/";
    }
}

save() : 바인딩 오류가 있으면 회원 가입 템플릿으로 없으면 회원가입 후 홈으로 이동

 

회원가입 테스트용 데이터

TestDataInit

@Component
@RequiredArgsConstructor
public class TestDataInit {
    private final ItemRepository itemRepository;
    private final MemberRepository memberRepository;

    /**
     * 테스트용 데이터 추가
     */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));

        //테스트용 회원
        Member member = new Member();
        member.setLoginId("test");
        member.setPassword("test!");
        member.setName("테스터");

        memberRepository.save(member);
    }
}

테스트할 때마다 회원가입을 하기엔 번거로우므로 테스터용 아이디를 미리 만들어 놓는다.

 

참고] @PostConstruct

의존 관계 주입이 완료된 후 실행되는 '초기화 콜백'을 적용할 수 있는 애노테이션, WAS가 띄워질 때 실행된다.

 


[5] 로그인 기능

LoginService

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;

    /*
    * @return null 로그인 실패
    * */

    public Member login(String loginId, String password){
        return memberRepository.findByLoginId(loginId).filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
}

로그인 핵심 비즈니스 로직. 파라미터로 넘어온 아이디와 패스워드가 일치하는지 확인해 같으면 회원을 반환하고, pw가 다르면 null을 반환한다.

 

참고] Optional Stream

Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);

Member member = findMemberOptional.get();

if (member.getPassword() == password) {

    return member;

} else {

    return null

}

=> login의 리턴값에 Optional Stream을 사용하기 이전 버전이다.

 

LoginController

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

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

    if (loginMember == null){
        //특정 필드 문제 아니므로 글로벌 오류[@ScriptAssert()론 불가능. 한 번 찾아야 하기 때문.]
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //로그인 성공 처리 TODO

    return "redirect:/";
}

로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 bindingResult.reject()를 사용해 글로벌 오류를 생성한다. 그리고 다시 정보를 입력하도록 로그인 폼으로 돌려보낸다.

 

실행하면 로그인이 되나, 로그인에 성공한 사용자에게 고객의 이름을 보여줘야 한다는 요구사항은 충족하지 못했다. 그러면 어떻게 로그인 상태를 유지해야 할까?

 


[6] 로그인 처리하기 - 쿠키 사용

■ 로그인 상태를 유지하는 방법

① 쿼리 파라미터를 계속 유지하면서 보내기 => 매우 번거롭고 어렵다.

② 쿠키를 사용하기

 

■ 쿠키

1. 쿠키 생성

로그인에 성공하면 서버는 쿠키를 생성해 응답에 실어 보낸다. 클라이언트는 받은 쿠키를 쿠키 저장소에 저장해 둔다.

 

2. 클라이언트 쿠키 전달

클라이언트는 요청을 보낼 때마다 쿠키를 서버로 보낸다.

 

■ 쿠키의 종류

영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 쿠키를 유지

세션 쿠키 : 만료 날짜 생략 시 브라우저 종료할 때까지만 유지

 

LoginController - 쿠키 사용

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

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

    if (loginMember == null){
        //특정 필드 문제 아니므로 글로벌 오류[@ScriptAssert()론 불가능. 한 번 찾아야 하기 때문.]
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //로그인 성공 처리
    //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

    return "redirect:/";
}

로그인에 성공하면 쿠키를 생성(new Cookie)하고 HttpServletResponse에 담아둔다.

쿠키 이름은 memberId, 값은 회원 id를 담아둔다. 회원 id가 Long타입인데, 쿠키는 String 타입을 요구하므로 id를 String.vlaueOf로 변환해야 한다.

 

HomeController - homeLogin

@GetMapping("/")
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";
}

① @CookieValue를 쓰면 편리하게 쿠키를 조회할 수 있다.

② 로그인하지 않은 사용자도 홈에 접근해야 하므로 required는 false로 지정. 

    => required=true로 하면 value 속성의 이름을 가진 쿠키가 없으면 예외를 발생시킨다.

③ 로직

    1] 쿠키가 없으면 home으로 보낸다.

    2] 쿠키가 있어도 회원이 없으면 home으로 보낸다.

    3] 로그인 쿠키가 있는 사용자는 로그인 사용자 전용 화면인 loginHome으로 보낸다.

        추가로 홈 화면에 회원 정보도 출력해야 하므로 member데이터를 모델에 담아 전달한다.

 

실행화면

① 로그인에 성공하면 loginHome.html의 아래 코드 부분에 사용자 이름이 출력된다.

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

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

 

■ 로그아웃 기능

LoginController - logout

@PostMapping("/logout")
public String logout(HttpServletResponse response){
    expireCookie(response, "memberId");
    return "redirect:/";
}

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

① logout을 실행하면 memberId란 쿠키의 종료 날짜를 0으로 지정한다. 그러면 해당 쿠키는 즉시 종료된다.