NullAway 소개
NullAway는 Java 코드에서 NullPointerException(NPE)
을 제거하는 데 도움을 주는 도구다. NullAway를 사용하려면, 먼저 필드, 메서드 파라미터, 또는 반환 값이 null
일 수 있는 경우 해당 부분에 @Nullable
어노테이션을 추가해야 한다. 이 어노테이션들을 기반으로, NullAway는 타입 기반의 로컬 검사를 수행하여 코드 내에서 dereference(참조)되는 어떤 포인터도 null이 아님을 보장한다. NullAway는 Kotlin이나 Swift 언어에서의 타입 기반 널 안정성 검사, 그리고 Java용 Checker Framework나 Eradicate와 유사하다.
NullAway는 매우 빠르다. Error Prone의 플러그인으로 제작되었으며, 모든 빌드 과정에서 실행할 수 있다. 측정 결과, NullAway 실행에 따른 빌드 시간 오버헤드는 보통 10% 미만이다. 또한 실용적이다. 코드의 모든 NPE를 방지하지는 않지만, 실제 프로덕션 환경에서 발생하는 대부분의 NPE를 잡아낼 수 있으며, 필요한 어노테이션의 양도 적당해서 효율적인 도구라 할 수 있다.
NullAway 의존성 추가
plugins {
id 'java'
id 'java-library'
id "net.ltgt.errorprone" version "${errorProneGradlePluginVersion}"
}
dependencies {
// nullaway
errorprone "com.uber.nullaway:nullaway:${nullawayVersion}"
api "org.jspecify:jspecify:${jspecifyVersion}"
errorprone "com.google.errorprone:error_prone_core:${errorProneVersion}"
}
- Maven 저장소
NullAway 기본 설정
import net.ltgt.gradle.errorprone.CheckSeverity
tasks.withType(JavaCompile) {
options.errorprone {
check("NullAway", CheckSeverity.ERROR)
option("NullAway:AnnotatedPackages", "${basePackage}")
}
}
check("NullAway", CheckSeverity.ERROR)
: NullAway가 감지한 null 문제를 오류로 처리하도록 설정한다.option("NullAway:AnnotatedPackages", "${basePackage}")
: NullAway가 분석할 대상 패키지를 지정한다.
NullAway 추가 설정
- 추가 설정 시 복수의 파라미터를 넣는 경우 띄어쓰기를 하지 않는다. (eg.
annotation1,annotation2
)
UnannotatedSubPackages
AnnotatedPackages 목록에서 제외할 하위 패키지들의 목록이다.
tasks.withType(JavaCompile) {
// ...
option("NullAway:UnannotatedSubPackages", "${basePackage}.config")
}
ExcludedClassAnnotations
클래스가 널 가능성 분석에서 제외되도록 하는 어노테이션 목록
NullAway는 이러한 클래스의 코드를 분석하지는 않지만, 클래스의 메서드에 대한 호출자를 분석할 때 API가 올바르게 어노테이션이 추가되어 있다고 가정한다.
tasks.withType(JavaCompile) {
// ...
option("NullAway:ExcludedClassAnnotations", "Annotation1,Annotation2")
}
테스트 클래스 제외
tasks.withType(JavaCompile) {
// ...
if (name.toLowerCase().contains("test")) {
options.errorprone {
disable("NullAway")
}
}
}
NullAway 지원 어노테이션
Nullability
NullAway는 단순 이름(패키지명이 없는 이름)이 @Nullable
인 모든 애노테이션을, 해당 파라미터/반환값/필드가 null 가능함을 나타내는 것으로 간주한다.
NullAway가 어노테이션으로 간주하는 코드에 대해서는, @Nullable
애노테이션이 없는 경우 해당 타입을 non-null(널이 아님)으로 가정한다. 하지만 NullAway에는 서드파티 JAR의 제한적 애노테이션을 인식하는 옵션 지원처럼, 명시적으로 @NonNull
애노테이션이 사용되었는지를 검사하는 기능도 일부 포함되어 있다. 이와 관련하여, NullAway는 단순 이름이 @NonNull, @Nonnull, 또는 @NotNull인 모든 애노테이션을 명시적인 non-null 타입으로 간주한다.
NullAway는 어노테이션이 달린 코드에서는 기본값을 non-null로 간주하지만, 다른 도구들은 위에서 언급된 애노테이션들이 명시적으로 붙어 있기를 기대할 수 있다. 특히 javax.validation.constraints.NotNull
(또는 @NotEmpty
)은 역직렬화된 데이터를 동적으로 검증할 때 사용되는 애노테이션으로, 외부에서 필드가 초기화됨을 의미하므로 -XepOpt:NullAway:ExcludedFieldAnnotations=...
옵션에 추가하기에 적합한 후보다.
그리고 NullAway에서는 다양한 도구와의 호환성을 위해 org.jspecify.annotations.Nullable
또는 javax.annotation.Nullable
과 같은 표준 nullability 애노테이션 사용을 강력히 권장하지만, NullAway는 사용자 정의 nullness 애노테이션을 추가로 구성할 수 있는 기능도 지원한다.
@NullMarked
and @NullUnmarked
NullAway는 JSpecify @NullMarked
및 @NullUnmarked
어노테이션을 지원한다. 클래스/메서드에 @NullMarked
로 주석을 달면 해당 API가 null에 대해 주석이 달린 것으로 처리되고, @NullUnmarked
는 해당 API가 주석이 없는 것으로 처리된다는 의미 한다. 또한 NullAway는 @NullUnmarked
코드 내에서 검사를 수행하지 않는다. 기본적으로 지정된 주석이 지정된 패키지 내의 클래스는 @NullMarked
로 처리되고, 해당 패키지 외부의 클래스는 @NullUnmarked
로 처리된다. NullMarked 클래스 내의 개별 메서드는 @NullUnmarked
로 처리될 수 있다.
Field Contracts (precondition and postcondition)
- Precondition:
@RequiresNonNull({"class_fields"})
- Postcondition:
@EnsuresNonNull({"class_fields"})
이러한 어노테이션을 사용하면 수신자 필드의 null 가능성과 관련하여 클래스 메서드에 전제 조건과 후제 조건을 설정할 수 있다. 그리고 메서드 호출자를 보다 정확하게 분석하는 데 사용된다.
@RequiresNonNull
메서드에 @RequiresNonNull
이 추가된 경우, NullAway는 메서드 본문을 확인하는 동안 어노테이션 매개변수에 지정된 @Nullable
필드가 @NonNull
이라고 가정하고 모든 호출 사이트에서 해당 필드가 @NonNull
인지 확인한다.
import org.checkerframework.errorprone.checker.nullness.qual.RequiresNonNull;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.stereotype.Component;
import java.util.Locale;
@Component
public class MessageSourceAccessor implements MessageSourceAware {
@Nullable
private static org.springframework.context.support.MessageSourceAccessor messageSourceAccessor;
@RequiresNonNull("messageSourceAccessor")
public static String getMessage(final String code) {
return messageSourceAccessor.getMessage(code);
}
@RequiresNonNull("messageSourceAccessor")
public static String getMessage(final String code, final Object... args) {
return messageSourceAccessor.getMessage(code, args);
}
@Override
public void setMessageSource(final MessageSource messageSource) {
messageSourceAccessor =
new org.springframework.context.support.MessageSourceAccessor(
messageSource, Locale.getDefault());
}
}
org.springframework.context.support.MessageSourceAccessor
를 예외 클래스에 매번 주입할 수는 없기에 위와 같이 MessageSourceAccessor
를 static으로 다룰 수 있도록 설정한 코드다. 위의 경우 static으로 선언한 messageSourceAccessor
는 @Nullable
이 될 수밖에 없고 getMessage
에서는 messageSourceAccessor
가 non-null인지 checker Framework는 보장하지 못한다. 이러한 경우 @RequiresNonNull
를 사용하면 checker Framework가 해당 메서드 내부에서 사용되는 필드가 non-null이라 가정할 수 있게 된다.
@EnsuresNonNull
이 애노테이션은 메서드가 성공적으로 종료되었을 때, 특정 필드나 변수들이 null이 아님(null이 아님이 보장됨)을 나타낸다. 즉, 이 애노테이션이 붙은 메서드가 예외 없이 정상적으로 반환되면, 명시된 필드들이 반드시 non-null 상태임이 보장된다는 의미다.
@EnsuresNonNull("this.field")
void initField() {
this.field = new Object(); // 이 메서드가 끝나면 field는 절대 null이 아님
}
@EnsuresNonNullIf
이 어노테이션을 사용하면 필드의 null이 아닌지 확인하는 메서드를 정의할 수 있다.@EnuresNonNullIf
어노테이션은 두 개의 매개변수를 제공한다.
- 필수 파라미터인
value
: 이 메서드가 non-null임을 보장하는 필드들의 목록을 지정해야 한다. - 선택 파라미터인
result
: 이 메서드가 해당 필드들이 non-null일 때 반환하는 boolean 값을 나타낸다. 기본값은 true다.
NullAway에서 Map
NullAway는 적절한 검사 연산을 찾지 못하면 java.util.Map.get()
또는 com.google.common.collect.ImmutableMap.get()
호출의 반환값이 @Nullable
이라고 가정한다. get()
호출의 무효성을 직접 확인할 수 있다.
if (m.get(key) != null) {
// here m.get(key) is @NonNull
}
if (m.containsKey(key)) {
// here m.get(key) is @NonNull
}
if (!m.containsKey(key)) {
m.put(key,newValue);
}
// here m.get(key) is @NonNull, assuming newValue is @NonNull
null 값을 가진 Map
containsKey()
가 true여도 해당 키의 값이 null일 수 있다. 하지만 NullAway는 이러한 경우를 무시하고 get()
결과를 non-null로 간주한다. 따라서 Map에 null 값을 저장하지 않는 것이 좋다.
복잡한 Map 사용
현재 NullAway는 keySet()
을 통한 반복과 같은 복잡한 Map 사용을 완전히 이해하지 못한다. 이러한 경우 경고를 억제하는 방법을 선택해야 한다. (@SuppressWarnings("NullAway")
)
NullAway에서 Stream
NullAway는 특정 스트림 API, 특히 java.util.stream
및 RxJava의 API를 전문적으로 처리한다.
필터 연산
스트림에서 null 값을 걸러낸 후, 해당 값들이 null이 아님을 전제로 추가 작업을 수행하는 패턴이 일반적이다.
class Foo { @Nullable Bar f; }
...
Stream<Foo> stream = ...;
stream
.filter(x -> x.f != null)
.forEach(x -> System.out.println(x.f.g)); // 경고 없음
일반적인 정적 분석 도구는 x.f가 @Nullable
로 간주되어 x.f.g 접근 시 경고를 발생시킬 수 있다. 그러나 NullAway는 filter에 전달된 람다의 본문을 분석하여, 이후 forEach에서 x.f가 null이 아님을 이해하고 경고를 발생시키지 않는다.
동기 콜백에 대한 이해
일반적으로 메서드에서 람다로 null 가능성 정보를 전파하는 것은 안전하지 않다. 왜냐하면 람다가 비동기적으로 호출될 수 있으며, 이 경우 해당 정보가 더 이상 유효하지 않을 수 있기 때문이다.
class Foo { @Nullable Bar f; }
...
Foo x = ...;
if (x.f != null) {
runAsync(() -> System.out.println(x.f.g));
}
람다가 실행되는 시점에는 x.f가 다시 null로 설정되었을 가능성이 있기 때문에, x.f != null 조건을 체크한 null 관련 정보는 람다 본문을 검사할 때 사용되지 않는다.
하지만 람다가 비동기가 아닌 즉시 실행되는 것으로 알려진 몇 가지 경우가 있다. 예를 들어 Map.forEach
나 Collection.removeIf
에 전달되는 람다가 그렇다. NullAway는 이러한 경우들을 내부적으로 인식하고, 해당 메서드 내의 null 여부 정보(x.f != null)를 람다 내부로 더 적극적으로 전파한다.
예를 들어 아래 코드에 대해 NullAway는 아무런 오류도 보고하지 않는다.
Foo x = ...;
Collection<String> c = ...;
if (x.f != null) {
c.removeIf((y) -> x.f.toString().equals(y));
}
또한, NullAway는 java.util.stream.Stream
의 모든 메서드에 전달된 콜백은 동기적으로 실행되는 것으로 간주한다. 이는 일반적으로 안전하지만, Stream 객체를 필드에 저장하고 나중에 터미널 연산을 호출하는 경우에는 이 가정이 타당하지 않을 수 있다.
'개발' 카테고리의 다른 글
Closures 정리 (0) | 2025.07.15 |
---|---|
빌더 패턴을 활용한 테스트 픽스처 (0) | 2025.06.11 |
HTTP MIME 정리 (0) | 2025.06.04 |
MySQL 날짜 데이터 타입 정리 (0) | 2025.06.02 |
캐시 스탬피드 현상 (0) | 2025.05.30 |