객체 배열 만들고 사용하기
Member 배열의 각 칸에 객체 대신 참조값이 들어간다는 점을 확인하고, null 칸과 향상된 for문을 다루며 실제 회원 수만큼만 안전하게 순회하는 법을 배웁니다.
회원이 여러 명이 되면 firstMember, secondMember처럼 변수 이름에 번호를 붙이는 방식은 금방 한계에 부딪힙니다. 3장에서 이메일들을 String[]에 모았던 것처럼, 회원들도 Member[]라는 배열에 모으면 반복문 하나로 처리할 수 있습니다. 그런데 배열 칸 안에 회원 객체가 통째로 들어 있다고 생각하면, 곧 만날 null 칸이나 같은 객체를 여러 곳에서 바꾸는 상황을 설명하기 어렵습니다.
객체 배열을 이해하는 열쇠는 객체가 두 종류라는 점입니다. 먼저 배열 자체가 하나의 객체이고, 그 배열의 각 칸에는 Member 객체를 가리키는 참조값이 들어갑니다. 회원 객체는 배열 밖에 따로 있습니다. 그래서 배열을 만드는 일과 회원 객체를 만드는 일을 각각 따로 해야 합니다.
이 글의 실행 클래스들은 모두 앞 글과 같은 세 필드의 Member.java를 사용합니다. src/members/ch04/v5 폴더에 Member.java와 실행할 클래스를 함께 두고, 작업 폴더에서 다음처럼 컴파일하고 실행합니다. 다른 예제를 실행할 때는 EmptyObjectArray 부분만 해당 클래스 이름으로 바꾸면 됩니다.
javac -d out src/members/ch04/v5/Member.java src/members/ch04/v5/EmptyObjectArray.java
java -cp out EmptyObjectArraynew Member[3]이 만드는 것
다음 코드는 길이가 3인 Member 배열을 만듭니다. new 뒤에 Member()가 아니라 Member[3]이 온다는 점을 보세요. 괄호가 아니라 대괄호이므로, 이 new는 회원 객체가 아니라 배열을 만듭니다.
Member[] members = new Member[3];이 한 줄이 만드는 것은 참조값을 담을 칸 세 개짜리 배열 하나뿐입니다. Member 객체 세 개가 자동으로 만들어지지는 않습니다. 각 칸은 참조형의 기본값인 null로 시작합니다. 확인해 보겠습니다.
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]Arrays.toString은 배열의 원소들을 대괄호 안에 차례로 나열한 문자열을 만들어 줍니다. 세 칸이 모두 null이라는 것은, 칸은 있지만 아직 아무 회원 객체도 가리키지 않는다는 뜻입니다. 3장에서 new String[3]의 칸이 null로 채워졌던 것과 같은 규칙입니다.
배열 칸에 실제 회원을 넣으려면 회원 객체를 따로 만들고, 그 참조값을 칸에 대입합니다.
Member first = new Member();
first.email = "[email protected]";
first.age = 35;
members[0] = first;members[0] = first도 평범한 대입입니다. 오른쪽 변수 first에 들어 있는 참조값이 배열의 0번 칸으로 복사될 뿐, 객체가 배열 안으로 옮겨지거나 복제되지는 않습니다. 그래서 대입한 뒤에도 first와 members[0]은 같은 객체를 가리키고, members[0] == first는 true입니다. 이 시점에 배열을 Arrays.toString으로 출력하면 [Member@2a139a55, null, null]처럼 0번 칸만 채워진 모습이 보입니다. Member@ 뒤의 숫자는 실행할 때마다 다를 수 있는 객체 표시로, 앞 글의 '더 깊이'에서 설명했습니다.
객체를 먼저 만들고 배열에 담기
배열 리터럴로 처음부터 칸을 채울 수도 있습니다. 다음 프로그램은 회원 객체 두 개를 만든 뒤, 두 변수를 나열해 배열을 만들고 반복문으로 출력합니다.
public class MemberArrayMain {
public static void main(String[] args) {
Member firstMember = new Member();
firstMember.email = "[email protected]";
firstMember.age = 35;
firstMember.active = true;
Member secondMember = new Member();
secondMember.email = "[email protected]";
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. [email protected] / 35세 / true
2. [email protected] / 25세 / false반복문 안의 Member member = members[i];는 i번 칸의 참조값을 지역 변수 member에 복사합니다. 덕분에 아래 출력문에서는 members[i].email 대신 member.email처럼 짧게 쓸 수 있습니다. 출력 번호에 i + 1을 쓴 것은 인덱스는 0부터 시작하지만 사람에게 보여 줄 번호는 1부터 시작하는 것이 자연스럽기 때문입니다.
배열 리터럴 {firstMember, secondMember}에 들어가는 것도 두 변수의 참조값입니다. 그래서 배열 칸을 통해 첫 회원의 필드를 바꾸면, firstMember로 읽어도 바뀐 값이 보입니다. 위 프로그램의 main 끝에 다음 두 줄을 덧붙여 실행해 보면 마지막 줄에 50이 출력됩니다.
members[0].age = 50;
System.out.println(firstMember.age); // 50members[0]과 firstMember가 같은 객체를 가리키기 때문에, 어느 쪽으로 바꾸든 객체는 하나뿐입니다.
members[i].email을 두 단계로 읽기
members[i].email에는 접근이 두 번 들어 있습니다. 먼저 members[i]가 배열의 i번 칸에서 Member 참조값을 읽고, 이어서 .email이 그 참조를 따라가 객체의 email 필드를 읽습니다. 앞 글에서 본 점 연산자 앞에 배열 칸 읽기가 하나 더 붙은 셈입니다. 아래 그림은 MemberArrayMain에서 배열을 만든 직후의 모습입니다. 배열 칸에는 참조만 있고, email과 age는 칸이 아니라 각 객체 안에 있다는 점을 눈여겨보세요.
MemberArrayMain의 초기화 직후 members 배열의 두 칸은 각각 별도 Member 객체를 참조합니다. 배열 참조, 인덱스, 선택한 칸의 참조를 구분하면 필드 접근의 세 실패 경계를 찾을 수 있습니다.
배열 칸의 참조를 읽은 뒤 객체 필드에 접근합니다
members → 배열 칸 → Member 객체의 경로를 따라갑니다. 배열 참조, 인덱스, 칸의 참조는 서로 다른 실패 경계입니다.
MemberArrayMain의 배열 초기화 직후 · members[0].email 읽기
members는 길이 2의 배열을 참조합니다.members[0]은 첫 Member의 참조값입니다. 그 객체의email은[email protected],age는 35입니다.members[1]은 둘째 Member의 참조값입니다. 그 객체의email은[email protected],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 |
접근을 막는 세 실패 경계
- 1 · 배열 참조
members == null- 배열 참조가 없어
NullPointerException이 발생합니다. - 2 · 인덱스 범위
i < 0또는i >= members.length- 배열이 있어도 범위를 벗어나면
ArrayIndexOutOfBoundsException이 발생합니다. - 3 · 선택한 칸의 참조
members[i] == null- 인덱스가 유효해도 칸이 null이면
.email에서NullPointerException이 발생합니다.
이렇게 단계를 나눠 보면, 이 짧은 식이 어디에서 실패할 수 있는지도 보입니다. members 변수 자체가 null일 수도 있고, 인덱스 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주석 처리하지 않은 마지막 줄을 실행하면 다음과 같은 예외가 납니다. 앞의 두 실패 지점은 주석으로 막아 두었으므로, 실행은 셋째 경우인 '칸이 null'에서 멈춥니다.
Exception in thread "main" java.lang.NullPointerException: Cannot read field "email" because "<local1>[0]" is nullmembers[0]은 존재하는 칸이지만 그 안의 값이 null이라서, .email이 따라갈 객체가 없습니다. 메시지의 <local1>[0]은 '1번 지역 변수, 즉 members의 0번 칸'이라는 뜻입니다. 주석을 풀어 다른 두 경우를 실행하면, 배열 참조가 null일 때는 Cannot load from object array because "<local1>" is null이라는 NullPointerException이, 인덱스가 범위를 벗어날 때는 Index 2 out of bounds for length 2라는 ArrayIndexOutOfBoundsException이 납니다.
예외가 난 줄만 보고는 세 경우 가운데 어느 것인지 알 수 없을 때가 많습니다. 그럴 때는 식을 나눠서 members, i, members[i]의 값을 차례로 확인하세요. 디버거를 쓴다면 이 세 값을 순서대로 살펴보면 됩니다.
향상된 for문의 반복 변수에는 참조값이 복사됩니다
인덱스가 필요 없다면 3장에서 배운 향상된 for문(for-each)으로 더 간단하게 쓸 수 있습니다. 인덱스 변수와 members[i]가 사라지고, 본문에서는 반복 변수 member로 바로 필드를 읽습니다.
for (Member member : members) {
System.out.println(member.email + " / " + member.age);
}향상된 for문은 반복할 때마다 배열 칸의 값을 반복 변수 member에 복사합니다. 객체 배열이므로 복사되는 값은 참조값입니다. 이 사실 하나로 다음 세 가지 코드의 결과가 모두 설명됩니다. 다음 프로그램은 회원 두 명이 담긴 배열에 세 가지 반복문을 차례로 실행합니다.
public class ForEachRebind {
public static void main(String[] args) {
Member first = new Member();
first.email = "[email protected]";
Member second = new Member();
second.email = "[email protected]";
Member[] members = {first, second};
// 1. 반복 변수로 필드를 바꾼다
for (Member member : members) {
member.active = true;
}
System.out.println("active: " + members[0].active + ", " + members[1].active);
// 2. 반복 변수에 새 객체를 대입한다
for (Member member : members) {
member = new Member();
member.email = "[email protected]";
}
System.out.println("rebind: " + members[0].email + ", " + members[1].email);
// 3. 인덱스로 배열 칸에 새 객체를 대입한다
for (int i = 0; i < members.length; i++) {
Member replacement = new Member();
replacement.email = "[email protected]";
members[i] = replacement;
}
System.out.println("replace: " + members[0].email + ", " + members[1].email);
System.out.println("first: " + first.email);
}
}active: true, true
rebind: [email protected], [email protected]
replace: [email protected], [email protected]
first: [email protected]첫 번째 반복에서 member에는 배열 칸과 같은 참조값이 들어 있습니다. 따라서 member.active = true는 배열 칸이 가리키는 바로 그 객체의 필드를 바꾸고, 배열을 통해 다시 읽어도 true가 나옵니다.
두 번째 반복은 의도와 다르게 동작합니다. 배열의 회원들을 새 객체로 바꾸려 했지만 출력된 이메일은 그대로입니다. member = new Member()는 지역 변수 member에 새 참조값을 넣었을 뿐이고, 배열의 members[0]이나 members[1]에는 아무것도 대입하지 않았기 때문입니다. 새로 만든 객체는 그 반복이 끝나면 아무도 가리키지 않게 됩니다.
배열 칸 자체를 바꾸려면 세 번째처럼 인덱스를 쓰는 반복문에서 members[i] = replacement라고 칸에 직접 대입해야 합니다. 그러면 배열은 새 객체들을 가리키게 됩니다. 마지막 줄을 보면, 배열에서 빠졌을 뿐 원래의 첫 회원 객체는 사라지지 않았습니다. 변수 first가 여전히 그 객체를 가리키고 있으므로 [email protected]이 출력됩니다.
아래 그림은 세 가지 대입이 각각 어느 저장 위치를 바꾸는지를 독립된 장면으로 보여 줍니다.
for-each의 필드 변경과 반복 변수 재대입
세 독립 사례에서 공유 필드 쓰기, foreach 지역 변수 재대입, 배열 칸 대입은 변경 대상이 다릅니다. 같은 참조 역할을 고정해 각 대입 전후를 비교하면 배열 교체 여부가 드러납니다.
for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다.
필드와 반복 변수와 배열 칸은 다른 저장 위치입니다
for-each는 배열 칸의 참조값을 지역 변수로 복사합니다. 무엇을 대입 대상으로 삼는지에 따라 배열에서 관찰되는 결과가 달라집니다.
현재 반복의 member에는 members[i]의 참조값이 복사되어 있고, 해당 칸은 null이 아닙니다.
| 실행하는 문장 | 바꾸는 저장 위치 | 배열에서 다시 읽으면 |
|---|---|---|
member.active = true; | 같이 참조하는 Member 객체의 active 필드 | members[i].active도 true |
| 지역 변수 member의 참조값, 그리고 새 객체의 필드 | 배열 칸의 참조와 기존 이메일은 그대로 |
members[i] = replacement; | 배열의 i번째 칸에 저장된 참조값 | 해당 칸에 replacement의 참조값을 저장 |
- 1 · 공유 객체의 필드 변경
member.active = true;- 같이 참조하는 객체의 active 필드를 씁니다.
members[i].active로 읽어도 true입니다. - 2 · 지역 변수의 참조 재대입
member = new Member();member.email = "[email protected]";- 지역 변수와 새 객체의 필드만 바뀝니다. 배열 칸의 참조와 기존 이메일은 그대로입니다.
- 3 · 배열 칸의 참조 교체
members[i] = replacement;- 배열의 i번째 칸에 replacement의 참조값을 저장합니다.
마지막 문장은 인덱스 반복문에서 배열 칸을 실제로 교체하는 방법입니다. 반복 변수 member에 대입하는 것과 구분합니다.
이 예의 조건
- 세 비교는 독립 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 독립 비교: 필드 쓰기는 member와 배열 칸이 공유한 객체의 active=true를 저장한다. 지역 member 재대입은 새 객체만 가리키고 배열은 원래 객체를 가리킨다. members[i] 대입은 해당 칸에 replacement 참조를 저장한다. 실선은 대입 후의 참조이며 실행 순서가 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 세 독립 비교: 필드 쓰기는 member와 배열 칸이 공유한 객체의 active=true를 저장한다. 지역 member 재대입은 새 객체만 가리키고 배열은 원래 객체를 가리킨다. members[i] 대입은 해당 칸에 replacement 참조를 저장한다. 실선은 대입 후의 참조이며 실행 순서가 아니다. |
| 전체 읽기 정보 | for-each의 필드 변경과 반복 변수 재대입 for-each의 필드 변경과 반복 변수 재대입 for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다. 필드와 반복 변수와 배열 칸은 다른 저장 위치입니다 for-each는 배열 칸의 참조값을 지역 변수로 복사합니다. 무엇을 대입 대상으로 삼는지에 따라 배열에서 관찰되는 결과가 달라집니다. 현재 반복의 member 에는 members[i] 의 참조값이 복사되어 있고, 해당 칸은 null 이 아닙니다. 서로 다른 대입 대상과 배열에서 관찰되는 결과 실행하는 문장 바꾸는 저장 위치 배열에서 다시 읽으면 member.active = true; 같이 참조하는 Member 객체의 active 필드 members[i].active 도 true member = new Member(); member.email = "[email protected]"; 지역 변수 member 의 참조값, 그리고 새 객체의 필드 배열 칸의 참조와 기존 이메일은 그대로 members[i] = replacement; 배열의 i번째 칸에 저장된 참조값 해당 칸에 replacement 의 참조값을 저장 1 · 공유 객체의 필드 변경 member.active = true; 같이 참조하는 객체의 active 필드를 씁니다. members[i].active 로 읽어도 true입니다. 2 · 지역 변수의 참조 재대입 member = new Member(); member.email = "[email protected]"; 지역 변수와 새 객체의 필드만 바뀝니다. 배열 칸의 참조와 기존 이메일은 그대로입니다. 3 · 배열 칸의 참조 교체 members[i] = replacement; 배열의 i번째 칸에 replacement의 참조값을 저장합니다. 마지막 문장은 인덱스 반복문에서 배열 칸을 실제로 교체하는 방법입니다. 반복 변수 member 에 대입하는 것과 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 필드 쓰기 member와 members[i]의 같은 객체에 active=true를 씁니다. |
|
|
| 지역 재대입 member가 새 Member를 가리키도록 바뀝니다. |
|
|
| 배열 칸 대입 members[i]에 replacement 참조를 저장합니다. |
|
|
for-each의 필드 변경과 반복 변수 재대입
세 독립 사례에서 공유 필드 쓰기, foreach 지역 변수 재대입, 배열 칸 대입은 변경 대상이 다릅니다. 같은 참조 역할을 고정해 각 대입 전후를 비교하면 배열 교체 여부가 드러납니다.
for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다.
필드와 반복 변수와 배열 칸은 다른 저장 위치입니다
for-each는 배열 칸의 참조값을 지역 변수로 복사합니다. 무엇을 대입 대상으로 삼는지에 따라 배열에서 관찰되는 결과가 달라집니다.
현재 반복의 member에는 members[i]의 참조값이 복사되어 있고, 해당 칸은 null이 아닙니다.
| 실행하는 문장 | 바꾸는 저장 위치 | 배열에서 다시 읽으면 |
|---|---|---|
member.active = true; | 같이 참조하는 Member 객체의 active 필드 | members[i].active도 true |
| 지역 변수 member의 참조값, 그리고 새 객체의 필드 | 배열 칸의 참조와 기존 이메일은 그대로 |
members[i] = replacement; | 배열의 i번째 칸에 저장된 참조값 | 해당 칸에 replacement의 참조값을 저장 |
- 1 · 공유 객체의 필드 변경
member.active = true;- 같이 참조하는 객체의 active 필드를 씁니다.
members[i].active로 읽어도 true입니다. - 2 · 지역 변수의 참조 재대입
member = new Member();member.email = "[email protected]";- 지역 변수와 새 객체의 필드만 바뀝니다. 배열 칸의 참조와 기존 이메일은 그대로입니다.
- 3 · 배열 칸의 참조 교체
members[i] = replacement;- 배열의 i번째 칸에 replacement의 참조값을 저장합니다.
마지막 문장은 인덱스 반복문에서 배열 칸을 실제로 교체하는 방법입니다. 반복 변수 member에 대입하는 것과 구분합니다.
이 예의 조건
- 세 비교는 독립 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 독립 비교: 필드 쓰기는 member와 배열 칸이 공유한 객체의 active=true를 저장한다. 지역 member 재대입은 새 객체만 가리키고 배열은 원래 객체를 가리킨다. members[i] 대입은 해당 칸에 replacement 참조를 저장한다. 실선은 대입 후의 참조이며 실행 순서가 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 세 독립 비교: 필드 쓰기는 member와 배열 칸이 공유한 객체의 active=true를 저장한다. 지역 member 재대입은 새 객체만 가리키고 배열은 원래 객체를 가리킨다. members[i] 대입은 해당 칸에 replacement 참조를 저장한다. 실선은 대입 후의 참조이며 실행 순서가 아니다. |
| 전체 읽기 정보 | for-each의 필드 변경과 반복 변수 재대입 for-each의 필드 변경과 반복 변수 재대입 for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다. 필드와 반복 변수와 배열 칸은 다른 저장 위치입니다 for-each는 배열 칸의 참조값을 지역 변수로 복사합니다. 무엇을 대입 대상으로 삼는지에 따라 배열에서 관찰되는 결과가 달라집니다. 현재 반복의 member 에는 members[i] 의 참조값이 복사되어 있고, 해당 칸은 null 이 아닙니다. 서로 다른 대입 대상과 배열에서 관찰되는 결과 실행하는 문장 바꾸는 저장 위치 배열에서 다시 읽으면 member.active = true; 같이 참조하는 Member 객체의 active 필드 members[i].active 도 true member = new Member(); member.email = "[email protected]"; 지역 변수 member 의 참조값, 그리고 새 객체의 필드 배열 칸의 참조와 기존 이메일은 그대로 members[i] = replacement; 배열의 i번째 칸에 저장된 참조값 해당 칸에 replacement 의 참조값을 저장 1 · 공유 객체의 필드 변경 member.active = true; 같이 참조하는 객체의 active 필드를 씁니다. members[i].active 로 읽어도 true입니다. 2 · 지역 변수의 참조 재대입 member = new Member(); member.email = "[email protected]"; 지역 변수와 새 객체의 필드만 바뀝니다. 배열 칸의 참조와 기존 이메일은 그대로입니다. 3 · 배열 칸의 참조 교체 members[i] = replacement; 배열의 i번째 칸에 replacement의 참조값을 저장합니다. 마지막 문장은 인덱스 반복문에서 배열 칸을 실제로 교체하는 방법입니다. 반복 변수 member 에 대입하는 것과 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 필드 쓰기 member와 members[i]의 같은 객체에 active=true를 씁니다. |
|
|
| 지역 재대입 member가 새 Member를 가리키도록 바뀝니다. |
|
|
| 배열 칸 대입 members[i]에 replacement 참조를 저장합니다. |
|
|
반복 변수에 새 값을 대입하는 것은 대부분 실수입니다. 이런 실수를 컴파일러가 잡아 주기를 바란다면 for (final Member member : members)처럼 반복 변수에 final을 붙일 수 있습니다. 그러면 member = new Member()는 컴파일 오류가 되지만, member.active = true처럼 필드를 바꾸는 것은 여전히 허용됩니다. final은 변수에 다른 참조값을 넣지 못하게 할 뿐, 그 변수가 가리키는 객체까지 바꾸지 못하게 하지는 않기 때문입니다. 이 차이는 6장의 final 참조와 객체 변경에서 자세히 다룹니다.
배열 길이와 실제 회원 수 구분하기
회원을 최대 다섯 명까지 받는 프로그램이라고 해서 처음부터 회원 객체 다섯 개를 만들 필요는 없습니다. 3장의 배열 기반 회원가입에서 했던 것처럼, 길이 5인 배열을 만들어 두고 지금까지 채운 칸의 수를 size 변수로 따로 관리합니다.
public class FixedCapacityRegistry {
public static void main(String[] args) {
Member[] members = new Member[5];
int size = 0;
Member member = new Member();
member.email = "[email protected]";
member.age = 30;
members[size] = member;
size++;
for (int i = 0; i < size; i++) {
System.out.println(members[i].email);
}
}
}새 회원은 언제나 members[size], 즉 지금까지 채운 칸 바로 다음 칸에 저장되고, 저장한 뒤에 size를 1 늘립니다. 그래서 0번부터 size - 1번까지가 실제 회원이 있는 칸이 됩니다. 아래 그림은 첫 회원을 저장한 직후의 상태로, 배열 길이는 5이지만 회원이 있는 칸은 0번 하나뿐입니다.
FixedCapacityRegistry에서 첫 회원을 저장하고 size를 증가시킨 직후 배열 길이는 5이고 size는 1입니다. 사용 구간은 0번 칸뿐이며 1부터 4번 칸은 null입니다.
용량 다섯 칸 중 회원이 있는 칸은 하나입니다
첫 회원 저장 후 length는 5, size는 1입니다. 회원 순회는 size 앞까지만 읽고, 다음 추가는 size 위치에 저장합니다.
FixedCapacityRegistry의 size++ 직후 · members.length = 5, size = 1
- 0번 칸 · 사용 중
member의 참조값이 저장되어 있습니다.- 1, 2, 3, 4번 칸 · 미사용
- 네 칸 모두
null이며 연결된 Member 객체가 없습니다.
- 회원 순회는
0 <= i && i < size, 즉 0번 칸만 읽습니다. 그 회원의 이메일은[email protected]입니다. - 5번 칸은 존재하지 않습니다. 추가 전
size >= members.length이면 중단하고, 여유가 있을 때만members[size] = member;뒤size++;를 실행합니다.
members.length는 저장할 수 있는 최대 칸 수(용량)이고, size는 실제로 채운 회원 수입니다. 둘을 혼동하면 두 종류의 오류가 생깁니다. 위 프로그램의 반복 조건을 i < members.length로 바꾸면, 첫 줄은 정상으로 출력되지만 1번 칸에서 NullPointerException이 납니다. 1번부터 4번 칸은 아직 null인데 .email로 따라가려 했기 때문입니다. 반대로 배열이 이미 가득 찼는데 members[size]에 저장하면, size가 5일 때 없는 칸에 쓰려다 ArrayIndexOutOfBoundsException이 납니다. 그래서 추가하기 전에 남은 칸이 있는지 먼저 확인합니다.
if (size >= members.length) {
System.out.println("더 이상 회원을 추가할 수 없습니다.");
} else {
members[size] = member;
size++;
}용량과 크기를 구분하는 이 구조는 14장에서 ArrayList 같은 동적 배열이 내부에서 어떻게 동작하는지 배울 때 다시 나옵니다. 지금은 고정 길이 배열에서 이 둘을 섞어 쓰면 어떤 오류가 생기는지 기억해 두면 충분합니다.
정리
new Member[3]은null로 채워진 참조 칸 세 개짜리 배열을 만들 뿐이고, 회원 객체는 따로 만들어 칸에 대입해야 합니다.- 배열 칸에 대입하거나 배열 리터럴에 변수를 나열하면 참조값이 복사되므로, 배열 칸과 원래 변수가 같은 객체를 가리킵니다.
members[i].email은 배열 칸 읽기와 참조 따라가기의 두 단계입니다. 배열 참조가null인 경우, 인덱스가 범위를 벗어난 경우, 칸이null인 경우를 구분해서 원인을 찾습니다.- 향상된
for문의 반복 변수에는 칸의 참조값이 복사됩니다. 필드 변경은 배열에도 보이지만, 반복 변수에 새 객체를 대입해도 배열 칸은 바뀌지 않습니다. members.length는 용량,size는 실제 회원 수입니다. 회원을 순회할 때는size까지만 읽습니다.
연습 문제
길이 4인 Member[]에 다음 세 회원을 순서대로 저장하세요. 마지막 칸은 비워 둡니다.
| 이메일 | 나이 | 활성 여부 |
|---|---|---|
[email protected] | 20 | 활성 |
[email protected] | 35 | 비활성 |
[email protected] | 25 | 비활성 |
size까지만 순회하면서 비활성 회원의 이메일과 나이를 출력하고, 비활성 회원의 나이 합계를 구하세요. 실행하기 전에 어떤 회원이 출력되고 합계가 얼마일지 먼저 적어 보세요.- 반복 조건을
i < members.length로 바꾸면 어떤 일이 생기는지 설명하세요. - 향상된
for문으로 같은 일을 하려면 비어 있는 칸을 어떻게 처리해야 할지 적으세요.
해설 보기
회원 세 명의 필드를 채우는 코드가 반복되므로, 해설에서는 create라는 보조 메서드를 만들어 객체 생성과 필드 대입을 한곳에 모았습니다. 메서드가 객체의 참조값을 돌려주는 원리는 같은 장의 메서드 호출과 값 전달에서 자세히 다룹니다.
public class PendingMembers {
public static void main(String[] args) {
Member[] members = new Member[4];
int size = 0;
members[size++] = create("[email protected]", 20, true);
members[size++] = create("[email protected]", 35, false);
members[size++] = create("[email protected]", 25, false);
int inactiveAgeSum = 0;
for (int i = 0; i < size; i++) {
Member member = members[i];
if (!member.active) {
System.out.println(member.email + " / " + member.age + "세");
inactiveAgeSum += member.age;
}
}
System.out.println("비활성 합계=" + inactiveAgeSum + "세");
}
static Member create(String email, int age, boolean active) {
Member member = new Member();
member.email = email;
member.age = age;
member.active = active;
return member;
}
}[email protected] / 35세
[email protected] / 25세
비활성 합계=60세members[size++] = ...에서 저장 위치는 증가하기 전의 size이고, size 자체는 1 늘어납니다. 2장에서 배운 것처럼 후위 증가 size++는 변수를 1 늘리면서 식의 결과로는 증가하기 전의 값을 내놓기 때문입니다. 세 번 저장하고 나면 size는 3이 되고, 반복문은 0번부터 2번 칸까지만 읽습니다. 그 가운데 active가 false인 [email protected]과 [email protected]만 출력되고, 합계는 35 + 25 = 60입니다.
2번에서 반복 조건을 i < members.length로 바꾸면 3번 칸까지 읽게 됩니다. 3번 칸은 null이므로 member.active를 읽는 순간 NullPointerException이 납니다. 예외가 나기 전까지의 두 줄은 정상으로 출력되지만, 합계 줄은 출력되지 않습니다.
3번처럼 향상된 for문을 쓰면 size를 쓸 수 없어 배열 전체를 돌게 되므로, 반복문 첫머리에 if (member == null) continue;를 두어 빈 칸을 건너뛰어야 합니다. 이렇게 해도 동작은 하지만, 실제 회원 수를 size로 알고 있다면 size까지만 순회하는 쪽이 '여기까지가 회원'이라는 뜻을 더 정확하게 드러냅니다.
다음 글
기본형과 참조형 복사에서는 지금까지 여러 번 본 '참조값이 복사된다'는 현상을 대입 규칙 하나로 정리합니다. 숫자를 복사할 때와 참조값을 복사할 때 결과가 왜 다르게 보이는지, 그리고 객체를 정말 독립적으로 복사하려면 어떻게 해야 하는지 살펴봅니다.