자바는 값을 담는 클래스에 손이 너무 많이 갔다. 좌표나 사용자 정보처럼 값 몇 개를 묶어 나르는 클래스를 만들 때, 자바는 유독 장황했다. 필드를 선언하고, 그 필드를 받는 생성자를 쓰고, 값을 읽는 접근자를 필드마다 만들고, 값으로 비교하는 equals와 그 짝인 hashCode, 출력용 toString까지 손으로 적어야 했다. 필드가 세 개면 이 뻔한 코드가 수십 줄로 늘고, 필드를 하나 추가하면 이 모두를 또 고쳐야 했다. 이 지겨운 반복을 없애려고 나온 것이 record다.


record는 한 줄로 데이터 클래스를 완성한다. JDK 16부터 정식이 된 record는 괄호 안에 담을 값들의 타입과 이름만 적으면 나머지를 컴파일러가 다 만들어 준다. 아래 한 줄이 앞서 말한 수십 줄과 맞먹는다.


public record Point(int x, int y) {}


컴파일러가 뒤에서 많은 것을 만들어 준다. 저 한 줄로 자바는 x, y 필드와 그 둘을 받는 생성자를 만든다. 접근자도 만드는데 이름이 getX가 아니라 그냥 x()다. 값을 감추기보다 그대로 드러내 나르는 것이 목적이라 이 단순한 이름을 택했다. 모든 값이 같으면 같다고 판단하는 equals와 그에 맞는 hashCode, 보기 좋은 toString까지 자동으로 생성한다.


Point p = new Point(3, 4);
System.out.println(p.x()); // 3 (getX가 아님)
System.out.println(p); // Point[x=3, y=4]
Point q = new Point(3, 4);
System.out.println(p.equals(q)); // true (값이 같으면 같다)


record는 한 번 만들면 바뀌지 않는다. record의 필드는 모두 final이라 만들어진 뒤에는 값을 바꿀 수 없고 setter도 없다. 값을 바꾸고 싶으면 바뀐 값을 담은 새 record를 만든다. 이 불변성 덕에 여러 곳에서 공유해도 누가 몰래 바꿀 걱정이 없고, 여러 스레드가 함께 써도 안전하며, HashMap의 키로 쓰기에도 좋다.


컴팩트 생성자로 값을 검증할 수 있다. 자동 생성에만 맡기면 아무 값이나 들어온다. 나이나 가격 같은 값은 음수가 들어오면 안 된다. record에는 컴팩트 생성자라는 문법이 있어서, 매개변수 목록을 다시 적지 않고도 검증만 끼워 넣을 수 있다. record 이름 뒤에 괄호 없이 본문만 적으면 그 검증이 자동 생성 생성자 앞에 실행된다.


public record Product(String name, int price) {
  public Product { // 컴팩트 생성자: 매개변수 목록 생략
    if (price < 0)
      throw new IllegalArgumentException("가격은 음수 불가");
  }
}


record에도 메서드를 더할 수 있다. 값만 담는다고 동작을 못 넣는 건 아니다. 다만 필드를 바꾸는 게 아니라 필드로부터 새 값을 계산해 돌려주는 형태여야 불변성과 어울린다. 값을 바꾼 결과가 필요하면 새 record를 만들어 돌려준다.


public record Point(int x, int y) {
  public double distanceTo(Point o) {
    int dx = x - o.x, dy = y - o.y;
    return Math.sqrt(dx * dx + dy * dy);
  }
  public Point moveX(int d) {
    return new Point(x + d, y); // 바꾸지 않고 새로 만든다
  }
}


record는 상속하지 못하지만 인터페이스는 구현한다. record는 다른 클래스를 물려받을 수 없고 사실상 final이다. 단순하고 예측 가능한 값 그릇으로 남게 하려는 제약이다. 대신 인터페이스는 얼마든지 구현할 수 있어서, Comparable을 구현해 정렬 순서를 정하거나 다형성에 참여시킬 수 있다. 다음 편에서 볼 sealed 인터페이스와 엮으면 특히 강력한 조합이 된다.


record는 switch 패턴 매칭과 만나 빛난다. JDK 21부터는 switchinstanceof에서 record의 속을 바로 분해해 받을 수 있다. case Point(int x, int y)라고 쓰면 들어온 것이 Point인지 확인하면서 동시에 그 안의 x, y를 꺼내 변수로 받는다. 접근자를 일일이 부를 필요 없이 구조를 그대로 풀어 헤친다.


Object obj = new Point(3, 4);
String msg = switch (obj) {
  case Point(int x, int y) -> "좌표 " + x + ", " + y;
  default -> "알 수 없음";
};


완벽히 불변이려면 담는 값도 불변이어야 한다. 필드가 final이라 다른 객체로 바꿔치기는 못 해도, 그 필드가 가리키는 객체 자체가 가변이면 이야기가 다르다. recordList를 품으면 참조는 못 바꿔도 그 안의 원소는 밖에서 바뀔 수 있다. 진짜 불변을 원하면 컴팩트 생성자에서 방어적 복사를 한다. "겉을 얼려도 속이 가변이면 반쯤만 불변"임을 기억해 두면 함정을 피한다.


public record Team(String name, List<String> members) {
  public Team {
    members = List.copyOf(members); // 방어적 복사로 진짜 불변
  }
}


메서드가 값 여러 개를 돌려줘야 할 때 record가 답이다. 자바 메서드는 값을 하나만 돌려준다. 최솟값과 최댓값을 함께 돌려주고 싶을 때, 예전에는 배열에 담아 어느 칸이 무엇인지 헷갈렸다. 돌려줄 값들을 이름과 함께 묶은 작은 record를 두면 받는 쪽이 result.min()처럼 뜻이 또렷한 이름으로 꺼낸다.


public record MinMax(int min, int max) {}
public MinMax range(int[] arr) {
  int lo = arr[0], hi = arr[0];
  for (int n : arr) { lo = Math.min(lo, n); hi = Math.max(hi, n); }
  return new MinMax(lo, hi);
}


record를 쓸 때 흔한 오해도 짚어 둔다. 첫째, 값을 못 바꾼다는 것이지 못 쓴다는 게 아니다. 바뀐 값이 필요하면 새 record를 만들면 된다. 둘째, 접근자에 get이 안 붙는 걸 잊고 getX를 부르다 컴파일 에러를 만나는 일이 잦다. 셋째, 모든 데이터 클래스를 record로 바꾸려는 욕심은 과하다. 상태가 바뀌거나 상속이 필요한 곳에는 여전히 일반 클래스가 맞다. record는 불변 값 그릇이라는 좁고 또렷한 목적에 쓸 때 가장 빛난다.


다음 편에서는 열거형과 sealed 클래스를 다룬다. record로 값을 간결하게 담는 법을 익혔으니, 다음은 정해진 몇 가지 중 하나를 표현하는 열거형과, 상속 가능한 자식을 명시적으로 제한하는 sealed다. 이 둘을 record, 그리고 앞서 본 switch 패턴 매칭과 엮으면 상태를 안전하게 모델링하는 자바의 현대적인 방식이 완성된다.