메서드로 회원가입 프로그램 완성하기
거절된 입력이 배열에 빈칸을 남기지 않도록 상태를 바꾸는 순서를 정하고, 입력·검증·저장·출력을 메서드로 나눠 키보드로 쓰는 회원가입 프로그램을 완성합니다.
이번 장에서는 키보드 입력, 배열과 size, 메서드와 반환값을 하나씩 배웠습니다. 이 글에서는 이것들을 모아 실제로 키보드로 쓰는 회원가입 프로그램을 완성합니다. 문법은 이미 다 배웠지만, 조각을 합치면 새로운 종류의 버그가 생깁니다. 잘못된 입력을 거절했는데도 배열에 빈칸이 생기거나, 목록에는 두 명이 보이는데 평균은 세 명으로 나누는 식입니다.
이런 버그는 대부분 상태를 바꾸는 순서에서 나옵니다. 여기서 상태란 배열에 들어 있는 값과 저장된 회원 수 size를 말합니다. 그래서 이 글은 먼저 순서를 잘못 잡은 코드가 어떻게 빈칸을 만드는지 보고, 메서드가 새 size를 돌려주는 방식으로 이를 막습니다. 그다음 프로그램을 메서드로 나눠 완성하고, 일부러 틀린 입력을 넣어 시험해 봅니다.
거절된 입력이 빈칸을 남기는 버그
다음 코드는 회원을 저장할 칸 번호를 먼저 정해 두고 나서 입력을 검증합니다. 언뜻 자연스러워 보이지만, 첫 입력의 이메일이 비어 있다는 점에 주목하세요.
public final class IncrementBeforeValidation {
public static void main(String[] args) {
String[] emails = new String[3];
int[] ages = new int[3];
int size = 0;
int index = size++;
String email = "";
int age = 30;
if (email.isBlank() || age < 14 || age > 120) {
System.out.println("invalid");
} else {
emails[index] = email;
ages[index] = age;
}
index = size++;
emails[index] = "[email protected]";
ages[index] = 50;
for (int current = 0; current < size; current++) {
System.out.println(emails[current] + "=" + ages[current]);
}
}
}invalid
null=0
[email protected]=50첫 입력은 거절되어 invalid가 출력되었습니다. 그런데 목록에는 아무도 저장하지 않은 null=0이 끼어 있습니다. 상태가 어떻게 바뀌었는지 한 줄씩 따라가 보겠습니다.
| 단계 | 실행한 코드 | index | size | emails | ages |
|---|---|---|---|---|---|
| 시작 | 배열 생성 | 없음 | 0 | [null, null, null] | [0, 0, 0] |
| 1 | int index = size++; | 0 | 1 | [null, null, null] | [0, 0, 0] |
| 2 | 검증 실패, invalid 출력 | 0 | 1 | [null, null, null] | [0, 0, 0] |
| 3 | index = size++; | 1 | 2 | [null, null, null] | [0, 0, 0] |
| 4 | 1번 칸에 기록 | 1 | 2 | [null, "[email protected]", null] | [0, 50, 0] |
문제는 1단계입니다. 검증하기도 전에 size를 1로 늘렸기 때문에, 검증이 실패해 아무것도 쓰지 않았는데도 0번 칸이 사용된 것으로 처리되었습니다. 다음 정상 회원은 1번 칸에 들어가고, 마지막 반복문은 0번부터 size - 1인 1번까지 출력합니다. 그래서 한 번도 쓰지 않은 0번 칸이 배열의 기본값인 null과 0으로 출력된 것입니다.
고치려면 상태를 바꾸는 순서를 남은 칸 확인 → 값 검증 → 두 배열에 기록 → size 증가로 정해야 합니다. 다음 코드는 메서드와 매개변수에서 만든 isValid를 써서 이 순서를 지킵니다.
if (size < emails.length && isValid(email, age)) {
emails[size] = email;
ages[size] = age;
size++;
}순서마다 이유가 있습니다. 남은 칸을 먼저 확인해야 배열 길이를 넘는 칸에 쓰다가 예외가 나지 않고, 값을 검증한 뒤에 써야 잘못된 값이 배열에 들어가지 않습니다. size를 맨 마지막에 늘리는 것은 두 배열에 값이 모두 들어간 칸만 저장된 회원으로 치기 위해서입니다. 검사 중 하나라도 실패하면 아무것도 바뀌지 않으므로, 다음 정상 회원은 방금 거절된 입력이 쓰려던 바로 그 칸에 들어갑니다. 앞 예제의 두 저장 부분을 이 순서로 바꾸고 첫 번째 저장이 실패했을 때 invalid를 출력하게 하면, 출력은 invalid와 [email protected]=50 두 줄이 되고 빈칸이 사라집니다.
더 깊이: 이 순서로도 막지 못하는 것
이 순서는 검증 실패를 깔끔하게 처리하지만, 두 배열에 기록하는 도중에 예외가 나는 경우까지 되돌려 주지는 않습니다. 예를 들어 실수로 ages가 emails보다 짧게 만들어졌다면, emails[size]에는 값이 들어간 뒤 ages[size]에서 ArrayIndexOutOfBoundsException이 날 수 있습니다. 자바는 이미 실행된 첫 번째 대입을 취소하지 않습니다. 다만 size를 늘리기 전이었으므로, 반쯤 쓴 그 칸은 0번부터 size - 1번까지라는 유효 범위 밖에 남습니다. 그래서 이 예외를 잡아 프로그램을 계속 실행하더라도, 유효 범위만 읽는 목록과 집계에 이메일만 있고 나이는 없는 회원이 나타나지는 않습니다. size 증가를 맨 뒤에 두는 순서가 이런 경우에도 피해를 줄여 주는 셈입니다.
여러 스레드가 같은 배열과 size를 함께 쓰는 경우는 또 다른 문제입니다. 두 스레드가 같은 size 값을 읽어 같은 칸에 기록할 수 있고, 한 스레드가 늘린 size를 다른 스레드가 언제 보게 될지도 정해져 있지 않습니다. 이 글의 프로그램은 스레드 하나로 실행되므로 해당하지 않으며, 이런 문제와 해결 방법은 가시성과 동기화를 다루는 장에서 배웁니다.
메서드가 새 size를 돌려주는 이유
저장 코드를 add 같은 메서드로 옮기면 곧바로 한 가지 문제를 만납니다. 메서드 안에서 size++를 해도 main의 size는 바뀌지 않는다는 점입니다. 메서드와 매개변수에서 본 것처럼 int 인수는 값이 복사되어 전달되므로, 메서드가 늘리는 것은 복사본일 뿐입니다.
그래서 메서드가 현재 회원 수를 받아 처리한 뒤의 회원 수를 돌려주고, 호출한 쪽이 그 값을 자기 변수에 다시 대입하게 합니다. 다음 예제는 배열 없이 회원 수만으로 이 방식을 보여 줍니다. 세 번째 인수 valid는 회원 정보가 검증을 통과했는지를 대신 나타냅니다.
public final class MemberCountMethods {
public static void main(String[] args) {
int memberCount = 0;
int capacity = 2;
memberCount = accept(memberCount, capacity, true);
memberCount = accept(memberCount, capacity, false);
memberCount = accept(memberCount, capacity, true);
memberCount = accept(memberCount, capacity, true);
System.out.println("memberCount=" + memberCount);
}
private static int accept(int memberCount, int capacity, boolean valid) {
if (!valid) {
System.out.println("rejected: invalid member");
return memberCount;
}
if (memberCount >= capacity) {
System.out.println("rejected: full");
return memberCount;
}
System.out.println("registered=" + (memberCount + 1));
return memberCount + 1;
}
}네 번의 호출은 차례로 정상 등록, 검증 실패, 정상 등록, 용량 초과 상황을 만듭니다. 거절된 호출 뒤에도 memberCount가 그대로 유지되는지 보면서 결과를 읽어 보세요.
registered=1
rejected: invalid member
registered=2
rejected: full
memberCount=2네 번의 호출에서 회원 수가 어떻게 오가는지 표로 정리하면 다음과 같습니다.
| 호출 | 넘긴 memberCount | 출력 | 돌려준 값 |
|---|---|---|---|
| 1 | 0 | registered=1 | 1 |
| 2 | 1 | rejected: invalid member | 1 |
| 3 | 1 | registered=2 | 2 |
| 4 | 2 | rejected: full | 2 |
두 번째 호출은 검증 실패라서, 네 번째 호출은 용량 2가 이미 찼기 때문에 받은 값을 그대로 돌려줍니다. 호출한 쪽은 성공이든 실패든 똑같이 memberCount = accept(...)로 받기만 하면 됩니다. 실패하면 같은 값이 다시 대입되므로 상태가 그대로 유지되고, 성공했을 때만 1 늘어난 값이 들어옵니다. 마지막 줄이 memberCount=2인 것은 성공한 호출이 두 번이었기 때문입니다.
회원가입의 add도 같은 방식으로 만듭니다. add는 상황에 따라 세 가지 중 하나로 끝납니다.
| 상황 | add가 하는 일 | 돌려주는 값 |
|---|---|---|
| 배열이 가득 참 | full을 출력하고 배열은 그대로 둠 | 기존 size |
| 이메일이 비어 있음 | invalid email을 출력하고 배열은 그대로 둠 | 기존 size |
| 모두 통과 | 두 배열의 size번 칸에 기록 | size + 1 |
return과 호출 스택에서는 저장 메서드가 성공 여부를 boolean으로 돌려주고 size 증가는 호출한 쪽이 맡는 방식도 보았습니다. 두 방식 모두 쓸 수 있지만, 새 size를 돌려주면 '두 칸을 다 쓴 뒤에만 size를 늘린다'는 규칙이 add 한 곳에 모입니다. 호출한 쪽이 size++를 빠뜨리거나 실패했을 때 잘못 늘릴 여지도 없어집니다.
다만 이 방식에도 흔한 실수가 하나 있습니다. 반환값을 받지 않고 add(emails, ages, size, email, value);처럼 호출만 하는 것입니다. 그러면 main의 size는 계속 0에 머뭅니다. 두 회원을 추가하면 added=는 두 번 출력되지만 두 번 모두 0번 칸에 기록되어 두 번째 회원이 첫 번째 회원을 덮어쓰고, list는 no entries를 출력합니다. 컴파일러는 반환값을 버리는 호출을 오류로 보지 않으므로 이 실수는 실행해 봐야 드러납니다.
할 일을 메서드로 나누기
코드를 쓰기 전에 프로그램이 해야 할 일을 적고, 일마다 맡을 곳을 정해 보겠습니다.
| 프로그램이 할 일 | 맡을 곳 | 돌려주는 것 |
|---|---|---|
| 입력 한 줄 읽기 | readLine | 앞뒤 공백을 없앤 문자열 |
| 나이 문자열이 14 이상 120 이하의 정수인지 확인하기 | parseAge | 나이, 또는 실패하면 null |
| 남은 칸과 이메일을 확인하고 새 회원 저장하기 | add | 새 size |
| 저장된 회원을 모두 보여 주기 | printEntries | 없음 |
| 합계와 평균 보여 주기 | printSummary, total | total만 합계를 돌려줌 |
| 명령 반복을 끝낼지 정하기 | main의 running 변수 | 해당 없음 |
모든 것을 메서드로 만들 필요는 없습니다. 명령을 반복해서 읽고 switch로 나누는 부분은 프로그램 전체의 흐름이므로 main에 남겨 두는 편이 읽기 쉽습니다. 이 부분을 메서드로 빼면 배열 두 개와 size, Scanner를 모두 넘겨야 해서 오히려 복잡해집니다. 반대로 입력 검증이나 배열 갱신 코드가 switch의 여러 분기에 복사되어 있다면 메서드로 모아야 합니다. 같은 규칙이 두 곳에 있으면 한쪽만 고치는 실수가 생기기 때문입니다.
메서드를 나눌 때는 각 메서드가 무엇을 받고, 성공하거나 실패하면 어떻게 되는지도 짧게 적어 둡니다.
add 받는 것: 길이가 같은 두 배열, 현재 size, 이메일, 검증을 통과한 나이
성공하면: 두 배열의 size번 칸에 기록하고 size + 1을 돌려준다
실패하면: 배열을 바꾸지 않고 size를 그대로 돌려준다
total 받는 것: 나이 배열, 저장된 회원 수 size
돌려주는 것: 0번부터 size - 1번까지 나이의 합이렇게 적어 두면 main을 읽을 때 메서드 안을 매번 들여다보지 않아도 됩니다. 예를 들어 size = add(...) 한 줄만 보고도 성공하든 실패하든 size가 올바른 값으로 유지된다는 것을 알 수 있습니다. 나중에 메서드 안을 고칠 때도 이 약속을 지키는지만 확인하면 됩니다.
키보드로 입력받는 회원가입 프로그램
이제 프로그램 전체를 완성합니다. 입력은 new Scanner(System.in)으로 키보드에서 받고, 무엇을 입력할 차례인지 알 수 있도록 command>, email>, age> 같은 안내 문구를 먼저 출력합니다.
코드를 보기 전에 parseAge의 반환 타입부터 짚고 넘어가겠습니다. 이 메서드는 기본형 int가 아니라 Integer를 돌려줍니다. int에는 정수만 담을 수 있지만, Integer에는 정수 값을 담을 수도 있고 '값이 없음'을 뜻하는 null을 담을 수도 있습니다. 그래서 parseAge는 나이를 해석하는 데 성공하면 그 정수를, 숫자가 아니거나 범위를 벗어나면 null을 돌려줄 수 있습니다.
성공한 int 값은 반환될 때 자동으로 Integer로 감싸지고, 나중에 add의 int 매개변수로 넘어갈 때 다시 int로 꺼내집니다. 이를 박싱(boxing)과 언박싱(unboxing)이라고 합니다. 단, null을 int로 꺼내려 하면 NullPointerException이 나므로, add를 호출하기 전에 반드시 null인지 먼저 확인해야 합니다. Integer 같은 래퍼 클래스는 래퍼 클래스와 박싱·언박싱에서 자세히 다룹니다.
import java.util.Scanner;
public final class MemberRegistryCli {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String[] emails = new String[3];
int[] ages = new int[3];
int size = 0;
boolean running = true;
while (running) {
System.out.print("command> ");
if (!scanner.hasNextLine()) {
break;
}
String command = scanner.nextLine().trim();
switch (command) {
case "add" -> {
String email = readLine(scanner, "email");
String rawAge = readLine(scanner, "age");
Integer value = parseAge(rawAge);
if (value == null) {
System.out.println("invalid age");
} else {
size = add(emails, ages, size, email, value);
}
}
case "list" -> printEntries(emails, ages, size);
case "summary" -> printSummary(ages, size);
case "quit" -> running = false;
default -> System.out.println("unknown command=" + command);
}
}
System.out.println("bye size=" + size);
}
private static String readLine(Scanner scanner, String field) {
System.out.print(field + "> ");
if (!scanner.hasNextLine()) {
throw new IllegalStateException("missing " + field);
}
return scanner.nextLine().trim();
}
private static Integer parseAge(String raw) {
try {
int value = Integer.parseInt(raw);
return value >= 14 && value <= 120 ? value : null;
} catch (NumberFormatException exception) {
return null;
}
}
private static int add(
String[] emails,
int[] ages,
int size,
String email,
int value
) {
if (size < 0 || size > emails.length || size > ages.length) {
throw new IllegalStateException("broken size");
}
if (size == emails.length || size == ages.length) {
System.out.println("full");
return size;
}
if (email == null || email.isBlank()) {
System.out.println("invalid email");
return size;
}
emails[size] = email;
ages[size] = value;
System.out.println("added=" + email);
return size + 1;
}
private static void printEntries(String[] emails, int[] ages, int size) {
if (size == 0) {
System.out.println("no entries");
return;
}
for (int index = 0; index < size; index++) {
System.out.println((index + 1) + ". " + emails[index] + "=" + ages[index]);
}
}
private static void printSummary(int[] ages, int size) {
if (size == 0) {
System.out.println("summary=none");
return;
}
int total = total(ages, size);
double average = (double) total / size;
System.out.println("total=" + total + ", average=" + average);
}
private static int total(int[] ages, int size) {
int result = 0;
for (int index = 0; index < size; index++) {
result += ages[index];
}
return result;
}
}컴파일하고 실행하는 명령은 앞 장들과 같습니다. macOS나 Linux에서도 그대로 쓰면 됩니다.
javac -d out src/MemberRegistryCli.java
java -cp out MemberRegistryCli실행한 뒤 아래처럼 입력해 보세요. command>, email>, age> 뒤에 적힌 것이 직접 입력한 내용이고, 나머지 줄은 프로그램이 출력한 것입니다. 두 번째 add에서는 이메일을 입력하지 않고 바로 Enter를 누릅니다.
command> add
email> [email protected]
age> 40
[email protected]
command> add
email>
age> 30
invalid email
command> add
email> [email protected]
age> 50
[email protected]
command> list
1. [email protected]=40
2. [email protected]=50
command> summary
total=90, average=45.0
command> quit
bye size=2첫 번째 add는 모든 검사를 통과해 0번 칸에 저장되고, add가 돌려준 1이 size에 대입됩니다. 두 번째 add의 나이 30은 parseAge를 통과하지만, add 안에서 이메일이 비어 있어 invalid email을 출력하고 기존 값 1을 그대로 돌려줍니다. 그래서 세 번째 add의 [email protected]은 1번 칸에 들어가고, 앞 절에서 본 빈칸이 생기지 않습니다. list와 summary는 둘 다 0번부터 size - 1번까지, 즉 같은 두 칸만 읽습니다. 그래서 summary는 목록에 보이는 두 명의 나이로 합계 90과 평균 45.0을 계산합니다. 마지막으로 quit이 running을 false로 바꾸면 반복이 끝나고 bye size=2가 출력됩니다.
다음 도표는 두 번째와 세 번째 add를 단계별로 비교합니다. 둘 다 size가 1인 같은 상태에서 시작하지만, 거절된 요청은 배열에 아무것도 쓰지 않고 1을 돌려주고, 성공한 요청만 1번 칸에 기록한 뒤 2를 돌려줍니다.
실패는 같은 칸을 남기고 성공만 두 배열과 size를 전진시킨다
같은 size=1 시작 상태에서 빈 이메일 거절은 index 1을 그대로 두고 정상 요청은 두 칸 기록 후 size=2를 반환합니다. 검증과 저장 확정의 전후가 구체적입니다.
kim/40 한 건을 저장한 size=1 상태에서 빈 이메일 요청이 실패하면 두 배열은 그대로다. 다음 lee/50 요청은 같은 index 1을 기록하고 호출자가 반환값 2를 size에 대입한다.
검증 실패는 다음 회원이 쓸 칸을 소비하지 않습니다
빈 이메일 요청 A를 거절한 뒤 정상 요청 B를 실행합니다. 두 요청은 같은 index 1에서 시작하고 성공한 B만 size를 2로 바꿉니다.
시작 size=1 · 용량 3index 0은 [email protected] / 40, index 1은 null / 0
A · 빈 이메일 / 30
parseAge에서 나이 30은 14~120 검사를 통과했습니다.
용량 확인 · PASS
size=1이고 두 배열 용량이 3이므로 남은 칸이 있습니다.이메일 확인 · FAIL
이메일이 비어 있으므로
invalid email을 출력합니다. 기존 1을 반환하며 아래 배열 기록에는 도달하지 않습니다.기록: NOT REACHED · 상태 보존
emails[1]=null,ages[1]=0이며 두 배열 전체가 그대로입니다. 호출자의size = add(...)는 1을 채택합니다. 유효 범위는[0, 1)입니다.
B · 다음 [email protected] / 50
A 이후에도 같은 size=1 상태입니다. parseAge에서 50도 검사를 통과합니다.
용량 확인 · PASS
아직
size=1, 두 배열 용량은 3입니다. A에서 거절된 칸을 소비하지 않았습니다.이메일 확인 · PASS
[email protected]은 비어 있지 않습니다. 여기가 A와 처음 달라지는 검사입니다.두 칸 기록 · 다음 상태 채택
emails[1]="[email protected]"과ages[1]=50을 기록한 뒤 2를 반환합니다. 호출자가size=2를 채택하여 유효 범위가[0, 2)가 됩니다. index 0은 kim/40 그대로입니다.
A 다음 B를 차례로 실행하므로 같은 index 1을 사용해 구멍이 생기지 않습니다. add는 나이 범위를 재검사하지 않으며, list·summary는 별도 명령입니다. 이 비교는 유효한 size와 같은 길이의 정상 배열에서 검증 실패를 보존하는 예제이지, 동시 실행이나 예외 중간 롤백 보장이 아닙니다.
이 예의 조건
- 용량 3, size 1이며 index 0=kim/40, index 1=null/0부터 시작합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 6 | index 0: [email protected] |
| 7 | 40 · index 1: null |
| 8 | 0 |
| 9 | A · 빈 이메일 |
| 10 | 30 |
| 11 | B · [email protected] |
| 12 | 50 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 실패는 같은 칸을 남기고 성공만 두 배열과 size를 전진시킨다 빈 이메일 실패 뒤 같은 index 1에 정상 회원을 저장하는 상태 경계 kim/40 한 건을 저장한 size=1 상태에서 빈 이메일 요청이 실패하면 두 배열은 그대로다. 다음 lee/50 요청은 같은 index 1을 기록하고 호출자가 반환값 2를 size에 대입한다. 검증 실패는 다음 회원이 쓸 칸을 소비하지 않습니다 빈 이메일 요청 A를 거절한 뒤 정상 요청 B를 실행합니다. 두 요청은 같은 index 1에서 시작하고 성공한 B만 size를 2로 바꿉니다. 시작 size=1 · 용량 3 index 0은 [email protected] / 40, index 1은 null / 0 A · 빈 이메일 / 30 parseAge 에서 나이 30은 14~120 검사를 통과했습니다. 용량 확인 · PASS size=1 이고 두 배열 용량이 3이므로 남은 칸이 있습니다. 이메일 확인 · FAIL 이메일이 비어 있으므로 invalid email 을 출력합니다. 기존 1 을 반환하며 아래 배열 기록에는 도달하지 않습니다. 기록: NOT REACHED · 상태 보존 emails[1]=null , ages[1]=0 이며 두 배열 전체가 그대로입니다. 호출자의 size = add(...) 는 1을 채택합니다. 유효 범위는 [0, 1) 입니다. B · 다음 [email protected] / 50 A 이후에도 같은 size=1 상태입니다. parseAge 에서 50도 검사를 통과합니다. 용량 확인 · PASS 아직 size=1 , 두 배열 용량은 3입니다. A에서 거절된 칸을 소비하지 않았습니다. 이메일 확인 · PASS [email protected] 은 비어 있지 않습니다. 여기가 A와 처음 달라지는 검사입니다. 두 칸 기록 · 다음 상태 채택 emails[1]="[email protected]" 과 ages[1]=50 을 기록한 뒤 2를 반환합니다. 호출자가 size=2 를 채택하여 유효 범위가 [0, 2) 가 됩니다. index 0은 kim/40 그대로입니다. A 다음 B를 차례로 실행하므로 같은 index 1을 사용해 구멍이 생기지 않습니다. add 는 나이 범위를 재검사하지 않으며, list · summary 는 별도 명령입니다. 이 비교는 유효한 size와 같은 길이의 정상 배열에서 검증 실패를 보존하는 예제이지, 동시 실행이나 예외 중간 롤백 보장이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실패 보존 빈 email/30은 parseAge·용량 확인을 통과한 뒤 email 확인에서 실패합니다. |
|
|
| 다음 정상 요청 [email protected]/50이 같은 size 1 위치에 기록됩니다. |
|
|
| 사용 범위 채택 메서드가 2를 반환하고 main이 size 2로 채택합니다. |
|
|
실패는 같은 칸을 남기고 성공만 두 배열과 size를 전진시킨다
같은 size=1 시작 상태에서 빈 이메일 거절은 index 1을 그대로 두고 정상 요청은 두 칸 기록 후 size=2를 반환합니다. 검증과 저장 확정의 전후가 구체적입니다.
kim/40 한 건을 저장한 size=1 상태에서 빈 이메일 요청이 실패하면 두 배열은 그대로다. 다음 lee/50 요청은 같은 index 1을 기록하고 호출자가 반환값 2를 size에 대입한다.
검증 실패는 다음 회원이 쓸 칸을 소비하지 않습니다
빈 이메일 요청 A를 거절한 뒤 정상 요청 B를 실행합니다. 두 요청은 같은 index 1에서 시작하고 성공한 B만 size를 2로 바꿉니다.
시작 size=1 · 용량 3index 0은 [email protected] / 40, index 1은 null / 0
A · 빈 이메일 / 30
parseAge에서 나이 30은 14~120 검사를 통과했습니다.
용량 확인 · PASS
size=1이고 두 배열 용량이 3이므로 남은 칸이 있습니다.이메일 확인 · FAIL
이메일이 비어 있으므로
invalid email을 출력합니다. 기존 1을 반환하며 아래 배열 기록에는 도달하지 않습니다.기록: NOT REACHED · 상태 보존
emails[1]=null,ages[1]=0이며 두 배열 전체가 그대로입니다. 호출자의size = add(...)는 1을 채택합니다. 유효 범위는[0, 1)입니다.
B · 다음 [email protected] / 50
A 이후에도 같은 size=1 상태입니다. parseAge에서 50도 검사를 통과합니다.
용량 확인 · PASS
아직
size=1, 두 배열 용량은 3입니다. A에서 거절된 칸을 소비하지 않았습니다.이메일 확인 · PASS
[email protected]은 비어 있지 않습니다. 여기가 A와 처음 달라지는 검사입니다.두 칸 기록 · 다음 상태 채택
emails[1]="[email protected]"과ages[1]=50을 기록한 뒤 2를 반환합니다. 호출자가size=2를 채택하여 유효 범위가[0, 2)가 됩니다. index 0은 kim/40 그대로입니다.
A 다음 B를 차례로 실행하므로 같은 index 1을 사용해 구멍이 생기지 않습니다. add는 나이 범위를 재검사하지 않으며, list·summary는 별도 명령입니다. 이 비교는 유효한 size와 같은 길이의 정상 배열에서 검증 실패를 보존하는 예제이지, 동시 실행이나 예외 중간 롤백 보장이 아닙니다.
이 예의 조건
- 용량 3, size 1이며 index 0=kim/40, index 1=null/0부터 시작합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 6 | index 0: [email protected] |
| 7 | 40 · index 1: null |
| 8 | 0 |
| 9 | A · 빈 이메일 |
| 10 | 30 |
| 11 | B · [email protected] |
| 12 | 50 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 실패는 같은 칸을 남기고 성공만 두 배열과 size를 전진시킨다 빈 이메일 실패 뒤 같은 index 1에 정상 회원을 저장하는 상태 경계 kim/40 한 건을 저장한 size=1 상태에서 빈 이메일 요청이 실패하면 두 배열은 그대로다. 다음 lee/50 요청은 같은 index 1을 기록하고 호출자가 반환값 2를 size에 대입한다. 검증 실패는 다음 회원이 쓸 칸을 소비하지 않습니다 빈 이메일 요청 A를 거절한 뒤 정상 요청 B를 실행합니다. 두 요청은 같은 index 1에서 시작하고 성공한 B만 size를 2로 바꿉니다. 시작 size=1 · 용량 3 index 0은 [email protected] / 40, index 1은 null / 0 A · 빈 이메일 / 30 parseAge 에서 나이 30은 14~120 검사를 통과했습니다. 용량 확인 · PASS size=1 이고 두 배열 용량이 3이므로 남은 칸이 있습니다. 이메일 확인 · FAIL 이메일이 비어 있으므로 invalid email 을 출력합니다. 기존 1 을 반환하며 아래 배열 기록에는 도달하지 않습니다. 기록: NOT REACHED · 상태 보존 emails[1]=null , ages[1]=0 이며 두 배열 전체가 그대로입니다. 호출자의 size = add(...) 는 1을 채택합니다. 유효 범위는 [0, 1) 입니다. B · 다음 [email protected] / 50 A 이후에도 같은 size=1 상태입니다. parseAge 에서 50도 검사를 통과합니다. 용량 확인 · PASS 아직 size=1 , 두 배열 용량은 3입니다. A에서 거절된 칸을 소비하지 않았습니다. 이메일 확인 · PASS [email protected] 은 비어 있지 않습니다. 여기가 A와 처음 달라지는 검사입니다. 두 칸 기록 · 다음 상태 채택 emails[1]="[email protected]" 과 ages[1]=50 을 기록한 뒤 2를 반환합니다. 호출자가 size=2 를 채택하여 유효 범위가 [0, 2) 가 됩니다. index 0은 kim/40 그대로입니다. A 다음 B를 차례로 실행하므로 같은 index 1을 사용해 구멍이 생기지 않습니다. add 는 나이 범위를 재검사하지 않으며, list · summary 는 별도 명령입니다. 이 비교는 유효한 size와 같은 길이의 정상 배열에서 검증 실패를 보존하는 예제이지, 동시 실행이나 예외 중간 롤백 보장이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실패 보존 빈 email/30은 parseAge·용량 확인을 통과한 뒤 email 확인에서 실패합니다. |
|
|
| 다음 정상 요청 [email protected]/50이 같은 size 1 위치에 기록됩니다. |
|
|
| 사용 범위 채택 메서드가 2를 반환하고 main이 size 2로 채택합니다. |
|
|
검증을 두 메서드가 나눠 맡는 방식도 눈여겨보세요. 숫자로 바꿀 수 없는 나이와 14 이상 120 이하를 벗어난 나이는 parseAge가 걸러 null로 알립니다. 그래서 add의 int value에는 이미 검증을 통과한 나이만 들어오고, add는 나이를 다시 검사하지 않습니다. 대신 add는 저장과 직접 관련된 남은 칸과 이메일을 확인합니다.
add의 첫 번째 검사는 성격이 조금 다릅니다. size가 음수이거나 배열 길이보다 크다는 것은 사용자가 잘못 입력한 것이 아니라 프로그램 어딘가에 버그가 있다는 뜻입니다. 그래서 다른 실패처럼 안내 문구를 출력하고 계속하는 대신, IllegalStateException을 던져 프로그램을 멈춥니다. 사용자의 실수는 알려 주고 다시 입력받으면 되지만, 망가진 상태로 계속 실행하면 이후의 모든 결과를 믿을 수 없기 때문입니다.
입력이 중간에 끝나는 경우도 생각해 두어야 합니다. command>에서 입력을 끝내면(Windows는 Ctrl+Z 다음 Enter, macOS·Linux는 Ctrl+D) hasNextLine()이 false가 되어 반복을 빠져나가고 bye를 출력합니다. 하지만 add를 입력한 뒤 이메일이나 나이를 입력하기 전에 입력이 끝나면 readLine이 missing email 또는 missing age라는 메시지로 IllegalStateException을 던지고 프로그램이 멈춥니다. 반면 forty처럼 숫자가 아닌 나이는 parseAge가 null로 처리하므로 프로그램이 멈추지 않고 다음 명령을 받습니다.
더 깊이: null 말고 실패를 알리는 방법
parseAge처럼 실패를 null로 알리는 방식은 지금까지 배운 문법만으로 쓸 수 있어 간단합니다. 하지만 null이 숫자가 아니라서인지, 범위를 벗어나서인지 구별되지 않고, 호출한 쪽이 null 검사를 잊으면 NullPointerException이 납니다. 자바 표준 라이브러리에는 값이 있을 수도 없을 수도 있다는 것을 타입으로 드러내는 OptionalInt 같은 클래스가 있습니다. 실패 이유까지 전달하려면 성공 값과 실패 이유를 함께 담는 결과 타입을 직접 만들기도 합니다. Optional은 Optional을 다루는 장에서 배웁니다.
규칙이 바뀌면 어디를 고칠까
메서드를 많이 만들었다고 해서 좋은 구조가 되는 것은 아닙니다. 좋은 구조인지는 요구 사항이 바뀌었을 때 고칠 곳이 얼마나 좁은지로 확인할 수 있습니다. 이 프로그램에 생길 만한 변경 네 가지를 놓고 따져 보겠습니다.
| 바뀌는 요구 사항 | 고칠 곳 | 영향이 없는 곳 |
|---|---|---|
| 최소 가입 나이를 18세로 올리기 | parseAge의 value >= 14를 value >= 18로 | 저장, 목록, 집계 |
목록을 email (age세) 형식으로 바꾸기 | printEntries의 출력 한 줄 | 입력 해석, 합계 계산 |
| 평균을 소수점 한 자리로 표시하기 | printSummary의 출력 부분 | total의 합계 계산 |
| 최대 회원 수를 늘리기 | main에서 배열을 만드는 크기 | add를 포함한 모든 메서드 |
네 경우 모두 한 메서드나 한 줄만 고치면 됩니다. 마지막 줄이 특히 중요합니다. add는 남은 칸을 emails.length와 ages.length로 판단하므로, main에서 배열 크기만 바꾸면 나머지 코드는 손댈 필요가 없습니다. 만약 add 안에 size == 3처럼 용량을 숫자로 직접 적어 두었다면, 배열 크기를 바꿀 때마다 그런 숫자를 모두 찾아 고쳐야 하고 하나라도 놓치면 버그가 됩니다.
여기서 얻을 수 있는 기준은 함께 바뀌는 코드는 한곳에 모으고, 다른 이유로 바뀌는 코드는 나누는 것입니다. 입력을 받는 방식, 저장 규칙, 계산 규칙, 출력 형식은 저마다 다른 이유로 바뀝니다. 그래서 이 넷을 서로 다른 메서드에 두면 한 가지가 바뀌어도 나머지는 그대로 둘 수 있습니다.
실패한 입력으로 시험해 보기
정상 입력으로 한 번 실행해 보는 것만으로는 충분하지 않습니다. 앞에서 본 빈칸 버그도 정상 입력만 넣었다면 드러나지 않았을 것입니다. 실패한 입력 바로 뒤에 정상 입력을 넣었을 때 비로소 보입니다. 다음 상황들을 직접 입력해 보고, 오른쪽 결과가 나오는지 확인하세요.
| 시험할 상황 | 입력 예 | 확인할 결과 |
|---|---|---|
| 아무도 없을 때 목록과 요약 | 첫 명령으로 list, summary | no entries, summary=none |
| 잘못된 이메일 | 빈 줄, 공백만 있는 줄 | invalid email, 회원 수 그대로 |
| 잘못된 나이 | 13, 121, forty | invalid age, 회원 수 그대로 |
| 용량만큼 추가 | 정상 회원 세 명 | added=가 세 번 |
| 용량을 넘는 추가 | 네 번째 회원 | full, 목록은 세 명 그대로 |
| 실패 뒤 정상 입력 | 빈 이메일 다음에 정상 회원 | 빈 번호 없이 이어서 저장 |
| 집계 대상 | 실패를 섞은 뒤 list, summary | 목록에 보이는 회원의 나이로만 계산 |
quit 뒤에 남은 입력 | 파일로 넘기는 입력(아래 참고)에서 quit 다음 줄에 다른 명령 | 더 처리하지 않고 bye 출력 |
용량을 넘는 경우에는 이메일과 나이를 다 입력한 뒤에야 full이 출력됩니다. 이 프로그램은 두 줄을 먼저 읽고 나서 add가 남은 칸을 확인하기 때문입니다. 키보드로 쓰는 사람에게는 조금 번거롭지만, 그 대신 add 다음에는 언제나 두 줄을 읽는다는 입력 형식이 지켜집니다. 남은 칸부터 확인하고 두 줄을 읽지 않은 채 넘어가는 프로그램에 아래처럼 미리 준비한 입력을 넘기면, 읽지 않은 이메일과 나이가 다음 명령으로 잘못 읽힙니다.
같은 상황을 여러 번 시험해야 한다면 매번 손으로 입력하는 대신 입력을 파일에 적어 두고 프로그램에 넘길 수 있습니다. 예를 들어 다음 내용을 src와 out 폴더가 있는 곳에 invalid.txt로 저장합니다. 두 번째 줄은 빈 이메일을 뜻하는 빈 줄입니다.
add
30
add
[email protected]
13
add
[email protected]
121
add
[email protected]
forty
list
quitPowerShell에서는 Get-Content로 파일 내용을 읽어 파이프(|)로 넘기고, macOS·Linux에서는 <로 파일을 입력으로 연결합니다. 두 방법 모두 프로그램 코드는 바꾸지 않습니다. MemberRegistryCli는 여전히 System.in에서 읽고, 셸이 키보드 대신 파일 내용을 표준 입력으로 넘겨 줍니다.
Get-Content invalid.txt | java -cp out MemberRegistryClijava -cp out MemberRegistryCli < invalid.txtcommand> email> age> invalid email
command> email> age> invalid age
command> email> age> invalid age
command> email> age> invalid age
command> no entries
command> bye size=0안내 문구가 한 줄에 몰려 있는 이유는 입력한 글자가 화면에 나타나지 않았기 때문입니다. 키보드로 입력할 때 보이는 글자와 줄바꿈은 터미널이 대신 보여 주는 것이고, 프로그램이 출력한 것이 아닙니다. 파일로 입력을 넘기면 이 과정이 없으니 프로그램이 출력한 안내 문구와 결과만 이어서 나옵니다. 네 번의 add가 모두 거절되었고, 마지막 list가 no entries를 출력하므로 거절된 입력이 아무 칸도 차지하지 않았다는 것을 확인할 수 있습니다.
배열 두 개로 회원을 관리하는 구조의 한계
지금 구조는 이번 장에서 배운 문법을 종합하기에는 알맞지만, 기능이 늘어나면 한계가 분명해집니다. 이메일과 나이를 서로 다른 배열에 나눠 두었기 때문에, 추가하거나 삭제하거나 정렬할 때마다 두 배열을 같은 번호로 함께 움직여야 합니다. 한쪽만 옮기면 다른 회원의 이메일과 나이가 짝지어집니다. 또 add, printEntries, printSummary처럼 거의 모든 메서드가 배열과 size를 매개변수로 받아야 해서 호출문이 길어집니다. 배열 길이가 고정되어 있으니 가득 차면 더 큰 배열을 새로 만들고 값을 복사해야 한다는 점도 불편합니다.
그렇다고 매개변수를 줄이려고 배열과 size를 static 변수로 빼서 어느 메서드에서나 고칠 수 있게 하면, 누가 언제 size를 바꿨는지 추적하기 어려워집니다. 앞으로는 다른 방법을 씁니다. 다음 장에서는 한 회원의 이메일과 나이를 클래스로 묶어 하나의 값처럼 다룹니다. 이어서 캡슐화로 회원 저장소 완성하기에서는 회원 배열과 size를 한 객체 안에 넣고 그 객체만 이 값을 고칠 수 있게 합니다. 그러면 add나 summary는 배열과 size를 매개변수로 받지 않고 객체 자신의 상태를 사용합니다. 나중에 컬렉션을 배우면 고정 용량과 값을 직접 옮기는 코드도 사라집니다.
그렇다고 이 글의 코드가 쓸모없어지는 것은 아닙니다. 상태를 바꾸기 전에 입력을 검증하고, 실패하면 상태를 그대로 두고, 저장된 범위까지만 읽고 집계한다는 규칙은 클래스와 컬렉션으로 바뀐 뒤에도 그대로 지켜야 합니다. 앞으로 배울 문법은 이 규칙을 더 안전하게 지키도록 도와주는 도구입니다.
정리
- 저장할 때는 남은 칸 확인, 값 검증, 두 배열에 기록,
size증가의 순서를 지킵니다. 검증 전에size를 늘리면 거절된 입력이 빈칸을 남깁니다. - 메서드 안에서 기본형 매개변수를 바꿔도 호출한 쪽 변수는 바뀌지 않습니다. 그래서
add는 새size를 돌려주고 호출한 쪽이size = add(...)로 받습니다. 실패하면 기존 값을 돌려주므로 상태가 그대로 유지됩니다. - 명령 반복과 분기는
main에 두고, 입력 읽기, 나이 해석, 저장, 출력, 집계처럼 다른 이유로 바뀌는 일은 메서드로 나눕니다. - 사용자의 잘못된 입력은 안내 문구를 출력하고 계속하지만,
size가 망가진 것 같은 프로그램 버그는 예외를 던져 멈춥니다. - 정상 입력만이 아니라 실패한 입력 바로 뒤에 정상 입력을 넣어 시험해야 순서 버그가 드러납니다.
연습 문제
remove메서드를 작성하세요. 이메일 배열, 나이 배열,size, 삭제할 위치target을 받습니다.target이 유효하면 그 뒤의 회원을 한 칸씩 앞으로 당기고, 마지막으로 쓰던 칸을null과 0으로 비운 뒤 새size를 돌려줍니다.target이 잘못되었으면 아무것도 바꾸지 않고 기존size를 돌려줍니다.
해설 보기
public final class RemoveMember {
public static void main(String[] args) {
String[] emails = {"[email protected]", "[email protected]", "[email protected]", null};
int[] ages = {40, 30, 50, 0};
int size = 3;
size = remove(emails, ages, size, 1);
print(emails, ages, size);
System.out.println("size=" + size);
}
private static int remove(
String[] emails,
int[] ages,
int size,
int target
) {
if (target < 0 || target >= size) {
return size;
}
for (int index = target; index < size - 1; index++) {
emails[index] = emails[index + 1];
ages[index] = ages[index + 1];
}
int last = size - 1;
emails[last] = null;
ages[last] = 0;
return size - 1;
}
private static void print(String[] emails, int[] ages, int size) {
for (int index = 0; index < size; index++) {
System.out.println(emails[index] + "=" + ages[index]);
}
}
}[email protected]=40
[email protected]=50
size=2예제는 회원 세 명 중 1번 칸의 [email protected]을 지웁니다. 두 배열이 어떻게 바뀌는지 따라가 보면 다음과 같습니다. 표에서는 이메일을 앞부분만 적었습니다.
| 단계 | emails | ages |
|---|---|---|
시작 (size는 3) | [kim, park, lee, null] | [40, 30, 50, 0] |
index가 1일 때 한 칸 당기기 | [kim, lee, lee, null] | [40, 50, 50, 0] |
| 마지막으로 쓰던 2번 칸 비우기 | [kim, lee, null, null] | [40, 50, 0, 0] |
반복 조건이 index < size - 1이므로 반복은 index가 1일 때 한 번만 실행되어 2번 칸의 값을 1번 칸으로 옮깁니다. 마지막으로 쓰던 칸은 뒤에서 당겨 올 값이 없으므로 반복 대상이 아니고, 반복이 끝난 뒤 따로 비웁니다. 비우지 않으면 2번 칸에 lee가 한 번 더 남아 있게 되는데, size 밖이라 출력되지는 않지만 나중에 디버깅할 때 헷갈리기 쉽습니다. 메서드가 2를 돌려주고 호출한 쪽이 size에 대입하면 이후 목록은 0번과 1번 칸만 읽습니다.
두 배열을 같은 반복문 안에서 함께 옮겨야 이메일과 나이가 어긋나지 않습니다. 또 remove도 add처럼 성공하면 size - 1을, 실패하면 기존 size를 돌려주므로 호출하는 쪽은 언제나 size = remove(...)로 받으면 됩니다.
- 완성한
MemberRegistryCli를 실행하고 다음 순서로 입력했습니다. 화면에 무엇이 출력될지 예상해 보세요.[email protected]은 몇 번 칸에 저장될까요?
add
[email protected]
200
add
40
add
[email protected]
20
list
summary
quit해설 보기
command> add
email> [email protected]
age> 200
invalid age
command> add
email>
age> 40
invalid email
command> add
email> [email protected]
age> 20
[email protected]
command> list
1. [email protected]=20
command> summary
total=20, average=20.0
command> quit
bye size=1첫 번째 add의 나이 200은 120을 넘으므로 parseAge가 null을 돌려주고, main이 invalid age를 출력합니다. 이때는 add가 아예 호출되지 않습니다. 두 번째 add는 나이 40이 통과하지만 이메일이 비어 있어 add가 invalid email을 출력하고 0을 그대로 돌려줍니다. 두 번 모두 상태가 바뀌지 않았으므로 size는 여전히 0이고, [email protected]은 0번 칸에 저장됩니다. 그래서 목록에는 한 명만 나오고, 합계와 평균도 20으로 계산되며, 종료할 때 bye size=1이 출력됩니다.