...
개발자의 영원한 숙제 NULL
null 이라는 단어는 프로그래밍을 배워보면 빠르나 늦나 반드시 접하게 되는 녀석이다. 프로그래밍을 갓 접한 사람들은 null을 그저 '값이 없는 것' 으로 외우고 넘어가버린다. 심지어 null을 부정의 뜻으로 0 이나 공백 그리고 false 와 동일선상에 놓고 생각하기도 한다. 당연히 이는 잘못된 정의 이다.
그저 값이 없다는 표현일 뿐인데 개발자의 영원한 숙제 라니 뭐니 라는 표현을 쓰는 이유는, 개발자들이 null을 마주하는 경우가 프로그램 실행중에 에러 메세지(NullPointerException) 로 인해 잘동작 하던 프로그램이 죽어버려 원인을 찾느라 심한 고생을 하기 때문이다.
우선 NULL 이라는 개념은, 영국의 컴퓨터 과학자인 토니 호어(Tony Hoare)가 1965년에 알골(ALGOL W)이라는 프로그래밍 언어를 설계하면서 처음 등장했다. 당시에 그는 '값이 존재하지 않는 상황' 을 편리하게 표현하기 위해 null 이라는 개념 고안했다. 하지만 시간이 흘러 한 소프트웨어 컨퍼런스에서 그는 자신이 고안한 null 참조를 '10억 달러짜리 실수' 라고 표현하며 null 참조를 만든 것을 후회한다고 토로하였다. 단순히 없는 값을 표현하기 위한 null 참조 개념이 이로 인해 수많은 오류, 취약성 및 시스템 충돌이 생기고 피해가 막대했기 때문이다.
자바의 NULL 파헤치기
사실 자바에서의 이 null 이라는 녀석은 굉장히 심오한 녀석이다. 왜냐하면 값이 없다는 걸 표현하기 위한 키워드인데, int 나 char같은 범용적인 타입에 대입할 수 없기 때문이다.
이들 같은 primitive 자료형일 경우 개별적으로 0이나 공백 같은 것으로 값이 없다는 것을 간접적으로 표현하기 때문에 null을 대입할 수 없는 것이다. 반면 자바의 reference 타입 같은 경우 값이 없거나 기본값을 null로 지정한다.
primitive는 0이나 false 그리고 reference는 null 이런식으로 주입식 암기 하는것도 나쁘지는 않지만, 왜 reference는 null로 되는지 이유를 안다면 추후에 자신의 프로그램이 null 관련 문제가 터졌을때 이를 유추하는데 도움이 되니 간단하게 알아보자.
null의 정확한 의미
C언어에서는 생성된 메모리의 주소를 '포인터'라는 것이 가리키게 되어 포인터를 통해 데이터를 가져올 수 있다. 포인터는 간단히 말하면 어느 메모리를 가리키는 주소를 저장하는 변수이다. 그런데 만일 포인터 변수를 선언 및 초기화를 한번에 진행하지 않고 선언만 한다면 어떻게 될까?
int나 double 타입 같은 경우 초기값으로 0이 들어간다는 것은 모두들 아는 사실이다. 원시값을 자체적으로 저장하기 때문에 그렇다. 반면 포인터 변수는 원래 메모리 주소값이 들어와야 되지만 초기화를 안할 경우 0이나 false 같은 주소의 무(無)의 값을 표현해야 하는데, 그 표현하는 키워드가 바로 NULL 인 것이다.
즉, NULL은 주소값이 없는 것(아무것도 가리키고 있지 않다는 것)을 말하는 것이다.
그런데 자바 프로그래밍을 하면서 포인터를 다룰 일도, 들을 일도 없었을 것이다. 왜냐하면 이는 자바에서 포인터 개념을 없애서 그런것이 아니라, 개발 편의성을 위해 철저히 숨겼기 때문이다. 사실 자바의 배열, 객체, 스트링 같은 reference 타입은 모두 참조(포인터) 변수인 셈이다.
자바의 참조 변수와 포인터의 정확한 공통점과 차이점은 둘다 주소 안의 메모리에 직접 접근한다는 공통점은 가지지만, 참조 변수는 직접 메모리를 핸들링 할 수 없어 주소값을 변경할수 없다.
반면 포인터는 주소값을 변경시켜 융연성과 성능을 향상 시킬수 있지만 대신 안정성이 떨어진다는 특징이 있다.
즉, 자바의 참조 변수는 메모리 주소값을 변경할 수 없는 포인터라고 봐도 된다. 이러한 특성을 기억하면서 다음 자바 프로그래밍에서의 null 처리가 어떻게 되는지 보자.
null 과 참조형 필드의 관계
다시 복습하자면, 모든 원시형 타입(Primitive type) 은 따로 값을 초기화 하지 않았을 경우 기본으로 갖게 되는 default 값이 있다. 예를 들어 기본형 타입인 boolean 타입은 false, 정수형 int는 0을 갖는다.
하지만 참조형 타입(Reference type)은 default 값으로 null을 갖게 된다. 왜냐하면 참조변수가 지역변수로 선언된 경우 선언과 동시에 초기화되어야 되는데, 선언할 때 참조변수가 가리킬 객체의 주소가 결정되지 않았기 때문에 이를 표현하기 위한 키워드로 null을 사용하기 때문이다.
class Test {
int i; // Primitive type
Integer ii; // Reference type
}
Test t = new Test(); // 따로 초기화 없이 바로 객체 생성
System.out.println(t.i); // 0
System.out.println(t.ii); // null
바로 위에서 C 언어의 포인터에 대해 설명할때 null은 포인터 변수에서 다뤄지는 주소값을 갖지 않는 키워드라 말한바가 있다. 이를 인용한다면 null은 자바의 참조 변수에만 들어갈 수 있는 값이라는 말이 된다.
그래서 실제로 null을 원시형(Primitive type) 타입의 변수에 할당하게 되면 컴파일 에러(NullPointerException)가 발생한다. 그리고 이는 개발자를 괴롭게 만드는 원인중 하나이다.
int i = null; // 컴파일 오류 발생
이것이 왜 개발자를 괴롭하게 하냐면, 자바의 오토박싱 & 오토언박싱 과정에서 에러라는 깜짝 선물을 내어주기 때문이다.
자동으로 형변환이 진행되는 만큼 편안함을 개발자에게 제공하지만 대신에 버그를 알아차리기 어렵게 한다. 예를들어 Integer나 Double과 같은 래퍼(Wrapper) 클래스 레퍼런스가 null을 참조하고 있을 때, 이를 기본형 타입으로 언박싱(unboxing)하는 로직이 있을 경우 에러가 발생한다.
// Integer 래퍼런스 타입을 사용하기 위해 변수를 선언하고 11 값으로 초기화한다.
Integer BoxedValue = new Integer(11);
// ...
// 만일 1000줄이 넘는 코드에서 BoxedValue 변수를 다루다가 어떠한 원인으로 인해 BoxedValue에 null 값이 들어갔다고 가정한다.
BoxedValue = null;
// Integer 래퍼 타입인 BoxedValue를 int 타입으로 자동 형변환 하는 과정에서 null은 primitive 타입에 넣을수 없어 에러가 발생하게 된다
int intValue = boxedValue; // NullPointerException 발생
이처럼 오토 박싱(Auto-boxing)의 동작을 기대하는 상황에서 개발자 의도와는 다르게 오류가 발생하기 쉽다는 취약점이 존재한다. 이는 컴파일 시점에서 확인할 수 없고 프로그램 실행 과정에서 나타나는 에러이기 때문에 굉장히 주의해야 한다.
null 과 static 키워드
형변환 뿐만 아니라 null 을 참조하는 오브젝트의 메서드를 호출하게 되면 에러(NullPointerException)이 발생한다.
그런데 메서드가 static으로 선언되어 있는 경우라면 예외가 발생하지 않고 정상 실행된다.
class Hello {
public void sayHello() {
System.out.println("hello object");
}
public static void sayHelloStatic() {
System.out.println("hello static");
}
}
Hello h = null; // Hello 객체 변수에 null을 참조
h.sayHello(); // ERROR !!!
h.sayHelloStatic(); // 레퍼런스가 null이지만 에러가 발생하지 않는다. (실행은 되고 경고만 뜬다)
왜냐하면 클래스의 정적(static) 멤버는 각각의 인스턴스가 아닌 클래스에 속하기 때문에 컴파일 타임에 JVM의 static area에 최적화가 된다. 그래서 오브젝트가 아닌 클래스를 통해 직방으로 정적 메서드를 호출하는 코드로 식별되기 때문에 객체 변수가 null이든 아니든 불러오는데는 문제가 없는 것이다. (컴파일러는 대신에 경고를 내준다)
보통 정적(static) 메서드는 여러분도 알다시피 클래스명을 통해 호출 Hello.sayHelloStatic() 해왔는데 이러한 이유가 위의 혼동을 줄일수 있기 때문에 그렇것이라고 봐도 무방하다.
null 과 instanceof 키워드
null의 또다른 독특한 특징운 null 을 참조하는 레퍼런스 변수에 instanceOf 연산자를 사용하면 false를 반환한다는 점이다.
instanceOf 키워드는 참조(reference) 변수가 어느 객체 타입인지 검사 해주는 연산자인데, 아무리 null 이라 해도 처음 변수를 선언 및 초기화 할때 String myReference = null 로 직접 변수의 타입을 String으로 명시 해줬음 불구하고 false라고 하니 무언가 이상할지도 모른다. 그러나 정확하게 말하면 instanceof 연산자는 참조 변수의 주소값을 타고 가서 힙 메모리에 있는 객체의 타입을 보고 반환하기 때문에, 결국 참조하는 주소값이 없는 null 상태이라서 false를 반환한 것이다.
그리고 직접 값을 다루는 >, >=, <, <= 와 같이 크고 작음을 비교하는 관계 연산자를 null에 사용하는 경우 에러(NullPointerException)이 발생한다. 다만 예외로 ==, != 과 같은 관계 연산자는 사용할 수 있다.
public class Main {
public static void main(String[] args) {
String o = null;
if (o instanceof String) {
} else {
System.out.println("instanceof returned false"); // 조건문에서 myReference는 null이기 때문에 false를 리턴해서 이쪽이 실행된다
}
String o2 = "Hello World";
if (o2 instanceof String) {
System.out.println("myReference is a String"); // 출력
}
}
}
NPE (NullPointerException)
위에서 언급하였던 에러 메세지 NullPointerException(이하 NPE)는 null 참조로 인해 자바 개발자들이 가장 골치아프게 겪는 1등 공신이다.
앞서 살펴본 것처럼 자바에서 null은 참조가 없는 경우를 뜻하는데, 만일 null을 참조하는 레퍼런스 변수로 객체의 인스턴스 메서드를 호출하는 등의 객체 코드를 수행하려는 경우 이때 NullPointerException가 발생한다.
특히나 개발자를 고통받게 하는 이유는 NullPointerException가 프로그램 실행중인 런타임(Runtime) 상황에서 발생하기 때문이다. 진작에 컴파일 시점에서 미리 알수 있으면 미리미리 예방할 수 있을 텐데 그렇지 않으니, 자바 개발자에게 NPE는 코드 베이스 곳곳에 깔려있는 지뢰같은 녀석인 것이다.
java.lang.NullPointerException
at seo.dale.java.practice(OptionalTest.java:26)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
NPE 발생 시나리오
그럼 정확히 자바 개발자들이 코드에서 어떤 실수를 범하기에 지뢰가 펑청 터지는 것일까?
다음과 같이 Person , Phone, OS 라는 클래스가 존재한다고 가정하자. 최종 목표는 각 클래스의 메서드 체이닝을 통해 OS의 스트링 값을 출력하는 예제이다.
class Person {
private Phone phone;
private String name; // 생성자에서 초기화를 함
Person(String name) {
this.name = name;
}
public Phone getPhone() {
return this.phone;
}
}
class Phone {
private OS os; // 생성자에서 초기화를 안함
public OS getOS() {
return this.os;
}
}
class OS {
public String printOS() {
return "Android";
}
}
public class Main {
public static void main(String[] args) {
Person p = new Person("홍길동");
p.getPhone().getOS().printOS(); // person의 Phone을 얻고 그 Phone의 OS를 얻고 OS명을 출력하는 함수 체이닝
}
}
하지만 위의 코드를 실행해보면 NPE(NullPointException)이 발생하게 된다. 왜냐하면 p.getPhone().getOS().printOS() 코드에서 p.getPhone() 의 반환값이 null 이기 때문에 null.getOS() 메서드가 작동하지 않기 때문이다.
왜 Phone 타입의 메서드 getPhone() 이 null을 반환하는 이유를 추적해보면, new Person() 이 초기화될때 인스턴스 객체인 private Phone phone 에 값이 들어가지않아 null로 값이 배정되었기 때문이다. 그리고 getPhone() 에서 Phone 타입의 객체 this.phone을 반환하면서 안에 들어있던 값인 null이 그대로 넘어가게 되고, null 객체 안에 있지도 않은 getOS() 메서드를 호출하니 당연히 NPE에러가 발생된 것이다.
보통 위와 같은 경우 자바스크립트(JavaScript) 진영에서는 ?. 연산자로 매우 간단하게 해결한다. (코틀린도 ?: 을 사용한다)
// 만약 p가 null이 아니면 getPhone() 실행
// 만약 getPhone() 실행한 결과 반환된 값이 null이 아닐경우 getOS() 실행 ...
p?.getPhone()?.getOS()?.printOS();
그러나 자바(Java)에서는 위의 물음표 연산자를 지원하지 않는다. 따라서 직접 조건문으로 해당 객체 변수가 null 인지 아닌지 직접 조건을 따져서 NPE 문제를 회피해야 한다.
사실 null 처리를 개선하려는 노력은 자바7 에서 부터 언급이 있었다.
자바스크립트의?.연산자와 같이 엘비스 연산자(elvis operator)가 제안되었으나 결과적으로는 승인되지 않았다.
NPE 가드하는 고전적인 방법
Java8 이전에는 이렇게 NPE의 위험에 노출된 코드를 다음과 같은 조건문 중첩 코딩 스타일로 회피하였다.
Person p = new Person("홍길동");
// p.getPhone().getOS().printOS();
Phone ph = p.getPhone();
if (ph != null) {
OS o = ph.getOS();
if(o != null) {
String n = o.printOS();
}
}
지금이야 메서드 갯수가 많지 않아 괜찮아 보이지만, 만일 메서드가 많으면 많을 수록 일일히 따져야 하는 if문 역시 기하급수적으로 증가되어 코드 가독성이 매우 안좋아 질 수도 있다. 그래서 다른 방법으로 널 객체 패턴(Null Object Pattern) 이라는 객체지향(oop)를 응용한 패턴도 있지만, 유지보수 측면에서 오히려 더 복잡해지는 단점이 있기 때문에 이 방법도 추천되어지지 않는다.
NPE 가드하는 최신 방법 (Optional 클래스)
자바의 혁신이라고 불리우는 Java8이 등장하면서 null에 대한 처리를 정식적으로 지원하는 java.util.Optional 클래스가 추가되었다. Optional 클래스는 '존재할 수도 있지만 안 할 수도 있는 객체', 즉, 'null이 될 수도 있는 객체'을 감싸고 있는 일종의 래퍼 클래스 이다.
쉽게 말하면 직접 다루기에 까다로운 null을 담을 수 있는 특수한 그릇 정도로 생각하면 된다. 따라서 Optional 객체를 사용하면 예상치 못한 NullPointerException 예외를 제공되는 메소드로 간단히 회피할 수 있게된다.
import java.util.Optional; // Optional 클래스 사용하기 위해 import
class Person { ... }
class Phone { ... }
class OS { ... }
public class Main {
public static void main(String[] args) {
Person p = new Person("홍길동");
Optional.ofNullable(p)
.map(Person::getPhone)
.map(Phone::getOS)
.map(OS::printOS);
}
}
# 참고 자료
https://gocoder.tistory.com/1856
https://madplay.github.io/post/what-is-null-in-java
https://www.daleseo.com/java8-optional-before/
https://programmerbay.com/how-to-make-an-object-eligible-for-garbage-collection-in-java/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.