렌딧에서는 매주 수요일, 백엔드 챕터 회의를 갖고 있어요.
이 챕터는 그 주에 발생한 에러와 여러가지 백엔드 이슈들에 대해 논의하는 자리이구요.

최근 챕터에서 제가 속한 채권플랫폼팀 팀장님이자 ✨렌딧 최고의 브레인✨ miles 가
아주 흥미로웠던 한 에러에 대해 설명해주셨는데요. 그 내용이 챕터에서만 공유되기엔 아까워서, 마치 제가 찾은 것처럼 글로 정리해보았습니다.
블로그 글 업로드에 흔쾌히 허락해주신 miles 감사합니다~

평화로운 오후, 느닷없는 에러

org.springframework.dao.InvalidDataAccessApiUsageException: Expected lazy evaluation to yield a non-null value but got null!

평화로운 오후, 느닷없는 에러가 떴습니다. null, lazy evaluation? 섯부른 추측은 뒤로하고,

렌딧에서 로그관리를 위해 사용 중인 Graylogsentry 를 통해 스택 트레이스부터 살펴봐야겠습니다.

1
Caused by: java.lang.IllegalStateException: Expected lazy evaluation to yield a non-null value but got null!
2
	at org.springframework.data.util.Lazy.get(Lazy.java:97)
3
	at org.springframework.data.mapping.PreferredConstructor$Parameter.hasSpelExpression(PreferredConstructor.java:285)
4
	at org.springframework.data.mapping.model.SpELExpressionParameterValueProvider.getParameterValue(SpELExpressionParameterValueProvider.java:48)
5
	at org.springframework.data.convert.KotlinClassGeneratingEntityInstantiator$DefaultingKotlinClassInstantiatorAdapter.extractInvocationArguments(KotlinClassGeneratingEntityInstantiator.java:230)
6
	at org.springframework.data.convert.KotlinClassGeneratingEntityInstantiator$DefaultingKotlinClassInstantiatorAdapter.createInstance(KotlinClassGeneratingEntityInstantiator.java:204)
7
	at org.springframework.data.convert.ClassGeneratingEntityInstantiator.createInstance(ClassGeneratingEntityInstantiator.java:84)
8
	at org.springframework.data.mongodb.core.convert.MappingMongoConverter.read(MappingMongoConverter.java:321)
9
	...
10
	at org.springframework.data.mongodb.core.MongoTemplate.execute(MongoTemplate.java:585)
11
	at org.springframework.data.mongodb.core.MongoTemplate.doAggregate(MongoTemplate.java:2172)
12
	at org.springframework.data.mongodb.core.MongoTemplate.aggregate(MongoTemplate.java:2141)

MongoDB 에 aggregate 를 요청한 뒤, 리스폰스에 맞게 객체를 생성하다 에러가 났군요.

에러와 직접적인 연관이 있는 코드는 4-1 부터 나오니 참고해주세요.
다만, 해당 코드가 왜 호출되었는지 이해하기 위해서, 엔티티 초기화 시점부터 따라가볼게요!
(바쁘신 분은 4-1. 로 번호 매긴 주석부터!)

1
// 1. 코틀린 클래스에 맞게 엔티티를 초기화해주는 클래스에요.
2
public class KotlinClassGeneratingEntityInstantiator extends ClassGeneratingEntityInstantiator {
3
4
    // ...중략
5
6
    // 1-1. 객체를 생성하기 위해, 해당 객체의 생성자의 인자를 순회하며 각각에 대응하는 값을 가져오는 함수입니다.
7
    private <P extends PersistentProperty<P>, T> Object[] extractInvocationArguments(
8
            @Nullable PreferredConstructor<? extends T, P> preferredConstructor, ParameterValueProvider<P> provider
9
    ) {
10
        // ...중략
11
12
        // 1-2. 생성자로부터 인자들 목록을 가져옵니다.
13
        List<Parameter<Object, P>> parameters = preferredConstructor.getParameters();
14
    
15
        for (int i = 0; i < userParameterCount; i++) {
16
    
17
            Parameter<Object, P> parameter = parameters.get(i);
18
            
19
            // 1-3. provider 를 통해 인자(parameter)에 해당하는 값을 알아냅니다. 
20
            // 이번 에러에서 provider 는 SpELExpressionParameterValueProvider 였네요.
21
            params[i] = provider.getParameterValue(parameter);
22
        }
23
24
        // ...중략
25
    }
26
    
27
    // ...중략
28
}

그렇담 SpELExpressionParameterValueProvider::getParameterValue() 를 확인해봅시다.

1
// 2. 생성자 인자의 필드 어노테이션으로 @Value("#root.property ?: 0") 등과 같이 
2
// 스펠(SpEL) 표현식을 쓸 수 있는데요. 그러한 스펠에 대응하는 값을 제공하는 구현체군요. - 중요하진 않습니다.
3
public class SpELExpressionParameterValueProvider<P extends PersistentProperty<P>>
4
		implements ParameterValueProvider<P> {
5
6
        // ...중략
7
    
8
	@Nullable
9
	public <T> T getParameterValue(Parameter<T, P> parameter) {
10
11
            // 2-1. 파라미터가 Spel 표현식을 사용하고 있는 지 확인하고 있어요.
12
            if (!parameter.hasSpelExpression()) {
13
                // ...중략
14
            }
15
            
16
                // ...중략
17
	}
18
}

Parameter::hasSpelExpression() 를 확인할 차례군요.

1
// 3. 위의 parameter 구현체에요.
2
public static class Parameter<T, P extends PersistentProperty<P>> {
3
4
    // ...중략    
5
6
    private final Lazy<Boolean> hasSpelExpression;
7
8
    // ...중략    
9
10
    public Parameter(@Nullable String name, TypeInformation<T> type, Annotation[] annotations, @Nullable PersistentEntity<T, P> entity) {
11
        // ...중략
12
        // 3-3. Lazy.of( ) 인자 변수명은 supplier 에요. 다음 클래스에서 supplier 가 쓰여요.
13
        // StringUtils.hasText(...) 의 리턴 값은 boolean이어서 null 일 수 없구요. 
14
        this.hasSpelExpression = Lazy.of(() -> StringUtils.hasText(getSpelExpression()));
15
    }
16
17
    // 3-1. 이게 호출되었어요.
18
    public boolean hasSpelExpression() {
19
        // 3-2. 여기서 this 는 parameter!
20
        return this.hasSpelExpression.get();
21
    }
22
    
23
    // ...중략
24
}

hasSpelExpression 의 타입은 Lazy<Boolean> 이니, Lazy::get() 을 확인해보면 되겠어요.

1
public class Lazy<T> implements Supplier<T> {
2
3
	private final Supplier<? extends T> supplier;
4
	private @Nullable T value = null;
5
    private boolean resolved = false;
6
7
    // ...중략
8
9
    public T get() {
10
        // 4-1. 여기서 null 이 반환되었어요.
11
        T value = getNullable();
12
        
13
        if (value == null) {
14
            // 여기서 예외가 발생했어요!
15
            throw new IllegalStateException("Expected lazy evaluation to yield a non-null value but got null!");
16
        }
17
18
        return value;
19
    }
20
21
    // ...중략
22
    // 4-2. (여기가 제일 중요해요!)
23
    public T getNullable() {
24
25
        T value = this.value;
26
   
27
        // 4-4. 즉 여기서 this.resolved = true 였고
28
        // value 가 null 인 상태로 리턴되었다는 말이겠네요.
29
        if (this.resolved) {
30
            return value;
31
        }
32
33
        // 4-3. 위 3-3. 에 람다로 정의된 함수가 아래 supplier 란 이름으로 쓰입니다.
34
        // 위에서 얘기한 것처럼 supplier.get() 은 boolean type이기 때문에 절대 null 일 수 없구요.
35
        // 다시말해 여기 라인 이하에서 value 는 반드시 not null 이겠어요.
36
        value = supplier.get();
37
38
        // 4-5. 엥...? 하지만 this.resolved = true 가 되는 경우는 여기 뿐이에요... 이상한데요?
39
        this.value = value;
40
        this.resolved = true;
41
42
        return value;
43
    }
44
}

이번 에러의 핵심인 Lazy 클래스의 코드와 주석을 바탕으로 짧게 정리하자면…

value 가 not null 일 때 비로소 resolved = true 인데, resolved = true 인 상태에서 value = null 인 경우가 발생했다!

miles 는 여기서 스레드 동시성 문제임을 의심하셨다는데요. 더하여 이 글 을 보고 확신하셨다고 합니다.
실제로도 비즈니스 로직에서 해당 로직을 호출할 때 Collection.parallelStream() 을 호출했구요.
여러분은 문제의 원인을 눈치채셨나요?

원인은?

코드를 다시한번 보겠습니다. 힌트를 적어두었어요!

만약 Lazy 클래스가 초기화된 직후, 두개의 스레드 A, B 가 T getNullable() 를 호출하면 어떻게 될까요?

1
public class Lazy<T> implements Supplier<T> {
2
    
3
    // 여기 선언된 this.value, this.resolved 는 두 스레드가 공유하는 공유자원!
4
    private @Nullable T value = null;
5
    private boolean resolved = false;
6
    
7
    public T getNullable() {
8
        // 여기 선언된 value 는 스레드가 공유하지 않는 지역변수! 
9
        T value = this.value; // line 1
10
    
11
        if (this.resolved) { // line 2
12
            return value; // line 3
13
        }
14
    
15
        value = supplier.get(); // line 4
16
    
17
        this.value = value; // line 5
18
        this.resolved = true; // line 6
19
    
20
        return value; // line 7
21
    }
22
}

다음과 같은 상황을 가정해봅시다.

  1. Lazy 클래스가 초기화된 직후이다.
  2. 스레드 A : line 1 ~ 6 을 수행하였다. ( context switch )
  3. 스레드 B : line 1 을 수행하였다. ( context switch )
  4. 스레드 A : line 6 을 수행하였다. ( context switch )
  5. 스레드 B : line 2 를 실행하려고 한다.

스레드 A의 line 6가 이미 실행되었기 때문에 this.resolved는 true 이고 line 3 을 수행하겠군요.
어라? 리턴하려는 value 는 지역변수?
line 5 에서 스레드 A 가 this.value 에 값을 할당하기 전의 값, 즉 null이 지역변수 value에 저장되었습니다.
스레드 B 는 null 을 리턴하게 됩니다 ㅠㅠ

에러는 Lazy 의 Thread Safety 가 보장되지 않아 발생한 것이었어요.
spring-data-commons 에 버그가 있었던 것입니다!

그래서, 어떻게 해결했나요?

이 버그에 대한 픽스 커밋은 다행히 최근에 머지되었어요. 불과 10달 전이죠.

수정된 내용은 spring-projects/spring-data-commons@a442b90 에서 확인할 수 있고요.

다행히도 마이너버전 업데이트로 해당 커밋을 반영하기에 충분했고,
스프링 기반 프로젝트들의 버전을 위 커밋이 반영된 스프링 버전으로 업데이트하여 해결하였습니다.

끝으로

직접적인 원인이었던 스레드 동시성에 관한 내용도 흥미로웠지만
무엇보다 이번 버그픽스 덕분에 느낀 바가 값졌던 것 같아요.

특히 널리 사용되는 오픈소스라도 내 눈으로 코드를 보고 끝까지 원인을 짚어내자. 덮어놓고 믿지말자. 란 생각이 들었거든요.
저는 이 에러를 만났을 때, 당연히 비즈니스 로직에 무언가 오류가 있을 것이라 생각해버렸습니다.
저는 스프링 쪽 버그일 것이란 생각은 한 번 하지않고 말이죠.
스프링에 관련한 스택 트레이스는 소홀히 했고, 돌이켜보니 아쉬웠습니다.
설령 패키징된 라이브러리라도, 직접적인 예외가 발생한 코드를 열어보고 꼼꼼히 파악하는 것이 중요한 것 같아요.

오늘도 하나 더 배운 느낌입니다. 렌딧에 입사한 뒤로 많은 것을 배우는 것 같습니다 ㅎㅎ

이 글이 흥미로우셨길 바라며 글을 마칩니다.
감사합니다, 채권플랫폼팀 ian 이었습니다.