본문 바로가기

spring

SpQL (Spring Expression Language) 에 대해서

[인트로]

@Value와 같은 어노테이션으로 쉽고 간편하게 필드의 값을 주입하여 빈에 대한 설정을 했지만 이때 value에 지정하는 값이 어떤 규칙으로 사용되는지에 대해서 크게 궁금하지는 않았지만 어쩌다 보니 이런 표현식이 많은 부분에서 사용되는 것을 발견하고 잘 쓸 수 있다면 spring 개발을 함에 있어서 유용한 경우가 있을 것 같다는 생각에 공부를 해보았다.

 

[spQL을 어디서 흔히 볼 수 있을까?]

1. spring framework :  @Value

2. data : @Query

3. security : @PreAuthorize @PostAuthorize @PreFilter @PostFilter

4. view : thymeleaf

 

스프링과 관련된 많은 부분에서 spQL을 사용하고 있었다.

 

[spQL의 문법?]

 

spQL은 공식 문서에 보면 특징을 나열해주는데

  • Literal expressions : 문자열 표현
  • Boolean and relational operators : 논리값과 관계식 표현들, ex) true, false, 1 > 1
  • Regular expressions : 정규표현식
  • Class expressions : 클래스 표현식 ex) com.mypackage.sub.HelloWorld 이런 것
  • Accessing properties, arrays, lists, maps : 객체의 필드 값 접근, 배열, 리스트 맵의 데이터 접근 표현식 제공
  • Method invocation : 자바 리플렉션의 메서드 invoke()를 활용한 것
  • Relational operators : 관계 표현식들 ex) 1 > 1, 3 eq 4, 정규표현식 match 문법들
  • Assignment : 값을 할당하는 것 set 메서드의 역할
  • Calling constructors : 생성자 호출로 객체 생성하는 것, 자바의 리플렉션 기술을 활용한 기술
  • Bean references : 빈 레퍼런스를 표현식으로 @을 통해서 빈을 지정하는 것
  • Array construction : 배열 생성하는 것, 자바 문법과 동일한 텍스트를 적는 것과 같았다.
  • Inline lists : 문자열 표현식으로 리스트 생성하는 것
  • Ternary operator : 삼항 연산자 표현식
  • Variables : 변수 설정하는 것
  • User defined functions : 함수형 프로그래밍이 생각나는 기능.
  • Collection projection : db projection과 같은 바로 그것, 단지 테이블이 컬랙션이 되었을 뿐
  • Collection selection : db selectino과 같은 바로 그것, 단지 테이블이 컬랙션이 되었을 뿐
  • Templated expressions : 커스텀한 구문 분석기 만들 수 있는 기능

그냥 나열된 기능을 보면 정말 어려워 보였지만 천천히 하나씩 테스트를 작성하면서 익혀보니까 어려운 것은 없었다.

 

자세한 기능에 대해서는 공식문서를 읽어보는 것을 강력히 추천한다. 

 

8. Spring Expression Language (SpEL)

This section introduces the simple use of SpEL interfaces and its expression language. The complete language reference can be found in the section Language Reference. The following code introduces the SpEL API to evaluate the literal string expression 'Hel

docs.spring.io

 

문자표현식으로 간단하게 원하는 상태를 기술할 수도 있고 많은 강력한 기능이 존재하고 spring에서 유용하게 쓸 수 있다는 점이 spEL의 강점이라고 생각했다.

 

단점은 런타임 시점에서 버그를 발견할 수 있다는 점이 라고 생각한다. 각종 설정 파일의 데이터를 설정하는 것은 초기 구동 시점에서 발견할 수 있어서 버그를 조기에 발견할 수 있지만 프로덕트 코드에서 spQL을 이용해서 객체를 다루는 것은 위험하다고 생각이 들었다.

 

[spEL 테스트 코드]

TestClass는 contructor 호출이 내부 클래스에서 작동하지 않아서 다른 패키지에 선언했다. 기능은 딱 테스트에 필요한 기능만 있는 POJO 클래스이다.

외존성은 부트와 spring-boot-starter-test 정도면 돌아가지 않을까? 싶다.

package com.study.practice.spring.spel;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import java.util.List;

import static org.assertj.core.api.Assertions.*;

/**
 * 공식 문서
 * https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html
 */
public class SpELTest {

    SpelExpressionParser parser = new SpelExpressionParser();

    @DisplayName("문자열 표현식")
    @Test
    void literal_expression() {
        //when //then
        Expression expression = parser.parseExpression("'Hello world'");
        System.out.println(expression.getValue());
        assertThat(expression.getValue()).isEqualTo("Hello world");
    }

    @DisplayName("문자열 메서드 호출")
    @Test
    void literal_method_call() {
        //given
        String expressionString = "'Hello world'.concat(' my name is xxx')";
        //when
        Expression expression = parser.parseExpression(expressionString);
        //then
        String result = "Hello world".concat(" my name is xxx");
        assertThat(result).isEqualTo(expression.getValue());
    }

    @DisplayName("문자열 메서드 byte 호출")
    @Test
    void literal_method_byte_call() {
        //given 매개 변수가 없는 메서드는 () 생략이 가능
        String expressionString = "'Hello world'.bytes";
        //when
        Expression expression = parser.parseExpression(expressionString);
        //then
        byte[] result = "Hello world".getBytes();
        assertThat(result).isEqualTo(expression.getValue());
    }

    class Sample {
        private String name = "sample data";

        public Sample() {
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }

    @DisplayName("Context 객체를 통해서 특정 객체의 필드 값 조회하기")
    @Test
    void extract_value_at_object() {
        //given
        Expression expression = parser.parseExpression("name");
        //when //then
        assertThat("sample data").isEqualTo(expression.getValue(new Sample()));
    }

    /**
     * EvaluationContext 를 사용하는 이유?
     * EvaluationContext 를 생성하는 비용이 크다. 따라서 동일한 context 를 사용하는 경우가 많다면
     * context 를 생성하여 생성 비용을 줄이는 것이 효율적이기 때문에 사용한다.
     */
    @DisplayName("Context 객체를 통해서 특정 객체의 필드 값 조회하기")
    @Test
    void extract_value_at_object_with_context() {
        //given
        Expression expression = parser.parseExpression("name");
        EvaluationContext context = new StandardEvaluationContext(new Sample());
        //when //then
        assertThat("sample data").isEqualTo(expression.getValue(context));
        /**
         * 특정 객체의 값을 조회할 때 public 필드이거나 getXXX() 와 같은 조회 메서드가 존재해야 한다.
         * 그렇지 않으면 SpelEvaluationException 발생
         */
    }

    class Sample2 {
        private String name = "sample data";
    }

    @DisplayName("Context 객체를 통해서 특정 객체 값 조회하기 private 접근")
    @Test
    void extract_value_at_object_with_context_throw_exception() {
        //given
        Expression expression = parser.parseExpression("name");
        EvaluationContext context = new StandardEvaluationContext(new Sample2());
        //when //then
        assertThatThrownBy(() -> expression.getValue(context))
                .isInstanceOf(SpelEvaluationException.class);
    }

    @DisplayName("spel 를 통한 객체 값 변경")
    @Test
    void set_value_at_object() {
        //given
        Expression expression = parser.parseExpression("name");
        Sample sample = new Sample();
        EvaluationContext context = new StandardEvaluationContext(sample);
        //when
        if (expression.isWritable(context)) expression.setValue(context, "my name");
        //then
        assertThat(sample.getName()).isEqualTo("my name");
    }

    @DisplayName("spel 을 이용한 객체 생성")
    @Test
    void create_object() {
        //given
        System.out.println(TestClass.class);
        Expression expression = parser.parseExpression("new com.study.practice.spring.spel.TestClass()");
        //when
        TestClass sample = expression.getValue(TestClass.class);
        //then
        assertThat(sample).isNotNull();
    }

    @DisplayName("spel 을 이용한 primitive type or string 객체 생성")
    @Test
    void create_primitive_value() {
        //given
        Expression stringExpression = parser.parseExpression("new String('hi')");
        //when
        String value = stringExpression.getValue(String.class);
        //then
        assertThat(value).isEqualTo("hi");
    }

    @DisplayName("spel 변수 등록해서 표현식에서 사용하기")
    @Test
    void variable() {
        //given
        Sample sample = new Sample();
        System.out.println("before : " + sample.getName());
        EvaluationContext context = new StandardEvaluationContext(sample);
        context.setVariable("name", "hong");
        Expression expression = parser.parseExpression("name = #name");

        //when
        expression.getValue(context);

        //then
        System.out.println("after : " + sample.getName());
        assertThat(sample.getName()).isEqualTo("hong");
    }

    @DisplayName("context 에 변수를 이용하여 컬랙션 필더링 하기")
    @Test
    void variable_this_and_root() {
        //given // create an array of integers
        List<Integer> primes = List.of(2, 3, 5, 7, 11, 13, 17);
        // create parser and set variable 'primes' as the array of integers
        EvaluationContext context = new StandardEvaluationContext();
        context.setVariable("primes", primes);
        //when //then // all prime numbers > 10 from the list (using selection ?{...})
        // evaluates to [11, 13, 17]
        List<Integer> primesGreaterThanTen =
                (List<Integer>) parser.parseExpression("#primes.?[#this>10]").getValue(context);
        System.out.println(primesGreaterThanTen);
        assertThat(primesGreaterThanTen).contains(11, 13, 17);
    }

    @DisplayName("Elvis Operator")
    @Test
    void elvisOperator() {
        //given
        Expression expression = parser.parseExpression("null?:'default'");
        //when //then
        assertThat(expression.getValue()).isEqualTo("default");
    }

    @DisplayName("null 타입 체크 이후 실행")
    @Test
    void safeNavigationOperator() {
        //given
        Sample sample = new Sample();
        sample.setName(null);

        EvaluationContext context = new StandardEvaluationContext(sample);
        Expression safeExpression = parser.parseExpression("name?.length()");
        Expression notSafeExpression = parser.parseExpression("name.length()");

        //when //then
        Integer value = safeExpression.getValue(context, Integer.class);
        System.out.println(value);
        assertThat(value).isNull();
        assertThatThrownBy(() -> notSafeExpression.getValue(context, Integer.class))
                .isInstanceOf(SpelEvaluationException.class);
    }

    @DisplayName("컬랙션 요소 select 하기")
    @Test
    void collectionSelection() {
        //given
        List<Integer> list = List.of(1, 2, 3, 4, 5);
        EvaluationContext context = new StandardEvaluationContext();
        context.setVariable("list", list);
        // lt, le, gt, ge, eq, ne = <, <=, >, >=, ==, !=
        Expression expression1 = parser.parseExpression("#list.?[#this lt 3]");
        // filter , sql where 와 같은 역할
        Expression expression2 = parser.parseExpression("#list.?[#this == 3]");
        // 앞에서 부터 하나의 값 select
        Expression expression3 = parser.parseExpression("#list.^[#this==3]");
        // 뒤에서 부터 하나의 값 select
        Expression expression4 = parser.parseExpression("#list.$[#this<5]");

        //when //then
        System.out.println(expression1.getValue(context).getClass());
        System.out.println(expression1.getValue(context));

        assertThat(expression1.getValue(context, List.class)).contains(1, 2);
        assertThat(expression2.getValue(context, List.class)).contains(3);
        assertThat(expression3.getValue(context, List.class)).contains(3);
        assertThat(expression4.getValue(context, List.class)).contains(4);
    }

    class ProjectionSample {
        public String field1;
        public String field2;
        public String field3;

        public ProjectionSample(String field1, String field2, String field3) {
            this.field1 = field1;
            this.field2 = field2;
            this.field3 = field3;
        }

        public String getField1() {
            return field1;
        }

        public String getField2() {
            return field2;
        }

        public String getField3() {
            return field3;
        }
    }

    @DisplayName("단일 칼럼 프로젝션하기")
    @Test
    void collectionProjection() {
        // given
        List<?> list = List.of(
                new ProjectionSample("1-1", "1-2", "1-3"),
                new ProjectionSample("2-1", "2-2", "2-3"),
                new ProjectionSample("3-1", "3-2", "3-3")
        );
        EvaluationContext context = new StandardEvaluationContext(list);
        context.setVariable("sample", list);
        Expression expression = parser.parseExpression("#sample.![field3]");
        // when
        List value = expression.getValue(context, List.class);
        // then
        assertThat(value).contains("1-3", "2-3", "3-3");
    }

    @DisplayName("복수 칼럼 프로젝션하기")
    @Test
    void collectionMultiColumnProjection() {
        // given
        List<?> list = List.of(
                new ProjectionSample("1-1", "1-2", "1-3"),
                new ProjectionSample("2-1", "2-2", "2-3"),
                new ProjectionSample("3-1", "3-2", "3-3")
        );
        EvaluationContext context = new StandardEvaluationContext(list);
        context.setVariable("sample", list);
        Expression expression = parser.parseExpression("{#sample.![field3], #sample.![field2]}");
        // when
        List value = expression.getValue(context, List.class);
        // then
        System.out.println(value);
        assertThat(value).contains(
                List.of("1-3", "2-3", "3-3"),
                List.of("1-2", "2-2", "3-2")
        );
    }
}