@Data는 왜 쓰면 안돼요?

개발하는 구황작물·2023년 11월 24일
1

회사 프로젝트

목록 보기
5/8

회사 프로젝트 도중 상사분이 @Data 에 대해 어떻게 생각하냐고 물어보셨다.
나는 막연히 @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 에 포함되어 있다. )

위의 어노테이션으로 인해 부작용이 발생할 수 있다고 하는데 이유를 하나씩 확인해보겠다.

무분별한 @Setter 남용

위에서 봤듯이 @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 사용 지양으로 불필요한 변경 포인트를 없엘 수 있어 안정성을 취할 수 있다.

@ToString : 양방향 연관관계시 순환참조

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

@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

@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 그리고 생성자를 직접 만들어주라고 “말해봐야” 아무 소용없다. 그냥 깔끔하게 금지시키는게 낫다.

profile
어쩌다보니 개발하게 된 구황작물

0개의 댓글