만약 우리가 처음부터 프로젝트를 작성한다고 할 때
패키지 구조를 아래와 같이 domain
과 web
으로 따로 구조화 해 두는 것이 좋다.
hello.login
여기서 domain
이 가장 중요하다.
domain
= 화면, UI, 기술 인프라 등등의 영역은 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 말한다.
즉, 향후 web을 다른 기술(thymeleaf
가 아닌 front-end framework
)로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다는 것이다.
이렇게 하려면 web
은 domain
을 의존하지만, domain
은 web
을 의존하지 않는다고 표현한다.
예를 들어 web 패키지를 모두 삭제해도 domain에는 전
혀 영향이 없도록 의존관계를 설계하는 것이 중요하다. 반대로 이야기하면 domain은 web을 참조하면 안된다.
@PostMapping("/login")
public String login(@Valid @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", "Miss match ID or Password");
return "login/loginForm";
}
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
// @GetMapping("/")
public String home() {
return "home";
}
@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";
}
}
Age
를 0으로 만들어버리면 된다.@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);
}
set-cookie
에 expires
속성값도 추가되어있는 것을 볼 수 있는데 이건 다른 브라우저의 하위 버전과의 호환성을 위해 과거시간으로 보내는 값도 서버에서 한꺼번에 보내준다.public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Objects> sessionStore = new ConcurrentHashMap;
/**
* 세션 생성
* 1. 랜덤값의 sessionID 생성
* 2. 생성한 sessionID 저장
* 3. 생성한 sessionID를 쿠키를 통하여 client에 전달
*/
public void createSession(Objects value, HttpServletResponse response) {
String sessionID = UUID.randomUUID().toString();
sessionStore.put(sessionID, value);
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionID);
response.addCookie(mySessionCookie);
}
opt + cmd + c
로 특정 변수를 static하개 바꿀 수 있다.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.getValue());
}
}
return null;
}
opt + cmd + m
로 매서드를 별도로 리팩토링해주었다.public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
private 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);
}
/**
* 세션에서 삭제
*/
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
HttpServletRequest
가 있다는 것이다. 이는 인터페이스인데 이를 구현한 구현체를 제공하기는 하는데 다 시원찮다.MockHttpServletRequest
가 있다.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();
}
}
정리
사실 세션이라는 것이 뭔가 특별한 것이 아니라 단지 쿠키를 사용하는데, 서버에서 데이터를 유지하는 방법일 뿐이라는 것이다.
그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편할 것이다.
그래서 servlet
도 세션 개념을 지원한다.
이제 직접 만드는 세션 말고, 서블릿이 공식 지원하는 세션을 알아보자.
서블릿이 공식 지원하는 세션은 우리가 직접 만든 세션과 동작 방식이 거의 같다.
추가로 세션을 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능을 제공한다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05
public abstract class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
HttpServletRequest
가 필요하다.HttpSession session = request.getSession();
getSession()
메서드로 나온 값을 보면 우리가 작성했던 모든 일련의 과정을 거져 자동으로 튀어나온다.세션의 create 파라미터(옵션)에 대해 알아보자.
request.getSession(true)
세션이 있으면 기존 세션을 반환한다.
세션이 없으면 새로운 세션을 생성해서 반환한다.
true가 기본값이다.
request.getSession(false)
세션이 있으면 기존 세션을 반환한다.
세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.
사용예 -> 로그아웃 때, 컨트롤러에서 사용자가 최초 로그인할 때, 즉, 세션도 메모리를 사용하기 때문에 세션을 생성할 의도가 있어야 생성한다.
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name=SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
// HttpSession session = request.getSession(false);
// if (session == null) {
// return "home";
// }
// Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
즉, 저기 위에있는 @SessionAttribute
어노테이션 하나로 원래는 값을 찾아서 set 해와야하는 일련의 과정들을 생략하고 바로 값을 꺼내서 갖다 쓸 수 있다는 점이 좋다.
참고로 이 어노테이션의 create 옵션
은 false
이다.
트래킹 모드라는 것이 존재한다. 바로 최초 로그인 시 뒤에 파라미터처럼 jsessionid가 붙어서 나오는 건데
사용자 입장에서는 이게 뭔지도 모를 것이고 굳이 보여줘서 혼선을 빚을 필요는 없기 때문에 없애주는 것이 좋다.
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
이 방법을 사용하려면 URL에 이 값을 계속 포함해서 전달해야 한다.
참고로 타임리프
같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다.
서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로, 쿠키 값도 전달하고, URL에 jsessionid 도 함께 전달해준다.
하지만 이 방법은 모든 url에 개발자가 jssessionid를 싣어서 보내주도록 해야하는데 구현방법이 굉장히 현실성이 없기 때문에 아예 사용하지 않는것이 좋다.
해제 방법은 URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음 옵션을 넣어주면 된다. 이렇게 하면 URL에 jsessionid 가 노출되지 않는다.
server.servlet.session.tracking-modes=cookie
@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("maxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()
가 호출 되는 경우에 삭제된다.
그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.
문제는 HTTP가 비 연결성(ConnectionLess)이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.
따라서 서버에서 세션데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.
세션 생성 시점
이 아니라 사용자가 서버에 최근에 요청한 시간
을 기준으로 30분 정도를 유지해주는 것 이다.server.servlet.session.timeout=60 : 60초
server.servlet.session.timeout=1800 : 1800초(30분)
session.setMaxInactiveInterval(300); // 300초(5분)
LastAccessedTime
이후로timeout
시간이 지나면,WAS
가 내부에서 해당 세션을 제거한다.
서블릿의 HttpSession 이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 사용할 수 있다. 마지막으로 주의 할 점은
1. 세션에는 최소한의 데이터만 보관해야 한다는 점이다.
보관한 데이터 용량
x사용자 수
로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다.
따라서보관한 데이터 용량
을 줄이기 위해 객체를 통째로 저장하는 것이 아닌, fit한 dto를 따로 만들어서 해당 값을 저장하는 것이 좋다.
2. 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다.
기본이 30분이라는 것을 기준으로 고민하면 된다.