생성자 오버로딩과 this() 호출
컴파일러가 기본 생성자를 만들어 주는 조건을 알아보고, 생성자를 여러 개 둘 때 this(...)로 검사와 초기화를 한 생성자에 모으는 방법을 배웁니다.
생성자와 this에서는 Member에 이메일과 나이를 받는 생성자를 두고, 검사를 통과한 값만 필드에 넣었습니다. 그런데 회원을 만드는 쪽의 사정은 제각각입니다. 가입 화면은 이메일과 나이만 알고, 관리자 도구는 활성 여부까지 정해서 회원을 만들고 싶어 합니다. 게다가 생성자를 추가한 순간부터 예전에 잘 되던 new Member()가 컴파일되지 않는 일도 생깁니다.
이 글에서는 먼저 인수 없는 new Member()가 언제 되고 언제 안 되는지 알아봅니다. 이어서 생성자를 여러 개 두는 생성자 오버로딩을 살펴보고, 생성자마다 같은 코드를 반복하면 어떤 문제가 생기는지 확인합니다. 마지막으로 this(...)로 한 생성자가 다른 생성자에게 초기화를 맡겨, 검사와 필드 대입을 한곳에 모으는 방법을 익힙니다.
생성자를 선언하면 new Member()가 안 되는 이유
다음 코드는 이메일 하나를 받는 생성자만 있는 Member를 인수 없이 만들려고 합니다. 앞 글의 MissingInitialization에서는 생성자를 하나도 쓰지 않았는데도 new Member()가 컴파일됐습니다. 생성자를 하나 선언한 것만으로 무엇이 달라지는지 보세요.
public final class NoDefaultConstructor {
public static void main(String[] args) {
Member member = new Member();
System.out.println(member);
}
private static final class Member {
private final String email;
Member(String email) {
this.email = email;
}
}
}이 파일은 컴파일되지 않습니다. 메시지의 핵심 부분만 옮기면 다음과 같습니다.
error: constructor Member in class Member cannot be applied to given types;
Member member = new Member();
^
required: String
found: no arguments
reason: actual and formal argument lists differ in lengthrequired: String은 Member의 생성자가 String 인수 하나를 요구한다는 뜻이고, found: no arguments는 호출한 쪽이 인수를 하나도 주지 않았다는 뜻입니다. 인수 없이 호출할 수 있는 생성자가 Member에 없으니 컴파일러는 이 new를 받아들이지 않습니다.
그런데 클래스와 인스턴스에서는 생성자를 하나도 쓰지 않았는데도 new Member()가 동작했습니다. 클래스에 생성자를 하나도 선언하지 않으면 컴파일러가 매개변수 없는 생성자를 대신 만들어 넣기 때문입니다. 이렇게 컴파일러가 만들어 주는 생성자를 기본 생성자(default constructor)라고 합니다. 하지만 생성자를 하나라도 직접 선언하면 컴파일러는 기본 생성자를 만들지 않습니다. 위 코드는 Member(String email)을 선언했으므로 기본 생성자가 없습니다.
이렇게 정해 둔 이유는 생성자를 선언한 사람의 의도를 지키기 위해서입니다. Member(String email)을 선언했다는 것은 '회원을 만들려면 이메일이 있어야 한다'는 뜻입니다. 그런데 컴파일러가 인수 없는 생성자를 몰래 덧붙인다면, 이메일 없이 회원을 만드는 길이 개발자도 모르게 열려 버립니다.
인수 없이 만드는 방법도 꼭 필요하다면 그 생성자를 직접 선언하면 됩니다.
Member() {
this.email = "untitled";
}다만 이 코드를 쓰기 전에 "untitled"가 정말 쓸 수 있는 이메일인지 따져 봐야 합니다. 그렇지 않다면 이 생성자는 잘못된 회원을 만드는 통로일 뿐이고, 문제는 그 회원에게 메일을 보내려는 실행 시점에야 드러납니다. 필수 값이 있는 객체라면 인수 없는 생성자를 억지로 만들지 말고, 컴파일 오류가 빠뜨린 인수를 알려 주게 두는 편이 낫습니다. 또 직접 선언한 Member()는 모양이 같아도 컴파일러가 만든 기본 생성자가 아니라 개발자가 본문을 쓴 보통 생성자라는 점을 구별해 두세요.
개발자 생성자가 없을 때만 컴파일러가 기본 생성자를 제공한다. NoDefaultConstructor의 Member(String)에는 자동 Member()가 없으며, 직접 선언한 Member()는 자동 제공과 다르다.
기본 생성자는 생성자 선언이 없을 때만 자동 제공된다
직접 선언한 Member()와 컴파일러가 제공한 기본 생성자는 구분합니다.
접근 가능하고 나머지 클래스 선언이 유효한 경우의 생성자 서명을 비교합니다.
| 개발자 선언 | 컴파일러가 자동 제공 | 인수 없는 생성 |
|---|---|---|
| 생성자 없음 | 매개변수 없는 기본 생성자 | new Member()에 대응하는 서명이 있습니다. |
| 한 인수만 | 없음
| NoDefaultConstructor의 new Member()는 컴파일 오류입니다. |
| 인수 없는 생성자도 직접 선언 | 없음
| 직접 선언한 Member()를 호출합니다. 자동 기본 생성자가 아닙니다. |
- 개발자 생성자 없음
- 컴파일러가 매개변수 없는 기본 생성자를 제공합니다.
new Member()에 대응하는 서명이 있습니다. - 한 인수 생성자만 직접 선언
Member(String email)이 있으면 자동 기본 생성자는 없습니다.NoDefaultConstructor의new Member()는 컴파일 오류입니다.- 인수 없는 생성자도 직접 선언
Member()는 개발자가 작성한 본문을 실행합니다. 컴파일러가 자동 제공한 기본 생성자가 아닙니다.
서명 제공 조건과 업무상 유효성은 별개입니다. 인수 없는 생성을 열기 위해 필수 이메일을 무효한 값으로 숨길 필요는 없습니다.
그림은 new Member()의 결과를 세 경우로 나눕니다. 생성자를 하나도 선언하지 않으면 기본 생성자가 생기고, Member(String email)만 선언하면 컴파일 오류가 나며, Member()를 직접 선언하면 개발자가 쓴 그 본문이 실행됩니다.
더 깊이: 기본 생성자의 모습
기본 생성자는 매개변수가 없고, 본문에서 부모 클래스의 인수 없는 생성자 super()를 호출하는 일만 합니다. 기본 생성자의 접근 제어자는 클래스를 따라갑니다. public 클래스라면 public, private 중첩 클래스라면 private이고, 접근 제어자가 없는 클래스라면 기본 생성자에도 없습니다. super()는 상속과 메서드 오버라이딩에서, 접근 제어자는 이 장의 접근 제어 글에서 다룹니다.
초깃값 없는 final 필드가 있는 클래스는 기본 생성자에 기댈 수 없습니다. 기본 생성자에는 그 필드에 값을 넣는 문장이 없는데, final 필드는 생성이 끝나기 전에 반드시 한 번 대입되어야 하기 때문입니다. 다음처럼 생성자를 생략하면 컴파일러가 이 문제를 바로 알려 줍니다.
private static final class Member {
private final String email;
}error: variable email not initialized in the default constructor생성자 오버로딩: 인수에 맞는 생성자 고르기
메서드처럼 생성자도 매개변수 목록만 다르면 여러 개를 선언할 수 있습니다. 이것을 생성자 오버로딩(constructor overloading)이라고 합니다. 다음 Member는 활성 여부를 생략하는 생성자와 직접 지정하는 생성자를 함께 둡니다.
public final class OverloadedConstructors {
public static void main(String[] args) {
Member basic = new Member("[email protected]", 40);
Member detailed = new Member("[email protected]", 50, true);
System.out.println(basic.describe());
System.out.println(detailed.describe());
}
private static final class Member {
private String email;
private int age;
private boolean active;
Member(String email, int age) {
this.email = email;
this.age = age;
this.active = false;
}
Member(String email, int age, boolean active) {
this.email = email;
this.age = age;
this.active = active;
}
String describe() {
return email + "=" + age + ":" + active;
}
}
}[email protected]=40:false
[email protected]=50:true인수가 두 개인 new Member("[email protected]", 40)은 (String, int) 생성자를, 인수가 세 개인 호출은 (String, int, boolean) 생성자를 실행합니다. 첫 회원은 두 인수 생성자가 active에 false를 넣었으므로 [email protected]=40:false가 출력됩니다. 둘째 회원은 호출할 때 준 true가 그대로 저장되어 [email protected]=50:true가 출력됩니다.
컴파일러가 생성자를 고르는 방식은 메서드 오버로딩과 자동 형변환에서 본 메서드 오버로딩과 같습니다. 생성자는 이름이 모두 클래스 이름이고 반환 타입도 없으므로, 생성자끼리 구별할 수 있는 것은 매개변수의 개수, 타입, 순서뿐입니다. 그래서 매개변수 이름만 다른 생성자는 함께 둘 수 없습니다. 예를 들어 Member(String email)과 Member(String displayName)을 같이 선언하면 컴파일러가 둘을 같은 생성자로 보고 거부합니다.
error: constructor Member(String) is already defined in class Member같은 코드를 생성자마다 쓰면 생기는 문제
OverloadedConstructors는 잘 동작하지만, email과 age를 대입하는 두 줄이 두 생성자에 똑같이 들어 있습니다. 지금은 두 줄이라 대수롭지 않아 보여도 앞 글처럼 이메일과 나이 검사를 추가하는 순간 문제가 됩니다. 예를 들어 세 인수 생성자에만 나이 검사를 넣고 두 인수 생성자를 깜빡하면, new Member("[email protected]", 7)은 두 인수 생성자를 거쳐 아무 문제 없이 만들어집니다.
생성 경로는 여러 개인데 규칙이 경로마다 따로 적혀 있으면, 어느 생성자로 만들었느냐에 따라 객체가 지키는 규칙이 달라집니다. 생성자가 늘어날수록 고쳐야 할 곳도 늘어나고, 하나라도 빠뜨리면 잘못된 객체가 만들어질 수 있습니다.
this(...)로 다른 생성자에 초기화 맡기기
해결책은 실제 초기화를 생성자 하나에만 두고, 나머지 생성자는 그 생성자를 호출하게 하는 것입니다. 생성자 안에서 this(...)라고 쓰면 같은 클래스의 다른 생성자를 호출할 수 있습니다. 여러 생성자 중 어느 것이 호출될지는 보통의 오버로딩처럼 괄호 안 인수의 개수와 타입으로 정해집니다.
public final class DelegatingConstructors {
public static void main(String[] args) {
Member basic = new Member("[email protected]", 40);
Member detailed = new Member("[email protected]", 50, true);
System.out.println(basic.describe());
System.out.println(detailed.describe());
}
private static final class Member {
private final String email;
private final int age;
private final boolean active;
Member(String email, int age) {
this(email, age, false);
}
Member(String email, int age, boolean active) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("email");
}
if (age < 14 || age > 120) {
throw new IllegalArgumentException("age");
}
this.email = email;
this.age = age;
this.active = active;
}
String describe() {
return email + "=" + age + ":" + active;
}
}
}[email protected]=40:false
[email protected]=50:true출력은 앞 예제와 같지만 두 인수 생성자의 본문은 this(email, age, false); 한 줄로 줄었습니다. 이 생성자는 '활성 여부를 생략하면 false로 한다'는 기본값만 정하고, 검사와 필드 대입은 세 인수 생성자에게 맡깁니다. 이렇게 다른 생성자에게 일을 넘기는 것을 위임(delegation)이라고 하고, 이 글에서는 위임이 모이는 세 인수 생성자를 중심 생성자라고 부르겠습니다.
이제 Member를 만드는 길은 두 개지만 검사 코드는 중심 생성자에 한 번만 있습니다. 그래서 어느 길로 만들어도 이메일은 비어 있지 않고 나이는 14 이상 120 이하라는 조건을 통과해야 합니다. 이처럼 객체가 존재하는 동안 늘 참이어야 하는 조건을 불변식(invariant)이라고 하는데, 위임을 쓰면 Member의 불변식을 한곳에서 지킬 수 있습니다. 나중에 규칙이 바뀌어도 중심 생성자 하나만 고치면 됩니다.
출력으로 호출 순서 확인하기
this(...)는 객체를 하나 더 만들지 않습니다. new가 만든 객체 하나에 대해 생성자 본문 두 개가 차례로 실행될 뿐입니다. 그 순서를 눈으로 확인하려고 생성자마다 출력문을 넣고, 나이가 범위를 벗어나는 경우도 함께 실행해 보겠습니다.
public final class ConstructorCallOrder {
public static void main(String[] args) {
Member member = new Member("[email protected]", 40);
System.out.println("created: " + member.describe());
try {
new Member("[email protected]", 7);
} catch (IllegalArgumentException exception) {
System.out.println("rejected: " + exception.getMessage());
}
}
private static final class Member {
private final String email;
private final int age;
private final boolean active;
Member(String email, int age) {
this(email, age, false);
System.out.println("2-arg done");
}
Member(String email, int age, boolean active) {
System.out.println("3-arg start");
if (age < 14 || age > 120) {
throw new IllegalArgumentException("age");
}
this.email = email;
this.age = age;
this.active = active;
System.out.println("3-arg done");
}
String describe() {
return email + "=" + age + ":" + active;
}
}
}3-arg start
3-arg done
2-arg done
created: [email protected]=40:false
3-arg start
rejected: age첫 new는 인수가 두 개이므로 두 인수 생성자가 호출되지만, 그 첫 문장이 this(...)라서 곧바로 세 인수 생성자로 들어갑니다. 그래서 3-arg start와 3-arg done이 먼저 출력되고, 세 인수 생성자가 끝난 뒤에야 두 인수 생성자의 나머지 본문이 실행되어 2-arg done이 나옵니다. 메서드를 호출했다가 호출한 자리로 돌아오는 것과 같은 순서입니다. 모든 생성자 본문이 끝나면 new가 완성된 객체의 참조를 돌려주고, created: 줄이 출력됩니다.
두 번째 new에서는 나이 7이 범위를 벗어나 세 인수 생성자가 예외를 던집니다. 예외는 두 인수 생성자의 나머지 본문을 건너뛰고 new 식 밖으로 나가므로 2-arg done은 출력되지 않습니다. new가 참조를 돌려주지 않으니 잘못된 회원이 변수에 담길 일도 없습니다. 검사를 중심 생성자로 옮겨도, 앞 글에서 본 '검사에 실패하면 객체를 내주지 않는다'는 성질은 그대로 유지되는 것입니다.
아래 그림은 DelegatingConstructors의 basic과 detailed가 같은 세 인수 생성자로 모이는 과정을 단계별로 보여 줍니다. 두 회원은 각자의 new로 만든 서로 다른 객체이고, 둘이 공유하는 것은 검사와 대입 코드뿐입니다.
두 Member 생성 경로가 한 검증 구현으로 모이는 관계
두 인수 생성자는 같은 객체의 세 인수 생성자로 위임해 false 정책을 넣고 검증·필드 대입 뒤 복귀합니다. this(...)가 새 객체 생성이 아니라는 호출/반환 순서를 설명할 수 있습니다.
DelegatingConstructors에서 두 인수 경로는 active=false를 보충해 같은 객체의 세 인수 생성자에 위임한다. 상세 경로는 직접 그 생성자를 호출하고 email과 age만 검증한다.
두 생성 경로는 같은 검증·대입 구현을 공유한다
this(...)는 같은 객체의 다른 생성자에 위임하고, 기본값 false를 보충합니다.
DelegatingConstructors의 생성 경로입니다. this(...)는 같은 객체의 다른 생성자에 위임하며 새 객체를 하나 더 만들지 않습니다.
- 두 인수 경로 · basic
"[email protected]", 40을 받는 생성자가this(email, age, false)로 같은 객체의 세 인수 생성자에 위임합니다.- 세 인수 경로 · detailed
"[email protected]", 50, true로 세 인수 생성자를 직접 호출합니다.- 두 경로가 공유하는 검증·대입 구현
email은null·공백이 아니고age는14~120이어야 합니다. 통과하면this.email,this.age,this.active에 대입합니다.실패하면
IllegalArgumentException이 발생하고 정상 참조를 반환하지 않습니다. 각 호출은 별도 객체를 생성하며 구현만 공유합니다.
결과는 basic: [email protected]=40:false, detailed: [email protected]=50:true입니다. active는 전달한 불리언 값을 저장하며 별도 검증 조건은 없습니다.
앞의 OverloadedConstructors는 두 생성자에 필드 대입이 복제되어 있고 검증 코드는 없습니다. 여기서는 대입과 추가한 검증을 세 인수 생성자 한곳에 모았습니다.
이 예의 조건
- basic과 detailed는 별도 객체 생성이고 검사 구현만 공유합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 두 Member 생성 경로가 한 검증 구현으로 모이는 관계 두 Member 생성 경로가 한 검증 구현으로 모이는 관계 DelegatingConstructors에서 두 인수 경로는 active=false를 보충해 같은 객체의 세 인수 생성자에 위임한다. 상세 경로는 직접 그 생성자를 호출하고 email과 age만 검증한다. 두 생성 경로는 같은 검증·대입 구현을 공유한다 this(...)는 같은 객체의 다른 생성자에 위임하고, 기본값 false를 보충합니다. DelegatingConstructors 의 생성 경로입니다. this(...) 는 같은 객체의 다른 생성자에 위임하며 새 객체를 하나 더 만들지 않습니다. 두 인수 경로 · basic "[email protected]", 40 을 받는 생성자가 this(email, age, false) 로 같은 객체의 세 인수 생성자에 위임합니다. 세 인수 경로 · detailed "[email protected]", 50, true 로 세 인수 생성자를 직접 호출합니다. 두 경로가 공유하는 검증·대입 구현 email 은 null·공백이 아니고 age 는 14~120 이어야 합니다. 통과하면 this.email , this.age , this.active 에 대입합니다. 실패하면 IllegalArgumentException 이 발생하고 정상 참조를 반환하지 않습니다. 각 호출은 별도 객체를 생성하며 구현만 공유합니다. 결과는 basic: [email protected]=40:false , detailed: [email protected]=50:true 입니다. active 는 전달한 불리언 값을 저장하며 별도 검증 조건은 없습니다. 앞의 OverloadedConstructors 는 두 생성자에 필드 대입이 복제되어 있고 검증 코드는 없습니다. 여기서는 대입과 추가한 검증을 세 인수 생성자 한곳에 모았습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 경로 basic은 두 인수, detailed는 세 인수를 전달합니다. |
|
|
| 기본값 보충 두 인수 생성자가 this(email,age,false)로 위임합니다. |
|
|
| 검증과 기록 email과 14–120 검사 뒤 세 필드를 대입합니다. |
|
|
두 Member 생성 경로가 한 검증 구현으로 모이는 관계
두 인수 생성자는 같은 객체의 세 인수 생성자로 위임해 false 정책을 넣고 검증·필드 대입 뒤 복귀합니다. this(...)가 새 객체 생성이 아니라는 호출/반환 순서를 설명할 수 있습니다.
DelegatingConstructors에서 두 인수 경로는 active=false를 보충해 같은 객체의 세 인수 생성자에 위임한다. 상세 경로는 직접 그 생성자를 호출하고 email과 age만 검증한다.
두 생성 경로는 같은 검증·대입 구현을 공유한다
this(...)는 같은 객체의 다른 생성자에 위임하고, 기본값 false를 보충합니다.
DelegatingConstructors의 생성 경로입니다. this(...)는 같은 객체의 다른 생성자에 위임하며 새 객체를 하나 더 만들지 않습니다.
- 두 인수 경로 · basic
"[email protected]", 40을 받는 생성자가this(email, age, false)로 같은 객체의 세 인수 생성자에 위임합니다.- 세 인수 경로 · detailed
"[email protected]", 50, true로 세 인수 생성자를 직접 호출합니다.- 두 경로가 공유하는 검증·대입 구현
email은null·공백이 아니고age는14~120이어야 합니다. 통과하면this.email,this.age,this.active에 대입합니다.실패하면
IllegalArgumentException이 발생하고 정상 참조를 반환하지 않습니다. 각 호출은 별도 객체를 생성하며 구현만 공유합니다.
결과는 basic: [email protected]=40:false, detailed: [email protected]=50:true입니다. active는 전달한 불리언 값을 저장하며 별도 검증 조건은 없습니다.
앞의 OverloadedConstructors는 두 생성자에 필드 대입이 복제되어 있고 검증 코드는 없습니다. 여기서는 대입과 추가한 검증을 세 인수 생성자 한곳에 모았습니다.
이 예의 조건
- basic과 detailed는 별도 객체 생성이고 검사 구현만 공유합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 두 Member 생성 경로가 한 검증 구현으로 모이는 관계 두 Member 생성 경로가 한 검증 구현으로 모이는 관계 DelegatingConstructors에서 두 인수 경로는 active=false를 보충해 같은 객체의 세 인수 생성자에 위임한다. 상세 경로는 직접 그 생성자를 호출하고 email과 age만 검증한다. 두 생성 경로는 같은 검증·대입 구현을 공유한다 this(...)는 같은 객체의 다른 생성자에 위임하고, 기본값 false를 보충합니다. DelegatingConstructors 의 생성 경로입니다. this(...) 는 같은 객체의 다른 생성자에 위임하며 새 객체를 하나 더 만들지 않습니다. 두 인수 경로 · basic "[email protected]", 40 을 받는 생성자가 this(email, age, false) 로 같은 객체의 세 인수 생성자에 위임합니다. 세 인수 경로 · detailed "[email protected]", 50, true 로 세 인수 생성자를 직접 호출합니다. 두 경로가 공유하는 검증·대입 구현 email 은 null·공백이 아니고 age 는 14~120 이어야 합니다. 통과하면 this.email , this.age , this.active 에 대입합니다. 실패하면 IllegalArgumentException 이 발생하고 정상 참조를 반환하지 않습니다. 각 호출은 별도 객체를 생성하며 구현만 공유합니다. 결과는 basic: [email protected]=40:false , detailed: [email protected]=50:true 입니다. active 는 전달한 불리언 값을 저장하며 별도 검증 조건은 없습니다. 앞의 OverloadedConstructors 는 두 생성자에 필드 대입이 복제되어 있고 검증 코드는 없습니다. 여기서는 대입과 추가한 검증을 세 인수 생성자 한곳에 모았습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 경로 basic은 두 인수, detailed는 세 인수를 전달합니다. |
|
|
| 기본값 보충 두 인수 생성자가 this(email,age,false)로 위임합니다. |
|
|
| 검증과 기록 email과 14–120 검사 뒤 세 필드를 대입합니다. |
|
|
this.와 this(...)는 다른 문법
this.email과 this(...)는 모양이 비슷하지만 하는 일이 다릅니다. this.email = email;의 this는 앞 글에서 본 대로 지금 다루고 있는 객체를 가리키고, 점 뒤의 필드나 메서드에 접근합니다. 반면 this(email, age, false);처럼 this 바로 뒤에 괄호가 오면 같은 클래스의 다른 생성자를 호출하라는 뜻입니다. 앞의 것은 생성자와 인스턴스 메서드 어디서나 쓸 수 있지만, 뒤의 것은 생성자 안에서만 쓸 수 있습니다.
this(...)를 쓸 때 지켜야 할 규칙
this(...)에는 보통의 메서드 호출에 없는 제약이 몇 가지 있습니다. 대부분 'this(...)가 실행될 때는 객체가 아직 완성되지 않았다'는 사실에서 나옵니다.
첫째, this(...)는 생성자 본문에서만, 그리고 한 생성자에 한 번만 쓸 수 있습니다. 일반 메서드 안에 쓰면 컴파일 오류가 나므로, 이미 만든 객체를 생성자로 다시 초기화하는 방법은 없습니다. 부모 클래스의 생성자를 부르는 super(...)도 같은 자리를 쓰기 때문에 한 생성자에 this(...)와 super(...)를 함께 쓸 수도 없습니다. 또 오랫동안 this(...)는 반드시 생성자의 첫 문장이어야 했습니다. 이 교재가 기준으로 삼는 Java 25에서는 객체를 쓰지 않는 문장이라면 그 앞에 둘 수 있는데, 이 내용은 아래 '더 깊이'에서 다룹니다.
둘째, 위임이 돌고 돌아 출발한 생성자로 되돌아오면 안 됩니다. 다음 두 생성자는 서로를 호출하므로 어느 쪽에서 시작해도 끝나지 않습니다.
Member(String email) {
this(email, 30);
}
Member(String email, int age) {
this(email);
}컴파일러는 이런 순환을 미리 찾아내 recursive constructor invocation 오류를 냅니다. 편의 생성자는 정보를 덜 받는 쪽에서 더 많이 받는 쪽으로, 한 방향으로만 위임하도록 설계합니다. 그러면 위임을 따라가다 보면 언제나 중심 생성자에 도착합니다.
셋째, this(...)의 인수에는 지금 만들고 있는 객체의 필드나 인스턴스 메서드를 쓸 수 없습니다. 위임받은 생성자가 아직 실행되지 않았으므로 필드는 제대로 초기화되기 전이고, 그 값을 읽어 봐야 의미가 없기 때문입니다. 예를 들어 this("default", capacity)에서 capacity가 인스턴스 필드라면, 컴파일러는 상위 타입 생성자가 호출되기 전에는 capacity를 참조할 수 없다는 오류를 냅니다. 매개변수, 리터럴, static 상수는 만들고 있는 객체와 상관이 없으므로 인수로 쓸 수 있습니다.
넷째, final 필드는 어느 경로로 만들든 정확히 한 번 대입되어야 합니다. 예를 들어 위임한 뒤 두 인수 생성자에서 this.age = age;를 또 쓰면, 중심 생성자가 이미 대입했으므로 variable age might already have been assigned 오류가 납니다. 컴파일러가 모든 생성 경로에서 이 규칙을 확인해 주므로, final 필드와 위임을 함께 쓰면 대입을 빠뜨리거나 두 번 하는 실수를 컴파일 단계에서 막을 수 있습니다.
더 깊이: Java 25의 유연한 생성자 본문
Java 21 같은 이전 버전에서는 this(...)가 첫 문장이어야 했기 때문에, 인수를 가공하거나 검사한 다음에 위임할 수 없었습니다. 예를 들어 "[email protected],40" 같은 한 줄 문자열을 받아 쉼표로 나눈 뒤 두 인수 생성자에 넘기는 생성자는 다음처럼 쓸 수 없었습니다. JDK 21에서 컴파일하면 call to this must be first statement in constructor 오류가 납니다.
Member(String line) {
String[] parts = line.split(",");
this(parts[0], Integer.parseInt(parts[1]));
}Java 22부터 프리뷰로 다듬어진 유연한 생성자 본문이 Java 25에서 정식 기능(JEP 513)이 되어, 이제 이 코드가 컴파일됩니다. this(...)나 super(...) 앞의 문장에서 인수를 검사해 예외를 던지거나, 지역 변수로 넘길 값을 계산할 수 있습니다. 하지만 이 구간에서도 객체는 아직 완성되지 않았으므로 this를 쓰거나, 현재 객체의 필드를 읽거나, 인스턴스 메서드를 호출하는 것은 여전히 금지됩니다. 또 this(...)는 if나 반복문 안이 아니라 생성자 본문의 맨 바깥에 있어야 하고, 그 앞의 문장에서 return할 수도 없습니다.
기본값으로 삼아도 되는 값
편의 생성자는 호출자가 생략한 값을 기본값으로 채웁니다. 그렇다면 어떤 값이 기본값이 될 수 있을까요? Member의 active를 false로 채운 것은 그것이 '가입했지만 아직 활성화되지 않았다'는, 실제로 있을 수 있는 상태이기 때문입니다. 새 회원이 이 상태로 시작한다는 것은 업무 규칙으로도 자연스럽습니다.
반면 이메일 ""이나 나이 0은 그런 상태가 아닙니다. 이 값으로 채우면 생성은 성공하지만 유효하지 않은 회원이 생기고, 그 사실은 나중에 메일을 보내거나 나이를 계산할 때에야 드러납니다. 필수 정보를 생성자 매개변수로 요구하면 빠뜨린 호출이 곧바로 컴파일 오류가 되는데, 호출을 짧게 하려고 기본값으로 숨기면 같은 실수를 실행 시점에야 알게 됩니다. 생성자 목록은 '이 객체를 만들려면 무엇이 꼭 필요한가'를 알려 주는 문서 역할도 하므로, 필수 값은 숨기지 않는 편이 좋습니다.
그렇다고 인수 없는 생성자가 늘 나쁜 것은 아닙니다. 다음과 같은 경우에는 쓸모가 있습니다.
- 모든 필드를 기본값으로 채워도 유효한 상태일 때. 0에서 시작하는 카운터가 그런 예입니다.
- 빈 장바구니나 빈 저장소처럼 내용이 없어도 완전한 객체일 때.
- 프레임워크가 인수 없는 생성자로 객체를 먼저 만든 뒤 값을 채우는 방식을 요구할 때. 이때는 값을 채운 다음 검사하는 절차가 따로 있어야 합니다.
MemberRegistry를 만드는 세 가지 방법
같은 방법을 회원 저장소에도 적용해 보겠습니다. 저장소는 이름과 용량을 모두 받아 만들 수도 있고, 용량만 정할 수도 있고, 둘 다 생략할 수도 있게 합니다. 생략한 이름은 "default", 생략한 용량은 10으로 채웁니다.
public final class MemberRegistryConstructorPolicy {
public static void main(String[] args) {
MemberRegistry defaultRegistry = new MemberRegistry();
MemberRegistry namedRegistry = new MemberRegistry("work", 2);
defaultRegistry.add("[email protected]", 40);
namedRegistry.add("[email protected]", 50);
System.out.println(defaultRegistry.summary());
System.out.println(namedRegistry.summary());
}
private static final class MemberRegistry {
private static final int DEFAULT_CAPACITY = 10;
private final String name;
private final Member[] members;
private int size;
MemberRegistry() {
this("default", DEFAULT_CAPACITY);
}
MemberRegistry(int capacity) {
this("default", capacity);
}
MemberRegistry(String name, int capacity) {
if (name == null || name.isBlank() || capacity <= 0) {
throw new IllegalArgumentException("invalid registry configuration");
}
this.name = name;
this.members = new Member[capacity];
}
void add(String email, int age) {
if (size == members.length) {
throw new IllegalStateException("full");
}
members[size++] = new Member(email, age);
}
String summary() {
return name + ":count=" + size + ", capacity=" + members.length;
}
}
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;
}
}
}default:count=1, capacity=10
work:count=1, capacity=2인수 없는 생성자와 용량만 받는 생성자는 빠진 값을 채워 MemberRegistry(String, int)에 위임합니다. 이름 검사, 용량 검사, 배열 생성은 이 중심 생성자에만 있으므로 세 가지 생성 방법이 모두 같은 규칙을 거칩니다. main은 이 가운데 인수 없는 생성자와 두 인수 생성자를 씁니다. 첫 저장소는 기본값으로 만들어져 회원 한 명을 넣은 뒤 default:count=1, capacity=10을 출력하고, 둘째 저장소는 지정한 값대로 work:count=1, capacity=2를 출력합니다. 용량만 받는 생성자는 선언만 되어 있고 이 main에서는 호출하지 않습니다.
기본 용량 10은 DEFAULT_CAPACITY라는 static final 상수로 이름을 붙여 두었습니다. 앞에서 본 대로 this(...)의 인수에는 인스턴스 필드를 쓸 수 없지만, static 상수는 객체마다 따로 있는 값이 아니라 클래스에 하나만 있는 값이므로 쓸 수 있습니다. 기본 용량을 바꾸고 싶을 때 이 한 줄만 고치면 된다는 장점도 있습니다. static 변수는 6장의 static 변수: 모든 객체가 함께 쓰는 값에서, 상수는 final 변수·필드·상수에서 자세히 다룹니다.
인수 없는 MemberRegistry()를 둘 수 있었던 것은 "default"와 10이 실제로 쓸 수 있는 이름과 용량이기 때문입니다. 이메일을 "untitled"로 채우는 Member()를 만들지 말자고 한 것과 같은 기준입니다. 한 가지 주의할 점은, 이 파일의 Member 생성자는 예제를 짧게 하려고 이메일과 나이를 검사 없이 대입만 한다는 것입니다. 이 예제가 검사하는 것은 저장소의 이름과 용량뿐이고, 회원 정보는 검사하지 않습니다.
MemberRegistryConstructorPolicy는 default 이름과 용량 10을 기본값으로 쓰고 세 생성 경로가 이름과 양수 용량 검사, 배열 생성 구현을 공유한다. main은 기본 경로와 work 용량 2 경로만 호출한다.
유효한 기본값을 보충하고 한 생성자에서 검증한다
이름과 양수 용량의 규칙은 세 생성 경로가 함께 사용합니다.
MemberRegistryConstructorPolicy의 세 서명입니다. DEFAULT_CAPACITY = 10이며 생성 직후에는 모두 size = 0입니다.
| 생성 경로 | 중심 생성자에 전달되는 이름과 용량 | 본문 main 관찰 |
|---|---|---|
| 인수 없음 | MemberRegistry()
이름 | 회원 1명 추가 후
|
| 용량만 | MemberRegistry(int capacity)
이름만 기본값, 용량은 호출자가 지정 | 선언되어 있지만 이 main에서는 호출하지 않습니다. |
| 이름과 용량 | MemberRegistry(String name, int capacity)호출자가 두 값을 모두 지정합니다. | "work", 2로 생성하고 회원 1명 추가 후
|
- 인수 없음
MemberRegistry()는this("default", DEFAULT_CAPACITY)로 위임해 이름"default", 용량10을 사용합니다.main에서 회원 1명 추가 후default:count=1, capacity=10을 출력합니다.- 용량만
MemberRegistry(int capacity)는this("default", capacity)로 위임합니다. 이름만 기본값이고 용량은 호출자가 지정합니다. 이main에서는 호출하지 않습니다.- 이름과 용량
MemberRegistry(String name, int capacity)는 두 값을 직접 받는 중심 생성자입니다.main에서는"work", 2로 생성하고 회원 1명 추가 후work:count=1, capacity=2를 출력합니다.
중심 생성자는 이름이 null·공백이거나 용량이 0 이하이면 IllegalArgumentException을 던집니다. 통과한 경우에만 이름을 대입하고 new Member[capacity]를 생성합니다.
이는 저장소 설정의 검증입니다. 이 파일의 Member 생성자는 이메일·나이 검증 없이 값을 대입하므로, 앞의 검증된 Member 예제와 혼합하지 않습니다.
정리
- 컴파일러는 생성자를 하나도 선언하지 않은 클래스에만 매개변수 없는 기본 생성자를 만들어 줍니다. 생성자를 하나라도 선언했다면
new Member()를 쓰려면 그 생성자를 직접 선언해야 합니다. - 생성자 오버로딩은 매개변수의 개수, 타입, 순서로 생성자를 구별합니다. 같은 초기화 코드를 생성자마다 반복하면 검사를 한쪽에만 고치는 실수가 생기기 쉽습니다.
this(...)는 같은 객체를 만드는 도중에 다른 생성자를 호출합니다. 편의 생성자는 기본값만 채워 중심 생성자에 위임하고, 검사와 대입은 중심 생성자 한곳에 둡니다.this(...)는 생성자 안에서 한 번만 쓸 수 있고, 위임이 순환하면 안 되며, 인수에 현재 객체의 필드를 쓸 수 없습니다. Java 25부터는 객체를 쓰지 않는 문장을 그 앞에 둘 수 있습니다.- 기본값은 실제로 유효한 상태일 때만 씁니다. 필수 값을 의미 없는 기본값으로 숨기면 오류를 실행 시점으로 미루게 됩니다.
다음 글에서는 지금까지 한 파일에 모아 두었던 클래스를 패키지로 나누고, 다른 패키지의 클래스를 import로 가져다 쓰는 방법을 배웁니다.
연습 문제
문제 1: 공개 여부를 기본값으로 채워 위임하기
이메일과 표시 이름은 꼭 받고, 프로필 공개 여부는 생략할 수 있는 MemberProfile을 만드세요. 두 인수 생성자는 '비공개'를 기본값으로 정해 세 인수 생성자에 위임합니다. 이메일과 표시 이름은 null이거나 공백일 수 없습니다.
해설 보기
public final class MemberProfileConstructorExercise {
public static void main(String[] args) {
MemberProfile privateProfile = new MemberProfile("[email protected]", "김회원");
MemberProfile publicProfile = new MemberProfile("[email protected]", "이회원", true);
System.out.println(privateProfile.describe());
System.out.println(publicProfile.describe());
}
private static final class MemberProfile {
private final String email;
private final String displayName;
private final boolean publicProfile;
MemberProfile(String email, String displayName) {
this(email, displayName, false);
}
MemberProfile(String email, String displayName, boolean publicProfile) {
if (email == null || email.isBlank() || displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("profile");
}
this.email = email;
this.displayName = displayName;
this.publicProfile = publicProfile;
}
String describe() {
return email + "/" + displayName + "/" + publicProfile;
}
}
}[email protected]/김회원/false
[email protected]/이회원/true두 인수 생성자는 false라는 기본값만 정해 위임하고, 검사 코드는 복사하지 않았습니다. 검사는 세 인수 생성자에만 있으므로 new MemberProfile("[email protected]", " ")처럼 두 인수 생성자로 만들든, 세 인수 생성자를 직접 부르든 공백 이름은 같은 자리에서 거절됩니다. 첫 줄의 false는 두 인수 생성자가 채운 기본값이고, 둘째 줄의 true는 호출할 때 직접 넘긴 값입니다.
문제 2: 위임이 이어질 때의 출력 순서
다음 프로그램에서 new Ticket()은 생성자 세 개를 거칩니다. 실행하기 전에 출력을 순서대로 예상해 보세요.
public final class ChainOrder {
public static void main(String[] args) {
new Ticket();
}
private static final class Ticket {
Ticket() {
this("general");
System.out.println("A");
}
Ticket(String grade) {
this(grade, 1);
System.out.println("B");
}
Ticket(String grade, int count) {
System.out.println("C " + grade + " " + count);
}
}
}해설 보기
C general 1
B
Anew Ticket()은 인수 없는 생성자로 들어가지만, 첫 문장 this("general") 때문에 Ticket(String)으로 넘어갑니다. 거기서도 첫 문장 this(grade, 1) 때문에 다시 Ticket(String, int)로 넘어가고, 이 생성자가 가장 먼저 본문을 실행해 C general 1을 출력합니다. 그다음 호출한 순서의 역순으로 돌아오면서 Ticket(String)의 B, 인수 없는 생성자의 A가 출력됩니다. 위임이 몇 단계든 객체는 new 한 번에 하나만 만들어집니다. 그리고 위임을 따라가면 정보를 가장 많이 받는 생성자가 가장 먼저 본문을 끝냅니다.