객체의 데이터와 책임
절차와 객체 중심 설계를 비교하고 Member와 MemberRegistry가 각각 책임질 상태와 행동을 배치합니다.
객체 지향 설계의 출발점은 “어떤 클래스를 만들까”가 아니라 “누가 이 상태를 알고 있으며 누가 규칙을 지킬까”입니다.
Member는 한 회원을 알고, MemberRegistry는 여러 회원의 저장 범위를 압니다.
행동을 이 지식 가까이에 두면 호출자가 내부 구조를 반복해서 해석하지 않아도 됩니다.
공개 필드의 불변식 훼손
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조차 안전하게 실행할 수 없습니다.
객체가 데이터 묶음이기는 해도 외부가 필드를 원하는 순서와 값으로 바꾸면 불변식을 보장할 수 없습니다.
접근 제어는 뒤에서 자세히 다루지만, 먼저 행동을 객체에 배치해 책임의 모양을 확인합니다.
객체의 자체 계산
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=truecelebrateBirthday는 현재 age와 허용 상한을 모두 아는 Member에 있습니다.
호출자는 현재 나이를 읽어 1을 더하고 다시 대입하는 절차를 알 필요가 없습니다.
describe는 한 회원의 기본 표현을 제공하고 isAdult은 분류 규칙을 이름으로 드러냅니다.
모든 행동을 무조건 데이터 클래스에 넣지는 않습니다.
화면 색상이나 파일 경로처럼 다른 계층의 관심사는 별도 객체가 맡을 수 있습니다.
책임 배치 기준은 해당 행동을 수행하는 데 필요한 정보와 변경 이유입니다.
MemberRegistry의 집계 책임
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=90main은 배열과 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 내부 구현이며 호출자의 관심사가 아닙니다.
객체를 단순히 static 메서드의 첫 인수로 바꾸어 읽을 수도 있습니다.
registry.add(email, age)
≈ add(registry, email, age)그러나 자바의 인스턴스 메서드는 수신자 this를 통해 자신의 필드에 접근하고, 접근 제어를 이용해 외부가 우회하지 못하게 할 수 있습니다.
이것이 상태 경계를 만드는 기반입니다.
과도한 객체 책임
MemberRegistry가 Scanner 입력, 파일 저장, 날짜 계산, 화면 색상, HTTP 전송까지 모두 맡으면 다시 하나의 거대한 절차 덩어리가 됩니다.
다음 질문으로 분리합니다.
- 이 행동이 바뀌는 이유는 저장 규칙과 같은가?
- 수행에 필요한 데이터 대부분을 이 객체가 이미 알고 있는가?
- 다른 객체의 내부를 많이 물어본 뒤 계산하는가?
- 외부 시스템 I/O와 핵심 규칙이 섞이는가?
MemberRegistry는 회원의 추가·삭제·조회·집계를 맡고, CLI는 문자열 입력과 메시지 출력을 맡는 구성이 자연스럽습니다.
CLI가 파싱한 값으로 registry.add를 호출하고 결과에 따라 안내합니다.
객체 협력 기반 CLI
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=90Scanner와 명령 형식은 main이 알고, 저장 불변식과 합계는 MemberRegistry가 압니다.
이 협력에서 어느 쪽도 상대의 내부 구현 전체를 알 필요가 없습니다.
연습 문제
현재 가입자 수와 목표 가입자 수를 가진 SignupQuota를 만드세요.
register(int count)는 양수만 받아 누적하고, progressPercent()는 가입 목표 대비 백분율을 반환하되 100을 넘지 않게 합니다.
main에서 가입 목표 100명에 40명과 70명을 등록하세요.
해설 보기
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목표와 현재 합계가 같은 객체에 있어 달성률 규칙도 그 객체가 책임집니다.
호출자는 계산식이나 상한 처리를 반복하지 않습니다.