쌩로그

@Builder패턴 클래스에 선언시 참조형이 null로 초기화되는 이유(feat. Jpa Entity에서 List호출시 NPE 발생 원인) 본문

Spring/Spring & Spring Boot

@Builder패턴 클래스에 선언시 참조형이 null로 초기화되는 이유(feat. Jpa Entity에서 List호출시 NPE 발생 원인)

.쌩수. 2023. 12. 8. 12:21
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. @Builder 선언하기
      2-2. 생성되는 생성자 코드 비교하기
      2-3. 이제 진짜 이유
      2-4. 빌더 패턴
  3. 요약

1. 포스팅 개요

List<Entity> list = new ArrayList<>();

예전에 @Builder를 클래스레벨에 선언하고, 사용하다가 위와 같이 Entity에서 선언한 List타입 객체를 호출하려 했을 때 초기화가 원활하게 일어나지 않아서 문제가 된 글을 포스팅한 적이 있다.

이 글이다.

지금 확인해보니 문제에 대한 원인을 정확히 파악하지 못 한 상태에서 글을 썼고, 오히려 나의 무지만... 드러낸 글이다..

문제되는 글은 이 문장인데..

클래스 레벨에 @Builder를 붙였기 때문에, 인스턴스 초기화시 모든 선언된 변수들은 각 타입의 기본값으로 들어간다.

헷갈린 내용을 바로 잡고자 포스팅을 하게 되었다.

2. 본론

2-1. @Builder 선언하기

"클래스 레벨에 @Builder를 붙였기 때문에, 인스턴스 초기화시 모든 선언된 변수들은 각 타입의 기본값으로 들어간다."

결론은 맞는 말이다. 그런데 왜 초기화가 기본 값으로 들어가는지 분명 이유가 있을 거 같았다. 아니 분명한 이유가 있다.

스프링은 lombok 라이브러리에서 제공하는 애너테이션을 클래스를 작성할 때 선언하면 빌드를 했을 때 코드가 풀어져서 나온다.
그리고 그 풀어진 코드를 가지고 동작하게 되는데, 이를 활용해서 내부적으로 어떻게 생성되는지 확인해 보았다.

핵심되는 베이스 코드는 다음과 같다.
(그냥 그렇구나 하고 넘기길 바란다..)
(Entity에 Setter가 있었다.. 프로젝트 할 당시 많이 성장한 듯 했지만, 지금도 그렇지만, 한참 멀었다..그래서 여기에서는 감춤..)

@Getter
@Entity
@NoArgsConstructor
public class Accompany extends Auditable {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ACCOMPANY_ID", updatable = false)
    private Long accompanyId;

    @Column(name = "NICKNAME",length = 50, updatable = false)
    private String nickname;

    @Enumerated(value = EnumType.STRING)
    @Column(name = "LOCAL", length = 16)
    private Local local;

    @Column(name = "MAX_MEMBER_NUM", nullable = false)
    private Long maxMemberNum;

    @Column(name = "ACCOMPANY_START_DATE")
    private LocalDate accompanyStartDate;

    @Column(name = "ACCOMPANY_END_DATE")
    private LocalDate accompanyEndDate;

    @Column(name = "TITLE", length = 100, nullable = false)
    private String title;

    @Lob
    @Column(name = "CONTENT", nullable = false)
    private String content;

    @ColumnDefault("false")     /
    @Column(name = "RECRUIT_COMPLETE")
    private boolean recruitComplete;

    @Column(name = "COORDINATE_X")
    private Double coordinateX;

    @Column(name = "COORDINATE_Y")
    private Double coordinateY;

    @Column(name = "PLACE_NAME", length = 50)
    private String placeName;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "accompany", orphanRemoval = true) // 고아 객체되면 자동으로 삭제
    private List<AccompanyMember> accompanyMemberList = new ArrayList<>();

    // Id와 List<AccompanyMember>를 제외한 생성자
    public Accompany(Local local, Long maxMemberNum, LocalDate accompanyStartDate, LocalDate accompanyEndDate, String title, String content, Double coordinateX, Double coordinateY, String placeName) {
        this.local = local;
        this.maxMemberNum = maxMemberNum;
        this.accompanyStartDate = accompanyStartDate;
        this.accompanyEndDate = accompanyEndDate;
        this.title = title;
        this.content = content;
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
        this.placeName = placeName;
    }
}

먼저 클래스레벨에 @Builder를 선언해보자

@Builder
@AllArgsConstructor
@Getter
@Entity
@NoArgsConstructor
public class Accompany extends Auditable {

  ...
}

코드는 위와 같을 것이다.
그냥 @Builder만 선언하면, 다음과 같은 에러가 발생하는데,

Lombok @Builder needs a proper constructor for this class

@Builder를 사용할 적절한 생성자가 필요하다고 한다.
@AllArgsConstructor을 선언하지 않으면 기본생성자만 적용되는데, 기본 생성자만 있으면, @Builder를 사용할 필요가 없다.

잠깐!

다른 얘기지만, Entity에 @AllArgsConstructor 이 애너테이션을 사용할 이유가 없다. Entity에 id는 RDBMS마다 다르겠지만(오라클은 시퀀스, MySQL 계열은 Auto Increment 등), RDBMS에서 알아서 올려준다.

모든 필드를 사용하는 생성자를 사용하려면, @AllArgsConstructor 애너테이션이 아니라, 별도로 생성자를 따로 만들어줘야 한다.

지금은 학습 및 실험차원에서 하는 것이니, 그냥 붙여주겠다.

그리고 생성자에 @Builder를 붙여보자.

@Getter
@Entity
@NoArgsConstructor
public class Accompany extends Auditable {
  ...

    @Builder
    public Accompany(Local local, Long maxMemberNum, LocalDate accompanyStartDate, LocalDate accompanyEndDate, String title, String content, Double coordinateX, Double coordinateY, String placeName) {
        this.local = local;
        this.maxMemberNum = maxMemberNum;
        this.accompanyStartDate = accompanyStartDate;
        this.accompanyEndDate = accompanyEndDate;
        this.title = title;
        this.content = content;
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
        this.placeName = placeName;
    }

}

List와 id 필드를 빼고 생성자를 작성하여 @Builder를 선언했다.

2-2. 생성되는 생성자 코드 비교하기

이제 프로젝트를 빌드하여 생성되는 클래스를 확인해보자.

인텔리제이를 사용한다면 다음 디렉터리에 클래스가 생성된다.

먼저 클래스 레벨에 @Builder를 선언했을 때의 코드다.
필드, getter를 제외한 생성자와 빌더와 관련된 코드만 확인해보자.

...

public static AccompanyBuilder builder() {
        return new AccompanyBuilder();
}

...

public Accompany(Local local, Long maxMemberNum, LocalDate accompanyStartDate, LocalDate accompanyEndDate, String title, String content, Double coordinateX, Double coordinateY, String placeName) {
        this.local = local;
        this.maxMemberNum = maxMemberNum;
        this.accompanyStartDate = accompanyStartDate;
        this.accompanyEndDate = accompanyEndDate;
        this.title = title;
        this.content = content;
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
        this.placeName = placeName;
}

public static AccompanyBuilder builder() {
        return new AccompanyBuilder();
    }

public Accompany(final Long accompanyId, final String nickname, final Local local, final Long maxMemberNum, final LocalDate accompanyStartDate, final LocalDate accompanyEndDate, final String title, final String content, final boolean recruitComplete, final Double coordinateX, final Double coordinateY, final String placeName, final List<AccompanyMember> accompanyMemberList) {
    this.accompanyId = accompanyId;
    this.nickname = nickname;
    this.local = local;
    this.maxMemberNum = maxMemberNum;
    this.accompanyStartDate = accompanyStartDate;
    this.accompanyEndDate = accompanyEndDate;
    this.title = title;
    this.content = content;
    this.recruitComplete = recruitComplete;
    this.coordinateX = coordinateX;
    this.coordinateY = coordinateY;
    this.placeName = placeName;
    this.accompanyMemberList = accompanyMemberList;
}

...

public Accompany() {
}
...

// 자동으로 생성되는 내부 클래스 TBuilder
public static class AccompanyBuilder {
    private Long accompanyId;
    private String nickname;
    private Local local;
    private Long maxMemberNum;
    private LocalDate accompanyStartDate;
    private LocalDate accompanyEndDate;
    private String title;
    private String content;
    private boolean recruitComplete;
    private Double coordinateX;
    private Double coordinateY;
    private String placeName;
    private List<AccompanyMember> accompanyMemberList;

    AccompanyBuilder() {
    }

    public AccompanyBuilder accompanyId(final Long accompanyId) {
        this.accompanyId = accompanyId;
        return this;
    }

    public AccompanyBuilder nickname(final String nickname) {
        this.nickname = nickname;
        return this;
    }

    public AccompanyBuilder local(final Local local) {
        this.local = local;
        return this;
    }

    public AccompanyBuilder maxMemberNum(final Long maxMemberNum) {
        this.maxMemberNum = maxMemberNum;
        return this;
    }

    public AccompanyBuilder accompanyStartDate(final LocalDate accompanyStartDate) {
        this.accompanyStartDate = accompanyStartDate;
        return this;
    }

    public AccompanyBuilder accompanyEndDate(final LocalDate accompanyEndDate) {
        this.accompanyEndDate = accompanyEndDate;
        return this;
    }

    public AccompanyBuilder title(final String title) {
        this.title = title;
        return this;
    }

    public AccompanyBuilder content(final String content) {
        this.content = content;
        return this;
    }

    public AccompanyBuilder recruitComplete(final boolean recruitComplete) {
        this.recruitComplete = recruitComplete;
        return this;
    }

    public AccompanyBuilder coordinateX(final Double coordinateX) {
        this.coordinateX = coordinateX;
        return this;
    }

    public AccompanyBuilder coordinateY(final Double coordinateY) {
        this.coordinateY = coordinateY;
        return this;
    }

    public AccompanyBuilder placeName(final String placeName) {
        this.placeName = placeName;
        return this;
    }

    public AccompanyBuilder accompanyMemberList(final List<AccompanyMember> accompanyMemberList) {
        this.accompanyMemberList = accompanyMemberList;
        return this;
    }

    public Accompany build() {
        return new Accompany(this.accompanyId, this.nickname, this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.recruitComplete, this.coordinateX, this.coordinateY, this.placeName, this.accompanyMemberList);
    }

    public String toString() {
        return "Accompany.AccompanyBuilder(accompanyId=" + this.accompanyId + ", nickname=" + this.nickname + ", local=" + this.local + ", maxMemberNum=" + this.maxMemberNum + ", accompanyStartDate=" + this.accompanyStartDate + ", accompanyEndDate=" + this.accompanyEndDate + ", title=" + this.title + ", content=" + this.content + ", recruitComplete=" + this.recruitComplete + ", coordinateX=" + this.coordinateX + ", coordinateY=" + this.coordinateY + ", placeName=" + this.placeName + ", accompanyMemberList=" + this.accompanyMemberList + ")";
    }
}


다음은 생성자에 @Builder를 선언한 경우다.
역시 위와 마찬가지로 필드, getter를 제외한 생성자와 빌더와 관련된 코드만 확인해보도록 하겠다.

...

public static AccompanyBuilder builder() {
        return new AccompanyBuilder();
}

...


public Accompany(Local local, Long maxMemberNum, LocalDate accompanyStartDate, LocalDate accompanyEndDate, String title, String content, Double coordinateX, Double coordinateY, String placeName) {
        this.local = local;
        this.maxMemberNum = maxMemberNum;
        this.accompanyStartDate = accompanyStartDate;
        this.accompanyEndDate = accompanyEndDate;
        this.title = title;
        this.content = content;
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
        this.placeName = placeName;
}

public Accompany() {
    }

// 자동으로 생성되는 내부 클래스 TBuilder
public static class AccompanyBuilder {
    private Local local;
    private Long maxMemberNum;
    private LocalDate accompanyStartDate;
    private LocalDate accompanyEndDate;
    private String title;
    private String content;
    private Double coordinateX;
    private Double coordinateY;
    private String placeName;

    AccompanyBuilder() {
    }

    public AccompanyBuilder local(final Local local) {
        this.local = local;
        return this;
    }

    public AccompanyBuilder maxMemberNum(final Long maxMemberNum) {
        this.maxMemberNum = maxMemberNum;
        return this;
    }

    public AccompanyBuilder accompanyStartDate(final LocalDate accompanyStartDate) {
        this.accompanyStartDate = accompanyStartDate;
        return this;
    }

    public AccompanyBuilder accompanyEndDate(final LocalDate accompanyEndDate) {
        this.accompanyEndDate = accompanyEndDate;
        return this;
    }

    public AccompanyBuilder title(final String title) {
        this.title = title;
        return this;
    }

    public AccompanyBuilder content(final String content) {
        this.content = content;
        return this;
    }

    public AccompanyBuilder coordinateX(final Double coordinateX) {
        this.coordinateX = coordinateX;
        return this;
    }

    public AccompanyBuilder coordinateY(final Double coordinateY) {
        this.coordinateY = coordinateY;
        return this;
    }

    public AccompanyBuilder placeName(final String placeName) {
        this.placeName = placeName;
        return this;
    }

    public Accompany build() {
        return new Accompany(this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.coordinateX, this.coordinateY, this.placeName);
    }


    public String toString() {
        return "Accompany.AccompanyBuilder(local=" + this.local + ", maxMemberNum=" + this.maxMemberNum + ", accompanyStartDate=" + this.accompanyStartDate + ", accompanyEndDate=" + this.accompanyEndDate + ", title=" + this.title + ", content=" + this.content + ", coordinateX=" + this.coordinateX + ", coordinateY=" + this.coordinateY + ", placeName=" + this.placeName + ")";
    }
}

그런데 여기서 살펴봐야 할 코드는 중요한 내부적으로 자동 생성되는 TBuilder(여기선 AccompanyBuilder)클래스이다.

클래스레벨에 둔 경우이다.

...

public static AccompanyBuilder builder() {
        return new AccompanyBuilder();
}

// 자동으로 생성되는 내부 클래스 TBuilder
public static class AccompanyBuilder {
    private Long accompanyId;
    private String nickname;
    private Local local;
    private Long maxMemberNum;
    private LocalDate accompanyStartDate;
    private LocalDate accompanyEndDate;
    private String title;
    private String content;
    private boolean recruitComplete;
    private Double coordinateX;
    private Double coordinateY;
    private String placeName;
    private List<AccompanyMember> accompanyMemberList;

    AccompanyBuilder() {
    }

    ...

    public Accompany build() {
        return new Accompany(this.accompanyId, this.nickname, this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.recruitComplete, this.coordinateX, this.coordinateY, this.placeName, this.accompanyMemberList);
    }
    ...
}

다음은 생성자 레벨에 둔 경우이다.

public static AccompanyBuilder builder() {
        return new AccompanyBuilder();
}

public static class AccompanyBuilder {
    private Local local;
    private Long maxMemberNum;
    private LocalDate accompanyStartDate;
    private LocalDate accompanyEndDate;
    private String title;
    private String content;
    private Double coordinateX;
    private Double coordinateY;
    private String placeName;

    AccompanyBuilder() {
    }

    ...

    public Accompany build() {
        return new Accompany(this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.coordinateX, this.coordinateY, this.placeName);
    }
    ...
}

차이를 못 느낄 것이다...
사진으로 비교해보면 차이점이 있다.

왼쪽(클래스 레벨)에는 id와 List가 있지만, 오른쪽(생성자 레벨)에는 없다.

중점적으로 얘기하고자 하는 바는 List 에 대한 내용이다.

저번 포스팅에서 클래스레벨에 @Builder를 두고, 생성한 인스턴스에서 list를 호출하려 했었는데, NullPointerException이 발생했었다.

잠깐 로직을 설명하자면,
컨트롤러에서 받은 요청 데이터를 가지고 Builder패턴을 이용하여 DB에 저장할 Accompany 객체를 생성하는데, 다음처럼 생성한다.

Accompany.builder()
                   .local(Local.findByLocal(accompanyPost.getLocal()))
                   .maxMemberNum(accompanyPost.getMaxMemberNum())
                   ... // controller로 받은 데이터들로 필드에 값을 넣는다.
                   .placeName(accompanyPost.getPlaceName())
                   .build();

2-3. 이제 진짜 이유

클래스 레벨에서의 동작

클래스 레벨에 @Builder를 두면 내부적으로, TBuilder(여기서는 AccompanyBuilder) 클래스가 내부클래스로 자동으로 생성된다고 했다.

바로 위의 코드를 한번 분석해보면 다음과 같은 방식으로 동작한다.

Accompany.builder() //  1번
                   .local(Local.findByLocal(accompanyPost.getLocal()))    // 각각의 필드에 값 넣기, 설명 생략
                   .maxMemberNum(accompanyPost.getMaxMemberNum())
                   ... // controller로 받은 데이터들로 필드에 값을 넣는다.
                   .placeName(accompanyPost.getPlaceName())
                   .build(); // 2번
  1. .builder() 메서드로 public static AccompanyBuilder builder() { return new AccompanyBuilder(); } 해당 생성자가 호출된다.

    1. 그러면 내부적으로 생성된 AccompanyBuilder 클래스에서 기본 생성자인 AccompanyBuilder() {} 를 호출한다.

    2. 그런데 AccompanyBuilder에는 이미 다음과 같은 필드들이 선언되어있다.

        ```
        private Long accompanyId;
        private String nickname;
        private Local local;
        private Long maxMemberNum;
        private LocalDate accompanyStartDate;
        private LocalDate accompanyEndDate;
        private String title;
        private String content;
        private boolean recruitComplete;
        private Double coordinateX;
        private Double coordinateY;
        private String placeName;
        private List<AccompanyMember> accompanyMemberList;
        ```
      
        (여기서 이미 눈치 챈 분들이 있을 것이다..)
  2. .build() 메서드를 통해서 AccompanyBuilder의 build()메서드를 호출한다.

public Accompany build() {
    return new Accompany(this.accompanyId, this.nickname, this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.recruitComplete, this.coordinateX, this.coordinateY, this.placeName, this.accompanyMemberList);
}

이 때 AccompanyBuilder에서 내부로 가진 값들을 매개변수로 주면서 Accompany 객체를 생성한다.
그런데 Entity 자체에서는 List<Entity> entityList 에 대해서 new ArrayList<>();를 할당했지만,
현재 내부 AccompanyBuilder에는 다음과 같이 선언되어 있고, 당연히 null일 수 밖에 없다.

ArrayList 객체가 할당되지 않은 값을 통해서 인스턴스가 생성되기 때문에, 클래스 레벨에 @AllArgsConstructor과 같이 @Builder를 선언하면 아무리 Entity에 ArrayList<>()를 선언해놓았다 할지라도, null 값으로 생성될 수 밖에 없는 것이다.

private List<AccompanyMember> accompanyMemberList;
private Long accompanyId;                       // 참조형 기본값 null
private String nickname;                        // 참조형 기본값 null
private Local local;                            // 참조형 기본값 null
private Long maxMemberNum;                      // 참조형 기본값 null
private LocalDate accompanyStartDate;           // 참조형 기본값 null
private LocalDate accompanyEndDate;             // 참조형 기본값 null
private String title;                           // 참조형 기본값 null
private String content;                         // 참조형 기본값 null
private boolean recruitComplete;                // 기본형 기본값 false
private Double coordinateX;                     // 참조형 기본값 null
private Double coordinateY;                     // 참조형 기본값 null
private String placeName;                       // 참조형 기본값 null
private List<AccompanyMember> accompanyMemberList; // 참조형 기본값 null

다시 말하지만, 클래스 레벨에 @AllArgsConstructor과 같이 @Builder를 선언하면 값을 할당하더라고 각 타입의 기본 값으로 초기화된다.

생성자 레벨에서의 동작

이번에 생성자 레벨도 한번 보자.

Accompany.builder() //  1번
                   .local(Local.findByLocal(accompanyPost.getLocal()))    // 각각의 필드에 값 넣기, 설명 생략
                   .maxMemberNum(accompanyPost.getMaxMemberNum())
                   ... // controller로 받은 데이터들로 필드에 값을 넣는다.
                   .placeName(accompanyPost.getPlaceName())
                   .build(); // 2번
  1. .builder() 메서드로 public static AccompanyBuilder builder() { return new AccompanyBuilder(); } 해당 생성자가 호출된다.

    1. 그러면 내부적으로 생성된 AccompanyBuilder 클래스에서 기본 생성자인 AccompanyBuilder() {} 를 호출한다.

    2. 이번 경우에 AccompanyBuilder에는 다음과 같은 필드들이 선언되어있다.

        ```
        private Local local;
        private Long maxMemberNum;
        private LocalDate accompanyStartDate;
        private LocalDate accompanyEndDate;
        private String title;
        private String content;
        private Double coordinateX;
        private Double coordinateY;
        private String placeName;
        ```
  2. .build() 메서드를 통해서 AccompanyBuilder의 build()메서드를 호출한다.

public Accompany build() {
        return new Accompany(this.local, this.maxMemberNum, this.accompanyStartDate, this.accompanyEndDate, this.title, this.content, this.coordinateX, this.coordinateY, this.placeName);
    }

이 때 AccompanyBuilder에서 내부로 가진 값들을 매개변수로 주면서 Accompany 객체를 생성한다.
그런데 이 때 생성자를 보면 List타입의 변수를 넘기지 않는다.

private List<AccompanyMember> accompanyMemberList = new ArrayList<>();

따라서 Entity에 위와 같은 코드가 작성되어 있을 때, ArrayList 객체가 생성되어 할당되게 되는 것이다.

결론은 클래스에 @Builder 애너테이션을 두지말고, 생성자에 @Builder를 두자.

2-4. 빌더 패턴

@Builder 는 빌더패턴을 손쉽게 사용할 수 있도록 해주는 lombok 라이브러리에서 애너테이션으로 제공하는 기능이다.

나는 @Builder 혹은 빌더패턴을 조금 특별하게(?_ 생각했던 거 같다.
하지만...!

@Builder는 빌더패턴을 손쉽게 사용하게 하는 것이고,
빌더 패턴은 인스턴스 생성시 필드에 값을 동적으로 넣고 싶을 때 사용하는 패턴일 뿐, 그 이상 그 이하도 아니라고 생각했다.

그런데 그 이상 이하도 아닌 기능이 매우 유용하다...

3. 요약

@Builder를 클래스에 선언했을 때랑 생성자에 두었을 때랑, 별 차이 없어보이는데, 왜 이렇게 동작하지? 라는 단순한 호기심에 파헤쳐보았다.
그냥 클래스 레벨에 두면, null로 나올 수 밖에 없는 이유가 분명히 있었던 것이다.
물론 클래스 레벨에 선언하고, 속성을 주는 방법도 있지만,

동작방식이 궁금하여 알아보았다.

그래서 결론은 다음과 같다.

@Builer는 생성자에 두자.

728x90
Comments