null과 참조 단절
필드 기본값, NullPointerException, 도달 가능성과 GC를 하나의 참조 경로로 설명합니다.
고정 크기 배열에 아직 회원을 채우지 않은 칸이 있거나, 객체의 참조형 필드를 초기화하지 않으면 null을 만나게 됩니다.
null은 빈 문자열이나 값이 0인 객체가 아닙니다.
“현재 어떤 인스턴스도 가리키지 않는다”는 특별한 참조값입니다.
문제는 null 자체보다 객체가 있다고 가정하고 점 연산을 적용할 때 생깁니다.
회원 저장소 CLI에서 members[i].email을 읽는다면 배열 참조, 배열 칸의 참조, 필드 접근이라는 경로가 모두 이어져야 합니다.
중간 참조가 null이면 JVM은 더 진행할 객체를 찾을 수 없습니다.
필드와 배열의 기본값
지역 변수는 읽기 전에 직접 초기화해야 하지만, 새 객체의 필드와 새 배열의 칸은 타입별 기본값으로 초기화됩니다.
public class MemberDefaults {
public static void main(String[] args) {
Member member = new Member();
Member[] members = new Member[2];
System.out.println("title=" + member.email);
System.out.println("age=" + member.age);
System.out.println("active=" + member.active);
System.out.println("slot=" + members[0]);
}
}title=null
age=0
active=false
slot=nullString과 Member는 참조형이므로 기본값이 null입니다.
int는 0, boolean은 false입니다.
이 자동 초기화는 메모리에 예측할 수 없는 값이 남는 것을 막아 주지만, 객체가 유효하다는 보장은 아닙니다.
이메일이 반드시 있어야 하는 도메인이라면 null 이메일의 인스턴스가 생성된 순간부터 불완전한 상태입니다.
지금은 객체를 만든 뒤 필드를 직접 채우고 있습니다.
이후 생성자를 사용하면 필수 값을 생성 시점에 받을 수 있고, 접근 제어로 초기화되지 않은 객체를 만들기 어렵게 할 수 있습니다.
null 참조 예외
다음 예제는 의도적으로 NullPointerException을 만듭니다.
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)메시지는 실패한 연산, null이었던 표현식, 소스 위치를 알려 줍니다.
스택 트레이스를 위에서부터 읽고 애플리케이션 코드의 첫 줄로 이동합니다.
그 줄에서 점 왼쪽 값이 무엇인지 확인하면 조사 범위가 빠르게 줄어듭니다.
실패 줄에 members[index].email처럼 여러 접근이 있다면 한 번에 추측하지 말고 중간값을 나눠 봅니다.
Member[] members = new Member[3];
int index = 1;
Member selected = members[index];
System.out.println("selected=" + selected);
System.out.println(selected.email);첫 출력이 null이면 배열과 인덱스 접근까지는 성공했고 해당 슬롯에 회원 참조가 없다는 뜻입니다.
members 자체가 null이면 첫 대입에서 이미 실패합니다.
인덱스가 잘못됐다면 NullPointerException이 아니라 ArrayIndexOutOfBoundsException이 발생합니다.
예외 종류는 경로의 어느 단계가 실패했는지를 알려 주는 단서입니다.
null 검사와 객체 규칙
NPE를 본 뒤 모든 점 연산 앞에 if (value != null)을 붙이면 당장의 예외는 사라질 수 있습니다.
하지만 이메일이 없는 회원을 조용히 건너뛰는 것이 올바른 동작인지는 별도 판단이 필요합니다.
if (member != null) {
System.out.println(member.email);
}다음 세 상황은 처리 방식이 달라야 합니다.
- 고정 배열의 사용하지 않은 슬롯:
size범위만 순회해 애초에 접근하지 않습니다. - 검색 결과가 없을 수 있음: 호출자에게 “없음”을 명시적으로 처리하게 합니다.
- 반드시 있어야 하는 이메일이나 회원 정보: 생성 또는 입력 경계에서 거부하고 원인을 알립니다.
null 검사는 값 없음이 허용되는 경계에서만 의미가 있습니다.
필수 데이터가 사라진 버그를 숨기는 용도로 사용하면 오류가 더 뒤에서 잘못된 결과로 나타납니다.
참조 소실과 객체 수명
참조 변수에 null을 대입하면 변수가 가리키던 객체를 즉시 지우는 것이 아닙니다.
public class Reachability {
public static void main(String[] args) {
Member member = new Member();
member.email = "temp@example.com";
Member alias = member;
member = null;
System.out.println(alias.email); // 여전히 접근 가능
alias = null;
// 이제 애플리케이션 코드에서 이전 객체에 도달할 참조가 없다.
}
}첫 member = null 뒤에도 alias가 같은 객체를 가리키므로 객체는 계속 사용 가능합니다.
alias까지 null이 되면 코드에서 그 객체의 참조를 다시 구할 경로가 없습니다.
이런 도달 불가능 객체는 가비지 컬렉터가 회수할 후보가 됩니다.
“후보”라는 표현이 중요합니다.
alias = null을 실행한 직후 메모리가 반드시 회수되는 것은 아닙니다.
GC 실행 시점은 JVM이 결정합니다.
System.gc()를 호출해도 강제 실행 계약이 아니므로 프로그램의 정확성을 즉시 회수에 의존해서는 안 됩니다.
중첩 참조
회원 프로필에 운영 메모 객체가 있다고 가정해 봅시다.
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 = "kim@example.com";
System.out.println(member.note.text);
}
}member는 실제 객체를 가리키므로 첫 점은 성공합니다.
그러나 note 필드의 기본값이 null이므로 두 번째 점에서 실패합니다.
해결은 member만 검사하는 것이 아니라 ProfileNote를 언제 만들어야 하는지 정하는 것입니다.
member.note = new ProfileNote();
member.note.text = "본인 확인 완료";복잡한 표현에서 NPE가 나면 왼쪽부터 참조 경로를 그려 보세요.
a.b.c에서는 a와 a.b가 각각 null일 수 있습니다.
NPE 조사 순서
재현할 수 있는 NPE라면 다음 순서로 조사합니다.
- 스택 트레이스에서 애플리케이션의 첫 실패 줄을 찾습니다.
- 그 줄의 점·대괄호 접근을 중간 지역 변수로 분리합니다.
null이 처음 생긴 대입, 반환, 배열 초기화 위치를 거슬러 올라갑니다.- 값 없음이 정상 상태인지 버그인지 계약을 판단합니다.
- 정상이라면 분기나 반환 타입으로 드러내고, 버그라면 더 이른 생성·입력 경계에서 막습니다.
단순히 실패 줄에 null 검사를 넣는 것은 3번과 4번을 생략한 처방일 수 있습니다.
원인이 배열의 size 관리 오류라면 순회 경계를 고쳐야 하고, 필수 필드 초기화 누락이라면 생성 과정을 고쳐야 합니다.
연습 문제
길이 5인 배열에 실제 회원은 앞의 세 칸만 들어 있습니다.
이메일로 회원을 찾는 findByEmail을 작성하세요.
size를 매개변수로 받아 채운 범위만 순회합니다.- 이메일이 같으면 해당
Member참조를 반환합니다. - 찾지 못하면 이번 절에서는
null을 반환합니다. - 호출자는 반환값을 검사한 뒤에만 필드를 읽습니다.
size가 배열 범위를 벗어나는 호출은IllegalArgumentException으로 거부합니다.
해설 보기
public class FindMember {
public static void main(String[] args) {
Member[] members = new Member[5];
members[0] = create("kim@example.com", 30);
members[1] = create("lee@example.com", 40);
members[2] = create("park@example.com", 20);
int size = 3;
Member found = findByEmail(members, size, "lee@example.com");
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;
}
}lee@example.com / 40세size 계약을 먼저 검사하면 배열의 null 슬롯을 데이터처럼 취급하지 않습니다.
findByEmail이 null을 반환할 수 있다는 사실을 호출자가 반드시 기억해야 한다는 단점도 보입니다.
이후 Optional을 배울 때 “없음”을 반환 타입에 드러내는 방법과 비교할 수 있습니다.
null은 객체가 없다는 참조값이고 NPE는 객체로 이어져야 할 경로가 중간에서 끊겼다는 신호입니다.
다음 절에서는 지금까지 배운 클래스, 객체 배열, 값 복사, 메서드 반환, null 경계를 하나의 실행 가능한 회원 저장소 CLI로 조립합니다.