이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스무 번째 글입니다. 19편으로 트랜잭션과 데이터 접근(Phase 4)이 끝났고, 이 글부터 1편 지도의 Phase 5, 웹이 시작됩니다. 요청 하나가 DispatcherServlet을 지나 응답이 되는 과정은 다음 글에서 따라가고, 이번 글은 그 아래에 깔린 바닥인 spring-web 모듈을 봅니다.
답하려는 질문은 네 가지입니다.
spring-web은 왜 따로 있는 모듈이고, 그 안에는 무엇이 들어 있는가- HTTP 요청·응답의 본문(JSON 같은 것)은 누가 Java 객체로 바꾸고, 여러 변환기 가운데 하나를 어떻게 고르는가
- 서블릿 컨테이너(Tomcat) 안에서 Spring 컨테이너는 언제, 누구에 의해 켜지고, 켜진 컨테이너는 어디에 보관되는가
- Spring이 주는 필터 기반 클래스(
OncePerRequestFilter,DelegatingFilterProxy)는 서블릿 필터에 무엇을 더하는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-web |
| 주로 읽는 클래스 | HttpMessageConverter, AbstractHttpMessageConverter, DefaultHttpMessageConverters, ContextLoader, SpringServletContainerInitializer, OncePerRequestFilter, DelegatingFilterProxy |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
spring-web은 파일 792개, 약 13만 2천 줄로 이 저장소에서 줄 수가 가장 많은 모듈입니다(2편 4절). 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.
먼저 알아 둘 말
HTTP 메시지. 브라우저와 서버가 주고받는 요청과 응답은 둘 다 같은 모양입니다. 앞쪽에 이름: 값 꼴의 헤더가 여러 줄 있고, 그 뒤에 본문(body)이 옵니다. 본문은 그냥 바이트 덩어리라서, 그것이 무엇인지는 헤더가 알려 줍니다. Content-Type: application/json은 "이 본문은 JSON이다", Accept: application/json은 "응답은 JSON으로 받고 싶다"는 뜻입니다.
미디어 타입. application/json, text/plain처럼 타입/서브타입으로 쓰는, 내용의 종류를 가리키는 이름입니다. text/*나 */*처럼 별표로 "아무거나"를 나타낼 수 있고, text/plain;charset=UTF-8처럼 뒤에 매개변수가 붙기도 합니다.
서블릿과 서블릿 컨테이너. 서블릿(Servlet)은 "HTTP 요청을 받아 응답을 쓰는 Java 객체"의 표준 인터페이스이고, 서블릿 컨테이너는 네트워크 연결을 받아 그 서블릿을 불러 주는 서버 프로그램입니다(Tomcat, Jetty). 컨테이너는 웹 애플리케이션 하나마다 **ServletContext**라는 객체를 하나 두는데, 이름으로 아무 객체나 넣고 꺼낼 수 있는 속성(attribute) 보관함이 딸려 있습니다. 이 보관함이 5절의 주인공입니다. "컨테이너"라는 말이 둘이라 헷갈리기 쉬운데, 이 글에서 서블릿 컨테이너는 Tomcat 같은 서버, Spring 컨테이너는 빈을 만드는 ApplicationContext입니다.
필터. 서블릿 앞에 줄지어 서서 요청과 응답을 먼저 만지는 객체입니다(jakarta.servlet.Filter). 필터는 자기 일을 하고 chain.doFilter(...)를 불러 다음 필터로 넘깁니다. 넘기지 않으면 요청은 거기서 멈춥니다.
직렬화와 역직렬화. 객체를 바이트(예: JSON 문자열)로 바꾸는 것이 직렬화, 그 반대가 역직렬화입니다. JSON에서 이 일을 하는 대표 라이브러리가 Jackson입니다.
ApplicationContext와 refresh(). Spring 컨테이너를 실제로 켜는 메서드가 refresh()이고, 열두 단계로 되어 있습니다. 7편에서 다뤘습니다. 이 글은 "웹 환경에서는 그 refresh()를 누가 부르나"를 봅니다.
1. spring-web은 두 웹 프레임워크의 바닥입니다
Spring에는 웹 프레임워크가 둘 있습니다. 서블릿 위에서 도는 Spring MVC(spring-webmvc)와 리액티브 방식의 WebFlux(spring-webflux)입니다. 둘은 요청을 처리하는 방식이 전혀 다르지만, "HTTP 헤더를 어떻게 표현하나", "application/json을 어떻게 파싱하나", "@RequestMapping 어노테이션은 어디에 정의하나" 같은 것은 똑같이 필요합니다. 그 공통 부분을 모아 둔 모듈이 spring-web입니다.
빌드 파일에서 확인할 수 있습니다. spring-webmvc, spring-webflux, spring-websocket이 모두 api(project(":spring-web"))로 이 모듈에 의존합니다(spring-webmvc.gradle 12행, spring-webflux.gradle 9행, spring-websocket.gradle 6행). 그리고 spring-web 자신이 api로 의존하는 Spring 모듈은 spring-beans와 spring-core 둘뿐이고(그 밖의 api 의존은 Micrometer의 micrometer-observation 하나입니다), spring-context와 서블릿 API(jakarta.servlet-api)는 **optional**입니다(spring-web.gradle 7–9행, 13행, 34행). api와 optional의 차이는 2편 3절에서 설명했습니다. 서블릿이 없는 WebFlux도, HTTP 클라이언트만 쓰는 프로그램도 이 모듈을 쓸 수 있어야 하기 때문입니다.
파란 상자가 이 글이 읽는 모듈입니다. 점선은 "없어도 되는" 의존입니다.
패키지는 크게 두 뿌리로 나뉩니다. org.springframework.http는 HTTP 자체를 다루고 서블릿을 몰라도 되는 쪽, org.springframework.web은 웹 애플리케이션을 만드는 데 쓰는 쪽입니다.
| 패키지 | 들어 있는 것 | 이 글에서 |
|---|---|---|
org.springframework.http |
HttpHeaders, MediaType, HttpEntity, ResponseEntity, HttpStatus |
2절 |
http.converter |
HttpMessageConverter와 구현들 (블로킹 I/O용) |
3·4절 |
http.codec |
HttpMessageReader/HttpMessageWriter (리액티브용. WebFlux가 쓴다) |
뺌 |
http.server, http.client |
서버·클라이언트 쪽 요청/응답 인터페이스 | 2절에서 이름만 |
web.context |
WebApplicationContext, ContextLoader, ContextLoaderListener |
5절 |
web.filter |
GenericFilterBean, OncePerRequestFilter, DelegatingFilterProxy 등 |
6절 |
web.client |
RestClient, RestTemplate |
4절에서 조금 |
web.bind, web.method |
@RequestMapping, @RequestBody 같은 어노테이션과 메서드 인자 처리의 공통 부분 |
22편 |
헷갈리기 쉬운 곳
@RequestMapping은spring-webmvc가 아니라spring-web에 있습니다. MVC와 WebFlux가 같은 어노테이션을 쓰기 때문입니다(web.bind.annotation패키지). 어노테이션을 해석하는 코드만 각 프레임워크에 있습니다.- 변환기가 두 벌입니다.
http.converter의HttpMessageConverter는InputStream/OutputStream으로 읽고 쓰는 블로킹용이고,http.codec의HttpMessageReader/HttpMessageWriter(44행)는 리액티브용입니다. 이름이 비슷하지만 서로 다른 인터페이스입니다. 이 글은 앞쪽만 봅니다.
2. HTTP를 객체로 — HttpMessage, HttpHeaders, MediaType
서블릿 API에는 이미 HttpServletRequest가 있는데 Spring은 왜 자기 인터페이스를 또 만들었을까요. 본문을 변환하는 코드를 서버에서도 클라이언트에서도, 서블릿이 있든 없든 똑같이 쓰고 싶었기 때문입니다. 그러려면 변환기가 아는 것이 "헤더와 본문 스트림" 정도로 작아야 합니다.
그 가장 작은 약속이 세 인터페이스입니다. HttpMessage는 getHeaders() 하나(26–34행), HttpInputMessage는 거기에 InputStream getBody()(32–41행), HttpOutputMessage는 OutputStream getBody()를 더한 것입니다(32–41행). 읽을 수 있는 메시지와 쓸 수 있는 메시지, 둘뿐입니다.
서버가 받은 요청과 클라이언트가 받은 응답은 둘 다 "읽는 메시지"입니다. 초록 상자는 서블릿 객체를 이 인터페이스로 감싸는 어댑터입니다.
그림에서 눈여겨볼 것은 방향입니다. 서버가 받은 요청(ServerHttpRequest, 34행)과 클라이언트가 받은 응답(ClientHttpResponse, 36행)은 둘 다 HttpInputMessage입니다. 그래서 "JSON 본문을 읽어 객체로 만드는" 코드 하나가 @RequestBody에도, RestClient의 응답 처리에도 쓰입니다. 서블릿 환경에서는 ServletServerHttpRequest가 HttpServletRequest를 감싸 이 인터페이스로 보이게 합니다(62행).
나머지 세 클래스는 값을 담는 그릇입니다.
HttpHeaders— 헤더 이름에서 값 목록으로 가는 맵을 감싼 클래스입니다. 기본 생성자는 이름의 대소문자를 가리지 않는 맵을 씁니다(454–456행). 문자열을 직접 다루지 않게getContentType()처럼 타입이 있는 접근자를 줍니다(1125–1128행).MediaType—spring-core의MimeType을 상속한 미디어 타입입니다(56행). 컨버터를 고를 때 쓰는 비교 메서드가 둘 있습니다.includes는 "text/*는text/plain을 포함한다"처럼 한쪽 방향이고(MimeType374–407행),isCompatibleWith는 어느 쪽이 별표든 맞으면 되는 양방향입니다(418행부터).HttpEntity— 헤더와 본문을 한데 묶은 것입니다(59–70행). 여기에 상태 코드를 더한 것이ResponseEntity(81–83행), 메서드와 URL을 더한 것이RequestEntity입니다. 컨트롤러가ResponseEntity.ok(body)를 돌려주면 "상태 200, 이 본문"이라는 뜻이 됩니다.
헷갈리기 쉬운 곳
HttpHeaders는 더 이상MultiValueMap이 아닙니다. 클래스 설명 주석에 "7.0부터 이 클래스는MultiValueMap계약을 구현하지 않는다"고 적혀 있고(87행), 선언도implements Serializable뿐입니다(98행). 예전 코드에서HttpHeaders를 맵처럼 넘기던 곳은 컴파일되지 않습니다.HttpInputMessage의 본문은 스트림이라 한 번만 읽을 수 있습니다. 인터페이스가 주는 것이InputStream하나이기 때문입니다. 필터에서 본문을 읽어 버리면 뒤의 컨버터는 빈 본문을 받습니다.
3. HttpMessageConverter — 본문과 객체 사이의 통역
이제 본문을 실제로 바꾸는 쪽입니다. 인터페이스는 메서드 네 개가 핵심입니다. (// ... 줄은 이 글에서 줄인 곳이고, 나머지는 소스 그대로입니다.)
public interface HttpMessageConverter<T> {
// ...
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
// ...
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
// ...
T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException;
// ...
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException;
}
spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverter.java 39–129행
canRead/canWrite는 물어보는 메서드이고 read/write는 실제로 하는 메서드입니다. 묻는 기준은 둘입니다. Java 타입(Class<?> clazz)과 미디어 타입. "Book 클래스를 application/json으로 읽을 수 있습니까"라는 질문에 컨버터마다 예/아니오로 답합니다. 이렇게 "할 수 있나"와 "해라"를 나눠 두면, 여러 컨버터를 목록에 넣어 두고 차례로 물어볼 수 있습니다(4절).
AbstractHttpMessageConverter — 공통 절차를 가진 뼈대
대부분의 컨버터는 AbstractHttpMessageConverter를 상속합니다(50행). 이 클래스는 17편 4절의 AbstractPlatformTransactionManager에서 본 것과 같은 템플릿 메서드 구조입니다. 순서는 부모가 정하고, 달라지는 부분만 하위 클래스가 채웁니다.
canRead는 두 질문의 &&입니다.
@Override
public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
return supports(clazz) && canRead(mediaType);
}
supports(clazz)는 하위 클래스가 구현하는 "이 타입을 다룹니까"이고(320행), canRead(mediaType)은 생성자에서 받아 둔 "지원하는 미디어 타입 목록" 가운데 하나가 요청의 타입을 includes하는지 봅니다. mediaType이 null이면 그냥 true입니다(145–155행).
write는 헤더를 먼저 채우고 본문 쓰기를 하위 클래스에 넘깁니다.
@Override
public final void write(final T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
final HttpHeaders headers = outputMessage.getHeaders();
addDefaultHeaders(headers, t, contentType);
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
// ...
}
else {
writeInternal(t, outputMessage);
outputMessage.getBody().flush();
}
}
addDefaultHeaders는 Content-Type이 아직 없으면 채웁니다. 넘겨받은 contentType이 null이거나 별표가 든 타입이면 컨버터의 첫 번째 지원 타입을 쓰고, 문자셋이 없으면 컨버터의 기본 문자셋을 ;charset=으로 붙입니다. 길이를 알 수 있으면 Content-Length도 넣습니다(244–271행). 하위 클래스가 채울 것은 셋뿐입니다. supports, readInternal(330행), writeInternal(340행).
대표 구현 둘
**StringHttpMessageConverter**는 가장 단순합니다. supports는 String.class == clazz이고(88–90행), 지원하는 미디어 타입은 text/plain과 */*, 곧 전부입니다(71–73행). 타입이 String이기만 하면 어떤 본문이든 글자로 읽어 줍니다.
**JacksonJsonHttpMessageConverter**는 Jackson 3의 JsonMapper로 JSON을 읽고 씁니다(55행, 소스에 @since 7.0). 지원하는 미디어 타입은 application/json과 application/*+json이고(60–61행), 타입 쪽 supports는 부모인 AbstractSmartHttpMessageConverter가 무조건 true를 돌려줍니다(66–69행). "JSON이라고 하면 무슨 객체든 맡겠다"는 컨버터입니다. 이름에 붙은 Smart는 SmartHttpMessageConverter를 가리키는데(41행, @since 6.2), Class 대신 List<Book> 같은 제네릭 정보까지 담는 ResolvableType(13편)으로 묻고 답하는 확장 인터페이스입니다. Jackson 2용인 MappingJackson2HttpMessageConverter는 7.0에서 제거 예정으로 표시되었습니다(47–49행).
예제 1: 컨버터를 직접 불러 보기
컨버터는 컨테이너도 서버도 없이 혼자 쓸 수 있습니다. spring-test에 있는 가짜 메시지 MockHttpInputMessage/MockHttpOutputMessage(33행)를 넣어 주면 됩니다. ex/u18/ConverterExample.java의 핵심 부분입니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public record Book(String title, int pages) { }
var string = new StringHttpMessageConverter(); // 기본 문자셋 ISO-8859-1
var json = new JacksonJsonHttpMessageConverter();
// ...
var out = new MockHttpOutputMessage();
json.write(new Book("소스 읽기", 320), null, out); // contentType 을 주지 않았다
System.out.println("Content-Type = " + out.getHeaders().getContentType());
System.out.println("body = " + out.getBodyAsString(StandardCharsets.UTF_8));
// ...
var in = new MockHttpInputMessage("{\"title\":\"Spring\",\"pages\":100}".getBytes(StandardCharsets.UTF_8));
in.getHeaders().setContentType(appJson);
Object book = json.read(Book.class, in);
System.out.println("read = " + book);
// ...
var latin = new MockHttpOutputMessage();
string.write("한글", textPlain, latin);
// ...
[1] canRead / canWrite
string.canRead(String, text/plain) = true
string.canRead(String, application/json) = true
string.canRead(Book, application/json) = false
json.canRead(Book, application/json) = true
json.canRead(Book, text/plain) = false
json.canWrite(Book, null) = true
[2] object -> JSON (write)
Content-Type = application/json
body = {"title":"소스 읽기","pages":320}
[3] JSON -> object (read)
read = Book[title=Spring, pages=100]
[4] default charset of StringHttpMessageConverter
new StringHttpMessageConverter() : text/plain;charset=ISO-8859-1, Content-Length=2, body=??
new StringHttpMessageConverter(UTF_8) : text/plain;charset=UTF-8, Content-Length=6, body=한글
타입과 미디어 타입, 두 질문이 따로 작동합니다. 초록은 true, 빨강은 false이고, 결과는 위 출력 [1] 그대로입니다.
- [1]
string은String이면application/json도 읽겠다고 하고,Book은 거절합니다.json은Book을 맡지만text/plain은 거절합니다. 타입과 미디어 타입, 두 기준이 따로 작동합니다. - [2]
contentType으로null을 줬는데Content-Type: application/json이 붙었습니다. 위에서 본addDefaultHeaders가 첫 번째 지원 타입을 넣은 것입니다. - [4] 아래 "헷갈리기 쉬운 곳"의 첫 항목입니다.
헷갈리기 쉬운 곳
StringHttpMessageConverter의 기본 문자셋은 UTF-8이 아니라 ISO-8859-1입니다(51행, 기본 생성자는 63–65행). 출력 [4]에서 "한글" 두 글자가??2바이트가 되었습니다.Content-Type에 문자셋이 적혀 있으면 그것을 쓰고, JSON 계열 미디어 타입이면 UTF-8을 쓰는 예외가 있지만(144–159행), 아무 정보가 없는text/plain은 기본값을 따릅니다. Jackson 컨버터 쪽은 문자셋이 없으면 UTF-8입니다(390–397행).read와write는final입니다(192–197행, 203–205행). 직접 컨버터를 만들 때 재정의하는 것은readInternal/writeInternal입니다.canRead가true라고 변환이 성공한다는 뜻은 아닙니다. Jackson 컨버터의canRead는 타입별 매퍼를 따로 등록하지 않았다면 미디어 타입만 보고 답합니다(262–268행). 실제로 그 클래스로 만들 수 있는지는read에서 드러나고, 실패하면HttpMessageNotReadableException이 됩니다(367–369행).
4. 컨버터 목록 — 누가 모으고 어떻게 고르나
컨버터 하나는 한 가지 형식만 압니다. 실제 애플리케이션은 문자열도, JSON도, 파일도 주고받으므로 컨버터 목록을 들고 있다가 그때그때 하나를 고릅니다.
모으기: HttpMessageConverters
7.0부터 목록을 만드는 일은 HttpMessageConverters라는 인터페이스가 맡습니다(34행, @since 7.0). 서버용과 클라이언트용 빌더가 따로 있고(forClient() 61행, forServer() 86행), registerDefaults()를 부르면 클래스패스에 어떤 라이브러리가 있는지 보고 기본 컨버터를 넣습니다.
"있는지 본다"는 말 그대로입니다. 구현 클래스 DefaultHttpMessageConverters는 클래스가 로드될 때 ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", …) 같은 검사를 한 번씩 해서 static final boolean에 적어 두고(152–172행), 그 값으로 분기합니다. JSON 컨버터를 고르는 부분입니다.
if (this.jsonConverter == null) {
if (JACKSON_PRESENT) {
this.jsonConverter = new JacksonJsonHttpMessageConverter();
}
else if (JACKSON_2_PRESENT) {
this.jsonConverter = new MappingJackson2HttpMessageConverter();
}
else if (GSON_PRESENT) {
this.jsonConverter = new GsonHttpMessageConverter();
}
else if (JSONB_PRESENT) {
this.jsonConverter = new JsonbHttpMessageConverter();
}
}
Jackson 3, Jackson 2, Gson, JSON-B 순으로 먼저 발견된 하나만 씁니다. 맨 위의 if (this.jsonConverter == null)은 사용자가 withJsonConverter(...)로 직접 넣었으면 탐지를 건너뛴다는 뜻입니다. spring-web이 Jackson을 optional로만 의존하면서도(1절에서 본 빌드 파일의 59행) Jackson이 있으면 알아서 쓰는 것이 이 방식 덕분입니다.
목록의 순서는 build()가 정합니다. 클라이언트 빌더는 사용자가 더한 컨버터 → ByteArray·String·폼 → Resource → 멀티파트 → JSON·XML 같은 나머지 순으로 넣습니다(500–529행). 서버 빌더도 같은 모양입니다(633–666행).
고르기: 앞에서부터 물어보고 처음 "예"라고 한 컨버터
고르는 코드는 쓰는 쪽에 있습니다. spring-web 안에서는 HTTP 클라이언트가 응답 본문을 읽을 때 쓰는 HttpMessageConverterExtractor가 가장 짧은 본보기입니다.
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) {
// ...
}
else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) {
// ...
}
else if (this.responseClass != null && messageConverter.canRead(this.responseClass, contentType)) {
// ...
return (T) messageConverter.read((Class) this.responseClass, responseWrapper);
}
}
spring-web/src/main/java/org/springframework/web/client/HttpMessageConverterExtractor.java 99–125행
for 문 하나입니다. 목록을 앞에서부터 돌며 canRead를 묻고, 처음 true가 나온 컨버터로 read하고 바로 돌아갑니다. 세 갈래로 나뉜 것은 컨버터가 제네릭 타입을 이해하는 확장 인터페이스(GenericHttpMessageConverter, SmartHttpMessageConverter)이면 그쪽 메서드로 묻기 위해서일 뿐, 하는 일은 같습니다. 끝까지 아무도 맡지 않으면 UnknownContentTypeException을 던집니다(132–134행).
노란 상자가 컨버터 목록입니다. "처음 맞는 것"을 쓰므로 순서가 결과를 바꿉니다.
서버 쪽, 곧 @RequestBody와 @ResponseBody를 처리하는 코드도 같은 모양의 for 문을 가지고 있습니다(AbstractMessageConverterMethodArgumentResolver 178행). 다만 그 코드는 spring-webmvc에 있고 Accept 헤더로 응답 형식을 정하는 협상까지 얽혀 있어서 22편에서 다룹니다. Spring MVC가 쓰는 목록은 WebMvcConfigurationSupport.createMessageConverters()가 HttpMessageConverters.forServer().registerDefaults()로 만듭니다(872–876행).
RestClient와 RestTemplate도 같은 컨버터를 씁니다
web.client 패키지의 HTTP 클라이언트들은 방향만 반대일 뿐 같은 부품을 씁니다. 보낼 객체는 canWrite/write로 요청 본문이 되고, 받은 응답은 위의 HttpMessageConverterExtractor로 객체가 됩니다. 목록을 만드는 곳도 같습니다. RestTemplate의 기본 생성자는 HttpMessageConverters.forClient().registerDefaults().build()를 부르고(129–131행), RestClient의 빌더도 사용자가 아무것도 설정하지 않았으면 같은 기본값을 등록합니다(498–514행).
예제 1 (이어서): 기본 목록과 선택
같은 예제의 마지막 부분이 이 for 문을 흉내 냅니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
HttpMessageConverters converters = HttpMessageConverters.forClient().registerDefaults().build();
// ...
System.out.println("first converter that can read Book + application/json = " + firstReader(converters, Book.class, appJson));
System.out.println("first converter that can read String + application/json = " + firstReader(converters, String.class, appJson));
// ...
private static String firstReader(HttpMessageConverters converters, Class<?> type, MediaType mediaType) {
for (HttpMessageConverter<?> converter : converters) {
if (converter.canRead(type, mediaType)) {
return converter.getClass().getSimpleName();
}
}
return "(none)";
}
[5] default converters (forClient) and who gets picked
0 ByteArrayHttpMessageConverter
1 StringHttpMessageConverter
2 ResourceHttpMessageConverter
3 AllEncompassingFormHttpMessageConverter
4 JacksonJsonHttpMessageConverter
5 Jaxb2RootElementHttpMessageConverter
first converter that can read Book + application/json = JacksonJsonHttpMessageConverter
first converter that can read String + application/json = StringHttpMessageConverter
마지막 두 줄이 핵심입니다. 본문이 똑같이 application/json이어도 원하는 타입이 String이면 1번 StringHttpMessageConverter가 먼저 "예"라고 해서 JSON 글자를 그대로 문자열로 받고, Book이면 0~3번이 모두 거절한 뒤 4번 Jackson 컨버터가 맡습니다.
이 목록은 7.0.9의 결과라는 점에 주의하세요. 이 글이 읽는 커밋의 build()(500–529행)는 조금 다릅니다. 폼 컨버터가 FormHttpMessageConverter로 String 바로 뒤에 오고(243–255행), 7.1에 새로 생긴 MultipartHttpMessageConverter(138–141행, @since 7.1)가 Resource 다음에 들어가며, 출력에 보이는 AllEncompassingFormHttpMessageConverter는 7.1에서 제거 예정으로 표시되었습니다(32–35행). 7.1.0-SNAPSHOT으로 직접 돌려 본 것은 아니므로 그쪽 목록은 싣지 않습니다. Jaxb2RootElementHttpMessageConverter가 보이는 것은 예제 프로젝트의 다른 의존성이 JAXB API를 끌고 왔기 때문입니다.
헷갈리기 쉬운 곳
- 순서가 곧 우선순위입니다. 직접 만든 컨버터를 목록 맨 뒤에 넣으면, 앞의 컨버터가 먼저 "예"라고 하는 한 한 번도 불리지 않습니다. 빌더가 사용자 컨버터를 맨 앞에 두는 이유입니다(507–508행).
- 컨트롤러가
String을 돌려주면 JSON 따옴표가 붙지 않습니다.String타입은StringHttpMessageConverter가 먼저 맡기 때문입니다. 위 출력의 마지막 줄이 읽는 방향에서 같은 일을 보여 줍니다. - 서버 빌더의 설명 주석과 코드의 순서가 다릅니다.
forServer()의 주석은 멀티파트 컨버터를 목록 맨 끝에 적어 두었지만(67–84행),build()는 JSON 같은 컨버터들보다 앞에 넣습니다(655–658행). 이 글은 코드를 따랐습니다.
5. WebApplicationContext — 서블릿 컨테이너 안에서 Spring 컨테이너 켜기
7편의 예제들은 main 메서드에서 컨텍스트를 만들고 refresh()를 직접 불렀습니다. 웹 애플리케이션에는 우리가 쓴 main이 없습니다. 프로그램의 주인은 Tomcat이고, Tomcat은 Spring을 모릅니다. 그러면 누가 refresh()를 부르고, 만들어진 컨텍스트를 서블릿과 필터는 어떻게 찾을까요.
WebApplicationContext가 더하는 것
WebApplicationContext는 ApplicationContext에 딱 두 가지를 더한 인터페이스입니다(45–105행). getServletContext() 메서드 하나, 그리고 상수 몇 개입니다. 그 가운데 가장 중요한 상수가 이것입니다.
String ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE = WebApplicationContext.class.getName() + ".ROOT";
spring-web/src/main/java/org/springframework/web/context/WebApplicationContext.java 55행
"먼저 알아 둘 말"에서 본 ServletContext의 속성 보관함에 이 이름으로 컨텍스트를 넣어 두겠다는 약속입니다. Tomcat이 아는 객체(ServletContext)에 Spring 컨테이너를 매달아 두면, ServletContext에 손이 닿는 누구든 그것을 꺼낼 수 있습니다. 이렇게 매달린 컨텍스트를 루트 컨텍스트라고 부릅니다.
구현 클래스는 7편 7절에서 본 두 갈래를 그대로 따릅니다. GenericWebApplicationContext는 GenericApplicationContext를(73–74행), AnnotationConfigWebApplicationContext와 XmlWebApplicationContext는 AbstractRefreshableWebApplicationContext를 상속합니다(100행, 73–74행). 웹용 컨텍스트가 일반 컨텍스트와 달라지는 곳은 refresh()의 4단계, 기본 구현이 비어 있던 postProcessBeanFactory()입니다. 여기서 request·session 스코프를 등록하고, ServletContext를 servletContext라는 이름의 빈으로 넣고, ServletContextAware를 구현한 빈에 ServletContext를 넣어 주는 후처리기를 답니다(150–156행, WebApplicationContextUtils 180–199행, 218–261행). Environment도 서블릿 초기화 매개변수를 PropertySource(12편)로 더한 StandardServletEnvironment로 바뀝니다(109–116행).
ContextLoaderListener — refresh()를 부르는 자리
서블릿 규격에는 "웹 애플리케이션이 시작될 때와 끝날 때 알려 달라"는 인터페이스 ServletContextListener가 있습니다. ContextLoaderListener는 그것을 구현한 클래스이고, 본문은 놀랄 만큼 짧습니다. 시작 알림을 받으면 initWebApplicationContext를, 끝 알림을 받으면 closeWebApplicationContext를 부릅니다(122–125행, 132–136행). 실제 일은 부모 클래스 ContextLoader에 있습니다.
public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
if (servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null) {
throw new IllegalStateException(
"Cannot initialize context because there is already a root application context present - " +
"check whether you have multiple ContextLoader* definitions in your web.xml!");
}
// ...
try {
// Store context in local instance variable, to guarantee that
// it is available on ServletContext shutdown.
if (this.rootContext == null) {
this.rootContext = createWebApplicationContext(servletContext);
}
if (this.rootContext instanceof ConfigurableWebApplicationContext cwac && !cwac.isActive()) {
// The context has not yet been refreshed -> provide services such as
// setting the parent context, setting the application context id, etc
if (cwac.getParent() == null) {
// The context instance was injected without an explicit parent ->
// determine parent for root web application context, if any.
ApplicationContext parent = loadParentContext(servletContext);
cwac.setParent(parent);
}
configureAndRefreshWebApplicationContext(cwac, servletContext);
}
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.rootContext);
// ...
return this.rootContext;
}
catch (RuntimeException | Error ex) {
logger.error("Context initialization failed", ex);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
throw ex;
}
}
spring-web/src/main/java/org/springframework/web/context/ContextLoader.java 242–295행
위에서부터 읽으면 이렇습니다.
- 이미 루트 컨텍스트가 있으면 예외. 속성 보관함에 그 이름으로 무언가 들어 있으면
ContextLoader를 두 번 등록했다는 뜻입니다. - 컨텍스트가 없으면 만듭니다. 생성자로 미리 받은 것이 있으면 그것을 쓰고, 없으면
createWebApplicationContext가 클래스 이름을 정해 객체를 만듭니다. 이름은contextClass라는 초기화 매개변수에서 찾고, 없으면spring-webjar 안의ContextLoader.properties에 적힌 기본값XmlWebApplicationContext입니다(326–359행). - 아직 켜지지 않았으면 설정하고
refresh().configureAndRefreshWebApplicationContext가setServletContext(sc)로ServletContext를 건네고,contextConfigLocation매개변수가 있으면 설정 위치로 넣고, 마지막 줄에서wac.refresh()를 부릅니다(361–392행). 7편의 열두 단계가 여기서 돕니다. ServletContext속성에 넣습니다(273행). 이 한 줄이 Tomcat의 세계와 Spring의 세계를 잇습니다.- 실패하면 예외 객체를 같은 이름으로 넣습니다(292행). 나중에 꺼내려는 쪽이 "없다"가 아니라 "왜 실패했는지"를 알 수 있게 하기 위해서입니다.
WebApplicationContextUtils.getWebApplicationContext는 꺼낸 값이 예외이면 그것을 다시 던집니다(109–128행).
노란 상자가 ServletContext의 속성 보관함입니다. 그 위는 넣는 길, 아래는 꺼내 쓰는 쪽입니다.
web.xml 없이 — SpringServletContainerInitializer와 WebApplicationInitializer
그러면 ContextLoaderListener는 누가 Tomcat에 등록할까요. 전통적으로는 WEB-INF/web.xml 파일에 <listener>로 적습니다. 서블릿 규격에는 파일 없이 코드로 등록하는 길도 있고, Spring은 그 길을 이렇게 이었습니다.
서블릿 규격은 jar 안에 META-INF/services/jakarta.servlet.ServletContainerInitializer라는 파일이 있으면 거기 적힌 클래스를 시작할 때 불러 줍니다. spring-web jar에 그 파일이 있고 내용은 org.springframework.web.SpringServletContainerInitializer 한 줄입니다. 이 클래스에는 @HandlesTypes(WebApplicationInitializer.class)가 붙어 있어서(110–111행), 컨테이너가 클래스패스에서 WebApplicationInitializer를 구현한 클래스를 전부 찾아 onStartup의 첫 인자로 넘겨 줍니다. 이 탐색은 서블릿 컨테이너가 하는 일이라 이 글에서 소스로 확인한 것은 아니고, onStartup 메서드의 설명 주석(115–118행)에 적힌 내용입니다.
public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
throws ServletException {
List<WebApplicationInitializer> initializers = Collections.emptyList();
if (webAppInitializerClasses != null) {
initializers = new ArrayList<>(webAppInitializerClasses.size());
for (Class<?> waiClass : webAppInitializerClasses) {
// ...
if (!waiClass.isInterface() && !Modifier.isAbstract(waiClass.getModifiers()) &&
WebApplicationInitializer.class.isAssignableFrom(waiClass)) {
try {
initializers.add((WebApplicationInitializer)
ReflectionUtils.accessibleConstructor(waiClass).newInstance());
}
// ...
}
}
}
// ...
AnnotationAwareOrderComparator.sort(initializers);
for (WebApplicationInitializer initializer : initializers) {
initializer.onStartup(servletContext);
}
}
spring-web/src/main/java/org/springframework/web/SpringServletContainerInitializer.java 140–173행
넘겨받은 클래스 가운데 인터페이스와 추상 클래스를 걸러 내고, 인자 없는 생성자로 객체를 만들고, @Order나 Ordered가 정한 순서로 정렬한 다음 하나씩 onStartup(servletContext)를 부릅니다. WebApplicationInitializer는 그 메서드 하나뿐인 인터페이스이고(159–171행), 우리는 그 안에서 servletContext.addListener(new ContextLoaderListener(...))처럼 web.xml에 적던 일을 Java로 합니다. 이 등록을 대신 해 주는 기반 클래스가 AbstractContextLoaderInitializer입니다(58–69행).
예제 2: 내장 Tomcat에 루트 컨텍스트 올리기
ex/u18/RootContextTomcatExample.java는 Tomcat을 프로그램 안에서 띄워 위 과정을 실제로 지나가 봅니다. 7.0.9와 tomcat-embed-core 11.0.15로 실행했습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** web.xml 대신 쓰는 자바 설정. 서블릿 컨테이너가 시작할 때 Spring 이 불러 준다. */
public static class MyInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) {
System.out.println("1. WebApplicationInitializer.onStartup");
var root = new AnnotationConfigWebApplicationContext();
root.register(RootConfig.class); // 아직 refresh 하지 않았다
System.out.println("2. ContextLoaderListener registered (root.isActive=" + root.isActive() + ")");
servletContext.addListener(new ContextLoaderListener(root));
servletContext.addFilter("stampFilter", new DelegatingFilterProxy("stampFilter"))
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
servletContext.addServlet("hello", new HttpServlet() {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
WebApplicationContext wac =
WebApplicationContextUtils.getRequiredWebApplicationContext(getServletContext());
response.setContentType("text/plain");
response.getWriter().print(wac.getBean(Greeter.class).greet("servlet"));
}
}).addMapping("/hello");
}
}
// ...
Context context = tomcat.addContext("", Files.createTempDirectory("u18-docbase").toString());
// 보통은 컨테이너가 jar 를 뒤져 찾아 주는 일을 여기서는 손으로 한다
context.addServletContainerInitializer(new SpringServletContainerInitializer(), Set.of(MyInitializer.class));
tomcat.start();
// ...
RootConfig에는 Greeter 빈과, 그것을 주입받아 응답에 X-Stamp 헤더를 다는 필터 빈 stampFilter가 있습니다(6절에서 다시 봅니다). 3번과 7번 줄은 RootConfig 안의 @EventListener가 찍습니다.
1. WebApplicationInitializer.onStartup
2. ContextLoaderListener registered (root.isActive=false)
3. root context refreshed: AnnotationConfigWebApplicationContext
4. ServletContext attribute name = org.springframework.web.context.WebApplicationContext.ROOT
value = AnnotationConfigWebApplicationContext, Environment = StandardServletEnvironment, has servletContext bean = true
5. GET /hello -> 200 body="hello, servlet" X-Stamp="hello, filter"
6. tomcat.stop()
7. root context closed
8. attribute after stop = null
- 1→2→3.
onStartup에서 리스너를 등록할 때는 컨텍스트가 아직 꺼져 있고(isActive=false), 그 뒤 Tomcat이 리스너를 부를 때refresh()가 됩니다.ContextLoader가 "아직 켜지지 않았으면 켠다"고 한 그 분기입니다. - 4. 속성 이름은
WebApplicationContext인터페이스의 전체 이름에.ROOT를 붙인 것이고,Environment는StandardServletEnvironment,servletContext라는 빈도 들어 있습니다. - 5. Spring MVC가 전혀 없는 평범한 서블릿이
WebApplicationContextUtils로 루트 컨텍스트를 꺼내 빈을 썼습니다. - 6→7→8. Tomcat을 멈추면
contextDestroyed가 불려 컨텍스트가 닫히고 속성도 지워집니다(502–519행).
헷갈리기 쉬운 곳
- 루트 컨텍스트는 "웹 계층이 아닌 것"을 두는 곳입니다.
WebApplicationContext의 설명 주석은 애플리케이션에 루트 컨텍스트가 하나 있고 서블릿마다 자기 자식 컨텍스트를 가진다고 적고 있습니다(32–34행).DispatcherServlet은 자기 컨텍스트를 만들 때 이 속성에서 루트 컨텍스트를 꺼내 부모로 삼습니다(FrameworkServlet557–558행). 그 이야기는 21편에서 이어집니다. ContextLoaderListener가 없어도 Spring MVC는 돕니다.DispatcherServlet혼자서도 컨텍스트를 만들 수 있기 때문입니다. 루트 컨텍스트가 꼭 필요한 것은 예제의DelegatingFilterProxy처럼 서블릿 밖에서 빈을 찾는 쪽입니다.contextConfigLocation을 주지 않으면 기본 컨텍스트는 XML을 찾습니다. 기본 클래스가XmlWebApplicationContext이기 때문입니다. Java 설정을 쓰려면 예제처럼 컨텍스트를 직접 만들어 생성자로 넘기거나contextClass매개변수를 줘야 합니다.
6. 필터 — GenericFilterBean, OncePerRequestFilter, DelegatingFilterProxy
web.filter 패키지에는 바로 쓸 수 있는 필터와, 필터를 만들 때 상속하는 기반 클래스가 함께 있습니다. 기반 클래스는 아래에서 위로 세 층입니다.
GenericFilterBean — 필터를 빈처럼
서블릿 필터는 원래 컨테이너가 만들고 init(FilterConfig)로 설정을 넘겨받습니다. GenericFilterBean(80–81행)은 이 필터가 두 세계 어느 쪽에서 만들어져도 같은 방식으로 초기화되게 합니다.
- 서블릿 컨테이너가 만든 경우:
init()이FilterConfig의 초기화 매개변수를BeanWrapper로 자기 속성에 넣습니다(205–238행).encoding=UTF-8이라는 매개변수가 있으면setEncoding("UTF-8")이 불리는 식입니다. 문자열로 속성을 넣는BeanWrapper는 14편에서 봤습니다. - Spring 빈으로 만든 경우:
InitializingBean이므로afterPropertiesSet()이 불립니다(167–169행).
두 길 모두 끝에서 initFilterBean()을 부르고, 하위 클래스는 그 메서드 하나만 재정의하면 됩니다(263–264행).
OncePerRequestFilter — 한 요청에 한 번만
브라우저에서 온 요청 하나가 필터를 여러 번 지날 수 있습니다. 서블릿이 요청을 다른 서블릿으로 넘기거나(forward), 오류 페이지로 보내거나, 비동기 처리를 마치고 다시 디스패치할 때 컨테이너가 필터 체인을 다시 태울 수 있기 때문입니다. 인증이나 로깅 같은 필터가 그때마다 두 번씩 돌면 곤란합니다. OncePerRequestFilter는 이것을 막는 기반 클래스이고, 방법은 요청 객체에 표시를 남기는 것입니다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// ...
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
else if (hasAlreadyFilteredAttribute) {
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
else {
// Do invoke this filter...
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
doFilterInternal(httpRequest, httpResponse, filterChain);
}
finally {
// Remove the "already filtered" request attribute for this request.
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java 89–123행
노란 상자가 표시를 남기는 줄입니다. 초록 상자로 가는 두 길은 모두 "이번에는 일하지 않는다"입니다.
- 표시로 쓸 속성 이름을 정합니다. 필터 이름(없으면 클래스 이름)에
.FILTERED를 붙인 것입니다(168–174행). - 건너뛰기로 한 디스패치(기본으로 ASYNC와 ERROR, 205–207행·216–218행)이거나
shouldNotFilter가true이면 일하지 않고 넘깁니다. - 요청에 이미 표시가 있으면 일하지 않고 넘깁니다.
- 표시가 없으면 표시를 남기고
doFilterInternal을 부릅니다. 하위 클래스가 구현하는 메서드이고, 인자가 처음부터HttpServletRequest/HttpServletResponse라 캐스팅할 필요가 없습니다(228–230행). finally에서 표시를 지웁니다.
표시를 요청 속성에 두는 이유는, forward를 해도 요청 속성은 그대로 따라가기 때문입니다. 필터 객체의 필드에 두면 동시에 들어온 다른 요청과 섞입니다.
CharacterEncodingFilter가 이 클래스를 상속한 대표적인 필터입니다. doFilterInternal에서 요청에 문자 인코딩이 없으면(또는 강제하라고 했으면) 정해 둔 인코딩을 넣고 체인을 넘기는 것이 전부입니다(186–200행).
예제 3: 같은 요청이 필터를 두 번 지나게 해 보기
ex/u18/OncePerRequestFilterExample.java는 spring-test의 MockFilterChain으로 forward를 흉내 냅니다. 첫 번째 서블릿이 같은 요청 객체로 필터 체인을 한 번 더 태웁니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** 평범한 필터: 불릴 때마다 일한다. */
static class PlainFilter extends GenericFilterBean {
int count;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println(" PlainFilter.doFilter #" + (++count));
chain.doFilter(request, response);
}
}
/** 요청 하나에 한 번만 일하는 필터. */
static class OnceFilter extends OncePerRequestFilter {
int count;
String flagName() {
return getAlreadyFilteredAttributeName(); // protected 메서드를 밖에서 보려고
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
System.out.println(" OnceFilter.doFilterInternal #" + (++count)
+ " (" + flagName() + " = " + request.getAttribute(flagName()) + ")");
chain.doFilter(request, response);
}
}
// ...
Servlet forwarding = new HttpServlet() {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println(" servlet: run the chain again with the same request (like a forward)");
new MockFilterChain(endpoint, plain, once).doFilter(request, response);
}
};
// ...
var request = new MockHttpServletRequest("GET", "/hello");
new MockFilterChain(forwarding, plain, once).doFilter(request, new MockHttpServletResponse());
[1] one request passes the filters twice
PlainFilter.doFilter #1
OnceFilter.doFilterInternal #1 (ex.u18.OncePerRequestFilterExample$OnceFilter.FILTERED = true)
servlet: run the chain again with the same request (like a forward)
PlainFilter.doFilter #2
servlet reached
PlainFilter ran 2 times, OnceFilter ran 1 time(s)
attribute after the request = null
[2] a new request
OnceFilter.doFilterInternal #2 (ex.u18.OncePerRequestFilterExample$OnceFilter.FILTERED = true)
servlet reached
OnceFilter ran 2 time(s) in total
[3] an ASYNC dispatch
servlet reached
OnceFilter ran 2 time(s) in total
- [1] 같은 요청이 두 번 지나가는 동안
PlainFilter는 두 번,OnceFilter는 한 번 일했습니다.doFilterInternal안에서 본 표시 속성은true이고, 요청이 끝난 뒤에는null로 지워져 있습니다. - [2] 새 요청 객체에는 표시가 없으므로 다시 일합니다. "한 번"의 단위는 필터 객체가 아니라 요청입니다.
- [3] 디스패치 종류가 ASYNC인 요청은
doFilterInternal이 아예 불리지 않았습니다.shouldNotFilterAsyncDispatch()의 기본값이true이기 때문입니다.
DelegatingFilterProxy — 필터를 Spring 빈으로
필터가 다른 빈을 주입받아야 하면 문제가 생깁니다. 필터는 서블릿 컨테이너가 관리하는 객체이고, 서블릿 컨테이너는 Spring의 빈을 모릅니다. DelegatingFilterProxy(86행)는 그 사이의 다리입니다. 컨테이너에는 이 프록시를 필터로 등록하고, 프록시는 요청이 오면 Spring 컨텍스트에서 진짜 필터 빈을 찾아 그대로 넘깁니다.
protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
String targetBeanName = getTargetBeanName();
Assert.state(targetBeanName != null, "No target bean name set");
Filter delegate = wac.getBean(targetBeanName, Filter.class);
if (isTargetFilterLifecycle()) {
delegate.init(getFilterConfig());
}
return delegate;
}
spring-web/src/main/java/org/springframework/web/filter/DelegatingFilterProxy.java 332–340행
노란 상자는 5절에서 본 그 속성입니다. 루트 컨텍스트를 ServletContext에 매달아 둔 덕분에 필터가 빈을 찾을 수 있습니다.
wac은 5절에서 본 ServletContext 속성에서 꺼낸 루트 컨텍스트입니다(300–316행). 빈 이름은 따로 정하지 않으면 필터 이름을 씁니다(229–231행). 진짜 필터를 찾는 시점은 두 번입니다. 필터가 초기화될 때 컨텍스트가 이미 있으면 그때 찾아 두고(224–244행), 아직 없으면 첫 요청이 올 때 찾습니다(247–273행). 그때도 없으면 "No WebApplicationContext found: no ContextLoaderListener or DispatcherServlet registered?"라는 예외가 납니다.
예제 2에서 이미 이것을 썼습니다. addFilter("stampFilter", new DelegatingFilterProxy("stampFilter"))로 등록한 프록시가 루트 컨텍스트의 stampFilter 빈을 찾았고, 그 빈은 Greeter를 주입받은 상태였기 때문에 응답에 X-Stamp="hello, filter"가 붙었습니다. Spring Security의 필터 체인이 서블릿 컨테이너에 연결되는 방식이 바로 이것으로 알려져 있는데, Spring Security는 이 저장소와 별개의 프로젝트라 이 글에서 소스로 확인한 내용은 아닙니다.
헷갈리기 쉬운 곳
- 표시는
doFilterInternal이 끝나면 지워집니다. 그래서 표시가 지워진 뒤에 같은 요청이 다시 디스패치되는 경우는 표시로 막을 수 없습니다. 클래스 설명 주석은 ASYNC와 ERROR 디스패치가 별도 스레드에서 일어난다고 적고 있고(38–59행), 이 둘을 건너뛰는 것은 표시가 아니라 2번의skipDispatch입니다(125–133행). - 필터 이름이 같으면 표시도 같습니다. 속성 이름이 필터 이름에서 나오므로, 같은 이름으로 두 번 등록된 같은 클래스의 필터는 서로를 "이미 지나간 것"으로 봅니다.
DelegatingFilterProxy는 기본으로 대상 빈의init/destroy를 부르지 않습니다.targetFilterLifecycle의 기본값이false이기 때문입니다(94행). 대상은 Spring 빈이므로 생명주기는 Spring 컨테이너가 맡는다는 전제입니다(6편).- 이 필터들은 서블릿용입니다. WebFlux에는
jakarta.servlet.Filter대신WebFilter라는 다른 인터페이스가 있습니다(32행). 이름이 비슷한web.filter.reactive패키지의 클래스들이 그쪽 구현입니다.
이 글에서 뺀 것
이름과 위치만 적어 둡니다. 리액티브용 변환기인 HttpMessageReader/HttpMessageWriter(http.codec, 24편에서 봅니다)와 그 아래에서 바이트를 다루는 Encoder/Decoder(인터페이스는 spring-core의 core.codec에 있습니다). RestClient가 요청을 실제로 보내는 길인 ClientHttpRequestFactory와 인터셉터(http.client), 그리고 인터페이스에 어노테이션만 붙여 HTTP 클라이언트를 만드는 web.service. Accept 헤더로 응답 형식을 정하는 콘텐츠 협상(web.accept)과 @RequestBody·@ResponseBody 쪽의 컨버터 선택(AbstractMessageConverterMethodProcessor 315행부터, 22편). request·session 스코프와 그것을 스레드에 묶는 RequestContextHolder(web.context.request). CORS(web.cors), 파일 업로드(web.multipart), URL을 조립하는 UriComponentsBuilder(web.util), 오류 응답 형식 ProblemDetail. 그리고 web.filter의 나머지 필터들 — ForwardedHeaderFilter, ShallowEtagHeaderFilter, CorsFilter, FormContentFilter, HiddenHttpMethodFilter 등입니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
spring-web |
MVC와 WebFlux가 함께 쓰는 바닥. spring-context와 서블릿 API는 optional |
HttpInputMessage / HttpOutputMessage |
헤더 + 본문 스트림. 서버가 받은 요청과 클라이언트가 받은 응답이 같은 인터페이스 |
HttpHeaders / MediaType |
헤더와 미디어 타입을 문자열 대신 타입으로. HttpHeaders는 7.0부터 MultiValueMap이 아니다 |
HttpMessageConverter |
canRead/canWrite로 묻고 read/write로 한다. 기준은 Java 타입과 미디어 타입 |
AbstractHttpMessageConverter |
헤더 채우기는 부모가, 본문 읽고 쓰기(readInternal/writeInternal)는 하위 클래스가 |
StringHttpMessageConverter |
String이면 모든 미디어 타입을 맡는다. 기본 문자셋은 ISO-8859-1 |
JacksonJsonHttpMessageConverter |
JSON 미디어 타입이면 어떤 객체든 맡는다. Jackson 3, 7.0부터 |
HttpMessageConverters |
클래스패스를 보고 기본 컨버터 목록을 만든다. 서버용·클라이언트용 빌더 |
| 컨버터 선택 | 목록을 앞에서부터 돌며 처음 "예"라고 한 컨버터. 순서가 우선순위 |
WebApplicationContext |
ApplicationContext + getServletContext() + 루트 컨텍스트를 매다는 속성 이름 |
ContextLoaderListener |
웹 앱이 시작될 때 컨텍스트를 refresh()하고 ServletContext 속성에 넣는다. 끝날 때 닫는다 |
SpringServletContainerInitializer |
서블릿 컨테이너가 시작할 때 불러 주는 진입점. WebApplicationInitializer들을 만들어 부른다 |
GenericFilterBean |
필터의 초기화 매개변수를 빈 속성으로. 컨테이너가 만들어도 Spring이 만들어도 같은 초기화 |
OncePerRequestFilter |
요청 속성에 표시를 남겨 한 요청에 한 번만 doFilterInternal을 부른다 |
DelegatingFilterProxy |
컨테이너에는 프록시를 등록하고, 실제 일은 루트 컨텍스트의 필터 빈에게 넘긴다 |
다음에 볼 것
바닥을 봤으니 이제 그 위를 지나가는 요청을 따라갈 차례입니다. 다음 글은 Spring MVC의 한가운데에 있는 서블릿 하나, **DispatcherServlet**입니다. 이 글에서 본 루트 컨텍스트를 부모로 삼아 자기 컨텍스트를 만드는 것부터, 요청을 처리할 핸들러를 찾고, 부르고, 결과를 응답으로 쓰기까지의 한 바퀴를 봅니다. 이 글의 HttpMessageConverter는 그 과정의 끝, 컨트롤러가 돌려준 객체가 응답 본문이 되는 자리에서 다시 등장합니다.
- 다음 글: Spring 소스 파헤치기 21: DispatcherServlet — 요청 하나가 응답이 되기까지
- 앞 글: 19편: ORM과 R2DBC 연동 — JPA 통합과 리액티브 트랜잭션
- 이 글이 기댄 글: 2편: 전체 지도 — 모듈과 패키지는 어떻게 나뉘어 있나, 7편: refresh() 12단계 — 컨테이너가 켜지는 순서, 12편: 이벤트, Environment, Resource, 14편: 타입 변환과 BeanWrapper
- 1편: AI와 함께 소스를 읽는 방법과 분석 지도
- 서블릿 컨테이너 쪽에서 본 SCI 탐색과 필터 체인: Tomcat 소스 파헤치기 10: 웹앱 배포와 Context 설정, 9편: 필터 체인과 서블릿 호출