Entity의 필드에서 NullPointerException이 발생했다면

공병주(Chris)·2022년 8월 17일
5
post-thumbnail

속닥속닥 팀 프로젝트에서, 동일한 사용자가 이미 신고한 게시글을 또 신고할 경우에 Bad Request(400)을 응답해야 하는 요구 사항이 있었습니다. 조시가 위 요구 사항을 구현하다가 문제를 겪어서 함께 해결해보았습니다.

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @OneToMany(mappedBy = "post")
    private List<PostReport> postReports = new ArrayList<>();

    public void addReport(PostReport other) {
        checkAlreadyReport(other); // 이미 신고한 게시글인지 확인
        postReports.add(other);
    }

    private void checkAlreadyReport(PostReport other) {
        postReports.stream()
                .filter(other::isSameReporter)
                .findAny()
                .ifPresent(it -> {
                    throw new AlreadyReportPostException();
                });
    }
}

따라서, Post에 새로운 신고(PostReport)를 등록하기 전에, PostReport의 isSameReporter 메서드를 통해 동일한 사용자가 제보한 게시글이 있는지 검사하도록 했습니다.

@Entity
@EntityListeners(AuditingEntityListener.class)
public class PostReport {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_report_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member reporter;

    // ...

    public boolean isSameReporter(PostReport other) {
        return this.reporter.equals(other.reporter);
    }
}

PostReport는 신고자인 Member 객체를 참조하고 있습니다. fetch 타입이 LAZY인 것을 기억해주세요.
또한, isSameReporter 메서드는 아래에 보이는 Member의 equals를 통해서 동일한 사용자에 의한 신고인지를 확인합니다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Member)) {
            return false;
        }
        Member member = (Member) o;
        return Objects.equals(id, member.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

하지만, 아래 인수테스트에서는 400이 응답되지 않고, NullPointerException가 발생합니다.

@DisplayName("이미 신고한 게시물은 다시 신고할 수 없다.")
@Test
void reportPost_Exception_AlreadyReport() {
    Long postId = addNewPost();
    httpPostWithAuthorization(REPORT_REQUEST, "/posts/" + postId + "/report", getChrisToken());
    //사용자가 특정 게시글에 대해서 신고 요청을 보냄.

    ExtractableResponse<Response> response = httpPostWithAuthorization(REPORT_REQUEST,
            "/posts/" + postId + "/report", getChrisToken());
    //동일한 사용자가 동일한 게시글에 대해서 신고 요청을 또 보냄

    assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value());
    //Bad Request(400)이 반환되기를 기대
}

NullPointerException이 발생한 이유

이유는 아래와 같습니다.

  1. PostReport가 Member를 LAZY로 fetch한다.
  2. Member의 equals는 getId()를 통해 id에 접근하지 않고 id에 바로 접근한다.
@Entity
public class PostReport {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_report_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY) //Lazy라서 Proxy 객체 생성
    @JoinColumn(name = "member_id")
    private Member reporter;

    // ...

    public boolean isSameReporter(PostReport other) {
        return this.reporter.equals(other.reporter);
    }
}

PostReport에서 Member를 Lazy 로딩하도록 설정해두었습니다. 따라서, PostReport가 조회될 때, Member에는 Proxy 객체가 할당되어 있습니다.

PostReport의 Member는 Proxy인데, isSameReporter에서 Proxy 객체의 equals를 사용합니다.
Member의 equals 메서드는 아래와 같이 getId() 메서드를 통해 Member의 id에 접근하는 것이 아니라, id를 통해서 필드에 바로 접근합니다.

결과적으로, Proxy 객체의 id에 바로 접근하게 되고 Proxy 객체의 id는 null이기 때문에 NullPointerException이 발생한 것입니다.

member의 equals에 id를 sout으로 출력해보면 아래와 같이 null이 출력되는 것을 알 수 있습니다.

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (!(o instanceof Member)) {
        return false;
    }
    Member member = (Member) o;
    System.out.println("-----------------------------------");
    System.out.println(member.id);
    return Objects.equals(id, member.id);
}

Lazy 로딩을 한다면, 위와 같이 내부적으로 필드에 직접 접근할 때 NullPointerException이 발생할 수 있으니 주의하는 것이 좋아보입니다.

도움주신 분

제이슨

2개의 댓글

comment-user-thumbnail
2022년 8월 17일

엔티티 접근 방식이 property 방식이면 getId는 프록시 초기화를 안한다고 하네요!

1개의 답글