
자바가 기본으로 제공하는 라이브러리, 그러니까 클래스의 모음 중에 가장 기본이 되는 것이 바로 java.lang 패키지다. java.lang 패키지의 대표적인 클래스들은 아래와 같다. 해당 클래스들은 자바 언어의 기본을 이루기 때문에 반드시 알아둬야 한다.
Object : 모든 자바 객체의 부모 클래스String : 문자열Integer, Long, Double : 래퍼 타입, 기본형 데이터 타입을 객체로 만든 것Class : 클래스 메타 정보System : 시스템과 관련된 기본 기능들을 제공이제 본격적으로 java.lang 패키지가 제공하는 기능들을 하나씩 알아보자.
"자바에서 모든 클래스의 최상위 부모 클래스는 항상
Object클래스다."

package lang.object;
// 부모가 없으면 묵시적으로 Object 클래스를 상속받음.
public class Parent {
public void parentMethod() {
System.out.println("Parent.parentMethod");
}
}
클래스에 상속 받을 부모 클래스가 없으면 묵시적으로 Object 클래스를 상속 받는다. 쉽게 말해, 자바가 자동으로 extends Object 코드를 넣어준다는 말이다.
package lang.object;
// 사실 이 코드랑 똑같지만, 생략을 권장한다.
public class Parent extends Object {
public void parentMethod() {
System.out.println("Parent.parentMethod");
}
}
반면, Child는 Parent를 명시적으로 상속 받는다. 따라서 자바는 Object 클래스를 상속하는 코드를 넣어주지 않는다.
package lang.object;
public class Child extends Parent {
public void childMethod() {
System.out.println("Child.childMethod");
}
}
package lang.object;
public class ObjectMain {
public static void main(String[] args) {
Child child = new Child();
child.childMethod();
child.parentMethod();
String string = child.toString();
System.out.println(string);
}
}
/*
Child.childMethod
Parent.parentMethod
lang.object.Child@3f99bd52
*/
위와 같이 main() 메서드를 돌려봤다. 여기서 toString()는 해당 객체에 대한 어떤 정보를 반환해주는 Object 클래스의 메서드다. 출력 결과를 보면, child는 java.lang.Object 패키지에 있는 child고, 그 녀석의 인스턴스의 참조값은 3f99bd52라고 말해주는 것이다. 그림을 보면서 이해해보자.

알다시피, Parent는 Object 클래스를 묵시적으로 상속 받았기 때문에 당연히 메모리 상에도 Object 인스턴스가 생성되었을 것이다. 여기서 child.toString() 호출하면, Child와 Parent에 없으므로 마지막인 Object 클래스에서 찾아 호출한다. 하여간 중요한 점은 자바에서 모든 객체의 최종 부모는 Object라는 것이다.
“묵시적” 이라는 말은, 개발자가 코드에 직접 기술하지 않아도 시스템 또는 컴파일러에 의해 자동으로 수행되는 것을 말하고, “명시적” 이라는 말은, 개발자가 코드에 직접 기술해서 작동하는 것을 의미한다.
첫 번째 이유로는 공통 기능 제공을 하기 위함이다. 말 그대로 객체가 정보를 제공하는 것이다. 해당 객체가 다른 객체와 같은지 비교하고, 객체가 어떤 클래스로 만들어졌는지 확인하는 기능은 모든 객체에게 필요한 기본 기능이다. 이런 기능을 객체를 생성할 때마다 개발자가 직접 메서드를 만든다? 상당히 번거로울 것이다. 그리고 만든다고 해도, 각자가 이름을 달리 할 수 있기 때문에 일관성이 없을 것이다. 그래서 Object라는 공통 기능을 제공함으로써 모든 객체는 상속 받아서 사용 가능하도록 한 것이다. 두 번째 이유로는 다형성의 기본 구현 을 말할 수 있다. Object는 모든 클래스의 부모 클래스다. 이 말은 모든 클래스를 다 담을 수 있고, 참조할 수 있다는 말이다. 모든 자바 객체는 Object 타입으로 처리될 수 있으며, 이는 다양한 타입의 객체를 통합적으로 처리할 수 있게 한다.
Object 클래스는 모든 클래스의 부모 클래스이므로, 모든 객체를 참조할 수 있다. 예제를 통해 더 알아보자.

Dog, Car 클래스는 서로 아무런 관련이 없고, 둘 다 부모 클래스도 없다. 따라서 Object 클래스를 묵시적으로 상속 받을 것이다.
package lang.object.poly;
public class Car {
public void move() {
System.out.println("자동차가 이동합니다.");
}
}
package lang.object.poly;
public class Dog {
public void sound() {
System.out.println("멍멍");
}
}
package lang.object.poly;
public class ObjectPolyExample1 {
public static void main(String[] args) {
Dog dog = new Dog();
Car car = new Car();
action(dog);
action(car);
}
// Object에는 모든 타입을 받을 수 있음.
private static void action(Object object) {
// Cannot resolve method 'sound' in 'Object'
// Cannot resolve method 'move' in 'Object'
// object.sound(); // Object는 자식 클래스에 대한 정보가 없음
// object.move(); // Object는 자식 클래스에 대한 정보가 없음
// 따라서 객체에 맞는 다운 캐스팅이 필요하다.
if (object instanceof Dog dog) {
dog.sound();
} else if (object instanceof Car car) {
car.move();
}
}
}
/*
멍멍
자동차가 이동합니다.
*/
Dog, Car 타입으로 인스턴스를 만들 때, Object 타입으로 만들어도 무방하다. 그리고 action(Object object) 메서드를 보면, Object는 모든 객체의 부모이기 때문에 어떤 객체든지 인자로 전달할 수 있다. 하지만, Object를 이용한 다형성은 한계가 있다. 위의 코드만 봐도 action() 메서드 안에서 object.sound(), object.move() 등 메서드를 호출하면 컴파일 오류가 발생한다. 왜냐하면 매개 변수가 Object 타입인데 알다시피 Object는 최상위 부모 클래스이기 때문에 sound(), move() 메서드의 존재를 알지 못한다.

그렇기 때문에 Dog나 Car 등 Object를 통해 전달 받은 객체를 호출하려면 각 객체에 맞는 다운 캐스팅 과정이 필요한 것이다.

이처럼 Object는 모든 객체의 부모이므로 모든 객체를 대상으로 다형적 참조를 할 수 있다. 하지만, Object에는 다른 객체의 메서드가 정의되어 있지 않기 때문에 메서드 오버라이딩을 사용할 수 없고, 각 객체의 기능을 호출하려면 다운 캐스팅을 해야 한다. Object 클래스 본인이 보유한 toString() 메서드는 당연히 자식 클래스에서 오버라이딩 할 수 있다. 그럼 이 Object 클래스… 어디에 사용할까?
Object는 모든 객체를 담을 수 있다고 했는데, 이걸로 배열을 만들면 담지 못할 것이 없을 것 같다. 한번 직접 확인해보자.
package lang.object.poly;
public class ObjectPolyExample2 {
public static void main(String[] args) {
Dog dog = new Dog();
Car car = new Car();
Object object = new Object();
// Object 타입의 배열
Object[] objects = {dog, car, object};
size(objects);
}
private static void size(Object[] objects) {
System.out.println("전달 받은 객체의 개수: " + objects.length);
}
}
/*
전달 받은 객체의 개수: 3
*/

배열 인덱스 순서대로 각각 Dog, Car, Object 객체의 참조값을 담고 있다. 그리고 Dog, Car 객체는 Object 클래스를 상속 받고 있다. 위의 size() 메서드는 배열에 담긴 객체의 개수를 세는 메서드인데, Object 타입만 사용한다. Object 타입의 배열은 세상 모든 객체를 담을 수 있기 때문에 새로운 클래스가 추가되거나 변경돼도 해당 메서드를 수정하지 않아도 된다. 지금 만든 이 size() 메서드는 자바를 사용하는 곳이라면 어디든지 사용될 수 있는 것이다. 이와 같이, 만약 Object 클래스가 없다면, action() 메서드와 같은 모든 객체를 받을 수 있는 메서드를 만들 수 없고, 세상 모든 객체를 담을 수 있는 배열도 만들 수도 없다.
Object 클래스에는 객체의 정보를 문자열 형태로 제공해주는 toString() 메서드가 있다. 보통 디버깅과 로깅에 자주 사용되고, Object 클래스에 정의되어 있기 때문에 모든 클래스에서 상속 받아 사용 가능하다.
package lang.object.tostring;
public class ToStringMain1 {
public static void main(String[] args) {
Object object = new Object();
String string = object.toString();
System.out.println("toString 메서드 출력값: " + string);
System.out.println("object의 참조값 출력: " + object);
}
}
/*
toString 메서드 출력값: java.lang.Object@2a84aee7
object의 참조값 출력: java.lang.Object@2a84aee7
*/
결과가 똑같다. toString() 메서드를 직접 찾아가 보자.
public class Object {
...
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
...
}
getClass().getName()과 같이 클래스에 대한 정보를 얻어낸다. 이게 위에 출력된 패키지명과 클래스명을 포함한 구문, java.lang.Object이 된다. 골뱅이 뒤는 인스턴스의 참조값처럼 생겼는데, toString() 내부를 보면, hashCode()...? 간단히 말하자면, 객체의 참조값이 숫자로 나오면 그걸 toHexString() 메서드를 이용해 16진수로 제공하는 것이다. 아무튼, toString() 메서드 출력값과 object 참조값이 왜 같냐면, 사실 System.out.println() 메서드는 내부에서 toString() 메서드를 호출한다. 자식을 포함한 Object 타입이 println()에 인수로 전달되면 내부에서 object.toString() 메서드를 호출해서 결과를 출력한다.
Object.toString() 메서드를 통해 객체의 정보, 즉 참조값을 알아낼 수는 있지만 그걸로 유의미한 객체의 상태를 끌어내지는 못한다. 그래서 일반적으로 toString() 메서드를 오버라이딩 해서 유용한 정보를 제공한다.
package lang.object.tostring;
public class Car {
private String carName;
public Car(String carName) {
this.carName = carName;
}
}
package lang.object.tostring;
public class Dog {
private String dogName;
private int age;
public Dog(String dogName, int age) {
this.dogName = dogName;
this.age = age;
}
// toString() 메서드 오버라이딩
@Override
public String toString() {
return "강아지 이름: " + dogName + ", 나이: " + age + "세";
}
}
package lang.object.tostring;
public class ObjectPrinter {
public static void print(Object obj) {
String string = "--객체의 정보 출력-- " + obj.toString();
System.out.println(string);
}
}
package lang.object.tostring;
public class ToStringMain2 {
public static void main(String[] args) {
Car car = new Car("Model Y");
Dog dog1 = new Dog("아르", 4);
Dog dog2 = new Dog("이브", 4);
System.out.println("1. 단순 toString 호출");
System.out.println(car.toString());
System.out.println(dog1.toString());
System.out.println(dog2.toString());
System.out.println("2. println 내부에서 toString 호출");
System.out.println(car);
System.out.println(dog1);
System.out.println(dog2);
System.out.println("3. Object 다형성 활용");
ObjectPrinter.print(car);
ObjectPrinter.print(dog1);
ObjectPrinter.print(dog2);
}
}
/*
1. 단순 toString 호출
lang.object.tostring.Car@30f39991
강아지 이름: 아르, 나이: 4세
강아지 이름: 이브, 나이: 4세
2. println 내부에서 toString 호출
lang.object.tostring.Car@30f39991
강아지 이름: 아르, 나이: 4세
강아지 이름: 이브, 나이: 4세
3. Object 다형성 활용
--객체의 정보 출력-- lang.object.tostring.Car@30f39991
--객체의 정보 출력-- 강아지 이름: 아르, 나이: 4세
--객체의 정보 출력-- 강아지 이름: 이브, 나이: 4세
*/
Car 인스턴스는 Object가 제공하는 기본 toString() 메서드를 사용했고, Dog 인스턴스는 toString() 메서드를 오버라이딩 해서 객체의 상태를 명확하게 확인할 수 있도록 했다. 세 번째 출력은 그림으로 자세하게 분석해보자.

ObjectPrinter에 Car 인스턴스를 넘기면, 메서드 내부에서 obj.toString()를 호출한다. 보다시피 obj는 Object 타입이다. 따라서 Object 내부의 toString() 메서드를 찾을 것이고 자식에 오버라이딩을 했는지 찾아본다. Car 클래스에서는 메서드 오버라이딩을 하지 않았으므로 Object.toString()을 실행한다.

반면, Dog 인스턴스에서는 toString() 메서드를 오버라이딩 했다. 따라서 ObjectPrinter에 Dog 인스턴스를 넘기면, Dog 클래스에서의 toString() 메서드를 우선적으로 호출한다.
만약 Object 클래스나 toString() 메서드가 없었다면, 공통의 부모가 없어 서로 관련이 없는 객체의 정보를 뽑아내기 어려웠을 것이다. 만약 한다고 해도, 각각의 클래스마다 별도의 메서드를 아래처럼 일일이 작성해줘야 한다.
package lang.object.tostring;
public class BadObjectPrinter {
public static void print(Car car) {
String string = car.carInfo();
System.out.println(string);
}
public static void print(Dog dog) {
String string = dog.dogInfo();
System.out.println(string);
}
}
현재 BadObjectPrinter는 구체적인 타입인 Car , Dog를 사용한다. 따라서 이후에 출력해야 할 구체적인 클래스가 10개로 늘어난다면 구체적인 클래스에 맞춰 메서드도 10개로 계속 늘어나게 된다. 이렇게 BadObjectPrinter 클래스가 구체적인 특정 클래스인 Car , Dog를 사용하는 것을 BadObjectPrinter는 Car , Dog에 의존한다고 표현한다. 다행히도 자바에는 객체의 정보를 사용할 때, 다형적 참조 문제를 해결해줄 Object 클래스와 메서드 오버라이딩 문제를 해결해줄 Object.toString() 메서드가 있다.
하지만, 앞서 만든 ObjectPrinter 클래스는 Car , Dog 같은 구체적인 클래스를 사용하는 것이 아니라, 추상적인 Object 클래스를 사용한다. 이렇게 ObjectPrinter 클래스가 Object 클래스를 사용하는 것을 ObjectPrinter 클래스가 Object 에 클래스에 의존한다고 표현한다. 여기서 “추상적” 이라는 뜻은 단순히 추상 클래스나 인터페이스만 말하는 것은 아니다. 예를 들어, Animal과 같은 부모 타입으로 올라갈수록 개념은 더 추상적으로 변하고, Dog, Cat과 같은 하위 타입을 내려갈수록 개념은 더욱 구체적이게 되는 것이다.

아무튼 위의 ObjectPrinter의 object를 사용하는 구조는 다형성이 아주 잘 녹아져 있다. 먼저 print(Object obj), Object 타입을 매개 변수로 사용해서 다형적 참조를 사용한다. 이러면 세상 모든 객체를 담을 수 있다. 그리고 Object는 최상위 부모이기 때문에 다른 구체적인 클래스에서 toString() 메서드를 오버라이딩 할 수 있는 것이다. 따라서 위의 코드에서 print(Object obj) 메서드는 Dog, Car과 같은 구체적인 타입에 의존하지 않고, 추상적인 Object 타입에 의존하면서 런타임에 각 인스턴스의 toString() 메서드를 호출할 수 있다.
OCP 원칙을 떠올려보자. 새로운 클래스를 추가하고, toString() 메서드를 오버라이딩 해서 기능을 확장할 수 있다. 그리고 새로운 클래스를 추가해도 Object와 toString() 메서드를 사용하는 클라이언트 코드인 ObjectPrinter는 변경하지 않아도 되는 것이다.
사실 지금까지 알아본 내용은 System.out.println()의 작동 방식을 설명하기 위한 것이다. println() 메서드도 Object 매개 변수를 사용하고 내부에서 toString()을 호출한다. 따라서 System.out.println() 메서드를 사용하면 세상 모든 객체의 정보를 쉽게 뽑아낼 수 있다.

이처럼 자바는 객체 지향 언어답게 언어 스스로도 객체 지향의 특징을 잘 살린다. toString() 메서드와 같이, 자바 언어가 기본으로 제공하는 다양한 메서드들을 필요에 따라 오버라이딩 해서 사용할 수 있도록 설계되었다.
“정적 의존관계” 는 컴파일하는 동안 결정되며, 주로 클래스 간의 관계를 의미한다. 위의 클래스 의존관계 그림이 바로 정적 의존관계를 나타낸 것이다. 프로그램을 실행하지 않고, 클래스에서 사용하는 타입들만 보더라도 쉽게 의존관계를 파악할 수 있는 것이다. 반면, “동적 의존관계” 는 런타임에 확인할 수 있는 의존관계다. 앞에서 ObjectPrinter.print(Object obj)에 인자로 어떤 객체가 넘어오는지 프로그램이 실행되야 알 수 있는 것이다. 보통 의존관계 또는 어디에 의존한다고 말하는 것은 주로 정적 의존관계를 말한다.
Object 클래스는 객체의 동등성을 비교하기 위한 equals() 메서드도 제공한다. 자바에서는 “두 객체가 같다” 라는 표현을 2가지로 얘기한다.
== 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키는지 확인한다.equals() 메서드를 사용해서 두 객체가 논리적으로 동등한지 확인한다.여기서 “동일” 이란, 완전히 같다는 말이다. 같은 메모리에 있는 객체 인스턴스인지 참조값을 확인하는 것이다. 동일성은 자바 머신 기준이고 메모리의 참조가 기준이므로 물리적이라고 할 수 있다. 반면, “동등” 은 같은 가치나 수준을 의미하지만 그 형태나 외관 등이 완전히 같지는 않을 수 있다는 것을 말한다. 동등성은 보통 사람이 생각하는 논리적인 기준에 맞춰 비교한다.
일단 느낌은 알겠지만, 실제 비교를 해보자.
User user1 = new User("철수");
User user2 = new User("철수");
위의 코드에서 user1 과 user2는 물리적으로 메모리 상에 찢어져 있기 때문에 다른 객체다. 하지만 이름을 기준으로 본다면, 논리적으로 같은 회원이라고 할 수 있다. 이런 경우에 “동일성은 다르지만, 동등성은 같다” 라고 한다.
아래 예제 코드를 자세히 살펴보자.
package lang.object.equals;
public class EqualsMainV1 {
public static void main(String[] args) {
UserV1 user1 = new UserV1("id-100");
UserV1 user2 = new UserV1("id-100");
// 참조가 다름
System.out.println("Identity: " + (user1 == user2));
// 논리적으로는 같다면서 왜 false가 나오지..?
System.out.println("Equality: " + user1.equals(user2));
}
}
/*
Identity: false
Equality: false
*/
위의 코드를 그림으로 표현하면 아래와 같을 것이다. 당연히 참조가 다른 인스턴스가 생성되고, 그 안에 값은 id-100으로 동일할 것이다.

참조값이 다르기 때문에 동일성은 당연히 성립하지 않는다. 근데 동등성은 왜 성립하지 않을까? 일단 Object.equals() 메서드가 어떻게 생겼는지 보자.
public class Object {
...
public boolean equals(Object obj) {
return (this == obj);
}
...
}
왜 ==으로 비교하지? 위에 동일성을 비교할 때도 ==을 사용했다. 둘 다 똑같은 방식으로 비교하는 것이다. 왜 equals() 메서드는 ==으로 동일성을 비교하는 걸까? 동등성이라는 개념은 각각의 클래스마다 다르다. 어떤 클래스는 주민등록번호를 기반으로 동등성을 처리할 수도 있고, 다른 클래스는 고객의 연락처를 기반으로 동등성을 처리할 수도 있는 것이다. 내 입맛에 맞게 동등성을 판단하려면 메서드를 오버라이딩 해서 사용해야 하는 것이다.
바로 메서드 오버라이딩 해서 다시 예제를 작성해보자. 고객 번호(id)가 같으면 논리적으로 같은, 즉 동등성이 성립하도록 정의할 것이다.
package lang.object.equals;
public class UserV2 {
private String id;
public UserV2(String id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
UserV2 user = (UserV2) obj; // obj에는 id 속성이 없기 때문에 다운 캐스팅 필요
return id.equals(user.id);
}
}
보다시피, Object의 equals() 메서드를 오버라이딩했다. 이제 UserV2의 동등성은 고객 번호(id)로 비교한다. 지금 매개 변수(obj)는 Object 타입이고, 고객 번호를 가지고 있지 않으므로 다운 캐스팅이 필요하다.
package lang.object.equals;
public class EqualsMainV2 {
public static void main(String[] args) {
UserV2 user1 = new UserV2("id-100");
UserV2 user2 = new UserV2("id-100");
System.out.println("Identity: " + (user1 == user2));
System.out.println("Equality: " + user1.equals(user2));
}
}
/*
Identity: false
Equality: true
*/
오버라이딩이 제대로 됐다. 이제 내 의도대로 동등성을 판단할 수 있다. 사실 위의 예제는 간단한 이해를 위한 것이고, 실제로 정확하게 동작하려면 아래와 같이 구현해야 한다.
// 변경 - 정확한 equals 구현, IDE 자동 생성
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
복잡하긴 하구나… 더군다나 이 equals() 메서드를 구현할 때 지켜야 하는 규칙들도 있다. 일단 보고 이런게 있구나 정도로 넘어가도록 하자.
x.equals(x)는 항상 true)x.equals(y)가 true이면, y.equals(x)도 true)equals() 메서드는 항상 동일한 값을 반환해야 한다.null에 대한 비교: 모든 객체는 null과 비교했을 때 false를 반환해야 한다.
그렇다고 한다… 일단 최종적으로 정리해보자. 동등성을 비교하는 과정이 항상 필요한 것은 아니지만, 만약 필요하다면 equals() 메서드를 오버라이딩 해서 사용하면 된다. 그리고 equals()와 hashCode()는 보통 함께 사용된다. 이 점은 참고만 하자.