개발자로 후회없는 삶 살기
spring PART.로그인, 세션관리 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/373
본론
- 로그인 요구사항
상품관리 시스템에 로그인과 관련된 요구사항이 나타났습니다.
홈 화면을 만들고 로그인 전 화면에서는 회원가입하는 페이지로 이동할 수 있고 로그인 화면으로 이동할 수 있습니다.
로그인 후 화면에서는 회원 이름이 나타나고, 상품관리와 로그아웃 버튼이 나오게 할 것입니다.
+ 보안 요구사항
이제는 로그인한 회원만 상품관리를 할 수 있고 로그인을 하지 않은 사용자가 상품관리에 url로 접근하면 로그인 화면으로 리다이렉트해서 상품 관리를 접근하지 못하게 할 것입니다.
- 도메인 구조
@GetMapping("/")
public String home() {
return redirect:"/items";
}
localhost:8080하면 redirect로 상품 목록으로 가게 만들어놨습니다. 도메인안에 item, login, member, web안에도 item, login, member 이렇게 나눠놨습니다. 패키지를 설계하는 다양한 방법이 있는데 왜 이렇게 설계했는지 알아보겠습니다.
우선 도메인과 웹을 쪼겠습니다. 왜냐하면 도메인이란 화면, ui 등등의 영역을 제외한 시스템이 구현해야하는 핵심 비즈니스 업부 영역을 말합니다. 화면 ui나 컨트롤러, DB는 핵심 업무 도메인이 아닙니다. item, item을 관리하는 레포 등이 핵심 도메인 영역이라고 할 수 있습니다. 도메인과 web의 하위 폴더 구조는 똑같으면 됩니다. item, login, member가 있고 도메인에는 item, login, member 객체와 저장소, 서비스 등 비즈니스와 관련된 핵심이 있고 web에는 item 하위 폴더에는 ItemController, form 전용 객체, member 하위 폴더에는 memberController를 둡니다.
> 향후 web을 다른 기술로 바꿔도 도메인은 그대로 유지할 수 있어야합니다. html form을 html api 방식으로 바꿔도 web 부분은 다 날려도 도메인 부분은 최대한 유지할 수 있도록 설계를 해야합니다. web은 도메인에 의존해도 되지만 도메인은 web에 의존하지 않게 설계를 해야합니다. 그래서 web을 바꿔도 도메인은 바뀌지 않게 해야하고 이렇듯 단방향으로 설계해야 api 방식으로 바꿔도 도메인 영역은 그래도 살릴 수 있습니다. 쉽게 말해서 컨트롤러에서 item을 써도 되지만 item에서 컨트롤러를 쓰게 하면 안된다는 것입니다.
+ 근데 이런게 복잡할 수 있다. 폼 전용 객체는 web에 컨트롤러 있는 곳에 form 패키지를 만들었습니다. 따라서 item 레포에서 이 form을 사용하면 도메인에서 웹을 참조하게 되는 것이라서 조심해야합니다.
- 회원가입
@Slf4j
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}
홈 화면 구성을 구성하고 개발을 시작할 것입니다. 홈 컨트롤러에서 리다이렉트를 하게 했었는데 그냥 home 템플릿을 보게 하겠습니다.
그냥 templates에 home.html 만들면 이제 '/'로 접근하면 홈 컨트롤러에서 홈페이지가 나옵니다.
-> 회원가입 개발
1. 도메인
1) 도메인 핵심 객체
@Getter
@Setter
public class Member {
private Long id;
@NotEmpty
private String loginId;
@NotEmpty
private String name;
@NotEmpty
private String password;
}
회원가입을 해야 로그인을 할 수 있으니 회원가입부터 개발합니다. 도메인에 새로운 객체가 생깁니다. 멤버로 Item 외에 새로운 객체가 될 것입니다. 원래는 item만 등록할 수 있었는데 회원가입하는 것이 회원 레포에 회원 도메인 객체를 저장하는 개념입니다. member 패키지에 member 클래스를 만들고 회원 일련번호, 로그인 id, 사용자이름, pw 필드를 가지게 하고 빈 검증을 NotEmpty로 하게 할 것입니다.
2) 저장소
public class MemberRepository {
private static final Map<Long, Member> store = new HashMap<>(); //static
private static long sequence = 0L; //static
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
public Member findById(Long id) {
return store.get(id);
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public Optional<Member> findByLoginId(String loginId) {
return findAll().stream()
.filter(m -> m.getLoginId().equals(loginId))
.findFirst();
}
}
멤버를 저장하고 관리할 저장소가 필요합니다. 멤버 레포 클래스를 만듭니다. 이전에 만든 코드와 비슷한데 일련번호로 못찾았을 때를 대비하여 로그인 id로 찾는 것을 만듭니다. 멤버 레포에 멤버를 저장하면 회원가입이 되는 개념입니다.
3) 컨트롤러
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add")
public String addForm(@ModelAttribute 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:/";
}
}
회원가입은 상품 등록과 똑같다. get으로 폼을 보여주고 post에서 레포에 save 하면 됩니다. 회원 컨트롤러는 web의 멤버 패키지에 만듭니다. 멤버 레포를 쓸 것이니 주입받고 GetMapping은 회원가입 폼 뷰를 보여주고 빈 객체를 넣어주면 좋으니 MA를 씁니다. Post에서는 회원 가입을 개발하고 있으니 save를 합니다. 검증을 위해서 br을 쓰고 hasErrors로 에러가 있으면 다시 회원 가입 폼으로 보내고 아니면 정상 로직을 처리하고 리다이렉으로 보여줄 화면으로 보냅니다. 여기서는 홈 화면을 보여줄 것입니다. 회원가입은 성공해도 일반 홈 화면을 보여주고 로그인은 성공 시 다른 페이지를 보여줄 것입니다.
이제 그에 맞는 html을 만듭니다. 회원가입을 개발하고 있으니 회원가입 폼 html을 만들어야 합니다. 맵핑 메서드에서 한 것과 같은 이름으로 members/addMemberForm.html을 만듭니다. type은 text로 로그인과 pw를 받고 th:errors로 빈 검증 에러 메세지를 보여줄 것입니다. 이렇게 하면 회원가입은 끝난 것입니다. 빈 검증과 컨트롤러, 레포를 잘 배워두니 정말 빨리 회원 가입을 만든 것입니다.
-> 로그인 개발
1) 서비스
먼저 id, pw를 쳤는데 맞냐 틀리냐를 판단할 핵심 로직이 있어야합니다. 핵심 비즈니스 로직이니 도메인에 login 패키지에 로그인 서비스 클래스에 작성합니다.
// 필자 코드
public Boolean login(String memberId, String pwd) {
Optional<Member> findMember = memberRepository.findByLoginId(memberId);
if(findMember.get().getPassword() == pwd) {
return true;
}
else return false;
}
// 강사님 코드
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null);
}
로그인을 생각해보면 어떻게 코딩을 해야하는지 알 수 있습니다. 회원가입을 하면 레포에 회원이 등록이 되는데 로그인은 등록된 회원인지 보는 것입니다. 따라서 레포에서 id로 멤버 객체를 찾아서 비번을 비교해보면 될 것입니다.
리턴이 null이면 로그인 실패로 할 것입니다. 로그인 id와 pwd를 받습니다. id를 던져서 회원이 있나 없나 찾고 찾은 멤버의 pwd와 받은 pwd과 같으면 member를 반환하고 없으면 null을 반환합니다. 이렇게 쓰면 굉장히 깁니다.
+ optional은 필터가 되는데 람다를 써서 같으면 멤버를 반환하고 아니면 null을 반환할 수 있습니다. 로그인의 핵심 로직은 파라미터로 넘어온 id의 회원의 pwd와 db에 저장되어있는 회원의 pwd랑 같으면 true를 하면 됩니다.
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
제일 중요한 것은 개발이 됐습니다. 이제 로그인을 하기 위한 폼 전용 객체를 만듭니다. 뭐든 입력을 받을 때는 핵심 도메인 객체를 바로 쓰면 안 되고 폼 전용객체를 만들어야합니다.
2) 컨트롤러
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute LoginForm form) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 필드 에러
if(bindingResult.hasErrors()) {
return "login/loginForm";
}
// 핵심 비즈니스 에러
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(login == null) {
bindingResult.reject("loginFail", "아이디 또는 비번이 맞지 않음");
return "login/loginForm";
}
// 로그인 성공 처리 TODO
return "redirect:/";
}
}
홈 화면에서 로그인 버튼을 누르면 로그인 폼을 보여주고 로그인 폼에서 실제 로그인을 하면 됩니다. br을 쓰서 빈 검증시 필드에러가 발생하면 폼 화면으로 보내버립니다. 성공로직을 돌리면 loginMember를 반환하고 null이면 회원을 못 찾거나 id, pwd가 안 맞는 것입니다.
> 로그인 id, pw가 틀린 경우는 특정 필드 null이나 범위 문제가 아닙니다. 그래서 reject로 글로벌 오류를 보여주며 아이디 또는 비밀번호가 맞지 않다고 합니다. 실제 실무에서는 DB에 접근해서 id, pwd를 비교하므로 글로벌 오류는 빈 검증이 아닌 실제 자바 코드로 작성하는게 좋습니다.
id나 pwd 필드 오류면 id 입력 칸이나 pwd 입력 칸에 오류 정보를 빨간색 글씨로 보였을 텐데 그렇게 하지 않고 글로벌 오류로 봅니다. 오류가 나면 역시나 입력 폼을 다시 보여주면 됩니다. 마무리로 로그인에 성공하면 홈으로 보냅니다. > 이제 로그인 뷰를 만들면 로그인도 끝났습니다.
- 로그인 처리하기 - 쿠키 사용
(쿠키 안 쓰고 위 화면 만들어 보기 뭔가 할 수 있는데 재 진입시에도 로그인이 되게 하기 위해 쿠키가 필요한 것 같습니다.) 아직 요구사항 중에 로그인을 성공하면 사용자 이름, 상품 관리, 로그아웃 버튼이 나오는 화면이 나오게 하지 못했습니다. 쿠키를 사용해서 로그인, 로그아웃 기능을 구현해보겠습니다.
=> 로그인 상태 유지
먼저 로그인 상태를 유지해야합니다. 로그인을 하면 웹 브라우저와 서버 사이에 정보가 유지되어야 합니다. 이를 주로 쿠키를 사용합니다.
-> 쿠키
서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에게 전달합니다. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보냅니다.
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 필드 에러
if(bindingResult.hasErrors()) {
return "login/loginForm";
}
// 핵심 비즈니스 에러
Member login = loginService.login(form.getLoginId(), form.getPassword());
if(login == null) {
bindingResult.reject("loginFail", "아이디 또는 비번이 맞지 않음");
return "login/loginForm";
}
// 로그인 성공 처리 TODO
return "redirect:/";
}
우리 프로젝트 같은 경우에는 로그인 맵핑 메서드가 호출이 되고 id, pwd도 같은 경우 핵심 로그인 로직을 성공하고 ToDo에서 쿠키를 만들어서 클라에게 전달해줘야 합니다. > 우리는 쿠키에 회원 일련번호를 담아 보낼 것입니다. 그러면 웹 브라우저는 쿠키 저장소에 쿠키를 가지고 있고 매 요청마다 쿠키를 요청 메시지에 담아서 요청합니다. 쿠키는 영속 쿠키와 세션 쿠키가 있는데 우리는 브라우저 종료시 로그아웃이 되길 기대하므로 세션쿠키로 합니다.
-> 쿠키 구현하기
// 로그인 성공 처리
Cookie cookie = new Cookie("memberId", String.valueOf(login.getId()));
response.addCookie(cookie);
로그인 성공 처리가 됐을 때 쿠키를 만들 것입니다. 쿠키에는 일련번호를 넣을 것입니다. 무조건 str을 받아야합니다. 생성한 쿠키를 http 응답에 담아서 보내야합니다. 따라서 HttpServletResponse를 만들고 addCookie하고 리턴합니다. response가 있으면 return시 자동으로 response 메세지에 적용이 됩니다. 이렇게 해서 쿠키를 담았습니다.
-> 실행
로그인을 하면 response header로 setCookie에 서버가 보낸 쿠키가 있습니다.
브라우저는 이 다음부터 요청을 보낼 때 request header에 cookie를 담에서 보냅니다. 이제 서버는 이 쿠키 값으로 이 사람이 로그인을 했구나를 알 수 있고 이 id를 가지고 회원 처리를 하면 될 것입니다. 이제 홈 화면에 로그인 결과를 보여주는 요구사항을 만족 시켜보겠습니다. 쿠키값을 꺼내서 다른 홈 화면에 보여주면 됩니다.
-> 홈 컨트롤러 처리
@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";
}
홈 컨트롤러를 수정합니다. 이제는 로그인이 된 사용자와 로그인이 되지 않은 사용자가 다른 화면이 나와야합니다. 먼저 쿠키를 꺼내야합니다. 서블릿 request에서 꺼내써도 되지만 스프링은 @CookieValue라는 것을 제공합니다. name은 서버에서 addCokie로 쿠키에 말아 넣은 키 값과 같으면 되고 required = false를 해서 로그인 안한 사용자도 들어오도록 해야합니다. add 할 때는 쿠키를 str로 넣었지만 스프링이 타입 컨버팅을 해서 Long을 사용할 수 있습니다.
> 쿠키가 없는 로그인을 하지 않은 사용자는 그냥 일반 홈을 보이면 됩니다. 로그인한 쿠키가 있는 사용자는 쿠키값으로 레포에서 회원을 찾습니다. 이때 쿠키가 너무 옛날에 만들어져서 DB에 맞는 id가 없을 수도 있습니다. 그러면 일반 홈 화면을 보여준다. 있으면 로그인 정보를 모델에 담은 후에 loginHome이라는 다른 뷰로 보냅니다. 이 뷰는 로그인 사용자 정보와 상품관리, 로그아웃을 사용할 수 있게 되어있다. 로그인을 한 사용자만 로그인 정보가 뜨고 상품 관리와 로그아웃을 할 수 있습니다.
쿠키가 브라우저에 있으면 다른 창으로 진입해도 로그인이 되어있습니다.
+ 로그아웃 기능
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
return "redirect:/";
}
로그아웃 버튼을 누르면 쿠키를 날려버리면 됩니다. 버튼을 누르면 로그인 컨트롤러에서 logout 맵핑 메서드를 호출하고 메서드에서는 쿠키를 다 날려버리고 홈화면으로 보내면 됩니다. setMaxAge(0)하고 다시 response에 담아서 일반 홈으로 리다이렉트하면 됩니다.
> 실행해보면 로그아웃을 누르는 순간 쿠키의 max-age를 0으로 만들어버립니다. 그러면 로그아웃이 됩니다. 이렇게 쿠키만으로 로그인, 로그아웃을 할 수 있고 로그아웃은 별도의 로직이 필요없습니다.
- 세션
하지만 이 방식은 보안상 심각한 문제가 있습니다.
1) 웹 브라우저 개발자 도구에서 쿠키값은 임의로 변경할 수 있습니다.
2) 쿠키에 보관된 정보를 훔쳐갈 수 있습니다. 쿠키에 일련번호말고 개인정보가 있으면 네트워크 요청마다 클라에서 서버로 전달되니 https를 써야합니다.
3) 해커가 쿠키(신용정보)를 가져가면 평생 악의적으로 사용할 수 있습니다. (대리 로그인)
-> 대안
1) 쿠키에 중요한 정보는 빼야하고 1, 2, 3처럼 예측 가능한 값을 넣지말고 임의의 랜덤값을 노출하고 서버에서 토큰을 이용하여 id를 매핑해서 사용하고 서버에서 토큰을 관리합니다.
2) 해커가 토큰을 털어가도 대리 로그인을 하려고 해도 시간이 지나면 사용할 수 없도록 서버에서 만료시간을 짧게 유지해야합니다. 서버에서 토큰을 짧게 쓰고 버려야합니다.
- 세션
위 문제를 해결하려면 중요한 정보를 모두 서버에 저장해야하고 클라와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야합니다. 이 대안책을 한번에 처리하는 것이 세션입니다.
-> 세션 동작 방식
로그인할 때 id, pw를 넣으면 서버로 데이터가 넘어와서 서버의 회원 저장소에서 로그인 정보에 맞는 회원 A를 찾았습니다. 그러면 세션 저장소라는 것이 서버에 있고 추정 불가능한, 절대 중복이 안되는 토큰(uuid)을 하나 만들고 이 값을 세션 id(키)로 사용합니다. value는 회원 A로하여 세션 id를 알면 회원 객체를 꺼낼 수 있게 합니다.
> 이제 브라우저에 쿠키를 알려줘야한데 중요한 일련번호나 정보가 아니라 아까 만든 세션 id를 보냅니다. 브라우저는 쿠키 저장소에 랜덤 세션 id를 저장하고 있다가 요청을 할 때 쿠키 저장소에 저장하던 세션 id를 전달합니다.
> 서버는 세션 id를 받아서 회원 A를 찾고 그 값으로 똑같이 홈 컨트롤러에서 그 id로 로그인된 사용자인지 아닌지(null) 확인할 것입니다.
※ 회원과 관련된 정보는 전혀 클라에 전달하지 않고 추정 불가능한 임의의 세션 ID만 쿠키를 통해서 클라에게 전달합니다.
- 세션 만들기
세션 관리는 총 3가지 기능을 제공하면 됩니다. 직접 세션 관리자를 구현해보겠습니다.
1) 세션 생성 : 세션 id를 만들고 세션 저장소에 세션 id를 키로 회원을 value로 저장하고 세션 id로 응답 쿠키를 만들어서 클라에게 전달
2) 세션 조회 : 세션 저장소에서 클라로부터 받은 세션 id로 멤버 A를 찾는 것
3) 만료 : 세션 id 값을 날려버리는 것
-> 구현
세션은 web쪽 기능이라서 web에 session 패키지와 매니저 클래스를 만듭니다. map을 하나 만드는데 id를 키로 저장할 값을 벨류로 합니다. 동시에 접속할 수 있으므로 concurrentHashMap을 씁니다.
1. 세션 생성
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 생성
*/
public void createSession(Object value, HttpServletResponse response) {
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
value를 받아서 세션 id를 생성할 것입니다. uuid를 통해 확실한 랜덤 값을 만들고 map에 키, 벨류로 저장합니다. 응답 쿠키를 만들기 위해서 new Cookie하고 값은 세션 id로 합니다. 이전에는 로그인을 하면 그 회원의 일련번호를 쿠키값로 넣었는데 이제는 세션 id를 쿠키값으로 넣습니다. (쿠키 이름은 자주 쓸 거라서 상수로 만들었습니다.) response에 addCookie를 해서 set-cookie 해더에 세션 id를 담아서 브라우저에게 보냅니다.
2. 세션 조회
// 세션 조회
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if(sessionCookie == null) {
return null;
}
System.out.println(sessionCookie);
System.out.println(sessionCookie.getValue());
return sessionStore.get(sessionCookie.getValue()); // sessionCookie 값이 mySessionId=1 이렇게 되어있어서 getValue??
}
private static Cookie findCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if(cookies == null) {
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
map에서 세션 id로 실제 Obj 값을 꺼냅니다. 클라이언트로부터 쿠키 요청을 받고 찾는 것입니다. getCookies는 배열로 반환이 됩니다. Cookie는 브라우저의 쿠키 저장소에 있는 모든 값을 가지고 요청 메세지에 담기므로 값이 여러개입니다. 루프를 돌려서 요청 받은 쿠키의 이름으로 cookie 헤더에서 세션 id를 찾습니다. Array.stream으로 배열을 스트림으로 바꾸고 람다식을 사용합니다.
이제 쿠키 이름으로 요청 메세지에서 세션 id를 찾았으니 세션 저장소에서 세션 값을 가져오면 됩니다. request 요청으로부터 쿠키 헤더인 cookie의 쿠키 이름에 해당하는 값을 찾고 쿠키값이 세션 id이니 그 세션 id로 세션 저장소에서 세션 값을 찾습니다.
3. 세션 만료
// 세션 만료
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if(sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
System.out.println(sessionCookie);
System.out.println(sessionCookie.getValue());
쿠키를 찾고 세션 저장소에서 이 세션을 지웁니다. 지울 때도 map은 키가 필요한데 키가 세션 id이니 ( ex) mySessionId=세션 id ) 세션 id로 지웁니다.
-> 테스트
class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest() {
//세션 생성
MockHttpServletResponse response = new MockHttpServletResponse();
Member member = new Member();
sessionManager.createSession(member, response);
//요청에 응답 쿠키 저장
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(response.getCookies());
//세션 조회
Object result = sessionManager.getSession(request);
assertThat(result).isEqualTo(member);
//세션 만료
sessionManager.expire(request);
Object expired = sessionManager.getSession(request);
assertThat(expired).isNull();
}
}
1) 세션 생성
파라미터로 response가 필요한데 인터페이스라서 스프링에서 가짜로 테스트에 써먹으라고 Mock을 제공합니다. 서버에서 웹 브라우저로 쿠키를 담아서 응답을 합니다.
2) 세션 조회
웹 브라우저가 쿠키로 세션을 보냈다고 setCookies를 해서 request에 쿠키 헤더를 넣고 쿠키를 받아서 서버에서 세션 저장소에서 세션 데이터를 조회합니다. getSession이 세션 저장소에서 세션 id로 조회한 값이므로 응답할 때 보낸 sessionId 값과 매칭되는 value값인 member와 같을 것입니다.
3) 세션 만료
세션 매니저가 세션을 저장소에서 지우고 조회하면 null이어야합니다.
정리해보자면 1) 로그인을 하면 세션을 만들어서 저장하고 세션 id를 쿠키에 말아서 응답합니다. 이전에는 쿠키만 사용했는데 쿠키에 세션id를 넣어서 둘 다 사용하는 것입니다. 2) 브라우저의 요청이 오면 받은 쿠키로 세션 저장소에서 세션 값을 찾고 처리합니다. 3) 로그아웃시에 세션을 만료합니다.
- 세션 사용하기
1. 로그인
@PostMapping("/login")
public String login2(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
// 필드 에러
if(bindingResult.hasErrors()) {
return "login/loginForm";
}
// 핵심 비즈니스 에러
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비번이 맞지 않음");
return "login/loginForm";
}
// 로그인 성공 처리
// Cookie cookie = new Cookie("memberId", String.valueOf(login.getId()));
// response.addCookie(cookie);
sessionManager.createSession(loginMember, response);
/**
* public void createSession(Object value, HttpServletResponse response) {
* String sessionId = UUID.randomUUID().toString();
* sessionStore.put(sessionId, value);
*
* Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
* response.addCookie(mySessionCookie);
* }
*/
return "redirect:/";
}
만든 세션을 프로젝트에 적용해보겠습니다. 먼저 로그인에 세션을 사용해보자 쿠키만 사용해서 memberId를 set-cookie:memberId=1 이렇게 하던 것을 세션을 사용하여 세션 id로 바꿔야합니다. 만들어놓은 세션 매니저를 주입받고 response.addCookie(cookie) 할 때 Cookie cookie = new Cookie("memberId", String.valueOf(login.getId())) 가 아닌 Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); 이거를 해서 쿠키에 세션을 말아 넣습니다. 이렇게 하면 세션 id를 만들고 저장소에 저장하고 쿠키에 말아서 response에 담는 것이 다 됐습니다.
2. 로그아웃
@PostMapping("/logout")
public String logout2(HttpServletRequest request) {
// Cookie cookie = new Cookie("memberId", null);
// cookie.setMaxAge(0);
// response.addCookie(cookie);
sessionManager.expire(request);
/**
* Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
* if(sessionCookie != null) {
* sessionStore.remove(sessionCookie.getValue());
* }
*/
return "redirect:/";
}
세션 저장소의 expire를 하면 됩니다. 쿠키만 사용할 때는 응답에 setMaxAge(0)을 넣어야하니 response가 필요했지만 이제는 요청에서 cookie를 꺼내고 쿠키값인 세션 id의 값을 세션 저장소에서 지워야하니 request가 필요합니다.
3. 컨트롤러
1) 로그인
@GetMapping("/")
public String homeLogin2(HttpServletRequest request, Model model) {
/* 사라지는 로직
// 로그인을 하지 않은 사용자
if(memberId == null) {
return "home";
}
// 로그인한 사용자
Member loginMember = memberRepository.findById(memberId);
*/
Member member = (Member)sessionManager.getSession(request);
// 너무 오래된 쿠키
if(member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
}
이전에는 홈 컨트롤러에서 로그인이 되어있는 멤버면 쿠키에서 "memberId"라는 쿠키이름으로 id 값을 찾아서 null이면 일반 홈페이지를 보여주고 null이 아니면 로그인한 사용자로 봐서 다른 홈 페이지를 보여줬었는데 이제는 세션을 활용합니다.
> 파라미터로 쿠키 받는 것을 빼고(@CookieValue) 세션 저장소에서 request로 받은 쿠키에 있는 세션 id에 맞는 세션 값을 찾습니다. 세션 값은 지금 회원 객체이므로 바로 회원 객체를 가져올 수 있습니다. 이전에는 쿠키 값이 memberid라서 그 id를 가지고 레포에서 멤버를 찾았는데 이제는 세션 저장소에 저장된 게 멤버라서 바로 찾습니다.
> 로그인 해서 세션 저장소에서 회원을 찾고 null이면 직접 구현한 세션 관리자에서 null은 요청 메세지에 쿠키가 없거나 쿠키는 있는데 세션 저장소에 그에 맞는 세션 id가 없는 경우로 일반 홈으로 가고 null이 아니면 모델에 멤버를 담고 로그인한 뷰에 뿌립니다.
로그아웃은 딱히 로직이 없다고 했는데 이전에는 있던 쿠키값을 maxAge(0)으로 없애버려서 쿠키값이 없으니깐 일반 홈으로 가도록 했습니다.
@PostMapping("/logout")
public String logout2(HttpServletRequest request) {
// Cookie cookie = new Cookie("memberId", null);
// cookie.setMaxAge(0);
// response.addCookie(cookie);
sessionManager.expire(request);
/**
* Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
* if(sessionCookie != null) {
* sessionStore.remove(sessionCookie.getValue());
* }
*/
return "redirect:/";
}
지금은 쿠키에 maxAge를 안했으니 요청에 계속 세션 id가 있긴 있습니다. 하지만 실제 저장소에서 expire 됐으니 로그인이 안 된 사용자로 봅니다.
-> 실행
로그인을 하면 response header에 sessionid가 있습니다. 재접속을 하면 request에 cookie에 sessionid가 담깁니다. 로그아웃하면 그래도 cookie에 세션 id가 담겨있습니다. 하지만 서버의 세션 저장소에서는 expire가 된 것이라서 아무리 요청해도 로그인이 안된 사용자로 보게됩니다.
- HTTP 세션 사용하기
서블릿도 세션 개념을 지원합니다.
-> HttpSession
직접 만든 것과 같은 방식으로 동작합니다. 로그인하면 응답에 쿠키에 세션 id를 uuid로 말아 넣고 요청이 올 때마다 request에서 cookie로 부터 받은 세션 id를 세션 저장소에서 비교해서 로그인한 사람인지 판단합니다.
-> HttpSession 사용
상수를 하나 만든다. 쿠키 이름으로 사용할 값입니다.
1. 로그인
// 이전
sessionManager.createSession(loginMember, request);
// 이후
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
원래는 로그인 성공 이후 직접 만든 세션 관리자로 세션을 만들었습니다. 이제는 request.getSession으로 세션을 생성하고 setAttribute로 보관하고 싶은 객체를 담으면 됩니다. 키는 세션이름, 값은 세션 값으로 지금은 회원이었습니다. request.getSession은 세션 저장소에 세션이 있었으면 있던 것을 반환하고 없었으면 신규 세션 id를 생성해서 줍니다. (기존에 있던 세션인 것은 브라우저 ip 주소같은 거로 할까요? -> 일단은 그냥 요청하는 브라우저와 서버와 1대1로 연결되어있다고 생각하겠습니다. 사실은 N대1이지만)
> getSession을 false로 하면 세션 id가 있으면 기존 세션 id를 반환하는데 없으면 새로 만들지 않고 null을 반환합니다.
2. 로그아웃
@PostMapping("/logout")
public String logout3(HttpServletRequest request) {
// 이전
sessionManager.expire(request);
// 이후
HttpSession session = request.getSession(false);
if(session != null) {
session.invalidate();
}
return "redirect:/";
}
위에서 한 것처럼 세션을 삭제하면 클라에는 있지만 세션 저장소에 없어서 의미없는 요청을 하게 합니다. 기존 세션이 필요하니 없으면 새로 생성하지 않고 null을 반환하도록 false로 세션을 가져옵니다. null이 아니면 invalidate로 세션id랑 value가 다 날라갑니다. (여기서는 멤버)
3. 컨트롤러
@GetMapping("/")
public String homeLogin3(HttpServletRequest request, Model model) {
// 이전
Member member = (Member)sessionManager.getSession(request);
// 이후
HttpSession session = request.getSession(false);
if(session == null) {
return "home";
}
Member loingMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
이번에도 홈 컨트롤러는 세션을 찾고 없으면 일반 홈페이지를, 없으면 다른 홈페이지를 보여줄 것입니다. getSession()을 일단은 false를 합니다. 처음 로그인한 사용자도 true로 하면 무조건 세션이 만들어져버리기 때문입니다. 지금은 /에 접근만했지 세션을 만들 의도가 없었고 세션은 메모리를 쓰는 것이기 때문에 꼭 필요할 때만 만들어야합니다.
session이 null이면 일반 홈 페이지를 보여주고 > 세션이 있으면 session.getAttribute(상수)로 로그인 멤버를 꺼냅니다. 세션 담을 때 캐스팅을 해야하며 값이 없으면 일반 홈페이지로 가고 있으면 다른 홈페이지를 보여줍니다.
-> 실행
로그인하면 response에 jsessionid가 있습니다. 이렇게 하면 직접 구현했을 때처럼 기능을 제공합니다. 얘들도 로그아웃하면 브라우저 쿠키는 남아있지만 세션 저장소의 세션은 날라가도록 설계되어있습니다.
- HTTP 세션 사용하기 강화
@GetMapping("/")
public String homeLogin4(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember
, Model model) {
/** 삭제될 지저분한 코드
* HttpSession session = request.getSession(false);
* if(session == null) {
* return "home";
* }
*
* Member loingMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
*/
// 너무 오래된 쿠키
if(loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
세션 코드를 보면 맘에 들지 않습니다. 스프링이 제공하는 세션을 사용해보자 홈 컨트롤러에서 request를 받아서 getSession하고 이러는데 다 스프링이 제공합니다. @SessionAttribute(name = )을 하면 한번에 됩니다.
required = false가 처음 로그인한 사용자를 위해 무조건 세션을 생성하지 않도록 했던 것과 같습니다. > 이렇게 하면 로그인을 할 때 세션을 봐서 있으면 찾아서 세션값인 멤버를 반환하고 아니면 null로 갑니다.
- 세션 정보와 타임아웃
세션 정보를 출력해보자 maxInactiveInterval는 1800초가 기본입니다. isNew는 처음 세션 생성할 때만 true이고 이후는 다 false입니다. LastAccessedTime 이후에 was에서 세션을 제거합니다. 이들을 잘 조합해서 타임아웃을 줄 수 있습니다.
- 세션 타임아웃 설정
세션은 사용자가 로그아웃해서 session.invalidate()할 때 삭제됩니다. 하지만 대부분의 사용자는 로그아웃을 안 누르고 걍 브라우저 꺼버립니다. 문제는 http는 비 연결성이라서 브라우저를 꺼버리면 서버에서 연결을 닫았는지 알 수 없습니다. 근데 세션은 메모리를 사용해서 서버 컴퓨터의 메모리에 다 쌓입니다. 10만명이 있으면 10만개의 세션이 있고 메모리를 다 먹고 있는 것입니다. 반드시 꼭 필요한 경우에만 세션을 사용해야하고 끊으면 바로 삭제해야합니다.
-> 세션의 종료 시점
세션의 종료 시점을 어떻게 하면 좋을까? 가장 단순하게 생각해보면 세션 생성 시점에서 30분 정도로 잡고 끊어버리면 됩니다. 근데 그러면 게임하다가 30분마다 로그아웃됩니다. 그래서 사용자가 최근에 요청한 시간을 기준으로 30분 정도를 유지하게 합니다. 네이버를 보면 클릭을 할 때 요청이 아니 그 시간부터 30분 연장을 하는 것입니다.
server.servlet.session.timeout=60초 이것을 프로퍼티스에 넣으면 모든 세션이 다 요청할 때마다 60초 연장하는 것입니다. 어느 한 세션만 다르게 하고 싶으면 session.setMaxInactiveInterval(1800);을 세션 만들 때 출 수 있습니다. 보안이 중요한 세션은 적게 줘야할 것입니다.
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
spring PART.예외 처리와 오류 페이지 (0) | 2023.04.26 |
---|---|
spring PART.필터 (0) | 2023.04.24 |
spring PART.빈 검증 (0) | 2023.04.21 |
spring PART.검증 (0) | 2023.04.20 |
spring PART.메시지, 국제화 (0) | 2023.04.19 |