배열과 인덱스
같은 타입의 값 여러 개를 배열에 담아 인덱스로 읽고 쓰는 법, length와 마지막 인덱스의 차이, 배열 대입과 복사의 차이를 배우고 회원 정보를 담는 고정 크기 저장소를 만듭니다.
앞 글의 회원가입 프로그램은 회원 수와 나이 합계만 기억했습니다. 회원 한 명 한 명의 이메일과 나이를 기억하려면 어떻게 해야 할까요? 지금까지 배운 방법으로는 email1, email2, email3처럼 회원마다 변수를 하나씩 만드는 수밖에 없습니다. 그러면 회원이 늘어날 때마다 변수를 추가해야 하고, 모든 회원을 출력하거나 나이를 합하는 코드도 변수 수만큼 길어집니다. 회원이 100명이라면 같은 모양의 줄을 100번 써야 합니다.
배열(array)은 같은 타입의 값 여러 개를 하나의 이름 아래 순서대로 담는 저장 공간입니다. 각 칸은 0부터 시작하는 번호로 구분하며, 이 번호를 인덱스(index)라고 부릅니다. 인덱스는 변수에 담아 바꿀 수 있는 숫자이므로, 반복문의 변수를 인덱스로 쓰면 회원이 몇 명이든 같은 코드 몇 줄로 모든 칸을 처리할 수 있습니다. 이 글에서는 배열을 만들고 읽고 쓰는 방법, 가장 흔한 실수인 인덱스 범위 오류, 배열을 다른 변수에 대입할 때 일어나는 일을 차례로 살펴보고, 회원 정보를 담는 고정 크기 저장소를 만듭니다.
배열을 만들고 칸에 값 넣기
먼저 나이 세 개를 담는 배열을 만들고, 칸에 값을 넣었다가 꺼내 보겠습니다. 세 칸에 차례로 값을 넣는데, 셋째 칸에는 앞의 두 칸을 읽어 더한 값을 넣습니다. 칸 번호가 1이 아니라 0부터 시작한다는 점을 눈여겨보세요.
public final class ArrayStoreLoad {
public static void main(String[] args) {
int[] age = new int[3];
age[0] = 20;
age[1] = 30;
age[2] = age[0] + age[1];
System.out.println(age[0]);
System.out.println(age[1]);
System.out.println(age[2]);
System.out.println("length=" + age.length);
}
}20
30
50
length=3int[]는 'int 값을 담는 배열'이라는 타입입니다. 따라서 int[] age는 int 배열을 가리킬 변수 age를 선언합니다. new int[3]은 int 값 세 개를 담을 수 있는 새 배열을 만듭니다. 대괄호 안의 3이 배열의 길이, 즉 칸의 수입니다.
배열의 각 칸은 배열이름[인덱스] 형태로 씁니다. age[0] = 20은 0번 칸에 20을 넣고, age[2] = age[0] + age[1]은 0번과 1번 칸의 값을 읽어 더한 결과를 2번 칸에 넣습니다. 이렇게 배열의 한 칸은 보통의 int 변수처럼 값을 넣을 수도, 꺼내 쓸 수도 있습니다. 배열의 칸 하나하나를 원소(element)라고 부릅니다.
age.length는 배열의 길이를 알려 줍니다. 문자열의 길이는 length()라는 메서드로 구했지만, 배열의 길이는 괄호 없이 length라고 씁니다. 배열의 길이는 만들 때 정해지고 그 뒤로는 바뀌지 않으며, age.length = 5처럼 바꾸려고 하면 컴파일 오류가 납니다. 칸이 더 필요해지면 더 큰 배열을 새로 만들어 값을 옮겨야 하는데, 그 방법은 뒤에서 Arrays.copyOf로 살펴봅니다.
새 배열의 칸에는 기본값이 들어 있다
값을 넣지 않은 칸을 읽으면 무엇이 나올까요? 다음 코드는 배열을 만든 뒤 어떤 칸에도 값을 대입하지 않고 바로 세 칸을 출력합니다. 이 코드가 컴파일되는지부터 예상해 보세요.
public final class ArrayCreation {
public static void main(String[] args) {
int[] age;
age = new int[3];
System.out.println(age[0]);
System.out.println(age[1]);
System.out.println(age[2]);
}
}0
0
0이번에는 선언과 생성을 두 줄로 나눴습니다. int[] age;는 변수만 만들고, 다음 줄의 age = new int[3];이 배열을 만들어 그 변수에 연결합니다. 값을 하나도 넣지 않았는데도 세 칸 모두 0이 출력됩니다. 지역 변수는 값을 대입하기 전에 읽으면 컴파일 오류가 나지만, 배열의 칸은 배열을 만드는 순간 타입별 기본값으로 채워지기 때문입니다.
| 원소 타입 | 기본값 |
|---|---|
int, long 같은 정수 타입 | 0 |
double, float | 0.0 |
boolean | false |
char | 코드값이 0인 문자 |
String 같은 참조 타입 | null |
null은 '아무 객체도 가리키지 않는다'는 뜻의 특별한 값입니다. 그래서 new String[3]으로 만든 이메일 배열의 칸은 모두 null입니다.
기본값이 편리해 보이지만, 회원 저장소에서는 주의해야 합니다. 나이 배열의 칸에 0이 들어 있다고 해서 0세 회원이 저장됐다는 뜻은 아닙니다. 아직 아무도 저장하지 않은 칸일 뿐입니다. 그래서 배열을 저장소로 쓸 때는 실제로 몇 칸을 채웠는지를 따로 세어야 합니다. 이 글 마지막 절에서 그 방법을 다룹니다.
값을 바로 넣어 만드는 배열 초기화
넣을 값을 처음부터 알고 있다면 중괄호 안에 값을 나열해 배열을 만들 수 있습니다. 이 형태를 배열 초기화(array initializer)라고 하며, 배열의 길이는 나열한 값의 수로 정해집니다.
int[] age = {20, 30, 40};
String[] emails = {"[email protected]", "[email protected]", "[email protected]"};age는 길이 3의 int 배열, emails는 길이 3의 문자열 배열입니다. 다만 이 짧은 형태는 변수를 선언하는 문장에서만 쓸 수 있습니다. 이미 선언한 변수에 나중에 새 배열을 대입할 때는 age = new int[] {20, 30, 40};처럼 new int[]를 함께 써야 하고, age = {20, 30, 40};이라고 쓰면 컴파일 오류가 납니다.
length는 칸의 수이고, 마지막 인덱스는 length - 1
인덱스가 0부터 시작하기 때문에, 길이가 3인 배열의 인덱스는 0, 1, 2입니다. 마지막 인덱스는 길이보다 하나 작습니다. 이 차이를 놓치면 다음과 같은 일이 생깁니다.
public final class LengthAsIndex {
public static void main(String[] args) {
int[] age = {20, 30, 40};
System.out.println("length=" + age.length);
System.out.println(age[age.length]);
}
}length=3
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3age.length는 3이므로 age[age.length]는 age[3], 즉 네 번째 칸을 읽으려는 코드입니다. 이 배열에는 칸이 세 개뿐이라서 ArrayIndexOutOfBoundsException이 발생합니다. 메시지의 Index 3 out of bounds for length 3은 '길이 3인 배열에서 인덱스 3은 범위 밖'이라는 뜻으로, 잘못 쓴 인덱스와 배열의 길이를 함께 알려 줍니다. 같은 예외는 age[-1]처럼 음수 인덱스를 써도 발생합니다. 자바는 배열 칸에 접근할 때마다 인덱스가 0 이상이고 길이보다 작은지 확인하고, 범위를 벗어나면 엉뚱한 메모리를 읽는 대신 즉시 예외를 던집니다.
배열의 모든 칸을 차례로 처리하는 반복문에서도 같은 원리가 적용됩니다. 반복 조건은 index < age.length로 씁니다. 그러면 index가 0, 1, 2일 때는 칸을 읽고, 3이 되는 순간 조건이 거짓이 되어 age[3]을 읽기 전에 반복이 끝납니다. 조건을 index <= age.length로 쓰면 index가 3일 때도 반복 본문이 실행되어 위와 똑같은 예외가 발생합니다.
length는 개수이고 읽기는 index가 length보다 작을 때만 한다
인덱스 0·1·2를 읽고 3에서 index<length 검사로 종료하는 흐름과 검사 없는 실패를 대비합니다. 조건이 잘못된 원소 접근을 막는 전환을 드러낼 수 있습니다.
age의 길이 3은 원소 수이며 정상 순회는 index 3에서 읽기를 멈춘다. age[length]를 직접 읽으면 범위 예외가 발생한다.
ARRAY · BOUNDARIES
length는 개수, 마지막 인덱스는 length−1
길이3의 배열은 인덱스0·1·2에서 읽습니다. 0부터 시작하는 정상 순회는3에서 멈추지만, age[3]을 직접 읽으면 범위 예외가 납니다.
- 실제 배열
age={20,30,40},length=3입니다.index 0→20,1→30,2→40이며 마지막 유효 인덱스는 2입니다.- 정상 순회
index < age.length를 먼저 검사합니다. 0·1·2에서만 읽고,index=3이면 false여서 읽지 않고 끝냅니다.- 검사 없는 접근
age[age.length]는age[3]이므로ArrayIndexOutOfBoundsException입니다.
new int[0]에는 유효 인덱스가 없습니다. index < empty.length 반복은 0회지만 empty[0]을 읽으면 예외입니다.
이 예의 조건
- age={20,30,40}, length=3이며 유효 인덱스는 0, 1, 2입니다.
- 정상 순회의 조건 검사와 원 실패 예제의 무조건 age[age.length] 접근을 같은 index=3 경계에서 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 22 | index = 0 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | length는 개수이고 읽기는 index가 length보다 작을 때만 한다 길이 3 배열의 유효 인덱스와 검사 없는 범위 밖 접근 age의 길이 3은 원소 수이며 정상 순회는 index 3에서 읽기를 멈춘다. age[length]를 직접 읽으면 범위 예외가 발생한다. ARRAY · BOUNDARIES length는 개수, 마지막 인덱스는 length−1 길이 3의 배열은 인덱스 0·1·2에서 읽습니다. 0부터 시작하는 정상 순회는 3에서 멈추지만, age[3]을 직접 읽으면 범위 예외가 납니다. 실제 배열 age={20,30,40} , length=3 입니다. index 0→20 , 1→30 , 2→40 이며 마지막 유효 인덱스는 2입니다. 정상 순회 index < age.length 를 먼저 검사합니다. 0·1·2에서만 읽고, index=3 이면 false여서 읽지 않고 끝냅니다. 검사 없는 접근 age[age.length] 는 age[3] 이므로 ArrayIndexOutOfBoundsException 입니다. new int[0] 에는 유효 인덱스가 없습니다. index < empty.length 반복은 0회지만 empty[0] 을 읽으면 예외입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 다음 인덱스 후보 정상 순회는 유효 위치를 읽은 뒤 3을 검사하고 실패 예제는 length=3을 직접 인덱스로 씁니다. |
|
|
| 읽기 허용 여부 순회는 3<3을 검사하지만 실패 예제는 사전 조건 없이 3번 위치를 읽으려 합니다. |
|
|
| 각 경로의 종료 조건 있는 순회는 정상 종료하고 무조건 접근은 예외로 끝납니다. |
|
|
length는 개수이고 읽기는 index가 length보다 작을 때만 한다
인덱스 0·1·2를 읽고 3에서 index<length 검사로 종료하는 흐름과 검사 없는 실패를 대비합니다. 조건이 잘못된 원소 접근을 막는 전환을 드러낼 수 있습니다.
age의 길이 3은 원소 수이며 정상 순회는 index 3에서 읽기를 멈춘다. age[length]를 직접 읽으면 범위 예외가 발생한다.
ARRAY · BOUNDARIES
length는 개수, 마지막 인덱스는 length−1
길이3의 배열은 인덱스0·1·2에서 읽습니다. 0부터 시작하는 정상 순회는3에서 멈추지만, age[3]을 직접 읽으면 범위 예외가 납니다.
- 실제 배열
age={20,30,40},length=3입니다.index 0→20,1→30,2→40이며 마지막 유효 인덱스는 2입니다.- 정상 순회
index < age.length를 먼저 검사합니다. 0·1·2에서만 읽고,index=3이면 false여서 읽지 않고 끝냅니다.- 검사 없는 접근
age[age.length]는age[3]이므로ArrayIndexOutOfBoundsException입니다.
new int[0]에는 유효 인덱스가 없습니다. index < empty.length 반복은 0회지만 empty[0]을 읽으면 예외입니다.
이 예의 조건
- age={20,30,40}, length=3이며 유효 인덱스는 0, 1, 2입니다.
- 정상 순회의 조건 검사와 원 실패 예제의 무조건 age[age.length] 접근을 같은 index=3 경계에서 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 22 | index = 0 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | length는 개수이고 읽기는 index가 length보다 작을 때만 한다 길이 3 배열의 유효 인덱스와 검사 없는 범위 밖 접근 age의 길이 3은 원소 수이며 정상 순회는 index 3에서 읽기를 멈춘다. age[length]를 직접 읽으면 범위 예외가 발생한다. ARRAY · BOUNDARIES length는 개수, 마지막 인덱스는 length−1 길이 3의 배열은 인덱스 0·1·2에서 읽습니다. 0부터 시작하는 정상 순회는 3에서 멈추지만, age[3]을 직접 읽으면 범위 예외가 납니다. 실제 배열 age={20,30,40} , length=3 입니다. index 0→20 , 1→30 , 2→40 이며 마지막 유효 인덱스는 2입니다. 정상 순회 index < age.length 를 먼저 검사합니다. 0·1·2에서만 읽고, index=3 이면 false여서 읽지 않고 끝냅니다. 검사 없는 접근 age[age.length] 는 age[3] 이므로 ArrayIndexOutOfBoundsException 입니다. new int[0] 에는 유효 인덱스가 없습니다. index < empty.length 반복은 0회지만 empty[0] 을 읽으면 예외입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 다음 인덱스 후보 정상 순회는 유효 위치를 읽은 뒤 3을 검사하고 실패 예제는 length=3을 직접 인덱스로 씁니다. |
|
|
| 읽기 허용 여부 순회는 3<3을 검사하지만 실패 예제는 사전 조건 없이 3번 위치를 읽으려 합니다. |
|
|
| 각 경로의 종료 조건 있는 순회는 정상 종료하고 무조건 접근은 예외로 끝납니다. |
|
|
더 깊이: 인덱스가 0부터 시작하는 이유
인덱스는 '몇 번째'라기보다 '배열의 시작에서 몇 칸 떨어져 있는가'를 나타내는 값으로 보면 자연스럽습니다. 배열의 원소는 메모리에 같은 크기로 나란히 놓이므로, i번 원소의 위치는 '배열이 시작하는 위치 + i × 원소 하나의 크기'로 곧바로 계산됩니다. 첫 원소는 시작 위치 그 자체에 있으니 떨어진 칸 수가 0이고, 그래서 인덱스도 0입니다. 자바는 이 계산 방식을 C 계열 언어에서 물려받았습니다.
다만 C는 범위를 벗어난 인덱스를 검사하지 않아 배열 바깥의 메모리를 그대로 읽거나 덮어쓸 수 있는 반면, 자바는 언어 명세가 모든 배열 접근에서 범위 검사를 하도록 정해 두었습니다. C++의 내장 배열도 C와 같아서, 범위 밖 접근이 어떤 결과를 내는지는 1차원 배열에서 볼 수 있습니다. 자바의 범위 검사에 드는 비용은 작고, JVM의 JIT 컴파일러는 index < age.length 조건으로 도는 반복처럼 범위를 벗어날 수 없음이 증명되는 경우 검사를 생략하기도 합니다.
반복문으로 모든 칸 처리하기
배열과 for 반복문을 함께 쓰면, 배열의 길이와 상관없이 같은 코드로 모든 칸을 처리할 수 있습니다. 다음 예제는 나이 배열의 각 칸을 출력하면서 합계를 구합니다.
public final class ArrayTraversal {
public static void main(String[] args) {
int[] age = {20, 30, 40};
int total = 0;
for (int index = 0; index < age.length; index++) {
total += age[index];
System.out.println(index + "=" + age[index]);
}
System.out.println("total=" + total);
}
}0=20
1=30
2=40
total=90반복이 진행되는 동안 index와 total이 어떻게 변하는지 보면 다음과 같습니다.
| 회차 | index | index < age.length | age[index] | 더한 뒤 total |
|---|---|---|---|---|
| 1 | 0 | true | 20 | 20 |
| 2 | 1 | true | 30 | 50 |
| 3 | 2 | true | 40 | 90 |
| 종료 | 3 | false | 읽지 않음 | 90 |
배열에 원소를 하나 더 추가해도 반복문은 고칠 필요가 없습니다. age.length가 알아서 4가 되고 반복도 한 번 더 돌기 때문입니다. 변수를 회원마다 따로 만들던 방식과 비교하면, 배열의 진짜 장점은 저장보다 이렇게 같은 처리를 반복할 수 있다는 데 있습니다.
인덱스로는 값을 읽을 뿐 아니라 바꿀 수도 있습니다. age[1] = 35는 1번 칸, 즉 두 번째 원소만 35로 바꾸고 나머지 칸은 그대로 둡니다.
길이가 0인 배열도 만들 수 있습니다. int[] empty = new int[0];은 칸이 하나도 없는 배열입니다. 이 배열에서 index < empty.length를 조건으로 하는 반복문은 처음부터 조건이 거짓이므로 한 번도 돌지 않고, 합계는 0으로 남습니다. 반면 칸이 없으니 empty[0]을 읽으면 ArrayIndexOutOfBoundsException이 발생합니다. 첫 번째 원소를 바로 읽는 코드를 쓸 때는 배열이 비어 있을 가능성을 먼저 생각해야 합니다.
Arrays.toString으로 배열 내용 한 번에 출력하기
배열 변수를 System.out.println(age)처럼 그대로 출력하면 원소 대신 [I@659e0bfd 같은 알아보기 힘든 문자열이 나옵니다. 앞의 [I는 'int 배열'이라는 표시이고, @ 뒤의 16진수는 원소와는 관계없는 해시 코드라서 실행할 때마다 달라질 수 있습니다. 원소를 확인하려면 java.util.Arrays의 Arrays.toString(age)을 씁니다. 이 메서드는 [20, 30, 40]처럼 원소를 대괄호 안에 쉼표로 나열한 문자열을 돌려주며, 다음 절부터 배열의 내용을 확인할 때 사용합니다.
배열 변수에 다른 배열 변수를 대입하면
int 변수끼리 대입하면 값이 복사됩니다. int b = a; 다음에 b를 바꿔도 a는 그대로입니다. 그렇다면 배열 변수끼리 대입하면 어떻게 될까요?
import java.util.Arrays;
public final class ArrayAliasing {
public static void main(String[] args) {
int[] original = {20, 30, 40};
int[] alias = original;
alias[1] = 99;
System.out.println("original=" + Arrays.toString(original));
System.out.println("alias=" + Arrays.toString(alias));
System.out.println("same=" + (original == alias));
}
}original=[20, 99, 40]
alias=[20, 99, 40]
same=truealias의 1번 칸만 바꿨는데 original의 1번 칸도 99가 됐습니다. 배열 변수에는 원소들이 직접 들어 있지 않기 때문입니다. 배열 자체는 메모리의 다른 곳에 만들어지고, 변수에는 그 배열을 찾아가는 값인 참조(reference)가 들어 있습니다. 변수 선언과 대입에서 String 변수를 대입하면 참조값이 복사된다고 했던 것과 같습니다. 따라서 int[] alias = original;은 원소 세 개를 복사하지 않고 참조 하나만 복사합니다. 그 결과 두 변수가 같은 배열 하나를 가리키게 되고, 어느 변수를 통해 바꾸든 바뀌는 것은 그 하나의 배열입니다.
마지막 줄의 original == alias는 이 사실을 직접 확인합니다. 배열 변수에 ==를 쓰면 원소를 비교하지 않고 두 변수가 같은 배열을 가리키는지를 비교하며, 여기서는 같은 배열이므로 true입니다.
독립된 복사본 만들기
원본은 그대로 두고 따로 고칠 수 있는 배열이 필요하면, 새 배열을 만들고 원소를 하나씩 옮겨야 합니다. 반복문으로 직접 옮길 수도 있지만, Arrays.copyOf를 쓰면 한 줄로 됩니다.
import java.util.Arrays;
public final class IndependentArrayCopy {
public static void main(String[] args) {
int[] original = {20, 30, 40};
int[] copied = Arrays.copyOf(original, original.length);
copied[1] = 99;
System.out.println("original=" + Arrays.toString(original));
System.out.println("copied=" + Arrays.toString(copied));
System.out.println("same=" + (original == copied));
}
}original=[20, 30, 40]
copied=[20, 99, 40]
same=falseArrays.copyOf(original, original.length)는 길이가 original.length인 새 배열을 만들고 original의 원소를 차례로 옮겨 담은 뒤, 그 새 배열의 참조를 돌려줍니다. copied는 처음부터 다른 배열을 가리키므로 copied[1]을 바꿔도 original은 그대로이고, == 비교도 false가 됩니다. 두 배열의 원소가 같은지를 비교하고 싶다면 == 대신 Arrays.equals(original, copied)를 씁니다.
참조 대입은 같은 배열을 공유하고 복사는 새 배열을 만든다
alias는 같은 배열을 가리키지만 copyOf는 새 배열을 만든 뒤 각 예제에서 index 1을 바꿉니다. 두 독립 실행을 대응 단계로 유지하여 참조와 변경 범위를 보여 줄 수 있습니다.
참조 대입 뒤에는 두 변수가 하나의 배열을 보지만 Arrays.copyOf를 사용한 별도 실행에서는 서로 다른 배열을 본다.
ARRAY · BOUNDARIES
참조 대입은 공유, 배열 복사는 독립
별도 실행 A와 B를 비교합니다. alias는 원본 배열을 함께 가리키고, Arrays.copyOf는 새 배열을 만들어 복사본의 변경을 분리합니다.
- 실행 A · ArrayAliasing
alias=original이므로 두 변수가 같은 배열을 참조합니다.alias[1]=99뒤 둘 다[20,99,40]을 읽고same=true입니다.- 실행 B · IndependentArrayCopy
- 다시
original=[20,30,40]으로 시작합니다.Arrays.copyOf(original,original.length)는 새 배열을 만듭니다.copied[1]=99뒤 원본은[20,30,40], 복사는[20,99,40],same=false입니다.
화살표는 변수의 참조입니다. 두 패널은 독립 실행이며 B는 A의 변경된 원본을 이어받지 않습니다. 이 예제는 int[] 원소 복사입니다.
이 예의 조건
- ArrayAliasing과 IndependentArrayCopy는 독립 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 참조 대입은 같은 배열을 공유하고 복사는 새 배열을 만든다 ArrayAliasing과 IndependentArrayCopy의 서로 독립된 실행에서의 참조 관계 참조 대입 뒤에는 두 변수가 하나의 배열을 보지만 Arrays.copyOf를 사용한 별도 실행에서는 서로 다른 배열을 본다. ARRAY · BOUNDARIES 참조 대입은 공유, 배열 복사는 독립 별도 실행 A와 B를 비교합니다. alias는 원본 배열을 함께 가리키고, Arrays.copyOf는 새 배열을 만들어 복사본의 변경을 분리합니다. 실행 A · ArrayAliasing alias=original 이므로 두 변수가 같은 배열을 참조합니다. alias[1]=99 뒤 둘 다 [20,99,40] 을 읽고 same=true 입니다. 실행 B · IndependentArrayCopy 다시 original=[20,30,40] 으로 시작합니다. Arrays.copyOf(original,original.length) 는 새 배열을 만듭니다. copied[1]=99 뒤 원본은 [20,30,40] , 복사는 [20,99,40] , same=false 입니다. 화살표는 변수의 참조입니다. 두 패널은 독립 실행이며 B는 A의 변경된 원본을 이어받지 않습니다. 이 예제는 int[] 원소 복사입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 배열 두 실행의 원본은 20,30,40입니다. |
|
|
| 인덱스 1 변경 alias 또는 copied의 1번을 99로 바꿉니다. |
|
|
| 원본 비교 A 원본은 바뀌고 B 원본은 유지됩니다. |
|
|
참조 대입은 같은 배열을 공유하고 복사는 새 배열을 만든다
alias는 같은 배열을 가리키지만 copyOf는 새 배열을 만든 뒤 각 예제에서 index 1을 바꿉니다. 두 독립 실행을 대응 단계로 유지하여 참조와 변경 범위를 보여 줄 수 있습니다.
참조 대입 뒤에는 두 변수가 하나의 배열을 보지만 Arrays.copyOf를 사용한 별도 실행에서는 서로 다른 배열을 본다.
ARRAY · BOUNDARIES
참조 대입은 공유, 배열 복사는 독립
별도 실행 A와 B를 비교합니다. alias는 원본 배열을 함께 가리키고, Arrays.copyOf는 새 배열을 만들어 복사본의 변경을 분리합니다.
- 실행 A · ArrayAliasing
alias=original이므로 두 변수가 같은 배열을 참조합니다.alias[1]=99뒤 둘 다[20,99,40]을 읽고same=true입니다.- 실행 B · IndependentArrayCopy
- 다시
original=[20,30,40]으로 시작합니다.Arrays.copyOf(original,original.length)는 새 배열을 만듭니다.copied[1]=99뒤 원본은[20,30,40], 복사는[20,99,40],same=false입니다.
화살표는 변수의 참조입니다. 두 패널은 독립 실행이며 B는 A의 변경된 원본을 이어받지 않습니다. 이 예제는 int[] 원소 복사입니다.
이 예의 조건
- ArrayAliasing과 IndependentArrayCopy는 독립 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 참조 대입은 같은 배열을 공유하고 복사는 새 배열을 만든다 ArrayAliasing과 IndependentArrayCopy의 서로 독립된 실행에서의 참조 관계 참조 대입 뒤에는 두 변수가 하나의 배열을 보지만 Arrays.copyOf를 사용한 별도 실행에서는 서로 다른 배열을 본다. ARRAY · BOUNDARIES 참조 대입은 공유, 배열 복사는 독립 별도 실행 A와 B를 비교합니다. alias는 원본 배열을 함께 가리키고, Arrays.copyOf는 새 배열을 만들어 복사본의 변경을 분리합니다. 실행 A · ArrayAliasing alias=original 이므로 두 변수가 같은 배열을 참조합니다. alias[1]=99 뒤 둘 다 [20,99,40] 을 읽고 same=true 입니다. 실행 B · IndependentArrayCopy 다시 original=[20,30,40] 으로 시작합니다. Arrays.copyOf(original,original.length) 는 새 배열을 만듭니다. copied[1]=99 뒤 원본은 [20,30,40] , 복사는 [20,99,40] , same=false 입니다. 화살표는 변수의 참조입니다. 두 패널은 독립 실행이며 B는 A의 변경된 원본을 이어받지 않습니다. 이 예제는 int[] 원소 복사입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 배열 두 실행의 원본은 20,30,40입니다. |
|
|
| 인덱스 1 변경 alias 또는 copied의 1번을 99로 바꿉니다. |
|
|
| 원본 비교 A 원본은 바뀌고 B 원본은 유지됩니다. |
|
|
Arrays.copyOf의 두 번째 인자로 원래 길이와 다른 값을 주면, 그 길이의 새 배열이 만들어집니다. 원래보다 길면 남는 뒤쪽 칸은 기본값으로 채워지고, 원래보다 짧으면 뒤쪽 원소가 잘립니다.
int[] expanded = Arrays.copyOf(original, 5); // [20, 30, 40, 0, 0]
int[] shrunk = Arrays.copyOf(original, 2); // [20, 30]이것은 원래 배열의 길이가 늘어난 것이 아니라, 다른 길이의 새 배열이 생긴 것입니다. original은 여전히 길이 3입니다. 배열이 가득 찼을 때 '용량을 늘린다'는 말은 실제로는 이렇게 더 큰 새 배열을 만들고 값을 옮긴 뒤, 변수가 새 배열을 가리키게 하는 일입니다.
이메일과 나이를 같은 인덱스에 담는 병렬 배열
회원 한 명에게는 이메일(문자열)과 나이(정수)가 있습니다. 배열 하나는 한 가지 타입만 담을 수 있으므로, 이메일 배열과 나이 배열을 따로 만들고 같은 인덱스에 같은 회원의 정보를 두는 방법을 쓸 수 있습니다. 이렇게 인덱스로 짝을 맞춘 배열들을 병렬 배열(parallel arrays)이라고 합니다.
public final class ParallelArrays {
public static void main(String[] args) {
String[] emails = {"[email protected]", "[email protected]"};
int[] age = {40, 50};
if (emails.length != age.length) {
throw new IllegalStateException("parallel lengths differ");
}
for (int index = 0; index < emails.length; index++) {
System.out.println(emails[index] + "=" + age[index]);
}
}
}인덱스 0의 [email protected]과 40이 첫 번째 회원이고, 인덱스 1의 [email protected]과 50이 두 번째 회원입니다. 반복문의 index 하나로 두 배열을 함께 읽으므로, 같은 회원의 정보가 한 줄에 출력됩니다.
반복 전의 if는 두 배열의 길이가 같은지 확인합니다. throw new IllegalStateException(...)은 예외를 직접 발생시켜 실행을 그 자리에서 멈추는 문장입니다. 길이가 다르다면 한쪽 배열에만 있는 회원이 생긴 것이고, 그대로 반복하면 짧은 배열의 범위를 넘어 엉뚱한 곳에서 예외가 나거나 정보가 빠진 채 출력됩니다. 그보다는 문제를 발견한 즉시 무엇이 잘못됐는지 알리는 편이 원인을 찾기 쉽습니다. 위의 두 배열은 길이가 모두 2이므로 이 문장은 실행되지 않습니다. 나이 배열에서 50을 지우고 실행하면 정상 출력 대신 parallel lengths differ 메시지가 담긴 예외가 발생하는 것을 확인할 수 있습니다.
병렬 배열의 약점은 두 배열의 관계를 프로그래머가 직접 지켜야 한다는 점입니다. 회원을 정렬하거나, 중간 회원을 삭제하거나, 사이에 끼워 넣을 때 한쪽 배열만 움직이면 이메일과 나이의 짝이 어긋납니다. 컴파일러는 이런 실수를 알아채지 못합니다. 4장의 클래스로 관련 데이터 묶기에서 이메일과 나이를 객체 하나로 묶는 방법을 배우기 전까지는, 두 배열을 바꾸는 모든 코드가 같은 인덱스를 함께 바꾸도록 주의해야 합니다.
배열의 크기와 실제로 채운 칸 수
회원가입 프로그램에서는 처음부터 회원이 몇 명일지 알 수 없습니다. 그래서 넉넉한 크기의 배열을 미리 만들어 두고, 앞에서부터 채워 나가는 방식을 씁니다. 이때 배열의 크기와 실제로 채운 칸 수는 서로 다른 값이므로 따로 관리해야 합니다.
public final class FixedMemberRegistry {
public static void main(String[] args) {
String[] emails = new String[3];
int[] age = new int[3];
int size = 0;
emails[size] = "[email protected]";
age[size] = 40;
size++;
emails[size] = "[email protected]";
age[size] = 50;
size++;
for (int index = 0; index < size; index++) {
System.out.println(emails[index] + "=" + age[index]);
}
System.out.println("size=" + size + ", capacity=" + emails.length);
}
}[email protected]=40
[email protected]=50
size=2, capacity=3size는 지금까지 저장한 회원 수이면서, 동시에 다음 회원을 넣을 칸의 인덱스입니다. 처음에는 0이므로 첫 회원은 0번 칸에 들어가고, size++로 1이 되면 다음 회원은 1번 칸에 들어갑니다. 회원을 넣을 때마다 상태가 이렇게 바뀝니다.
| 시점 | emails | age | size |
|---|---|---|---|
| 생성 직후 | [null, null, null] | [0, 0, 0] | 0 |
| 첫 회원 저장 후 | [[email protected], null, null] | [40, 0, 0] | 1 |
| 둘째 회원 저장 후 | [[email protected], [email protected], null] | [40, 50, 0] | 2 |
emails.length는 배열이 담을 수 있는 최대 회원 수인 용량(capacity) 3이고, size는 그중 실제로 채운 2입니다. 출력 반복문의 조건을 index < size로 쓴 이유가 여기에 있습니다. index < emails.length로 쓰면 비어 있는 2번 칸까지 읽어서 null=0이라는 줄이 하나 더 출력됩니다. 기본값 null과 0이 마치 세 번째 회원처럼 보이게 되는 것입니다.
이메일과 나이를 같은 인덱스로 맞춘 두 배열에서 size 2는 사용 영역이고 length 3은 전체 용량이다.
ARRAY · BOUNDARIES
같은 인덱스가 한 회원, size 뒤는 빈 용량
두 배열의 길이는3이지만 저장된 회원은2명입니다. 조회는 size 앞까지 하고, 추가는 같은 위치에 두 값을 쓴 뒤 개수를 늘립니다.
size=2, capacity=3
- index 0 · 회원
emails[0][email protected]·age[0]=40- index 1 · 회원
emails[1][email protected]·age[1]=50- index 2 · 미사용
emails[2]=null·age[2]=0. 기본값은 세 번째 회원이 아닙니다.
불변식: 두 배열의 길이는 같고 0 <= size && size <= length입니다. 조회는 index < size까지입니다. length까지 출력하면 null=0도 나옵니다.
추가 전에 용량 검사
size < emails.length인지 먼저 확인해야 합니다. 현재 예제의 두 번 쓰기에 이 검사 코드가 이미 있다는 뜻은 아닙니다.같은 위치에 두 값 쓰기
emails[size]와age[size]에 한 회원의 값을 씁니다.마지막에 개수 증가
두 값을 쓴 뒤
size++합니다. 새로 생긴 기본값 칸은 회원 수에 넣지 않습니다.
회원을 추가하는 코드는 세 가지 규칙을 지켜야 합니다. 첫째, 쓰기 전에 size < emails.length인지 확인합니다. 위 예제는 용량 3에 두 명만 넣는다는 것을 알고 있어서 이 검사를 생략했지만, 사용자 입력으로 회원을 받는다면 반드시 필요합니다. 배열이 가득 찼는데 그대로 쓰면 ArrayIndexOutOfBoundsException이 발생합니다. 둘째, 두 배열의 같은 size 위치에 이메일과 나이를 모두 씁니다. 셋째, 두 값을 다 쓴 뒤 마지막에 size를 하나 늘립니다. size를 먼저 늘리면, 그 사이에 문제가 생겼을 때 값이 들어가지 않은 칸이 회원 수에 포함됩니다.
이 규칙을 지키면 배열은 언제나 다음 상태를 유지합니다. size는 0 이상이고 배열의 길이 이하입니다. 인덱스 0부터 size - 1까지는 실제 회원이고, size부터 length - 1까지는 아직 쓰지 않은 칸입니다. 용량을 늘리려고 Arrays.copyOf로 두 배열을 새로 만들 때도 이 상태는 그대로여야 합니다. 두 배열을 같은 길이로 늘리고, size는 바꾸지 않습니다. 새로 생긴 뒤쪽 칸은 기본값이 든 빈 칸일 뿐 회원이 아니기 때문입니다.
정리
- 배열은 같은 타입의 값 여러 개를 담는 고정 길이 저장 공간입니다.
new int[3]으로 만들거나{20, 30, 40}처럼 값을 나열해 만들고, 새 배열의 칸은 타입별 기본값으로 채워집니다. - 인덱스는 0부터 시작하므로 마지막 인덱스는
length - 1입니다. 범위를 벗어난 인덱스는ArrayIndexOutOfBoundsException을 일으키며, 모든 칸을 도는 반복의 조건은index < 배열.length로 씁니다. - 배열 변수에는 참조가 들어 있습니다. 배열 변수끼리 대입하면 같은 배열을 함께 가리키게 되고, 독립된 복사본이 필요하면
Arrays.copyOf로 새 배열을 만듭니다. - 배열을 저장소로 쓸 때는 용량(
length)과 실제로 채운 수(size)를 구분합니다. 조회와 집계는size까지만 하고, 추가할 때는 용량을 확인한 뒤 값을 모두 쓰고 마지막에size를 늘립니다.
연습 문제
{25, 55, 35, 40}배열에서 가장 큰 값과 그 인덱스를 찾으세요. 최댓값의 처음 값으로0처럼 배열에 없는 값을 쓰지 말고, 배열의 첫 원소를 쓰세요.- 다음 코드를 실행하면 무엇이 출력될까요?
second에 새 배열을 대입하는 줄에 주목하세요.
int[] first = {1, 2, 3};
int[] second = first;
second = new int[] {7, 8, 9};
second[0] = 0;
System.out.println("first=" + Arrays.toString(first));
System.out.println("second=" + Arrays.toString(second));FixedMemberRegistry에서 출력 반복문의 조건을index < emails.length로 바꾸면 출력이 어떻게 달라지는지 쓰고, 왜 그런지 설명하세요.
해설 보기
- 최댓값 자체 대신 최댓값이 있는 인덱스를 기억하면, 값과 위치를 한꺼번에 알 수 있습니다.
public final class OldestMember {
public static void main(String[] args) {
int[] age = {25, 55, 35, 40};
int maxIndex = 0;
for (int index = 1; index < age.length; index++) {
if (age[index] > age[maxIndex]) {
maxIndex = index;
}
}
System.out.println("index=" + maxIndex);
System.out.println("age=" + age[maxIndex]);
}
}index=1
age=55maxIndex를 0으로 시작하면 '첫 원소가 지금까지의 최댓값'이라는 뜻이 되므로, 반복은 인덱스 1부터 시작합니다. 인덱스 1의 55가 25보다 커서 maxIndex가 1이 되고, 그 뒤의 35와 40은 55보다 작아 바뀌지 않습니다. 처음 값을 0으로 두는 방식은 모든 값이 음수인 배열에서 틀린 답을 내지만, 첫 원소에서 시작하면 그런 문제가 없습니다. 같은 최댓값이 여러 개라면 >는 먼저 나온 인덱스를, >=는 마지막에 나온 인덱스를 남깁니다. 단, 빈 배열에는 인덱스 0조차 없으므로 이 코드를 쓰기 전에 age.length가 0인지 먼저 확인해야 합니다.
- 출력은 다음과 같습니다.
first=[1, 2, 3]
second=[0, 8, 9]int[] second = first; 직후에는 두 변수가 같은 배열을 가리킵니다. 하지만 다음 줄에서 second에 새 배열을 대입하면 second만 새 배열 {7, 8, 9}를 가리키게 되고, first는 여전히 원래 배열을 가리킵니다. 그래서 second[0] = 0은 새 배열만 바꿉니다. 원소를 바꾸는 것과 변수가 가리키는 배열을 바꾸는 것은 다른 일입니다.
- 마지막에
null=0이라는 줄이 하나 더 출력됩니다.emails.length는 용량 3이므로 반복이 인덱스 2까지 돌고, 아직 아무것도 저장하지 않은 2번 칸의 기본값null과 0이 출력되기 때문입니다. 저장된 회원은size가 가리키는 2명뿐이므로, 회원을 다루는 반복은index < size로 써야 합니다.
다음 글
2차원 배열과 향상된 for에서는 지역별, 채널별 가입자 수처럼 행과 열이 있는 표를 배열의 배열로 표현하고, 인덱스 없이 모든 원소를 차례로 읽는 향상된 for를 배웁니다.