안동민 개발노트

본문 시작

객체의 데이터와 책임

절차와 객체 중심 설계를 비교하고 Member와 MemberRegistry가 각각 책임질 상태와 행동을 배치합니다.

객체 지향 설계의 출발점은 “어떤 클래스를 만들까”가 아니라 “누가 이 상태를 알고 있으며 누가 규칙을 지킬까”입니다.

Member는 한 회원을 알고, MemberRegistry는 여러 회원의 저장 범위를 압니다.

행동을 이 지식 가까이에 두면 호출자가 내부 구조를 반복해서 해석하지 않아도 됩니다.


공개 필드의 불변식 훼손

lab/PublicFieldInvariantFailure.java
public final class PublicFieldInvariantFailure {
    public static void main(String[] args) {
        Member member = new Member();
        member.email = "kim@example.com";
        member.age = 40;

        member.email = null;

        System.out.println(member.describe());
    }

    private static final class Member {
        String email;
        int age;

        String describe() {
            return email.toUpperCase() + "=" + age;
        }
    }
}
실패 관찰
Exception in thread "main" java.lang.NullPointerException

처음에는 email과 age를 모두 채운 정상 객체였지만, 외부 코드가 공개 필드를 null로 바꿨습니다.

이후 객체 자신의 describe조차 안전하게 실행할 수 없습니다.

객체가 데이터 묶음이기는 해도 외부가 필드를 원하는 순서와 값으로 바꾸면 불변식을 보장할 수 없습니다.

접근 제어는 뒤에서 자세히 다루지만, 먼저 행동을 객체에 배치해 책임의 모양을 확인합니다.


객체의 자체 계산

src/MemberResponsibility.java
public final class MemberResponsibility {
    public static void main(String[] args) {
        Member member = new Member("kim@example.com", 40);

        member.celebrateBirthday();
        System.out.println(member.describe());
        System.out.println("adult=" + member.isAdult());
    }

    private static final class Member {
        private String email;
        private int age;

        Member(String email, int age) {
            if (email == null || email.isBlank() || age < 14 || age > 120) {
                throw new IllegalArgumentException("invalid member");
            }
            this.email = email;
            this.age = age;
        }

        void celebrateBirthday() {
            if (age >= 120) {
                throw new IllegalStateException("maximum age");
            }
            age++;
        }

        boolean isAdult() {
            return age >= 18;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
kim@example.com=41
adult=true

celebrateBirthday는 현재 age와 허용 상한을 모두 아는 Member에 있습니다.

호출자는 현재 나이를 읽어 1을 더하고 다시 대입하는 절차를 알 필요가 없습니다.

describe는 한 회원의 기본 표현을 제공하고 isAdult은 분류 규칙을 이름으로 드러냅니다.

모든 행동을 무조건 데이터 클래스에 넣지는 않습니다.

화면 색상이나 파일 경로처럼 다른 계층의 관심사는 별도 객체가 맡을 수 있습니다.

책임 배치 기준은 해당 행동을 수행하는 데 필요한 정보와 변경 이유입니다.

필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다

서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이40을41로 바꾼다. 후자는 나이120 이상이면 증가 전 예외를 던진다.

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님
시점PublicFieldInvariantFailureMemberResponsibility
처음 상태email = kim@example.com, age = 40new Member("kim@example.com", 40)
변경 요청member.email = null;member.celebrateBirthday();
관찰 결과describe()의 email.toUpperCase()에서 NullPointerExceptionkim@example.com=41와 adult=true 출력
PublicFieldInvariantFailure
처음에는 email=kim@example.com, age=40이다.
member.email = null 뒤 describe()의 email.toUpperCase()가 NullPointerException을 일으킨다.
MemberResponsibility
new Member("kim@example.com", 40) 뒤 celebrateBirthday()를 호출한다.
kim@example.com=41와 adult=true가 출력된다.

celebrateBirthday()는 age >= 120이면 age++ 전에 IllegalStateException("maximum age")를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.


MemberRegistry의 집계 책임

src/ObjectOrientedMemberRegistry.java
public final class ObjectOrientedMemberRegistry {
    public static void main(String[] args) {
        MemberRegistry registry = new MemberRegistry(3);

        System.out.println(registry.add("kim@example.com", 40));
        System.out.println(registry.add("", 30));
        System.out.println(registry.add("lee@example.com", 50));

        registry.printEntries();
        System.out.println("count=" + registry.count());
        System.out.println("total=" + registry.totalAge());
    }

    private static final class MemberRegistry {
        private final Member[] members;
        private int size;

        MemberRegistry(int capacity) {
            if (capacity <= 0) {
                throw new IllegalArgumentException("capacity must be positive");
            }
            members = new Member[capacity];
        }

        boolean add(String email, int age) {
            if (size == members.length || !Member.isValid(email, age)) {
                return false;
            }
            members[size] = new Member(email, age);
            size++;
            return true;
        }

        int count() {
            return size;
        }

        int totalAge() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return total;
        }

        void printEntries() {
            for (int index = 0; index < size; index++) {
                System.out.println((index + 1) + ". " + members[index].describe());
            }
        }
    }

    private static final class Member {
        private final String email;
        private final int age;

        Member(String email, int age) {
            if (!isValid(email, age)) {
                throw new IllegalArgumentException("invalid member");
            }
            this.email = email;
            this.age = age;
        }

        static boolean isValid(String email, int age) {
            return email != null && !email.isBlank() && age >= 14 && age <= 120;
        }

        int age() {
            return age;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
true
false
true
1. kim@example.com=40
2. lee@example.com=50
count=2
total=90

main은 배열과 size를 전달하지 않습니다.

registry.add, registry.totalAge라는 메시지만 보냅니다.

MemberRegistry는 저장 범위와 용량을 책임지고, Member는 한 회원의 값과 표현을 책임집니다.

빈 이메일은 false를 반환하고 size가 변하지 않습니다.


절차 호출과 객체 메시지

절차 중심 호출은 데이터를 인수로 넘깁니다.

size = add(members, size, email, age);
int total = total(members, size);
print(members, size);

객체 중심 호출은 상태를 가진 수신자를 먼저 적습니다.

registry.add(email, age);
int total = registry.totalAge();
registry.printEntries();

두 코드 모두 메서드를 사용합니다.

차이는 메서드가 static인지 여부만이 아니라, 상태와 행동의 결합 위치입니다.

객체 버전에서 members와 size는 MemberRegistry 내부 구현이며 호출자의 관심사가 아닙니다.

호출자는 registry에 요청하고 배열과 size는 내부에 둔다

ObjectOrientedMemberRegistry의 세 add 호출 뒤 registry는 길이3 배열과 size2를 가진다. 두 슬롯은 kim40과lee50 Member를 참조하고 마지막 슬롯은null이다. main은 메서드를 호출해 저장여부와count2,total90을 얻는다.

ObjectOrientedMemberRegistry · 세 add 호출 뒤의 상태

main의 호출과 MemberRegistry 내부의 배열·size main은 registry의 메서드를 호출한다. registry의 size는2이고 길이3 배열의0,1번 참조는 각각 kim40과lee50 Member에 도달한다.2번은null이다. 점선은 호출이고 실선은 객체 참조이며 두 회원은 배열 칸 자체가 아니다. mainregistry.add(...)registry.count()registry.totalAge()add: true / false / truecount=2, total=90 MemberRegistrysize = 2 · members.length = 3사용 구간: [0, size) = [0, 2)Member[] members [0]참조[1]참조[2]null Memberemail: kim@example.comage: 40 Memberemail: lee@example.comage: 50
  • 메서드 호출
  • 객체 참조
호출자 main
registry.add(...)의 반환은 true / false / true. registry.count()는 2, registry.totalAge()는90을 반환한다.
MemberRegistry 내부
members.length = 3, size = 2. 사용 구간은 [0, size) = [0, 2)이다.
members[0]의 참조 대상
Member: email = kim@example.com, age = 40.
members[1]의 참조 대상
Member: email = lee@example.com, age = 50.
사용하지 않은 members[2]
null. 배열 칸은 Member 자체가 아니라 참조값을 저장한다.

빈 이메일의 add는 false를 반환해 저장·증가를 건너뜁니다. 이 프로그램에서 집계와 출력은 Member.age()와 Member.describe()를 사용하며, 호출자는 배열과 size를 인수로 넘기지 않습니다.

객체를 단순히 static 메서드의 첫 인수로 바꾸어 읽을 수도 있습니다.

registry.add(email, age)
≈ add(registry, email, age)

그러나 자바의 인스턴스 메서드는 수신자 this를 통해 자신의 필드에 접근하고, 접근 제어를 이용해 외부가 우회하지 못하게 할 수 있습니다.

이것이 상태 경계를 만드는 기반입니다.


과도한 객체 책임

MemberRegistry가 Scanner 입력, 파일 저장, 날짜 계산, 화면 색상, HTTP 전송까지 모두 맡으면 다시 하나의 거대한 절차 덩어리가 됩니다.

다음 질문으로 분리합니다.

  • 이 행동이 바뀌는 이유는 저장 규칙과 같은가?
  • 수행에 필요한 데이터 대부분을 이 객체가 이미 알고 있는가?
  • 다른 객체의 내부를 많이 물어본 뒤 계산하는가?
  • 외부 시스템 I/O와 핵심 규칙이 섞이는가?

MemberRegistry는 회원의 추가·삭제·조회·집계를 맡고, CLI는 문자열 입력과 메시지 출력을 맡는 구성이 자연스럽습니다.

CLI가 파싱한 값으로 registry.add를 호출하고 결과에 따라 안내합니다.


객체 협력 기반 CLI

src/MemberRegistryCollaboration.java
import java.util.Scanner;

public final class MemberRegistryCollaboration {
    public static void main(String[] args) {
        Scanner scanner = new Scanner("add kim@example.com 40\nadd lee@example.com 50\nsummary\nquit\n");
        MemberRegistry registry = new MemberRegistry(2);

        while (scanner.hasNextLine()) {
            String[] parts = scanner.nextLine().trim().split("\\s+");
            if (parts[0].equals("quit")) {
                break;
            }
            if (parts[0].equals("summary")) {
                System.out.println(registry.summary());
                continue;
            }
            if (parts.length == 3 && parts[0].equals("add")) {
                boolean saved = registry.add(parts[1], Integer.parseInt(parts[2]));
                System.out.println(saved ? "saved" : "rejected");
            }
        }
    }

    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 || email.isBlank() || age < 14 || age > 120) {
                return false;
            }
            members[size++] = new Member(email, age);
            return true;
        }

        String summary() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age;
            }
            return "count=" + size + ", total=" + total;
        }
    }

    private static final class Member {
        private final String email;
        private final int age;

        private Member(String email, int age) {
            this.email = email;
            this.age = age;
        }
    }
}
saved
saved
count=2, total=90

Scanner와 명령 형식은 main이 알고, 저장 불변식과 합계는 MemberRegistry가 압니다.

이 협력에서 어느 쪽도 상대의 내부 구현 전체를 알 필요가 없습니다.

고정 입력 CLI에서 실제 코드가 맡는 책임을 나눈다

MemberRegistryCollaboration의 main은 문자열을 파싱하고 결과를 출력한다. MemberRegistry는 값검사·저장·size·합계와summary문자열을 맡고 Member 생성자는 최종필드 대입만 한다. 고정 입력의 결과는saved두번과count2,total90이다.

MemberRegistryCollaboration · 고정 문자열 입력 예제

MemberRegistryCollaboration · 고정 문자열 입력 예제
담당 코드직접 다루는 값실제 행동
mainScanner, 명령 토큰, 이메일·나이 문자열nextLine().trim().split(...)와 Integer.parseInt로 해석한다. add/summary를 호출하고 saved/rejected 또는 요약을 출력한다.
MemberRegistrymembers, size, 용량과 나이 범위add에서 용량·빈 이메일·14~120세를 검사해 저장한다. summary가 나이를 합산하고 count=..., total=... 문자열도 만든다.
Memberfinal email, final age생성자가 전달된 값을 필드에 대입한다. 이 프로그램의 Member 생성자에는 검증 코드가 없다.
main
Scanner의 고정 문자열을 줄·토큰으로 나누고 Integer.parseInt로 나이를 해석한다.
add와 summary를 호출하고 저장 여부의 saved/rejected 또는 요약 문자열을 출력한다.
MemberRegistry
members, size, 용량과14~120세 범위를 다룬다.
add가 용량·빈 이메일·나이를 검사해 저장한다. summary는 합산뿐 아니라 count=..., total=... 문자열 조립도 맡는다.
Member
final email, final age를 보관한다. 이 예제의 생성자는 대입만 하며 검증하지 않는다.

입력 순서: add kim@example.com 40, add lee@example.com 50, summary, quit. 출력은 saved 두 번과 count=2, total=90입니다.

앞의 ObjectOrientedMemberRegistry와 다른 프로그램입니다. 별도 View는 없으며, 잘못된 숫자의 예외 복구나 실제 콘솔 대화까지 구현한 예제로 해석하지 않습니다.


연습 문제

현재 가입자 수와 목표 가입자 수를 가진 SignupQuota를 만드세요.

register(int count)는 양수만 받아 누적하고, progressPercent()는 가입 목표 대비 백분율을 반환하되 100을 넘지 않게 합니다.

main에서 가입 목표 100명에 40명과 70명을 등록하세요.

해설 보기
src/SignupQuotaResponsibility.java
public final class SignupQuotaResponsibility {
    public static void main(String[] args) {
        SignupQuota goal = new SignupQuota(100);
        goal.register(40);
        goal.register(70);

        System.out.println("total=" + goal.totalCount());
        System.out.println("progress=" + goal.progressPercent());
    }

    private static final class SignupQuota {
        private final int targetCount;
        private int totalCount;

        SignupQuota(int targetCount) {
            if (targetCount <= 0) {
                throw new IllegalArgumentException("invalid target");
            }
            this.targetCount = targetCount;
        }

        void register(int count) {
            if (count <= 0) {
                throw new IllegalArgumentException("invalid count");
            }
            totalCount += count;
        }

        int totalCount() {
            return totalCount;
        }

        int progressPercent() {
            int percent = totalCount * 100 / targetCount;
            return Math.min(percent, 100);
        }
    }
}
total=110
progress=100

목표와 현재 합계가 같은 객체에 있어 달성률 규칙도 그 객체가 책임집니다.

호출자는 계산식이나 상한 처리를 반복하지 않습니다.