Song[coding diary index]

Song 배열에 코딩 흔적 남겨두기

spring

[EP 1-3] spring jpa json serialize 문제 해결과정 (부제: JPA 지연 로딩)

singsangssong 2024. 10. 15. 18:08
반응형

문제 상황

동아리 홈페이지 만들기를 하면서, 프론트에서 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를 가져오는 걸 왜 생각하지 못했을까...😇)

피드백, 충고, 댓글, 조언, 지적 모두 언제나 환영입니다!