캡슐화로 회원 저장소 완성하기
내부 배열을 그대로 돌려주면 private 필드도 소용없어지는 이유를 알아보고, record와 불변식을 이용해 회원 저장소와 명령줄 프로그램을 완성합니다.
앞 글에서는 회원 저장소의 필드를 private으로 숨기고 add, memberAt, totalAge 같은 메서드만 공개했습니다. 이제 바깥 코드는 members 배열이나 size를 직접 바꿀 수 없습니다. 그런데 회원 목록이 필요하다는 요청을 받고 저장소에 내부 배열을 돌려주는 메서드를 하나 추가하면 어떻게 될까요? 필드는 여전히 private이지만, 돌려받은 배열을 통해 바깥 코드가 저장소 안의 칸을 다시 마음대로 바꿀 수 있게 됩니다.
캡슐화(encapsulation)는 객체의 상태와 그 상태를 다루는 코드를 한곳에 모으고, 바깥에는 필요한 행동만 내보이는 설계 방법입니다. 필드에 private을 붙이는 것은 출발점일 뿐이고, 메서드가 무엇을 돌려주는지와 상태가 어떤 순서로 바뀌는지까지 객체가 직접 정해야 합니다. 이 글에서는 먼저 내부 배열이 새어 나가는 버그를 재현하고 저장소가 항상 지켜야 할 조건을 글로 적어 봅니다. 그다음 그 조건을 지키는 저장소와 명령줄 프로그램(CLI)을 완성합니다. 그에 앞서 이 글부터 회원 데이터를 선언할 때 쓰는 record 문법을 살펴봅니다.
Member를 record로 선언하기
지금까지 Member는 필드, 생성자, 읽기 메서드를 모두 손으로 적은 클래스였습니다. 이메일과 나이처럼 '어떤 값들로 이루어진 데이터인가'가 전부인 타입이라면 이런 코드를 타입마다 되풀이하게 됩니다. record(레코드)는 구성 값의 목록만 적으면 나머지 코드를 컴파일러가 채워 주는 특별한 형태의 클래스로, Java 16부터 정식 기능입니다. 클래스로 관련 데이터 묶기에서 잠깐 소개했는데, 이 교재는 이 글부터 값 중심의 작은 타입에 record를 사용합니다. 다음은 같은 Member를 두 방식으로 선언한 것입니다.
private static final class MemberClass {
private final String email;
private final int age;
MemberClass(String email, int age) {
this.email = email;
this.age = age;
}
String email() { return email; }
int age() { return age; }
}
private record Member(String email, int age) {}아래쪽 한 줄 private record Member(String email, int age) {}가 위쪽 클래스의 필드 두 개, 생성자, 읽기 메서드 두 개를 모두 대신합니다. 괄호 안에 적은 email과 age를 구성 요소(component)라고 부르며, 컴파일러는 구성 요소마다 private final 필드와 같은 이름의 읽기 메서드를 만듭니다. 읽기 메서드의 이름이 getEmail()이 아니라 email()이라는 점에 주의하세요. 여기에 더해 위쪽 클래스에는 없는 equals, hashCode, toString도 만들어 줍니다. 두 선언 모두 다른 클래스 안에 넣는 중첩 선언이므로, 실제로는 아래 예제처럼 실행 클래스 안에 둡니다.
public final class RecordBasics {
public static void main(String[] args) {
Member first = new Member("[email protected]", 40);
Member second = new Member("[email protected]", 40);
System.out.println(first.email() + ", " + first.age());
System.out.println(first);
System.out.println(first == second);
System.out.println(first.equals(second));
}
private record Member(String email, int age) {}
}[email protected], 40
Member[[email protected], age=40]
false
true첫 줄은 컴파일러가 만든 읽기 메서드 email()과 age()의 결과입니다. 둘째 줄은 println이 record를 문자열로 바꿀 때 부르는 toString()의 결과로, 타입 이름 뒤 대괄호 안에 구성 요소의 이름과 값을 나열합니다. 셋째와 넷째 줄은 같은 값으로 따로 만든 두 객체를 비교합니다. ==는 두 변수가 같은 객체를 가리키는지를 비교하므로 false이고, record가 만든 equals는 구성 요소의 값이 모두 같은지를 비교하므로 true입니다. equals와 hashCode를 직접 작성할 때 지켜야 할 규칙은 9장에서 다룹니다.
record의 필드는 final이라서 객체를 만든 뒤에는 다시 대입할 수 없습니다. 하지만 이것이 안에 든 것까지 전부 바뀌지 않는다는 뜻은 아닙니다. 구성 요소로 ArrayList 같은 가변 목록을 넣으면 record는 그 목록을 가리키는 참조만 고정할 뿐, 목록에 원소를 더하거나 빼는 것은 막지 못합니다. 이 글의 Member는 바꿀 수 없는 String과 기본형 int만 가지므로 이런 문제가 없습니다. 가변 구성 요소를 안전하게 다루는 방법은 36장의 record 심화에서 다룹니다.
더 깊이: record가 만들어 주는 것과 허용하지 않는 것
record 선언 하나로 컴파일러는 구성 요소마다 private final 필드와 public 읽기 메서드를 만들고, 모든 구성 요소를 선언 순서대로 받는 정식 생성자(canonical constructor)도 만듭니다. 그 대신 일반 클래스에는 없는 제약이 있습니다. record는 자동으로 final이라 다른 클래스가 상속할 수 없고, record 자신도 extends로 다른 클래스를 상속할 수 없습니다. 또 구성 요소 말고는 인스턴스 필드를 선언할 수 없어서, record 본문에 private int visits;를 넣으면 javac가 field declaration must be static 오류를 냅니다. 메서드와 정적 필드는 추가할 수 있으므로, 뒤의 예제처럼 describe() 같은 메서드를 넣는 것은 문제없습니다.
다른 클래스 안에 선언한 record는 static을 적지 않아도 정적 중첩 타입이 됩니다. 위쪽 클래스 버전에 붙인 static final을 record는 처음부터 갖고 있는 셈입니다. 그리고 toString()이 만드는 문자열 형식은 java.lang.Record의 Javadoc에 바뀔 수 있다고 적혀 있습니다. 출력 문자열을 잘라서 값을 꺼내는 코드를 쓰지 말고 읽기 메서드를 써야 하는 이유입니다.
내부 배열을 그대로 돌려주면 생기는 일
다음 저장소는 필드를 모두 private으로 숨겼지만, 회원 목록이 필요한 호출자를 위해 members() 메서드로 내부 배열을 돌려줍니다. main은 돌려받은 배열의 0번 칸에 null을 넣은 다음, 저장소에 첫 회원의 설명을 요청합니다. 버그를 보여 주는 데 필요 없는 용량 검사와 회원 규칙 검사는 생략했습니다. 실행하기 전에 어떤 결과가 나올지 먼저 예상해 보세요.
public final class ExposedArrayBug {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry();
registry.add("[email protected]", 40);
Member[] leaked = registry.members();
leaked[0] = null;
System.out.println(registry.firstDescription());
}
private static final class MemberRegistry {
private final Member[] members = new Member[2];
private int size;
void add(String email, int age) {
members[size++] = new Member(email, age);
}
Member[] members() {
return members;
}
String firstDescription() {
return members[0].describe();
}
}
private record Member(String email, int age) {
String describe() {
return email + "=" + age;
}
}
}Exception in thread "main" java.lang.NullPointerException: Cannot invoke "ExposedArrayBug$Member.describe()" because "this.members[0]" is null
at ExposedArrayBug$MemberRegistry.firstDescription(ExposedArrayBug.java:25)
at ExposedArrayBug.main(ExposedArrayBug.java:9)main은 저장소의 필드를 직접 건드리지 않고 공개된 메서드만 호출했습니다. 그런데도 저장소 안의 배열이 바뀌었습니다. 원인은 members()가 배열의 복사본이 아니라 배열을 가리키는 참조값을 돌려준다는 데 있습니다. 기본형과 참조형 복사에서 본 것처럼 참조값을 복사하면 같은 객체로 가는 길이 하나 늘어날 뿐 객체가 복제되지는 않습니다. 그래서 지역 변수 leaked와 필드 members는 길이 2인 같은 배열을 가리키고, leaked[0] = null은 바로 그 배열의 0번 칸을 비웁니다.
그 결과 저장소의 상태에 모순이 생깁니다. size는 여전히 1이라 저장소는 0번 칸에 회원이 있다고 여기지만, 실제 0번 칸은 비어 있습니다. 그래서 firstDescription()이 그 칸의 describe()를 호출하는 순간 NullPointerException이 발생합니다. 오류 메시지의 "this.members[0]" is null이 비어 버린 칸을 정확히 가리키고, ExposedArrayBug$Member는 ExposedArrayBug 안에 선언한 중첩 타입 Member를 부르는 이름입니다. 아래 그림은 반환된 참조를 통해 내부 배열의 칸이 바뀌는 과정을 단계별로 보여 줍니다.
반환된 참조로 내부 배열의 칸이 바뀐다
members()로 실제 배열 참조가 새어 leaked[0]=null이 내부 칸을 비우지만 size=1은 유지됩니다. 참조 반환과 외부 대입 뒤 불변식 파괴를 전후로 보여 줄 수 있습니다.
ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다.
내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다
members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다.
ExposedArrayBug · leaked[0] = null; 직후
- members · 내부 필드
- 길이 2인 배열을 참조하며
size = 1입니다. - leaked · 반환받은 참조
members()는 복사본이 아닌 같은 배열을 반환합니다.- 공유된 배열의 변경
leaked[0] = null;뒤 두 경로로 읽은 0번 칸이 모두null입니다. 1번 칸도 처음부터null입니다.
size는 여전히 1인데 사용 영역의 0번 칸이 비었습니다. 뒤의 firstDescription()은 members[0].describe()에서 NullPointerException을 만납니다.
이 그림은 배열 원소의 참조 변경을 보여 줍니다. 회원 객체의 필드를 수정하거나 객체 자체를 즉시 삭제한 동작이 아닙니다.
이 예의 조건
- 내부 배열 길이 2, size 1의 반환 참조 노출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 반환된 참조로 내부 배열의 칸이 바뀐다 반환된 참조로 내부 배열의 칸이 바뀐다 ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다. 내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다 members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다. ExposedArrayBug · leaked[0] = null; 직후 members · 내부 필드 길이 2인 배열을 참조하며 size = 1 입니다. leaked · 반환받은 참조 members() 는 복사본이 아닌 같은 배열을 반환합니다. 공유된 배열의 변경 leaked[0] = null; 뒤 두 경로로 읽은 0번 칸이 모두 null 입니다. 1번 칸도 처음부터 null 입니다. size 는 여전히 1인데 사용 영역의 0번 칸이 비었습니다. 뒤의 firstDescription() 은 members[0].describe() 에서 NullPointerException 을 만납니다. 이 그림은 배열 원소의 참조 변경을 보여 줍니다. 회원 객체의 필드를 수정하거나 객체 자체를 즉시 삭제한 동작이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 내부 보관 members[0]에 Kim 참조가 있고 1번은 미사용입니다. |
|
|
| 반환 참조 leaked가 같은 배열을 받습니다. |
|
|
| 외부 슬롯 쓰기 leaked[0]=null을 대입합니다. |
|
|
반환된 참조로 내부 배열의 칸이 바뀐다
members()로 실제 배열 참조가 새어 leaked[0]=null이 내부 칸을 비우지만 size=1은 유지됩니다. 참조 반환과 외부 대입 뒤 불변식 파괴를 전후로 보여 줄 수 있습니다.
ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다.
내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다
members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다.
ExposedArrayBug · leaked[0] = null; 직후
- members · 내부 필드
- 길이 2인 배열을 참조하며
size = 1입니다. - leaked · 반환받은 참조
members()는 복사본이 아닌 같은 배열을 반환합니다.- 공유된 배열의 변경
leaked[0] = null;뒤 두 경로로 읽은 0번 칸이 모두null입니다. 1번 칸도 처음부터null입니다.
size는 여전히 1인데 사용 영역의 0번 칸이 비었습니다. 뒤의 firstDescription()은 members[0].describe()에서 NullPointerException을 만납니다.
이 그림은 배열 원소의 참조 변경을 보여 줍니다. 회원 객체의 필드를 수정하거나 객체 자체를 즉시 삭제한 동작이 아닙니다.
이 예의 조건
- 내부 배열 길이 2, size 1의 반환 참조 노출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 반환된 참조로 내부 배열의 칸이 바뀐다 반환된 참조로 내부 배열의 칸이 바뀐다 ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다. 내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다 members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다. ExposedArrayBug · leaked[0] = null; 직후 members · 내부 필드 길이 2인 배열을 참조하며 size = 1 입니다. leaked · 반환받은 참조 members() 는 복사본이 아닌 같은 배열을 반환합니다. 공유된 배열의 변경 leaked[0] = null; 뒤 두 경로로 읽은 0번 칸이 모두 null 입니다. 1번 칸도 처음부터 null 입니다. size 는 여전히 1인데 사용 영역의 0번 칸이 비었습니다. 뒤의 firstDescription() 은 members[0].describe() 에서 NullPointerException 을 만납니다. 이 그림은 배열 원소의 참조 변경을 보여 줍니다. 회원 객체의 필드를 수정하거나 객체 자체를 즉시 삭제한 동작이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 내부 보관 members[0]에 Kim 참조가 있고 1번은 미사용입니다. |
|
|
| 반환 참조 leaked가 같은 배열을 받습니다. |
|
|
| 외부 슬롯 쓰기 leaked[0]=null을 대입합니다. |
|
|
이 예에서 알 수 있듯이 private이 막는 것은 필드 이름을 통한 접근이지, 필드가 가리키는 객체 자체가 아닙니다. 참조가 한 번 바깥으로 나가면 그 참조를 가진 코드는 누구든 객체를 바꿀 수 있습니다. 그래서 캡슐화된 객체는 무엇을 숨길지와 함께 무엇을 돌려줄지도 골라야 합니다.
참고로 이 글의 예제는 한 파일로 실행할 수 있도록 저장소와 Member를 실행 클래스 안에 중첩 타입으로 선언했습니다. 이렇게 하면 같은 파일의 main은 중첩 타입의 private 필드에도 접근할 수 있지만, 예제의 main은 언제나 메서드만 호출합니다. 저장소를 별도 파일의 클래스로 옮겨도 이 버그는 똑같이 일어납니다. 또 이 글에서는 public을 붙이지 않았더라도 바깥 코드가 호출하라고 내놓은 메서드를 공개 메서드라고 부릅니다.
복사본이나 필요한 결과만 돌려주기
가장 간단한 해결책은 배열을 새로 만들어 돌려주는 것입니다. 배열과 인덱스에서 쓴 Arrays.copyOf로 사용 중인 칸만 복사하면, 호출자는 저장소와 무관한 새 배열을 받습니다. 파일 맨 위에 import java.util.Arrays;를 추가하고 members()를 다음처럼 바꿉니다.
Member[] members() {
return Arrays.copyOf(members, size);
}같은 main을 다시 실행하면 이번에는 예외 없이 [email protected]=40이 출력됩니다. leaked[0] = null이 비운 것은 길이 1인 복사본의 칸이고, 저장소의 배열은 그대로이기 때문입니다. 복사본의 길이를 size에 맞췄으므로 사용하지 않는 null 칸이 바깥에 드러나지도 않습니다.
다만 복사에도 한계가 있습니다. Arrays.copyOf가 새로 만드는 것은 배열뿐이고, 각 칸에는 원본 배열과 같은 Member 객체의 참조가 들어갑니다. 이렇게 바깥의 배열만 새로 만들고 안의 객체는 공유하는 복사를 얕은 복사(shallow copy)라고 합니다. 이 글의 Member는 record라서 객체를 손에 넣어도 바꿀 방법이 없으니 문제가 되지 않습니다. 하지만 앞 글의 correctAge처럼 상태를 바꾸는 메서드가 있는 Member였다면, 호출자가 복사본에서 꺼낸 회원의 나이를 바꿀 수 있고 그 변경은 저장소 안의 회원에게 그대로 나타납니다.
그래서 더 나은 방법은 배열 자체를 내주지 않고, 호출자가 실제로 필요로 하는 결과만 돌려주는 것입니다. 회원 수가 필요하면 개수를, 한 회원의 설명이 필요하면 그 문자열을 돌려주면 됩니다. 뒤에서 완성할 저장소도 describeAt, summary처럼 결과만 돌려주는 메서드를 공개합니다. 회원 설명 목록을 새 배열로 돌려주는 메서드는 연습 문제에서 직접 만들어 봅니다.
저장소가 항상 지켜야 할 조건
앞의 버그에서 깨진 것은 'size가 1이면 0번 칸에 회원이 있다'는 약속이었습니다. 이처럼 객체가 언제 보아도 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 여기서 '언제 보아도'는 바깥 코드가 객체를 볼 수 있는 시점, 즉 공개 메서드를 호출하기 전과 호출이 끝난 뒤를 말합니다. 불변식을 글로 적어 두면 메서드를 작성할 때 무엇을 지켜야 하는지가 분명해지고, 버그가 났을 때도 어느 조건이 깨졌는지부터 따져 볼 수 있습니다. 회원 저장소 MemberRegistry의 불변식은 다음 네 가지입니다.
0 <= size && size <= members.length입니다. 회원 수는 음수일 수 없고 배열 길이를 넘을 수도 없습니다.- 0번부터
size - 1번까지의 칸에는null이 아닌 Member가 들어 있습니다. size번부터 마지막 칸까지는 사용하지 않는 칸이며null로 비어 있습니다.- 저장된 모든 Member는 이메일이
null도 아니고 비어 있거나 공백만 있는 문자열도 아니며, 나이가 14세 이상 120세 이하입니다.
여기에 상태가 아니라 메서드가 지켜야 할 규칙을 하나 더합니다. 추가나 삭제가 실패하면 저장소의 상태는 호출하기 전과 같아야 합니다. 실패한 추가 때문에 size만 늘어나면 2번 조건이 깨지기 때문입니다. 앞 글의 members[size++] = new Member(email, age)가 바로 이 문제를 안고 있었습니다. 생성자가 예외를 던져도 이미 늘어난 size는 되돌아가지 않았습니다.
이 조건들은 클래스의 여러 부분이 나눠서 지킵니다. private 필드는 바깥 코드가 메서드를 거치지 않고 상태를 바꾸는 길을 막고, 생성자는 비어 있는 배열과 size 0이라는 올바른 시작 상태를 만듭니다. 그다음부터는 공개 메서드가 상태를 바꿀 때마다 끝나기 전에 네 조건을 다시 성립시키면 됩니다. 시작 상태가 올바르고 모든 공개 메서드가 조건을 지키면, 바깥에서 메서드를 어떤 순서로 몇 번 부르든 조건은 깨지지 않습니다. 단, 앞 절처럼 내부 배열의 참조가 새어 나가면 메서드를 거치지 않는 길이 생겨 이 논리가 무너집니다.
한 가지 덧붙이면, 불변식은 공개 메서드가 실행되는 도중에는 잠시 깨질 수 있습니다. 메서드 안에서 상태를 여러 문장에 걸쳐 바꾸다 보면 중간 상태가 생기기 마련입니다. 다음 절의 삭제 과정에서 그런 중간 상태를 직접 확인합니다.
조건을 지키며 추가하고 삭제하기
이제 네 조건을 지키는 저장소를 완성합니다. 다음 프로그램은 용량이 3인 저장소에 회원 세 명을 넣고 가운데 회원을 삭제한 뒤, 남은 회원과 요약을 출력합니다. Member는 record로 선언하고, 회원 규칙은 record 안에서 검사합니다.
public final class EncapsulatedMemberRegistry {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry(3);
registry.add("[email protected]", 40);
registry.add("[email protected]", 30);
registry.add("[email protected]", 50);
System.out.println(registry.removeAt(1));
System.out.println(registry.describeAt(0));
System.out.println(registry.describeAt(1));
System.out.println(registry.summary());
}
private static final class MemberRegistry {
private final Member[] members;
private int size;
MemberRegistry(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity");
members = new Member[capacity];
}
boolean add(String email, int age) {
if (size == members.length) return false;
try {
members[size] = new Member(email, age);
size++;
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
boolean removeAt(int target) {
if (target < 0 || target >= size) return false;
for (int index = target; index < size - 1; index++) {
members[index] = members[index + 1];
}
members[size - 1] = null;
size--;
return true;
}
String describeAt(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
return members[index].describe();
}
String summary() {
int total = 0;
for (int index = 0; index < size; index++) {
total += members[index].age();
}
double average = size == 0 ? 0.0 : (double) total / size;
return "count=" + size + ", total=" + total + ", average=" + average;
}
}
private record Member(String email, int age) {
Member {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
}
String describe() {
return email + "=" + age;
}
}
}true
[email protected]=40
[email protected]=50
count=2, total=90, average=45.0첫 줄의 true는 인덱스 1의 park 회원을 지운 removeAt(1)의 반환값입니다. 삭제하면서 lee 회원이 인덱스 1로 당겨졌으므로 describeAt(1)은 lee를 출력하고, 요약에는 남은 두 명의 나이 합 90과 평균 45.0이 나옵니다. 평균이 45가 아니라 45.0인 것은 summary가 (double) total / size로 실수 나눗셈을 하기 때문입니다.
회원은 만들어지는 순간 검사한다
Member record 본문의 Member { ... }는 컴팩트 생성자(compact constructor)입니다. 매개변수 목록을 다시 적지 않고 검사 코드만 쓰는 생성자 형식으로, 본문이 정상적으로 끝나면 컴파일러가 매개변수 값을 필드에 대입합니다. 그래서 본문 안에서 this.age = age;처럼 필드에 직접 대입하면 cannot assign a value to final variable age 오류가 납니다. 검사에 실패해 예외를 던지면 생성자와 this에서 본 것처럼 new 식은 객체를 돌려주지 않습니다. 따라서 4번 조건은 Member가 만들어지는 순간 이미 지켜집니다.
add는 먼저 용량을 확인합니다. 가득 찼으면 Member를 만들기도 전에 false를 돌려주므로 아무것도 바뀌지 않습니다. 자리가 있으면 new Member(email, age)로 회원을 만드는데, 생성자가 예외를 던지면 배열 대입과 size++를 건너뛰고 곧바로 catch 블록에서 false를 돌려줍니다. 배열에 저장한 다음 줄에서야 size를 늘리므로, size는 저장이 성공했을 때만 늘어납니다. 이처럼 실패할 수 있는 일을 먼저 하고 상태를 바꾸는 문장을 뒤에 두면, 실패한 호출은 상태를 바꾸지 않습니다.
예외를 false로 바꾸는 데에는 대가도 있습니다. 호출자는 실패했다는 사실만 알 뿐, 용량이 가득 차서인지 회원 값이 잘못돼서인지는 알 수 없습니다. 뒤의 CLI는 rejected 한 단어만 출력하면 되므로 이것으로 충분합니다. 실패 이유를 알려 줘야 한다면 앞 글에서 고친 add처럼 잘못된 회원의 예외는 잡지 않고 호출자에게 그대로 전달하는 방법도 있습니다. 예외를 언제 잡고 언제 전달할지는 12장에서 다룹니다.
삭제는 참조를 당긴 뒤 마지막 칸을 비운다
removeAt은 먼저 target이 회원이 있는 0부터 size - 1 사이인지 확인하고, 범위 밖이면 아무것도 바꾸지 않고 false를 돌려줍니다. 범위 안이면 삭제할 칸부터 뒤의 회원을 한 칸씩 앞으로 당기고, 마지막 사용 칸을 null로 비운 다음 size를 줄입니다. main의 removeAt(1)이 실행되는 동안 배열과 size는 다음과 같이 바뀝니다.
| 실행한 문장 | members[0] | members[1] | members[2] | size |
|---|---|---|---|---|
| 호출 전 | kim | park | lee | 3 |
members[1] = members[2] | kim | lee | lee | 3 |
members[2] = null | kim | lee | null | 3 |
size-- | kim | lee | null | 2 |
반복문은 index가 1일 때 한 번만 실행됩니다. 조건이 index < size - 1, 즉 index < 2이기 때문입니다. 반복문 안에서 members[index + 1]을 읽으므로, index + 1이 마지막 회원의 인덱스인 size - 1을 넘지 않도록 index는 size - 2에서 멈춰야 합니다. 둘째 줄에서 1번과 2번 칸이 모두 lee를 가리키지만 회원 객체가 둘로 늘어난 것은 아닙니다. 옮긴 것은 같은 lee 객체를 가리키는 참조이고, 이메일과 나이는 그 객체 안에 함께 들어 있습니다. 그래서 절차 지향 코드가 커질 때 생기는 문제에서 이메일 배열과 나이 배열을 따로 두던 때처럼 두 값이 어긋날 일이 없습니다.
표의 둘째와 셋째 줄이 앞 절에서 말한 중간 상태입니다. 둘째 줄에서는 park가 이미 사라졌는데 size가 3이라 lee가 두 번 세어집니다. 이 상태에서 합계를 구하면 40 + 50 + 50 = 140이 나올 것입니다. 셋째 줄에서는 size가 아직 3인데 2번 칸이 비어 있어 2번 조건이 깨져 있습니다. 마지막 size--가 실행된 뒤에야 네 조건이 모두 다시 성립하고, 그 상태에서 true를 돌려줍니다. 지금까지의 프로그램처럼 코드가 한 줄씩 차례로 실행되는 동안에는 바깥 코드가 메서드가 끝난 뒤의 상태만 보므로, 이런 중간 상태를 마주칠 일이 없습니다. 여러 작업이 동시에 같은 객체를 다룰 때 생기는 문제는 19장에서 다룹니다.
회원 삭제의 참조 이동과 사용 범위
삭제는 Lee 참조를 index 1에 복사한 뒤 index 2를 null로 비우고 size를 3→2로 줄입니다. 객체 복제가 아니라 같은 참조의 임시 중복이라는 실제 네 상태입니다.
EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다.
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다
removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다.
| 실행 위치 | size | members[0] | members[1] | members[2] |
|---|---|---|---|---|
| 삭제 호출 전 | 3 | kim 참조 | park 참조 | lee 참조 |
members[1] = members[2] | 3 | kim 참조 | lee 참조 | lee 참조 |
members[2] = null | 3 | kim 참조 | lee 참조 | null |
size-- · 반환 전 | 2 | kim 참조 | lee 참조 | null |
- 삭제 호출 전:
size = 3, 배열 칸의 참조는[kim, park, lee]입니다. members[1] = members[2]:size = 3, 참조는[kim, lee, lee]가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다.members[2] = null:size = 3, 참조는[kim, lee, null]입니다.size--:size = 2가 되어 사용 영역은 0·1번 칸, 미사용 영역은 2번 칸입니다. 이후true를 반환합니다.
이메일과 나이를 함께 가진 회원을 줄여서 kim(40), park(30), lee(50)로 표시했습니다. 이동한 것은 lee 객체의 참조이며 이메일과 나이 필드를 따로 옮기지 않습니다.
중간 행은 메서드 안의 실행 상태입니다. 공개 호출이 끝나면 사용 영역과 size가 다시 일치합니다. summary()는 count=2, total=90, average=45.0을 반환합니다.
아래 CLI의 별도 구현은 members[--size] = null;을 사용합니다. 위 표의 마지막 두 문장을 그 구현의 순서로 해석하지 않습니다.
이 예의 조건
- 독립 removeAt(1) 예제이며 CLI의 --size 대입 순서와 구분한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | target 1 [kim 40 park 30 lee 50]→[kim 40 lee 50 lee 50]의 두 lee는 같은 객체→[kim 40 lee 50 null] size 3→size 2 true, total 90, average 45.0이다. CLI의 별도 --size 구현 순서와 구분한다. |
| 5 | 40 |
| 6 | [1] park |
| 7 | 30 |
| 9 | 50 |
| 21 | 40 |
| 23 | 50 |
| 29 | 40 |
| 31 | 50 |
| 33 | true · total90 |
| 34 | average45.0 |
| 구분 | 내용 |
|---|---|
| 설명 | target 1 [kim 40 park 30 lee 50]→[kim 40 lee 50 lee 50]의 두 lee는 같은 객체→[kim 40 lee 50 null] size 3→size 2 true, total 90, average 45.0이다. CLI의 별도 --size 구현 순서와 구분한다. |
| 전체 읽기 정보 | 회원 삭제의 참조 이동과 사용 범위 회원 삭제의 참조 이동과 사용 범위 EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다. 회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다. EncapsulatedMemberRegistry · removeAt(1) 의 대입 직후 상태 실행 위치 size members[0] members[1] members[2] 삭제 호출 전 3 kim 참조 park 참조 lee 참조 members[1] = members[2] 3 kim 참조 lee 참조 lee 참조 members[2] = null 3 kim 참조 lee 참조 null size-- · 반환 전 2 kim 참조 lee 참조 null 삭제 호출 전: size = 3 , 배열 칸의 참조는 [kim, park, lee] 입니다. members[1] = members[2] : size = 3 , 참조는 [kim, lee, lee] 가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다. members[2] = null : size = 3 , 참조는 [kim, lee, null] 입니다. size-- : size = 2 가 되어 사용 영역은 0·1번 칸, 미사용 영역은 2번 칸입니다. 이후 true 를 반환합니다. 이메일과 나이를 함께 가진 회원을 줄여서 kim(40), park(30), lee(50)로 표시했습니다. 이동한 것은 lee 객체의 참조이며 이메일과 나이 필드를 따로 옮기지 않습니다. 중간 행은 메서드 안의 실행 상태입니다. 공개 호출이 끝나면 사용 영역과 size 가 다시 일치합니다. summary() 는 count=2, total=90, average=45.0 을 반환합니다. 아래 CLI의 별도 구현은 members[--size] = null; 을 사용합니다. 위 표의 마지막 두 문장을 그 구현의 순서로 해석하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 삭제 전 세 사용 칸에 Kim/Park/Lee가 있습니다. |
|
|
| 참조 이동 Lee 참조를 index 1로 복사합니다. |
|
|
| 마지막 칸 비우기 index 2에 null을 대입합니다. |
|
|
| 사용 구간 확정 size를 2로 줄이고 true를 반환합니다. |
|
|
회원 삭제의 참조 이동과 사용 범위
삭제는 Lee 참조를 index 1에 복사한 뒤 index 2를 null로 비우고 size를 3→2로 줄입니다. 객체 복제가 아니라 같은 참조의 임시 중복이라는 실제 네 상태입니다.
EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다.
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다
removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다.
| 실행 위치 | size | members[0] | members[1] | members[2] |
|---|---|---|---|---|
| 삭제 호출 전 | 3 | kim 참조 | park 참조 | lee 참조 |
members[1] = members[2] | 3 | kim 참조 | lee 참조 | lee 참조 |
members[2] = null | 3 | kim 참조 | lee 참조 | null |
size-- · 반환 전 | 2 | kim 참조 | lee 참조 | null |
- 삭제 호출 전:
size = 3, 배열 칸의 참조는[kim, park, lee]입니다. members[1] = members[2]:size = 3, 참조는[kim, lee, lee]가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다.members[2] = null:size = 3, 참조는[kim, lee, null]입니다.size--:size = 2가 되어 사용 영역은 0·1번 칸, 미사용 영역은 2번 칸입니다. 이후true를 반환합니다.
이메일과 나이를 함께 가진 회원을 줄여서 kim(40), park(30), lee(50)로 표시했습니다. 이동한 것은 lee 객체의 참조이며 이메일과 나이 필드를 따로 옮기지 않습니다.
중간 행은 메서드 안의 실행 상태입니다. 공개 호출이 끝나면 사용 영역과 size가 다시 일치합니다. summary()는 count=2, total=90, average=45.0을 반환합니다.
아래 CLI의 별도 구현은 members[--size] = null;을 사용합니다. 위 표의 마지막 두 문장을 그 구현의 순서로 해석하지 않습니다.
이 예의 조건
- 독립 removeAt(1) 예제이며 CLI의 --size 대입 순서와 구분한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | target 1 [kim 40 park 30 lee 50]→[kim 40 lee 50 lee 50]의 두 lee는 같은 객체→[kim 40 lee 50 null] size 3→size 2 true, total 90, average 45.0이다. CLI의 별도 --size 구현 순서와 구분한다. |
| 5 | 40 |
| 6 | [1] park |
| 7 | 30 |
| 9 | 50 |
| 21 | 40 |
| 23 | 50 |
| 29 | 40 |
| 31 | 50 |
| 33 | true · total90 |
| 34 | average45.0 |
| 구분 | 내용 |
|---|---|
| 설명 | target 1 [kim 40 park 30 lee 50]→[kim 40 lee 50 lee 50]의 두 lee는 같은 객체→[kim 40 lee 50 null] size 3→size 2 true, total 90, average 45.0이다. CLI의 별도 --size 구현 순서와 구분한다. |
| 전체 읽기 정보 | 회원 삭제의 참조 이동과 사용 범위 회원 삭제의 참조 이동과 사용 범위 EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다. 회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다. EncapsulatedMemberRegistry · removeAt(1) 의 대입 직후 상태 실행 위치 size members[0] members[1] members[2] 삭제 호출 전 3 kim 참조 park 참조 lee 참조 members[1] = members[2] 3 kim 참조 lee 참조 lee 참조 members[2] = null 3 kim 참조 lee 참조 null size-- · 반환 전 2 kim 참조 lee 참조 null 삭제 호출 전: size = 3 , 배열 칸의 참조는 [kim, park, lee] 입니다. members[1] = members[2] : size = 3 , 참조는 [kim, lee, lee] 가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다. members[2] = null : size = 3 , 참조는 [kim, lee, null] 입니다. size-- : size = 2 가 되어 사용 영역은 0·1번 칸, 미사용 영역은 2번 칸입니다. 이후 true 를 반환합니다. 이메일과 나이를 함께 가진 회원을 줄여서 kim(40), park(30), lee(50)로 표시했습니다. 이동한 것은 lee 객체의 참조이며 이메일과 나이 필드를 따로 옮기지 않습니다. 중간 행은 메서드 안의 실행 상태입니다. 공개 호출이 끝나면 사용 영역과 size 가 다시 일치합니다. summary() 는 count=2, total=90, average=45.0 을 반환합니다. 아래 CLI의 별도 구현은 members[--size] = null; 을 사용합니다. 위 표의 마지막 두 문장을 그 구현의 순서로 해석하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 삭제 전 세 사용 칸에 Kim/Park/Lee가 있습니다. |
|
|
| 참조 이동 Lee 참조를 index 1로 복사합니다. |
|
|
| 마지막 칸 비우기 index 2에 null을 대입합니다. |
|
|
| 사용 구간 확정 size를 2로 줄이고 true를 반환합니다. |
|
|
이제 출력을 설명할 수 있습니다. 첫 줄 true는 삭제가 성공했다는 뜻입니다. describeAt(0)은 자리를 지킨 kim을, describeAt(1)은 2번 칸에서 당겨 온 lee를 설명합니다. 마지막 줄의 합계는 40 + 50 = 90이고, 평균은 90 / 2 = 45.0입니다.
조회와 집계는 사용 중인 칸만 읽는다
summary는 0번부터 size - 1번까지만 더합니다. 2번 조건 덕분에 이 범위의 칸에는 모두 회원이 있으므로 null인지 따로 검사할 필요가 없습니다. 회원이 한 명도 없을 때는 평균을 0.0으로 정했는데, 이 처리가 없으면 (double) total / size가 0.0을 0으로 나누게 되어 '숫자가 아님'을 뜻하는 NaN이 출력됩니다. (double) 없이 정수끼리 나눴다면 ArithmeticException으로 프로그램이 멈췄을 것입니다.
describeAt도 범위를 먼저 확인하지만, 잘못된 번호를 반환값으로 알리지 않고 IndexOutOfBoundsException을 던진다는 점이 removeAt과 다릅니다. 두 방식은 쓰임새가 다릅니다. 잘못 들어오는 일이 흔하고 호출자가 처리할 수 있는 값이라면 반환값으로 알리고, 호출하는 코드의 실수로만 생기는 값이라면 예외를 던져 곧바로 드러나게 합니다. 삭제 번호는 사용자가 잘못 입력하기 쉬운 값이라 앞의 경우에, 조회 번호는 코드가 범위 안에서 넘겨야 하는 값이라 뒤의 경우에 가깝습니다.
바깥 코드는 이 모든 과정을 알지 못합니다. main은 removeAt(1)을 호출하고 결과만 받을 뿐, 참조를 당기는 반복문이나 size가 있다는 사실조차 알 필요가 없습니다. 그래서 나중에 저장소 내부를 배열이 아닌 다른 자료구조로 바꾸더라도 main은 고치지 않아도 됩니다. 캡슐화가 주는 이점이 바로 이것입니다.
더 깊이: 비운 칸을 null로 지우는 이유
members[size - 1] = null;을 빼도 이 예제의 출력은 같습니다. size 이후의 칸은 어떤 메서드도 읽지 않기 때문입니다. 그런데도 3번 조건으로 빈 칸을 null로 비워 두게 한 것은, 비우지 않으면 마지막 칸에 참조가 하나 남기 때문입니다. 가운데 회원을 삭제했다면 앞으로 옮긴 회원의 참조가 중복으로 남고, 마지막 회원을 삭제했다면 삭제한 회원의 참조가 그대로 남습니다. 앞의 경우는 배열 전체를 디버거나 Arrays.toString으로 볼 때 같은 회원이 두 번 있는 것처럼 보여 혼란을 주고, 뒤의 경우는 메모리 문제로 이어집니다.
null과 NullPointerException에서 본 것처럼 가비지 컬렉터는 어떤 경로로도 도달할 수 없는 객체만 회수합니다. 그런데 배열에 도달할 수 있는 동안에는 그 칸이 가리키는 객체에도 도달할 수 있으므로, 저장소를 쓰는 동안에는 이미 삭제한 회원 객체도 메모리에 계속 남습니다. 더는 쓰지 않을 객체를 이렇게 의도치 않게 붙잡아 두는 것도 자바에서는 메모리 누수의 한 형태로 봅니다. 표준 라이브러리의 ArrayList도 원소를 삭제할 때 뒤 원소를 앞으로 당긴 다음 마지막 칸을 null로 비웁니다. 저장소, 배열, 회원 객체 사이의 참조를 메모리 그림으로 따라가는 방법은 다음 장에서 다룹니다.
다른 객체에도 같은 원리를 적용하기
지금까지 회원 저장소에서 한 일은 세 단계로 정리할 수 있습니다. 서로 관련된 상태를 한 객체에 모으고, 그 상태가 지켜야 할 조건을 정한 다음, 그 조건을 지키는 행동만 공개했습니다. 이 방법은 회원 저장소에만 쓰이는 것이 아닙니다. 앞 글의 연습 문제에서 만든 최댓값이 있는 카운터를 떠올려 보세요. 여기에 상품을 배열에 담고 개수를 세는 장바구니 클래스를 만든다고 가정하고, 세 객체를 같은 틀로 비교해 보겠습니다.
| 객체 | 함께 가지는 상태 | 지켜야 할 조건 | 공개하는 행동 | 숨기는 구현 |
|---|---|---|---|---|
| 최댓값이 있는 카운터 | count, maximum | 0 <= count && count <= maximum | increment, value | 최댓값 검사 |
| 장바구니 | items, count | 0번부터 count - 1번 칸에만 상품이 있음 | add, total | 배열과 인덱스 |
| 회원 저장소 | members, size | 앞에서 정한 네 가지 불변식 | add, removeAt, summary | 참조 이동, 집계 범위 |
카운터에서 중요한 것은 count를 숨기는 일 자체가 아니라, 숨긴 덕분에 count가 최댓값을 넘지 않도록 Counter가 직접 지킬 수 있다는 점입니다. 장바구니도 상품 배열과 개수를 한 객체가 함께 가지고 있어야 추가와 합계 계산이 같은 사용 범위를 봅니다. 반대로 바깥 코드가 이 상태를 메서드를 거치지 않고 고칠 수 있게 두면, 카운터는 최댓값을 넘을 수 있고, 장바구니와 회원 저장소에서는 이 글 앞부분의 버그처럼 개수와 실제 내용이 어긋나는 문제가 다시 생깁니다.
입력 해석은 CLI가, 저장 규칙은 저장소가
마지막으로 저장소를 명령줄 프로그램에 연결합니다. 사용자는 add 이메일 나이, remove 번호, list, summary, quit 명령을 한 줄씩 입력합니다. 이 예제는 실행할 때마다 같은 결과가 나오도록, Scanner로 키보드 입력 받기에서처럼 키보드 대신 코드 안의 문자열을 입력으로 씁니다.
import java.util.Scanner;
public final class EncapsulatedMemberRegistryCli {
public static void main(String[] args) {
String input = String.join("\n",
"add [email protected] 40",
"add bad 0",
"add [email protected] 50",
"list",
"remove 1",
"summary",
"quit") + "\n";
Scanner scanner = new Scanner(input);
MemberRegistry registry = new MemberRegistry(3);
while (scanner.hasNextLine()) {
String line = scanner.nextLine().trim();
if (line.equals("quit")) break;
handle(line, registry);
}
System.out.println("bye");
}
private static void handle(String line, MemberRegistry registry) {
String[] parts = line.split("\\s+");
try {
switch (parts[0]) {
case "add" -> {
if (parts.length != 3) {
System.out.println("usage");
return;
}
boolean saved = registry.add(parts[1], Integer.parseInt(parts[2]));
System.out.println(saved ? "saved" : "rejected");
}
case "remove" -> {
if (parts.length != 2) {
System.out.println("usage");
return;
}
int oneBased = Integer.parseInt(parts[1]);
System.out.println(registry.removeAt(oneBased - 1) ? "removed" : "not-found");
}
case "list" -> System.out.print(registry.listText());
case "summary" -> System.out.println(registry.summary());
default -> System.out.println("unknown");
}
} catch (NumberFormatException exception) {
System.out.println("number-error");
}
}
private static final class MemberRegistry {
private final Member[] members;
private int size;
MemberRegistry(int capacity) {
members = new Member[capacity];
}
boolean add(String email, int age) {
if (size == members.length) return false;
try {
members[size] = new Member(email, age);
size++;
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
boolean removeAt(int target) {
if (target < 0 || target >= size) return false;
for (int index = target; index < size - 1; index++) {
members[index] = members[index + 1];
}
members[--size] = null;
return true;
}
String listText() {
if (size == 0) return "no members\n";
StringBuilder result = new StringBuilder();
for (int index = 0; index < size; index++) {
result.append(index + 1).append(". ").append(members[index].describe()).append('\n');
}
return result.toString();
}
String summary() {
int total = 0;
for (int index = 0; index < size; index++) {
total += members[index].age();
}
return "count=" + size + ", total=" + total;
}
}
private record Member(String email, int age) {
Member {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
}
String describe() {
return email + "=" + age;
}
}
}saved
rejected
saved
1. [email protected]=40
2. [email protected]=50
removed
count=1, total=50
byeString.join("\n", ...)은 여러 문자열 사이에 줄바꿈을 넣어 하나로 이어 붙입니다. 그래서 Scanner는 이 문자열을 사용자가 일곱 줄을 차례로 입력한 것처럼 읽습니다. 끝에 덧붙인 "\n"은 마지막 줄도 줄바꿈으로 끝나게 합니다. 실제 키보드 입력을 받으려면 new Scanner(input)을 new Scanner(System.in)으로 바꾸면 됩니다.
출력을 입력 순서대로 따라가 보겠습니다. 첫 명령은 kim을 저장해 saved를 출력합니다. 둘째 명령 add bad 0은 항목이 세 개이고 0도 정수로 바뀌므로 저장소까지 전달되지만, Member 생성자가 나이 0을 거절해 rejected가 나옵니다. 이메일 자리의 bad는 비어 있지 않으므로 이메일 검사는 통과합니다. 이 예제는 이메일에 @가 있는지까지는 검사하지 않습니다. 셋째 명령으로 lee가 저장되면 list는 두 회원에게 1부터 번호를 붙여 출력합니다. 이어서 remove 1이 사용자가 본 1번, 즉 인덱스 0의 kim을 지워 removed를 출력하고, summary는 남은 lee 한 명에 대해 count=1, total=50을 출력합니다. 마지막으로 quit을 만나면 반복문을 빠져나와 bye를 출력합니다.
add 명령이 거치는 세 단계의 검사
add 명령 한 줄은 세 단계의 검사를 차례로 지납니다. 먼저 CLI가 항목 수를 확인해 맞지 않으면 usage를 출력하고, 다음으로 Integer.parseInt가 나이를 숫자로 바꾸지 못하면 CLI가 NumberFormatException을 잡아 number-error를 출력합니다. 두 단계를 모두 통과해야 저장소의 add가 호출되고, 저장소는 용량과 회원 규칙을 검사해 true나 false를 돌려줍니다. 앞의 두 검사에서 걸린 요청은 저장소에 아예 닿지 않습니다. remove 명령도 항목 수, 숫자 변환, 저장소의 범위 검사를 같은 순서로 거칩니다.
아래 그림은 서로 다른 네 가지 add 요청이 각각 어느 단계에서 멈추는지 보여 줍니다. 각 요청은 빈 저장소에 따로 보낸 것이고, 앞의 두 요청은 실행 결과에 없던 오류 경우를 보여 주려고 더한 것입니다. 용량이 가득 찬 저장소라면 Member를 만들기도 전에 rejected가 나온다는 점도 함께 확인하세요.
CLI 형식 오류와 저장소 거절의 경계
항목 수·parseInt·용량·Member 생성 검사를 차례로 통과해야 저장·size 증가에 도달합니다. 일부 가정 오류 입력을 실제 main 관측과 구분하여 첫 거절 위치를 보여 줄 수 있습니다.
EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다.
입력 형식과 회원 규칙은 다른 경계에서 판정한다
usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다.
| 독립 요청 | 판정 위치 | 출력과 저장 결과 |
|---|---|---|
add [email protected] | CLI의 항목 수 검사 | usage · 숫자 변환과 registry.add에 도달하지 않음 |
add [email protected] forty | CLI의 Integer.parseInt | number-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음 |
add bad 0 | Member의 생성자 검사 | rejected · 저장소가 예외를 false로 바꿈, 배열·size 유지 |
add [email protected] 40 | 저장소의 생성·저장·개수 갱신 | saved · 생성 성공 후 배열에 저장하고 size 증가 |
- 항목 수 오류 · usage
add [email protected]: CLI가 세 항목이 아님을 확인하고 반환합니다. 숫자 변환과 저장소 호출 전입니다.- 숫자 변환 오류 · number-error
add [email protected] forty:Integer.parseInt의 예외를 CLI가 잡습니다. 저장소 호출 전입니다.- 회원 규칙 거절 · rejected
add bad 0: 숫자 변환은 성공하지만 Member 생성자에서 나이 0을 거절합니다. 저장소는false를 반환하며 배열과size는 그대로입니다.- 저장 성공 · saved
add [email protected] 40: 새 Member를 생성한 뒤 배열에 저장하고size를 증가시킵니다.
각 행은 빈 저장소(용량 3)에 대한 독립 요청입니다. 앞 두 요청은 코드의 오류 분기를 확인하는 입력이며 원문 main의 고정 입력에는 없습니다. 뒤 두 요청은 원문에도 등장합니다.
용량이 가득 차면 회원 생성 전 false를 반환하므로 출력도 rejected입니다. 나이 범위는 14~120이며 이메일은 null·공백만 검사합니다. @ 형식을 검사한다고 해석하지 않습니다.
remove 1은 CLI에서 사용자 번호 1을 인덱스 0으로 바꿉니다. 삭제 성공·실패의 반환값을 각각 removed·not-found로 출력합니다.
이 예의 조건
- 항목 오류·forty·bad 0·Kim 40은 각각 빈 용량 3 저장소에서 시작하는 독립 요청입니다.
- full은 이미 가득 찬 별도 저장소 조건이며 빈 시작 네 요청과 구분합니다. email의 @ 형식을 검사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각각 빈 capacity 3의 저장소에 대한 독립 요청이다. 항목 오류의 usage/숫자 forty의 number-error는 registry 호출 전에 종료한다. 용량 full이면 생성 전 false를 반환하고, Member bad 0의 예외를 catch하면 false/rejected를 반환하며 상태를 보존한다. Kim 40은 정상 참조를 저장한 후 size++를 실행하고 saved를 출력한다. email의 @ 검사는 없다. |
| 3 | 독립 add 요청 · 빈 용량 3 저장소 |
| 21 | 이메일 null·공백 |
| 22 | 나이 14~120 |
| 27 | Kim 40 생성·저장 → size++ |
| 구분 | 내용 |
|---|---|
| 설명 | 각각 빈 capacity 3의 저장소에 대한 독립 요청이다. 항목 오류의 usage/숫자 forty의 number-error는 registry 호출 전에 종료한다. 용량 full이면 생성 전 false를 반환하고, Member bad 0의 예외를 catch하면 false/rejected를 반환하며 상태를 보존한다. Kim 40은 정상 참조를 저장한 후 size++를 실행하고 saved를 출력한다. email의 @ 검사는 없다. |
| 전체 읽기 정보 | CLI 형식 오류와 저장소 거절의 경계 CLI 형식 오류와 저장소 거절의 경계 EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다. 입력 형식과 회원 규칙은 다른 경계에서 판정한다 usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다. EncapsulatedMemberRegistryCli 의 add 요청: 어디에서 처리가 멈추는가 독립 요청 판정 위치 출력과 저장 결과 add [email protected] CLI의 항목 수 검사 usage · 숫자 변환과 registry.add 에 도달하지 않음 add [email protected] forty CLI의 Integer.parseInt number-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음 add bad 0 Member의 생성자 검사 rejected · 저장소가 예외를 false 로 바꿈, 배열·size 유지 add [email protected] 40 저장소의 생성·저장·개수 갱신 saved · 생성 성공 후 배열에 저장하고 size 증가 항목 수 오류 · usage add [email protected] : CLI가 세 항목이 아님을 확인하고 반환합니다. 숫자 변환과 저장소 호출 전입니다. 숫자 변환 오류 · number-error add [email protected] forty : Integer.parseInt 의 예외를 CLI가 잡습니다. 저장소 호출 전입니다. 회원 규칙 거절 · rejected add bad 0 : 숫자 변환은 성공하지만 Member 생성자에서 나이 0을 거절합니다. 저장소는 false 를 반환하며 배열과 size 는 그대로입니다. 저장 성공 · saved add [email protected] 40 : 새 Member를 생성한 뒤 배열에 저장하고 size 를 증가시킵니다. 각 행은 빈 저장소(용량 3)에 대한 독립 요청입니다. 앞 두 요청은 코드의 오류 분기를 확인하는 입력이며 원문 main 의 고정 입력에는 없습니다. 뒤 두 요청은 원문에도 등장합니다. 용량이 가득 차면 회원 생성 전 false 를 반환하므로 출력도 rejected 입니다. 나이 범위는 14~120이며 이메일은 null·공백만 검사합니다. @ 형식을 검사한다고 해석하지 않습니다. remove 1 은 CLI에서 사용자 번호 1을 인덱스 0으로 바꿉니다. 삭제 성공·실패의 반환값을 각각 removed · not-found 로 출력합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 각 요청의 CLI 형식 검사 항목 수와 정수 변환을 검사하며 통과한 요청만 저장소를 부릅니다. |
|
|
| 저장소 조건별 처리 빈 저장소의 bad 0·Kim 40과 이미 가득 찬 full 조건을 함께 비교합니다. |
|
|
| 각 요청의 결과 출력 각 독립 요청은 자신이 얻은 거절 또는 성공 결과만 출력합니다. |
|
|
CLI 형식 오류와 저장소 거절의 경계
항목 수·parseInt·용량·Member 생성 검사를 차례로 통과해야 저장·size 증가에 도달합니다. 일부 가정 오류 입력을 실제 main 관측과 구분하여 첫 거절 위치를 보여 줄 수 있습니다.
EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다.
입력 형식과 회원 규칙은 다른 경계에서 판정한다
usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다.
| 독립 요청 | 판정 위치 | 출력과 저장 결과 |
|---|---|---|
add [email protected] | CLI의 항목 수 검사 | usage · 숫자 변환과 registry.add에 도달하지 않음 |
add [email protected] forty | CLI의 Integer.parseInt | number-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음 |
add bad 0 | Member의 생성자 검사 | rejected · 저장소가 예외를 false로 바꿈, 배열·size 유지 |
add [email protected] 40 | 저장소의 생성·저장·개수 갱신 | saved · 생성 성공 후 배열에 저장하고 size 증가 |
- 항목 수 오류 · usage
add [email protected]: CLI가 세 항목이 아님을 확인하고 반환합니다. 숫자 변환과 저장소 호출 전입니다.- 숫자 변환 오류 · number-error
add [email protected] forty:Integer.parseInt의 예외를 CLI가 잡습니다. 저장소 호출 전입니다.- 회원 규칙 거절 · rejected
add bad 0: 숫자 변환은 성공하지만 Member 생성자에서 나이 0을 거절합니다. 저장소는false를 반환하며 배열과size는 그대로입니다.- 저장 성공 · saved
add [email protected] 40: 새 Member를 생성한 뒤 배열에 저장하고size를 증가시킵니다.
각 행은 빈 저장소(용량 3)에 대한 독립 요청입니다. 앞 두 요청은 코드의 오류 분기를 확인하는 입력이며 원문 main의 고정 입력에는 없습니다. 뒤 두 요청은 원문에도 등장합니다.
용량이 가득 차면 회원 생성 전 false를 반환하므로 출력도 rejected입니다. 나이 범위는 14~120이며 이메일은 null·공백만 검사합니다. @ 형식을 검사한다고 해석하지 않습니다.
remove 1은 CLI에서 사용자 번호 1을 인덱스 0으로 바꿉니다. 삭제 성공·실패의 반환값을 각각 removed·not-found로 출력합니다.
이 예의 조건
- 항목 오류·forty·bad 0·Kim 40은 각각 빈 용량 3 저장소에서 시작하는 독립 요청입니다.
- full은 이미 가득 찬 별도 저장소 조건이며 빈 시작 네 요청과 구분합니다. email의 @ 형식을 검사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각각 빈 capacity 3의 저장소에 대한 독립 요청이다. 항목 오류의 usage/숫자 forty의 number-error는 registry 호출 전에 종료한다. 용량 full이면 생성 전 false를 반환하고, Member bad 0의 예외를 catch하면 false/rejected를 반환하며 상태를 보존한다. Kim 40은 정상 참조를 저장한 후 size++를 실행하고 saved를 출력한다. email의 @ 검사는 없다. |
| 3 | 독립 add 요청 · 빈 용량 3 저장소 |
| 21 | 이메일 null·공백 |
| 22 | 나이 14~120 |
| 27 | Kim 40 생성·저장 → size++ |
| 구분 | 내용 |
|---|---|
| 설명 | 각각 빈 capacity 3의 저장소에 대한 독립 요청이다. 항목 오류의 usage/숫자 forty의 number-error는 registry 호출 전에 종료한다. 용량 full이면 생성 전 false를 반환하고, Member bad 0의 예외를 catch하면 false/rejected를 반환하며 상태를 보존한다. Kim 40은 정상 참조를 저장한 후 size++를 실행하고 saved를 출력한다. email의 @ 검사는 없다. |
| 전체 읽기 정보 | CLI 형식 오류와 저장소 거절의 경계 CLI 형식 오류와 저장소 거절의 경계 EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다. 입력 형식과 회원 규칙은 다른 경계에서 판정한다 usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다. EncapsulatedMemberRegistryCli 의 add 요청: 어디에서 처리가 멈추는가 독립 요청 판정 위치 출력과 저장 결과 add [email protected] CLI의 항목 수 검사 usage · 숫자 변환과 registry.add 에 도달하지 않음 add [email protected] forty CLI의 Integer.parseInt number-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음 add bad 0 Member의 생성자 검사 rejected · 저장소가 예외를 false 로 바꿈, 배열·size 유지 add [email protected] 40 저장소의 생성·저장·개수 갱신 saved · 생성 성공 후 배열에 저장하고 size 증가 항목 수 오류 · usage add [email protected] : CLI가 세 항목이 아님을 확인하고 반환합니다. 숫자 변환과 저장소 호출 전입니다. 숫자 변환 오류 · number-error add [email protected] forty : Integer.parseInt 의 예외를 CLI가 잡습니다. 저장소 호출 전입니다. 회원 규칙 거절 · rejected add bad 0 : 숫자 변환은 성공하지만 Member 생성자에서 나이 0을 거절합니다. 저장소는 false 를 반환하며 배열과 size 는 그대로입니다. 저장 성공 · saved add [email protected] 40 : 새 Member를 생성한 뒤 배열에 저장하고 size 를 증가시킵니다. 각 행은 빈 저장소(용량 3)에 대한 독립 요청입니다. 앞 두 요청은 코드의 오류 분기를 확인하는 입력이며 원문 main 의 고정 입력에는 없습니다. 뒤 두 요청은 원문에도 등장합니다. 용량이 가득 차면 회원 생성 전 false 를 반환하므로 출력도 rejected 입니다. 나이 범위는 14~120이며 이메일은 null·공백만 검사합니다. @ 형식을 검사한다고 해석하지 않습니다. remove 1 은 CLI에서 사용자 번호 1을 인덱스 0으로 바꿉니다. 삭제 성공·실패의 반환값을 각각 removed · not-found 로 출력합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 각 요청의 CLI 형식 검사 항목 수와 정수 변환을 검사하며 통과한 요청만 저장소를 부릅니다. |
|
|
| 저장소 조건별 처리 빈 저장소의 bad 0·Kim 40과 이미 가득 찬 full 조건을 함께 비교합니다. |
|
|
| 각 요청의 결과 출력 각 독립 요청은 자신이 얻은 거절 또는 성공 결과만 출력합니다. |
|
|
검사를 이렇게 나누는 이유는 규칙마다 속한 곳이 다르기 때문입니다. 명령어 이름과 항목 수, 나이 문자열을 숫자로 바꾸는 일, 사용자에게 보여 줄 saved나 rejected 같은 문구는 명령줄이라는 입력 방식에 딸린 것입니다. 반면 용량을 넘지 않는다는 것과 나이가 14세 이상 120세 이하라는 것은 회원을 어떤 방식으로 입력받든 똑같이 지켜야 하는 저장 규칙입니다. 저장 규칙을 CLI에 두면 나중에 웹 화면 같은 다른 입력 방식을 만들 때 같은 검사를 또 작성해야 하고, 저장소가 직접 메시지를 출력하면 명령줄이 아닌 곳에서는 저장소를 쓰기 어려워집니다.
remove 명령의 번호 변환도 같은 기준으로 CLI에 두었습니다. 사용자에게는 1부터 세는 번호가 자연스럽지만 배열 인덱스는 0부터 시작하므로, CLI가 oneBased - 1로 바꿔 저장소에 넘깁니다. 저장소는 0부터 시작하는 인덱스만 다루고, 범위를 벗어난 인덱스는 false로 알려 CLI가 not-found를 출력하게 합니다.
다만 이 기준이 코드에 완전히 지켜진 것은 아닙니다. list가 보여 주는 1부터 시작하는 번호는 저장소의 listText가 index + 1로 만들기 때문에, 화면에 보여 줄 형식이 저장소 안에 들어가 있습니다. 기준대로 나누려면 저장소는 회원 설명만 돌려주고 번호는 CLI가 붙이는 편이 일관됩니다. 연습 문제 1의 descriptions()가 그런 방향의 메서드입니다.
CLI 예제의 저장소는 앞 예제를 조금 줄인 것입니다. 생성자의 용량 값 검사와 describeAt, 평균 계산을 빼고, 목록 문자열을 만드는 listText를 더했습니다. 또 removeAt의 마지막 두 문장을 members[--size] = null; 한 줄로 합쳤습니다. 증감·비교·논리·대입 연산자에서 본 것처럼 전위 감소 --size는 size를 먼저 줄이고 줄어든 값을 결과로 내놓으므로, 이 한 줄은 줄어든 size 위치의 칸, 즉 원래의 마지막 사용 칸을 비웁니다. size가 먼저 줄고 칸이 나중에 비워진다는 순서만 앞 예제와 반대일 뿐, 문장이 끝난 뒤의 상태는 같습니다. 그래도 상태 변화를 한 단계씩 따라가기에는 앞 예제처럼 두 문장으로 나눠 쓰는 편이 읽기 쉽습니다.
정리
private은 필드 이름을 통한 접근을 막을 뿐, 필드가 가리키는 객체까지 지켜 주지는 않습니다. 내부 배열처럼 바꿀 수 있는 객체의 참조를 돌려주면 바깥 코드가 상태를 바꿀 수 있으므로, 복사본이나 필요한 결과만 돌려줍니다.Arrays.copyOf로 만든 복사본은 배열만 새로 만드는 얕은 복사입니다. 원소가 바꿀 수 없는 객체일 때만 안심하고 내줄 수 있습니다.- 불변식은 공개 메서드를 호출하기 전과 호출이 끝난 뒤에 항상 참이어야 하는 조건입니다. 생성자가 올바른 시작 상태를 만들고 모든 공개 메서드가 끝날 때 조건을 다시 성립시키면, 메서드 도중의 중간 상태는 바깥에 드러나지 않습니다.
- 실패할 수 있는 일을 먼저 하고 상태를 바꾸는 문장을 뒤에 두면, 실패한 호출이 상태를 바꾸지 않습니다.
- record는 값 묶음 타입을 한 줄로 선언하게 해 주고,
final필드와 읽기 메서드,equals,hashCode,toString을 만들어 줍니다. 컴팩트 생성자로 만들어지는 순간 값을 검사할 수 있습니다. - 입력 형식은 CLI가, 저장 규칙은 저장소가 검사하도록 나누면 각 규칙을 한곳에서만 관리할 수 있습니다.
연습 문제
문제 1: 회원 설명을 새 배열로 돌려주기
MemberRegistry에 회원 설명을 String[] 새 배열로 돌려주는 descriptions() 메서드를 작성하세요. 호출자가 돌려받은 배열을 바꿔도 저장소의 내부 상태는 변하지 않아야 합니다. 코드를 작성하기 전에 두 가지를 먼저 예상해 보세요. 회원이 없을 때 돌려줄 배열의 길이는 얼마인가요? 회원이 두 명일 때 돌려받은 배열의 첫 문자열만 바꾸면, 다음 호출은 무엇을 돌려줘야 할까요? 내부 Member[]를 그대로 돌려준 앞의 버그 예제와 무엇이 다른지도 설명하세요.
해설 보기
해설의 저장소는 문제와 관계없는 용량과 회원 규칙 검사를 생략한 축약판입니다.
import java.util.Arrays;
public final class DefensiveDescriptionCopy {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry();
registry.add("[email protected]", 40);
registry.add("[email protected]", 50);
String[] first = registry.descriptions();
first[0] = "tampered";
System.out.println(Arrays.toString(registry.descriptions()));
}
private static final class MemberRegistry {
private final Member[] members = new Member[2];
private int size;
void add(String email, int age) {
members[size++] = new Member(email, age);
}
String[] descriptions() {
String[] result = new String[size];
for (int index = 0; index < size; index++) {
result[index] = members[index].email() + "=" + members[index].age();
}
return result;
}
}
private record Member(String email, int age) {}
}[[email protected]=40, [email protected]=50]descriptions()는 호출될 때마다 길이가 size인 String 배열을 새로 만들고, 사용 중인 칸의 회원마다 설명 문자열을 만들어 채웁니다. 회원이 없으면 size가 0이므로 길이 0인 빈 배열을 돌려줍니다. null 대신 빈 배열을 돌려주면 호출자가 null 검사 없이 바로 반복문을 돌릴 수 있습니다. main의 first[0] = "tampered"가 바꾼 것은 첫 호출이 만든 배열의 칸일 뿐이고, 두 번째 호출은 저장소의 회원에게서 설명을 다시 만들어 새 배열에 담습니다. 그래서 출력에는 [email protected]=40과 [email protected]=50이 그대로 나옵니다.
앞의 버그 예제와 다른 점은 바깥으로 나가는 것이 무엇이냐입니다. 버그 예제는 저장소가 실제로 쓰는 Member[]의 참조를 내줬기 때문에, 바깥에서 칸을 바꾸면 저장소의 상태가 바뀌었습니다. 여기서는 내부 배열의 참조가 전혀 나가지 않고, 나가는 것은 매번 새로 만든 String 배열입니다. 설명 문자열도 호출할 때마다 새로 만들어지며, 설령 같은 문자열을 공유하더라도 String은 바꿀 수 없는 객체입니다. 그래서 배열 칸에 다른 문자열을 넣어도 기존 문자열이나 회원에게는 아무 영향이 없습니다.
문제 2: 반복 조건 하나가 만드는 버그
EncapsulatedMemberRegistry의 removeAt에서 반복 조건 index < size - 1을 index < size로 바꿨다고 합시다. 첫째, main을 그대로 실행하면 어떻게 될까요? 둘째, new MemberRegistry(3)을 new MemberRegistry(4)로 바꿔 실행하면 결과가 어떻게 달라질까요? 셋째, 이런 버그가 시험해 볼 때 잘 드러나지 않는 이유는 무엇이고, 어떤 경우를 꼭 시험해야 할까요?
해설 보기
main을 그대로 실행하면 삭제 도중에 예외로 멈춥니다.
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
at EncapsulatedMemberRegistry$MemberRegistry.removeAt(EncapsulatedMemberRegistry.java:37)
at EncapsulatedMemberRegistry.main(EncapsulatedMemberRegistry.java:8)용량이 3인 배열에 회원이 세 명이라 size는 3이고, 바뀐 반복문은 index가 1일 때와 2일 때 두 번 실행됩니다. index가 1일 때는 원래대로 members[1] = members[2]를 실행하지만, 2일 때 members[3]을 읽으려는 순간 길이 3인 배열의 범위를 벗어나 ArrayIndexOutOfBoundsException이 발생합니다. 이때 첫 번째 반복은 이미 실행됐으므로 배열은 kim, lee, lee이고 size는 3인 채로 남습니다. 예외를 잡고 프로그램을 계속 실행했다면 summary()는 count=3, total=140으로 시작하는 결과를 냈을 것입니다. 실패한 삭제가 상태를 바꿔 버린 것입니다.
용량을 4로 늘리면 원래 코드와 똑같이 출력됩니다. members[3]이 존재하고 그 칸은 비어 있으므로, index가 2일 때 members[2] = members[3]은 2번 칸에 null을 넣을 뿐 예외가 나지 않습니다. 이어지는 members[size - 1] = null도 이미 비어 있는 2번 칸에 다시 null을 넣으므로, 최종 상태가 올바른 코드와 같아집니다.
그래서 이 버그는 배열이 가득 찼을 때만 드러납니다. 여유 공간이 있는 상태로만 시험하면 모든 결과가 맞게 나오므로 지나치기 쉽습니다. 배열을 다루는 반복문을 고쳤다면 배열이 가득 찬 상태에서의 삭제, 첫 칸과 마지막 칸의 삭제처럼 범위의 양 끝을 꼭 시험해 보세요.
문제 3: 입력 한 줄이 어디서 멈추는지 따라가기
EncapsulatedMemberRegistryCli의 입력을 다음 여덟 줄로 바꾸면 무엇이 출력될까요? 각 출력이 CLI의 항목 수 검사, CLI의 숫자 변환, 저장소의 검사 중 어디에서 결정되는지도 함께 설명하세요.
add [email protected] 40
add [email protected] 130
remove 2
remove 0
add [email protected] forty
add [email protected] 50 extra
list
quit해설 보기
saved
rejected
not-found
not-found
number-error
usage
1. [email protected]=40
bye첫 줄은 세 단계를 모두 통과해 kim이 저장되고 size가 1이 됩니다. 둘째 줄의 130은 정수로는 바뀌지만 Member 생성자가 120을 넘는 나이를 거절하므로, 저장소가 false를 돌려주고 CLI가 rejected를 출력합니다. 이때 size는 1 그대로입니다.
셋째와 넷째 줄의 remove는 둘 다 저장소의 범위 검사에서 걸립니다. remove 2는 인덱스 1이 되는데 회원이 한 명뿐이라 사용 중인 칸은 0번뿐이고, remove 0은 인덱스 -1이 되어 역시 범위를 벗어납니다. 두 경우 모두 숫자 변환은 성공했다는 점에서 number-error와 다릅니다.
다섯째 줄은 forty를 정수로 바꾸지 못해 CLI가 number-error를 출력하고, 여섯째 줄은 항목이 네 개라서 숫자 변환을 시도하기도 전에 usage를 출력합니다. 두 요청 모두 저장소에 닿지 않으므로 list에는 kim 한 명만 나오고, quit에서 반복이 끝나 bye가 출력됩니다.
다음 글
다음 장에서는 이 글의 저장소, 배열, 회원 객체가 실행 중에 메모리의 어디에 놓이는지 스택과 힙으로 나누어 살펴봅니다. 참조가 끊긴 객체가 언제 회수 대상이 되는지도 다루므로, 삭제한 칸을 null로 비운 이유를 메모리 관점에서 다시 확인할 수 있습니다.