안녕하세요 코이킹입니다.
이번 포스트는 세션을 활용한 로그인과 로그아웃 구현에 대한 내용이 되겠습니다.
1. 목표
- 스프링 부트를 사용하여 복잡한 프로세스가 포함된 기능을 갖춘 웹 어플리케이션을 구현할 수 있다.
- 세션을 활용한 로그인 / 로그아웃 기능을 구현할 수 있다.
- 스코프(페이지, 리퀘스트, 세션, 어플리케이션) 라는 키워드에 대해 알기
2. 어떻게 구현할지에 대한 설명
로그인 기능의 핵심을 정의해보면 '유저로 부터 입력받은 인증정보가 일치하면, 권한을 부여하여 회원만이 사용 가능한 기능을 사용할 수 있게 하는 것과 로그아웃을 하기 전이나 권한 부여 기간이 만료되기 전까지는 페이지 이동이 있더라도 권한이 유지되야하는 것'이며 로그아웃은 '로그인으로 인해 부여된 권한을 제거하는 것'입니다.
이제 핵심을 바탕으로 해야할 일을 정의해보자면 다음과 같습니다.
| 스스로 질문하기 | 답변추려내기 | |
| 데이터 다루기 | 로그인시의 데이터 입력방법 | 로그인 페이지를 추가하여, 데이터 입력 폼에서 로그인에 필요한 정보를 입력 받는다. |
| 로그인 페이지의 이동경로 | 페이지의 헤더부분의 링크에서 이동 | |
| 로그인 처리요청시 데이터 전송방법 | POST방식으로 데이터 송신하기 | |
| 로그인 페이지 요청, 로그인 처리 요청 수신방법 | 회원기능 컨트롤러에서 로그인 페이지 요청, 로그인 처리 요청을 매칭하는 메서드를 추가하기. | |
| 로그인 처리완료후 인증정보는 어떻게 저장할것 인지? | 세션계층에 데이터 저장하기 | |
| 프로세스 | 로그인 유지 기간은? | 개발중에는 3 또는 5분 배포시에는 30분으로 설정 |
| 로그인 하지 않은 유저와 로그인을 완료한 유저의 차이는? | 미 로그인 시 : - 상단의 '로그인'과 '회원가입' 링크버튼이 표시 - 글 수정 / 글 삭제 기능 사용 불가 로그인 완료 시 : - 상단에 '로그인'과 '회원가입' 링크버튼을 비표시하고, '로그아웃 '버튼표시 - 글 수정 / 글 삭제 기능 사용가능 |
|
해야할 일을 추려내면서 세션이라는 단어가 몇 번이고 등장하고 있는 것을 확인할 수 있으실 겁니다.
이 포스트를 통해 구현과정을 설명하려는 로그인 / 로그아웃 기능에서 인증정보를 담을 곳으로 세션을 선택한 이유를 알기 위해서는 서블릿의 스코프에 대해서 알아야 합니다.
| 영역 | 값이 유지되는 주기 | 사용처 |
| 리퀘스트 | 요청를 보내고 서버가 응답할 때까지 | - CRUD 게시판 구현시 데이터를 담아온 영역 - forward시에 데이터 유지에 사용 |
| 세션 | 유효기간 만료 또는 브라우저 종료시 | - 각각의 유저가 자신의 브라우저에서 페이지를 이동해도 값이 유지되야하는 기능을 구현할 때 ex) 인증정보 저장, 장바구니등.. |
| 어플리케이션 | 웹 어플리케이션이 종료될때 까지 | - 어플리케이션을 이용하는 사용자 전원에게 전역적으로 사용되어야하는 값을 설정할 때 ex) 어플리케이션의 URL |
위의 스코프에 대한 설명 대로 페이지를 이동해도 인증정보를 유지하기 위해서 로그인 기능을 구현할 때 세션을 사용할 겁니다.
3. 프로세스 흐름

4. 데이터 흐름

① 브라우저에서 서버로 로그인 페이지 표시 요청을 보내고, 서버에서 브라우저로 로그인 페이지를 리턴합니다.
② 로그인 요청을 보냅니다. 로그인 처리 요청의 일련의 처리를 행한 후 결과를 리턴하는데,
리턴시 세션에 인증 정보를 담아 게시판의 글목록으로 리턴합니다.
다른 페이지로 이동하더라도 인증정보는 남아있습니다.
③ 브라우저에서 서버로 로그아웃 요청을 보냅니다. 서버에선 세션에 등록된 인증정보를 제거하고, 세션을 만료하여 로그아웃 처리를 실시한 후 게시판의 글목록으로 리턴합니다.
5. 소스코드와 해석
1) 로그인 페이지 템플릿 : /template-springboot/src/main/resources/templates/auth/login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{common/layout}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, inital-scale=1.0"/>
</head>
<body>
<div layout:fragment="contents">
<form method="post" th:action=@{/auth/login}>
<h1 class="h3 mb-3 fw-normal text-center">Please Login</h1>
<div class="mb-3 col-8">
<label class="form-label">ID (E-mail)</label>
<input type="text" name="memberId" class="form-control" aria-describedby="emailHelp">
<div id="emailHelp" class="form-text">We'll never share your ID with anyone else.</div>
</div>
<div class="mb-3 col-8">
<label class="form-label"> Password </label>
<input class="form-control" type="password" name="password">
</div>
<div class="nav justify-content-center col-8">
<input class="btn btn-primary" type="submit" value="Login Complete">
</div>
</form>
<hr/>
<div>
<button class="btn btn-secondary py-2 my-2" type="button" onclick="location.href='javascript:history.back();'">Return to Previous Page</button>
<button class="btn btn-info py-2 my-2" type="button" onclick="location.href='/board/list'">Back to List</button>
</div>
</div>
</body>
</html>
※ 구현된 대부분의 코드는 게시판 기능 구현 시에 사용된 것과 동일하므로 자세한 설명은 생략합니다.
- 폼 태그를 사용하여 로그인 처리요청을 서버로 보내는 템플릿입니다.
2) 회원 기능 컨트롤러 : /template-springboot/src/main/java/com/sb/template/controller/MembershipController.java
package com.sb.template.controller;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.sb.template.dto.ResponseDto;
import com.sb.template.entity.Member;
import com.sb.template.enums.ResponseInfo;
import com.sb.template.forms.AuthForm;
import com.sb.template.service.MemberService;
import lombok.extern.slf4j.Slf4j;
@Controller
@RequestMapping("auth")
@Slf4j
public class MembershipController {
@Autowired
private MemberService memberService;
@RequestMapping(method = RequestMethod.GET, path = "/join")
public String joinMember(@ModelAttribute AuthForm form) {
return "auth/join";
}
@RequestMapping(method = RequestMethod.POST, path = "/join")
public String joinMember(@ModelAttribute @Validated AuthForm form, BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getAllErrors().stream().map(e -> e.getDefaultMessage()).collect(Collectors.toList());
model.addAttribute("message", String.join(", ", errors));
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
Member member = memberService.createMember(form);
if (member == null) {
model.addAttribute("message", form.getMemberId()+" is already exist!!");
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
model.addAttribute("message", member.getMemberId()+"has been joined!!");
model.addAttribute("redirectUrl", "/auth/login");
return "/common/message";
}
@PostMapping(path = "/checkId")
@ResponseBody
public ResponseEntity<?> checkId(
@RequestParam(required = true,
name = "memberId")
@Email
@NotBlank
String memberId
) {
log.info("Input memberId : {}", memberId);
return ResponseEntity.ok(ResponseDto.builder()
.resultCode(ResponseInfo.SUCCESS.getResultCode())
.message(ResponseInfo.SUCCESS.getMessage())
.data(memberService.existMemberId(memberId))
.build()
);
}
@RequestMapping(method = RequestMethod.GET, path = "/login")
public String loginForm() {
return "auth/login";
}
@RequestMapping(method = RequestMethod.POST, path = "/login")
public String doLogin(@ModelAttribute @Validated AuthForm form, BindingResult bindingResult, HttpServletRequest req, HttpServletResponse res, Model model) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getAllErrors().stream().map(e -> e.getDefaultMessage()).collect(Collectors.toList());
model.addAttribute("message", String.join(", ", errors));
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
log.info("LoginForm Data : {} ", form.toString());
memberService.loginProcess(form, req, res, model);
return "/common/message";
}
@RequestMapping(method = RequestMethod.GET, path = "/logout")
public String logout(HttpServletRequest req, HttpServletResponse res, Model model) {
HttpSession session = req.getSession();
Object obj = session.getAttribute("member");
if (obj != null) {
session.removeAttribute("member");
session.invalidate();
}
model.addAttribute("message", "Logout Success!!");
model.addAttribute("redirectUrl", "/");
return "/common/message";
}
}
- 102행 : 로그인 처리 메서드를 추가했습니다.
112행에서 서비스를 호출하여 로그인 처리를 위임합니다. 세션은 스프링에서 제공해주는 기능이 아닌 서블릿의 기능인 HttpServletRequest를 사용하여 다루고 있습니다.
- 119행 : 로그아웃 처리 메서드를 추가했습니다.
인증 정보를 세션에서 제거하고, 세션을 만료시킴으로써 로그아웃 처리를 완료합니다.
3) 회원기능 서비스 : /template-springboot/src/main/java/com/sb/template/service/MemberService.java
package com.sb.template.service;
import java.util.Date;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import com.sb.template.entity.Member;
import com.sb.template.enums.MemberRole;
import com.sb.template.forms.AuthForm;
import com.sb.template.repo.MemberRepository;
import com.sb.template.utils.EncUtil;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
@Autowired
private EncUtil enc;
public Member createMember(AuthForm form) {
Optional<Member> memberOp = memberRepository.findByMemberId(form.getMemberId());
if (!memberOp.isEmpty()) {
log.warn("{} is already exist!!", form.getMemberId());
return null;
}
Member member = form.toEntity();
member.setPassword(enc.generateSHA512(member.getPassword()));
member.setRole(MemberRole.COMMON.value);
member = memberRepository.save(member);
return member;
}
public boolean existMemberId(String memberId) {
Optional<Member> result = memberRepository.findByMemberId(memberId);
return result.isEmpty() ? false : true;
}
public Optional<Member> getMemberInfoByMemberId(String memberId) {
return memberRepository.findByMemberId(memberId);
}
public void loginProcess(AuthForm form,
HttpServletRequest req, HttpServletResponse res, Model model) {
// exist and data
Optional<Member> memberOp = memberRepository.findByMemberId(form.getMemberId());
if (memberOp.isEmpty()) {
model.addAttribute("message", form.getMemberId()+" is not exist!!");
model.addAttribute("redirectUrl", "/auth/join");
return;
}
// Password OK?
if (!memberOp.get().getPassword().equals(enc.generateSHA512(form.getPassword()))) {
model.addAttribute("message", "Unmatch Password!!");
model.addAttribute("redirectUrl", "/auth/login");
return;
}
// Set User Data in Session
HttpSession session = req.getSession();
session.setMaxInactiveInterval(300000);
session.setAttribute("member", memberOp.get());
// Update latest login time.
memberOp.get().setUpdatedTime(new Date());
memberRepository.save(memberOp.get());
log.info("Save login user info {} : ", memberOp.get().toString());
model.addAttribute("message", "Login Success!!");
model.addAttribute("redirectUrl", "/");
}
}
- 60행 : 로그인 처리 메서드입니다.
-> 65행 : ID존재 여부를 확인하여, 없다면 회원가입 화면으로 리다이렉트 하도록 설정합니다.
-> 72행 : 패스워드가 일치하는 지 확인합니다. 단방향 암호화가 된 상태로 패스워드가 저장되어 있으므로, 패스워드를 확인할 때도 입력된 패스워드를 암호화를 한 후 DB의 패스워드 값과 비교합니다. 패스워드 불일치라면 로그인 화면으로 리다이렉트 하도록 설정합니다.
- 80행 : 세션의 만료 시간을 5분으로 설정합니다.
- 81행 : 세션에 인증정보를 등록합니다.
4) 로그인 이후 조건부 렌더링이 추가된 템플릿
(1) 글 상세보기 : /template-springboot/src/main/resources/templates/board/read.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{common/layout}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, inital-scale=1.0"/>
</head>
<body>
...
...
<div>
<button class="btn btn-info" type="button" onclick="location.href='/board/list'">Back to List</button>
<button class="btn btn-warning" th:if="${not #strings.isEmpty(session?.member?.memberId)} and ${session.member.memberId}==${board.memberId}" type="button" th:onclick="'location.href=\'' + @{/board/update/{boardNo}(boardNo=${board.boardNo})} +'\';'">Update</button>
<button class="btn btn-danger" th:if="${not #strings.isEmpty(session?.member?.memberId)} and ${session.member.memberId}==${board.memberId}" type="button" th:onclick="'location.href=\'' + @{/board/delete/{boardNo}(boardNo=${board.boardNo})} +'\';'">Delete</button>
</div>
</div>
</body>
</html>
-16~17행 : 세션에 member객체(인증정보)가 있는지 확인하여, 글 수정과 삭제 버튼을 조건부 렌더링합니다.
session?. member?. memberId와 같이? 표시가 들어있는 이유는 값이 null일 경우 타입 리프 템플릿을 브라우저에 표시할 때 에러가 발생하는데?를 넣어주면 null이어도 에러가 발생하지 않습니다.
${not #strings.isEmpty(session?.member?.memberId)} : 세션에 저장되어 있는 member객체의 memberId가 비어있지 않다면 true
and ${session.member.memberId}==${board.memberId} : 세션에 저장되어 있는 member객체의 memberId가 리퀘스트에 저장된 memberId가 일치한다면 true
두 개의 조건식이 모두 true의 경우 버튼이 렌더링 됩니다.
※ 두번째 조건식의 세션 값을 가져올 때?를 사용하지 않은 건 and를 사용 시 왼쪽의 식부터 실행되는 데 왼쪽의 식이 true가 아니라면 두 번째 조건식에 애초에 실행되지 않기 때문에 굳이 사용하지 않았습니다.
(2) 상단의 공통부분 : /template-springboot/src/main/resources/templates/common/common_header.html
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
<div th:fragment="commonHeader">
<header class="bg-dark text-white d-flex flex-wrap align-items-center justify-content-center justify-content-md-between border-bottom">
<h3>
<a href="/" class="d-flex align-items-center col-md-3 text-white text-decoration-none px-1 py-1 mx-1 my-1">
Template-SpringBoot
</a>
</h3>
<div class="nav col-md-3 px-1">
<div th:if="${not #strings.isEmpty(session?.member?.memberId)}">
<p th:text="'Welcome ['+ ${session.member.memberId}+']'"></p>
</div>
<div class="nav-item px-1 py-1" th:if="${#strings.isEmpty(session?.member?.memberId)} and ${#httpServletRequest.requestURI != '/auth/login'}">
<button class="btn btn-primary" id="write-board" type="button" onclick="location.href='/auth/login'">Login Member</button>
</div>
<div class="nav-item px-1 py-1" th:if="${#strings.isEmpty(session?.member?.memberId)} and ${#httpServletRequest.requestURI != '/auth/join'}">
<button class="btn btn-success me-2" id="write-board" type="button" onclick="location.href='/auth/join'">Join Member</button>
</div>
<div class="nav-item px-1 py-1 row" th:if="${not #strings.isEmpty(session?.member?.memberId)}">
<div class="col-6 mx-auto my-auto">
<button class="btn btn-warning" id="write-board" type="button" onclick="location.href='/auth/logout'">Logout</button>
</div>
</div>
</div>
</header>
</div>
</body>
</html>
- 20행 : 로그인 버튼을 조건부 렌더링합니다.
세션에 저장된 인증정보가 없고, 현재 위치한 페이지가 로그인 페이지가 아닌 경우 표시됩니다.
- 23행 : 회원가입 버튼을 조건부 렌더링합니다.
세션에 저장된 인증정보가 없고, 현재 위치한 페이지가 회원가입 페이지가 아닌 경우 표시됩니다.
- 26행 : 로그아웃 버튼을 조건부 렌더링합니다. 세션에 저장된 인증정보가 있다면 표시됩니다.
6. 동작확인
7. 전체 소스코드
https://github.com/leeyoungseung/template-springboot/tree/feature/10_membership_board_login
GitHub - leeyoungseung/template-springboot
Contribute to leeyoungseung/template-springboot development by creating an account on GitHub.
github.com
이것으로 로그인과 로그아웃 구현에 대한 포스트는 마치겠습니다.
다음 포스트는 쿠키를 활용한 자동 로그인에 대한 내용이 되겠습니다.
'프로그래밍 > Springboot-토이프로젝트' 카테고리의 다른 글
| 【게시판-12】프로필사진 등록('폼 태그'와 '드래그 앤 드롭'방식으로 파일 업로드) (0) | 2022.09.29 |
|---|---|
| 【게시판-11】쿠키와 인터셉터를 활용한 자동 로그인 (1) | 2022.09.21 |
| 【게시판-09】회원가입 (1) | 2022.09.20 |
| 【게시판-08】타임리프 템플릿 결합과 부트스트랩 적용 (0) | 2022.09.17 |
| 【게시판-07】페이징 처리 (1) | 2022.09.16 |
댓글