Spring/Spring MVC

[Spring MVC] 2. Filters

noahkim_ 2025. 4. 11. 13:43

1. Form Data

  • Servlet API는 POST 요청에 대해서만 request.getParamaeter*() 사용 가능

 

FormContentFilter

  • PUT, PATCH, DELETE 요청은 서블릿 스펙상으로는 바디 패싱을 하지 않음
  • PUT, PATCH, DELETE 요청에서도 body를 파싱하여 getParamaeter*()로 접근 가능하게 함
    • body를 application/x-www-form-urlencoded로 파싱함
    • getParamaeter*()로 인해 FormData를 @RequestParam, @ModelAttribute에서 바인딩할 수 있음

 

예제) FormContentFilter 등록

더보기
더보기
더보기
public class MyWebInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext();
        ctx.register(DatabaseConfig.class);
        ctx.register(WebConfig.class);

        DispatcherServlet dispatcherServlet = new DispatcherServlet(ctx);
        ServletRegistration.Dynamic registration = servletContext.addServlet("app", dispatcherServlet);
        registration.setLoadOnStartup(1);
        registration.addMapping("/");

        FilterRegistration.Dynamic formContentFilter = servletContext.addFilter("formContentFilter", new FormContentFilter());
        formContentFilter.addMappingForUrlPatterns(null, false, "/*");
    }
}

 

2. Forwarded Headers

  • 서버 앞에 프록시가 있는 경우, 실제 클라이언트의 정보를 직접 알 수 없음
  • 이럴 경우 Forwarded or X-Forwarded 헤더를 통해 정보를 전달받음

 

Forwarded (표준 헤더)

  • RFC 7239
  • 원 요청의 host, post, schema 정보를 전달함

 

X-Forwarded (비표준 헤더)

Header 설명
X-Forwarded-Host 원래 요청의 host
X-Forwarded-Port 원래 요청의 port
X-Forwarded-Proto
원래 요청의 프로토콜 (http/https)
X-Forwarded-Ssl
원래 요청이 HTTPS였는지 (on/off)
X-Forwarded-Prefix 원래 URL 경로 prefix

 

ForwardedHeaderFilter

  • Forwarded, X-Forwarded-* 헤더를 읽어서 HttpServletRequest의 host, post, schema 값을 수정해주는 필터
  • 옵션 설정에 따라 해당 헤더 제거 가능
구분 내용
보안 설정
removeOnly=true 설정 시, 헤더 제거
주의사항
신뢰 경계에서는 외부 클라이언트가 Forwarded 헤더를 위조할 수 있으므로 제거하는 것이 안전함 (예: 프록시)
DispatcherType
REQUEST, ASYNC, ERROR 타입 모두에 적용해야 함
⇒ 비동기/에러 상황에서도 헤더 정보가 일관되게 적용되기 위함

 

예시) ForwardedHeaderFilter 등록

더보기
더보기
더보기
public class MyWebInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext();
        ctx.register(DatabaseConfig.class);
        ctx.register(WebConfig.class);

        DispatcherServlet dispatcherServlet = new DispatcherServlet(ctx);
        ServletRegistration.Dynamic registration = servletContext.addServlet("app", dispatcherServlet);
        registration.setLoadOnStartup(1);
        registration.addMapping("/");

        FilterRegistration.Dynamic forwardedHeaderFilter = servletContext.addFilter("forwardedHeaderFilter", new ForwardedHeaderFilter());
        forwardedHeaderFilter.addMappingForUrlPatterns(null, false, "/*");
    }
}
@GetMapping("/test")
@ResponseBody
public String test(HttpServletRequest request) {
    String scheme = request.getScheme();
    String remoteAddr = request.getRemoteAddr();
    String host = request.getServerName();

    return String.format("scheme: %s, remoteAddr: %s, host: %s", scheme, remoteAddr, host);
}

 

3. Shallow ETag

구분 내용
기능
- 응답 body 내용을 MD5 해시로 계산해 ETag 생성
- 클라이언트가 If-None-Match 헤더로 같은 ETag를 보내면 → 304 Not Modified 응답 반환
성능 특성
- 클라이언트는 중복 요청 시 응답 body를 생략받을 수 있어 네트워크 절약
- 하지만 서버는 항상 응답을 계산하므로 CPU 사용량은 줄지 않음
옵션 (writeWeakETag)
- writeWeakETag=true 설정 시, 약한 ETag(W/"...") 형식 사용
→ 콘텐츠가 약간 달라도 동일한 것으로 간주 가능 (변경 무시 허용)
DispatcherType 설정
- DispatcherType.ASYNC 포함하여 매핑 필요
→ 비동기 응답에서도 ETag 캐싱이 제대로 작동하게 함

 

예제) ETagFilter 등록

더보기
public class ETagFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        ETagResponseWrapper wrappedResponse = new ETagResponseWrapper(response);

        chain.doFilter(request, wrappedResponse);

        byte[] content = wrappedResponse.getContentAsBytes();
        String etag = generateETag(content);
        String clientETag = request.getHeader("If-None-Match");

        if (etag.equals(clientETag)) {
            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
        } else {
            response.setHeader("ETag", etag);
            response.setContentLength(content.length);
            response.getOutputStream().write(content);
        }
    }

    private String generateETag(byte[] content) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(content);
            return "\"" + Base64.getEncoder().encodeToString(hash) + "\"";
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
public class ETagResponseWrapper extends HttpServletResponseWrapper {
    private ByteArrayOutputStream buffer = new ByteArrayOutputStream();
    private ServletOutputStream out = new WrappedOutputStream(buffer);
    private PrintWriter writer = new PrintWriter(new OutputStreamWriter(buffer));

    public ETagResponseWrapper(HttpServletResponse response) {
        super(response);
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return out;
    }

    @Override
    public PrintWriter getWriter() throws IOException {
        return writer;
    }

    public byte[] getContentAsBytes() {
        try {
            writer.flush();
            out.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return buffer.toByteArray();
    }

    private static class WrappedOutputStream extends ServletOutputStream {
        private OutputStream stream;

        public WrappedOutputStream(OutputStream stream) {
            this.stream = stream;
        }

        @Override
        public void write(int b) throws IOException {
            stream.write(b);
        }

        @Override
        public boolean isReady() {
            return true;
        }

        @Override
        public void setWriteListener(WriteListener writeListener) {
            // no-op
        }
    }
}
public class MyWebInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext();
        ctx.register(DatabaseConfig.class);
        ctx.register(WebConfig.class);

        DispatcherServlet dispatcherServlet = new DispatcherServlet(ctx);
        ServletRegistration.Dynamic registration = servletContext.addServlet("app", dispatcherServlet);
        registration.setLoadOnStartup(1);
        registration.addMapping("/");

        // ...

        FilterRegistration.Dynamic ETagHeaderFilter = servletContext.addFilter("eTagHeaderFilter", new ETagFilter());
        ETagHeaderFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
    }
}

 

예시 흐름

  1. 서버가 응답에 ETag: "abcd1234"를 담아 응답
  2. 클라이언트가 다음 요청 시 If-None-Match: "abcd1234" 헤더 포함
  3. 서버가 body를 계산해 해시 확인 → 동일하면 304 Not Modified 반환 (body 없음)

실습) curl 테스트

더보기

1. 첫번째 요청

curl -i http://localhost:8080/
// 첫번째 요청 응답 헤더

HTTP/1.1 200 
ETag: "6rUuhDrhGxkVNvh9jflwh8AA9a4zg3TcvsYCTJw6fbw="
Content-Type: text/html;charset=UTF-8
Content-Language: ko-KR
Content-Length: 194
Date: Fri, 11 Apr 2025 09:38:03 GMT
Keep-Alive: timeout=20
Connection: keep-alive

 

2. 두번째 요청

curl -i -H "If-None-Match: \"6rUuhDrhGxkVNvh9jflwh8AA9a4zg3TcvsYCTJw6fbw=\"" http://localhost:8080/
HTTP/1.1 304 
Date: Fri, 11 Apr 2025 09:38:34 GMT
Keep-Alive: timeout=20
Connection: keep-alive

 

4. CORS

  • 브라우저에서 리소스 기반 요청 시, 다른 도메인이 내 서버 API에 접근할 수 있도록 허용하는 보안 매커니즘

 

동작 흐름

  1. 브라우저는 요청 헤더에 자동으로 Origin 헤더를 추가함 (프로토콜 + 도메인 + 포트)
  2. 서버는 이 Origin을 보고 클라이언트의 도메인을 확인
  3. CORS를 위반한 클라이언트는 자신의 요청이 CORS 허용과 관련한 응답 헤더에 부합하는지에 따라 응답 여부가 결정됨

 

예시) CorsFilter 등록

더보기
public class CorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse res = (HttpServletResponse) response;
        HttpServletRequest req = (HttpServletRequest) request;

        String origin = req.getHeader("Origin");

        if (Objects.nonNull(origin)) {
            res.setHeader("Access-Control-Allow-Origin", origin);
            res.setHeader("Access-Control-Allow-Methods", "*");
            res.setHeader("Access-Control-Allow-Headers", "Authorization");
            res.setHeader("Access-Control-Allow-Credentials", "true");

            if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
                res.setStatus(HttpServletResponse.SC_OK);
                return;
            }
        }

        chain.doFilter(request, res);
    }
}
  • Access-Control-Allow-Headers는 * 사용 시 브라우저에 따라 동작 안할수도 있음

 

public class MyWebInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext();
        ctx.register(DatabaseConfig.class);
        ctx.register(WebConfig.class);

        DispatcherServlet dispatcherServlet = new DispatcherServlet(ctx);
        ServletRegistration.Dynamic registration = servletContext.addServlet("app", dispatcherServlet);
        registration.setLoadOnStartup(1);
        registration.addMapping("/");

        // ... 
        
        FilterRegistration.Dynamic corsFilter = servletContext.addFilter("corsFilter", new CorsFilter());
        corsFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*");
    }
}

 

CORS 관련 응답 헤더

헤더 이름 의미 주의사항
Access-Control-Allow-Origin 요청을 허용할 Origin (출처)
*와 Access-Control-Allow-Credentials: true
같이 쓰면 안 됨!
Access-Control-Allow-Methods 허용할 HTTP 메서드들
"*"는 브라우저에 따라 비표준
(정확한 메서드 명시 권장)
Access-Control-Allow-Headers 허용할 요청 헤더들 "*"는 브라우저에 따라 비표준
(정확한 메서드 명시 권장)
Access-Control-Allow-Credentials 쿠키/인증정보 포함 허용 여부
기본적으로 허용되지 않음
이게 true면 Allow-Origin은 *이 되면 안 됨

 

Preflight 요청

  • 실제 요청 전, 브라우저가 서버에 안전성 검사를 요청하는 단계
  • OPTIONS 메서드로 요청함

 

발생 조건
분류 조건 예시
Request Method
PUT, DELETE, PATCH 안전하지 않은 메서드
POST + 비표준 Content-Type
application/json, applcation/xml, text/html 등
(application/x-www-form-urlencoded, multipart/form-data, text/plain 제외)
Request Header
커스텀 헤더 사용
Authorization, X-Custom-*, X-Requested-With 등
인코딩 관련 헤더
Content-Encoding, Accept-Encoding
Response Type blob, arrayBuffer 응답 사용
일부 브라우저에서 preflight 발생 가능
Fetch 설정 credentials: "include" +
Access-Control-Allow-Origin: *
동시에 사용 시 CORS 오류 또는 preflight 발생

 

5. UrlHandlerFilter

  • URL 끝에 슬래시(/)가 붙는지 여부를 처리하기 위한 기능

 

처리 방식

처리 방식 설명 예시
Redirect 슬래시가 있는 URL을 없는 URL로 리디렉트
/blog/my-post/ → /blog/my-post (308 Permanent Redirect)
WrapRequest 슬래시 여부에 상관없이 내부적으로 요청 경로를 처리
/admin/user/ → 내부적으로 /admin/user와 동일하게 처리

 

 

출처