namalzi

← 글 목록

Tomcat 소스 파헤치기 13: Jasper — JSP가 서블릿이 되기까지

Tomcat · 게시

hello.jsp를 처음 요청하면 Tomcat 안에서 무슨 일이 일어날까요? JspServlet이 요청을 받아 JSP 파일을 노드 트리로 읽고, 자바 소스로 옮겨 쓰고, ecj로 컴파일해 work 폴더에 .class를 남긴 뒤 그 클래스를 서블릿처럼 부르는 과정을 Jasper 소스로 따라갑니다. 생성된 .java 파일을 직접 열어 보고, JSP를 고쳤을 때 언제 다시 컴파일되는지(development, modificationTestInterval, checkInterval)도 예제로 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열세 번째 글입니다(바로 앞 글은 12편: 비동기 서블릿과 논블로킹 I/O입니다). 9편에서는 밸브 사슬의 끝에서 필터 체인을 지나 서블릿의 service()가 불리는 데까지 봤습니다. 그때 서블릿은 우리가 직접 짜서 컴파일해 둔 클래스였습니다. 그런데 .jsp 파일은 아무도 컴파일하지 않았는데도 요청하면 응답이 나옵니다. 이번에는 Tomcat의 JSP 엔진 Jasper가 그 사이에서 하는 일을 따라갑니다.

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

항목 내용
대상 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>

conf/web.xml 268–280행

    <servlet-mapping>
        <servlet-name>jsp</servlet-name>
        <url-pattern>*.jsp</url-pattern>
        <url-pattern>*.jspx</url-pattern>
    </servlet-mapping>

conf/web.xml 442–446행

이름이 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 설정을 바꾸고 싶을 때 쓰는 방법입니다.

JSP 요청이 처리되는 큰 흐름. GET /demo/hello.jsp가 오면 Mapper가 확장자 규칙 *.jsp로 이름이 jsp인 StandardWrapper를 고른다. 그 서블릿인 JspServlet.service()가 jspUri를 /hello.jsp로 정하고, JspRuntimeContext에서 그 주소의 JspServletWrapper를 꺼낸다. JspServletWrapper.service()는 먼저 ctxt.compile()로 낡았으면 .java와 .class를 다시 만들고, getServlet()으로 클래스를 JasperLoader로 실어 init()을 부른 뒤, hello_jsp.service()를 부른다. 이것은 HttpJspBase가 물려준 메서드이고, 안에서 JSP 본문이 바뀐 _jspService()를 부른다. 파란 상자가 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행

  1. 컴파일 — ctxt는 이 JSP의 JspCompilationContext입니다. compile()은 "낡았으면" .java와 .class를 새로 만듭니다(2절). 조건에 options.getDevelopment()가 있는 것을 기억해 두세요. 개발 모드(기본값)면 요청마다 이 줄에 들어오고, 아니면 mustCompile이 true인 첫 요청에만 들어옵니다. 4절의 주인공입니다.
  2. 싣기 — getServlet()은 클래스를 아직 싣지 않았거나 다시 실어야 할 때(reload) 새 인스턴스를 만들고 init(config)를 부릅니다(183–213행). 클래스는 ctxt.getJspLoader()가 주는 JasperLoader 로 싣습니다(195행). JasperLoader는 JSP마다 하나씩 만드는 작은 클래스로더로, org.apache.jsp 패키지의 클래스만 스스로 싣고 나머지는 부모인 웹앱 클래스로더(4편)에게 넘깁니다(JasperLoader.java 89–99행).
  3. 부르기 — 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행).

JSP 컴파일 과정. JspCompilationContext.compile()이 Compiler.isOutDated()로 .class와 JSP, 포함 파일의 시각을 비교한다. 같으면 아무것도 하지 않는다. 다르면 ParserController가 1차로 지시어만, 2차로 전체를 읽어 Root 아래 TemplateText, Scriptlet 같은 노드가 달린 Node 트리를 만든다. Validator, Collector, TextOptimizer가 검사하고 다듬은 뒤 Generator가 노드를 방문하며 자바 코드를 써서 work 폴더 아래 org/apache/jsp/hello_jsp.java를 만든다. JDTCompiler가 ecj로 같은 JVM 안에서 컴파일해 hello_jsp.class를 만들고 그 시각을 JSP와 같게 맞춘다. 마지막으로 JspServletWrapper.getServlet()에서 JasperLoader가 그 클래스를 싣는다. 파란 상자가 하는 일, 노란 상자가 그 결과물입니다.

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

같은 예제의 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행

세 메서드 모두 앞뒤로 setBeginJavaLine()과 setEndJavaLine()을 부르는 데 주목하세요. JSP의 몇 번째 줄이 .java의 몇 번째 줄이 되었는지를 노드마다 기록합니다. 컴파일 오류를 "JSP 몇 번째 줄"로 되돌릴 때 이 기록을 쓰고(예제 3), 같은 기록으로 만든 SMAP(JSR-45 소스 지도)을 .class에도 넣어 디버거가 JSP 줄을 찾을 수 있게 합니다.

JSP 조각이 자바 코드로 바뀌는 대응. include 지시어(IncludeDirective)는 포함 파일의 글자가 그 자리에 들어가 out.write("[header v1]…")가 된다. 선언 <%! private int hits = 0; %>(Declaration)는 클래스 몸통에 그대로 private int hits = 0;으로 들어간다. 스크립틀릿 <% hits++; … %>(Scriptlet)는 _jspService() 안에 그대로 들어간다. 템플릿 텍스트 hello,는 out.write("hello, ");가 된다. 식 <%= who %>(Expression)는 out.print( who );가 된다. EL ${param.who}(ELExpression)는 PageContextImpl.proprietaryEvaluate(…) 호출이 된다. 왼쪽이 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행]

_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행). 읽을 것이 다섯 가지 있습니다.

  1. 메서드 검사가 맨 앞에 있습니다. JSP는 GET, POST, HEAD만 받고, OPTIONS에는 Allow 헤더만 답하고, 나머지는 405입니다(Generator.java 739–761행). 오류 페이지로 디스패치된 요청(ERROR)은 원래 메서드가 무엇이든 통과합니다.
  2. JSP의 "암시 객체"는 이 지역 변수들입니다. JSP 안에서 선언 없이 쓰는 request, response, session, application, config, out, page, pageContext는 이 메서드의 매개변수와 지역 변수일 뿐입니다. 스크립틀릿이 이 메서드 안에 그대로 들어가므로 그 이름들이 보이는 것입니다.
  3. include 지시어의 내용이 글자로 들어와 있습니다(out.write("[header v2]\n")). header.jspf는 따로 컴파일되지 않았습니다. 번역할 때 붙여 넣어졌기 때문에, 포함 파일만 고쳐도 이 hello_jsp를 다시 번역해야 바뀐 내용이 나옵니다(4절).
  4. 짧은 글자는 char 하나씩 씁니다. " / "처럼 세 글자 이하의 템플릿 텍스트는 out.write(' ')처럼 글자마다 한 줄이 됩니다(visit(Node.TemplateText)의 "Special case small text strings", 1825–1841행).
  5. 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.java 128–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 엔티티(&lt; 등)를 되돌린 부분이 이렇습니다.

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

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행

  1. modificationTestInterval(기본 4초) — 지난번 검사에서 4초가 지나지 않았으면 파일을 보지도 않고 "낡지 않았다"고 답합니다. 요청이 몰릴 때 매번 디스크를 보지 않으려는 장치입니다.
  2. .class가 없으면 낡은 것입니다(처음 요청).
  3. .class의 시각이 JSP의 시각과 다르면 낡은 것입니다. "더 새로우면"(<)이 아니라 "다르면"(!=)입니다. 그래서 JSP를 백업본으로 되돌려 시각이 과거로 가도 다시 컴파일됩니다.
  4. 그래도 같으면 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

이번에는 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행).

JSP 변경을 알아채는 길. 요청이 JspServletWrapper.service()에 오면, development=true(기본값)일 때는 요청마다 compile()을 부르고 modificationTestInterval(기본 4초) 안이면 건너뛰며, 지났으면 Compiler.isOutDated()로 JSP와 포함 파일의 시각이 기록과 다른지 본다. development=false일 때는 첫 요청에만 compile()을 부르고, checkInterval이 기본값 0이면 고쳐도 반영되지 않으며 다음 기동 때 첫 요청에서 검사한다. 다른 길로, Engine의 ContainerBackgroundProcessor가 기본 10초마다 StandardWrapper.backgroundProcess()와 JspServlet.periodicEvent()를 부르고, JspRuntimeContext.checkCompile()이 checkInterval이 0보다 클 때만 isOutDated()를 부른다. 다르면 새 JasperLoader로 새 인스턴스를 만들어 다시 싣는다. 빨간 상자가 운영 모드의 기본 동작입니다. 오른쪽 위의 회색 상자는 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

다시 멈추고 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 식이 값이 되기까지. 3절의 생성된 hello_jsp.java는 문자열 "${param.who}"를 그대로 PageContextImpl.proprietaryEvaluate()에 넘기고, 이 메서드는 EL 표준 API인 ExpressionFactory와 ELContext를 쓴다. exprFactory.createValueExpression()은 ExpressionBuilder.createNodeInternal(expr)로 이어지고, 먼저 식 문자열을 열쇠로 expressionCache(ConcurrentCache, 기본 5,000개)를 찾는다. 같은 식을 다시 만나 캐시에 있으면 바로 값을 구하러 간다. 처음 만난 식이면 parserCache.pop()으로 파서를 꺼내되 비어 있으면 new ELParser를, 있으면 ReInit()으로 다시 쓰고, JavaCC가 ELParser.jjt로 만든 파서의 CompositeExpression()이 AstPlus, AstValue 같은 Ast 노드 트리를 만든다. 그 트리를 expressionCache.put(expr, n)으로 넣은 뒤, ve.getValue(ctx)에서 트리의 노드들이 getValue()로 값을 구한다. 위 두 코드 조각을 이었습니다. 노란 칸이 두 캐시(식 트리, 파서)를 쓰는 곳입니다.

정리하면 JSP 쪽은 한 번 번역해 자바 바이트코드로, EL 쪽은 실행할 때 해석하되 파싱 결과를 캐시하는 방식입니다. Spring의 SpEL도 해석기를 기본으로 하고, 필요하면 바이트코드로 컴파일하는 모드를 따로 둡니다(Spring 15편).

이 글에서 뺀 것

정리

개념 한 줄 요약
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로는 본문이 비어 있었던 것도, 가짜 환경에는 이 엔진이 없기 때문입니다.

다른 글