0%

Backend

파일은 잘 받아지는데 왜 Access Denied가 뜰까?

전*림··수정됨 2026.09.03

파일은 잘 받아지는데 왜 Access Denied가 뜰까?

여러 파일을 하나의 ZIP으로 묶어 내려주는 “일괄 다운로드”를 만들었다. 로컬에서 잘 됐고, 배포하고도 파일은 멀쩡히 받아졌다. 그런데 로그를 보다가 멈칫했다.

Access Denied

분명 로그인한 사용자가, 분명 파일을 잘 받았는데, 서버 로그에는 인가 실패가 찍혀 있었다. 게다가 요청이 이상하게 늦게 끝났다. 다른 API는 다 멀쩡한데 유독 이 다운로드 하나만 그랬다.

범인은 딱 하나 남은 변수였다. StreamingResponseBody.


스트림을 흘려보내고 싶었을 뿐인데

파일은 오브젝트 스토리지(MinIO)에 있었다. 여러 개를 한꺼번에 메모리에 올리면 부담이 크니, 스트림을 하나씩 열어 ZIP으로 흘려보내고 싶었다. Spring MVC에서 “본문을 스트리밍으로 직접 쓰겠다”는 표준 도구가 StreamingResponseBody라, 자연스럽게 이렇게 짰다.

@Override
public ResponseEntity<StreamingResponseBody> bulkDownload(BulkDownloadRequest request) {
    Long actorUserId = currentActorPort.currentUserId();
    BulkDownloadResult result = fileService.bulkDownload(actorUserId, request.toCommand());

    BulkDownloadView view = result.view();
    ContentDisposition disposition = ContentDisposition.attachment()
        .filename(view.zipFileName(), UTF_8)
        .build();

    return ResponseEntity.ok()
        .contentType(new MediaType("application", "zip"))
        .header(CONTENT_DISPOSITION, disposition.toString())
        .body(output -> writeZip(output, view.entries()));   // StreamingResponseBody
}

writeZip은 파일 InputStream을 하나씩 열어 ZipOutputStream에 흘려보낸다. 전체 바이트를 메모리에 안 올리니 깔끔해 보였다. 실제로 테스트도 통과했다.

문제는, 이 한 줄이 요청을 조용히 비동기(async) 로 바꿔버린다는 점이었다.


async가 인증 필터를 건너뛴다

핵심은 이 문장 하나다.

StreamingResponseBody를 반환하면 Spring MVC는 요청을 비동기(async)로 전환한다.

컨트롤러가 리턴해도 요청이 그 자리에서 안 끝난다. 별도 스레드에서 스트리밍을 처리하고, 스트리밍이 끝나면 서블릿 컨테이너가 그 요청을 ASYNC 디스패치 타입으로 한 번 더 필터 체인에 태운다.

여기서 세션 인증 필터가 발목을 잡았다. 우리 인증 필터는 전형적인 OncePerRequestFilter였다.

public class UserSessionAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        if (!hasAuthenticatedPrincipal()) {
            authenticateFromCookie(request);   // 쿠키에서 세션 꺼내 SecurityContext 채움
        }
        filterChain.doFilter(request, response);
    }
}

OncePerRequestFilter에는 이런 메서드가 숨어 있다.

protected boolean shouldNotFilterAsyncDispatch() {
    return true;   // 기본값: async 재디스패치에서는 이 필터를 실행하지 않는다
}

기본값이 true다. async 재디스패치 구간에서는 이 필터가 통째로 스킵된다. 원래 의도는 “이미 처음 디스패치에서 인증을 끝냈으니 또 할 필요 없다”는 거지만—

문제는 SecurityContext스레드 로컬(ThreadLocal) 기반이라는 점이다. async 처리는 다른 스레드에서 돌고, 재디스패치 구간에서 인증 필터가 스킵되면 그 스레드의 SecurityContext는 비어 있다.

그 상태로 뒤따르는 AuthorizationFilter가 실행되면

인증 정보 없음 → Access Denied

그림으로 정리하면 이렇다.

[최초 디스패치] REQUEST
  UserSessionAuthenticationFilter  ✅ 쿠키 → SecurityContext 채움
  AuthorizationFilter              ✅ 통과
  Controller → StreamingResponseBody 반환 → 요청을 async로 전환
      ↓ (다른 스레드에서 ZIP 스트리밍)

[스트리밍 종료 후] ASYNC 재디스패치
  UserSessionAuthenticationFilter  ⏭️ shouldNotFilterAsyncDispatch=true → 스킵
  AuthorizationFilter              ❌ SecurityContext 비어있음 → Access Denied

파일이 잘 받아진 건 스트리밍 자체는 성공했기 때문이고, 서버가 화나 있던 건 그 뒤에 몰래 도는 재디스패치 때문이었다. 요청 지연도 이 async 사이클이 요청 수명을 늘린 결과였다.


진짜 질문: 이게 async여야 할 이유가 있나?

해결책은 두 갈래로 보였다.

선택 A. async를 유지하고, 필터를 async에서도 돌게 만든다.

  • shouldNotFilterAsyncDispatch()를 오버라이드

  • Spring Security의 async SecurityContext 전파 설정

선택 B. async 자체를 없앤다.

  • StreamingResponseBody를 버리고

  • HttpServletResponse에 동기로 직접 쓴다.

곰곰이 따져보니, 파일 다운로드는 SSE나 실시간 로그 tailing처럼 커넥션을 오래 붙잡는 워크로드가 아니었다.

그냥 바이트를 순서대로 흘려보내면 되는 일이고, 동기로 써도 전체를 버퍼링하지 않는다는 스트리밍의 본질은 그대로 유지된다.

async는 여기서 얻는 것 없이 인증 복잡도만 끌고 온 셈이었다.

그래서 B를 택했다.

단일 파일 다운로드가 이미 HttpServletResponse 동기 방식이라 일관성도 맞았다.


해법 — 동기로 직접 쓰기

시그니처부터 바뀐다.

ResponseEntity<StreamingResponseBody> 대신 HttpServletResponse를 받아 void로 처리한다.

@Override
public void bulkDownload(
    BulkDownloadRequest request,
    HttpServletResponse response
) throws IOException {

    Long actorUserId = currentActorPort.currentUserId();
    BulkDownloadResult result = fileService.bulkDownload(actorUserId, request.toCommand());

    // 일부라도 불가 파일이 있으면 ZIP을 만들지 않고 409(JSON) 응답
    if (result.hasFailures()) {
        BulkDownloadFailureResponse failure =
            BulkDownloadFailureResponse.from(result.failedFiles());

        ApiResponse<BulkDownloadFailureResponse> body =
            new ApiResponse<>(
                false,
                BULK_DOWNLOAD_UNAVAILABLE.getCode(),
                BULK_DOWNLOAD_UNAVAILABLE.getMessage(),
                null,
                failure,
                null
            );

        response.setStatus(CONFLICT.value());
        response.setContentType(APPLICATION_JSON_VALUE);
        objectMapper.writeValue(response.getOutputStream(), body);
        return;
    }

    BulkDownloadView view = result.view();

    ContentDisposition disposition =
        ContentDisposition.attachment()
            .filename(view.zipFileName(), UTF_8)
            .build();

    response.setContentType("application/zip");
    response.setHeader(CONTENT_DISPOSITION, disposition.toString());

    writeZip(response.getOutputStream(), view.entries());
}

writeZip을 호출하는 순간부터 끝까지 요청은 하나의 스레드에서 동기로 처리된다.

즉,

  • async 재디스패치가 발생하지 않고

  • 인증 필터가 다시 스킵될 일도 없으며

  • SecurityContext가 그대로 유지된다.

그 결과 Access Denied 로그와 요청 지연이 함께 사라졌다.


그래서, 교훈

오해를 막자면 StreamingResponseBody가 나쁜 게 아니다.

세션(쿠키) 기반 인증 + async 스레드 전환이라는 조합이 지뢰였다.

  • 진짜로 커넥션을 오래 유지해야 하는 워크로드(SSE, 실시간 로그)라면 async가 맞다.

  • 하지만 파일을 스트리밍으로 내려주는 것이 목적이라면 동기 HttpServletResponse만으로도 충분하다.

스트리밍이 필요하다고 해서 반드시 async가 필요한 건 아니다. 특히 세션 인증 환경에서 StreamingResponseBody는 인증 필터와 조용히 충돌한다.


부록 — async를 걷어내고 나서 얻은 것들

async를 없애고 writeZip을 직접 제어하게 되니, 오히려 다운로드 경험을 다듬을 여지가 생겼다.

1. 병렬 prefetch로 스토리지 지연 겹치기

파일마다 스토리지 왕복이 직렬로 쌓이면 느리다.

그래서 앞으로 몇 개를 미리 병렬로 열어둔다.

전체 바이트를 버퍼링하지 않고 열린 스트림만 보유하므로 메모리는 열린 스트림 수(≤4)에만 비례한다.

private static final int BULK_DOWNLOAD_PREFETCH = 4;

// ...
zip.setLevel(NO_COMPRESSION);

2. ZIP 압축 생략 (

NO_COMPRESSION)

내려주는 파일이 대부분 이미 압축된 미디어(mp3, m4a, jpg 등)라 DEFLATE를 걸어봤자 크기는 거의 안 줄고 CPU만 사용한다.

zip.setLevel(NO_COMPRESSION);

즉, “압축”이 아니라 “묶기만” 한다.

3. 정확한 Content-Length로 다운로드 진행률 살리기

동기 스트리밍만으로는 브라우저가 전체 크기를 몰라 다운로드 퍼센티지를 표시하지 못한다.

그래서 ZIP을 임시 파일에 먼저 만든 뒤 정확한 크기를 헤더에 넣는다.

response.setContentLengthLong(Files.size(spool));

메모리를 버퍼링하는 대신 디스크를 스풀 공간으로 사용하는 트레이드오프다.

4. 마지막 반전 — “늦게 뜬다”는 사실 프론트 문제였다

최적화 후에도 다운로드 창이 늦게 뜬다는 피드백이 남았다.

원인은 서버가 아니라 프론트였다.

프론트가 응답을 blob으로 끝까지 받은 뒤에야 저장을 시작하고 있었기 때문이다.

서버는 첫 바이트를 바로 보내도, 프론트가 전부 버퍼링하면 사용자 입장에서는 다운로드가 늦게 시작되는 것처럼 보인다.

결국 서버의 스트리밍 방식과 프론트의 blob 처리 방식을 함께 봐야 다운로드 경험이 완성된다.


한 줄 요약

스트리밍이 필요하다고 async가 필요한 건 아니다. 세션 인증 환경에서 StreamingResponseBody는 인증 필터와 조용히 싸운다.

읽기 도구

12분 읽기

이 글이 도움이 되었나요?

Work with GIWorks

프로젝트에 GIWorks의 경험이 필요하신가요?

전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.

문의하기

다음으로 읽기