Welcome! Everything is fine.

[Java/Study] 김영한의 실전 자바 중급 1편 - 스터디 6회차 본문

Java

[Java/Study] 김영한의 실전 자바 중급 1편 - 스터디 6회차

개발곰발 2024. 11. 20.
728x90

 

인프런 강의 <김영한의 실전 자바 - 중급 1편>을 보고 정리한 내용입니다.

매주 모여 각자 정리한 내용을 기반으로 발표하고 질문 공유하는 스터디입니다.


 

📘Object 클래스

Object 클래스란

자바가 제공하는 라이브러리 중 자바 언어를 이루는 가장 기본이 되는 클래스들을 보관하는 java.lang 패키지가 있다. java.lang 패키지는 모든 자바 애플리케이션에 자동으로 import 된다.

 

✔️ java.lang 패키지의 대표적인 클래스

  • Object :  모든 자바 객체의 부모 클래스
  • String : 문자열
  • Integer, Long, Double : 래퍼 타입, 기본형 데이터 타입을 객체로 만든 것
  • Class : 클래스 메타 정보
  • System : 시스템과 관련된 기본 기능들을 제공

이 중에서도 모든 클래스의 최상위 부모 클래스인 Object 클래스에 대해 알아보자. Object 클래스는 공통 기능다형성의 기본 구현을 제공한다. Object 클래스는 모든 클래스의 부모 클래스이기 때문에 모든 객체를 다 담을 수 있다.

 

✔️ Object 가 제공하는 기능

  • 객체의 정보를 제공하는 toString()
  • 객체의 같음을 비교하는 equals()
  • 객체의 클래스 정보를 제공하는 getClass()
  • 멀티스레드용 메서드인 notify(), notifyAll(), wait()
  • 기타 여러가지 기능

 

Parent 클래스와, Parent 클래스를 상속받은 Child 클래스가 있다고 할 때, 클래스들의 관계는 다음과 같다.

 

  • 클래스에 상속받을 부모 클래스가 없으면 묵시적으로 Object 클래스를 상속받는다. extends Object 코드는 생략하는 것을 권장하므로 extends 키워드가 없더라도 상속받았다고 생각하면 된다.
  • 클래스에 상속 받을 부모 클래스를 명시적으로 지정하면 Object 를 상속 받지 않는다.
❔묵시적(Implicit) vs 명시적(Explicit)
묵시적: 개발자가 코드에 직접 기술하지 않아도 시스템 또는 컴파일러에 의해 자동으로 수행되는 것을 의미
명시적: 개발자가 코드에 직접 기술해서 작동하는 것을 의미

Object  다형성

Object 클래스는 다형성의 기본 구현을 제공한다고 했다. 아래 예시를 보면 Dog과 Car은 서로 관련이 없는 클래스이고, Object를 자동으로 상속받고 있다.

public class Car {
    public void move() {
        System.out.println("자동차 이동");
    }
}
public class Dog {
    public void sound() {
        System.out.println("강쥐멍멍");
    }
}

 

하지만 다음 코드에서 action() 메서드를 보면 Object 타입의 매개변수를 이용해서 Car 인스턴스, Dog 인스턴스 모두 action() 메서드를 이용할 수 있다. 물론, Object 타입으로 받았기 때문에 Car 클래스의 메서드인 move()나 Dog 클래스의 메서드인 sound()를 바로 호출할 수는 없다. 각 객체에 맞는 다운 캐스팅을 진행해 메서드를 호출한다.

public class ObjectPolyExample1 {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Car car = new Car();
        Object o = dog; // 부모는 자식을 담을 수 있다.

        dog.sound();
        action(dog);
        action(car);
    }

    private static void action(Object obj) {
//        obj.sound(); // 컴파일 오류, Object에는 sound()가 없다.
//        obj.move(); // 컴파일 오류, Object에는 move()가 없다.

        // 객체에 맞는 다운 캐스팅 필요
        if (obj instanceof Dog dog) {
            dog.sound();
        } else if (obj instanceof Car car) {
            car.move();
        }
    }
}

 

이렇게 Object 클래스는 다형적 참조가 가능하지만, 메서드 오버라이딩이 안되기 때문에 다형성(다형적 참조 + 메서드 오버라이딩을 함께 사용)을 제대로 사용하기에는 부족하다. 그러나 Object 배열, toString(), equals() 등으로 Object 클래스를 제대로 활용할 수 있다.

Object 배열

Object를 이용하면 모든 객체를 담을 수 있는 배열을 만들 수 있다. Dog 타입, Car 타입, Object 타입 상관없이 배열에 저장할 수 있다. 또한 아래 size() 메서드는 Object 타입만 받기 때문에 어떤 새로운 클래스가 추가되어도 수정할 필요가 없다.

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);

    }

    // 배열에 담긴 객체의 수를 세는 메서드 - Object 타입만 사용, 세상의 모든 객체를 담을 수 있다.
    private static void size(Object[] objects) {
        System.out.println("전달된 객체의 수는 : " + objects.length);
    }
}

toString()

Object 클래스의 메서드 중 하나인 toString() 메서드는 객체의 정보를 문자열 형태로 제공해 디버깅과 로깅에 유용하게 사용된다. 우리가 자주 사용하는 System.out.println() 메서드는 사실 내부에서 toString() 을 호출한다. 따라서 출력을 할 때는 toString()을 직접 호출하지 않아도 바로 객체의 정보를 출력할 수 있는 것이다.

 

⭐ toString() 메서드는 오버라이딩해서 사용하는 것이 일반적이다!

 

Dog 클래스에 다음과 같이 toString() 메서드를 오버라이딩했다.

public class Dog {
    private String dogName;
    private int age;

    public Dog(String dogName, int age) {
        this.dogName = dogName;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Dog{" +
                "dogName='" + dogName + '\'' +
                ", age=" + age +
                '}';
    }
}

 

그 다음 Dog 인스턴스를 생성하고 출력하면 앞서 오버라이딩한대로 결과가 나오는 것을 볼 수 있다.

Dog dog1 = new Dog("멍멍이1", 2);
System.out.println(dog1);

출력 결과

 

여기서 Objectprinter라는 클래스를 만든 후, Object 타입을 매개변수로 받는 print() 메서드를 생성했다. 그리고나서 Car 인스턴스(toString() 오버라이딩X)와 Dog 인스턴스(toString() 오버라이딩O)를 이자로 넣어 호출했다. 결과는 어떨까?

public class ObjectPrinter {
    public static void print(Object obj) {
        String string = "객체 정보 출력: " + obj.toString();
        System.out.println(string);
    }
}

...

ObjectPrinter.print(car);
ObjectPrinter.print(dog1);

 

다음과 같이 toString()을 재정의하지 않은 Car 인스턴스와 toString()을 재정의한 Dog 인스턴스의 결과가 다르게 나왔다. Dog 인스턴스가 to String()을 재정의해서 객체의 정보를 더 확실히 알 수 있다.

출력 결과

 

Objectprinter의 print() 메서드를 호출하면 다음과 같은 과정이 진행된다.

  • Object obj 의 인수로 car(Car) 혹은 dog(Dog) 가 전달 된다.
  • 메서드 내부에서 obj.toString() 을 호출한다. obj 는 Object 타입이다. 따라서 Object 에 있는 toString() 을 찾는다.
  • 이때 자식에 재정의(오버라이딩)된 메서드가 있는지 찾아본다.
  • 재정의된 메서드가 없으면 Object.toString() 을 실행하고, 있으면 해당 인스턴스에 재정의된 toString()을 실행한다.

Object와 OCP

만약 Object 클래스가 없다면 지금 만든 Objectprinter는 이런 식으로 각 타입마다 다르게 만들어야 할 것이다.

public class BadObjectPrinter {
    public static void print(Car car) { //Car 전용 메서드
        String string = "객체 정보 출력: " + car.carInfo(); 
        System.out.println(string);
    }
    
    public static void print(Dog dog) { //Dog 전용 메서드
      String string = "객체 정보 출력: " + dog.dogInfo();
      System.out.println(string);
    }
    
    ... 클래스가 늘어날수록 무수히 많은 print() 메서드 생성
}

 

BadObjectPrinter 클래스는 Car, Dog에 의존하고, Objectprinter 클래스는 Object 클래스에 의존한다. ObjectPrinter 클래스는 Car, Dog과 같이 구체적인 것에 의존하는 것이 아니라 추상적인 것에 의존하면서 런타임에 각 인스턴스의 toString()을 호출할 수 있다. 즉, 이렇게 추상적인 것에 의존하면서 OCP 원칙을 지킬 수 있다.

 

✔️OCP(Open-Closed Principle)

  • Open: 새로운 클래스를 추가하고, toString() 을 오버라이딩해서 기능을 확장할 수 있다.
  • Closed: 새로운 클래스를 추가해도 Object 와 toString() 을 사용하는 클라이언트 코드인 ObjectPrinter 클래스는 변경하지 않아도 된다.

equals()

Object 는 동등성 비교를 위한 equals() 메서드를 제공한다. 자바에서 '같다'라는 표현은 ==과 equals()로 하는데, 그 차이는 다음과 같다.

  • 동일성 : == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인(물리적으로 같은 위치인가?)
  • 동등성 : equals() 메서드를 사용하여 두 객체가 논리적으로 동등한지 확인

예를 들어 다음과 같이 같은 회원번호를 가진 회원 객체가 2개라면, 우리가 보기에는 같다고 판단한다. 그러나 서로 다른 메모리에 있으므로 물리적으로는 다르다. 따라서 동일성은 다르지만, 동등성은 같다고 표현할 수 있다.

User a = new User("id-100");
User b = new User("id-100");

 

그런데 Object가 기본으로 제공하는 equals()는 다음과 같이 ==으로 동일성 비교를 하기 때문에, 동등성 비교를 하고 싶다면 equals() 메서드를 재정의해서 사용해야한다.

public boolean equals(Object obj) {
    return (this == obj);
}

 

User 클래스에서 equals() 메서드를 재정의해보았다. 회원번호만을 가지고 비교하는 최소한의 구현과 보다 더 정확한 구현이 있다. 정확한 equals() 구현은 IDE에서 만들어준다.

public class User {
    private String id;

    public User(String id) {
        this.id = id;
    }

    // 최소한의 equals() 구현
/*    @Override
    public boolean equals(Object obj) {
        User user = (User) obj;
        return id.equals(user.id);
    }*/

    // 정확한 equals() 구현
    @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);
    }
}
더보기

 

  • 참조가 같은 경우 바로 true 반환 (this == o)
  • null이거나 클래스가 다르면 false 반환
  • 내용 비교: id 값이 같으면 true, 그렇지 않으면 false
  • Objects.equals()를 사용해 null 안전성을 보장하며 동등성을 비교

📘불변 객체

⭐ 자바는 항상 값을 복사해서 대입한다!

  • 기본형은 하나의 값을 여러 변수에서 절대로 공유하지 않는다.
  • 참조형은 같은 참조값을 통해 같은 인스턴스를 참조할 수 있다.

공유 참조와 사이드 이펙트

여러 객체가 같은 인스턴스를 참조할 때, 사이드 이펙트가 발생할 수 있다. 

  • 사이드 이펙트(Side Effect) : 프로그래밍에서 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것, 쉽게 말해 a를 바꾸려고 했는데 b까지 바뀌게 되는 상황을 말한다. 사이드 이펙트로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있다.

따라서 만약 a와 b가 같은 인스턴스를 참조한다면, a의 값을 변경하면 b도 바뀔 수 있기 때문에 위험하다. 사이드 이펙트를 해결하려면 다음과 같이 서로 다른 인스턴스를 참조하면 된다. 그러나, 여러 변수가 하나의 객체를 공유하는 것을 막을 방법은 없다.

// 객체 공유O
Address a = new Address("서울");
Address b = a; // 참조값 대입을 막을 수 있는 방법이 없음

b.setValue("부산"); // a의 주소까지 '부산'으로 변경됨

// 객체 공유X
Address a = new Address("서울");
Address b = new Address("서울");

 

잘 생각해보면, 위 코드에서 사이드 이펙트를 발생시킨 원인은 b가 공유 참조하는 인스턴스의 값을 변경한 것이다. 따라서 값을 변경할 수 없는 불변 객체를 사용한다면, 사이드 이펙트를 방지할 수 있다.

b.setValue("부산"); // 이 부분이 문제!

불변 객체

✔️ 가변(Mutable) 객체 vs 불변(Immutable) 객체

  • 가변 객체 : 처음 만든 이후 상태가 변할 수 있는 객체
  • 불변 객체 :처음 만든 이후 상태가 변하지 않는 객체

불변이라는 제약으로 사이드 이펙트를 막을 수 있다. 불변 객체는 값을 변경할 수 없고, 값을 꼭 변경하고 싶다면 새로운 불변 객체를 생성해 변경하고자 하는 값으로 넣어주어야 한다.

 

Address 클래스를 불변 객체로 바꾼 모습이다. value를 final로 선언했고, setValue() 메서드도 없어서 내부 값인 value를 바꾸는 것은 불가능하다.

public class ImmutableAddress {
    // 내부 값들이 바뀌지만 않으면 불변 객체라고 할 수 있다.
    private final String value;

    public ImmutableAddress(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "Address{" +
                "value='" + value + '\'' +
                '}';
    }
}

 

이렇게 불변 객체로 만들면 다음과 같이 값을 바꾸려면 새로운 ImmutableAddress를 생성해 b에 대입하는 수밖에 없다.

public class RefMain2 {
    public static void main(String[] args) {

        ImmutableAddress a = new ImmutableAddress("서울");
        ImmutableAddress b = a;
        System.out.println("a = " + a);
        System.out.println("b = " + b);

//        b.setValue("부산"); // 못바꾼다! -> 새로운 객체를 생성해서 담아야한다.
        b = new ImmutableAddress("부산");
        System.out.println("부산 -> b");
        System.out.println("a = " + a);
        System.out.println("b = " + b);
    }
}

 

불변 객체를 사용하면서 값을 변경해야하는 메서드가 필요하다면, 게산 결과를 바탕으로 새로운 객체를 만들어서 반환한다. 다음 코드에서 add() 함수는 기존 값에 바로 새로운 값을 더하지 않고 새로운 ImmutableObj 객체를 반환함으로써 기존값을 유지하면서 새로운 값을 모두 유지할 수 있다.

public class ImmutableObj {
    private final int value;
    public ImmutableObj(int value) {
        this.value = value;
    }

    public ImmutableObj add(int addValue) {
        int result = value + addValue;
        return new ImmutableObj(result);
    }

    public int getValue() {
        return value;
    }
}
public class ImmutableMain1 {
    public static void main(String[] args) {
        ImmutableObj obj1 = new ImmutableObj(10);
        ImmutableObj obj2 = obj1.add(20);

        // 계산 이후에도 기존값과 신규값 모두 확인 가능
        System.out.println("obj1 = " + obj1.getValue());
        System.out.println("obj2 = " + obj2.getValue());
    }
}

 

자바에서 기본적으로 제공하는 Integer, LocalDate 등 많은 클래스가 불변으로 설계되어 있기 때문에 불변 객체의 특성을 잘 이해해야한다. 그것말고도 캐시 안정성, 멀티 쓰레드 안정성, 엔티티의 값 타입 등의 이유로 클래스를 불변으로 설계하지만 나중에 배울 것들이다. 따라서 불변 클래스의 원리를 제대로 이해하는 것이 먼저다!

📘 출처 - 김영한의 실전 자바 - 중급 1편