본문 바로가기

항해 99/Spring

lombok 주의 사항

Lombok 사용 시 주의 사항

Lombok은 자바 컴파일 시점에서 특정 어노테이션으로 해당 코드를 추가할 수 있는 라이브러리이다.

간편한 코드 작성, 가독성, 유지 보수에 많은 도움이 되지만 편리한 만큼 잘못 사용하기 쉬운 것이 Lombok이다.

 

@Data 사용 지양

@Data는 @ToString, @EqualsAnHashCode, @Getter, @Setter, @RequiredArgsConstructor을 모두 포함하는 강력한 어노테이션임.

 

@Data 사용으로 인해 발생할 수 있는 문제점

 

1. 무분별한 Setter 남용

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

 

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

 

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

Member와 Coupon이 1:N 양방향으로 매핑되어 있는 상황을 가정. 이때, ToString을 호출하면 무한 순환 참조가 발생함.

 

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

 

3. @EqualsAndHashCode

@EqualsAndHashCode는 상당히 고품질의 equals( )와 hasCode( ) 메서드를 만들어 줌, 잘 사용하면 좋지만 남발하면 심각한 문제가 생김

 

특히 문제가 되는 점은 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={ "필드명시" }) 형태로 동등성 비교에 필요한 필드를 명시하는 형태로 사용
    • 실무에서는 누군가는 이에 대해 실수할 수 있음, 사용을 완전히 금지시키고 꼭 필요한 필드를 지정하는 것이 나음

팁: @EqualsAndHashCode.Exclude 를 활용

@Getter
@Setter
@EqualsAndHashCode
public class TestUser {
    public String id;
    @EqualsAndHashCode.Exclude
    public String password;
}
  • @EqualsAndHashCode.Exclude 애노테이션을 이용하면 특정 필드만 뺄 수 있다. 직접 글자로 치는 것보다 오타없이 뺄 수 있어서 좋음
  • @EqualsAndHashCode(onlyExplicitlyIncluded = true)를 사용하는 것도 가능

 

 

@Value 사용 금지

모든 필드를 private final로 변경해주는 기능이 있지만, 이 또한 @EqualsAndHashCode, @AllArgsConstructor 를 포함함.

@EqualsAndHashCode 는 애초에 필드 값 자체가 변경 불가능하게 바뀌기 때문에 상관이 없는데, @AllArgsConstructor 가 또 문제가 됨

 

 

@Builder는 웬만하면 직접 만든 생성자에서 사용

@Builder 를 편하게 사용하기 위해 @AllArgsConstructor 와 같이 써버리는데, 이렇게 해도 access = PRIVATE 을 하면 웬만해서는 실수할 일이 없지만 직접 만든 생성자에 @Builder를 붙이면 더욱 안전함

public class TestUser {
    public String id;
    public String password;

    @Builder
    public TestUser(String id, String password) {
        this.id = id;
        this.password = password;
    }
}
  • Builder 를 통해서 굳이 설정할 일이 없는 값 혹은 설정해서는 안되는 값을 제외하고 Builder 를 생성해줄 수 있음

 

 

@Log

@Log를 통해 각종 Logger 를 자동생성할 수 있음

기본적으로 private static final 로 생성하는데, static 이 아닌 필드로 만들고자 하거나 Logger 객체 이름을 변경하고자 한다면 lombok.config를 사용

lombok.log.fieldName=logger # 로거 객체 이름을 logger로 변경. 원래는 log
lombok.log.fieldIsStatic=false # 로거를 static이 아닌 필드로 생성
  • 가급적이면 @Sl4j만 사용하고 나머지는 사용할 수 없게 금지시키는 것도 가능

 

 

@NonNull

불필요하게 branch converage를 증가시킴(프로젝트 코드 커버리지를 유지하고 싶다면 Null인 상황에서 오류발생과 그렇지 않은 상황에 대한 테스트를 모든 사용처에서 만들어야함)

이 코드를 일일이 테스트를 만들자니 이미 검증된 라이브러리의 기능을 사용하는 곳에서 모두 테스트를 만드는 것도 굉장히 소모적인 일임

  • @NonNull을 사용하지 않고 Guava 의 Preconditions로 null을 검증하고 오류 처리하는 것도 가능

 

 

생성자 자동 생성 어노테이션 사용 지양

@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로 열어두길 권장하고 있음
  • 객체에 대한 생성자를 하나로 두고, @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이라 기본 생성자 호출 코드에서는 인자 순서를 변경하지 않았음에도, 어떠한 오류도 발생하지 않음
  • 바뀐 코드는 아무런 에러 없이 잘 작동하는 것처럼 보이지만 실제로는 Person이 이상한 값으로 바뀌어 버림
  • 이러한 문제는 @AllArgsConstructor와 @RequiredArgsConstructor에 둘 다 존재함, 따라서 두 lombok 어노테이션은 사용하지 않도록 지양해야 함.
    • @Builder 등을 이용하기 위함이라면, 접근 레벨을 낮춰놓자.
  • 생성자를 직접 만들고 필요한 경우에 직접 만든 생성자에 @Builder 어노테이션을 붙이는 것을 권장(파리미터 순서가 아닌 이름으로 값을 설정하기 때문에 리팩토링에 유연하게 대응할 수 있다).
@AllArgsConstructor
public static class Person {
    private String firstName;
    private String lastName;
    
    @Builder
    private Person(String firstName, String lastName){
        this.firstName = firstName;
        this.lastName = lastName;
    }
}
// 필드 순서를 변경해도 한국식 이름이 만들어진다.
Person me = Person.builder().lastName("현수").firstName("권").build();
System.out.println(me);

 

 

 

@RequiredArgsConstructor 지양

의존 관계를 주입하기 위해 사용하는 어노테이션

 

의존 주입 방식

  • 생성자 주입 방식
  • 필드 주입 방식
  • Setter 주입 방식

공식 레퍼런스에서는 생성자 주입 방식을 권장하고 있음, 필수적인 의존성을 모두 만족해야만(파라미터로 모두 받아야만) 객체를 생성할 수 있도록 강제할 수 있기 때문

 

 

 

프로젝트 리팩토링 예시

리팩토링 전

@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);
    }
}

 

'항해 99 > Spring' 카테고리의 다른 글

QueryDSL  (0) 2024.03.08
Entity와 DTO의 분리  (0) 2024.03.08
Controller, RestController 차이  (0) 2024.03.05
메서드 명 find와 get의 차이  (0) 2024.03.04
TDD(Test-Driven Development), JUnit  (0) 2024.03.03