클래스·참조 종합 실습
입력에서 객체 생성, 배열 저장, 검색과 출력까지 값과 참조의 흐름을 하나의 프로그램으로 연결합니다.
지금까지는 참조의 한 장면을 보기 위해 작은 프로그램을 따로 실행했습니다.
이제 회원 저장소 CLI에 세 개의 회원 정보를 입력하고, 객체 배열에 저장하고, 이메일로 검색하고, 전체 회원을 출력해 봅시다.
이 종합 예제의 목적은 기능을 많이 만드는 데 있지 않습니다.
입력 문자열과 숫자 값, 새 객체의 참조, 배열 칸의 참조가 어느 경계에서 복사되는지를 끝까지 추적하는 데 있습니다.
이번 버전의 Member는 여전히 필드만 가진 데이터 객체입니다.
입력·검증·출력 메서드는 MemberRegistryCli에 있습니다.
다음 객체 지향 장에서는 이 책임 중 무엇을 객체 안으로 옮겨야 하는지 판단합니다.
데이터 타입 설계
Member는 앞 문서와 같은 세 필드를 가집니다.
public class Member {
String email;
int age;
boolean active;
}CLI는 최대 세 개를 입력받는 고정 배열과 실제 회원 수 size를 관리합니다.
Member[] members = new Member[3];
int size = 0;배열 길이는 용량이고 size는 채운 칸의 수입니다.
회원 생성에 실패했는데 size부터 증가시키면 빈 슬롯이 데이터 범위에 포함됩니다.
따라서 다음 순서를 지킵니다.
- 입력을 읽고 검증합니다.
- 완성된
Member를 만듭니다. members[size]에 참조를 저장합니다.- 저장에 성공한 뒤
size를 증가시킵니다.
크기와 저장 순서
다음 코드는 빈 자리를 예약한다는 생각으로 size를 먼저 증가시킵니다.
int index = size;
size++;
Member member = createMember(email, age);
members[index] = member;createMember가 잘못된 나이 때문에 예외를 던지면 배열 칸은 null인데 size는 이미 증가한 상태가 됩니다.
이후 for (int i = 0; i < size; i++)가 그 슬롯을 정상 회원으로 보고 members[i].email을 읽다가 NPE가 납니다.
오류가 난 입력 지점과 NPE가 난 출력 지점이 멀어져 원인을 찾기도 어렵습니다.
개선 버전은 객체가 성공적으로 만들어진 다음에만 상태를 갱신합니다.
Member member = createMember(email, age);
members[size] = member;
size++;한 줄 차이지만 불변 조건이 생깁니다.
0 <= i < size인 모든 배열 칸에는null이 아닌 Member가 있다.
입력 변환 경계
Scanner.nextInt() 뒤 nextLine()이 빈 줄을 읽는 문제를 피하기 위해 이번 버전은 모든 입력을 nextLine()으로 받고 숫자만 Integer.parseInt로 변환합니다.
System.out.print("회원 나이(세): ");
String rawAge = scanner.nextLine();
int age = Integer.parseInt(rawAge);숫자가 아닌 입력은 NumberFormatException을 일으킵니다.
대화형 프로그램이 종료되지 않도록 입력 메서드에서 처리하고 다시 묻습니다.
변환된 int가 양수인지도 별도로 검사해야 합니다.
“숫자로 변환 가능함”과 “유효한 회원 나이임”은 다른 계약입니다.
전체 프로그램 구성
다음 두 파일은 같은 디렉터리에 둡니다.
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 + "세"
);
}
}javac -d out src/members/ch04/finalv/Member.java src/members/ch04/finalv/MemberRegistryCli.java
java -cp out MemberRegistryCli[1/3]
이메일: kim@example.com
회원 나이(세): 35
[2/3]
이메일: lee@example.com
회원 나이(세): 삼십
정수를 입력해 주세요.
회원 나이(세): 30
[3/3]
이메일: park@example.com
회원 나이(세): 20
== 회원 저장소 ==
1. kim@example.com / 35세 / 비활성
2. lee@example.com / 30세 / 비활성
3. park@example.com / 20세 / 비활성
찾을 이메일: lee@example.com
검색 결과: lee@example.com / 30세이 프로그램에는 지금까지 배운 값 복사가 모두 들어 있습니다.
email과keyword에는 문자열 참조가 저장됩니다.age와size에는 정수 값이 저장됩니다.createMember는 새 객체 참조를 반환합니다.members[size] = member는 그 참조를 배열 칸에 복사합니다.printMembers와findByEmail의 매개변수에는 배열 참조와 숫자size가 각각 복사됩니다.findByEmail은 찾은 객체의 참조 또는null을 반환합니다.
객체 참조와 변경 전파
findByEmail이 반환하는 것은 복제된 회원이 아니라 배열이 가리키는 객체의 참조입니다.
따라서 검색 결과를 통해 필드를 바꾸면 전체 출력에서도 변경이 보입니다.
Member found = findByEmail(members, size, "kim@example.com");
if (found != null) {
found.active = true;
}
printMembers(members, size);이 동작이 의도라면 검색 메서드는 편리합니다.
검색 결과를 수정본 초안으로 사용하려던 것이라면 공유 참조가 버그가 됩니다.
반환 타입이 같아도 호출자가 그 참조로 무엇을 할지에 따라 복사 정책이 달라집니다.
지금은 필드가 외부에 열려 있어 어느 메서드든 age = -1 같은 잘못된 상태를 만들 수 있습니다.
다음 장에서 접근 제어와 객체 메서드를 도입하면 “활성 처리”와 “나이 변경”의 규칙을 객체가 지키도록 개선할 수 있습니다.
연습 문제
종합 프로그램에 다음 기능을 추가하세요.
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를 유지하고 먼저 저장된 회원이 선택됩니다.
반환값은 배열 칸과 같은 객체를 가리키므로 oldest.age += 5는 배열에서 읽는 값도 바꿉니다.
독립적인 결과가 필요하면 새 Member를 만들고 세 필드를 복사한 뒤 그 참조를 반환해야 합니다.
다음 단계의 설계 과제
프로그램은 실행되지만 설계상 불편이 남아 있습니다.
- 필드 초기화 순서와 유효성 검사를
createMember호출자가 지켜야 합니다. - 모든 필드가 같은 패키지의 코드에 열려 있어 잘못된 값을 직접 대입할 수 있습니다.
- 활성 처리와 출력 형식이 객체 밖의 여러 메서드에 흩어질 수 있습니다.
- 고정 배열은 용량이 차면 더 이상 추가할 수 없습니다.
- 검색 실패를
null로 표현해 호출자가 검사를 잊을 수 있습니다.
이 문제들은 한 번에 해결하지 않습니다.
생성자, 캡슐화, 컬렉션, Optional을 배울 때 같은 회원 저장소를 다시 열어 각 도구가 어느 경계를 개선하는지 비교합니다.
클래스와 참조를 배우는 목적은 문법을 늘리는 것이 아니라 데이터가 함께 움직이는 경계와 객체에 도달하는 경로를 코드에서 읽는 것입니다.
이제 회원 저장소는 한 회원을 객체로 표현하지만 행동과 유효성은 아직 바깥에 있습니다.
다음 객체 지향 장에서는 절차를 객체의 책임으로 옮겨 이 허술한 경계를 단단하게 만듭니다.