Spring SpEL 표현식 심화

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의 인가 표현식부터 캐시 키 생성, 동적 규칙 엔진까지 — 올바르게 사용하면 코드량을 대폭 줄이고 선언적인 설계를 달성할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux