[๐Ÿ”งTroubleshooting] Spring โ†’ FastAPI ํ”„๋ก์‹œ์—์„œ JSESSIONID ์ „๋‹ฌ ๋ˆ„๋ฝ ๋ฒ„๊ทธ

Spring Boot๊ฐ€ FastAPI ํ”„๋ก์‹œ ์—ญํ• ์„ ํ•  ๋•Œ JSESSIONID๋ฅผ ํ•˜์œ„ ์„œ๋น„์Šค๋กœ ์ „๋‹ฌํ•˜์ง€ ์•Š์•„ ๋ฐœ์ƒํ•œ 401 ๋ฒ„๊ทธ โ€” ์›์ธ ๋ถ„์„๋ถ€ํ„ฐ MockMvc ํ…Œ์ŠคํŠธ ํ•จ์ •๊นŒ์ง€ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

[๐Ÿ”งTroubleshooting] Spring โ†’ FastAPI ํ”„๋ก์‹œ์—์„œ JSESSIONID ์ „๋‹ฌ ๋ˆ„๋ฝ ๋ฒ„๊ทธ

Spring Boot๊ฐ€ FastAPI ์•ž๋‹จ ํ”„๋ก์‹œ ์—ญํ• ์„ ํ•˜๋Š” ๊ตฌ์กฐ์—์„œ, FastAPI๊ฐ€ Spring์„ ์—ญํ˜ธ์ถœํ•  ๋•Œ 401์ด ํ„ฐ์ง€๋Š” ๋ฒ„๊ทธ๋ฅผ ๋งˆ์ฃผ์ณค๋‹ค. ์›์ธ์€ ๋‹จ์ˆœํ–ˆ์ง€๋งŒ โ€” JSESSIONID๋ฅผ FastAPI๋กœ ์ „๋‹ฌํ•˜์ง€ ์•Š์€ ๊ฒƒ โ€” MockMvc์—์„œ ์žฌํ˜„ํ•˜๋Š” ๊ณผ์ •์—์„œ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ํ•จ์ •์ด ์žˆ์—ˆ๋‹ค. ๋ถ„์„๋ถ€ํ„ฐ ํ…Œ์ŠคํŠธ ํ”ฝ์Šค๊นŒ์ง€ ๊ธฐ๋กํ•œ๋‹ค.


What this post covers

  • ๋ฌธ์ œ ์ƒํ™ฉ ๋ฐ ์š”์ฒญ ํ๋ฆ„ ์ •๋ฆฌ
  • JSESSIONID ๋ฏธ์ „๋‹ฌ์˜ ๊ทผ๋ณธ ์›์ธ
  • HttpServletRequest๋กœ ์ฟ ํ‚ค ํฌ์›Œ๋”ฉ ๊ตฌํ˜„
  • MockMvc์—์„œ getRequestedSessionId()๊ฐ€ null์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ด์œ ์™€ ํ•ด๊ฒฐ๋ฒ•

Problem

Spring Boot๊ฐ€ FastAPI ์•ž๋‹จ ํ”„๋ก์‹œ ์—ญํ• ์„ ํ•˜๋Š” ๊ตฌ์กฐ์—์„œ, ์ฑ—๋ด‡ ๋ฉ”์‹œ์ง€ ์ „์†ก API(POST /sessions/{id}/messages)๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด FastAPI๊ฐ€ Spring์˜ onboarding ์—”๋“œํฌ์ธํŠธ๋ฅผ ์—ญ์œผ๋กœ ํ˜ธ์ถœํ•  ๋•Œ 401 ์ด ๋ฐ˜ํ™˜๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

ํ๋ฆ„์„ ์ •๋ฆฌํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

1
2
3
4
5
6
7
8
9
10
11
12
๋ธŒ๋ผ์šฐ์ € โ”€โ”€JSESSIONIDโ”€โ”€โ–บ Spring :8080
                          โ”‚
                          โ”‚ (JSESSIONID ๋ฏธ์ „๋‹ฌ)
                          โ–ผ
                       FastAPI :8000
                          โ”‚
              onboarding tool ํ˜ธ์ถœ ์‹œ
                          โ”‚ JSESSIONID=""
                          โ–ผ
                       Spring /onboarding
                          โ”‚
                          โ””โ”€โ–บ 401 Unauthorized (์„ธ์…˜ ์—†์Œ)

FastAPI์˜ send_message ์—”๋“œํฌ์ธํŠธ๋Š” JSESSIONID ์ฟ ํ‚ค๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์•„ onboarding tool์˜ context์— ์ „๋‹ฌํ•œ๋‹ค.

1
2
# router.py
jsessionid: str | None = Cookie(None, alias="JSESSIONID")

onboarding tool์€ ์ด ๊ฐ’์„ Spring ์—ญํ˜ธ์ถœ ์‹œ ์ฟ ํ‚ค๋กœ ์‚ฌ์šฉํ•œ๋‹ค.

1
2
# tools/onboarding.py
cookies={"JSESSIONID": context.get("jsessionid", "")}

๋ฌธ์ œ๋Š” Spring ChatProxyController๊ฐ€ ๋ธŒ๋ผ์šฐ์ € ์š”์ฒญ์˜ JSESSIONID๋ฅผ FastAPI๋กœ ์ „๋‹ฌํ•˜์ง€ ์•Š์•˜๋‹ค ๋Š” ๊ฒƒ์ด๋‹ค. FastAPI๊ฐ€ ๋ฐ›๋Š” jsessionid๋Š” ํ•ญ์ƒ None์ด์—ˆ๊ณ , context.get("jsessionid", "") ๋Š” ๋นˆ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ–ˆ๋‹ค.

Root Cause

ChatProxyController์˜ sendMessage, streamMessage๋Š” FastAPI๋กœ ๋ณด๋‚ด๋Š” ํ—ค๋”๋ฅผ ์ง์ ‘ ๊ตฌ์„ฑํ•œ๋‹ค. ๋‹น์‹œ ์ฝ”๋“œ๋Š” X-Guest-Id์™€ X-Internal-Key๋งŒ ์„ค์ •ํ–ˆ๊ณ , ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ณด๋‚ธ JSESSIONID๋ฅผ Cookie ํ—ค๋”๋กœ ํฌ์›Œ๋”ฉํ•˜๋Š” ๋กœ์ง์ด ์—†์—ˆ๋‹ค.

1
2
3
4
5
6
7
// ์ˆ˜์ • ์ „: JSESSIONID ์—†์Œ
private HttpHeaders buildGuestHeaders(String guestId) {
    HttpHeaders headers = new HttpHeaders();
    headers.set("X-Guest-Id", guestId);
    headers.set("X-Internal-Key", internalApiKey);
    return headers;
}

Spring์€ ์ž์ฒด HttpSession์—์„œ guestId๋ฅผ ๊บผ๋‚ด๋Š” ๋ฐฉ์‹์ด๋ผ JSESSIONID๋ฅผ ์ง์ ‘ ๋‹ค๋ฃจ์ง€ ์•Š์•„๋„ ๋๋‹ค. ํ•˜์ง€๋งŒ FastAPI๊ฐ€ ๊ทธ ๊ฐ’์„ ๋‹ค์‹œ Spring์œผ๋กœ ์ „๋‹ฌํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ ์„ ๊ณ ๋ คํ•˜์ง€ ๋ชปํ–ˆ๋‹ค.

Solution

HttpServletRequest๋ฅผ ์ปจํŠธ๋กค๋Ÿฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ฃผ์ž…๋ฐ›์•„, getRequestedSessionId() ๋กœ ๋ธŒ๋ผ์šฐ์ € ์„ธ์…˜ ID๋ฅผ ์ถ”์ถœํ•œ ๋’ค FastAPI ์š”์ฒญ์˜ Cookie ํ—ค๋”์— ์ถ”๊ฐ€ํ–ˆ๋‹ค.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ChatProxyController.java
@PostMapping("/sessions/{sessionId}/messages")
public ResponseEntity<String> sendMessage(
        @GuestId String guestId,
        @PathVariable Long sessionId,
        @RequestBody Map<String, Object> body,
        HttpServletRequest servletRequest) {          // ์ถ”๊ฐ€

    HttpHeaders headers = buildGuestHeaders(guestId);
    headers.setContentType(MediaType.APPLICATION_JSON);
    forwardSessionCookie(servletRequest, headers);   // ์ถ”๊ฐ€
    ...
}

private void forwardSessionCookie(HttpServletRequest servletRequest, HttpHeaders headers) {
    String jsessionid = servletRequest.getRequestedSessionId();
    if (jsessionid != null) {
        headers.set(HttpHeaders.COOKIE, "JSESSIONID=" + jsessionid);
    }
}

SSE ์ŠคํŠธ๋ฆผ ์—”๋“œํฌ์ธํŠธ(streamMessage)๋Š” RequestCallback ๋žŒ๋‹ค ๋‚ด๋ถ€์—์„œ ํ—ค๋”๋ฅผ ์„ค์ •ํ•˜๋ฏ€๋กœ, ๋žŒ๋‹ค ์บก์ฒ˜๋ฅผ ์œ„ํ•ด ๋กœ์ปฌ ๋ณ€์ˆ˜๋กœ ๋จผ์ € ์ถ”์ถœ ํ•ด์•ผ ํ•œ๋‹ค.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PostMapping(value = "/sessions/{sessionId}/messages/stream", ...)
public ResponseEntity<StreamingResponseBody> streamMessage(...,
        HttpServletRequest servletRequest) {

    String jsessionid = servletRequest.getRequestedSessionId(); // ๋žŒ๋‹ค ๋ฐ–์—์„œ ์บก์ฒ˜

    StreamingResponseBody stream = outputStream -> {
        chatRestTemplate.execute(url, HttpMethod.POST,
            request -> {
                ...
                if (jsessionid != null) {
                    request.getHeaders().set(HttpHeaders.COOKIE, "JSESSIONID=" + jsessionid);
                }
            }, ...);
    };
}

Testing Gotcha: MockMvc์—์„œ getRequestedSessionId() ๋Š” null

ํ…Œ์ŠคํŠธ์—์„œ .session(authSession)์œผ๋กœ ์„ธ์…˜์„ ์ฃผ์ž…ํ•˜๋ฉด getRequestedSessionId()๊ฐ€ null์„ ๋ฐ˜ํ™˜ ํ•œ๋‹ค.

MockHttpServletRequest.getRequestedSessionId()๋Š” requestedSessionId ํ•„๋“œ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•˜๋Š”๋ฐ, MockMvc๊ฐ€ .session()์„ ์ฒ˜๋ฆฌํ•  ๋•Œ ์ด ํ•„๋“œ๋ฅผ ์ž๋™์œผ๋กœ ์ฑ„์šฐ์ง€ ์•Š๋Š”๋‹ค. ์‹ค์ œ ์„œ๋ธ”๋ฆฟ ํ™˜๊ฒฝ์—์„œ๋Š” JSESSIONID ์ฟ ํ‚ค๋ฅผ ํŒŒ์‹ฑํ•ด ์ด ๊ฐ’์„ ์„ค์ •ํ•˜์ง€๋งŒ, MockMvc๋Š” ๊ทธ ๊ณผ์ •์„ ์ƒ๋žตํ•œ๋‹ค.

ํ•ด๊ฒฐ: MockHttpSession์„ ๊ณ ์ • ID๋กœ ์ƒ์„ฑํ•˜๊ณ , RequestPostProcessor๋กœ requestedSessionId๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์„ค์ • ํ•œ๋‹ค.

1
2
3
4
5
// ํ…Œ์ŠคํŠธ setUp
private static final String SESSION_ID = "test-jsessionid-001";

authSession = new MockHttpSession(null, SESSION_ID); // ๊ณ ์ • ID ์ง€์ •
authSession.setAttribute("GUEST_ID", GUEST_ID);
1
2
3
4
5
6
// ํ…Œ์ŠคํŠธ ์š”์ฒญ
mockMvc.perform(post("/api/v1/chat/sessions/1/messages")
        .session(authSession)
        .with(req -> { req.setRequestedSessionId(SESSION_ID); return req; }) // ๋ช…์‹œ์  ์„ค์ •
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"message\":\"์•ˆ๋…•\"}"))

๊ทธ๋Ÿฐ ๋‹ค์Œ ํ—ค๋” ์บก์ฒ˜ ํ›„ ๊ฒ€์ฆ:

1
assertThat(headers.getFirst(HttpHeaders.COOKIE)).contains("JSESSIONID=" + SESSION_ID);

Takeaway

  • Spring์ด ์ง์ ‘ ์„ธ์…˜ ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•˜๋”๋ผ๋„, ํ•˜์œ„ ์„œ๋น„์Šค๊ฐ€ ๊ทธ ์„ธ์…˜์„ ์—ญ์œผ๋กœ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ ๋Š” ์ฟ ํ‚ค ํฌ์›Œ๋”ฉ์„ ๋ช…์‹œ์ ์œผ๋กœ ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.
  • MockMvc๋Š” ์‹ค์ œ ์„œ๋ธ”๋ฆฟ ์ปจํ…Œ์ด๋„ˆ์˜ ์ฟ ํ‚ค ํŒŒ์‹ฑ ๊ณผ์ •์„ ์ƒ๋žตํ•œ๋‹ค. getRequestedSessionId()์ฒ˜๋Ÿผ ์ฟ ํ‚ค์—์„œ ํŒŒ์ƒ๋˜๋Š” ๊ฐ’์€ RequestPostProcessor๋กœ ์ˆ˜๋™ ์„ค์ •ํ•ด์•ผ ํ…Œ์ŠคํŠธ๊ฐ€ ๋™์ž‘ํ•œ๋‹ค.