속닥속닥 팀 프로젝트에서, 동일한 사용자가 이미 신고한 게시글을 또 신고할 경우에 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)이 반환되기를 기대
}
이유는 아래와 같습니다.
LAZY
로 fetch한다.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이 발생할 수 있으니 주의하는 것이 좋아보입니다.
끗
제이슨
엔티티 접근 방식이 property 방식이면 getId는 프록시 초기화를 안한다고 하네요!