[TIL] JPA - 좀 더 안전하게 Lombok을 사용하는 방법

phdljr·2023년 11월 17일
0

TIL

목록 보기
32/70

JPA를 사용할 때, Lombok에서 제공해주는 어노테이션을 아무 생각없이 사용해왔다.
기본 생성자와 getter를 자동으로 생성해준다는데 이를 안쓸 이유가 없었다.

하지만, Lombok에서 제공하는 다양한 어노테이션을 JPA에서 함부로 남용해버리면 문제가 일어날 수도 있다.

어떠한 문제가 있는지, 그리고 JPA에서는 어떻게 사용해야 안전할지에 대해 살펴보는 시간을 가져본다.


@Data의 사용을 지양해라

@Data@ToString, @EqualsAndHashCode, @Getter, @Setter, @RequiredArgsConstructor을 모두 포함하는 어노테이션이다.

하지만, @Getter를 제외한 나머지 어노테이션들은 문제가 일어날 수도 있다.

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

MemberCoupon 이 1:N 양방향으로 매핑되어 있는 상황을 가정할 수 있다.

이때, ToString을 호출하면 무한 순환 참조가 발생한다.

이러한 문제를 해결하기 위해서는 @ToString(exclude = "coupons") 처럼 어노테이션을 사용해서 특정 항목을 제외시키는 방법을 사용할 수 있다.

@EqualsAndHashCode

@EqualsAndHashCode는 상당히 고품질의 euqals()hashCode() 메소드를 만들어준다. 따라서 잘 사용하면 좋지만, 남발하면 심각한 문제가 생긴다.

특히 문제가 되는 점은 Mutable 객체에 아무런 파라미터 없이 그냥 사용하는 경우이다.

@EqualsAndHashCode
public static class Order {
    private Long orderId;
    private long orderPrice;
    private long cancelPrice;
    public Order(Long orderId, long orderPrice, long cancelPrice) {
        this.orderId = orderId;
        this.orderPrice = orderPrice;
        this.cancelPrice = cancelPrice;
    }
}
Order order = new Order(1000L, 19800L, 0L);
Set<Order> orders = new HashSet<>();
orders.add(order); // Set에 객체 추가
System.out.println("변경전 : " + orders.contains(order)); // true
order.setCancelPrice(5000L); // cancelPrice 값 변경
System.out.println("변경후 : " + orders.contains(order)); // false
위와 같이 동일한 객체여도 필드 값을 변경시키면 hashCode가 변경되면서 찾을 수 없는 값이 되버린다.

핵심은, 어노테이션 자체의 문제라기 보다는 변경 가능한 필드에 이를 남발함으로써 생기는 문제이다.

Immutable 클래스를 제외하고는 아무 파라미터 없는 @EqualsAndHashCode 사용은 지양한다.

항상 @EqualsAndHashCode(of={“필드명시”}) 형태로 동등성 비교에 필요한 필드를 명시하는 형태로 사용한다.

실무에서는 누군가는 이에 대해 실수하기 마련인지라 차라리 사용을 완전히 금지시키고, 꼭 필요한 필드를 지정하는 것이 나을 수도 있다.

무분별한 Setter 남용

의도가 분명하지 않고 객체를 언제든지 변경할 수 있는 상태가 되어서 객체의 안정성을 보장받기가 힘들다.

불필요한 변경 포인트를 제공하지 않음으로써 안정성을 취할 수 있다.

만약 변경 포인트가 필요하다면, 원하는 데이터만 변경할 수 있는 메소드를 따로 만들자.

public class Card extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String title;

    @Column
    private String content;

    @Column
    private boolean isFinished;

    @Column
    private boolean isHidden;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public void update(final CardRequestDto cardRequestDto) {
        this.title = cardRequestDto.getTitle();
        this.content = cardRequestDto.getContent();
    }

    public void toggleHide() {
        this.isHidden = !this.isHidden;
    }

    public void toggleFinish() {
        this.isFinished = !this.isFinished;
    }
}

@NoArgsConstructor 접근 권한 최소화

JPA에서는 프록시를 생성을 위해서 기본 생성자를 반드시 하나를 생성해야한다. 이때 접근 권한이 protected 이면 된다. 굳이 외부에서 생성을 열어둘 필요가 없다.

예를 들어, 다음과 같이 접근 권한이 public인 경우에 문제가 발생할 수 있다.

@Entity
@Table(name = "product")
@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC) // 테스트를 위해 임시로 Public
public class Product {
    @Id
    private String id;
    private String name;
    @Builder
    public Product(String name) {
        this.id = UUID.randomUUID().toString();
        this.name = name;
    }
}

기본 키 생성을 UUID로 가지도록 하였다. 하지만 public 생성자를 통해 객체를 생성하면 Id 값은 null 이 된다.

이처럼 기본 생성자를 아무 이유 없이 열어두는 것은 객체 생성 시 안전성을 심각하게 떨어뜨린다.

그렇다고 private으로 되어있으면 JPA가 프록시를 만들 때 접근하지 못해 객체를 생성하질 못하게 된다.

따라서, 스펙에서는 기본 생성자 접근을 protected로 열어두길 권장하고 있다.

위의 예에서는, 생성자 메소드에서 UUID 생성 코드를 넣어 객체 생성 시 반드시 ID 값을 보장받도록 하고 있다.

이처럼 객체에 대한 생성자를 하나로 두고 @Builder를 통해 사용하면 반드시 필요한 값이 있어야 객체가 생성됨을 보장할 수 있어 안전성을 높일 수 있다.

@AllArgsConstructor 사용 지양

다음과 같은 예를 들 수 있다.

@AllArgsConstructor
public static class Person {
    private String firstName;
    private String lastName;
}
// 성은 권, 이름은 현수
Person me = new Person("권", "현수");

위 클래스에 대해 자동으로 firstName, lastName 순서로 인자를 받는 생성자가 만들어진다. 그런데 누군가 lastName이 성인줄 알고 순서를 다음과 같이 바꾼다고 가정해보자

@AllArgsConstructor
public static class Person {
    private String lastName;
    private String firstName;
}

이 경우, IDE가 제공해주는 리팩토링이 전혀 작동하지 않고, lombok이 생성자의 파라미터 순서를 필드 선언 순서에 맞춰 변경해버린다.

게다가 이 두 필드는 동일한 Type 이라서 기존 생성자 호출 코드에서는 인자 순서를 변경하지 않았음에도, 어떠한 오류도 발생하지 않는다.

이러한 문제는 @AllArgsConstructor@RequiredArgsConstructor 에 둘 다 존재한다. 따라서 이 두 lombok 어노테이션은 사용하지 않도록 지향해야 한다.

그럼에도 사용해야 한다면, 같은 타입의 필드 위치가 변한다면 이를 주의깊게 봐야할 것이다.

생성자를 직접 만들어서 @Builder 사용

위와 같은 상황을 겪고싶지 않다면, 생성자를 직접 만들어서 @Builder를 적용해서 사용하면 된다.

대부분의 IDE에서 생성자를 자동으로 만들어 주는 기능이 있으니, 이를 활용해보자.

public static class Person {
    private String firstName;
    private String lastName;
    
    @Builder
    private Person(String firstName, String lastName){
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

심지어, 직접 생성자를 만들어서 @Builder를 적용한다면, 양방향 관계에서 @OneToMany 부분에 @Builder.Default를 달아주지 않아도 된다.

@Builder.Default는 빌더 패턴으로 객체를 생성할 시, 설정하지 않은 필드에 기본 값을 넣어주는 기능을 맡는다.

만약, 클래스 레벨에 @Builder를 사용한다면, 기본 값이 존재하는 모든 필드에 @Builder.Default를 달아줘야 할 것이다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "TB_CARD")
public class Card extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String title;

    @Column
    private String content;

    @Column
    private boolean isFinished;

    @Column
    private boolean isHidden;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @OneToMany(mappedBy = "card", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();

    @Builder
    public Card(final Long id, final String title, final String content, final boolean isFinished,
        final boolean isHidden,
        final User user) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.isFinished = isFinished;
        this.isHidden = isHidden;
        this.user = user;
    }
}

실제 프로젝트 리팩토링 적용

리팩토링 전 코드

@Entity
@NoArgsConstructor // 문제 상황 1. 기본 생성자의 접근 제어자가 불명확함 
@AllArgsConstructor // 문제 상황 2. 부작용이 많은 모든 파라미터를 받는 생성자 자동 생성
@Builder // 문제 상황 3. 클래스 단위의 Builder 패턴 적용
@Getter
public class Board extends BaseTimeEntity {
    @Id
    @GeneratedValue
    @Column(name="board_id")
    private Long id;
    @Enumerated(EnumType.STRING)
    private Category category;
    private String title;
    private String content;
    private String author;
    @ManyToOne
    @JoinColumn(name="member_id")
    private Member member;
    @Builder.Default @OneToMany(mappedBy="board") // 문제 상황 4. 잘못된 @Builder 위치로 인해 추가해야했던 초기화를 위해 불필요한 코드  
    private List<Comment> commentList = new ArrayList<Comment>();
    public void edit(BoardForm boardForm){
        content = changedInfo(content, boardForm.getContent());
    }
    private String changedInfo(String original, String changed){
        return (changed == null || changed.equals("")) ? original : changed;
    }
    public void setMember(Member member){
        if(this.member!=null){
            this.member.getBoardList().remove(this);
        }
        this.member=member;
        member.getBoardList().add(this);
    }
    // 문제 상황 5. 생성자 메소드 대신 생성해주는 메소드 사용
    public static Board createBoard(Member member,BoardForm boardForm){
        Board board =Board.builder()
            .category(boardForm.getCategory())
            .title(boardForm.getTitle())
            .content(boardForm.getContent())
            .author(member.getNickname())
            .build();
        board.setMember(member);
        return board;
    }
}

리팩토링 후 코드

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 리팩토링 1. protected 접근 제어자로 생성자의 접근 제어
// @AllArgsConstructor  리팩토링 2. 불필요한 생성자 제거  
@Getter
public class Board extends BaseTimeEntity {
    @Id
    @GeneratedValue
    @Column(name="board_id")
    private Long id;
    @Enumerated(EnumType.STRING)
    private Category category;
    private String title;
    private String content;
    private String author;
    @ManyToOne
    @JoinColumn(name="member_id")
    private Member member;
    @OneToMany(mappedBy="board", cascade = CascadeType.REMOVE) // 리팩토링 3. 클래스 단위의 빌더 패턴 제거로 객체 생성 시 자동으로 List 초기화 
    private List<Comment> commentList = new ArrayList<Comment>();
    public void edit(BoardForm boardForm){
        content = changedInfo(content, boardForm.getContent());
    }
    private String changedInfo(String original, String changed){
        return (changed == null || changed.equals("")) ? original : changed;
    }
    public void setMember(Member member){
        if(this.member!=null){
            this.member.getBoardList().remove(this);
        }
        this.member=member;
        member.getBoardList().add(this);
    }
    @Builder // 리팩토링 4. 생성자 메소드 생성 후 본 메서드에 Builder 패턴 적용  
    public Board (Member member, BoardForm boardForm){
        this.author = member.getNickname();
        this.category = boardForm.getCategory();
        this.title = boardForm.getTitle();
        this.content = boardForm.getContent();
        this.setMember(member);
    }
}

결론

Lombok을 함부로 사용한다면 부작용이 생길 수도 있다는 것을 알게 되었다. 또한, 옳바른 사용법을 알게 되어서 좋았다.


참조

https://www.nowwatersblog.com/springboot/springstudy/lombok

profile
난 Java도 좋고, 다른 것들도 좋아

0개의 댓글