이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열세 번째 글입니다(바로 앞 글은 12편: 비동기 서블릿과 논블로킹 I/O입니다). 9편에서는 밸브 사슬의 끝에서 필터 체인을 지나 서블릿의 service()가 불리는 데까지 봤습니다. 그때 서블릿은 우리가 직접 짜서 컴파일해 둔 클래스였습니다. 그런데 .jsp 파일은 아무도 컴파일하지 않았는데도 요청하면 응답이 나옵니다. 이번에는 Tomcat의 JSP 엔진 Jasper가 그 사이에서 하는 일을 따라갑니다.
답하려는 질문은 네 가지입니다.
.jsp요청은 어느 서블릿이 받고, JSP 파일 하나하나는 그 안에서 어떻게 관리되는가- JSP 파일은 어떤 단계를 거쳐
.java가 되고.class가 되는가. 그 파일들은 어디에 남는가 - JSP의
<% %>,<%= %>,${ }는 자바 코드의 무엇으로 바뀌는가 - JSP를 고치면 언제, 무엇을 보고 다시 컴파일하는가. 운영 환경에서는 무엇이 다른가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main, 개발판 12.0.0-M1-dev) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | jasper/servlet/JspServlet, jasper/servlet/JspServletWrapper, jasper/JspCompilationContext, jasper/compiler/Compiler·Parser·Generator·JDTCompiler·JspRuntimeContext |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 글에서 인용한 Jasper 파일들은 안정판 11.0.x 브랜치와 내용이 같습니다(파일을 비교해 확인했습니다). 다른 곳은 JDTCompiler의 코드 모양과 기본 자바 버전 하나뿐이고, 해당하는 절에서 밝힙니다.
org/apache/jasper는 파일 109개, 4만 줄이 넘는 패키지이고(1편의 표), 자바 코드를 쓰는 Generator 하나만 4,235줄입니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 태그 라이브러리, 태그 파일, XML 문법의 JSP 문서처럼 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
JSP(Jakarta Server Pages)는 HTML 같은 글자 사이에 자바 코드를 끼워 넣은 파일입니다. <% ... %> 안에 자바 문장을, <%= ... %> 안에 출력할 자바 식을 적습니다. 브라우저에는 자바 코드가 아니라 그 코드를 실행한 결과만 갑니다. 규격에서는 JSP 파일 하나를 번역 단위(translation unit)라고 부르고, 이것을 서블릿 클래스로 번역해서 실행하라고 정해 둡니다. Tomcat에서 그 번역을 맡은 것이 Jasper입니다.
JSP 스펙의 판은 두 군데가 다르게 말합니다. 이 커밋의 build.xml은 JSP 4.1(초안)이라고 적어 두었지만(1편), Jasper 안의 상수 org.apache.jasper.Constants.SPEC_VERSION은 "4.0"입니다(Constants.java 29행). 생성 코드가 넣는 X-Powered-By 헤더는 이 상수를 쓰지 않고 "JSP/4.0"이라는 글자를 그대로 적습니다(Generator.java 799–801행). 예제 이미지에서 xpoweredBy를 true로 켜고 JSP를 요청해 보니 X-Powered-By: JSP/4.0이 왔습니다. 서블릿 스펙에서 build.xml의 6.2와 ServletContext가 답하는 6.1이 다른 것과 같은 모양이고, 그 이야기는 1편의 "서블릿 스펙은 몇 판인가" 절에 있습니다.
지시어(directive)는 <%@ ... %> 모양으로, 출력이 아니라 "이 페이지를 어떻게 번역할지"를 알려 줍니다. <%@ page contentType="..." %>는 응답의 Content-Type을, <%@ include file="..." %>는 다른 파일의 내용을 번역할 때 그 자리에 붙여 넣으라는 뜻입니다.
EL(Expression Language, 표현 언어)은 ${param.who}처럼 ${ } 안에 적는 짧은 식입니다. 자바 문법 없이 요청 파라미터나 속성을 꺼낼 수 있게 만든 언어이고, Tomcat에서는 org.apache.el 패키지가 구현합니다. Spring의 SpEL(#{...})과 생김새가 닮았는데, SpEL은 Spring 15편에서 봤습니다.
ecj(Eclipse Compiler for Java)는 Eclipse가 만든 자바 컴파일러입니다. javac와 달리 라이브러리로 불러 쓰기 쉬워서, Tomcat은 이것을 lib/ecj-4.41.jar로 함께 싣고(1편의 lib 목록) 같은 JVM 안에서 JSP를 컴파일합니다.
work 폴더(작업 폴더)는 Tomcat이 웹앱마다 하나씩 주는 임시 폴더입니다. 기본 위치는 $CATALINA_BASE/work/<엔진 이름>/<호스트 이름>/<웹앱 이름>이고, Jasper는 생성한 .java와 .class를 여기에 둡니다. 서블릿 규격은 이 폴더를 jakarta.servlet.context.tempdir 속성(ServletContext.TEMPDIR)으로 웹앱에 알려 주라고 정해 두었습니다.
Wrapper는 서블릿 정의 하나를 맡는 컨테이너입니다(2편). 요청 URL로 Wrapper를 고르는 규칙 — 정확히 일치, 경로 접두사, 확장자, 기본 — 은 7편에서 봤습니다. JSP는 이 가운데 확장자 규칙으로 고릅니다.
1. 모든 JSP를 받는 서블릿 하나: JspServlet
conf/web.xml에 이미 적혀 있다
JSP를 쓰려고 웹앱의 web.xml에 무언가를 적은 적이 없을 겁니다. Tomcat의 전역 conf/web.xml이 모든 웹앱에 이미 서블릿 하나를 등록해 두었기 때문입니다(10편에서 본 것처럼 이 파일은 웹앱마다 web.xml보다 먼저 합쳐집니다).
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>fork</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>xpoweredBy</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
이름이 jsp인 서블릿 하나가 *.jsp와 *.jspx를 모두 받습니다. 웹앱에 JSP가 100개 있어도 Tomcat의 컨테이너 계층에서 보면 Wrapper는 이 jsp 하나뿐입니다. 그 위의 주석(137–266행)에는 이 서블릿이 받는 초기화 파라미터가 기본값과 함께 정리되어 있어, 4절에서 볼 development, checkInterval, modificationTestInterval도 여기서 찾을 수 있습니다.
웹앱이 자기 web.xml에 이름이 같은 jsp 서블릿을 다시 적으면 전역 것을 갈아 끼웁니다. StandardContext.addChild()는 이름이 jsp인 자식이 들어오면 옛것을 지우고, 옛것의 매핑(*.jsp, *.jspx)을 새것에 그대로 물려줍니다(StandardContext.java 2834–2855행). 웹앱 하나만 JSP 설정을 바꾸고 싶을 때 쓰는 방법입니다.
파란 상자가 Jasper의 길입니다. 노란 상자는 JSP마다 하나씩 만든 객체를 보관하는 곳입니다.
JspServlet.service() — JSP 주소를 알아내고 넘긴다
JspServlet.service()가 하는 일은 많지 않습니다. 어느 JSP를 요청했는지 알아내고(include로 불렸으면 jakarta.servlet.include.servlet_path 속성에서, 아니면 getServletPath()와 getPathInfo()에서, 247–298행), serviceJspFile()에 넘깁니다.
private void serviceJspFile(HttpServletRequest request, HttpServletResponse response, String jspUri,
boolean precompile) throws ServletException, IOException {
JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
synchronized (this) {
wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
// Check if the requested JSP page exists, to avoid
// creating unnecessary directories and files.
if (null == context.getResource(jspUri)) {
handleMissingResource(request, response, jspUri);
return;
}
wrapper = new JspServletWrapper(config, options, jspUri, rctxt);
rctxt.addWrapper(jspUri, wrapper);
}
}
}
try {
wrapper.service(request, response, precompile);
} catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
}
}
java/org/apache/jasper/servlet/JspServlet.java 318–344행
rctxt는 웹앱마다 하나 있는 JspRuntimeContext이고, init()에서 만듭니다(105행). 안에 jspUri → JspServletWrapper 지도(ConcurrentHashMap, JspRuntimeContext.java 135행)를 들고 있습니다. 처음 보는 JSP면 JspServletWrapper를 하나 만들어 지도에 넣습니다. 이름이 헷갈리기 쉬운데, 이것은 Tomcat 컨테이너의 StandardWrapper가 아니라 Jasper가 JSP 파일 하나마다 만드는 관리 객체입니다. 그 JSP의 컴파일 상태, 실은 클래스, 서블릿 인스턴스를 들고 있습니다.
맨 처음 getWrapper()를 잠금 없이 한 번 보고, 없을 때만 synchronized 안에서 다시 보는 모양은 "이중 확인 잠금"(double-checked locking)입니다. 이미 만든 뒤의 요청은 잠금 없이 지나가고, 처음 몇 요청이 동시에 와도 Wrapper는 하나만 만들어집니다. 파일이 없으면 Wrapper를 만들지도 않고 404를 보냅니다(handleMissingResource(), 347–366행).
JspServletWrapper.service() — (1) 컴파일, (2) 싣기, (3) 부르기
이제 JSP 하나의 일입니다. 소스 주석이 번호를 붙여 두었습니다.
/*
* (1) Compile
*/
if (options.getDevelopment() || mustCompile) {
synchronized (this) {
if (options.getDevelopment() || mustCompile) {
// The following sets reload to true, if necessary
ctxt.compile();
mustCompile = false;
}
}
} else {
if (compileException != null) {
// Throw cached compilation exception
throw compileException;
}
}
/*
* (2) (Re)load servlet class file
*/
servlet = getServlet();
java/org/apache/jasper/servlet/JspServletWrapper.java 433–454행
- 컴파일 —
ctxt는 이 JSP의JspCompilationContext입니다.compile()은 "낡았으면".java와.class를 새로 만듭니다(2절). 조건에options.getDevelopment()가 있는 것을 기억해 두세요. 개발 모드(기본값)면 요청마다 이 줄에 들어오고, 아니면mustCompile이true인 첫 요청에만 들어옵니다. 4절의 주인공입니다. - 싣기 —
getServlet()은 클래스를 아직 싣지 않았거나 다시 실어야 할 때(reload) 새 인스턴스를 만들고init(config)를 부릅니다(183–213행). 클래스는ctxt.getJspLoader()가 주는JasperLoader로 싣습니다(195행).JasperLoader는 JSP마다 하나씩 만드는 작은 클래스로더로,org.apache.jsp패키지의 클래스만 스스로 싣고 나머지는 부모인 웹앱 클래스로더(4편)에게 넘깁니다(JasperLoader.java89–99행). - 부르기 —
servlet.service(request, response)(501행). 이servlet은 JSP를 번역한 클래스(hello_jsp)의 인스턴스입니다.
그 사이의 (3)번 블록(476–496행)은 maxLoadedJsps나 jspIdleTimeout을 켰을 때 오래 안 쓴 JSP를 내리기 위한 기록이고, 기본값(둘 다 -1)에서는 건너뜁니다.
생성된 클래스는 모두 HttpJspBase를 상속합니다. 이 추상 클래스는 서블릿의 init(), service(), destroy()를 final로 막아 두고 JSP 쪽 메서드로 넘깁니다.
@Override
public final void init(ServletConfig config) throws ServletException {
super.init(config);
jspInit();
_jspInit();
}
// ...
@Override
public final void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
_jspService(request, response);
}
java/org/apache/jasper/runtime/HttpJspBase.java 46–68행
JSP 본문은 _jspService()라는 메서드 하나가 됩니다(3절). doGet()과 doPost()로 나뉘지 않는 이유가 여기 있습니다.
예제 1: JSP 안에서 찍은 호출 스택
정말 이 길로 오는지, JSP 안에서 스택을 찍어 확인했습니다. 이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 예제 폴더는 ex/u13-jasper-jsp/이고, app/ 아래의 JSP 파일들을 $CATALINA_BASE/webapps/demo에 두고 Tomcat을 띄워 curl로 요청합니다.
<%-- 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다) --%>
<%@ page contentType="text/plain; charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%
// 지금 이 줄을 실행하는 스레드의 호출 스택을, StandardWrapperValve 까지만 찍는다
for (StackTraceElement e : new Throwable().getStackTrace()) {
out.println("at " + e.getClassName() + "." + e.getMethodName());
if (e.getClassName().endsWith("StandardWrapperValve")) {
break;
}
}
%>
[예제 ex/u13-jasper-jsp/app/stack.jsp]
trimDirectiveWhitespaces="true"는 지시어 줄이 남기는 빈 줄을 출력에서 지워 달라는 page 지시어 속성입니다.
===== stack.jsp =====
at org.apache.jsp.stack_jsp._jspService
at org.apache.jasper.runtime.HttpJspBase.service
at jakarta.servlet.http.HttpServlet.service
at org.apache.jasper.servlet.JspServletWrapper.service
at org.apache.jasper.servlet.JspServlet.serviceJspFile
at org.apache.jasper.servlet.JspServlet.service
at jakarta.servlet.http.HttpServlet.service
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.tomcat.websocket.server.WsFilter.doFilter
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.catalina.core.StandardWrapperValve.invoke
아래에서 위로 읽으면 9편에서 본 StandardWrapperValve → 필터 체인(WebSocket 지원이 걸어 둔 WsFilter가 하나 끼어 있습니다, 15편) → JspServlet → JspServletWrapper → HttpJspBase.service() → stack_jsp._jspService()입니다. HttpServlet.service가 두 번 보이는 것은 ServletRequest를 받는 판이 HttpServletRequest를 받는 판을 부르기 때문입니다(JspServlet도 HttpJspBase도 뒤의 것을 고쳐 썼습니다).
헷갈리기 쉬운 곳: 필터는 JSP가 아니라 JspServlet 앞에 선다
스택에서 보듯 Tomcat이 아는 서블릿은 JspServlet이고, stack_jsp는 JspServletWrapper가 직접 부르는 객체입니다. stack_jsp는 컨테이너의 Wrapper로 등록되지 않으므로, 서블릿 이름으로 거는 필터 매핑(<servlet-name>)은 jsp라는 이름을 대상으로 해야 걸립니다. URL 패턴으로 건 필터는 평소대로 *.jsp 요청에 걸립니다.
2. JSP가 .java와 .class가 되기까지
JspCompilationContext.compile() — 낡았을 때만
JspServletWrapper를 만들 때 함께 만든 JspCompilationContext(JspServletWrapper.java 111행)가 "이 JSP를 어디에, 어떤 이름으로, 무엇으로 컴파일하나"를 압니다. compile()의 뼈대는 이렇습니다.
public void compile() throws JasperException, FileNotFoundException {
createCompiler();
if (jspCompiler.isOutDated()) {
if (isRemoved()) {
throw new FileNotFoundException(jspUri);
}
try {
jspCompiler.removeGeneratedFiles();
jspLoader = null;
jspCompiler.compile();
jsw.setCompilationException(null);
} catch (JasperException ex) {
// Cache compilation exception
jsw.setCompilationException(ex);
// ...
throw ex;
// ...
} finally {
jsw.setReload(true);
}
}
}
java/org/apache/jasper/JspCompilationContext.java 726–757행
isOutDated()가 true일 때만 옛 파일을 지우고(removeGeneratedFiles()), jspLoader를 버리고, 새로 컴파일합니다. 끝나면 성공이든 실패든 reload를 true로 둡니다. 그래서 바로 다음 줄의 getServlet()이 새 JasperLoader로 새 클래스를 싣습니다. 컴파일 오류는 compileException에 기억해 두었다가, 개발 모드가 아니면 다시 컴파일하지 않고 같은 예외를 계속 던집니다(1절의 코드 444–449행).
createCompiler()는 컴파일러를 고릅니다. compiler 초기화 파라미터가 없으면 JDTCompiler(ecj)를 먼저 시도하고, 그 클래스를 못 찾을 때만 Ant의 javac 작업을 쓰는 AntCompiler로 갑니다(237–262행).
파란 상자가 하는 일, 노란 상자가 그 결과물입니다.
Compiler.generateJava() — 두 번 읽고, 다듬고, 쓴다
jspCompiler.compile()은 추상 클래스 Compiler의 메서드이고, 안에서 generateJava()로 .java를 만든 뒤 generateClass()로 .class를 만듭니다. generateJava()의 가운데 토막입니다.
// Parse the file
ParserController parserCtl = new ParserController(ctxt, this);
// Pass 1 - the directives
Node.Nodes directives = parserCtl.parseDirectives(ctxt.getJspFile());
Validator.validateDirectives(this, directives);
// Pass 2 - the whole translation unit
pageNodes = parserCtl.parse(ctxt.getJspFile());
java/org/apache/jasper/compiler/Compiler.java 222–230행
같은 파일을 두 번 읽습니다. 바로 위의 주석(213–220행)이 이유를 솔직하게 적어 두었습니다. isELIgnored(EL을 무시할지) 같은 설정은 번역 단위의 어느 파일에든 — 심지어 끝 쪽에 포함된 파일에도 — 적을 수 있는데, 그 값에 따라 ${를 EL로 읽을지 글자로 읽을지가 달라집니다. 그래서 1차로 지시어만 훑어 설정을 확정하고, 2차로 전체를 읽습니다("Arghh!"라는 감탄사까지 적혀 있습니다).
그다음은 노드 트리를 검사하고 다듬는 단계들입니다(247–276행). Validator.validateExDirectives()(속성 검사), Collector.collect()(페이지 정보 모으기), 태그 파일 컴파일, ScriptingVariabler, 태그 플러그인, TextOptimizer.concatenate()(이어진 글자 노드 합치기), ELFunctionMapper.map()을 차례로 지나고, 마지막에 Generator.generate()가 .java를 씁니다(279–281행).
.java를 쓸 경로는 getServletJavaFileName()이 정합니다. 출력 폴더 + 클래스 이름 + ".java"이고(590–592행), 출력 폴더는 스크래치 폴더 + 패키지 경로입니다(830–852행). 스크래치 폴더는 scratchdir 초기화 파라미터가 없으면 웹앱의 ServletContext.TEMPDIR 속성, 곧 work 폴더입니다(EmbeddedServletOptions.java 693–698행). 그 속성은 StandardContext.postWorkDirectory()가 work/<엔진>/<호스트>/<웹앱>으로 만들어 넣습니다(StandardContext.java 5463–5467행, 5491행). 패키지는 기본값 org.apache.jsp(EmbeddedServletOptions.java 214행) 뒤에 JSP가 들어 있는 폴더 경로를 붙인 것입니다(getServletPackageName(), 536–552행). 그래서 /admin/list.jsp라면 org.apache.jsp.admin 패키지가 됩니다.
클래스 이름 만들기
클래스 이름은 JSP 파일 이름을 자바 식별자로 바꾼 것입니다.
int iSep = jspUri.lastIndexOf('/') + 1;
className = JspUtil.makeJavaIdentifier(jspUri.substring(iSep));
java/org/apache/jasper/JspCompilationContext.java 404–405행
private static String makeJavaIdentifier(String identifier, boolean periodToUnderscore) {
StringBuilder modifiedIdentifier = new StringBuilder(identifier.length());
if (!Character.isJavaIdentifierStart(identifier.charAt(0))) {
modifiedIdentifier.append('_');
}
for (int i = 0; i < identifier.length(); i++) {
char ch = identifier.charAt(i);
if (Character.isJavaIdentifierPart(ch) && (ch != '_' || !periodToUnderscore)) {
modifiedIdentifier.append(ch);
} else if (ch == '.' && periodToUnderscore) {
modifiedIdentifier.append('_');
} else {
modifiedIdentifier.append(mangleChar(ch));
}
}
// ...
java/org/apache/jasper/compiler/JspUtil.java 862–876행
점(.)은 밑줄(_)이 되어 hello.jsp는 hello_jsp가 됩니다. 그런데 원래 있던 밑줄은 그대로 두지 않고 mangleChar()로 바꿉니다. 그대로 두면 a_b.jsp와 a.b.jsp가 둘 다 a_b_jsp가 되어 부딪치기 때문입니다.
예제 2: 처음 요청하면 work 폴더에 생기는 것
예제의 hello.jsp입니다. 스크립틀릿, 식, EL, include 지시어를 하나씩 넣었습니다.
<%-- 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다) --%>
<%@ page contentType="text/plain; charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ include file="/WEB-INF/header.jspf" %>
<%! private int hits = 0; %>
<% hits++; String who = request.getParameter("who"); %>
hello, <%= who %> (v1)
hits=<%= hits %>
EL: ${param.who} / ${1 + 2}
[예제 ex/u13-jasper-jsp/app/hello.jsp]
/WEB-INF/header.jspf에는 [header v1] 한 줄만 있습니다. run.sh는 Tomcat을 띄운 뒤 JSP를 부르기 전과 후의 work 폴더를 find로 찍고, 같은 주소를 두 번 요청해 걸린 시간을 봅니다.
===== work dir before the first JSP request =====
/tmp/base/work/Catalina/localhost/demo
===== 1st request (compiles) / 2nd request =====
1st: 200 in 3.332047s
2nd: 200 in 0.010880s
===== work dir after the request =====
/tmp/base/work/Catalina/localhost/demo
/tmp/base/work/Catalina/localhost/demo/org
/tmp/base/work/Catalina/localhost/demo/org/apache
/tmp/base/work/Catalina/localhost/demo/org/apache/jsp
/tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.class
/tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.java
- 기동 직후 work 폴더(
work/Catalina/localhost/demo)는 비어 있습니다. JSP는 처음 요청될 때 번역됩니다.JspServlet자체는load-on-startup으로 미리 올라오지만, 그때 하는 일은 설정 읽기와JspRuntimeContext만들기뿐입니다. - 첫 요청은 3.3초, 두 번째는 0.01초입니다(시간은 실행마다 다릅니다. 이 예제를 여러 번 돌리는 동안 첫 요청은 1.7~3.3초였습니다). 첫 요청 안에서 파싱, 코드 생성, ecj 컴파일, 클래스 싣기가 모두 일어났습니다.
- 파일은
org/apache/jsp/hello_jsp.java와.class, 둘 다 남았습니다..java가 남는 것은keepgenerated기본값이true이기 때문입니다(EmbeddedServletOptions.java59행).false면JDTCompiler가 컴파일한 뒤 지웁니다(JDTCompiler.java543–548행).
같은 예제의 my_page.jsp(자기 클래스 이름을 찍는 JSP)를 부르면 밑줄 바꾸기가 보입니다.
===== my_page.jsp =====
class=org.apache.jsp.my_005fpage_jsp
my_005fpage_jsp.class
my_005fpage_jsp.java
밑줄(_, 유니코드 005f)이 _005f로 바뀌었습니다. work 폴더에서 JSP의 생성 파일을 찾을 때 알아 두면 좋습니다(첫 빈 줄은 JSP 맨 위 주석 줄이 남긴 줄바꿈입니다).
3. 노드 트리와 Generator — <% %>는 무엇이 되나
Parser — 첫 글자 몇 개로 노드 종류를 고른다
2차 읽기에서 실제로 글자를 노드로 바꾸는 것은 Parser입니다. Parser.parse()는 입력이 남아 있는 동안 parseElements()를 되풀이해 Node.Root 아래에 노드를 붙입니다(124–126행). parseElements()는 지금 위치의 첫 몇 글자만 보고 갈래를 고릅니다.
start = reader.mark();
if (reader.matches("<%--")) {
parseComment(parent);
} else if (reader.matches("<%@")) {
parseDirective(parent);
} else if (reader.matches("<jsp:directive.")) {
parseXMLDirective(parent);
} else if (reader.matches("<%!")) {
parseDeclaration(parent);
} else if (reader.matches("<jsp:declaration")) {
parseXMLDeclaration(parent);
} else if (reader.matches("<%=")) {
parseExpression(parent);
} else if (reader.matches("<jsp:expression")) {
parseXMLExpression(parent);
} else if (reader.matches("<%")) {
parseScriptlet(parent);
} else if (reader.matches("<jsp:scriptlet")) {
parseXMLScriptlet(parent);
} else if (reader.matches("<jsp:text")) {
parseXMLTemplateText(parent);
} else if (!pageInfo.isELIgnored() && reader.matches("${")) {
parseELExpression(parent, '$');
} else if (!pageInfo.isELIgnored() && !pageInfo.isDeferredSyntaxAllowedAsLiteral() && reader.matches("#{")) {
parseELExpression(parent, '#');
} else if (reader.matches("<jsp:")) {
parseStandardAction(parent);
} else if (!parseCustomTag(parent)) {
checkUnbalancedEndTag();
parseTemplateText(parent);
}
java/org/apache/jasper/compiler/Parser.java 1280–1310행
순서에 뜻이 있습니다. <%--, <%@, <%!, <%=를 모두 본 뒤에 <%를 봅니다. 순서를 뒤집으면 <%=도 스크립틀릿으로 읽힐 것입니다. ${는 앞에서 말한 isELIgnored가 꺼져 있을 때만 EL입니다. 어디에도 해당하지 않으면 템플릿 텍스트(TemplateText, 그대로 내보낼 글자)이고, parseTemplateText()는 다음 <나 ${가 나올 때까지 글자를 모읍니다(1148–1196행).
<%@ include %>는 노드 하나로 끝나지 않습니다. parseIncludeDirective()가 IncludeDirective 노드를 만들고 processIncludeDirective()로 포함할 파일을 그 자리에서 읽어 그 노드 아래에 붙입니다(352–358행). 파일을 읽을 때마다 ParserController.doParse()가 그 파일과 수정 시각을 "의존 파일"로 적어 둡니다(addDependant(), ParserController.java 221행). 4절의 변경 감지가 이 기록을 씁니다.
Generator — 노드마다 자바 몇 줄
Generator.generate()는 클래스 머리(패키지, import, 클래스 선언, 필드, _jspService()의 앞부분)를 쓰고(generatePreamble()), 노드 트리를 GenerateVisitor로 한 바퀴 돌며 노드마다 코드를 쓴 뒤, 꼬리(catch, finally)를 씁니다(3459–3465행). 방문자(visitor)는 "노드 종류마다 visit() 메서드를 하나씩 두고, 트리를 돌며 그 종류에 맞는 메서드를 불러 주는" 설계 방식입니다. 세 가지 노드의 visit()입니다.
@Override
public void visit(Node.Expression n) throws JasperException {
n.setBeginJavaLine(out.getJavaLine());
out.printin("out.print(");
out.printMultiLn(n.getText());
out.println(");");
n.setEndJavaLine(out.getJavaLine());
}
@Override
public void visit(Node.Scriptlet n) throws JasperException {
n.setBeginJavaLine(out.getJavaLine());
out.printMultiLn(n.getText());
out.println();
n.setEndJavaLine(out.getJavaLine());
}
@Override
public void visit(Node.ELExpression n) throws JasperException {
n.setBeginJavaLine(out.getJavaLine());
out.printil("out.write(" + elInterpreter.interpreterCall(ctxt, this.isTagFile,
n.getType() + "{" + n.getText() + "}", String.class, n.getEL().getMapName()) + ");");
n.setEndJavaLine(out.getJavaLine());
}
java/org/apache/jasper/compiler/Generator.java 1034–1057행
- 식(
<%= who %>)은out.print( who );가 됩니다. 괄호 안에 JSP에 적은 글자를 그대로 붙여 넣을 뿐입니다. - 스크립틀릿(
<% ... %>)은 감싸는 것도 없이 그대로 옮겨집니다. - EL(
${param.who})은 식을 문자열 그대로 넣은 런타임 호출이 됩니다. 컴파일할 때 EL을 자바로 바꾸지 않는다는 뜻입니다(5절).
세 메서드 모두 앞뒤로 setBeginJavaLine()과 setEndJavaLine()을 부르는 데 주목하세요. JSP의 몇 번째 줄이 .java의 몇 번째 줄이 되었는지를 노드마다 기록합니다. 컴파일 오류를 "JSP 몇 번째 줄"로 되돌릴 때 이 기록을 쓰고(예제 3), 같은 기록으로 만든 SMAP(JSR-45 소스 지도)을 .class에도 넣어 디버거가 JSP 줄을 찾을 수 있게 합니다.
왼쪽이 JSP 조각과 그 노드 종류, 오른쪽이 생성된 코드입니다. 오른쪽은 예제의 실제 생성 파일에서 옮겼습니다(헤더는 처음 생성한 판의 v1).
예제 2 계속: 생성된 hello_jsp.java
work 폴더에 남은 파일을 열어 봤습니다. 이것은 Tomcat의 소스가 아니라 Jasper가 만들어 낸 파일입니다. 4절의 실험으로 (v2), [header v2]로 고친 뒤 다시 생성된 판이고, 줄 번호는 생성 파일의 것입니다.
/*
* Generated by the Jasper component of Apache Tomcat
* Version: Apache Tomcat/12.0.0-M1-dev
* Generated at: 2026-10-24 13:48:47 UTC
* Note: The last modified time of this file was set to
* the last modified time of the source file after
* generation to assist with modification tracking.
*/
package org.apache.jsp;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import jakarta.servlet.jsp.*;
public final class hello_jsp extends org.apache.jasper.runtime.HttpJspBase
implements org.apache.jasper.runtime.JspSourceDependent,
org.apache.jasper.runtime.JspSourceImports,
org.apache.jasper.runtime.JspSourceDirectives {
private int hits = 0;
private static final jakarta.servlet.jsp.JspFactory _jspxFactory =
jakarta.servlet.jsp.JspFactory.getDefaultFactory();
private static java.util.Map<java.lang.String,java.lang.Long> _jspx_dependants;
static {
_jspx_dependants = new java.util.LinkedHashMap<java.lang.String,java.lang.Long>(1);
_jspx_dependants.put("/WEB-INF/header.jspf", Long.valueOf(1792849718611L));
}
// ... (31–89행: import 목록, EL·InstanceManager 를 꺼내는 메서드, 빈 _jspInit()·_jspDestroy())
[생성 파일 work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.java 1–29행]
- 클래스는
HttpJspBase를 상속하고, 이름은hello_jsp, 패키지는org.apache.jsp입니다(2절). <%! private int hits = 0; %>가 클래스 필드가 되었습니다(20행). 선언은_jspService()밖, 클래스 몸통에 들어갑니다(generateDeclarations(),Generator.java187행부터)._jspx_dependants에 포함 파일/WEB-INF/header.jspf와 그 파일의 수정 시각(밀리초)이 박혀 있습니다. 이 클래스가JspSourceDependent를 구현해getDependants()로 이 지도를 돌려줍니다. 4절의 변경 감지에서 씁니다. 시각 값은 실행마다 다릅니다.
_jspService() 본체입니다.
public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response)
throws java.io.IOException, jakarta.servlet.ServletException {
if (!jakarta.servlet.DispatcherType.ERROR.equals(request.getDispatcherType())) {
final java.lang.String _jspx_method = request.getMethod();
if ("OPTIONS".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
return;
}
if (!"GET".equals(_jspx_method) && !"POST".equals(_jspx_method) && !"HEAD".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JSPs only permit GET, POST or HEAD. Jasper also permits OPTIONS");
return;
}
}
final jakarta.servlet.jsp.PageContext pageContext;
jakarta.servlet.http.HttpSession session = null;
final jakarta.servlet.ServletContext application;
final jakarta.servlet.ServletConfig config;
jakarta.servlet.jsp.JspWriter out = null;
final java.lang.Object page = this;
jakarta.servlet.jsp.JspWriter _jspx_out = null;
jakarta.servlet.jsp.PageContext _jspx_page_context = null;
try {
response.setContentType("text/plain; charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context = pageContext;
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
_jspx_out = out;
out.write("[header v2]\n");
hits++; String who = request.getParameter("who");
out.write("\n");
out.write("hello, ");
out.print( who );
out.write(" (v2)\n");
out.write("hits=");
out.print( hits );
out.write("\n");
out.write("EL: ");
out.write((java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate("${param.who}", java.lang.String.class, (jakarta.servlet.jsp.PageContext)_jspx_page_context, null));
out.write(' ');
out.write('/');
out.write(' ');
out.write((java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate("${1 + 2}", java.lang.String.class, (jakarta.servlet.jsp.PageContext)_jspx_page_context, null));
} catch (java.lang.Throwable t) {
// ... (예외가 나면 버퍼를 비우고 pageContext.handlePageException(t) 로 오류 페이지에 넘긴다)
} finally {
_jspxFactory.releasePageContext(_jspx_page_context);
}
}
}
[생성 파일 hello_jsp.java 90–160행, 143–155행을 줄임]
이 코드를 그대로 써 내려간 것이 Generator.generatePreamble()입니다(690–821행). 읽을 것이 다섯 가지 있습니다.
- 메서드 검사가 맨 앞에 있습니다. JSP는
GET,POST,HEAD만 받고,OPTIONS에는Allow헤더만 답하고, 나머지는 405입니다(Generator.java739–761행). 오류 페이지로 디스패치된 요청(ERROR)은 원래 메서드가 무엇이든 통과합니다. - JSP의 "암시 객체"는 이 지역 변수들입니다. JSP 안에서 선언 없이 쓰는
request,response,session,application,config,out,page,pageContext는 이 메서드의 매개변수와 지역 변수일 뿐입니다. 스크립틀릿이 이 메서드 안에 그대로 들어가므로 그 이름들이 보이는 것입니다. include지시어의 내용이 글자로 들어와 있습니다(out.write("[header v2]\n")).header.jspf는 따로 컴파일되지 않았습니다. 번역할 때 붙여 넣어졌기 때문에, 포함 파일만 고쳐도 이hello_jsp를 다시 번역해야 바뀐 내용이 나옵니다(4절).- 짧은 글자는
char하나씩 씁니다." / "처럼 세 글자 이하의 템플릿 텍스트는out.write(' ')처럼 글자마다 한 줄이 됩니다(visit(Node.TemplateText)의 "Special case small text strings", 1825–1841행). getPageContext(…, null, true, 8192, true)의 숫자들은page지시어의 값입니다. 차례로 오류 페이지(없음), 세션 사용(기본true), 버퍼 크기(8KB), 자동 플러시입니다(803–809행).session = pageContext.getSession()(123행)이 있는 것은 기본값이session="true"이기 때문입니다. 세션이 필요 없는 JSP에<%@ page session="false" %>를 적으면 이 줄과session변수가 생성되지 않습니다(766–768행, 815–817행). 기본값 그대로면getPageContext()안에서request.getSession()을 부르므로(PageContextImpl.java128–129행) JSP를 열기만 해도 세션이 생깁니다. 세션이 만들어지는 길은 11편에서 봅니다.
헷갈리기 쉬운 곳: <%! %> 필드는 모든 요청이 함께 쓴다
hits는 클래스 필드이고, JspServletWrapper.getServlet()은 인스턴스를 하나 만들어 계속 씁니다(theServlet, JspServletWrapper.java 208행). 예제 출력에서 hits=3처럼 숫자가 요청마다 올라가는 이유입니다. 여러 스레드가 동시에 요청하면 hits++는 서로 덮어쓸 수 있습니다. 서블릿 필드와 똑같은 함정입니다. 요청마다 따로 써야 하는 값은 <% %> 안의 지역 변수로 둡니다. 반대로 JSP가 다시 컴파일되면 새 클래스의 새 인스턴스가 되므로 hits는 다시 1부터 셉니다. 4절의 예제에서 이것으로 재컴파일을 확인합니다.
4. ecj 컴파일과 변경 감지
JDTCompiler — 파일 대신 메모리로 묻고 답한다
Compiler.compile()은 .java를 만든 뒤 generateClass()를 부르는데, 이것이 JDTCompiler에 구현되어 있습니다. ecj의 Compiler에 세 가지를 넘깁니다. 컴파일할 소스(ICompilationUnit — .java 파일을 읽어 char[]로 주는 클래스, 91–161행), 참조하는 클래스를 찾아 줄 환경(INameEnvironment — jakarta.servlet.*이나 웹앱의 클래스를 이 JSP의 JasperLoader, 곧 그 부모인 웹앱의 클래스로더를 거쳐 찾아 줍니다(86행, 163행부터)), 그리고 결과를 받을 쪽(ICompilerRequestor)입니다.
final ICompilerRequestor requestor = result -> {
try {
if (result.hasProblems()) {
IProblem[] problems = result.getProblems();
for (IProblem problem : problems) {
if (problem.isError()) {
String name = new String(problem.getOriginatingFileName());
try {
problemList.add(ErrorDispatcher.createJavacError(name, pageNodes,
new StringBuilder(problem.getMessage()), problem.getSourceLineNumber(), ctxt));
} catch (JasperException e) {
log.error(Localizer.getMessage("jsp.error.compilation.jdtProblemError"), e);
}
}
}
}
if (problemList.isEmpty()) {
ClassFile[] classFiles = result.getClassFiles();
for (ClassFile classFile : classFiles) {
// ... (패키지 이름으로 outputDir/org/apache/jsp/hello_jsp.class 경로를 만든다)
byte[] bytes = classFile.getBytes();
classFileName.append(".class");
try (FileOutputStream fout = new FileOutputStream(classFileName.toString());
BufferedOutputStream bos = new BufferedOutputStream(fout)) {
bos.write(bytes);
}
}
}
} catch (IOException ioe) {
log.error(Localizer.getMessage("jsp.error.compilation.jdt"), ioe);
}
};
java/org/apache/jasper/compiler/JDTCompiler.java 467–505행, 486–493행을 줄임
오류가 있으면 .class를 쓰지 않고, 오류마다 ErrorDispatcher.createJavacError()로 .java의 줄 번호를 JSP의 줄 번호로 되돌립니다. 3절에서 본 노드의 beginJavaLine/endJavaLine 기록을 거꾸로 찾는 것입니다. 실제 컴파일은 그 아래 new Compiler(env, policy, cOptions, requestor, problemFactory)와 compiler.compile(compilationUnits) 두 줄입니다(533–534행). 별도 프로세스를 띄우지 않고 Tomcat의 JVM 안에서 끝납니다.
컴파일할 자바 버전은 compilerSourceVM/compilerTargetVM 파라미터가 정하고, 기본값은 이 커밋에서 21입니다(EmbeddedServletOptions.java 122행, 127행). 11.0.x 브랜치에서는 17입니다. 같은 브랜치의 JDTCompiler는 버전마다 if/else if를 늘어놓고 결과를 받는 쪽을 익명 클래스로 썼는데, main에서는 switch 식과 람다로 바뀌었을 뿐 하는 일은 같습니다.
예제 3: 컴파일 오류는 JSP 줄 번호로 나온다
broken.jsp의 4행에 일부러 타입 오류를 넣었습니다.
<%-- 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다) --%>
<%@ page contentType="text/plain; charset=UTF-8" %>
line 3 is fine
<% int count = "not a number"; %>
[예제 ex/u13-jasper-jsp/app/broken.jsp]
응답은 500이고, 오류 페이지 HTML에서 태그를 지우고 HTML 엔티티(< 등)를 되돌린 부분이 이렇습니다.
===== broken.jsp =====
HTTP 500
Exception
org.apache.jasper.JasperException: org.apache.jasper.JasperException: Unable to compile class for JSP:
An error occurred at line: [4] in the jsp file: [/broken.jsp]
Type mismatch: cannot convert from String to int
1: <%-- 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다) --%>
2: <%@ page contentType="text/plain; charset=UTF-8" %>
3: line 3 is fine
4: <% int count = "not a number"; %>
broken_jsp.java
Type mismatch: cannot convert from String to int는 ecj가 낸 메시지 그대로입니다(javac라면 문구가 다릅니다).- 그런데 위치는
.java의 줄이 아니라 JSP 파일의 4행이고, JSP 원문 몇 줄까지 붙어 있습니다(displaySourceFragment기본값true). 3절에서 본 노드의 줄 대응 기록 덕분입니다. - 마지막 줄은
ls의 결과입니다. work 폴더에broken_jsp.java만 있고.class는 없습니다. 오류가 있으면 requestor가.class를 쓰지 않기 때문입니다. - 오류 페이지에 예외 내용까지 나오는 것은 8편의
ErrorReportValve가 기본으로 보여 주기 때문입니다. 운영에서는 끄는 것이 보통입니다.
isOutDated() — 무엇과 무엇의 시각을 비교하나
이제 "언제 다시 컴파일하나"입니다. Compiler.compile()은 컴파일을 마친 뒤 .java와 .class 파일의 수정 시각을 JSP 파일의 수정 시각으로 바꿔 놓습니다.
final Long jspLastModified = ctxt.getLastModified(ctxt.getJspFile());
Map<String,SmapStratum> smaps = generateJava();
File javaFile = new File(ctxt.getServletJavaFileName());
if (!javaFile.setLastModified(jspLastModified.longValue())) {
throw new JasperException(Localizer.getMessage("jsp.error.setLastModified", javaFile));
}
if (compileClass) {
generateClass(smaps);
// Fix for bugzilla 41606
// Set JspServletWrapper.servletClassLastModifiedTime after successful compile
File targetFile = new File(ctxt.getClassFileName());
if (targetFile.exists()) {
if (!targetFile.setLastModified(jspLastModified.longValue())) {
throw new JasperException(Localizer.getMessage("jsp.error.setLastModified", targetFile));
}
java/org/apache/jasper/compiler/Compiler.java 402–416행
생성 파일 머리의 주석 "The last modified time of this file was set to the last modified time of the source file"이 이 말이었습니다. 그러면 isOutDated()는 두 시각이 같은지만 보면 됩니다.
public boolean isOutDated(boolean checkClass) {
if (jsw != null && (ctxt.getOptions().getModificationTestInterval() > 0)) {
if (jsw.getLastModificationTest() + (ctxt.getOptions().getModificationTestInterval() * 1000L) > System
.currentTimeMillis()) {
return false;
}
jsw.setLastModificationTest(System.currentTimeMillis());
}
// ...
if (!targetFile.exists()) {
return true;
}
long targetLastModified = targetFile.lastModified();
// ...
Long jspRealLastModified = ctxt.getLastModified(ctxt.getJspFile());
if (jspRealLastModified.longValue() < 0) {
// Something went wrong - assume modification
return true;
}
if (targetLastModified != jspRealLastModified.longValue()) {
// ...
return true;
}
java/org/apache/jasper/compiler/Compiler.java 461–501행
modificationTestInterval(기본 4초) — 지난번 검사에서 4초가 지나지 않았으면 파일을 보지도 않고 "낡지 않았다"고 답합니다. 요청이 몰릴 때 매번 디스크를 보지 않으려는 장치입니다..class가 없으면 낡은 것입니다(처음 요청)..class의 시각이 JSP의 시각과 다르면 낡은 것입니다. "더 새로우면"(<)이 아니라 "다르면"(!=)입니다. 그래서 JSP를 백업본으로 되돌려 시각이 과거로 가도 다시 컴파일됩니다.- 그래도 같으면
jsw.getDependants()— 생성된 클래스의_jspx_dependants지도 — 를 꺼내 포함 파일들의 시각도 하나씩 기록과 비교합니다(503–581행). 다르면 낡은 것입니다.
기본값은 development=true(EmbeddedServletOptions.java 49행), checkInterval=0(85행), modificationTestInterval=4(157행)입니다.
예제 4: 고치면 언제 반영되나 (개발 모드)
예제 2에 이어, hello.jsp의 (v1)을 sed로 (v2)로 고치고 곧바로 요청했습니다. 그다음 1초마다 요청하며 (v2)가 보일 때까지 걸린 시간을 쟀습니다.
===== edited hello.jsp to v2, request right away =====
[header v1]
hello, tomcat (v1)
hits=3
EL: tomcat / 3
===== mtime: jsp / java / class =====
2026-10-24 13:48:32.844223831 +0000 /tmp/base/webapps/demo/hello.jsp
2026-10-24 13:48:25.593000000 +0000 /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.java
2026-10-24 13:48:25.593000000 +0000 /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.class
===== polling until v2 shows up =====
"(v2)" visible after about 6 seconds
[header v1]
hello, tomcat (v2)
hits=2
EL: tomcat / 3
2026-10-24 13:48:32.844223831 +0000 /tmp/base/webapps/demo/hello.jsp
2026-10-24 13:48:32.844000000 +0000 /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.java
2026-10-24 13:48:32.844000000 +0000 /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.class
- 고친 직후의 요청은 아직
(v1)입니다. 첫 요청에서 검사한 지 4초가 지나지 않아isOutDated()가 파일을 보지 않았습니다.hits=3은 같은 인스턴스가 세 번째 요청을 받았다는 뜻입니다. - 그 사이
.java와.class의 시각은 처음 JSP의 시각(13:48:25.593)이고, 고친 JSP만13:48:32입니다(시각은 실행마다 다릅니다). 둘이 달라졌으니 다음 검사에서 낡은 것으로 판정됩니다. - 6초쯤 뒤
(v2)가 나왔고, 그 뒤.java·.class의 시각이 새 JSP의 시각(13:48:32.844)으로 맞춰졌습니다. 나노초 아래가 잘린 것은 자바의setLastModified()가 밀리초 단위이기 때문입니다. hits=2입니다. 폴링하던 요청이 1, 이 요청이 2입니다. 새JasperLoader가 실은 새 클래스의 새 인스턴스라 필드가 처음부터 다시 셉니다.
이번에는 hello.jsp는 그대로 두고 포함 파일 header.jspf만 고쳤습니다.
===== edited only header.jspf, polling =====
"header v2" visible after about 9 seconds
[header v2]
hello, tomcat (v2)
hits=2
EL: tomcat / 3
hello.jsp의 시각은 그대로인데도 [header v2]가 나왔고 hits도 다시 셌습니다. _jspx_dependants에 적어 둔 header.jspf의 시각과 실제 시각이 달라져 hello_jsp를 통째로 다시 번역한 것입니다. 앞에서 본 생성 파일이 바로 이때 다시 만든 것이라 [header v2]가 들어 있습니다.
헷갈리기 쉬운 곳: "몇 초 뒤"는 정해져 있지 않다
반영되기까지 걸린 시간은 실행마다 달랐습니다. 같은 예제를 네 번 돌리는 동안 hello.jsp는 2~6초, header.jspf는 5~9초였고, 처음 실행에서는 header.jspf를 고친 뒤 5초를 기다린 요청에 아직 옛 내용이 나왔습니다. 확인한 바로는 시계가 둘입니다. 하나는 위의 modificationTestInterval(4초)이고, 다른 하나는 Tomcat의 정적 자원 캐시입니다. Jasper는 JSP와 포함 파일의 시각을 ServletContext.getResource()로 얻는데, 이 자원은 웹앱의 자원 캐시(CachedResource)를 거치고, 캐시 항목은 기본 5초(ttl = 5000, webresources/Cache.java 53행) 동안 디스크를 다시 보지 않습니다(CachedResource.java 145–149행). 두 시계가 어긋나는 정도에 따라 몇 초가 더 걸릴 수 있습니다. 개발 중에 "저장했는데 안 바뀐다"면 몇 초 기다렸다가 다시 요청해 보면 됩니다.
운영 모드: development=false와 checkInterval
개발 모드는 요청마다 compile()을 부르고 4초마다 디스크를 봅니다. 운영에서 그 비용을 없애려면 development를 false로 둡니다. 그러면 1절의 코드에서 mustCompile이 true인 첫 요청 한 번만 compile()에 들어갑니다. 그 뒤로 고친 JSP를 알아채는 길은 백그라운드 검사 하나뿐이고, checkInterval이 0보다 클 때만 켜집니다.
// If this web application context is running from a
// directory, start the background compilation thread
String appBase = context.getRealPath("/");
if (!options.getDevelopment() && appBase != null && options.getCheckInterval() > 0) {
lastCompileCheck = System.currentTimeMillis();
}
java/org/apache/jasper/compiler/JspRuntimeContext.java 99–104행
lastCompileCheck의 기본값은 -1이고(127행), checkCompile()은 이 값이 음수면 아무것도 하지 않습니다(305–308행). 켜져 있으면 checkInterval초가 지났을 때 등록된 모든 JSP에 ctxt.compile()을 불러 보고, 다시 실어야 할 것을 모아 getServlet()으로 새로 싣습니다(309–361행).
빨간 상자가 운영 모드의 기본 동작입니다. 오른쪽 위의 회색 상자는 Jasper가 아니라 컨테이너가 돌리는 스레드입니다.
헷갈리기 쉬운 곳: "background compilation thread"는 Jasper의 스레드가 아니다
위 코드의 주석은 "백그라운드 컴파일 스레드를 시작한다"고 말하지만, 실제로 하는 일은 lastCompileCheck에 시각을 적는 것뿐입니다. Jasper가 스레드를 따로 만들지 않습니다. checkCompile()을 부르는 것은 JspServlet.periodicEvent()이고(JspServlet.java 310–314행), 그것을 부르는 것은 Tomcat 컨테이너의 StandardWrapper.backgroundProcess()입니다.
@Override
public synchronized void backgroundProcess() {
super.backgroundProcess();
if (!getState().isAvailable()) {
return;
}
if (getServlet() instanceof PeriodicEventListener) {
((PeriodicEventListener) getServlet()).periodicEvent();
}
}
java/org/apache/catalina/core/StandardWrapper.java 480–491행
이 메서드는 Engine에 걸린 백그라운드 스레드(ContainerBackgroundProcessor)가 Host → Context → Wrapper로 내려가며 부릅니다(ContainerBase.java 1179–1185행). 10편의 자동 배포, 11편의 세션 만료와 같은 스레드입니다. 그 주기는 Engine의 backgroundProcessorDelay, 기본 10초입니다(StandardEngine.java 62행). 그래서 checkInterval=2로 적어도 실제 검사는 10초에 한 번보다 자주 일어나지 않습니다.
예제 5: 운영 모드에서 고치면
같은 컨테이너에서 Tomcat을 멈추고, 복사해 둔 conf/web.xml의 jsp 서블릿에 development=false를 넣어 다시 띄웠습니다. hello.jsp는 예제 4에서 고친 (v2) 상태이고, work 폴더도 그대로입니다.
===== restart with development=false =====
2026-10-24 13:48:47.288893163 +0000 (ctime) /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.class
[header v2]
hello, tomcat (v2)
hits=1
EL: tomcat / 3
2026-10-24 13:48:47.288893163 +0000 (ctime) /tmp/base/work/Catalina/localhost/demo/org/apache/jsp/hello_jsp.class
===== edited hello.jsp to v3, 6 seconds later (development=false) =====
[header v2]
hello, tomcat (v2)
hits=2
EL: tomcat / 3
- 재시작 전후로
.class의 ctime(파일 내용이 바뀐 시각 — mtime은 Jasper가 JSP 시각으로 바꿔 놓으므로 여기서는 쓸모가 없습니다)이 같습니다. 재시작한 뒤의 첫 요청은compile()에 들어갔지만,isOutDated()가 시각이 같다고 보고 이전 기동에서 만든.class를 그대로 실었습니다. work 폴더는 재시작해도 지워지지 않는 캐시라는 뜻입니다.hits=1은 JVM이 새로 떴으니 당연합니다. (v3)으로 고치고 6초를 기다려도(v2)입니다. 운영 모드에서checkInterval=0이면 고친 JSP는 다음 재시작 때까지 반영되지 않습니다.
다시 멈추고 checkInterval=2를 더해 띄운 뒤, (v4)로 고치고 1초마다 요청했습니다.
===== restart with development=false, checkInterval=2 =====
[header v2]
hello, tomcat (v3)
hits=1
EL: tomcat / 3
"(v4)" visible after about 6 seconds
[header v2]
hello, tomcat (v4)
hits=2
EL: tomcat / 3
재시작 뒤 첫 요청에서 (v3)이 나왔습니다. 첫 요청의 compile()이 바뀐 시각을 알아챘기 때문입니다. 그리고 (v4)는 요청이 아니라 백그라운드 검사가 다시 컴파일해 약 6초 뒤에 보였습니다. 예제를 돌릴 때마다 5~9초 사이였고, 위에서 본 대로 10초 남짓까지 걸릴 수 있습니다.
헷갈리기 쉬운 곳: 비교하는 것은 JSP와 포함 파일뿐
isOutDated()가 보는 것은 JSP 자신과 _jspx_dependants에 적힌 파일(정적 include로 붙인 파일, 태그 파일·TLD 같은 것)의 시각뿐입니다. JSP가 <jsp:include>(실행할 때 포함하는 동작)로 부르는 다른 JSP는 의존 파일이 아니라 따로 번역되는 별개의 JSP라서, 그쪽을 고치면 그쪽만 다시 컴파일됩니다. 또 JSP가 쓰는 자바 클래스(WEB-INF/classes의 클래스)만 바꾼 것은 JSP가 낡았다는 신호가 되지 않습니다. 이 경우를 예제로 돌려 보지는 않았습니다.
5. EL 맛보기 — 실행할 때 해석하고, 캐시한다
3절에서 ${param.who}는 자바 코드로 바뀌지 않고 문자열 "${param.who}"를 넘기는 PageContextImpl.proprietaryEvaluate() 호출이 되었습니다. 그 메서드는 EL의 표준 API로 식을 만들어 값을 구합니다.
public static Object proprietaryEvaluate(final String expression, final Class<?> expectedType,
final PageContext pageContext, final ProtectedFunctionMapper functionMap) throws ELException {
final ExpressionFactory exprFactory =
jspf.getJspApplicationContext(pageContext.getServletContext()).getExpressionFactory();
ELContext ctx = pageContext.getELContext();
// ...
ValueExpression ve = exprFactory.createValueExpression(ctx, expression, expectedType);
return ve.getValue(ctx);
}
java/org/apache/jasper/runtime/PageContextImpl.java 625–639행, 630–636행을 줄임
요청마다 문자열을 다시 파싱한다면 느릴 것입니다. Tomcat의 EL 구현(org.apache.el)은 파싱한 결과를 문자열을 열쇠로 캐시합니다.
Node n = expressionCache.get(expr);
if (n == null) {
ELParser parser = parserCache.pop();
try {
if (parser == null) {
parser = new ELParser(new StringReader(expr));
} else {
parser.ReInit(new StringReader(expr));
}
n = parser.CompositeExpression();
java/org/apache/el/lang/ExpressionBuilder.java 105–114행
expressionCache는 기본 5,000개짜리 ConcurrentCache이고(크기는 시스템 속성 org.apache.el.ExpressionBuilder.CACHE_SIZE, 52–60행), 파싱한 트리를 141행에서 넣습니다. 파서 객체도 parserCache에 모아 다시 씁니다. 파서 ELParser는 사람이 짠 코드가 아니라 문법 파일 ELParser.jjt에서 JavaCC라는 파서 생성기가 만든 코드입니다(파일 첫머리에 "Generated By:JJTree&JavaCC: Do not edit this line"이라고 적혀 있습니다). 파싱 결과는 AstPlus, AstValue처럼 Ast로 시작하는 노드 클래스(org/apache/el/parser에 46개)의 트리이고, 각 노드가 getValue()로 값을 구합니다.
위 두 코드 조각을 이었습니다. 노란 칸이 두 캐시(식 트리, 파서)를 쓰는 곳입니다.
정리하면 JSP 쪽은 한 번 번역해 자바 바이트코드로, EL 쪽은 실행할 때 해석하되 파싱 결과를 캐시하는 방식입니다. Spring의 SpEL도 해석기를 기본으로 하고, 필요하면 바이트코드로 컴파일하는 모드를 따로 둡니다(Spring 15편).
이 글에서 뺀 것
- 태그 라이브러리와 TLD 찾기(
JasperInitializer가 SCI로 불려TldScanner를 돌린다):servlet/JasperInitializer.java62–112행,servlet/TldScanner.java,compiler/TldCache.java— SCI 자체는 10편 - 커스텀 태그의 코드 생성과 태그 핸들러 풀(
enablePooling):Generator.java의visit(Node.CustomTag),runtime/TagHandlerPool.java - 태그 파일(
.tag)과 그 컴파일:compiler/TagFileProcessor.java,JspServletWrapper.loadTagFile()(268–301행) - XML 문법의 JSP 문서(
.jspx):compiler/JspDocumentParser.java, 판별은ParserController.determineSyntaxAndEncoding() - 페이지 인코딩 판별(BOM,
pageEncoding):compiler/EncodingDetector.java,ParserController.java280행부터 - 미리 컴파일하기:
?jsp_precompile요청(JspServlet.preCompile(), 202–243행), 빌드할 때 번역하는JspC(jasper/JspC.java) - web.xml의
<jsp-file>을JspServlet+jspFile파라미터로 바꾸는 곳:startup/ContextConfig.java1801–1818행 - 오래 안 쓴 JSP 내리기(
maxLoadedJsps,jspIdleTimeout):JspRuntimeContext.checkUnload()(471행부터) - SMAP을
.class에 넣는 일:compiler/SmapUtil.java
정리
| 개념 | 한 줄 요약 |
|---|---|
JspServlet |
전역 conf/web.xml의 jsp 서블릿 하나가 *.jsp, *.jspx를 모두 받는다 |
JspRuntimeContext |
웹앱마다 하나. jspUri → JspServletWrapper 지도와 백그라운드 검사 |
JspServletWrapper |
JSP 파일마다 하나. (1) 컴파일 (2) 싣기 (3) service() |
JspCompilationContext |
이 JSP의 패키지·클래스 이름·출력 폴더·컴파일러를 안다. isOutDated()일 때만 컴파일 |
| 두 번 읽기 | 1차 지시어만, 2차 전체. isELIgnored가 파싱을 바꾸기 때문 |
Parser → Node |
첫 글자 몇 개로 Comment·Directive·Declaration·Expression·Scriptlet·EL·TemplateText를 고른다 |
Generator |
노드마다 자바 몇 줄. 식은 out.print(), 스크립틀릿은 그대로, EL은 런타임 호출 |
| 생성된 클래스 | org.apache.jsp.hello_jsp extends HttpJspBase, 본문은 _jspService() 하나 |
JDTCompiler |
ecj로 같은 JVM 안에서 컴파일. 오류는 노드의 줄 기록으로 JSP 줄 번호가 된다 |
| work 폴더 | work/<엔진>/<호스트>/<웹앱>/org/apache/jsp/. 재시작해도 남아 재사용된다 |
| 변경 감지 | .class의 시각을 JSP 시각으로 맞춰 두고 "다르면" 다시 컴파일. 포함 파일도 본다 |
development=true(기본) |
요청마다 검사하되 modificationTestInterval 4초마다 |
development=false |
첫 요청만 검사. checkInterval이 0보다 크면 백그라운드 스레드(기본 10초 주기)가 검사 |
| EL | 문자열 그대로 넘겨 실행할 때 해석. 파싱 결과는 5,000개 캐시 |
다음에 볼 것
JSP가 서블릿이 되는 길을 봤습니다. 이 연재에서 요청이 서블릿에 닿기까지의 길은 이것으로 다 봤고, 남은 것은 그 길 위에 얹힌 기능들입니다. 다음 글은 web.xml의 <security-constraint>가 어떻게 지켜지는지, 인증을 맡는 밸브(AuthenticatorBase)와 사용자·역할을 아는 Realm을 따라갑니다. 14편: 인증과 Realm — security-constraint는 어떻게 지켜지나에서 이어집니다.
Spring MVC로 JSP를 써 봤다면 이 글의 앞쪽을 이미 지나 본 셈입니다. Spring 23편에서 InternalResourceViewResolver는 뷰 이름 "hello"를 /WEB-INF/views/hello.jsp로 바꿔 forward만 하고, "이 뒤로 HTML을 쓰는 것은 Spring이 아니라 서블릿 컨테이너의 JSP 엔진"이라고 했습니다. 그 JSP 엔진이 이 글의 JspServlet입니다. forward는 Mapper를 다시 거쳐 *.jsp 매핑으로 JspServlet에 닿고(forward의 길은 9편), 거기서부터 이 글의 1절이 시작됩니다. 그 테스트가 MockMvc로는 본문이 비어 있었던 것도, 가짜 환경에는 이 엔진이 없기 때문입니다.