객체 배열로 회원 저장소 만들기
4장에서 배운 클래스, 객체 배열, 값 복사, 메서드 반환, null을 모아 회원을 입력받아 저장하고 검색하는 콘솔 프로그램을 완성하고, 그 안에서 값과 참조가 어떻게 움직이는지 따라갑니다.
지금까지는 참조의 성질을 하나씩 보려고 작은 프로그램을 따로따로 실행했습니다. 이번에는 그 조각들을 모아 실제로 쓸 수 있는 프로그램을 만듭니다. 키보드로 회원 세 명의 이메일과 나이를 입력받아 객체 배열에 저장하고, 전체 목록을 출력한 뒤, 이메일로 회원을 찾는 명령줄 프로그램(CLI, command-line interface)입니다.
목표는 기능을 많이 넣는 것이 아닙니다. 입력한 문자열과 숫자, 새로 만든 객체의 참조, 배열 칸에 저장한 참조가 프로그램의 어느 지점에서 복사되는지를 처음부터 끝까지 따라가 보는 것입니다. 그 과정에서 앞 글의 연습 문제에서 남겨 둔 조건, 곧 '앞쪽 size개 칸에는 반드시 회원이 있다'를 코드가 어떻게 지키는지도 확인합니다.
이번 버전의 Member도 여전히 필드만 가진 데이터 묶음이고, 입력·검사·출력 메서드는 모두 실행 클래스 MemberRegistryCli에 있습니다. 이 가운데 무엇을 Member 안으로 옮겨야 하는지는 5장에서 다룹니다.
회원 클래스와 저장 공간
Member는 앞 글들과 같은 세 필드를 가집니다. 4장의 마지막 버전이므로 src/members/ch04/finalv 디렉터리에 둡니다.
public class Member {
String email;
int age;
boolean active;
}회원을 담을 곳은 길이 3인 배열과, 지금까지 채운 칸의 수를 세는 size 변수입니다. 처음에는 회원이 하나도 없으므로 size는 0에서 시작합니다.
Member[] members = new Member[3];
int size = 0;3장에서 배열로 회원 목록을 관리할 때처럼 배열 길이는 담을 수 있는 최대 개수, 곧 용량이고 size는 실제로 채운 칸의 수입니다. 회원을 하나 저장할 때마다 members[size]에 넣고 size를 1 늘리면, 다음 회원은 자연스럽게 그다음 빈칸에 들어갑니다. 출력이나 검색은 0부터 size - 1까지만 순회하므로 아직 비어 있는 null 칸은 읽지 않습니다.
이 방식이 안전하려면 '앞쪽 size개 칸에는 언제나 null이 아닌 회원이 있다'는 조건이 항상 성립해야 합니다. 이처럼 프로그램이 실행되는 동안 늘 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 기호로 쓰면 다음과 같습니다.
0 <= i < size인 모든 i에 대해members[i]는null이 아니다.
배열 범위나 size 검사만으로는 이 조건이 지켜지지 않습니다. 회원을 저장하는 코드가 매번 올바른 순서로 움직여야 합니다.
저장 순서: 객체를 다 만든 뒤에 size를 늘린다
회원 하나를 저장하는 과정은 네 단계입니다. 입력을 읽고 검사한 다음, 완성된 Member를 만들고, 그 참조를 members[size]에 저장한 뒤, 마지막으로 size를 늘립니다. 마지막 두 단계의 순서가 왜 중요한지는 순서를 거꾸로 해 보면 알 수 있습니다. 다음 코드는 빈칸을 미리 예약해 둔다는 생각으로 size부터 늘립니다.
int index = size;
size++;
Member member = createMember(email, age, false);
members[index] = member;createMember는 뒤에 나올 전체 프로그램에 있는 메서드로, 나이가 14세부터 120세 사이가 아니면 IllegalArgumentException을 던집니다. 빈 저장소에서 나이 200으로 이 코드를 실행했다고 해 봅시다. size는 이미 1이 되었는데 예외 때문에 members[index] = member는 실행되지 않습니다. 예외는 이미 실행된 size++를 되돌려 주지 않으므로, 0번 칸은 null인데 size는 1인 상태가 남습니다.
| 시점 | size | members[0] |
|---|---|---|
| 시작 | 0 | null |
size++ 뒤 | 1 | null |
createMember에서 예외 | 1 | null (저장되지 않음) |
이제 불변식이 깨졌습니다. 나중에 예외를 잡아서 프로그램이 계속 실행된다면, 회원 목록을 출력하는 for (int i = 0; i < size; i++)가 0번 칸을 정상 회원으로 여기고 members[i].email을 읽다가 NPE를 냅니다. 잘못된 입력은 저장할 때 들어왔는데 예외는 출력할 때 나기 때문에, 원인과 증상이 멀리 떨어져 찾기도 어렵습니다.
그래서 순서를 바꿉니다. 객체가 무사히 만들어진 다음에만 배열과 size를 바꿉니다.
Member member = createMember(email, age, false);
members[size] = member;
size++;이번에는 createMember에서 예외가 나면 그 아래 두 줄이 모두 실행되지 않으므로, size는 0이고 0번 칸도 null인 처음 상태가 그대로 남습니다. 불변식이 깨지지 않았습니다. 두 코드의 차이는 줄 순서 하나지만, 이 순서 덕분에 회원 저장이 '전부 되거나 전혀 안 되거나' 둘 중 하나가 됩니다.
생성 실패 때 size를 보존하는 갱신 순서
같은 생성 실패 입력에서 size를 먼저 올린 버전은 size=1/null, 생성 후 갱신 버전은 size=0/null이 남습니다. 예외가 앞서 실행한 부수 효과를 취소하지 않는다는 점을 갱신 순서별 결과로 확인할 수 있습니다.
생성이 실패하는 같은 조건에서 size를 먼저 늘리면 null 슬롯이 사용 구간에 들어가고, 생성 후에만 저장과 증가를 하면 size와 배열이 그대로입니다.
객체 생성에 성공한 뒤 저장하고 size를 늘립니다
같은 생성 실패를 두 갱신 순서로 비교합니다. size를 먼저 늘리면 null 칸을 회원 범위에 포함하게 됩니다.
| 관찰 지점 | size를 먼저 증가 | 생성 성공 뒤에만 갱신 |
|---|---|---|
| 생성 호출 전 | index = size 후 size++: index = 0, size = 1 | size = 0 그대로 |
| 생성에서 예외 | 잘못된 나이로 createMember가 IllegalArgumentException을 던짐 | 같은 예외가 발생함 |
| 도달하지 못한 연산 | members[index] = member 미실행 | members[size] = member와 size++ 모두 미실행 |
| 예외 직후 상태 | size = 1, members[0] = null. 빈 칸이 사용 구간에 포함됨 | size = 0, members[0] = null. 사용 구간은 여전히 비어 있음 |
- size를 먼저 증가
index = size,size++로 index는 0, size는 1입니다.- 잘못된 나이로
createMember가IllegalArgumentException을 던집니다. members[index] = member는 실행되지 않습니다.
결과:
size = 1,members[0] = null. 빈 칸이 사용 구간에 포함됩니다.- 생성 성공 뒤에만 갱신
size = 0에서 바로 생성 함수를 호출합니다.- 같은 예외가 발생합니다.
- 배열 저장과
size++모두 실행되지 않습니다.
결과:
size = 0,members[0] = null. 사용 구간은 여전히 비어 있습니다.
실제 정의에 맞는 호출은 createMember(email, age, false)입니다. 두 순서를 비교할 때는 같은 유효하지 않은 나이와 나머지 입력을 사용합니다.
이는 생성 실패를 따로 유발한 비교입니다. 전체 CLI는 readMemberAge에서 범위를 먼저 검사합니다. 예외 직후 상태는 진단용 catch에서 관찰할 수 있지만, 원래 조각이 예외 뒤에도 계속 실행한다는 뜻은 아닙니다.
이 예의 조건
- 두 독립 버전은 같은 잘못된 나이 입력에서 시작한다.
- 예외 뒤 상태는 관찰 설명이며 원 실행은 예외로 중단한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 size 0/null과 유효하지 않은 나이로 두 버전을 독립적으로 비교한다. 먼저 증가시키면 size 1/null로 오염되고, 먼저 생성하면 예외 시 size 0/null을 유지한다. 관찰 점선은 catch 후 재개를 보장하지 않으며 전체 CLI는 나이를 사전에 검사한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 size 0/null과 유효하지 않은 나이로 두 버전을 독립적으로 비교한다. 먼저 증가시키면 size 1/null로 오염되고, 먼저 생성하면 예외 시 size 0/null을 유지한다. 관찰 점선은 catch 후 재개를 보장하지 않으며 전체 CLI는 나이를 사전에 검사한다. |
| 전체 읽기 정보 | 생성 실패 때 size를 보존하는 갱신 순서 생성 실패 때 size를 보존하는 갱신 순서 생성이 실패하는 같은 조건에서 size를 먼저 늘리면 null 슬롯이 사용 구간에 들어가고, 생성 후에만 저장과 증가를 하면 size와 배열이 그대로입니다. 객체 생성에 성공한 뒤 저장하고 size를 늘립니다 같은 생성 실패를 두 갱신 순서로 비교합니다. size를 먼저 늘리면 null 칸을 회원 범위에 포함하게 됩니다. 실패 시 상태 비교 · 시작은 size = 0, members[0] = null 관찰 지점 size를 먼저 증가 생성 성공 뒤에만 갱신 생성 호출 전 index = size 후 size++ : index = 0 , size = 1 size = 0 그대로 생성에서 예외 잘못된 나이로 createMember 가 IllegalArgumentException 을 던짐 같은 예외가 발생함 도달하지 못한 연산 members[index] = member 미실행 members[size] = member 와 size++ 모두 미실행 예외 직후 상태 size = 1 , members[0] = null . 빈 칸이 사용 구간에 포함됨 size = 0 , members[0] = null . 사용 구간은 여전히 비어 있음 size를 먼저 증가 index = size , size++ 로 index는 0, size는 1입니다. 잘못된 나이로 createMember 가 IllegalArgumentException 을 던집니다. members[index] = member 는 실행되지 않습니다. 결과: size = 1 , members[0] = null . 빈 칸이 사용 구간에 포함됩니다. 생성 성공 뒤에만 갱신 size = 0 에서 바로 생성 함수를 호출합니다. 같은 예외가 발생합니다. 배열 저장과 size++ 모두 실행되지 않습니다. 결과: size = 0 , members[0] = null . 사용 구간은 여전히 비어 있습니다. 실제 정의에 맞는 호출은 createMember(email, age, false) 입니다. 두 순서를 비교할 때는 같은 유효하지 않은 나이와 나머지 입력을 사용합니다. 이는 생성 실패를 따로 유발한 비교입니다. 전체 CLI는 readMemberAge 에서 범위를 먼저 검사합니다. 예외 직후 상태는 진단용 catch에서 관찰할 수 있지만, 원래 조각이 예외 뒤에도 계속 실행한다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 동일 시작 빈 첫 칸과 size 0을 둡니다. |
|
|
| 생성 전 차이 A는 size를 먼저 늘리고 B는 생성부터 호출합니다. |
|
|
| 생성 예외 두 생성이 실패해 저장에 도달하지 않습니다. |
|
|
생성 실패 때 size를 보존하는 갱신 순서
같은 생성 실패 입력에서 size를 먼저 올린 버전은 size=1/null, 생성 후 갱신 버전은 size=0/null이 남습니다. 예외가 앞서 실행한 부수 효과를 취소하지 않는다는 점을 갱신 순서별 결과로 확인할 수 있습니다.
생성이 실패하는 같은 조건에서 size를 먼저 늘리면 null 슬롯이 사용 구간에 들어가고, 생성 후에만 저장과 증가를 하면 size와 배열이 그대로입니다.
객체 생성에 성공한 뒤 저장하고 size를 늘립니다
같은 생성 실패를 두 갱신 순서로 비교합니다. size를 먼저 늘리면 null 칸을 회원 범위에 포함하게 됩니다.
| 관찰 지점 | size를 먼저 증가 | 생성 성공 뒤에만 갱신 |
|---|---|---|
| 생성 호출 전 | index = size 후 size++: index = 0, size = 1 | size = 0 그대로 |
| 생성에서 예외 | 잘못된 나이로 createMember가 IllegalArgumentException을 던짐 | 같은 예외가 발생함 |
| 도달하지 못한 연산 | members[index] = member 미실행 | members[size] = member와 size++ 모두 미실행 |
| 예외 직후 상태 | size = 1, members[0] = null. 빈 칸이 사용 구간에 포함됨 | size = 0, members[0] = null. 사용 구간은 여전히 비어 있음 |
- size를 먼저 증가
index = size,size++로 index는 0, size는 1입니다.- 잘못된 나이로
createMember가IllegalArgumentException을 던집니다. members[index] = member는 실행되지 않습니다.
결과:
size = 1,members[0] = null. 빈 칸이 사용 구간에 포함됩니다.- 생성 성공 뒤에만 갱신
size = 0에서 바로 생성 함수를 호출합니다.- 같은 예외가 발생합니다.
- 배열 저장과
size++모두 실행되지 않습니다.
결과:
size = 0,members[0] = null. 사용 구간은 여전히 비어 있습니다.
실제 정의에 맞는 호출은 createMember(email, age, false)입니다. 두 순서를 비교할 때는 같은 유효하지 않은 나이와 나머지 입력을 사용합니다.
이는 생성 실패를 따로 유발한 비교입니다. 전체 CLI는 readMemberAge에서 범위를 먼저 검사합니다. 예외 직후 상태는 진단용 catch에서 관찰할 수 있지만, 원래 조각이 예외 뒤에도 계속 실행한다는 뜻은 아닙니다.
이 예의 조건
- 두 독립 버전은 같은 잘못된 나이 입력에서 시작한다.
- 예외 뒤 상태는 관찰 설명이며 원 실행은 예외로 중단한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 size 0/null과 유효하지 않은 나이로 두 버전을 독립적으로 비교한다. 먼저 증가시키면 size 1/null로 오염되고, 먼저 생성하면 예외 시 size 0/null을 유지한다. 관찰 점선은 catch 후 재개를 보장하지 않으며 전체 CLI는 나이를 사전에 검사한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 size 0/null과 유효하지 않은 나이로 두 버전을 독립적으로 비교한다. 먼저 증가시키면 size 1/null로 오염되고, 먼저 생성하면 예외 시 size 0/null을 유지한다. 관찰 점선은 catch 후 재개를 보장하지 않으며 전체 CLI는 나이를 사전에 검사한다. |
| 전체 읽기 정보 | 생성 실패 때 size를 보존하는 갱신 순서 생성 실패 때 size를 보존하는 갱신 순서 생성이 실패하는 같은 조건에서 size를 먼저 늘리면 null 슬롯이 사용 구간에 들어가고, 생성 후에만 저장과 증가를 하면 size와 배열이 그대로입니다. 객체 생성에 성공한 뒤 저장하고 size를 늘립니다 같은 생성 실패를 두 갱신 순서로 비교합니다. size를 먼저 늘리면 null 칸을 회원 범위에 포함하게 됩니다. 실패 시 상태 비교 · 시작은 size = 0, members[0] = null 관찰 지점 size를 먼저 증가 생성 성공 뒤에만 갱신 생성 호출 전 index = size 후 size++ : index = 0 , size = 1 size = 0 그대로 생성에서 예외 잘못된 나이로 createMember 가 IllegalArgumentException 을 던짐 같은 예외가 발생함 도달하지 못한 연산 members[index] = member 미실행 members[size] = member 와 size++ 모두 미실행 예외 직후 상태 size = 1 , members[0] = null . 빈 칸이 사용 구간에 포함됨 size = 0 , members[0] = null . 사용 구간은 여전히 비어 있음 size를 먼저 증가 index = size , size++ 로 index는 0, size는 1입니다. 잘못된 나이로 createMember 가 IllegalArgumentException 을 던집니다. members[index] = member 는 실행되지 않습니다. 결과: size = 1 , members[0] = null . 빈 칸이 사용 구간에 포함됩니다. 생성 성공 뒤에만 갱신 size = 0 에서 바로 생성 함수를 호출합니다. 같은 예외가 발생합니다. 배열 저장과 size++ 모두 실행되지 않습니다. 결과: size = 0 , members[0] = null . 사용 구간은 여전히 비어 있습니다. 실제 정의에 맞는 호출은 createMember(email, age, false) 입니다. 두 순서를 비교할 때는 같은 유효하지 않은 나이와 나머지 입력을 사용합니다. 이는 생성 실패를 따로 유발한 비교입니다. 전체 CLI는 readMemberAge 에서 범위를 먼저 검사합니다. 예외 직후 상태는 진단용 catch에서 관찰할 수 있지만, 원래 조각이 예외 뒤에도 계속 실행한다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 동일 시작 빈 첫 칸과 size 0을 둡니다. |
|
|
| 생성 전 차이 A는 size를 먼저 늘리고 B는 생성부터 호출합니다. |
|
|
| 생성 예외 두 생성이 실패해 저장에 도달하지 않습니다. |
|
|
입력은 한 줄씩 읽고 숫자로 바꾼다
3장에서 Scanner로 입력을 받을 때 본 것처럼 nextInt() 다음에 nextLine()을 호출하면 줄바꿈만 남은 빈 줄을 읽는 문제가 생깁니다. 그래서 이 프로그램은 모든 입력을 nextLine()으로 한 줄씩 읽고, 나이만 Integer.parseInt로 숫자로 바꿉니다.
System.out.print("회원 나이(세): ");
String rawAge = scanner.nextLine();
int age = Integer.parseInt(rawAge);삼십처럼 숫자가 아닌 문자열을 넣으면 Integer.parseInt가 NumberFormatException을 던집니다. 대화형 프로그램이 오타 하나로 끝나 버리면 곤란하므로, 입력 메서드 안에서 이 예외를 잡아 다시 묻습니다.
숫자로 바꾸는 데 성공했다고 해서 끝은 아닙니다. 200도 정수로는 문제가 없지만 회원 나이로는 허용되지 않습니다. 즉 '숫자로 바꿀 수 있는가'와 '회원 나이로 허용되는 값인가'는 서로 다른 검사이므로, 변환한 뒤에 14 이상 120 이하인지를 따로 확인해야 합니다.
전체 프로그램
다음 두 파일을 같은 디렉터리에 둡니다. 실행하기 전에 이 프로그램이 일부러 단순하게 만든 부분을 짚어 두겠습니다.
- 사용자가 회원 세 명의 정보를 끝까지 입력한다고 가정합니다. 도중에 입력이 끝나면(파일 끝, Ctrl+D나 Ctrl+Z)
nextLine()이NoSuchElementException을 던지며 프로그램이 멈춥니다. - 이메일은 앞뒤 공백을 지운 뒤 비어 있는지만 확인합니다.
@가 있는지 같은 이메일 형식은 검사하지 않습니다. - 검색은 대소문자를 구분하지 않고, 같은 이메일이 여러 명이면 먼저 저장된 회원을 돌려줍니다.
import java.util.Scanner;
public class MemberRegistryCli {
private static final int CAPACITY = 3;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Member[] members = new Member[CAPACITY];
int size = 0;
while (size < members.length) {
System.out.println("\n[" + (size + 1) + "/" + members.length + "]");
String email = readEmail(scanner);
int age = readMemberAge(scanner);
Member member = createMember(email, age, false);
members[size] = member;
size++;
}
printMembers(members, size);
System.out.print("\n찾을 이메일: ");
String keyword = scanner.nextLine().trim();
Member found = findByEmail(members, size, keyword);
printSearchResult(found);
}
static String readEmail(Scanner scanner) {
while (true) {
System.out.print("이메일: ");
String email = scanner.nextLine().trim();
if (!email.isEmpty()) {
return email;
}
System.out.println("이메일은 비워 둘 수 없습니다.");
}
}
static int readMemberAge(Scanner scanner) {
while (true) {
System.out.print("회원 나이(세): ");
String input = scanner.nextLine().trim();
try {
int age = Integer.parseInt(input);
if (age >= 14 && age <= 120) {
return age;
}
System.out.println("회원 나이는 14세 이상 120세 이하여야 합니다.");
} catch (NumberFormatException exception) {
System.out.println("정수를 입력해 주세요.");
}
}
}
static Member createMember(
String email,
int age,
boolean active
) {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
Member member = new Member();
member.email = email;
member.age = age;
member.active = active;
return member;
}
static void printMembers(Member[] members, int size) {
System.out.println("\n== 회원 저장소 ==");
for (int i = 0; i < size; i++) {
Member member = members[i];
System.out.printf(
"%d. %s / %d세 / %s%n",
i + 1,
member.email,
member.age,
member.active ? "활성" : "비활성"
);
}
}
static Member findByEmail(
Member[] members,
int size,
String email
) {
for (int i = 0; i < size; i++) {
Member member = members[i];
if (member.email.equalsIgnoreCase(email)) {
return member;
}
}
return null;
}
static void printSearchResult(Member found) {
if (found == null) {
System.out.println("일치하는 회원이 없습니다.");
return;
}
System.out.println(
"검색 결과: " + found.email + " / " + found.age + "세"
);
}
}main은 size가 배열 길이보다 작은 동안 회원 하나를 입력받아 저장하는 일을 반복합니다. 반복 한 번은 앞에서 정한 순서 그대로입니다. readEmail과 readMemberAge가 검사를 통과한 값을 돌려줄 때까지 다시 묻고, createMember가 객체를 만들면, 그 참조를 members[size]에 저장하고 size를 늘립니다. 세 칸을 모두 채우면 반복이 끝나고, 전체 목록을 출력한 뒤 검색어를 한 줄 읽어 결과를 보여 줍니다.
코드에 처음 나오는 요소도 몇 가지 있습니다.
CAPACITY는private static final로 선언한 상수로, 프로그램 안에서 바뀌지 않는 용량 3에 이름을 붙인 것입니다.static과final의 정확한 의미는 6장에서 배웁니다.trim()은 문자열 앞뒤의 공백을 지운 새 문자열을 돌려줍니다. 3장에서 쓴strip()과의 차이는 Scanner로 키보드 입력 받기에서 설명했고, 키보드로 입력하는 일반적인 공백에는 둘이 똑같이 동작합니다.member.active ? "활성" : "비활성"은 2장에서 배운 삼항 연산자로,active가true면"활성"을, 아니면"비활성"을 고릅니다.equalsIgnoreCase는 대소문자를 무시하고 두 문자열을 비교하는String메서드입니다. 그래서[email protected]으로 검색해도 lee 회원을 찾습니다.
readMemberAge가 이미 검사했는데 createMember는 왜 또 검사할까
readMemberAge가 14부터 120 사이의 값만 돌려주므로, 이 프로그램의 main에서는 createMember의 검사가 실패할 일이 없습니다. 그래도 검사를 남겨 두는 이유는 createMember가 main 말고 다른 곳에서도 호출될 수 있는 메서드이기 때문입니다. 나중에 파일에서 회원을 읽어 오는 기능처럼 키보드 입력을 거치지 않고 createMember를 부르는 코드가 생겨도, 이 검사가 잘못된 회원 객체가 만들어지는 것을 마지막으로 막아 줍니다. 앞에서 본 저장 순서는 바로 이 검사가 예외를 던지는 경우를 대비한 것입니다.
컴파일하고 실행하기
작업 폴더에서 두 파일을 함께 컴파일하고 실행합니다. MemberRegistryCli가 Member 클래스를 쓰므로 javac에는 두 소스를 모두 넘기고, 실행할 때는 main이 있는 MemberRegistryCli만 적습니다.
javac -d out src/members/ch04/finalv/Member.java src/members/ch04/finalv/MemberRegistryCli.java
java -cp out MemberRegistryCli다음은 두 번째 회원의 나이에 삼십을 잘못 입력했다가 다시 입력한 실행 예입니다. 사용자가 입력한 부분은 이메일:, 회원 나이(세):, 찾을 이메일: 뒤에 있습니다.
[1/3]
이메일: [email protected]
회원 나이(세): 35
[2/3]
이메일: [email protected]
회원 나이(세): 삼십
정수를 입력해 주세요.
회원 나이(세): 30
[3/3]
이메일: [email protected]
회원 나이(세): 20
== 회원 저장소 ==
1. [email protected] / 35세 / 비활성
2. [email protected] / 30세 / 비활성
3. [email protected] / 20세 / 비활성
찾을 이메일: [email protected]
검색 결과: [email protected] / 30세[1/3] 위에는 빈 줄이 하나 더 출력됩니다. System.out.println("\n[" + ...)가 줄바꿈 문자 \n으로 시작하기 때문이며, 같은 이유로 회원과 회원 사이에도 빈 줄이 들어갑니다. 삼십을 입력했을 때는 Integer.parseInt가 NumberFormatException을 던지고, readMemberAge가 그 예외를 잡아 안내를 출력한 뒤 나이를 다시 묻습니다. 이 재입력은 readMemberAge 안의 반복에서 일어나므로 main의 반복은 아직 두 번째 회원에 머물러 있고, 객체를 만들지 않았으니 size도 1 그대로입니다. 세 회원을 모두 받으면 size가 3이 되어 반복 조건 3 < 3이 거짓이 되고, 목록 출력과 검색으로 넘어갑니다.
MemberRegistryCli의 입력 반복과 검색 진입
용량 조건을 확인하고 입력값 확정·Member 생성·배열 저장·size 증가를 반복해 세 회원 후 출력과 검색을 합니다. 현재 슬롯과 읽기/확정 단계를 고정하여 CLI의 실제 순서를 보여 줄 수 있습니다.
길이 3의 배열에서 size가 3보다 작은 동안 입력, 생성, 저장, 증가를 반복하고 세 회원을 채운 뒤 전체 출력과 이메일 검색으로 진행합니다.
세 회원을 저장한 뒤 출력과 검색으로 진행합니다
size < 3 동안 입력 확정→객체 생성→저장→size 증가를 반복합니다. 세 회원을 채우면 전체 출력과 이메일 검색을 실행합니다.
- 길이 3인
Member[]를 만들고size = 0으로 시작합니다. size < 3이면readEmail과readMemberAge로 입력값을 확정합니다. 거절된 값은 각 메서드 내부에서 다시 묻습니다.createMember(email, age, false)의 새 객체 참조를member에 보관합니다.members[size] = member로 먼저 저장한 뒤size++합니다. 반복 조건으로 돌아갑니다.- 세 회원을 채워 조건이 거짓이면
printMembers로 전체 출력하고, 이메일 검색과 결과 출력을 한 뒤 종료합니다.
검색은 findByEmail의 참조 또는 null 반환을 printSearchResult가 처리합니다. 입력 나이는 정수 14~120, 이메일은 trim 후 비어 있지 않은지만 검사합니다.
이 흐름은 본문의 전체 CLI가 정상 입력 스트림으로 진행하는 경로입니다. 조기 종료 메뉴나 EOF 복구는 구현되어 있지 않습니다.
이 예의 조건
- 배열 길이 3, size 0에서 시작하며 거절 입력은 메서드 내부에서 재입력합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | MemberRegistryCli의 입력 반복과 검색 진입 MemberRegistryCli의 입력 반복과 검색 진입 길이 3의 배열에서 size가 3보다 작은 동안 입력, 생성, 저장, 증가를 반복하고 세 회원을 채운 뒤 전체 출력과 이메일 검색으로 진행합니다. 세 회원을 저장한 뒤 출력과 검색으로 진행합니다 size < 3 동안 입력 확정→객체 생성→저장→size 증가를 반복합니다. 세 회원을 채우면 전체 출력과 이메일 검색을 실행합니다. 길이 3인 Member[] 를 만들고 size = 0 으로 시작합니다. size < 3 이면 readEmail 과 readMemberAge 로 입력값을 확정합니다. 거절된 값은 각 메서드 내부에서 다시 묻습니다. createMember(email, age, false) 의 새 객체 참조를 member 에 보관합니다. members[size] = member 로 먼저 저장한 뒤 size++ 합니다. 반복 조건으로 돌아갑니다. 세 회원을 채워 조건이 거짓이면 printMembers 로 전체 출력하고, 이메일 검색과 결과 출력을 한 뒤 종료합니다. 검색은 findByEmail 의 참조 또는 null 반환을 printSearchResult 가 처리합니다. 입력 나이는 정수 14~120, 이메일은 trim 후 비어 있지 않은지만 검사합니다. 이 흐름은 본문의 전체 CLI가 정상 입력 스트림으로 진행하는 경로입니다. 조기 종료 메뉴나 EOF 복구는 구현되어 있지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 반복 진입 size<3에서 readEmail과 readMemberAge를 호출합니다. |
|
|
| 생성·저장 createMember(email,age,false)의 참조를 members[size]에 둡니다. |
|
|
| 반복 끝 검색 세 회원을 저장한 size 3에서 printMembers와 email 검색을 수행합니다. |
|
|
MemberRegistryCli의 입력 반복과 검색 진입
용량 조건을 확인하고 입력값 확정·Member 생성·배열 저장·size 증가를 반복해 세 회원 후 출력과 검색을 합니다. 현재 슬롯과 읽기/확정 단계를 고정하여 CLI의 실제 순서를 보여 줄 수 있습니다.
길이 3의 배열에서 size가 3보다 작은 동안 입력, 생성, 저장, 증가를 반복하고 세 회원을 채운 뒤 전체 출력과 이메일 검색으로 진행합니다.
세 회원을 저장한 뒤 출력과 검색으로 진행합니다
size < 3 동안 입력 확정→객체 생성→저장→size 증가를 반복합니다. 세 회원을 채우면 전체 출력과 이메일 검색을 실행합니다.
- 길이 3인
Member[]를 만들고size = 0으로 시작합니다. size < 3이면readEmail과readMemberAge로 입력값을 확정합니다. 거절된 값은 각 메서드 내부에서 다시 묻습니다.createMember(email, age, false)의 새 객체 참조를member에 보관합니다.members[size] = member로 먼저 저장한 뒤size++합니다. 반복 조건으로 돌아갑니다.- 세 회원을 채워 조건이 거짓이면
printMembers로 전체 출력하고, 이메일 검색과 결과 출력을 한 뒤 종료합니다.
검색은 findByEmail의 참조 또는 null 반환을 printSearchResult가 처리합니다. 입력 나이는 정수 14~120, 이메일은 trim 후 비어 있지 않은지만 검사합니다.
이 흐름은 본문의 전체 CLI가 정상 입력 스트림으로 진행하는 경로입니다. 조기 종료 메뉴나 EOF 복구는 구현되어 있지 않습니다.
이 예의 조건
- 배열 길이 3, size 0에서 시작하며 거절 입력은 메서드 내부에서 재입력합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | MemberRegistryCli의 입력 반복과 검색 진입 MemberRegistryCli의 입력 반복과 검색 진입 길이 3의 배열에서 size가 3보다 작은 동안 입력, 생성, 저장, 증가를 반복하고 세 회원을 채운 뒤 전체 출력과 이메일 검색으로 진행합니다. 세 회원을 저장한 뒤 출력과 검색으로 진행합니다 size < 3 동안 입력 확정→객체 생성→저장→size 증가를 반복합니다. 세 회원을 채우면 전체 출력과 이메일 검색을 실행합니다. 길이 3인 Member[] 를 만들고 size = 0 으로 시작합니다. size < 3 이면 readEmail 과 readMemberAge 로 입력값을 확정합니다. 거절된 값은 각 메서드 내부에서 다시 묻습니다. createMember(email, age, false) 의 새 객체 참조를 member 에 보관합니다. members[size] = member 로 먼저 저장한 뒤 size++ 합니다. 반복 조건으로 돌아갑니다. 세 회원을 채워 조건이 거짓이면 printMembers 로 전체 출력하고, 이메일 검색과 결과 출력을 한 뒤 종료합니다. 검색은 findByEmail 의 참조 또는 null 반환을 printSearchResult 가 처리합니다. 입력 나이는 정수 14~120, 이메일은 trim 후 비어 있지 않은지만 검사합니다. 이 흐름은 본문의 전체 CLI가 정상 입력 스트림으로 진행하는 경로입니다. 조기 종료 메뉴나 EOF 복구는 구현되어 있지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 반복 진입 size<3에서 readEmail과 readMemberAge를 호출합니다. |
|
|
| 생성·저장 createMember(email,age,false)의 참조를 members[size]에 둡니다. |
|
|
| 반복 끝 검색 세 회원을 저장한 size 3에서 printMembers와 email 검색을 수행합니다. |
|
|
이 프로그램 안에서 복사되는 값들
이 짧은 프로그램에는 4장에서 배운 값 복사가 거의 다 들어 있습니다. 어떤 변수에 무엇이 담기는지 순서대로 짚어 보겠습니다.
| 코드 | 복사되는 값 |
|---|---|
String email = readEmail(scanner) | readEmail이 돌려준 문자열 객체의 참조 |
int age = readMemberAge(scanner) | 정수 값 그 자체 |
Member member = createMember(...) | 메서드 안에서 만든 새 회원 객체의 참조 |
members[size] = member | 같은 회원 객체의 참조가 배열 칸으로 |
printMembers(members, size) | 배열 객체의 참조와 정수 size |
Member found = findByEmail(...) | 배열 칸에 있던 회원의 참조, 또는 null |
createMember 안의 지역 변수 member는 메서드가 끝나면 사라지지만, 반환된 참조가 main의 member에 복사되고 다시 배열 칸에 복사되므로 객체는 계속 살아 있습니다. printMembers와 findByEmail에는 배열 전체가 아니라 배열을 가리키는 참조값이 복사되므로, 메서드 안에서도 main과 같은 배열과 같은 회원 객체를 봅니다.
검색 결과를 바꾸면 저장소도 바뀐다
findByEmail이 돌려주는 것은 회원의 복사본이 아니라 배열 칸에 들어 있던 참조 그대로입니다. 그래서 검색 결과를 통해 필드를 바꾸면, 배열로 읽어도 바뀐 값이 보입니다. main의 검색 부분을 다음 코드로 바꿔 kim 회원을 활성 상태로 만든 뒤 다시 출력해 보겠습니다.
Member found = findByEmail(members, size, "[email protected]");
if (found != null) {
found.active = true;
}
printMembers(members, size);앞의 실행 예와 같이 세 회원을 입력했다면, 마지막 출력은 다음과 같습니다. 바꾼 코드는 검색 결과를 출력하지 않고, kim 회원을 활성으로 바꾼 뒤 printMembers로 저장소 전체를 다시 출력합니다. 앞의 실행 예에서 비활성이었던 kim 회원의 줄과 비교해 보세요.
== 회원 저장소 ==
1. [email protected] / 35세 / 활성
2. [email protected] / 30세 / 비활성
3. [email protected] / 20세 / 비활성found와 members[0]은 같은 회원 객체를 가리키므로 found.active = true가 그 객체의 active 필드를 바꾸고, printMembers가 members[0]을 따라 읽을 때도 활성이 출력됩니다. found == members[0]을 출력해 보면 true입니다. if (found != null) 검사는 검색 결과가 없는 정상 상황을 처리하는 것이고, 이것이 없으면 찾는 회원이 없을 때 found.active에서 NPE가 납니다.
검색 결과와 배열 칸이 가리키는 같은 회원
검색이 배열 칸의 같은 Member 참조를 반환하여 found.active=true가 저장소에서도 보입니다. 검색 전후와 필드 false→true 전환을 구분할 가치가 있습니다.
본문 예제의 kim 회원을 검색하면 found와 members[0]이 같은 객체를 가리키므로 found.active를 true로 바꾸면 배열을 통해 읽어도 true입니다.
검색 결과는 배열과 같은 회원을 참조합니다
kim 회원 검색의 성공 경로입니다. found.active를 바꾸면 members[0]에서도 변경된 같은 필드를 읽습니다.
- 배열 칸 · members[0]
- 본문 실행 예의 kim 회원 객체를 참조합니다.
- 검색 결과 · found
findByEmail(members, size, "[email protected]")이 반환한 참조를 보관합니다. 배열 칸과 같은 객체를 가리킵니다.- 공유된 Member 한 개
email = [email protected],age = 35입니다.found.active = true는 이 객체의 active를 false에서 true로 바꾸므로members[0].active로 읽어도 true입니다.
본문 실행 예의 세 회원을 저장한 뒤, findByEmail(members, size, "[email protected]")과 if (found != null) found.active = true;를 수행한 성공 경로입니다.
검색은 새 Member를 복제하지 않습니다. printMembers도 배열의 같은 참조를 따라 읽어 변경된 활성 상태를 출력합니다. 검색 실패의 null은 가드에서 걸러져 필드를 바꾸지 않습니다.
이 예의 조건
- 세 회원 저장 뒤 [email protected] 성공 검색 경로입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 검색 결과와 배열 칸이 가리키는 같은 회원 검색 결과와 배열 칸이 가리키는 같은 회원 본문 예제의 kim 회원을 검색하면 found와 members[0]이 같은 객체를 가리키므로 found.active를 true로 바꾸면 배열을 통해 읽어도 true입니다. 검색 결과는 배열과 같은 회원을 참조합니다 kim 회원 검색의 성공 경로입니다. found.active를 바꾸면 members[0]에서도 변경된 같은 필드를 읽습니다. 배열 칸 · members[0] 본문 실행 예의 kim 회원 객체를 참조합니다. 검색 결과 · found findByEmail(members, size, "[email protected]") 이 반환한 참조를 보관합니다. 배열 칸과 같은 객체를 가리킵니다. 공유된 Member 한 개 email = [email protected] , age = 35 입니다. found.active = true 는 이 객체의 active를 false에서 true로 바꾸므로 members[0].active 로 읽어도 true입니다. 본문 실행 예의 세 회원을 저장한 뒤, findByEmail(members, size, "[email protected]") 과 if (found != null) found.active = true; 를 수행한 성공 경로입니다. 검색은 새 Member를 복제하지 않습니다. printMembers 도 배열의 같은 참조를 따라 읽어 변경된 활성 상태를 출력합니다. 검색 실패의 null 은 가드에서 걸러져 필드를 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장된 회원 members[0]이 Kim 35, false 객체를 참조합니다. |
|
|
| 검색 반환 found가 검색에서 같은 참조를 받습니다. |
|
|
| 활성 변경 found.active=true가 공유 객체를 바꿉니다. |
|
|
검색 결과와 배열 칸이 가리키는 같은 회원
검색이 배열 칸의 같은 Member 참조를 반환하여 found.active=true가 저장소에서도 보입니다. 검색 전후와 필드 false→true 전환을 구분할 가치가 있습니다.
본문 예제의 kim 회원을 검색하면 found와 members[0]이 같은 객체를 가리키므로 found.active를 true로 바꾸면 배열을 통해 읽어도 true입니다.
검색 결과는 배열과 같은 회원을 참조합니다
kim 회원 검색의 성공 경로입니다. found.active를 바꾸면 members[0]에서도 변경된 같은 필드를 읽습니다.
- 배열 칸 · members[0]
- 본문 실행 예의 kim 회원 객체를 참조합니다.
- 검색 결과 · found
findByEmail(members, size, "[email protected]")이 반환한 참조를 보관합니다. 배열 칸과 같은 객체를 가리킵니다.- 공유된 Member 한 개
email = [email protected],age = 35입니다.found.active = true는 이 객체의 active를 false에서 true로 바꾸므로members[0].active로 읽어도 true입니다.
본문 실행 예의 세 회원을 저장한 뒤, findByEmail(members, size, "[email protected]")과 if (found != null) found.active = true;를 수행한 성공 경로입니다.
검색은 새 Member를 복제하지 않습니다. printMembers도 배열의 같은 참조를 따라 읽어 변경된 활성 상태를 출력합니다. 검색 실패의 null은 가드에서 걸러져 필드를 바꾸지 않습니다.
이 예의 조건
- 세 회원 저장 뒤 [email protected] 성공 검색 경로입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 검색 결과와 배열 칸이 가리키는 같은 회원 검색 결과와 배열 칸이 가리키는 같은 회원 본문 예제의 kim 회원을 검색하면 found와 members[0]이 같은 객체를 가리키므로 found.active를 true로 바꾸면 배열을 통해 읽어도 true입니다. 검색 결과는 배열과 같은 회원을 참조합니다 kim 회원 검색의 성공 경로입니다. found.active를 바꾸면 members[0]에서도 변경된 같은 필드를 읽습니다. 배열 칸 · members[0] 본문 실행 예의 kim 회원 객체를 참조합니다. 검색 결과 · found findByEmail(members, size, "[email protected]") 이 반환한 참조를 보관합니다. 배열 칸과 같은 객체를 가리킵니다. 공유된 Member 한 개 email = [email protected] , age = 35 입니다. found.active = true 는 이 객체의 active를 false에서 true로 바꾸므로 members[0].active 로 읽어도 true입니다. 본문 실행 예의 세 회원을 저장한 뒤, findByEmail(members, size, "[email protected]") 과 if (found != null) found.active = true; 를 수행한 성공 경로입니다. 검색은 새 Member를 복제하지 않습니다. printMembers 도 배열의 같은 참조를 따라 읽어 변경된 활성 상태를 출력합니다. 검색 실패의 null 은 가드에서 걸러져 필드를 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장된 회원 members[0]이 Kim 35, false 객체를 참조합니다. |
|
|
| 검색 반환 found가 검색에서 같은 참조를 받습니다. |
|
|
| 활성 변경 found.active=true가 공유 객체를 바꿉니다. |
|
|
이 동작이 의도한 것이라면 편리합니다. '검색한 회원을 활성화한다'는 기능은 바로 이렇게 구현합니다. 하지만 검색 결과를 수정용 초안으로 받아 이것저것 바꿔 본 뒤 저장 여부를 나중에 정하려 했다면, 초안을 고치는 순간 저장소의 원본까지 바뀌는 버그가 됩니다. 이런 경우에는 기본형과 참조형 복사에서 본 것처럼 새 Member를 만들어 필드를 복사한 사본을 써야 합니다. 같은 반환 타입이라도 호출자가 그 참조로 무엇을 하려는지에 따라 공유해도 되는지가 달라집니다.
또 지금은 필드가 바깥에 열려 있어서, 어느 메서드든 found.age = -1처럼 createMember의 검사를 거치지 않은 값을 직접 넣을 수 있습니다. 5장에서 접근 제어와 객체의 메서드를 배우면 '활성 처리'나 '나이 변경' 같은 규칙을 객체가 스스로 지키게 만들 수 있습니다.
정리
- 배열 길이는 용량이고
size는 채운 칸의 수입니다. 출력과 검색은0부터size - 1까지만 순회합니다. - '앞쪽
size개 칸에는 회원이 있다'는 불변식을 지키려면, 객체를 다 만든 뒤에 배열에 저장하고 마지막에size를 늘립니다. 예외는 앞서 실행한size++를 되돌리지 않습니다. - 입력은
nextLine()으로 읽고 필요한 것만 숫자로 바꿉니다. 숫자로 바뀌는지와 허용 범위에 드는지는 따로 검사합니다. - 메서드 사이를 오가는 것은 객체가 아니라 참조값입니다. 검색 결과로 필드를 바꾸면 배열에서도 바뀐 값이 보입니다.
연습 문제
종합 프로그램에 다음 기능을 추가하세요. 배열은 null이 아니고 앞쪽 size개 칸의 회원도 null이 아니며, size는 0 이상 배열 길이 이하라는 조건을 그대로 둡니다. 확인용 데이터는 저장 순서대로 kim 회원 35세, lee 회원 40세, park 회원 40세입니다. 코드를 쓰기 전에 누가 반환되어야 하는지, size가 0이면 무엇이 반환되어야 하는지 먼저 적어 보세요.
findOldest(Member[] members, int size)메서드를 작성합니다.- 회원이 한 명 이상 있다고 가정하지 말고,
size == 0이면null을 반환합니다. - 가장 많은 나이가 같은 회원이 여러 명이면 먼저 저장된 회원을 반환합니다.
- 반환된 회원의 나이를 바꾸면 배열에서도 바뀐 값이 보이는지 확인합니다.
- 배열과 상관없는 독립적인 결과가 필요하다면 어떤 복사를 추가해야 하는지 설명합니다.
해설 보기
static Member findOldest(Member[] members, int size) {
if (size < 0 || size > members.length) {
throw new IllegalArgumentException("잘못된 회원 수: " + size);
}
if (size == 0) {
return null;
}
Member oldest = members[0];
for (int i = 1; i < size; i++) {
Member candidate = members[i];
if (candidate.age > oldest.age) {
oldest = candidate;
}
}
return oldest;
}Member oldest = findOldest(members, size);
if (oldest == null) {
System.out.println("회원이 없습니다.");
} else {
System.out.println("최고 연령 회원=" + oldest.email);
oldest.age += 5;
}oldest를 첫 회원으로 시작해 나머지와 비교합니다. 비교 연산을 >=가 아니라 >로 썼기 때문에 나이가 같으면 후보를 바꾸지 않고, 그래서 먼저 저장된 회원이 남습니다. 확인용 데이터로 따라가 보면 다음과 같습니다.
| i | candidate | 비교 | oldest |
|---|---|---|---|
| 시작 | kim (35) | ||
| 1 | lee (40) | 40 > 35 → 참 | lee (40) |
| 2 | park (40) | 40 > 40 → 거짓 | lee (40) |
따라서 최고 연령 회원[email protected]이 출력됩니다. size가 0이면 members[0]을 읽기 전에 null을 반환하므로, 비어 있는 0번 칸을 회원처럼 다루지 않습니다. 확인용 데이터에서는 배열이 꽉 차 있어 0번 칸이 실제 회원이지만, 회원이 하나도 없는 저장소라면 그 칸은 null입니다.
반환값은 배열의 1번 칸과 같은 객체를 가리키므로 oldest.age += 5를 실행하면 members[1].age도 45가 됩니다. 배열과 상관없는 독립적인 결과가 필요하다면, 새 Member를 만들고 email, age, active 세 필드를 복사한 뒤 그 새 객체의 참조를 반환해야 합니다. 이 Member의 필드는 기본형과 변경할 수 없는 String뿐이므로 필드 세 개를 복사하는 것만으로 충분히 독립적인 사본이 됩니다.
다음 장에서 풀 문제들
프로그램은 잘 돌아가지만 설계에는 불편한 점이 남아 있습니다.
- 객체를 만들고 검사하는 순서를
createMember를 호출하는 쪽이 알아서 지켜야 합니다.new Member()를 직접 호출하면 검사를 건너뛸 수 있습니다. - 모든 필드가 열려 있어서 같은 패키지의 어느 코드든 잘못된 값을 직접 넣을 수 있습니다.
- 활성 처리나 출력 형식 같은 회원 관련 규칙이 객체 밖의 여러 메서드에 흩어지기 쉽습니다.
- 고정 크기 배열은 용량이 차면 더 넣을 수 없습니다.
- 검색에 실패하면
null을 돌려주므로, 호출자가 검사를 잊으면 NPE가 납니다.
이 문제들은 한 번에 해결하지 않습니다. 생성자와 캡슐화(5장), 컬렉션, Optional을 차례로 배우면서 같은 회원 저장소를 다시 열어, 각 도구가 이 중 어떤 문제를 푸는지 비교합니다.
다음 장
4장에서는 관련 있는 데이터를 클래스로 묶고, 변수와 배열 칸에는 객체가 아니라 객체로 가는 참조가 담긴다는 것을 배웠습니다. 지금의 회원 저장소는 회원 한 명을 객체로 표현하지만, 회원에 관한 규칙과 동작은 아직 바깥의 static 메서드에 있습니다. 5장의 첫 글에서는 이런 구조가 기능이 늘어날 때 어떤 비용을 만드는지 살펴보고, 데이터와 그 데이터를 다루는 동작을 객체 안으로 옮기기 시작합니다.