1. record 클래스와 불변성
불변성은 객체가 상태를 생성 후에 변경할 수 없는 성질을 말합니다. 다시 말해, 객체가 만들어지면 내부 데이터를 바꿀 수 없습니다. 끝! 돌에 새긴 것처럼요.
기차표를 떠올려 보세요. 일단 발권이 되면 출발 날짜나 장소를 바꿀 수 없습니다(포토샵 같은 편법은 논외로). 표는 불변 객체입니다. 다른 표가 필요하면 새로 사야 합니다.
프로그래밍에서는 이런 객체를 immutable objects라고 부릅니다. 프로그램을 우발적 변경으로부터 보호하고, 코드를 더 안전하고 예측 가능하게 만듭니다.
불변 객체의 특징
- 객체의 모든 필드는 final입니다(보통 생성자에서 단 한 번만 할당할 수 있음).
- 세터(필드 값을 바꾸는 메서드)가 없습니다.
- 내부 데이터를 반환하는 메서드는 복사본을 반환하거나, 그 데이터 자체가 불변입니다.
Java의 record 클래스는 불변 데이터 구조를 쉽고 간단하게 만들기 위해 고안되었습니다.
record가 왜 불변인가?
- 모든 record 컴포넌트는 final.
record를 선언하면 컴파일러가 모든 필드를 자동으로 private final로 만듭니다. 즉, 객체가 만들어진 뒤에는 필드를 변경할 수 없습니다. - 세터가 없음.
record 클래스에서는 setX(int x) 같은 메서드를 추가할 수 없습니다 — 객체 생성 후 필드 값을 바꾸는 것을 컴파일러가 허용하지 않습니다. - 생성자는 값을 한 번만 할당.
모든 값은 객체가 생성되는 순간에만 설정됩니다.
예시
public record Point(int x, int y) {}
Point p = new Point(5, 10);
// p.x = 7; // 컴파일 오류: 필드 x는 private이고 final임
// p.x(7); // 오류: 세터가 없음!
System.out.println(p.x()); // 5
필드를 바꾸거나 존재하지 않는 세터를 호출하려 하면 컴파일 오류가 발생합니다. Java는 불변성 계약을 엄격히 지킵니다.
2. 불변 객체의 장점
멀티스레드에서의 안전성
멀티스레드 프로그램(요즘 대부분이 그렇죠)에서 불변 객체는 방탄조끼와 같습니다. 객체를 변경할 수 없다면 여러 스레드가 동시에 안심하고 읽을 수 있고, 그 순간 누군가가 데이터를 바꿀까 걱정할 필요가 없습니다. 접근을 동기화하거나 데이터 레이스를 염려할 필요도 줄어듭니다.
사실: 멀티스레드 시나리오에서 자주 쓰이는 Java 표준 라이브러리의 많은 클래스는 불변이거나, 변경으로부터 특별히 보호되어 있습니다.
코드 이해가 쉬워짐
객체가 불변이라면, 다른 메서드나 클래스로 전달하더라도 "내 눈앞에서" 바뀌지 않는다는 확신을 가질 수 있습니다. 이는 코드 읽기와 디버깅을 크게 단순화합니다. 누가, 어디서 필드를 바꿨는지 의심할 필요가 없습니다 — 아무도 바꾸지 못하니까요.
컬렉션의 키로 쓰기 편리
불변 객체는 HashMap이나 HashSet 같은 컬렉션의 키로 매우 적합합니다. 왜냐하면 equals와 hashCode가 변하지 않는 필드에만 의존하기 때문입니다. 따라서 상태 변경 때문에 컬렉션에서 "사라지는" 일이 없습니다.
숨은 버그가 줄어듦
가변 객체는 잘못된 참조 전달 한 번으로도 쉽게 망가질 수 있습니다. 반면 불변 객체는 인쇄된 책과 같습니다. 아무도 페이지를 뜯어내거나 고쳐 쓸 수 없습니다.
3. 일반 클래스와의 비교
일반 클래스와 record 클래스를 비교해 봅시다. 예로 평면상의 점 모델을 사용하겠습니다.
일반 클래스(mutable)
public class PointClass {
private int x;
private int y;
public PointClass(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setX(int x) {
this.x = x;
}
public void setY(int y) {
this.y = y;
}
}
객체를 만든 뒤 원하는 만큼 상태를 바꿀 수 있습니다:
PointClass p = new PointClass(1, 2);
p.setX(10); // 이제 p.x는 10
record 클래스(immutable)
public record Point(int x, int y) {}
객체를 생성하면 끝입니다. 생성된 그대로 영원히 유지됩니다:
Point p = new Point(1, 2);
// p.x = 10; // 오류! 필드에 접근할 수 없음
// p.x(10); // 오류! 세터가 없음
표: 동작 비교
| 일반 클래스 | record 클래스 | |
|---|---|---|
| 필드 | 임의 | 오직 private final |
| 세터 | 추가 가능 | 추가 불가 |
| 가변성 | 가변 (mutable) | 불변 |
| 자동 생성 | 아니오 | 예 (equals, hashCode, toString) |
4. 실습: record 클래스의 불변성 사용법
작은 예제로 record 클래스를 적용해 봅시다. 은행 애플리케이션에서 트랜잭션 정보를 저장한다고 가정합니다:
public record Transaction(String fromAccount, String toAccount, double amount) {}
객체를 생성합니다:
Transaction t = new Transaction("12345", "67890", 1500.0);
System.out.println(t);
// Transaction[fromAccount=12345, toAccount=67890, amount=1500.0]
다른 계좌로 "보내기"를 시도해 봅시다:
// t.toAccount = "11111"; // 오류! 필드는 final이며 접근할 수 없음
// t.toAccount("11111"); // 오류! 세터 없음
다른 트랜잭션이 필요하면 새 객체를 만듭니다:
Transaction t2 = new Transaction(t.fromAccount(), "11111", t.amount());
중요: 불변이라고 해서 "불편하다"는 뜻은 아닙니다. 필요한 상태가 달라지면 새 객체를 만들면 됩니다.
5. 불변성의 특징: 기억해야 할 점
불변성은 언제나 절대적인 것이 아니다!
record 클래스는 그 필드가 바뀌지 않음을 보장합니다. 그러나 필드가 배열이나 일반 클래스 같은 가변 객체를 참조한다면, 그 객체의 내용은 변경될 수 있습니다.
배열 예시
public record DataHolder(int[] data) {}
int[] arr = {1, 2, 3};
DataHolder holder = new DataHolder(arr);
arr[0] = 99;
System.out.println(holder.data()[0]); // 99! 배열이 변경됨
결론: 진짜 불변을 원한다면 String, Integer, 다른 record 등 불변 타입만 사용하거나, record 클래스의 정규 생성자(canonical constructor) 안에서 가변 객체를 방어적 복사(defensive copy)하세요. 예:
int[] copy = Arrays.copyOf(data, data.length);
6. 일반 클래스를 직접 불변으로 만드는 방법
일반 클래스를 immutable(불변)로 만들고 싶다면 수작업으로 다음을 해야 합니다:
- 모든 필드를 private final로 표시한다,
- 세터를 추가하지 않는다,
- 모든 필드는 생성자를 통해서만 초기화한다,
- 필드가 가변 객체라면 방어적 복사(defensive copy)를 수행한다.
예시
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public int age() {
return age;
}
}
record 클래스로 하면 더 간단하고 짧습니다:
public record User(String name, int age) {}
7. record 불변성과 관련한 흔한 실수
오류 №1: 생성 후 필드를 변경하려는 시도.
초보자는 종종 record 객체에 대해 p.x = 42; 또는 p.x(42); 같은 코드를 씁니다. 하지만 컴파일러는 곧바로 말할 것입니다: "안 됩니다! 필드는 final이고, 세터가 없습니다."
오류 №2: record 컴포넌트로 가변 객체를 사용함.
record에 List, Map, 배열 또는 다른 가변 객체를 필드로 넣으면, record 자체가 그 내용 변경을 막아주지 않습니다. 예를 들어 record User(List<String> hobbies)가 있다면, 누군가 리스트에 원소를 추가하거나 삭제하면 그만큼 record 객체의 상태도 바뀝니다. 이를 방지하려면 불변 컬렉션(List.copyOf, Collections.unmodifiableList)을 사용하거나, record 생성자 내부에서 컬렉션을 복사하세요.
오류 №3: 불변성에 대한 오해.
어떤 이는 객체가 record이면 어떤 변경으로부터도 보호된다고 생각합니다. 하지만 필드가 가변 객체를 참조하면 그 내용은 바뀔 수 있고, 이는 예상치 못한 버그로 이어질 수 있습니다.
GO TO FULL VERSION