NULL...Null...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은 주소값이 없는 것(아무것도 가리키고 있지 않다는 것)을 말하는 것이다.
그런데 자바 프로그래밍을 하면서 포인터를 다룰 일도, 들을 일도 없었을 것이다. 왜냐하면 이는 자바에서 포인터 개념을 없애서 그런것이 아니라, 개발 편의성을 위해 철저히 숨겼기 때문이다. 사실 자바의 배열, 객체, String같은 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 참조로 인해 자바 개발자들이 가장 골치아프게 겪는 에러다.
앞서 살펴본 것처럼 자바에서 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 발생 시나리오
그럼 정확히 자바 개발자들이 코드에서 어떤 실수를 범하기에 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) 진영에서는 ?. 연산자로 매우 간단하게 해결한다. (코틀린도 ?: 을 사용한다)
Javascript
// 만약 p가 null이 아니면 getPhone() 실행
// 만약 getPhone() 실행한 결과 반환된 값이 null이 아닐경우 getOS() 실행 ...
p?.getPhone()?.getOS()?.printOS();
그러나 자바(Java)에서는 위의 물음표 연산자를 지원하지 않는다. 따라서 직접 조건문으로 해당 객체 변수가 null 인지 아닌지 직접 조건을 따져서 NPE 문제를 회피해야 한다.
! Info
사실 null 처리를 개선하려는 노력은 자바7 에서 부터 언급이 있었다.
자바스크립트의 ?. 연산자와 같이 엘비스 연산자(elvis operator)가 제안되었으나 결과적으로는 승인되지 않았다.
NULL을 안전하게 다루는 방법
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);
}
}
자바 기본 장치
(1) 단정문(Assertion)
- 자바 1.4부터 새롭게 추가되었다고 한다.
- JSR(Java Specifiaction Request)에서는 아래와 같이 정의하고 있다.
- Assertion은 부울식(expression)을 포함하고 있는 문장으로서, 프로그래머는 그 문장이 실행될 경우 불리언 식이 참이라고 단언할 수 있다.
- 즉, 개발자가 본인의 코드에서 가정한 사실이 올바른 지 검사할 수 있게 해주는 기능
- Exception을 통해서 본인이 가정한 상황 외의 예외에 대해서 처리를 할 수도 있지만 Assertion은 예외 처리가 아니라 검사라는 기능을 제공한다.
- 정리하자면 예외 처리는 프로그램 구동 중에 생길 수 있는 예외 상황들(네트워크 이상, 파일을 읽을 수 없는 등)에 대해서 처리를 하는 것이고, Assertion은 개발자가 의도한 특정 코드나 조건, 변수값을 먼저 검증, 검사한다는 점에서 차이가 있다.
- Assertion은 프로그램이 올바르게 실행되도록 해주는 효과적인 도구가 될 수 있으며, 프로그램의 안정성을 높여줄 수 있다.
기본 사용법
- assert 부울식; 혹은 assert 부울식 : 수식;
- 예1) assert age > 0 : “나이는 음수가 될 수 없습니다:”+age;
- 예2) aasert val < 10 ; “10보다 작은 값만 쓸 수 있습니다.”;
- 부울식이 거짓이면 AssertionError 발생
- 수식은 AssertionError에 포함될 상세 정보를 만드는 생성식
주의해야할 점
Assertion은 일반적으로 Compile되는 상황에서는 실행되지 않는다. 따라서 아래와 같이 별도의 옵션을 줘야 한다.
- -enableassertions 또는 -ea
Assertion을 사용하면 안되는 상황이 있다.
- public 메소드의 파라미터를 검사하는 경우
- 파라미터값이 잘못되었을 경우는 Assertion으로 검사하기보다는 IllegalArgumentException를 발생시키는게 맞다.
- 올바른 수행을 위해 필요한 작업을 수행하는 경우
- assert checkName();와 같이 특정 상황을 체크하여 어떠한 결과를 기대하는 메서드를 만들었을 때, assert를 이용하려고 한다면 위에서 말했듯이 Compile시 실행이 되지 않기 때문에 위 assert checkName();은 실행되지 않는다. 때문에 별도의 결과값을 담을 변수를 생성하여 그 변수를 가지고 assert에 사용하도록 해야 한다.
(2) Java.util.Objects
- Java 8
- isNull(Object obj)
- nonNull(Object obj)
- requireNonNull(T obj)
- requireNonNull(T obj, String message)
- requireNonNull(T obj, Supplier<String> messageSupplier)
- Java 9
- requireNonNullElse(T obj, T defaultObj)
- requireNonNullElseGet(T obj, Supplier<? extends T> supplier)
(3) java.util.Optional
- The Mother of All Bikesheds by Stuart Marks
- 절대로 Optional 변수와 반환값에 null을 사용하지 말라
- Optional을 필드, 메서드 매개변수, 집합 자료형에 쓰지 말라
null 잘 쓰는 법
- API에 null을 최대한 쓰지 말아라
- null로 지나치게 유연한 메서드를 만들지 말고 명시적인 메서드를 만들어라
- API에 null을 받아서 분기처리 하지말고 애초에 null이 있을 때 메서드와 없을 때 메서드를 나눠서 만들어라
- null을 반환하지 말라
- null을 반환하지 말고 예외를 던져라
- 빈 반환 값은 빈 컬렉션이나 Null 객체를 활용하라
- null로 지나치게 유연한 메서드를 만들지 말고 명시적인 메서드를 만들어라
- 사전 조건과 사후 조건을 확인하라: “계약에 의한 설계”
- Spring의 Assert 클래스
- (상태와 같이) null의 범위를 지역(클랫, 메서드)에 제한하라
- 상태와 비슷하게 null도 지역적으로 제한하면 큰 문제가 안된다
- 클래스와 메서드를 작게 만들어라
- 설게가 잘 된 코드에선 null의 위험도 줄어든다
- 초기화를 명확히 하라
null에 안전하다고 보장해주는 도구
- 자바의 엘비스 연산자(?:) 논의
- 도입하면 null을 더 많이 사용할거라 생각하여 최종 도입에서 탈락
- 대신 Optional 사용 권유
- JSR 305
- JSR 308
- Checker Framework
- JSR 308 타입 어노테이션
- 선언부가 아닌 타입 지정 위치에 어노테이션 사용 가능
예상 면접 질문 및 답변
Q. 자바에서 null을 안전하게 다루는 방법에 대해 설명해주세요.
공개 메서드가 아닌 곳에는 assert를 사용하여 null을 방어할 수 있습니다. 또한 메서드의 인자를 받을 때 Objects.requireNonNull()을 사용하여 방어할 수 있습니다. 그리고 Optional을 사용해 리턴 타입에서 null을 반환하지 않도록 방어할 수 있습니다. 마지막으로 사전 조건과 사후 조건을 명확히 하여 계약에 의한 설계를 실천해야 합니다.
참고
- 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/
- https://eastglow.github.io/back-end/2020/01/10/Java-%EC%9E%90%EB%B0%94%EC%97%90%EC%84%9C-null%EC%9D%84-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B0%A9%EB%B2%95.html