Spring

[인프런/스프링 MVC 2편] 6. 로그인 처리1 - 쿠키, 세션

주니어주니 2023. 5. 25. 23:52

 

 

1. 로그인 요구사항 

 

  • 홈 화면 - 로그인 전
    • 회원 가입
    • 로그인 

  • 회원가입, 로그인

  • 홈 화면 - 로그인 후 
    • 본인 이름
    • 상품 관리
    • 로그아웃

  • 보안 요구사항
    • 로그인 사용자만 상품에 접근하고, 관리할 수 있음
    • 로그인 하지 않은 사용자가 상품 관리에 접근하면 로그인 화면으로 이동

 


 

2. 패키지 구조 설계 

  • package 구조

  • 도메인 : 화면, UI, 기술 인프라 등의 영역을 제외한, 시스템이 구현해야 하는 핵심 비즈니스 업무 영역
  • web을 다른 기술로 바꾸어도 도메인은 유지해야 함
    • web --> domain (web은 domain 의존 O)
    • web <-/- domain (domain은 web 의존 X)

 

💡 패키지 폴더 구조 예시 -> 상황에 맞게 구조 

 * 도메인 단위로 상위 패키지 개념을 더 추가 

project

- blog
    - controller
    - dto
    - entity
    - service
- member
    - controller
    - dto
    - entity
    - service


*  블로그 컨트롤러에서 회원과 관련된 서비스를 자주 사용할 때 

project

- controller
    - blog
    - member
- service
    - blog
    - member
- entity
    - blog
    - member

 

 


 

3. 홈 화면 

 

* HomeController 

package hello.login.web;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Slf4j
@Controller
public class 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>

 

 

 


 

4. 회원 가입

 

1) 회원 객체 - Member 

 

package hello.login.domain.member;

import lombok.Data;

import javax.validation.constraints.NotEmpty;

@Data
public class Member {

    private Long id;            // 시퀀스로 부여되는 아이디

    @NotEmpty
    private String loginId;     // 로그인 ID
    @NotEmpty
    private String name;
    @NotEmpty
    private String password;
}

 

 

 

2) db, dao 역할 - MemberRepository

** 람다식 쓴 거 잘 보기~! 

package hello.login.domain.member;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

import java.util.*;

@Slf4j
@Repository
public class MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();   // static 사용
    private static long sequence = 0L;  // static 사용

    public Member save(Member member) {
        member.setId(++sequence);
        log.info("save: member={}", member);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public Optional<Member> findByLoginId(String loginId) {
        return findAll().stream()
                .filter(m -> m.getLoginId().equals(loginId))
                .findFirst();
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}

 

 

 

3) 컨트롤러 - MemberController

 

package hello.login.web.member;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@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:/";
    }
}

 

 

 

4) 회원가입 뷰 템플릿 - addMemberForm.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>

  <h4 class="mb-3">회원 정보 입력</h4>

  <form action="" th:action th:object="${member}" 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>

    <div>
      <label for="name">이름</label>
      <input type="text" id="name" th:field="*{name}" class="form-control" th:errorclass="field-error">
      <div class="field-error" th:errors="*{name}" />
    </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>

 

 

 

5) 실행 

 

 

 

 

6) 테스트용 회원 데이터 추가 - TestDataInit 

 

package hello.login;

import hello.login.domain.item.Item;
import hello.login.domain.item.ItemRepository;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

@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);
    }

}

 

 


 

5. 로그인 

 

 

1) 로그인 핵심 비즈니스 로직 - LoginService 

** optional, stream, 람다식 사용한 것 잘 보기

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) {
        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(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;
    }
}

 

 

 

2) 로그인 폼 객체 - LoginForm

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;
}

 

 

 

3) 컨트롤러 - LoginController 

  • 로그인 서비스 호출 -> 성공하면 홈화면으로 이동, 실패하면 글로벌 오류 생성( bindingResult.reject()
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.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

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

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

        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

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

        return "redirect:/";
    }
}

 

 

 

4) 로그인 폼 뷰 템플릿 - 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>

 

 

 

5) 실행

 

 


 

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

 

 

1) 쿠키 설명

  • 쿠키 사용 -> 로그인 상태 유지 

 

 

쿠키 생성 

 

서버에서 로그인 성공 처리할 때, HTTP 응답에 쿠키 만들어서 브라우저에 전달 

 

 

 

쿠키 전달

 

브라우저가 앞으로 해당 쿠키를 지속해서 보내줌 -> 로그인 유지

 

 

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

 

 

 

2) 쿠키 생성 - LoginController 

 

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

        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

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

        // 로그인 성공 처리

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

        return "redirect:/";
    }

 

  • 로그인 성공시 세션 쿠키 생성 -> 응답 객체(HttpServletResponse)에 담음
    (쿠키 이름 : memberId, 값 : 회원의 Id)
  • 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내줌

 

 

 

 

 

 

3) 로그인 한 사용자 전용 홈화면 

 

(1) 홈 컨트롤러 - HomeController 

 

@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 : 편리하게 쿠키 조회
    • name : 쿠키 이름 
    • required = false : 로그인 하지 않은 사용자도 홈에 접근할 수 있음
  • 로그인 쿠키(memberId)가 없는 사용자 -> 기존 home 
  • 로그인 쿠키가 있어도 회원 중에 없으면 -> 기존 home
  • 로그인 쿠키가 있는 사용자 (로그인 한 사용자) -> 로그인 사용자 전용 loginHome 으로 이동
    홈 화면에 회원 이름 출력하기 위해 모델에 member 데이터 담아서 전달

 

 

(2) 로그인 사용자 전용 홈 - 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}|" : 로그인에 성공한 사용자 이름 출력
  • 세션 쿠키가 지속해서 유지

 

 

 

 

4) 로그아웃 

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

 

 

(1) 로그인 컨트롤러 - LoginController

 

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

private static void expireCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}
  • 로그인한 사용자 전용 홈에서 로그아웃을 POST Form 형태로 요청 -> PostMapping
  • 쿠키 이름이 "memberId"인 쿠키의 종료 날짜를 0으로 지정 -> 종료

 

 

 

 

 

5) 보안 문제 

  • 쿠키의 값을 임의로 변경 가능 
    • 개발자모드 -> 애플리케이션 -> 쿠키 -> 값 변경 가능
  • 쿠키에 보관된 정보를 훔쳐갈 수 있음
    • 개인정보 / 신용카드 정보 등 

 

 

 

 

6) 대안 

  • 예측 불가능한 임의의 토큰(랜덤값) 노출, 서버에서 토큰과 사용자 id를 매핑해서 인식, 서버에서 토큰 관리 
  • 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지

-> 서버에 저장 

 

 

 


 

7. 로그인 처리 - 세션 사용 

 

서버에 중요한 정보 보관, 연결 유지하는 방법 

 

7-1. 세션 동작 방식

 

(1) 로그인

 

  • 사용자가 loginId, password 정보 전달 -> 서버에서 회원 저장소를 뒤져서 해당 사용자가 맞는지 확인

 

 

(2) 세션 생성

 

  • 회원 정보를 찾으면 UUID를 이용해 세션 ID 생성 -> UUID는 추정 불가능
    ( 예 - Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61 )
  • 생성된 세션 ID와 세션에 보관할 값(memberA-회원정보)을 서버의 세션 저장소에 보관

 

 

(3) 세션 id를 응답 쿠키로 전달 

 

  • 클라이언트와 서버는 결국 쿠키로 연결
    • 서버는 클라이언트에 mySessionId라는 이름으로 세션 ID만 쿠키에 담아서 전달 
    • 클라이언트는 쿠키 저장소에 mySessionId 쿠키 보관 
  • ** 중요
    • 회원과 관련된 정보는 전혀 클라이언트에 전달하지 X 
    • 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달

 

 

(4) 클라이언트의 세션 id 쿠키 전달

 

  • 클라이언트는 요청 시 항상 mySessionId 쿠키 전달
  • 서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용

 

 

(5) 정리 

 

세션 사용 -> 서버에서 중요한 정보 관리

  • 쿠키 값 변조 가능 -> 해결 : 예상 불가능한 복잡한 세션 Id 사용
  • 클라이언트 해킹 시 쿠키에 보관한 정보 해킹 가능 -> 해결 : 세션 Id가 털려도 쓸 만한 정보 X
  • 쿠키 탈취 후 사용 -> 토큰을 털어가도 세션의 만료시간을 짧게 유지, 서버에서 해당 세션 강제 제거 가능

 

 

 

 

7-2. 세션 직접 만들기 

 

1) 세션 관리 기능

  • 세션 생성
    • sessionId 생성 (UUID -> 추정 불가능한 랜덤 값)
    • 세션 저장소sessionId보관할 값(회원 객체) 저장 
    • sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
      ( Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61 ) 
  • 세션 조회
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값(회원 객체) 조회
  • 세션 만료
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

 

 

2) 세션 관리 객체 - SessionManager 

  • @Component : 스프링 빈으로 자동 등록
  • ConcurrentHashMap : 동시 요청에 안전함

 

package hello.login.web.session;

import org.springframework.stereotype.Component;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 세션 관리
 */
@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {

        // 세션 id를 생성하고, 값(예 - 회원 객체)을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        // 쿠키 생성 (쿠키 이름 : SESSION_COOKIE_NAME, 쿠키 값 : 랜덤으로 생성한 세션 아이디)
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    public Object getSession(HttpServletRequest request) {
        // 클라이언트의 요청 request에서 이름이 SESSION_COOKIE_NAME 인 쿠키 조회
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }
        // 세션 저장소에서 쿠키의 이름이 SESSION_COOKIE_NAME 일 때의 쿠키 값(세션 아이디)으로 세션 조회
        return sessionStore.get(sessionCookie.getValue());
    }

    /**
     * 세션 만료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }

    /**
     * 쿠키 조회 메소드 추출
     */
    public Cookie findCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }

}

 

 

 

* 세션 조회 

아래 코드 -> 메소드 추출, stream 람다식 사용 

public Object getSession(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null) {
        return null;
    }
    for (Cookie cookie : cookies) {
        if(cookie.getName().equals(SESSION_COOKIE_NAME)) {
            return sessionStore.get(cookie.getName());
        }
    }
    return null;
}
public Object getSession(HttpServletRequest request) {
    // 클라이언트의 요청 request에서 이름이 SESSION_COOKIE_NAME 인 쿠키 조회
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
        return null;
    }
    // 세션 저장소에서 쿠키의 이름이 SESSION_COOKIE_NAME 일 때의 쿠키 값(세션 아이디)으로 세션 조회
    return sessionStore.get(sessionCookie.getValue());
}

public Cookie findCookie(HttpServletRequest request, String cookieName) {
    if (request.getCookies() == null) {
        return null;
    }
    return Arrays.stream(request.getCookies())
            .filter(cookie -> cookie.getName().equals(cookieName))
            .findAny()
            .orElse(null);
}

 

 

 

3) 테스트 - SessionManagerTest

  • httpServletRequest 이런애들은 톰캣이 별도 제공 -> 테스트에서 객체를 직접 사용할 수 X -> 가짜로 기능 테스트용 제공 

 

package hello.login.web.session;

import hello.login.domain.member.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.*;

public class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest() {

        // 서버 -> 세션 생성 테스트
        // http를 직접 사용할 수 X, 가짜 테스트용
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        // 요청에 응답 쿠키 저장 (클라이언트에서 해당 쿠키를 request에 넣어서 서버에 다시 전달해야 쿠키 유지)
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());  // mySessionId = dkfsodfo~~

        // 서버 -> 세션 조회 테스트
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        // 세션 만료 테스트
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }
}

 

 

 

 

7-3. 직접 만든 세션 적용

 

1) 로그인 - LoginController 

  • 로그인 성공 시 세션 생성, 저장, 쿠키 생성
@PostMapping("/login")
public String loginV2(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

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

    // 로그인 성공 처리

    // 세션 관리자를 통해 세션 생성, 회원 데이터 보관
    sessionManager.createSession(loginMember, response);

    return "redirect:/";
}

* SessionManager 

/**
 * 세션 생성
 */
public void createSession(Object value, HttpServletResponse response) {

    // 세션 id를 생성하고, 값(예 - 회원 객체)을 세션에 저장
    String sessionId = UUID.randomUUID().toString();
    sessionStore.put(sessionId, value);

    // 쿠키 생성 (쿠키 이름 : SESSION_COOKIE_NAME, 쿠키 값 : 랜덤으로 생성한 세션 아이디)
    Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
    response.addCookie(mySessionCookie);
}

 

 

 

2) 로그아웃 - LoginController 

 

@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
    sessionManager.expire(request);
    return "redirect:/";
}

* SessionManager

/**
 * 세션 만료
 */
public void expire(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie != null) {
        sessionStore.remove(sessionCookie.getValue());
    }
}

/**
 * 쿠키 조회 메소드 추출
 */
public Cookie findCookie(HttpServletRequest request, String cookieName) {
    if (request.getCookies() == null) {
        return null;
    }
    return Arrays.stream(request.getCookies())
            .filter(cookie -> cookie.getName().equals(cookieName))
            .findAny()
            .orElse(null);
}

 

 

 

3) 로그인한 사용자 전용 홈 - HomeController 

 

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {

    // 세션 관리자에 저장된 회원정보 조회
    Member member = (Member)sessionManager.getSession(request);

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

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

* SessionManager

/**
 * 세션 조회
 */
public Object getSession(HttpServletRequest request) {
    // 클라이언트의 요청 request에서 이름이 SESSION_COOKIE_NAME 인 쿠키 조회
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
        return null;
    }
    // 세션 저장소에서 쿠키의 이름이 SESSION_COOKIE_NAME 일 때의 쿠키 값(세션 아이디)으로 세션 조회
    return sessionStore.get(sessionCookie.getValue());
}

/**
 * 쿠키 조회 메소드 추출
 */
public Cookie findCookie(HttpServletRequest request, String cookieName) {
    if (request.getCookies() == null) {
        return null;
    }
    return Arrays.stream(request.getCookies())
            .filter(cookie -> cookie.getName().equals(cookieName))
            .findAny()
            .orElse(null);
}

 

 

 

 

 

4) 정리 

  • 세션 - 쿠키 사용 + 서버에서 데이터 유지
  • 서블릿 -> 세션 개념 지원

 

 


 

8. 로그인 처리 - 서블릿 HTTP Session 

 

  • HttpSession
    • 서블릿을 통해 HttpSession 생성 -> 쿠키 생성 (JSESSIONID)
      ( Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05 )

 

1) 세션 상수 - SessionConst

  • HttpSession에 데이터를 보관하고 조회할 때, "LoginMember"가 중복되어 사용 -> 상수로 정의
package hello.login.web;

public class SessionConst {
    public static final String LOGIN_MEMBER = "LoginMember";
}

 

 

 

2) 로그인 - LoginController 

 

@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult, HttpServletRequest request) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());

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

    // 로그인 성공 처리

    // 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession();
    // 세션에 로그인 회원 정보 보관
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    return "redirect:/";
}

 

(1) 세션 생성과 조회 

  • request.getSession( ) 
    • request.getSession(true) - 디폴트
      • 세션이 있으면 기존 세션 반환
      • 세션이 없으면 새로운 세션 생성해서 반환
    • request.getSession(false
      • 세션이 있으면 기존 세션 반환
      • 세션이 없으면 새로운 세션 생성 X, null 반환

 

(2) 세션에 로그인 한 회원 데이터 저장

  • session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

 

 

3) 로그아웃 - LoginController 

 

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
    //세션을 삭제한다.
    HttpSession session = request.getSession(false);
    if (session != null) {
    	session.invalidate();
    }
    return "redirect:/";
}
  • session.invalidate( ) : 세션 제거 

 

 

4) 세션 조회 (로그인한 사용자 전용 홈) - HomeController 

 

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {

    HttpSession session = request.getSession(false);
    if (session == null) {
        return "home";
    }

    Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);

    // 세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }

    // 세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • request.getSession(false) : 세션이 없으면 생성 X, null 반환 (true로 하면 로그인 하지 않은 사용자도 의미없는 세션 생성됨)
  • session.getAttribute(SessionConst.LOGIN_MEMBER) : 로그인 시점에 세션에 보관한 회원 객체 조회 

 

 

5) 실행 

 

JSESSIONID 쿠키 생성 

 

 

 

6) @SessionAttribute 

  • @SessionAttribute(name = "loginMember", required = false) Member loginMember : 이미 로그인된 사용자 조회

 

* 세션 조회 (위 4) 수정) - HomeController 

@GetMapping("/")
public String homeLoginV3Spring(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {

    // 세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }

    // 세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

 

7) TrackingModes 

 

로그인 처음 시도 -> URL이 jsessionid 포함 

 

 

  • 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 안하는지 최초에 판단 X 
    -> 쿠키 값 전달 + URL에 jsessionid 전달 

 

URL 전달 방식 끄고, 항상 쿠키를 통해서만 세션 유지하려면 

 

*applicaition.properties 

server.servlet.session.tracking-modes=cookie

 

 


 

9. 세션 정보와 타임아웃 설정 

 

 

1) 세션이 제공하는 정보 확인 

 

*SessionInfoController

 

package hello.login.web.session;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Date;

@Slf4j
@RestController
public class SessionInfoController {

    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return "세션이 없습니다.";
        }

        // 세션 데이터 출력
        session.getAttributeNames().asIterator()
                .forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));

        log.info("sessionId={}", session.getId());
        log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());

        return "세션 출력";
    }
}

 

로그인 전 

 

로그인 후 

  • sessionId : 세션Id (= JSESSIONID) 
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신
  • isNew : 새로 생성된 세션인지(false), 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청해서 조회된 세션인지(true) 여부

 

 

 

2) 세션 타임아웃 설정 

 

(1) 세션 종료 : 로그아웃 호출 -> session.invalidate() -> 세션 삭제 

 

근데 로그아웃 호출 X, 웹 브라우저 종료할 경우

HTTP는 비연결성 -> 서버 입장에서 해당 사용자가 웹 브라우저를 종료한 건지 아닌지 인식 X 

-> 세션 데이터를 언제 삭제할지 판단 X 

-> 세션과 관련된 쿠키 (jsessionid) 탈튀 -> 해당 쿠키로 악의적인 요청 가능 

-> 세션은 메모리에 생성 -> 메모리 과다

 

 

(2) 세션의 종료 시점 : 가장 최근에 서버로 요청한 시간 기준 ( lastAccessedTime )

 

 

(3) 세션 타임아웃 설정 

 

  • 스프링 부트로 글로벌 설정 

* application.properties 

server.servlet.session.timeout=60 	// 60초

 

  • 특정 세션 단위로 시간 설정 

* java

session.setMaxInactiveInterval(1800);  // 1800초

 

 

 

(4) 정리 

 

서블릿의 HttpSession이 제공하는 타임아웃 기능 -> 세션을 안전, 편리하게 사용 

 

*주의 : 세션에는 최소한의 데이터만 보관 (메모리)