문제 상황
동아리 홈페이지 만들기를 하면서, 프론트에서 API테스트를 하다가 /profile/blog에서 문제가 발생했다.
팀장이다보니 빠르게 버그수정을 위해서 팀원이 짠 코드를 빠르게 고치고자 직접 수정하려했다.
(/profile/blog: 내 프로필에서 내가 작성한 블로그를 조회하는 기능)
(Post(1) : Comments(N)(N:1관계) / Post(1) : tags(N)(N:1관계)임을 참고)
/*
내 블로그 조회기능
@param: studentId(학번)
@return: 내가 작성한 블로그 전체(pagination)
- 학번으로 jpa를 통해서 유저 확인.
- user pk를 통해서 내가 작성한 블로그를 가져옴.
*/
@Override
public List<PostEntity> showMyBlog(String studentId){
User user = userRepository.findByStudentId(studentId)
.orElseThrow(UserNotFoundException::new);
List<PostEntity> myBlog = postRepository.findByWriter(user.getId());
return myBlog;
}
언뜻보면 필요한 요구사항을 모두 수행한 것처럼 보인다.
-> (학번으로 유저 확인했고.. 그 유저pk로 유저가 작성한 블로그 잘 가져온거 아닌가?)
하지만 Spring으로 프로젝트를 한번이라도 만들어본 사람이라면 바로 문제를 눈치챌 수 있을 것이다.
데이터를 꺼낸 Entity째로 데이터를 json화 시키고 있다. 이를 Dto화시키지 않고 바로 보낸 것이 어색하다!
문제를 바로 알아채긴 했지만, 에러 메세지가 의미하는 바가 이해가 가지 않았다.
ERROR: Post의 Commet의 지연로딩으로 인해 Serializable 못한다!(spring jpa data의 데이터를 Serializable 못함)
💡예? Dto로 감싸지 않았다고 Serializable을 못한다? 왜지?💡
해결 과정
이건 JPA라는 기술 자체에 대한 이해가 조금 필요하다.
아래 코드를 먼저 살펴보자
Post
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "writer_id")
private User writer;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
@Column(name = "thumbnail_url")
private String thumbnail;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private Set<Hashtag> hashtags = new HashSet<>();
위는 Post의 Entity 코드를 가져왔다. 보면 양방향 매핑으로 이루어진 것을 볼 수 있다.
(Comments Entity에는 @ManyToOne 코드가 작성되어있다. @OneToMany만 사용하는 경우는 거의 없다고 보면 된다. 문제가 매우 많이 발생할 수 있어서...)
이때 Post의 데이터를 DB에서 가져온다면 아래와 같은 형태이다.
- id
- writer
- title
- content
- thumbnail
- Proxy(List<Comment>)
- Proxy(List<Hashtag>)
이렇게 프록시로 감싸져 있으며 직접적으로 comment나 Hashtag에 접근하는 코드가 있어야지! 비로소 데이터를 직접 가져오게 된다.
결론: Proxy로 감싸져있는 보호되는 데이터를 Serialize하려했기에 실패했다!
-> 그렇다면 이 Proxy를 풀어서 Java형태로 데이터를 가져오면 되겠지?
이때 Entity -> Dto 과정을 거친다면 자연스럽게 데이터에 접근할 수 있다!
/*
내 블로그 조회기능
@param: studentId(학번), pageable(페이지네이션)
@return: 내가 작성한 블로그 전체(pagination)
- 학번으로 jpa를 통해서 유저 확인.
- user pk를 통해서 내가 작성한 블로그를 가져옴. + paging한 채로
- post(entity) -> postOverview(dto)로 변환하여 필요한 데이터만 전송
*/
@Override
public Page<PostOverview> showMyBlog(String studentNumber,Pageable pageable){
User user = userRepository.findByStudentNumber(studentNumber)
.orElseThrow(UserNotFoundException::new);
Page<Post> myBlog = postRepository.findByWriter(user, pageable);
List<PostOverview> overviews = myBlog.stream().map(PostOverview::of)
.collect(Collectors.toList());
return new PageImpl<>(overviews, pageable, myBlog.getTotalElements());
}
작성한 글이 많을 수 있기 때문에 Pagination까지 수행해둔 모습이다.
PostOverview가 dto를 수행하고 가져온 post데이터 중 필요한 정보만 가져와서 dto로 변환하는 모습이다.
위는 repository로 쿼리문을 통해 직접 db에 접근한 모습이다.
해당 user의 학번을 확인하여 user를 확인하고, user가 작성한 post를 가져온다.
이후 entity -> dto로 변환할 때 위 쿼리문이 발생한다. EntityManager에서 proxy로 감싸진 comment와 hashtag에 접근되는 것을 감지하고, comment와 hashtag entity에 쿼리문을 날아간다.
PostOverview
@Data
@Builder
public class PostOverview {
public static final int SUMMARY_LENGTH = 30;
//...(맴버 변수 선언)
public static PostOverview of(Post post) {
String content = post.getContent();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return PostOverview.builder()
.id(post.getId())
.writer(ProfileDTO.of(post.getWriter()))
.title(post.getTitle())
.summary((content.length() < SUMMARY_LENGTH) ? content : content.substring(0, SUMMARY_LENGTH))
.thumbnail(post.getThumbnail())
.modDate(post.getModDate().format(formatter))
.commentCount(post.getComments().size())
.hashtags(post.getHashtags().stream().map(hashtag -> hashtag.getName()).toList())
.build();
}
}
(@Data는 되도록 사용하지 맙시다... 객체에 너무 쉽게 접근할 수 있어요...)
위처럼 of메소드를 만들어 Entity -> dto화 하는 과정을 거친다. 이때, comment와 hashtag에 접근해 데이터를 조회하면
이때 쿼리문이 날아가도록 된다.
+ 지연로딩(Lazy)를 사용하는 이유(vs 즉시로딩(Eager))
우리는 spring jpa data를 통해 엔티티간의 연관관계를 맺을 때 로딩전략을 세운다. 그것이 lazy와 eager로 나뉘는데
위를 이용해 예시를 들며 설명하겠다.
Example
만약 1번 post에 comment가 10만개가 있다고 가정하자. 그리고 나는 commet정보는 필요없이, post의 제목정보만 필요하다.
우리는 post-comment사이에 연관관계를 맺었고, lazy와 eager의 경우를 살펴보자.
Eager: 1번 post를 가져오기 위한 쿼리문을 날리고, 이와 동시에 연결된 comment에 대한 쿼리문도 함께 날아간다.
Lazy: 1번 post를 가져오기 위한 쿼리문을 날리고, comment는 proxy로 묶인채 객체만이 존재한다. 만약 comment 데이터를 조회하고자 접근할 때 쿼리문이 날아간다.
이처럼 Eager전략을 사용하면 불필요한 데이터까지 전부 조회해아하며, 그 데이터의 크기가 너무 방대하다면 손실이 매우 크게 발생한다.
이에 대부분의 경우에는 Lazy전략을 사용한다.
후기
- 코드 리뷰를 서로 하는 이유가 늘 있는 것 같다. 여러 기회를 통해 다른 사람들의 코드를 분석하다보면, 같은 기능이라도 다양하게 구현한 것을 보며 배워가는 점이 많다고 생각한다. 늘 다른 사람이 짠 코드를 유심히 보자!
- 공부하고 배운 개념들을 서로 조합하는것도 중요하다...
(Dto가 Jpa의 지연로딩에 접근해서 연관관계된 entity를 가져오는 걸 왜 생각하지 못했을까...😇)
피드백, 충고, 댓글, 조언, 지적 모두 언제나 환영입니다!
'spring' 카테고리의 다른 글
[EP 1-4] Spring 소셜로그인(OAuth2) 써보기1: 소셜로그인 완벽 이해하기 (0) | 2025.03.13 |
---|---|
[EP 1-2] 비밀번호 암호화 (0) | 2023.11.17 |
[EP 1-1] Spring Entity Default 설정 방법 (1) | 2023.11.16 |