JPA 순환참조 문제 해결하기

dev_hwan·2023년 4월 5일
0

게시글 및 게시글 태그 관리 프로젝트를 진행하던 중 순환 참조 문제를 마주하게 되었고 이를 해결했던 글을 적어 보고자 합니다.

양방향 엔티티의 구성

  • Board와 BoardTag를 설계하는 과정에서 @OneToMnany, @ManyToOne으로 참조하여 양방향 연관관계를 형성하고
    Board 객체를 조회할 때, BoardTag의 정보를 같이 조회해서 클라이언트로 전달하고자 하였습니다.
// Board 클래스 
public class Board {
    @Id
    @Column(name = "article_id")
    private String articleId;

    @Column(name = "writer")
    private String writer;

    @Column(name = "board_title")
    private String boardTitle;

    // 생략
    ...

    @Builder.Default
    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<BoardTag> boardTags = new ArrayList<>();
}

// BoardTag 클래스
public class BoardTag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_tag_id", nullable = false)
    private Long boardTagId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id", nullable = false)
    private Board board;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tag_id", nullable = false)
    private TagObj tagObj;
}

// TagObj 클래스
// ... 생략

Board 조회 시 순환참조 문제 발생

  • 순환 참조는 JPA에서 양방향으로 연결된 1:N, N:1 관계에서 발생할 수 있습니다.

Service Layer

public List<Board> findAllBoard() {
    return boardRepository.findAllBoard();
}

Repository Layer

public List<Board> findAllBoard() {
    return em.createQuery( "SELECT b FROM Board b LEFT JOIN FETCH b.boardTags", Board.class)
            .getResultList();
}
  • 위와 같이 JPA에서 양방향으로 연결된 엔티티를 그대로 조회하는 경우 서로의 정보를 순환하면서 조회하다가 stackoverflow가 발생하게 됩니다.
  • 여기서는 Board와 BoardTag 엔티티가 양방향 연관관계를 가지고 있기 때문에 발생한 문제입니다.
  • BoardTag 엔티티의 board 속성이 Board 엔티티를 참조하고 있고, Board 엔티티의 boardTags 속성이 BoardTag 엔티티 리스트를 참조하고 있기 때문입니다.

Spring Boot가 JSON으로 객체를 직렬화할 때, 해당 객체가 다른 객체를 참조하고 있고 그 참조된 객체 또한 다시 해당객체를 참고하고 있어 무한히 객체를 참조하는 과정에서 StackOverflowError 가 발생하게 됩니다.

※ Spring Boot가 REST API 메시지를 구현 할 때, JSON 형태로 메시지를 전달 하게 됩니다. 이때 Object를 JSON으로 변환하기 위하여 Jackson 라이브러리를 이용하는데, Jackson 라이브러리에서 Entity의 getter를 호출하고, 직렬화를 이용해 JSON으로 변환하게 전송가능한 형태로 바꾸어 줍니다. 이를 마샬링(Marshalling)이라고 합니다. 마샬링 과정 중 엔티티의 getter를 호출하는 과정에서 연관된 엔티티를 계속해서 불러오다 보면 순환참조가 발생하여 StackOverflowError 가 발생하게 됩니다.

순환참조가 발생하는 경우

  • 매핑된 데이터를 FetchType.LAZY 로 사용하고 있고,
  • 두 Entity 가 1:N , N:1 양방향 관계를 가지고 있고,
  • Entity 자체를 JSON 으로 직렬화하여 반환할 경우 순환 참조가 발생
  • 또는, Entity 에 @Data, @ToString, @EqualsAndHashCode 을 사용하면서 두 객체가 서로의 필드를 계속 참조하며 순환참조 발생

순환참조를 방지하는 방법

1. @JsonIgnore 어노테이션 사용

  • @JsonIgnore 어노테이션을 사용하여 엔티티 클래스에서 JSON 변환시 해당 필드를 무시하도록 지정합니다.
  • 이 방법은 일시적인 방법이지만 간단하게 적용할 수 있습니다.
  • 하지만 이 방법은 해당 필드를 완전히 무시하게 되므로 필요한 경우 해당 필드를 포함하지 않아야할 경우에는 부적합합니다.
// Board 클래스 
public class Board {
    // 생략
    ...
    @JsonIgnore // 순환참조 방지
    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<BoardTag> boardTags = new ArrayList<>();
}

2. @JsonManagedReference@JsonBackReference 어노테이션 사용하기

  • @JsonManagedReference@JsonBackReference 어노테이션을 사용하여 엔티티 간의 양방향 참조를 지정합니다.
  • 이 방법은 @JsonManagedReference를 사용하여 부모 엔티티에서 자식 엔티티를 참조하고, @JsonBackReference를 사용하여 자식 엔티티에서 부모 엔티티를 참조합니다.
    • @JsonManagedReference는 역참조를 관리하는 객체에서 사용하며,
    • @JsonBackReference는 역참조를 하지 않는 객체에서 사용합니다.
  • 이 방법은 자식 엔티티를 직렬화하지 않기 때문에 무한 순환 참조 문제를 방지할 수 있습니다.
// Board 클래스 
public class Board {
    // 생략
    ...
    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JsonManagedReference
    private List<BoardTag> boardTags = new ArrayList<>();
}

// BoardTag 클래스
public class BoardTag {
    // 생략
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id", nullable = false)
    @JsonBackReference
    private Board board;
}

3. DTO를 사용하기

  • 가장 좋은 방법입니다. DTO 클래스를 직접 JSON으로 반환합니다.
  • 위와 같은 상황이 발생한 직접적인 원인은 양방향 매핑입니다만, 이러한 원인이 되는 부분은 엔티티 자체를 response로 return 한데 있습니다. 따라서, 엔티티를 return 하지말고, DTO 객체를 만들어서 필요한 데이터만 클라이언트로 return 하게되면 해당 문제를 방지할 수 있습니다.
  • Entity 클래스는 데이터베이스와 직접적으로 연관이 되어있는 핵심 클래스 입니다.
  • Entity를 중심으로 많은 클래스나 비즈니스 로직이 동작하고 있기 때문에 Entity 클래스를 Request/Response 클래스로 사용하는 것은 강력하게 추천하지 않습니다.
  • 따라서, 컨트롤러에서 Response 값으로 여러 테이블을 조인해야하는 경우가 많으므로, DB Layer 와 View Layer 의 역할 분리를 철저하게 해줄 필요가 있습니다.
/**
 * 게시글 목록을 리턴할 Response 클래스
 * Entity 클래스를 생성자 파라미터로 받아 DTO 로 변환하여 응답합니다.
 * Board 엔티티를 그대로 반환하는 경우 Board 와 BoardTag 의 무한참조를 방지하기 위해
 * 별도의 responseDto 객체를 생성합니다.
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardResponseDto {

    private String articleId;
    private String boardTitle;
    private String writer;
    private LocalDateTime regDate;
    private List<BoardTagResponseDto> boardTags;

    /* Entity -> Dto */
    public BoardResponseDto(Board board) {
        this.articleId = board.getArticleId();
        this.boardTitle = board.getBoardTitle();
        this.writer = board.getWriter();
        this.regDate = board.getRegDate();
        this.boardTags = board.getBoardTags().stream().map(BoardTagResponseDto::new).collect(
            Collectors.toList());
    }

    /**
     * 리스트 형태로 전달
     */
    public static List<BoardResponseDto> from(List<Board> boards) {
        return boards.stream()
            .map(BoardResponseDto::new)
            .collect(Collectors.toList());
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class BoardTagResponseDto {

    private Long boardTagId;
    private TagObjResponseDto tagObjResponseDto;

    /* Entity -> DTO */
    public BoardTagResponseDto(BoardTag boardTag) {
        this.boardTagId = boardTag.getBoardTagId();
        this.tagObjResponseDto = new TagObjResponseDto(boardTag.getTagObj());
    }

}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TagObjResponseDto {

    private Long tagId;
    private String tagName;
    private String parentTagId;
    private String childTagId;


    public TagObjResponseDto(TagObj tagObj) {
        this.tagId = tagObj.getTagId();
        this.tagName = tagObj.getTagName();
        this.parentTagId = tagObj.getParentTagId();
        this.childTagId = tagObj.getChildTagId();
    }
}
// Controller Layer
@GetMapping("/api/boards/")
public ResponseEntity<List<BoardResponseDto>> findAllBoard() {
    return ResponseEntity.ok().body(boardService.findAllBoard());
}

// Service Layer
public List<BoardResponseDto> findAllBoard() {
    List<Board> allBoard = boardRepository.findAllBoard();
    return BoardResponseDto.from(allBoard);
}
  • 3번인 DTO를 만들어서 순환참조 문제를 해결하였습니다.
  • 객체 지향적인 코드를 만들기 위해 역할책임을 분리하는 것이 왜 중요한지 알게 해주는 문제였던거 같습니다.

Reference

profile
내맘대로 주제잡고 재미로 글쓰는 개발일지 블로그 👨‍💻

0개의 댓글