BLOG기술
로그인한 사용자가 응답 직전 '익명'이 됐다

DeferredResult 401, 범인은 ThreadLocal이 아니었다
안녕하세요, 디라이브 개발팀 정종길입니다.
인증 필터는 요청이 들어올 때 한 번 통과하면 그 요청이 끝날 때까지 인증된 상태가 이어진다고 생각하기 쉽습니다. 저도 그렇게 생각했는데, JWT로 인증을 통과한 롱 폴링 요청이 정작 결과를 내려보낼 차례에 401로 돌아온 적이 있습니다. 그때는 검색으로 찾은 방법으로 문제를 해결하고 원인을 코드 주석에 적어 두었는데, 이 글을 쓰려고 다시 들여다보니 그 주석에 적은 원인은 절반만 맞는 설명이었습니다.
그래서 무엇이 401을 냈는지, 그때 쓴 방법이 왜 통했는지, 그리고 주석의 어느 부분이 틀렸는지를 적어 봤는데요. Spring Boot 3.5(Spring Security 6.5)에서 세션 없이 JWT로 인증하는 서버에 DeferredResult 기반의 비동기 API를 붙였을 때 어떤 일이 생기는지에 대한 이야기입니다.
지난 글 당신의 로깅 필터는 안녕하신가요에서는 같은 롱 폴링 API가 로깅 필터 때문에 빈 응답을 내려보냈던 일을 다뤘는데요, 이번 글은 그보다 앞서 이 API를 처음 만들 때 Spring Security에서 겪은 일입니다. 사실 두 문제는 원인이 같은 곳에 있었는데, 그 이야기는 글 끝에서 다시 하겠습니다.
기다리던 응답이 401로 돌아왔습니다
운전자 앱이 운전점수를 롱 폴링으로 받아 가는 서버를 처음 만들던 때였습니다. 서버는 세션을 쓰지 않는 STATELESS 설정이었고, OncePerRequestFilter를 상속한 JWT 필터가 Authorization 헤더의 토큰을 검증한 뒤 SecurityContextHolder에 인증 정보를 넣어 주는 흔한 구조였습니다. 운전자용 경로는 hasRole()로 운전자 권한이 있어야만 통과하게 막아 두었습니다.
요청은 JWT 필터를 무사히 통과해서 컨트롤러까지 들어갔고, 컨트롤러는 DeferredResult를 반환한 뒤 새 점수가 들어오기를 기다렸습니다. 그런데 점수가 들어와 setResult()가 호출되고 결과를 내려보낼 차례가 되자, 클라이언트에는 결과 대신 401이 돌아왔습니다. 들어올 때는 인증을 통과한 요청이 나갈 때 인증이 없다고 거절당한 것입니다. 보안 설정에서 모든 경로를 잠깐 permitAll로 열어 보면 결과가 정상으로 나갔기 때문에, 인가 단계에서 막히고 있다는 것까지는 알 수 있었습니다.
검색 끝에 찾은 방법은 컨트롤러에서 SecurityContext를 직접 만들어 RequestAttributeSecurityContextRepository에 저장해 두는 것이었고, 요점만 남기면 이런 모양이었습니다.
@GetMapping("/drivers/driving-score")
public DeferredResult<Score> score(@AuthenticationPrincipal UserPrincipal principal,
HttpServletRequest request, HttpServletResponse response) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities()));
new RequestAttributeSecurityContextRepository().saveContext(context, request, response);
var result = new DeferredResult<Score>(30_000L);
waiters.add(result);
return result;
}이렇게 바꾸자 401은 사라졌습니다. 저는 그 위에 원인을 주석으로 남겼는데, Spring Security는 인증 정보를 ThreadLocal에 저장하고 DeferredResult의 응답은 별도 스레드에서 처리되니 인증 정보가 그 스레드로 전파되지 않아서 401이 난다는 내용이었습니다.
주석에 적은 원인은 절반만 맞았습니다
스레드가 바뀌는 것은 사실입니다. 다만 401의 직접적인 원인은 스레드가 아니라, 비동기 요청이 필터 체인을 두 번 지난다는 데 있었습니다. 컨트롤러가 DeferredResult를 반환하면 요청은 비동기 모드로 전환되고 첫 번째 패스인 REQUEST 디스패치는 그대로 끝납니다. 나중에 setResult()가 호출되면 컨테이너가 같은 요청을 ASYNC 디스패치로 다시 보냅니다. Spring Boot는 보안 필터 체인을 모든 디스패치 타입에 등록하기 때문에 이때 Spring Security의 필터 체인도 다시 실행되고, 체인 안의 각 필터는 디스패치 타입을 보고 실행할지를 정합니다.
Spring Security 6에서 SecurityContextHolderFilter는 이렇게 다시 들어온 디스패치에서도 SecurityContextRepository에서 인증 정보를 불러와 SecurityContextHolder에 넣고, 디스패치가 끝나면 비웁니다. 그리고 6.0부터는 SecurityContextHolder에 넣은 인증 정보를 저장소에 자동으로 저장하지 않아서, 저장하려면 saveContext()를 직접 호출해야 합니다. 그런데 제 JWT 필터는 SecurityContextHolder에만 인증 정보를 넣고 저장소에는 아무것도 저장하지 않았으니, ASYNC 디스패치에서 SecurityContextHolderFilter가 불러올 수 있는 인증 정보는 없었습니다.
JWT 필터가 다시 돌면서 토큰을 검증해 줬다면 문제가 없었겠지만, OncePerRequestFilter는 기본적으로 ASYNC 디스패치를 건너뜁니다. 반면 6.0에서 인가를 맡는 필터가 FilterSecurityInterceptor에서 AuthorizationFilter로 바뀌면서 인가는 REQUEST뿐 아니라 모든 디스패치에서 하는 것이 기본값이 됐고, 그래서 ASYNC 디스패치에서도 hasRole() 검사를 다시 했습니다. 5.x에서는 인가가 요청당 한 번만 돌았으니 이 문제가 드러나지 않았을 것입니다. 인증 정보가 없으니 요청은 익명 사용자로 취급됐고, 권한 검사에서 거절된 익명 사용자는 AuthenticationEntryPoint로 넘어가 401을 받았습니다. 같은 스레드에서 ASYNC 디스패치가 일어났더라도 SecurityContextHolder는 이미 비워진 뒤였을 테니, 결과는 같았을 것입니다.
그때 쓴 방법이 통한 이유도 여기에 있습니다. STATELESS로 설정하면 Spring Security가 기본으로 쓰는 저장소가 RequestAttributeSecurityContextRepository입니다. 이름 그대로 인증 정보를 request attribute에 담아 두는 저장소이고, Javadoc에도 디스패치 타입이 바뀌어도 복원할 수 있게 하려는 용도라고 적혀 있습니다. 컨트롤러에서 이 저장소에 saveContext()를 해 두면 ASYNC 디스패치의 SecurityContextHolderFilter가 같은 요청의 attribute에서 인증 정보를 다시 불러오기 때문에, AuthorizationFilter도 운전자 권한을 확인하고 요청을 통과시킵니다. 스레드 사이에 인증 정보를 전파한 것이 아니라, 두 번째 디스패치가 읽을 수 있는 자리에 저장해 둔 것입니다.
ThreadLocal 전파라는 설명이 딱 맞는 경우는 따로 있습니다. 컨트롤러가 Callable을 반환하면 Spring MVC가 별도 스레드에서 그 Callable을 실행하는데, Spring Security는 WebAsyncManagerIntegrationFilter로 그 스레드에 SecurityContext를 넘겨줍니다. 다만 Callable도 실행이 끝나면 똑같이 ASYNC 디스패치를 거치기 때문에, 저장소에 저장해 두지 않으면 같은 401이 납니다. DeferredResult에는 이런 전파가 자동으로 붙지 않는데, 제 코드는 결과를 세팅하는 스레드에서 인증 정보를 쓰지 않아서 전파가 필요하지도 않았습니다. 문제가 생기는 곳은 어느 쪽이든 그 뒤의 ASYNC 디스패치입니다. 두 경우를 섞어서 이해하면 저처럼 원인을 스레드로 적게 됩니다.
고칠 수 있는 자리는 여러 곳입니다
원인을 알고 나면 해법은 컨트롤러 말고도 여러 곳에 둘 수 있습니다. 하나는 JWT 필터에서 인증에 성공했을 때 SecurityContextHolder에 넣는 데서 그치지 않고 저장소에도 saveContext()를 해 두는 방법입니다. 이렇게 하면 엔드포인트마다 코드를 넣지 않아도 비동기로 처리되는 모든 API가 ASYNC 디스패치에서 인증 정보를 되찾습니다.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response); // ASYNC 디스패치에서 다시 불러올 수 있게다른 방법은 JWT 필터의 shouldNotFilterAsyncDispatch()를 false로 바꿔 ASYNC 디스패치에서도 토큰을 다시 검증하게 하는 것입니다. 헤더는 같은 요청에 그대로 남아 있으니 동작은 하지만, 비동기 요청은 토큰을 두 번 검증하게 되고, 롱 폴링으로 기다리는 사이에 토큰이 만료되면 결과를 내려보낼 때 401이 날 수 있습니다. 인가 설정에서 dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()로 ASYNC 디스패치를 열어 두는 방법도 있습니다. 인가는 REQUEST 디스패치에서 이미 한 번 했으니 이 방법도 통하지만, 규칙은 먼저 맞는 것이 적용되므로 다른 requestMatchers보다 앞에 둬야 하고, ASYNC 디스패치 안에서는 사용자가 여전히 익명으로 보인다는 점도 그대로 남습니다.
저는 당시 이 롱 폴링 엔드포인트에만 적용하려고 컨트롤러에 넣는 방법을 골랐고, 지금도 그 코드가 그대로 돌고 있습니다. 비동기 엔드포인트가 하나뿐이라 문제는 없었지만, 다시 고른다면 JWT 필터에서 저장하는 쪽을 택할 것 같습니다.
마치며
이 일은 지난 글에서 다룬 로깅 필터 사고보다 석 달쯤 먼저 있었습니다. 지난 글의 교훈은 비동기 요청이 필터 체인을 두 번 지난다는 것이었는데, 돌아보면 이 401이 이미 같은 이야기를 하고 있었습니다. 그때 원인을 스레드가 아니라 두 번째 디스패치로 정확히 이해했다면, 로깅 필터를 짤 때도 ASYNC 디스패치를 먼저 떠올렸을 것입니다.
그래서 저는 Spring Security를 쓰는 서버에 DeferredResult나 Callable 같은 비동기 엔드포인트가 있다면, 인증 정보가 어디에 저장되고 두 번째 디스패치에서 어디서 다시 불러와지는지 한 번 따라가 보셨으면 합니다. 그리고 검색으로 찾은 해법이 통했을 때도, 왜 통했는지를 한 번 더 확인해 보시면 좋겠습니다. 통하는 코드와 맞는 설명은 다른 문제였습니다.
비슷한 경험이 있거나 다른 방식으로 푸신 분이 있다면 이야기 나눠 보고 싶습니다. 감사합니다.