Spring Expression Language(SpEL)란?
SpEL은 Spring Framework에 내장된 강력한 표현식 언어다. 런타임에 객체 그래프를 조회·조작하고, 메서드를 호출하며, 컬렉션을 필터링할 수 있다. @Value, @Cacheable, @PreAuthorize, @ConditionalOnExpression 등 Spring 전반에서 사용되며, 단순 프로퍼티 바인딩을 넘어서는 동적 로직을 선언적으로 표현할 수 있다.
기본 문법과 @Value 활용
@Component
public class SpELBasics {
// 리터럴
@Value("#{42}")
private int number;
@Value("#{'Hello SpEL'}")
private String text;
// 프로퍼티 참조 + 기본값
@Value("${app.timeout:5000}")
private int timeout;
// SpEL + 프로퍼티 조합
@Value("#{${app.timeout:5000} * 2}")
private int doubleTimeout;
// 시스템 프로퍼티·환경변수
@Value("#{systemProperties['user.home']}")
private String userHome;
@Value("#{systemEnvironment['PATH']}")
private String path;
// Bean 참조
@Value("#{dataSource.url}")
private String dbUrl;
// 삼항 연산자
@Value("#{${app.cache.enabled:true} ? 'redis' : 'none'}")
private String cacheType;
// Elvis 연산자 (null 대체)
@Value("#{systemProperties['app.name'] ?: 'default-app'}")
private String appName;
// 안전 탐색 연산자 (?.)
@Value("#{userService.currentUser?.email}")
private String email;
}
#{...}가 SpEL 표현식이고, ${...}는 프로퍼티 플레이스홀더다. 둘을 조합할 때는 프로퍼티가 안쪽에 위치해야 한다.
컬렉션 프로젝션과 셀렉션
SpEL의 가장 강력한 기능 중 하나는 컬렉션 필터링(Selection)과 변환(Projection)이다.
@Component
@RequiredArgsConstructor
public class CollectionSpEL {
// Selection: 조건에 맞는 요소 필터링
// .?[조건] → 전체 필터링
// .^[조건] → 첫 번째 매칭
// .$[조건] → 마지막 매칭
@Value("#{userService.allUsers.?[age >= 18]}")
private List<User> adults;
@Value("#{productService.products.?[price > 10000 and category == 'electronics']}")
private List<Product> expensiveElectronics;
// Projection: 컬렉션 변환 (map 연산)
// .![표현식]
@Value("#{userService.allUsers.![name]}")
private List<String> userNames;
@Value("#{orderService.orders.![#this.total * 0.9]}")
private List<Double> discountedTotals;
// 조합: 필터링 후 변환
@Value("#{userService.allUsers.?[active == true].![email]}")
private List<String> activeUserEmails;
}
| 연산자 | 의미 | Java Stream 등가 |
|---|---|---|
.?[expr] |
필터링 (전체) | .filter() |
.^[expr] |
첫 번째 매칭 | .filter().findFirst() |
.$[expr] |
마지막 매칭 | .filter().reduce((a,b)->b) |
.![expr] |
변환 (프로젝션) | .map() |
@Cacheable에서 SpEL 활용
@Service
public class ProductService {
// 기본: 파라미터를 키로 사용
@Cacheable(value = "products", key = "#productId")
public Product findById(Long productId) { ... }
// 복합 키
@Cacheable(value = "search", key = "#category + ':' + #page")
public List<Product> search(String category, int page) { ... }
// 객체 속성 접근
@Cacheable(value = "users", key = "#request.userId")
public UserProfile getProfile(ProfileRequest request) { ... }
// 조건부 캐싱: 결과가 null이 아니고 가격이 0 이상일 때만
@Cacheable(
value = "products",
key = "#id",
condition = "#id > 0",
unless = "#result == null or #result.price <= 0"
)
public Product findProduct(Long id) { ... }
// root 객체: 메서드명, 타겟 클래스 등 접근
@Cacheable(
value = "data",
key = "#root.targetClass.simpleName + '.' + #root.methodName + ':' + #id"
)
public Data getData(String id) { ... }
// 캐시 매니저 동적 선택
@CacheEvict(
value = "products",
allEntries = true,
condition = "#product.category == 'featured'"
)
public void updateProduct(Product product) { ... }
}
Spring Cache Abstraction에서 SpEL은 캐시 키 생성과 조건부 캐싱의 핵심이다.
@PreAuthorize / @PostAuthorize 보안 SpEL
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// 역할 기반
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/all")
public List<Order> getAllOrders() { ... }
// 파라미터 참조: 자기 자신의 주문만 조회
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
@GetMapping("/user/{userId}")
public List<Order> getUserOrders(@PathVariable Long userId) { ... }
// Bean 메서드 호출
@PreAuthorize("@orderPermissionEvaluator.canAccess(#orderId, authentication)")
@GetMapping("/{orderId}")
public Order getOrder(@PathVariable Long orderId) { ... }
// 반환값 필터링 — 결과에서 민감 데이터 제거
@PostAuthorize("returnObject.userId == authentication.principal.id")
@GetMapping("/{id}/detail")
public OrderDetail getOrderDetail(@PathVariable Long id) { ... }
// @PostFilter: 컬렉션 필터링
@PostFilter("filterObject.department == authentication.principal.department")
@GetMapping("/team")
public List<Order> getTeamOrders() { ... }
}
// 커스텀 Permission Evaluator
@Component("orderPermissionEvaluator")
@RequiredArgsConstructor
public class OrderPermissionEvaluator {
private final OrderRepository orderRepository;
public boolean canAccess(Long orderId, Authentication auth) {
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) return false;
UserPrincipal user = (UserPrincipal) auth.getPrincipal();
return order.getUserId().equals(user.getId())
|| auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
}
프로그래매틱 SpEL 평가
@Service
public class DynamicRuleEngine {
private final ExpressionParser parser = new SpelExpressionParser();
// 단순 표현식 평가
public Object evaluate(String expression) {
Expression exp = parser.parseExpression(expression);
return exp.getValue();
}
// 컨텍스트 바인딩
public boolean evaluateRule(String rule, Map<String, Object> facts) {
StandardEvaluationContext context = new StandardEvaluationContext();
facts.forEach(context::setVariable);
Expression exp = parser.parseExpression(rule);
return Boolean.TRUE.equals(exp.getValue(context, Boolean.class));
}
// 실전: 동적 할인 규칙 엔진
public BigDecimal calculateDiscount(Order order, String discountRule) {
// discountRule = "#order.totalAmount > 50000 ? #order.totalAmount * 0.1 : 0"
StandardEvaluationContext ctx = new StandardEvaluationContext();
ctx.setVariable("order", order);
ctx.setVariable("now", LocalDateTime.now());
return parser.parseExpression(discountRule)
.getValue(ctx, BigDecimal.class);
}
// 타입 안전: 허용된 메서드만 실행
public Object safeEvaluate(String expression, Object root) {
SimpleEvaluationContext context = SimpleEvaluationContext
.forReadOnlyDataBinding() // 읽기 전용
.withInstanceMethods() // 인스턴스 메서드 허용
.build();
return parser.parseExpression(expression)
.getValue(context, root);
}
}
보안 주의: 사용자 입력을 SpEL로 직접 평가하면 SpEL Injection 공격에 취약하다. 외부 입력에는 반드시 SimpleEvaluationContext를 사용하고, StandardEvaluationContext는 내부 로직에서만 사용해야 한다.
@ConditionalOnExpression과 설정
// 조건부 Bean 등록
@Configuration
public class FeatureConfig {
@Bean
@ConditionalOnExpression(
"${feature.premium:false} and '${spring.profiles.active}' != 'test'"
)
public PremiumService premiumService() {
return new PremiumService();
}
@Bean
@ConditionalOnExpression("#{T(java.lang.Runtime).getRuntime().availableProcessors() >= 4}")
public ParallelProcessor parallelProcessor() {
return new ParallelProcessor();
}
}
커스텀 함수 등록
@Component
public class SpELFunctionRegistrar {
public StandardEvaluationContext createContext() throws NoSuchMethodException {
StandardEvaluationContext ctx = new StandardEvaluationContext();
// 정적 메서드를 SpEL 함수로 등록
ctx.registerFunction("mask",
StringUtils.class.getDeclaredMethod("maskEmail", String.class));
ctx.registerFunction("format",
String.class.getDeclaredMethod("format", String.class, Object[].class));
return ctx;
}
}
// 사용
// #{#mask(#user.email)} → "j***@gmail.com"
// #{#format('Order-%05d', #order.id)} → "Order-00042"
성능 최적화와 주의사항
| 항목 | 권장사항 |
|---|---|
| 파싱 캐싱 | Expression 객체를 캐싱하라. 매번 파싱하면 성능 저하 |
| 컴파일 모드 | SpelCompilerMode.IMMEDIATE로 바이트코드 컴파일 활성화 |
| SpEL Injection | 외부 입력은 SimpleEvaluationContext만 사용 |
| 복잡한 로직 | SpEL이 3줄 이상이면 Bean 메서드로 추출 (@beanName.method()) |
// 컴파일 모드 활성화
SpelParserConfiguration config = new SpelParserConfiguration(
SpelCompilerMode.IMMEDIATE, // 바이트코드 컴파일
this.getClass().getClassLoader()
);
ExpressionParser parser = new SpelExpressionParser(config);
// Expression 캐싱
private final Map<String, Expression> cache = new ConcurrentHashMap<>();
public Object evaluate(String expr, EvaluationContext ctx) {
Expression expression = cache.computeIfAbsent(expr, parser::parseExpression);
return expression.getValue(ctx);
}
SpEL은 Spring의 숨겨진 스위스 아미 나이프다. Spring Method Security의 인가 표현식부터 캐시 키 생성, 동적 규칙 엔진까지 — 올바르게 사용하면 코드량을 대폭 줄이고 선언적인 설계를 달성할 수 있다.