본문 바로가기
dev/java

Nullness User Guide

by igooo 2025. 2. 19.
728x90
@NullMarked
public class NumberList<E extends Number> implements List<E> {...}

개요

Java의 타입 시스템은 null 안정성을 표현할 수 없다. 하지만 Spring Framework 코드 베이스는 API, 필드 및 타입 사용 시 nullness를 선언하기 위해 JSpecify 어노테이션을 사용하고 있다. 이러한 어노테이션과 의미에 익숙해지려면 JSpecify 사용자 가이드를 읽어 보는 것이 좋다.

이러한 명시적 null 안정성의 주요 목표는 NullPointerException을 빌드 타입에 검사하여 런타임에 throw 되는 것을 방지하고 명시적 nullness를 사용하여 null의 가능성을 표현하는 방법으로 전환하는 것이다. Spring Framework에서 사용하는 JSpecify에 대하여 알아본다.

 

JSpecify는 Java 타입에 null 값이 포함되어 있는지 여부를 설명하는 어노테이션을 정의한다. 이러한 어노테이션은 다음과 같은 경우 유용하게 사용된다.

  • 코드를 읽는 프로그래머
  • 개발자가 NullPointerExcetpion을 방지하는데 도움이 되는 도구들
  • 런타임 검사 및 테스트 생성을 수행하는 도구 
  • 문서화 시스템

 

Nullness

Nullness는 null이거나 값이 없는 상태를 말한다. 프로그래밍에서 nullness는 개발자가 NullPointerException을 피하는 데 도움이 되는 개념이다. Nullness 분석은 어노테이션을 사용하여 코드에서 nullness 문제를 식별하는데 도움이 되는 정적 분석 검사이다.

 

Java variables are references

Java에서 primitive 타입이 아닌 모든 변수는 null이거나 객체에 대한 참조다. 우리는 종종 String x와 같은 선언을 xString 타입이라는 의미로 생각하지만 실제로는 변수 xnull이거나 실제 String 객체에 대한 참조임을 의미한다. JSpecify는 정말로 null을 가질 수 있는 변수인지, 아니면 x가 확실한 null이 아닌 String 객체에 대한 참조라는 뜻인지 명확하게 정의하는 방법을 제공한다.

 

Types and nullness

JSpecify는 각 타입 사용에 대해 4가지 종류의 nullness 중 어떤 것을 가질 것인지를 결정하는 규칙을 제공한다.

  • null을 포함할 수 있다. (It is "nullable")
  • null은 포함되지 않는다. (It is "non-nullable")
  • For type variables only: 대체된 타입 인자가 null을 포함하는 경우, 그것도 null을 포함한다. (It has "parametric nullness")
  • null을 포함할 수 있는지 여부는 알 수 없다. (It has "unspecified nullness"). 이는 JSpecify 어노테이션이 없는 세계의 상태와 동일하다.

주어진 변수 x에 대해 xnull일 수 있으면 x.getClass()NullPointerException이 발생할 수 있으므로 안전하지 않다. xnull일 될 수 없는 경우 x.getClass()NullPointerException이 발생할 수 없다. xnull일 수 있는지 여부를 모른다면 x.getClass()가 안전한지 알 수 없다. (적어도 JSpecify 관련하여)

 

"null일 수 없다"는 개념은 "문제의 코드 중 지정되지 않은 nullness와 관련이 없는 경우"라는 각주와 함께 읽어야 한다. 예를 들어, null 여부가 지정되지 않은 타입을 @NonNull 인자만 받는 메서드에 전달하는 코드가 있으면, 도구들이 "null이 될 수 없음" 매개변수를 기대하는 메서드에 null 가능성이 있는 값을 전달하도록 할 수 있다.

모든 타입 사용의 nullness를 나태내기 위해 함께 사용되는 4가지 JSpecify 어노테이션이 있다.

  • 특정 타입 사용이 null을 포함하는지 여부를 나타내는 두 가지 어노테이션: @Nullable, @NonNull
  • 대부분의 경우 @NonNull을 입력하지 않도록 해주는 범위 어노테이션: @NullMarked
  • 어노테이션을 점진적으로 적용할 수 있도록 @NullMarked의 효과를 무효화하는 또 다른 범위의 어노테이션: @NullUnmarked

 

@Nullable and @NonNull

타입에 @Nullable 어노테이션이 추가되면 해당 타입은 null이 될 수 있음을 의미한다. @Nullable Sring xxnull일 수 있음을 의미한다. 해당 값을 사용하는 코드는 null에 대하여 예외처리를 할 수 있어야 하며, 해당 변수에 null을 할당하거나 해당 매개변수에 null을 전달해도 괜찮다.

 

타입에 @NonNull 어노테이션이 추가되면 해당 타입의 값이 null이 아니어야 함을 의미한다. @NonNull String x는 결코 xnull이 될 수 없음을 의미한다. 이러한 값을 사용하는 코드는 해당 값이 null이 아니라고 가정할 수 있으므로 해당 값에 null을 할당하거나 해당 매개변수에 null을 전달하는 것은 좋지 않다.(대부분의 경우 @NonNull을 피하는 방법은 아래를 참조한다.

static @Nullable String emptyToNull(@NonNull String x) {
  return x.isEmpty() ? null : x;
}

위 예제에서 emptyToNull 메서드에 대한 파라미터에 @NonNull 어노테이션이 추가되므로 x는 null이 될 수 없다. emptyToNull(null)은 유효한 메서드 호출이 아니다. emptyToNull 메서드의 본문은 해당 가정의 의존함으로 x.isEmpty()를 바로 호출한다. x.isEmpty()x가 실제로 null인 경우 NullPointerException을 발생시킨다. 반대로 emptyToNullnull을 반환할 수 있음으로 반환 타입에 @Nullable이라는 어노테이션을 추가한다. 

static @NonNull String nullToEmpty(@Nullable String x) {
  return x == null ? "" : x;
}

반면 nullToEmptynull 파라미터를 처리 가능하도록 함으로 해당 파라미터는 x@Nullable 어노테이션이 추가되어 nullToEmpty(null)은 유효한 메서드 호출이다. 메서드 본문에서도 파라미터가 null인 경우를 고려하고 NullPointerException을 발생시키지 않는다. null을 반환할 수 없음으로 반환 타입이 @NonNull이라는 어노테이션을 추가한다.

void doSomething() {
  // OK: nullToEmpty accepts null but won't return it
  int length1 = nullToEmpty(null).length();

  // Not OK: emptyToNull doesn't accept null; also, it might return null!
  int length2 = emptyToNull(null).length();
}

도구는(Tools) @Nullable, @NonNull 어노테이션을 사용하여 사용자에게 안전하지 않은 호출에 대한 경고를 할 수 있다.

JSpecify에 관한 한 @NonNull String@Nullable String은 다른 타입이다. @NonNull String 타입의 변수는 모든 String 객체를 참조할 수 있다. @Nullable String 타입의 변수도 모든 String 객체를 참조 가능하지만 null일 수도 있다. 이는 IntegerNumber의 하위 타입인 것과 마찬가지로 @NonNull String@Nullable String의 하위 타입을 의미한다. 이를 보는 한 가지 방법은 하위 타입이 가능한 값의 범위를 좁힌다는 것이다. 예를 들어 Number 변수에 IntegerLong 값을 모두 할당할 수 있지만 반대로 Integer 변수에는 Number 타입을 할당할 수 없다.(Number의 값이 Long일 수 있음으로) 마찬가지로 @Nullable String@NonNull String을 할당할 수 있지만 @NonNull String@Nullable String을 할당할 수 없다. (null이 될 수 있으므로) 

class Example {
  void useNullable(@Nullable String x) {...}
  void useNonNull(@NonNull String x) {...}

  void example(@Nullable String nullable, @NonNull String nonNull) {
    useNullable(nonNull); // JSpecify allows this
    useNonNull(nullable); // JSpecify doesn't allow this
  }
}

 

What about unaonnotated types?

@Nullable, @NonNull 중 하나의 어노테이션도 달려있지 않는 String 타입은 문서에 따라 null이 모함되거나 포함되지 않는다는 것을 의미한다. JSpecify에서는 이를 "unspecified nullness"라고 한다.

class Unannotated {
  void whoKnows(String x) {...}

  void example(@Nullable String nullable) {
    whoKnows(nullable); // ¯\_(ツ)_/¯
  }
}

 

@NullMarked

지정되지 않은 nullness를 방지하기 위해(특히 generic을 추가한 경우!) Java 코드의 모든 타입 사용에 @Nullable 또는 @NonNull로 어노테이션을 추가해야 하는 것은 매우 귀찮은 작업이다.

따라서 JSpecifysms @NullMarked 어노테이션을 제공한다. @NullMarked를 모듈, 패키지, 클래스 또는 메서드에 적용하면 해당 범위에 어노테이션이 없는 타입이 @NonNull로 어노테이션이 달린 것처럼 처리된다는 것을 의미한다.(아래에서는 지역 변수와 타입 변수에 대한 몇 가지 예외기 있음을 볼 수 있다.) @NullMarked가 적용되는 코드에서 String x@NonNull String x와 동일함을 의미한다.

 

모듈에 적용하면 해당 범위는 모듈이 모든 코드에 반영된다. 패키지에 적용하면 해당 범위는 패키지의 모든 코드로 반영된다.(패키지는 계층적으로 반영되지 않는다. org.igooo 패키지에 @NullMarked를 적용해도 org.igooo.blog @NullMarked 패키지에 적용되지 않는다.) 클래스, 인터페이스 또는 메서드에 적용되는 경우 해당 범위는 해당 클래스. 인터페이스 또는 메서드의 모든 코드가 적용 범위가 된다.

@NullMarked
class Strings {
  static @Nullable String emptyToNull(String x) {
    return x.isEmpty() ? null : x;
  }

  static String nullToEmpty(@Nullable String x) {
    return x == null ? "" : x;
  }
}

위 예제는 @NullMarked 어노테이션이 달린 클래스의 달린 메서드를 보여준다. 타입에 nullness는 이전과 동일하다. emptyToNullnull 파라미터를 허용하지 않지만 null을 반환할 수 있다.

 

nullToEmptynull 파라미터를 허용하지만 null을 반환하지 않는다. 하지만 우리는 더 적은 수의 어노테이션으로 이를 수행할 수 있었다. 일반적으로 @NullMarked를 사용하면 더 적은 수의 어노테이션으로 올바른 nullness 의미 체계를 얻을 수 있다. @NullMarked 코드에서는 String과 같이 어노테이션이 지정되지 않은 일반 타입을 String 객체에 대한 실제 참조를 의미하며 null이 아닌 것으로 생각하는데 익숙해진다. 

 

위에서 언급했듯이 지역 변수와 타입 변수에 대한 해석에는 몇 가지 예외가 있다.

 

@NullUnmarked

코드에 JSpecify 어노테이션을 적용하는 경우 한 번에 모든 코드에 어노테이션을 적용하지 못할 수 있다. 일부 코드에 @NullMarked를 적용하고 나머지 코드는 나중에 추가하는 방법이 있다. 하지만 이는 일부 클래스나 메서드를 제외하고 모듈, 패키지 또는 클래스를 null-mark 해야 할 수도 있음을 의미한다. 이를 위해 @NullMarked 콘텍스트 내에 있는 패키지, 클래스 또는 메서드에 @NullUnmarked를 적용한다. @NullUnmarked는 단순히 주변 @NullMarked의 효과를 무효화하여 어노테이션이 없는 타입이 @Nullable 또는 @NonNull로 어노테이션이 지정되지 않는 한 null 여부가 지정되지 않도록 한다. @NullUnmarked 범위는 중첩된 @NullMarked가 포함되어 더 좁은 범위 내에서 어노테이션이 없는 대부분의 타입 사용을 null이 아닌 상태로 만들 수 있다.

 

Local vaiables

@Nullable@NonNull은 로컬 변수에는 적용되지 않는다. - 적어도 루트 타입에는 그렇다. (타입 인자와 배열 구성 요소에는 적용되어야 한다.) 그 이유는 변수에 할당된 값을 기반으로 변수가 null일 수 있는지 여부를 추론할 수 있기 때문이다. 아래 예를 들어

@NullMarked
class MyClass {
  void myMethod(@Nullable String one, String two) {
    String anotherOne = one;
    String anotherTwo = two;
    String oneOrTwo = random() ? one : two;
    String twoOrNull = Strings.emptyToNull(two);
    ...
  }
}

분석을 통해 annotherTwo를 제외한 모든 변수는 null일 수 있음을 알 수 있다. anotherTow는 파라미터 two@Nullable이 아니며, @NullMarked 범위 내에 있기 때문에 null일 수 없다. anotherOne@Nullable 파리미터에서 할당되었기 때문에 null일 수 있다. oneOrTwo@Nullable 파라미터에서 할당될 수 있기 때문에 null일 수 있다. 마지막으로 twoOrNull@Nullable String을 반환하는 메서드에서 값을 가져오기 때문에 null일 수 있다.

 

Generics

제네릭 타입을 사용할 때, @Nullable, @NonNull 그리고 @NullMarked에 대한 규칙은 위에서 살펴본 것과 같다. 예를 들어, @NullMakred 콘텍스트 내에서 List<@Nullable String>은 리스트의 각 요소가 String 객체의 참조이거나 nullList(not null)를 의미한다. 하지만 List<String>은 각 요소가 String 객체에 대한 참조이며 null일 수 없는 List(not null)를 의미한다.

Declaring generic types

제네릭 타입을 선언할 때는 상황이 조금 더 복잡해진다.

@NullMarked
public class NumberList<E extends Number> implements List<E> {...}

extends Number는 타입 변수 E에 대한 경계를 정의한다. 이는 NumberList<Integer>를 생성할 수 있다는 것을 의미한다. 왜냐하면 IntegerNumber에 할당될  수 있기 때문이다. 하지만 NumberList<String>은 생성할 수 없다. 왜냐하면 StringNumber에 할당될 수 없기 때문이다.

 

하지만 이제 그 경계를 @NullMarked와 관련하여 생각해 보면 NumberList<@Nullable Integer>를 생성할 수 있을까?

@NullMarked 범위 안에서는 어노테이션이 없는 타입은 @NonNull로 어노테이션이 달린 것과 동일하다는 것을 기억해야 한다. E의 경계는 @Nullable Number가 아닌 @NonNull Number와 동일하므로, E의 타입 인자는 null을 포함할 수 없다. 따라서 @Nullable Integer는 타입 인자가 될 수 없다. 왜냐면 null을 포함할 수 있기 때문이다. (다시 말해, @Nullable IntegerNumber의 하위 타입이 아니기 때문이다.)

 

@NullMarked 내에서 타입 매개변수에 대해 nullable 타입 인자를 대체할 수 있도록 하려면, 타입 변수에 명시적으로 @Nullable 경계를 제공해야 한다.

@NullMarked
public class NumberList<E extends @Nullable Number> implements List<E> {...}

이제 @Nullable Integer@Nullable Number 경계에 할당될 수 있기 때문에 NumberList<@Nullable Integer>를 생성하는 것이 가능하다. 또한 일반 Integer@Nullable Number에 할당될 수 있기 때문에 NumberList<Integer>를 생성하는 것도 가능하다. @NullMarked 내에서 일반 Integer@NonNull Integer와 동일한 의미를 가진다. 실제 Integer 값에 대한 참조이면, 절대 null이 아니다. 이는 E로 표현된 값이 NumberList의 다른 매개변수에서는 null일 수 있지만, NumberList<Integer>의 인스턴스에서는 그렇지 않다는 것을 의미한다.

물론 이는 List 자체가 nullable 타입 인자를 허용하는 방식으로 작성되었다는 것을 전제로 한다.

@NullMarked
public interface List<E extends @Nullable Object> {...}

만약 interface List<E>interface List<E extends @Nullable Object>가 아니라면, NumberList<E extends @Nullable Number> implemtnes List<E>는 유효하지 않다. 그 이유는 interface List<E>interface List<E extends Object>의 축약형이기 때문이다. @NullMarked 내부에서 , 단순한 Object는 "null이 될 수 없는 객체 참조"를 의미한다. NumberList의 <E extends @Nullable Number><E extends Object>와 호환되지 않는다. 

 

이 모든 것의 함축된 의미는 E와 같은 타입 변수를 정의할 때마다 그것이 @Nullable 타입으로 대체될 수 있는지 결정해야 한다는 것이다. 만약 가능하다면, @Nullable 경계를 가져야 한다. 종종 이것은 <E extends @Nullable Object>가 될 것이다. 반면, @Nullable 타입을 나타낼 수 없다면, 이는 경계에 @Nullable이 포함되지 않음으로 표현된다. (명시적인 경계가 전혀 없는 경우도 포함된다.) 다음은 또 다른 예제다.

@NullMarked
public class ImmutableList<E> implements List<E> {...}

여기서, ImmutableList<E>이고 ImmutableList<E extends @Nullable Object>가 아니기 때문에, ImmutableList<@Nullable String>을 생성하는 것은 유효하지 않다. ImmutableList<String>만 작성할 수 있으며, 이는 null이 아닌 String 참조의 리스트다.

 

Using type variables in generic types

List 인터페이스가 어떻게 생겼는지 살펴보자.

@NullMarked
public interface List<E extends @Nullable Object> {
  boolean add(E element);
  E get(int index);
  @Nullable E getFirst();
  Optional<@NonNull E> maybeFirst();
  ...
}

add 메서드의 파라미터 타입 E는 리스트 요소의 실제 타입과 호환되는 참조를 의미한다. 예를 들어 List<String>Integer를 추가할 수 없는 것처럼, @Nullable StringList<String>에 추가할 수 없다. 하지만 List<@Nullable String>에는 추가할 수 있다.

 

마찬가지로, get 메서드의 리턴 타입 E는 리스트 요소의 실제 타입을 가진 참조를 리턴한다는 것을 의미한다. 만약 리스트가 List<@Nullable String>이라면, 그 참조는 @Nullable String이다. 리스트가 List<String> 이라면, 그 참조는 String이다.

 

반면 getFirst 메서드의 리턴 타입 @Nullable E는 항상 @Nullable이다. 이는 List<@Nullable String> 이든 List<String> 이든 호출 시 @Nullable String이 된다. 이 메서드는 리스트의 첫 번째 요소를 반환하거나, 리스트가 비어 있으면 null을 반환하는 것이 목적이다. 마찬가지로, 실제 메서드인 Map@Nullable V get(Object key)Queue@Nullable E peek()VEnull일 수 없을 때에도 null을 반환할 수 있다.

 

여기서 중요한 구문은 반복할 가치가 있다. E와 같은 타입 변수를 사용할 때는 E 자체가 null일 수 없더라도 참조가 null일 수 있음을 의미하는 경우에만 @Nullable E로 사용해야 한다. 그렇지 않으면, 일반 EE@Nullable 타입일 때만 참조가 null일 수 있음을 의미한다. 예를 들어, 이 예제에서 @Nullable String처럼 말이다.(그리고 보다시피, E@Nullable Object와 같은 @Nullable 경계를 가진 정의가 있을 때만 @Nullable 타입이 될 수 있다.)

 

마찬가지로 @NonNull E를 사용하여 Enullable일 때에도 non-nullable 타입을 나타낼 수 있다. 가상의 maybeFirst() 메서드는 non-nullable Optional을 반환한다. Optional 객체는 non-null값만을 가질 수 있으므로, 이를 class Optional<T>로 정의하는 것이 합리적이다. 즉 타입 인수는 nullable이 될 수 없다. 따라서 List<@Nullable String>의 경우에도 maybeFirst()Optional<@Nullable String>을 반환해야 한다. 이를 선언하는 방법은 maybeFirst()의 리턴 타입을 Optional<@NonNull E>로 선언하는 것이다. 

 

앞서 @NullMarked가 "참조는 @Nullable로 표시되지 않는 한 null이 될 수 없다"는 의미임을 보았고, 이는 로컬 변수에는 적용되지 않는다는 것도 보았다 여기서 우리는 @NullMarked가 어노테이션이 없는 타입 변수 사용에도 적용되지 않음을 알 수 있다. 어노테이션이 없는 타입 변수 사용이 @Nullable 경계를 가지는 경우, 이는 @Nullable 타입 인수로 대체될 수 있다.

 

Using type variables in generic methods

기본적으로 위에서 generic 타입에서 알아본 것과 동일한 사항이 generic 메서드에도 적용된다.

@NullMarked
public class Methods {
  public static <T> @Nullable T
      firstOrNull(List<T> list) {
    return list.isEmpty() ? null : list.get(0);
  }

  public static <T> T
      firstOrNonNullDefault(List<T> list, T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }

  public static <T extends @Nullable Object> T
      firstOrDefault(List<T> list, T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }

  public static <T extends @Nullable Object> @Nullable T
      firstOrNullableDefault(List<T> list, @Nullable T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }
}

firstOrNull 메서드는 List<String>을 허용하지만 List<@Nullable String>은 허용하지 않는다. List<String>유형의 인수를 받으면 T는  String이 됨으로 리턴 타입 @Nullable T@Nullable String이 된다. 입력 리스트는 null 요소를 포함할 수 없지만 리턴 값은 null일 수 있다. 

 

firstOrNonNullDefault 메서드는 T@Nullable 유형이 되는 것을 허용하지 않으므로 List<@Nullable String>은 허용되지 않는다. 이제 리턴 값도 @Nullable이 아니므로 null이 될 수 없다.

 

firstOrDefault 메서드는 List<String>List<@Nullable String> 모두를 허용한다. 첫 번째 경우에는 TString이므로 defaultValue 매개변수와 리턴 값의 유형이 String이 되어 둘 다 null이 될 수 없다. 두 번째 경우에는 T@Nullable String 이므로 defaultValue와 리턴 값의 유형이 @Nullable String 이 되어 둘 다 null이 될수 있다.

 

firstOrNullableDefault 메서드는 List<String>List<@Nullable String> 모두를 다시 허용하지만, 이제 defaultValue 매개변수 @Nullable로 표시되어 List<String>의 경우에도 null이 될 수 있다. 마찬가지로 리턴 값도 @Nullable T이므로 Tnull이 될 수 없는 경우에도 null이 될 수 있다.

 

또 다른 예제를 보자.

@NullMarked
public static <T> List<@Nullable T> nullOutMatches(List<T> list, T toRemove) {
  List<@Nullable T> copy = new ArrayList<>(list);
  for (int i = 0; i < copy.size(); i++) {
    if (copy.get(i).equals(toRemove)) {
      copy.set(i, null);
    }
  }
  return copy;
}

위 메서드는 null 요소를 포함하지 않는 List<T>를 받아서 toRemove 변수와 일치하는 경우 toRemove 값 대신 null로 변경하여 List<@Nullable T>를 생성한다. 호출 결과는 List<@Nullable T>로, T 자체가 null이 될 수 없는 경우 null 요소를 초함할 수 있다.

 

Some subtler details

위 섹션에서는 JSpecify 어노테이션을 효과적으로 사용하는데 필요한 99%의 내용을 설명했다. 여기서는 아마 알 필요 없는 몇 가지 세부사항을 알아본다.

Type-use annotation syntax

@Nullable@NonNull과 같은 type-use 어노테이션의 구문이 놀라울 수 있는 몇 가지 경우가 있다.

  1. Map.Entry와 같은 중첩된 타입의 경우 값이 null일 수 있다고 말하고 싶다면 구문은 Map.@Nullable Entry다. 종종 중첩된 타입을 직접 가져와서 이를 피할 수 있지만, 이 경우 Entry가 매우 일반적인 타입 이름이기 때문에 import java.util.Map.Entry는 바람직하지 않을 수 있다. 
  2. 배열 타입의 경우 배열 요소가 null일 수 있다고 말하고 싶다면 구문은 @Nullable String[]이다. 배열 자체가 null일 수 있다고 말하고 싶다면 구문은 String @Nullable []이다. 그리고 배열 요소와 배열 자체가 모두 null일 수 있다면 구문은 @Nullable String @Nullable []이다.

이를 기억하는 좋은 방법은 @Nullable 바로 뒤에 있는 것이 null이 될 수 있다는 것이다. Map.@Nullable Entry에서는 Map이 아니라. Entrynull이 될 수 있다. @Nullable String []에서는 Stringnull이 될 수 있고, String @Nullable []에서는 []즉, 배열이 null이 될 수 있다.

 

Wildcard bounds

@NullMarked 내부에서 와일드카드 경계는 타입 변수 경계와 거의 동일하게 작동한다. <E extends @Nullable Number>는 E가 @Nullable 타입일 수 있음을 의미한다, <E extends Number>는 그러지 않음을 의미한다. 마찬가지로, List<? extends @Nullable Nubmer>는 요소가 null일 수 있는 리스트를 의미하고, List<? extends Number>는 그렇지 않음을 의미한다.

 

그러나 명시적 경계가 없는 때는 차이가 있다. 타입 변수 정의인 <E><E extends Object>를 의미하면 이는 @Nullable이 아님을 의미한다. 하지만 <?>는 실제로 <? extends B>를 의미하며, 여기서 B는 해당 타입 변수의 경계다. 따라서 우리가 다음 같은 경우를 가진다면,

interface List<E extends @Nullable Object> {...}

그러면 List<?>List<? extends @Nullable Object>와 동일한 의미다. 약 우리가

class ImmutableList<E> implements List<E> {...}

그러면 우리는 동일한 의미임을 알 수 있다.

class ImmutableList<E extends Object> implements List<E>

따라서 ImmutableList<?>ImmutableList<? extends Object>와 동일한 의미다. 여기서 @NullMarkedObjectnull을 제외한다는 것을 의미한다. List<?>get(int) 메서드는 null을 반환할 수 있지만, ImmutableList<?>의 동일한 메서드는 null을 반환할 수 있다. 

 

 

 

참고

https://jspecify.dev/docs/user-guide/

 

 

728x90

'dev > java' 카테고리의 다른 글

Win10 SDKMAN으로 JAVA 설치하기  (0) 2025.01.14
Building a SpringBoot Monorepo with Gradle  (2) 2024.11.06
Java 23 : Structured Concurrency  (0) 2024.09.28
Java - ReentrantLock  (0) 2024.07.19
Generational ZGC in Java 21  (0) 2024.07.02