Java 24

생성자, this, 생성자 오버로딩과 초기 상태 검증

new 옆 괄호에 값을 넣는 것이 메서드 호출처럼 보이지만 반환형도 없고 클래스명과 같아서 일반 메서드와 구분하기 어렵다.

근거 · 교안 p167-p176

생성자, this, 생성자 오버로딩과 초기 상태 검증 대표 이미지

1장: 생성자는 ‘특별한 메서드’가 아니라 ‘초기화 계약’입니다

1. 생성자는 ‘특별한 메서드’가 아니라 ‘초기화 계약’입니다

솔라가 노트북 화면을 뚫어져라 쳐다보고 있었다. 말없이 화면 위를 떠다니던 마우스 커서가 어느 한 문장에서 멈춰 섰다. 솔라는 그 문장을 블록으로 지정했다가 풀기를 반복했다.

“생성자는 객체가 생성될 때 호출되는 특별한 메서드다.”

“언니.”

나지막한 부름에 옆에서 조용히 책을 읽던 루나가 고개를 들었다.

“나 이거 때문에 머리가 아파. ‘특별한 메서드’라니. 아무리 봐도 그냥 이상한 메서드 같은데. 반환형도 없고, 이름은 클래스랑 똑같고, 호출할 땐 꼭 new라는 키워드를 붙여야 하고. 그냥 메서드라고 하기엔 규칙이 너무 다르잖아.”

솔라의 말에는 짜증 섞인 의문이 가득했다. 메서드라는 단어에 익숙해질 만하니, 그 단어를 빌려와 전혀 다른 규칙을 설명하는 것이 영 마음에 들지 않는 눈치였다. 그건 그냥 메서드가 아니라고 말하는 것 같았다.

루나는 잠시 솔라의 노트북 화면을 들여다보더니, 의자 등받이에 편안히 몸을 기댔다.

“메서드처럼 부를 수 없다는 게 솔라 너를 가장 답답하게 하는 것 같네.”

“응. new 없이 그냥은 못 불러? 만약 부르면 어떻게 되는데?”

“좋은 질문이야. 직접 해보면 알겠지.”

루나는 말없이 턱짓으로 노트북을 가리켰다. 솔라는 잠시 망설이다가, 이내 간단한 클래스 코드를 작성하기 시작했다. 장난감 로봇을 만드는 Robot 클래스였다.

class Robot {
    String name;

    // 생성자
    public Robot(String name) {
        System.out.println(name + " 로봇 생성!");
        this.name = name;
    }

    // 일반 메서드
    public void activate() {
        System.out.println("시스템 활성화.");
    }
}

“자, activate 메서드는 그냥 robot.activate() 이렇게 부르면 되잖아. 그럼 생성자는?”

솔라는 잠시 고민하더니, 일반 메서드를 호출하는 것처럼 코드를 짜 보았다.

// 될까?
Robot.Robot("R-1"); // 클래스 이름으로 호출?

코드 편집기는 즉시 빨간 밑줄을 그으며 문법 오류를 표시했다. ‘메서드가 아닙니다(Not a method)‘라는 식의 차가운 메시지가 떴다.

“그럼 객체를 만들고 나서 호출하면?”

Robot myRobot = new Robot("R-0"); // 일단 하나 만들고
myRobot.Robot("R-2"); // 다시 호출?

이번에도 결과는 마찬가지였다. 컴파일러는 Robot이라는 이름의 메서드를 찾을 수 없다고 완강히 버텼다.

솔라는 키보드에서 손을 떼고 의자에 등을 기댔다. “거봐, 안 되잖아. 그냥은 절대 호출할 수 없어. 그런데 왜 자꾸 ‘메서드’라고 부르는 걸까?”

“이제 확실해졌네. 생성자는 우리가 아는 일반적인 방법으로는 절대 호출할 수 없다는 게.”

루나가 차분하게 말했다.

“그럼 둘의 역할이 완전히 다르다고 생각해 보는 건 어때? 메서드는 이미 만들어진 객체에게 ‘어떤 행동을 하라’고 시키는 명령문이야. robot.activate()는 ‘R-0 로봇아, 활성화해!’라는 명령이지. 그런데 생성자는 그 이전에 일어나.”

루나는 잠시 말을 고르다, 비유를 들었다.

“생성자는 객체의 ‘탄생’ 그 자체에 관한 규칙이야. ‘객체가 세상에 태어날 때는 반드시 이런 모습이어야 한다’고 명시하는 초기화 계약서 같은 거지.”

“초기화 계약서?”

“응. 그리고 그 계약을 발동시키는 유일한 키워드가 바로 new인 거야. new는 ‘이 계약서 내용대로 새로운 객체를 하나 만들어주세요’라는 요청이고, 그 뒤에 오는 Robot(...) 부분이 바로 그 계약서의 구체적인 내용인 셈이지.”

솔라의 눈이 동그래졌다. new Robot("R-1") 코드가 새롭게 보이기 시작했다. 지금까지는 newRobot()이 어색하게 붙어있는 하나의 덩어리처럼 느껴졌었다.

“아…! 그럼 new가 ‘만들어라!’라는 주문이고, Robot("R-1")이 ‘이름이 R-1인 로봇으로’라는 계약 조건이구나. 뭔가를 만들어서 돌려주는 게 아니라, 그냥 그 자리에서 객체 자체가 완성되는 거니까… 그래서 반환 타입이 없는 거였어!”

솔라는 무릎을 탁 쳤다. ‘특별한 메서드’라는 애매한 표현에 갇혀 있던 생각이 한순간에 풀려나는 기분이었다. 생성자는 호출해서 무언가를 얻어내는 장치가 아니었다. 객체의 탄생을 관장하는, 단 한 번 실행되는 엄격한 의식이었던 것이다.

“맞아. 생성자는 객체가 존재하기 위한 최소한의 조건을 보장하는 첫 번째 방어선이야. 그래서 일반 메서드처럼 아무 때나 부를 수 없게 막아둔 거지. 객체의 정체성이 중간에 마음대로 바뀌면 안 되니까.”

이제 솔라는 ‘생성자는 클래스명과 같고 반환 타입이 없으며 new와 함께 자동 호출된다’는 건조한 규칙이 왜 그렇게 디자인되었는지 이해할 수 있었다. 그것은 규칙이라기보다, 하나의 온전한 객체를 탄생시키기 위한 당연한 절차였다.

“그럼 생성자는 ‘특별한 메서드’가 아니라, 객체의 ‘탄생 계약서’라고 부르는 게 훨씬 낫겠다.”

솔라는 만족스러운 표정으로 중얼거리며 자신이 짰던 코드를 다시 들여다봤다. 계약서라는 관점으로 보니 생성자 안의 코드가 다르게 읽혔다. System.out.println(...)은 출생 신고 메시지 같았고, this.name = name은 출생 증명서에 이름을 적는 행위처럼 보였다. 그런데 거기서 새로운 궁금증이 고개를 들었다.

“언니, 그런데 이 코드는 왜 이렇게 썼을까? this.name = name;… 그냥 매개변수 이름을 robotName처럼 다르게 쓰면 this 안 써도 되잖아. 필드랑 매개변수 이름이 같을 때 쓴다는데, 이게 꼭 필요한 거야?“

2장: 객체 자신을 가리키는 this, 왜 필요할까요?

솔라는 이전의 대화가 끝나자마자 키보드 위로 손을 가져갔다. 루나가 펼쳐 든 책장 너머로 타닥거리는 소리가 경쾌하게 들려왔다. 솔라는 방금 전까지 자신이 궁금해했던 this.name = name; 라인을 뚫어져라 쳐다봤다. ‘이게 꼭 필요할까?’라는 의문은 ‘이건 불필요해.’라는 확신으로 바뀌어 있었다.

솔라는 자신의 가설을 증명하기 위해 망설임 없이 Robot 클래스의 생성자를 수정했다. 필드 name과 매개변수 name의 이름이 같아서 혼란스럽다면, 컴퓨터가 똑똑하게 알아서 처리해주지 않을까? 혹은, 에러라도 내주겠지. 솔라는 this. 부분을 지워버렸다. 코드는 훨씬 간결해 보였다.

class Robot {
    String name;

    // 생성자 (수정됨)
    public Robot(String name) {
        System.out.println(name + " 로봇 생성 시도...");
        name = name; // this를 제거했다!
    }

    public void sayName() {
        System.out.println("제 이름은 " + name + "입니다.");
    }
}

“좋아.”

만족스러운 혼잣말과 함께, 솔라는 로봇 객체를 만들고 이름을 말하게 하는 테스트 코드를 실행했다.

public class Main {
    public static void main(String[] args) {
        Robot myRobot = new Robot("R-1"); // "R-1" 이라는 이름으로 생성
        myRobot.sayName(); // 이름 말하기
    }
}

솔라는 당연히 “제 이름은 R-1입니다.”라는 출력을 기대했다. 하지만 콘솔에 나타난 결과는 예상과 전혀 달랐다.

R-1 로봇 생성 시도...
제 이름은 null입니다.

“어?”

솔라의 미간이 좁혀졌다. 생성 시도 메시지는 잘 나왔다. 분명 “R-1”이라는 값을 생성자에 전달했는데, 로봇의 이름(name)은 null이었다. 이름이 정해지지 않은 상태였다. name = name; 라인은 아무런 효과가 없었던 것이다. 컴파일 에러조차 나지 않았다.

“왜… 이름이 null이지? namename을 넣으라고 했는데.”

솔라는 중얼거리며 고개를 갸웃거렸다. 바로 옆에 있던 루나가 책에서 눈을 떼고 나지막이 물었다.

“솔라야, 그 name = name; 줄에서, 왼쪽 name과 오른쪽 name은 각각 누구의 것이라고 생각해?”

“누구의 것…?”

솔라는 다시 코드를 들여다봤다. Robot 클래스 안에는 두 개의 name이 있었다. 하나는 클래스 바로 밑에 선언된 필드 String name;이었고, 다른 하나는 생성자 public Robot(String name)의 괄호 안에 있는 매개변수 name이었다.

“하나는 로봇의 부품 같은 필드 name이고, 다른 하나는 생성자에 잠시 전달되는 값인 매개변수 name… 아!”

순간 솔라의 머릿속에 불이 켜졌다. 컴퓨터의 입장에서 생각해 본 적이 없었다. 생성자라는 좁은 공간 안에서 그냥 name이라고 부르면, 컴퓨터는 가장 가까운 대상을 먼저 떠올린다. 바로 자기 자신에게 전달된 매개변수 name이다.

“설마… name = name; 이라는 코드는 그냥 ‘매개변수 name에 매개변수 name의 값을 넣어라’는 뜻이었던 거야? 자기 자신한테 값을 넣고 끝나는, 아무 의미 없는 코드?”

루나는 대답 대신 조용히 고개를 끄덕였다.

그제야 모든 것이 명확해졌다. Robot 객체의 진짜 이름표인 필드 name은 이 무의미한 연산 과정에서 철저히 소외되었던 것이다. 생성자로 전달된 “R-1”이라는 값은 매개변수 name에 담겼다가, 그 자신에게 다시 할당되고, 생성자가 끝나는 순간 허공으로 사라져 버렸다. 정작 객체의 name 필드에는 아무것도 닿지 않았으니, 초기값 그대로 null이었던 것이다.

솔라는 다시 this.을 타이핑해 넣었다.

public Robot(String name) {
    System.out.println(name + " 로봇 생성 시도...");
    this.name = name;
}

this는… 이 객체 ‘자신’을 가리키는 거구나.”

솔라는 감탄하며 말했다. this는 ‘지금 만들어지고 있는 바로 이 로봇 객체’를 지칭하는 특별한 대명사였다. this.name은 ‘이 로봇의 이름 필드’라고 정확하게 주소를 지정해주는 것이었다.

this.name = name;은 ‘이 로봇의 이름표(필드)에, 외부에서 전달받은 이름(매개변수)을 붙여라’는 명확한 지시였어. 그냥 문법적 편의 기능인 줄 알았는데, 객체의 속성과 외부에서 온 값을 구분하는 정말 중요한 거였네.”

객체의 탄생 계약서에 이름을 적는 행위는 정확해야만 했다. 동명이인이 있다고 해서 엉뚱한 사람의 서류에 이름을 적을 수는 없는 노릇이다. this는 계약서의 주체를 명확히 하는 도장이자 서명과도 같았다.

솔라는 코드를 다시 실행했고, 이번에는 기대했던 결과가 정확히 출력되었다.

R-1 로봇 생성 시도...
제 이름은 R-1입니다.

명확한 역할 분담을 깨닫고 나니 한결 후련해졌다. 솔라는 이제 Robot 클래스를 자신감 있게 바라볼 수 있었다. 그런데 문득 새로운 의문이 떠올랐다.

“언니, 지금은 로봇을 만들 때 무조건 이름을 줘야 하잖아. 만약에 내가 생성자를 하나도 안 만들면 어떻게 돼? 객체를 아예 못 만드나? 아니면… 이름 없는 로봇을 만들고 싶으면, 생성자를 하나 더 만들어도 되는 거야?”

3장: 숨겨진 기본 생성자와 다양한 객체 탄생 방법

솔라의 손가락이 키보드 위에서 잠시 멈칫했다. 화면에 떠 있는 Robot 클래스의 생성자 public Robot(String name) { ... } 전체가 파랗게 블록으로 지정되어 있었다. ‘객체의 탄생 계약서’라는 말이 머릿속을 맴돌았다. 지금 이 Robot 클래스의 탄생 계약서는 ‘이름을 반드시 받아야 한다’는 단 하나의 조항만을 가지고 있었다.

‘만약 이 계약서 자체를 없애버리면 어떻게 될까?’

솔라의 머릿속에 떠오른 질문은 엉뚱했지만, 가장 확실한 방법이기도 했다. 계약서가 없다면 계약 자체가 성립할 수 없으니, 객체도 만들어지지 않는 것이 논리적이었다. 솔라는 자신의 가설을 확인하기 위해 과감하게 Delete 키를 눌렀다. 방금 전까지 클래스의 일부를 당당히 차지하던 생성자 코드가 흔적도 없이 사라졌다. 이제 Robot 클래스에는 필드 name과 메서드 sayName()만 덩그러니 남았다.

이제 main 메서드의 new Robot("R-1"); 라인에 즉시 빨간 밑줄이 그어졌다. 예상했던 반응이었다. 이름을 받는 계약서가 사라졌으니 당연한 일이다. 하지만 솔라의 진짜 궁금증은 그 다음이었다. 그녀는 빨간 밑줄이 그어진 코드를 지우고, 대신 괄호 안을 텅 비운 채로 다시 타이핑했다.

new Robot();

이것도 당연히 안 될 거라고 생각했다. Robot()이라는 비어있는 계약서는 어디에도 정의한 적이 없으니까. 솔라는 컴파일 에러를 예상하며 실행 버튼을 눌렀다. 하지만 잠시 후, 콘솔 창에 뜬 결과는 솔라의 예상을 완전히 빗나갔다.

제 이름은 null입니다.

에러가 나지 않았다. 객체가 성공적으로 생성된 것이다. 비록 이름은 null이었지만, 어쨌든 로봇은 태어났다.

“언니, 이거 이상해.”

솔라는 당혹스러운 목소리로 루나를 불렀다.

“생성자를 전부 지웠는데… 계약서를 전부 찢어버렸는데 new Robot()이 돼. 어떻게 객체가 만들어진 거지?”

루나는 솔라의 화면을 잠시 들여다보더니, 텅 비어버린 Robot 클래스 코드를 손가락으로 가리켰다.

“정말 계약서가 전부 사라졌을까? 아니면, 솔라 네가 직접 쓰지 않았을 때 누군가 대신 써 주는 최소한의 계약서가 있는 건 아닐까?”

“대신 써 주는 계약서?”

“응. 클래스를 설계하는 사람이 아무런 탄생 계약서(생성자)도 만들지 않으면, 컴파일러가 아주 친절하게 기본 계약서를 하나 몰래 넣어주거든. ‘아무것도 필요 없고 그냥 만들기만 해’라는 내용의 계약서지.”

루나는 마치 눈에 보이는 것처럼 허공에 코드를 그렸다.

“아마 이런 모습일 거야. public Robot() {}. 내용이 텅 빈, 매개변수도 없는 생성자. 우리는 이걸 **기본 생성자(default constructor)**라고 불러.”

그제야 솔라의 머릿속에서 조각들이 맞춰지기 시작했다. 아무것도 정의하지 않았을 때 new Robot()이 가능했던 이유는 바로 컴파일러가 만들어준 이 보이지 않는 기본 생성자 덕분이었다.

“아! 그럼 내가 public Robot(String name) 생성자를 만들었을 때는, 컴파일러가 ‘아, 개발자가 직접 계약서를 만들기 시작했네. 이제 나는 빠져야겠다’ 하고 기본 생성자를 안 만들어줬던 거구나! 그래서 아까 new Robot()을 쓰려고 하니 에러가 났던 거고!”

“정확해. 컴파일러의 친절은 어디까지나 우리가 아무것도 하지 않았을 때만 발동되는 조건부 친절이야. 우리가 직접 생성자를 단 하나라도 만드는 순간, 객체 생성의 모든 책임은 우리에게 넘어오는 거지.”

이제 솔라는 객체를 생성하는 다양한 방법을 떠올릴 수 있었다. 이름 없이 일단 만들고 나중에 이름을 붙여줄 수도 있고, 처음부터 이름을 가지고 태어나게 할 수도 있다. 두 가지 탄생 방법이 모두 필요하다면?

“그럼 언니, 이름 없는 로봇을 위한 기본 계약서랑, 이름 있는 로봇을 위한 계약서를 둘 다 가질 수도 있는 거야? new Robot()도 되고 new Robot("R-2")도 되게?”

솔라는 질문이 끝나기도 전에 직접 코드를 수정하기 시작했다.

class Robot {
    String name;

    // 1. 이름 없이 태어나는 경우
    public Robot() {
        System.out.println("이름 없는 로봇이 생성되었습니다.");
        this.name = "Unknown";
    }

    // 2. 이름을 가지고 태어나는 경우
    public Robot(String name) {
        System.out.println(name + " 로봇 생성!");
        this.name = name;
    }

    public void sayName() {
        System.out.println("제 이름은 " + name + "입니다.");
    }
}

코드는 아무런 문제 없이 받아들여졌다. 이제 Robot 클래스는 두 가지의 탄생 계약서를 갖게 되었다. 이렇게 매개변수의 종류나 개수를 다르게 해서 여러 개의 생성자를 만드는 것을 **생성자 오버로딩(Constructor Overloading)**이라고 한다.

솔라는 만족스럽게 두 개의 생성자를 번갈아 호출해보았다.

Robot namelessRobot = new Robot(); // 가능!
Robot namedRobot = new Robot("R-2"); // 물론 가능!

namelessRobot.sayName();
namedRobot.sayName();
이름 없는 로봇이 생성되었습니다.
R-2 로봇 생성!
제 이름은 Unknown입니다.
제 이름은 R-2입니다.

결과는 완벽했다. 이제 솔라는 객체의 탄생을 원하는 대로 설계할 수 있다는 자신감을 얻었다. 더 이상 ‘숨겨진 규칙’에 당황하지 않을 것 같았다. 그런데 완성된 코드를 찬찬히 훑어보던 솔라의 눈이 한 곳에 머물렀다. 두 개의 생성자, 두 개의 탄생 계약서. 어딘가 모르게 비효율적인 구석이 보였다.

“언니, 그런데 봐봐. 생성자가 여러 개가 되니까 코드가 조금씩 중복되는 것 같아. 지금은 간단하지만, 만약 초기화할 내용이 더 많아진다면… 여기도 쓰고 저기도 써야 하잖아. 이거, 혹시 생성자 안에서 다른 생성자를 불러서 일을 대신 시킬 수는 없어?“

4장: 중복을 줄이는 생성자 체이닝: this()의 마법

솔라는 방금 자신이 완성한 Robot 클래스를 흐뭇하게 바라보았다. 이름 없이 태어나는 로봇과, 멋진 이름을 갖고 태어나는 로봇. 두 가지 탄생 시나리오를 모두 지원하는 제법 그럴듯한 설계도였다. 하지만 만족감도 잠시, 솔라의 시선이 두 개의 생성자에 번갈아 꽂혔다. 무언가 찜찜한 구석이 있었다.

그녀는 중복의 냄새를 맡았다. 지금은 초기화할 필드가 name 하나뿐이라 눈에 잘 띄지 않지만, 만약 로봇이 더 복잡해진다면? 솔라는 상상 속의 Robot 클래스를 더 구체적으로 만들기 시작했다. 모든 로봇은 고유한 id제조일을 가져야 할 것이다. 그녀는 코드를 수정해 새로운 필드를 추가했다.

import java.time.LocalDate;

class Robot {
    String name;
    String id;
    LocalDate createdAt;

    // 1. 이름 없이 태어나는 경우
    public Robot() {
        System.out.println("새 로봇 생성 프로세스 시작...");
        this.name = "Unknown";
        this.id = "ID-" + System.currentTimeMillis(); // 임시 ID 발급
        this.createdAt = LocalDate.now(); // 제조일 기록
    }

    // 2. 이름을 가지고 태어나는 경우
    public Robot(String name) {
        System.out.println("새 로봇 생성 프로세스 시작...");
        this.name = name;
        this.id = "ID-" + System.currentTimeMillis(); // 임시 ID 발급
        this.createdAt = LocalDate.now(); // 제조일 기록
    }
    // ... sayName() 메서드 등 ...
}

수정하고 나니 문제가 훨씬 선명하게 보였다. id를 만들고 createdAt을 설정하는 코드가 두 생성자에 똑같이 반복되고 있었다. System.out.println 메시지까지. 이건 그냥 보기 싫은 수준이 아니었다. 만약 나중에 id 생성 규칙이 바뀐다면? 양쪽을 모두 수정해야 한다. 하나라도 빠뜨리면 어떤 로봇은 옛날 규칙으로, 다른 로봇은 새 규칙으로 태어나는 끔찍한 불일치가 발생할 것이다. 객체의 탄생 계약서가 두 개인데, 내용이 살짝 다른 셈이다.

“언니, 이거 봐. 생성자마다 똑같은 코드를 계속 써야 해. 이러다가 실수할 것 같아. 한쪽 생성자에서 다른 쪽 생성자를 그냥 부를 순 없을까? 예를 들면 이렇게.”

솔라는 자신의 아이디어를 코드로 옮겨 보았다. 이름 없는 로봇을 만드는 생성자가, 이름 있는 로봇을 만드는 생성자를 호출하게 하는 것이다.

public Robot() {
    // 이렇게 하면 안 될까?
    Robot("Unknown"); // 다른 생성자를 메서드처럼 호출!
}

하지만 코드 편집기는 가차없이 빨간 밑줄을 그었다. ‘메서드가 아니다’라는, 이미 한번 봤던 익숙한 오류 메시지였다. 솔라는 미간을 찌푸렸다.

“아, 맞다. 생성자는 new 없이 부를 수 없다고 했지. 근데 생성자 안에서 new를 또 쓸 수는 없잖아. 그럼 자기 자신을 또 만드는 무한 루프에 빠질 텐데… 그럼 방법이 없는 건가?”

솔라가 키보드 위에서 손을 떼고 한숨을 쉬었다. 그때 옆에서 조용히 지켜보던 루나가 입을 열었다.

this 키워드 기억나? this.name처럼 쓰면 ‘이 객체의 필드’를 가리켰지.”

“응. 필드랑 매개변수 이름이 겹칠 때 꼭 필요했잖아.”

this는 ‘현재 객체 자신’을 가리키는 대명사라고 했어. 그게 필드를 가리킬 수도 있지만, 다른 것도 가리킬 수 있다면 어떨까? 예를 들어… ‘이 객체의 다른 생성자’ 같은 것 말이야.”

솔라의 눈이 반짝였다. ‘이 객체의 다른 생성자’. 생각지도 못한 가능성이었다. this에 그런 기능이 숨어있을 줄이야.

“어떻게? this.Robot()?”

“비슷하지만 조금 달라. 이렇게 해봐.”

루나는 솔라의 코드에서 Robot("Unknown"); 부분을 지우고 대신 this("Unknown"); 이라고 타이핑했다.

public Robot() {
    this("Unknown"); // '이 클래스의 다른 생성자'를 호출!
}

놀랍게도 빨간 밑줄이 사라졌다. 문법적으로 완벽한 코드였다. 솔라는 잠시 멍하니 화면을 바라보다가, this()의 의미를 깨닫고는 탄성을 질렀다.

“아! this 뒤에 점(.)을 찍으면 필드나 메서드에 접근하는 거고, 괄호()를 붙이면 생성자를 호출하는 거구나! 똑같은 this인데 역할이 다르네!”

솔라는 신이 나서 코드를 마저 정리했다. 이제 모든 초기화 로직을 이름 받는 생성자 한 곳으로 모을 수 있었다. 그곳이 모든 Robot 객체가 반드시 거쳐 가는 핵심 탄생 관문이 되는 셈이다.

class Robot {
    String name;
    String id;
    LocalDate createdAt;

    // 시나리오 1: 이름 없이 생성 요청이 오면
    public Robot() {
        // 시나리오 2에게 "Unknown"이란 이름을 주며 생성을 위임한다.
        this("Unknown");
        System.out.println("이름 없는 로봇이 최종 완성되었습니다.");
    }

    // 시나리오 2: 이름을 받아 생성하는 '진짜' 계약서
    public Robot(String name) {
        System.out.println("핵심 생성 프로세스 시작...");
        this.name = name;
        this.id = "ID-" + System.currentTimeMillis();
        this.createdAt = LocalDate.now();
        System.out.println(name + " 로봇 기본 골격 완성.");
    }
}

솔라가 new Robot(); 코드를 실행하자, 콘솔에 찍히는 결과는 그녀의 생각을 증명해주었다.

핵심 생성 프로세스 시작...
Unknown 로봇 기본 골격 완성.
이름 없는 로봇이 최종 완성되었습니다.

Robot() 생성자에서 this("Unknown")을 호출하자, 프로그램의 실행 흐름이 Robot(String name) 생성자로 점프해서 모든 초기화 로직을 수행하고, 다시 원래의 Robot() 생성자로 돌아와 나머지 코드를 실행했다. 이렇게 생성자가 다른 생성자를 연쇄적으로 호출하는 것을 **생성자 체이닝(Constructor Chaining)**이라고 부른다.

“대박이다. 이제 초기화 로직은 Robot(String name) 생성자 하나만 관리하면 돼. 계약서가 여러 개가 아니라, 하나의 완전한 ‘마스터 계약서’가 있고 나머지는 그 계약서를 채우기 위한 간편한 신청서 같은 거였어.”

중복 코드가 사라진 것은 물론, 이제 객체의 탄생 과정이 한 곳으로 모여 명확하게 통제되기 시작했다. 솔라는 자부심을 느끼며 깔끔해진 코드를 바라봤다. 모든 로봇은 이제 예외 없이 일관된 방식으로 idcreatedAt을 부여받게 되었다.

그런데 바로 그 ‘통제’라는 단어에서 새로운 의문이 피어올랐다.

“언니, 이제 모든 탄생은 저 핵심 생성자를 거치게 됐잖아. 그럼 저기서 모든 걸 통제할 수 있다는 뜻이네. 근데 만약… 누군가 new Robot("") 이렇게 이름에 빈 문자열을 넣거나, 나중에 추가될 로봇 버전 정보에 new Robot("R-3", -1)처럼 말도 안 되는 음수 값을 넣어서 객체를 만들려고 하면 어떡하지? 지금 우리 계약서는 그냥 오는 대로 다 받아주잖아. 태어날 때부터 ‘잘못된 상태’인 객체가 만들어질 수도 있는 거 아닐까?”

5장: 처음부터 안전한 객체: 초기 상태 검증

솔라는 방금 완성한 Robot 클래스를 의심스러운 눈초리로 쳐다봤다. 모든 로봇의 탄생을 단 하나의 핵심 생성자로 모으는 데 성공했지만, 바로 그 ‘통제’라는 단어가 마음에 걸렸다. 과연 완벽한 통제일까? 그녀는 자신의 의심을 코드로 증명하기 위해 main 메서드에 새로운 줄을 추가했다. 이전과는 다른, 의도적으로 비틀린 코드였다.

Robot invalidRobot = new Robot(""); 라는 한 줄이 커서를 깜빡이며 나타났다. 이름에 빈 문자열을 넣는 시도. 만약 이 계약서에 허점이 있다면, 이름 없는 유령 로봇이 태어날 것이다. 솔라는 망설임 없이 실행 버튼을 눌렀다.

public class Main {
    public static void main(String[] args) {
        // 이름에 빈 문자열을 넣으면 어떻게 될까?
        Robot invalidRobot = new Robot("");
        invalidRobot.sayName();
    }
}

솔라는 이 코드를 실행하며 내심 프로그램이 멈추거나, 똑똑한 컴파일러가 경고라도 해주길 바랐다. 하지만 콘솔에 찍힌 결과는 냉정했다.

핵심 생성 프로세스 시작...
 로봇 기본 골격 완성.
제 이름은 입니다.

“어…?”

이름이 텅 빈 로봇이 아무런 저항 없이 태어나 버렸다. 심지어 “제 이름은 입니다.” 라고 뻔뻔하게 자신을 소개까지 했다. 이름은 없지만, 객체는 분명히 존재했다. 시스템 어딘가에 유효하지 않은 상태의 로봇이 돌아다니게 된 것이다.

“언니, 이거 봐. 계약서에 빈 종이를 냈는데도 통과시켜 줬어. 이름 없는 유령 로봇이 만들어졌다고.”

솔라의 목소리에는 실망감이 섞여 있었다.

“이러면 객체를 만들고 나서, setName 같은 메서드를 또 만들어서 거기서 이름이 비어있는지 검사하고… 그런 식으로 나중에 수습해야 하는 거잖아. 처음부터 잘못 만들어지지 않게 막을 수는 없는 걸까?”

이것이 솔라가 생각한 최선의 방법이었다. 객체 생성 후, 별도의 검증 메서드를 호출해 상태를 바로잡는 것. 하지만 어딘가 꺼림칙했다. 이미 태어난 객체가 ‘잠시나마’ 잘못된 상태였다는 사실이 마음에 걸렸다.

루나는 솔라의 고민을 듣고는 책상 위에 놓여 있던 펜을 집어 들었다. 그러고는 솔라의 노트북 옆 빈 메모지에 간결한 코드를 써 내려갔다.

// 이름을 '설정'하는 메서드
public void setName(String name) {
    if (name == null || name.isBlank()) {
        // 만약 이름이 비어있으면, 계약 위반!
        throw new IllegalArgumentException("이름은 비워둘 수 없습니다.");
    }
    this.name = name;
}

“솔라 네가 말한 setName 메서드가 아마 이런 모습이겠지. 이름이 유효한지 검사해서, 이상하면 예외(Exception)를 던져서 작업을 중단시키는 거야. ‘이런 값은 받을 수 없다’고 소리치는 거지.”

“응. 이렇게 하면 최소한 나중에라도 잘못된 이름으로 바꾸는 건 막을 수 있겠네.”

“그렇지. 그런데 ‘탄생 계약서’의 역할이 뭐라고 했지?”

루나의 질문에 솔라는 잠시 생각에 잠겼다. “객체가 세상에 태어날 때의 규칙… 객체가 존재하기 위한 최소한의 조건을 보장하는 첫 번째 방어선.”

“맞아. 첫 번째 방어선이야.”

루나는 펜 끝으로 자신이 방금 적은 if 검증 로직 부분을 톡톡 두드렸다.

“그럼 이 방어선을 객체가 태어난 후가 아니라, 태어나는 그 순간에 세우면 어떨까? 탄생 계약서 자체에 ‘유효한 이름만 받겠다’는 조항을 넣는 거야.”

그 순간, 솔라의 머릿속에 흩어져 있던 생각들이 하나로 합쳐졌다. 생성자는 값을 단순히 할당하는 곳이 아니었다. 생성자는 계약의 내용이 올바른지 심사하는 ‘심사관’의 역할까지 할 수 있었던 것이다.

솔라는 루나가 적어준 유효성 검증 코드를 그대로 복사해, 자신의 핵심 생성자 Robot(String name)의 가장 첫 줄에 붙여 넣었다.

public Robot(String name) {
    // 계약서 심사 시작!
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("로봇의 이름은 비워둘 수 없습니다.");
    }
    // 심사 통과. 이하 생성 절차 진행.
    System.out.println("핵심 생성 프로세스 시작...");
    this.name = name;
    this.id = "ID-" + System.currentTimeMillis();
    this.createdAt = LocalDate.now();
    System.out.println(name + " 로봇 기본 골격 완성.");
}

코드가 바뀌었다. 이제 Robot의 탄생 계약서에는 새로운 조항이 추가되었다. “이름은 절대 비워둘 수 없다.” 솔라는 떨리는 마음으로 아까와 똑같은 테스트 코드를 다시 실행했다.

Robot invalidRobot = new Robot("");

이번에는 결과가 완전히 달랐다. 콘솔에는 평범한 출력 메시지 대신, 낯설지만 강력한 붉은색 에러 메시지가 나타났다.

Exception in thread "main" java.lang.IllegalArgumentException: 로봇의 이름은 비워둘 수 없습니다.
	at Robot.<init>(Robot.java:12)
	at Main.main(Main.java:4)

유령 로봇은 태어나지 않았다. 그 대신, “로봇의 이름은 비워둘 수 없습니다.”라는 단호한 외침과 함께 프로그램 전체가 멈춰 섰다. 객체 생성이 원천적으로 거부된 것이다.

솔라는 그 차이를 온몸으로 느꼈다. 이전에는 불완전한 객체라도 일단 만들어졌지만, 이제는 계약 조건을 위반하는 객체는 아예 세상에 나올 기회조차 얻지 못했다. 생성자는 객체의 탄생을 주관하는 마지막 관문이자, 불량품의 탄생을 막는 가장 완벽한 방패였다.

“이거였구나…! 생성자는 그냥 필드 값을 채워 넣는 곳이 아니었어. 객체가 ‘항상 유효한 상태’로 태어나는 것을 보장하는, 말 그대로 ‘탄생 계약서’ 그 자체였네. 잘못된 계약서로는 아예 객체를 만들 수 없게 하는 것!”

이제 솔라는 새로운 클래스를 볼 때마다 가장 먼저 생성자를 찾아볼 것 같았다. 그 클래스의 객체들이 어떤 약속 하에 태어나는지, 어떤 상태를 보장받는지 확인하기 위해서였다.

그녀는 더 이상 Robot 클래스를 수정하지 않았다. 대신, 깨끗한 새 파일을 열었다. 간단한 게임 캐릭터를 만드는 Player 클래스를 설계해보고 싶었다. 솔라는 키보드에 손을 얹고 잠시 눈을 감았다. 어떤 플레이어가 태어나야 할까?

‘플레이어는 반드시 닉네임을 가져야 해. 빈 닉네임은 안 돼. 그리고 모든 플레이어는 레벨 1부터 시작해야지. 마이너스 레벨 같은 건 말도 안 되고.’

솔라는 눈을 뜨고, 망설임 없이 Player 클래스의 탄생 계약서를 작성하기 시작했다. 필드 선언 아래, 그녀의 손끝에서 새로운 약속이 만들어지고 있었다.

class Player {
    String nickname;
    int level;
    int health;

    public Player(String nickname) {
        // 계약 조항 1: 닉네임은 비거나 null일 수 없다.
        if (nickname == null || nickname.isBlank()) {
            throw new IllegalArgumentException("플레이어 닉네임은 필수입니다.");
        }

        // 모든 검증을 통과한 후, 안전하게 객체 상태를 초기화한다.
        this.nickname = nickname;
        this.level = 1;
        this.health = 100;
    }

    // ...
}

이제 new Player(" ") 같은 시도는 객체를 생성하지 못하고 실패할 것이다. 모든 플레이어는 탄생하는 순간부터 유효한 닉네임과 정해진 초기 능력치를 갖게 된다. 솔라는 자신이 작성한 코드를 보며 만족스러운 미소를 지었다. 이제 그녀는 객체에게 안전한 탄생을 선물하는 방법을 알게 되었다.