[JPA]쏘아올린 JPA의 순환 참조, @JsonManagedReference에서 볼까, DTO로 볼까

봄도둑·2023년 2월 12일
3

Spring 개인 노트

목록 보기
10/17
post-custom-banner

JPA 스터디를 진행하다보면 스터디 리뷰를 진행할 때 가장 많이 보는 어노테이션 중 하나가 @JsonManagedReference , 그리고 이 녀석과 짝을 지어 사용하는 @JsonBackReference 가 있습니다.

그런데 설계 관점에서 보면 저 어노테이션은 근본적인 문제를 해결해주지 않습니다. 그렇다면, 왜 JPA를 공부하는 사람들이 저 어노테이션을 통해 무슨 문제를 해결하려 했고, 그럼에도 불구하고 해결하지 못한 근본적인 문제가 무엇인지 살펴 봅시다.

1. RDB의 세계와 JPA의 세계 비교

JPA는 RDB를 구성하고 있는 테이블 구조를 객체로 사용할 수 있게끔 바꿔줍니다. RDB에서는 foreign key, join으로 관계를 맺고 있는 테이블을 찾지만 JPA는 객체 간의 참조를 통해 관계를 맺고 있는 객체를 탐색합니다. 그렇기 때문에 JPA를 이해하기 위해서는 객체의 참조가 어떤 것인지 이해할 수 있어야 합니다.

먼저 간단한 연관 관계를 맺고 있는 두 개의 엔티티를 살펴 봅시다. 게시판을 구성하고 있는 Board와 게시글을 작성하는 User의 엔티티입니다.

먼저, User의 엔티티입니다.

@Entity(name = "user")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "user_id")
    private Long userId;

    private String nickname;
    private Boolean quit;
    @Column(name = "created_dt")
    private LocalDateTime createdDt;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "type_id")
    private AccountType accountType;

    @OneToMany(mappedBy = "user")
    private List<Board> boardList = new ArrayList<>();

    public User(String nickname, String accountId, AccountType accountType) {
        this.nickname = nickname;
        this.accountId = accountId;
        this.quit = false;
        this.createdDt = LocalDateTime.now();

        if (Objects.nonNull(accountType)) {
            changeAccountType(accountType);
        }
    }
}

Board의 엔티티입니다.

@Entity(name = "board")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Board {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "board_id")
    private Long boardId;
    private String title;
    private String content;
    @Column(name = "created_dt")
    private LocalDateTime createdDt;
    @Column(name = "modified_dt")
    private LocalDateTime modifiedDt;
    @Column(name = "is_deleted")
    private boolean isDeleted;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public Board(String title, String content, User user) {
        this.title = title;
        this.content = content;
        this.createdDt = LocalDateTime.now();
        this.isDeleted = false;
        updateModifiedDt();

        if (Objects.nonNull(user)) {
            setUser(user);
        }
    }

    public void setUser(User user) {
        this.user = user;
        user.getBoardList().add(this);
    }

    public void updateBoard(BoardDTO boardDTO) {
        if (Objects.nonNull(boardDTO.getTitle())) {
            this.title = boardDTO.getTitle();
        }
        if (Objects.nonNull(boardDTO.getContent())) {
            this.content = getContent();
        }
        updateModifiedDt();
    }
}

두 엔티티를 살펴보면 User와 Board는 1대다 관계를 맺고 있습니다. 관계를 맺고 있다는 건 객체 지향 세계인 자바 관점에서 서로가 서로를 참조 하고 있다는 것을 의미합니다. 이것이 바로 RDB 세계에서 서로 다른 두 개의 테이블이 관계를 맺고 있는 것과 다른 부분입니다. RDB의 세계에서는 Board 테이블에 User는 들어 있지 않습니다. 다만 foreign key인 user_id를 통해 Board row를 작성한 user가 누구를 가리키고 있는지 알 수 있게 됩니다. 즉 foreign key인 user_id는 board 테이블과 user 테이블을 조인해서 쓰지 않는 이상 단순한 number 이상의 의미를 갖지 못하지만 join을 하게 되었을 때 user_id가 가리키고 있는 user가 누구인지 알려주는 중요한 정보로서 의미를 가지게 됩니다.

반면 객체인 Board 엔티티의 경우에는 User 엔티티를 직접적으로 참조를 하고 있습니다. 그런데 User 엔티티는 해당 User가 작성한 Board 엔티티의 리스트를 참조 하고 있습니다. 서로가 서로를 참조를 하고 있는 구조가 되었습니다.

정리하자면 RBD의 세계에서는 연관 관계를 가지고 있는 테이블의 정보를 직접적으로 들고 있는 것이 아니라 관계 테이블의 값을 확인할 수 있는 유일한 키 값을 가지고 있지만 객체 지향의 세계에서는 연관 관계를 가지고 있는 객체를 직접적으로 참조하고 있습니다.

2. JPA의 순환 참조

위에서 본 두 개의 엔티티에 toString을 아래와 같이 직접 정의해봅시다.

//User
@Entity(name = "user")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class User {
	//...
    @Override
    public String toString() {
        return "User{" +
                "userId=" + userId +
                ", nickname='" + nickname + '\'' +
                ", accountId='" + accountId + '\'' +
                ", quit=" + quit +
                ", createdDt=" + createdDt +
                ", boardList=" + boardList +
                '}';
    }
}

//Board
@Entity(name = "board")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Board {
	//...
    @Override
    public String toString() {
        return "Board{" +
                "boardId=" + boardId +
                ", title='" + title + '\'' +
                ", content='" + content + '\'' +
                ", createdDt=" + createdDt +
                ", modifiedDt=" + modifiedDt +
                ", isDeleted=" + isDeleted +
                ", user=" + user +
                '}';
    }
}

그리고 이제 이렇게 게시글을 조회하고 조회한 Board 엔티티를 toString으로 문자열로 변환해봅시다.

@Test
public void Board_리스트를_조회하고_문자열로_바꾸기() {
	List<Board> findAll = boardRepository.findAll();

	for (Board board : findAll) {
		System.out.println(board.toString());
	}
}

그리고 우리는 stackOverFlow error를 마주합니다. 우리가 해준 것은 단순한 toString이었을 뿐입니다. 그러나 이러한 단순한 toString 조차 객체 지향의 세계에 대한 이해가 없으면 에러가 왜 일어나는지 알 수 없습니다.

쉽게 생각해보면 우리는 board.toString을 호출했습니다. 그런데 board.toString에는 잘 살펴보면 user를 문자열로 바꿔주는데 이 때 java는 암시적으로 user.toString을 호출하는 것으로 인식합니다. 그런데 user.toString을 실행하면 Board 엔티티의 list를 toString을 하게 됩니다. 이 역시 board.toString을 호출해 문자열을 반환하도록 합니다. 결국 구조는 아래처럼 서로가 서로의 toString을 무한히 참조하는 결과를 가져옵니다.

⏰ 첫번째 board.toString 호출 > 첫번째 board를 작성한 user.toString 호출 > 첫번째 board를 작성한 user의 boardList를 문자열로 변환 > 첫번째 board.toString 호출 > …

탈출할 수 없는 무한 루프가 객체의 세계의 참조라는 개념 아래 발생합니다. 비단 toString 뿐만 아니라 equals 등 내부 변수를 가지고 동작하는 함수들 모두에게서 순환 참조(stackOverFlow)가 발생합니다.

3. JPA의 순환 참조 해결 방법

JPA의 순환 참조의 해결 방법은 객체의 세계에서 관계를 어떻게 다루는지 알면 쉽게 해결할 수 있습니다. 이러한 순환 참조 문제의 가장 쉬운 해결 방법은 서로가 서로를 참조하지 않도록 바꿔주는 것입니다.

이를 해결하는 방법은 여러 가지가 있지만 대표적인 2가지 방법을 살펴보겠습니다.

먼저 참조의 관계를 끊어 다시 정의 하는 것입니다. toString을 override해 참조하는 부분을 빼고 다시 정의합니다.

//Board
@Entity(name = "board")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Board {
		//...
		@Override
		public String toString() {
		    return "Board{" +
            "boardId=" + boardId +
            ", title='" + title + '\'' +
            ", content='" + content + '\'' +
            ", createdDt=" + createdDt +
            ", modifiedDt=" + modifiedDt +
            ", isDeleted=" + isDeleted +
            ", user=" + user.getUserId() +
            '}';
		}
}

//User
@Entity(name = "user")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class User {
	//...
    @Override
    public String toString() {
        return "User{" +
                "userId=" + userId +
                ", nickname='" + nickname + '\'' +
                ", accountId='" + accountId + '\'' +
                ", quit=" + quit +
                ", createdDt=" + createdDt +
                '}';
    }
}

Board.toString 처럼 참조하는 연관관계 엔티티의 대표적인 속성만 불러온다거나 User.toString처럼 toString 변환 시 연관 관계를 가지는 엔티티는 아예 변환 항목에서 없애버리는 것입니다.

2번째 방법은 간단하게 어노테이션 단 2개만 붙여주면 됩니다. 바로 @JsonManagedReference , 그리고 이 녀석과 짝을 지어 사용하는 @JsonBackReference 입니다.

@Entity(name = "user")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class User {
	//...
    @OneToMany(mappedBy = "user")
    @JsonManagedReference
    private List<Board> boardList = new ArrayList<>();
    
    //...
}

@Entity(name = "board")
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Board {
	//...
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    @JsonBackReference
    private User user;
    
    //...
}

연관 관계의 주인이 되는 entity에 참조 관계가 들어가는 속성 위에 @JsonBackReference 를 붙여주고 연관관계의 주인과 mapping되는 entity에 @JsonManagedReference 를 붙여주기만 하면 됩니다.

@JsonManagedReference 어노테이션은 양방향 관계의 엔티티의 직렬화 방향을 설정해 서로간의 참조를 발생하지 않도록 방어해주는 어노테이션입니다. 번거롭게 정의한 함수를 override해서 참조 관계를 빼거나 참조 관계를 무한 참조에 빠지지 않게 하는 속성들만 가져올 수 있도록 복잡하게 생각할 필요 없이 직렬화 시 서로 참조만 하지 않도록 바꿔주는 이 어노테이션은 쓰기가 정말 간편해 보입니다. 그런데 과연 이 어노테이션이 능사일까요?

4. entity를 직접 return 하는 게 문제

이러한 JPA의 순환 참조 문제는 주로 entity를 controller 레벨에서 바로 리턴해주는 일을 수행하다가 많이 발생합니다. 주로 아래와 같은 구조에서 발생하는 문제로 볼 수 있습니다.

@RestController
@AllArgsConstructor
@RequestMapping("sample/board")
public class BoardController {

    private final BoardService boardService;

    @GetMapping("boards")
    public List<Board> getBoards(@RequestBody Pagination pagination) {
       return boardService.getBoards(pagination);
    }

	//...
}

Spring에서는 Object를 응답할 json으로 변환 시 HttpMessageConverters에서 jackson 라이브러리를 이용해 json으로 변환합니다. (jackson 라이브러리의 상세 동작은 여기서 보시면 됩니다. )

쉽게 표현하자면 jackson converter가 toString과 비슷한 동작으로 Board를 json으로 만들게 됩니다. 이 과정에서 위에서 살펴봤던 toString을 통해 무한루프에 빠진 것 처럼 Board를 json으로 변환하는 과정에서 User를 참조하고 User를 json으로 만드는 과정에서 다시 Board를 참조하는 순환 참조에 빠지게 됩니다.

그래서 이렇게 controller 레벨에서 직접 엔티티를 리턴해주는 과정에서 발생한 순환 참조 오류를 구글링 하다보면 많은 기술 블로거들이 알려주는, 순환 참조를 한 방에 해결하는 마술같은 어노테이션 @JsonManagedReference 을 사용하게 되는 것입니다.

그런데 진짜 문제는 순환 참조를 해결하는데 있지 않습니다. 이 코드의 진짜 문제는 순환 참조가 아니라 구조의 문제입니다. entity를 직접 json으로 return 하고 있는 것입니다.

4-1. 왜 entity를 직접 return하는 게 문제야?

이 질문에 대한 답은 바로 엔티티라는 객체의 존재 이유입니다. 엔티티란 RDB 세계를 구성하는 가장 기초적인 단위인 테이블을 JPA를 사용하기 위해 객체 세계로 변환해서 가져온, 객체 세계를 구성하는 기초 단위인 오브젝트(객체)인 녀석입니다.

여기서 엔티티는 2가지 의존 관계를 가지게 되는 이질적인 개념이 됩니다.

  • DB 계층의 테이블의 구조에 대한 의존 관계
  • JPA라는 도구에 대한 의존 관계

바로 이 2가지 의존 관계가 엔티티를 직접 리턴하면 안되는 이유가 되기도 합니다.

DB 계층의 테이블 구조에 대한 의존 관계의 관점으로 생각해 봅시다.

우리는 JPA를 사용하고 있는 Backend를 다루고 있습니다. 그리고 엔티티는 DB의 테이블 구조에 대해 의존 관계를 가지고 있습니다. 즉, 엔티티는 DB의 테이블 구조와 똑같은 형태를 취하고 있습니다. Frontend 레벨에서는 DB가 어떻게 생겼는지 궁금하지도 않고, 알고 싶어 하지도 않습니다. 그런데 Backend에서 엔티티를 그대로 return 함으로서 frontend는 DB 계층에 대한 정보를 알게 됩니다. 특히 외부로 노출되어서는 안되는 DB 구조를 사용자에게 보여지는 화면 계층인 frontend가 알게 됩니다. frontend가 알 필요가 없는 정보인데 보안에서 중요한 속성일 경우 우리는 엔티티를 직접 노출할 것이 아니라 캡슐화 해서 외부로 보여지는 정보를 숨겨야 합니다.

또한 frontend는 엔티티를 응답 받아 사용하게 되는데 엔티티는 DB의 테이블 구조와 동일한 형태를 가지고 있습니다. 즉 frontend가 DB 계층에 대한 의존성을 가지게 됩니다. 조금 더 쉽게 표현하자면 DB 테이블의 구조 변화가 생기게 되면 이 모양을 그대로 사용하는 엔티티도 바뀌게 됩니다. frontend 역시 엔티티를 직접 받아서 사용하기 때문에 결국 DB 테이블의 변화가 생긴다면 frontend도 같이 바뀌게 됩니다.

우리가 이러한 계층 구조를 분리해서 사용하는 이유 중 하나는 각 계층 간의 변화에 의존 관계를 없애기 위함에 있습니다. frontend는 backend가 파이썬인지 자바인지 node.js인지 알 필요가 없습니다. 그저 요청에 대한 응답만 잘 내려주기만 하면 다 일 뿐입니다. 각각의 계층 간 의존 관계를 없앰으로써 다른 계층의 변화에 민감하게 반응하지 않아도 되는 것이 바로 그 이유인데 엔티티를 직접 return함으로써 frontend는 DB에 의존적인 구조로 바뀌게 됩니다.

이제 JPA라는 도구에 대한 의존 관계의 관점으로 생각해봅시다.

이 관점은 엔티티를 사용하는 backend에게 해당하는 내용이기도 하지만 방금 다뤘던 frontend와 DB와의 관계와 매우 유사하게 설명하는 내용입니다.

엔티티는 JPA라는 도구가 DB와 통신할 때 사용하는 것으로 엔티티는 JPA에 대한 의존도, 즉 JPA의 성질을 가지고 있습니다. 쉽게 말하자면 엔티티가 사용되는 곳에서는 JPA에 대한 의존성이 발생할 수 있다는 것입니다.

이렇게 JPA의 성질을 가지고 있는 엔티티가 controller 계층에서 사용된다면 controller는 결국 JPA에 대한 의존성을 가지게 됩니다. controller, service등 각 계층 구조를 분리해서 사용 하는 이유 역시 기능에 대한 분리도 있지만 상호간의 의존 관계를 최대한 줄이는 것을 목표로 합니다. 그런데 비즈니스 로직의 레벨에서 사용하는 JPA가 최종적으로 데이터를 반환해줘야 하는 controller에 붙어 버림으로써 이러한 계층 간의 도구 의존도가 생기게 됩니다. 예를 들어 JPA에서 Mybatis로 전환하게 될 경우 DB와 통신을 해서 값을 가공하는 비즈니스 로직 계층은 당연히 바뀌어야 하지만, 요청을 분기하고 최종 응답을 내려주는 controller가 DB 접근 기술이 바뀌었다고 해서 Mybatis에 맞게 바뀌게 됩니다.

정리하자면 이러한 엔티티의 return은 각 계층 간의 결합을 강화하거나 특정 도구에 대해 의존하게끔 만드는 최악의 설계 구조에 대한 결과를 만들게 됩니다.

5. 해결책은 DTO

DTO를 사용해야 하는 이유는 여러가지가 있지만 엔티티가 가지고 있는 문제를 해결하기 위해 사용되어야 합니다. 이러한 형태로 사용하면 좋습니다.

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {
    protected String title;
    protected String content;
    protected Long boardId;
}

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {

    protected Long userId;
    protected String nickname;
}

@Getter
public class BoardResponse extends BoardDTO{
    protected LocalDateTime createdDt;
    protected LocalDateTime modifiedDt;
    protected UserDTO writer;

    public BoardResponse(String title, String content, Long boardId) {
        super(title, content, boardId);
    }

    public BoardResponse(Board board) {
        super(board.getTitle(), board.getContent(), board.getBoardId());
        this.createdDt = board.getCreatedDt();
        this.modifiedDt = board.getModifiedDt();
        this.writer = new UserDTO(board.getUser().getUserId(), board.getUser().getNickname());
    }
}

@Getter
public class Boards implements FirstClassCollection {
    private List<BoardResponse> boardList;

    public Boards(List<BoardResponse> boardList) {
        this.boardList = boardList;
    }

    @Override
    public int getCount() {
        return this.boardList.size();
    }
}

@Getter
public class ListResponse<CollectionType> {
    private CollectionType CollectionType;

    public ListResponse(CollectionType CollectionType) {
        this.CollectionType = CollectionType;
    }
}

BoardDTO와 UserDTO는 Board와 User를 구성하는 가장 기본적인 정보를 담고 있습니다. 그리고 우리는 Board에 대한 정보에 대해 return만을 담당할 BoardResponse를 사용하고, BoardResponse에 대한 list를 일급 컬렉션으로 만든 Boards를 사용합니다.

그리고 controller는 이렇게 만든 DTO만 return 해주기만 하면 됩니다.

@GetMapping("boards")
public ListResponse<Boards> getReadableBoards(@RequestBody Pagination pagination) {
    return boardService.getReadableBoards(pagination);
}

※ 이부분은 저의 코딩 스타일이 반영 되어 있기도 합니다. 바로 List<BoardResponse>를 리턴해도 되지만 최근 공부했던 일급 컬렉션을 사용해보고 싶어 Boards라는 별도의 일급 컬렉션을 사용해보았습니다. 또한 return Object를 제너릭으로 명시적으로 나타내는 것을 선호하는 편이라 ListResponse라는 별도의 return 전용 객체를 사용합니다. ListResponse<Boards> 이런 식으로 사용하기 위한 용도로 굳이 필요하지 않는 객체이기도 합니다.

그리고 우리는 BoardService에서 비즈니스 로직을 통해 가공이 끝난 값을 new Boards로 만들어서 던져주기만 하면 됩니다.

6. 마무리

이 글을 쓰게 된 계기는 2차례의 JPA 스터디를 진행하면서 스터디를 진행한 모두가 @JsonManagedReference 으로 작성한 코드를 가져왔을 때 DTO의 사용을 권장하는 글을 써야 겠다는 생각이 들었습니다. 물론 저 역시 혼자 JPA를 공부하던 시절 수 없이 많이 사용하던 어노테이션이었습니다.

우리는 에러가 발생하면 이 에러를 어떻게 해결할지에 대해 구글링을 합니다. 그리고 구글링해서 수 없이 나온 레퍼런스 중 가장 잘 작동되는 것을 찾아 적용합니다. 그 레퍼런스가 최선의 방법인지 혹은 근본적인 문제를 해결할 수 있는 코드인지 모르는 채 말입니다. (이 글도 최선의 방법이 아닐 수 있습니다.)

그러나 우리는 에러라는 현상을 해결하는데 목적을 두어서는 안됩니다. 발생한 원인이 구조적으로 가지고 있는 문제, 에러를 일으켰던 가장 근본적인 문제에 대해 접근해야 합니다. 그리고 현상을 해결하는 것이 아니라 근본적인 문제를 해결하는 방법에 대해 접근해야 합니다.

이러한 관점으로 보았을 때 @JsonManagedReference 는 현상을 해결하는 코드이지만, 이 원인 발생했던 그 이면의 구조적 문제인 entity return에 대해서는 답을 내놓지 않습니다.

이 글이 JPA의 순환 참조 문제를 해결하는데 도움이 되었으면 하지만, 그보다 우리가 공부하는 방향에 대해 다시 한 번 생각해볼 수 있는 시간을 만들었으면 합니다.

(다음 글은 DTO와 entity의 불변성, setter는 필요한가에 대해 다뤄볼 예정입니다. 근데 보통 다음 시리즈는 ~입니다 하면 그거에 대해 안쓰게 되더라구요ㅋㅋㅋㅋ)


*REFERENCE

https://subji.github.io/posts/2020/08/06/infiniterecusionofjpa
https://www.baeldung.com/jackson-field-serializable-deserializable-or-not
https://stackoverflow.com/questions/23973347/jpa-java-lang-stackoverflowerror-on-adding-tostring-method-in-entity-classes
https://martinfowler.com/eaaCatalog/dataTransferObject.html
https://martinfowler.com/eaaCatalog/serviceLayer.html
https://techblog.woowahan.com/2550/

profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.
post-custom-banner

0개의 댓글