본문으로 건너뛰기

안동민 개발노트

본문 시작

객체의 데이터와 책임

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

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

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

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

회원 저장소 상태는 지식을 가진 객체에 행동을 배치한다

한 회원·회원 저장소·입력 화면은 알고 있는 정보와 지켜야 할 규칙의 범위가 서로 다릅니다.

  1. Member

    email·age · 한 건 검증

  2. MemberRegistry

    members·size · 추가·집계

  3. CLI

    입력 문자열 · 명령·오류 안내


공개 필드의 불변식 훼손

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

처음에는 emailage를 모두 채운 정상 객체였지만, 외부 코드가 공개 필드를 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은 분류 규칙을 이름으로 드러냅니다.

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

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

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

공개 필드 대입과 의도 있는 행동은 허용 범위가 다르다

원시 대입은 모든 값을 허용하지만 행동 메서드는 변경 목적과 성공 조건을 한 계약으로 묶습니다.

  1. member.age = 121

    의도 불명 · 모든 정수 허용 · 불변식 우회

  2. member.celebrateBirthday()

    생일 반영 · 1 증가 · 최대 나이 검사


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를 조작하지 않고 원하는 결과를 메시지로 요청합니다.

  1. CLI

    email· age 준비

  2. registry. add

    용량· 값 검사

  3. member 생성

    한 회원 불변식

  4. registry. total Age

    사용 영역 집계

  5. 결과

    저장 여부· 합계

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

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인지 여부만이 아니라, 상태와 행동의 결합 위치입니다.

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

객체를 단순히 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토큰·대화 흐름
회원 검증Member한 회원 규칙
용량·순서MemberRegistry저장 정책
화면 표현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

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

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