본문 바로가기

캡슐화된 회원 저장소

배열 노출과 불변식 파괴를 막고 입력·저장·집계 책임을 분리한 캡슐화 회원 저장소 CLI를 완성합니다.

캡슐화는 필드를 private으로 바꾸는 작업에서 끝나지 않습니다.

내부 배열을 그대로 반환하거나 모든 필드 setter를 열면 외부가 다시 불변식을 우회합니다.

객체가 유효한 상태와 핵심 행동을 함께 숨기고, 호출자에게 필요한 결과만 공개 계약으로 제공해야 합니다.

이 문서부터 값 중심의 작은 타입에는 record를 사용합니다.

record는 생성자·필드·읽기 메서드를 반복해서 쓰는 대신 어떤 값들로 이루어진 데이터인가를 선언하는 문법입니다.

일반 클래스와 record의 같은 의도
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) {}

new Member("[email protected]", 40)으로 만들고 member.email(), member.age()로 읽습니다.

컴파일러가 두 읽기 메서드와 값 기반 equals, hashCode, toString을 만듭니다.

record의 구성 요소 참조는 다시 대입할 수 없지만, 그 안에 가변 목록을 넣으면 목록까지 자동으로 불변이 되는 것은 아닙니다.

방어적 복사와 컴팩트 생성자 같은 심화 규칙은 ch36-1에서 다룹니다.


배열 반환과 캡슐화

lab/ExposedArrayBug.java
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

필드는 private이지만 members()가 실제 배열 참조를 반환했습니다.

호출자는 index 0을 null로 바꾸면서 size=1이라는 내부 상태와 실제 원소를 모순되게 만들었습니다.

캡슐화는 접근 제어자뿐 아니라 반환하는 참조의 소유권까지 포함합니다.

목록이 필요하면 문자열 표현, 읽기 전용 복사본, 필요한 원소 하나처럼 제한된 결과를 제공합니다.

배열 복사본을 반환해도 배열 안 객체가 변경 가능하면 얕은 복사의 한계를 검토해야 합니다.

반환된 참조로 내부 배열의 칸이 바뀐다

members()로 실제 배열 참조가 새어 leaked[0]=null이 내부 칸을 비우지만 size=1은 유지됩니다. 참조 반환과 외부 대입 뒤 불변식 파괴를 전후로 보여 줄 수 있습니다.

반환된 참조로 내부 배열의 칸이 바뀐다

ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다.

내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다

members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다.

ExposedArrayBug · leaked[0] = null; 직후

두 참조가 같은 내부 배열에 도달한다members 필드와 반환값을 받은 leaked는 같은 길이 2 배열을 가리킵니다. leaked의 0번 칸을 null로 바꾸면 members의 0번 칸도 null이고 size는 1로 남습니다. membersMemberRegistry의 배열 필드size = 1 leakedmembers()의 반환 참조 같은 Member[2] 배열 [0] nullkim 회원 참조가 지워짐 [1] null처음부터 미사용인 칸
내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다 members필드와leakedlocal같은length2배열2참조. leaked0null대입이공유slot0변경,slot1원미사용null,size1유지. 뒤firstDescription NPE. 객체필드수정/즉시삭제아님. members 내부 필드 size=1 그대로 leaked 지역 변수 members() 반환 참조 같은 Member[2] 배열 [0] null Kim 참조가 지워짐 [1] null 처음부터 미사용 점선: leaked[0]=null 대입 뒤 firstDescription() size=1인데 [0] null NullPointerException 실선: 배열 참조·후속 사용 칸 변경은 객체 필드 변경과 다름
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번은 미사용입니다.

  • size: 1
  • 배열: Member[2]
  • 필드가 내부 배열 참조를 보관합니다.
반환 참조

leaked가 같은 배열을 받습니다.

  • 배열 동일성: 같음
  • 사본: 없음
  • 외부 호출자도 내부 슬롯 쓰기 경로를 얻습니다.
외부 슬롯 쓰기

leaked[0]=null을 대입합니다.

  • members[0]: null
  • size: 1 유지
  • 배열 노출이 저장소의 개수·항목 일치 규칙을 깨뜨립니다.

반환된 참조로 내부 배열의 칸이 바뀐다

members()로 실제 배열 참조가 새어 leaked[0]=null이 내부 칸을 비우지만 size=1은 유지됩니다. 참조 반환과 외부 대입 뒤 불변식 파괴를 전후로 보여 줄 수 있습니다.

반환된 참조로 내부 배열의 칸이 바뀐다

ExposedArrayBug에서 members와 leaked가 같은 배열을 참조하여 0번 칸 변경이 공유되고, size는 1인 채 null 역참조로 실패합니다.

내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다

members와 leaked는 같은 배열을 참조하여 size와 사용 영역이 어긋납니다.

ExposedArrayBug · leaked[0] = null; 직후

두 참조가 같은 내부 배열에 도달한다members 필드와 반환값을 받은 leaked는 같은 길이 2 배열을 가리킵니다. leaked의 0번 칸을 null로 바꾸면 members의 0번 칸도 null이고 size는 1로 남습니다. membersMemberRegistry의 배열 필드size = 1 leakedmembers()의 반환 참조 같은 Member[2] 배열 [0] nullkim 회원 참조가 지워짐 [1] null처음부터 미사용인 칸
내부 배열을 그대로 반환하면 외부에서 칸을 바꿀 수 있다 members필드와leakedlocal같은length2배열2참조. leaked0null대입이공유slot0변경,slot1원미사용null,size1유지. 뒤firstDescription NPE. 객체필드수정/즉시삭제아님. members 내부 필드 size=1 그대로 leaked 지역 변수 members() 반환 참조 같은 Member[2] 배열 [0] null Kim 참조가 지워짐 [1] null 처음부터 미사용 점선: leaked[0]=null 대입 뒤 firstDescription() size=1인데 [0] null NullPointerException 실선: 배열 참조·후속 사용 칸 변경은 객체 필드 변경과 다름
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번은 미사용입니다.

  • size: 1
  • 배열: Member[2]
  • 필드가 내부 배열 참조를 보관합니다.
반환 참조

leaked가 같은 배열을 받습니다.

  • 배열 동일성: 같음
  • 사본: 없음
  • 외부 호출자도 내부 슬롯 쓰기 경로를 얻습니다.
외부 슬롯 쓰기

leaked[0]=null을 대입합니다.

  • members[0]: null
  • size: 1 유지
  • 배열 노출이 저장소의 개수·항목 일치 규칙을 깨뜨립니다.

객체 불변식

MemberRegistry가 공개 메서드의 호출 전후에 지켜야 할 조건은 다음과 같습니다. 아래 삭제 과정에는 참조를 당기는 중간 상태가 있으므로, 내부 문장 하나마다 완료 상태와 같다고 가정하지 않습니다.

  1. 0 <= size && size <= members.length
  2. index 0부터 size - 1까지는 null이 아닌 유효한 Member입니다.
  3. size부터 length - 1까지는 사용하지 않는 영역입니다.
  4. Member의 email은 공백이 아니고 age는 14~120입니다.
  5. 실패한 추가와 삭제는 기존 상태를 바꾸지 않습니다.

메서드를 작성할 때 각 불변식이 어느 문장으로 보존되는지 확인합니다.

private은 외부 우회를 막고, 생성자는 시작 상태를 보장하며, 공개 행동은 전이 순서를 통제합니다.


내부 상태와 행동

src/EncapsulatedMemberRegistry.java
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

삭제는 객체 참조 하나를 이동하므로 이메일과 나이가 어긋나지 않습니다.

마지막 사용 칸을 null로 비운 뒤 size를 줄여 사용 영역과 실제 원소가 일치합니다. removeAt(1)은 [김40, 박30, 이50]에서 이 회원 참조를 1번에 복사해 [김40, 이50, 이50]로 만들고, 2번을 null로 비운 뒤 size를 3에서 2로 줄입니다. 두 칸이 잠시 같은 이 회원을 참조할 뿐 이 회원 객체를 두 개 만든 것이 아닙니다. 반환 뒤 합계는 40 + 50 = 90, 평균은 90 / 2 = 45.0입니다.

외부는 이동 반복과 size를 알지 못합니다.

회원 삭제의 참조 이동과 사용 범위

삭제는 Lee 참조를 index 1에 복사한 뒤 index 2를 null로 비우고 size를 3→2로 줄입니다. 객체 복제가 아니라 같은 참조의 임시 중복이라는 실제 네 상태입니다.

회원 삭제의 참조 이동과 사용 범위

EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다.

회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다

removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다.

EncapsulatedMemberRegistry · removeAt(1)의 대입 직후 상태
실행 위치sizemembers[0]members[1]members[2]
삭제 호출 전3kim 참조park 참조lee 참조
members[1] = members[2]3kim 참조lee 참조lee 참조
members[2] = null3kim 참조lee 참조null
size-- · 반환 전2kim 참조lee 참조null
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 target1 [kim40 park30 lee50]→[kim40 lee50 lee50]두lee는같은객체→[kim40 lee50 null]size3→size2true,total90average45.0. CLI별도--size구현순서와구분. 호출 전 · size=3 [0] kim / 40 [1] park / 30 [2] lee / 50 세 칸 사용 참조 복사 뒤 · size=3 [0] kim [1] lee [2] lee 같은 Lee age=50 복제 아님 members[2] = null 칸 비운 뒤 · size=3 [0] kim / 40 [1] lee / 50 [2] null size 감소 전 size-- 반환 전 · size=2 [0] kim / 40 [1] lee / 50 [2] null · 미사용 true · total90 / average45.0 두 Lee 칸은 같은 객체의 참조 · CLI의 members[--size]=null과 마지막 순서 구별
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 target1 [kim40 park30 lee50]→[kim40 lee50 lee50]두lee는같은객체→[kim40 lee50 null]size3→size2true,total90average45.0. CLI별도--size구현순서와구분. 호출 전 · size=3 [0] kim / 40 [1] park / 30 [2] lee / 50 모두 사용 중 [1]에 [2]의 참조 복사 복사 뒤 · size=3 [0] kim 참조 [1] lee 참조 [2] lee 참조 같은 Lee 객체 [email protected] age=50 · 복제 아님 members[2] = null 마지막 칸 비움 · size=3 [0] kim / 40 [1] lee / 50 [2] null 아직 size 감소 전 size-- 반환 전 · size=2 [0] kim / 40 [1] lee / 50 [2] null · 미사용 true 반환 합계90 · 평균45.0 실선: 문장 실행 또는 객체 참조 CLI의 --size 구현과 순서 구별
  1. 삭제 호출 전: size = 3, 배열 칸의 참조는 [kim, park, lee]입니다.
  2. members[1] = members[2]: size = 3, 참조는 [kim, lee, lee]가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다.
  3. members[2] = null: size = 3, 참조는 [kim, lee, null]입니다.
  4. 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 대입 순서와 구분한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2target 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 구현 순서와 구분한다.
540
6[1] park
730
950
2140
2350
2940
3150
33true · total90
34average45.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가 있습니다.

  • 슬롯: Kim 40, Park 30, Lee 50
  • size: 3
  • 가운데 Park를 제거할 준비를 한다.
참조 이동

Lee 참조를 index 1로 복사합니다.

  • 슬롯: Kim,Lee,Lee
  • size: 3
  • Lee 객체: 한 개
  • 두 칸이 같은 객체를 잠시 공유하며 복제하지 않는다.
마지막 칸 비우기

index 2에 null을 대입합니다.

  • 슬롯: Kim,Lee,null
  • size: 3
  • 중복된 끝 참조를 제거한다.
사용 구간 확정

size를 2로 줄이고 true를 반환합니다.

  • size: 2
  • total: 90
  • average: 45.0
  • 사용 접두부와 실제 회원 슬롯이 다시 일치한다.

회원 삭제의 참조 이동과 사용 범위

삭제는 Lee 참조를 index 1에 복사한 뒤 index 2를 null로 비우고 size를 3→2로 줄입니다. 객체 복제가 아니라 같은 참조의 임시 중복이라는 실제 네 상태입니다.

회원 삭제의 참조 이동과 사용 범위

EncapsulatedMemberRegistry의 removeAt(1)에서 lee 참조를 복사하고 마지막 사용 칸을 비운 다음 size를 줄이는 실제 대입 순서를 추적합니다.

회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다

removeAt(1)의 중간 상태와 반환 시점의 사용 영역을 구분합니다.

EncapsulatedMemberRegistry · removeAt(1)의 대입 직후 상태
실행 위치sizemembers[0]members[1]members[2]
삭제 호출 전3kim 참조park 참조lee 참조
members[1] = members[2]3kim 참조lee 참조lee 참조
members[2] = null3kim 참조lee 참조null
size-- · 반환 전2kim 참조lee 참조null
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 target1 [kim40 park30 lee50]→[kim40 lee50 lee50]두lee는같은객체→[kim40 lee50 null]size3→size2true,total90average45.0. CLI별도--size구현순서와구분. 호출 전 · size=3 [0] kim / 40 [1] park / 30 [2] lee / 50 세 칸 사용 참조 복사 뒤 · size=3 [0] kim [1] lee [2] lee 같은 Lee age=50 복제 아님 members[2] = null 칸 비운 뒤 · size=3 [0] kim / 40 [1] lee / 50 [2] null size 감소 전 size-- 반환 전 · size=2 [0] kim / 40 [1] lee / 50 [2] null · 미사용 true · total90 / average45.0 두 Lee 칸은 같은 객체의 참조 · CLI의 members[--size]=null과 마지막 순서 구별
회원 참조를 이동하고 마지막 칸을 비운 뒤 개수를 줄인다 target1 [kim40 park30 lee50]→[kim40 lee50 lee50]두lee는같은객체→[kim40 lee50 null]size3→size2true,total90average45.0. CLI별도--size구현순서와구분. 호출 전 · size=3 [0] kim / 40 [1] park / 30 [2] lee / 50 모두 사용 중 [1]에 [2]의 참조 복사 복사 뒤 · size=3 [0] kim 참조 [1] lee 참조 [2] lee 참조 같은 Lee 객체 [email protected] age=50 · 복제 아님 members[2] = null 마지막 칸 비움 · size=3 [0] kim / 40 [1] lee / 50 [2] null 아직 size 감소 전 size-- 반환 전 · size=2 [0] kim / 40 [1] lee / 50 [2] null · 미사용 true 반환 합계90 · 평균45.0 실선: 문장 실행 또는 객체 참조 CLI의 --size 구현과 순서 구별
  1. 삭제 호출 전: size = 3, 배열 칸의 참조는 [kim, park, lee]입니다.
  2. members[1] = members[2]: size = 3, 참조는 [kim, lee, lee]가 됩니다. 두 칸은 같은 lee 객체를 가리킵니다.
  3. members[2] = null: size = 3, 참조는 [kim, lee, null]입니다.
  4. 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 대입 순서와 구분한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2target 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 구현 순서와 구분한다.
540
6[1] park
730
950
2140
2350
2940
3150
33true · total90
34average45.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가 있습니다.

  • 슬롯: Kim 40, Park 30, Lee 50
  • size: 3
  • 가운데 Park를 제거할 준비를 한다.
참조 이동

Lee 참조를 index 1로 복사합니다.

  • 슬롯: Kim,Lee,Lee
  • size: 3
  • Lee 객체: 한 개
  • 두 칸이 같은 객체를 잠시 공유하며 복제하지 않는다.
마지막 칸 비우기

index 2에 null을 대입합니다.

  • 슬롯: Kim,Lee,null
  • size: 3
  • 중복된 끝 참조를 제거한다.
사용 구간 확정

size를 2로 줄이고 true를 반환합니다.

  • size: 2
  • total: 90
  • average: 45.0
  • 사용 접두부와 실제 회원 슬롯이 다시 일치한다.

책임 중심 문제 해석

최대 카운터 문제의 핵심은 count를 숨기는 것이 아니라 0 <= count <= maximum을 Counter가 보장하는 것입니다.

장바구니 문제의 핵심은 Item 배열과 count를 Cart가 함께 소유하고, 추가·합계·목록이 같은 사용 범위를 보게 하는 것입니다.

MemberRegistry도 같은 저장소 패턴을 가집니다.

문제소유 상태공개 행동숨길 구현
최대 카운터count, maximumincrement, value범위 검사
장바구니items, countadd, total배열 인덱스
회원 저장소members, sizeadd, removeAt, summary이동·집계 범위

객체 이름만 바꾸는 것이 아니라 불변식과 행동의 대응을 옮깁니다.

저장소 외부에서 배열과 count를 동시에 조작하게 두면 세 문제 모두 같은 결함이 다시 생깁니다.


CLI와 도메인 책임

src/EncapsulatedMemberRegistryCli.java
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
bye

CLI는 명령 토큰과 숫자 변환을 책임지고 MemberRegistry는 저장 규칙을 책임집니다.

잘못된 나이는 도메인 객체 생성에서 거절되며 size가 증가하지 않습니다.

remove 명령의 1부터 시작하는 사용자 번호를 0 기반 인덱스로 바꾸는 것도 CLI 경계에 있습니다.

CLI 형식 오류와 저장소 거절의 경계

항목 수·parseInt·용량·Member 생성 검사를 차례로 통과해야 저장·size 증가에 도달합니다. 일부 가정 오류 입력을 실제 main 관측과 구분하여 첫 거절 위치를 보여 줄 수 있습니다.

CLI 형식 오류와 저장소 거절의 경계

EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다.

입력 형식과 회원 규칙은 다른 경계에서 판정한다

usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다.

EncapsulatedMemberRegistryCli의 add 요청: 어디에서 처리가 멈추는가
독립 요청판정 위치출력과 저장 결과
add [email protected]CLI의 항목 수 검사usage · 숫자 변환과 registry.add에 도달하지 않음
add [email protected] fortyCLI의 Integer.parseIntnumber-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음
add bad 0Member의 생성자 검사rejected · 저장소가 예외를 false로 바꿈, 배열·size 유지
add [email protected] 40저장소의 생성·저장·개수 갱신saved · 생성 성공 후 배열에 저장하고 size 증가
입력 형식과 회원 규칙은 다른 경계에서 판정한다 각emptycapacity3독립요청. 항목오류usage/숫자forty number-error는registry전종료. 용량full생성전false,Memberbad0catchfalse/rejected보존,Kim40정상참조저장후size++saved. email@검사없음. 독립 add 요청 · 빈 용량3 저장소 CLI 항목 수 → 숫자 형식 → 저장소 검사 항목 수 = 3 ? CLI 구조 검사 아니요 usage 파싱·저장소 미호출 Integer.parseInt 정수 변환 성공만 저장소로 forty → 예외 catch number-error 저장소 미호출 용량 가득 참? 생성보다 먼저 검사 예 false → rejected Member 생성 없음 new Member 검증 이메일 null·공백 / 나이14~120 정상 참조만 다음 저장 단계로 bad 0 → 예외 catch false → rejected 배열·size 유지 Kim40 생성·저장 → size++ size 0→1 · true → saved 각 거절 노드는 해당 요청의 끝 용량 거절은 별도 조건 · 항목/숫자 오류 둘은 원 main의 고정 입력에 없음
입력 형식과 회원 규칙은 다른 경계에서 판정한다 각emptycapacity3독립요청. 항목오류usage/숫자forty number-error는registry전종료. 용량full생성전false,Memberbad0catchfalse/rejected보존,Kim40정상참조저장후size++saved. email@검사없음. add 요청 하나 빈 저장소·용량3 각 사례는 독립 요청 항목 수가 3인가? CLI 입력 구조 검사 아니요 · 이메일만 입력 usage · 종료 숫자 변환·저장소 미호출 예 Integer.parseInt 나이 문자열 → int CLI에서 숫자 형식 판정 forty · 변환 예외 catch number-error 저장소 미호출 변환 성공 → 저장소 호출 용량이 가득 찼는가? 생성보다 먼저 검사 예 · 이 빈 저장소에서는 아님 false → rejected Member 생성 없음 아니요 new Member 검증 이메일 null·공백 / 14~120세 검사 bad 0 · 생성 예외 catch false → rejected 배열·size 유지 Kim40 · 정상 참조 생성 저장 → size++ size: 0 → 1 true → saved 사용 칸에 유효한 회원 형식 오류 ≠ 회원 규칙 거절 이메일의 @ 형식 검사는 없음
항목 수 오류 · 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
27Kim 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 형식 검사

항목 수와 정수 변환을 검사하며 통과한 요청만 저장소를 부릅니다.

  • 항목 오류: usage → 파싱·저장소 미호출
  • forty: number-error → 저장소 미호출
  • bad 0·Kim 40: 정수 변환 통과 → 저장소 호출
  • 별도 full 조건: 올바른 형식으로 이미 가득 찬 저장소 호출
  • CLI에서 종료한 두 요청은 저장소 검사에 도달하지 않습니다.
저장소 조건별 처리

빈 저장소의 bad 0·Kim 40과 이미 가득 찬 full 조건을 함께 비교합니다.

  • 별도 full: 생성 전 false, Member 생성 없음
  • 빈 시작 bad 0: 생성자 나이 예외 catch → 배열·size 유지
  • 빈 시작 Kim 40: 정상 참조 저장 → size 0→1
  • 앞선 CLI 거절: 이미 종료, 저장소 미호출 유지
  • 용량 거절과 생성자 값 검증 실패를 다른 시작 상태와 경계로 유지합니다.
각 요청의 결과 출력

각 독립 요청은 자신이 얻은 거절 또는 성공 결과만 출력합니다.

  • 항목 오류·forty: usage / number-error
  • bad 0·별도 full: false → rejected
  • Kim 40: true → saved, size=1
  • 정상 저장 뒤에만 saved이며 앞선 거절 다음 성공 입력을 같은 요청으로 연결하지 않습니다.

CLI 형식 오류와 저장소 거절의 경계

항목 수·parseInt·용량·Member 생성 검사를 차례로 통과해야 저장·size 증가에 도달합니다. 일부 가정 오류 입력을 실제 main 관측과 구분하여 첫 거절 위치를 보여 줄 수 있습니다.

CLI 형식 오류와 저장소 거절의 경계

EncapsulatedMemberRegistryCli의 add에서 항목 수와 숫자 변환은 CLI가, 유효한 회원과 용량은 도메인 코드가 판정하며 서로 다른 출력과 도달 범위를 만듭니다.

입력 형식과 회원 규칙은 다른 경계에서 판정한다

usage·number-error·rejected는 처리가 멈추는 위치와 도달 범위가 다릅니다.

EncapsulatedMemberRegistryCli의 add 요청: 어디에서 처리가 멈추는가
독립 요청판정 위치출력과 저장 결과
add [email protected]CLI의 항목 수 검사usage · 숫자 변환과 registry.add에 도달하지 않음
add [email protected] fortyCLI의 Integer.parseIntnumber-error · 숫자 예외를 잡으며 저장소 호출에 도달하지 않음
add bad 0Member의 생성자 검사rejected · 저장소가 예외를 false로 바꿈, 배열·size 유지
add [email protected] 40저장소의 생성·저장·개수 갱신saved · 생성 성공 후 배열에 저장하고 size 증가
입력 형식과 회원 규칙은 다른 경계에서 판정한다 각emptycapacity3독립요청. 항목오류usage/숫자forty number-error는registry전종료. 용량full생성전false,Memberbad0catchfalse/rejected보존,Kim40정상참조저장후size++saved. email@검사없음. 독립 add 요청 · 빈 용량3 저장소 CLI 항목 수 → 숫자 형식 → 저장소 검사 항목 수 = 3 ? CLI 구조 검사 아니요 usage 파싱·저장소 미호출 Integer.parseInt 정수 변환 성공만 저장소로 forty → 예외 catch number-error 저장소 미호출 용량 가득 참? 생성보다 먼저 검사 예 false → rejected Member 생성 없음 new Member 검증 이메일 null·공백 / 나이14~120 정상 참조만 다음 저장 단계로 bad 0 → 예외 catch false → rejected 배열·size 유지 Kim40 생성·저장 → size++ size 0→1 · true → saved 각 거절 노드는 해당 요청의 끝 용량 거절은 별도 조건 · 항목/숫자 오류 둘은 원 main의 고정 입력에 없음
입력 형식과 회원 규칙은 다른 경계에서 판정한다 각emptycapacity3독립요청. 항목오류usage/숫자forty number-error는registry전종료. 용량full생성전false,Memberbad0catchfalse/rejected보존,Kim40정상참조저장후size++saved. email@검사없음. add 요청 하나 빈 저장소·용량3 각 사례는 독립 요청 항목 수가 3인가? CLI 입력 구조 검사 아니요 · 이메일만 입력 usage · 종료 숫자 변환·저장소 미호출 예 Integer.parseInt 나이 문자열 → int CLI에서 숫자 형식 판정 forty · 변환 예외 catch number-error 저장소 미호출 변환 성공 → 저장소 호출 용량이 가득 찼는가? 생성보다 먼저 검사 예 · 이 빈 저장소에서는 아님 false → rejected Member 생성 없음 아니요 new Member 검증 이메일 null·공백 / 14~120세 검사 bad 0 · 생성 예외 catch false → rejected 배열·size 유지 Kim40 · 정상 참조 생성 저장 → size++ size: 0 → 1 true → saved 사용 칸에 유효한 회원 형식 오류 ≠ 회원 규칙 거절 이메일의 @ 형식 검사는 없음
항목 수 오류 · 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
27Kim 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 형식 검사

항목 수와 정수 변환을 검사하며 통과한 요청만 저장소를 부릅니다.

  • 항목 오류: usage → 파싱·저장소 미호출
  • forty: number-error → 저장소 미호출
  • bad 0·Kim 40: 정수 변환 통과 → 저장소 호출
  • 별도 full 조건: 올바른 형식으로 이미 가득 찬 저장소 호출
  • CLI에서 종료한 두 요청은 저장소 검사에 도달하지 않습니다.
저장소 조건별 처리

빈 저장소의 bad 0·Kim 40과 이미 가득 찬 full 조건을 함께 비교합니다.

  • 별도 full: 생성 전 false, Member 생성 없음
  • 빈 시작 bad 0: 생성자 나이 예외 catch → 배열·size 유지
  • 빈 시작 Kim 40: 정상 참조 저장 → size 0→1
  • 앞선 CLI 거절: 이미 종료, 저장소 미호출 유지
  • 용량 거절과 생성자 값 검증 실패를 다른 시작 상태와 경계로 유지합니다.
각 요청의 결과 출력

각 독립 요청은 자신이 얻은 거절 또는 성공 결과만 출력합니다.

  • 항목 오류·forty: usage / number-error
  • bad 0·별도 full: false → rejected
  • Kim 40: true → saved, size=1
  • 정상 저장 뒤에만 saved이며 앞선 거절 다음 성공 입력을 같은 요청으로 연결하지 않습니다.

연습 문제

MemberRegistry의 회원 설명을 String[] 새 배열로 반환하는 descriptions()를 작성하세요.

호출자가 반환 배열을 바꿔도 저장소 내부 상태가 변하지 않아야 합니다. 회원이 없을 때 반환할 배열의 길이와, 반환한 두 원소 중 첫 문자열만 바꿨을 때 다음 호출이 읽어야 할 값을 먼저 적어 보세요. 내부 Member[]를 그대로 반환하는 앞의 실패 예제와 무엇이 다른지도 설명합니다.

해설 보기
src/DefensiveDescriptionCopy.java
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) {}
}

호출자가 수정한 것은 새 String 배열뿐입니다.

내부 Member 배열 참조는 외부로 나가지 않았고 size 불변식도 유지됩니다. 회원이 없으면 길이 0인 새 배열을 반환합니다. 두 회원의 첫 반환 배열을 바꾸어도 다음 호출은 저장소에서 다시 읽은 [email protected]=40과 [email protected]=50을 새 배열에 담습니다. String은 불변이므로 문자열 참조를 복사한 뒤 그 배열 칸을 재대입하는 일도 기존 문자열이나 회원을 바꾸지 않습니다.