null과 NullPointerException
아무 객체도 가리키지 않는 참조값 null이 어디서 생기는지, null에 점 연산을 하면 왜 NullPointerException이 나는지, 참조가 모두 끊긴 객체는 어떻게 되는지 배웁니다.
회원 세 명을 담으려고 new Member[5]로 배열을 만들었다면 뒤쪽 두 칸은 비어 있습니다. 회원 객체를 만들고 email을 채우지 않았다면 그 필드도 비어 있습니다. 자바는 이렇게 '아직 아무 객체도 가리키지 않는' 참조형 변수와 배열 칸에 null이라는 특별한 참조값을 넣어 둡니다. null은 빈 문자열 ""도 아니고, 필드가 모두 0인 객체도 아닙니다. 가리키는 객체가 없다는 사실 자체를 나타내는 값입니다.
null을 변수에 넣어 두거나 출력하는 것은 아무 문제가 없습니다. 문제는 객체가 있다고 가정하고 점(.)으로 필드에 접근할 때 생깁니다. members[i].email을 읽으려면 배열 변수 members에서 배열 객체로, 배열의 i번 칸에서 회원 객체로, 다시 그 객체의 email 필드로 참조를 차례로 따라가야 합니다. 이 가운데 어느 한 단계의 참조가 null이면 따라갈 객체가 없으므로 JVM은 NullPointerException(줄여서 NPE)을 던지고 실행을 멈춥니다.
이 글에서는 null이 어디서 생기는지, NPE 메시지를 어떻게 읽고 원인을 찾는지, 그리고 객체를 가리키는 참조가 모두 사라지면 그 객체가 어떻게 되는지를 차례로 살펴봅니다. 예제는 앞 글과 같은 세 필드의 Member를 쓰므로, 예제 디렉터리에 Member.java를 함께 두고 같이 컴파일하세요.
새 객체의 필드와 배열 칸은 기본값으로 시작한다
지역 변수는 값을 넣기 전에 읽으면 컴파일 오류가 납니다. 하지만 new로 만든 객체의 필드와 배열의 칸은 타입마다 정해진 기본값으로 자동 초기화됩니다. 다음 프로그램으로 확인해 보겠습니다.
public class MemberDefaults {
public static void main(String[] args) {
Member member = new Member();
Member[] members = new Member[2];
System.out.println("email=" + member.email);
System.out.println("age=" + member.age);
System.out.println("active=" + member.active);
System.out.println("slot=" + members[0]);
}
}email=null
age=0
active=false
slot=nullemail은 String 타입이고 members[0]은 Member 타입입니다. 둘 다 참조형이므로 기본값이 null이고, 문자열과 이어 붙이면 null이라는 글자로 출력됩니다. 기본형인 int는 0, boolean은 false로 시작합니다. member와 members 변수 자체는 실제 객체를 가리키므로, 여기서는 필드와 칸을 읽기만 할 뿐 NPE가 나지 않습니다.
자동 초기화 덕분에 이전에 메모리에 남아 있던 알 수 없는 값을 읽는 일은 없습니다. 그렇다고 객체가 쓸 만한 상태라는 뜻은 아닙니다. 회원에게 이메일이 반드시 있어야 한다면, email이 null인 회원 객체는 만들어진 순간부터 불완전합니다. 지금은 객체를 만든 뒤 필드를 하나씩 채우고 있어서 이런 객체가 생기기 쉽습니다. 5장에서 배울 생성자는 객체를 만드는 순간 필수 값을 받게 하고, 접근 제어는 바깥 코드가 필드를 마음대로 비워 두지 못하게 합니다.
null에 점 연산을 하면 NullPointerException이 난다
다음 예제는 일부러 NPE를 일으킵니다. 지역 변수 member에 null을 넣고, 그 변수로 email 필드를 읽습니다.
public class NullDereference {
public static void main(String[] args) {
Member member = null;
System.out.println(member.email);
}
}Exception in thread "main" java.lang.NullPointerException: Cannot read field "email" because "member" is null
at NullDereference.main(NullDereference.java:4)member에는 null이 분명히 대입되어 있으므로 컴파일은 통과합니다. 실행 중에 member.email을 계산하려고 member의 참조를 따라가 보니 가리키는 객체가 없고, 그래서 4번째 줄에서 예외가 발생합니다. 예외를 잡는 코드가 없으므로 프로그램은 그 자리에서 끝납니다.
메시지는 세 부분으로 읽습니다. 첫 줄의 Cannot read field "email"은 실패한 동작, 곧 email 필드 읽기를 알려 줍니다. because "member" is null은 그때 null이었던 식이 member라는 뜻입니다. 둘째 줄의 at NullDereference.main(NullDereference.java:4)는 예외가 난 위치로, NullDereference.java 파일의 4번째 줄입니다. 이렇게 실패한 동작과 원인 식을 함께 알려 주는 메시지는 Java 14에서 도입되어 Java 15부터 기본으로 켜졌습니다.
다만 javac를 옵션 없이 쓰면 지역 변수 이름이 클래스 파일에 남지 않아서, 이름 대신 번호가 나옵니다. 클래스와 인스턴스에서 본 것처럼 <local1>은 1번 지역 변수라는 뜻이고, 0번은 args가 차지합니다.
Exception in thread "main" java.lang.NullPointerException: Cannot read field "email" because "<local1>" is null
at NullDereference.main(NullDereference.java:4)javac -g로 컴파일하거나 IDE에서 실행하면 첫 번째처럼 변수 이름이 보입니다. 줄 번호는 어느 쪽이든 나오지만 파일의 실제 줄 배치에 따라 달라집니다. 그래서 메시지 문구를 외우기보다 '무엇을 하다가 실패했는가'와 '어느 줄인가'를 찾아 읽는 습관이 중요합니다.
더 깊이: 메시지 문구로 실패한 동작 구분하기
NPE 메시지의 앞부분은 null에 대해 시도한 동작에 따라 달라집니다. 대표적인 문구는 다음과 같습니다.
| 코드 | 메시지 앞부분 |
|---|---|
member.email 읽기 | Cannot read field "email" |
member.age = 30 쓰기 | Cannot assign field "age" |
members[0] 읽기 (members가 null) | Cannot load from object array |
members.length | Cannot read the array length |
email.length() 호출 | Cannot invoke "String.length()" |
because 뒤의 식도 상황에 따라 다르게 나옵니다. find().email처럼 메서드의 반환값이 null이었다면 because the return value of "ReturnNull.find()" is null처럼 어떤 메서드의 반환값인지까지 알려 줍니다. 이 메시지가 기본으로 켜지기 전에는 예외 이름과 위치만 나왔고, 지금도 JVM 옵션 -XX:-ShowCodeDetailsInExceptionMessages를 주면 그때처럼 상세 메시지 없이 출력됩니다.
한 줄에 접근이 여러 개일 때는 나눠서 확인한다
System.out.println(members[index].email);처럼 한 줄에 참조를 따라가는 단계가 여러 개 있으면, NPE가 났을 때 어느 단계가 null이었는지 바로 알기 어렵습니다. 이럴 때는 추측하기보다 중간 값을 지역 변수로 꺼내 단계별로 확인합니다.
public class SplitDebug {
public static void main(String[] args) {
Member[] members = new Member[3];
int index = 1;
Member selected = members[index];
System.out.println("selected=" + selected);
System.out.println(selected.email);
}
}selected=null
Exception in thread "main" java.lang.NullPointerException: Cannot read field "email" because "selected" is null
at SplitDebug.main(SplitDebug.java:8)첫 줄에 selected=null이 출력됐다는 것은 배열에 접근하고 1번 칸을 읽는 데까지는 성공했고, 그 칸에 회원 참조가 없었다는 뜻입니다. null 값을 읽고 출력하는 것 자체는 객체의 필드에 접근하는 일이 아니므로 예외가 나지 않습니다. 예외는 그다음 줄에서 selected의 참조를 따라 email을 읽으려 할 때 비로소 발생합니다.
사실 javac -g로 컴파일했다면 나누지 않은 한 줄짜리 코드에서도 메시지가 because "members[index]" is null이라고 알려 줍니다. 하지만 옵션 없이 컴파일하면 "<local1>[<local2>]"처럼 번호만 나오고, 메서드 호출이 섞인 긴 식에서는 메시지만 보고 값이 어디서 왔는지 알기 어렵습니다. 그럴 때 중간 값을 꺼내 출력해 보면 어느 단계가 null인지 확실히 알 수 있습니다.
경로의 다른 단계가 문제였다면 다른 결과가 나옵니다. members 변수 자체가 null이었다면 members[index]를 읽는 첫 대입에서 Cannot load from object array라는 NPE가 납니다. 인덱스가 3처럼 배열 범위를 벗어났다면 NPE가 아니라 ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3이 발생합니다. 이처럼 예외의 종류와 메시지만 봐도 경로의 어느 단계에서 실패했는지 알 수 있습니다.
필드도 null일 수 있다: 중첩 참조
참조형 필드는 그 자체로 또 다른 객체를 가리킬 수 있습니다. 회원 프로필에 운영 메모 객체를 붙인 다음 예제에서는 점이 두 번 이어집니다.
class ProfileNote {
String text;
}
class MemberProfile {
String email;
ProfileNote note;
}
public class NestedNull {
public static void main(String[] args) {
MemberProfile member = new MemberProfile();
member.email = "[email protected]";
System.out.println(member.note.text);
}
}Exception in thread "main" java.lang.NullPointerException: Cannot read field "text" because "member.note" is null
at NestedNull.main(NestedNull.java:15)이 파일에는 클래스가 세 개 들어 있지만 public 클래스는 NestedNull 하나이므로 NestedNull.java 하나로 컴파일할 수 있습니다. 이 예제는 Member를 쓰지 않습니다.
member는 실제 MemberProfile 객체를 가리키므로 첫 번째 점, 곧 member.note를 읽는 데는 성공합니다. 그런데 note 필드는 아무도 채우지 않았으니 기본값 null이고, 두 번째 점에서 null의 text를 읽으려다 실패합니다. 메시지도 null이었던 것이 member가 아니라 member.note라고 정확히 알려 줍니다. 그러니 if (member != null) 같은 검사를 추가해도 member는 null이 아니라서 그대로 통과하고, 다음 줄에서 똑같은 NPE가 납니다.
진짜 해결은 'ProfileNote는 언제 만들어져야 하는가'를 정하는 것입니다. 모든 프로필에 메모가 있어야 한다면 프로필을 만들 때 메모 객체도 함께 만들어야 합니다.
member.note = new ProfileNote();
member.note.text = "본인 확인 완료";반대로 메모가 없는 프로필이 정상이라면, note를 읽는 코드가 null인 경우를 처리해야 합니다. 어느 쪽이든 a.b.c 같은 긴 식에서 NPE가 나면 왼쪽부터 참조 경로를 그려 보세요. a가 null일 수도 있고, a는 멀쩡한데 a.b가 null일 수도 있습니다. 아래 도표는 지금까지 본 세 예제에서 마지막으로 성공한 읽기와 처음 실패한 접근을 나란히 보여 줍니다.
null 접근에서 성공한 읽기와 실패한 역참조
null 값 읽기·유효 배열 칸 읽기·member.note 읽기는 성공하고 이후 null 대상 필드 접근에서 각각 NPE가 납니다. 세 독립 예제의 첫 실패 위치를 접근 단계로 구분할 수 있습니다.
세 원문 예제에서 null 참조값 읽기까지는 가능하지만 그 참조로 필드를 읽는 지점에서 NPE가 발생합니다.
null 값 읽기와 객체 필드 접근은 다릅니다
세 독립 예제에서 마지막으로 성공한 읽기와 처음 실패하는 역참조를 비교합니다. 참조 경로를 왼쪽부터 확인합니다.
| 예제 | 실패 전까지 읽은 값 | 실패하는 필드 접근 |
|---|---|---|
NullDereference | member의 값은 null | member.email: 따라갈 Member가 없어 NPE |
| 디버깅을 위한 분해 | 길이 3인 배열의 1번 칸을 읽어 selected = null. selected=null 출력까지 성공 | selected.email: 그다음 필드 읽기에서 NPE |
NestedNull | member는 MemberProfile 객체. member.note 읽기는 성공하고 결과는 null | member.note.text: null인 note에서 text를 읽다 NPE |
- NullDereference
member의 값은null입니다.member.email은 따라갈 Member가 없어 NPE가 납니다.- 디버깅을 위한 분해
길이 3인 배열의 1번 칸을 읽어
selected = null을 얻습니다.selected=null출력은 성공하지만 다음selected.email에서 NPE가 납니다.- NestedNull
member는 MemberProfile 객체입니다.member.note읽기는 성공하고 결과는null입니다.member.note.text는 그 null 참조로 text를 읽는 지점에서 NPE가 납니다.
null을 읽거나 출력하는 것과, 그 값으로 객체의 필드에 접근하는 것은 다릅니다. 위 예제는 서로 독립된 코드이며 하나의 실행 순서가 아닙니다.
이 예의 조건
- A,B,C는 독립 예제이며 NPE는 NullPointerException입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | A는 member의 null 읽기 성공 뒤 .email에서 NPE가 발생한다. B는 길이 3인 배열이 있고 1번 칸이 유효하여 selected의 null 출력 성공 뒤 .email에서 NPE가 발생한다. C는 member→MemberProfile/노트의 null 읽기 성공 뒤 .text에서 NPE가 발생한다. 각 예제는 독립적이다. |
| 구분 | 내용 |
|---|---|
| 설명 | A는 member의 null 읽기 성공 뒤 .email에서 NPE가 발생한다. B는 길이 3인 배열이 있고 1번 칸이 유효하여 selected의 null 출력 성공 뒤 .email에서 NPE가 발생한다. C는 member→MemberProfile/노트의 null 읽기 성공 뒤 .text에서 NPE가 발생한다. 각 예제는 독립적이다. |
| 전체 읽기 정보 | null 접근에서 성공한 읽기와 실패한 역참조 null 접근에서 성공한 읽기와 실패한 역참조 세 원문 예제에서 null 참조값 읽기까지는 가능하지만 그 참조로 필드를 읽는 지점에서 NPE가 발생합니다. null 값 읽기와 객체 필드 접근은 다릅니다 세 독립 예제에서 마지막으로 성공한 읽기와 처음 실패하는 역참조를 비교합니다. 참조 경로를 왼쪽부터 확인합니다. 독립된 세 예제 · 왼쪽 참조 경로부터 확인 예제 실패 전까지 읽은 값 실패하는 필드 접근 NullDereference member 의 값은 null member.email : 따라갈 Member가 없어 NPE 디버깅을 위한 분해 길이 3인 배열의 1번 칸을 읽어 selected = null . selected=null 출력까지 성공 selected.email : 그다음 필드 읽기에서 NPE NestedNull member 는 MemberProfile 객체. member.note 읽기는 성공하고 결과는 null member.note.text : null인 note에서 text를 읽다 NPE NullDereference member 의 값은 null 입니다. member.email 은 따라갈 Member가 없어 NPE가 납니다. 디버깅을 위한 분해 길이 3인 배열의 1번 칸을 읽어 selected = null 을 얻습니다. selected=null 출력은 성공하지만 다음 selected.email 에서 NPE가 납니다. NestedNull member 는 MemberProfile 객체입니다. member.note 읽기는 성공하고 결과는 null 입니다. member.note.text 는 그 null 참조로 text를 읽는 지점에서 NPE가 납니다. null 을 읽거나 출력하는 것과, 그 값으로 객체의 필드에 접근하는 것은 다릅니다. 위 예제는 서로 독립된 코드이며 하나의 실행 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| null 값 읽기 A의 member=null을 읽거나 B의 유효 배열 1번 칸을 읽습니다. |
|
|
| 필드 접근 실패 A와 B가 null.email을 읽으려 합니다. |
|
|
| 중첩 접근 경계 C는 member.note 읽기 성공 뒤 null.text에서 실패합니다. |
|
|
null 접근에서 성공한 읽기와 실패한 역참조
null 값 읽기·유효 배열 칸 읽기·member.note 읽기는 성공하고 이후 null 대상 필드 접근에서 각각 NPE가 납니다. 세 독립 예제의 첫 실패 위치를 접근 단계로 구분할 수 있습니다.
세 원문 예제에서 null 참조값 읽기까지는 가능하지만 그 참조로 필드를 읽는 지점에서 NPE가 발생합니다.
null 값 읽기와 객체 필드 접근은 다릅니다
세 독립 예제에서 마지막으로 성공한 읽기와 처음 실패하는 역참조를 비교합니다. 참조 경로를 왼쪽부터 확인합니다.
| 예제 | 실패 전까지 읽은 값 | 실패하는 필드 접근 |
|---|---|---|
NullDereference | member의 값은 null | member.email: 따라갈 Member가 없어 NPE |
| 디버깅을 위한 분해 | 길이 3인 배열의 1번 칸을 읽어 selected = null. selected=null 출력까지 성공 | selected.email: 그다음 필드 읽기에서 NPE |
NestedNull | member는 MemberProfile 객체. member.note 읽기는 성공하고 결과는 null | member.note.text: null인 note에서 text를 읽다 NPE |
- NullDereference
member의 값은null입니다.member.email은 따라갈 Member가 없어 NPE가 납니다.- 디버깅을 위한 분해
길이 3인 배열의 1번 칸을 읽어
selected = null을 얻습니다.selected=null출력은 성공하지만 다음selected.email에서 NPE가 납니다.- NestedNull
member는 MemberProfile 객체입니다.member.note읽기는 성공하고 결과는null입니다.member.note.text는 그 null 참조로 text를 읽는 지점에서 NPE가 납니다.
null을 읽거나 출력하는 것과, 그 값으로 객체의 필드에 접근하는 것은 다릅니다. 위 예제는 서로 독립된 코드이며 하나의 실행 순서가 아닙니다.
이 예의 조건
- A,B,C는 독립 예제이며 NPE는 NullPointerException입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | A는 member의 null 읽기 성공 뒤 .email에서 NPE가 발생한다. B는 길이 3인 배열이 있고 1번 칸이 유효하여 selected의 null 출력 성공 뒤 .email에서 NPE가 발생한다. C는 member→MemberProfile/노트의 null 읽기 성공 뒤 .text에서 NPE가 발생한다. 각 예제는 독립적이다. |
| 구분 | 내용 |
|---|---|
| 설명 | A는 member의 null 읽기 성공 뒤 .email에서 NPE가 발생한다. B는 길이 3인 배열이 있고 1번 칸이 유효하여 selected의 null 출력 성공 뒤 .email에서 NPE가 발생한다. C는 member→MemberProfile/노트의 null 읽기 성공 뒤 .text에서 NPE가 발생한다. 각 예제는 독립적이다. |
| 전체 읽기 정보 | null 접근에서 성공한 읽기와 실패한 역참조 null 접근에서 성공한 읽기와 실패한 역참조 세 원문 예제에서 null 참조값 읽기까지는 가능하지만 그 참조로 필드를 읽는 지점에서 NPE가 발생합니다. null 값 읽기와 객체 필드 접근은 다릅니다 세 독립 예제에서 마지막으로 성공한 읽기와 처음 실패하는 역참조를 비교합니다. 참조 경로를 왼쪽부터 확인합니다. 독립된 세 예제 · 왼쪽 참조 경로부터 확인 예제 실패 전까지 읽은 값 실패하는 필드 접근 NullDereference member 의 값은 null member.email : 따라갈 Member가 없어 NPE 디버깅을 위한 분해 길이 3인 배열의 1번 칸을 읽어 selected = null . selected=null 출력까지 성공 selected.email : 그다음 필드 읽기에서 NPE NestedNull member 는 MemberProfile 객체. member.note 읽기는 성공하고 결과는 null member.note.text : null인 note에서 text를 읽다 NPE NullDereference member 의 값은 null 입니다. member.email 은 따라갈 Member가 없어 NPE가 납니다. 디버깅을 위한 분해 길이 3인 배열의 1번 칸을 읽어 selected = null 을 얻습니다. selected=null 출력은 성공하지만 다음 selected.email 에서 NPE가 납니다. NestedNull member 는 MemberProfile 객체입니다. member.note 읽기는 성공하고 결과는 null 입니다. member.note.text 는 그 null 참조로 text를 읽는 지점에서 NPE가 납니다. null 을 읽거나 출력하는 것과, 그 값으로 객체의 필드에 접근하는 것은 다릅니다. 위 예제는 서로 독립된 코드이며 하나의 실행 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| null 값 읽기 A의 member=null을 읽거나 B의 유효 배열 1번 칸을 읽습니다. |
|
|
| 필드 접근 실패 A와 B가 null.email을 읽으려 합니다. |
|
|
| 중첩 접근 경계 C는 member.note 읽기 성공 뒤 null.text에서 실패합니다. |
|
|
null 검사를 어디에 둘 것인가
NPE를 만나면 실패한 줄 앞에 if (member != null)을 붙이고 싶어집니다. 그러면 당장의 예외는 사라집니다.
if (member != null) {
System.out.println(member.email);
}하지만 이 코드는 member가 왜 null이었는지 묻지 않습니다. 회원이 반드시 있어야 하는 상황이었다면 이 회원은 출력되지 않은 채 조용히 넘어가고, 문제는 한참 뒤에 '회원 수가 맞지 않는다' 같은 엉뚱한 결과로 드러납니다. 예외는 사라졌지만 버그는 남아 있고 찾기만 더 어려워진 셈입니다.
null을 만나는 상황은 대개 다음 셋 중 하나이고, 상황마다 고치는 방법이 다릅니다.
- 고정 크기 배열에서 아직 쓰지 않은 칸:
null검사 대신0부터size - 1까지만 순회해서 애초에 그 칸을 읽지 않습니다. - 검색처럼 결과가 없을 수도 있는 경우: 메서드가
null을 돌려줄 수 있다는 사실을 호출자가 알고, 결과를 쓰기 전에 반드시 검사합니다. - 이메일처럼 반드시 있어야 하는 값: 객체를 만들거나 입력을 받는 시점에 빈 값을 거부하고, 무엇이 빠졌는지 알립니다.
즉 null 검사는 '값이 없는 것도 정상'인 자리에서만 의미가 있습니다. 반드시 있어야 할 값이 없는 것은 버그이므로, null이 처음 생긴 곳을 찾아 고쳐야 합니다.
더 깊이: 필수 값은 일찍 거부한다
필수 값이 null이면 그 값을 받는 즉시 실패시키는 편이 낫습니다. 나중에 엉뚱한 곳에서 터지는 것보다, 받는 지점에서 터져야 원인이 메시지와 스택 트레이스에 그대로 남기 때문입니다. 표준 라이브러리의 java.util.Objects.requireNonNull이 이 일을 하며, 쓰려면 파일 맨 위에 import java.util.Objects;가 필요합니다.
String email = null;
Objects.requireNonNull(email, "email은 필수입니다");email이 null이면 이 줄에서 바로 java.lang.NullPointerException: email은 필수입니다가 발생하고, 스택 트레이스에는 requireNonNull을 호출한 줄이 나옵니다. 값이 있으면 아무 일도 하지 않고 넘겨받은 값을 그대로 돌려줍니다. 이 방법은 생성자를 배운 뒤 객체를 만드는 순간 필수 필드를 검사할 때 특히 유용합니다.
참조가 모두 끊긴 객체는 어떻게 될까
참조 변수에 null을 대입한다고 해서 변수가 가리키던 객체가 바로 지워지지는 않습니다. 같은 객체를 가리키는 다른 참조가 남아 있을 수 있기 때문입니다.
public class Reachability {
public static void main(String[] args) {
Member member = new Member();
member.email = "[email protected]";
Member alias = member;
member = null;
System.out.println(alias.email); // 여전히 접근 가능
alias = null;
// 이제 이 코드에는 이전 객체에 도달할 참조가 없다.
}
}alias = member는 참조값을 복사하므로 두 변수가 같은 객체를 가리킵니다. 그다음 member = null은 member 변수의 값만 바꿉니다. alias는 여전히 같은 객체를 가리키므로 alias.email로 [email protected]을 읽을 수 있습니다. 마지막 alias = null까지 실행하면 그 객체를 가리키는 변수가 하나도 남지 않습니다.
| 시점 | member | alias | 객체에 도달할 수 있는가 |
|---|---|---|---|
alias = member 뒤 | 객체 참조 | 같은 객체 참조 | 예 |
member = null 뒤 | null | 같은 객체 참조 | 예 |
alias = null 뒤 | null | null | 아니요 |
Reachability의 두 참조를 차례로 끊은 상태
참조 복사 후 member=null이어도 alias가 도달 경로를 유지하고 alias=null 후에만 경로가 끊깁니다. 즉시 GC 회수를 추가하지 않고 참조 단절의 세 시점을 보여 줄 수 있습니다.
member에 null을 대입해도 alias로 객체를 읽을 수 있고, alias까지 null이 되면 이 코드에서 보관하던 두 경로가 모두 사라집니다.
한 변수의 null 대입은 객체를 즉시 지우지 않습니다
member의 참조를 끊어도 alias가 같은 객체에 도달합니다. 이 코드의 마지막 참조까지 끊기면 GC 회수 후보가 됩니다.
| 대입 직후 | member | alias | 첫 객체에 도달하는 경로 |
|---|---|---|---|
Member alias = member; | 첫 객체 참조 | 첫 객체 참조 | 두 변수 모두 같은 객체에 도달 |
member = null; | null | 첫 객체 참조 | alias.email로 [email protected] 출력 가능 |
alias = null; | null | null | 이 코드가 보관하던 참조 경로 없음 |
- Member alias = member;
member와alias모두 같은 첫 객체를 참조합니다.- member = null;
member는null이지만alias는 첫 객체 참조를 유지합니다.alias.email로[email protected]을 출력할 수 있습니다.- alias = null;
두 변수 모두
null입니다. 이 코드가 보관하던 첫 객체로의 참조 경로가 없습니다.
참조 단절은 즉시 삭제가 아닙니다. 도달 불가능해진 객체는 GC 회수 후보이며, 이 표는 실제 메모리 회수 시점이나 System.gc()의 강제 실행을 보장하지 않습니다.
이 예의 조건
- 참조 단절은 즉시 메모리 회수를 관측한 사실이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 첫 객체 temp를 세 시점에 반복 표시한다. 최초 member/alias의 2개 참조→member가 null일 때 alias의 1개 참조→모두 null인 0개 참조이다. 마지막 도달 불가 상태는 GC 후보이며 즉시 회수·물리 배치를 보장하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 첫 객체 temp를 세 시점에 반복 표시한다. 최초 member/alias의 2개 참조→member가 null일 때 alias의 1개 참조→모두 null인 0개 참조이다. 마지막 도달 불가 상태는 GC 후보이며 즉시 회수·물리 배치를 보장하지 않는다. |
| 전체 읽기 정보 | Reachability의 두 참조를 차례로 끊은 상태 Reachability의 두 참조를 차례로 끊은 상태 member에 null을 대입해도 alias로 객체를 읽을 수 있고, alias까지 null이 되면 이 코드에서 보관하던 두 경로가 모두 사라집니다. 한 변수의 null 대입은 객체를 즉시 지우지 않습니다 member의 참조를 끊어도 alias가 같은 객체에 도달합니다. 이 코드의 마지막 참조까지 끊기면 GC 회수 후보가 됩니다. Reachability · 각 대입 직후 같은 첫 객체를 추적 대입 직후 member alias 첫 객체에 도달하는 경로 Member alias = member; 첫 객체 참조 첫 객체 참조 두 변수 모두 같은 객체에 도달 member = null; null 첫 객체 참조 alias.email 로 [email protected] 출력 가능 alias = null; null null 이 코드가 보관하던 참조 경로 없음 Member alias = member; member 와 alias 모두 같은 첫 객체를 참조합니다. member = null; member 는 null 이지만 alias 는 첫 객체 참조를 유지합니다. alias.email 로 [email protected] 을 출력할 수 있습니다. alias = null; 두 변수 모두 null 입니다. 이 코드가 보관하던 첫 객체로의 참조 경로가 없습니다. 참조 단절은 즉시 삭제가 아닙니다. 도달 불가능해진 객체는 GC 회수 후보이며, 이 표는 실제 메모리 회수 시점이나 System.gc() 의 강제 실행을 보장하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 별칭 생성 member와 alias가 같은 객체를 참조합니다. |
|
|
| 첫 참조 제거 member를 null로 바꿉니다. |
|
|
| 마지막 참조 제거 alias도 null로 바꿉니다. |
|
|
Reachability의 두 참조를 차례로 끊은 상태
참조 복사 후 member=null이어도 alias가 도달 경로를 유지하고 alias=null 후에만 경로가 끊깁니다. 즉시 GC 회수를 추가하지 않고 참조 단절의 세 시점을 보여 줄 수 있습니다.
member에 null을 대입해도 alias로 객체를 읽을 수 있고, alias까지 null이 되면 이 코드에서 보관하던 두 경로가 모두 사라집니다.
한 변수의 null 대입은 객체를 즉시 지우지 않습니다
member의 참조를 끊어도 alias가 같은 객체에 도달합니다. 이 코드의 마지막 참조까지 끊기면 GC 회수 후보가 됩니다.
| 대입 직후 | member | alias | 첫 객체에 도달하는 경로 |
|---|---|---|---|
Member alias = member; | 첫 객체 참조 | 첫 객체 참조 | 두 변수 모두 같은 객체에 도달 |
member = null; | null | 첫 객체 참조 | alias.email로 [email protected] 출력 가능 |
alias = null; | null | null | 이 코드가 보관하던 참조 경로 없음 |
- Member alias = member;
member와alias모두 같은 첫 객체를 참조합니다.- member = null;
member는null이지만alias는 첫 객체 참조를 유지합니다.alias.email로[email protected]을 출력할 수 있습니다.- alias = null;
두 변수 모두
null입니다. 이 코드가 보관하던 첫 객체로의 참조 경로가 없습니다.
참조 단절은 즉시 삭제가 아닙니다. 도달 불가능해진 객체는 GC 회수 후보이며, 이 표는 실제 메모리 회수 시점이나 System.gc()의 강제 실행을 보장하지 않습니다.
이 예의 조건
- 참조 단절은 즉시 메모리 회수를 관측한 사실이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 첫 객체 temp를 세 시점에 반복 표시한다. 최초 member/alias의 2개 참조→member가 null일 때 alias의 1개 참조→모두 null인 0개 참조이다. 마지막 도달 불가 상태는 GC 후보이며 즉시 회수·물리 배치를 보장하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 첫 객체 temp를 세 시점에 반복 표시한다. 최초 member/alias의 2개 참조→member가 null일 때 alias의 1개 참조→모두 null인 0개 참조이다. 마지막 도달 불가 상태는 GC 후보이며 즉시 회수·물리 배치를 보장하지 않는다. |
| 전체 읽기 정보 | Reachability의 두 참조를 차례로 끊은 상태 Reachability의 두 참조를 차례로 끊은 상태 member에 null을 대입해도 alias로 객체를 읽을 수 있고, alias까지 null이 되면 이 코드에서 보관하던 두 경로가 모두 사라집니다. 한 변수의 null 대입은 객체를 즉시 지우지 않습니다 member의 참조를 끊어도 alias가 같은 객체에 도달합니다. 이 코드의 마지막 참조까지 끊기면 GC 회수 후보가 됩니다. Reachability · 각 대입 직후 같은 첫 객체를 추적 대입 직후 member alias 첫 객체에 도달하는 경로 Member alias = member; 첫 객체 참조 첫 객체 참조 두 변수 모두 같은 객체에 도달 member = null; null 첫 객체 참조 alias.email 로 [email protected] 출력 가능 alias = null; null null 이 코드가 보관하던 참조 경로 없음 Member alias = member; member 와 alias 모두 같은 첫 객체를 참조합니다. member = null; member 는 null 이지만 alias 는 첫 객체 참조를 유지합니다. alias.email 로 [email protected] 을 출력할 수 있습니다. alias = null; 두 변수 모두 null 입니다. 이 코드가 보관하던 첫 객체로의 참조 경로가 없습니다. 참조 단절은 즉시 삭제가 아닙니다. 도달 불가능해진 객체는 GC 회수 후보이며, 이 표는 실제 메모리 회수 시점이나 System.gc() 의 강제 실행을 보장하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 별칭 생성 member와 alias가 같은 객체를 참조합니다. |
|
|
| 첫 참조 제거 member를 null로 바꿉니다. |
|
|
| 마지막 참조 제거 alias도 null로 바꿉니다. |
|
|
어떤 경로로도 다시 참조를 얻을 수 없는 객체를 도달할 수 없는(unreachable) 객체라고 합니다. 도달할 수 없는 객체는 프로그램이 다시 사용할 방법이 없으므로, 가비지 컬렉터(garbage collector, GC)가 메모리를 회수할 수 있는 대상이 됩니다.
여기서 '회수할 수 있는 대상'이라는 표현이 중요합니다. alias = null을 실행한 직후에 메모리가 바로 회수되는 것은 아닙니다. GC가 언제 실행될지는 JVM이 메모리 상황을 보고 정합니다. System.gc()를 호출해도 JVM에 회수를 요청할 뿐이고, 공식 문서도 특정 시점에 특정 양의 메모리가 회수된다는 보장은 없다고 밝힙니다. 그러니 프로그램이 올바르게 동작하는 데 '객체가 지금 바로 회수된다'는 가정이 필요해서는 안 됩니다. 실행 중 메모리가 어떻게 나뉘고 객체 수명이 어떻게 관리되는지는 6장의 JVM 메모리 구조: 스택, 힙, 메서드 영역에서 더 자세히 다룹니다.
NPE의 원인을 찾는 순서
재현할 수 있는 NPE는 다음 순서로 추적하면 대부분 원인을 찾을 수 있습니다.
- 스택 트레이스의
at줄을 위에서부터 읽으며 내가 작성한 코드가 처음 나오는 줄을 찾습니다.at java.base/...처럼 표준 라이브러리 안의 줄이 먼저 나오면 건너뜁니다. - 그 줄에 점이나 대괄호 접근이 여러 개라면 중간 값을 지역 변수로 나눠, 어느 단계가
null인지 확인합니다. - 그
null이 처음 들어간 곳을 거슬러 올라갑니다. 대입하지 않은 필드,null을 반환한 메서드, 채우지 않은 배열 칸이 흔한 출발점입니다. - 그 자리에서 값이 없는 것이 정상인지, 있어야 하는데 빠진 버그인지 판단합니다.
- 정상이라면
null인 경우를 처리하는 분기를 두고, 버그라면 더 앞에서 값을 채우거나 빈 값을 거부하도록 고칩니다.
실패한 줄에 null 검사만 덧붙이는 것은 3번과 4번을 건너뛴 처방일 때가 많습니다. 원인이 배열의 size 관리 실수라면 순회 범위를 고쳐야 하고, 필수 필드를 채우지 않은 것이라면 객체를 만드는 과정을 고쳐야 합니다.
정리
null은 어떤 객체도 가리키지 않는 참조값입니다. 새 객체의 참조형 필드와 새 배열의 참조형 칸은null로 시작합니다.null을 저장하거나 출력하는 것은 괜찮지만,null에 점이나 대괄호로 접근하면NullPointerException이 납니다.- NPE 메시지는 실패한 동작,
null이었던 식, 소스 위치를 알려 줍니다. 지역 변수 이름은javac -g로 컴파일해야 보입니다. null검사는 값이 없어도 되는 자리에서만 쓰고, 반드시 있어야 할 값이 없다면null이 생긴 곳을 고칩니다.- 참조가 모두 끊긴 객체는 GC의 회수 대상이 되지만, 언제 회수될지는 JVM이 정합니다.
연습 문제
- 다음 프로그램은 몇 번째 출력에서 멈추고, NPE 메시지의
because뒤에는 어떤 식이 나올까요?javac -g로 컴파일했다고 가정하고 예측한 뒤 실행해 확인하세요.
public class NpeQuiz {
public static void main(String[] args) {
Member[] members = new Member[2];
members[0] = new Member();
members[0].age = 30;
System.out.println(members[0].age);
System.out.println(members[0].email.length());
}
}해설 보기
30
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "members[0].email" is null
at NpeQuiz.main(NpeQuiz.java:8)첫 출력은 성공합니다. members는 배열을 가리키고, 0번 칸은 실제 회원을 가리키며, age는 기본형이라 null이 될 수 없기 때문입니다. 둘째 출력에서 members[0].email까지 읽는 것은 성공하지만, 아무도 채우지 않은 email의 값은 null입니다. 그 null에 length() 메서드를 호출하려다 실패하므로, 메시지는 Cannot invoke "String.length()"로 시작하고 원인 식은 members[0].email입니다. 1번 칸은 null이지만 이 프로그램은 1번 칸을 읽지 않으므로 아무 문제가 되지 않습니다.
- 길이 5인 배열의 앞 세 칸에만 실제 회원이 들어 있습니다. 이메일로 회원을 찾는
findByEmail을 작성하세요. 배열 자체, 앞쪽size개 칸의 회원, 각 회원의 이메일은 모두null이 아니라고 가정합니다.size를 매개변수로 받아 회원이 들어 있는 칸만 순회합니다.- 이메일이 같으면 그
Member참조를 반환하고, 찾지 못하면null을 반환합니다. - 호출자는 반환값이
null인지 검사한 뒤에만 필드를 읽습니다. size가 0보다 작거나 배열 길이보다 크면IllegalArgumentException을 던집니다.
해설 보기
public class FindMember {
public static void main(String[] args) {
Member[] members = new Member[5];
members[0] = create("[email protected]", 30);
members[1] = create("[email protected]", 40);
members[2] = create("[email protected]", 20);
int size = 3;
Member found = findByEmail(members, size, "[email protected]");
if (found == null) {
System.out.println("찾지 못했습니다.");
} else {
System.out.println(found.email + " / " + found.age + "세");
}
}
static Member findByEmail(
Member[] members,
int size,
String email
) {
if (size < 0 || size > members.length) {
throw new IllegalArgumentException("잘못된 회원 수: " + size);
}
for (int i = 0; i < size; i++) {
Member member = members[i];
if (member.email.equals(email)) {
return member;
}
}
return null;
}
static Member create(String email, int age) {
Member member = new Member();
member.email = email;
member.age = age;
return member;
}
}[email protected] / 40세반복문이 i < size에서 멈추므로 아직 쓰지 않은 3번과 4번 칸의 null은 읽지 않습니다. 앞에서 본 '쓰지 않은 칸은 순회 범위로 피한다'를 적용한 것입니다. size 검사는 음수나 배열 길이를 넘는 값을 막아 줍니다.
다만 size 검사가 막아 주는 것은 숫자가 범위를 벗어나는 경우뿐입니다. size가 3이어도 누군가 1번 칸을 비워 두었다면 member.email에서 NPE가 납니다. '앞쪽 size개 칸에는 반드시 회원이 있다'는 조건은 숫자 검사로 확인할 수 없고, 배열에 회원을 넣고 빼는 코드가 함께 지켜야 합니다. 다음 글의 종합 프로그램은 이 조건이 깨지지 않도록 회원을 저장하는 순서를 정합니다.
호출자가 found == null을 검사하는 것은 검색 결과가 없을 수도 있는 정상 상황을 처리하는 것이므로, 앞에서 본 '원인을 덮는 검사'와는 다릅니다. 다만 findByEmail이 null을 돌려줄 수 있다는 사실을 호출자가 기억해야 한다는 약점이 있습니다. 결과가 없을 수 있다는 것을 반환 타입으로 드러내는 방법은 나중에 Optional을 다루는 장에서 배웁니다.
Reachability예제에서member = null과alias = null의 순서를 바꾸면,System.out.println(alias.email)은 어떻게 될까요?
해설 보기
alias = null을 먼저 실행하면 alias에는 null이 들어가므로, alias.email을 읽는 줄에서 NullPointerException이 발생합니다. 이때 객체는 아직 member를 통해 도달할 수 있으므로 회수 대상이 아닙니다. 객체가 살아 있더라도 내가 점 연산을 하는 그 변수가 null이면 예외가 난다는 점을 확인할 수 있습니다.
다음 글
다음 글에서는 지금까지 배운 클래스, 객체 배열, 값 복사, 메서드 반환, null을 하나로 모아 실제로 입력을 받는 회원 저장소 프로그램을 만듭니다. 이 글에서 본 '앞쪽 size개 칸에는 반드시 회원이 있다'는 조건을 지키는 코드도 그 프로그램에서 직접 작성합니다.