Java에서 null을 안전하게 다루는 방법

2024-09-04
#JAVA
5

Null 이 뭐야?

Java에서의 Null

  • 의미가 모호함.

    • 초기화 되지 않음

    • 정의되지 않음

    • 값이 없음

    • 말그대로 null이라는 값이 존재

  • 모든 참조의 기본이 null이고, null이 가능함.

 

1000여 개의 어플리케이션의 소프트웨어 결함 중 두번째로 많은 것이 Null Pointer Exception 이라고 한다.

이런 NPE 는 개발자의 부주의로 대부분 발생하게 된다.

 

따라서 우리는 null 을 안전하게 다루는 것이 중요하다.

 

우리는 if (object ≠ null) 과 같이 null 을 확인하는 코드를 짜고는 한다.

하지만 이런 코드가 반복적으로 계속 등장하게 된다면 가독성이 떨어지게 될 것이다.

 

NPE 를 피하는 방법

1. 프로그래밍 습관을 통한 방법

  • equals() 등과 같은 비교 시 문자열이나 null이 아닌 객체를 선행하여 비교

Java
String str = null; if (str.equals("Hello") { ... }
Java
String str = null; if ("Hello".equals(str) { ... }

두 코드 중 위의 코드를 사용하게 된다면 str 이 null 인 경우 equals() 메서드를 호출하면 NPE 가 발생한다. 이는 str이 null 일 가능성을 고려하지 않았기 때문이다.

따라서, 우리는 null일 가능성이 있는 객체를 뒤에 두고 비교해야 더욱 안전하게 사용할 수 있는 것이다.

 

  • toString() 보다는 String.valueOf()를 사용

Java
Object obj = null; System.out.println(obj.toString()); // NullPointerException 발생
Java
Object obj = null; System.out.println(String.valueOf(obj)); // "null" 출력

우리는 대부분 객체의 문자열 표현을 얻기 위해 toString() 메서드를 많이 사용한다. 하지만 toString()은 null 일 때 NPE를 발생시킨다.

반면에 String.valueOf()는 전달된 객체가 null일 경우 자동으로 null 문자열을 반환하므로 null 안전성을 제공한다.

 

  • 필요한 경우가 아니라면 reference 타입 보다는 primitive 타입 사용

이 내용은 다들 알듯이 reference 타입은 null 을 사용할 수 있으므로 추가적인 확인이 필요하지만, primitive 타입은 null 을 사용할 수 없으므로 추가적인 처리가 필요없다.

또한, 메모리적으로도 primitive 타입이 더 효율적일 수 있다.

 

  • 함수에서 null 대신 빈 Collection 객체를 반환

Java
public List<String> getItems() { return null; // null을 반환하는 경우 } List<String> items = getItems(); if (items != null) { for (String item : items) { System.out.println(item); } }
Java
public List<String> getItems() { return Collections.emptyList(); // 빈 리스트 반환 } List<String> items = getItems(); for (String item : items) { System.out.println(item); // null 체크 없이 안전하게 사용 가능 }

위의 코드에서 첫번째 코드는 items가 null 인지 체크를 해야만 안전하게 사용이 가능하다. 만약 확인하는 과정이 생략된다면 NPE가 발생할 수 있을 것이다.

하지만 두번째 코드는 빈 Collection을 반환함으로써 null 체크를 하지 않아도 안전하게 사용할 수 있게 했다.

 

  • null 에 안전한 자바 내장 함수나 commons-lang과 같은 helper class 활용

    • 내장 함수

      • Objects 클래스

      • Optional zmffotm

    • commons-lang

      • StringUtils

      • ObjectUtils

 

  • assert, Unit Test를 활용하여 논리적으로 발생이 불가한 상황에 대해서도 사전 확인과 다양한 상황에서듸 테스트 수행

 

2. 협업 시 기능 및 제약사항을 명시하고 공유

Java
public String getData(String range, String keyword) { //if (range == null || range.length() == 0)) { if (StringUtils.isEmpty(range)) { throw new IllegalArgumentException("range는 반드시 있어야 합니다"); } ... }

위와 같이 리턴 값과 발생할 수 있는 Exception에 대해 명시해주어야 한다는 것이다.

 

 

Optional을 이용

Optional은 값의 유무가 확실하지 않은 객체를 위한 래퍼 타입이다.

이는 null을 직접 다루는 것을 피하기 위한 방법을 제공한다.

이를 통해 우리는 NPE를 피하고 코드의 가독성을 향상시킬 수 있다.

 

Java
import java.util.Optional; public class Main { public static void main(String[] args) { // 실제 코드에서는 데이터베이스 등에서 값을 가져온다고 가정 String str = "Hello"; // Optional을 사용하지 않은 경우 if (str != null) { System.out.println(str.toUpperCase()); } else { System.out.println("String is null"); } // Optional을 사용한 경우 Optional<String> optionalStr = Optional.ofNullable(str); optionalStr.ifPresent(s -> System.out.println(s.toUpperCase())); } }

위와 같이 우리는 Optional을 사용했을 때 코드가 더욱 간결해지고 더 안전하게 사용이 가능하다.

 

그럼 우리는 Optional을 어떤 식으로 사용해야 코드가 더욱 효율적이고 안전하게 돌아갈 수 있을까?

 

 

1. Optional 변수와 반환값에 null 사용 금지

2. Optional에 값이 들어있는 것을 확신하지 않는다면 Optional.get()을 사용하지 마라

이는 결국 No Element Exception이 되어 돌아오게 된다.

3. Optional.isPresent()에서 Optional.get()으로 이어지는 코드는 피해라

해당 방식은 null을 check하는 것과 다를바가 없다.

따라서 우리는 isPresent() → get() 으로 이어지는 코드를 피하기 위해 아래와 같은 메서드들을 사용할 수 있다.

  • orElse(), orElseGet(), orElseThrow()

  • map()

  • filter()

  • ifPresent()

4. 값을 가져오기 위해 Optional을 생성하여 메서드를 연결하는 것은 좋은 방법이 아니다

Java
// BAD String process(String s) { return Optional.ofNullable(s).orElseGet(this::getDefault); } // GOOD String process(String s) { return (s != null) ? s : getDefault(); }

위와 같은 경우에는 Optional을 사용함으로써 코드가 더욱 복잡해지고 너무 불필요한 작업이라는 것이다.

불필요한 Optional 보다는 단순한 null 체크가 여기서는 더욱 효율적이고 간결하다.

Optional은 메서드의 반환값이 null 일 수 있는 경우에 사용해야 하고, 단순히 값이 null인지 확인하는 용도로는 사용하지 않는 것이 좋다는 것이다.

5. Optional을 필드, 매개변수, collection 자료형에 사용하지 마라

(이렇게 했다가 정XX씨가 겁나 뭐라함)

6. Collection 자료형 (List, Set, Map)에는 Optional을 사용하지 말고 빈 Collection을 사용해라

 

이와 같이 Optional은 반환값으로만 사용해야하고, 너무 자주 쓰면 또 안좋다고 한다.

 

 

번외) Null 리턴이 안좋은 이유?

우리는 null이 왜 안좋은지 코드를 짜는 사람의 관점이 아닌 코드를 읽는 사람의 입장에서 생각해보도록 하겠다.

 

Java
al user: User? = userRepository.findByName("이승현") println(user) // nullable

위의 코드에서 user가 null 이라면 이유는 무엇일까?

이유는 아래와 같이 여러가지 이유가 있을 수 있다.

  • db에 이승현이라는 사람이 존재하지 않는다

  • db와의 연결이 불안정해서 값을 못 불러왔다

  • 이승현은 탈퇴한 회원이다

등등…

여러가지 이유가 있을 수 있다.

하지만 실제로는 아래와 같은 이유로 null이 리턴된 것이다.

  • 매주 월요일 신입이 들어온다.

  • 매주 월요일 시스템에 신입의 정보가 추가된다.

  • 시스템에 정보가 업데이트 되는 시점은 정확히 알 수 없다.

  • 해당 서버의 db는 매주 월요일에서 화요일로 넘어가는 00시에 시스템과 동기화된다.

  • 이승현은 예외적으로 월요일이 아닌 수요일에 들어왔다.

  • 따라서 이승현은 아직 해당 서버의 db에 존재하지 않는다.

 

즉, 우리는 null이 왔다는 사실 하나로 위의 이유를 추론해야 하는 것이다.

우리가 null이 온 이유를 파악하기 위해 findByName()의 세부 구현 사항을 들여다 보게 되는 순간 개발자의 생산성은 수직 하락 하게 되는 것이다.

 

이와 비슷한 예시로는 어떤 것들이 있을까

  • 빈 문자열”” 을 사용한 코드

    • 사용자가 입력 시도조차 안했다

    • 사용자가 입력했지만 오류가 있었다

    • 사용자가 실제로 빈 문자열을 입력했다.

  • person.getAge()의 int 타입 반환값이 -1 일때

    • 함수 실행이 잘 됐지만 person의 age 데이터가 누락되었다

    • 함수 실행이 잘 됐지만 person의 age 데이터가 입력 선택 사항이라 알 수 없다

    • 함수 실행에 실패했지만 원인을 알 수 없다

  • person.getPhoneNumbers() 가 리스트 반환

    • 빈 리스트가 왔다면 함수 실행이 잘 됐지만 해당 사람은 휴대폰이 없다

    • null이 왔다면 함수 실행이 잘 됐지만 해당 사람은 휴대폰이 없다.

 

이와 같이 꼭 null이 아니더라도 비슷한 상황이 많을 것이다.

 

해결법 1 : 로그 사용

이런 문제를 해결하기 위한 가장 간단한 방법은 왜 우리가 null을 리턴하게 되었는지 상세한 이유를 로그에 작성하는 것이다.

Java
@Service public class UserService { @Autowired private UserRepository userRepository; public User findByUsername(String username) { return userRepository.findByUsername(username) .orElseThrow(() -> { return new IllegalStateException( "인사관리 시스템과 동기화되지 않은 유저의 이름을 입력한 경우 이 메시지를 볼 수 있습니다.\n" + "매주 월->화 넘어가는 자정에 인사 관리 시스템과의 데이터 동기화가 수행되므로, " + "새로운 사람이 월요일이 아닌 다른 날짜에 입사하지 않았는지 확인하십시오.\n" + "다음 주 월요일까지 기다리거나, 수동 동기화를 실행하면 문제가 해결될 수 있습니다.\n\n" + "인사 관리 시스템과의 데이터 동기화 로직은 UserRepositorySync 클래스를 참고하십시오. \n" + "문제가 된 name=[" + username + "]" ); }); } }

이런 방식으로 null 이 리턴된 자세한 이유를 기술해둔다면 코드를 자세하게 들여다보지 않더라도 user가 null 이 된 이유와 배경까지 큰 노력없이 알 수 있게된다.

 

해결법 2 : 맥락 처리를 위한 기능 구현

위의 경우에서는 null 인 경우를 해결할 수 있는 기능을 구현하는 것이다.

Java
User user = userRepository.findByName(name); // 유저가 null이면 유저가 시스템과 동기화 되지 않은 경우 // 동기화 시키고 다시 시도 if (user == null) { // 동기화 트리거 실행 userRepositorySync.trigger(); //db 동기화를 위한 메서드 // 다시 유저를 조회 user = userRepository.findByName(name); // 동기화 후에도 유저가 null이면 예외 처리 if (user == null) { throw new IllegalStateException("동기화 후에도 유저가 없습니다: " + name); } }

위와 같이 null 을 리턴했지만 주석으로 이유를 밝히고 해당 문제를 해결할 수 있는 기능을 구현해두었다.

 

 

이와 같은 방식으로 우리는 null을 더 안전하고 조심히 다루는 방법을 익혀야 할 것이다.