Devin.KR
로그인

자바 배열 - 선언 초기화 다차원 배열과 크기가 고정이라는 한계 (자바 초급 4단원)

개발자 조회 1

이 단원에서 배우는 것

3단원까지는 책 한 권을 다뤘다. title 하나, overdueDays 하나. 그런데 도서관에는 책이 여러 권이다. 변수 이름을 title1, title2, title3 로 늘려 가는 방식은 세 권째부터 무너진다. 반복문으로 훑을 수도 없다. 이번 단원은 그 문제를 배열로 푼다.

그리고 배열을 다 배우고 나면 배열이 못 하는 것이 보인다. 그 지점이 중급 단원의 컬렉션으로 넘어가는 다리다.

  • 배열을 선언·초기화하고, 초기화하지 않은 원소에 무엇이 들어 있는지 안다.
  • 인덱스 for 와 향상된 for 를 구분해 쓰고, java.util.Arrays 의 도구를 쓴다.
  • 배열이 참조형이라는 사실이 복사와 비교에서 무엇을 바꾸는지 설명한다.

왜 필요한가

변수 이름으로는 반복을 못 돈다

도서 세 권의 연체료 합계를 구한다고 하자. 변수를 따로 두면 이렇게 된다.

int overdueDays1 = 0;
int overdueDays2 = 12;
int overdueDays3 = 3;
int total = overdueDays1 + overdueDays2 + overdueDays3;

책이 100권이 되면 줄이 100개가 된다. 더 나쁜 것은 3단원에서 배운 반복문을 쓸 수 없다는 점이다. for 안에서 i 를 1, 2, 3 으로 바꾼다고 overdueDays1 이라는 이름이 만들어지지는 않기 때문이다. 변수 이름은 컴파일 시점에 고정되고, 반복문의 값은 실행 시점에 정해진다.

배열은 이 간격을 메운다. 값들을 이름이 아니라 번호로 구분하니까, 번호를 반복문 변수로 만들 수 있다.

int[] overdueDays = {0, 12, 3};
int total = 0;
for (int i = 0; i < overdueDays.length; i++) {
    total += overdueDays[i];
}
System.out.println(total);   // 15

이제 책이 100권이어도 이 코드는 그대로다.

문법과 예제

선언과 초기화

배열을 만드는 방법은 세 가지다. 셋 다 실무에서 다 쓰인다.

// (1) 값을 이미 알고 있을 때
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};

// (2) 크기만 정하고 나중에 채울 때
int[] overdueDays = new int[3];
overdueDays[0] = 0;
overdueDays[1] = 12;
overdueDays[2] = 3;

// (3) 이미 선언된 변수에 새 배열을 통째로 대입할 때
overdueDays = new int[]{0, 12, 3};

String[] titles 처럼 대괄호를 타입 쪽에 붙인다. String titles[] 도 문법상 되지만, 타입 정보가 이름 뒤로 흩어져서 읽기 나쁘다. 실무 코드는 앞의 형태로 통일한다.

(1) 의 중괄호 형태는 선언과 동시에만 쓸 수 있다. 나중에 대입할 때는 (3) 처럼 new int[] 를 앞에 붙여야 한다.

초기화하지 않은 원소의 값

new 로 만든 배열은 비어 있는 게 아니라 타입별 기본값으로 채워져 있다. 2단원 표의 기본값 열이 여기서 쓰인다.

import java.util.Arrays;

public class Defaults {
    public static void main(String[] args) {
        int[] nums = new int[3];
        String[] strs = new String[2];
        boolean[] flags = new boolean[2];
        double[] rates = new double[2];

        System.out.println(Arrays.toString(nums));   // [0, 0, 0]
        System.out.println(Arrays.toString(strs));   // [null, null]
        System.out.println(Arrays.toString(flags));  // [false, false]
        System.out.println(Arrays.toString(rates));  // [0.0, 0.0]
    }
}

참조형 배열의 기본값이 null 이라는 점이 중요하다. new String[2] 를 만들고 바로 strs[0].length() 를 부르면 NullPointerException 이다. "배열을 만들었으니 안이 채워졌다"는 감각이 여기서 어긋난다.

훑기: 인덱스 for 와 향상된 for

3단원에서 미뤄 둔 향상된 for 를 이제 쓴다. 원소를 순서대로 꺼내 주기만 하면 될 때는 이쪽이 짧고 실수가 적다.

import java.util.Arrays;

public class LoanReport {
    public static void main(String[] args) {
        String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
        int[] overdueDays = {0, 12, 3};

        // 인덱스가 필요할 때: 두 배열을 나란히 봐야 한다
        for (int i = 0; i < titles.length; i++) {
            System.out.println(titles[i] + " / " + overdueDays[i] + "일");
        }

        // 인덱스가 필요 없을 때
        int total = 0;
        for (int d : overdueDays) {
            total += d;
        }
        System.out.println("연체일 합계 " + total);   // 15
    }
}
자바의 정석 / 0일
이펙티브 자바 / 12일
클린 코드 / 3일
연체일 합계 15

향상된 for읽기 전용이라고 생각하는 편이 안전하다. for (int d : overdueDays) { d = 0; } 는 배열을 바꾸지 않는다. d 는 원소를 복사해 담은 별개의 변수이기 때문이다. 원소를 고쳐야 하면 인덱스 for 를 쓴다.

3단원 연체료 정책을 여러 권에 적용하기

public class LateFeeReport {
    public static void main(String[] args) {
        String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
        int[] overdueDays = {0, 12, 3};
        int[] fees = new int[titles.length];

        for (int i = 0; i < titles.length; i++) {
            int days = overdueDays[i];
            if (days <= 0) {
                fees[i] = 0;
            } else if (days <= 7) {
                fees[i] = days * 100;
            } else {
                fees[i] = 700 + (days - 7) * 200;
            }
        }

        int sum = 0;
        for (int fee : fees) {
            sum += fee;
        }

        for (int i = 0; i < titles.length; i++) {
            System.out.println(titles[i] + " : " + fees[i] + "원");
        }
        System.out.println("합계 : " + sum + "원");
    }
}
자바의 정석 : 0원
이펙티브 자바 : 1700원
클린 코드 : 300원
합계 : 2000원

java.util.Arrays

배열을 다룰 때 매번 손으로 짜지 말아야 할 것들이 표준 라이브러리에 있다. 자주 쓰는 다섯 개다.

import java.util.Arrays;

public class ArraysDemo {
    public static void main(String[] args) {
        int[] days = {0, 12, 3};

        System.out.println(Arrays.toString(days));          // [0, 12, 3]

        int[] bigger = Arrays.copyOf(days, 5);
        System.out.println(Arrays.toString(bigger));        // [0, 12, 3, 0, 0]

        int[] sorted = Arrays.copyOf(days, days.length);
        Arrays.sort(sorted);
        System.out.println(Arrays.toString(sorted));        // [0, 3, 12]
        System.out.println(Arrays.toString(days));          // [0, 12, 3]  원본은 그대로

        int[] filled = new int[4];
        Arrays.fill(filled, 100);
        System.out.println(Arrays.toString(filled));        // [100, 100, 100, 100]

        System.out.println(Arrays.equals(new int[]{1, 2}, new int[]{1, 2}));  // true
    }
}

Arrays.sort원본을 직접 바꾼다. 원본이 필요하면 위처럼 copyOf 로 사본을 뜬 뒤 정렬한다.

다차원 배열

자바에 진짜 2차원 배열은 없다. 배열을 담은 배열이 있을 뿐이다. 이 사실이 문법의 여러 곳에서 드러난다. 도서관 서가를 예로 든다. 책장이 3개, 각 책장에 칸이 4개다.

import java.util.Arrays;

public class Shelf {
    public static void main(String[] args) {
        int[][] shelf = new int[3][4];   // 책장 3개, 각 4칸, 값은 책 권수
        shelf[0][0] = 5;
        shelf[2][3] = 9;

        System.out.println(Arrays.deepToString(shelf));
        // [[5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 9]]

        System.out.println(shelf.length);      // 3  책장 수
        System.out.println(shelf[0].length);   // 4  0번 책장의 칸 수

        for (int i = 0; i < shelf.length; i++) {
            for (int j = 0; j < shelf[i].length; j++) {
                System.out.print(shelf[i][j] + " ");
            }
            System.out.println();
        }
    }
}

안쪽 배열의 길이는 서로 달라도 된다. 이걸 가변 배열(jagged array)이라고 부른다. 책장마다 칸 수가 다른 상황이 정확히 이 모양이다.

int[][] jagged = new int[3][];      // 바깥 크기만 정한다
jagged[0] = new int[]{1, 2};
jagged[1] = new int[]{3};
jagged[2] = new int[]{4, 5, 6};

for (int[] row : jagged) {
    System.out.print(row.length + " ");
}
System.out.println();               // 2 1 3

그래서 2차원 배열을 훑을 때 안쪽 조건은 shelf[0].length 가 아니라 shelf[i].length 로 써야 한다. 사각형이라고 가정한 코드는 가변 배열을 만나는 순간 ArrayIndexOutOfBoundsException 으로 죽는다.

배열의 한계와 컬렉션

배열의 크기는 new 하는 순간 정해지고 절대 안 바뀐다. 책을 한 권 더 등록하려면 더 큰 배열을 새로 만들어 전부 복사해야 한다.

String[] titles = {"자바의 정석", "클린 코드"};
// titles[2] = "이펙티브 자바";   // ArrayIndexOutOfBoundsException

String[] bigger = Arrays.copyOf(titles, titles.length + 1);
bigger[2] = "이펙티브 자바";
titles = bigger;                  // 매번 이 짓을 해야 한다

중간에서 하나를 빼는 것은 더 번거롭다. 뒤의 원소를 전부 한 칸씩 앞으로 당기고, 배열을 하나 줄여 다시 만들어야 한다. 이걸 매번 손으로 쓰면 반드시 어딘가에서 인덱스를 틀린다.

그 작업을 미리 해 둔 것이 컬렉션이다. 미리보기로만 보여 준다. 문법 설명은 중급 단원에서 한다.

import java.util.ArrayList;
import java.util.List;

public class Preview {
    public static void main(String[] args) {
        List<String> titles = new ArrayList<>();
        titles.add("자바의 정석");
        titles.add("클린 코드");
        titles.add("이펙티브 자바");
        titles.remove("클린 코드");

        System.out.println(titles);          // [자바의 정석, 이펙티브 자바]
        System.out.println(titles.size());   // 2
    }
}

정리하면 이렇다. 크기가 처음부터 끝까지 고정이고 기본형을 많이 담아야 하면 배열이 여전히 최선이다. 넣고 빼는 일이 있으면 컬렉션이다. 실무에서 새로 쓰는 코드는 대부분 후자다.

실무에서 자주 틀리는 것

1. length 와 length() 를 혼동한다

배열은 필드 length, 문자열은 메서드 length() 다. 규칙이 아니라 그냥 역사적 결과라서 외우는 수밖에 없다.

String[] titles = {"자바의 정석", "클린 코드"};
System.out.println(titles.length);       // 2     배열은 필드
System.out.println(titles[0].length());  // 6     문자열은 메서드
// titles.length()   컴파일 에러
// titles[0].length  컴파일 에러

2. 마지막 인덱스를 length 로 쓴다

인덱스는 0 부터 시작하므로 마지막은 length - 1 이다. <= 로 쓰면 딱 한 칸을 넘긴다.

String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};

for (int i = 0; i <= titles.length; i++) {   // 틀림
    System.out.println(titles[i]);
}
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException:
// Index 3 out of bounds for length 3

예외 메시지가 친절하게 알려 준다. Index 3 out of bounds for length 3 을 보면 조건의 부등호를 먼저 의심한다.

3. 배열을 == 나 equals 로 비교한다

배열은 참조형이다. 2단원에서 참조형 변수에는 주소가 들어 있다고 했다. 그래서 == 는 내용이 아니라 같은 배열이냐를 묻는다. 더 나쁜 건 equals 도 마찬가지라는 점이다. 배열은 equals 를 따로 정의하지 않아서 결국 주소 비교로 떨어진다.

int[] a = {1, 2, 3};
int[] b = {1, 2, 3};

System.out.println(a == b);              // false
System.out.println(a.equals(b));         // false   ← 함정
System.out.println(Arrays.equals(a, b)); // true    ← 이걸 쓴다

같은 이유로 배열을 그냥 출력하면 내용이 안 나온다.

int[] days = {0, 12, 3};
System.out.println(days);                    // [I@1b6d3586  같은 것
System.out.println(Arrays.toString(days));   // [0, 12, 3]
// 2차원은 deepToString
System.out.println(Arrays.deepToString(new int[][]{{1, 2}, {3}}));  // [[1, 2], [3]]

4. 배열을 대입하면 복사되는 줄 안다

배열 변수를 다른 변수에 대입하면 주소만 복사된다. 두 변수가 같은 배열을 가리키므로 한쪽으로 고치면 다른 쪽도 바뀐다.

int[] original = {1, 2, 3};
int[] alias = original;      // 복사가 아니다
alias[0] = 99;
System.out.println(Arrays.toString(original));   // [99, 2, 3]

int[] copy = Arrays.copyOf(original, original.length);   // 진짜 사본
copy[0] = 1;
System.out.println(Arrays.toString(original));   // [99, 2, 3]  영향 없음

이 성질이 5단원에서 메서드에 배열을 넘길 때 다시 나온다. 왜 메서드 안에서 배열 내용을 고치면 밖에도 반영되는지가 여기서 설명된다.

덧붙이면 Arrays.copyOf 도 한 겹만 복사한다. 2차원 배열에 쓰면 안쪽 배열들은 여전히 공유된다. 깊은 복사는 한 줄씩 직접 복사해야 한다.

스스로 확인하기

  1. int[] fees = new int[5]; 로 만든 배열을 그대로 출력하면 무엇이 나오는가? 그리고 [0, 0, 0, 0, 0] 이 나오게 하려면 어떻게 해야 하는가?
  2. 연체일 배열 {0, 12, 3, 25, 5} 에서 가장 큰 값과 그 인덱스를 구하는 코드를 쓰라. Arrays.sort 는 쓰지 않는다.
  3. 아래 코드는 왜 ArrayIndexOutOfBoundsException 이 나는가? 한 글자만 고쳐 해결하라.
    int[][] shelf = new int[3][];
    shelf[0] = new int[]{1, 2, 3, 4};
    shelf[1] = new int[]{5, 6};
    shelf[2] = new int[]{7};
    
    for (int i = 0; i < shelf.length; i++) {
        for (int j = 0; j < shelf[0].length; j++) {
            System.out.print(shelf[i][j] + " ");
        }
    }

정답

  1. [I@ 로 시작하는 타입 기호와 해시값이 찍힌다. 배열이 자기 내용을 문자열로 만드는 방법을 갖고 있지 않기 때문이다. System.out.println(Arrays.toString(fees)); 로 출력한다.
  2. 첫 원소를 최댓값 후보로 잡고 나머지를 비교해 나간다.
    int[] days = {0, 12, 3, 25, 5};
    int maxValue = days[0];
    int maxIndex = 0;
    for (int i = 1; i < days.length; i++) {
        if (days[i] > maxValue) {
            maxValue = days[i];
            maxIndex = i;
        }
    }
    System.out.println("최대 " + maxValue + "일, 인덱스 " + maxIndex);
    // 최대 25일, 인덱스 3
    후보를 0 이나 Integer.MIN_VALUE 대신 days[0] 으로 잡는 이유는 음수만 든 배열에서도 맞기 때문이다.
  3. 안쪽 반복 조건이 shelf[0].length 로 고정돼 있어서, 0번 책장의 길이 4를 1번·2번 책장에도 적용한다. 1번 책장은 길이가 2뿐이라 인덱스 2에서 터진다. shelf[0].lengthshelf[i].length 로 고친다.

다음 단원에서는 지금까지 main 안에 몰아넣은 연체료 계산을 별도의 메서드로 떼어낸다. 그 과정에서 자바가 매개변수를 어떻게 전달하는지, 왜 배열은 바뀌고 int 는 안 바뀌는지가 분명해진다.