namalzi

← 글 목록

Spring 소스 파헤치기 20: spring-web 기반 — HttpMessageConverter, WebApplicationContext, 필터

Spring · 게시

Spring MVC와 WebFlux가 함께 딛고 서는 spring-web 모듈을 처음 보는 사람 기준으로 읽습니다. HTTP 메시지를 객체로 다루는 인터페이스들, 본문을 Java 객체로 바꾸는 HttpMessageConverter와 그것을 고르는 규칙, 서블릿 컨테이너 안에서 Spring 컨테이너가 켜지는 자리(ContextLoaderListener), 그리고 한 요청에 한 번만 도는 필터가 어떻게 만들어져 있는지를 소스와 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스무 번째 글입니다. 19편으로 트랜잭션과 데이터 접근(Phase 4)이 끝났고, 이 글부터 1편 지도의 Phase 5, 웹이 시작됩니다. 요청 하나가 DispatcherServlet을 지나 응답이 되는 과정은 다음 글에서 따라가고, 이번 글은 그 아래에 깔린 바닥인 spring-web 모듈을 봅니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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 클라이언트만 쓰는 프로그램도 이 모듈을 쓸 수 있어야 하기 때문입니다.

spring-webmvc, spring-webflux, spring-websocket이 모두 spring-web에 의존하고, spring-web 안에는 http, http.converter, http.codec, web.context, web.filter, web.client 패키지가 있으며, spring-web은 spring-beans와 spring-core에 실선으로, spring-context와 서블릿 API에는 점선(optional)으로 이어진 그림. 파란 상자가 이 글이 읽는 모듈입니다. 점선은 "없어도 되는" 의존입니다.

패키지는 크게 두 뿌리로 나뉩니다. 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편

헷갈리기 쉬운 곳

2. HTTP를 객체로 — HttpMessage, HttpHeaders, MediaType

서블릿 API에는 이미 HttpServletRequest가 있는데 Spring은 왜 자기 인터페이스를 또 만들었을까요. 본문을 변환하는 코드를 서버에서도 클라이언트에서도, 서블릿이 있든 없든 똑같이 쓰고 싶었기 때문입니다. 그러려면 변환기가 아는 것이 "헤더와 본문 스트림" 정도로 작아야 합니다.

그 가장 작은 약속이 세 인터페이스입니다. HttpMessage는 getHeaders() 하나(26–34행), HttpInputMessage는 거기에 InputStream getBody()(32–41행), HttpOutputMessage는 OutputStream getBody()를 더한 것입니다(32–41행). 읽을 수 있는 메시지와 쓸 수 있는 메시지, 둘뿐입니다.

HttpMessage를 뿌리로 하는 인터페이스 계층. HttpMessage에서 HttpInputMessage, HttpOutputMessage, HttpRequest가 갈라지고, ServerHttpRequest는 HttpRequest와 HttpInputMessage를, ClientHttpRequest는 HttpRequest와 HttpOutputMessage를 잇는다. ServerHttpResponse는 HttpOutputMessage를, ClientHttpResponse는 HttpInputMessage를 잇는다. 서블릿 구현으로 ServletServerHttpRequest와 ServletServerHttpResponse가 있다. 서버가 받은 요청과 클라이언트가 받은 응답은 둘 다 "읽는 메시지"입니다. 초록 상자는 서블릿 객체를 이 인터페이스로 감싸는 어댑터입니다.

그림에서 눈여겨볼 것은 방향입니다. 서버가 받은 요청(ServerHttpRequest, 34행)과 클라이언트가 받은 응답(ClientHttpResponse, 36행)은 둘 다 HttpInputMessage입니다. 그래서 "JSON 본문을 읽어 객체로 만드는" 코드 하나가 @RequestBody에도, RestClient의 응답 처리에도 쓰입니다. 서블릿 환경에서는 ServletServerHttpRequest가 HttpServletRequest를 감싸 이 인터페이스로 보이게 합니다(62행).

나머지 세 클래스는 값을 담는 그릇입니다.

헷갈리기 쉬운 곳

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);
	}

spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java 131–134행

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();
		}
	}

spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java 203–235행

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=한글

두 컨버터가 예제 1의 질문에 답하는 방식. StringHttpMessageConverter 의 canRead 는 supports(clazz) && canRead(mediaType) 이고, 타입은 String.class 일 때만, 미디어 타입은 text/plain 과 */*(곧 전부)다. 그래서 (String, text/plain) 은 타입·미디어 모두 맞아 true, (String, application/json) 도 */* 가 포함해 true, (Book, application/json) 은 타입이 안 맞아 false. JacksonJsonHttpMessageConverter 는 타입별 매퍼가 없으면 타입을 따로 가리지 않고 미디어 타입(application/json, application/*+json)만 본다. (Book, application/json) 은 true, (Book, text/plain) 은 미디어가 안 맞아 false, canWrite(Book, null) 은 미디어 타입이 null 이면 묻지 않아 true 타입과 미디어 타입, 두 질문이 따로 작동합니다. 초록은 true, 빨강은 false이고, 결과는 위 출력 [1] 그대로입니다.

헷갈리기 쉬운 곳

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();
				}
			}

spring-web/src/main/java/org/springframework/http/converter/DefaultHttpMessageConverters.java 315–328행

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행).

컨버터를 고르는 흐름. Content-Type이 application/json이고 원하는 타입이 Book인 입력 메시지에 대해 컨버터 목록을 앞에서부터 꺼내 canRead를 묻고, 아니면 다음 컨버터로, 그렇다면 read를 불러 Book 객체를 얻는다. 더 꺼낼 컨버터가 없으면 예외가 난다. 노란 상자가 컨버터 목록입니다. "처음 맞는 것"을 쓰므로 순서가 결과를 바꿉니다.

서버 쪽, 곧 @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를 끌고 왔기 때문입니다.

헷갈리기 쉬운 곳

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행

위에서부터 읽으면 이렇습니다.

  1. 이미 루트 컨텍스트가 있으면 예외. 속성 보관함에 그 이름으로 무언가 들어 있으면 ContextLoader를 두 번 등록했다는 뜻입니다.
  2. 컨텍스트가 없으면 만듭니다. 생성자로 미리 받은 것이 있으면 그것을 쓰고, 없으면 createWebApplicationContext가 클래스 이름을 정해 객체를 만듭니다. 이름은 contextClass라는 초기화 매개변수에서 찾고, 없으면 spring-web jar 안의 ContextLoader.properties에 적힌 기본값 XmlWebApplicationContext입니다(326–359행).
  3. 아직 켜지지 않았으면 설정하고 refresh(). configureAndRefreshWebApplicationContext가 setServletContext(sc)로 ServletContext를 건네고, contextConfigLocation 매개변수가 있으면 설정 위치로 넣고, 마지막 줄에서 wac.refresh()를 부릅니다(361–392행). 7편의 열두 단계가 여기서 돕니다.
  4. ServletContext 속성에 넣습니다(273행). 이 한 줄이 Tomcat의 세계와 Spring의 세계를 잇습니다.
  5. 실패하면 예외 객체를 같은 이름으로 넣습니다(292행). 나중에 꺼내려는 쪽이 "없다"가 아니라 "왜 실패했는지"를 알 수 있게 하기 위해서입니다. WebApplicationContextUtils.getWebApplicationContext는 꺼낸 값이 예외이면 그것을 다시 던집니다(109–128행).

루트 컨텍스트가 켜지는 순서. 서블릿 컨테이너가 웹 앱을 시작하면 META-INF/services 파일에서 SpringServletContainerInitializer를 찾고, 그것이 WebApplicationInitializer.onStartup을 부르고, 거기서 등록한 ContextLoaderListener의 contextInitialized가 ContextLoader.initWebApplicationContext를 부르고, 컨텍스트를 만들어 refresh한 뒤 ServletContext 속성에 저장한다. 나중에 WebApplicationContextUtils, DelegatingFilterProxy, DispatcherServlet이 그 속성에서 컨텍스트를 꺼내 쓴다. 노란 상자가 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

헷갈리기 쉬운 곳

6. 필터 — GenericFilterBean, OncePerRequestFilter, DelegatingFilterProxy

web.filter 패키지에는 바로 쓸 수 있는 필터와, 필터를 만들 때 상속하는 기반 클래스가 함께 있습니다. 기반 클래스는 아래에서 위로 세 층입니다.

GenericFilterBean — 필터를 빈처럼

서블릿 필터는 원래 컨테이너가 만들고 init(FilterConfig)로 설정을 넘겨받습니다. GenericFilterBean(80–81행)은 이 필터가 두 세계 어느 쪽에서 만들어져도 같은 방식으로 초기화되게 합니다.

두 길 모두 끝에서 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행

OncePerRequestFilter.doFilter의 판단 흐름. 건너뛸 디스패치이거나 shouldNotFilter가 true이면 아무것도 하지 않고 체인만 넘긴다. 아니면 요청 속성에 필터이름.FILTERED가 있는지 보고, 있으면 체인만 넘기고, 없으면 속성을 TRUE로 넣은 뒤 doFilterInternal을 부르고 finally에서 속성을 지운다. 노란 상자가 표시를 남기는 줄입니다. 초록 상자로 가는 두 길은 모두 "이번에는 일하지 않는다"입니다.

  1. 표시로 쓸 속성 이름을 정합니다. 필터 이름(없으면 클래스 이름)에 .FILTERED를 붙인 것입니다(168–174행).
  2. 건너뛰기로 한 디스패치(기본으로 ASYNC와 ERROR, 205–207행·216–218행)이거나 shouldNotFilter가 true이면 일하지 않고 넘깁니다.
  3. 요청에 이미 표시가 있으면 일하지 않고 넘깁니다.
  4. 표시가 없으면 표시를 남기고 doFilterInternal을 부릅니다. 하위 클래스가 구현하는 메서드이고, 인자가 처음부터 HttpServletRequest/HttpServletResponse라 캐스팅할 필요가 없습니다(228–230행).
  5. 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

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행

DelegatingFilterProxy가 일하는 방식. 서블릿 컨테이너가 필터 체인에서 DelegatingFilterProxy를 부르면, 프록시는 findWebApplicationContext로 ServletContext 속성에 있는 루트 WebApplicationContext를 찾고, initDelegate에서 targetBeanName으로 Filter 타입의 빈을 꺼낸 뒤, doFilter 호출을 그 빈에게 그대로 넘긴다. 빈은 chain.doFilter로 다음 필터나 서블릿에 넘긴다. 노란 상자는 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는 이 저장소와 별개의 프로젝트라 이 글에서 소스로 확인한 내용은 아닙니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

이름과 위치만 적어 둡니다. 리액티브용 변환기인 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는 그 과정의 끝, 컨트롤러가 돌려준 객체가 응답 본문이 되는 자리에서 다시 등장합니다.

다른 글