Backend 08

Spring MVC의 숨겨진 흐름: 요청이 응답이 되기까지

URL을 호출하면 내 메서드가 왜 실행되는지 중간 과정이 보이지 않는다.

근거 · 교안 p56-p57

Spring MVC의 숨겨진 흐름: 요청이 응답이 되기까지 대표 이미지

1장: DispatcherServlet의 첫인사: 요청은 어디로 가나?

솔라는 노트북 화면 한구석에 떠 있는 문장에 시선을 고정한 채, 마우스를 무의미하게 딸깍거렸다. 개발 서적에서 옮겨 적은, 이제는 익숙하기까지 한 문장이었다.

클라이언트의 요청을 DispatcherServlet이 받고 HandlerMapping을 통해 Controller를 검색한다.

방금 전 솔라는 간단한 스프링 부트 애플리케이션을 실행했다. 브라우저 주소창에 localhost:8080/hello를 입력하자, 화면에는 깔끔하게 “Hello, World!”가 나타났다. 자신의 코드에 있는 @GetMapping("/hello") 어노테이션이 붙은 hello() 메서드가 틀림없이 실행된 결과였다.

하지만 그게 다였다. 주소창의 URL과 내 코드의 메서드 사이, 그 거대한 암흑 공간에서 무슨 일이 일어나는지 알 수 없었다. 문장은 그저 ‘DispatcherServlet이 요청을 받는다’고 말할 뿐이었다. 당연한 소리처럼 들렸다. 요청이 허공으로 사라지는 게 아니라면 누군가는 받아야 할 테니까. 하지만 그 ‘받는다’는 행위가 도대체 무엇을 의미하는지, 그 이후는 어떻게 되는지 전혀 감이 오지 않았다. 마치 잘 짜인 마술 쇼의 트릭을 모른 채 박수만 치는 관객이 된 기분이었다.

“언니.”

거실에서 조용히 책을 읽던 루나가 고개를 들었다. 솔라는 화면을 가리키며 답답하다는 듯 말했다.

“이 문장, 꼭 유령 이야기 같아. ‘DispatcherServlet이 요청을 받는다’는데, 그게 끝이야. 내 hello() 메서드가 실행되는 건 알겠는데, 그 사이는 그냥 깜깜해. 요청이 들어오면 그냥 내 컨트롤러가 ‘짠’하고 나타나서 일하는 것 같아.”

솔라의 말에는 ‘자동으로’, ‘마법처럼’이라는 단어가 생략되어 있었다. 루나는 솔라의 노트북 화면으로 시선을 옮겼다. 한쪽에는 인텔리제이의 코드와 콘솔 창이, 다른 쪽에는 “Hello, World!”가 떠 있는 브라우저 창이 보였다. 루나는 잠시 솔라가 짚은 문장과 화면을 번갈아 보더니, 입을 열었다.

“그럼, 정말로 DispatcherServlet이 받았는지 확인해 보면 되겠네.”

“어떻게? 스프링 프레임워크 소스 코드를 다 뒤져야 해?”

솔라의 목소리에 막막함이 묻어났다. 루나는 고개를 저으며 솔라의 콘솔 창을 턱짓으로 가리켰다.

“아니, 더 쉬운 방법이 있어. 스프링 부트가 시작될 때 저 콘솔에 수많은 로그가 찍히잖아. 요청이 들어올 때도 마찬가지로 흔적을 남겨. 우리가 그 흔적을 보도록 신호를 보내주기만 하면 돼.”

루나는 솔라에게 application.properties 파일을 열고 한 줄을 추가하게 했다.

logging.level.org.springframework.web=DEBUG

“로그 레벨을 디버그로 바꾸면, 스프링의 웹 계층이 내부에서 무슨 일을 하는지 좀 더 수다스러워질 거야.”

솔라는 반신반의하며 애플리케이션을 재시작했다. 이전보다 훨씬 더 많은 양의 로그가 콘솔 창을 빠르게 스쳐 지나갔다. 그리고 브라우저로 돌아가 localhost:8080/hello 페이지를 새로고침했다. 그 순간, 콘솔 창에 새로운 로그들이 폭포수처럼 쏟아졌다.

“으, 뭐가 이렇게 많아.”

솔라가 미간을 찌푸리며 스크롤바를 위아래로 움직였다. 의미를 알 수 없는 대문자와 약어들이 가득했다. 루나가 화면의 한 부분을 손가락으로 짚었다.

“천천히 봐. ‘DispatcherServlet’이라는 단어를 찾아봐.”

솔라는 눈을 가늘게 뜨고 로그를 훑었다. 마침내 익숙한 단어가 눈에 들어왔다.

DEBUG ... o.s.web.servlet.DispatcherServlet : GET request for "/hello" DEBUG ... o.s.web.servlet.DispatcherServlet : Initializing framework servlet 'dispatcherServlet' DEBUG ... o.s.web.servlet.DispatcherServlet : Processing GET request for [/hello]

솔라의 손가락이 멈췄다. 자신의 브라우저 요청 GET /helloDispatcherServlet이라는 이름과 함께 로그에 선명하게 박혀 있었다. 막연한 개념인 줄 알았던 존재가 실제로 동작하며 남긴 기록이었다. 요청이 컨트롤러에 ‘마법처럼’ 도달하는 게 아니었다. 모든 요청을 맨 앞에서 맞이하는, 구체적인 이름과 역할을 가진 부품이 있었던 것이다.

“아… 진짜네. 그냥 책에 나오는 단어가 아니라, 내 모든 요청을 얘가 제일 먼저 보고 있었구나.”

솔라는 방금 전까지 자신을 답답하게 했던 문장을 다시 떠올렸다. ‘클라이언트의 요청을 DispatcherServlet이 받고…’ 이제 문장의 첫 구절은 더 이상 희미하지 않았다. 모든 요청을 받아 기록을 남기는 중앙 관문, 그 모습이 머릿속에 그려졌다. 불투명했던 과정의 입구를 발견한 느낌이었다.

안도감도 잠시, 솔라의 시선은 다시 로그와 코드, 그리고 원래의 문장을 오갔다. 새로운 궁금증이 꼬리를 물고 피어올랐다.

“알았어. DispatcherServlet이 문지기처럼 모든 요청을 가장 먼저 받는다는 건 이제 알겠어. 그런데 그 다음에… 이 많은 로그들 속에서 대체 어떻게@GetMapping("/hello") 메서드를 찾아내는 거지? 문지기가 방문객을 받았는데, 그 방문객이 누굴 만나러 왔는지 알아내야 하잖아. 그게 바로 ‘HandlerMapping을 통해 검색한다’는 부분인 건가?”

솔라의 질문은 더 이상 막연한 불만이 아니었다. 어둠 속에서 입구를 찾은 사람이 이제 복도 저편의 다음 문을 향해 던지는, 명확한 방향을 가진 질문이었다.

2장: HandlerMapping: 누가 내 컨트롤러를 찾아주나요?

솔라의 손가락이 키보드 위에서 분주하게 움직였다. 어제 DispatcherServlet의 존재를 확인했던 HelloController에 새로운 코드를 몇 줄 추가하는 중이었다.

@GetMapping("/bye")
public String bye() {
    return "Bye, World!";
}

이제 컨트롤러에는 /hello/bye라는 두 개의 길이 생겼다. 솔라는 애플리케이션을 다시 실행하고, 재빨리 브라우저에서 두 개의 탭을 열어 각각 localhost:8080/hellolocalhost:8080/bye를 요청했다. 화면에는 예상대로 “Hello, World!”와 “Bye, World!”가 각각 나타났다. 어제와 마찬가지로, 콘솔 창에는 디버그 로그가 쏟아졌다.

하지만 이제 솔라는 로그를 보는 관점이 달라져 있었다. 그녀는 DispatcherServlet이라는 단어가 포함된 줄들을 유심히 살폈다.

DEBUG ... o.s.web.servlet.DispatcherServlet : Processing GET request for [/hello] DEBUG ... o.s.web.servlet.DispatcherServlet : Processing GET request for [/bye]

중앙 관문인 DispatcherServlet이 두 요청을 모두 차별 없이 처리하고 있다는 증거였다. 어제는 이 사실을 발견한 것만으로도 큰 수확이었지만, 오늘은 달랐다. 두 개의 문이 생긴 지금, 새로운 질문이 선명하게 떠올랐다. 이 문지기는 방문객이 /hello로 왔는지, /bye로 왔는지 어떻게 알고 정확한 담당자에게 안내하는 걸까?

솔라는 다시금 책에서 봤던 문장을 떠올렸다. ...HandlerMapping을 통해 Controller를 검색한다. 문지기 다음 등장하는 역할, 핸들러 매핑. 이름만 봐서는 대충 ‘요청을 담당자(Handler)에게 연결(Mapping)해주는 역할’ 같았다. 하지만 여전히 그 방식이 불투명했다. ‘검색’이라는 단어는 여전히 마법처럼 느껴졌다.

“언니, 이것 좀 봐봐.”

솔라가 화면을 가리키자, 루나가 다가와 솔라의 어깨너머로 코드를 들여다보았다.

“DispatcherServlet이 /hello 요청이랑 /bye 요청을 둘 다 받는 건 알겠어. 그런데 그 다음에 대체 무슨 기준으로, 어떻게 내 hello() 메서드랑 bye() 메서드를 구분해서 찾아가는 걸까? 그냥 마법처럼 @GetMapping에 적힌 주소랑 연결되는 건가?”

솔라의 질문에는 ‘자동으로’ 연결되는 것 같다는 불신이 담겨 있었다. 루나는 고개를 끄덕이며 솔라의 코드에 있는 @GetMapping("/hello") 어노테이션을 손가락으로 가볍게 짚었다.

“마법은 아니야. 실마리는 솔라 네가 이미 코드에 다 적어놨어. DispatcherServlet은 요청 URL, 즉 ‘/hello’라는 정보를 들고 있지. 그리고 스프링은 시작될 때 네 코드를 전부 스캔해서 이 어노테이션 정보를 미리 다 읽어둬.”

“미리 읽어둔다고?”

“응. 마치 도서관을 개관하기 전에 모든 책의 제목과 위치를 색인 카드에 정리해두는 것처럼. 스프링은 애플리케이션에 있는 모든 @Controller@RequestMapping을 찾아서 ‘어떤 URL 패턴이 오면 어떤 클래스의 어떤 메서드를 실행해야 한다’는 정보를 거대한 목록으로 만들어두는 거야.”

루나는 솔라의 개발 도구(IDE)에서 디버거를 설정하도록 안내했다. 어제 로그를 확인한 것보다 한 걸음 더 들어가는 방법이었다. 루나는 DispatcherServlet 클래스의 doDispatch 메서드 안, getHandler라는 코드가 호출되는 줄에 중단점(breakpoint)을 찍게 했다.

“이제 다시 /hello를 요청해봐. 이번엔 프로그램이 이 지점에서 잠시 멈출 거야.”

솔라가 브라우저를 새로고침하자, 화면이 로딩 상태에서 멈추고 개발 도구 창이 깜빡였다. 프로그램의 실행 흐름이 정확히 getHandler 호출 직전에 멈춰 있었다.

“여기서 한 단계만 안으로 들어가 보자.”

솔라가 ‘Step Into’ 버튼을 누르자, 화면이 RequestMappingHandlerMapping이라는 클래스의 코드로 바뀌었다. 이름부터 ‘리퀘스트 매핑을 다루는 핸들러 매핑’이라는 정체성을 드러내고 있었다. 그리고 그 안에서 솔라는 놀라운 것을 발견했다. 디버거의 변수 창에 mappingRegistry라는 이름의 객체가 보였다. 그 안을 펼쳐보자, /hello/bye라는 URL 패턴을 키(key)로 하고, 각각 HelloController.hello()HelloController.bye() 메서드 정보를 값(value)으로 가지는 거대한 맵(Map) 데이터 구조가 들어 있었다.

“아!”

솔라의 입에서 짧은 탄성이 터져 나왔다.

“이거였구나! ‘검색’이라는 게 진짜 데이터베이스를 뒤지듯 복잡한 게 아니라, 그냥 미리 만들어둔 이 거대한 지도에서 요청 URL을 찾아보는 거였어! 도서관 색인 카드 비유가 딱 맞네!”

HandlerMapping은 더 이상 마법 상자가 아니었다. 애플리케이션 시작 시점에 @RequestMapping 같은 어노테이션을 샅샅이 뒤져 URL과 메서드의 대응 관계를 꼼꼼하게 정리해둔 ‘지도’ 그 자체였고, 요청이 들어왔을 때 그 지도에서 길을 찾아주는 ‘내비게이터’였다. 컨트롤러 자동 연결이라는 막연한 믿음은 깨지고, 그 자리에는 ‘어노테이션 기반의 사전 등록 및 조회’라는 명확한 원리가 자리 잡았다.

솔라는 중단점을 풀고 프로그램을 끝까지 실행시켰다. 브라우저에 “Hello, World!”가 뜨는 것을 확인한 그녀의 마음은 한결 가벼워졌다. 불투명한 과정의 두 번째 문을 연 기분이었다.

하지만 기쁨도 잠시, 솔라의 시선은 다시 디버거가 보여줬던 HandlerMethod 객체, 즉 HelloController.hello() 메서드 정보를 담고 있던 그 객체를 떠올렸다.

“좋아. HandlerMapping이 지도책을 뒤져서 내 hello() 메서드를 찾아냈어. 그리고 그 정보를 DispatcherServlet에게 돌려줬지. 그런데… 그래서 이제 뭐? 메서드를 찾기만 했지 아직 실행한 건 아니잖아. 그리고 내 메서드는 ‘Hello, World!’라는 문자열(String)을 반환하는데, 이 문자열은 어떻게 웹페이지가 되는 거지? 대체 누가 이 메서드를 호출하고 그 결과를 처리하는 거야?”

지도에서 목적지를 찾았지만, 아직 그곳에 도착한 것은 아니었다. 솔라의 머릿속에는 다음 단계로 향하는 새로운 질문이 피어오르고 있었다.

3장: Controller의 춤: 요청 처리와 응답 준비

솔라의 HelloController.java 파일은 어제와 사뭇 다른 모습이었다. 지도에서 목적지를 찾았지만, 그곳으로 가는 방법이 여러 갈래인 것처럼, 컨트롤러 안에도 세 개의 길이 나 있었다.

첫 번째는 익숙한 길이었다. String을 반환하는 helloPage() 메서드.

@GetMapping("/hello-page")
public String helloPage() {
    return "hello"; // View 이름 반환
}

두 번째는 솔라가 인터넷 예제를 보고 따라 만든, 조금 더 복잡해 보이는 길이었다. ModelAndView라는 낯선 객체를 직접 만들어 반환했다.

@GetMapping("/hello-model")
public ModelAndView helloModel() {
    ModelAndView mav = new ModelAndView("hello"); // View 이름과
    mav.addObject("message", "Hello from ModelAndView!"); // 데이터를 함께 담는다
    return mav;
}

마지막은 API 서버를 만들 때 쓴다는 ResponseEntity를 반환하는 길이었다.

@GetMapping("/hello-string")
@ResponseBody // 메서드의 반환 값을 HTTP 응답 본문에 직접 쓰도록 지시
public String helloString() {
    return "Hello, Raw String!"; // 데이터 자체를 반환
}

솔라는 세 개의 탭을 열어 각각 /hello-page, /hello-model, /hello-string을 요청했다. 결과는 제각각이었다. 처음 두 요청은 동일하게 “Hello, World!”가 적힌 웹페이지를 보여줬지만, 마지막 요청은 꾸밈없는 “Hello, Raw String!”이라는 텍스트만 덩그러니 화면에 나타났다. 분명 HandlerMapping이 이 세 메서드를 찾아냈을 것이다. 하지만 그 이후가 문제였다. 메서드를 찾았으면 실행해야 하는데, 대체 누가 실행하며, 실행한 뒤에 나오는 이 제각각인 결과물들(String, ModelAndView, String)은 어떻게 처리되는 걸까. 특히 첫 번째와 두 번째는 다른 것을 반환하는데도 왜 결과는 같은 웹페이지일까? 컨트롤러가 직접 웹페이지를 만들어 보내는 줄 알았는데, 그게 아닌 것 같았다.

“언니.”

부엌에서 커피를 내리던 루나가 솔라의 목소리에 뒤를 돌아봤다. 솔라는 혼란스러운 표정으로 자신의 화면을 가리켰다.

HandlerMapping이 메서드를 찾아준 것까진 알겠어. 그런데 그 다음이 이상해. 어떤 녀석은 String을 반환하고, 어떤 녀석은 ModelAndView라는 걸 반환하는데 똑같은 화면이 나와. 또 어떤 녀석은 String을 반환하는데 그냥 글자만 뱉어내. 메서드를 실행하는 건 DispatcherServlet이 하는 건가? 그리고 이 반환 값들은 대체 뭐야? 약속된 신호라도 있는 것처럼 다 다르게 행동하잖아.”

‘응답 직접 생성’이라는 솔라의 초기 모델이 삐걱거리기 시작한 것이다. 루나는 커피 잔을 들고 솔라 옆에 앉았다. 그녀는 복잡하게 얽힌 세 개의 길을 잠시 들여다보더니, 첫 번째 메서드인 helloPage()를 짚었다.

“DispatcherServlet이 하는 일이 맞아. HandlerMapping이 찾아준 메서드 정보를 바탕으로, 실제 메서드를 실행하는 역할까지 하지. 그런데 중요한 건 그 다음이야. 컨트롤러 메서드가 일을 마치고 무언가를 돌려주잖아. 그건 최종 결과물이 아니야. DispatcherServlet에게 주는 ‘다음 일을 위한 지시서’에 가까워.”

“지시서?”

“응. 직접 확인해보자.”

루나는 어제처럼 디버거를 설정하게 했다. 이번에는 DispatcherServletdoDispatch 메서드 안, mv = ha.handle(processedRequest, response, mappedHandler) 라는 줄에 중단점을 걸었다. ha.handle이 바로 HandlerMapping이 찾아준 컨트롤러 메서드를 실행하는 부분이었다.

솔라가 /hello-page를 요청하자, 프로그램은 어김없이 멈췄다. 솔라가 ‘Step Over’ 버튼을 눌러 ha.handle 라인을 실행시키자, mv라는 변수에 값이 채워졌다. 솔라는 변수 창을 보고 눈을 동그랗게 떴다.

mv = {ModelAndView@1234} viewName="hello"; model={}

“어? 내 helloPage() 메서드는 분명 String 타입의 “hello”를 반환했는데, 왜 ModelAndView라는 객체로 바뀌어있지?”

“바로 그거야.” 루나가 말했다. “솔라 네가 String을 반환하면, DispatcherServlet은 ‘아, 개발자가 보여줄 페이지의 이름만 알려줬구나. 그럼 내가 이걸 ModelAndView 객체로 포장해서 다음 단계로 넘겨야겠다’라고 판단하는 거야. 네가 반환한 문자열 “hello”는 응답 본문이 아니라, 다음에 처리할 ‘뷰(View)의 논리적인 이름’이라는 신호였던 거지.”

솔라는 고개를 끄덕이며 다음 실험으로 넘어갔다. 이번엔 /hello-model을 요청했다. 다시 프로그램이 멈추고 ha.handle을 실행했다. mv 변수에는 이번에도 ModelAndView 객체가 담겼다.

mv = {ModelAndView@5678} viewName="hello"; model={message="Hello from ModelAndView!"}

이번에는 놀랍지 않았다. 솔라가 코드에서 직접 ModelAndView 객체를 만들어 반환했으니 당연한 결과였다. 하지만 첫 번째 실험과 비교하자 의미가 명확해졌다. 첫 번째 경우는 DispatcherServlet이 암묵적으로 해주던 포장 작업을, 두 번째 경우는 개발자가 명시적으로 직접 한 것이었다. 둘 다 결국 DispatcherServlet에게 “hello라는 이름의 뷰를 찾아서, 이 모델 데이터를 사용해 화면을 만들어줘”라는 동일한 지시서를 전달한 셈이었다.

마지막으로 솔라는 /hello-string을 요청했다. @ResponseBody가 붙어있던 메서드였다. 다시 ha.handle을 실행하자, 이전과는 다른 일이 벌어졌다. mv 변수에 null이 할당되었다. ModelAndView 객체가 만들어지지 않은 것이다.

“이번엔 ModelAndView가 없네?”

“응. @ResponseBody 어노테이션 때문이야. 저 어노테이션은 DispatcherServlet에게 보내는 또 다른 종류의 신호거든. ‘이번엔 뷰를 찾거나 화면을 만들 필요 없어. 내가 반환하는 이 문자열 데이터를 그냥 그대로 응답 본문에 실어서 보내줘’라는 뜻이지.”

그제야 솔라의 머릿속에 흩어져 있던 조각들이 맞춰졌다. 컨트롤러의 메서드 반환 값은 최종 결과물이 아니었다. 그것은 중앙 관제탑인 DispatcherServlet에게 다음에 무엇을 해야 할지 알려주는, 약속된 형식의 ‘신호’ 또는 ‘지시서’였다. String을 반환하면 뷰 이름으로 해석하고, ModelAndView를 반환하면 뷰 이름과 데이터를 담은 지시서로, @ResponseBody와 함께 데이터를 반환하면 뷰 처리 과정을 건너뛰라는 신호로 받아들이는 것이다.

컨트롤러는 응답을 직접 생성하는 공장이 아니라, 요청을 처리하고 다음 단계를 지시하는 똑똑한 중간 관리자였던 셈이다. 불투명했던 과정의 세 번째 문이 열리는 순간이었다.

“알겠다! 컨트롤러가 춤을 추면, DispatcherServlet이 그 춤을 보고 다음에 틀 음악을 고르는 거였네. 반환 타입이 바로 그 춤의 종류였던 거고.”

솔라는 만족스러운 표정으로 ModelAndView 객체에 담겨있던 viewName="hello" 부분을 바라보았다. 새로운 의문이 자연스럽게 고개를 들었다.

“좋아, 이제 DispatcherServlet이 ‘hello’라는 이름의 뷰를 처리해야 한다는 것까지는 알겠어. 그런데… 내 프로젝트 어디에도 ‘hello’라는 파일은 없잖아. hello.html 파일만 있을 뿐인데. DispatcherServlet은 대체 어떻게 ‘hello’라는 이름만 가지고 수많은 파일들 속에서 정확히 resources/templates/hello.html 파일을 찾아내는 거지?”

지시서를 해독했지만, 지시서에 적힌 장소를 찾아가는 방법은 아직 미지수였다. 솔라의 탐험은 다음 복도를 향하고 있었다.

4장: ViewResolver: 퍼즐 조각 맞추기 (View 찾기)

솔라는 자신의 IDE 화면을 두 개로 나눠 띄웠다. 왼쪽에는 어제 디버거가 마지막으로 보여주었던 ModelAndView 객체의 내용이 기억 속에 선명했고, 오른쪽에는 프로젝트의 파일 탐색기 창이 열려 있었다. 기억 속의 객체는 viewName="hello"라는 정보를 담고 있었다. 하지만 오른쪽 창에 보이는 실제 파일의 이름은 hello.html이었고, 그마저도 src/main/resources/templates/라는 여러 폴더 깊숙한 곳에 자리 잡고 있었다.

솔라는 고개를 갸웃거렸다. 논리적인 이름 ‘hello’와 물리적인 경로 …/templates/hello.html. 둘 사이에는 명백한 간격이 있었다. ‘자동으로’ 찾아준다고 생각하기엔, 그 과정이 너무나 작위적이었다. 컴퓨터가 ‘hello’라는 이름표를 들고 온갖 폴더를 뒤지다가, 우연히 이름이 비슷한 hello.html 파일을 발견하고는 ‘이건가?’ 하고 추측할 리는 없었다. 만약 그랬다면, hello.jsphello.txt 같은 파일이 있을 때 어떤 혼란이 벌어질까. ‘뷰 자동 감지’라는 말은 편리하지만 현실적이지 않았다.

“언니, 이거 좀 이상해.”

솔라가 화면을 들여다보던 루나에게 말했다.

“어제 DispatcherServlet이 ‘hello’라는 뷰 이름을 받은 것까지는 확인했어. 그런데 내 파일은 ‘hello.html’이고, ‘templates’ 폴더 안에 있단 말이야. 대체 DispatcherServlet은 ‘hello’라는 이름만 가지고 어떻게 이 파일의 정확한 위치와 확장자를 알아내는 거지? 내가 파일 이름을 greeting.html로 바꾸면 못 찾는 거 아닐까?”

솔라의 질문은 더 이상 막연한 궁금증이 아니었다. 컨트롤러가 반환한 값과 실제 파일 사이의 연결 고리가 빠져있다는, 구체적인 문제 제기였다. 루나는 말없이 솔라의 프로젝트 탐색기에서 다른 파일을 하나 클릭했다. application.properties 파일이었다. 로그 레벨을 바꿀 때 한 번 열어봤던, 텅 비어 있거나 몇 줄의 설정만 적혀 있는 그 파일이었다.

“DispatcherServlet이 혼자서 모든 일을 하지는 않아. 자기가 모르는 건 다른 전문가에게 물어보지. 예를 들어, 길을 찾을 땐 내비게이션 전문가에게 물어보는 것처럼. 뷰의 위치를 찾는 일도 마찬가지야.”

루나는 솔라가 application.properties 파일에 다음 두 줄을 직접 입력하게 했다.

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

“스프링 부트는 우리가 아무 설정도 하지 않으면, 내부적으로 이런 규칙을 기본으로 사용하고 있어. 우리가 직접 써넣음으로써 그 규칙을 눈으로 확인하는 거야.”

솔라는 자신이 입력한 두 줄의 코드와 파일 탐색기의 폴더 구조를 번갈아 보았다. prefix는 ‘접두사’, suffix는 ‘접미사’. 단어의 뜻은 명확했다. 잠시 두 개의 정보를 맞추어보던 솔라의 눈이 커졌다.

“아…!”

그녀는 마치 암호 해독표를 발견한 사람처럼 손가락으로 화면을 짚어가며 말했다.

“설마, 이럴 수가. prefix에 적힌 classpath:/templates/랑… 컨트롤러가 반환한 뷰 이름 'hello'랑… suffix에 적힌 .html을… 그냥 순서대로 합치는 거야?”

솔라의 머릿속에서 세 개의 문자열 조각이 하나의 완성된 그림으로 합쳐졌다.

classpath:/templates/ + hello + .html = classpath:/templates/hello.html

마법은 없었다. 거창한 인공지능 검색도 아니었다. 그저 약속된 규칙에 따라 문자열을 조립하는, 지극히 단순하고 기계적인 작업이었을 뿐이다. 그제야 ViewResolver(뷰 해석기)라는 이름의 역할이 명확하게 이해되기 시작했다. ViewResolver는 파일을 ‘검색’하는 탐정이 아니라, 논리적인 이름에 설정된 앞뒤 장식을 붙여 물리적인 경로를 ‘조립’하는 건축가에 가까웠다.

“바로 그거야. DispatcherServlet은 컨트롤러로부터 hello라는 뷰 이름을 받으면, ViewResolver라는 부품에게 그 이름을 그대로 전달해. 그럼 ViewResolver는 자기가 가진 설정, 즉 prefix와 suffix 규칙에 따라 완전한 파일 경로를 만들어내서 다시 DispatcherServlet에게 알려주는 거지. ‘네가 찾던 뷰는 이 경로에 있어’ 하고.”

솔라는 자신이 가졌던 ‘뷰 자동 감지’라는 생각이 얼마나 막연했는지 깨달았다. 시스템은 추측하지 않았다. 단지 정해진 규칙을 따를 뿐이었다. 이제 솔라는 컨트롤러가 return "user/profile"과 같은 문자열을 반환했을 때, ViewResolver가 어떤 경로의 파일을 찾아 나설지 정확히 예측할 수 있었다. /templates/user/profile.html 파일을 찾을 것이다. 이 규칙을 이해하자, 복잡해 보였던 프로젝트의 폴더 구조가 오히려 명확하고 질서정연하게 느껴졌다.

불투명했던 과정의 네 번째 문이 열렸다. 이제 요청이 컨트롤러를 거쳐 화면을 그릴 파일까지 도달하는 여정의 대부분이 밝혀졌다.

솔라는 만족스럽게 고개를 끄덕였다. 하지만 시선은 다시 어제 보았던 ModelAndView 객체의 또 다른 부분, model={message="Hello from ModelAndView!"}에 머물렀다.

“좋아. 이제 hello.html이라는 텅 빈 설계도를 찾아냈어. 그런데 컨트롤러에서 보낸 ‘Hello from ModelAndView!’ 같은 데이터는 이 설계도 위에 어떻게 그려지는 거지? 파일을 찾기만 했지, 아직 아무것도 채워지지 않았잖아. 이 데이터가 채워지고, 완성된 HTML 문서가 되어서 내 브라우저까지 돌아오려면 또 누가 일해야 하는 거야?”

목적지에 도착했지만, 아직 건물 안으로 들어가 불을 켜지는 못했다. 솔라의 여정은 마지막 관문을 눈앞에 두고 있었다.

5장: 응답의 완성: 브라우저로 돌아가기

솔라의 손가락이 키보드 위에서 멈칫했다. 화면에는 hello.html 파일의 내용이 떠 있었다. 어제 ViewResolver의 비밀을 파헤친 덕분에, 이제 이 파일이 어떻게 선택되었는지는 명확했다. 하지만 파일의 내용은 너무나 평범했다.

<!DOCTYPE html>
<html>
<head>
    <title>Hello</title>
</head>
<body>
    <p>Hello, World!</p>
</body>
</html>

이건 그냥 텅 빈 설계도였다. 반면, 솔라의 머릿속에는 컨트롤러가 애써 만들었던 ModelAndView 객체의 모습이 선명했다. 그 객체 안에는 model={message="Hello from ModelAndView!"}라는, 이 설계도를 채울 소중한 데이터가 들어있었다. 설계도는 찾았고, 데이터도 준비되었다. 하지만 설계도 위에 데이터를 그려 넣는 마지막 작업자가 누구인지 알 수 없었다. ‘파일을 찾으면 웹페이지가 저절로 나타난다’는 생각은 이제 통하지 않았다. 빈 설계도와 데이터 꾸러미 사이에는 아직 건너지 못한 강이 흐르고 있었다.

솔라는 행동하기로 마음먹었다. 이 텅 빈 설계도가 데이터를 인식하게 만들 실마리를 직접 심어보기로 했다. 그녀는 인터넷에서 본 기억을 더듬어 hello.html 파일의 <p> 태그를 수정했다.

<p th:text="${message}">Default Text</p>

th:text="${message}". 타임리프(Thymeleaf) 템플릿 엔진의 문법이었다. ‘message라는 이름의 데이터를 찾아서 이 태그의 텍스트로 채워줘’라는 의미의 약속된 신호. 솔라는 애플리케이션을 재시작하고, /hello-model 경로로 다시 접속했다. 화면에는 놀랍게도 “Hello from ModelAndView!”라는 문구가 선명하게 나타났다.

“됐다! 되긴 되는데…”

성공의 기쁨도 잠시, 솔라의 미간은 다시 좁혀졌다. 과정의 마지막 조각이 여전히 불투명했다. 템플릿 파일이 마법처럼 데이터를 빨아들여 스스로 완성된 페이지가 된 것 같았다. 마지막 단계에 또 다른 ‘마법’이 숨어있는 기분이었다.

“언니, 내가 hello.html 파일에 신호를 심어두니까 데이터가 제대로 표시돼. 그런데 여전히 이상해. ViewResolver는 파일 위치만 알려줬잖아. 그럼 DispatcherServlet이 이 th:text 같은 문법을 해석해서 HTML을 완성한 다음 보내주는 거야? 아니면 또 다른 누군가가 있는 거야? 마치 설계도를 찾고 나서, 그걸 채색하는 전문 화가가 따로 있는 것처럼 말이야.”

부엌에서 돌아온 루나는 솔라의 화면과 질문을 잠시 들여다보았다. 그녀는 고개를 끄덕이며 솔라의 브라우저를 가리켰다.

“화가가 따로 있는 게 맞아. 그리고 DispatcherServlet은 그 화가를 고용해서 일을 시키는 총감독이지. 마지막 퍼즐을 맞춰보자. 그 페이지에서 마우스 오른쪽 버튼을 누르고 ‘페이지 소스 보기’를 해봐.”

솔라가 소스 보기를 클릭하자, 새로운 탭에 코드가 나타났다.

<!DOCTYPE html>
<html>
<head>
    <title>Hello</title>
</head>
<body>
    <p>Hello from ModelAndView!</p>
</body>
</html>

솔라의 눈이 커졌다. 자신이 작성했던 th:text="${message}" 속성은 온데간데없고, 그 자리에 컨트롤러가 보낸 데이터가 채워진, 완전하고 깨끗한 HTML만 남아있었다. 브라우저가 받은 것은 타임리프 문법이 담긴 ‘템플릿’이 아니라, 모든 작업이 끝난 ‘완성된 HTML 문서’였다.

“아…”

“바로 이거야.” 루나가 말했다. “DispatcherServlet은 ViewResolver에게서 hello.html의 위치 정보를 받은 게 아니야. 정확히는 그 위치에 있는 템플릿을 처리할 수 있는 View 객체, 예를 들면 ThymeleafView라는 전문가를 소개받은 거지. 그럼 DispatcherServlet은 그 View 전문가에게 컨트롤러가 남긴 데이터(Model) 꾸러미를 통째로 넘기면서 말하는 거야. ‘이 데이터로 이 템플릿을 채워서 최종 결과물을 만들어줘’라고.”

그제야 모든 조각이 제자리를 찾았다. 마지막 단계는 단순히 파일을 전송하는 것이 아니었다. View라는 이름의 렌더링 전문가가 수행하는 능동적인 작업이었다.

  1. DispatcherServletViewResolver를 통해 View 객체를 얻는다.
  2. DispatcherServletView 객체의 render() 메서드를 호출하며, 컨트롤러가 전달한 Model 데이터와 최종 응답을 담을 HttpServletResponse 객체를 넘긴다.
  3. View 객체(정확히는 그 안의 템플릿 엔진)는 Model의 데이터로 템플릿 파일의 빈칸을 모두 채워 완전한 HTML 문자열을 생성한다.
  4. View 객체는 이 완성된 HTML 문자열을 HttpServletResponse 객체에 실어 보낸다.

솔라의 머릿속에서 마침내 전체 흐름이 하나의 그림으로 완성되었다. 그녀는 홀린 듯 책상 위 메모지를 가져와 그림을 그리기 시작했다. 중앙에는 DispatcherServlet이라는 큰 상자를 그렸다.

  • 클라이언트 요청DispatcherServlet
  • DispatcherServletHandlerMapping : (URL에 맞는 Controller 메서드 정보 획득)
  • DispatcherServletHandlerAdapter : (Controller 메서드 실행, ModelAndView 획득)
  • DispatcherServletViewResolver : (viewName으로 View 객체 획득)
  • DispatcherServletView.render() : (Model 데이터로 HTML 생성 후 Response에 담기)
  • DispatcherServlet클라이언트 응답

더 이상 막연한 문장이 아니었다. 각 부품의 역할과 그들이 주고받는 신호가 명확히 보이는, 살아 움직이는 시스템의 설계도였다.

솔라는 자신이 그린 흐름도를 보며 중얼거렸다. “만약 페이지를 찾을 수 없다는 404 에러가 나면… 문제는 HandlerMapping이 내 URL에 맞는 컨트롤러를 못 찾았을 가능성이 크겠네. 그리고… 페이지는 뜨는데 내가 보낸 데이터가 안 보인다면, 컨트롤러가 Model에 데이터를 제대로 안 넣었거나, 아니면 hello.html 파일의 th:text 같은 곳에 오타가 나서 View가 렌더링을 못한 거겠구나.”

루나는 말없이 솔라가 그린 다이어그램을 보며 미소 지었다. 답답했던 검은 상자는 사라졌다. 그 자리에는 이제 어떤 문제가 발생했을 때 어디를 살펴봐야 할지 알려주는, 솔라 자신이 직접 그린 명확한 지도가 놓여 있었다. 요청이 응답이 되기까지의 기나긴 여정이, 마침내 그 끝을 보였다.