Java Record

작업을 하다보면 intelliJ가 람다를 메서드 참조로 변경을 추천(?)할 때 처럼 클래스를 record로 추천해주는 경우를 종종 보았다. 그리고 인프런의 강의를 들으면서 record를 사용하는 강사님들을 보았다. 작업을 진행하니 DTO을 만들 때 record를 사용하면 편해서 요즘 작업할 때 적극 사용하고 있다.

// 람다
str -> str.toUpperCase()
str -> System.out.println(str)

// 메서드 참조
String::upUpperCase
System.out::println

기존에 사용하던 DTO

자주 사용하던 DTO (Lombok을 자주 사용하였지만 여기서는 순수 Java 코드)를 IDE를 활용하여 작성

public final class MemberClassDto {
    private final String name;
    private final int age;

    public MemberClassDto(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public boolean equals(Object object) {
        if (object == null || getClass() != object.getClass()) return false;
        MemberClassDto that = (MemberClassDto) object;
        return age == that.age && Objects.equals(name, that.name);
    }

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

    @Override
    public String toString() {
        return "MemberClassDto{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

public record MemberRecordDto(String name, int age) {

}

private으로 캡슐화를 통해 내부 구현을 숨기고, 값 변경을 통제해 불변식과 유효성을 유지한다. 결합도와 실수를 줄이며, 보안과 동시에 안전성도 높여준다.
final 필드는 생성 후 재할당을 막아 불변과 불변식 유지로 버그를 줄인다. 스레드 안전성 (JMM final field semantics)으로 초기값 가시성이 보장되어 스레드 안전하고, equals/hashCode 안정으로 컬렉션 키에도 안전하다. 의도가 명확하고 최적화에도 유리하다.
따라서 불변 객체(Value Object)로 사용하기에 적합하다.

100% 필수는 아니지만 대부분의 불변 객체에서는 toString(), equals(), hashCode()가 필수에 가깝다.

toString(): 디버깅과 로깅에 용이하다. 불변 객체는 주로 데이터 중심 클래스이므로 상태 확인이 자주 필요하며 의미 있는 toString()이 있으면 디버깅이 쉬워진다.

equals(): 내용이 같으면 같다고 판단해야 한다. 불변 객체는 상태가 한 번 정해지면 바뀌지 않으므로, 내용으로 객체를 비교하는 게 합리적이다. ==는 참고(주소)를 비교하지만, 불볍 객체는 값 기반 비교가 필요하다.

hashCode(): HashMap, HashSet 등에서 안전하게 사용하기 위해서 필요하다. equals()가 true인 객체들은 항상 같은 hashCode()를 반환해야 하고, 불변 객체는 값이 바뀌지 않기 때문에 해시값도 고정된다. 그러면 해시 컬렉션에 안정적으로 키로 사용이 가능해진다.

Java Record 특징

1. 불변성 (Immutability)

객체 지향 프로그래밍에 있어서 불변객체는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다.

public record MemberRecordDto(String name, int age) {

}

MemberRecordDto member = new MemberRecordDto("혁", 100);
// person.name = "혁";
// 모든 필드가 private final로 생성됨

2. 자동 생성 메서드들

public record MemberRecordDto(String name, int age) {

}

MemberRecordDto member = new MemberRecordDto("혁", 100);
member.name() // getter 대신 accessor
member.age() // getAge()가 아닌 age()
member.toString(); // "MemberRecordDto[name=혁, age=100]"
member.equals(otherMember) // 모든 필드 비교
member.hashCode() // 모든 필드 기반

3. 암묵적으로 final

// Record는 자동으로 final 클래스
public final class MemberRecordDto extends Record {
    private final String name;
    private final int age;
}

4. 극단적인 간결성

Lombok을 사용해도 보일러플레이트 코드는 여전히 반복적으로 발생하며, 여러 개의 어노테이션을 함께 사용해야 하는 번거로움이 있다.
@Value를 활용하면 더욱 간소화 할 수 있다고 들었지만, 사용해 본 적은 없다.

@Getter
@AllArgsConstructor 
@EqualsAndHashCode
@ToString
public final class MemberClassDto() {
    private final String name;
    private final int age;
}

public record MemberRecordDto(String name, int age) {

}

Record를 사용하는 주된 이유는 위 4개의 이유때문이고 공부하면서 알게된 사실은 아래와 같다. 실무를 하다 Record를 사용하는데 “아래 내용이 이래서 필요하구나”를 느낄 때 주된 이유에 올려야겠다.

5. 성능과 메모리

  • 더 작은 바이트코드: 컴파일러 최적화
  • 객체 생성 오버헤드 감소: 직접적인 생성자 호출
  • JVM 최적화: Record는 JVM이 특별히 최적화

6. 상속 제약

// Record는 다른 클래스 상속 불가능
public record MemberRecordDto(String name, int age) extends Object {}

// Record를 상속 불가능
public MemberDetailRecordDto(String address) extends MemberRecordDto {}

// 인터페이스는 구현 가능
public record MemberRecordDto(String name, int age) implements Serializable {}
Record에서 상속이 불가능한 이유
  • 목적이 단순 데이터 전달이기 때문 (불변 객체)
  • 생성자, equals, hashCode 등 자동 생성 메서드가 상속 시 충돌 우려
  • 불변성과 단순성을 지키기 위해 상속 계층을 허용하지 않음
Record가 인터페이스 구현이 가능한 이유
  • 인터페이스는 구현 없는 계약한 정의하므로 구조적 충돌 없음
  • 상태를 공유하지 않아서 불변성에도 영향 없음
  • 다형성을 통해 일관된 방식으로 처리 가능

상속은 상태를 공유하고 구현체를 상속받아 복잡성을 증가시키며 불변성을 위험에 빠뜨린다. 반면 인터페이스는 계약만 정의하고 구현을 직접 제공하므로 단순성을 유지하면서 불변성을 보장한다

Record는 데이터 보관에 집중하고, 인터페이스를 통해 기능적 계약만 추가로 제공할 수 있도록 설게되었다. 이는 단순함과 안정성을 유지하면서도 필요한 유연성을 제공하는 균형잡힌 설계이다.Record가 상속을 허용하지 않는 것은 복잡성을 피하고 본래 목적인 순수한 데이터 전달에 집중하기 위함이며, 인터페이스 구현을 허용하는 것은 기능적 확장성을 제공하되 핵심 설계 원칙은 해치지 않기 위함이다

7. 컴팩트 생성자

public record Temperature(double celsius) {
    // 매개변수 없는 특별한 생성자
    public Temperature {
        if (celsius < -273.15) {
            throw new IllegalArgumentException("절대영도 이하 불가능");
        }
        // 필드 할당은 자동으로 처리됨
    }
}

8. 제네릭 지원

9. 정적 메서드와 인스턴스 메서드 추가 기능

10. 직렬화 지원

11. 리플렉션 지원

12. 어노테이션 적용

record는 인스턴스 필드 추가와 상속이 불가하며 항상 불변이다. DTO(Data Tranfer Object), VO(Value Object), API 응답/요청 객체 등에 적합하며 record는 순수한 데이터 보관에 특화된 간결하고 안전한 클래스이다.