spring-data 의 버그가 괴롭힐 줄이야
렌딧에서는 매주 수요일, 백엔드 챕터 회의를 갖고 있어요.
이 챕터는 그 주에 발생한 에러와 여러가지 백엔드 이슈들에 대해 논의하는 자리이구요.
최근 챕터에서 제가 속한 채권플랫폼팀 팀장님이자 ✨렌딧 최고의 브레인✨
miles 가
아주 흥미로웠던 한 에러에 대해 설명해주셨는데요. 그 내용이 챕터에서만 공유되기엔 아까워서, 마치 제가 찾은 것처럼 글로 정리해보았습니다.
블로그 글 업로드에 흔쾌히 허락해주신 miles 감사합니다~
평화로운 오후, 느닷없는 에러
org.springframework.dao.InvalidDataAccessApiUsageException: Expected lazy evaluation to yield a non-null value but got null!
평화로운 오후, 느닷없는 에러가 떴습니다. null
, lazy evaluation
? 섯부른 추측은 뒤로하고,
렌딧에서 로그관리를 위해 사용 중인 Graylog
와 sentry
를 통해 스택 트레이스부터 살펴봐야겠습니다.
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 | 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 | |
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 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 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 | } |
다음과 같은 상황을 가정해봅시다.
Lazy
클래스가 초기화된 직후이다.- 스레드 A : line 1 ~ 6 을 수행하였다. ( context switch )
- 스레드 B : line 1 을 수행하였다. ( context switch )
- 스레드 A : line 6 을 수행하였다. ( context switch )
- 스레드 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 이었습니다.