생성자(Constructor)

생성자는 특별한 종류의 메서드입니다. 우리가 new 키워드를 통해 객체를 생성할 때 자동으로 호출되며, 보통 필드의 값을 초기화하는 데 사용합니다. 언제든지 호출될 수 있는 메서드와는 다르게, 객체 생성 시 단 한 번만 호출되며 반환형 자체가 존재하지 않습니다. 그리고 생성자명은 항상 클래스명과 같아야 합니다. 여기서는 접근 제어자를 우선 public으로 뒀는데, 나중에 접근 제어자 편에서 이를 자세히 살펴볼 것입니다.

[접근제어자] 생성자명(매개변수1, 매개변수2, ...) {
	// ...
}

객체와 클래스 편에서 봤던 코드에 생성자를 추가해서 다시 보도록 하겠습니다.

class Car {
    private int speed; // 현재 속도를 나타내는 필드
    private int maxSpeed = 100; // 최대 속도를 나타내는 필드
    
    // 생성자
    public Car(int currentSpeed) {
    	speed = currentSpeed;
    }

    // 차량의 현재 속도를 가져온다.
    public int getSpeed() {
    	return speed;
    }

    // 차량의 최대 속도를 가져온다.
    public int getMaxSpeed() {
        return maxSpeed;
    }

    // 속도를 올린다.
    public void speedUp(int increment) {
        if (speed + increment > maxSpeed){
            System.out.println("최대 속도 " + maxSpeed + "km/h를 넘어설 수 없습니다.");
        } else {
            speed += increment;
        }
    }

    // 속도를 내린다.
    public void speedDown(int decrement) {
        if(speed - decrement < 0) {
            System.out.println("속도는 0 아래로 떨어질 수 없습니다.");
        } else {
            speed -= decrement;
        }
    }
}

class ConstructorExamples {
	public static void main(String[] args) {
		Car car = new Car(30);
		
		car.speedUp(10); // car.speed = 40
		System.out.println("현재 자동차의 속도: " + car.getSpeed() + "km/h");
		
		car.speedUp(50); // car.speed = 90
		System.out.println("현재 자동차의 속도: " + car.getSpeed() + "km/h");
	}
}

여기서 생성자와 생성자를 호출하는 부분만 살펴보도록 하겠습니다. 먼저 41행을 보시면 생성자를 호출하면서 30이란 값을 넘겨주는 것을 볼 수 있습니다.

Car car = new Car(30);

이어서 6~8행을 보시면 생성자를 정의했는데, 매개변수 하나를 받아서 speed 필드의 값을 초기화하는 것을 볼 수 있습니다. 여기에선 speed의 값이 30으로 초기화됩니다.

public Car(int currentSpeed) {
    speed = currentSpeed;
}

이를 그림으로 다시 살펴보면 아래와 같습니다. 간단하죠?

다른 초기화 방법과의 비교

필드 초기화

그런데 생성자의 역할이 필드의 값을 초기화하는 거라면 처음부터 아래와 같이 필드에 기본값을 줄 수도 있지 않을까요?

class Car {
    private int speed = 30; // 현재 속도를 나타내는 필드
    private int maxSpeed = 100; // 최대 속도를 나타내는 필드
    
    // ...
}

class ConstructorExamples {
	public static void main(String[] args) {
		Car car = new Car();
		
		// ...
	}
}

이 방법은 자동차 객체의 최대 속도와 같이 어느 자동차 객체든 동일한 값을 가져야 할 때 유용합니다. 하지만 각각의 자동차마다 다른 초기 속도를 가지고 있다면 필드 초기화가 아닌 생성자를 통해 초기화를 하셔야 합니다. 어떤 조건이나 상태 등에 따라서 초깃값을 다르게 줘야 하는 것과 같이 초기화 식이 복잡한 경우에도 보통 생성자를 사용합니다.

class Car {
    private int speed = 30;
    
    public Car(int currentSpeed) {
    	speed = currentSpeed;
    }

    public int getSpeed() {
    	return speed;
    }
}

class ConstructorExamples {
	public static void main(String[] args) {
		Car car = new Car(50);
		
		System.out.println("현재 자동차의 속도: " + car.getSpeed() + "km/h");
	}
}

위의 코드를 실행하면 아실 수 있는데, 필드 초기화가 먼저 이루어진 다음에 생성자를 통한 초기화가 이루어지기 때문에 결과적으로 speed의 값은 50이 됩니다.

메서드를 이용한 초기화

생성자를 이용하는 것 대신에 초기화 메서드를 호출해서 값을 초기화할 수도 있지 않을까요?

class Car {
    private int speed = 30;
    
    public void initialize(int currentSpeed) {
    	speed = currentSpeed;
    }

    public int getSpeed() {
    	return speed;
    }
}

class ConstructorExamples {
	public static void main(String[] args) {
		Car car = new Car();
		
		car.initialize(30);
		System.out.println("현재 자동차의 속도: " + car.getSpeed() + "km/h");
	}
}

이 방식은 생성자라는 개념이 도입되기 전에 자주 사용되었으나, 이것의 문제점은 만약 우리가 실수로 초기화 메서드를 호출하지 않았을 때, 객체의 초기 상태를 보장할 수 없다는 것과 개발자마다 setUpToUse(), initialize(), validate() 등 제각기 다른 이름의 초기화 메서드를 사용하여 혼란을 준다는 것입니다. 생성자를 통해 초기화하는 것을 권장하지만, 생성자에서 시간이 걸리는 무거운 작업 등이 필요한 경우 상황에 따라서 초기화 메서드로 분리할 수 있습니다.

기본 생성자(Default Contructor)

객체를 생성할 때 항상 생성자가 호출되는데, 우리가 만약 생성자를 정의하지 않으면 어떻게 될까요? 만약 우리가 정의하지 않는다면 자바 컴파일러에서 기본 생성자를 만들어줍니다. 기본 생성자를 소스 코드에서 확인할 순 없습니다.

기본 생성자는 매개변수도 없으며 본문은 비어있습니다. 하지만 우리가 생성자를 직접 정의할 때는 기본 생성자가 생성되지 않습니다. 따라서 아래와 같은 경우 에러가 발생하게 됩니다. 매개변수가 없는 생성자가 존재하지 않기 때문입니다.

생성자 오버로딩(Constructor overloading)

메서드 편에서 설명한 메서드 오버로딩과 비슷합니다. 메서드 시그니처가 다른 여러 개의 메서드를 정의할 수 있는 것처럼, 생성자 시그니처(매개변수의 개수, 매개변수의 타입)가 다르면 여러 개의 생성자를 정의할 수 있습니다.

this

this 키워드는 객체 자신을 가리키는 참조형 변수와 비슷하다고 생각해도 무방합니다. 물론 실제로는 변수는 아니므로 값을 변경할 수는 없습니다. 이 키워드는 다음과 같이 주로 생성자나 인스턴스 메서드 내부에서 사용됩니다.

// 파이썬을 접했던 개발자는 self를 떠올려보자.
// this는 더 구체적으로 인스턴스 이니셜라이저, 디폴트 메서드, 리시버 매개변수에 사용될 수 있다.
// 디폴트 메서드는 인터페이스 편에서 다루고 나머지 부분은 사용되는 빈도가 정말 낮기 때문에 따로 다루지는 않는다.
// 지금은 그저 인스턴스 메서드와 생성자에 집중하자.
class Foo {
	private int data;
	
	public Foo(int param) { // 생성자
		this.data = param;
		// ...
	}

	public void doSomething(int param) { // 인스턴스 메서드
		this.data = param;
		// ...
	}
}

이 키워드는 어디에 사용될까요? 대표적으로는 아래와 같이 구분하기 모호할 때 자주 사용됩니다. 혹은 내부에서 다른 생성자를 호출할 때에도 사용할 수 있습니다.

class Person {
	private String name;
	private int age;
	private String address;

	public Person(String name, int age) {
		// 매개변수 name 때문에 인스턴스 필드 name이 가려진다.
		// 따라서 그냥 name이라고 하면 매개변수 name을 지칭하게 된다.
		// 이런 모호함을 피하기 위해서 this 키워드를 사용해 확실하게 인스턴스 필드를 가리켜야 한다.
		this.name = name;
		this.age = age;
	}

	public Person(String name, int age, String address) {
		// 이러면 Person(String, int) 생성자가 호출된다.
		this(name, age);
		this.address = address;
	}

	// this는 말 그대로 객체 자신을 가리키는 참조라고 할 수 있다.
	public void methodA(int num) {
		// 내부에 있는 다른 메서드를 호출하거나...
		this.methodB();

		// 참조를 다른 참조형 변수에 대입하거나...
		Person person = this;
	}

	public void methodB() {
		// ...
	}
}

키워드 this의 비밀

실은 우리는 이미 this를 줄곧 사용하고 있었습니다. 아래의 예시에서 인스턴스 메서드 methodA() 안에 있는 methodB() 호출은 더 정확하게는 this.methodB()와 같습니다. 

class Person {
	// ...
	public void methodA(Person this, int num) {
		this.methodB(); // methodB();
	}
}

원래 코드를 바이트코드 수준에서 살펴보면 다음과 같습니다. 여기서 인스턴스 메서드를 호출할 때 해당 인스턴스(객체)의 참조가 필요하므로 aload_0으로 전달하는 것을 볼 수 있습니다. 여기서 말하는 내용은 전혀 이해하지 못해도 문제가 없으며, 실제로는 this를 이미 사용하고 있었다는 사실만 알아두면 충분합니다.

래퍼 클래스(Wrapper classes)

래퍼(wrapper)의 wrap은 '감싸다, 포장하다'라는 의미로, 자바에서 래퍼 클래스(wrapper class)는 기본 타입을 감싼 것을 말합니다. 생성한 래퍼 객체에는 기본 타입의 값을 저장할 수 있으므로 래퍼 객체를 통해서 기본 타입을 객체로 다룰 수 있습니다. 각각의 기본 타입에 대해서 아래와 같은 래퍼 클래스를 가지고 있습니다.

래퍼 객체는 아래와 같은 방법으로 만들 수 있습니다. 이를 차례대로 살펴보도록 하겠습니다.

// 자바 9 이후에는 'new Integer()'가 사용되지 않으며 대신에 Integer.valueOf() 메서드를 사용합니다.
Integer a = new Integer(10);
Integer b = Integer.valueOf(10);
Integer c = 10;

new

자바 9 이후로는 이 방법이 더 이상 사용되지 않습니다. 자바독에서는 아래와 같이 적혀 있습니다.

이 생성자를 사용하는 건 별로 적절하지 않습니다. 정적 팩토리(static factory) valueOf()가 공간 및 시간 성능을 크게 향상시킬 가능성이 높기 때문에 보통 더 나은 선택입니다.

이클립스에서 아래의 코드를 작성하면 자바 9 이후로는 더 이상 사용되지 않고 앞으로 제거될 것이라는 경고가 발생합니다.

valueOf()

이어서 valueOf() 메서드를 이용하는 방법을 살펴보겠습니다. valueOf() 메서드의 내부를 살펴보면 다음과 같습니다.

public static Integer valueOf(int i) {
	// -127 <= i <= 127의 경우에는 이미 만들어진 래퍼 객체를 활용합니다.
	if (i >= IntegerCache.low && i <= IntegerCache.high)
		return IntegerCache.cache[i + (-IntegerCache.low)];
	// 그렇지 않은 경우에는 래퍼 객체를 새롭게 생성합니다.
	return new Integer(i);
}

자주 사용되는 수들을 래퍼 객체로 미리 만들어두고 이를 활용하므로 메모리 공간을 절약할 수 있고, 이미 만들어진 객체를 이용하므로 다시 만들지 않아도 된다는 장점이 있습니다. 하지만 위에서 지정한 범위를 벗어나면 새로운 객체를 생성합니다. 아래의 예시에서는 '주소가 동일합니다.'를 출력할 것입니다.

Integer a = Integer.valueOf(10);
Integer b = Integer.valueOf(10);

if (a == b) {
	System.out.println("주소가 동일합니다.");
} else {
	System.out.println("주소가 동일하지 않습니다.");
}

하지만 아래와 같이 지정된 범위를 벗어날 경우에는 새로운 객체를 생성합니다.

Integer a = Integer.valueOf(128);
Integer b = Integer.valueOf(128);

참고로 Byte, Short, Integer, Long은 -127부터 127까지, Character는 0에서 127까지를 미리 만들어두고 있지만 사용하고 있는 JVM이나 버전에 따라서 달라질 수 있으므로 이 범위를 너무 신뢰하지는 마세요. '-XX:AutoBoxCacheMax=<크기>' 옵션으로 범위가 변경될 수도 있습니다.

오토박싱(Auto-boxing)

우리가 생성자를 이용해서 래퍼 객체를 만들거나 valueOf() 메서드를 통해서 따로 변환하지 않아도, 자바 컴파일러는 자동으로 기본 타입에 해당하는 래퍼 객체로 변환해줍니다. 예를 들어서 int는 Integer로 변환하고, double은 Double로 변환하는 식입니다.

// 오토박싱(auto-boxing)
Integer a = 10;

이를 컴파일 후 생성되는 바이트코드에서 확인하면 실제로는 valueOf() 메서드를 호출하고 있음을 확인할 수 있습니다.

따라서 사실은 아래와 같이 변환되는 것입니다.

Integer a = Integer.valueOf(10);

언박싱(Unboxing)

오토박싱과 비슷하게, 우리가 따로 변환시키지 않아도 자바 컴파일러는 자동으로 래퍼 객체에 해당하는 기본 타입으로 변환해줍니다.

Integer aObj = 10;
int a = aObj;

이를 컴파일 후 생성되는 바이트코드에서 확인하면 실제로는 intValue() 메서드를 호출하고 있음을 확인할 수 있습니다.

typeValue()

어떤 래퍼 객체를 원하는 기본 타입으로 변환하고 싶은 경우에는 typeValue() 메서드를 사용할 수 있습니다. 예를 들어서 Integer 객체를 double 타입으로 변환하고 싶다면 doubleValue() 메서드를 호출하면 됩니다.

Integer a = 3;
double b = a.doubleValue();

주의할 점

null을 항상 조심하자!

래퍼 클래스는 참조 타입이며 참조형 변수에는 null이 들어갈 수 있다는 점을 항상 조심해야 합니다.

class Person {
	private String name;
	private Integer age;
	
	public void setName(String name) {
		this.name = name;
	}
	
	public void setAge(int age) {
		this.age = age;
	}
	
	public String getName() {
		return name;
	}
	
	public int getAge() {
		return age; // (1)
	}
}

class WrapperClassExamples {
	public static void main(String[] args) {
		Person bob = new Person();
		
		if (bob.getAge() >= 18) { // (2)
			// ...
		}
	}
}

위의 코드를 컴파일 후 실행시키면 (1)번에서 NullPointerException 예외가 발생합니다. getAge() 메서드의 반환형이 기본 타입이므로 래퍼 객체를 기본 타입으로 변환하는 언박싱을 거치게 되면서 return age.intValue();로 바뀌게 되는데, 여기서 age는 null이기 때문에 intValue() 메서드를 호출할 수 없습니다.

class Person {
	...
	private Integer age;
	...
	public int getAge() {
		return age.intValue(); // NullPointerException 예외!
	}
}

반환형을 Integer로 고쳐도 (2)번에서 기본 타입과 비교할 때, 래퍼 객체와 기본 타입을 그냥 비교할 수는 없으므로 다시 언박싱이 일어나서 동일한 문제가 발생합니다.

public static void main(String[] args) {
	Person bob = new Person();

	if (bob.getAge().intValue() >= 18) { // NullPointerException 예외!
		// ...
	}
}

값을 비교할 때 조심하자!

문자열 편에서도 이미 살펴봤지만 래퍼 클래스는 참조 타입이기에 값을 비교할 때는 항상 조심해야 합니다. == 연산자로 래퍼 객체를 서로 비교하는 것은 담고 있는 값을 비교하는 게 아니라 주소를 비교하는 것이라는 걸 주의하세요.

class WrapperClassExamples {
	public static void main(String[] args) {
		Integer a = 256;
		Integer b = 256;
		
		if (a == b) {
			System.out.println("a와 b는 같습니다.");
		} else {
			System.out.println("a와 b는 같지 않습니다.");
		}
	}
}

위의 코드를 컴파일 후 실행하면 'a와 b는 같지 않습니다.'라는 문장이 출력됩니다. 담고 있는 값이 아니라 주소를 비교하기 때문입니다. 따라서 올바르게 비교하려면 아래와 같이 equals() 메서드를 사용해야 합니다.

...
if (a.equals(b)) {
...

래퍼 클래스와 기본 타입 간의 비교는 언박싱을 통해서 래퍼 객체가 기본 타입으로 변환되지만, 방금 살펴봤듯이 참조형 변수에는 null이 들어갈 수 있다는 점을 조심해야 합니다.

필요할 때만 사용하자!

기본 타입과는 다르게 래퍼 클래스는 객체를 생성해야 하고 비교할 때나 할당할 때도 오토박싱이나 언박싱 같이 별도의 변환 과정을 거쳐야 하므로 성능이 떨어집니다. 따라서 래퍼 클래스를 사용해서 얻는 이점에 비해 성능 면에서 얻는 불이익이 크다면 기본 타입을 사용해야 합니다. 래퍼 클래스가 굳이 필요한 곳이 아니면 기본 타입을 대신 사용합니다.

용도

래퍼 클래스를 이용하면 래퍼 클래스에서 지원하는 메서드들을 사용할 수 있습니다. 그리고 나중에 만나볼 수 있는 제네릭에는 기본 타입이 아닌 객체가 필요하기 때문에 래퍼 클래스를 사용해야 합니다.

// 에러가 발생한다. 기본 타입이 아니라 래퍼 클래스를 사용해야 한다.
ArrayList<int> nums = new ArrayList<>();

래퍼 객체를 가리키는 참조형 변수에는 null 값이 들어갈 수 있다는 것이 문제가 되기도 하지만, 한편으론 장점이 되기도 합니다. 데이터베이스를 예로 들면 거의 항상 대부분의 필드가 필수가 아니라 선택 사항이므로 null이 들어갈 수 있습니다. 만약에 데이터베이스에서 이를 읽어오려면 기본 타입은 null을 허용하지 않으므로 래퍼 클래스를 사용해야 합니다.