본문 바로가기

🧑🏻‍💻 Dev/Java

[Java] 불변 객체(Immutable Object)와 Record

평소에 늘 JDK11 버전을 사용해서 모르고 있었는데, 최근 JDK16으로 버전을 바꿔보면서 새롭게 사용해 본 기능이 있어서 기록해 볼까 합니다. 추가로 평소에 궁금했었던 불변 객체에 대해서도 조금 찾아서 기록해보려고 합니다.

 

 

📖 불변 객체(Immutable Object)란?

  • 생성 후 그 상태를 바꿀 수 없는 객체를 의미합니다.
  • 불변객체는 읽을 수만 있는 Read-Only 메서드만을 제공합니다.
  • Java의 대표적인 불변객체로는 String이 있습니다.

 

# String Class Description

 

그럼 String은 생성된 후 값을 변경할 수 없다는건가? 그럼 아래 코드는 무엇인가? 값을 변경하는 것이 아닌가?

public class Main {
    public static void main(String[] args) {
        String stringEx = "Hello";
        System.out.println(stringEx); // Hello
        
        stringEx = "World";
        System.out.println(stringEx); // World
    }
}
  • stringEx라는 String 객체의 값을 Hello에서 World로 바꾼 것이 아닌가?라는 생각이 들 수 있습니다.
  • 이는 값을 변경한 것이 아니고 기존에는 String Constant Pool에 있는 "Hello"를 참조하고 있다가 "World"로 참조 방향이 변경된 것 입니다. 즉, stringEx의 값이 변경된 것이 아닙니다.
  • String 포스팅이 아니기 때문에 해당 링크를 참고해주시면 이해가 쉬울 거 같습니다.

 

 

 

📖 불변 객체를 만드는 방법

1. final 키워드를 이용하자

  • Java에서는 불변성을 확보하기 위해서 final이라는 키워드를 제공합니다.
  • Java의 기본 필드는 가변적이지만 final 키워드를 앞에 붙여주면 객체 생성시 값 초기화는 가능하지만, 생성된 이후에는 변경이 불가능합니다.

 

# final 키워드를 이용한 필드 불변성 확보 시 문제점

  • 하지만 final 키워드를 사용하더라도 해당 객체 내부의 상태를 변경시키지 못하는 것은 아닙니다.
  • 아래의 예시는 List에 final 키워드를 붙여서 불변성을 확보했습니다. 그리고 List에 값을 추가해 보는 예시입니다.
public class Ex {
    public static void main(String[] args) {
        WorkSpace workSpace = new WorkSpace("Test WorkSpace");
        workSpace.getWorkSpace().add(new Worker("윤씨", 27)); // 불변 필드의 내부 상태를 변경
        workSpace.getWorkSpace().add(new Worker("정씨", 29)); // 불변 필드의 내부 상태를 변경
        
        workSpace.getWorkers().forEach(worker -> {
            System.out.println(worker);
        });
        // [출력]
        // Worker[김씨, 26]
        // Worker[박씨, 30]
        // Worker[윤씨, 27]
        // Worker[정씨, 29]
    }
}

class WorkSpace {
    // 불변성을 확보하기 위해서 필드를 final로 선언
    private final List<Worker> workers;
    private final String workSpaceName;

    public WorkSpace(String workSpaceName) {
        this.workers = new ArrayList<>(List.of(new Worker("김씨", 26), new Worker("박씨", 30)));
        this.workSpaceName = workSpaceName;
    }

    // final 필드를 읽기만 하는 Read-Only 메서드 선언
    public List<Worker> getWorkers() {
        return this.workers;
    }
    
    public String getWorkSpaceName() {
        return this.workSpaceName;
    }
}

class Worker {
    private String name;
    private Integer age;

    public Worker(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return "Worker[" + name + ", " + age + "]";
    }
}
  • 위 코드를 보면 알 수 있듯이 WorkSpace Class 내부의 workSpace 필드를 불변성 확보를 위해서 final 키워드로 선언했습니다.
  • 그리고 해당 필드를 읽을 수 있는 Getter 메서드를 선언해줬습니다.
  • 하지만 외부(Ex Class)에서 final로 불변성을 확보한 필드 내부에 새로운 객체가 계속 추가돼도 문제가 없는 상태가 되었습니다.
  • Java에서는 위와 같이 참조에 의해서 값이 변경될 수 있는 점을 주의해야 합니다. 그래서 완벽한 불변 객체를 만들기 위해서는 "불변 클래스"로 만들어야 합니다.

 

 

2. 불변 클래스로 만들자

  • 불변 클래스가 되기 위해서는 Class를 final 키워드를 사용해서 선언합니다.
  • Class의 모든 필드를 private final로 선언합니다.
  • 객체를 생성할 수 있는 생성자 또는 정적 팩토리 메서드(of)를 선언합니다.
  • 위에서 봤던 List와 같이 참조에 의해서 변경될 가능성이 있는 필드는 방어적 복사를 이용하여 제공합니다.

 

# WorkSpace를 완벽한 불변 클래스로 만드는 리펙토링

// final 키워드로 class를 선언
final class WorkSpace {
    // 불변성을 확보하기 위해서 필드를 private final로 선언
    private final List<Worker> workers;
    private final String workSpaceName;

    // 생성자를 내부적으로 호출할 수 있도록 private로 선언
    private WorkSpace(String workSpaceName) {
        this.workers = new ArrayList<>(List.of(new Worker("김씨", 26), new Worker("박씨", 30)));
        this.workSpaceName = workSpaceName;
    }

    // 정적 팩토리 메서드를 선언
    public static WorkSpace of(String workSpaceName) {
        return new WorkSpace(workSpaceName);
    }

    // 방어적 복사를 이용하여 workers를 제공
    public List<Worker> getWorkers() {
        return Collections.unmodifiableList(this.workers);
    }

    public String getWorkSpaceName() {
        return this.workSpaceName;
    }
}
  • 첫 번째로 final 키워드로 class를 선언하여 불변 클래스로 만들었습니다.
  • 두 번째로 모든 필드는 private final로 선언해 줬습니다.
  • 세 번째로 내부 생성자를 private로 선언하여 정적 팩토리 메서드를 통해서만 객체를 생성할 수 있도록 했습니다.

    • 이렇게 설정한 이유는 아무런 생성자 없이 정적 팩토리 메서드만 생성한다면 기본적으로 비어있는 내부 생성자를 컴파일러에서 자동으로 만들어줍니다. 그렇기 때문에 클래스 외부에서 어디서든 객체를 만들고 접근할 수 있기 때문입니다.
  • 마지막으로 workers List가 필요한 경우가 있을 수 있기 때문에 불변성을 확보하기 위해서 방어적 복사를 사용하여 List를 제공했습니다.

 

 

 

📖 Java의 Record

  • JDK14에서 preview로 소개하고, JDK16 버전부터 정식으로 사용할 수 있게 되었습니다.
  • 불변 데이터 객체를 쉽게 생성할 수 있도록 도와줍니다.

출처: https://www.baeldung.com/java-15-new

 

1. Record의 특징

  • Record는 따로 적어주지 않아도 암묵적으로 final 클래스이기 때문에 상속이 불가능합니다.
  • 다른 클래스를 상속(extends) 받을 수는 없지만, 인터페이스를 구현(implements)은 할 수 있습니다.
  • 필드에 따로 private final 키워드를 붙여주지 않아도 됩니다.
  • 모든 필드를 파라미터로 갖는 생성자를 기본적으로 제공합니다.
  • 객체 생성 시 재정의 해줘야 하는 toString(), hashCode(), equals()를 기본적으로 제공합니다.

 

 

2. Class  vs Record (비교)

비교를 위해서 먼저 Record를 사용하지 않고, 불변 객체를 만드는 코드를 작성해 봅니다.

 

# 기존의 Class를 이용하여 불변 객체 Student를 생성

public final class Student {
    private final String studentId;
    private final String name;
    private final Integer age;
    
    public Student(String studentId, String name, Integer age) {
        this.studentId = studentId;
        this.name = name;
        this.age = age;
    }

    public String getStudentId() {
        return this.studentId;
    }

    public String getName() {
        return this.name;
    }

    public Integer getAge() {
        return this.age;
    }

    @Override
    public String toString() {
        return String.format("Student[studentId=%s, name=%s, age=%d]", studentId, name, age);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Student student = (Student) o;

        if (!Objects.equals(studentId, student.studentId)) return false;
        if (!Objects.equals(name, student.name)) return false;
        return Objects.equals(age, student.age);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, name, age);
    }
}

 

# Record를 이용하여 불변객체 Student 생성

public record Student(String studentId, String name, Integer age) {
   // 끝
}
  • 이렇게만 봐도 엄청난 기능을 가지고 있다고 볼 수 있습니다. 값을 가져올 수 있는 getter()도 모두 기본적으로 제공됩니다. 메서드의 이름은 만약 studentId를 가져오고 싶다면 그냥 studentId()라는 메서드로 사용하면 getter와 동일한 기능을 합니다.
  • @Override 해서 재정의해줬던 toString(), hashCode(), equals()도 모두 기본적으로 제공하기 때문에 따로 정의해주지 않아도 됩니다.
  • 모든 필드를 파라미터로 갖는 생성자도 기본적으로 제공해 주기 때문에 따로 정의해주지 않아도 됩니다.

 

 

3. Record의 추가적인 기능

추가적인 기능을 기존의 class와 비교하면서 설명드리겠습니다.

 

 

(1) 생성자로 들어오는 파라미터 유효성 검사하기

20살이 넘는 age 값이 들어왔을 때는 객체가 생성되지 않고, 예외를 던지도록 코드를 설정해 봅니다.

// Class 사용

public final class Student {
    private final String studentId;
    private final String name;
    private final Integer age;

    public Student(String studentId, String name, Integer age) {
        if (age >= 20) {
            throw new IllegalArgumentException("20살 이상은 학생이 아닙니다!");
        }
        this.studentId = studentId;
        this.name = name;
        this.age = age;
    }
}

 

// Record 사용

public record Student(String studentId, String name, Integer age) {

    public Student {
        if (age >= 20) {
            throw new IllegalArgumentException("20살 이상은 학생이 아닙니다!");
        }
    }
}

위 방법을 이용하면 Null에 대한 처리도 충분히 할 수 있을 거 같습니다.

 

 

(2) Person interface 구현하기

// Person Interface

public interface Person {
    void walk();
    void eat();
}
// Class 사용

public final class Student implements Person {
    /* 코드 생략 */
    
    @Override
    public void walk() {
        System.out.println(this.name + "가 걷습니다.");
    }

    @Override
    public void eat() {
        System.out.println(this.name + "가 밥을 먹습니다.");
    }
}
// Record 사용

public record Student(String studentId, String name, Integer age) implements Person {
    /* 코드 생략 */
    
    @Override
    public void walk() {
        System.out.println(this.name + "가 걷습니다.");
    }

    @Override
    public void eat() {
        System.out.println(this.name + "가 밥을 먹습니다.");
    }

}

Record는 Interface는 기존의 Class처럼 구현할 수 있습니다. 하지만 Class를 상속(extends) 받는 것은 불가능합니다.

 

Record에서 extends를 하려고 할 때

 

 

 

 

 

사실 Lombok 라이브러리나 IDE에서 toString(), hashCode(), equals()를 자동으로 Generate해주는 기능도 보면서 정말 편하게 사용하기 위해서 여러 가지 기능을 만들고 있구나라는 생각을 했는데, Record를 보고 나서 발전은 정말 끝이 없다는 것을 느꼈습니다. 나중에는 어떤 기능들이 점점 더 추가될지 기대되면서도 무섭다는 생각을 했습니다.

 

 

 

[Reference]

https://www.baeldung.com/java-15-new

https://scshim.tistory.com/372

https://www.baeldung.com/java-native

https://mangkyu.tistory.com/131