MockHttpServletResponse에 세션 쿠키 주입하기

, ,

🍪 세션이 만들어주는 쿠키 한 조각

세션을 활용하던 중, 컨트롤러에서 httpServletRequest.getSession()을 통해 세션을 생성하고 있었다. 이렇게 하면 톰캣 내부에서 세션을 생성할 때 응답 헤더에 Set-Cookie 필드를 추가하게 된다. 아래와 같은 예제 코드를 보자!

@RestController
public class MyController {

    @GetMapping("/session")
    public void session(HttpServletRequest request) {
        HttpSession session = request.getSession();
        session.setAttribute("createdAt", LocalDateTime.now());
    }
}

서버를 실행하고 /session에 접근하면, 아래와 같은 응답 헤더를 확인할 수 있었다. 예상했던 대로 Set-Cookie 필드에 세션 ID가 포함되어 있었고, HttpOnly와 같은 보안 설정도 확인할 수 있었다.

🧙🏻‍♀️ 헨젤과 그레텔 그리고 쿠키

프로젝트를 진행하면서 세션을 추가해야할 일이 생겼다. 프론트엔드와의 협업을 위해 우선 API 문서를 만들고자 했다. API 문서는 RestAssuredMockMvc와 RestDocs를 사용해 만든다. 어떤 쿠키를 설정하는지에 대한 설명을 API 문서에 담아둬야 했기에, 응답으로부터 JSESSIONID를 가진 쿠키를 가져오려고 했다.

헨젤과 그레텔을 찍는 RestDocs와 나

응답 헤더에 Set-Cookie 필드가 없다. 오다가 다 부서진 걸까??? 🤨 우선 이 문제가 RestAssuredMockMvc 때문인 건지, RestDocs를 위한 Configurer 때문인지, 어디부터 문제인지를 찾아보아야 했다. 프로젝트는 너무 광활하니 처음에 소개한 작은 클래스를 시작으로 여러 실험을 진행했다.

🤔 RestAssured 너냐?

    @Test
    void setCookieOnSession() {
        RestAssured.given().log().all()
                .when().get("/session")
                .then().log().all()
                .assertThat()
                .cookie("JSESSIONID")
                .statusCode(200);

실험해보긴 했지만, 이 친구는 반드시 통과할 것을 알고 있었다. @SpringBootTest를 활용해 Context가 활성화되므로, 외부에서 통신하는 것과 같은 결과를 낳을 것이기 때문이다. 테스트 환경에서는 문제가 없음을 알았으니, 추가적으로 여러 변인통제를 해보면서 어떤 부분을 놓치고 있는지 파악해야 했다. 특히, getSession()을 할 때 언제, 어떤 방식으로 HttpServletResponse 객체에 헤더/쿠키가 추가되는지를 확인해야 했다.

😼 고마워요 톰캣!

기본적으로 서블릿은 Servlet 클래스를 구현해야 하는 것처럼, 서블릿에서 주고받는 요청/응답은 ServletRequest, ServletResponse 인터페이스를 구현해야 한다. 나아가 Http 통신을 하는 서블릿의 경우에는 HttpServlet, HttpServletRequest, HttpServletResponse를 구현한다. 아래로 내려갈수록 구체화되는 것이 보이는가? 쿠키나 세션과 같은 내용은 ServletRequest에는 존재하지 않고, HttpServletRequest에만 존재한다.

책임 분리 확실하죠 🔥

톰캣은 각각을 구현해 서블릿의 요청을 처리한다. 디버깅 중에 발견한 사실 중 하난데, Tomcat은 세션을 가져온 뒤 Response에 직접 쿠키를 넣어주고 있었다. 내부적으로 RequestResponse를 가지고 있게끔 설계해서 그렇다. 아래 코드를 거친 Response 객체의 헤더에는 Set-Cookie가 잘 들어 있었다. RestAssured를 통한 테스트나 바깥에서 직접 요청을 한 경우에서 쿠키가 보이는 이유는 이 때문이었다.

// org.apache.catalina.connector.Request
// ...

    protected Session doGetSession(boolean create) {
       // ...
       // Creating a new session cookie based on that session
        if (session != null && trackModesIncludesCookie) {
            Cookie cookie =
                    ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());

            response.addSessionCookieInternal(cookie);
        }
// ...

🧐 MockHttpServletRequest 너구나?

그럼 이제 용의선상에 오른 클래스가 특정되었다. MockMvc가 어떻게 getSession을 하는지를 확인하면 된다. 디버깅을 통해서 확인해보면, HttpServletRequest 인터페이스의 구현체로 MockHttpServletRequest가 들어와 있었다.

MockHttpServletRequest는 스프링에서 테스트를 용이하게 할 때 쓰이는 Fixture 중 하나다. Tomcat의 구현체와는 다르게, 내부적으로 응답 객체를 가지고있지도 않다. 이말은 즉 getSession()이 쿠키를 직접 추가해주지 않는다는 이야기가 된다.

public class MockHttpServletRequest implements HttpServletRequest {
    // ...
    @Override
    @Nullable
    public HttpSession getSession(boolean create) {
        checkActive();
        // Reset session if invalidated.
        if (this.session instanceof MockHttpSession mockSession && mockSession.isInvalid()) {
            this.session = null;
        }
        // Create new session if necessary.
        if (this.session == null && create) {
            this.session = new MockHttpSession(this.servletContext);
        }
        return this.session;
    }
    // ...
}

내부적으로 지원하지 않으니 밖에서 넣어줄 수밖에 없다. 어떻게 하면 세션이 생성될 때 Response에 쿠키를 추가할 수 있을까? 🤔

🕸️ Servlet의 Filter를 활용하자

서블릿 컨테이너는 요청이 들어오면 Filter를 거친 뒤 서블릿에게 요청을 건넨다. 마찬가지로 서블릿의 응답 또한 Filter를 거친 뒤 사용자에게 전달된다. MockHttpServletRequestgetSession()MockHttpSession을 만들 뿐, 응답에 쿠키를 추가하고 있지는 않다. Filter를 활용해서 응답에 쿠키를 추가하는 것이 하나의 해결책이 될 수 있겠다.

Filter sessionCookieFilter = (request, response, chain) -> {
    chain.doFilter(request, response); // doFilter를 기준으로 위 아래로 서블릿 호출 전후가 나뉜다.
    HttpSession session = ((HttpServletRequest) request).getSession(false);
    if (session != null) {
        Cookie sessionCookie = new Cookie("JSESSIONID", session.getId());
        sessionCookie.setHttpOnly(true);
        sessionCookie.setPath("/");
        sessionCookie.setSecure(true);
        ((HttpServletResponse) response).addCookie(sessionCookie);
    }
};

사실 각각의 쿠키 설정은 SessionCookieConfig을 따라야 하지만, Mock 환경에서는 이를 설정하고 있지 않으니 직접 HttpOnly, Path, Secure 설정을 추가해 주었다. doFilter 위에 세션 설정을 적게 되면 아직 서블릿에 도달하지 않았으니 세션이 존재하지 않으므로, 모든 것이 처리된 뒤에 세션을 가져오도록 doFilter 뒤에 로직을 적었다.

실제로 MockMvc에서 필터를 거치게 하려면 등록해야 한다. Mvc를 빌드하기 전에 필터를 추가할 수 있으며, 아래와 같이 필터를 추가하면 된다. 하지만 이 경우는 세션이 생성될 때만이 아니라 세션이 존재할 때에도 Set-Cookie가 전송됨에 유의해야 한다.

MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MyController())
        .addFilter(sessionCookieFilter)
        .build();

집나간 쿠키 찾아오기 🍪

🌱 왜 기본적으로 지원하지 않을까?

톰캣이 해주니까 스프링도 지원해주었으면 좋겠다 싶지만, 사실 톰캣이 해 주는 것이지, Servlet에 명세돼 있는 항목이 아니다. 톰캣의 Request 클래스는 context 정보부터 시작해서 Response에 대한 정보까지 많은 것을 아우르고 있다. 몸집이 거대하며 테스트하는 데에 사용하기에는 쉽지 않다.

앞에서도 적어두었지만, MockHttpServletRequest는 테스트를 용이하게 하기 위한 하나의 Fixture에 불과하다. 간단한 요청에 어떻게 응답하는지가 궁금한데 내부적으로 context, response 설정을 다 하고 있으면 이것이 과연 Fixture인가? 라는 생각도 든다.

하지만 마음 한구석으로는 이런 Filter를 기본적으로 MockMvc에 달아두면 어떨까? 라는 생각을 했다. 톰캣과 다른 동작을 매번 생각하니 조금 귀찮다.. 🤨

Categories