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 )
- 서블릿을 통해 HttpSession 생성 -> 쿠키 생성 (JSESSIONID)
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 반환
- request.getSession(true) - 디폴트
(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이 제공하는 타임아웃 기능 -> 세션을 안전, 편리하게 사용
*주의 : 세션에는 최소한의 데이터만 보관 (메모리)
'Spring' 카테고리의 다른 글
[Spring] 스프링 빈 등록 어노테이션 @Bean, @Configuration, @Component (0) | 2023.05.27 |
---|---|
[인프런/스프링 MVC 2편] 7. 로그인 처리2 - 필터, 인터셉터 (0) | 2023.05.26 |
[인프런/스프링 MVC 2편] 5. 검증 (2) - Bean Validation (2) | 2023.05.24 |
[인프런/스프링 MVC 2편] 4. 검증, 오류처리 (1) - Validation (0) | 2023.05.21 |
[인프런/스프링 MVC 2편] 3. 메시지, 국제화 (0) | 2023.05.18 |