안동민 개발노트

본문 시작

생성자와 this

초기화가 누락된 객체의 실패를 재현하고 생성자와 this로 유효한 Member의 시작 상태를 보장합니다.

객체가 생성된 뒤 필드를 하나씩 대입하면 “만들어졌지만 아직 쓸 수 없는” 시간이 생깁니다.

다른 메서드가 그 중간 상태를 먼저 사용하거나 한 필드 대입을 빼먹을 수 있습니다.

생성자는 객체 생성과 필수 초기화를 하나의 연산으로 묶어 처음부터 유효한 상태를 만들게 합니다.


초기화 누락

lab/MissingInitialization.java
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

new Member()는 필드를 기본값으로 초기화합니다.

참조형 email은 null, int age는 0입니다.

호출자가 age만 대입했으므로 email의 메서드를 호출할 때 실패했습니다.

초기화 누락 예제에서 필드 값과 실패 시점

MissingInitialization에서 email은 null로 남고 age만 40으로 바뀐다. null 값 읽기는 가능하지만 그 값으로 toUpperCase를 호출하면 예외가 발생한다.

MissingInitialization의 같은 객체를 실행 순서대로 추적합니다.

실행 문장별 email과 age 상태 및 결과
실행 시점객체 필드관찰
생성 직후email = null

age = 0

new Member()가 정상 참조를 반환합니다. 필드는 언어 기본값입니다.
나이 대입 후email = null

age = 40

member.age = 40은 이메일을 초기화하지 않습니다.
이메일 사용email = null

age = 40

member.email 읽기는 성공합니다. 이어지는 toUpperCase() 호출에서 NullPointerException이 발생합니다.
  1. 생성 직후

    new Member()는 정상 참조를 반환합니다. 필드는 email = null, age = 0입니다.

  2. 나이 대입 후

    member.age = 40 이후에도 email = null이며, age = 40만 달라집니다.

  3. 이메일 사용

    member.email 읽기는 성공하지만, 그 값으로 toUpperCase()를 호출하면 NullPointerException이 발생합니다. 필드 값은 그대로입니다.

이 예제의 기본 생성자는 이메일 필수 조건을 검사하지 않습니다. 정상 생성과 업무상 유효한 초기 상태는 같은 뜻이 아닙니다.

클래스를 보는 것만으로는 어떤 필드를 반드시 채워야 하는지 강제되지 않습니다.

생성자 없이 다음 절차를 반복하면 순서와 누락을 모든 호출자가 책임집니다.

Member member = new Member();
member.email = email;
member.age = age;

필수 값이 늘어날수록 기존 생성 위치를 모두 찾아 수정해야 합니다.

생성자 매개변수로 필수 값을 요구하면 컴파일러가 누락된 호출을 찾습니다.


생성자 선언

src/ConstructorMember.java
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)는 메서드처럼 보이지만 반환 타입이 없고 클래스 이름과 같습니다.

new Member("[email protected]", 40)는 메모리 할당, 필드 기본값 설정, 생성자 본문 실행을 거쳐 참조를 반환합니다.

개발자가 생성자를 직접 일반 메서드처럼 호출할 수는 없습니다.

생성자는 객체 생성 시 한 번 실행됩니다.

이후 같은 검증을 거쳐 값을 바꾸려면 별도의 행동 메서드가 필요합니다.

생성자를 다시 호출해 기존 객체를 재초기화하는 방식은 없습니다.


this 필드 참조

lab/MissingThisAssignment.java
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

이름을 찾을 때 가장 가까운 지역 범위의 매개변수가 우선합니다.

email = email은 매개변수 값을 같은 매개변수에 다시 대입하고, 필드는 건드리지 않습니다.

this.email은 현재 생성 중인 객체의 필드를 명시합니다.

Member(String email, int age) {
    this.email = email;
    this.age = age;
}

왼쪽은 객체 필드, 오른쪽은 생성자 매개변수입니다.

매개변수 이름을 inputEmail으로 바꾸면 this 없이도 구분되지만, 필드와 매개변수가 같은 개념이라면 같은 이름과 this가 대응을 분명하게 보여 줍니다.

this는 “현재 인스턴스의 참조”입니다.

인스턴스 메서드에서 this.describe()처럼 메서드를 호출하거나 다른 객체에 현재 인스턴스를 전달할 수도 있습니다.

다만 명확하지 않은 곳마다 this를 붙이는 것이 목표가 아니라 지역 변수와 필드의 경계를 드러내는 데 사용합니다.

매개변수 자기 대입과 this 필드 대입의 결과

MissingThisAssignment의 [email protected]과 50을 기준으로 매개변수만 다시 대입하는 코드와 본문의 this 교정 코드를 비교한다.

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는 현재 인스턴스의 참조입니다. 필드 대입을 구분할 뿐, 입력값 검증을 자동으로 추가하지는 않습니다.


생성자 불변식

src/ValidatedConstructor.java
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 표현식이 정상 참조를 반환하지 않습니다.

생성된 모든 Member가 이메일과 나이 범위를 만족한다는 가정을 다른 메서드가 사용할 수 있습니다.

CLI에서 사용자 입력 오류를 예외로 끝내면 안 되므로 입력 경계에서 예외를 잡아 rejected로 바꿉니다.

생성자는 클래스 규칙을 지키고, CLI는 사용자와 대화를 계속하는 책임을 맡습니다.

검증 생성자의 첫 실패와 정상 참조 반환 경계

ValidatedConstructor의 Member는 이메일 검사 뒤 나이 검사를 수행하고 둘 다 통과해야 필드에 대입한다. 예외가 나면 new는 정상 참조를 반환하지 않으며 printCreation이 rejected 메시지로 바꾼다.

ValidatedConstructor의 Member 생성자입니다. 앞의 단순 대입 예제와 달리 검증 코드가 있습니다.

이메일, 나이 순서로 검사하고 필드를 대입하는 생성자 이메일이 null 또는 공백이면 email 예외로 끝나고 나이는 검사하지 않는다. 이메일이 통과한 뒤 나이가 14 미만 또는 120 초과이면 age 예외로 끝난다. 둘 다 통과해야 필드 대입 뒤 new가 정상 참조를 반환한다. 예 아니요 예 아니요 Member(email, age) email이 null 또는공백인가? 예외로 종료"email" age < 14 또는age > 120인가? 예외로 종료"age" 현재 객체 필드에 대입 this.email / this.age new가 정상 참조를 반환
  1. 이메일 검사

    email == null || email.isBlank()이면 IllegalArgumentException("email")로 종료합니다. 나이 검사는 실행하지 않습니다.

  2. 나이 검사

    이메일이 통과한 뒤 age < 14 || age > 120이면 IllegalArgumentException("age")로 종료합니다.

  3. 필드 대입과 정상 반환

    두 검사를 통과해야 this.email과 this.age에 대입하고, new가 정상 참조를 반환합니다.

어느 검사에서든 예외가 나면 필드 대입에 도달하지 않고, new도 정상 참조를 반환하지 않습니다. 호출자 printCreation이 예외를 잡아 rejected=email 또는 rejected=age를 출력합니다.

이 불변식은 검증을 직접 구현한 이 예제의 보장입니다. 생성자라는 문법만으로 입력값이 검증되지는 않습니다.


생성 책임 분리

MemberRegistry의 add가 원시 값으로 Member를 생성할 수 있습니다.

src/ConstructingMemberRegistry.java
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

세 번째 요청은 용량 검사에서 거절돼 생성자도 호출하지 않습니다.

성공한 생성 뒤에만 size를 증가시킵니다.

Member 생성 규칙과 MemberRegistry 용량 규칙이 각각 자기 상태 가까이에 있습니다.

회원 생성 성공 뒤 배열 저장과 size 증가

ConstructingMemberRegistry의 용량 2에서 첫 두 add는 생성 후 저장하고 size를 늘린다. 세 번째 add는 가득 찬 검사를 먼저 만나 Member 생성도 하지 않는다.

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

  1. 첫 번째: kim, 40

    "[email protected]", 40의 생성 성공 후 members[0]에 저장합니다. size: 0 → 1, 반환값은 true입니다.

  2. 두 번째: lee, 50

    "[email protected]", 50의 생성 성공 후 members[1]에 저장합니다. size: 1 → 2, 반환값은 true입니다.

  3. 세 번째: park, 30

    "[email protected]", 30은 용량 검사에서 거절됩니다. Member 생성자를 호출하지 않으며 size: 2 → 2, 반환값은 false입니다.

최종 요약은 count=2, total=90입니다. 저장 공간이 있어도 생성자가 IllegalArgumentException을 던지면 add가 false를 반환하며 배열 칸과 size를 바꾸지 않습니다.

이 예제는 생성·저장을 끝낸 다음 별도 문장으로 size++를 실행합니다. members[size++] = new Member(...)와 실행 순서가 다릅니다.


연습 문제

SignupQuota에 캠페인 이름과 목표 가입자 수를 필수로 받는 생성자를 추가하세요.

이름은 공백일 수 없고 목표 가입자 수는 1~1,000명이어야 합니다.

this로 필드와 매개변수를 구분하고 describe()가 daily=120을 반환하게 하세요.

해설 보기
src/SignupQuotaConstructor.java
public final class SignupQuotaConstructor {
    public static void main(String[] args) {
        SignupQuota goal = new SignupQuota("daily", 120);
        System.out.println(goal.describe());
    }

    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

필수 값 두 개가 생성자 서명에 나타나므로 인수를 빼먹은 호출은 컴파일되지 않습니다.

생성 완료 뒤에는 두 필드가 항상 유효합니다.