객체 배열과 참조 경로
객체 배열의 칸에 저장되는 참조와 반복 변수가 복사하는 값을 단계별로 추적합니다.
회원 정보가 여러 개가 되면 entry1, entry2, entry3처럼 변수 이름에 번호를 붙이는 방식도 곧 한계에 닿습니다.
반복문을 쓰려면 회원들을 하나의 순서로 보관해야 합니다.
이때 Member[]를 사용하지만, 배열 안에 객체가 통째로 들어간다고 생각하면 이후의 null과 공유 참조를 설명하기 어렵습니다.
객체 배열은 두 종류의 객체를 함께 사용합니다.
배열 객체가 먼저 있고, 각 배열 칸에는 Member 인스턴스에 도달하는 참조값이 저장됩니다.
배열 생성과 기록 생성을 따로 해야 하는 이유가 여기에 있습니다.
객체 배열의 초기 상태
다음 코드는 길이가 3인 Member 배열을 만듭니다.
Member[] members = new Member[3];이 한 줄이 만든 것은 참조를 담을 칸 세 개뿐입니다.
각 칸은 참조형의 기본값인 null로 시작합니다.
Member 인스턴스 세 개를 자동으로 만들지는 않습니다.
import java.util.Arrays;
public class EmptyObjectArray {
public static void main(String[] args) {
Member[] members = new Member[3];
System.out.println(Arrays.toString(members));
}
}[null, null, null]배열 칸에 실제 회원을 넣으려면 각 객체를 만들고 그 참조를 대입합니다.
Member first = new Member();
first.email = "kim@example.com";
first.age = 35;
members[0] = first;members[0] = first도 대입입니다.
first 변수에 들어 있는 참조값이 배열의 0번 칸으로 복사됩니다.
객체가 배열 안으로 이동하거나 복제되는 것이 아닙니다.
대입 뒤에도 first와 members[0]은 같은 인스턴스에 도달합니다.
객체 배열 초기화
인스턴스를 먼저 만든 뒤 배열을 초기화할 수도 있습니다.
public class MemberArrayMain {
public static void main(String[] args) {
Member firstMember = new Member();
firstMember.email = "kim@example.com";
firstMember.age = 35;
firstMember.active = true;
Member secondMember = new Member();
secondMember.email = "lee@example.com";
secondMember.age = 25;
secondMember.active = false;
Member[] members = {firstMember, secondMember};
for (int i = 0; i < members.length; i++) {
Member member = members[i];
System.out.printf(
"%d. %s / %d세 / %s%n",
i + 1, member.email, member.age, member.active
);
}
}
}1. kim@example.com / 35세 / true
2. lee@example.com / 25세 / false배열 리터럴 {firstMember, secondMember}에 들어가는 것도 두 변수의 참조값입니다.
따라서 배열 칸을 통해 첫 회원을 바꾸면 firstMember로 읽어도 바뀐 값이 보입니다.
members[0].age = 50;
System.out.println(firstMember.age); // 50참조 접근식 해석
members[i].email에는 접근이 두 번 들어 있습니다.
members[i]가 배열의 i번째 칸에서Member참조를 읽습니다..email이 그 참조를 따라 인스턴스의 이메일 필드를 읽습니다.
MemberArrayMain의 초기화 직후 members 배열의 두 칸은 각각 별도 Member 객체를 참조합니다. 배열 참조, 인덱스, 선택한 칸의 참조를 구분하면 필드 접근의 세 실패 경계를 찾을 수 있습니다.
MemberArrayMain의 배열 초기화 직후 · members[0].email 읽기
members는 길이 2의 배열을 참조합니다.members[0]은 첫 Member의 참조값입니다. 그 객체의email은kim@example.com,age는 35입니다.members[1]은 둘째 Member의 참조값입니다. 그 객체의email은lee@example.com,age는 25입니다.
members[0]과 firstMember, members[1]과 secondMember는 각각 같은 객체에 도달합니다. 배열 칸에는 객체 자체가 아니라 참조값이 들어 있습니다.
new Member[3]만 실행한 초기 상태는 [null, null, null]입니다. 위 그림은 그 초기 상태가 아니라, 두 객체를 만든 뒤 배열 리터럴로 참조를 넣은 별도 예제입니다.
| 먼저 확인할 값 | 실패 조건 | 발생하는 예외 |
|---|---|---|
members | 배열 참조가 null | NullPointerException |
i | 배열이 있을 때 i < 0 또는 i >= members.length | ArrayIndexOutOfBoundsException |
members[i] | 인덱스가 유효해도 선택한 칸이 null | .email에서 NullPointerException |
접근을 막는 세 실패 경계
members == null- 배열 참조가 없으므로
NullPointerException. i < 0또는i >= members.length- 배열이 있어도 인덱스가 범위를 벗어나면
ArrayIndexOutOfBoundsException. members[i] == null- 인덱스가 유효해도 선택한 칸의 참조가 없으면
.email에서NullPointerException.
오류를 찾을 때 이 표현을 한 덩어리로 보지 마세요.
members 자체가 null일 수 있고, 인덱스가 범위를 벗어날 수 있고, members[i]가 null일 수도 있습니다.
각각 실패 종류가 다릅니다.
Member[] members = new Member[2];
// members = null;
// System.out.println(members[0]); // 배열 참조가 null: NullPointerException
// System.out.println(members[2]); // 인덱스 범위 초과: ArrayIndexOutOfBoundsException
System.out.println(members[0].email); // 칸이 null: NullPointerException스택 트레이스가 같은 줄을 가리키더라도 어느 중간값이 잘못됐는지 분해해 확인해야 합니다.
디버거에서는 members, i, members[i]를 차례로 관찰하면 됩니다.
for-each의 참조 복사
인덱스가 필요 없다면 향상된 for문으로 읽기 쉽도록 바꿀 수 있습니다.
for (Member member : members) {
System.out.println(member.email + " / " + member.age);
}매 반복마다 배열 칸의 참조값이 지역 변수 member에 복사됩니다.
그래서 다음 필드 변경은 원본 배열이 가리키는 객체에도 보입니다.
for (Member member : members) {
member.active = true;
}반면 반복 변수 자체를 새 객체로 바꿔도 배열 칸은 바뀌지 않습니다.
for (Member member : members) {
member = new Member();
member.email = "replacement@example.com";
}
System.out.println(members[0].email); // 기존 이메일 그대로member = new Member()는 지역 변수에 새 참조를 넣었을 뿐입니다.
배열의 members[0]이나 members[1]에 대입하지 않았습니다.
배열 칸을 실제로 교체하려면 인덱스 반복문에서 members[i] = replacement처럼 써야 합니다.
for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다.
현재 반복의 member에는 members[i]의 참조값이 복사되어 있고, 해당 칸은 null이 아닙니다.
| 실행하는 문장 | 바꾸는 저장 위치 | 배열에서 다시 읽으면 |
|---|---|---|
member.active = true; | 같이 참조하는 Member 객체의 active 필드 | members[i].active도 true |
| 지역 변수 member의 참조값, 그리고 새 객체의 필드 | 배열 칸의 참조와 기존 이메일은 그대로 |
members[i] = replacement; | 배열의 i번째 칸에 저장된 참조값 | 해당 칸에 replacement의 참조값을 저장 |
member.active = true;- 같이 참조하는 객체의
active필드를 씁니다.members[i].active로 읽어도true입니다. member = new Member();member.email = "replacement@example.com";- 지역 변수의 참조값과 새 객체의 필드만 바뀝니다. 배열 칸의 참조와 기존 이메일은 그대로입니다.
members[i] = replacement;- 배열의 i번째 칸에
replacement의 참조값을 저장합니다.
마지막 문장은 인덱스 반복문에서 배열 칸을 실제로 교체하는 방법입니다. 반복 변수 member에 대입하는 것과 구분합니다.
용량과 회원 수
회원 저장소가 최대 다섯 개까지 입력을 받는다고 해서 처음부터 다섯 회원을 만들 필요는 없습니다.
길이 5인 배열을 만들고 현재 사용한 칸의 수를 별도 변수로 관리할 수 있습니다.
public class FixedCapacityRegistry {
public static void main(String[] args) {
Member[] members = new Member[5];
int size = 0;
Member member = new Member();
member.email = "member@example.com";
member.age = 30;
members[size] = member;
size++;
for (int i = 0; i < size; i++) {
System.out.println(members[i].email);
}
}
}members.length는 저장할 수 있는 최대 칸 수이고 size는 실제로 채운 회원 수입니다.
둘을 같은 뜻으로 사용하면 남은 null 슬롯을 역참조하거나, 배열이 찼는데 새 값을 넣어 범위 오류가 납니다.
if (size >= members.length) {
System.out.println("더 이상 회원을 추가할 수 없습니다.");
} else {
members[size] = member;
size++;
}이 구조는 나중에 ArrayList가 내부에서 용량과 크기를 구분하는 이유를 이해할 준비가 됩니다.
지금은 고정 배열의 한계를 분명히 관찰하는 데 집중합니다.
FixedCapacityRegistry에서 첫 회원을 저장하고 size를 증가시킨 직후 배열 길이는 5이고 size는 1입니다. 사용 구간은 0번 칸뿐이며 1부터 4번 칸은 null입니다.
FixedCapacityRegistry의 size++ 직후 · members.length = 5, size = 1
- 0번 칸 · 사용 중
member의 참조값이 저장되어 있습니다.- 1, 2, 3, 4번 칸 · 미사용
- 네 칸 모두
null이며 연결된 Member 객체가 없습니다.
- 회원 순회는
0 <= i && i < size, 즉 0번 칸만 읽습니다. 그 회원의 이메일은member@example.com입니다. - 5번 칸은 존재하지 않습니다. 추가 전
size >= members.length이면 중단하고, 여유가 있을 때만members[size] = member;뒤size++;를 실행합니다.
연습 문제
다음 조건의 Member[]가 있습니다.
Member[] members = new Member[4];
int size = 3;0~2번 칸에는 객체가 있고 3번 칸은 null입니다.
다음을 구현하세요.
size까지만 순회합니다.active == false인 회원의 이메일과 나이를 출력합니다.- 총 비활성 회원 나이를 합산합니다.
members.length까지 무조건 순회했을 때 생길 수 있는 실패를 설명합니다.for-each를 사용한다면null슬롯을 어떻게 처리할지 적습니다.
해설 보기
public class PendingMembers {
public static void main(String[] args) {
Member[] members = new Member[4];
int size = 0;
members[size++] = create("kim@example.com", 20, true);
members[size++] = create("lee@example.com", 35, false);
members[size++] = create("park@example.com", 25, false);
int pendingAge = 0;
for (int i = 0; i < size; i++) {
Member member = members[i];
if (!member.active) {
System.out.println(member.email + " / " + member.age + "세");
pendingAge += member.age;
}
}
System.out.println("비활성 합계=" + pendingAge + "세");
}
static Member create(String email, int age, boolean active) {
Member member = new Member();
member.email = email;
member.age = age;
member.active = active;
return member;
}
}lee@example.com / 35세
park@example.com / 25세
비활성 합계=60세여기서는 다음 절에서 자세히 다룰 생성 메서드를 미리 사용했습니다.
create가 돌려준 참조값이 배열 칸에 저장됩니다.
members.length 전체를 순회하면서 곧바로 member.active를 읽으면 마지막 null 슬롯에서 NullPointerException이 납니다.
for-each가 필요하다면 if (member == null) continue;를 둘 수 있지만, 실제 데이터 수를 알고 있다면 size 범위만 순회하는 쪽이 상태를 더 정확하게 표현합니다.
객체 배열은 객체를 통째로 담지 않고 참조값을 담습니다.
배열 칸과 반복 변수에 같은 참조가 복사될 수 있다는 사실을 확인했습니다.
다음 절에서는 숫자 복사와 참조값 복사를 같은 대입 규칙으로 설명하고, 왜 공유된 필드 변경이 여러 경로에서 보이는지 파고듭니다.