1. DTO 클래스의 문제: 왜 record 클래스를 써야 할까?
솔직히 말해 봅시다. 이런 클래스를 몇 번이나 작성해 보셨나요?
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point{" + "x=" + x + ", y=" + y + '}';
}
}
그리 어렵진 않아 보여도, 줄 수를 세어 보세요! 이제 이런 클래스가 10개 있고, 각 클래스마다 필드가 5–6개라고 상상해 보세요. IDE조차 이 보일러플레이트를 생성하느라 지칩니다. 새 필드를 추가하기로 했다면 — 생성자, 게터, equals, hashCode, toString까지 일일이 수정해야 하죠... 지루하고 반복적이며 오류의 원인이 됩니다.
이런 클래스는 DTO(Data Transfer Object) 또는 Value Object라고 부릅니다. 그저 데이터를 담기만 합니다 — 그것뿐이죠. 하지만 보일러플레이트 때문에 유지 보수가 힘듭니다.
이게 문제가 아니라고 느껴지면, 이런 클래스 50개를 한 번에 바꿔야 할 때를 기다려 보세요. 그때 record 클래스를 각별히 그리워하게 될 겁니다!
2. record 입문: Java 16+의 문법과 마법
Java 16부터 모든 것이 달라졌습니다. 새로운 종류의 클래스 — record가 등장했죠. 데이터 묶음을 단순히 보관해야 하는 경우를 위해 만들어졌습니다. 문법은 다른 언어의 튜플과 거의 비슷합니다.
record를 어떻게 선언할까?
public record Point(int x, int y) { }
그리고... 끝! 지금 막 두 개의 필드, 생성자, 게터, equals, hashCode, toString을 가진 불변 클래스를 만들었습니다. 쓸데없는 장황함 없이요.
Java가 내부적으로 무엇을 해 주나?
- 필드 x와 y는 private final이 됩니다.
- 게터가 자동으로 생성됩니다: int x() 및 int y().
- 생성자: public Point(int x, int y).
- equals/hashCode: 모든 필드를 값으로 비교합니다.
- toString: "Point[x=1, y=2]" 형태의 문자열을 반환합니다.
요컨대, record는 “스테로이드를 맞은 DTO”입니다: 코드는 적고, 보장은 더 많고, 버그는 더 적죠.
불변성 (immutability)
record 클래스의 모든 필드는 자동으로 final입니다. 객체를 생성한 뒤에는 수정할 수 없으며 — 컴파일러가 이를 보장합니다.
세터를 추가하거나 필드를 final이 아니게 만들려고 하면 — 컴파일러가 막습니다. 이런 친절한 안전장치는 흔치 않죠!
3. record 클래스 사용 예
일반 클래스(코드 많음):
public class Client {
private final String name;
private final int id;
public Client(String name, int id) {
this.name = name;
this.id = id;
}
public String getName() { return name; }
public int getId() { return id; }
// equals, hashCode, toString ...
}
record 클래스(한 줄!):
public record Client(String name, int id) { }
사용 예:
public class Main {
public static void main(String[] args) {
Client client = new Client("이반", 123);
System.out.println(client.name()); // 이반
System.out.println(client.id()); // 123
System.out.println(client); // Client[name=이반, id=123]
}
}
주의하세요:
- 필드 접근 메서드의 이름은 필드 이름과 같습니다: name(), id().
- setName()이나 setId()가 없습니다 — 객체는 생성 후 변경할 수 없습니다.
4. record 클래스의 장점: 코드도 적고, 오류도 적다
코드는 적을수록 행복하다
40줄을 왜 쓰나요? 한 줄이면 됩니다. record 클래스는 특히 DTO와 value 객체가 많은 대규모 프로젝트에서 시간과 노력을 절약해 줍니다.
“계약에 의한” 불변성
- record 클래스는 항상 final이며 불변입니다.
- 객체를 위조하거나 실수로 변경할 수 없습니다.
- 예상치 못한 위치에서 객체 상태가 바뀌어 생기는 “이상한” 버그가 없습니다.
- 모든 필드도 immutable이라면 멀티스레드 프로그램에서 안전하게 사용할 수 있습니다.
equals/hashCode/toString 자동 생성
비교, 해시 계산, 예쁜 출력 메서드를 손으로 작성할 필요가 없습니다. 자동으로, 그리고 올바르게 처리됩니다.
Client c1 = new Client("안나", 42);
Client c2 = new Client("안나", 42);
System.out.println(c1.equals(c2)); // true
System.out.println(c1.hashCode() == c2.hashCode()); // true
System.out.println(c1); // Client[name=안나, id=42]
컬렉션과 키에 제격
record 객체는 HashMap의 키, HashSet의 요소 등으로 사용할 수 있습니다 — equals와 hashCode가 모든 필드를 반영하므로 올바르게 동작합니다.
import java.util.HashMap;
import java.util.Map;
Map<Client, String> clients = new HashMap<>();
clients.put(new Client("안나", 42), "VIP");
System.out.println(clients.get(new Client("안나", 42))); // VIP
데이터를 명시적으로 표현
record 클래스의 문법을 보면 어떤 데이터를 담고 있으며 객체가 불변이라는 점이 즉시 드러납니다. 이는 다른 개발자(그리고 6개월 뒤의 여러분)에게 코드를 더 이해하기 쉽게 만듭니다.
5. 표: 일반 클래스와 record 클래스 비교
| 일반 클래스 | record 클래스 | |
|---|---|---|
| 문법 | 코드 많음 | 한 줄 |
| 불변성 | 직접 구현해야 함 | 컴파일러가 보장 |
| 메서드 자동 생성 | 아니오 | 예 (equals, hashCode, toString) |
| 필드 추가 가능 | 예 | record 컴포넌트만 가능 |
| 상속 | 상속 가능 | 항상 final, 상속 불가 |
| 컬렉션에서의 사용 | 메서드를 올바르게 구현해야 함 | 바로 동작함 |
6. 학습용 애플리케이션 확장: record 클래스 예시
예를 들어, 학습용 뱅킹 애플리케이션에서 계좌 거래를 저장해야 한다고 합시다: 날짜, 금액, 거래 유형(예: “입금” 또는 “출금”).
Java 16 이전:
public class Transaction {
private final LocalDate date;
private final double amount;
private final String type;
public Transaction(LocalDate date, double amount, String type) {
this.date = date;
this.amount = amount;
this.type = type;
}
public LocalDate getDate() { return date; }
public double getAmount() { return amount; }
public String getType() { return type; }
// equals, hashCode, toString ...
}
Java 16과 record 사용:
import java.time.LocalDate;
public record Transaction(LocalDate date, double amount, String type) { }
사용 예:
Transaction t = new Transaction(LocalDate.now(), 100.0, "deposit");
System.out.println(t); // Transaction[date=2024-06-01, amount=100.0, type=deposit]
System.out.println(t.amount()); // 100.0
7. 시각화: record가 생성해 주는 것
“펼쳐진” record 클래스를 살펴봅시다(컴파일러가 대략적으로 생성하는 것):
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) { /* 필드 기준 비교 */ }
@Override
public int hashCode() { /* 필드 기준 계산 */ }
@Override
public String toString() { /* 보기 좋은 출력 */ }
}
요약: record를 언제 쓰면 좋을까
- 불변 데이터 묶음이 필요할 때.
- 수작업 없이도 “정직한” equals/hashCode/toString이 필요할 때.
- DTO, Value Object, 쌍/삼중쌍, 색상, 점, 구간, 컬렉션 키 등과 같은 용도를 만들 때.
8. record 클래스를 사용할 때 흔한 실수
오류 №1: 세터를 추가하거나 생성 후 필드를 변경하려는 시도.
record 클래스는 자신의 필드를 변경할 수 없습니다. setX(int x) 같은 메서드를 추가하려 하면 컴파일러가 곧바로 “안 됩니다”라고 말합니다. 필드를 직접 변경하려는 시도도 마찬가지입니다.
오류 №2: 비정적 필드를 추가하려는 시도.
record 클래스에서는 컴포넌트(레코드 이름 뒤 괄호 안에 지정된 필드)와 정적 필드만 선언할 수 있습니다. 일반 비정적 필드는 추가할 수 없으며 — 컴파일러가 허용하지 않습니다.
오류 №3: mutable 로직에 record를 사용하는 것.
record 클래스는 상태가 변하는 객체를 위한 것이 아닙니다. 생성 후 무언가를 변경해야 한다면 — 일반 클래스를 사용하세요.
오류 №4: record는 항상 final이라는 점을 잊는 것.
record 클래스는 상속할 수 없고, 슈퍼클래스로 만들 수도 없습니다. 이 제한을 어기려 하면 컴파일 오류가 발생합니다. 핵심 포인트 — record를 “확장”하려 하지 마세요: 완결된 불변 타입으로 설계되었습니다.
오류 №5: 자동 생성되는 메서드를 무시하는 것.
equals, hashCode 또는 toString을 재정의한다면 주의하세요 — 그 계약을 깨지 말아야 컬렉션과 비교가 올바르게 동작합니다.
GO TO FULL VERSION