회사 프로젝트 도중 상사분이 @Data
에 대해 어떻게 생각하냐고 물어보셨다.
나는 막연히 @Data
는 쓰면 안된다고 알고 있어서 쓰면 안된다고 알고 있다라고만 이야기 할 수 밖에 없었다.
이를 계기로 막연히 안된다고만 알기보다는 왜 안되는지 정확히 알기 위해 찾아보게 되었다.
/* ...
*
* @see Getter
* @see Setter
* @see RequiredArgsConstructor
* @see ToString
* @see EqualsAndHashCode
* @see lombok.Value
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Data {
/**
...
*/
String staticConstructor() default "";
}
위의 @Data
어노테이션을 보면 알 듯이 @Getter, @Setter
, @EqualsAndHashCode
, @ToString
, @RequiredArgsConstructor
,@AllArgsConstructor
를 모두 포함하고 있다. (@AllArgsConstructor
는 @Value
에 포함되어 있다. )
위의 어노테이션으로 인해 부작용이 발생할 수 있다고 하는데 이유를 하나씩 확인해보겠다.
위에서 봤듯이 @Data에는 @Setter이 들어가있다. @Setter를 사용하면 의도가 불명확하고 변경하는 안되는 값임에도 불구하고 변경 가능한 값이라고 착각하고 바꿀 수 있다.(안전성 보장x)
극단적인 예시를 들면
@Setter
public class Project {
private Integer id;
private Integer memberId; // 바뀌면 안되는 값
private String title;
private String description;
...
public static void main(String[] args) {
ProjectModel projectModel = new ProjectModel(1, "project title", "project name"); //현재 memberId = 1
projectModel.setMemberId(3); // !!!!
}
}
@Setter로 인해 위와 같이 memberId
같이 바뀌면 안되는 값까지 언제든지 바꿀 수 있다.
@Setter 사용 지양으로 불필요한 변경 포인트를 없엘 수 있어 안정성을 취할 수 있다.
JPA에서 N:1관계 양방향 참조시 @ToString을 사용하면 순환 참조 문제가 발생할 수 있다.
Project와 Member라는 도메인이 있고 Project와 Member가 1:N 관계를 맺고 있다고 가정하겠다.
@ToString
@Entity
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@OneToMany(mappedBy = "project")
private List<Member> members = new ArryaList<>();
private String title;
private String description;
...
}
@ToString
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@JoinColumn(name = "projectId")
@ManyToOne
private Project project;
private String title;
private String description;
...
}
위와 같은 예시에서 Member를 조회하면
Member toString이 호출되면서 Stack에 메모리가 쌓이고 연관관계를 정의했으므로 Member의 Project가 불려지면서 Project toString이 호출되고 다시 Project 내의 Member를 호출하면서 또 toString을 호출하면서 Project 를 호출하고 그렇게 무한 참조가 걸리고 Stack 메모리가 차면서 StackOverFlowError가 걸리게 된다.
이런 문제를 해결하기 위해서는 @ToString(exclude = "project")처럼 어노테이션을 사용하여 특정 항목을 제외시킬 수 있다.
@EqualsAndHashCode는 equals()와 hashCode() 메소드를 자동 생성해준다.
equals()는 두 객체의 내용이 같은지 동등성을 비교하고
hashCode()는 두 객체가 같은 객체인지 동일성을 비교한다.
좋은 기능이나 Mutable
객체에 아무런 파라미터 없이 그냥 사용하는 경우이다.
public class Main {
@EqualsAndHashCode
static class MainClass {
private Long id;
private String name;
private int age;
public MainClass(Long id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
public void setAge(int age) {
this.age = age;
}
}
public static void main(String[] args) {
MainClass mainClass = new MainClass(1L, "name1", 10);
Set<MainClass> mainClasses = new HashSet<>();
mainClasses.add(mainClass);
System.out.printf("변경전 = %s \n", mainClasses.contains(mainClass));
//변경전 = true
mainClass.setAge(20);
System.out.printf("변경후 = %s", mainClasses.contains(mainClass));
//변경후 = false
}
}
필드 값이 변경되어 hashCode도 같이 변경되어 동일한 객체임에도 찾을 수 없게 된다.
HashSet는 contains로 검색시 hashCode로 인덱스를 계산하여 검색한다.
이를 방지하기 위해서는 @EqualsAndHashCode(of = {필드명})
으로 동등성 비교에 필요한 필드를 지정해주어야 한다.
public class Main {
@EqualsAndHashCode(of = {"id"})
static class MainClass {
// ... 위와 같은 코드
}
public static void main(String[] args) {
// ... 위와 같은 코드
System.out.printf("변경전 = %s \n", mainClasses.contains(mainClass));
//변경전 = true
mainClass.setAge(20);
System.out.printf("변경후 = %s", mainClasses.contains(mainClass));
//변경후 = true
}
}
@RequiredArgsConstructor
를 설명하기 이전에 @AllArgsConstructor
에 대해 설명하자면
@AllArgsConstructor
는 클래스 내부에 선언된 모든 field마다 하나의 parameter를 가진 생성자를 생성한다.
@AllArgsConstructor
를 풀어쓰면 아래와 같다
public class HighSchoolStudent {
private int age;
private int grade;
public HighSchoolStudent(int age, int grade) {
this.age = age;
this.grade = grade;
}
}
편리하지만 단점이 있다면
public class RequiredArgConstructorTest {
@AllArgsConstructor
public static class HighSchoolStudent {
private int age;
private int grade;
}
public static void main(String[] args) {
HighSchoolStudent student = new HighSchoolStudent(1, 17); //!!
}
}
같은 타입인 필드가 나란히 있을 때 개발자의 실수로 위치를 바꿔먹을 수 있다.
고등학생이 1살이 되는 기적
@RequiredArgsConstructor
는 final이나 @NonNull같은 Constraints가 붙은 field의 parameter를 가진 생성자를 생성한다.
그러므로 @RequiredArgsConstructor
에도 @AllArgsConstructor
에서 나타나는 문제점이 나타난다.
@Data
를 사용할 때는 equals, hashCode 그리고 생성자를 직접 만들어주면 @Data
를 사용해도 문제점이 발생하지 않을 것이다.
그런데 굳이 일일이 주의사항을 지키라고 말하는 것보단 아예 쓰지 말라고 못 박는게 나을 것 같다.
일일이 주의사항 말하는 것보단 안 돼! 라고 말하는게 더 알아먹기 쉬운 법이다.
어차피 @Data 안써도 대체 방법이 있기도 하고...
어떤 블로그에서 본 글인데 상당히 공감한 글이다.
개발자에게 @Data 사용시 equals, hashCode 그리고 생성자를 직접 만들어주라고 “말해봐야” 아무 소용없다. 그냥 깔끔하게 금지시키는게 낫다.