본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : 객체 책임과 캡슐화

캡슐화된 회원 저장소

배열 노출과 불변식 파괴를 막고 입력·저장·집계 책임을 분리한 캡슐화 회원 저장소 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("kim@example.com", 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("kim@example.com", 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이라는 내부 상태와 실제 원소를 모순되게 만들었습니다.

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

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

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


객체 불변식

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("kim@example.com", 40);
        registry.add("park@example.com", 30);
        registry.add("lee@example.com", 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
kim@example.com=40
lee@example.com=50
count=2, total=90, average=45.0

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

마지막 사용 칸을 null로 비운 뒤 size를 줄여 사용 영역과 실제 원소가 일치합니다.

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


책임 중심 문제 해석

최대 카운터 문제의 핵심은 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 kim@example.com 40",
                "add bad 0",
                "add lee@example.com 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. kim@example.com=40
2. lee@example.com=50
removed
count=1, total=50
bye

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

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

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


연습 문제

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

호출자가 반환 배열을 바꿔도 저장소 내부 상태가 변하지 않아야 합니다.

해설 보기
src/DefensiveDescriptionCopy.java
import java.util.Arrays;

public final class DefensiveDescriptionCopy {
    public static void main(String[] args) {
        MemberRegistry registry = new MemberRegistry();
        registry.add("kim@example.com", 40);
        registry.add("lee@example.com", 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) {}
}
[kim@example.com=40, lee@example.com=50]

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

내부 Member 배열 참조는 외부로 나가지 않았고 size 불변식도 유지됩니다.