thenullpage.com
좌표 하나를 담을 뿐인 클래스에 필드 둘, 생성자, 게터 둘, equals, hashCode, toString까지 적으면 예순 줄이 넘어간다. 정작 그 클래스가 하는 일은 x와 y를 들고 다니는 것뿐이다. 값만 묶어 나르는 클래스를 짧게 쓰라고 자바가 내놓은 문법이 record다.
record는 한 줄로 끝난다. class 대신 record라고 적고 괄호 안에 담을 값을 나열한다.
public record Point(int x, int y) {}
Point p = new Point(3, 4);
System.out.println(p.x()); // 3
System.out.println(p); // Point[x=3, y=4]
괄호 안에 적은 x와 y를 컴포넌트라고 부른다. 각각 private final 필드가 되고, 값을 돌려주는 메서드가 필드와 같은 이름으로 생긴다. getX가 아니라 x()다. 전부를 받는 생성자와 읽을 만한 toString도 같이 만들어진다. 저 한 줄이 예순 줄을 대신한다.
값이 같으면 같은 객체로 친다. 일반 클래스와 나란히 놓아 보면 차이가 바로 보인다.
class PlainPoint {
int x, y;
PlainPoint(int x, int y) { this.x = x; this.y = y; }
}
PlainPoint a1 = new PlainPoint(3, 4);
PlainPoint a2 = new PlainPoint(3, 4);
System.out.println(a1.equals(a2)); // false
Point b1 = new Point(3, 4);
Point b2 = new Point(3, 4);
System.out.println(b1.equals(b2)); // true
일반 클래스의 equals는 주소를 비교해서 값이 같아도 남남으로 나온다. record는 컴포넌트를 하나씩 비교하는 equals와 그 결과에 맞는 hashCode를 같이 만들어 준다. 그래서 Set이나 Map 키로 넣어도 값 기준으로 동작한다.
Set<Point> set = new HashSet<>();
set.add(new Point(3, 4));
set.add(new Point(3, 4));
System.out.println(set.size()); // 1
한번 만든 값은 못 바꾼다. 필드가 전부 final이라 대입하는 줄이 컴파일되지 않고 setter도 없다.
Point p = new Point(3, 4);
// p.x = 10;
// 컴파일 에러: cannot assign a value to final variable x
Point moved = new Point(p.x() + 1, p.y());
System.out.println(p); // Point[x=3, y=4]
System.out.println(moved); // Point[x=4, y=4]
값을 고치는 대신 고쳐진 값을 가진 새 객체를 만든다. 원본은 그대로 남는다. 여기저기 넘겨줘도 받은 쪽이 몰래 바꿔 놓을 수 없으니, 스레드 여럿이 동시에 읽어도 따로 잠글 것이 없다.
검증은 압축 생성자에 적는다. 매개변수 목록도 this 대입도 없이 이름만 적은 생성자를 쓸 수 있다.
public record Order(String item, int quantity) {
public Order {
if (quantity <= 0) {
throw new IllegalArgumentException("수량은 1 이상이어야 한다");
}
item = item.trim();
}
}
System.out.println(new Order(" 키보드 ", 2));
// 출력: Order[item=키보드, quantity=2]
new Order("마우스", 0);
// Exception in thread "main" java.lang.IllegalArgumentException:
// 수량은 1 이상이어야 한다
이 형태를 압축 생성자라고 한다. 검사만 적어 두면 필드에 넣는 코드는 자바가 뒤에 붙여 준다. 매개변수 이름에 값을 다시 대입하면 그 값이 필드로 들어가기 때문에 위처럼 앞뒤 공백을 떼어 저장할 수도 있다. 잘못된 값을 든 record는 아예 만들어지지 않는다.
메서드는 얼마든지 더 붙인다. 값 계산이나 사본 만들기를 안에 두면 쓰는 쪽이 짧아진다.
public record Point(int x, int y) {
public double distance() {
return Math.sqrt(x * x + y * y);
}
public Point withX(int newX) {
return new Point(newX, y);
}
}
System.out.println(new Point(3, 4).distance()); // 5.0
System.out.println(new Point(3, 4).withX(9)); // Point[x=9, y=4]
메서드 안에서는 x처럼 컴포넌트 이름을 그대로 쓴다. 값 하나만 바꾼 사본이 자주 필요하면 withX 같은 메서드를 만들어 두는 게 흔한 방식이다. static 메서드도 자유롭게 넣는다.
불변은 껍데기까지만이다. 컴포넌트가 목록이면 그 안은 여전히 밖에서 바뀐다.
public record Team(String name, List<String> members) {}
List<String> list = new ArrayList<>();
list.add("김개발");
Team t = new Team("백엔드", list);
list.add("박서버");
System.out.println(t.members()); // [김개발, 박서버]
record가 지켜 주는 건 members가 다른 목록을 가리키지 못한다는 것뿐이다. 원본 변수를 쥔 쪽은 내용을 계속 건드릴 수 있다. 들어올 때 복사해 두면 막힌다.
public record Team(String name, List<String> members) {
public Team {
members = List.copyOf(members);
}
}
list.add("박서버");
System.out.println(t.members()); // [김개발]
t.members().add("최운영");
// Exception in thread "main" java.lang.UnsupportedOperationException
List.copyOf는 내용을 복사한 읽기 전용 목록을 돌려준다. 원본을 나중에 고쳐도 영향이 없고, 꺼내서 add를 시도하면 예외가 난다. 안까지 막으려면 이 한 줄이 필요하다.
record가 못 하는 것도 분명하다.
public record Point3D(int x, int y, int z) extends Point {}
// 컴파일 에러: no extends clause allowed for record
public record Counter(int start) {
private int count;
// 컴파일 에러: field declaration must be static
}
record는 이미 Record를 물려받은 상태고 자신도 final이라 다른 클래스를 상속하지도, 누가 상속하지도 못한다. implements로 인터페이스를 구현하는 것은 된다. 값이 계속 바뀌는 대상은 그냥 클래스로 두는 게 맞다. 좌표, 금액, 응답 한 건처럼 값 몇 개가 한 덩어리로 다니고 만들어진 뒤로는 변하지 않는 자리가 record 자리다.
정리하면 셋이다. 컴포넌트만 적으면 생성자와 게터, equals, hashCode, toString이 따라온다는 것, 값 비교가 기본이라 Set과 Map 키로 바로 쓸 수 있다는 것, 불변은 참조까지라 목록을 담을 때는 List.copyOf로 복사해야 한다는 것이다. Point를 직접 쳐 놓고 p.x에 값을 넣어 보면 컴파일러가 어느 줄에서 막는지 그 자리에서 보인다.