객체의 데이터와 책임
절차와 객체 중심 설계를 비교하고 Member와 MemberRegistry가 각각 책임질 상태와 행동을 배치합니다.
객체 지향 설계의 출발점은 “어떤 클래스를 만들까”가 아니라 “누가 이 상태를 알고 있으며 누가 규칙을 지킬까”입니다.
Member는 한 회원을 알고, MemberRegistry는 여러 회원의 저장 범위를 압니다.
행동을 이 지식 가까이에 두면 호출자가 내부 구조를 반복해서 해석하지 않아도 됩니다.
공개 필드의 불변식 훼손
public final class PublicFieldInvariantFailure {
public static void main(String[] args) {
Member member = new Member();
member.email = "[email protected]";
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("[email protected]", 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;
}
}
}[email protected]=41
adult=truecelebrateBirthday는 현재 age와 허용 상한을 모두 아는 Member에 있습니다.
호출자는 현재 나이를 읽어 1을 더하고 다시 대입하는 절차를 알 필요가 없습니다.
describe는 한 회원의 기본 표현을 제공하고 isAdult는 분류 규칙을 이름으로 드러냅니다.
모든 행동을 무조건 데이터 클래스에 넣지는 않습니다.
화면 색상이나 파일 경로처럼 다른 계층의 관심사는 별도 객체가 맡을 수 있습니다.
책임 배치 기준은 해당 행동을 수행하는 데 필요한 정보와 변경 이유입니다.
필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다
직접 email=null 대입 뒤 describe가 실패하는 경로와 birthday가 상한 검사 후 age 40→41을 허용하는 경로입니다. 두 독립 실행을 비교하여 행동이 규칙을 지키는 전환을 보여 줄 수 있습니다.
서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이40을41로 바꾼다. 후자는 나이120 이상이면 증가 전 예외를 던진다.
상태 변경은 조건을 확인하는 행동으로 요청한다
직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다.
서로 다른 프로그램 · 같은 객체의 복구 과정이 아님
| 시점 | PublicFieldInvariantFailure | MemberResponsibility |
|---|---|---|
| 처음 상태 | email = [email protected], age = 40 | new Member("[email protected]", 40) |
| 변경 요청 | member.email = null; | member.celebrateBirthday(); |
| 관찰 결과 | describe()의 email.toUpperCase()에서 NullPointerException | [email protected]=41와 adult=true 출력 |
PublicFieldInvariantFailure- 처음에는
[email protected], age=40이다. member.email = null뒤describe()의email.toUpperCase()가NullPointerException을 일으킨다.MemberResponsibilitynew Member("[email protected]", 40)뒤celebrateBirthday()를 호출한다.[email protected]=41와adult=true가 출력된다.
celebrateBirthday()는 age >= 120이면 age++ 전에 IllegalStateException("maximum age")를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.
이 예의 조건
- 두 실행은 독립이며 같은 객체 복구가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다. |
| 전체 읽기 정보 | 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이 40을 41로 바꾼다. 후자는 나이 120 이상이면 증가 전 예외를 던진다. 상태 변경은 조건을 확인하는 행동으로 요청한다 직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다. 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 시점 PublicFieldInvariantFailure MemberResponsibility 처음 상태 email = [email protected] , age = 40 new Member("[email protected]", 40) 변경 요청 member.email = null; member.celebrateBirthday(); 관찰 결과 describe() 의 email.toUpperCase() 에서 NullPointerException [email protected]=41과 adult=true 출력 PublicFieldInvariantFailure 처음에는 [email protected], age=40 이다. member.email = null 뒤 describe() 의 email.toUpperCase() 가 NullPointerException 을 일으킨다. MemberResponsibility new Member("[email protected]", 40) 뒤 celebrateBirthday() 를 호출한다. [email protected]=41과 adult=true 가 출력된다. celebrateBirthday() 는 age >= 120 이면 age++ 전에 IllegalStateException("maximum age") 를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 상태 각 Member가 Kim 40부터 시작합니다. |
|
|
| 직접 쓰기 경로 email=null 대입 뒤 describe가 toUpperCase를 호출합니다. |
|
|
| 행동 요청 경로 birthday가 40<120을 확인하고 41로 늘립니다. |
|
|
필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다
직접 email=null 대입 뒤 describe가 실패하는 경로와 birthday가 상한 검사 후 age 40→41을 허용하는 경로입니다. 두 독립 실행을 비교하여 행동이 규칙을 지키는 전환을 보여 줄 수 있습니다.
서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이40을41로 바꾼다. 후자는 나이120 이상이면 증가 전 예외를 던진다.
상태 변경은 조건을 확인하는 행동으로 요청한다
직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다.
서로 다른 프로그램 · 같은 객체의 복구 과정이 아님
| 시점 | PublicFieldInvariantFailure | MemberResponsibility |
|---|---|---|
| 처음 상태 | email = [email protected], age = 40 | new Member("[email protected]", 40) |
| 변경 요청 | member.email = null; | member.celebrateBirthday(); |
| 관찰 결과 | describe()의 email.toUpperCase()에서 NullPointerException | [email protected]=41와 adult=true 출력 |
PublicFieldInvariantFailure- 처음에는
[email protected], age=40이다. member.email = null뒤describe()의email.toUpperCase()가NullPointerException을 일으킨다.MemberResponsibilitynew Member("[email protected]", 40)뒤celebrateBirthday()를 호출한다.[email protected]=41와adult=true가 출력된다.
celebrateBirthday()는 age >= 120이면 age++ 전에 IllegalStateException("maximum age")를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.
이 예의 조건
- 두 실행은 독립이며 같은 객체 복구가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다. |
| 전체 읽기 정보 | 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이 40을 41로 바꾼다. 후자는 나이 120 이상이면 증가 전 예외를 던진다. 상태 변경은 조건을 확인하는 행동으로 요청한다 직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다. 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 시점 PublicFieldInvariantFailure MemberResponsibility 처음 상태 email = [email protected] , age = 40 new Member("[email protected]", 40) 변경 요청 member.email = null; member.celebrateBirthday(); 관찰 결과 describe() 의 email.toUpperCase() 에서 NullPointerException [email protected]=41과 adult=true 출력 PublicFieldInvariantFailure 처음에는 [email protected], age=40 이다. member.email = null 뒤 describe() 의 email.toUpperCase() 가 NullPointerException 을 일으킨다. MemberResponsibility new Member("[email protected]", 40) 뒤 celebrateBirthday() 를 호출한다. [email protected]=41과 adult=true 가 출력된다. celebrateBirthday() 는 age >= 120 이면 age++ 전에 IllegalStateException("maximum age") 를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 상태 각 Member가 Kim 40부터 시작합니다. |
|
|
| 직접 쓰기 경로 email=null 대입 뒤 describe가 toUpperCase를 호출합니다. |
|
|
| 행동 요청 경로 birthday가 40<120을 확인하고 41로 늘립니다. |
|
|
MemberRegistry의 집계 책임
public final class ObjectOrientedMemberRegistry {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry(3);
System.out.println(registry.add("[email protected]", 40));
System.out.println(registry.add("", 30));
System.out.println(registry.add("[email protected]", 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. [email protected]=40
2. [email protected]=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 내부 구현이며 호출자의 관심사가 아닙니다.
ObjectOrientedMemberRegistry의 세 add 호출 뒤 registry는 길이3 배열과 size2를 가진다. 두 슬롯은 kim40과lee50 Member를 참조하고 마지막 슬롯은null이다. main은 메서드를 호출해 저장여부와count2,total90을 얻는다.
호출자는 요청하고 Registry는 배열과 size를 관리한다
세 번의 추가 요청 중 빈 이메일은 거절되어 두 회원만 저장됩니다.
ObjectOrientedMemberRegistry · 세 add 호출 뒤의 상태
- 메서드 호출
- 객체 참조
- 호출자
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 = [email protected],age = 40.members[1]의 참조 대상Member:email = [email protected],age = 50.- 사용하지 않은
members[2] null. 배열 칸은 Member 자체가 아니라 참조값을 저장한다.
빈 이메일의 add는 false를 반환해 저장·증가를 건너뜁니다. 이 프로그램에서 집계와 출력은 Member.age()와 Member.describe()를 사용하며, 호출자는 배열과 size를 인수로 넘기지 않습니다.
객체를 단순히 static 메서드의 첫 인수로 바꾸어 읽을 수도 있습니다.
registry.add(email, age)
≈ add(registry, email, age)그러나 자바의 인스턴스 메서드는 수신자 this를 통해 자신의 필드에 접근합니다. 필드를 private으로 두면 다른 최상위 클래스의 호출자가 직접 바꾸는 경로를 제한할 수 있습니다. 이 한 파일 예제처럼 같은 바깥 클래스 안에 선언한 중첩 타입끼리는 private 접근이 가능하므로, 여기서는 직접 대입 대신 행동을 사용하는 설계 흐름을 봅니다.
이것이 상태 경계를 만드는 기반입니다.
과도한 객체 책임
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 [email protected] 40\nadd [email protected] 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가 압니다.
이 협력에서 어느 쪽도 상대의 내부 구현 전체를 알 필요가 없습니다.
이번 Scanner는 코드 안의 고정 명령 문자열을 읽습니다. 임의의 콘솔 입력에서 항목이 부족하거나 나이가 forty라면 이 버전은 복구하지 못합니다. 입력 검증과 반복에서 배운 항목 수 검사와 숫자 예외 처리는 실제 입력을 받을 때 CLI 경계에 추가하고, 저장소의 나이·용량 규칙과 구분합니다.
MemberRegistryCollaboration의 main은 문자열을 파싱하고 결과를 출력한다. MemberRegistry는 값검사·저장·size·합계와summary문자열을 맡고 Member 생성자는 최종필드 대입만 한다. 고정 입력의 결과는saved두번과count2,total90이다.
파싱·저장·출력 책임을 실제 코드에 맞춰 나눈다
이 고정 입력 예제에서 검증은 Registry가, Member 생성자는 필드 대입을 맡습니다.
MemberRegistryCollaboration · 고정 문자열 입력 예제
| 담당 코드 | 직접 다루는 값 | 실제 행동 |
|---|---|---|
main | Scanner, 명령 토큰, 이메일·나이 문자열 | nextLine().trim().split(...)와 Integer.parseInt로 해석한다. add/summary를 호출하고 saved/rejected 또는 요약을 출력한다. |
MemberRegistry | members, size, 용량과 나이 범위 | add에서 용량·빈 이메일·14~120세를 검사해 저장한다. summary가 나이를 합산하고 count=..., total=... 문자열도 만든다. |
Member | final email, final age | 생성자가 전달된 값을 필드에 대입한다. 이 프로그램의 Member 생성자에는 검증 코드가 없다. |
mainScanner의 고정 문자열을 줄·토큰으로 나누고Integer.parseInt로 나이를 해석한다.add와summary를 호출하고 저장 여부의saved/rejected또는 요약 문자열을 출력한다.MemberRegistrymembers,size, 용량과14~120세 범위를 다룬다.add가 용량·빈 이메일·나이를 검사해 저장한다.summary는 합산뿐 아니라count=..., total=...문자열 조립도 맡는다.Memberfinal email,final age를 보관한다. 이 예제의 생성자는 대입만 하며 검증하지 않는다.
입력 순서: add [email protected] 40, add [email protected] 50, summary, quit. 출력은 saved 두 번과 count=2, total=90입니다.
앞의 ObjectOrientedMemberRegistry와 다른 프로그램입니다. 별도 View는 없으며, 잘못된 숫자의 예외 복구나 실제 콘솔 대화까지 구현한 예제로 해석하지 않습니다.
연습 문제
현재 가입자 수와 목표 가입자 수를 가진 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목표와 현재 합계가 같은 객체에 있어 달성률 규칙도 그 객체가 책임집니다.
호출자는 계산식이나 상한 처리를 반복하지 않습니다.
원문은 40명과 70명의 작은 합계에서 110 * 100 / 100 = 110을 구한 뒤 100으로 제한합니다. 그러나 목표 100에 30,000,000명을 등록하면 totalCount * 100이 int 범위를 넘어 달성률이 음수가 됩니다. 이미 넘친 값에 Math.min을 적용해도 원래 계산으로 되돌릴 수 없습니다. 누적 합계 자체도 int 상한을 넘을 수 있습니다.
양수 인수를 일반적으로 받으려면 위 클래스의 두 메서드를 다음처럼 바꿉니다. 합계는 int 범위까지만 저장하고, 이를 넘는 등록은 상태를 바꾸기 전에 ArithmeticException으로 거절합니다. 백분율의 곱셈은 먼저 long으로 변환합니다.
void register(int count) {
if (count <= 0) {
throw new IllegalArgumentException("invalid count");
}
totalCount = Math.addExact(totalCount, count);
}
int progressPercent() {
long percent = (long) totalCount * 100 / targetCount;
return (int) Math.min(percent, 100L);
}Math.addExact는 합이 int 범위를 넘으면 예외를 던집니다. 오른쪽 계산이 성공해야 대입되므로 실패한 등록의 이전 합계는 유지됩니다. 목표가 양수이고 합계가 0 이상인 이 계약에서는 정수 나눗셈으로 소수 부분을 버리고 0–100을 반환합니다. 먼저 변환한 곱셈의 최댓값도 Integer.MAX_VALUE * 100L이어서 long 안에 들어갑니다.
독립적으로 목표 100·등록 30,000,000의 결과와, 목표 100·등록 Integer.MAX_VALUE 뒤 추가 1명의 결과를 예측하세요. 보완 버전은 첫 경우 달성률 100이며, 두 번째 추가에서 예외가 나고 합계는 Integer.MAX_VALUE로 유지됩니다.