본문 바로가기

Spring/JPA

JPA @OneToOne은 FetchType.LAZY가 안 먹힐 수 있다?

반응형

JPA에서 @OneToOne 연관관계일 때 지연 로딩이 안 될 수 있다?

이제는 JPA가 상당히 많이 쓰이고 있기도 하고 유명한 모 강의도 있어서 많은 사람들이 잘 알고 쓰고 있긴하다.

그러나 실제 경험해본 것과 학습을 통해서 아는 것에는 차이가 있다.

실무 중에 @OneToOne 연관 관계가 설정되어 있는 엔티티에 Querydsl을 이용하여 통계 데이터를 조회해보면서 Lazy로 동작하지 않는 것을 확인하며 블로그로 남겨야겠다고 생각했다.

(🔥 이 포스트의 독자의 기준은 JPA 기초 정도는 아는 사람이라고 판단하고 작성했다!)

대부분의 JPA를 학습한 사람들이 알고 있듯, ~ToOne(@OneToOne, @ManyToOne) 연관 관계일 때는 Default로 FetchType.EAGER로 동작하고 있다.

유명 모 강의에서도 이 부분을 강의하며 실무 TIP으로 의도하지 않은 쿼리가 나갔을 때의 리스크가 더 크기 때문에 의도적으로 FetchType을 LAZY로 변경해서 쓰기를 추천한다.

좋은 방법이라고 생각하고 동의한다.

그러나 무지성으로 다 LAZY로 때려박는다고 다 그렇게 동작하지 않는다는 것을 이해하면 더 좋을 것 같다.

@OneToOne에 대해서는 한 가지 더 알아둬야할 것이 있다. (JPA는 이런게 단점인 것 같다. 뭐 하나를 학습하면 항상 예외로 또는 부가적으로 알아둬야할 것이 많은 것 같다...)

단방향 @OneToOne 관계에서는 문제없지만 양방향 @OneToOne 관계에서는 FetchType을 Lazy로 설정하더라도 Eager로 동작하는 경우가 있다.

엄밀하게 얘기하면 @OneToOne 양방향 연관 관계에서 연관 관계의 주인이 아닌 쪽 엔티티를 조회할 때, Lazy로 동작할 수 없다.


양방향 연관 관계에서의 문제!

예제를 간단하게 만들어봤다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @OneToOne(fetch = FetchType.LAZY)
    private Post post;

    @Builder
    public Board(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    public void addPost(Post post) {
        this.post = post;
    }
}

게시판과 게시글이 일대일 관계에 있다고 가정하였다. (하나의 게시판에 하나의 게시글이 이상하지만 예시니까...)

@OneToOne 연관 관계를 설정하였고 마찬가지로 아래와 같이 Post에서도 양방향으로 게시판과 연관 관계를 설정해주었다. (예전에 쓰던 예제라 이상한게 많은데... Board와 Post만 봐주면 될 것 같다.)

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id @GeneratedValue
    private Long id;

    private String title;
    private String description;
    private LocalDate expiredDate;
    @Enumerated(EnumType.STRING)
    private Status status;
    @OneToOne(fetch = FetchType.LAZY, mappedBy = "post")
    private Board board;

    @Builder
    public Post(Long id, String title, String description, LocalDate expiredDate, Status status, Board board) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.expiredDate = expiredDate;
        this.status = status;
    }

    public enum Status {
        UNKNOWN, // default
        TEMP,    // 임시저장
        NORMAL,  // 정상
        DELETED  // 삭제
    }
}

테스트는 두 가지로 나뉜다.

  1. 연관 관계의 주인인 엔티티만 조회했을 때, Lazy로 동작하는가?
  2. 연관 관계의 주인이 아닌 엔티티만 조회했을 때, Lazy로 동작하는가?

바로 Repository를 만들어서 쿼리가 어떻게 나가고 있는지 확인하면 될 것이다.

위와 같이 연관 관계의 주인인 Board 엔티티를 조회했을 때는 의도한대로 Lazy로 동작했다.

위와 같이 연관 관계의 주인이 아닌 Post 엔티티를 조회했을 때는 의도한 것과 다르게 Eager로 동작했다.

왜 이렇게 동작했는지는 아래에서 살펴본다.

단방향 연관 관계에서는 문제가 없나?

왜 위와 같이 의도한대로 동작하지 않았는지 알아보기 전에 단방향 연관 관계에서는 문제가 없는지 알아본다.

아까의 양방향 연관 관계를 단방향 연관관계로 만들기 위해서 Board에서는 연관관계를 지워버렸고 Post에서는 양방향 관계가 아니니까 연관 관계의 주인을 정할 필요가 없어졌으므로 이제 mappedBy 속성을 지웠다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @Builder
    public Board(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String description;
    private LocalDate expiredDate;
    @Enumerated(EnumType.STRING)
    private Status status;
    @OneToOne(fetch = FetchType.LAZY)
    private Board board;

    @Builder
    public Post(Long id, String title, String description, LocalDate expiredDate, Status status) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.expiredDate = expiredDate;
        this.status = status;
    }

    public void changeBoard(Board board) {
        this.board = board;
    }

    public enum Status {
        UNKNOWN, // default
        TEMP,    // 임시저장
        NORMAL,  // 정상
        DELETED  // 삭제
    }
}

그 다음에는 아까와 마찬가지로 Board와 Post 엔티티를 각각 조회해보고 Lazy로 동작하는지를 살펴봤다.

(사실 Board에는 연관관계가 없으므로 굳이 조회하지 않아도 되지만 했다.)

결과는 위와 같이 OneToOne 단방향의 경우 의도한대로 Lazy로 동작했다.

그렇다면 무슨 이유 때문에 @OneToOne 양방향 연관 관계에서 연관 관계의 주인이 아닌 엔티티 테이블을 조회 했을 때 fetchType.EAGER로 동작했을까?

무슨 이유 때문인가

기본적으로 연관 관계 엔티티를 fetchType.LAZY 로 조회가 가능하도록 하려면 JPA 구현체에서 “프록시”를 만들어줘야한다.

이 전제 조건과 더불어 또 하나의 조건이 있다.

JPA 구현체는 연관 관계 엔티티에 null 또는 프록시 객체가 할당되어야만 한다.

@OneToOne에서 null 이라는 것은 연관 관계에 해당되는 엔티티가 없다는 것을 의미하고 프록시 객체가 할당되었다는 것은 연관관계에 해당되는 엔티티가 있다는 것을 의미한다. (틀린 내용일 수 있으니 지적해주세요?!)

그러면 데이터 베이스 관점에서 바라보자.

연관 관계의 주인인 엔티티에 매핑된 테이블에는 @OneToOne으로 연결된 엔티티의 존재 여부를 알 수 있는 어떤(ex. FK)컬럼이 있을 것이다.

즉 연관 관계 주인인 엔티티에 매핑된 테이블만 조회하더라도 @OneToOne으로 연결된 객체가 있는지 없는지를 해당 컬럼 값을 보면 알 수 있다.

null로 들어가있으면 연관 관계 엔티티가 없구나? 하고 null을 할당할 수 있고, null이 아닌 값이 해당 컬럼에 있으면 연관 관계 엔티티가 있구나! 하고 프록시 객체를 잘 할당해놓을 수 있기 때문에 프록시 객체에 접근할 때, LAZY로 처리가 가능하다.

반대로 연관 관계의 주인이 아닌 테이블에서는 컬럼 자체가 없기 때문에 해당 테이블만 읽어서는 연관 관계의 엔티티가 존재하는지 여부를 알 수 없다.

즉, 이런 상태만으로는 null을 넣기도 애매하고 프록시를 만들기도 애매하다. (프록시로 일단 만들어놓고 막상 Lazy loading 하고보니 데이터가 없네? → null 리턴할게! 이게 안된다.)

그래서 optional=false로 두면 연관 관계 엔티티에 매핑된 테이블을 조회하지 않더라도 반드시 연관 관계의 엔티티가 존재할 것이라는 것을 JPA 구현체가 알 수 있기 때문에 굳이 찾아보지 않고 무조건 프록시 객체를 만들어서 Lazy loading이 가능한 것이다. (대신 optional=false로 뒀기 때문에 실제로 엔티티가 존재해야한다. 앞서 말했듯 “앗! 막상 조회해보니 값이 없어 미안!” 이게 안되기 때문이다.)

@OneToMany는 괜찮나?

양방향일 때 외래키를 관리하는 쪽(연관관계의 주인)은 다 쪽이다.

일(One)쪽에는 다의 존재를 알 수 있는 방법이 없다.

그러면 마찬가지로 Lazy loading을 못할까? 그렇지 않다.

이유는 컬렉션이다. 컬렉션이기에 null을 표현할 방법이 있다.

무조건 프록시 객체를 만들어놓고 막상 조회해보니 없어 미안! 하고 빈 컬렉션을 리턴하면 표현이 가능하다! (null을 리턴하지 않고 null과 같이 연관 관계가 없음을 표현할 size=0이 있기 때문이다.

@ManyToOne은 괜찮나?

사실 알아볼 필요도 없다.

기본은 Eager로 조회하지만 Lazy로 변경해도 당연히 가능하다.

다(Many) 쪽에서 컬럼 존재를 알 수 있기에 처리가 가능하고 반대의 경우는 위에서 살펴봤기 때문이다.


다른 해결책을 찾는 사람들

@OneToOne 양방향에서 연관 관계의 주인이 아닌 쪽에서의 Lazy 조회가 안 되는 문제를 해결하기 위해서 다양한 방법으로 푸는 경우가 있다.

간단하게는 그냥 포기하고 fetch join으로 같이 가져와서 해결하는 방법이 있고, 억지로 엔티티에 FK를 넣어서 해결해볼 수도 있다.

다른 억지 방법으로 OneToOne을 OneToMany, ManyToOne 으로 분리하여 Lazy 조회를 유지하는 방법도 있다.

반응형