Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.StackOverflowError] with root cause
could not initialize proxy [com.~.~.entity.UserEntity#13] - no Session
@Entity
@ToString
public class BoardEntity {
...
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "u_num", nullable = false)
UserEntity userEntity;
...
}
@Entity
@Getter
public class UserEntity{
...
@OneToMany
private List<BoardEntity> boards = new ArrayList<>();
...
}
BoardEntity와 UserEntity는 일대다 관계를 가지고 있습니다.
public void toStringTest(){
BoardEntity board = br.findByBoardNum(2);
log.info(board+": board)
}
그런 관계를 가지고 있는 상황에서 pk를 이용해 조회한 BoardEntity 객체를 board에 담고 그를 출력합니다. 이런 간단한 코드에서 전 에러를 마주하게 됐습니다.
저같은 경우 에러의 원인은 @ToString 이 친구였습니다. 그런데 @ToString을 보기 전, 우리는 위 BoardEntity 클래스에서 집중🙌해야할 부분이 있습니다.
@ManyToOne의 fetch 타입으로 FetchType.LAZY가 적용이 된 걸 볼 수 있습니다. 그렇다면 FetchType.LAZY는 뭘 나타내는 거길래 이 부분에 집중해야 하는 걸까요? 🔍
두 개의 테이블이 관계가 있을 때, 예를 들어 Board 테이블과 User 테이블이 있을 때, Board 테이블을 조회할 때 User 테이블도 함께 조회해야 할까요? 아니면 조회하지 말아야 할까요? 이를 정해주는 게 FetchType입니다. 두 개의 테이블에 관계가 있을 때 @ManyToOne 등의 어노테이션에는 fetch 타입을 줄 수 있습니다.
FetchType.LAZY는 지연 로딩입니다. FetchType.LAZY를 설정하고 Board를 조회한 후 User을 확인해보면 Proxy 객체가 조회됩니다. 후에 board.getUser.getName()으로 User 필드에 접근하려 하면 그때서야 User의 name을 찾는 쿼리가 나갑니다.
Proxy 객체란?
실제 엔티티 객체 대신에 사용되는 객체로써 실제 엔티티 클래스와 상속 관계 및 위임 관계에 있는 객체입니다. 실제 엔티티와 겉모습이 같습니다. 가짜 객체라고 보시면 될 것같습니다.
하지만 대부분의 로직에서 Board와 User을 같이 사용한다면 select 쿼리가 두 번 실행되는 것은 좋지 않습니다. 그래서 이런 경우엔 즉시 로딩 (FetchType.EAGER)을 사용합니다. 즉시 로딩을 사용하면 JOIN을 사용해서 한 번에 다 조회해 옵니다. 하지만 그럼에도 실무에서는 지연 로딩을 많이 사용합니다.
첫 째로, 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생합니다. 연관된 테이블이 많은 경우 JOIN이 여러번 일어나기 때문입니다.
둘 째로, N+1 문제가 일어납니다.
@OneToMany와 @ManyToMany는 기본이 지연 로딩이지만, @XXXToOne은 기본이 즉시 로딩이기때문에 LAZY로 명시해서 사용하는 것이 좋습니다.
@ToString은 toString()메서드를 Override 해서 각각의 필드를 출력할 수 있도록 합니다. 하지만 @ToString은 특히 주의할 필요가 있는데, 연관 관계를 가질 경우 잘못하면 서로를 계속 참조하면서 무한 순환 참조가 발생한다고 합니다.
그러니까 A 엔티티의 toString에서는 연결된 B 엔티티를 확인하고 싶어서 b.toString을 호출하고 B 엔티티의 toString에서도 연결된 A 엔티티 데이터를 확인하고 싶어서 a.toString을 호출하는 것입니다. 결국 이는 무한 순환 참조 에러를 발생시킵니다. 😰😱
그래서 가급적이면 엔티티에서 @Data와 @ToString을 지향한다고 합니다.
그럼 어떻게 해결할까요? 다른 방법도 많습니다. 무한 순환 참조의 원인이 @ToString 뿐만이 아닌 만큼요.
@ToString이 원인일 경우 위에 언급했던 지연 로딩을 즉시 로딩으로 바꾸는 해결 방법도 있습니다. 하지만 그런 해결은 좋지 않다 느꼈습니다. 그래서 저는 @ToString에 exclude를 사용했습니다.
@Entity
@ToString(exclude={"제외할 필드","제외할 필드", "userEntity"})
public class BoardEntity {
...
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "u_num", nullable = false)
UserEntity userEntity;
...
}
exlude는 특정 필드를 toString에 포함되지 않게 합니다. 무한 참조가 일어나는 클래스를 exclude 하면 그 클래스의 toString을 호출하지 않습니다. 서로를 궁금해 하던 클래스들이 exclude로 인해 서로를 궁금해하지 않게 된 것입니다. 이렇게 하면 출력이 잘 되는 것을 보실 수 있을 것입니다.