본문 바로가기

3.구현/Java or Kotlin

[java] spring session 처리해보자

들어가기

HTTP는 기본적으로 상태를 유지하지 않기 때문에 이전에 결과가 다음 작업에 영향을 미치지 않는다. 즉, 이전 결과 데이터를 다음 처리에서 사용할 수 없다는 의미이다. Spring에서는 이를 처리히기 위해서 다양한 방법들이 있다. Spring에서 session관리 기본상태는 브라우저의 cookie를 사용해서 유지하고 있다. Spring는 session 값을 서로 교환함으로서 세션을 식별하고 있다.

Spring의 기본 설정에는 cookie를 사용한 session 관리를 사용하지 않는다. 먼저 session를 사용하기 위한 기본 설정을 해보자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

Interceptor 설정

먼저 HandlerInterceptor를 설정해보자. 이를 통해서 Spring에서 session 생성을 허용 또는 거부를 할 수 있다. Spring에서 정의된 HandlerInterceptor 인터페이스는 아래와 같다.

package org.springframework.web.servlet;
public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable Exception ex) throws Exception {
    }
}

Handlernterceptor 인터페이스의 각 메소드는 다음과 같은 상황에서 호출된다.

  • preHandle: HandlerMapping에 의해 처리할 핸들러 객체가 정해진 직후에 호출딘다. 즉, 요청 시점에 실행된다.
  • postHandle: HandlerAdapter에의해서 처리된 후에 DispatcherServlet에 의해 뷰를 랜더 전에 실행된다. ModelAndView가 넘겨지므로 마지막으로 추가적인 뷰에 처리될 객체를 추가할 수 있다.
  • afterCompletion: 요청 처리가 끝난 시점에 실행된다. 뷰가 랜더된 후에 호출된다. 어떤 경우이든 실행 후에 반드시 호출된다. 물론 preHandler에서 true가 반환된 경우에 호출된다.

다음은 SessionInterceptor 클래스로 HandlerInterceptor 인터페이스를 구현했다. 3개의 메소드를 모두 구현할 필요 없이 필요한 메소드만 구현해도 된다.

public class SessionInterceptor implements HandlerInterceptor {
    static Logger logger = LoggerFactory.getLogger(SessionService.class);

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        logger.info("preHandle : id[{}]", req.getSession().getId());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest req, HttpServletResponse res, Object handler, ModelAndView modelAndView) throws Exception {
        logger.info("postHandle : id[{}]", req.getSession().getId());
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
        logger.info("afterCompletion : id[{}]", req.getSession().getId());
    }
}

kotlin 버전

class SessionInterceptor : HandlerInterceptor {
    companion object
    private val logger = LoggerFactory.getLogger(SessionInterceptor::class.java);


    @Throws(ServletException::class)
     override
     fun preHandle( req:HttpServletRequest, res:HttpServletResponse, handler:Any):Boolean  {
         logger.info("preHandle : id[{}]", req.getSession().getId())
         return true
    }

    @Throws(ServletException::class)
    override
    fun postHandle( req:HttpServletRequest, res:HttpServletResponse, handler:Any, view:ModelAndView?)  {
         logger.info("postHandle : id[{}]", req.getSession().getId())
    }

    @Throws(ServletException::class)
    override
    fun afterCompletion( req:HttpServletRequest, res:HttpServletResponse, handler:Any, ex:Exception?)  {
         logger.info("afterCompletion : id[{}]", req.getSession().getId())
    }    
}

지금은 구현해놓았을 뿐이고 아직은 spring에서 동작하지 않는다. 다음으로 Spring 실행할 때에 SessionInterceptor 클래스를 호출하도록 등록해보자.

Interceptor을 등록

Spring boot에서는 interceptor을 추가하기 위해서는 WebMvcConfigurer을 구현해야 한다. 이때에 addInterceptors 메소드를 오버로딩하고 필요한 인터셉터들을 추가하면 된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SessionInterceptor());
    }
    //중략
}

이제 실행해서 확인해보면 된다. 아래는 실행해서 출력된 로그 결과이다.

2024-01-29T10:43:25.939+09:00 INFO 14828 --- [nio-8080-exec-6] com.tistory.ospace.SessionInterceptor : preHandle : id[BFE497322F4AD60DAF2A3BE88DB6DFCC]
2024-01-29T10:43:25.940+09:00 INFO 14828 --- [nio-8080-exec-6] com.tistory.ospace.ApiController : hello called
2024-01-29T10:43:25.944+09:00 INFO 14828 --- [nio-8080-exec-6] com.tistory.ospace.SessionInterceptor : postHandle : id[BFE497322F4AD60DAF2A3BE88DB6DFCC]
2024-01-29T10:43:25.951+09:00 INFO 14828 --- [nio-8080-exec-6] com.tistory.ospace.SessionInterceptor : afterCompletion : id[BFE497322F4AD60DAF2A3BE88DB6DFCC]

"/hello"라는 API를 호출한 결과를 확인할 수 있다. 그리고 session ID 값이 "BFE497322F4AD60DAF2A3BE88DB6DFCC" 임을 확인할 수 있고, 실제 cookie를 확인하면 JSESSIONID으로 저장되어 있음을 확인할 수 있다. 이는 서버에서 응답으로 cookie을 설정해서 반환해주었기 때문에 브라우저에서 이 값을 다시 설정해서 서버로 보내게 된다. 다시 spring 서버는 이 값을 확인해서 session을 식별하고 유지할 수 있게 된다.

기존 session의 한계점

Spring에서 기본적으로 제공되는 session은 cookie을 사용한 방식이다. Cookie을 사용하는 경우의 장점은 사용자나 프론트엔드 개발자가 신경쓰지 않아도 브라우저에서 자동으로 처리되기 때문에 별도로 프로트엔드 개발자는 세션 키 관리에 신경쓸 필요가 없다. 일반적인 프론트 페이지 개발에는 문제가 없다. 하나의 사용자가 하나의 세션을 유지하기 때문에 문제가 없고 오히려 세션관리가 투명하게 처리되기 때문에 더 편리하다.

그러나 RESTfull 기반의 API를 처리하게 되는 경우 분리된 세션으로 동시에 호출해야하는 경우가 생긴다. 특히 브라우저에서 탭으로 분리되고 각 탭별로 따로 처리해야하는 경우가 발생한다. 각 탭별로 분리되어서 처리되어야 하는데 같은 세션으로 존재하는 경우 서로 간에 영향을 미칠 수가 있다. Cookie는 여러 개의 탭이 존재해도 명시적으로 세션을 분리하지 않는다면 여러 개의 탭이 같은 cookie을 공유하게 된다. 즉, 여러 개의 탭이 같은 세션을 공유한다는 의미가 된다. 탭 별로 세션을 분리하고 싶다면 cookie을 사용하지 않고 다른 곳에 session을 저장해야 한다.

또한 RESTfull Api 기반으로 서버 간에 처리가 필요한 경우 cookie을 자동으로 관리할 수 없는 경우도 있어서 앞에서 말한 cookie의 장점이 없어지고 직접 관리해야하는 경우가 발생한다.

이를 해결하기 위한 방법으로 Spring에서는 HTTP 헤더의 파라미터에 session을 저장하여 처리하는 방식을 제공하고 있다. 이 방식은 session을 별도로 관리해야하기 때문에 프론트엔드 개발자는 session을 관리해야하는 불편함이 있다. 불편함은 있지만 session 관리가 더 유연해지고 서비스도 다각적으로 처리할 수 있는 이점이 생긴다.

그럼 Spring에서 HTTP헤더의 파라미터로 session을 처리하는 방법을 살펴보자.

Spring session 설정

Spring boot에 세션관리를 위한 spring-session-core라는 dependency를 추가해보자.

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-core</artifactId>
    <version>3.2.1</version>
</dependency>

만약 예전 버전이 필요하신 분은 spring-session을 사용하면 된다. spring-session-core 버전은 현재 사용 중인 spring 버전에 맞게 설치하면 된다. Spring boot을 사용하고 있다면 자동으로 관리되기 때문에 생략하면 된다. 다음으로 HTTP 헤더의 파라미터를 처리할 filter와 repository를 등록하면 된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    private Map<String, Session> sessions = new HashMap<>();

    @Bean MapSessionRepository sessionRepository() {
        return new MapSessionRepository(sessions);
    }

    @Bean SessionRepositoryFilter<?> springSessionRepositoryFilter(
            MapSessionRepository sessionRepository) {
        SessionRepositoryFilter<?> ret = new SessionRepositoryFilter<>(sessionRepository);
        ret.setHttpSessionIdResolver(HeaderHttpSessionIdResolver.xAuthToken());

        return ret;
    }
    // 중략
}

주의할 부분은 위의 MapSessionRepository을 설정하지 않고 filter만 등록되어 있다면 에러가 뜨면서 실행되지 않는다. Repository 빈객체가 설정되어 있지 않다면 SessionRepository 빈 객체를 찾을 수 없어서 실행되지 않는다.

현재는 기존 cookie를 대처하는 것이기에 MapSessionRepository를 사용하여 session 저장소를 지정했다. 기존 로직 그대로 하여 실행한 결과는 다음과 같다.

2024-01-29T11:18:36.641+09:00 INFO 21188 --- [nio-8080-exec-1] com.tistory.ospace.SessionInterceptor : preHandle : id[2b9b9f6c-4393-4239-9803-6d2fa3db3e38]
2024-01-29T11:18:36.672+09:00 INFO 21188 --- [nio-8080-exec-1] com.tistory.ospace.ApiController : hello called
2024-01-29T11:18:36.703+09:00 INFO 21188 --- [nio-8080-exec-1] com.tistory.ospace.SessionInterceptor : postHandle : id[2b9b9f6c-4393-4239-9803-6d2fa3db3e38]
2024-01-29T11:18:36.704+09:00 INFO 21188 --- [nio-8080-exec-1] com.tistory.ospace.SessionInterceptor : afterCompletion : id[2b9b9f6c-4393-4239-9803-6d2fa3db3e38]

호출로 생성된 session 값이 "2b9b9f6c-4393-4239-9803-6d2fa3db3e38"가 된다. 이 값은 응답 메시지의 헤더에 "x-auth-token"에 저장되어 응답한다. 이 값은 클라이언트에서 저장하고 있다가 다시 서버 호출시 사용된다. 응답된 값의 header에서 x-auth-token 파라미터로 저장되어 있다. 이 값을 추출해서 다시 서버로 요청을 보낼때 header에 x-auth-token 파라미터로 설정해서 전송하게 된다.

결론

대부분의 경우 기존의 cookie을 사용하면 별 문제가 없고 사용도 간편하다. 그러나, session을 세분해서 관리하거나, RESTfull API에 의한 session 처리는 cookie 보다는 header로 처리하는 경우가 더 이점이 많기 때문에 HTTP header를 사용해서 처리를 한다. 물론, HTTP header가 아닌 다른 곳에 저장해서 처리할 수도 있지만, 그럴 경우 직접 해당 기능을 구현해서 spring session에 붙이거나 혹은 별도 기능으로 분리해서 처리해야 한다.
부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활하세요.^^ ospace.

Troubleshooting

Refused to get unsafe header "x-auth-token"

브라우저에서 session 값(x-auth-token 파라미터)을 획득하는 경우는

Refused to get unsafe header "x-auth-token"

위의 같은 에러가 발생한다. 이 경우는 브라우저 보안상의 문제로 원격에 있는 API 서버를 호출하는 경우에 발생한다. 해결하는 방법은 간단하다. 서버 측에서 컨트롤에 CrossOrigin 어노테이션의 exposedHeaders를 사용하면 된다. 아래 예제는 전체 메소드에 영향을 주는 방법이고 메소드 단위로도 지정이 가능하다.

@RestController
@CrossOrigin(origins="*", exposedHeaders="x-auth-token", allowedHeaders="x-auth-token")
public class AccountController {
    //중략
}

401 unauthorized

간혹 PostMan등으로 테스트할 경우에는 잘되는데 브라우저에서 실행하는 경우 401 에러가 발생하는 경우가 있다.

이는 브라우저에서 OPTIONS이라는 메소드를 사용해서 호출하는 경우에 발생한다. 이 메소드는 대상 자원에 대한 접근 가능 여부에 대한 미리 검증하는 목적으로 사용한다. 그런데 SessionInterceptor에서 해당 요청에 대한 session에 대해 추출하는데 값이 없다. 빈값으로 OPTIONS 메소드를 요청하기에 제대로된 데이터가 없다. 그래서 잘못된 데이터로 처리하는 경우에 발생한다.

OPTIONS HTTP 메소드는 접근 대상 자원에 대한 지원 HTTP 메소드 정보를 확인하기 위한 목적이 있다. 서버에서는 정상으로 값을 반환했는데, 브라우저에서 처리하지 못해서 에러가 발생한다. 이 경우 OPTIONS 메소드가 호출되고 다음에 원하는 메소드가 호출되는 순서를 거치면서 사전에 OPTIONS을 호출해서 생기는 문제이다. 예를 들어 SessionInterceptor에서 session 키를 획득하는 과정에서 OPTIONS 요청에는 session 정보가 없기에 null이 된다. 그러면 session 처리에 실패되면서 다음 과정으로 넘어가지 못하면서 unauthorized 에러가 발생하게 된다. 추가로 OPTIONS 메소드도 하나의 요청이기 때문에 SessionInterceptor에서 preHandle()에 의해 새로운 session이 생성되면서 불필요한 session이 생성되는 문제가 발생한다.

간단한 해결책은 메소드가 OPTIONS인 경우는 session값을 추출하기 않고 조건만 확인하고 true을 반환하면 된다. 그리고 session 생성 시점을 마지막으로 미룬다.

public class SessionInterceptor implements HandlerInterceptor {
    static Logger logger = LoggerFactory.getLogger(SessionService.class);

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
      String id = req.getRequestedSessionId();
      logger.info("preHandle : id\[{}\]", id);
      if("OPTIONS".equals(req.getMethod())) return true;
      //중략
      req.getSession();
      //session 생성
      return true;
    }
    // 중략
}

OPTIONS 처리 분리

간혹 Controller에서 OPTIONS 메시지를 별도로 분리해서 처리도 가능하다. 특별한 문제가 있는 것은 아니지만, 분리해서 처리할 경우가 발생하면 유용하다.

@RestController
class DemoController {
    @RequestMapping(method=RequestMethod.OPTIONS)
    public ResponseEntity handleOptions() {
        return ResponseEntity(HttpStatus.NO_CONTENT);
    }
    //중략
}

unauthoriaed 에러 반환

session 처리하다기 유효하지 않은 session인 경우 unautorized 에러를 반한해야 한다. SessionInterceptor에서는 Controller 처럼 결과값을 리턴하거나 할 수 없다. 다행히 파라미터 중에서 HttpServletResponse res가 있기 때문에 반환하는 내용을 기록하면 된다. 그런데 API 서버에서 그냥 값만 반환하면 Orign오류가 발생한다. 이부분도 고려해서 작업해야 한다. unauthorized()는 미인증 처리하는 부분에서 HttpServeletResponse를 넘겨서 호출하면 된다.

void unauthorized(HttpServletResponse res) throws IOException {
    res.setStatus(HttpStatus.UNAUTHORIZED.value());
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Expose-Headers", "x-auth-token");
    res.setContentType("application/json");
    res.getWriter().write("{\"res\":1,\"msg\":\"invalid session\"}");
}

"Java.lang.ClassNotFoundException: javax.servlet.ServletRequest" 에러 발생

이는 이전 버전을 사용했다가 소스코드가 썩이는 경우에 발생할 수 있다. javax는 2020년 12월 경에 네임스페이스 변환로 인해 jakarta로 변경되면서 발생했다. 발생원인은 dependency를 spring-session-core가 아닌 spring-session를 사용할 경우에 발생한다. spring-session은 예전 버전을 사용했었기에 javax.servlet 패키지를 사용하고 있어 에러가 발생한다. 만약 legacy 코드를 사용하고 있다면 바대로 해야겠죠.

참조

[1] Annotaion Type CrossOrigin, 2018.07.10, https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/CrossOrigin.html

반응형

'3.구현 > Java or Kotlin' 카테고리의 다른 글

[java] POJO, JavaBeans, VO, DTO, PO, BO  (0) 2024.01.31
[java] spring에서 event 사용하기  (0) 2024.01.30
[java] Collections like SQL  (0) 2024.01.26
[java] CompletableFuture 사용하기  (0) 2024.01.22
Kotlin 배우기3 - Generic  (0) 2023.11.16