스프링/Spring MVC

[SpringBoot] 세션 직접 만들어서 로그인 처리하기

킵고잉 개발자 2021. 10. 25. 22:21

스프링 부트에서 로그인을 처리하기 위해서는 서블릿이 지원하는 HttpSession을 사용하면 쉽게 구현이 가능합니다.

하지만 세션에 대한 이해도를 높이기 위해 직접 만들어보기로 하겠습니다. 이전에 웹브라우저와 서버 간에 로그인 상태를 유지하는 방법에 대해서 알아보겠습니다.

 

 

로그인 상태 유지하기

로그인 상태를 유지하기 위해 대표적으로 다음과 같은 방법이 있습니다.

1.  요청할 때 사용자 정보를 쿼리 파라미터로 전달하기

사용자 정보를 쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 어렵고 번거로운 작업입니다. 게다가 식별 가능한 사용자 정보를 쿼리 파라미터에 노출하는 것은 보안상 위험합니다.

2. 쿠키에 사용자 정보 담아서 사용하기

서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달합니다. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 서버로 보내줘서 서버는 사용자를 식별할 수 있습니다.

Cookie: memberId=1

물론 여기에도 보안 문제가 있습니다.

  • 쿠키 값은 임의로 변경할 수 있음.
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 됨.
    • 실제 웹브라우저 개발자 모드 Application Cookie 변경으로 확인 가능
    • Cookie: memberId=1 Cookie: memberId=2 (다른 사용자의 이름이 보임)
  • 쿠키에 보관된 정보는 훔쳐갈 수 있음.
    • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
    • 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달됨
    • 쿠키의 정보가 나의 로컬 PC가 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있음.
  • 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있음.
    • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있음

 

쿠키에는 영속 쿠키와 세션 쿠키가 있습니다.
  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료 시까지만 유지

브라우저 종료시 로그아웃이 되길 원하면 세션 쿠키를 사용하면 됩니다.

 

3. 쿠키에 세션 정보를 담아서 사용하기

세션 생성

세션 ID를 생성하는데, 추정 불가능해야 한다. UUID는 추정이 불가능합니다.

Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61

생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관합니다.

클라이언트와 서버는 결국 쿠키로 연결이 되어야 합니다.

서버는 클라이언트에 mySessionId라는 이름으로 세션 ID 만 쿠키에 담아서 전달합니다

클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관합니다.

 

중요
여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다

 

 

세션 관리하기 

크게 다음 3가지 기능을 제공하면 됩니다.

세션 생성

sessionId 생성 (임의의 추정 불가능한 랜덤 값)

세션 저장소에 sessionId와 보관할 값 저장

sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

세션 조회

클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회

세션 만료

클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

 

 

SessionManager - 세션 관리

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

    private 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);

        // 쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    public Object getSession(HttpServletRequest request){

        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null){
            return null;
        }

        return sessionStore.get(sessionCookie.getValue());
    }

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

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


}
  • ConcurrentHashMap : HashMap 은 동시 요청에 안전하지 않다. 동시 요청에 안전한 ConcurrentHashMap를 사용했다.

 

SessionManagerTest - 테스트

class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest(){

        // 세션 생성 및 Http 응답을 받고 세션을 쿠키에 담고, response에 쿠키가 담김
        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();
    }

}

여기서는 HttpServletRequest , HttpservletResponse 객체를 직접 사용할 수 없기 때문에, 테스트에서 비슷한 역할을 해주는 가짜 MockHttpServletRequest, MockHttpServletResponse를 사용했습니다.

 

LoginController - 로그인 (세션 생성)

@Controller
@Slf4j
@RequiredArgsConstructor
public class LoginController {
    private final LoginService loginService;
    private final SessionManager sessionManager;
    
    @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());
        log.info("login?{}", loginMember);

        if (loginMember == null){
            bindingResult.reject("loginFali", "아이디 또는 비번이 맞지 않아~~");
            return "login/loginForm";
        }

        // 로그인 성공 처리
        // 세션 관리자를 통해 세션을 생성하고, 회원 데이터를 보관
        sessionManager.createSession(loginMember, response);
        return "redirect:/";
	}
}
  • private final SessionManager sessionManager; 주입
  • sessionManager.createSession(loginMember, response); 로그인 성공 시 세션을 등록한다. 세션에 loginMember를 저장해 두고, 쿠키도 함께 발행합니다.

 

HomeController - 홈 화면 요청 (세션 조회)

세션 보유 여부에 따라 다른 페이지 이동시키도록 했습니다.

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

        Member member = (Member) sessionManager.getSession(request);
        if (member == null) {

            return "home";
        }

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

 

LoginController - 로그아웃 (세션 만료시키기)

쿠키의 만료 시간인 MaxAge를 0으로 설정함으로써 쿠키를 만료시킵니다.

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

 

Spring에서 지금까지 로그인 상태 유지하는 방법과 세션을 직접 개발하는 방법을 알아봤습니다.

다음에는 서블릿이 지원하는 HttpSession을 사용해 세션을 개발해보겠습니다.