본문 바로가기

배열로 회원 목록 관리하기

배열의 용량과 실제 회원 수를 구분해 회원을 추가하고, 저장된 회원만으로 나이의 합계·평균·최솟값·최댓값을 구하는 방법을 배웁니다.

회원 네 명을 담을 배열을 만들어 두었는데 아직 두 명만 가입했다고 해 봅시다. 배열에는 칸이 네 개 있지만 회원은 두 명뿐입니다. 그렇다고 나머지 두 칸이 비어 있는 것도 아닙니다. 배열을 만들 때 자바가 채워 넣은 기본값 null과 0이 들어 있습니다. 그래서 배열을 회원 저장소로 쓰려면 '칸이 몇 개인가'와 '회원이 몇 명 들어 있는가'를 따로 관리해야 합니다.

이 글에서는 배열과 인덱스에서 만든 병렬 배열과 회원 수 변수 size로 회원을 추가하고, 저장된 회원의 나이로 합계·평균·최솟값·최댓값을 구합니다. 마지막에는 이 기능들을 명령어로 실행하는 작은 프로그램으로 묶습니다.

배열 길이만큼 반복하면 생기는 일

먼저 자주 하는 실수부터 보겠습니다. 아래 프로그램은 용량 4인 배열 두 개에 회원 두 명을 넣은 뒤, 배열 길이 age.length만큼 반복하며 회원을 출력하고 나이 평균을 계산합니다.

lab/LengthInsteadOfSize.java
public final class LengthInsteadOfSize {
    public static void main(String[] args) {
        String[] emails = new String[4];
        int[] age = new int[4];
        int size = 0;

        emails[size] = "[email protected]";
        age[size] = 40;
        size++;

        emails[size] = "[email protected]";
        age[size] = 50;
        size++;

        int total = 0;
        for (int index = 0; index < age.length; index++) {
            total += age[index];
            System.out.println(emails[index] + "=" + age[index]);
        }
        System.out.println("average=" + total / age.length);
    }
}
실행 결과
[email protected]=40
[email protected]=50
null=0
null=0
average=22

예외 없이 끝났지만 결과는 틀렸습니다. 세 번째와 네 번째 줄의 null=0은 존재하지 않는 회원입니다. new String[4]와 new int[4]로 배열을 만들면 모든 칸이 기본값으로 채워지는데, String 배열은 null, int 배열은 0이 기본값입니다. 반복이 age.length, 즉 4까지 돌았으므로 아직 아무도 쓰지 않은 두 칸까지 출력된 것입니다.

평균도 틀렸습니다. 실제 회원 두 명의 평균은 (40 + 50) / 2 = 45인데, 이 프로그램은 합계 90을 용량 4로 나눴습니다. 게다가 total과 age.length가 둘 다 int라서 정수 나눗셈이 일어나고, 22.5에서 소수 부분까지 버려져 22가 출력되었습니다.

용량 4, size 2의 병렬 배열에서 index 0과 1은 회원이고 index 2와 3은 기본값이 남은 미사용 영역이다. 같은 합계 90의 평균 분모는 용량 4가 아니라 회원 수 2다.

LengthInsteadOfSize의 동일한 입력 상태를 사용합니다. size가 회원 집합의 경계이고 null이나 0이라는 기본값은 회원 존재를 뜻하지 않습니다.

MEMBER · STATE CONTRACT

회원의 범위와 평균 분모는 size로 결정합니다

용량4의 배열에 두 회원만 있습니다. 미사용 기본값을 출력하지 않고, 실제 회원 나이 합계90을 회원 수2로 나누면 평균45.0입니다.

length = 4 · size = 2 사용 [0, 2) / 미사용 [2, 4)

LengthInsteadOfSize · 같은 index의 email과 age가 한 회원
indexemails[index]age[index]회원 여부
0[email protected]40사용 · 실제 회원
1[email protected]50사용 · 실제 회원
2 · sizenull0미사용 · size 경계
3null0미사용 · 기본값
회원의 범위와 평균 분모는 size로 결정합니다 length4 안의 size2 사용/2빈칸 분리;같은사용40+50=90→용량분모int22/실제회원double45.0 비교;0회원정책 전체 length4 size2 사용 미사용 기본값 index 0 [email protected] / 40 index 1 [email protected] / 50 index 2 null / 0 index 3 null / 0 사용 값의 합계 40+50=90 같은 회원 집합 잘못된 용량 분모 90/4 = 22 정수몫 · 평균 아님 실제 회원 수 분모 (double)90/2 = 45.0 size2로 나눔 size0이면 나누기 전 분기 0.0은 회원 없음 표시
회원의 범위와 평균 분모는 size로 결정합니다 length4 안의 size2 사용/2빈칸 분리;같은사용40+50=90→용량분모int22/실제회원double45.0 비교;0회원정책 length=4 · 전체 용량 size=2 · 사용 [0,2) 미사용 [2,4) index 0 [email protected] age=40 index 1 [email protected] age=50 index 2 null age=0 index 3 null age=0 사용한 두 값만 합산 40+50=90 미사용 두 칸 제외 잘못된 용량 분모 90/4 = 22 정수몫 · 회원 평균 아님 실제 회원 수 분모 (double)90/2 45.0 size=0은 나누기 전 분기 0.0은 회원 없음 표시 정책
index 0 · 사용 · 실제 회원
[email protected] / age 40
index 1 · 사용 · 실제 회원
[email protected] / age 50
index 2 · 미사용 · size 경계
null / age 0
index 3 · 미사용 · 기본값
null / age 0
잘못된 분모 · 용량 4
미사용 두 칸까지 출력하면 null=0이 두 줄 나타납니다. 정수 나눗셈 90 / 4 = 22는 회원 평균이 아닙니다.
같은 회원 집합 · 개수 2
index < size만 읽으면 합계는 40 + 50 = 90, 평균은 (double) 90 / 2 = 45.0입니다.

0 ≤ size ≤ length. size == 0은 나눗셈 전에 분기하며 예제의 0.0은 회원 없음의 표시 정책이지 나이 0인 회원이 아닙니다.

그림처럼 배열의 칸은 두 영역으로 나뉩니다. length는 배열에 담을 수 있는 칸의 수, 즉 용량(capacity)이며 배열을 만들 때 정해진 뒤로 바뀌지 않습니다. 반면 size는 지금 실제로 저장된 회원 수이고, 회원이 추가될 때마다 1씩 늘어납니다. 회원을 인덱스 0부터 빈틈없이 채우면 0부터 size - 1까지가 실제 회원이고, size부터 length - 1까지는 아직 쓰지 않은 칸입니다.

따라서 회원을 출력하거나 나이를 더하는 반복은 size까지만 돌고, 평균의 분모도 size를 씁니다. length는 새 회원을 넣을 자리가 남았는지 판단할 때 씁니다. 위 프로그램의 반복 조건을 index < size로, 평균 계산을 (double) total / size로 바꾸면 두 회원과 average=45.0이 출력됩니다. (double)을 붙이는 이유는 뒤에서 평균을 계산할 때 설명합니다.

회원 추가하기: 자리 확인, 기록, 개수 증가

size에는 쓸모가 하나 더 있습니다. 회원을 0번 칸부터 빈틈없이 채우므로, 현재 회원 수 size가 곧 다음 회원이 들어갈 빈칸의 인덱스입니다. 회원이 두 명이면 0번과 1번 칸이 차 있고 다음 자리는 2번입니다. 그래서 회원 추가는 'size번 칸에 쓰고 size를 1 늘린다'로 정리됩니다.

다만 쓰기 전에 자리가 남았는지 먼저 확인해야 합니다. size가 용량과 같아지면 배열이 가득 찬 것이고, 그때의 size번 칸은 배열에 존재하지 않습니다.

if (size >= emails.length) {
    System.out.println("저장 공간이 가득 찼습니다.");
} else {
    emails[size] = "[email protected]";
    age[size] = 35;
    size++;
}

두 배열에 값을 모두 쓴 다음 맨 마지막에 size를 늘리는 순서에도 이유가 있습니다. size는 '여기까지는 회원이 확실히 들어 있다'는 표시이므로 두 칸을 모두 채운 뒤에 늘려야 합니다. size부터 늘려 놓고 이메일 검사에서 거절하면, 아무것도 쓰지 않은 칸이 회원 수에 포함되어 목록에 null=0이 나타납니다. 이 실수는 3장의 마지막 글에서 직접 재현해 봅니다.

자리 확인을 빼면 생기는 일

용량 검사를 빼고 용량 2인 배열에 세 번째 회원을 넣으면 어떻게 되는지 보겠습니다. 다음 코드는 앞의 if (size >= emails.length) 검사 없이 대입만 세 번 연달아 실행합니다. 문법에는 문제가 없어 컴파일은 되므로, 문제는 실행 중에 드러납니다.

lab/CapacityOverflow.java
public final class CapacityOverflow {
    public static void main(String[] args) {
        String[] emails = new String[2];
        int size = 0;

        emails[size++] = "[email protected]";
        emails[size++] = "[email protected]";
        emails[size++] = "[email protected]";
    }
}
실행 결과
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
	at CapacityOverflow.main(CapacityOverflow.java:8)

emails[size++] = ...는 지금의 size 값을 인덱스로 써서 그 칸에 대입하고, size는 1 늘리는 짧은 표현입니다. 세 번째 대입에서 size는 2인데, 길이 2인 배열의 인덱스는 0과 1뿐이므로 ArrayIndexOutOfBoundsException이 발생합니다. 메시지는 접근하려던 인덱스 2와 배열 길이 2를 함께 알려 주고, 다음 줄의 CapacityOverflow.java:8은 예외가 난 소스 코드의 줄 번호입니다.

예외 메시지가 원인을 정확히 알려 주기는 하지만, 사용자가 회원을 한 명 더 입력했다는 이유로 프로그램 전체가 멈추는 것은 바람직하지 않습니다. 배열이 가득 차는 것은 충분히 예상할 수 있는 상황입니다. 그러니 쓰기 전에 공간을 확인하고, 예외 대신 '저장 공간이 가득 찼습니다' 같은 안내로 바꿔 주는 편이 낫습니다. 앞의 if (size >= emails.length)가 바로 그 일을 합니다.

더 깊이: emails[size++]는 실패해도 size를 늘린다

emails[size++] = value는 짧아서 자주 쓰이지만, 대입이 실패했을 때 size가 어떻게 되는지는 겉으로 드러나지 않습니다. Java 언어 명세(JLS §15.26.1)에 따르면 배열 원소에 대입할 때는 배열 식, 인덱스 식, 오른쪽 값을 차례로 계산한 다음에 인덱스가 범위 안인지 검사합니다. 그래서 인덱스 식의 size++는 예외가 나기 전에 이미 실행됩니다.

lab/IncrementBeforeStore.java
public final class IncrementBeforeStore {
    public static void main(String[] args) {
        String[] emails = new String[2];
        int size = 2;

        try {
            emails[size++] = "[email protected]";
        } catch (ArrayIndexOutOfBoundsException error) {
            System.out.println("error: " + error.getMessage());
        }
        System.out.println("size=" + size);
    }
}
실행 결과
error: Index 2 out of bounds for length 2
size=3

대입은 실패했는데 size는 3이 되었습니다. 이 예외를 잡고 프로그램을 계속 진행하면 회원은 두 명인데 size는 3인 상태가 남습니다. 쓰기와 개수 증가를 별도의 문장으로 나누고 size++를 마지막에 두면, 쓰기에서 예외가 날 때 size++까지 가지 않으므로 이런 일이 생기지 않습니다.

저장된 회원만 합산하고 평균 내기

합계와 평균은 반복 범위를 size로 바꾸기만 하면 됩니다. 아래 프로그램은 용량 5인 배열에 회원 네 명의 나이를 넣고 합계와 평균을 구합니다. 비교를 위해 정수 나눗셈 결과도 함께 출력합니다.

src/SummaryWithSize.java
public final class SummaryWithSize {
    public static void main(String[] args) {
        int[] age = new int[5];
        int size = 4;
        age[0] = 40;
        age[1] = 50;
        age[2] = 30;
        age[3] = 25;

        int total = 0;
        for (int index = 0; index < size; index++) {
            total += age[index];
        }

        double average = size == 0 ? 0.0 : (double) total / size;
        System.out.println("total=" + total);
        System.out.println("int division=" + total / size);
        System.out.println("average=" + average);
    }
}
실행 결과
total=145
int division=36
average=36.25

다섯 번째 칸(인덱스 4)은 반복에 들어가지 않으므로 합계는 40 + 50 + 30 + 25 = 145입니다. 두 번째 줄은 int끼리 나눈 결과입니다. 정수 나눗셈은 몫만 남기고 소수 부분을 버리므로 36.25가 아니라 36이 됩니다. 결과를 double 변수에 담는다고 해결되지도 않습니다. double average = total / size;라고 쓰면 오른쪽의 정수 나눗셈이 먼저 끝나 36이 되고, 그 36이 36.0으로 바뀔 뿐입니다.

실수 나눗셈을 하려면 나누기 전에 피연산자 하나를 double로 바꿔야 합니다. (double) total / size에서 캐스트 (double)은 나눗셈보다 먼저 적용되어 total을 145.0으로 바꾸고, 그러면 size도 자동으로 double로 넓혀져 세 번째 줄의 36.25가 나옵니다. 반대로 (double) (total / size)처럼 괄호로 나눗셈을 먼저 묶으면 이미 정수 나눗셈이 끝난 36을 바꾸므로 36.0이 됩니다.

size == 0 ? 0.0 : ... 부분은 회원이 한 명도 없을 때를 위한 것입니다. 회원이 없는데 그대로 나누면 어떻게 될까요? int끼리 0으로 나누면 ArithmeticException: / by zero 예외가 발생합니다. 한쪽을 double로 바꾼 나눗셈에서는 예외 대신 0.0 / 0.0이 계산되어, '숫자가 아님'을 뜻하는 특별한 값 NaN(Not a Number)이 나옵니다. 어느 쪽도 사용자에게 보여 줄 결과가 아니므로 나누기 전에 size를 확인합니다.

여기서는 삼항 연산자로 회원이 없을 때 0.0을 쓰기로 했습니다. 하지만 평균 0.0은 '나이가 0세인 회원들'처럼 오해될 수도 있습니다. 화면에 회원 없음을 출력하는 편이 더 정확할 때도 많으므로, 회원이 없을 때 무엇을 보여 줄지는 프로그램의 요구사항에 맞춰 정합니다.

합계를 담는 변수의 타입도 한 번 따져 볼 만합니다. int가 담을 수 있는 가장 큰 값은 2,147,483,647(약 21억)입니다. 회원 나이 몇 개를 더할 때는 충분하지만, 하루를 밀리초로 나타낸 86,400,000을 25번만 더해도 이 범위를 넘습니다. 범위를 넘은 int 계산은 예외 없이 음수 같은 엉뚱한 값으로 바뀌므로(이를 오버플로라고 합니다), 합계가 커질 수 있으면 long total을 씁니다.

가장 어린 회원과 가장 나이 많은 회원 찾기

최솟값을 구할 때 int min = 0;으로 시작하는 실수가 흔합니다. 회원 나이는 모두 양수이므로 어떤 나이도 0보다 작지 않고, min은 끝까지 0으로 남습니다. 0은 저장된 회원의 나이가 아니라 개발자가 임의로 넣은 값이기 때문입니다.

올바른 출발점은 실제 회원 가운데 하나입니다. 첫 회원(인덱스 0)을 일단 최솟값이자 최댓값으로 정하고, 나머지 회원과 하나씩 비교하면서 더 작거나 더 큰 값을 만나면 바꿉니다. 이렇게 하면 결과는 언제나 실제로 저장된 회원 중 하나가 됩니다. 또 하나의 요령은 나이 값 대신 인덱스를 기억하는 것입니다. 병렬 배열에서는 같은 인덱스의 이메일과 나이가 한 회원이므로, 인덱스만 알면 그 회원의 이메일도 함께 찾을 수 있습니다.

src/MemberExtremes.java
public final class MemberExtremes {
    public static void main(String[] args) {
        String[] emails = {"[email protected]", "[email protected]", "[email protected]", null};
        int[] age = {40, 55, 25, 0};
        int size = 3;

        if (size == 0) {
            System.out.println("회원 없음");
            return;
        }

        int minIndex = 0;
        int maxIndex = 0;
        for (int index = 1; index < size; index++) {
            if (age[index] < age[minIndex]) {
                minIndex = index;
            }
            if (age[index] > age[maxIndex]) {
                maxIndex = index;
            }
        }

        System.out.println("min=" + emails[minIndex] + ":" + age[minIndex]);
        System.out.println("max=" + emails[maxIndex] + ":" + age[maxIndex]);
    }
}
실행 결과

반복이 진행되는 동안 두 인덱스가 어떻게 바뀌는지 따라가 보겠습니다. 처음에는 둘 다 0, 즉 [email protected]의 나이 40을 가리킵니다. 반복은 인덱스 0이 이미 출발점으로 쓰였으므로 1부터 시작합니다.

indexage[index]최솟값과 비교최댓값과 비교minIndexmaxIndex
시작0 (40)0 (40)
15555 < 40 아님55 > 40 맞음0 (40)1 (55)
22525 < 40 맞음25 > 55 아님2 (25)1 (55)

인덱스 1의 55는 지금까지의 최댓값 40보다 크므로 maxIndex가 1로 바뀌고, 인덱스 2의 25는 최솟값 40보다 작으므로 minIndex가 2로 바뀝니다. 인덱스 3은 size가 3이라 비교하지 않습니다. 그 칸의 null과 0은 회원이 아니기 때문입니다. 마지막에 두 인덱스로 이메일과 나이를 함께 꺼내 출력합니다.

같은 최댓값이 둘 이상이면 비교 연산자에 따라 결과가 달라집니다. 나이가 {40, 55, 25, 55}라면 >로 비교할 때는 먼저 나온 인덱스 1이 남고, >=로 비교할 때는 같은 값을 만날 때마다 바꾸므로 나중에 나온 인덱스 3이 남습니다. 어느 쪽이 맞는지는 문법이 아니라 '동점이면 먼저 가입한 회원을 보여 준다' 같은 요구사항이 정합니다.

마지막으로 size == 0 검사를 맨 앞에 둔 이유를 보겠습니다. return;은 main을 그 자리에서 끝내는 문장으로, 회원이 없으면 회원 없음만 출력하고 나머지 계산은 건너뜁니다(return은 return과 호출 스택에서 자세히 다룹니다). 이 검사를 빼면 반복이 한 번도 돌지 않아 출발점인 인덱스 0이 그대로 결과가 됩니다. 용량 4인 빈 배열이라면 min=null:0처럼 존재하지 않는 회원이 출력되고, 길이가 0인 배열이라면 인덱스 0에 접근하는 순간 ArrayIndexOutOfBoundsException이 발생합니다.

칸이 다섯 개인 new int[5]도 size가 0이면 회원은 한 명도 없습니다. 배열에 칸이 있는 것과 회원이 있는 것은 다른 이야기이고, 최솟값과 최댓값은 회원이 한 명 이상일 때만 정의됩니다.

명령을 읽어 회원 추가·목록·요약 실행하기

이제 지금까지의 조각을 한 프로그램으로 묶어 보겠습니다. 이 프로그램은 한 줄에 명령 하나를 읽습니다. add를 읽으면 다음 두 줄을 이메일과 나이로 읽어 회원을 추가하고, list는 목록을, summary는 합계와 평균을 출력하며, quit을 읽으면 끝납니다. 실행할 때마다 같은 결과를 볼 수 있도록 Scanner로 키보드 입력 받기에서처럼 입력을 문자열로 고정했습니다.

src/ArrayMemberRegistry.java
import java.util.Scanner;

public final class ArrayMemberRegistry {
    public static void main(String[] args) {
        Scanner scanner = new Scanner("add\n[email protected]\n40\nadd\n[email protected]\n50\nlist\nsummary\nquit\n");
        String[] emails = new String[3];
        int[] age = new int[3];
        int size = 0;
        boolean running = true;

        while (running && scanner.hasNextLine()) {
            String command = scanner.nextLine().trim();

            switch (command) {
                case "add" -> {
                    if (size >= emails.length) {
                        System.out.println("full");
                        break;
                    }

                    String email = scanner.nextLine().trim();
                    int value = Integer.parseInt(scanner.nextLine().trim());
                    if (email.isEmpty() || value < 14 || value > 120) {
                        System.out.println("invalid");
                        break;
                    }

                    emails[size] = email;
                    age[size] = value;
                    size++;
                    System.out.println("added");
                }
                case "list" -> {
                    for (int index = 0; index < size; index++) {
                        System.out.println((index + 1) + ". " + emails[index] + "=" + age[index]);
                    }
                }
                case "summary" -> {
                    int total = 0;
                    for (int index = 0; index < size; index++) {
                        total += age[index];
                    }
                    double average = size == 0 ? 0.0 : (double) total / size;
                    System.out.println("total=" + total + ", average=" + average);
                }
                case "quit" -> running = false;
                default -> System.out.println("unknown");
            }
        }
    }
}
실행 결과
added
added
1. [email protected]=40
2. [email protected]=50
total=90, average=45.0

while은 running이 참이고 읽을 줄이 남아 있는 동안 명령을 하나씩 읽습니다. 화살표(->)를 쓴 switch는 일치한 case의 블록만 실행하고 다음 case로 넘어가지 않습니다. add 블록 안의 break는 switch를 즉시 끝낼 뿐 바깥의 while까지 끝내지는 않으므로, 실행은 다음 명령을 읽는 반복 조건으로 돌아갑니다.

add 블록은 앞에서 정한 순서를 그대로 따릅니다. 먼저 자리가 남았는지 확인하고, 그다음 이메일과 나이를 읽어 검사합니다. trim()은 앞뒤 공백을 지우고, Integer.parseInt는 "40" 같은 문자열을 정수 40으로 바꿉니다. 이메일이 비어 있거나 나이가 14 이상 120 이하가 아니면 invalid를 출력하고 switch를 끝내며, 모든 검사를 통과해야 두 배열의 size번 칸에 쓰고 마지막에 size를 늘립니다.

출력의 처음 두 줄은 두 번의 add가 성공한 결과입니다. list는 인덱스 0과 1의 회원을 출력하는데, 사람에게 보여 주는 번호는 1부터 시작하도록 index + 1을 붙였습니다. summary는 두 회원의 합계 90과 평균 45.0을 출력하고, quit은 running을 거짓으로 바꿔 반복을 끝냅니다. 아래 단계별 그림은 add 블록이 공간 검사, 값 검사, 기록과 size 증가를 어떤 순서로 실행하는지 보여 줍니다.

추가 실패는 저장 범위를 보존하고, 두 배열 쓰기 뒤 size를 확정한다

용량 확인 전에는 상세 입력을 읽지 않고 검증 후 두 배열의 같은 칸을 쓴 다음 size를 올립니다. 저장 확정 시점과 거절 시 미소비 입력을 실제 순서로 보여 줄 수 있습니다.

ArrayMemberRegistry의 add 분기. 공간과 값을 검사한 뒤에만 emails와 age를 순차 기록하고 마지막으로 size를 늘린다. full과 invalid는 쓰기 없이 switch를 빠져나가 다음 명령 조건으로 돌아간다.

전제는 같은 용량의 두 배열, 유효한 size, 완전한 정수 입력입니다. 두 배열의 쓰기는 순차 실행이며 트랜잭션이나 예외 롤백 보장을 뜻하지 않습니다.

MEMBER · STATE CONTRACT

검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다

full과 invalid는 배열을 바꾸지 않고 switch를 끝냅니다. 성공은 같은 index에 이메일·나이를 순서대로 기록한 뒤 사용 영역을 확장합니다.

ArrayMemberRegistry의 add 명령 · 같은 배열 용량 · 0 ≤ size ≤ capacity · 완전한 정수 입력 전제

검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full은상세입력미읽기/invalid는쓰기없음,성공emails→age→size++순차;모두switch끝낸뒤while조건,동시원자성아님 size >= capacity? 공간 부족? 참 full · switch 종료 상세 입력 미읽기 거짓 이메일·정수 입력 trim / parseInt 이메일 있고 나이14–120? 거짓 invalid · switch 종료 쓰기 없이 복귀 참 emails[size] = email 첫 번째 쓰기 age[size] = value 두 번째 쓰기 size++ 사용 영역 확장 added 출력 다음 while 조건 running 및 hasNextLine()
검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full은상세입력미읽기/invalid는쓰기없음,성공emails→age→size++순차;모두switch끝낸뒤while조건,동시원자성아님 size >= capacity? 공간이 없는가? 참 full · switch 종료 상세 입력도 읽지 않음 거짓 이메일·나이 입력 trim / parseInt 완전한 정수 입력 전제 이메일 있고 14–120 나이인가? 참 emails[size] = email age[size] = value size++ 사용 영역 확장 added 출력 거짓 invalid · switch 종료 배열·size 쓰기 없음 다음 while 조건 running 및 hasNextLine()
  1. 공간을 먼저 검사

    size >= emails.length이면 full을 출력하고 break로 switch를 끝냅니다. 상세 입력을 읽거나 배열에 쓰지 않고 4단계로 갑니다.

    공간이 남았을 때만 다음 검사를 진행합니다.

  2. 이메일·정수 나이를 읽고 값 검사

    trim()으로 이메일을 정리하고 Integer.parseInt로 나이를 변환합니다. 이메일이 비었거나 나이가14–120 밖이면 invalid 출력 후 switch를 끝내고 4단계로 갑니다.

    입력이 완전하고 나이 문자열이 정수라는 예제의 전제 아래, 통과한 값만 기록합니다.

  3. 같은 위치에 순차 기록 → 마지막에 개수 증가

    ① 이메일

    emails[size] = email;

    ② 나이

    age[size] = value;

    ③ 사용 영역 확장

    size++;

    added 출력

  4. 다음 명령의 while 조건으로

    거절과 성공 모두 running && scanner.hasNextLine()을 다시 검사합니다. 참이면 다음 명령을 읽고, 거짓이면 반복이 끝납니다.

    1·2단계의 break는 switch를 끝냅니다. 바깥 while을 직접 끝내지 않습니다.

숫자 파싱 실패와 입력 조기 종료의 복구는 이 흐름에 없습니다. 두 쓰기는 원자적 동시 작업이나 예외 롤백이 아닌 순차 실행입니다.

이 예의 조건

  • 순차 기록이며 동시 원자성을 보장하는 예제가 아닙니다.
  • 상세 입력은 모두 존재하고 나이 입력은 parseInt가 변환할 수 있는 정수 문자열이라는 전제입니다.
  • NumberFormatException과 조기 EOF를 invalid로 복구하는 코드가 없습니다. invalid는 변환 뒤 이메일·나이 값 조건 실패입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2full은 상세 입력 미읽기/invalid는 쓰기 없음, 성공은 emails→age→size++의 순차 실행; 모두 switch를 끝낸 뒤 while 조건으로 돌아가며 동시 원자성이 아님
10trim
11parseInt
13나이 14–120?
원 도표의 설명과 주석
구분내용
설명full은 상세 입력 미읽기/invalid는 쓰기 없음, 성공은 emails→age→size++의 순차 실행; 모두 switch를 끝낸 뒤 while 조건으로 돌아가며 동시 원자성이 아님
전체 읽기 정보추가 실패는 저장 범위를 보존하고, 두 배열 쓰기 뒤 size를 확정한다 ArrayMemberRegistry의 add 분기. 공간과 값을 검사한 뒤에만 emails와 age를 순차 기록하고 마지막으로 size를 늘린다. full과 invalid는 쓰기 없이 switch를 빠져나가 다음 명령 조건으로 돌아간다. 전제는 같은 용량의 두 배열, 유효한 size, 완전한 정수 입력입니다. 두 배열의 쓰기는 순차 실행이며 트랜잭션이나 예외 롤백 보장을 뜻하지 않습니다. MEMBER · STATE CONTRACT 검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full과 invalid는 배열을 바꾸지 않고 switch를 끝냅니다. 성공은 같은 index에 이메일·나이를 순서대로 기록한 뒤 사용 영역을 확장합니다. ArrayMemberRegistry 의 add 명령 · 같은 배열 용량 · 0 ≤ size ≤ capacity · 완전한 정수 입력 전제 공간을 먼저 검사 size >= emails.length 이면 full 을 출력하고 break 로 switch를 끝냅니다. 상세 입력을 읽거나 배열에 쓰지 않고 4단계로 갑니다. 공간이 남았을 때만 다음 검사를 진행합니다. 이메일·정수 나이를 읽고 값 검사 trim() 으로 이메일을 정리하고 Integer.parseInt 로 나이를 변환합니다. 이메일이 비었거나 나이가 14–120 밖이면 invalid 출력 후 switch를 끝내고 4단계로 갑니다. 입력이 완전하고 나이 문자열이 정수라는 예제의 전제 아래, 통과한 값만 기록합니다. 같은 위치에 순차 기록 → 마지막에 개수 증가 ① 이메일 emails[size] = email; ② 나이 age[size] = value; ③ 사용 영역 확장 size++; added 출력 다음 명령의 while 조건으로 거절과 성공 모두 running && scanner.hasNextLine() 을 다시 검사합니다. 참이면 다음 명령을 읽고, 거짓이면 반복이 끝납니다. 1·2단계의 break는 switch를 끝냅니다. 바깥 while을 직접 끝내지 않습니다. 숫자 파싱 실패와 입력 조기 종료의 복구는 이 흐름에 없습니다. 두 쓰기는 원자적 동시 작업이나 예외 롤백이 아닌 순차 실행입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공간 가드

size≥capacity이면 상세 입력 없이 full입니다.

  • full: switch 종료
  • 공간 있음: email·age 읽기
  • 사용 영역 끝에 쓸 공간이 있어야 입력을 받습니다.
입력 검증

완전한 정수 입력을 trim과 parseInt로 읽은 뒤 이메일·14–120 값 조건을 검사합니다.

  • 값 조건 실패: invalid, 쓰기 없음
  • 통과: index=size 사용
  • 이메일·나이 값 조건 실패는 쓰기 없이 invalid로 복귀하며 사용 영역을 늘리지 않습니다.
기록 후 size 증가

emails[size],age[size]를 차례로 쓴 뒤 size++합니다.

  • 순서: email→age→size
  • 다음: added 후 while 조건
  • 두 칸 기록 뒤 사용 범위를 확장합니다.

추가 실패는 저장 범위를 보존하고, 두 배열 쓰기 뒤 size를 확정한다

용량 확인 전에는 상세 입력을 읽지 않고 검증 후 두 배열의 같은 칸을 쓴 다음 size를 올립니다. 저장 확정 시점과 거절 시 미소비 입력을 실제 순서로 보여 줄 수 있습니다.

ArrayMemberRegistry의 add 분기. 공간과 값을 검사한 뒤에만 emails와 age를 순차 기록하고 마지막으로 size를 늘린다. full과 invalid는 쓰기 없이 switch를 빠져나가 다음 명령 조건으로 돌아간다.

전제는 같은 용량의 두 배열, 유효한 size, 완전한 정수 입력입니다. 두 배열의 쓰기는 순차 실행이며 트랜잭션이나 예외 롤백 보장을 뜻하지 않습니다.

MEMBER · STATE CONTRACT

검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다

full과 invalid는 배열을 바꾸지 않고 switch를 끝냅니다. 성공은 같은 index에 이메일·나이를 순서대로 기록한 뒤 사용 영역을 확장합니다.

ArrayMemberRegistry의 add 명령 · 같은 배열 용량 · 0 ≤ size ≤ capacity · 완전한 정수 입력 전제

검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full은상세입력미읽기/invalid는쓰기없음,성공emails→age→size++순차;모두switch끝낸뒤while조건,동시원자성아님 size >= capacity? 공간 부족? 참 full · switch 종료 상세 입력 미읽기 거짓 이메일·정수 입력 trim / parseInt 이메일 있고 나이14–120? 거짓 invalid · switch 종료 쓰기 없이 복귀 참 emails[size] = email 첫 번째 쓰기 age[size] = value 두 번째 쓰기 size++ 사용 영역 확장 added 출력 다음 while 조건 running 및 hasNextLine()
검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full은상세입력미읽기/invalid는쓰기없음,성공emails→age→size++순차;모두switch끝낸뒤while조건,동시원자성아님 size >= capacity? 공간이 없는가? 참 full · switch 종료 상세 입력도 읽지 않음 거짓 이메일·나이 입력 trim / parseInt 완전한 정수 입력 전제 이메일 있고 14–120 나이인가? 참 emails[size] = email age[size] = value size++ 사용 영역 확장 added 출력 거짓 invalid · switch 종료 배열·size 쓰기 없음 다음 while 조건 running 및 hasNextLine()
  1. 공간을 먼저 검사

    size >= emails.length이면 full을 출력하고 break로 switch를 끝냅니다. 상세 입력을 읽거나 배열에 쓰지 않고 4단계로 갑니다.

    공간이 남았을 때만 다음 검사를 진행합니다.

  2. 이메일·정수 나이를 읽고 값 검사

    trim()으로 이메일을 정리하고 Integer.parseInt로 나이를 변환합니다. 이메일이 비었거나 나이가14–120 밖이면 invalid 출력 후 switch를 끝내고 4단계로 갑니다.

    입력이 완전하고 나이 문자열이 정수라는 예제의 전제 아래, 통과한 값만 기록합니다.

  3. 같은 위치에 순차 기록 → 마지막에 개수 증가

    ① 이메일

    emails[size] = email;

    ② 나이

    age[size] = value;

    ③ 사용 영역 확장

    size++;

    added 출력

  4. 다음 명령의 while 조건으로

    거절과 성공 모두 running && scanner.hasNextLine()을 다시 검사합니다. 참이면 다음 명령을 읽고, 거짓이면 반복이 끝납니다.

    1·2단계의 break는 switch를 끝냅니다. 바깥 while을 직접 끝내지 않습니다.

숫자 파싱 실패와 입력 조기 종료의 복구는 이 흐름에 없습니다. 두 쓰기는 원자적 동시 작업이나 예외 롤백이 아닌 순차 실행입니다.

이 예의 조건

  • 순차 기록이며 동시 원자성을 보장하는 예제가 아닙니다.
  • 상세 입력은 모두 존재하고 나이 입력은 parseInt가 변환할 수 있는 정수 문자열이라는 전제입니다.
  • NumberFormatException과 조기 EOF를 invalid로 복구하는 코드가 없습니다. invalid는 변환 뒤 이메일·나이 값 조건 실패입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2full은 상세 입력 미읽기/invalid는 쓰기 없음, 성공은 emails→age→size++의 순차 실행; 모두 switch를 끝낸 뒤 while 조건으로 돌아가며 동시 원자성이 아님
10trim
11parseInt
13나이 14–120?
원 도표의 설명과 주석
구분내용
설명full은 상세 입력 미읽기/invalid는 쓰기 없음, 성공은 emails→age→size++의 순차 실행; 모두 switch를 끝낸 뒤 while 조건으로 돌아가며 동시 원자성이 아님
전체 읽기 정보추가 실패는 저장 범위를 보존하고, 두 배열 쓰기 뒤 size를 확정한다 ArrayMemberRegistry의 add 분기. 공간과 값을 검사한 뒤에만 emails와 age를 순차 기록하고 마지막으로 size를 늘린다. full과 invalid는 쓰기 없이 switch를 빠져나가 다음 명령 조건으로 돌아간다. 전제는 같은 용량의 두 배열, 유효한 size, 완전한 정수 입력입니다. 두 배열의 쓰기는 순차 실행이며 트랜잭션이나 예외 롤백 보장을 뜻하지 않습니다. MEMBER · STATE CONTRACT 검사를 통과한 뒤 두 값을 쓰고 size를 늘립니다 full과 invalid는 배열을 바꾸지 않고 switch를 끝냅니다. 성공은 같은 index에 이메일·나이를 순서대로 기록한 뒤 사용 영역을 확장합니다. ArrayMemberRegistry 의 add 명령 · 같은 배열 용량 · 0 ≤ size ≤ capacity · 완전한 정수 입력 전제 공간을 먼저 검사 size >= emails.length 이면 full 을 출력하고 break 로 switch를 끝냅니다. 상세 입력을 읽거나 배열에 쓰지 않고 4단계로 갑니다. 공간이 남았을 때만 다음 검사를 진행합니다. 이메일·정수 나이를 읽고 값 검사 trim() 으로 이메일을 정리하고 Integer.parseInt 로 나이를 변환합니다. 이메일이 비었거나 나이가 14–120 밖이면 invalid 출력 후 switch를 끝내고 4단계로 갑니다. 입력이 완전하고 나이 문자열이 정수라는 예제의 전제 아래, 통과한 값만 기록합니다. 같은 위치에 순차 기록 → 마지막에 개수 증가 ① 이메일 emails[size] = email; ② 나이 age[size] = value; ③ 사용 영역 확장 size++; added 출력 다음 명령의 while 조건으로 거절과 성공 모두 running && scanner.hasNextLine() 을 다시 검사합니다. 참이면 다음 명령을 읽고, 거짓이면 반복이 끝납니다. 1·2단계의 break는 switch를 끝냅니다. 바깥 while을 직접 끝내지 않습니다. 숫자 파싱 실패와 입력 조기 종료의 복구는 이 흐름에 없습니다. 두 쓰기는 원자적 동시 작업이나 예외 롤백이 아닌 순차 실행입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공간 가드

size≥capacity이면 상세 입력 없이 full입니다.

  • full: switch 종료
  • 공간 있음: email·age 읽기
  • 사용 영역 끝에 쓸 공간이 있어야 입력을 받습니다.
입력 검증

완전한 정수 입력을 trim과 parseInt로 읽은 뒤 이메일·14–120 값 조건을 검사합니다.

  • 값 조건 실패: invalid, 쓰기 없음
  • 통과: index=size 사용
  • 이메일·나이 값 조건 실패는 쓰기 없이 invalid로 복귀하며 사용 영역을 늘리지 않습니다.
기록 후 size 증가

emails[size],age[size]를 차례로 쓴 뒤 size++합니다.

  • 순서: email→age→size
  • 다음: added 후 while 조건
  • 두 칸 기록 뒤 사용 범위를 확장합니다.

가득 찼을 때 남는 입력 줄

용량이 가득 찼을 때의 동작을 보려면 두 배열의 길이를 함께 3에서 1로 바꾸고 같은 입력으로 실행해 보세요. 출력은 다음과 같습니다.

실행 결과 (용량 1)
added
full
unknown
unknown
1. [email protected]=40
total=40, average=40.0

첫 회원은 저장되지만 두 번째 add에서는 자리가 없어 full을 출력하고 switch를 끝냅니다. 이때 이메일과 나이는 아직 읽기 전이므로 [email protected]과 50 두 줄이 입력에 그대로 남습니다. 그래서 다음 두 번의 반복이 이 줄들을 명령으로 읽고, 둘 다 아는 명령이 아니어서 unknown을 출력합니다. 그 뒤의 목록에는 첫 회원만 나오고 합계와 평균은 40과 40.0입니다.

이 동작이 맞는지는 프로그램을 어떻게 쓰느냐에 달려 있습니다. 사람이 키보드로 한 줄씩 입력하는 대화형 프로그램이라면, 자리가 없을 때 이메일과 나이를 묻지 않고 바로 full을 알리는 지금 순서가 맞습니다. 저장하지도 못할 정보를 입력하게 만들 필요가 없으니까요. 반대로 이 예제처럼 입력이 미리 묶여 있다면 거절한 명령의 나머지 줄을 건너뛰는 처리가 따로 필요합니다. 입력을 언제 읽을지와 실패한 뒤 남은 입력을 어떻게 처리할지는 함께 정해야 하는 문제입니다.

키보드로 직접 입력하기

키보드로 직접 입력하려면 Scanner를 만드는 줄만 Scanner scanner = new Scanner(System.in);으로 바꾸면 됩니다. 이 프로그램은 입력 안내 문구를 출력하지 않으므로, 실행하면 화면에 직접 친 줄과 프로그램의 출력이 번갈아 나타납니다. add, 이메일, 나이를 차례로 입력하고 list, quit을 입력하면 다음과 같습니다.

입력과 출력
add
[email protected]
40
added
list
1. [email protected]=40
quit

다만 이 프로그램은 add 뒤에 이메일과 정수 나이 두 줄이 반드시 온다고 가정합니다. 나이에 forty를 입력하면 Integer.parseInt가 NumberFormatException을 던지며 프로그램이 멈추고, add만 보낸 채 입력이 끝나 버리면 nextLine이 NoSuchElementException을 던집니다. 잘못된 입력 뒤에도 계속 동작하게 하려면 입력 검증과 재입력 반복에서 배운 hasNextLine 검사와 try/catch를 더해야 합니다.

이 프로그램이 지키는 네 가지 규칙

코드가 길어 보여도, 저장소가 올바르려면 몇 가지 조건만 늘 참이면 됩니다. 프로그램이 실행되는 동안 언제나 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 이 프로그램의 불변식은 다음 네 가지입니다.

  1. size는 0 이상이고 배열 길이 이하입니다.
  2. 인덱스 0부터 size - 1까지는 두 배열 모두 같은 회원의 값이 들어 있습니다.
  3. 인덱스 size부터 length - 1까지는 아직 쓰지 않은 칸이므로 회원으로 읽지 않습니다.
  4. 인덱스 0부터 size - 1까지 저장된 회원은 모두 검사를 통과한 값입니다. 이메일은 비어 있지 않고 나이는 14 이상 120 이하입니다.

add의 검사 순서, list와 summary의 반복 범위, 두 배열에 다 쓴 뒤에야 size를 늘리는 size++의 위치는 모두 이 네 가지를 지키기 위한 것입니다. 검사 순서는 1번과 4번을, 반복 범위는 3번을, size++의 위치는 2번을 지킵니다. 새 기능을 추가할 때도 이 규칙이 깨지지 않는지부터 확인하면 됩니다. 예를 들어 회원 삭제 기능을 만든다면, 삭제한 뒤에도 2번 규칙대로 회원이 빈틈없이 앞에서부터 채워져 있어야 합니다(연습 문제 2).

이 구조는 회원 목록에만 쓰이는 것이 아닙니다. 상품 관리 예제도 흔히 상품명과 가격을 병렬 배열에 넣고 추가와 목록 출력을 반복하는데, 단어만 바꾸면 회원 목록과 똑같습니다.

상품 관리회원 목록공통점
상품명 배열이메일 배열같은 인덱스의 문자열이 이름 역할
가격 배열나이 배열같은 인덱스의 숫자가 속성 역할
상품 수회원 수0 <= size && size <= length
등록 가능 수 초과 방지회원 용량 초과 방지추가 전에 용량 검사

다만 무엇을 받아들일지는 데이터마다 다릅니다. 무료 상품이 있다면 가격 0은 허용해야 하지만, 회원 나이 0은 거절해야 합니다. 저장하는 방법은 그대로 가져다 쓰고, 검증 규칙은 데이터에 맞게 정합니다.

정리

  • 배열의 length는 담을 수 있는 칸의 수(용량)이고, size는 실제로 저장된 회원 수입니다. 회원을 읽고 합산하는 반복과 평균의 분모에는 size를 씁니다.
  • 회원을 추가할 때는 자리 확인, 두 배열에 기록, size 증가의 순서를 지킵니다. 자리 확인을 빼면 ArrayIndexOutOfBoundsException으로 프로그램이 멈춥니다.
  • 정수끼리 나누면 소수 부분이 버려지므로, 평균은 (double) total / size처럼 나누기 전에 한쪽을 double로 바꿉니다. 회원이 없을 때 보여 줄 결과는 나누기 전에 따로 정합니다.
  • 최솟값과 최댓값은 실제 회원인 인덱스 0에서 출발합니다. 값 대신 인덱스를 기억하면 같은 회원의 이메일도 함께 찾을 수 있습니다.

연습 문제

1. 조건에 맞는 회원만 집계하기

나이 배열과 size가 주어졌을 때 30세 이상인 회원의 수, 나이 합계, 평균을 출력하세요. 조건을 만족하는 회원이 하나도 없으면 평균 대신 none을 출력합니다.

해설 보기
src/QualifiedSummary.java
public final class QualifiedSummary {
    public static void main(String[] args) {
        int[] age = {20, 45, 30, 60, 0};
        int size = 4;
        int count = 0;
        int total = 0;

        for (int index = 0; index < size; index++) {
            if (age[index] >= 30) {
                count++;
                total += age[index];
            }
        }

        System.out.println("count=" + count);
        System.out.println("total=" + total);
        if (count == 0) {
            System.out.println("average=none");
        } else {
            System.out.println("average=" + (double) total / count);
        }
    }
}
실행 결과
count=3
total=135
average=45.0

반복은 size까지만 돌므로 마지막 칸의 0은 처음부터 대상이 아닙니다. 회원 네 명 가운데 45, 30, 60이 조건을 만족해 count는 3, total은 135입니다. 평균의 분모는 전체 회원 수 size가 아니라 조건을 통과한 count라는 점이 핵심입니다. 135를 4로 나누면 '30세 이상 회원의 평균'이 아니게 되므로, 걸러 낸 뒤 집계할 때는 어떤 집합의 평균인지 끝까지 맞춰야 합니다. count가 0이면 나누기 전에 none을 출력해 0으로 나누는 일도 막습니다.

2. 회원 삭제하기

회원 세 명이 저장된 상태에서 인덱스 0의 회원을 삭제하는 코드를 작성하세요. 삭제한 뒤에도 0부터 size - 1까지는 빈틈없이 회원이 있어야 하고, 두 배열의 같은 인덱스는 같은 회원이어야 합니다. 삭제할 인덱스에 회원이 없으면 안내 문구만 출력합니다.

해설 보기
src/RemoveMember.java
public final class RemoveMember {
    public static void main(String[] args) {
        String[] emails = {"[email protected]", "[email protected]", "[email protected]", null};
        int[] age = {40, 50, 35, 0};
        int size = 3;
        int target = 0;

        if (target < 0 || target >= size) {
            System.out.println("no member at " + target);
            return;
        }
        for (int index = target; index < size - 1; index++) {
            emails[index] = emails[index + 1];
            age[index] = age[index + 1];
        }
        size--;
        emails[size] = null;
        age[size] = 0;

        for (int index = 0; index < size; index++) {
            System.out.println((index + 1) + ". " + emails[index] + "=" + age[index]);
        }
        System.out.println("size=" + size + ", emails[2]=" + emails[2] + ", age[2]=" + age[2]);
    }
}
실행 결과
1. [email protected]=50
2. [email protected]=35
size=2, emails[2]=null, age[2]=0

삭제한 칸부터 뒤의 회원을 한 칸씩 앞으로 당깁니다. 인덱스 0에는 [email protected]이, 인덱스 1에는 [email protected]이 옮겨 오고, 두 배열을 같은 반복 안에서 함께 옮기므로 같은 인덱스의 이메일과 나이가 계속 같은 회원으로 남습니다. 반복 조건이 index < size - 1인 이유는 index + 1이 마지막 회원의 인덱스 size - 1을 넘지 않게 하기 위해서입니다.

다 옮긴 뒤 size를 1 줄이면 마지막 회원이 있던 인덱스 2는 사용하지 않는 칸이 됩니다. 그 칸을 null과 0으로 지우지 않아도 목록 출력은 같습니다. 하지만 지우지 않으면 마지막 줄에 emails[2][email protected], age[2]=35가 나옵니다. 같은 회원이 인덱스 1과 2에 두 번 들어 있는 셈이라, 나중에 배열 전체를 살펴볼 때 혼란을 줍니다. 처음의 범위 검사는 target이 회원이 있는 0부터 size - 1 사이가 아닐 때 아무것도 바꾸지 않고 끝내는 역할을 합니다.

다음 글

지금은 모든 코드가 main 하나에 들어 있습니다. 나이를 합산하는 반복문은 summary에 있는데, 나중에 보고서 메뉴를 추가하면 같은 반복문을 또 쓰게 될 것입니다. 메서드와 매개변수에서는 이런 코드를 이름 붙은 메서드로 묶어 한곳에서 관리하는 방법을 배웁니다.