생성자와 this
생성자로 객체를 만들면서 필수 값을 받고 검사하는 방법과, this로 필드와 매개변수를 구분하는 방법을 배웁니다.
4장에서는 new Member()로 객체를 만든 다음 member.email = ..., member.age = ...처럼 필드에 값을 하나씩 넣었습니다. 이 방식에서는 객체가 만들어진 순간부터 모든 필드가 채워질 때까지, 객체는 있지만 아직 쓸 수 없는 상태로 놓입니다. 그 사이에 다른 코드가 객체를 먼저 쓰거나, 필드 하나를 넣는 것을 잊어버리면 문제가 생깁니다.
앞 글의 Member는 생성자(constructor)로 이 문제를 피했습니다. 생성자는 new로 객체를 만들 때 실행되는 초기화 코드로, 필요한 값을 받아 필드에 넣는 일을 객체가 만들어지는 과정 안에서 끝냅니다. 이 글에서는 생성자가 없을 때 무슨 일이 생기는지부터 확인하고, 생성자를 선언하는 방법, 생성자 안에서 this가 하는 일, 생성자에서 값을 검사하면 무엇을 믿을 수 있게 되는지를 차례로 살펴봅니다.
필드 하나를 빠뜨리면
다음 프로그램은 생성자가 없는 Member를 만들고 나이만 넣은 뒤, 이메일을 대문자로 바꿔 출력하려고 합니다. email에 값을 넣는 줄은 어디에도 없지만, 필드는 지역 변수와 달리 대입하기 전에 읽어도 컴파일 오류가 나지 않습니다. 그래서 문제는 실행 중에 드러납니다.
public final class MissingInitialization {
public static void main(String[] args) {
Member member = new Member();
member.age = 40;
System.out.println(member.email.toUpperCase());
}
private static final class Member {
String email;
int age;
}
}Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local1>.email" is null
at MissingInitialization.main(MissingInitialization.java:6)클래스와 인스턴스에서 본 것처럼 new Member()는 객체의 필드를 타입별 기본값으로 채웁니다. 참조형인 email은 null, int인 age는 0입니다. 이어서 age에는 40을 넣었지만 email에는 아무것도 넣지 않았으므로 null이 그대로 남습니다. 메시지의 <local1>은 1번 지역 변수, 즉 member를 가리킵니다. javac -g로 컴파일하면 이 자리에 변수 이름이 나옵니다.
여기서 실패한 것은 email 필드를 읽는 일이 아니라 toUpperCase()를 호출하는 일입니다. member.email을 읽는 것은 문제없이 성공해 null을 내놓습니다. 그 null에 대고 메서드를 호출하는 순간 예외가 납니다. 아래 그림은 객체를 만든 직후, 나이를 넣은 뒤, 이메일을 쓰는 순간의 필드 값을 차례로 보여 줍니다.
초기화 누락 예제에서 필드 값과 실패 시점
new의 email=null/age 0에서 age만 40으로 바꾸고 email 값 읽기 성공 뒤 toUpperCase 호출에서 실패합니다. 초기화 누락과 역참조 실패의 다른 시점을 보여 줄 수 있습니다.
MissingInitialization에서 email은 null로 남고 age만 40으로 바뀐다. null 값 읽기는 가능하지만 그 값으로 toUpperCase를 호출하면 예외가 발생한다.
정상 생성 뒤에도 누락된 email은 null로 남는다
age만 대입한 객체에서 이메일을 읽는 것과 메서드를 호출하는 것을 구분합니다.
MissingInitialization의 같은 객체를 실행 순서대로 추적합니다.
| 실행 시점 | 객체 필드 | 관찰 |
|---|---|---|
| 생성 직후 | email = null
| new Member()가 정상 참조를 반환합니다. 필드는 언어 기본값입니다. |
| 나이 대입 후 | email = null
| member.age = 40은 이메일을 초기화하지 않습니다. |
| 이메일 사용 | email = null
| member.email 읽기는 성공합니다. 이어지는 toUpperCase() 호출에서 NullPointerException이 발생합니다. |
- 생성 직후
new Member()는 정상 참조를 반환합니다. 필드는email = null,age = 0입니다. - 나이 대입 후
member.age = 40이후에도email = null이며,age = 40만 달라집니다. - 이메일 사용
member.email읽기는 성공하지만, 그 값으로toUpperCase()를 호출하면NullPointerException이 발생합니다. 필드 값은 그대로입니다.
이 예제의 기본 생성자는 이메일 필수 조건을 검사하지 않습니다. 정상 생성과 업무상 유효한 초기 상태는 같은 뜻이 아닙니다.
이 예의 조건
- email 초기화가 누락된 원 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | new Member의 기본 null 0→age 40, email null 유지→email 필드 읽기 결과 null·age 40 유지→null.toUpperCase에서 NPE가 발생한다. 필드 읽기와 메서드 호출을 구분하며 예외 뒤에도 null 40을 유지한다. |
| 11 | 필드: null |
| 12 | 40 |
| 구분 | 내용 |
|---|---|
| 설명 | new Member의 기본 null 0→age 40, email null 유지→email 필드 읽기 결과 null·age 40 유지→null.toUpperCase에서 NPE가 발생한다. 필드 읽기와 메서드 호출을 구분하며 예외 뒤에도 null 40을 유지한다. |
| 전체 읽기 정보 | 초기화 누락 예제에서 필드 값과 실패 시점 초기화 누락 예제에서 필드 값과 실패 시점 MissingInitialization에서 email은 null로 남고 age만 40으로 바뀐다. null 값 읽기는 가능하지만 그 값으로 toUpperCase를 호출하면 예외가 발생한다. 정상 생성 뒤에도 누락된 email은 null로 남는다 age만 대입한 객체에서 이메일을 읽는 것과 메서드를 호출하는 것을 구분합니다. MissingInitialization 의 같은 객체를 실행 순서대로 추적합니다. 실행 문장별 email과 age 상태 및 결과 실행 시점 객체 필드 관찰 생성 직후 email = null age = 0 new Member() 가 정상 참조를 반환합니다. 필드는 언어 기본값입니다. 나이 대입 후 email = null age = 40 member.age = 40 은 이메일을 초기화하지 않습니다. 이메일 사용 email = null age = 40 member.email 읽기는 성공합니다. 이어지는 toUpperCase() 호출에서 NullPointerException 이 발생합니다. 생성 직후 new Member() 는 정상 참조를 반환합니다. 필드는 email = null , age = 0 입니다. 나이 대입 후 member.age = 40 이후에도 email = null 이며, age = 40 만 달라집니다. 이메일 사용 member.email 읽기는 성공하지만, 그 값으로 toUpperCase() 를 호출하면 NullPointerException 이 발생합니다. 필드 값은 그대로입니다. 이 예제의 기본 생성자는 이메일 필수 조건을 검사하지 않습니다. 정상 생성과 업무상 유효한 초기 상태는 같은 뜻이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기본 필드 new Member 뒤 email null, age 0입니다. |
|
|
| 일부 대입과 읽기 age 40만 대입하고 email 필드를 읽습니다. |
|
|
| 메서드 실패 null.toUpperCase를 호출해 NPE입니다. |
|
|
초기화 누락 예제에서 필드 값과 실패 시점
new의 email=null/age 0에서 age만 40으로 바꾸고 email 값 읽기 성공 뒤 toUpperCase 호출에서 실패합니다. 초기화 누락과 역참조 실패의 다른 시점을 보여 줄 수 있습니다.
MissingInitialization에서 email은 null로 남고 age만 40으로 바뀐다. null 값 읽기는 가능하지만 그 값으로 toUpperCase를 호출하면 예외가 발생한다.
정상 생성 뒤에도 누락된 email은 null로 남는다
age만 대입한 객체에서 이메일을 읽는 것과 메서드를 호출하는 것을 구분합니다.
MissingInitialization의 같은 객체를 실행 순서대로 추적합니다.
| 실행 시점 | 객체 필드 | 관찰 |
|---|---|---|
| 생성 직후 | email = null
| new Member()가 정상 참조를 반환합니다. 필드는 언어 기본값입니다. |
| 나이 대입 후 | email = null
| member.age = 40은 이메일을 초기화하지 않습니다. |
| 이메일 사용 | email = null
| member.email 읽기는 성공합니다. 이어지는 toUpperCase() 호출에서 NullPointerException이 발생합니다. |
- 생성 직후
new Member()는 정상 참조를 반환합니다. 필드는email = null,age = 0입니다. - 나이 대입 후
member.age = 40이후에도email = null이며,age = 40만 달라집니다. - 이메일 사용
member.email읽기는 성공하지만, 그 값으로toUpperCase()를 호출하면NullPointerException이 발생합니다. 필드 값은 그대로입니다.
이 예제의 기본 생성자는 이메일 필수 조건을 검사하지 않습니다. 정상 생성과 업무상 유효한 초기 상태는 같은 뜻이 아닙니다.
이 예의 조건
- email 초기화가 누락된 원 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | new Member의 기본 null 0→age 40, email null 유지→email 필드 읽기 결과 null·age 40 유지→null.toUpperCase에서 NPE가 발생한다. 필드 읽기와 메서드 호출을 구분하며 예외 뒤에도 null 40을 유지한다. |
| 11 | 필드: null |
| 12 | 40 |
| 구분 | 내용 |
|---|---|
| 설명 | new Member의 기본 null 0→age 40, email null 유지→email 필드 읽기 결과 null·age 40 유지→null.toUpperCase에서 NPE가 발생한다. 필드 읽기와 메서드 호출을 구분하며 예외 뒤에도 null 40을 유지한다. |
| 전체 읽기 정보 | 초기화 누락 예제에서 필드 값과 실패 시점 초기화 누락 예제에서 필드 값과 실패 시점 MissingInitialization에서 email은 null로 남고 age만 40으로 바뀐다. null 값 읽기는 가능하지만 그 값으로 toUpperCase를 호출하면 예외가 발생한다. 정상 생성 뒤에도 누락된 email은 null로 남는다 age만 대입한 객체에서 이메일을 읽는 것과 메서드를 호출하는 것을 구분합니다. MissingInitialization 의 같은 객체를 실행 순서대로 추적합니다. 실행 문장별 email과 age 상태 및 결과 실행 시점 객체 필드 관찰 생성 직후 email = null age = 0 new Member() 가 정상 참조를 반환합니다. 필드는 언어 기본값입니다. 나이 대입 후 email = null age = 40 member.age = 40 은 이메일을 초기화하지 않습니다. 이메일 사용 email = null age = 40 member.email 읽기는 성공합니다. 이어지는 toUpperCase() 호출에서 NullPointerException 이 발생합니다. 생성 직후 new Member() 는 정상 참조를 반환합니다. 필드는 email = null , age = 0 입니다. 나이 대입 후 member.age = 40 이후에도 email = null 이며, age = 40 만 달라집니다. 이메일 사용 member.email 읽기는 성공하지만, 그 값으로 toUpperCase() 를 호출하면 NullPointerException 이 발생합니다. 필드 값은 그대로입니다. 이 예제의 기본 생성자는 이메일 필수 조건을 검사하지 않습니다. 정상 생성과 업무상 유효한 초기 상태는 같은 뜻이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기본 필드 new Member 뒤 email null, age 0입니다. |
|
|
| 일부 대입과 읽기 age 40만 대입하고 email 필드를 읽습니다. |
|
|
| 메서드 실패 null.toUpperCase를 호출해 NPE입니다. |
|
|
문제는 이 클래스 선언만 봐서는 어떤 필드를 꼭 채워야 하는지 알 수 없다는 데 있습니다. 생성자가 없으면 회원을 만드는 모든 곳에서 다음 세 줄을 빠짐없이, 올바른 순서로 써야 합니다.
Member member = new Member();
member.email = email;
member.age = age;나중에 필수 필드가 하나 늘면 회원을 만드는 곳을 모두 찾아가 한 줄씩 추가해야 하는데, 하나를 놓쳐도 컴파일러는 아무 말을 하지 않습니다. 반면 필수 값을 생성자의 매개변수로 받으면, 값을 빠뜨린 호출은 컴파일되지 않습니다. 실수를 실행 중에 만나는 대신 컴파일할 때 발견하게 되는 것입니다.
생성자 선언하기
다음 Member는 이메일과 나이를 받는 생성자를 가집니다. main에서 new Member(...)의 괄호 안에 넣은 두 값과, Member 안에서 클래스 이름과 같은 이름으로 선언된 부분을 짝지어 보세요.
public final class ConstructorMember {
public static void main(String[] args) {
Member member = new Member("[email protected]", 40);
System.out.println(member.describe());
}
private static final class Member {
private String email;
private int age;
Member(String email, int age) {
this.email = email;
this.age = age;
}
String describe() {
return email + "=" + age;
}
}
}Member(String email, int age) { ... }는 메서드처럼 생겼지만 두 가지가 다릅니다. 이름이 클래스 이름과 똑같고, 반환 타입을 적는 자리가 비어 있습니다. void조차 적지 않습니다. 이 생성자가 있으면 new Member("[email protected]", 40)이 받은 두 값을 생성자에 넘기고, 생성자 본문이 그 값을 필드에 넣습니다. 그래서 describe()를 호출하는 시점에는 두 필드가 모두 채워져 있습니다.
생성자가 있으면 클래스와 인스턴스에서 본 new의 단계 가운데 생성자를 실행하는 단계가 실제로 일을 합니다. Member member = new Member("[email protected]", 40);은 다음 순서로 실행됩니다.
| 단계 | 하는 일 | 직후의 객체 상태 |
|---|---|---|
| 1 | 힙에 Member 객체가 들어갈 공간을 확보합니다 | 새 객체가 생깁니다 |
| 2 | 모든 필드를 기본값으로 채웁니다 | email은 null, age는 0 |
| 3 | 생성자 본문을 실행합니다 | email은 [email protected], age는 40 |
| 4 | 새 객체의 참조값을 new 식의 결과로 돌려줍니다 | 변경 없음 |
| 5 | =가 그 참조값을 member에 저장합니다 | 변경 없음 |
생성자 본문이 끝나야 4단계로 넘어가므로, member 변수가 객체를 가리키게 될 때는 이미 두 필드가 채워져 있습니다. 바깥 코드가 '반쯤 만들어진' 객체를 볼 틈이 없는 것입니다. 이 표는 이 장의 예제에 맞게 줄인 것으로, 필드 선언에 int age = 1;처럼 초깃값을 적었다면 그 대입은 3단계에서 생성자 본문보다 먼저 실행됩니다.
생성자에 반환 타입을 적으면 어떻게 될까요? 위 코드에서 생성자를 void Member(String email, int age)로 바꾸면 컴파일러는 이것을 이름이 우연히 Member인 평범한 메서드로 봅니다. 그러면 클래스에 선언한 생성자가 하나도 없게 되어, 컴파일러가 매개변수 없는 기본 생성자를 자동으로 넣습니다. 그래서 main의 new Member("[email protected]", 40)이 맞는 생성자를 찾지 못해 다음 오류가 납니다.
src/ConstructorMember.java:3: error: constructor Member in class Member cannot be applied to given types;
Member member = new Member("[email protected]", 40);
^
required: no arguments
found: String,int
reason: actual and formal argument lists differ in length
1 errorrequired: no arguments는 지금 쓸 수 있는 생성자가 인수를 받지 않는다는 뜻입니다. 생성자를 고친 줄이 아니라 생성자를 호출한 줄에서 오류가 나므로, 이 메시지를 보면 생성자 선언에 반환 타입이 붙지 않았는지 확인해 보세요. 생성자를 하나라도 선언하면 기본 생성자가 자동으로 생기지 않는다는 규칙은 다음 글에서 자세히 다룹니다.
생성자는 객체를 만들 때 new를 통해 한 번 실행될 뿐, 이미 만든 객체에 대고 메서드처럼 다시 호출할 수는 없습니다. member.Member("[email protected]", 50);이라고 쓰면 컴파일러는 그런 이름의 메서드가 없다는 cannot find symbol 오류를 냅니다. 만들어진 객체의 값을 나중에 바꿔야 한다면, 생성자와 같은 검사를 거쳐 값을 바꾸는 별도의 메서드를 만들어야 합니다. 다음 글에서 배울 this(...)는 생성자 안에서 같은 객체를 만드는 도중에 다른 생성자를 이어서 실행하는 문법이므로, 이미 만든 객체를 다시 초기화하는 것과는 다릅니다.
필드와 매개변수의 이름이 같을 때
생성자 안의 this.email = email;에서 this를 빼면 어떻게 될까요? 다음 프로그램의 생성자는 this 없이 대입합니다.
public final class MissingThisAssignment {
public static void main(String[] args) {
Member member = new Member("[email protected]", 50);
System.out.println(member.email + "=" + member.age);
}
private static final class Member {
String email;
int age;
Member(String email, int age) {
email = email;
age = age;
}
}
}null=0컴파일도 되고 예외도 없지만, 넘긴 이메일과 나이 대신 null=0이 출력됩니다. 생성자 안에는 email이라는 이름이 둘 있습니다. 하나는 객체의 필드이고, 다른 하나는 생성자의 매개변수입니다. 자바는 이름을 찾을 때 가장 안쪽 범위부터 찾으므로, 생성자 본문에서 email이라고만 쓰면 매개변수를 가리킵니다. 그래서 email = email;은 매개변수의 값을 같은 매개변수에 다시 넣을 뿐이고, 필드는 한 번도 건드리지 않습니다. 필드에는 2단계에서 받은 기본값 null과 0이 그대로 남습니다. 이처럼 안쪽 범위의 이름이 바깥 범위의 같은 이름을 가리는 것을 가려짐(shadowing)이라고 합니다.
필드를 가리키려면 this를 붙입니다. this는 지금 실행 중인 생성자나 메서드가 다루는 객체, 즉 현재 객체의 참조입니다.
Member(String email, int age) {
this.email = email;
this.age = age;
}왼쪽의 this.email은 현재 객체의 필드이고, 오른쪽의 email은 생성자가 받은 매개변수입니다. 생성자를 이렇게 고치면 같은 프로그램이 [email protected]=50을 출력합니다. 아래 그림은 같은 입력 [email protected]과 50을 받은 두 생성자가 각각 어느 저장 공간에 값을 넣는지 비교합니다. this는 대입할 곳을 정확히 가리킬 뿐, 값이 올바른지 검사해 주지는 않습니다.
매개변수 자기 대입과 this 필드 대입의 결과
매개변수 자기 대입은 필드 기본값을 유지하고 this 필드 대입은 전달값을 저장합니다. 서로 다른 생성자 구현의 대입 전후를 같은 역할로 비교할 수 있습니다.
MissingThisAssignment의 [email protected]과 50을 기준으로 매개변수만 다시 대입하는 코드와 본문의 this 교정 코드를 비교한다.
this로 매개변수와 현재 객체의 필드를 구분한다
자기 대입은 필드를 바꾸지 않아 null과 0이 그대로 남습니다.
MissingThisAssignment에 전달한 "[email protected]", 50을 기준으로 원본과 본문의 교정 코드를 비교합니다.
| 구현 | 대입 대상 | 생성 후 객체 필드 |
|---|---|---|
| 원본 | email = email;
오른쪽도 왼쪽도 같은 매개변수입니다. | email = null
출력: |
| 교정 | this.email = email;
매개변수 값을 현재 객체의 필드에 저장합니다. | email = "[email protected]"
같은 입력의 결과: |
- 원본: 매개변수에 자기 대입
email = email;과age = age;는 매개변수만 다시 대입합니다.객체 필드는
email = null,age = 0으로 남아null=0을 출력합니다.- 교정: 현재 객체 필드에 대입
this.email = email;과this.age = age;는 매개변수 값을 필드에 저장합니다.같은 입력이면 필드는
email = "[email protected]",age = 50이고 결과는[email protected]=50입니다.
this는 현재 인스턴스의 참조입니다. 필드 대입을 구분할 뿐, 입력값 검증을 자동으로 추가하지는 않습니다.
이 예의 조건
- 서로 다른 생성자 구현에 동일 Lee 50을 전달합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 Lee 50 입력에 대한 서로 다른 구현이다. email=email/age=age는 매개변수의 자기 대입이어서 필드는 null 0이다. this.email/this.age는 받은 값을 현재 인스턴스 필드에 대입하여 Lee 50이 된다. this 자체가 유효성 검사는 아니다. |
| 16 | this.email |
| 17 | this.age |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 Lee 50 입력에 대한 서로 다른 구현이다. email=email/age=age는 매개변수의 자기 대입이어서 필드는 null 0이다. this.email/this.age는 받은 값을 현재 인스턴스 필드에 대입하여 Lee 50이 된다. this 자체가 유효성 검사는 아니다. |
| 전체 읽기 정보 | 매개변수 자기 대입과 this 필드 대입의 결과 매개변수 자기 대입과 this 필드 대입의 결과 MissingThisAssignment의 [email protected]과 50을 기준으로 매개변수만 다시 대입하는 코드와 본문의 this 교정 코드를 비교한다. this로 매개변수와 현재 객체의 필드를 구분한다 자기 대입은 필드를 바꾸지 않아 null과 0이 그대로 남습니다. MissingThisAssignment 에 전달한 "[email protected]" , 50 을 기준으로 원본과 본문의 교정 코드를 비교합니다. 대입문이 바꾸는 저장 공간과 생성 후 필드 값 구현 대입 대상 생성 후 객체 필드 원본 email = email; age = age; 오른쪽도 왼쪽도 같은 매개변수입니다. email = null age = 0 출력: null=0 교정 this.email = email; this.age = age; 매개변수 값을 현재 객체의 필드에 저장합니다. email = "[email protected]" age = 50 같은 입력의 결과: [email protected]=50 원본: 매개변수에 자기 대입 email = email; 과 age = age; 는 매개변수만 다시 대입합니다. 객체 필드는 email = null , age = 0 으로 남아 null=0 을 출력합니다. 교정: 현재 객체 필드에 대입 this.email = email; 과 this.age = age; 는 매개변수 값을 필드에 저장합니다. 같은 입력이면 필드는 email = "[email protected]" , age = 50 이고 결과는 [email protected]=50 입니다. this 는 현재 인스턴스의 참조입니다. 필드 대입을 구분할 뿐, 입력값 검증을 자동으로 추가하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 매개변수 수신 email과 age 매개변수가 Lee 50을 받습니다. |
|
|
| 대입 대상 비교 자기 대입은 매개변수, this 대입은 현재 필드를 대상으로 합니다. |
|
|
| 생성 결과 잘못된 구현은 null 0, 필드 지정은 Lee 50입니다. |
|
|
매개변수 자기 대입과 this 필드 대입의 결과
매개변수 자기 대입은 필드 기본값을 유지하고 this 필드 대입은 전달값을 저장합니다. 서로 다른 생성자 구현의 대입 전후를 같은 역할로 비교할 수 있습니다.
MissingThisAssignment의 [email protected]과 50을 기준으로 매개변수만 다시 대입하는 코드와 본문의 this 교정 코드를 비교한다.
this로 매개변수와 현재 객체의 필드를 구분한다
자기 대입은 필드를 바꾸지 않아 null과 0이 그대로 남습니다.
MissingThisAssignment에 전달한 "[email protected]", 50을 기준으로 원본과 본문의 교정 코드를 비교합니다.
| 구현 | 대입 대상 | 생성 후 객체 필드 |
|---|---|---|
| 원본 | email = email;
오른쪽도 왼쪽도 같은 매개변수입니다. | email = null
출력: |
| 교정 | this.email = email;
매개변수 값을 현재 객체의 필드에 저장합니다. | email = "[email protected]"
같은 입력의 결과: |
- 원본: 매개변수에 자기 대입
email = email;과age = age;는 매개변수만 다시 대입합니다.객체 필드는
email = null,age = 0으로 남아null=0을 출력합니다.- 교정: 현재 객체 필드에 대입
this.email = email;과this.age = age;는 매개변수 값을 필드에 저장합니다.같은 입력이면 필드는
email = "[email protected]",age = 50이고 결과는[email protected]=50입니다.
this는 현재 인스턴스의 참조입니다. 필드 대입을 구분할 뿐, 입력값 검증을 자동으로 추가하지는 않습니다.
이 예의 조건
- 서로 다른 생성자 구현에 동일 Lee 50을 전달합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 Lee 50 입력에 대한 서로 다른 구현이다. email=email/age=age는 매개변수의 자기 대입이어서 필드는 null 0이다. this.email/this.age는 받은 값을 현재 인스턴스 필드에 대입하여 Lee 50이 된다. this 자체가 유효성 검사는 아니다. |
| 16 | this.email |
| 17 | this.age |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 Lee 50 입력에 대한 서로 다른 구현이다. email=email/age=age는 매개변수의 자기 대입이어서 필드는 null 0이다. this.email/this.age는 받은 값을 현재 인스턴스 필드에 대입하여 Lee 50이 된다. this 자체가 유효성 검사는 아니다. |
| 전체 읽기 정보 | 매개변수 자기 대입과 this 필드 대입의 결과 매개변수 자기 대입과 this 필드 대입의 결과 MissingThisAssignment의 [email protected]과 50을 기준으로 매개변수만 다시 대입하는 코드와 본문의 this 교정 코드를 비교한다. this로 매개변수와 현재 객체의 필드를 구분한다 자기 대입은 필드를 바꾸지 않아 null과 0이 그대로 남습니다. MissingThisAssignment 에 전달한 "[email protected]" , 50 을 기준으로 원본과 본문의 교정 코드를 비교합니다. 대입문이 바꾸는 저장 공간과 생성 후 필드 값 구현 대입 대상 생성 후 객체 필드 원본 email = email; age = age; 오른쪽도 왼쪽도 같은 매개변수입니다. email = null age = 0 출력: null=0 교정 this.email = email; this.age = age; 매개변수 값을 현재 객체의 필드에 저장합니다. email = "[email protected]" age = 50 같은 입력의 결과: [email protected]=50 원본: 매개변수에 자기 대입 email = email; 과 age = age; 는 매개변수만 다시 대입합니다. 객체 필드는 email = null , age = 0 으로 남아 null=0 을 출력합니다. 교정: 현재 객체 필드에 대입 this.email = email; 과 this.age = age; 는 매개변수 값을 필드에 저장합니다. 같은 입력이면 필드는 email = "[email protected]" , age = 50 이고 결과는 [email protected]=50 입니다. this 는 현재 인스턴스의 참조입니다. 필드 대입을 구분할 뿐, 입력값 검증을 자동으로 추가하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 매개변수 수신 email과 age 매개변수가 Lee 50을 받습니다. |
|
|
| 대입 대상 비교 자기 대입은 매개변수, this 대입은 현재 필드를 대상으로 합니다. |
|
|
| 생성 결과 잘못된 구현은 null 0, 필드 지정은 Lee 50입니다. |
|
|
매개변수 이름을 inputEmail처럼 필드와 다르게 지으면 this 없이도 구분됩니다. 하지만 필드와 매개변수가 같은 정보를 뜻한다면 같은 이름을 쓰고 this로 구분하는 편이 둘의 대응을 더 분명하게 보여 줍니다. 자바 코드에서 생성자를 이렇게 쓰는 것이 흔한 이유입니다.
this는 생성자에서만 쓰는 것이 아닙니다. 인스턴스 메서드 안에서도 this.describe()처럼 현재 객체의 다른 메서드를 부르거나, printer.print(this)처럼 현재 객체를 다른 객체의 메서드에 인수로 넘길 때 씁니다. 다만 이름이 겹치지 않는 곳에서는 this.를 붙이지 않아도 같은 필드를 가리키므로, 모든 필드에 습관처럼 붙일 필요는 없습니다. static 메서드에는 현재 객체가 없으므로 this를 쓸 수 없고, 쓰면 non-static variable this cannot be referenced from a static context 오류가 납니다.
더 깊이: final 필드라면 컴파일러가 잡아 준다
앞의 실수는 필드에 final을 붙이면 컴파일 단계에서 드러납니다. Java 언어 명세(JLS §8.3.1.2)에 따르면, 선언할 때 값을 주지 않은 final 인스턴스 필드는 모든 생성자가 끝나기 전에 반드시 값을 한 번 받아야 합니다. MissingThisAssignment의 두 필드를 private final String email;과 private final int age;로 바꾸면, email = email;은 필드에 아무것도 넣지 않으므로 컴파일러가 생성자 끝에서 다음 오류를 냅니다.
lab/MissingThisAssignment.java:14: error: variable email might not have been initialized
}
^
1 error이 장의 예제 대부분이 private final 필드를 쓰는 이유 가운데 하나가 이것입니다. 생성 뒤에 바뀌지 않을 필드라면 final을 붙여 두는 것만으로 대입을 빠뜨리는 실수를 컴파일러가 대신 찾아 줍니다. final 필드의 다른 규칙은 6장의 final 변수·필드·상수에서 다룹니다.
생성자에서 값을 검사하기
생성자가 필수 값을 받게 만들면 값을 빠뜨리는 실수는 막을 수 있지만, 빈 이메일이나 130세 같은 잘못된 값까지 막지는 못합니다. 다음 프로그램의 생성자는 필드에 값을 넣기 전에 이메일과 나이를 차례로 검사하고, 규칙에 맞지 않으면 IllegalArgumentException을 던집니다. printCreation 메서드는 생성을 시도한 뒤 성공하면 created=를, 예외가 나면 그 메시지를 rejected= 뒤에 붙여 출력합니다.
public final class ValidatedConstructor {
public static void main(String[] args) {
printCreation("[email protected]", 40);
printCreation("", 30);
printCreation("[email protected]", 130);
}
private static void printCreation(String email, int age) {
try {
Member member = new Member(email, age);
System.out.println("created=" + member.describe());
} catch (IllegalArgumentException exception) {
System.out.println("rejected=" + exception.getMessage());
}
}
private static final class Member {
private final String email;
private final int age;
Member(String email, int age) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("email");
}
if (age < 14 || age > 120) {
throw new IllegalArgumentException("age");
}
this.email = email;
this.age = age;
}
String describe() {
return email + "=" + age;
}
}
}[email protected]=40
rejected=email
rejected=age세 호출은 서로 독립적입니다. 첫 호출은 두 검사를 모두 통과해 필드에 값을 넣고, new가 돌려준 회원의 describe() 결과가 출력됩니다. 둘째 호출은 빈 이메일이라 첫 번째 if에서 "email" 예외가 납니다. 예외가 나면 생성자의 나머지 문장은 실행되지 않으므로 나이는 검사하지도 않습니다. 셋째 호출은 이메일 검사를 통과하지만 130이 120을 넘으므로 "age" 예외가 납니다. 두 경우 모두 printCreation의 catch가 예외를 잡아 메시지를 출력합니다.
예외가 나면 new 식은 객체의 참조를 돌려주지 않습니다. Member member = new Member(email, age);의 대입도 일어나지 않고, 실행은 곧장 catch로 넘어갑니다. 그래서 바깥 코드는 규칙을 어긴 회원 객체를 손에 넣을 방법이 없습니다. 검사를 필드 대입보다 앞에 둔 이유도 같습니다. 잘못된 값이 필드에 들어가는 일 자체가 없도록 하려는 것입니다. 아래 그림은 생성자 안에서 두 검사와 필드 대입이 어떤 순서로 이어지는지, 그리고 어디서 실패하면 참조를 받지 못하는지를 보여 줍니다.
검증 생성자의 첫 실패와 정상 참조 반환 경계
이메일과 나이 검사를 통과한 뒤 필드를 대입하고 new가 정상 참조를 반환합니다. 실패한 생성에서 외부 정상 참조가 나오지 않는 실행 경계를 나눌 수 있습니다.
ValidatedConstructor의 Member는 이메일 검사 뒤 나이 검사를 수행하고 둘 다 통과해야 필드에 대입한다. 예외가 나면 new는 정상 참조를 반환하지 않으며 printCreation이 rejected 메시지로 바꾼다.
검사를 모두 통과해야 필드 대입과 정상 반환에 도달한다
이메일 검사에서 실패하면 나이 검사도 건너뛰고 호출자가 rejected로 알립니다.
ValidatedConstructor의 Member 생성자입니다. 앞의 단순 대입 예제와 달리 검증 코드가 있습니다.
- 이메일 검사
email == null || email.isBlank()이면IllegalArgumentException("email")로 종료합니다. 나이 검사는 실행하지 않습니다. - 나이 검사
이메일이 통과한 뒤
age < 14 || age > 120이면IllegalArgumentException("age")로 종료합니다. - 필드 대입과 정상 반환
두 검사를 통과해야
this.email과this.age에 대입하고,new가 정상 참조를 반환합니다.
어느 검사에서든 예외가 나면 필드 대입에 도달하지 않고, new도 정상 참조를 반환하지 않습니다. 호출자 printCreation이 예외를 잡아 rejected=email 또는 rejected=age를 출력합니다.
이 불변식은 검증을 직접 구현한 이 예제의 보장입니다. 생성자라는 문법만으로 입력값이 검증되지는 않습니다.
이 예의 조건
- 원 main의 세 독립 호출 중 [email protected],40의 정상 생성 하나만 추적합니다.
- 별도 빈 이메일 30과 [email protected],130의 실패 요청은 원 전체 표에 보존하며 정상 호출의 후속이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 검증 생성자의 첫 실패와 정상 참조 반환 경계 검증 생성자의 첫 실패와 정상 참조 반환 경계 ValidatedConstructor의 Member는 이메일 검사 뒤 나이 검사를 수행하고 둘 다 통과해야 필드에 대입한다. 예외가 나면 new는 정상 참조를 반환하지 않으며 printCreation이 rejected 메시지로 바꾼다. 검사를 모두 통과해야 필드 대입과 정상 반환에 도달한다 이메일 검사에서 실패하면 나이 검사도 건너뛰고 호출자가 rejected로 알립니다. ValidatedConstructor 의 Member 생성자입니다. 앞의 단순 대입 예제와 달리 검증 코드가 있습니다. 이메일 검사 email == null || email.isBlank() 이면 IllegalArgumentException("email") 로 종료합니다. 나이 검사는 실행하지 않습니다. 나이 검사 이메일이 통과한 뒤 age < 14 || age > 120 이면 IllegalArgumentException("age") 로 종료합니다. 필드 대입과 정상 반환 두 검사를 통과해야 this.email 과 this.age 에 대입하고, new 가 정상 참조를 반환합니다. 어느 검사에서든 예외가 나면 필드 대입에 도달하지 않고, new 도 정상 참조를 반환하지 않습니다. 호출자 printCreation 이 예외를 잡아 rejected=email 또는 rejected=age 를 출력합니다. 이 불변식은 검증을 직접 구현한 이 예제의 보장입니다. 생성자라는 문법만으로 입력값이 검증되지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Kim의 입력 검증 [email protected]은 null·공백이 아니고 나이 40은 14–120 범위입니다. |
|
|
| 현재 객체 필드 대입 두 검사 통과 후 this.email과 this.age에 입력값을 기록합니다. |
|
|
| new 정상 참조 반환 생성자가 정상 완료한 뒤 new가 Kim 40 객체의 참조를 반환합니다. |
|
|
검증 생성자의 첫 실패와 정상 참조 반환 경계
이메일과 나이 검사를 통과한 뒤 필드를 대입하고 new가 정상 참조를 반환합니다. 실패한 생성에서 외부 정상 참조가 나오지 않는 실행 경계를 나눌 수 있습니다.
ValidatedConstructor의 Member는 이메일 검사 뒤 나이 검사를 수행하고 둘 다 통과해야 필드에 대입한다. 예외가 나면 new는 정상 참조를 반환하지 않으며 printCreation이 rejected 메시지로 바꾼다.
검사를 모두 통과해야 필드 대입과 정상 반환에 도달한다
이메일 검사에서 실패하면 나이 검사도 건너뛰고 호출자가 rejected로 알립니다.
ValidatedConstructor의 Member 생성자입니다. 앞의 단순 대입 예제와 달리 검증 코드가 있습니다.
- 이메일 검사
email == null || email.isBlank()이면IllegalArgumentException("email")로 종료합니다. 나이 검사는 실행하지 않습니다. - 나이 검사
이메일이 통과한 뒤
age < 14 || age > 120이면IllegalArgumentException("age")로 종료합니다. - 필드 대입과 정상 반환
두 검사를 통과해야
this.email과this.age에 대입하고,new가 정상 참조를 반환합니다.
어느 검사에서든 예외가 나면 필드 대입에 도달하지 않고, new도 정상 참조를 반환하지 않습니다. 호출자 printCreation이 예외를 잡아 rejected=email 또는 rejected=age를 출력합니다.
이 불변식은 검증을 직접 구현한 이 예제의 보장입니다. 생성자라는 문법만으로 입력값이 검증되지는 않습니다.
이 예의 조건
- 원 main의 세 독립 호출 중 [email protected],40의 정상 생성 하나만 추적합니다.
- 별도 빈 이메일 30과 [email protected],130의 실패 요청은 원 전체 표에 보존하며 정상 호출의 후속이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 검증 생성자의 첫 실패와 정상 참조 반환 경계 검증 생성자의 첫 실패와 정상 참조 반환 경계 ValidatedConstructor의 Member는 이메일 검사 뒤 나이 검사를 수행하고 둘 다 통과해야 필드에 대입한다. 예외가 나면 new는 정상 참조를 반환하지 않으며 printCreation이 rejected 메시지로 바꾼다. 검사를 모두 통과해야 필드 대입과 정상 반환에 도달한다 이메일 검사에서 실패하면 나이 검사도 건너뛰고 호출자가 rejected로 알립니다. ValidatedConstructor 의 Member 생성자입니다. 앞의 단순 대입 예제와 달리 검증 코드가 있습니다. 이메일 검사 email == null || email.isBlank() 이면 IllegalArgumentException("email") 로 종료합니다. 나이 검사는 실행하지 않습니다. 나이 검사 이메일이 통과한 뒤 age < 14 || age > 120 이면 IllegalArgumentException("age") 로 종료합니다. 필드 대입과 정상 반환 두 검사를 통과해야 this.email 과 this.age 에 대입하고, new 가 정상 참조를 반환합니다. 어느 검사에서든 예외가 나면 필드 대입에 도달하지 않고, new 도 정상 참조를 반환하지 않습니다. 호출자 printCreation 이 예외를 잡아 rejected=email 또는 rejected=age 를 출력합니다. 이 불변식은 검증을 직접 구현한 이 예제의 보장입니다. 생성자라는 문법만으로 입력값이 검증되지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Kim의 입력 검증 [email protected]은 null·공백이 아니고 나이 40은 14–120 범위입니다. |
|
|
| 현재 객체 필드 대입 두 검사 통과 후 this.email과 this.age에 입력값을 기록합니다. |
|
|
| new 정상 참조 반환 생성자가 정상 완료한 뒤 new가 Kim 40 객체의 참조를 반환합니다. |
|
|
이렇게 하면 'Member 객체가 있다면 그 이메일은 비어 있지 않고 나이는 14세부터 120세 사이다'라는 사실을 믿을 수 있습니다. 이 사실은 생성자라는 문법이 저절로 만들어 주는 것이 아니라, 이 생성자가 직접 검사하기 때문에 성립합니다. 그리고 final 필드라서 만든 뒤에 바뀌지도 않습니다. 그래서 describe()나 다른 메서드는 email이 null인지 매번 다시 확인할 필요가 없습니다. 회원이 만들어지는 길목 한 곳에서 검사했기 때문입니다.
사용자 입력을 받는 CLI라면 잘못 입력할 때마다 프로그램이 예외로 끝나면 안 됩니다. 그래서 printCreation처럼 입력을 처리하는 쪽에서 예외를 잡아 rejected 같은 안내로 바꿉니다. 생성자는 회원의 규칙을 지키고, CLI는 사용자와의 대화를 이어 가는 일을 맡는 것입니다. 예외를 언제 던지고 어디서 잡을지는 12장에서 자세히 다룹니다.
저장소가 회원을 만들 때
이제 저장소 MemberRegistry가 이메일과 나이를 받아 직접 회원을 만들게 해 보겠습니다. 이번에는 앞 글처럼 저장소가 isValid로 미리 검사하지 않고, 회원 생성을 시도한 뒤 생성자가 예외를 던지면 그것을 잡아 false를 돌려줍니다. 나이 규칙은 Member 생성자에, 용량 규칙은 저장소의 add에 둡니다. 용량 2인 저장소에 회원 세 명을 차례로 추가합니다.
public final class ConstructingMemberRegistry {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry(2);
System.out.println(registry.add("[email protected]", 40));
System.out.println(registry.add("[email protected]", 50));
System.out.println(registry.add("[email protected]", 30));
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");
}
this.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;
}
}
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;
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;
}
int age() {
return age;
}
}
}true
true
false
count=2, total=90첫 두 요청은 용량이 남아 있으므로 회원을 만들어 0번과 1번 칸에 넣고, 그때마다 size를 0에서 1로, 1에서 2로 늘린 뒤 true를 돌려줍니다. 세 번째 요청은 size가 배열 길이 2와 같아 첫 if에서 바로 false를 돌려줍니다. 이때는 Member 생성자가 아예 호출되지 않습니다. 저장된 회원은 두 명뿐이므로 요약은 count=2, total=90입니다.
add 안의 순서가 중요합니다. members[size] = new Member(email, age);에서는 오른쪽의 생성자가 정상적으로 끝나야 배열 칸에 대입이 일어나고, 그다음 줄에서야 size가 늘어납니다. 생성자가 예외를 던지면 대입도 size++도 건너뛰고 catch로 넘어가므로, 배열과 size는 요청 전과 똑같이 남습니다. 예를 들어 빈 칸이 있는 저장소에 add("", 30)을 요청하면 false가 나오고 회원 수는 그대로입니다. 4장의 객체 배열로 회원 저장소 만들기에서 객체를 다 만든 뒤에 size를 늘렸던 것과 같은 원칙입니다. 아래 그림은 세 요청이 각각 어디까지 진행되는지, 그리고 생성자가 실패했을 때 어느 단계를 건너뛰는지 보여 줍니다.
회원 생성 성공 뒤 배열 저장과 size 증가
Kim과 Lee는 생성 성공 후 저장·size 증가하고 Park은 용량 검사에서 생성 전에 거절됩니다. 생성 예외도 저장 확정 전에 잡아 상태를 보존하는 실제 add 게이트입니다.
ConstructingMemberRegistry의 용량 2에서 첫 두 add는 생성 후 저장하고 size를 늘린다. 세 번째 add는 가득 찬 검사를 먼저 만나 Member 생성도 하지 않는다.
생성이 성공한 뒤 저장하고 회원 수를 늘린다
용량 2의 세 번째 요청은 생성자에 도달하기 전에 거절됩니다.
ConstructingMemberRegistry의 main: 용량 2, 처음 size = 0인 같은 저장소입니다.
| 요청 | 생성·저장 | 결과와 size |
|---|---|---|
| 첫 번째 | "[email protected]", 40검증 생성 성공 후 | true
|
| 두 번째 | "[email protected]", 50검증 생성 성공 후 | true
|
| 세 번째 | "[email protected]", 30
| false
|
- 첫 번째: kim, 40
"[email protected]", 40의 생성 성공 후members[0]에 저장합니다.size: 0 → 1, 반환값은true입니다. - 두 번째: lee, 50
"[email protected]", 50의 생성 성공 후members[1]에 저장합니다.size: 1 → 2, 반환값은true입니다. - 세 번째: park, 30
"[email protected]", 30은 용량 검사에서 거절됩니다.Member생성자를 호출하지 않으며size: 2 → 2, 반환값은false입니다.
최종 요약은 count=2, total=90입니다. 저장 공간이 있어도 생성자가 IllegalArgumentException을 던지면 add가 false를 반환하며 배열 칸과 size를 바꾸지 않습니다.
이 예제는 생성·저장을 끝낸 다음 별도 문장으로 size++를 실행합니다. members[size++] = new Member(...)와 실행 순서가 다릅니다.
이 예의 조건
- 원 main의 용량 2 같은 저장소에 Kim 40, Lee 50, Park 30을 순차 요청하는 경로만 추적합니다.
- 빈 공간이 있는 별도 저장소의 생성자 예외 조건은 독립 분기이며 원 전체 표에 보존합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 capacity 2 저장소에서 Kim 40은 0→1 true, Lee 50은 1→2 true를 반환하고 Park 30은 capacity full로 2를 유지하며 false를 반환하고 생성하지 않는다. 빈 공간이 있어도 생성자의 IAA를 catch하면 false를 반환하며 배열/size를 유지한다. 생성에 성공해 저장한 후 별도로 size++를 실행한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 capacity 2 저장소에서 Kim 40은 0→1 true, Lee 50은 1→2 true를 반환하고 Park 30은 capacity full로 2를 유지하며 false를 반환하고 생성하지 않는다. 빈 공간이 있어도 생성자의 IAA를 catch하면 false를 반환하며 배열/size를 유지한다. 생성에 성공해 저장한 후 별도로 size++를 실행한다. |
| 전체 읽기 정보 | 회원 생성 성공 뒤 배열 저장과 size 증가 회원 생성 성공 뒤 배열 저장과 size 증가 ConstructingMemberRegistry의 용량 2에서 첫 두 add는 생성 후 저장하고 size를 늘린다. 세 번째 add는 가득 찬 검사를 먼저 만나 Member 생성도 하지 않는다. 생성이 성공한 뒤 저장하고 회원 수를 늘린다 용량 2의 세 번째 요청은 생성자에 도달하기 전에 거절됩니다. ConstructingMemberRegistry 의 main : 용량 2 , 처음 size = 0 인 같은 저장소입니다. 세 add 요청의 생성 여부, 저장 위치, 회원 수 변화 요청 생성·저장 결과와 size 첫 번째 "[email protected]", 40 검증 생성 성공 후 members[0] 에 저장합니다. true 0 → 1 두 번째 "[email protected]", 50 검증 생성 성공 후 members[1] 에 저장합니다. true 1 → 2 세 번째 "[email protected]", 30 size == members.length 이므로 Member 생성자를 호출하지 않습니다. false 2 → 2 첫 번째: kim, 40 "[email protected]", 40 의 생성 성공 후 members[0] 에 저장합니다. size: 0 → 1 , 반환값은 true 입니다. 두 번째: lee, 50 "[email protected]", 50 의 생성 성공 후 members[1] 에 저장합니다. size: 1 → 2 , 반환값은 true 입니다. 세 번째: park, 30 "[email protected]", 30 은 용량 검사에서 거절됩니다. Member 생성자를 호출하지 않으며 size: 2 → 2 , 반환값은 false 입니다. 최종 요약은 count=2, total=90 입니다. 저장 공간이 있어도 생성자가 IllegalArgumentException 을 던지면 add 가 false 를 반환하며 배열 칸과 size 를 바꾸지 않습니다. 이 예제는 생성·저장을 끝낸 다음 별도 문장으로 size++ 를 실행합니다. members[size++] = new Member(...) 와 실행 순서가 다릅니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 요청 성공 Kim 40과 Lee 50의 검증 생성 참조를 각각 저장한 뒤 size를 늘립니다. |
|
|
| Park의 용량 거절 같은 저장소에 Park 30을 요청하면 가득 찬 용량 검사에서 생성 전에 끝납니다. |
|
|
| 원 main의 집계 두 성공 회원을 가진 같은 저장소의 count와 total을 읽습니다. |
|
|
회원 생성 성공 뒤 배열 저장과 size 증가
Kim과 Lee는 생성 성공 후 저장·size 증가하고 Park은 용량 검사에서 생성 전에 거절됩니다. 생성 예외도 저장 확정 전에 잡아 상태를 보존하는 실제 add 게이트입니다.
ConstructingMemberRegistry의 용량 2에서 첫 두 add는 생성 후 저장하고 size를 늘린다. 세 번째 add는 가득 찬 검사를 먼저 만나 Member 생성도 하지 않는다.
생성이 성공한 뒤 저장하고 회원 수를 늘린다
용량 2의 세 번째 요청은 생성자에 도달하기 전에 거절됩니다.
ConstructingMemberRegistry의 main: 용량 2, 처음 size = 0인 같은 저장소입니다.
| 요청 | 생성·저장 | 결과와 size |
|---|---|---|
| 첫 번째 | "[email protected]", 40검증 생성 성공 후 | true
|
| 두 번째 | "[email protected]", 50검증 생성 성공 후 | true
|
| 세 번째 | "[email protected]", 30
| false
|
- 첫 번째: kim, 40
"[email protected]", 40의 생성 성공 후members[0]에 저장합니다.size: 0 → 1, 반환값은true입니다. - 두 번째: lee, 50
"[email protected]", 50의 생성 성공 후members[1]에 저장합니다.size: 1 → 2, 반환값은true입니다. - 세 번째: park, 30
"[email protected]", 30은 용량 검사에서 거절됩니다.Member생성자를 호출하지 않으며size: 2 → 2, 반환값은false입니다.
최종 요약은 count=2, total=90입니다. 저장 공간이 있어도 생성자가 IllegalArgumentException을 던지면 add가 false를 반환하며 배열 칸과 size를 바꾸지 않습니다.
이 예제는 생성·저장을 끝낸 다음 별도 문장으로 size++를 실행합니다. members[size++] = new Member(...)와 실행 순서가 다릅니다.
이 예의 조건
- 원 main의 용량 2 같은 저장소에 Kim 40, Lee 50, Park 30을 순차 요청하는 경로만 추적합니다.
- 빈 공간이 있는 별도 저장소의 생성자 예외 조건은 독립 분기이며 원 전체 표에 보존합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 같은 capacity 2 저장소에서 Kim 40은 0→1 true, Lee 50은 1→2 true를 반환하고 Park 30은 capacity full로 2를 유지하며 false를 반환하고 생성하지 않는다. 빈 공간이 있어도 생성자의 IAA를 catch하면 false를 반환하며 배열/size를 유지한다. 생성에 성공해 저장한 후 별도로 size++를 실행한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 같은 capacity 2 저장소에서 Kim 40은 0→1 true, Lee 50은 1→2 true를 반환하고 Park 30은 capacity full로 2를 유지하며 false를 반환하고 생성하지 않는다. 빈 공간이 있어도 생성자의 IAA를 catch하면 false를 반환하며 배열/size를 유지한다. 생성에 성공해 저장한 후 별도로 size++를 실행한다. |
| 전체 읽기 정보 | 회원 생성 성공 뒤 배열 저장과 size 증가 회원 생성 성공 뒤 배열 저장과 size 증가 ConstructingMemberRegistry의 용량 2에서 첫 두 add는 생성 후 저장하고 size를 늘린다. 세 번째 add는 가득 찬 검사를 먼저 만나 Member 생성도 하지 않는다. 생성이 성공한 뒤 저장하고 회원 수를 늘린다 용량 2의 세 번째 요청은 생성자에 도달하기 전에 거절됩니다. ConstructingMemberRegistry 의 main : 용량 2 , 처음 size = 0 인 같은 저장소입니다. 세 add 요청의 생성 여부, 저장 위치, 회원 수 변화 요청 생성·저장 결과와 size 첫 번째 "[email protected]", 40 검증 생성 성공 후 members[0] 에 저장합니다. true 0 → 1 두 번째 "[email protected]", 50 검증 생성 성공 후 members[1] 에 저장합니다. true 1 → 2 세 번째 "[email protected]", 30 size == members.length 이므로 Member 생성자를 호출하지 않습니다. false 2 → 2 첫 번째: kim, 40 "[email protected]", 40 의 생성 성공 후 members[0] 에 저장합니다. size: 0 → 1 , 반환값은 true 입니다. 두 번째: lee, 50 "[email protected]", 50 의 생성 성공 후 members[1] 에 저장합니다. size: 1 → 2 , 반환값은 true 입니다. 세 번째: park, 30 "[email protected]", 30 은 용량 검사에서 거절됩니다. Member 생성자를 호출하지 않으며 size: 2 → 2 , 반환값은 false 입니다. 최종 요약은 count=2, total=90 입니다. 저장 공간이 있어도 생성자가 IllegalArgumentException 을 던지면 add 가 false 를 반환하며 배열 칸과 size 를 바꾸지 않습니다. 이 예제는 생성·저장을 끝낸 다음 별도 문장으로 size++ 를 실행합니다. members[size++] = new Member(...) 와 실행 순서가 다릅니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 요청 성공 Kim 40과 Lee 50의 검증 생성 참조를 각각 저장한 뒤 size를 늘립니다. |
|
|
| Park의 용량 거절 같은 저장소에 Park 30을 요청하면 가득 찬 용량 검사에서 생성 전에 끝납니다. |
|
|
| 원 main의 집계 두 성공 회원을 가진 같은 저장소의 count와 total을 읽습니다. |
|
|
이렇게 나누면 각 규칙이 그 규칙과 관련된 상태 바로 옆에 놓입니다. 이메일과 나이 규칙은 그 값을 가진 Member의 생성자에, 용량 규칙은 배열과 size를 가진 MemberRegistry의 add에 있습니다. 나이 범위가 바뀌어도 저장소는 고칠 필요가 없고, 저장 방식이 바뀌어도 회원의 규칙은 그대로입니다.
정리
- 생성자가 없으면 객체를 만든 뒤 필드를 하나씩 채워야 하고, 하나를 빠뜨려도 기본값이 남을 뿐 컴파일러는 알려 주지 않습니다. 필수 값을 생성자 매개변수로 받으면 빠뜨린 호출은 컴파일되지 않습니다.
- 생성자는 이름이 클래스와 같고 반환 타입이 없습니다.
new는 필드를 기본값으로 채운 뒤 생성자 본문을 실행하고, 본문이 끝나야 참조를 돌려줍니다. - 생성자나 메서드 안에서 이름만 쓰면 매개변수가 필드를 가립니다.
this.필드로 현재 객체의 필드를 가리켜야 하며,final필드는 대입을 빠뜨리면 컴파일 오류가 납니다. - 생성자에서 값을 검사하고 실패하면 예외를 던지면, 규칙을 어긴 객체는 바깥으로 나가지 않습니다. 그 결과 만들어진 객체는 언제나 규칙을 지킨다고 믿을 수 있습니다.
연습 문제
- 캠페인 이름과 목표 가입자 수를 반드시 받아야 만들 수 있는
SignupQuota클래스를 작성하세요. 이름은null이거나 공백일 수 없고, 목표는 1명부터 1,000명까지만 허용합니다. 규칙에 맞지 않으면 문제가 된 값의 이름("name"또는"targetCount")을 메시지로 하는IllegalArgumentException을 던지세요.this로 필드와 매개변수를 구분하고,new SignupQuota("daily", 120)의describe()가daily=120을 돌려주게 하세요. 그런 다음 목표가 1, 1,000, 0, 1,001인 경우와 이름이null인 경우, 그리고 이름과 목표가 둘 다 잘못된 경우에 각각 무엇이 출력될지 예상하고 확인하세요. - 다음 생성자를 가진 클래스로
new Member("[email protected]", 40).describe()를 출력하면 무엇이 나올까요? 두 필드가 모두final이었다면 어떻게 될지도 설명하세요.
Member(String email, int age) {
this.email = email;
age = age;
}ConstructingMemberRegistry의add를 다음처럼 줄였다고 해 봅시다. 새로 만든 용량 2의 빈 저장소에add("", 30)을 요청하면 무엇이 달라지고, 그 뒤summary()를 호출하면 어떻게 될까요?
try {
members[size++] = new Member(email, age);
return true;
} catch (IllegalArgumentException exception) {
return false;
}해설 보기
1번은 다음처럼 작성할 수 있습니다. 여러 입력을 한 번에 확인하려고 앞의 printCreation과 같은 방식의 메서드를 두었습니다.
public final class SignupQuotaConstructor {
public static void main(String[] args) {
SignupQuota goal = new SignupQuota("daily", 120);
System.out.println(goal.describe());
printCreation("daily", 1);
printCreation("daily", 1_000);
printCreation("daily", 0);
printCreation("daily", 1_001);
printCreation(null, 120);
printCreation(" ", 0);
}
private static void printCreation(String name, int targetCount) {
try {
SignupQuota quota = new SignupQuota(name, targetCount);
System.out.println("created=" + quota.describe());
} catch (IllegalArgumentException exception) {
System.out.println("rejected=" + exception.getMessage());
}
}
private static final class SignupQuota {
private final String name;
private final int targetCount;
SignupQuota(String name, int targetCount) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name");
}
if (targetCount < 1 || targetCount > 1_000) {
throw new IllegalArgumentException("targetCount");
}
this.name = name;
this.targetCount = targetCount;
}
String describe() {
return name + "=" + targetCount;
}
}
}daily=120
created=daily=1
created=daily=1000
rejected=targetCount
rejected=targetCount
rejected=name
rejected=name필수 값 두 개가 생성자의 매개변수로 드러나 있으므로, 둘 중 하나를 빠뜨린 new SignupQuota("daily") 같은 호출은 컴파일되지 않습니다. 목표 1과 1,000은 범위의 양 끝이라 허용되고, 0과 1,001은 범위를 벗어나 거절됩니다. 이름이 null이면 name == null이 참이므로 ||의 오른쪽 name.isBlank()는 실행되지 않습니다. 순서를 바꿔 name.isBlank() || name == null로 썼다면 null에 메서드를 호출해 NullPointerException이 났을 것입니다. 이름이 공백이고 목표도 0인 마지막 경우에는 먼저 나오는 이름 검사에서 "name" 예외가 나고, 목표 검사와 필드 대입은 실행되지 않습니다. 어느 값이 잘못됐는지 하나씩 알려 주려면 이처럼 검사를 나누고, 먼저 확인할 값을 앞에 둡니다.
2번은 [email protected]=0을 출력합니다. 이메일은 this.email로 필드에 들어갔지만, age = age;는 매개변수를 자기 자신에 대입할 뿐이라 age 필드는 기본값 0으로 남습니다. 두 필드가 final이었다면 age 필드가 생성자에서 한 번도 값을 받지 않으므로 variable age might not have been initialized 오류가 나서 컴파일되지 않습니다.
3번에서는 members[size++] = ...의 size++가 오른쪽의 생성자보다 먼저 실행된다는 점이 문제입니다. 배열 원소에 대입할 때 자바는 배열과 인덱스 식을 먼저 계산하고, 그다음에 오른쪽 값을 계산합니다. 그래서 size가 이미 1로 늘어난 상태에서 생성자가 예외를 던집니다. catch는 false를 돌려주지만 size는 되돌아가지 않고, 0번 칸은 null인 채로 회원 수에 포함됩니다. 그 뒤 summary()가 0번 칸의 age()를 호출하면 NullPointerException이 납니다. 원래 코드처럼 생성자와 대입이 성공한 다음 줄에서 size를 늘려야 실패한 요청이 상태를 바꾸지 않습니다. 이 평가 순서는 배열로 회원 목록 관리하기의 '더 깊이'에서 자세히 설명합니다.
다음 글
지금까지의 Member는 생성자가 하나뿐이었습니다. 다음 글에서는 생성자를 하나도 선언하지 않았을 때 컴파일러가 넣어 주는 기본 생성자의 규칙을 확인하고, 생성자를 여러 개 두면서도 검사와 대입은 한곳에 모으는 this(...) 호출을 배웁니다.