NPE(Null Pointer Exception)를 방지하는 방법
NPE(Null Pointer Exception)란?
Null Pointer Exception의 줄임말로, null인 객체를 참조하려고 할 때 발생하는 Exception이다.
좀더 정확히 말하자면 java의 데이터 타입중 기본 타입(primitive type)과 참조 타입(reference type)이 있다.
기본 타입의 대표 예시로는 int가 있고, 참조 타입의 대표 예시로는 String이 있다. 참조 타입은 객체의 생성 이전에는 할당된 메모리 주소가 없는 null을 참조하는 변수이며, 이때 할당된 주소가 없는 null 상태의 변수를 가지고 작업을 진행하다면 NPE를 맞딱뜨리게 된다.
간과하기 쉬운 예외
물론 NPE를 처리 안한다고 당장 서비스가 안돌아가는건 아니다. 하지만 매번 불필요한 NPE로 stack trace가 쌓이고, 어디서 NPE가 발생하는지 하나하나 찾아나간다면 불필요한 공수가 수반되기 마련이다. 특히 대규모 서비스에 그런 불필요한 예외가 반복해서 쌓인다면 분명한 손실을 야기할 것이다. 하지만 그렇다고 과하게 NPE를 대비하다가는 반복적인 null check로 코드의 가독성을 떨어트리곤 한다. 때문에 간결하지만 정확하게 NPE를 방지하는것은 중요하다.
분명 NPE처리는 신입 입사후 1개월 쯤, Github에 올렸던 PR에서 선배님이 리뷰로 피드백을 남겨줬던 부분이다.
그때는 구글링으로 '그렇구나'하고 대충 외우고 넘어갔지만, 1년이 지난 지금도 똑같은 실수를 저지르고 말았다.
암기력이 그리 좋지 못해서 그런것도 있겠지만, 대충 외우고 기록하지 않아서 같은 실수를 저질렀던것 같다. 이 기회에 제대로 기록하고 넘어가자.
NPE를 방지하는 방법
NPE를 방지하는 방법은 많지만, 실무에서 쉽게 발생할 수 있는 실수들을 기준으로 작성했다.
1. String이 아닌 StringUtils 사용하기
import org.apache.commons.lang3.StringUtils;
public class ServerApiController {
@GetMapping("/test/{temp2}")
public String test(@PathVariable(name = "temp2") @Nullable String temp2) {
String temp1 = null;
if(temp1.equals(temp2)) {
System.out.println("OK"); // NPE 발생
} else if(StringUtils.equals(temp1, temp2)) {
System.out.println("OK");
}
return temp1;
}
}
위 코드에서 temp1이 null인 상태에서 equals를 사용해서 null인 객체에 접근했기 때문에 NPE가 발생한다.
이를 방지하려면 String 클래스가 아닌 StringUtils 클래스를 사용하면 된다.
단, apache가 제공하는 StringUtils를 사용하려면 gradle에서 아래 dependency를 추가해야한다.
implementation 'org.apache.commons:commons-text:1.9'
+ 위 코드를 실행해서 발생하는 NPE
java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "temp1" is null
at com.example.serverapicontroller.controller.ServerApiController.test(ServerApiController.java:68) ~[main/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:564) ~[na:na]
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) ~[spring-web-5.3.13.jar:5.3.13]
// 약 52줄에 달하는 NPE가 발생했다.
실제로 에러를 한번 발생시키면 얼마나 불필요한 Stack Trace가 쌓인건지 확인할 수 있다.
2. toString()이 아닌 valueOf() 사용
@GetMapping("/test2")
public void test2() {
Integer temp1 = null;
System.out.println(temp1.toString()); // NPE 발생
System.out.println(String.valueOf(temp1));
}
위 코드에서 toString()때문에 NPE가 발생한다.
대신 valueOf()를 사용하면 NPE가 발생하지 않는데, 이유는 String.valueOf()는 객체가 null일 경우, "null"로 반환해주기 때문이다.
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
3. 메소드 체이닝 사용 자제하기
변수의.메소드1를.호출하고.또.메소드2를.호출하고.또.메소드3를.호출하고;
위와 같은 구조의 패턴을 메소드 체이닝이라고 한다.
실무에서 메소드 체이닝을 사용한다면 불필요한 함수나 메서드를 만들 필요가 없기 때문에 겉보기에는 편해보인다.
그러나 무분별하게 메소드 체이닝을 남발했다가는 NPE가 발생했을때 당췌 어디서 발생했는지를 몰라 시간을 잡아먹힐 가능성이 있다.
시간을 잡아먹을 수 밖에 없는게, 만약 위와 같이 모든 메소드를 한줄에 때려넣은 꼬리에 꼬리를 무는 메소드 체이닝에서 NPE가 발생하면 메소드1에서 발생한건지, 2에서 발생한건지, 3에서 발생한건지 확인하기가 까다롭기 때문이다.
4. 객체 초기화는 가급적 빈값으로하기
String temp1 = "";
List temp2 = new ArrayList();
혹시모를 NPE를 대비해 가급적 빈값으로 객체를 초기화하는것도 좋은 방법이다.
5. 함수나 메서드에서 null 대신 Collection 반환하기
public List<Book> test3() {
// 로직 생략
return Collections.EMPTY_LIST;
}
null을 반환해야하는 함수나 메서드의 경우, Collection을 반환하도록 하자.
null을 반환할 경우, 호출자는 그에 대비하여 별도의 null check 구문이 필요하기 때문이다.