Java Category/Spring

[Spring MVC] 세션을 이용한 로그인

ReBugs 2024. 3. 17.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


세션을 이용한 로그인 처리

쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있다.
이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.

 

사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.

 

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

UUID ex) mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61

세션 저장소의 세션 아이디에는 UUID를 저장하고, value에는 정보를 저장한다.

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

 

서버는 클라이언트에 mySessionId 라는 이름으로 세션ID(UUID) 만 쿠키에 담아서 전달한다.

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

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

 

클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.

 

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다.
따라서 아래와 같은 보안 문제들을 해결할 수 있다.

-쿠키 값을 변조 가능 → 예상 불가능한 복잡한 세션Id를 사용한다.
-쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. → 세션Id가 털려도 여기에는 중요한 정보가 없다.

쿠키 탈취후 사용 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료 시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.

 

직접 세션 관리

세션 관리는 크게 아래와 같이 3가지 기능을 제공하면 된다.

  • 세션 생성

    →sessionId 생성 (임의의 추정 불가능한 랜덤 값)
    세션 저장소에 sessionId와 보관할 값 저장
    sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

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

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

 

/**
 * 세션 관리
 */
@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);

        //쿠키 생성
        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) {

//        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;
        
        if (request.getCookies() == null) {
            return null;
        }

        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}
Stream API에는 findFirst()와 findAny() 메소드가 있다.
이 메소드들은 스트림에서 요소를 찾을 때 사용되며, Optional<T> 타입의 결과를 반환한다. 이는 찾은 요소가 존재할 수도 있고, 존재하지 않을 수도 있다는 것을 의미한다.


stream.findFirst()
findFirst() 메소드는 스트림의 첫 번째 요소를 반환한다. 이 메소드는 순서가 중요한 스트림에서 특히 유용하다.
예를 들어, 리스트나 정렬된 컬렉션에서 생성된 스트림, 또는 정렬된 스트림에서 첫 번째 요소를 찾고 싶을 때 사용된다.


stream.findAny()
findAny() 메소드는 스트림의 "어떤" 요소라도 반환할 수 있다. 병렬 스트림에서 작업을 수행할 때 이 메소드를 사용하면 효율적이다.
순서가 중요하지 않거나, 단순히 스트림 내의 어떤 요소라도 반환받기를 원할 때 유용하다. findAny()는 첫 번째 요소가 아닌 다른 요소를 반환할 수도 있다는 점에서 findFirst()와 구별된다.

orElse()
Optional 객체가 값이 있으면 그 값을 반환하고, 값이 없을 경우에는 메소드에 전달된 인자를 기본값으로 반환

 

ConcurrentHashMap
java.util.concurrent 패키지에 있는 스레드 안전한 해시 테이블의 구현체이다.
이 클래스는 멀티 스레드 환경에서 고성능을 유지하면서도, 데이터의 일관성을 보장하기 위해 설계되었다. HashMap과 비교했을 때, ConcurrentHashMap은 동시성(concurrency)을 향상시키기 위한 여러 가지 전략을 채택하고 있다.

특징
스레드 안전: ConcurrentHashMap의 모든 연산은 스레드 안전하다. 여러 스레드가 동시에 맵을 수정하거나 조회해도, ConcurrentHashMap은 데이터의 일관성을 유지한다.

락 분할(Lock Stripping): ConcurrentHashMap은 내부적으로 여러 개의 락을 사용하여 데이터 구조의 다른 부분을 동시에 수정할 수 있도록 한다. 이로 인해, 동시에 여러 스레드가 맵에 접근하더라도 높은 동시성을 제공한다.

null 값 불허: ConcurrentHashMap은 키(key)나 값(value)으로 null을 허용하지 않는다. 이는 HashMap과의 주요 차이점 중 하나이다.

고성능: 락 분할 기법 덕분에, ConcurrentHashMap은 동시에 여러 스레드가 맵을 사용할 때 뛰어난 성능을 제공한다.

 

테스트 코드

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();
    }
}

MockHttpServletResponse와 MockHttpServletRequest

스프링 프레임워크에서 제공하는 목(mock) 객체로, 실제 HTTP 요청과 응답을 모의하는데 사용된다.
이를 통해 실제 네트워크 환경이나 웹 서버 없이 HTTP 통신을 시뮬레이션할 수 있다.

 

세션 생성: SessionManager의 createSession() 메소드를 사용하여 Member 객체를 세션에 저장한다. 이 과정에서 MockHttpServletResponse 객체를 사용하여 HTTP 응답에 세션 정보를 쿠키로 추가한다.

 

요청에 응답 쿠키 저장: 생성된 세션 쿠키를 MockHttpServletRequest 객체에 설정한다. 이는 클라이언트가 서버로 요청을 보낼 때 쿠키를 포함시키는 것을 모의한다.

 

세션 조회: SessionManager의 getSession() 메소드를 호출하여 요청 객체를 기반으로 세션 정보를 조회한다. 조회 결과가 원래 저장한 Member 객체와 동일한지 검증한다.

 

세션 만료: SessionManager의 expire() 메소드를 사용하여 세션을 만료시킨다. 이후 같은 요청 객체로 세션을 조회했을 때 null이 반환되는지 검증한다. 이는 세션이 성공적으로 만료되었음을 확인한다.

 

 

HttpSession

서블릿은 세션을 위해 HttpSession 이라는 기능을 제공하는데, 지금까지 나온 문제들을 해결해준다.

직접 구현한 세션의 개념이 이미 구현되어 있고, 더 잘 구현되어 있다.

서블릿이 제공하는 HttpSession 도 결국 직접 만든 SessionManager 와 같은 방식으로 동작한다.

서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다.

쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.

 

세션 생성: 사용자가 웹 사이트에 처음 방문하거나 세션이 만료된 후 새로운 요청을 할 때, 서버는 자동으로 새로운 HttpSession 객체를 생성한다.

 

세션 데이터 저장 및 검색: HttpSession 객체는 사용자별 정보를 저장할 수 있는 속성을 제공한다.

-setAttribute(String name, Object value) 메소드를 사용해 데이터를 세션에 저장하고

-getAttribute(String name) 메소드로 데이터를 검색할 수 있다.

 

세션 유효 시간 관리: HttpSession의 유효 시간은 기본적으로 웹 서버에서 설정되며, setMaxInactiveInterval(int interval) 메소드를 사용해 프로그래밍 방식으로 세션의 유효 시간을 조정할 수 있다. 사용자가 설정된 시간 동안 활동이 없으면 세션이 만료된다.

 

세션 종료: invalidate() 메소드를 호출하여 언제든지 세션을 종료시킬 수 있다. 이 메소드는 세션에 저장된 모든 데이터를 삭제하고 세션을 무효화한다.

 

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 세션 얻기
    HttpSession session = request.getSession();

    // 세션에 데이터 저장
    session.setAttribute("user", "John Doe");

    // 세션에서 데이터 검색
    String user = (String) session.getAttribute("user");
    System.out.println("User: " + user);

    // 세션 유효 시간 설정 (초 단위)
    session.setMaxInactiveInterval(300); // 5분

    // 세션 종료
    session.invalidate();
}

 

세션의 생성과 조회

세션을 생성하려면 request.getSession(true) 를 사용하면 된다.
public HttpSession getSession(boolean create);​


세션의 create 옵션
request.getSession(true) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성해서 반환한다. (기본 값은 true)
request.getSession(false) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다. 


세션을 찾아서 사용하는 시점에는 create: false 옵션을 사용해서 세션을 생성하지 않아야 한다.

 

@SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute 을 지원한다.
이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다.

@SessionAttribute(name = "loginMember", required = false) Member loginMember

이 기능은 세션을 생성하지 않는다.

 

이 어노테이션을 사용하면, 세션에서 직접 속성을 가져오거나 세션에 속성을 추가하는 복잡한 작업 없이, 세션에 저장된 데이터를 쉽게 사용할 수 있다.

 

@SessionAttribute는 주로 컨트롤러 클래스 레벨에 선언되며, 이 어노테이션으로 지정된 속성은 해당 컨트롤러의 모든 핸들러 메소드에서 접근할 수 있게 된다. 또한, 메소드 파라미터 레벨에서 사용될 수도 있어, 특정 핸들러 메소드에서만 세션 속성을 사용하고 싶을 때 유용하다.

 

클래스 레벨에서의 사용 예

@Controller
@SessionAttributes("user")
public class MyController {

    @ModelAttribute("user")
    public User createUserModel() {
        return new User();
    }

    @GetMapping("/user/profile")
    public String userProfile(Model model) {
        // "user" 세션 속성 사용 가능
        User user = (User) model.getAttribute("user");
        return "userProfile";
    }
}

 

메소드 파라미터 레벨에서의 사용 예

@GetMapping("/user/profile")
public String getUserProfile(@SessionAttribute("user") User user, Model model) {
    // 메소드 파라미터로 "user" 세션 속성 직접 접근
    return "userProfile";
}

 

 @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";
}

 

@SessionAttribute로 지정된 세션 속성은 컨트롤러 내부에서 모델에 자동으로 추가되므로, 뷰 템플릿에서 해당 데이터를 사용할 수 있다.

 

이 어노테이션은 주로 읽기 전용 시나리오에서 사용될 것을 권장한다. 

 

세션 데이터를 변경하고 싶다면, 직접 HttpSession을 사용하는 것이 좋다.

 

@SessionAttributes와 혼동하지 말아야 한다. @SessionAttributes는 모델 속성을 세션에 저장하기 위해 사용되며, 주로 폼 제출 과정에서 사용자의 입력 데이터를 임시로 저장하는 데 쓰인다. 

 

반면, @SessionAttribute는 이미 세션에 저장된 속성에 접근하는 데 사용된다.

 

세션에서 직접 관리해야 하는 데이터가 아니라면, 가능한 한 세션 사용을 최소화하는 것이 좋다. 세션 데이터는 서버의 메모리를 사용하기 때문에, 과도한 사용은 애플리케이션의 성능에 부정적인 영향을 줄 수 있다.

 

TrackingModes

세션을 이용해 로그인을 하게되면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.

-http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

jsessionid가 노출되지 않도록 하기 위해서는 application.properties에 아래의 코드를 넣어주면 된다.

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()); //세션Id, JSESSIONID 의 값
        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 "세션 출력";
    }
}

 

  • sessionId: 세션Id, JSESSIONID의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval: 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime: 세션 생성일시
  • lastAccessedTime: 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId (JSESSIONID)를 요청한 경우에 갱신된다.
  • isNew: 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId (JSESSIONID)를 요청해서 조회된 세션인지 여부

 

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.

그런데 대부분 의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.

문제는 HTTP가 비 연결성(ConnectionLess) 이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.

따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

 

이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

  • 세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
  • 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.

 

세션의 종료 시점

세션 생성 시점으로부터 30분 정도로 잡는다면,  30분이 지나면 세션이 삭제되기 때문에 30분 마다 계속 로그인해야 하는 번거로움이 발생한다.

더 나은 대안은 세션 생성 시점이 아니라 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.

따라서 30 분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

 

세션 타임아웃 설정

application.properties 에서 타임아웃을 설정할 수 있다.

server.servlet.session.timeout=60

60초, 기본은 1800(30분) (글로벌 설정은 분 단위로 설정해야 한다. 60(1분), 120(2분), ...)

 

특정 세션 단위로 시간 설정

session.setMaxInactiveInterval(1800); //1800초

 

세션 타임아웃 발생

세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다.

이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.

  • session.getLastAccessedTime() : 최근 세션 접근 시간

LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

 

보관한 데이터 용량 사용자 수로 세션의 메모리 사용량 이 급격하게 늘어나서 장애로 이어질 수 있다.
추가로 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다.
기본이 30분이라는 것을 기준으로 고민하면 된다.

댓글