SpEL은 기본적으로 인터프리터다(03장). AST를 매번 재귀 순회하며 리플렉션으로 프로퍼티·메서드에 접근한다. 유연하지만, 같은 표현식을 초당 수만 번 평가하는 Spring Integration·메시징 같은 곳에서는 느리다. 이 장은 SpEL이 자주 쓰이는 표현식을 실제 JVM 바이트코드로 컴파일해 인터프리터를 우회하는 메커니즘을 다룬다.
핵심 아이디어는 이렇다. SpEL은 표현식의 정적 타입을 문법만으로는 알 수 없다. 그래서 인터프리트로 몇 번 돌려 본 뒤, 그때 학습한 타입 정보로 바이트코드를 찍어낸다(03장에서 본 exitTypeDescriptor가 그 학습 결과다). 타입 검사가 없는 빠른 코드를 생성하므로, 타입이 안정적인 표현식에 가장 잘 맞는다.
SpelParserConfiguration(compilerMode = OFF|IMMEDIATE|MIXED)
│ 평가 횟수 임계치 도달
▼
SpelExpression.compileExpression()
│
▼
SpelCompiler.compile(ast)
│ 1) ast.isCompilable() ? (모든 노드가 컴파일 가능?)
│ 2) ClassWriter 로 CompiledExpression 서브클래스 바이트코드 생성
│ - ast.generateCode(mv, codeFlow) 로 getValue 본문 채움
│ 3) ChildClassLoader 로 정의·로드
▼
CompiledExpression 인스턴스 ──▶ SpelExpression.compiledAst 에 저장
│
▼ 이후 getValue 는
compiledAst.getValue(root, context) (리플렉션 없이 직접 실행)
spel/SpelCompilerMode.java의 세 값이 정책을 정한다.
| 모드 | 동작 | 컴파일 실패/타입 변화 시 |
|---|---|---|
OFF (기본) |
항상 인터프리트 | — |
IMMEDIATE |
첫 인터프리트 직후 컴파일 | 컴파일 표현식 실행 실패가 호출자에게 예외로 전파 |
MIXED |
일정 횟수(100회) 인터프리트 후 컴파일 | 실패를 내부에서 흡수, 조용히 인터프리트로 복귀 후 재시도 |
MIXED는 타입이 가끔 바뀌어도 견디고, IMMEDIATE는 타입이 절대 안 바뀐다고 확신할 때 빠르게 컴파일한다. 모드는 시스템 프로퍼티 spring.expression.compiler.mode로도 설정할 수 있다(SpelParserConfiguration의 정적 초기화).
컴파일 결과는 CompiledExpression(spel/CompiledExpression.java)을 상속하는 런타임 생성 클래스다. 추상 메서드는 단 하나.
public abstract Object getValue(Object target, EvaluationContext context) throws EvaluationException;생성된 서브클래스의 getValue 본문이 곧 그 표현식의 동작을 바이트코드로 박은 것이다. 사용자가 상속할 물건이 아니라 컴파일러 전용이다.
CodeFlow(spel/CodeFlow.java)는 바이트코드를 찍는 동안의 중간 상태와 헬퍼를 모은다. 가장 중요한 역할은 "지금 스택 맨 위에 있는 값의 타입"을 추적하는 것이다(compilationScopes). 자바 바이트코드는 타입을 알아야 박싱/언박싱·체크캐스트·올바른 연산 명령을 고를 수 있기 때문이다. 또한 노드가 클래스 필드나 정적 초기화 코드를 추가 등록할 수 있는 FieldAdder/ClinitAdder 훅도 보유한다.
03장에서 매 평가 끝에 checkCompile(state)가 호출되는 것을 봤다. 여기가 컴파일을 촉발한다.
private void checkCompile(ExpressionState state) {
this.interpretedCount.incrementAndGet(); // 평가 횟수 누적
SpelCompilerMode mode = state.getConfiguration().getCompilerMode();
if (mode == SpelCompilerMode.IMMEDIATE) {
if (this.interpretedCount.get() > 1) compileExpression();
}
else if (mode == SpelCompilerMode.MIXED) {
if (this.interpretedCount.get() > INTERPRETED_COUNT_THRESHOLD /*100*/) compileExpression();
}
}compileExpression()은 동기화 블록 안에서 SpelCompiler.compile(ast)를 호출하고, 성공하면 결과를 compiledAst(volatile)에 저장한다. 100번 실패하면(FAILED_ATTEMPTS_THRESHOLD) 더는 시도하지 않는다.
이후 getValue는 맨 앞에서 compiledAst를 확인해, 있으면 컴파일 경로로 빠진다(SpelExpression.getValue).
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
try {
return compiledAst.getValue(context.getRootObject().getValue(), context);
}
catch (Throwable ex) {
if (mode == MIXED) { this.compiledAst = null; this.interpretedCount.set(0); } // 인터프리트 복귀
else throw new SpelEvaluationException(ex, EXCEPTION_RUNNING_COMPILED_EXPRESSION); // IMMEDIATE
}
}
// ... 인터프리트 경로 ...MIXED에서 컴파일된 코드가 던지면 조용히 compiledAst를 버리고 인터프리트로 돌아간다. 이것이 표 위쪽에서 말한 "실패 흡수"의 실제 코드다.
SpelCompiler.compile은 먼저 ast.isCompilable()로 트리 전체가 컴파일 가능한지 묻는다. 한 노드라도 불가하면 null을 반환하고 인터프리트가 유지된다. 가능하면 createExpressionClass가 ASM ClassWriter로 클래스를 만든다.
createExpressionClass(ast):
1) "org/.../generated/CompiledExpression#####" 이름의 클래스 골격 생성
extends CompiledExpression, target V17
2) 기본 생성자 바이트코드
3) getValue(Object, EvaluationContext) 메서드 시작
CodeFlow cf = new CodeFlow(className, cw)
ast.generateCode(mv, cf) ← 각 노드가 자기 부분 바이트코드 기여
4) 스택 맨 위 값을 박싱(insertBoxIfNecessary), void면 null 푸시
ARETURN
5) cf.finish() → 등록된 필드/정적초기화 코드 마감
6) loadClass(...) 로 ChildClassLoader 에 정의
generateCode는 SpelNode의 기본 메서드로, 컴파일 불가 노드는 IllegalStateException을 던지게 되어 있다. 코드 생성 중 이 예외가 나면 컴파일러는 조용히 컴파일을 포기한다(createExpressionClass의 catch). 즉 isCompilable()을 통과해도 코드 생성 단계에서 한 번 더 옵트아웃할 수 있다.
생성된 클래스는 메모리를 잡아먹고 GC를 막는다. SpelCompiler는 클래스로더당 하나씩 캐시되고(getCompiler), 내부에 ChildClassLoader를 둔다. 한 차일드 로더가 정의한 클래스가 CLASSES_DEFINED_LIMIT(100)을 넘으면 새 차일드 로더로 교체해, 옛 변형들이 GC될 수 있게 한다(loadClass). 표현식이 타입 변화로 계속 재컴파일되는 상황에서 메모리 누수를 줄이는 장치다.
SpelExpression.checkCompile/compileExpression— 평가 횟수를 세고 임계치에서 컴파일을 촉발. 실패 횟수 누적으로 포기 판단.SpelCompiler.compile/createExpressionClass—isCompilable()게이트 → ASM으로CompiledExpression서브클래스 생성. 코드 생성 중 2차 옵트아웃.SpelNode.isCompilable/generateCode— 각 노드가 자신을 컴파일 가능 여부와 바이트코드로 표현. 컴파일러의 확장 단위.SpelCompiler.loadClass— 차일드 클래스로더 교체로 생성 클래스 누적을 제어.SpelExpression.revertToInterpreted— 컴파일 형태를 버리고 인터프리트로 되돌리는 공개 API.
- 프로파일 기반 JIT 유사 전략: 정적 타입을 모르는 동적 언어를 다루기 위해, 인터프리트 실행에서 타입을 "학습"한 뒤 컴파일한다. 평가와 컴파일이
exitTypeDescriptor로 이어진다. - 안전장치의 다층 구조:
isCompilable게이트 →generateCode옵트아웃 → 실패 카운트 →MIXED의 런타임 폴백. 컴파일은 어디서 막혀도 인터프리트로 안전하게 후퇴한다. - 검사 없는 속도: 생성 코드에는 타입 체크가 없다. 그래서 빠르지만, 타입이 바뀌면
ClassCastException류로 실패한다. 모드 선택이 곧 이 트레이드오프의 조율 손잡이다. - 노드 단위 확장: 새 AST 노드를 컴파일 대상으로 만들려면
isCompilable()/generateCode()만 구현하면 된다. 컴파일러 본체는 손대지 않는다.
SpEL 컴파일러는 자주 평가되는 표현식을 CompiledExpression 서브클래스 바이트코드로 굳혀 인터프리터를 우회한다. 인터프리트 중 학습한 타입 정보를 바탕으로 ASM이 getValue 본문을 생성하고, CodeFlow가 스택 타입을 추적하며 올바른 명령을 고른다. SpelCompilerMode가 컴파일 시점과 실패 처리 정책을 정하며, isCompilable 게이트·코드 생성 옵트아웃·런타임 폴백의 다층 안전망이 "빠르지만 검사 없는" 코드의 위험을 막는다. 마지막 장은 이 모든 평가가 문자열 템플릿 안에 섞여 들어가는 common 패키지를 다룬다.