안동민 개발노트

본문 시작

객체 배열과 참조 경로

객체 배열의 칸에 저장되는 참조와 반복 변수가 복사하는 값을 단계별로 추적합니다.

회원 정보가 여러 개가 되면 entry1, entry2, entry3처럼 변수 이름에 번호를 붙이는 방식도 곧 한계에 닿습니다.

반복문을 쓰려면 회원들을 하나의 순서로 보관해야 합니다.

이때 Member[]를 사용하지만, 배열 안에 객체가 통째로 들어간다고 생각하면 이후의 null과 공유 참조를 설명하기 어렵습니다.

객체 배열은 두 종류의 객체를 함께 사용합니다.

배열 객체가 먼저 있고, 각 배열 칸에는 Member 인스턴스에 도달하는 참조값이 저장됩니다.

배열 생성과 기록 생성을 따로 해야 하는 이유가 여기에 있습니다.


객체 배열의 초기 상태

다음 코드는 길이가 3인 Member 배열을 만듭니다.

Member[] members = new Member[3];

이 한 줄이 만든 것은 참조를 담을 칸 세 개뿐입니다.

각 칸은 참조형의 기본값인 null로 시작합니다.

Member 인스턴스 세 개를 자동으로 만들지는 않습니다.

src/members/ch04/v5/EmptyObjectArray.java
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]은 같은 인스턴스에 도달합니다.


객체 배열 초기화

인스턴스를 먼저 만든 뒤 배열을 초기화할 수도 있습니다.

src/members/ch04/v5/MemberArrayMain.java
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에는 접근이 두 번 들어 있습니다.

  1. members[i]가 배열의 i번째 칸에서 Member 참조를 읽습니다.
  2. .email이 그 참조를 따라 인스턴스의 이메일 필드를 읽습니다.
배열 슬롯에서 Member 필드까지의 접근과 실패 경계

MemberArrayMain의 초기화 직후 members 배열의 두 칸은 각각 별도 Member 객체를 참조합니다. 배열 참조, 인덱스, 선택한 칸의 참조를 구분하면 필드 접근의 세 실패 경계를 찾을 수 있습니다.

MemberArrayMain의 배열 초기화 직후 · members[0].email 읽기

members 배열의 두 슬롯에서 별도 Member 객체로 가는 참조 members는 길이 2의 배열을 참조합니다. 0번 칸은 김 회원 객체를, 1번 칸은 이 회원 객체를 참조하며 email 필드는 배열 칸이 아닌 각 객체 안에 있습니다. members 배열의 참조값 Member[] · length=2 [0] 첫 Member 참조 [1] 둘째 Member 참조 첫 Member email: kim@example.com age: 35 둘째 Member email: lee@example.com age: 25
  1. members는 길이 2의 배열을 참조합니다.
  2. members[0]은 첫 Member의 참조값입니다. 그 객체의 email은 kim@example.com, age는 35입니다.
  3. members[1]은 둘째 Member의 참조값입니다. 그 객체의 email은 lee@example.com, age는 25입니다.

members[0]과 firstMember, members[1]과 secondMember는 각각 같은 객체에 도달합니다. 배열 칸에는 객체 자체가 아니라 참조값이 들어 있습니다.

new Member[3]만 실행한 초기 상태는 [null, null, null]입니다. 위 그림은 그 초기 상태가 아니라, 두 객체를 만든 뒤 배열 리터럴로 참조를 넣은 별도 예제입니다.

members[i].email에서 구분할 세 실패 경계
먼저 확인할 값실패 조건발생하는 예외
members배열 참조가 nullNullPointerException
i배열이 있을 때 i < 0 또는 i >= members.lengthArrayIndexOutOfBoundsException
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의 필드 변경과 반복 변수 재대입

for-each의 member에는 현재 배열 칸의 참조값이 복사됩니다. 객체 필드 쓰기, 지역 변수 재대입, 배열 칸 대입은 서로 다른 저장 위치를 바꿉니다.

현재 반복의 member에는 members[i]의 참조값이 복사되어 있고, 해당 칸은 null이 아닙니다.

서로 다른 대입 대상과 배열에서 관찰되는 결과
실행하는 문장바꾸는 저장 위치배열에서 다시 읽으면
member.active = true;같이 참조하는 Member 객체의 active 필드members[i].active도 true

member = new Member();

member.email = "replacement@example.com";

지역 변수 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인 배열을 만들고 현재 사용한 칸의 수를 별도 변수로 관리할 수 있습니다.

src/members/ch04/v5/FixedCapacityRegistry.java
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가 내부에서 용량과 크기를 구분하는 이유를 이해할 준비가 됩니다.

지금은 고정 배열의 한계를 분명히 관찰하는 데 집중합니다.

고정 배열의 용량 5와 사용한 회원 수 1

FixedCapacityRegistry에서 첫 회원을 저장하고 size를 증가시킨 직후 배열 길이는 5이고 size는 1입니다. 사용 구간은 0번 칸뿐이며 1부터 4번 칸은 null입니다.

FixedCapacityRegistry의 size++ 직후 · members.length = 5, size = 1

배열 다섯 칸 중 0번만 사용한 상태 0번 칸에는 첫 회원의 참조가 있고 1, 2, 3, 4번 칸은 null입니다. size가 1이므로 회원 순회는 0 이상 1 미만이고, 배열의 유효 인덱스 범위는 0 이상 5 미만입니다. members length = 5 [0] member 참조 [1] null [2] null [3] null [4] null 사용 중 1칸 미사용 4칸 · 객체 참조 없음
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입니다.

다음을 구현하세요.

  1. size까지만 순회합니다.
  2. active == false인 회원의 이메일과 나이를 출력합니다.
  3. 총 비활성 회원 나이를 합산합니다.
  4. members.length까지 무조건 순회했을 때 생길 수 있는 실패를 설명합니다.
  5. for-each를 사용한다면 null 슬롯을 어떻게 처리할지 적습니다.
해설 보기
src/members/ch04/exercise/PendingMembers.java
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 범위만 순회하는 쪽이 상태를 더 정확하게 표현합니다.


객체 배열은 객체를 통째로 담지 않고 참조값을 담습니다.

배열 칸과 반복 변수에 같은 참조가 복사될 수 있다는 사실을 확인했습니다.

다음 절에서는 숫자 복사와 참조값 복사를 같은 대입 규칙으로 설명하고, 왜 공유된 필드 변경이 여러 경로에서 보이는지 파고듭니다.