[JAVA] 자바 null 박살내기

개발자들의 영원한 적, null의 탄생

개발자들에게 프로그래밍 하면서 자신을 가장 괴롭히는 것을 몇 가지 꼽아보라 한다면, 보통 그 리스트에 null처리는 반드시 들어가게 될 것이다. 그만큼 null은 개발자를 괴롭히는 적이며, 그들은 이 null을 처리하기 위해 끝이 없어보이는 힘겨운 싸움을 하게 된다.

null은 컴퓨터 과학자인 토니 호어(Tony Hoare)알골 W(ALGOL W)라는 프로그래밍 언어를 설계하면서 고안한 개념이다. 당시 토니 호어는 알골 W라는 객체 지향 프로그래밍 언어를 설계하면서, 컴파일 타임에 참조 오류를 잡아내 완벽하게 안전한 참조의 사용을 보장할 수 있는 프로그래밍 언어를 목표로 설계했다. 그러나 토니 호어는 컴파일러를 구현하기 쉽다는 이유로 null 참조자를 도입하려는 유혹을 버리지 못했다고 한다. 이는 10억불 짜리 실수라며, 토니 호어 본인이 2009년에 직접 회고한 적이 있다.


자바 개발자는 NullPointerException이 싫다

자바 개발자라면 누구나 NullPointerException(이하 NPE)을 겪어봤을 것이다. 이 예외는 null 참조자가 할당된 객체의 필드나 메소드에 접근하려고 하면 발생한다. 자바는 Null Safety하지 않은 언어이기 때문에, 언제 어디서 null이 발생할지 예측하기 힘들다. 따라서 개발자가 자바로 프로그래밍 할 때, NPE는 거의 숙명이나 다름없다.

Null Safety 언어란, null 참조자로 인해 발생할 수 있는 문제들을 원천 봉쇄하기 위한 타입 선언을 사용하는 언어들을 의미한다. 대표적으로 TypeScriptKotlin과 같은 언어들이 있다.

하지만 숙명이라고 해서, 방치할 수 만도 없는 노릇이다. 따라서 자바 개발자는 항상 null이 들어올 것을 대비해 방어적으로 프로그래밍 해야만 한다.


NPE를 줄이는 방법

Null Safety하지 않은 언어들에서 null 참조자는 항상 골칫거리다. 그래서 이런 언어들은 null 참조자를 처리하는 방법이 존재하고 또 비슷한 편이다. 여기서 말하는 null 방어법은 대부분의 언어에서도 통용되는 얘기지만, 자바에서만 사용할 수 있는 얘기도 나올 것이다.

1. null을 매개변수로 넘기지 말 것

가장 기본적인 것이다. 대부분의 메소드들은 인수가 null일 것을 고려하고 설계하지 않는다. 그럴 바에는 차라리 오버로딩으로 여러 개의 메소드를 만드는 것이 더 현명하기 때문이다. 설령 오버로딩을 지원하지 않는 언어라 하더라도, 대부분의 최신 고급 언어는 Default Argument, Named Argument를 지원하는 경우가 많다. 따라서 메소드를 호출할 때는 항상 NPE의 가능성을 두고 매개변수를 전달해야 한다.

// BAD
public void methodA() {
    methodB(null);
}

// GOOD
public void methodA() {
    methodB("Hello!");
}

public void methodB(String message) {

    // message가 null이면 NPE 발생
    String newMessage = message.concat(", World!");
    System.out.println(newMessage);
}

2. 반환 값으로 null을 반환하지 말 것

반환 값이 존재하는 메소드 중에는 간혹가다 마지막에  null이 반환되는 것들이 있다. 이것은 반환할 변수에 마땅히 대입될 만한 값이 없어, 처음 초기화 시 대입된 null 참조자가 그대로 반환 되는 경우가 많다. 그런데 개발자 입장에서 메소드가 null을 반환하는 경우는 그다지 달가운 상황이 아니다. 그 이유는 메인 로직에 null 값이 아님을 검증해야 하는 검증 작업을 추가로 작성해야하는 번거로움이 발생하기 때문이다. 또한 책임 측면에서 볼 때, 하나의 메소드에서 발생한 책임을 다른 곳으로 떠넘기는 것은 전형적인 안티 패턴이다. 이런 경우에는 null 대신 빈 객체를 반환하는 것이 좋다.

// BAD
public List<T> returnList(boolean exist) {
    List<T> list = null;
    
    if (exist) {
        // do something...
    }
    
    // 이 list는 null이 반환 될 가능성을 내포하고 있다!
    return list;
}

// GOOD
public List<T> returnList(boolean exist) {
    List<T> list = null;
    
    if (exist) {
        // do something...
    }
    
    // list가 null이라면 비어있는 List 객체를 대신 반환한다.
    return list == null ? Collections.emptyList() : list;
}
Collections 래퍼 객체는 emptyList(), emptyMap()과 같은 메소드를 지원한다.

위와 같이 메소드를 작성하면, 호출부에서 다음과 같이 코드를 작성할 수 있다.

// 이 코드를...
List<Object> list = returnList(true);

if (list != null) {
    int size = list.size();
    for (int i = 0; i < size; i++) {
        // do something
    }
}

// 이렇게 바꿀 수 있다.
List<Object> list = returnList(true);

int size = list.size();
for (int i = 0; i < size; i++) {
    // do something
}
메인 로직에서 if문을 이용한 null 검증을 할 필요가 없어졌다!

만약 일반적인 참조형 변수라서 빈 값을 생성할 수 없다면, 별도의 두 가지 선택지가 존재한다. 하나는 예외를 던지는 것이고, 다른 하나는 Optional.empty()를 반환하는 것이다. Java8 이상을 쓰고 있다면 후자를 권장한다.

// BAD
public void methodA() {

    // 호출자는 이 메소드가 null을 반환하는지 알기 어렵다...
    String message = methodB();
}

public String methodB() {
    return null;
}

// GOOD
public void methodA() {
    Optional<String> wrappedMessage = methodB();
}

public Optional<String> methodB() {
    return Optional.empty();
}

Optional을 사용하는 것은 한가지 소소한 장점을 주는데, 그건 해당 메소드가 null을 포함한 래퍼 클래스를 반환할 가능성이 있다는 점을 명시적으로 알려줄 수 있다는 것이다. 이는 실제로 Otional 래퍼 클래스 정의를 설명한 글에도 나와있는 내용이다. Optional 래퍼 클래스는 내부의 값이 null인지 검증하고 후속조치를 취하는 다양한 방법을 제공하므로, 호출자가 편하게 null을 처리할 수 있도록 도와준다.

3. 객체에 접근하기 전에는 null 체크부터

다른 개발자가 작성한 소스를 사용하거나 외부 라이브러리를 경우에는 1, 2번을 모두 준수해서 코드를 작성했을 것이라 확신할 수 없다. 따라서 이 경우에는, 메인 로직에서 null 검증 작업을 거쳐야 한다. 다음 코드를 보자.

// BAD
public void updateUser(User user) {

    // user가 null이면 NPE 발생.
    Phone phone = user.getPhone();
}

// GOOD
public void updateUser(User user) {
    if (user != null) {
        Phone phone = user.getPhone();
    }
}

사실 이정도는 자바 개발자 초보라도 다들 해본적이 있을 것이다. 그런데 User 객체 내부의 Phone 객체의 접근하려고 한다면 어떻게 될까? 아마 코드가 다음과 같이 바뀌게 될 것이다.

public void updateUser(User user) {
    if (user != null) {
        if (phone != null) {
            String phoneNumber = user.getPhone().getNumber();
        }
    }
}

if 문을 이용한 null 검증은 중첩 객체(객체 내부에 또 다른 객체가 존재하는 구조)인 경우 분기문의 깊이가 깊어지는 문제가 존재한다. Java8이상을 사용중 이라면, 이 문제를 해결하기 위해 Optional을 사용할 수 있다. Optional을 사용하면 다음과 같이 코드를 변경할 수 있다.

public void updateUser(User user) {
    String phoneNumber = Optional.ofNullable(user)
                                 .map(User::getPhone)
                                 .map(Phone::getNumber)
                                 .orElseGet("번호 없음");
}
줄 수는 비슷하지만, 분기문으로 인한 깊이는 깊어지지 않으므로 좀 더 이해하기 쉬운 코드가 된다.

만약 Optional을 사용할 수 없다면... 그때는 안타까운 일이지만, 일일이 검증을 하거나 예외를 던지는 수 밖에는 없다.

번외. 자바 대신 코틀린을 사용하자

웃기려고 하는 소린가 싶겠지만, 이것은 꽤나 진지한 얘기다. 우선 코틀린이 Null Safety한 언어라는 사실은 위에서 이미 언급한 사실이다. 코틀린 언어의 특징은 변수와 반환 값의 타입에 null 허용 여부를 명시적으로 지정할 수 있다는 점이다. 이는 자바에서 코틀린 코드로 작성된 메소드를 호출할 때도 어느정도 효과를 볼 수 있다. 코틀린으로 작성된 메소드의 인수가 null이 허용되지 않는 타입일 경우, 해당 메소드를 호출한 자바 코드에서 null을 매개변수로 넘기면 NPE가 발생한다. 로직상 발생한 NPE와 차이 없는 것이 아니냐고 생각할 수 있다. 하지만 로직 내부에서 발생한 NPE와는 달리, 인수가 null이어서 발생한 NPE는 어디서 발생했는지 바로 알 수 있다. 따라서 코틀린으로 작성한 코드는 자바나 코틀린 어디서나 사용해도 null 로부터 어느정도 안전성을 보장받을 수 있다.

문제라면 한국은 안드로이드/개인/소규모 개발자들을 제외하면  대부분 자바를 사용할 수 밖에 없는 환경이라는 점이다. 이런 경우에는 어쩔 수 없지만 코틀린 도입을 깔끔하게 포기하거나, 일부 라이브러리만 코틀린으로 개발해 메이븐 의존성으로 추가하는 방법밖에 없을 것이다.


정리하며

이번 글은 자바 언어로 프로그래밍 하면서 발생할 수 있는 NPE 회피 방법에 대한 내용으로 작성해 보았다. 원래는 대부분의 언어에서 사용할 수 있는 방법론에 대해서만 다루려고 했으나, 자바의 Otional 래퍼 클래스가 NPE를 해결하기 위한 유용한 도구이기 때문에 부득이하게 같이 다루게 되었다. 이번 글의 주제는 null을 처리하는 방법에 대해 다루는 글이었기 때문에 Optional에 대한 깊은 이야기는 다루지 않았다. 추후 시간이 나면 Optional 래퍼 클래스에 대해 심도있는 글을 추가로 작성해 보도록 하겠다.


참고한 문헌 및 글

  1. ☕ 개발자들을 괴롭히는 자바 NULL 파헤치기 (tistory.com)
  2. [Java] 언제 Optional을 사용해야 하는가? 올바른 Optional 사용법 가이드 - (2/2) - MangKyu's Diary (tistory.com)