Java 14에 새롭게 추가된 record에 대해 알아보고 이를 활용할 방안을 제시하며 record 관련 질문들에 대비하는 시간을 가져보려고 한다.
Record의 경우 백엔드 프로젝트에서 dto를 class가 아니라 record 타입으로 짜는 다른 분의 코드를 보고 처음 알게 되어 내가 직접 dto에 record를 적용하면서 record의 진가에 대해 알게 되었다.
Record란?
레코드는 코틀린의 data class라고 생각하면 이해하기 쉽다. 정말 순수하게 코틀린의 data class처럼 데이터를 보유하기 위한 specific class이다.
아래는 실제 내가 프로젝트에서 적용한 record이다.
package com.sejong.sejongpeer.domain.honbab.dto.response;
import com.sejong.sejongpeer.domain.honbab.entity.honbab.Honbab;
import com.sejong.sejongpeer.domain.member.entity.Member;
public record MatchingPartnerInfoResponse(
String collegeMajor,
Integer grade,
String name,
String kakaoAccount,
String menuCategoryOption
) {
public static MatchingPartnerInfoResponse of(Member member, Honbab honbab) {
return new MatchingPartnerInfoResponse(member.getCollegeMajor().getMajor(),
member.getGrade(), member.getName(), member.getKakaoAccount(), honbab.getMenuCategoryOption().toString());
}
}
클래스로 구현을 했다면 훨씬 코드의 길이가 길어졌을 텐데 아래의 record 특징 덕분에 필드만으로도 충분히 제 기능을 할 수 있게 된다.
- record 클래스는 final 클래스라 상속 불가능하다
- 각 필드는 private final 필드로 정의된다
- 모든 필드를 초기화 하는 RequiredAllArgument 생성자가 자동으로 생성된다
- 각 필드의 getter는 필드명을 딴 getter가 자동으로 생성된다
(위 두 특징 덕분에 lombok annotation이 별도로 필요하지 않다)
- equals, hashCode, toString 메소드가 자동으로 생성된다
- record 클래스는 위에서처럼 static&public 메소드와 static 변수를 가질 수 있다
- record 클래스를 spring controller와 연계해서 사용하면 간결한 injection이 가능하다
record에 대해 다시 한번 제대로 요약하면 아래와 같다.
- record는 불변 객체로 abstract로 선언할 수 없으며 암시적으로 final로 선언된다. 한번 값이 정해지면 setter를 통해 값을 변경할 수 없으며 상속 불가능하다.
- record 내 각 필드는 private final로 정의된다.
- 다른 클래스를 상속 받을 수는 없지만 interface로는 구현이 가능하다 (extends는 안되지만 implements는 가능)
- 레코드 내부에 멤버 변수(인스턴스 필드)를 선언할 수 없다. 그러나 static 변수는 생성 가능하다
- 클래스와의 공통점 : new 키워드로 객체화 가능, static 메소드 및 필드 선언 가능, 중첩 클래스 사용 가능, 제네릭 타입으로 지정 가능
기술 면접 대비
Record를 사용하면 안되는 경우에는 어떤 사례가 있는가
record는 final 클래스이기 때문에 abstract로 선언이 불가능하다.
지연 로딩 방식의 경우 JPA는 엔티티 객체의 프록시 객체를 생성하는데 프록시 객체는 원본 객체를 상속하여 생성된 확장 클래스이다. 따라서 record는 JPA의 지연로딩 때문에 entity에서 사용할 수 없다.
Record에 setter를 사용하는 경우는 무엇인가
레코드는 기본적으로 불변임을 보장해주기 때문에 기존 dto를 클래스로 구현할 때와 달리 final 속성을 붙이고 setter를 붙이는 수고로움을 덜 수 있다. 하지만 dto의 데이터가 수정되는 일은 존재할 수 있으므로 데이터가 가공이 되어야 한다면 record가 아니라 class로 변환하여 @setter를 붙이는 것이 바람직하다.
equals, hashCode가 왜 필요한가
객체의 상태_가지고 있는 값_가 완전히 같아도(동등성equals) 주소 공간이 다르면 동일한 객체가 아니기 때문에 동일성==에 위배가 된다. 상태 간 비교가 아닌 더 큰 개념에서 객체와 객체 자체를 비교하기 위해서 equals라는 메소드를 재정의해 "너 얘랑 같아?"라는 질문만 던질 수 있는 동등성을 판단해야 한다. 또한 동등성 관리를 제대로 하지 않을 경우 같은 클라이언트를 다른 것으로 인식해버릴 수 있기 때문에 필요하다.
아래는 동등성을 고려하지 않아(equals를 구현하지 않아) 발생하는 오류이다.
public static void main(String[] args) {
Car car1 = new Car(1,"sonata");
Car car2 = new Car(1,"sonata");
System.out.println(car1.equals(car2)); //false
}
// equals 재정의를 했다면 true로 찍혀야 한다
// 아래와 같이 equals를 재정의해야 한다
public class Car {
private final int id;
private final String name;
public Car(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Car car = (Car) o;
if (id != car.id) {
return false;
}
return Objects.equals(name, car.name);
}
}
hashCode는 hash collection 객체 때문에 필요하다. equals 재정의만으로 모든 객체의 동등성이 보장되지 못한다. 이 이유는 hash collection 자료구조 때문이다. hashMap, hashSet, hashTable와 같은 hash를 사용하는 collection들은 hashCode가 같아야지만 equals 메소드로 객체 비교를 수행할 수 있다. (hashCode가 다르면 입구컷을 당한다) 따라서 상태 값이 같을 때 hashCode도 같도록 오버라이딩을 해야한다.
// 위의 Car 클래스에 hashCode를 추가한다
@Override
public int hashCode() {
int result = id;
// result 할당 로직 등 추가
return result;
}
dto와 vo의 차이점이 무엇인가
dto는 계층 간 데이터 교환을 하기 위해 사용하는 로직을 가지지 않는 순수한 데이터 객체이다. 즉 dto는 getter와 setter 메소드만 가진 클래스(이 이외의 로직은 불필요)이고 보통은 entity를 dto 형태로 반환하여 service나 controller 등으로 보낼 때 사용된다. 원칙적으로는 vo처럼 불변성을 가지면 좋다.
vo는 dto와 달리 read-only 속성을 지닌 값 object_불변 객체_이다. vo의 경우 setter 없이 getter로 구성된다는 특징이 차이점이다.
다시 말해 dto와 vo의 차이점은 dto은 인터런스 개념이고 vo는 리터럴 값 개념이다. vo는 값들에 대해 read-only를 보장해줘야 존재의 신뢰성이 확보되지만 dto의 경우 데이터를 담는 그릇 역할을 한다. 값 자체에 의미가 있는 vo와 데이터를 보존해야 하는 dto의 관점에서 이 둘은 차이점이 있다.
그렇다면 어떤 경우 dto이고 어떤 경우 vo를 사용하는가
vo는 의미있는 비즈니스 개념을 포함하기 때문에 불변성을 갖고 비즈니스 로직에 사용되는 곳이 쓰인다.
public class Money {
private final int amount;
private final String currency;
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
// getters, equals, hashCode
// 금액 계산 등 비즈니스 로직 수행
}
dto는 데이터 전송을 위한 컨테이너 역할을 하기 때문에 로직을 포함하지 않는다. 따라서 서버와 클라이언트 간 통신이나 데이터베이스의 레코드를 가지고 오는 작업에 쓰이면 좋다.
즉 vo는 값의 불변성에 중점을 두고 dto는데이터의 전달과 변경에 초점을 맞춘다. 따라서 vo는 값이 같다면 동일한 객체로 취급하는 반면 dto는 식별자가 같다면 동일한 객체로 취급되고 내부 데이터는 변할 수 있다.
역직렬화 transient 키워드가 걸렸을 때 어떻게 해결해야 하는가
transient 키워드는 final 변수에 적용이 불가능하다. 따라서 record에 transient 키워드가 적용된 필드는 존재할 수 없으므로 질문이 모순되었다. 다만 필드로 modifiable한 객체를 사용해서 해당 객체 내 transient한 필드를 만드는 방법은 가능하다.
'CS > Java' 카테고리의 다른 글
| [Java] stream API (0) | 2024.04.24 |
|---|